aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/app-core.js6
-rw-r--r--scripts/theme-studio/app-util.js20
-rw-r--r--scripts/theme-studio/app.js11
-rw-r--r--scripts/theme-studio/generate.py23
-rw-r--r--scripts/theme-studio/test-app-core.mjs19
-rw-r--r--scripts/theme-studio/test-app-util.mjs70
-rw-r--r--scripts/theme-studio/test_generate.py42
-rw-r--r--scripts/theme-studio/theme-studio.html29
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
// 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';}}