diff options
| -rw-r--r-- | scripts/theme-studio/app-core.js | 6 | ||||
| -rw-r--r-- | scripts/theme-studio/app-util.js | 20 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 11 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 23 | ||||
| -rw-r--r-- | scripts/theme-studio/test-app-core.mjs | 19 | ||||
| -rw-r--r-- | scripts/theme-studio/test-app-util.mjs | 70 | ||||
| -rw-r--r-- | scripts/theme-studio/test_generate.py | 42 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 29 |
8 files changed, 199 insertions, 21 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 34fb8051..91b9b1a9 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -25,4 +25,8 @@ function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[ // cur is set but no longer in the palette, surface it as a "(gone)" entry first. function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone) '+cur],...palette])];} -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList }; +// Turn a theme name into a safe filename slug: collapse runs of disallowed +// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'. +function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify }; diff --git a/scripts/theme-studio/app-util.js b/scripts/theme-studio/app-util.js new file mode 100644 index 00000000..e3f76dd8 --- /dev/null +++ b/scripts/theme-studio/app-util.js @@ -0,0 +1,20 @@ +// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status +// color, and the readable text color for a background. These are kept out of +// colormath.js (the pure math core) but are unit-tested and inlined into the page +// the same way. textOn leans on rl from colormath; the import is for the tests — +// generate.py strips it on inline, where rl is already present from the inlined +// colormath core. +import { rl } from './colormath.js'; + +// Normalize a hex string: trim, accept an optional leading #, require exactly six +// hex digits, lowercase the result. Returns null for anything else. +function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} + +// Map a WCAG contrast ratio to a status color: AAA green (>=7), AA grey (>=4.5), +// otherwise the fail red. +function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} + +// Pick black or white text for a background hex, by WCAG relative luminance. +function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} + +export { normHex, ratingColor, textOn }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 6767accf..c5a618e3 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -11,14 +11,14 @@ function seedPkgmap(){return buildPkgmap(APPS,PALETTE);} let PKGMAP=seedPkgmap(); function esc(t){return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} // Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex, -// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex, -// textOn, and ratingColor stay below as UI-boundary helpers. +// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. COLORMATH_J // Pure package-model + dropdown logic, inlined verbatim from app-core.js. The // wrappers above (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) delegate here. APP_CORE_J -function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} -function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} +// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from +// app-util.js. textOn uses rl from the colormath core above. +APP_UTIL_J // The contrast-cell readout shared by every table: a WCAG ratio colored by its // AA/AAA rating, with the rating word. Callers compute r for their own fg/bg. function crHtml(r){return `<span style="color:${ratingColor(r)}">${r.toFixed(1)} ${rating(r)}</span>`;} @@ -192,7 +192,6 @@ function updateColor(){ for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } -function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask) @@ -269,7 +268,7 @@ function addColor(){const h=curHex();const name=document.getElementById('newname if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} -function fileSlug(){return themeName().replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} +function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} function exportState(){const t=document.getElementById('export');t.value=JSON.stringify(exportObj(),null,1);t.style.display='block';t.focus();t.select();} function toggleJSON(){const t=document.getElementById('export'),b=document.getElementById('jsonbtn');if(t.style.display==='block'){t.style.display='none';b.textContent='show';}else{exportState();b.textContent='hide';}} diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 24ad7a1b..56aa5800 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -2,15 +2,19 @@ import json, os HERE=os.path.dirname(os.path.abspath(__file__)) def strip_exports(src): - """Drop ES-module `export` lines so the body loads as a classic <script>. + """Drop ES-module `export`/`import` lines so the body loads as a classic <script>. - A top-level `export` is a syntax error outside a module, so it must go before - the body is spliced into the page. test-colormath.mjs applies the identical - strip and asserts the page carries the result verbatim (inline-integrity), so - the two copies cannot drift. NOTE: this is line-based — the export statement in - colormath.js must stay on a single line or the continuation lines survive. + A top-level `export` (or `import`) is a syntax error outside a module, so it + must go before the body is spliced into the page. Imports are stripped too so a + pure module may import a peer for its own unit tests (e.g. app-util.js imports + rl from colormath.js) while the inlined copy relies on the peer already being + in the page. The .mjs inline-integrity tests apply the identical strip and + assert the page carries the result verbatim, so the two copies cannot drift. + NOTE: this is line-based — each export/import statement must stay on a single + line or the continuation lines survive. """ - return '\n'.join(l for l in src.splitlines() if not l.startswith('export')).rstrip() + return '\n'.join(l for l in src.splitlines() + if not (l.startswith('export') or l.startswith('import'))).rstrip() # Pure color-math core, inlined verbatim into the page so the browser runs the # same code the Node tests import (one source of truth). @@ -25,6 +29,10 @@ APP_BODY=open(os.path.join(HERE,'app.js')).read() # Pure package-model + dropdown logic, inlined into the page (and unit-tested via # test-app-core.mjs) the same way colormath.js is. APP_CORE_BODY=strip_exports(open(os.path.join(HERE,'app-core.js')).read()) +# Pure color/UI-boundary helpers (normHex/ratingColor/textOn), unit-tested via +# test-app-util.mjs. Its `import rl` line is stripped on inline (rl is already in +# the page from the colormath core). +APP_UTIL_BODY=strip_exports(open(os.path.join(HERE,'app-util.js')).read()) ns={} src=open(os.path.join(HERE,'samples.py')).read() exec(src[:src.index('cols=')], ns) @@ -509,6 +517,7 @@ APP_JS</script>""" def fill_data(s): return (s.replace("COLORMATH_J",COLORMATH_BODY) .replace("APP_CORE_J",APP_CORE_BODY) + .replace("APP_UTIL_J",APP_UTIL_BODY) .replace("SAMPLES_J",json.dumps(SAMPLES)) .replace("PALETTE_J",json.dumps(PALETTE)).replace("CATS_J",json.dumps(CATS)) .replace("UIFACES_J",json.dumps(UI_FACES)).replace("UIMAP_J",json.dumps(UIMAP)).replace("APPS_J",json.dumps(APPS)) diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs index 0befeb43..9bf5145f 100644 --- a/scripts/theme-studio/test-app-core.mjs +++ b/scripts/theme-studio/test-app-core.mjs @@ -7,7 +7,7 @@ import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { - nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, + nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, } from './app-core.js'; const here = fileURLToPath(new URL('.', import.meta.url)); @@ -127,6 +127,23 @@ test('mergePackagesInto: Boundary — undefined pkgs is a no-op', () => { assert.deepEqual(m, { a: { f: { fg: '#000000' } } }); }); +test('slugify: Normal — spaces and punctuation collapse to single dashes', () => { + assert.equal(slugify('My Cool Theme'), 'My-Cool-Theme'); + assert.equal(slugify('dupre revised'), 'dupre-revised'); + assert.equal(slugify('keeps.dots_and-dashes'), 'keeps.dots_and-dashes'); +}); + +test('slugify: Boundary — leading/trailing junk is trimmed', () => { + assert.equal(slugify(' spaced '), 'spaced'); + assert.equal(slugify('!!!edges!!!'), 'edges'); + assert.equal(slugify(''), 'theme'); // empty falls back +}); + +test('slugify: Error — an all-disallowed name falls back to "theme"', () => { + assert.equal(slugify('!!!'), 'theme'); + assert.equal(slugify(' '), 'theme'); +}); + // Guards the one-source-of-truth contract, same as the colormath integrity test: // the page must carry app-core.js's body (sans exports) verbatim. Requires // `python3 generate.py` to have run first. diff --git a/scripts/theme-studio/test-app-util.mjs b/scripts/theme-studio/test-app-util.mjs new file mode 100644 index 00000000..2cb08e0e --- /dev/null +++ b/scripts/theme-studio/test-app-util.mjs @@ -0,0 +1,70 @@ +// Unit tests for the pure color/UI-boundary helpers (app-util.js). +// Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { normHex, ratingColor, textOn } from './app-util.js'; + +const here = fileURLToPath(new URL('.', import.meta.url)); + +test('normHex: Normal — adds the #, lowercases, accepts an existing #', () => { + assert.equal(normHex('67809C'), '#67809c'); + assert.equal(normHex('#E8BD30'), '#e8bd30'); + assert.equal(normHex('#67809c'), '#67809c'); +}); + +test('normHex: Boundary — trims surrounding whitespace; empty is null', () => { + assert.equal(normHex(' 67809c '), '#67809c'); + assert.equal(normHex(''), null); + assert.equal(normHex(' '), null); + assert.equal(normHex('abc'), null); // 3-digit shorthand is not accepted +}); + +test('normHex: Error — bad characters and wrong length give null', () => { + assert.equal(normHex('#gggggg'), null); + assert.equal(normHex('#12345'), null); // 5 digits + assert.equal(normHex('#1234567'), null); // 7 digits + assert.equal(normHex('red'), null); +}); + +test('ratingColor: Normal — AAA green, AA grey, fail red', () => { + assert.equal(ratingColor(10), '#5d9b86'); + assert.equal(ratingColor(5), '#a9b2bb'); + assert.equal(ratingColor(2), '#cb6b4d'); +}); + +test('ratingColor: Boundary — the AAA (7) and AA (4.5) thresholds are inclusive', () => { + assert.equal(ratingColor(7), '#5d9b86'); + assert.equal(ratingColor(6.99), '#a9b2bb'); + assert.equal(ratingColor(4.5), '#a9b2bb'); + assert.equal(ratingColor(4.49), '#cb6b4d'); +}); + +test('ratingColor: Error — zero and negative ratios are the fail color', () => { + assert.equal(ratingColor(0), '#cb6b4d'); + assert.equal(ratingColor(-1), '#cb6b4d'); +}); + +test('textOn: Normal — white text on black, black text on white', () => { + assert.equal(textOn('#000000'), '#fff'); + assert.equal(textOn('#ffffff'), '#000'); +}); + +test('textOn: Boundary — straddles the ~0.179 luminance crossover', () => { + assert.equal(textOn('#707070'), '#fff'); // just below the crossover + assert.equal(textOn('#777777'), '#000'); // just above the crossover +}); + +// Inline-integrity: the page must carry app-util.js's body (sans import/export) +// verbatim — the same strip generate.py applies. Requires `python3 generate.py`. +const stripModule = (s) => + s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import'))) + .join('\n').replace(/\s+$/, ''); + +test('inline-integrity: theme-studio.html contains the app-util.js body verbatim', () => { + const body = stripModule(readFileSync(here + 'app-util.js', 'utf8')); + const html = readFileSync(here + 'theme-studio.html', 'utf8'); + assert.ok(html.includes(body), 'generated page is missing the app-util.js body verbatim'); +}); diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py index 077382e4..ee13f8de 100644 --- a/scripts/theme-studio/test_generate.py +++ b/scripts/theme-studio/test_generate.py @@ -30,6 +30,12 @@ class StripExports(unittest.TestCase): src = "export const a=1;\ncode();\nexport { a };" self.assertEqual(generate.strip_exports(src), "code();") + def test_removes_import_lines_too(self): + # A pure module may import a peer for its own tests; the import must be + # stripped on inline (the peer is already in the page). + src = "import { rl } from './colormath.js';\nfunction f(){return rl();}" + self.assertEqual(generate.strip_exports(src), "function f(){return rl();}") + def test_matches_the_js_side_strip_so_integrity_holds(self): # test-colormath.mjs strips with the same rule: drop lines starting with # 'export', then trim trailing whitespace. Keep the two in lockstep. @@ -62,7 +68,7 @@ class ColormathInlining(unittest.TestCase): class AssembledPage(unittest.TestCase): PLACEHOLDERS = [ - "STYLES_CSS", "APP_JS", "APP_CORE_J", + "STYLES_CSS", "APP_JS", "APP_CORE_J", "APP_UTIL_J", "COLORMATH_J", "SAMPLES_J", "PALETTE_J", "CATS_J", "UIFACES_J", "UIMAP_J", "APPS_J", "BOLD_J", "MAP_J", ] @@ -81,6 +87,15 @@ class AssembledPage(unittest.TestCase): # and the unit-tested module cannot drift. self.assertIn(generate.APP_CORE_BODY, generate.HTML) + def test_page_carries_the_app_util_body_verbatim(self): + # app-util.js inlines verbatim after its import line is stripped. + self.assertIn(generate.APP_UTIL_BODY, generate.HTML) + + def test_app_util_inlined_body_has_no_import_line(self): + # The `import rl` line must be gone, or the page <script> is invalid. + for line in generate.APP_UTIL_BODY.splitlines(): + self.assertFalse(line.startswith("import"), f"import survived: {line!r}") + def test_page_carries_the_stylesheet_verbatim(self): # styles.css has no placeholders, so it inlines verbatim: the inlined copy # and the source file cannot drift. @@ -97,5 +112,30 @@ class AssembledPage(unittest.TestCase): self.assertEqual(generate.HTML.count("</script>"), 1) +class FacesHelper(unittest.TestCase): + def test_strips_prefix_and_derives_label_and_merges_seed(self): + # Normal: the prefix comes off the label, and the per-face seed is attached. + rows = generate._faces(["org-todo", "org-done"], "org-", {"org-todo": {"fg": "gold"}}) + self.assertEqual(rows, [ + ["org-todo", "todo", {"fg": "gold"}], + ["org-done", "done", {}], + ]) + + def test_label_drops_face_suffix_and_spaces_remaining_dashes(self): + # Boundary: "-face" is removed and the rest of the dashes become spaces. + rows = generate._faces(["lsp-rename-placeholder-face"], "lsp-", {}) + self.assertEqual(rows[0][1], "rename placeholder") + + def test_name_without_the_prefix_is_left_intact(self): + # Boundary: a name that doesn't start with the prefix keeps its full text + # (only "-face" removal and dash-spacing apply). + rows = generate._faces(["shr-text"], "org-", {}) + self.assertEqual(rows[0], ["shr-text", "shr text", {}]) + + def test_empty_names_gives_empty_list(self): + # Error/Boundary: nothing in, nothing out. + self.assertEqual(generate._faces([], "org-", {"org-todo": {"fg": "gold"}}), []) + + if __name__ == "__main__": unittest.main() diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 219a4a03..0e04d012 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -186,8 +186,7 @@ function seedPkgmap(){return buildPkgmap(APPS,PALETTE);} let PKGMAP=seedPkgmap(); function esc(t){return t.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');} // Pure color-math core (lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex, -// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. normHex, -// textOn, and ratingColor stay below as UI-boundary helpers. +// plus OKLab/OKLCH/APCA/deltaE), inlined verbatim from colormath.js. // colormath.js — pure color-math core for theme-studio. // // One source of truth: node imports this module (tests); generate.py inlines its @@ -407,8 +406,29 @@ function effResolve(map,app,face,attr,seen){seen=seen||{};const f=map[app]&&map[ // Standard swatch-dropdown option list: a default entry, then the palette. When // cur is set but no longer in the palette, surface it as a "(gone)" entry first. function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);return [['','— default —'],...(have?palette:[[cur,'(gone) '+cur],...palette])];} -function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} + +// Turn a theme name into a safe filename slug: collapse runs of disallowed +// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'. +function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} +// Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from +// app-util.js. textOn uses rl from the colormath core above. +// Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status +// color, and the readable text color for a background. These are kept out of +// colormath.js (the pure math core) but are unit-tested and inlined into the page +// the same way. textOn leans on rl from colormath; the import is for the tests — +// generate.py strips it on inline, where rl is already present from the inlined +// colormath core. + +// Normalize a hex string: trim, accept an optional leading #, require exactly six +// hex digits, lowercase the result. Returns null for anything else. +function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} + +// Map a WCAG contrast ratio to a status color: AAA green (>=7), AA grey (>=4.5), +// otherwise the fail red. function ratingColor(r){return r>=7?'#5d9b86':r>=4.5?'#a9b2bb':'#cb6b4d';} + +// Pick black or white text for a background hex, by WCAG relative luminance. +function textOn(h){const L=rl(h);return ((L+0.05)/0.05)>(1.05/(L+0.05))?'#000':'#fff';} // The contrast-cell readout shared by every table: a WCAG ratio colored by its // AA/AAA rating, with the rating word. Callers compute r for their own fg/bg. function crHtml(r){return `<span style="color:${ratingColor(r)}">${r.toFixed(1)} ${rating(r)}</span>`;} @@ -582,7 +602,6 @@ function updateColor(){ for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } -function normHex(s){s=s.trim();if(/^[0-9a-fA-F]{6}$/.test(s))s='#'+s;return /^#[0-9a-fA-F]{6}$/.test(s)?s.toLowerCase():null;} function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} let pkH=0,pkS=0,pkV=0.5,pickerOn=false; let pkMode='any'; // contrast mask: any / aa / aaa (what constraint to mask) @@ -659,7 +678,7 @@ function addColor(){const h=curHex();const name=document.getElementById('newname if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} -function fileSlug(){return themeName().replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';} +function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} function exportState(){const t=document.getElementById('export');t.value=JSON.stringify(exportObj(),null,1);t.style.display='block';t.focus();t.select();} function toggleJSON(){const t=document.getElementById('export'),b=document.getElementById('jsonbtn');if(t.style.display==='block'){t.style.display='none';b.textContent='show';}else{exportState();b.textContent='hide';}} |
