diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-16 05:59:55 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-16 05:59:55 -0500 |
| commit | b126dafca9f8424907904619e2b3d2d0d78d1635 (patch) | |
| tree | 372eff36cf8666e0fa319615753e351323f108e3 /scripts | |
| parent | d27783bd9ed5441f71762c0a4ac863bc0443ac16 (diff) | |
| download | dotemacs-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.js | 21 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 9 | ||||
| -rw-r--r-- | scripts/theme-studio/browser-gates.js | 21 | ||||
| -rw-r--r-- | scripts/theme-studio/styles.css | 5 | ||||
| -rw-r--r-- | scripts/theme-studio/test-app-core.mjs | 34 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 54 |
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. |
