aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-16 05:59:55 -0500
committerCraig Jennings <c@cjennings.net>2026-06-16 05:59:55 -0500
commitb126dafca9f8424907904619e2b3d2d0d78d1635 (patch)
tree372eff36cf8666e0fa319615753e351323f108e3 /scripts
parentd27783bd9ed5441f71762c0a4ac863bc0443ac16 (diff)
downloaddotemacs-b126dafca9f8424907904619e2b3d2d0d78d1635.tar.gz
dotemacs-b126dafca9f8424907904619e2b3d2d0d78d1635.zip
feat(theme-studio): mark per-face setting boxes that differ from default
A non-default height looks identical to the default in the size input, so a stray 1.1 hides in plain sight. I added a small gold corner flag on any per-face setting cell (fg, bg, style, inherit, size, box) whose value differs from the face's seed default. A pure faceBoxNonDefaults helper computes the per-box flags. buildPkgTable resolves fg/bg to hex before comparing, so a palette-name-vs-hex difference doesn't read as a change.
Diffstat (limited to 'scripts')
-rw-r--r--scripts/theme-studio/app-core.js21
-rw-r--r--scripts/theme-studio/app.js9
-rw-r--r--scripts/theme-studio/browser-gates.js21
-rw-r--r--scripts/theme-studio/styles.css5
-rw-r--r--scripts/theme-studio/test-app-core.mjs34
-rw-r--r--scripts/theme-studio/theme-studio.html54
6 files changed, 140 insertions, 4 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 4df6e8a24..df99a0d37 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -417,4 +417,23 @@ function appViewKeysSorted(apps){
String((apps[b]&&apps[b].label)||b), undefined, {sensitivity:'base'}));
}
-export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
+// Which of the six per-face setting boxes (fg, bg, style, inherit, height, box)
+// differ from the face's seed default, so the table can mark a non-default box.
+// A non-default height looks identical to the default in the number input, so the
+// mark is the only at-a-glance signal. cur and def are face objects; the caller
+// resolves fg/bg to hex first so a palette-name-vs-hex difference doesn't read as a
+// change. The four style attributes collapse to one "style" flag.
+function faceBoxNonDefaults(cur,def){
+ cur=cur||{}; def=def||{};
+ const eq=(a,b)=>(a??null)===(b??null);
+ return {
+ fg: !eq(cur.fg,def.fg),
+ bg: !eq(cur.bg,def.bg),
+ style: ['bold','italic','underline','strike'].some(a=>!!cur[a]!==!!def[a]),
+ inherit: !eq(cur.inherit,def.inherit),
+ height: (cur.height||1)!==(def.height||1),
+ box: JSON.stringify(cur.box??null)!==JSON.stringify(def.box??null),
+ };
+}
+
+export { nameToHex, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, 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 3ebd37587..23307edac 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -539,9 +539,14 @@ function buildPkgTable(){
const app=curApp(),tb=document.getElementById('pkgbody');if(!tb)return;tb.innerHTML='';
const flt=(document.getElementById('pkgfilter').value||'').trim().toLowerCase();
const inh=[''].concat(BASE_INHERITS).concat(APPS[app].faces.map(r=>r[0]));
- for(const [face,label] of APPS[app].faces){
+ for(const row of APPS[app].faces){
+ const face=row[0],label=row[1];
if(flt&&!(face.toLowerCase().includes(flt)||label.toLowerCase().includes(flt)))continue;
const f=PKGMAP[app][face],tr=document.createElement('tr');tr.dataset.face=face;
+ const def=normalizePkgFace(row[2]||{},'default',PALETTE);
+ const nd=faceBoxNonDefaults(
+ {fg:nameToHex(f.fg,PALETTE),bg:nameToHex(f.bg,PALETTE),bold:f.bold,italic:f.italic,underline:f.underline,strike:f.strike,inherit:f.inherit,height:f.height,box:f.box},
+ {fg:nameToHex(def.fg,PALETTE),bg:nameToHex(def.bg,PALETTE),bold:def.bold,italic:def.italic,underline:def.underline,strike:def.strike,inherit:def.inherit,height:def.height,box:def.box});
const c0=document.createElement('td');c0.className='cat';c0.textContent=label;c0.title=face;c0.style.cursor='pointer';c0.onclick=()=>flashPkgPreview(face);
const fgd=mkColorDropdown(ddList(f.fg||''),f.fg||'',h=>{f.fg=h||null;f.source='user';pkgChanged();},{compact:true,defaultHex:effFg(pkgEffFg(app,face))}),
bgd=mkColorDropdown(ddList(f.bg||''),f.bg||'',h=>{f.bg=h||null;f.source='user';pkgChanged();},{compact:true,defaultHex:effBg(pkgEffBg(app,face))});
@@ -555,6 +560,8 @@ function buildPkgTable(){
const cc=document.createElement('td');cc.style.fontSize='10pt';cc.style.whiteSpace='nowrap';const efg=effFg(pkgEffFg(app,face)),ebg=effBg(pkgEffBg(app,face)),r=contrast(efg,ebg);cc.innerHTML=crHtml(r);
const cx=document.createElement('td');const boxCtl=mkBoxControl(()=>f.box,b=>{f.box=b;f.source='user';pkgChanged();},{compact:true});cx.appendChild(boxCtl);
const cL=mkLockCell('pkg:'+app+':'+face,[fgd,bgd,...pkBtns,isel,hin,boxCtl]);
+ if(nd.fg)cf.classList.add('nd');if(nd.bg)cb.classList.add('nd');if(nd.style)cw.classList.add('nd');
+ if(nd.inherit)ci.classList.add('nd');if(nd.height)ch.classList.add('nd');if(nd.box)cx.classList.add('nd');
tr.append(c0,cL,cf,cb,cw,cc,ci,ch,cx);tb.appendChild(tr);
}
applyTableSort('pkgbody');
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index 0eae09dcf..d0986b56b 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -698,6 +698,27 @@ if(location.hash==='#viewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
}
document.title='VIEWTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='viewtest';d.textContent='VIEWTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+// Non-default-marker gate (open with #ndtest): a per-face setting cell gets the
+// .nd corner flag only when its value differs from the face's seed default. Cell
+// order in a pkg row: 0 label, 1 lock, 2 fg, 3 bg, 4 style, 5 contrast, 6 inherit,
+// 7 size, 8 box.
+if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ LOCKED.clear();
+ const app=curApp(),row=APPS[app].faces[0],face=row[0];
+ PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
+ const tr0=document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ A(tr0&&![...tr0.cells].some(c=>c.classList.contains('nd')),'default-face-has-no-marker');
+ PKGMAP[app][face].height=1.7;PKGMAP[app][face].source='user';buildPkgTable();
+ const tr1=document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ A(tr1.cells[7].classList.contains('nd'),'nondefault-height-marks-size-box');
+ A(!tr1.cells[4].classList.contains('nd'),'unchanged-style-box-stays-unmarked');
+ PKGMAP[app][face].height=(row[2]&&row[2].height)||1;PKGMAP[app][face].bold=!((row[2]&&row[2].bold));buildPkgTable();
+ const tr2=document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ A(tr2.cells[4].classList.contains('nd'),'toggled-bold-marks-style-box');
+ A(!tr2.cells[7].classList.contains('nd'),'restored-height-unmarks-size-box');
+ PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
+ document.title='NDTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='ndtest';d.textContent='NDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
// Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.
diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css
index 47f58aca6..9c8b5aac9 100644
--- a/scripts/theme-studio/styles.css
+++ b/scripts/theme-studio/styles.css
@@ -23,6 +23,11 @@
.stylecluster .sbtn{margin:0}
table.leg th:hover{color:#e8bd30}
select.chip{appearance:none;border:1px solid #00000060;border-radius:5px;padding:5px 10px;font:bold 14px monospace;width:160px;cursor:pointer}
+ /* Non-default marker: a small gold corner flag on a per-face setting cell whose
+ value differs from the face's default. The size box looks identical default
+ or not, so the flag is the only at-a-glance cue that a value was changed. */
+ td.nd{position:relative}
+ td.nd::after{content:'';position:absolute;top:0;right:0;width:0;height:0;border-top:8px solid #e8bd30;border-left:8px solid transparent;pointer-events:none}
.cstep{display:inline-flex;align-items:center;gap:4px}
.cstepbtn{width:22px;height:28px;padding:0;border:1px solid #3a3a3a;border-radius:4px;background:#1f1c19;color:#e8bd30;font:bold 14px monospace;cursor:pointer}
.cstepbtn:disabled{opacity:.28;cursor:default;color:#8f8977}
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index a55abadd0..20f3d5734 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -9,7 +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, appViewKeysSorted,
+ galleryModel, appViewKeysSorted, faceBoxNonDefaults,
} from './app-core.js';
import { planPaletteGenerator, entriesForGeneratedColumn } from './palette-generator-core.js';
import { oklch2hex, deltaE } from './colormath.js';
@@ -846,3 +846,35 @@ test('appViewKeysSorted: an app with no label falls back to its key for ordering
const apps = { zebra: {}, apple: { label: 'apple' } };
assert.deepEqual(appViewKeysSorted(apps), ['apple', 'zebra']);
});
+
+// faceBoxNonDefaults: which of the six per-face setting boxes differ from the
+// face's seed default, so the table can mark them. fg/bg are compared as the
+// caller passes them (already hex-resolved), the rest by value.
+test('faceBoxNonDefaults: a face equal to its default flags nothing', () => {
+ const f = { fg: '#abc', bg: null, bold: false, italic: false, underline: false, strike: false, inherit: null, height: 1, box: null };
+ assert.deepEqual(faceBoxNonDefaults(f, { ...f }),
+ { fg: false, bg: false, style: false, inherit: false, height: false, box: false });
+});
+test('faceBoxNonDefaults: a non-1 height flags only the height box', () => {
+ const def = { height: 1 };
+ assert.deepEqual(faceBoxNonDefaults({ height: 1.1 }, def),
+ { fg: false, bg: false, style: false, inherit: false, height: true, box: false });
+});
+test('faceBoxNonDefaults: a set fg over an empty default flags fg', () => {
+ assert.equal(faceBoxNonDefaults({ fg: '#8ea85e' }, {}).fg, true);
+ assert.equal(faceBoxNonDefaults({}, {}).fg, false);
+});
+test('faceBoxNonDefaults: any style attr differing flags the style box once', () => {
+ assert.equal(faceBoxNonDefaults({ bold: true }, { bold: false }).style, true);
+ assert.equal(faceBoxNonDefaults({ strike: true }, {}).style, true);
+ assert.equal(faceBoxNonDefaults({ bold: true }, { bold: true }).style, false);
+});
+test('faceBoxNonDefaults: inherit and box differences are flagged', () => {
+ assert.equal(faceBoxNonDefaults({ inherit: 'bold' }, { inherit: null }).inherit, true);
+ assert.equal(faceBoxNonDefaults({ box: { style: 'line' } }, { box: null }).box, true);
+ assert.equal(faceBoxNonDefaults({ box: { style: 'line' } }, { box: { style: 'line' } }).box, false);
+});
+test('faceBoxNonDefaults: nullish inputs flag nothing', () => {
+ assert.deepEqual(faceBoxNonDefaults(null, null),
+ { fg: false, bg: false, style: false, inherit: false, height: false, box: false });
+});
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 5501135b5..fdc5be974 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -25,6 +25,11 @@
.stylecluster .sbtn{margin:0}
table.leg th:hover{color:#e8bd30}
select.chip{appearance:none;border:1px solid #00000060;border-radius:5px;padding:5px 10px;font:bold 14px monospace;width:160px;cursor:pointer}
+ /* Non-default marker: a small gold corner flag on a per-face setting cell whose
+ value differs from the face's default. The size box looks identical default
+ or not, so the flag is the only at-a-glance cue that a value was changed. */
+ td.nd{position:relative}
+ td.nd::after{content:'';position:absolute;top:0;right:0;width:0;height:0;border-top:8px solid #e8bd30;border-left:8px solid transparent;pointer-events:none}
.cstep{display:inline-flex;align-items:center;gap:4px}
.cstepbtn{width:22px;height:28px;padding:0;border:1px solid #3a3a3a;border-radius:4px;background:#1f1c19;color:#e8bd30;font:bold 14px monospace;cursor:pointer}
.cstepbtn:disabled{opacity:.28;cursor:default;color:#8f8977}
@@ -921,6 +926,25 @@ function appViewKeysSorted(apps){
String((apps[a]&&apps[a].label)||a).localeCompare(
String((apps[b]&&apps[b].label)||b), undefined, {sensitivity:'base'}));
}
+
+// Which of the six per-face setting boxes (fg, bg, style, inherit, height, box)
+// differ from the face's seed default, so the table can mark a non-default box.
+// A non-default height looks identical to the default in the number input, so the
+// mark is the only at-a-glance signal. cur and def are face objects; the caller
+// resolves fg/bg to hex first so a palette-name-vs-hex difference doesn't read as a
+// change. The four style attributes collapse to one "style" flag.
+function faceBoxNonDefaults(cur,def){
+ cur=cur||{}; def=def||{};
+ const eq=(a,b)=>(a??null)===(b??null);
+ return {
+ fg: !eq(cur.fg,def.fg),
+ bg: !eq(cur.bg,def.bg),
+ style: ['bold','italic','underline','strike'].some(a=>!!cur[a]!==!!def[a]),
+ inherit: !eq(cur.inherit,def.inherit),
+ height: (cur.height||1)!==(def.height||1),
+ box: JSON.stringify(cur.box??null)!==JSON.stringify(def.box??null),
+ };
+}
// 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
@@ -2113,9 +2137,14 @@ function buildPkgTable(){
const app=curApp(),tb=document.getElementById('pkgbody');if(!tb)return;tb.innerHTML='';
const flt=(document.getElementById('pkgfilter').value||'').trim().toLowerCase();
const inh=[''].concat(BASE_INHERITS).concat(APPS[app].faces.map(r=>r[0]));
- for(const [face,label] of APPS[app].faces){
+ for(const row of APPS[app].faces){
+ const face=row[0],label=row[1];
if(flt&&!(face.toLowerCase().includes(flt)||label.toLowerCase().includes(flt)))continue;
const f=PKGMAP[app][face],tr=document.createElement('tr');tr.dataset.face=face;
+ const def=normalizePkgFace(row[2]||{},'default',PALETTE);
+ const nd=faceBoxNonDefaults(
+ {fg:nameToHex(f.fg,PALETTE),bg:nameToHex(f.bg,PALETTE),bold:f.bold,italic:f.italic,underline:f.underline,strike:f.strike,inherit:f.inherit,height:f.height,box:f.box},
+ {fg:nameToHex(def.fg,PALETTE),bg:nameToHex(def.bg,PALETTE),bold:def.bold,italic:def.italic,underline:def.underline,strike:def.strike,inherit:def.inherit,height:def.height,box:def.box});
const c0=document.createElement('td');c0.className='cat';c0.textContent=label;c0.title=face;c0.style.cursor='pointer';c0.onclick=()=>flashPkgPreview(face);
const fgd=mkColorDropdown(ddList(f.fg||''),f.fg||'',h=>{f.fg=h||null;f.source='user';pkgChanged();},{compact:true,defaultHex:effFg(pkgEffFg(app,face))}),
bgd=mkColorDropdown(ddList(f.bg||''),f.bg||'',h=>{f.bg=h||null;f.source='user';pkgChanged();},{compact:true,defaultHex:effBg(pkgEffBg(app,face))});
@@ -2129,6 +2158,8 @@ function buildPkgTable(){
const cc=document.createElement('td');cc.style.fontSize='10pt';cc.style.whiteSpace='nowrap';const efg=effFg(pkgEffFg(app,face)),ebg=effBg(pkgEffBg(app,face)),r=contrast(efg,ebg);cc.innerHTML=crHtml(r);
const cx=document.createElement('td');const boxCtl=mkBoxControl(()=>f.box,b=>{f.box=b;f.source='user';pkgChanged();},{compact:true});cx.appendChild(boxCtl);
const cL=mkLockCell('pkg:'+app+':'+face,[fgd,bgd,...pkBtns,isel,hin,boxCtl]);
+ if(nd.fg)cf.classList.add('nd');if(nd.bg)cb.classList.add('nd');if(nd.style)cw.classList.add('nd');
+ if(nd.inherit)ci.classList.add('nd');if(nd.height)ch.classList.add('nd');if(nd.box)cx.classList.add('nd');
tr.append(c0,cL,cf,cb,cw,cc,ci,ch,cx);tb.appendChild(tr);
}
applyTableSort('pkgbody');
@@ -3317,6 +3348,27 @@ if(location.hash==='#viewtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
}
document.title='VIEWTEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='viewtest';d.textContent='VIEWTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
+// Non-default-marker gate (open with #ndtest): a per-face setting cell gets the
+// .nd corner flag only when its value differs from the face's seed default. Cell
+// order in a pkg row: 0 label, 1 lock, 2 fg, 3 bg, 4 style, 5 contrast, 6 inherit,
+// 7 size, 8 box.
+if(location.hash==='#ndtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ LOCKED.clear();
+ const app=curApp(),row=APPS[app].faces[0],face=row[0];
+ PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
+ const tr0=document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ A(tr0&&![...tr0.cells].some(c=>c.classList.contains('nd')),'default-face-has-no-marker');
+ PKGMAP[app][face].height=1.7;PKGMAP[app][face].source='user';buildPkgTable();
+ const tr1=document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ A(tr1.cells[7].classList.contains('nd'),'nondefault-height-marks-size-box');
+ A(!tr1.cells[4].classList.contains('nd'),'unchanged-style-box-stays-unmarked');
+ PKGMAP[app][face].height=(row[2]&&row[2].height)||1;PKGMAP[app][face].bold=!((row[2]&&row[2].bold));buildPkgTable();
+ const tr2=document.querySelector('#pkgbody tr[data-face="'+face+'"]');
+ A(tr2.cells[4].classList.contains('nd'),'toggled-bold-marks-style-box');
+ A(!tr2.cells[7].classList.contains('nd'),'restored-height-unmarks-size-box');
+ PKGMAP[app][face]=seedFace(row[2]||{});buildPkgTable();
+ document.title='NDTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='ndtest';d.textContent='NDTEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
// Box-cluster gate (open with #boxtest): the box control is a 2x2 cluster of
// four radio buttons (none / line / pressed / raised); the color swatch shows
// only while a box style is active.