aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-19 00:40:30 -0500
committerCraig Jennings <c@cjennings.net>2026-06-19 00:40:30 -0500
commit3e2adc828860075e205d4b43c0b17fd8d448459b (patch)
tree39036f095d7a9e6cb408e66afc587c21fb90bd0a /scripts/theme-studio
parent65bccaed1ab56538c654919420d7977777395490 (diff)
downloaddotemacs-3e2adc828860075e205d4b43c0b17fd8d448459b.tar.gz
dotemacs-3e2adc828860075e205d4b43c0b17fd8d448459b.zip
refactor(theme-studio): polish the expander (underline inside, dynamic colspan, nd flag)
Three cleanups to the per-row expander from 3B-2. The underline control moves from the in-row style cell into the expander, next to overline. The row keeps weight, slant, and strike inline, so the style cell drops from three wrapped rows to two and the table reads flatter. mkExpander no longer hardcodes each table's colspan. tableColCount reads the column count from the table's header, so a detail row spans correctly even if a column is added later. A collapsed expander now flags itself when it hides an attribute that differs from the face's default, so a non-default value is never invisible. overflowNonDefault (app-core.js, unit-tested) compares the expander's attributes against the default. The toggle re-checks after every edit and gets the gold marker when any differ. faceBoxNonDefaults drops underline from the in-row style box in the same move, since underline is now the expander's concern. The #expandtest gate covers the underline control in its new home, its wavy write, and the flag appearing then clearing. Full suite green: Python 59, Node 201, ERT 41, plus the browser hash gates.
Diffstat (limited to 'scripts/theme-studio')
-rw-r--r--scripts/theme-studio/app-core.js22
-rw-r--r--scripts/theme-studio/app.js27
-rw-r--r--scripts/theme-studio/browser-gates.js20
-rw-r--r--scripts/theme-studio/styles.css1
-rw-r--r--scripts/theme-studio/test-app-core.mjs31
-rw-r--r--scripts/theme-studio/theme-studio.html68
6 files changed, 139 insertions, 30 deletions
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 81a667bd1..566e5a69b 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -451,11 +451,29 @@ function faceBoxNonDefaults(cur,def){
return {
fg: !eq(cur.fg,def.fg),
bg: !eq(cur.bg,def.bg),
- style: ['weight','slant','underline','strike'].some(a=>JSON.stringify(cur[a]??null)!==JSON.stringify(def[a]??null)),
+ style: ['weight','slant','strike'].some(a=>JSON.stringify(cur[a]??null)!==JSON.stringify(def[a]??null)),
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, migrateLegacyFace, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet };
+// True when the per-row expander hides at least one attribute that differs from
+// the face's default, so the collapsed toggle can flag it. Covers exactly the
+// attributes the expander holds: distant-fg, family, underline, overline,
+// inverse, extend, and (for ui/syntax) inherit + height. The in-row controls
+// (fg/bg/weight/slant/strike/box) have their own cell markers and are excluded.
+function overflowNonDefault(cur,def,showInheritHeight){
+ cur=cur||{}; def=def||{};
+ const eq=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
+ if(['distant-fg','family','underline','overline'].some(a=>!eq(cur[a],def[a])))return true;
+ if((!!cur.inverse)!==(!!def.inverse))return true;
+ if((!!cur.extend)!==(!!def.extend))return true;
+ if(showInheritHeight){
+ if(!eq(cur.inherit,def.inherit))return true;
+ if((cur.height||1)!==(def.height||1))return true;
+ }
+ return false;
+}
+
+export { nameToHex, migrateLegacyFace, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, stepViewIndex, 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 6cdcd63a6..8e6b01de6 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -166,12 +166,14 @@ function mkUnderlineControl(get,set,opts={}){
return mkLineStyleControl([['','no underline',''],['line','underline','_'],['wave','wavy underline','~']],get,set,Object.assign({styled:true},opts));}
function mkStrikeControl(get,set,opts={}){
return mkLineStyleControl([['','no strike',''],['on','strike-through','S']],get,set,Object.assign({styled:false},opts));}
+// In-row style controls: weight + slant selectors and a strike control. The
+// underline control lives in the per-row expander (it carries the wave/color
+// detail), keeping the row compact.
function mkStyleControls(face,onChange,opts={}){
const w=mkEnumSelect(WEIGHT_OPTS,()=>face.weight,v=>{face.weight=v;onChange();},'font weight');
const s=mkEnumSelect(SLANT_OPTS,()=>face.slant,v=>{face.slant=v;onChange();},'font slant');
- const u=mkUnderlineControl(()=>face.underline,v=>{face.underline=v;onChange();},opts);
const k=mkStrikeControl(()=>face.strike,v=>{face.strike=v;onChange();},opts);
- return [w,s,u,k];}
+ return [w,s,k];}
function mkOverlineControl(get,set,opts={}){
return mkLineStyleControl([['','no overline',''],['on','overline','O']],get,set,Object.assign({styled:false},opts));}
function mkCheck(get,set){const c=document.createElement('input');c.type='checkbox';c.className='detailcheck';c.checked=!!get();c.onchange=()=>set(c.checked);return c;}
@@ -187,6 +189,7 @@ function mkDetailEditor(face,onChange,opts={}){
add('distant fg',df);
const fam=document.createElement('input');fam.type='text';fam.className='detailinput';fam.placeholder='font family';fam.value=face.family||'';fam.onchange=()=>{face.family=fam.value.trim()||null;onChange();};
add('family',fam);
+ add('underline',mkUnderlineControl(()=>face.underline,v=>{face.underline=v;onChange();},opts));
add('overline',mkOverlineControl(()=>face.overline,v=>{face.overline=v;onChange();},opts));
add('inverse',mkCheck(()=>face.inverse,v=>{face.inverse=v;onChange();}));
add('extend',mkCheck(()=>face.extend,v=>{face.extend=v;onChange();}));
@@ -203,10 +206,20 @@ function mkDetailEditor(face,onChange,opts={}){
// right after the main row.
function mkExpander(face,colspan,onChange,opts={}){
const detail=document.createElement('tr');detail.className='detailrow';detail.style.display='none';
- const td=document.createElement('td');td.colSpan=colspan;const {el,locks}=mkDetailEditor(face,onChange,opts);td.appendChild(el);detail.appendChild(td);
- const btn=document.createElement('button');btn.className='exptoggle';btn.textContent='⋯';btn.title='more attributes';
+ const btn=document.createElement('button');btn.className='exptoggle';btn.textContent='⋯';
+ // Flag the toggle when collapsed and at least one hidden attribute differs from
+ // the default, so a non-default attribute is never invisible. ndCheck re-runs
+ // after every edit (for tiers whose onChange does not rebuild the row).
+ const ndCheck=opts.ndCheck||(()=>false);
+ const refreshNd=()=>{const nd=ndCheck();btn.classList.toggle('exp-nd',nd);btn.title=nd?'more attributes (some differ from default)':'more attributes';};
+ const wrapped=()=>{onChange();refreshNd();};
+ const td=document.createElement('td');td.colSpan=colspan;const {el,locks}=mkDetailEditor(face,wrapped,opts);td.appendChild(el);detail.appendChild(td);
btn.onclick=()=>{const open=detail.style.display==='none';detail.style.display=open?'':'none';btn.classList.toggle('on',open);};
+ refreshNd();
return {btn,detail,locks};}
+// Column count for a table's detail-row colspan, read from its header so the
+// expander never hardcodes a width that drifts when a column is added.
+function tableColCount(tableId){const h=document.querySelector('#'+tableId+' thead tr');return h?h.cells.length:1;}
// Apply a batch action to every editable row in a tier. keyFn maps a row entry to
// its lock key, or null to skip the row entirely (syntax bg and the default fg);
// resetFn does the actual clearing. Locked rows are left untouched.
@@ -281,7 +294,7 @@ function buildTable(){
const c0=document.createElement('td');c0.appendChild(dd);
const cB=document.createElement('td');cB.appendChild(bgd);
const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>syntaxFace(kind).box,b=>{syntaxFace(kind).box=b;styleEx();renderCode();},{compact:true});cX.appendChild(boxCtl);
- const exp=mkExpander(syntaxFace(kind),8,()=>{styleEx();renderCode();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:rowFg()});
+ const exp=mkExpander(syntaxFace(kind),tableColCount('legtable'),()=>{styleEx();renderCode();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:rowFg(),ndCheck:()=>overflowNonDefault(syntaxFace(kind),DEFAULT_SYNTAX[kind],true)});
exp.detail.dataset.detailFor=kind;
const lkTd=mkLockCell(kind,[dd,bgd,...stCtls,boxCtl,...exp.locks]);
const c2=document.createElement('td');c2.className='cat';c2.appendChild(exp.btn);
@@ -623,7 +636,7 @@ function buildPkgTable(){
const nd=faceBoxNonDefaults(
{fg:nameToHex(f.fg,PALETTE),bg:nameToHex(f.bg,PALETTE),weight:f.weight,slant:f.slant,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),weight:def.weight,slant:def.slant,underline:def.underline,strike:def.strike,inherit:def.inherit,height:def.height,box:def.box});
- const exp=mkExpander(f,9,()=>{f.source='user';pkgChanged();},{defaultHex:effFg(pkgEffFg(app,face))});
+ const exp=mkExpander(f,tableColCount('pkgtable'),()=>{f.source='user';pkgChanged();},{defaultHex:effFg(pkgEffFg(app,face)),ndCheck:()=>overflowNonDefault(f,def,false)});
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.title=face;c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.onclick=()=>flashPkgPreview(face);c0.appendChild(c0lbl);
@@ -1174,7 +1187,7 @@ function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
const tr=document.createElement('tr');tr.dataset.face=face;
- const exp=mkExpander(UIMAP[face],8,()=>{paintUI(face);buildMockFrame();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:effFg(UIMAP[face].fg)});
+ const exp=mkExpander(UIMAP[face],tableColCount('uitable'),()=>{paintUI(face);buildMockFrame();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:effFg(UIMAP[face].fg),ndCheck:()=>overflowNonDefault(UIMAP[face],DEFAULT_UIMAP[face],true)});
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.title='flash this face in the live preview';c0lbl.onclick=()=>flashUiPreview(face);c0.appendChild(c0lbl);
diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js
index f3a237666..ebd4a3f00 100644
--- a/scripts/theme-studio/browser-gates.js
+++ b/scripts/theme-studio/browser-gates.js
@@ -159,9 +159,6 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(uiWeight&&uiWeight.value==='','ui weight select starts empty when model is unset');
uiWeight.value='bold';uiWeight.dispatchEvent(new Event('change'));
A(UIMAP['region'].weight==='bold','ui weight select writes the model');
- const uiUnder=regionRow.querySelector('.boxctl .boxbtn[data-style="wave"]');
- uiUnder.click();
- A(UIMAP['region'].underline&&UIMAP['region'].underline.style==='wave','ui underline control writes a wavy underline object');
const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].weight=null;buildPkgTable();
const pkgWeight=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"] select.stylesel');
A(pkgWeight()&&pkgWeight().value==='','pkg weight select starts empty when model is unset');
@@ -843,7 +840,7 @@ if(location.hash==='#styletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(sels.length===2,'weight-and-slant-selectors-present');
A(sels[0]&&[...sels[0].options].some(o=>o.value==='semibold'),'weight-selector-offers-the-curated-range');
A(sels[1]&&[...sels[1].options].some(o=>o.value==='oblique'),'slant-selector-offers-oblique');
- A(cluster&&cluster.querySelectorAll('.boxctl').length===2,'underline-and-strike-controls-present');
+ A(cluster&&cluster.querySelectorAll('.boxctl').length===1,'strike-control-in-row-underline-moved-to-expander');
document.title='STYLETEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='styletest';d.textContent='STYLETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
// Expander gate (open with #expandtest): the per-row "more" toggle reveals a
@@ -859,10 +856,15 @@ if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
btn&&btn.click();
A(detail&&detail.style.display!=='none','toggle-reveals-detail-row');
const ed=detail&&detail.querySelector('.detailedit');
- A(ed&&ed.querySelectorAll('.detailfield').length>=5,'detail-editor-has-the-overflow-fields');
+ A(ed&&ed.querySelectorAll('.detailfield').length>=6,'detail-editor-has-the-overflow-fields');
// ui faces also expose inherit + height in the expander
A(ed&&ed.querySelector('select.detailsel'),'ui-expander-offers-inherit');
A(ed&&ed.querySelector('input.hstep'),'ui-expander-offers-height');
+ // underline moved into the expander; its wave style writes a styled object
+ const uiUnder=ed&&ed.querySelector('.boxctl .boxbtn[data-style="wave"]');
+ A(!!uiUnder,'underline-control-in-expander');
+ uiUnder&&uiUnder.click();
+ A(UIMAP['region'].underline&&UIMAP['region'].underline.style==='wave','underline-control-writes-a-wavy-object');
// family text input writes the model
const fam=ed&&ed.querySelector('input.detailinput');
if(fam){fam.value='Iosevka';fam.dispatchEvent(new Event('change'));}
@@ -871,6 +873,14 @@ if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
const inv=ed&&ed.querySelector('input.detailcheck');
if(inv){inv.checked=true;inv.dispatchEvent(new Event('change'));}
A(UIMAP['region'].inverse===true,'inverse-checkbox-writes-the-model');
+ // a hidden non-default attribute flags the collapsed toggle (reset region to its
+ // default first, since the edits above left several overflow attrs changed)
+ UIMAP['region']=JSON.parse(JSON.stringify(DEFAULT_UIMAP['region']));buildUITable();
+ const cleanbtn=document.querySelector('#uibody tr[data-face="region"] .exptoggle');
+ A(cleanbtn&&!cleanbtn.classList.contains('exp-nd'),'toggle-unflagged-when-overflow-matches-default');
+ UIMAP['region']=JSON.parse(JSON.stringify(DEFAULT_UIMAP['region']));UIMAP['region'].overline={color:null};buildUITable();
+ const ndbtn=document.querySelector('#uibody tr[data-face="region"] .exptoggle');
+ A(ndbtn&&ndbtn.classList.contains('exp-nd'),'collapsed-toggle-flags-a-hidden-non-default-attr');
// package expander omits inherit/height (they have inline columns)
buildPkgTable();const pface=APPS[curApp()].faces[0][0];
const pdetail=document.querySelector('#pkgbody tr.detailrow[data-detail-for="'+pface+'"]');
diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css
index 794ebd399..de9aa9fd0 100644
--- a/scripts/theme-studio/styles.css
+++ b/scripts/theme-studio/styles.css
@@ -23,6 +23,7 @@
select.stylesel{width:78px;padding:2px 4px;font:11px monospace;font-weight:normal}
.exptoggle{width:18px;height:18px;padding:0;border:1px solid #3a3a3a;border-radius:3px;background:#1f1c19;color:#8a9496;font:12px monospace;line-height:1;cursor:pointer;vertical-align:middle}
.exptoggle.on{background:#3a3320;border-color:#e8bd30;color:#e8bd30}
+ .exptoggle.exp-nd{border-color:#e8bd30;color:#e8bd30}
.exptoggle:disabled{opacity:.3;cursor:default}
tr.detailrow>td{background:#15120f;border-top:1px solid #2a2a2a;padding:8px 14px}
.detailedit{display:flex;flex-wrap:wrap;align-items:center;gap:14px}
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index d5f015d93..457f04d17 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, migrateLegacyFace, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, dropdownRowTextColor, paletteOptionList, spanNeighborHex, slugify,
clearPalettePlan, deletePaletteColumnPlan, groundColumnMembersFromPalette, areAllLocked, lockToggleLabel, toggleLockSet,
- galleryModel, appViewKeysSorted, faceBoxNonDefaults, stepViewIndex,
+ galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, stepViewIndex,
} from './app-core.js';
import { planPaletteGenerator, entriesForGeneratedColumn } from './palette-generator-core.js';
import { oklch2hex, deltaE } from './colormath.js';
@@ -957,12 +957,37 @@ 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', () => {
+test('faceBoxNonDefaults: an in-row style attr differing flags the style box once', () => {
assert.equal(faceBoxNonDefaults({ weight: 'bold' }, { weight: null }).style, true);
assert.equal(faceBoxNonDefaults({ slant: 'italic' }, {}).style, true);
- assert.equal(faceBoxNonDefaults({ underline: { style: 'line', color: null } }, {}).style, true);
+ assert.equal(faceBoxNonDefaults({ strike: { color: null } }, {}).style, true);
+ // underline lives in the expander now, so it does not flag the in-row style box
+ assert.equal(faceBoxNonDefaults({ underline: { style: 'line', color: null } }, {}).style, false);
assert.equal(faceBoxNonDefaults({ weight: 'bold' }, { weight: 'bold' }).style, false);
});
+
+test('overflowNonDefault: Normal — flags an expander attr that differs from default', () => {
+ assert.equal(overflowNonDefault({ family: 'Iosevka' }, {}, false), true);
+ assert.equal(overflowNonDefault({ underline: { style: 'wave', color: null } }, {}, false), true);
+ assert.equal(overflowNonDefault({ inverse: true }, {}, false), true);
+ assert.equal(overflowNonDefault({ 'distant-fg': '#222222' }, {}, false), true);
+});
+
+test('overflowNonDefault: Boundary — matching attrs and in-row attrs do not flag', () => {
+ // identical overflow attrs -> no flag
+ const f = { family: 'Iosevka', overline: { color: '#abc' }, inverse: true };
+ assert.equal(overflowNonDefault(f, f, false), false);
+ // weight/slant/strike are in-row, not the expander's concern
+ assert.equal(overflowNonDefault({ weight: 'bold', slant: 'italic', strike: { color: null } }, {}, false), false);
+});
+
+test('overflowNonDefault: Boundary — inherit/height only count when shown in the expander', () => {
+ // packages keep inherit/height inline (showInheritHeight false) -> not flagged here
+ assert.equal(overflowNonDefault({ inherit: 'shadow', height: 1.4 }, {}, false), false);
+ // ui/syntax expose them in the expander (showInheritHeight true) -> flagged
+ assert.equal(overflowNonDefault({ inherit: 'shadow' }, {}, true), true);
+ assert.equal(overflowNonDefault({ height: 1.4 }, {}, true), true);
+});
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);
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index aa358e5fe..c00dccb0a 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -25,6 +25,7 @@
select.stylesel{width:78px;padding:2px 4px;font:11px monospace;font-weight:normal}
.exptoggle{width:18px;height:18px;padding:0;border:1px solid #3a3a3a;border-radius:3px;background:#1f1c19;color:#8a9496;font:12px monospace;line-height:1;cursor:pointer;vertical-align:middle}
.exptoggle.on{background:#3a3320;border-color:#e8bd30;color:#e8bd30}
+ .exptoggle.exp-nd{border-color:#e8bd30;color:#e8bd30}
.exptoggle:disabled{opacity:.3;cursor:default}
tr.detailrow>td{background:#15120f;border-top:1px solid #2a2a2a;padding:8px 14px}
.detailedit{display:flex;flex-wrap:wrap;align-items:center;gap:14px}
@@ -975,12 +976,30 @@ function faceBoxNonDefaults(cur,def){
return {
fg: !eq(cur.fg,def.fg),
bg: !eq(cur.bg,def.bg),
- style: ['weight','slant','underline','strike'].some(a=>JSON.stringify(cur[a]??null)!==JSON.stringify(def[a]??null)),
+ style: ['weight','slant','strike'].some(a=>JSON.stringify(cur[a]??null)!==JSON.stringify(def[a]??null)),
inherit: !eq(cur.inherit,def.inherit),
height: (cur.height||1)!==(def.height||1),
box: JSON.stringify(cur.box??null)!==JSON.stringify(def.box??null),
};
}
+
+// True when the per-row expander hides at least one attribute that differs from
+// the face's default, so the collapsed toggle can flag it. Covers exactly the
+// attributes the expander holds: distant-fg, family, underline, overline,
+// inverse, extend, and (for ui/syntax) inherit + height. The in-row controls
+// (fg/bg/weight/slant/strike/box) have their own cell markers and are excluded.
+function overflowNonDefault(cur,def,showInheritHeight){
+ cur=cur||{}; def=def||{};
+ const eq=(a,b)=>JSON.stringify(a??null)===JSON.stringify(b??null);
+ if(['distant-fg','family','underline','overline'].some(a=>!eq(cur[a],def[a])))return true;
+ if((!!cur.inverse)!==(!!def.inverse))return true;
+ if((!!cur.extend)!==(!!def.extend))return true;
+ if(showInheritHeight){
+ if(!eq(cur.inherit,def.inherit))return true;
+ if((cur.height||1)!==(def.height||1))return true;
+ }
+ return false;
+}
// 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
@@ -1559,12 +1578,14 @@ function mkUnderlineControl(get,set,opts={}){
return mkLineStyleControl([['','no underline',''],['line','underline','_'],['wave','wavy underline','~']],get,set,Object.assign({styled:true},opts));}
function mkStrikeControl(get,set,opts={}){
return mkLineStyleControl([['','no strike',''],['on','strike-through','S']],get,set,Object.assign({styled:false},opts));}
+// In-row style controls: weight + slant selectors and a strike control. The
+// underline control lives in the per-row expander (it carries the wave/color
+// detail), keeping the row compact.
function mkStyleControls(face,onChange,opts={}){
const w=mkEnumSelect(WEIGHT_OPTS,()=>face.weight,v=>{face.weight=v;onChange();},'font weight');
const s=mkEnumSelect(SLANT_OPTS,()=>face.slant,v=>{face.slant=v;onChange();},'font slant');
- const u=mkUnderlineControl(()=>face.underline,v=>{face.underline=v;onChange();},opts);
const k=mkStrikeControl(()=>face.strike,v=>{face.strike=v;onChange();},opts);
- return [w,s,u,k];}
+ return [w,s,k];}
function mkOverlineControl(get,set,opts={}){
return mkLineStyleControl([['','no overline',''],['on','overline','O']],get,set,Object.assign({styled:false},opts));}
function mkCheck(get,set){const c=document.createElement('input');c.type='checkbox';c.className='detailcheck';c.checked=!!get();c.onchange=()=>set(c.checked);return c;}
@@ -1580,6 +1601,7 @@ function mkDetailEditor(face,onChange,opts={}){
add('distant fg',df);
const fam=document.createElement('input');fam.type='text';fam.className='detailinput';fam.placeholder='font family';fam.value=face.family||'';fam.onchange=()=>{face.family=fam.value.trim()||null;onChange();};
add('family',fam);
+ add('underline',mkUnderlineControl(()=>face.underline,v=>{face.underline=v;onChange();},opts));
add('overline',mkOverlineControl(()=>face.overline,v=>{face.overline=v;onChange();},opts));
add('inverse',mkCheck(()=>face.inverse,v=>{face.inverse=v;onChange();}));
add('extend',mkCheck(()=>face.extend,v=>{face.extend=v;onChange();}));
@@ -1596,10 +1618,20 @@ function mkDetailEditor(face,onChange,opts={}){
// right after the main row.
function mkExpander(face,colspan,onChange,opts={}){
const detail=document.createElement('tr');detail.className='detailrow';detail.style.display='none';
- const td=document.createElement('td');td.colSpan=colspan;const {el,locks}=mkDetailEditor(face,onChange,opts);td.appendChild(el);detail.appendChild(td);
- const btn=document.createElement('button');btn.className='exptoggle';btn.textContent='⋯';btn.title='more attributes';
+ const btn=document.createElement('button');btn.className='exptoggle';btn.textContent='⋯';
+ // Flag the toggle when collapsed and at least one hidden attribute differs from
+ // the default, so a non-default attribute is never invisible. ndCheck re-runs
+ // after every edit (for tiers whose onChange does not rebuild the row).
+ const ndCheck=opts.ndCheck||(()=>false);
+ const refreshNd=()=>{const nd=ndCheck();btn.classList.toggle('exp-nd',nd);btn.title=nd?'more attributes (some differ from default)':'more attributes';};
+ const wrapped=()=>{onChange();refreshNd();};
+ const td=document.createElement('td');td.colSpan=colspan;const {el,locks}=mkDetailEditor(face,wrapped,opts);td.appendChild(el);detail.appendChild(td);
btn.onclick=()=>{const open=detail.style.display==='none';detail.style.display=open?'':'none';btn.classList.toggle('on',open);};
+ refreshNd();
return {btn,detail,locks};}
+// Column count for a table's detail-row colspan, read from its header so the
+// expander never hardcodes a width that drifts when a column is added.
+function tableColCount(tableId){const h=document.querySelector('#'+tableId+' thead tr');return h?h.cells.length:1;}
// Apply a batch action to every editable row in a tier. keyFn maps a row entry to
// its lock key, or null to skip the row entirely (syntax bg and the default fg);
// resetFn does the actual clearing. Locked rows are left untouched.
@@ -1674,7 +1706,7 @@ function buildTable(){
const c0=document.createElement('td');c0.appendChild(dd);
const cB=document.createElement('td');cB.appendChild(bgd);
const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>syntaxFace(kind).box,b=>{syntaxFace(kind).box=b;styleEx();renderCode();},{compact:true});cX.appendChild(boxCtl);
- const exp=mkExpander(syntaxFace(kind),8,()=>{styleEx();renderCode();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:rowFg()});
+ const exp=mkExpander(syntaxFace(kind),tableColCount('legtable'),()=>{styleEx();renderCode();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:rowFg(),ndCheck:()=>overflowNonDefault(syntaxFace(kind),DEFAULT_SYNTAX[kind],true)});
exp.detail.dataset.detailFor=kind;
const lkTd=mkLockCell(kind,[dd,bgd,...stCtls,boxCtl,...exp.locks]);
const c2=document.createElement('td');c2.className='cat';c2.appendChild(exp.btn);
@@ -2267,7 +2299,7 @@ function buildPkgTable(){
const nd=faceBoxNonDefaults(
{fg:nameToHex(f.fg,PALETTE),bg:nameToHex(f.bg,PALETTE),weight:f.weight,slant:f.slant,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),weight:def.weight,slant:def.slant,underline:def.underline,strike:def.strike,inherit:def.inherit,height:def.height,box:def.box});
- const exp=mkExpander(f,9,()=>{f.source='user';pkgChanged();},{defaultHex:effFg(pkgEffFg(app,face))});
+ const exp=mkExpander(f,tableColCount('pkgtable'),()=>{f.source='user';pkgChanged();},{defaultHex:effFg(pkgEffFg(app,face)),ndCheck:()=>overflowNonDefault(f,def,false)});
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.title=face;c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.onclick=()=>flashPkgPreview(face);c0.appendChild(c0lbl);
@@ -2818,7 +2850,7 @@ function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
const tr=document.createElement('tr');tr.dataset.face=face;
- const exp=mkExpander(UIMAP[face],8,()=>{paintUI(face);buildMockFrame();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:effFg(UIMAP[face].fg)});
+ const exp=mkExpander(UIMAP[face],tableColCount('uitable'),()=>{paintUI(face);buildMockFrame();},{showInheritHeight:true,inheritOptions:[''].concat(BASE_INHERITS),defaultHex:effFg(UIMAP[face].fg),ndCheck:()=>overflowNonDefault(UIMAP[face],DEFAULT_UIMAP[face],true)});
exp.detail.dataset.detailFor=face;
const c0=document.createElement('td');c0.className='cat';c0.appendChild(exp.btn);
const c0lbl=document.createElement('span');c0lbl.textContent=' '+label;c0lbl.style.cursor='pointer';c0lbl.title='flash this face in the live preview';c0lbl.onclick=()=>flashUiPreview(face);c0.appendChild(c0lbl);
@@ -3022,9 +3054,6 @@ if(location.hash==='#mocktest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c
A(uiWeight&&uiWeight.value==='','ui weight select starts empty when model is unset');
uiWeight.value='bold';uiWeight.dispatchEvent(new Event('change'));
A(UIMAP['region'].weight==='bold','ui weight select writes the model');
- const uiUnder=regionRow.querySelector('.boxctl .boxbtn[data-style="wave"]');
- uiUnder.click();
- A(UIMAP['region'].underline&&UIMAP['region'].underline.style==='wave','ui underline control writes a wavy underline object');
const app=curApp(),face=APPS[app].faces[0][0];PKGMAP[app][face].weight=null;buildPkgTable();
const pkgWeight=()=>document.querySelector('#pkgbody tr[data-face="'+face+'"] select.stylesel');
A(pkgWeight()&&pkgWeight().value==='','pkg weight select starts empty when model is unset');
@@ -3706,7 +3735,7 @@ if(location.hash==='#styletest'){let ok=true;const notes=[];const A=(c,n)=>{if(!
A(sels.length===2,'weight-and-slant-selectors-present');
A(sels[0]&&[...sels[0].options].some(o=>o.value==='semibold'),'weight-selector-offers-the-curated-range');
A(sels[1]&&[...sels[1].options].some(o=>o.value==='oblique'),'slant-selector-offers-oblique');
- A(cluster&&cluster.querySelectorAll('.boxctl').length===2,'underline-and-strike-controls-present');
+ A(cluster&&cluster.querySelectorAll('.boxctl').length===1,'strike-control-in-row-underline-moved-to-expander');
document.title='STYLETEST '+(ok?'PASS':'FAIL');
const d=document.createElement('div');d.id='styletest';d.textContent='STYLETEST '+(ok?'PASS':'FAIL')+(notes.length?' fails='+notes.join(','):'');document.body.appendChild(d);}
// Expander gate (open with #expandtest): the per-row "more" toggle reveals a
@@ -3722,10 +3751,15 @@ if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
btn&&btn.click();
A(detail&&detail.style.display!=='none','toggle-reveals-detail-row');
const ed=detail&&detail.querySelector('.detailedit');
- A(ed&&ed.querySelectorAll('.detailfield').length>=5,'detail-editor-has-the-overflow-fields');
+ A(ed&&ed.querySelectorAll('.detailfield').length>=6,'detail-editor-has-the-overflow-fields');
// ui faces also expose inherit + height in the expander
A(ed&&ed.querySelector('select.detailsel'),'ui-expander-offers-inherit');
A(ed&&ed.querySelector('input.hstep'),'ui-expander-offers-height');
+ // underline moved into the expander; its wave style writes a styled object
+ const uiUnder=ed&&ed.querySelector('.boxctl .boxbtn[data-style="wave"]');
+ A(!!uiUnder,'underline-control-in-expander');
+ uiUnder&&uiUnder.click();
+ A(UIMAP['region'].underline&&UIMAP['region'].underline.style==='wave','underline-control-writes-a-wavy-object');
// family text input writes the model
const fam=ed&&ed.querySelector('input.detailinput');
if(fam){fam.value='Iosevka';fam.dispatchEvent(new Event('change'));}
@@ -3734,6 +3768,14 @@ if(location.hash==='#expandtest'){let ok=true;const notes=[];const A=(c,n)=>{if(
const inv=ed&&ed.querySelector('input.detailcheck');
if(inv){inv.checked=true;inv.dispatchEvent(new Event('change'));}
A(UIMAP['region'].inverse===true,'inverse-checkbox-writes-the-model');
+ // a hidden non-default attribute flags the collapsed toggle (reset region to its
+ // default first, since the edits above left several overflow attrs changed)
+ UIMAP['region']=JSON.parse(JSON.stringify(DEFAULT_UIMAP['region']));buildUITable();
+ const cleanbtn=document.querySelector('#uibody tr[data-face="region"] .exptoggle');
+ A(cleanbtn&&!cleanbtn.classList.contains('exp-nd'),'toggle-unflagged-when-overflow-matches-default');
+ UIMAP['region']=JSON.parse(JSON.stringify(DEFAULT_UIMAP['region']));UIMAP['region'].overline={color:null};buildUITable();
+ const ndbtn=document.querySelector('#uibody tr[data-face="region"] .exptoggle');
+ A(ndbtn&&ndbtn.classList.contains('exp-nd'),'collapsed-toggle-flags-a-hidden-non-default-attr');
// package expander omits inherit/height (they have inline columns)
buildPkgTable();const pface=APPS[curApp()].faces[0][0];
const pdetail=document.querySelector('#pkgbody tr.detailrow[data-detail-for="'+pface+'"]');