From 3581c7d1c05eb514aa5462b1142605541fb64d9e Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 2 Jul 2026 23:40:16 -0400 Subject: feat(theme-studio): inline height control on the exposed face rows A new size column in the UI and package tables carries one numeric field plus an abs/rel toggle, exposed per the editable-height spec: chrome faces (mode-line family, line-number family, and header-line/tab-bar/tab-line when they arrive) default to absolute 1/10pt entry with a computed pt hint; the seeded heading faces (org-level-*, document title/info, agenda structure/dates, shr headings, and friends) default to a relative multiplier. Any other face carrying a live height exposes the control dynamically; the long tail gets none. Absolute entry takes a positive integer only; relative entry clamps into the 0.1-2.0 range the old field used; garbage never reaches the model. The toggle writes heightMode explicitly and clears the number on a flip, since 130 tenth-points and 1.3x mean different things. The kind-unaware height field in the row expander is retired, and a non-default height now marks the size cell instead of the expander toggle. The seeded set is named statically in app-core.js because the per-row default comes from the captured Emacs snapshot, which carries no heights for those faces. The #preview screenshot hash now accepts @ui/@code view keys so the harness can shoot the UI table. --- scripts/theme-studio/app-core.js | 55 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) (limited to 'scripts/theme-studio/app-core.js') diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index d4e9f9e7..e8f99835 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -523,15 +523,14 @@ function faceBoxNonDefaults(cur,def){ // 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){ +function overflowNonDefault(cur,def,showInherit){ 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(showInherit){ if(!eq(cur.inherit,def.inherit))return true; - if((cur.height||1)!==(def.height||1))return true; } return false; } @@ -554,6 +553,54 @@ function clampHeight(raw,min=HEIGHT_MIN,max=HEIGHT_MAX){ return nmax?max:n; } +// --- height control (editable-height spec, Phase 2) -------------------------- +// The chrome faces pin a fixed 1/10pt height so they never track a buffer's +// enlarged default face. Matching is name-based, so chrome faces added to the +// studio later (header-line, tab-bar, tab-line) expose the control on arrival; +// the line-number family matches by prefix. +const HEIGHT_CHROME=['mode-line','mode-line-inactive','header-line','tab-bar','tab-line']; +function isChromeFace(face){return HEIGHT_CHROME.includes(face)||/^line-number/.test(face);} +// The seeded text faces (the ~15 carrying a relative height in face_data.py's +// curated seeds). Named statically because the runtime per-face default comes +// from the captured Emacs snapshot, which has no heights for these -- the +// curated seed is not reachable from the row. org-level-* exposes as a family +// (only 1-4 carry seeds, but a height on level 5 must be editable too). +const HEIGHT_SEEDED=['org-document-title','org-document-info','org-agenda-structure', + 'org-agenda-date','org-agenda-date-today','shr-h1','shr-h2','shr-sup', + 'lsp-details-face','dashboard-banner-logo-title','embark-verbose-indicator-title', + 'calibredb-current-page-button-face']; +function isSeededHeightFace(face){return HEIGHT_SEEDED.includes(face)||/^org-level-[1-8]$/.test(face);} +// Which height control a face row exposes: 'abs' for chrome, 'rel' for the +// seeded text faces and for any face that already carries a height (live value +// or row default), null for the long tail (no control). An explicit heightMode +// on the face wins, so a user's toggle choice survives rebuilds. +function heightControlKind(face,cur,def){ + const has=v=>typeof v==='number'&&isFinite(v)&&v!==1; + const mode=cur&&cur.heightMode; + if(isChromeFace(face))return mode||'abs'; + if(isSeededHeightFace(face)||has(cur&&cur.height)||has(def&&def.height))return mode||'rel'; + return null; +} +// Validate a typed height for KIND. Absolute takes a positive integer (the raw +// 1/10pt value Emacs stores); relative takes a positive float, clamped into +// [HEIGHT_MIN,HEIGHT_MAX] like the old expander field. Blank -> null (unset); +// anything else -> undefined (rejected; the caller keeps the old value). +function parseHeightEntry(kind,raw){ + if(raw==null)return null; + const s=(''+raw).trim(); + if(s==='')return null; + if(kind==='abs'){ + if(!/^\d+$/.test(s))return undefined; + const n=parseInt(s,10); + return n>0?n:undefined; + } + if(!/^\d*\.?\d+$/.test(s))return undefined; + const n=parseFloat(s); + return n>0?clampHeight(n):undefined; +} +// The computed hint beside an absolute entry: 130 -> "= 13.0pt". +function ptHint(height){return typeof height==='number'&&isFinite(height)?('= '+(height/10).toFixed(1)+'pt'):'';} + // Compose an element-hover tooltip: the face's docstring on top, the existing // hover text (e.g. the bare face name) below it, separated by a blank line. A // missing doc or base collapses to whichever is present; missing both yields ''. @@ -709,4 +756,4 @@ function formatLocateTitle(meta){ return parts.concat(locateAttrsList(meta.attrs)).join(', '); } -export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, clampHeight, HEIGHT_MIN, HEIGHT_MAX, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet, buildLocateRegistry, locateFaceMeta, formatLocateTitle, isLocateOnPane }; +export { nameToHex, migrateLegacyFace, cssWeight, faceDecoration, boxCss, faceCss, composeHoverTitle, normalizePkgFace, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, resolveSyntaxFg, resolveUiAttr, paletteOptionList, galleryModel, appViewKeysSorted, faceBoxNonDefaults, overflowNonDefault, clampHeight, HEIGHT_MIN, HEIGHT_MAX, isChromeFace, heightControlKind, parseHeightEntry, ptHint, stepViewIndex, spanNeighborHex, slugify, fgSetFor, floor, lMax, COVERED_FACES, columnsFromPalette, usedPaletteHexes, paletteUsages, regenColumn, rankByLightness, stepRepointPlan, sortColumns, sortColumnMembers, groundRoleOfEntry, groundColumnMembersFromPalette, clearPalettePlan, deletePaletteColumnPlan, areAllLocked, lockToggleLabel, toggleLockSet, buildLocateRegistry, locateFaceMeta, formatLocateTitle, isLocateOnPane }; -- cgit v1.2.3