aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-10 01:56:15 -0500
committerCraig Jennings <c@cjennings.net>2026-06-10 01:56:15 -0500
commit04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e (patch)
treec34f9d75c4d005f31df7e7b42fa2684c5a688531 /scripts/theme-studio
parent980cbe1a3a7a3930690b7780ea2c6aa674e9f2a0 (diff)
downloaddotemacs-04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e.tar.gz
dotemacs-04b82bbe0d99b9ff4aa8d892e2d44046ccfdc85e.zip
feat(theme-studio): group families by lightness-conditioned complete linkage
Replace the hue-anchor bucketing and the tent neutral threshold with the model two independent reviews of color-sorting.org converged on (Codex and Fable, with Fable's harness measuring pairwise F1 0.63 → 0.96 on the real palette). Chromatic colors now cluster by complete-linkage agglomeration on a lightness-conditioned hue distance: hue must match tightly at equal lightness and may drift across a lightness gap, because a tonal ramp drifts in hue with lightness by design. A low-chroma noise term widens the tolerance where hue is ill-defined, and a chroma clause keeps a vivid accent out of a soft same-hue family. Complete linkage makes single-linkage chaining structurally impossible. The neutral threshold is floored at both ends instead of tapering to zero, which fixes two real defects: pale warm grays (gray+1, gray+2) that leaked into a color column, and pure white (C=0 at L=1) that evaded a zero threshold. On the sterling/distinguished palette this separates the gold and olive ramps (the green/yellow complaint), keeps the red and blue ramps whole including drifted tints, isolates intense-red, and consolidates every gray and steel into the neutral column. The one residual — pale yellow+2 lands on the olive ramp — is geometrically irreducible from the hex (it sits on the olive trajectory by nearest-neighbor, ramp-line fit, and eye); only its name says gold. That needs the deferred per-hex family-hint override. New node tests cover the gold/olive split, blue pale-tint cohesion, gray/white neutrality, intense-red isolation, and palette-order independence. The count gate now asserts the count action adds all ramp colors to the palette rather than that they all display in one family, since a chroma-eased extreme can sit at the neutral boundary.
Diffstat (limited to 'scripts/theme-studio')
-rw-r--r--scripts/theme-studio/app-core.js57
-rw-r--r--scripts/theme-studio/app.js5
-rw-r--r--scripts/theme-studio/test-families.mjs68
-rw-r--r--scripts/theme-studio/theme-studio.html62
4 files changed, 146 insertions, 46 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index cf3e7ff8..60ee1410 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -127,20 +127,48 @@ function lMax(hue,chroma,fgSet,target){
// truth; these pure functions group it, regenerate a family's ramp, and plan the
// assignment re-point across a regenerate.
-// Perceptual hue-category centers (OKLCH degrees). A chromatic color joins the
-// family of its nearest anchor, so adjacent categories (yellow vs green) stay
-// separate by construction and there's no single-linkage chaining across them.
-const HUE_ANCHORS=[30,65,100,145,200,255,310,350]; // red,orange,yellow,green,teal,blue,purple,pink
function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;}
+function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);}
-// Nearest hue anchor to H, by circular distance.
-function nearestAnchor(H){let best=HUE_ANCHORS[0],bd=999;for(const a of HUE_ANCHORS){let d=Math.abs(H-a);d=Math.min(d,360-d);if(d<bd){bd=d;best=a;}}return best;}
// A color reads as neutral below this chroma. Lightness-scaled (the Munsell
-// insight): the mid-tones need more chroma to read as a hue, so a faint warm gray
-// at mid lightness is neutral while an equally-faint tint near either extreme keeps
-// its hue. A tent peaking near mid lightness and tapering toward both ends.
-function neutralThreshold(L){const PK=0.6,MAX=0.035,d=L<PK?(PK-L)/PK:(L-PK)/(1-PK);return MAX*(1-Math.min(1,d));}
+// insight): the mid-tones need more chroma to read as a hue. Floored at both ends
+// rather than tapering to zero, so pale warm grays stay neutral (and pure white,
+// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay
+// colored. Tuned on real palettes (Codex + Fable color-sorting reviews).
+function neutralThreshold(L){
+ if(L<=0.2)return 0.020;
+ if(L<0.6)return 0.020+0.015*(L-0.2)/0.4;
+ if(L<0.85)return 0.035-0.017*(L-0.6)/0.25;
+ return 0.018;
+}
+// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL):
+// hue must match tightly at equal lightness and may drift across a lightness gap,
+// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise
+// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma
+// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is
+// compatible. Source: ~/color-sorting-fable.org.
+function pairRatio(a,b){
+ const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H);
+ const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI);
+ return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL));
+}
+// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two
+// clusters whose worst cross-pair is most compatible, stopping when no merge has
+// every cross-pair compatible. Complete linkage makes single-linkage chaining
+// structurally impossible — two ramps can't fuse through their converging pale
+// ends because their mid-lightness members stay far apart.
+function clusterChromatic(ms){
+ let cl=ms.map(m=>[m]);
+ const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b))));
+ for(;;){
+ let best=null;
+ for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};}
+ if(!best||best.d>1)break;
+ cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1);
+ }
+ return cl;
+}
// A family from its members: base is the most-saturated member (tie toward
// mid-lightness), the anchor for a generated ramp.
function makeFamily(ms,neutral){
@@ -152,23 +180,22 @@ function makeFamily(ms,neutral){
// those two hexes form the pinned ground strip even when absent from the palette,
// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals
// (chroma below the lightness-scaled threshold) form one neutral family; the rest
-// bucket by nearest hue anchor.
+// cluster by lightness-conditioned complete linkage (clusterChromatic).
function familiesFromPalette(palette,ground){
const bg=ground&&ground.bg,fg=ground&&ground.fg;
const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase()));
const groundStrip=[];
if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)});
if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)});
- const neutrals=[],buckets=new Map();
+ const neutrals=[],chromatic=[];
for(const [hex,name] of palette){
if(gset.has(hex.toLowerCase()))continue;
const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H};
- if(c.C<neutralThreshold(c.L))neutrals.push(m);
- else{const a=nearestAnchor(c.H);if(!buckets.has(a))buckets.set(a,[]);buckets.get(a).push(m);}
+ (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m);
}
const families=[];
if(neutrals.length)families.push(makeFamily(neutrals,true));
- for(const ms of buckets.values())families.push(makeFamily(ms,false));
+ for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false));
return {ground:groundStrip,families};
}
// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index 92eec644..0b6663ee 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -1135,8 +1135,9 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex;
A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg);
setFamilyCount('#67809c',3);
- const fam3=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>f.base.toLowerCase()==='#67809c');
- A(fam3&&fam3.members.length===7,'count up to 3 yields 7 members, got '+(fam3&&fam3.members.length));
+ const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase());
+ const have=new Set(PALETTE.map(p=>p[0].toLowerCase()));
+ A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
document.title='COUNTTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs
index 6d436394..c6602aeb 100644
--- a/scripts/theme-studio/test-families.mjs
+++ b/scripts/theme-studio/test-families.mjs
@@ -21,30 +21,24 @@ test('familiesFromPalette: Normal — separated hues split into one family each'
for (const f of families) assert.equal(f.members.length, 1);
});
-test('familiesFromPalette: Boundary — hues sharing an anchor stay one family', () => {
- const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; // both nearest the blue anchor
+test('familiesFromPalette: Boundary — near hues at the same lightness stay one family', () => {
+ const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')];
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
assert.equal(families.length, 1, 'a near hue-pair is one family');
assert.equal(families[0].members.length, 2);
});
-test('familiesFromPalette: Boundary — different anchors split (blue vs teal)', () => {
+test('familiesFromPalette: Boundary — well-separated hues split', () => {
const pal = [at(0.6, 0.1, 255, 'b'), at(0.6, 0.1, 200, 'c')];
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
assert.equal(families.length, 2);
});
-test('familiesFromPalette: Normal — yellow and green land in separate families', () => {
- const pal = [at(0.7, 0.12, 100, 'yellow'), at(0.6, 0.12, 145, 'green')];
- const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.equal(families.length, 2, 'yellow and green are separate anchors');
-});
-
-test('familiesFromPalette: Boundary — an intermediate chain does not merge yellow into green', () => {
- // gold, olive, yellow-green, green: anchor assignment buckets by nearest, no single-linkage chaining
+test('familiesFromPalette: Boundary — an intermediate chain does not merge gold into green', () => {
+ // complete linkage requires every cross-pair compatible, so the far endpoints (90° vs 150°) keep the chain from fusing
const pal = [at(0.7, 0.1, 90, 'gold'), at(0.65, 0.1, 110, 'olive'), at(0.6, 0.1, 130, 'yg'), at(0.55, 0.1, 150, 'green')];
const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
- assert.equal(families.length, 2, 'two anchors (yellow, green), not one chained family');
+ assert.equal(families.length, 2, 'not one chained family');
});
test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => {
@@ -65,6 +59,56 @@ test('familiesFromPalette: Boundary — near-neutral colors form a separate fami
assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'blue')));
});
+// --- real-palette grouping (the hard cases the color-sorting reviews measured) ---
+
+// The contested region of the distinguished/sterling palette: the gold ramp and
+// the olive ramp whose hue ranges nearly touch but whose mid-tones are far apart.
+const GOLD = [['#875f00', 'yellow-2'], ['#8e784c', 'yellow-1'], ['#d7af5f', 'yellow'], ['#ffd75f', 'yellow+1']];
+const OLIVE = [['#646d14', 'green-2'], ['#869038', 'green-1'], ['#a4ac64', 'green'], ['#ccc768', 'green+1']];
+const famOf = (families, name) => families.find(f => f.members.some(m => m.name === name));
+
+test('familiesFromPalette: Normal — the gold and olive ramps separate', () => {
+ const { families } = familiesFromPalette([...GOLD, ...OLIVE], { bg: '#000000', fg: '#ffffff' });
+ const gold = famOf(families, 'yellow'), olive = famOf(families, 'green');
+ assert.notEqual(gold, olive, 'gold and olive are different families');
+ assert.ok(!gold.members.some(m => m.name.startsWith('green')), 'gold family has no greens');
+ assert.ok(!olive.members.some(m => m.name.startsWith('yellow')), 'olive family has no yellows');
+});
+
+test('familiesFromPalette: Normal — the blue ramp stays whole despite pale-tint hue drift', () => {
+ // blue (H 252), blue+1 (H 231), blue+2 (H 272): low-chroma pale tints swing in hue but belong together
+ const pal = [['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#d9e2ff', 'blue+2']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ const blue = famOf(families, 'blue');
+ assert.equal(blue.members.length, 3, 'all three blues in one family');
+});
+
+test('familiesFromPalette: Boundary — pale warm grays and pure white read as neutral', () => {
+ const pal = [['#b4b1a2', 'gray+1'], ['#d0cbc0', 'gray+2'], ['#ffffff', 'white'], ['#67809c', 'blue']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#f0fef0' }); // fg distinct from the white swatch
+ const neutral = families.find(f => f.neutral);
+ for (const n of ['gray+1', 'gray+2', 'white']) assert.ok(neutral.members.some(m => m.name === n), n + ' is neutral');
+ assert.ok(famOf(families, 'blue') && !famOf(families, 'blue').neutral, 'blue stays chromatic');
+});
+
+test('familiesFromPalette: Boundary — a vivid accent stays out of a soft same-hue family', () => {
+ // intense-red (C 0.246) vs red (C 0.120) at similar lightness: the chroma clause keeps them apart
+ const pal = [['#ff2a00', 'intense-red'], ['#d47c59', 'red'], ['#a7502d', 'red-1']];
+ const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' });
+ assert.notEqual(famOf(families, 'intense-red'), famOf(families, 'red'), 'intense-red is its own family');
+});
+
+test('familiesFromPalette: Boundary — grouping is independent of palette order', () => {
+ const base = [...GOLD, ...OLIVE, ['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#969385', 'gray']];
+ const key = pal => familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }).families
+ .map(f => f.members.map(m => m.name).sort().join(',')).sort().join(' | ');
+ const ref = key(base);
+ for (const seed of [1, 2, 3]) { // a few deterministic shuffles
+ const shuffled = base.map((e, i) => [e, ((i + 1) * seed * 7) % base.length]).sort((a, b) => a[1] - b[1]).map(x => x[0]);
+ assert.equal(key(shuffled), ref, 'shuffle ' + seed + ' yields the same grouping');
+ }
+});
+
test('familiesFromPalette: Boundary — ground hex absent from the palette still forms the strip', () => {
const pal = [at(0.6, 0.1, 250, 'blue')];
const { ground } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' });
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 1a0b72c7..33358704 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -512,20 +512,48 @@ function lMax(hue,chroma,fgSet,target){
// truth; these pure functions group it, regenerate a family's ramp, and plan the
// assignment re-point across a regenerate.
-// Perceptual hue-category centers (OKLCH degrees). A chromatic color joins the
-// family of its nearest anchor, so adjacent categories (yellow vs green) stay
-// separate by construction and there's no single-linkage chaining across them.
-const HUE_ANCHORS=[30,65,100,145,200,255,310,350]; // red,orange,yellow,green,teal,blue,purple,pink
function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));}
function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;}
+function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);}
-// Nearest hue anchor to H, by circular distance.
-function nearestAnchor(H){let best=HUE_ANCHORS[0],bd=999;for(const a of HUE_ANCHORS){let d=Math.abs(H-a);d=Math.min(d,360-d);if(d<bd){bd=d;best=a;}}return best;}
// A color reads as neutral below this chroma. Lightness-scaled (the Munsell
-// insight): the mid-tones need more chroma to read as a hue, so a faint warm gray
-// at mid lightness is neutral while an equally-faint tint near either extreme keeps
-// its hue. A tent peaking near mid lightness and tapering toward both ends.
-function neutralThreshold(L){const PK=0.6,MAX=0.035,d=L<PK?(PK-L)/PK:(L-PK)/(1-PK);return MAX*(1-Math.min(1,d));}
+// insight): the mid-tones need more chroma to read as a hue. Floored at both ends
+// rather than tapering to zero, so pale warm grays stay neutral (and pure white,
+// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay
+// colored. Tuned on real palettes (Codex + Fable color-sorting reviews).
+function neutralThreshold(L){
+ if(L<=0.2)return 0.020;
+ if(L<0.6)return 0.020+0.015*(L-0.2)/0.4;
+ if(L<0.85)return 0.035-0.017*(L-0.6)/0.25;
+ return 0.018;
+}
+// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL):
+// hue must match tightly at equal lightness and may drift across a lightness gap,
+// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise
+// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma
+// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is
+// compatible. Source: ~/color-sorting-fable.org.
+function pairRatio(a,b){
+ const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H);
+ const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI);
+ return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL));
+}
+// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two
+// clusters whose worst cross-pair is most compatible, stopping when no merge has
+// every cross-pair compatible. Complete linkage makes single-linkage chaining
+// structurally impossible — two ramps can't fuse through their converging pale
+// ends because their mid-lightness members stay far apart.
+function clusterChromatic(ms){
+ let cl=ms.map(m=>[m]);
+ const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b))));
+ for(;;){
+ let best=null;
+ for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};}
+ if(!best||best.d>1)break;
+ cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1);
+ }
+ return cl;
+}
// A family from its members: base is the most-saturated member (tie toward
// mid-lightness), the anchor for a generated ramp.
function makeFamily(ms,neutral){
@@ -537,23 +565,22 @@ function makeFamily(ms,neutral){
// those two hexes form the pinned ground strip even when absent from the palette,
// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals
// (chroma below the lightness-scaled threshold) form one neutral family; the rest
-// bucket by nearest hue anchor.
+// cluster by lightness-conditioned complete linkage (clusterChromatic).
function familiesFromPalette(palette,ground){
const bg=ground&&ground.bg,fg=ground&&ground.fg;
const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase()));
const groundStrip=[];
if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)});
if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)});
- const neutrals=[],buckets=new Map();
+ const neutrals=[],chromatic=[];
for(const [hex,name] of palette){
if(gset.has(hex.toLowerCase()))continue;
const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H};
- if(c.C<neutralThreshold(c.L))neutrals.push(m);
- else{const a=nearestAnchor(c.H);if(!buckets.has(a))buckets.set(a,[]);buckets.get(a).push(m);}
+ (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m);
}
const families=[];
if(neutrals.length)families.push(makeFamily(neutrals,true));
- for(const ms of buckets.values())families.push(makeFamily(ms,false));
+ for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false));
return {ground:groundStrip,families};
}
// Regenerate a family's members as a symmetric ramp around the base: n=0 is the
@@ -1744,8 +1771,9 @@ if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex;
A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg);
setFamilyCount('#67809c',3);
- const fam3=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>f.base.toLowerCase()==='#67809c');
- A(fam3&&fam3.members.length===7,'count up to 3 yields 7 members, got '+(fam3&&fam3.members.length));
+ const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase());
+ const have=new Set(PALETTE.map(p=>p[0].toLowerCase()));
+ A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette');
PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette();
document.title='COUNTTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}