aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-15 20:48:39 -0500
committerCraig Jennings <c@cjennings.net>2026-06-15 20:48:39 -0500
commita4b9e796ca57e7af75b001d5f0f5e4055b686929 (patch)
tree041198b0e4d5d378e6dfceb07c7361fb54556310 /scripts/theme-studio
parent1b4e5f88353180cf999412faa2be9e0326b78361 (diff)
downloaddotemacs-a4b9e796ca57e7af75b001d5f0f5e4055b686929.tar.gz
dotemacs-a4b9e796ca57e7af75b001d5f0f5e4055b686929.zip
feat(theme-studio): 2D gallery color picker for the assignment dropdowns
- The color dropdown opens a grid, not a long list. - The grid mirrors the palette: ground strip, then a row per family. - Members run dark to light, with the current color outlined. - A default chip clears the assignment. - A (gone) cell shows a color no longer in the palette. - The trigger and step buttons stay the same. - All three tiers share the one dropdown.
Diffstat (limited to 'scripts/theme-studio')
-rw-r--r--scripts/theme-studio/app-core.js25
-rw-r--r--scripts/theme-studio/app.js30
-rw-r--r--scripts/theme-studio/browser-gates.js37
-rw-r--r--scripts/theme-studio/styles.css17
-rw-r--r--scripts/theme-studio/test-app-core.mjs51
-rw-r--r--scripts/theme-studio/theme-studio.html107
6 files changed, 234 insertions, 33 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 96feb89c2..739d198db 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -365,6 +365,29 @@ function paletteOptionList(cur,palette,ground){
sortColumns(grouped.columns).forEach(f=>lightestFirstMembers(f.members).forEach(m=>add(m.hex,m.name)));
return out;
}
+// Grid model for the gallery color picker. Mirrors the palette panel layout: a
+// ground row (bg/fg + ground steps) then one row per color family, members run
+// dark->light to match the panel. cur marks the one selected cell. The leading
+// "default" entry (clears the assignment) and, when cur points at a color no
+// longer in the palette, a "(gone)" entry live outside the family grid so every
+// dropdown choice stays reachable. Pure — shares columnsFromPalette / sortColumns
+// with the panel and the option list.
+function galleryModel(cur,palette,ground){
+ const want=(cur||'').toLowerCase(),sel=h=>(h||'').toLowerCase()===want;
+ const byLightAsc=(a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L;
+ const cell=m=>({hex:m.hex,name:m.name||m.hex,selected:sel(m.hex)});
+ const rows=[];
+ const groundCells=groundColumnMembersFromPalette(palette,ground||{})
+ .filter(m=>m&&m.hex).sort(byLightAsc).map(cell);
+ if(groundCells.length)rows.push({kind:'ground',cells:groundCells});
+ sortColumns(columnsFromPalette(palette,ground||{}).columns).forEach(f=>{
+ const cells=[...f.members].filter(m=>m&&m.hex).sort(byLightAsc).map(cell);
+ if(cells.length)rows.push({kind:'column',column:f.column,cells});
+ });
+ const have=cur===''||cur==null||rows.some(r=>r.cells.some(c=>sel(c.hex)));
+ const gone=(cur&&!have)?{hex:cur,name:'(gone)',selected:true}:null;
+ return {default:{hex:'',selected:cur===''||cur==null},gone,rows};
+}
function spanNeighborHex(cur,palette,ground,dir){
if(!cur)return null;
const wanted=(cur||'').toLowerCase(),groups=[],byLight=(a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L;
@@ -384,4 +407,4 @@ function spanNeighborHex(cur,palette,ground,dir){
return null;
}
-export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
+export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index 25764521a..cfa5b8705 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -80,18 +80,34 @@ function mkColorDropdown(options,cur,onPick,opts={}){
left.onclick=e=>{e.stopPropagation();step(-1);};
right.onclick=e=>{e.stopPropagation();step(1);};
t.onclick=(e)=>{e.stopPropagation();if(wrap.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;}
- const pop=document.createElement('div');pop.className='cddpop';
- for(const [hex,name] of options){const row=document.createElement('div');row.className='cddrow'+(hex===cur?' sel':'');
- const shown=displayHex(hex),nm=hex?name:(opts.defaultName||name);
- row.style.background=hex?'':shown;row.style.color=dropdownRowTextColor(hex,shown,textOn);
- row.innerHTML=`<span class="cddsw" style="background:${shown||'transparent'}"></span><span class="cddnm">${esc(nm)}</span><span class="cddhx">${hex||shown||''}</span>`;
- row.onclick=(ev)=>{ev.stopPropagation();cur=hex;paint();closeColorDropdown();onPick(hex);};
- pop.appendChild(row);}
+ // 2D gallery: a grid of swatches in the palette-panel shape (ground strip,
+ // then one row per family) instead of a long vertical list. galleryModel is
+ // the shared pure layout (app-core.js).
+ const pop=document.createElement('div');pop.className='cddpop cddgrid';
+ const model=galleryModel(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const pick=(hex)=>{cur=hex;paint();closeColorDropdown();onPick(hex);};
+ const head=document.createElement('div');head.className='cddghead';
+ const def=document.createElement('button');def.type='button';
+ def.className='cddgdef'+(model.default.selected?' sel':'');
+ def.textContent=opts.defaultName||'default';def.title='clear — use the default';
+ def.onclick=(ev)=>{ev.stopPropagation();pick('');};head.appendChild(def);
+ if(model.gone){const g=document.createElement('span');g.className='cddgc gone sel';
+ g.style.background=model.gone.hex;g.title='(gone) '+model.gone.hex;head.appendChild(g);
+ const gl=document.createElement('span');gl.className='cddglbl';gl.textContent='(gone) '+model.gone.hex;head.appendChild(gl);}
+ pop.appendChild(head);
+ for(const row of model.rows){const rr=document.createElement('div');rr.className='cddgrow';
+ for(const c of row.cells){const sw=document.createElement('button');sw.type='button';
+ sw.className='cddgc'+(c.selected?' sel':'');sw.style.background=c.hex;
+ sw.dataset.hex=c.hex;sw.dataset.name=c.name;sw.title=c.name+' '+c.hex;
+ sw.onclick=(ev)=>{ev.stopPropagation();pick(c.hex);};rr.appendChild(sw);}
+ pop.appendChild(rr);}
document.body.appendChild(pop);const r=t.getBoundingClientRect();
pop.style.left=r.left+'px';pop.style.minWidth=r.width+'px';
pop.style.top=(r.bottom+2)+'px';
const ph=pop.getBoundingClientRect().height;
if(r.bottom+ph>window.innerHeight-6)pop.style.top=Math.max(6,r.top-ph-2)+'px';
+ const pr=pop.getBoundingClientRect();
+ if(pr.right>window.innerWidth-6)pop.style.left=Math.max(6,window.innerWidth-6-pr.width)+'px';
_ddPop=pop;};
t.setValue=h=>{cur=h;paint();};
wrap.setValue=h=>{cur=h;paint();};
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 9c49798e1..18f0b80c6 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -363,7 +363,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i
const pdd=document.querySelector('#legbody tr[data-kind="p"] .cdd');
if(pdd){pdd.click();
const pHex=PALETTE.find(p=>p[0]!==MAP['p'])[0];
- const prow=[...document.querySelectorAll('.cddpop .cddrow')].find(r=>r.querySelector('.cddhx').textContent===pHex);
+ const prow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>c.dataset.hex===pHex);
if(prow)prow.click();
const pf=document.getElementById('uicr-fringe'),pfWant=contrast(pHex,'#aabbcc');
A(prow&&pf&&Math.abs(parseFloat(pf.textContent)-pfWant)<0.06,'default-fg change re-rates a p-fallback face: got '+(pf&&pf.textContent.trim())+' want '+pfWant.toFixed(1));
@@ -394,15 +394,46 @@ if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
PALETTE=[['#ff0000','red','red'],['#30343c','slate','slate']];
buildUITable();
const mlrow=document.querySelector('#uibody tr[data-face="mode-line"]'),boxCell=mlrow&&mlrow.cells[7],lineBtn=boxCell&&boxCell.querySelector('.boxbtn[data-style="line"]'),boxDd=boxCell&&boxCell.querySelector('.cdd');
- if(lineBtn&&boxDd){lineBtn.click();boxDd.click();const redRow=[...document.querySelectorAll('.cddpop .cddrow')].find(r=>r.textContent.includes('red'));if(redRow)redRow.click();}
+ if(lineBtn&&boxDd){lineBtn.click();boxDd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(UIMAP['mode-line'].box&&UIMAP['mode-line'].box.color==='#ff0000','UI box color dropdown writes box.color');
const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].box={style:'line',width:1,color:null};buildPkgTable();
const prow=document.querySelector('#pkgbody tr[data-face="'+face+'"]'),pbox=prow&&prow.cells[8],pdd=pbox&&pbox.querySelector('.cdd');
- if(pdd){pdd.click();const redRow=[...document.querySelectorAll('.cddpop .cddrow')].find(r=>r.textContent.includes('red'));if(redRow)redRow.click();}
+ if(pdd){pdd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(PKGMAP[app][face].box&&PKGMAP[app][face].box.color==='#ff0000','package box color dropdown writes box.color');
PALETTE=saveP;PKGMAP=savePK;for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();buildPkgTable();
document.title='BEVELTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='beveltest';d.textContent='BEVELTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Gallery gate (open with #gallerytest): the color dropdown opens a 2D grid in
+// the palette-panel shape. Driven on a throwaway dropdown so no real face state
+// is mutated. Covers: grid opens, every palette color has a cell, a cell click
+// fires onPick + updates the trigger, the pick highlights on reopen, the default
+// chip clears.
+if(location.hash==='#gallerytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ let picked='__none__';
+ const dd=mkColorDropdown(ddList(''),'',(hex)=>{picked=hex;},{});
+ document.body.appendChild(dd);
+ const trig=dd.querySelector('.cdd');trig.click();
+ const pop=document.querySelector('.cddpop.cddgrid');
+ A(pop,'dropdown opens a grid popup');
+ const cells=pop?[...pop.querySelectorAll('.cddgc')]:[];
+ A(cells.length>=PALETTE.length,'grid covers every palette color: '+cells.length+' cells for '+PALETTE.length+' palette colors');
+ A(pop&&pop.querySelector('.cddgdef'),'grid has a default chip');
+ A(pop&&pop.querySelector('.cddgrow'),'grid lays the colors out in rows');
+ const target=PALETTE.find(p=>p[0]!==MAP['bg'])||PALETTE[0];
+ const cell=cells.find(c=>c.dataset.hex===target[0]);
+ A(cell,'the target color has a cell');
+ if(cell){cell.click();
+ A(picked===target[0],'clicking a cell calls onPick with the color: '+picked);
+ A(trig.dataset.val===target[0],'the trigger button updates to the picked color: '+trig.dataset.val);
+ trig.click();
+ const sel=document.querySelector('.cddpop.cddgrid .cddgc.sel');
+ A(sel&&sel.dataset.hex===target[0],'the picked color is highlighted on reopen: '+(sel&&sel.dataset.hex));
+ closeColorDropdown();}
+ trig.click();const defc=document.querySelector('.cddpop.cddgrid .cddgdef');if(defc)defc.click();
+ A(picked==='','the default chip clears the assignment: '+JSON.stringify(picked));
+ dd.remove();closeColorDropdown();
+ document.title='GALLERYTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='gallerytest';d.textContent='GALLERYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
// Preview-link gate (open with #previewlinktest): known bespoke-preview face
// mappings stay wired to the face that Emacs actually uses.
if(location.hash==='#previewlinktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css
index 46eafe4a2..298421157 100644
--- a/scripts/theme-studio/styles.css
+++ b/scripts/theme-studio/styles.css
@@ -32,12 +32,17 @@
.cdd.compact.is-default{border-color:#8f7810;box-shadow:inset 0 0 0 1px #8f7810}
.cddsw{display:inline-block;width:13px;height:13px;border-radius:3px;border:1px solid #0007;flex:none}
.cdd.compact .cddsw{width:18px;height:18px}
- .cddpop{position:fixed;z-index:200;background:#161412;border:1px solid #3a3a3a;border-radius:6px;box-shadow:0 12px 34px #000c;max-height:60vh;overflow:auto;padding:4px}
- .cddrow{display:flex;align-items:center;gap:9px;padding:4px 9px;cursor:pointer;color:#cdced1;font:12px monospace;border-radius:4px;white-space:nowrap}
- .cddrow:hover{background:#252321}
- .cddrow.sel{outline:1px solid #e8bd30;outline-offset:-1px}
- .cddrow .cddnm{flex:1}
- .cddrow .cddhx{opacity:.55;margin-left:10px}
+ .cddpop{position:fixed;z-index:200;background:#161412;border:1px solid #3a3a3a;border-radius:6px;box-shadow:0 12px 34px #000c;max-height:70vh;overflow:auto;padding:8px}
+ .cddghead{display:flex;align-items:center;gap:8px;margin-bottom:7px}
+ .cddgdef{font:bold 11px monospace;color:#cdced1;background:#161412;border:1px solid #3a3a3a;border-radius:4px;padding:3px 10px;cursor:pointer}
+ .cddgdef:hover{background:#252321}
+ .cddgdef.sel{outline:1px solid #e8bd30;outline-offset:1px}
+ .cddglbl{font:11px monospace;color:#c66}
+ .cddgrow{display:flex;gap:3px;margin-bottom:3px}
+ .cddgc{display:inline-block;width:22px;height:22px;border-radius:3px;border:1px solid #0007;padding:0;cursor:pointer;flex:none}
+ .cddgc:hover{outline:1px solid #fff8;outline-offset:1px}
+ .cddgc.sel{outline:2px solid #e8bd30;outline-offset:1px}
+ .cddgc.gone{border:2px solid #c66}
.cstep.locked .cdd{cursor:default;opacity:.85;box-shadow:inset 0 0 0 2px #e8bd3088}
.lockbtn{background:none;border:none;cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;opacity:.5;filter:grayscale(1)}
.lockbtn.on{opacity:1;filter:none}
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index 42ce4e0a2..7f537d128 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -9,6 +9,7 @@ import { fileURLToPath } from 'node:url';
import {
nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify,
clearPalettePlan, deletePaletteColumnPlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet,
+ galleryModel,
} from './app-core.js';
import { planPaletteGenerator, entriesForGeneratedColumn } from './palette-generator-core.js';
import { oklch2hex, deltaE } from './colormath.js';
@@ -70,6 +71,56 @@ test('paletteOptionList: Normal — colors within each column are lightest to da
);
});
+const GALLERY_PAL = [
+ ['#111111', 'bg', 'ground'],
+ ['#eeeeee', 'fg', 'ground'],
+ ['#444444', 'gray-dark', 'gray'],
+ ['#cccccc', 'gray-light', 'gray'],
+ ['#888888', 'gray-mid', 'gray'],
+ ['#330000', 'red-dark', 'red'],
+ ['#dd8888', 'red-light', 'red'],
+];
+const GALLERY_GROUND = { bg: '#111111', fg: '#eeeeee' };
+const allCells = m => m.rows.flatMap(r => r.cells);
+
+test('galleryModel: Normal — ground row then one row per family, default cell present', () => {
+ const m = galleryModel('#888888', GALLERY_PAL, GALLERY_GROUND);
+ assert.equal(m.default.hex, '');
+ assert.equal(m.gone, null);
+ assert.equal(m.rows[0].kind, 'ground');
+ assert.deepEqual(m.rows[0].cells.map(c => c.hex), ['#111111', '#eeeeee']);
+ const cols = m.rows.filter(r => r.kind === 'column');
+ assert.equal(cols.length, 2, 'one row per color family');
+ assert.deepEqual(
+ cols.find(r => r.column === 'gray').cells.map(c => c.hex),
+ ['#444444', '#888888', '#cccccc'],
+ 'family members run dark to light',
+ );
+});
+
+test('galleryModel: Normal — exactly the current color is selected', () => {
+ const m = galleryModel('#888888', GALLERY_PAL, GALLERY_GROUND);
+ const selected = allCells(m).filter(c => c.selected);
+ assert.deepEqual(selected.map(c => c.hex), ['#888888']);
+ assert.equal(m.default.selected, false);
+});
+
+test('galleryModel: Boundary — empty cur selects the default cell, nothing in the grid', () => {
+ const m = galleryModel('', GALLERY_PAL, GALLERY_GROUND);
+ assert.equal(m.default.selected, true);
+ assert.equal(m.gone, null);
+ assert.equal(allCells(m).filter(c => c.selected).length, 0);
+});
+
+test('galleryModel: Error — a cur outside the palette surfaces a selected (gone) cell', () => {
+ const m = galleryModel('#abcdef', GALLERY_PAL, GALLERY_GROUND);
+ assert.ok(m.gone, 'gone cell exists');
+ assert.equal(m.gone.hex, '#abcdef');
+ assert.equal(m.gone.name, '(gone)');
+ assert.equal(m.gone.selected, true);
+ assert.equal(allCells(m).filter(c => c.selected).length, 0, 'no grid cell claims the gone color');
+});
+
test('paletteOptionList: Boundary — assignment-only ground colors are selectable', () => {
const list = paletteOptionList('', [['#67809c', 'blue']], { bg: '#0d0b0a', fg: '#f0fef0' });
assert.ok(list.some(([hex, name]) => hex === '#0d0b0a' && name === 'bg'));
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 6cbe85f5e..9b71fc9be 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -34,12 +34,17 @@
.cdd.compact.is-default{border-color:#8f7810;box-shadow:inset 0 0 0 1px #8f7810}
.cddsw{display:inline-block;width:13px;height:13px;border-radius:3px;border:1px solid #0007;flex:none}
.cdd.compact .cddsw{width:18px;height:18px}
- .cddpop{position:fixed;z-index:200;background:#161412;border:1px solid #3a3a3a;border-radius:6px;box-shadow:0 12px 34px #000c;max-height:60vh;overflow:auto;padding:4px}
- .cddrow{display:flex;align-items:center;gap:9px;padding:4px 9px;cursor:pointer;color:#cdced1;font:12px monospace;border-radius:4px;white-space:nowrap}
- .cddrow:hover{background:#252321}
- .cddrow.sel{outline:1px solid #e8bd30;outline-offset:-1px}
- .cddrow .cddnm{flex:1}
- .cddrow .cddhx{opacity:.55;margin-left:10px}
+ .cddpop{position:fixed;z-index:200;background:#161412;border:1px solid #3a3a3a;border-radius:6px;box-shadow:0 12px 34px #000c;max-height:70vh;overflow:auto;padding:8px}
+ .cddghead{display:flex;align-items:center;gap:8px;margin-bottom:7px}
+ .cddgdef{font:bold 11px monospace;color:#cdced1;background:#161412;border:1px solid #3a3a3a;border-radius:4px;padding:3px 10px;cursor:pointer}
+ .cddgdef:hover{background:#252321}
+ .cddgdef.sel{outline:1px solid #e8bd30;outline-offset:1px}
+ .cddglbl{font:11px monospace;color:#c66}
+ .cddgrow{display:flex;gap:3px;margin-bottom:3px}
+ .cddgc{display:inline-block;width:22px;height:22px;border-radius:3px;border:1px solid #0007;padding:0;cursor:pointer;flex:none}
+ .cddgc:hover{outline:1px solid #fff8;outline-offset:1px}
+ .cddgc.sel{outline:2px solid #e8bd30;outline-offset:1px}
+ .cddgc.gone{border:2px solid #c66}
.cstep.locked .cdd{cursor:default;opacity:.85;box-shadow:inset 0 0 0 2px #e8bd3088}
.lockbtn{background:none;border:none;cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;opacity:.5;filter:grayscale(1)}
.lockbtn.on{opacity:1;filter:none}
@@ -869,6 +874,29 @@ function paletteOptionList(cur,palette,ground){
sortColumns(grouped.columns).forEach(f=>lightestFirstMembers(f.members).forEach(m=>add(m.hex,m.name)));
return out;
}
+// Grid model for the gallery color picker. Mirrors the palette panel layout: a
+// ground row (bg/fg + ground steps) then one row per color family, members run
+// dark->light to match the panel. cur marks the one selected cell. The leading
+// "default" entry (clears the assignment) and, when cur points at a color no
+// longer in the palette, a "(gone)" entry live outside the family grid so every
+// dropdown choice stays reachable. Pure — shares columnsFromPalette / sortColumns
+// with the panel and the option list.
+function galleryModel(cur,palette,ground){
+ const want=(cur||'').toLowerCase(),sel=h=>(h||'').toLowerCase()===want;
+ const byLightAsc=(a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L;
+ const cell=m=>({hex:m.hex,name:m.name||m.hex,selected:sel(m.hex)});
+ const rows=[];
+ const groundCells=groundColumnMembersFromPalette(palette,ground||{})
+ .filter(m=>m&&m.hex).sort(byLightAsc).map(cell);
+ if(groundCells.length)rows.push({kind:'ground',cells:groundCells});
+ sortColumns(columnsFromPalette(palette,ground||{}).columns).forEach(f=>{
+ const cells=[...f.members].filter(m=>m&&m.hex).sort(byLightAsc).map(cell);
+ if(cells.length)rows.push({kind:'column',column:f.column,cells});
+ });
+ const have=cur===''||cur==null||rows.some(r=>r.cells.some(c=>sel(c.hex)));
+ const gone=(cur&&!have)?{hex:cur,name:'(gone)',selected:true}:null;
+ return {default:{hex:'',selected:cur===''||cur==null},gone,rows};
+}
function spanNeighborHex(cur,palette,ground,dir){
if(!cur)return null;
const wanted=(cur||'').toLowerCase(),groups=[],byLight=(a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L;
@@ -1369,18 +1397,34 @@ function mkColorDropdown(options,cur,onPick,opts={}){
left.onclick=e=>{e.stopPropagation();step(-1);};
right.onclick=e=>{e.stopPropagation();step(1);};
t.onclick=(e)=>{e.stopPropagation();if(wrap.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;}
- const pop=document.createElement('div');pop.className='cddpop';
- for(const [hex,name] of options){const row=document.createElement('div');row.className='cddrow'+(hex===cur?' sel':'');
- const shown=displayHex(hex),nm=hex?name:(opts.defaultName||name);
- row.style.background=hex?'':shown;row.style.color=dropdownRowTextColor(hex,shown,textOn);
- row.innerHTML=`<span class="cddsw" style="background:${shown||'transparent'}"></span><span class="cddnm">${esc(nm)}</span><span class="cddhx">${hex||shown||''}</span>`;
- row.onclick=(ev)=>{ev.stopPropagation();cur=hex;paint();closeColorDropdown();onPick(hex);};
- pop.appendChild(row);}
+ // 2D gallery: a grid of swatches in the palette-panel shape (ground strip,
+ // then one row per family) instead of a long vertical list. galleryModel is
+ // the shared pure layout (app-core.js).
+ const pop=document.createElement('div');pop.className='cddpop cddgrid';
+ const model=galleryModel(cur,PALETTE,{bg:MAP['bg'],fg:MAP['p']});
+ const pick=(hex)=>{cur=hex;paint();closeColorDropdown();onPick(hex);};
+ const head=document.createElement('div');head.className='cddghead';
+ const def=document.createElement('button');def.type='button';
+ def.className='cddgdef'+(model.default.selected?' sel':'');
+ def.textContent=opts.defaultName||'default';def.title='clear — use the default';
+ def.onclick=(ev)=>{ev.stopPropagation();pick('');};head.appendChild(def);
+ if(model.gone){const g=document.createElement('span');g.className='cddgc gone sel';
+ g.style.background=model.gone.hex;g.title='(gone) '+model.gone.hex;head.appendChild(g);
+ const gl=document.createElement('span');gl.className='cddglbl';gl.textContent='(gone) '+model.gone.hex;head.appendChild(gl);}
+ pop.appendChild(head);
+ for(const row of model.rows){const rr=document.createElement('div');rr.className='cddgrow';
+ for(const c of row.cells){const sw=document.createElement('button');sw.type='button';
+ sw.className='cddgc'+(c.selected?' sel':'');sw.style.background=c.hex;
+ sw.dataset.hex=c.hex;sw.dataset.name=c.name;sw.title=c.name+' '+c.hex;
+ sw.onclick=(ev)=>{ev.stopPropagation();pick(c.hex);};rr.appendChild(sw);}
+ pop.appendChild(rr);}
document.body.appendChild(pop);const r=t.getBoundingClientRect();
pop.style.left=r.left+'px';pop.style.minWidth=r.width+'px';
pop.style.top=(r.bottom+2)+'px';
const ph=pop.getBoundingClientRect().height;
if(r.bottom+ph>window.innerHeight-6)pop.style.top=Math.max(6,r.top-ph-2)+'px';
+ const pr=pop.getBoundingClientRect();
+ if(pr.right>window.innerWidth-6)pop.style.left=Math.max(6,window.innerWidth-6-pr.width)+'px';
_ddPop=pop;};
t.setValue=h=>{cur=h;paint();};
wrap.setValue=h=>{cur=h;paint();};
@@ -2938,7 +2982,7 @@ if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{i
const pdd=document.querySelector('#legbody tr[data-kind="p"] .cdd');
if(pdd){pdd.click();
const pHex=PALETTE.find(p=>p[0]!==MAP['p'])[0];
- const prow=[...document.querySelectorAll('.cddpop .cddrow')].find(r=>r.querySelector('.cddhx').textContent===pHex);
+ const prow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>c.dataset.hex===pHex);
if(prow)prow.click();
const pf=document.getElementById('uicr-fringe'),pfWant=contrast(pHex,'#aabbcc');
A(prow&&pf&&Math.abs(parseFloat(pf.textContent)-pfWant)<0.06,'default-fg change re-rates a p-fallback face: got '+(pf&&pf.textContent.trim())+' want '+pfWant.toFixed(1));
@@ -2969,15 +3013,46 @@ if(location.hash==='#beveltest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
PALETTE=[['#ff0000','red','red'],['#30343c','slate','slate']];
buildUITable();
const mlrow=document.querySelector('#uibody tr[data-face="mode-line"]'),boxCell=mlrow&&mlrow.cells[7],lineBtn=boxCell&&boxCell.querySelector('.boxbtn[data-style="line"]'),boxDd=boxCell&&boxCell.querySelector('.cdd');
- if(lineBtn&&boxDd){lineBtn.click();boxDd.click();const redRow=[...document.querySelectorAll('.cddpop .cddrow')].find(r=>r.textContent.includes('red'));if(redRow)redRow.click();}
+ if(lineBtn&&boxDd){lineBtn.click();boxDd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(UIMAP['mode-line'].box&&UIMAP['mode-line'].box.color==='#ff0000','UI box color dropdown writes box.color');
const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].box={style:'line',width:1,color:null};buildPkgTable();
const prow=document.querySelector('#pkgbody tr[data-face="'+face+'"]'),pbox=prow&&prow.cells[8],pdd=pbox&&pbox.querySelector('.cdd');
- if(pdd){pdd.click();const redRow=[...document.querySelectorAll('.cddpop .cddrow')].find(r=>r.textContent.includes('red'));if(redRow)redRow.click();}
+ if(pdd){pdd.click();const redRow=[...document.querySelectorAll('.cddpop .cddgc')].find(c=>(c.dataset.name||'').includes('red'));if(redRow)redRow.click();}
A(PKGMAP[app][face].box&&PKGMAP[app][face].box.color==='#ff0000','package box color dropdown writes box.color');
PALETTE=saveP;PKGMAP=savePK;for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();buildPkgTable();
document.title='BEVELTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='beveltest';d.textContent='BEVELTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Gallery gate (open with #gallerytest): the color dropdown opens a 2D grid in
+// the palette-panel shape. Driven on a throwaway dropdown so no real face state
+// is mutated. Covers: grid opens, every palette color has a cell, a cell click
+// fires onPick + updates the trigger, the pick highlights on reopen, the default
+// chip clears.
+if(location.hash==='#gallerytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ let picked='__none__';
+ const dd=mkColorDropdown(ddList(''),'',(hex)=>{picked=hex;},{});
+ document.body.appendChild(dd);
+ const trig=dd.querySelector('.cdd');trig.click();
+ const pop=document.querySelector('.cddpop.cddgrid');
+ A(pop,'dropdown opens a grid popup');
+ const cells=pop?[...pop.querySelectorAll('.cddgc')]:[];
+ A(cells.length>=PALETTE.length,'grid covers every palette color: '+cells.length+' cells for '+PALETTE.length+' palette colors');
+ A(pop&&pop.querySelector('.cddgdef'),'grid has a default chip');
+ A(pop&&pop.querySelector('.cddgrow'),'grid lays the colors out in rows');
+ const target=PALETTE.find(p=>p[0]!==MAP['bg'])||PALETTE[0];
+ const cell=cells.find(c=>c.dataset.hex===target[0]);
+ A(cell,'the target color has a cell');
+ if(cell){cell.click();
+ A(picked===target[0],'clicking a cell calls onPick with the color: '+picked);
+ A(trig.dataset.val===target[0],'the trigger button updates to the picked color: '+trig.dataset.val);
+ trig.click();
+ const sel=document.querySelector('.cddpop.cddgrid .cddgc.sel');
+ A(sel&&sel.dataset.hex===target[0],'the picked color is highlighted on reopen: '+(sel&&sel.dataset.hex));
+ closeColorDropdown();}
+ trig.click();const defc=document.querySelector('.cddpop.cddgrid .cddgdef');if(defc)defc.click();
+ A(picked==='','the default chip clears the assignment: '+JSON.stringify(picked));
+ dd.remove();closeColorDropdown();
+ document.title='GALLERYTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='gallerytest';d.textContent='GALLERYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
// Preview-link gate (open with #previewlinktest): known bespoke-preview face
// mappings stay wired to the face that Emacs actually uses.
if(location.hash==='#previewlinktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};