diff options
Diffstat (limited to 'scripts')
| -rwxr-xr-x | scripts/google-keep/keep-bridge.py | 92 | ||||
| -rw-r--r-- | scripts/google-keep/test_keep_bridge.py | 152 | ||||
| -rw-r--r-- | scripts/theme-studio/app-core.js | 20 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 240 | ||||
| -rw-r--r-- | scripts/theme-studio/browser-gates.js | 19 | ||||
| -rw-r--r-- | scripts/theme-studio/controls.js | 209 | ||||
| -rw-r--r-- | scripts/theme-studio/face_coverage.py | 8 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 40 | ||||
| -rw-r--r-- | scripts/theme-studio/palette-generator-core.js | 3 | ||||
| -rw-r--r-- | scripts/theme-studio/styles.css | 7 | ||||
| -rw-r--r-- | scripts/theme-studio/test-locate.mjs | 27 | ||||
| -rw-r--r-- | scripts/theme-studio/test_generate.py | 33 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 88 |
13 files changed, 609 insertions, 329 deletions
diff --git a/scripts/google-keep/keep-bridge.py b/scripts/google-keep/keep-bridge.py new file mode 100755 index 000000000..ef1fdd75a --- /dev/null +++ b/scripts/google-keep/keep-bridge.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +"""keep-bridge -- fetch Google Keep notes via gkeepapi and emit JSON. + +The one place the unofficial Google Keep API lives, isolated so a break is +contained and the elisp renderer talks only to this script's JSON contract. +See docs/specs/google-keep-emacs-integration-spec.org (Bridge JSON schema). + +Reads two environment variables (set by the elisp caller, which pulls the +token from authinfo.gpg via auth-source): + + KEEP_EMAIL the Google account email + KEEP_MASTER_TOKEN the gkeepapi master token + +On success: prints a JSON array of note objects on stdout, exits 0. An empty +Keep prints "[]". On failure: exits non-zero with one reason token on stderr, +which the elisp sentinel maps to a display-warning: + + no-gkeepapi gkeepapi is not importable + no-token KEEP_MASTER_TOKEN or KEEP_EMAIL is unset + auth-failed gkeepapi rejected the credentials + network a network/other error reaching Keep +""" + +import json +import os +import sys +from datetime import timezone +from typing import NoReturn + + +def iso8601_utc(dt): + """Format DT (a datetime) as ISO8601 UTC with a trailing Z, or None.""" + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def color_name(color): + """Return the Keep color as a plain string from a gkeepapi enum or a string.""" + return getattr(color, "value", None) or getattr(color, "name", None) or str(color) + + +def note_to_dict(note): + """Shape one gkeepapi note (or a duck-typed stand-in) into the schema dict.""" + return { + "id": note.id, + "title": note.title or "", + "text": note.text or "", + "labels": [label.name for label in note.labels.all()], + "pinned": bool(note.pinned), + "archived": bool(note.archived), + "color": color_name(note.color), + "updated": iso8601_utc(note.timestamps.updated), + } + + +def notes_to_json(notes): + """Serialize an iterable of NOTES to the schema JSON string.""" + return json.dumps([note_to_dict(n) for n in notes], ensure_ascii=False) + + +def _fail(token) -> NoReturn: + sys.stderr.write(token + "\n") + sys.exit(1) + + +def main(): + try: + import gkeepapi # type: ignore[import] # optional runtime dep + except ImportError: + _fail("no-gkeepapi") + email = os.environ.get("KEEP_EMAIL") + token = os.environ.get("KEEP_MASTER_TOKEN") + if not email or not token: + _fail("no-token") + keep = gkeepapi.Keep() + try: + keep.resume(email, token) + except Exception as exc: # gkeepapi raises LoginException on bad credentials + _fail("auth-failed" if type(exc).__name__ == "LoginException" else "network") + try: + keep.sync() + notes = list(keep.all()) + except Exception: + _fail("network") + sys.stdout.write(notes_to_json(notes)) + + +if __name__ == "__main__": + main() diff --git a/scripts/google-keep/test_keep_bridge.py b/scripts/google-keep/test_keep_bridge.py new file mode 100644 index 000000000..a24132744 --- /dev/null +++ b/scripts/google-keep/test_keep_bridge.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Tests for keep-bridge's pure shaping helpers + its failure degradation. + +The gkeepapi auth/fetch path is the IO boundary and is exercised live once the +token is configured; here we test the JSON-shaping logic (the round-trip +contract the elisp side reads) with duck-typed stand-ins, plus a subprocess +smoke test that the script degrades with a reason token rather than crashing. + +Run: python3 -m unittest test_keep_bridge (from scripts/google-keep/) +""" + +import importlib.util +import os +import subprocess +import sys +import unittest +from datetime import datetime, timezone, timedelta + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_BRIDGE = os.path.join(_HERE, "keep-bridge.py") + +_spec = importlib.util.spec_from_file_location("keep_bridge", _BRIDGE) +assert _spec and _spec.loader +kb = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(kb) + + +# --- duck-typed stand-ins for a gkeepapi note --------------------------------- + +class FakeLabel: + def __init__(self, name): + self.name = name + + +class FakeLabels: + def __init__(self, names): + self._labels = [FakeLabel(n) for n in names] + + def all(self): + return self._labels + + +class FakeTimestamps: + def __init__(self, updated): + self.updated = updated + + +class FakeColor: + def __init__(self, value): + self.value = value + + +class FakeNote: + def __init__(self, id="n1", title: object = "T", text: object = "B", labels=(), + pinned=False, archived=False, color: object = "WHITE", updated=None): + self.id = id + self.title = title + self.text = text + self.labels = FakeLabels(labels) + self.pinned = pinned + self.archived = archived + self.color = color + self.timestamps = FakeTimestamps(updated) + + +class TestIso8601Utc(unittest.TestCase): + def test_normal_naive_datetime_treated_as_utc(self): + self.assertEqual(kb.iso8601_utc(datetime(2026, 6, 25, 4, 12, 0)), + "2026-06-25T04:12:00Z") + + def test_normal_aware_datetime_converted_to_utc(self): + est = timezone(timedelta(hours=-5)) + self.assertEqual(kb.iso8601_utc(datetime(2026, 6, 24, 23, 12, 0, tzinfo=est)), + "2026-06-25T04:12:00Z") + + def test_boundary_none_returns_none(self): + self.assertIsNone(kb.iso8601_utc(None)) + + +class TestColorName(unittest.TestCase): + def test_normal_enum_with_value(self): + self.assertEqual(kb.color_name(FakeColor("RED")), "RED") + + def test_normal_plain_string(self): + self.assertEqual(kb.color_name("WHITE"), "WHITE") + + def test_boundary_name_only_object(self): + class C: + name = "BLUE" + self.assertEqual(kb.color_name(C()), "BLUE") + + +class TestNoteToDict(unittest.TestCase): + def test_normal_full_note(self): + note = FakeNote(id="abc", title="Groceries", text="milk\neggs", + labels=("shopping", "home"), pinned=True, archived=False, + color=FakeColor("YELLOW"), + updated=datetime(2026, 6, 25, 4, 0, 0, tzinfo=timezone.utc)) + self.assertEqual(kb.note_to_dict(note), { + "id": "abc", + "title": "Groceries", + "text": "milk\neggs", + "labels": ["shopping", "home"], + "pinned": True, + "archived": False, + "color": "YELLOW", + "updated": "2026-06-25T04:00:00Z", + }) + + def test_boundary_empty_title_and_no_labels(self): + note = FakeNote(title="", labels=(), color="WHITE", + updated=datetime(2026, 1, 1, tzinfo=timezone.utc)) + d = kb.note_to_dict(note) + self.assertEqual(d["title"], "") + self.assertEqual(d["labels"], []) + + def test_boundary_none_title_text_coerced_to_empty(self): + note = FakeNote(title=None, text=None, color="WHITE", + updated=datetime(2026, 1, 1, tzinfo=timezone.utc)) + d = kb.note_to_dict(note) + self.assertEqual(d["title"], "") + self.assertEqual(d["text"], "") + + +class TestNotesToJson(unittest.TestCase): + def test_normal_array_of_notes(self): + import json + notes = [FakeNote(id="a", updated=datetime(2026, 1, 1, tzinfo=timezone.utc)), + FakeNote(id="b", updated=datetime(2026, 1, 2, tzinfo=timezone.utc))] + parsed = json.loads(kb.notes_to_json(notes)) + self.assertEqual([n["id"] for n in parsed], ["a", "b"]) + + def test_boundary_empty_keep_is_empty_array(self): + self.assertEqual(kb.notes_to_json([]), "[]") + + +class TestDegradation(unittest.TestCase): + def test_error_no_env_exits_nonzero_with_reason_token(self): + # With no KEEP_EMAIL/KEEP_MASTER_TOKEN the script must exit non-zero + # with a single reason token, never crash. The exact token depends on + # whether gkeepapi is installed in this environment. + env = {k: v for k, v in os.environ.items() + if k not in ("KEEP_EMAIL", "KEEP_MASTER_TOKEN")} + proc = subprocess.run([sys.executable, _BRIDGE], env=env, + capture_output=True, text=True) + self.assertNotEqual(proc.returncode, 0) + self.assertIn(proc.stderr.strip(), ("no-gkeepapi", "no-token")) + self.assertEqual(proc.stdout, "") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 966010f4c..94b5d7ae8 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -652,15 +652,6 @@ function locateFaceMeta(owner,face,registry){ return e||{owner,face,unassigned:true}; } -// The owner-aware membership check the preview gate calls: the entry's attributes -// when (owner, face) is a known face of that owner, null when it isn't (a bad -// owner is rejected). A known face with no non-default attributes returns {} -- -// still truthy, so membership reads cleanly off the result. -function previewFaceAttrs(owner,face,registry){ - const e=registry&®istry[locateKey(owner,face)]; - return e?e.attrs:null; -} - // Clickable predicate: an element is on-pane only when its owner is the pane being // viewed. Recomputed from the current view at render time (never stored in the // registry), since switching panes changes clickability but not ownership. @@ -708,13 +699,4 @@ function formatLocateTitle(meta){ return parts.concat(locateAttrsList(meta.attrs)).join(', '); } -// The immediate-wayfinding info line shown in the preview-label area on hover: -// "section > face — value" (effective fg, plus bg when set). An unassigned meta -// reads "<face> — unassigned". Terser than the title; the title is the full record. -function locateInfoLine(meta){ - if(!meta||meta.unassigned)return (meta&&meta.face?meta.face:'')+' — unassigned'; - const val=meta.value.fg+(meta.value.bg?' / '+meta.value.bg:''); - return meta.section+' > '+meta.face+' — '+val; -} - -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, previewFaceAttrs, isLocateOnPane, locateInfoLine }; +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 }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index b50315981..ce1480ffb 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -68,213 +68,7 @@ function renderCode(){ cp.onclick=(e)=>{const s=e.target.closest('[data-k]');if(s)flashAssign(s.dataset.k);}; buildMockFrame(); } -// Custom color dropdown: a real swatch + name + hex per row, since native -// <option> background colors render unreliably on Linux Chrome. The popup is -// fixed-positioned on <body> so a table's overflow can't clip it. -let _ddPop=null; -function closeColorDropdown(){if(_ddPop){_ddPop.remove();_ddPop=null;}} -document.addEventListener('pointerdown',e=>{if(_ddPop&&!e.target.closest('.cdd')&&!e.target.closest('.cddpop'))closeColorDropdown();}); -function mkColorDropdown(options,cur,onPick,opts={}){ - const wrap=document.createElement('div');wrap.className='cstep'; - const left=document.createElement('button'),right=document.createElement('button'); - left.className='cstepbtn';right.className='cstepbtn';left.type=right.type='button'; - left.textContent='‹';right.textContent='›';left.title='move to next darker color in this column';right.title='move to next lighter color in this column'; - const t=document.createElement('div');t.className='cdd'+(opts.compact?' compact':'');t.tabIndex=0; - const nameOf=h=>{const o=options.find(p=>p[0]===h);return o?o[1]:(h||'none');}; - const displayHex=h=>h||(opts.defaultHex||''); - const displayName=h=>h?nameOf(h):(opts.defaultName||nameOf(h)); - function step(dir){if(wrap.dataset.locked==='1')return;const next=spanNeighborHex(cur,PALETTE,groundPair(),dir);if(!next)return;cur=next;paint();onPick(next);} - function paintStepButtons(){ - const locked=wrap.dataset.locked==='1'; - left.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),-1); - right.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),1); - } - function paint(){const shown=displayHex(cur),nm=displayName(cur),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)'); - t.innerHTML=opts.compact?`<span class="cddsw" style="background:${shown||'transparent'}"></span>`:`<span class="cddsw" style="background:${shown||'transparent'}"></span>${esc(nm)}`;paintStepButtons();} - paint(); - 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;} - // 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,groundPair()); - 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();}; - wrap.syncLocked=paintStepButtons; - wrap.appendChild(left);wrap.appendChild(t);wrap.appendChild(right);paintStepButtons(); - return wrap;} -// Standard option list for a swatch dropdown: a "default" entry, then the -// palette in the same ground/column order as the palette panel. If cur is set -// but no longer in the palette, surface it as a "(gone)" entry so the row still -// shows what it points at. Shared by all three tiers. -function ddList(cur){return paletteOptionList(cur,PALETTE,groundPair());} -// Shared lock toggle for any table row. lockKey is namespaced per tier (bare -// syntax kind, 'ui:'+face, 'pkg:'+app+':'+face). els are the row's editable -// controls — native selects/buttons/inputs are disabled; the custom swatch -// dropdown (a div) gets data-locked so its onclick refuses to open. -function mkLockCell(lockKey,els){ - const td=document.createElement('td');td.style.textAlign='center'; - const lk=document.createElement('button');lk.className='lockbtn'; - function paint(){const on=LOCKED.has(lockKey);lk.textContent=on?'🔒':'🔓';lk.classList.toggle('on',on); - lk.title=on?'locked — click to unlock':'click to lock this decision'; - (els||[]).forEach(el=>{if(!el)return; - if(el.tagName==='SELECT'||el.tagName==='BUTTON'||el.tagName==='INPUT')el.disabled=on; - else{el.dataset.locked=on?'1':'';el.classList.toggle('locked',on);if(el.syncLocked)el.syncLocked();}});} - lk.onclick=()=>{LOCKED.has(lockKey)?LOCKED.delete(lockKey):LOCKED.add(lockKey);paint();updateLockToggles();}; - paint();td.appendChild(lk);return td;} -// The in-row style controls, shared by the syntax / UI / package tables: a weight -// selector, a slant selector, and box-like underline and strike controls. Each -// edit mutates the face object and calls onChange to repaint. Returns the control -// elements so the caller lays them out and hands them to mkLockCell. -const WEIGHT_OPTS=[['light','light'],['normal','normal'],['medium','medium'],['semibold','semibold'],['bold','bold'],['heavy','heavy']]; -const SLANT_OPTS=[['normal','normal'],['italic','italic'],['oblique','oblique']]; -// A compact custom dropdown for an enum attribute (weight / slant), themed like -// the color dropdown. The trigger shows the current value drawn in its own weight -// or slant; the popup lists each option drawn with the attribute applied, so the -// choice previews itself. opts.styleFor(value) returns the preview style props -// ({fontWeight} / {fontStyle}); opts.placeholder is the unset-state label. -function mkEnumDropdown(options,get,set,opts={}){ - const t=document.createElement('div');t.className='cdd enumdd';t.tabIndex=0; - const styleFor=opts.styleFor||(()=>({})); - const labelOf=v=>{const o=options.find(p=>p[0]===v);return o?o[1]:'';}; - function applyPreview(el,v){el.style.fontWeight='';el.style.fontStyle='';const s=styleFor(v);if(s.fontWeight)el.style.fontWeight=s.fontWeight;if(s.fontStyle)el.style.fontStyle=s.fontStyle;} - function paint(){const v=get()||'';t.dataset.val=v;t.classList.toggle('is-default',!v); - t.textContent=v?labelOf(v):(opts.placeholder||'set');applyPreview(t,v);t.title=opts.title||'';} - paint(); - t.onclick=(e)=>{e.stopPropagation();if(t.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;} - const pop=document.createElement('div');pop.className='cddpop enumpop';const cur=get()||''; - const pick=v=>{set(v||null);paint();closeColorDropdown();}; - const def=document.createElement('button');def.type='button'; - def.className='enumopt enumdef'+(cur===''?' sel':'');def.textContent='default'; - def.title='clear — use the default';def.onclick=ev=>{ev.stopPropagation();pick('');};pop.appendChild(def); - for(const [v,label] of options){const b=document.createElement('button');b.type='button'; - b.className='enumopt'+(v===cur?' sel':'');b.textContent=label;applyPreview(b,v); - b.onclick=ev=>{ev.stopPropagation();pick(v);};pop.appendChild(b);} - 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'; - _ddPop=pop;}; - t.setValue=()=>paint();t.syncLocked=()=>paint(); - return t;} -// Underline control: none / line / wave glyph buttons plus a color swatch shown -// while a style is active. Mirrors mkBoxControl; get()/set() read and write the -// underline object ({style,color}) or null. -function mkLineStyleControl(states,get,set,opts={}){const wrap=document.createElement('div');wrap.className='boxctl'; - const cluster=document.createElement('div');cluster.className='boxcluster';const btns={}; - states.forEach(([v,title,glyph])=>{const b=document.createElement('button');b.className='boxbtn';b.dataset.style=v;b.textContent=glyph;b.title=title; - b.onclick=()=>{const cur=get();set(v?(opts.toState?opts.toState(v,cur):Object.assign({color:(cur&&cur.color)||null},opts.styled?{style:v}:{})):null);paint();}; - cluster.appendChild(b);btns[v]=b;}); - const dd=mkColorDropdown(ddList((get()&&get().color)||''),(get()&&get().color)||'',h=>{const cur=get();if(!cur)return;set(Object.assign({},cur,{color:h||null}));paint();},{compact:true,defaultHex:opts.defaultHex}); - function paint(){const cur=get(),active=opts.styled?(cur&&cur.style?cur.style:''):(cur?'on':''); - for(const v in btns)btns[v].classList.toggle('on',v===active); - dd.style.display=active?'':'none';dd.setValue(cur&&cur.color?cur.color:''); - const locked=wrap.dataset.locked==='1';for(const v in btns)btns[v].disabled=locked; - const ddoff=locked||!active;dd.dataset.locked=ddoff?'1':'';dd.classList.toggle('locked',ddoff);if(dd.syncLocked)dd.syncLocked();} - wrap.syncLocked=()=>paint();wrap.append(cluster,dd);paint();return wrap;} -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=mkEnumDropdown(WEIGHT_OPTS,()=>face.weight,v=>{face.weight=v;onChange();},{placeholder:'weight',title:'font weight',styleFor:v=>({fontWeight:cssWeight(v)})}); - const s=mkEnumDropdown(SLANT_OPTS,()=>face.slant,v=>{face.slant=v;onChange();},{placeholder:'slant',title:'font slant',styleFor:v=>({fontStyle:v||'normal'})}); - const k=mkStrikeControl(()=>face.strike,v=>{face.strike=v;onChange();},opts); - 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;} -// The per-row attribute editor revealed by the expander: distant-fg, family, -// overline, inverse, extend, and (for ui/syntax, where inherit/height have no -// inline column) inherit + height. Each control mutates FACE and calls onChange. -// Returns the element plus the interactive controls so the row's lock cell can -// disable them. opts.inheritOptions and opts.showInheritHeight gate the last two. -// Hover help for each expander field, so the detail labels explain themselves the -// way the table-header labels do. Keyed by the label text passed to add(). -const DETAIL_HOVERS={ - 'distant fg':'foreground swapped in when the text sits on a background too close to its own color to read (Emacs :distant-foreground)', - 'family':'font family for this face; blank inherits the default (Emacs :family)', - 'underline':'underline style and color (Emacs :underline)', - 'overline':'a line drawn above the text (Emacs :overline)', - 'inverse':'swap the foreground and background (Emacs :inverse-video)', - 'extend':'extend the background past the end of the line to the window edge (Emacs :extend)', - 'inherit':'base face this one inherits unset attributes from (Emacs :inherit)', - 'height':'text size as a scaling factor of the inherited height, 0.1 to 2.0 (Emacs :height)' -}; -function mkDetailEditor(face,onChange,opts={}){ - const wrap=document.createElement('div');wrap.className='detailedit';const locks=[]; - const add=(label,el)=>{const g=document.createElement('label');g.className='detailfield';g.title=DETAIL_HOVERS[label]||'';const s=document.createElement('span');s.textContent=label;g.append(s,el);wrap.appendChild(g);locks.push(el);}; - const df=mkColorDropdown(ddList(face['distant-fg']||''),face['distant-fg']||'',h=>{face['distant-fg']=h||null;onChange();},{compact:true,defaultHex:opts.defaultHex}); - 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();})); - if(opts.showInheritHeight){ - const isel=document.createElement('select');isel.className='chip detailsel'; - (opts.inheritOptions||['']).forEach(o=>{const op=document.createElement('option');op.value=o;op.textContent=o||'— none —';isel.appendChild(op);}); - isel.value=face.inherit||'';isel.onchange=()=>{face.inherit=isel.value||null;onChange();};add('inherit',isel); - const hin=document.createElement('input');hin.type='number';hin.min=''+HEIGHT_MIN;hin.max=''+HEIGHT_MAX;hin.step='0.05';hin.className='hstep';hin.value=face.height||1;hin.onchange=()=>{const raw=hin.value,h=clampHeight(raw);face.height=h;hin.value=h==null?1:h;if(h!=null&&parseFloat(raw)!==h)notify('height clamped to '+h+' (allowed '+HEIGHT_MIN+'–'+HEIGHT_MAX+')',false);onChange();};add('height',hin); - } - return {el:wrap,locks};} -// Wire a per-row expander: a toggle button plus a hidden detail row (colspan -// across the table) holding mkDetailEditor. The caller drops the button into a -// cell, adds the returned locks to the row's lock cell, and inserts detailRow -// right after the main row. -// Which rows have their detail expanded, keyed by the row's element/face key. -// Held outside the DOM so a table rebuild (a package edit rebuilds the whole -// table) re-opens the rows that were open, instead of collapsing them under the -// user — editing a value in an open expander must not close it. -let EXPANDED=new Set(); -function mkExpander(face,colspan,onChange,opts={}){ - const detail=document.createElement('tr');detail.className='detailrow';detail.style.display='none'; - if(opts.expandKey&&EXPANDED.has(opts.expandKey))detail.style.display=''; - const btn=document.createElement('button');btn.className='exptoggle'; - // The disclosure triangle shows the row's state: ▶ collapsed, ▼ expanded. - const setGlyph=()=>{const open=detail.style.display!=='none';btn.textContent=open?'▼':'▶';btn.classList.toggle('on',open);}; - // 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 willOpen=detail.style.display==='none';detail.style.display=willOpen?'':'none'; - if(opts.expandKey){willOpen?EXPANDED.add(opts.expandKey):EXPANDED.delete(opts.expandKey);} - setGlyph();syncExpandAllBtns();}; - refreshNd();setGlyph(); - return {btn,detail,locks};} +CONTROLS_J // Expand/collapse every row in a table at once, then sync the per-row triangles. function setAllExpanded(tableId,expand){ const tb=document.getElementById(tableId);if(!tb)return; @@ -304,7 +98,7 @@ function clearUnlockedRows(items,keyFn,resetFn){ for(const it of items){const k=keyFn(it);if(k===null)continue;if(!LOCKED.has(k))resetFn(it);} } function rebuildColorTables(){ - buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); + buildTable();buildUITable();buildPkgTable();// buildPkgTable self-guards when #pkgbody is absent } function refreshPaletteState(opts={}){ renderPalette();rebuildColorTables(); @@ -595,7 +389,9 @@ function flashPkg(f){flashRow(document.querySelector(`#pkgbody tr[data-face="${f function flashPkgPreview(f){const sp=document.querySelectorAll(`#pkgpreview [data-face="${f}"]`);if(sp.length){flashEls(sp);return;}const row=document.querySelector(`#pkgbody tr[data-face="${f}"]`);if(row)flashEl(row.querySelector('.cat'));} function mockSpan(k,t){return `<span data-k="${k}" style="${syntaxStyle(k)}">${esc(t)}</span>`;} function uiCss(o,fgv,bgv,opts={}){const fg=fgv===undefined?effFg(o.fg):fgv,bg=bgv===undefined?o.bg:bgv;return faceCss(o,fg,bg,{noBg:opts.noBg,boxBg:bg||MAP['bg']});} -function syncMockHeight(){const t=document.getElementById('uitable'),m=document.getElementById('mockframe');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} +// Size a preview pane to its faces table, minus the label bar above it. Shared by +// the UI mock and the package preview, which differ only in their element IDs. +function syncPaneHeight(tableId,paneId){const t=document.getElementById(tableId),m=document.getElementById(paneId);if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} function buildMockFrame(){ const fr=document.getElementById('mockframe');if(!fr)return; rebuildLocateRegistry(); @@ -720,9 +516,9 @@ function onViewChange(){const s=document.getElementById('viewsel');const v=(s&&s const show=(id,on)=>{const e=document.getElementById(id);if(e)e.style.display=on?'':'none';}; show('view-code',v==='@code');show('view-ui',v==='@ui');show('view-pkg',v[0]!=='@'); if(v==='@code')renderCode(); - else if(v==='@ui'){buildUITable();buildMockFrame();syncMockHeight();} + else if(v==='@ui'){buildUITable();buildMockFrame();syncPaneHeight('uitable','mockframe');} else pkgChanged();} -function pkgChanged(){buildPkgTable();buildPkgPreview();syncPkgHeight();} +function pkgChanged(){buildPkgTable();buildPkgPreview();syncPaneHeight('pkgtable','pkgpreview');} function buildPkgTable(){ const app=curApp(),tb=document.getElementById('pkgbody');if(!tb)return;tb.innerHTML=''; const flt=(document.getElementById('pkgfilter').value||'').trim().toLowerCase(); @@ -832,7 +628,6 @@ function buildPkgPreview(){ // no separate info line. } function resetApp(){const app=curApp();for(const [face,,d] of APPS[app].faces)if(!LOCKED.has('pkg:'+app+':'+face))PKGMAP[app][face]=seedFace(d);pkgChanged();notify('reset editable '+app+' faces to package defaults',false);} -function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} // --- worst-case readout for the covered overlay faces (spec Phase 4) --------- // Default WCAG target for the worst-case verdict (AA). AAA is selectable. let WORST_TARGET=4.5; @@ -875,7 +670,7 @@ function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getEle function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=cssWeight(o.weight);pv.style.fontStyle=o.slant||'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box,effBg(o.bg)); const report=coveredContrastReport(face); pv.title=''; - const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';if(report!==null){if(report.empty){cr.title='this overlay has no syntax foreground set yet';cr.innerHTML='<span title="this overlay has no syntax foreground set yet">no fg set</span>';}else{const title=failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1);cr.title=title;cr.innerHTML=`<span style="color:${ratingColor(report.worst.ratio)}" title="${esc(title)}">${report.worst.ratio.toFixed(1)}</span>`;}}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}} + const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';const wc=worstCellHtml(face);if(wc!==null){cr.title=report.empty?'this overlay has no syntax foreground set yet':(failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1));cr.innerHTML=wc;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}} function buildUITable(){ const tb=document.getElementById('uibody');tb.innerHTML=''; for(const [face,label,ex] of UI_FACES){ @@ -884,16 +679,23 @@ function buildUITable(){ exp.detail.dataset.detailFor=face; const c0=document.createElement('td');c0.className='cat';c0.title=composeHoverTitle(FACE_DOCS[face],c0.title);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); + // Emacs draws the cursor as a rectangle: its fg colors the glyph sitting on + // it and its bg is the cursor color, but weight/slant/underline/strike and + // box are no-ops on it. Show only fg+bg for the cursor row; mute the rest. + const cursorOnly=(face==='cursor'); + const naCell=t=>{const s=document.createElement('span');s.textContent='—';s.style.opacity='0.4';s.title=t;return s;}; const fgSel=uiSelect(face,'fg'),bgSel=uiSelect(face,'bg'); const cF=document.createElement('td');cF.appendChild(fgSel); const cB=document.createElement('td');cB.appendChild(bgSel); const cS=document.createElement('td'); - const stCtls=mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)}); - const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster); + const stCtls=cursorOnly?[]:mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)}); + if(cursorOnly){cS.appendChild(naCell('Emacs ignores weight/slant/underline/strike on the cursor face'));} + else{const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);} const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt'; const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px'; - const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});cX.appendChild(boxCtl); - const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]); + const cX=document.createElement('td');const boxCtl=cursorOnly?null:mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true}); + if(cursorOnly){cX.appendChild(naCell('Emacs ignores the box attribute on the cursor face'));}else{cX.appendChild(boxCtl);} + const cL=mkLockCell('ui:'+face,cursorOnly?[fgSel,bgSel,...exp.locks]:[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]); tr.appendChild(cL);tr.appendChild(c0);tr.appendChild(cF);tr.appendChild(cB);tr.appendChild(cS);tr.appendChild(cX);tr.appendChild(cC);tr.appendChild(cP);tb.appendChild(tr);tb.appendChild(exp.detail);paintUI(face); } applyTableSort('uibody'); @@ -918,9 +720,9 @@ function initApp(){ paletteShowFull=false; // open collapsed to base colors; the arrow expands the spans buildLangSel();buildViewSel();renderPalette();rebuildColorTables();renderCode();applyGround(); initGeneratorControls(); - updateTitle();initPicker();buildPkgPreview();syncMockHeight();syncPkgHeight(); + updateTitle();initPicker();buildPkgPreview();syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview'); onViewChange(); } initApp(); -addEventListener('resize',()=>{syncMockHeight();syncPkgHeight();}); +addEventListener('resize',()=>{syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview');}); BROWSER_GATES_J diff --git a/scripts/theme-studio/browser-gates.js b/scripts/theme-studio/browser-gates.js index 0bc6b2fbd..fcdfaff00 100644 --- a/scripts/theme-studio/browser-gates.js +++ b/scripts/theme-studio/browser-gates.js @@ -221,6 +221,21 @@ if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMA pickEnum(pkgWeight(),'heavy'); A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited'); })); +// Cursor-row gate (open with #cursorrowtest): the cursor face honors only fg +// (the glyph on it) and bg (the cursor color); weight/slant/underline/strike and +// box are no-ops, so the row mutes them to a dash while non-cursor rows keep them. +if(location.hash==='#cursorrowtest')gate('cursorrowtest',A=>{ + buildUITable(); + const rows=[...document.querySelectorAll('#uibody tr')]; + const cur=rows.find(r=>r.dataset.face==='cursor'); + A(!!cur,'cursor row present'); + A(!!cur.cells[2].querySelector('.cdd'),'cursor keeps the fg swatch'); + A(!!cur.cells[3].querySelector('.cdd'),'cursor keeps the bg swatch'); + A(!cur.cells[4].querySelector('.enumdd')&&cur.cells[4].textContent.includes('—'),'cursor mutes the style controls'); + A(cur.cells[5].textContent.includes('—'),'cursor mutes the box control'); + const ml=rows.find(r=>r.dataset.face==='mode-line'); + A(!!ml.cells[4].querySelector('.enumdd'),'non-cursor rows keep the style controls'); +}); // Palette-generator gate (open with #generatortest): previewing is non-mutating, // clicking a generated tile loads the existing selector, adding creates a normal // singleton base column, and appending a preview column commits all span members @@ -937,7 +952,7 @@ if(location.hash==='#pickertest')gate('pickertest',A=>{ // four radio buttons (none / line / pressed / raised); the color swatch shows // only while a box style is active. if(location.hash==='#boxtest')gate('boxtest',A=>{ - LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box; + LOCKED.clear();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor');const saveBox=UIMAP[f].box; // cursor has no box control by design UIMAP[f].box=null;buildUITable(); const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5]; A(!!cell.querySelector('.boxcluster'),'box-cluster-present'); @@ -956,7 +971,7 @@ if(location.hash==='#boxtest')gate('boxtest',A=>{ // Style-cluster gate (open with #styletest): the style cell holds a weight // selector, a slant selector, and box-like underline and strike controls. if(location.hash==='#styletest')gate('styletest',A=>{ - buildUITable();const f=UI_FACES[0][0]; + buildUITable();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor'); // cursor row has no style cluster by design const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4]; const cluster=cell.querySelector('.stylecluster'); A(!!cluster,'style-cluster-present'); diff --git a/scripts/theme-studio/controls.js b/scripts/theme-studio/controls.js new file mode 100644 index 000000000..e98a69a5c --- /dev/null +++ b/scripts/theme-studio/controls.js @@ -0,0 +1,209 @@ +// controls.js -- the custom dropdown / detail-editor / expander control +// factories, extracted from app.js for navigability. Inlined raw at the +// CONTROLS_J token: these are hoisting function declarations plus the +// dropdown popup state, so the token's position preserves execution order. +// Custom color dropdown: a real swatch + name + hex per row, since native +// <option> background colors render unreliably on Linux Chrome. The popup is +// fixed-positioned on <body> so a table's overflow can't clip it. +let _ddPop=null; +function closeColorDropdown(){if(_ddPop){_ddPop.remove();_ddPop=null;}} +document.addEventListener('pointerdown',e=>{if(_ddPop&&!e.target.closest('.cdd')&&!e.target.closest('.cddpop'))closeColorDropdown();}); +function mkColorDropdown(options,cur,onPick,opts={}){ + const wrap=document.createElement('div');wrap.className='cstep'; + const left=document.createElement('button'),right=document.createElement('button'); + left.className='cstepbtn';right.className='cstepbtn';left.type=right.type='button'; + left.textContent='‹';right.textContent='›';left.title='move to next darker color in this column';right.title='move to next lighter color in this column'; + const t=document.createElement('div');t.className='cdd'+(opts.compact?' compact':'');t.tabIndex=0; + const nameOf=h=>{const o=options.find(p=>p[0]===h);return o?o[1]:(h||'none');}; + function step(dir){if(wrap.dataset.locked==='1')return;const next=spanNeighborHex(cur,PALETTE,groundPair(),dir);if(!next)return;cur=next;paint();onPick(next);} + function paintStepButtons(){ + const locked=wrap.dataset.locked==='1'; + left.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),-1); + right.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),1); + } + function paint(){const shown=cur||(opts.defaultHex||''),nm=cur?nameOf(cur):(opts.defaultName||nameOf(cur)),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)'); + t.innerHTML=opts.compact?`<span class="cddsw" style="background:${shown||'transparent'}"></span>`:`<span class="cddsw" style="background:${shown||'transparent'}"></span>${esc(nm)}`;paintStepButtons();} + paint(); + 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;} + // 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,groundPair()); + 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();}; + wrap.syncLocked=paintStepButtons; + wrap.appendChild(left);wrap.appendChild(t);wrap.appendChild(right);paintStepButtons(); + return wrap;} +// Standard option list for a swatch dropdown: a "default" entry, then the +// palette in the same ground/column order as the palette panel. If cur is set +// but no longer in the palette, surface it as a "(gone)" entry so the row still +// shows what it points at. Shared by all three tiers. +function ddList(cur){return paletteOptionList(cur,PALETTE,groundPair());} +// Shared lock toggle for any table row. lockKey is namespaced per tier (bare +// syntax kind, 'ui:'+face, 'pkg:'+app+':'+face). els are the row's editable +// controls — native selects/buttons/inputs are disabled; the custom swatch +// dropdown (a div) gets data-locked so its onclick refuses to open. +function mkLockCell(lockKey,els){ + const td=document.createElement('td');td.style.textAlign='center'; + const lk=document.createElement('button');lk.className='lockbtn'; + function paint(){const on=LOCKED.has(lockKey);lk.textContent=on?'🔒':'🔓';lk.classList.toggle('on',on); + lk.title=on?'locked — click to unlock':'click to lock this decision'; + (els||[]).forEach(el=>{if(!el)return; + if(el.tagName==='SELECT'||el.tagName==='BUTTON'||el.tagName==='INPUT')el.disabled=on; + else{el.dataset.locked=on?'1':'';el.classList.toggle('locked',on);if(el.syncLocked)el.syncLocked();}});} + lk.onclick=()=>{LOCKED.has(lockKey)?LOCKED.delete(lockKey):LOCKED.add(lockKey);paint();updateLockToggles();}; + paint();td.appendChild(lk);return td;} +// The in-row style controls, shared by the syntax / UI / package tables: a weight +// selector, a slant selector, and box-like underline and strike controls. Each +// edit mutates the face object and calls onChange to repaint. Returns the control +// elements so the caller lays them out and hands them to mkLockCell. +const WEIGHT_OPTS=[['light','light'],['normal','normal'],['medium','medium'],['semibold','semibold'],['bold','bold'],['heavy','heavy']]; +const SLANT_OPTS=[['normal','normal'],['italic','italic'],['oblique','oblique']]; +// A compact custom dropdown for an enum attribute (weight / slant), themed like +// the color dropdown. The trigger shows the current value drawn in its own weight +// or slant; the popup lists each option drawn with the attribute applied, so the +// choice previews itself. opts.styleFor(value) returns the preview style props +// ({fontWeight} / {fontStyle}); opts.placeholder is the unset-state label. +function mkEnumDropdown(options,get,set,opts={}){ + const t=document.createElement('div');t.className='cdd enumdd';t.tabIndex=0; + const styleFor=opts.styleFor||(()=>({})); + const labelOf=v=>{const o=options.find(p=>p[0]===v);return o?o[1]:'';}; + function applyPreview(el,v){el.style.fontWeight='';el.style.fontStyle='';const s=styleFor(v);if(s.fontWeight)el.style.fontWeight=s.fontWeight;if(s.fontStyle)el.style.fontStyle=s.fontStyle;} + function paint(){const v=get()||'';t.dataset.val=v;t.classList.toggle('is-default',!v); + t.textContent=v?labelOf(v):(opts.placeholder||'set');applyPreview(t,v);t.title=opts.title||'';} + paint(); + t.onclick=(e)=>{e.stopPropagation();if(t.dataset.locked==='1')return;if(_ddPop){closeColorDropdown();return;} + const pop=document.createElement('div');pop.className='cddpop enumpop';const cur=get()||''; + const pick=v=>{set(v||null);paint();closeColorDropdown();}; + const def=document.createElement('button');def.type='button'; + def.className='enumopt enumdef'+(cur===''?' sel':'');def.textContent='default'; + def.title='clear — use the default';def.onclick=ev=>{ev.stopPropagation();pick('');};pop.appendChild(def); + for(const [v,label] of options){const b=document.createElement('button');b.type='button'; + b.className='enumopt'+(v===cur?' sel':'');b.textContent=label;applyPreview(b,v); + b.onclick=ev=>{ev.stopPropagation();pick(v);};pop.appendChild(b);} + 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'; + _ddPop=pop;}; + t.setValue=()=>paint();t.syncLocked=()=>paint(); + return t;} +// Underline control: none / line / wave glyph buttons plus a color swatch shown +// while a style is active. Mirrors mkBoxControl; get()/set() read and write the +// underline object ({style,color}) or null. +function mkLineStyleControl(states,get,set,opts={}){const wrap=document.createElement('div');wrap.className='boxctl'; + const cluster=document.createElement('div');cluster.className='boxcluster';const btns={}; + states.forEach(([v,title,glyph])=>{const b=document.createElement('button');b.className='boxbtn';b.dataset.style=v;b.textContent=glyph;b.title=title; + b.onclick=()=>{const cur=get();set(v?(opts.toState?opts.toState(v,cur):Object.assign({color:(cur&&cur.color)||null},opts.styled?{style:v}:{})):null);paint();}; + cluster.appendChild(b);btns[v]=b;}); + const dd=mkColorDropdown(ddList((get()&&get().color)||''),(get()&&get().color)||'',h=>{const cur=get();if(!cur)return;set(Object.assign({},cur,{color:h||null}));paint();},{compact:true,defaultHex:opts.defaultHex}); + function paint(){const cur=get(),active=opts.styled?(cur&&cur.style?cur.style:''):(cur?'on':''); + for(const v in btns)btns[v].classList.toggle('on',v===active); + dd.style.display=active?'':'none';dd.setValue(cur&&cur.color?cur.color:''); + const locked=wrap.dataset.locked==='1';for(const v in btns)btns[v].disabled=locked; + const ddoff=locked||!active;dd.dataset.locked=ddoff?'1':'';dd.classList.toggle('locked',ddoff);if(dd.syncLocked)dd.syncLocked();} + wrap.syncLocked=()=>paint();wrap.append(cluster,dd);paint();return wrap;} +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=mkEnumDropdown(WEIGHT_OPTS,()=>face.weight,v=>{face.weight=v;onChange();},{placeholder:'weight',title:'font weight',styleFor:v=>({fontWeight:cssWeight(v)})}); + const s=mkEnumDropdown(SLANT_OPTS,()=>face.slant,v=>{face.slant=v;onChange();},{placeholder:'slant',title:'font slant',styleFor:v=>({fontStyle:v||'normal'})}); + const k=mkStrikeControl(()=>face.strike,v=>{face.strike=v;onChange();},opts); + 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;} +// The per-row attribute editor revealed by the expander: distant-fg, family, +// overline, inverse, extend, and (for ui/syntax, where inherit/height have no +// inline column) inherit + height. Each control mutates FACE and calls onChange. +// Returns the element plus the interactive controls so the row's lock cell can +// disable them. opts.inheritOptions and opts.showInheritHeight gate the last two. +// Hover help for each expander field, so the detail labels explain themselves the +// way the table-header labels do. Keyed by the label text passed to add(). +const DETAIL_HOVERS={ + 'distant fg':'foreground swapped in when the text sits on a background too close to its own color to read (Emacs :distant-foreground)', + 'family':'font family for this face; blank inherits the default (Emacs :family)', + 'underline':'underline style and color (Emacs :underline)', + 'overline':'a line drawn above the text (Emacs :overline)', + 'inverse':'swap the foreground and background (Emacs :inverse-video)', + 'extend':'extend the background past the end of the line to the window edge (Emacs :extend)', + 'inherit':'base face this one inherits unset attributes from (Emacs :inherit)', + 'height':'text size as a scaling factor of the inherited height, 0.1 to 2.0 (Emacs :height)' +}; +function mkDetailEditor(face,onChange,opts={}){ + const wrap=document.createElement('div');wrap.className='detailedit';const locks=[]; + const add=(label,el)=>{const g=document.createElement('label');g.className='detailfield';g.title=DETAIL_HOVERS[label]||'';const s=document.createElement('span');s.textContent=label;g.append(s,el);wrap.appendChild(g);locks.push(el);}; + const df=mkColorDropdown(ddList(face['distant-fg']||''),face['distant-fg']||'',h=>{face['distant-fg']=h||null;onChange();},{compact:true,defaultHex:opts.defaultHex}); + 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();})); + if(opts.showInheritHeight){ + const isel=document.createElement('select');isel.className='chip detailsel'; + (opts.inheritOptions||['']).forEach(o=>{const op=document.createElement('option');op.value=o;op.textContent=o||'— none —';isel.appendChild(op);}); + isel.value=face.inherit||'';isel.onchange=()=>{face.inherit=isel.value||null;onChange();};add('inherit',isel); + const hin=document.createElement('input');hin.type='number';hin.min=''+HEIGHT_MIN;hin.max=''+HEIGHT_MAX;hin.step='0.05';hin.className='hstep';hin.value=face.height||1;hin.onchange=()=>{const raw=hin.value,h=clampHeight(raw);face.height=h;hin.value=h==null?1:h;if(h!=null&&parseFloat(raw)!==h)notify('height clamped to '+h+' (allowed '+HEIGHT_MIN+'–'+HEIGHT_MAX+')',false);onChange();};add('height',hin); + } + return {el:wrap,locks};} +// Wire a per-row expander: a toggle button plus a hidden detail row (colspan +// across the table) holding mkDetailEditor. The caller drops the button into a +// cell, adds the returned locks to the row's lock cell, and inserts detailRow +// right after the main row. +// Which rows have their detail expanded, keyed by the row's element/face key. +// Held outside the DOM so a table rebuild (a package edit rebuilds the whole +// table) re-opens the rows that were open, instead of collapsing them under the +// user — editing a value in an open expander must not close it. +let EXPANDED=new Set(); +function mkExpander(face,colspan,onChange,opts={}){ + const detail=document.createElement('tr');detail.className='detailrow';detail.style.display='none'; + if(opts.expandKey&&EXPANDED.has(opts.expandKey))detail.style.display=''; + const btn=document.createElement('button');btn.className='exptoggle'; + // The disclosure triangle shows the row's state: ▶ collapsed, ▼ expanded. + const setGlyph=()=>{const open=detail.style.display!=='none';btn.textContent=open?'▼':'▶';btn.classList.toggle('on',open);}; + // 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 willOpen=detail.style.display==='none';detail.style.display=willOpen?'':'none'; + if(opts.expandKey){willOpen?EXPANDED.add(opts.expandKey):EXPANDED.delete(opts.expandKey);} + setGlyph();syncExpandAllBtns();}; + refreshNd();setGlyph(); + return {btn,detail,locks};} diff --git a/scripts/theme-studio/face_coverage.py b/scripts/theme-studio/face_coverage.py index c6200e05c..57b44815a 100644 --- a/scripts/theme-studio/face_coverage.py +++ b/scripts/theme-studio/face_coverage.py @@ -179,12 +179,12 @@ def classify(name, items, src, pkgfaces): if name == 'emacs-core': return 'core' c = collections.Counter(bucket_of_source(src.get(f, '')) for f in items) - loaded = c['elpa'] + c['builtin'] + c['user'] + c['other'] - if loaded == 0: + elpa, builtin, user, other = c['elpa'], c['builtin'], c['user'], c['other'] + if elpa + builtin + user + other == 0: return 'package' if any(f in pkgfaces for f in items) else 'general' - if c['elpa'] >= max(c['builtin'], c['user'], c['other']): + if elpa >= max(builtin, user, other): return 'package' - if c['other'] > c['builtin'] and c['other'] >= c['elpa']: + if other > builtin and other >= elpa: return 'package' return 'general' diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index 09c25d804..797fcc28e 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -15,6 +15,22 @@ def read_json(name): NERD_ICONS_LEGEND_FIELDS = ("key", "label", "face", "category", "glyph") NERD_ICONS_GALLERY_GLYPH_FIELDS = ("glyph", "name") +_NO_ARTIFACT = object() # distinguishes absent/malformed from a file that parsed to null + +def _load_nerd_icons_artifact(path, kind, tail): + """Open and JSON-parse the nerd-icons artifact at PATH. Return the parsed value, + or _NO_ARTIFACT (with a KIND/TAIL-labeled warning) when absent or malformed. + Shared skeleton for the legend and gallery loaders.""" + if not os.path.exists(path): + print(f"WARNING: nerd-icons {kind} absent ({path}); {tail}") + return _NO_ARTIFACT + try: + with open(path) as src: + return json.load(src) + except (json.JSONDecodeError, OSError) as exc: + print(f"WARNING: nerd-icons {kind} malformed ({path}: {exc}); {tail}") + return _NO_ARTIFACT + def load_nerd_icons_legend(path=None): """Return the nerd-icons legend rows, or None when the artifact is unusable. @@ -27,14 +43,8 @@ def load_nerd_icons_legend(path=None): file, which lands here as None. """ path = path or os.path.join(HERE, "nerd-icons-legend.json") - if not os.path.exists(path): - print(f"WARNING: nerd-icons legend absent ({path}); generic nerd-icons app") - return None - try: - with open(path) as src: - data = json.load(src) - except (json.JSONDecodeError, OSError) as exc: - print(f"WARNING: nerd-icons legend malformed ({path}: {exc}); generic nerd-icons app") + data = _load_nerd_icons_artifact(path, "legend", "generic nerd-icons app") + if data is _NO_ARTIFACT: return None rows = data.get("legend") if isinstance(data, dict) else data if not isinstance(rows, list) or not rows: @@ -59,14 +69,8 @@ def load_nerd_icons_gallery(path=None): the legend data still loads. Never raises. """ path = path or os.path.join(HERE, "nerd-icons-legend.json") - if not os.path.exists(path): - print(f"WARNING: nerd-icons gallery absent ({path}); legend without gallery") - return None - try: - with open(path) as src: - data = json.load(src) - except (json.JSONDecodeError, OSError) as exc: - print(f"WARNING: nerd-icons gallery malformed ({path}: {exc}); legend without gallery") + data = _load_nerd_icons_artifact(path, "gallery", "legend without gallery") + if data is _NO_ARTIFACT: return None groups = data.get("gallery") if isinstance(data, dict) else None if not isinstance(groups, list) or not groups: @@ -129,6 +133,9 @@ if os.path.exists(os.path.join(HERE,_FONT_WOFF2)): STYLES=STYLES.replace('url("%s")'%_FONT_WOFF2, 'url("data:font/woff2;base64,%s")'%_FONT_B64) APP_BODY=read_text('app.js') +# Custom dropdown / detail-editor / expander factories, split from app.js for +# navigability and spliced in at the CONTROLS_J token. Raw (no imports/exports). +CONTROLS_BODY=read_text('controls.js') # Bespoke per-package preview renderers, spliced into the page <script> via the # PREVIEWS_J token in app.js. No imports/exports, so read raw. PREVIEWS_BODY=read_text('previews.js') @@ -381,6 +388,7 @@ def _build(): def fill_data(s): return (s.replace("COLORMATH_J",COLORMATH_BODY) .replace("APP_CORE_J",APP_CORE_BODY) + .replace("CONTROLS_J",CONTROLS_BODY) .replace("PREVIEWS_J",PREVIEWS_BODY) .replace("APP_UTIL_J",APP_UTIL_BODY) .replace("PALETTE_GENERATOR_CORE_J",PALETTE_GENERATOR_CORE_BODY) diff --git a/scripts/theme-studio/palette-generator-core.js b/scripts/theme-studio/palette-generator-core.js index 6ad2bf44f..033fff373 100644 --- a/scripts/theme-studio/palette-generator-core.js +++ b/scripts/theme-studio/palette-generator-core.js @@ -50,8 +50,7 @@ function generatorHues(baseHue,scheme,count,rng){ const offsets=[0,120,240,30,150,270,60,180,300,90,210,330]; return offsets.slice(0,n).map(o=>(b+o)%360); } - if(scheme==='manual')return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); - return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); + return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); // even spread (manual/default/unknown) } function generatorChroma(mode){ return mode==='subdued'?0.055:mode==='vivid'?0.13:0.085; diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css index a32dbafd1..0d13f423c 100644 --- a/scripts/theme-studio/styles.css +++ b/scripts/theme-studio/styles.css @@ -39,8 +39,11 @@ select.navsel,select.navsel option{background:#1f1c19;color:#e8bd30} /* Prev/next arrows flanking the view dropdown: step the selection without reopening it. Scoped under .pkgbar to outweigh the generic `.pkgbar button` rule above. */ - .pkgbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle} - .pkgbar .viewnav:hover{border-color:#e8bd30} + .pkgbar .viewnav,.langbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle} + .pkgbar .viewnav:hover,.langbar .viewnav:hover{border-color:#e8bd30} + /* Disabled nav (a single-pane preview): keep the gold, dim it, don't look clickable. */ + .viewnav:disabled{opacity:0.5;cursor:default} + select.navsel:disabled{opacity:0.5} /* 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. */ diff --git a/scripts/theme-studio/test-locate.mjs b/scripts/theme-studio/test-locate.mjs index faac7f916..09d15b8bc 100644 --- a/scripts/theme-studio/test-locate.mjs +++ b/scripts/theme-studio/test-locate.mjs @@ -8,7 +8,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - buildLocateRegistry, locateFaceMeta, formatLocateTitle, previewFaceAttrs, isLocateOnPane, locateInfoLine, + buildLocateRegistry, locateFaceMeta, formatLocateTitle, isLocateOnPane, } from './app-core.js'; // A constructed model: two package apps that BOTH own a face literally named @@ -142,31 +142,6 @@ test('formatLocateTitle: Error — an unassigned meta reads "unassigned"', () => assert.equal(formatLocateTitle(locateFaceMeta('org-faces', 'ghost', reg)), 'ghost, unassigned'); }); -// --- locateInfoLine: "section > face — value" ------------------------------- - -test('locateInfoLine: Normal — section > face — value (fg only, then fg / bg)', () => { - const reg = buildLocateRegistry(APPS, PKGMAP, UIMAP, MAP); - assert.equal(locateInfoLine(locateFaceMeta('org-faces', 'org-todo', reg)), 'org-faces > org-todo — #cc3333'); - const pkgmap = { app: { face: { fg: '#aabbcc', bg: '#223344', inherit: null, source: 'user' } } }; - const apps = { app: { label: 'App', faces: [['face', 'F', {}]] } }; - const reg2 = buildLocateRegistry(apps, pkgmap, {}, MAP); - assert.equal(locateInfoLine(locateFaceMeta('app', 'face', reg2)), 'App > face — #aabbcc / #223344'); -}); - -test('locateInfoLine: Error — an unassigned meta reads "<face> — unassigned"', () => { - const reg = buildLocateRegistry(APPS, PKGMAP, UIMAP, MAP); - assert.equal(locateInfoLine(locateFaceMeta('org-faces', 'ghost', reg)), 'ghost — unassigned'); -}); - -// --- previewFaceAttrs: owner-aware validation ------------------------------- - -test('previewFaceAttrs: Normal — a known owner/face validates; a bad owner is rejected', () => { - const reg = buildLocateRegistry(APPS, PKGMAP, UIMAP, MAP); - assert.ok(previewFaceAttrs('org-faces', 'org-todo', reg), 'known face validates'); - assert.equal(previewFaceAttrs('org-mode', 'minibuffer-prompt', reg), null, 'a UI face under a package owner is rejected'); - assert.equal(previewFaceAttrs('nope', 'org-todo', reg), null, 'an unknown owner is rejected'); -}); - // --- lifecycle + perf ------------------------------------------------------- test('buildLocateRegistry: lifecycle — a rebuild after an edit reflects the new value', () => { diff --git a/scripts/theme-studio/test_generate.py b/scripts/theme-studio/test_generate.py index bc0e87815..3bc78bdf8 100644 --- a/scripts/theme-studio/test_generate.py +++ b/scripts/theme-studio/test_generate.py @@ -18,6 +18,39 @@ from collections import Counter, defaultdict from contextlib import redirect_stdout import generate # importable without side effects: the file write is __main__-guarded +import face_coverage +from unittest import mock + + +class ClassifyBucket(unittest.TestCase): + """Characterization of face_coverage.classify's core/general/package decision, + locking each branch before the named-locals rewrite. bucket_of_source is mocked + to identity, so the src dict maps each face straight to its bucket name.""" + + def _classify(self, src, pkgfaces=(), name="x"): + with mock.patch.object(face_coverage, "bucket_of_source", lambda s: s): + return face_coverage.classify(name, list(src), src, set(pkgfaces)) + + def test_emacs_core_short_circuits_to_core(self): + self.assertEqual(face_coverage.classify("emacs-core", [], {}, set()), "core") + + def test_nothing_loaded_with_a_package_face_is_package(self): + self.assertEqual(self._classify({"a": "unloaded", "b": "unloaded"}, pkgfaces={"b"}), "package") + + def test_nothing_loaded_without_a_package_face_is_general(self): + self.assertEqual(self._classify({"a": "unloaded"}), "general") + + def test_elpa_plurality_is_package(self): + self.assertEqual(self._classify({"a": "elpa", "b": "elpa", "c": "builtin"}), "package") + + def test_elpa_tied_with_builtin_is_package(self): + self.assertEqual(self._classify({"a": "elpa", "b": "builtin"}), "package") + + def test_other_beats_builtin_and_ties_elpa_is_package(self): + self.assertEqual(self._classify({"a": "other", "b": "other", "c": "elpa", "d": "builtin"}), "package") + + def test_builtin_plurality_is_general(self): + self.assertEqual(self._classify({"a": "builtin", "b": "builtin", "c": "elpa"}), "general") from app_inventory import face_rows from default_faces import DefaultFaces, changed_summary from face_specs import face_spec, package_face_spec, ui_face_spec diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index 7c077610d..7f5727cef 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -41,8 +41,11 @@ select.navsel,select.navsel option{background:#1f1c19;color:#e8bd30} /* Prev/next arrows flanking the view dropdown: step the selection without reopening it. Scoped under .pkgbar to outweigh the generic `.pkgbar button` rule above. */ - .pkgbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle} - .pkgbar .viewnav:hover{border-color:#e8bd30} + .pkgbar .viewnav,.langbar .viewnav{appearance:none;border:1px solid #00000060;border-radius:5px;background:#1f1c19;color:#e8bd30;font:bold 16px monospace;width:26px;height:30px;padding:0;margin:0;cursor:pointer;vertical-align:middle} + .pkgbar .viewnav:hover,.langbar .viewnav:hover{border-color:#e8bd30} + /* Disabled nav (a single-pane preview): keep the gold, dim it, don't look clickable. */ + .viewnav:disabled{opacity:0.5;cursor:default} + select.navsel:disabled{opacity:0.5} /* 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. */ @@ -1206,15 +1209,6 @@ function locateFaceMeta(owner,face,registry){ return e||{owner,face,unassigned:true}; } -// The owner-aware membership check the preview gate calls: the entry's attributes -// when (owner, face) is a known face of that owner, null when it isn't (a bad -// owner is rejected). A known face with no non-default attributes returns {} -- -// still truthy, so membership reads cleanly off the result. -function previewFaceAttrs(owner,face,registry){ - const e=registry&®istry[locateKey(owner,face)]; - return e?e.attrs:null; -} - // Clickable predicate: an element is on-pane only when its owner is the pane being // viewed. Recomputed from the current view at render time (never stored in the // registry), since switching panes changes clickability but not ownership. @@ -1261,15 +1255,6 @@ function formatLocateTitle(meta){ } return parts.concat(locateAttrsList(meta.attrs)).join(', '); } - -// The immediate-wayfinding info line shown in the preview-label area on hover: -// "section > face — value" (effective fg, plus bg when set). An unassigned meta -// reads "<face> — unassigned". Terser than the title; the title is the full record. -function locateInfoLine(meta){ - if(!meta||meta.unassigned)return (meta&&meta.face?meta.face:'')+' — unassigned'; - const val=meta.value.fg+(meta.value.bg?' / '+meta.value.bg:''); - return meta.section+' > '+meta.face+' — '+val; -} // 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 @@ -1349,8 +1334,7 @@ function generatorHues(baseHue,scheme,count,rng){ const offsets=[0,120,240,30,150,270,60,180,300,90,210,330]; return offsets.slice(0,n).map(o=>(b+o)%360); } - if(scheme==='manual')return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); - return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); + return Array.from({length:n},(_,i)=>(b+(i*360)/n)%360); // even spread (manual/default/unknown) } function generatorChroma(mode){ return mode==='subdued'?0.055:mode==='vivid'?0.13:0.085; @@ -1735,6 +1719,10 @@ function renderCode(){ cp.onclick=(e)=>{const s=e.target.closest('[data-k]');if(s)flashAssign(s.dataset.k);}; buildMockFrame(); } +// controls.js -- the custom dropdown / detail-editor / expander control +// factories, extracted from app.js for navigability. Inlined raw at the +// CONTROLS_J token: these are hoisting function declarations plus the +// dropdown popup state, so the token's position preserves execution order. // Custom color dropdown: a real swatch + name + hex per row, since native // <option> background colors render unreliably on Linux Chrome. The popup is // fixed-positioned on <body> so a table's overflow can't clip it. @@ -1748,15 +1736,13 @@ function mkColorDropdown(options,cur,onPick,opts={}){ left.textContent='‹';right.textContent='›';left.title='move to next darker color in this column';right.title='move to next lighter color in this column'; const t=document.createElement('div');t.className='cdd'+(opts.compact?' compact':'');t.tabIndex=0; const nameOf=h=>{const o=options.find(p=>p[0]===h);return o?o[1]:(h||'none');}; - const displayHex=h=>h||(opts.defaultHex||''); - const displayName=h=>h?nameOf(h):(opts.defaultName||nameOf(h)); function step(dir){if(wrap.dataset.locked==='1')return;const next=spanNeighborHex(cur,PALETTE,groundPair(),dir);if(!next)return;cur=next;paint();onPick(next);} function paintStepButtons(){ const locked=wrap.dataset.locked==='1'; left.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),-1); right.disabled=locked||!spanNeighborHex(cur,PALETTE,groundPair(),1); } - function paint(){const shown=displayHex(cur),nm=displayName(cur),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)'); + function paint(){const shown=cur||(opts.defaultHex||''),nm=cur?nameOf(cur):(opts.defaultName||nameOf(cur)),ttl=cur?(nm+' '+cur):(nm+(shown?' -> '+shown:''));t.style.background=shown||'#161412';t.style.color=shown?textOn(shown):'#b4b1a2';t.dataset.val=cur||'';t.title=ttl;t.classList.toggle('is-default',!cur);t.classList.toggle('gone',!!cur&&nameOf(cur)==='(gone)'); t.innerHTML=opts.compact?`<span class="cddsw" style="background:${shown||'transparent'}"></span>`:`<span class="cddsw" style="background:${shown||'transparent'}"></span>${esc(nm)}`;paintStepButtons();} paint(); left.onclick=e=>{e.stopPropagation();step(-1);}; @@ -1942,6 +1928,7 @@ function mkExpander(face,colspan,onChange,opts={}){ setGlyph();syncExpandAllBtns();}; refreshNd();setGlyph(); return {btn,detail,locks};} + // Expand/collapse every row in a table at once, then sync the per-row triangles. function setAllExpanded(tableId,expand){ const tb=document.getElementById(tableId);if(!tb)return; @@ -1971,7 +1958,7 @@ function clearUnlockedRows(items,keyFn,resetFn){ for(const it of items){const k=keyFn(it);if(k===null)continue;if(!LOCKED.has(k))resetFn(it);} } function rebuildColorTables(){ - buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); + buildTable();buildUITable();buildPkgTable();// buildPkgTable self-guards when #pkgbody is absent } function refreshPaletteState(opts={}){ renderPalette();rebuildColorTables(); @@ -2513,7 +2500,9 @@ function flashPkg(f){flashRow(document.querySelector(`#pkgbody tr[data-face="${f function flashPkgPreview(f){const sp=document.querySelectorAll(`#pkgpreview [data-face="${f}"]`);if(sp.length){flashEls(sp);return;}const row=document.querySelector(`#pkgbody tr[data-face="${f}"]`);if(row)flashEl(row.querySelector('.cat'));} function mockSpan(k,t){return `<span data-k="${k}" style="${syntaxStyle(k)}">${esc(t)}</span>`;} function uiCss(o,fgv,bgv,opts={}){const fg=fgv===undefined?effFg(o.fg):fgv,bg=bgv===undefined?o.bg:bgv;return faceCss(o,fg,bg,{noBg:opts.noBg,boxBg:bg||MAP['bg']});} -function syncMockHeight(){const t=document.getElementById('uitable'),m=document.getElementById('mockframe');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} +// Size a preview pane to its faces table, minus the label bar above it. Shared by +// the UI mock and the package preview, which differ only in their element IDs. +function syncPaneHeight(tableId,paneId){const t=document.getElementById(tableId),m=document.getElementById(paneId);if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} function buildMockFrame(){ const fr=document.getElementById('mockframe');if(!fr)return; rebuildLocateRegistry(); @@ -2638,9 +2627,9 @@ function onViewChange(){const s=document.getElementById('viewsel');const v=(s&&s const show=(id,on)=>{const e=document.getElementById(id);if(e)e.style.display=on?'':'none';}; show('view-code',v==='@code');show('view-ui',v==='@ui');show('view-pkg',v[0]!=='@'); if(v==='@code')renderCode(); - else if(v==='@ui'){buildUITable();buildMockFrame();syncMockHeight();} + else if(v==='@ui'){buildUITable();buildMockFrame();syncPaneHeight('uitable','mockframe');} else pkgChanged();} -function pkgChanged(){buildPkgTable();buildPkgPreview();syncPkgHeight();} +function pkgChanged(){buildPkgTable();buildPkgPreview();syncPaneHeight('pkgtable','pkgpreview');} function buildPkgTable(){ const app=curApp(),tb=document.getElementById('pkgbody');if(!tb)return;tb.innerHTML=''; const flt=(document.getElementById('pkgfilter').value||'').trim().toLowerCase(); @@ -3278,7 +3267,6 @@ function buildPkgPreview(){ // no separate info line. } function resetApp(){const app=curApp();for(const [face,,d] of APPS[app].faces)if(!LOCKED.has('pkg:'+app+':'+face))PKGMAP[app][face]=seedFace(d);pkgChanged();notify('reset editable '+app+' faces to package defaults',false);} -function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';} // --- worst-case readout for the covered overlay faces (spec Phase 4) --------- // Default WCAG target for the worst-case verdict (AA). AAA is selectable. let WORST_TARGET=4.5; @@ -3321,7 +3309,7 @@ function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getEle function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=cssWeight(o.weight);pv.style.fontStyle=o.slant||'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box,effBg(o.bg)); const report=coveredContrastReport(face); pv.title=''; - const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';if(report!==null){if(report.empty){cr.title='this overlay has no syntax foreground set yet';cr.innerHTML='<span title="this overlay has no syntax foreground set yet">no fg set</span>';}else{const title=failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1);cr.title=title;cr.innerHTML=`<span style="color:${ratingColor(report.worst.ratio)}" title="${esc(title)}">${report.worst.ratio.toFixed(1)}</span>`;}}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}} + const cr=document.getElementById('uicr-'+face);if(cr){cr.title='';const wc=worstCellHtml(face);if(wc!==null){cr.title=report.empty?'this overlay has no syntax foreground set yet':(failureTitle(report)||'all covered text clears '+WORST_TARGET.toFixed(1));cr.innerHTML=wc;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}} function buildUITable(){ const tb=document.getElementById('uibody');tb.innerHTML=''; for(const [face,label,ex] of UI_FACES){ @@ -3330,16 +3318,23 @@ function buildUITable(){ exp.detail.dataset.detailFor=face; const c0=document.createElement('td');c0.className='cat';c0.title=composeHoverTitle(FACE_DOCS[face],c0.title);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); + // Emacs draws the cursor as a rectangle: its fg colors the glyph sitting on + // it and its bg is the cursor color, but weight/slant/underline/strike and + // box are no-ops on it. Show only fg+bg for the cursor row; mute the rest. + const cursorOnly=(face==='cursor'); + const naCell=t=>{const s=document.createElement('span');s.textContent='—';s.style.opacity='0.4';s.title=t;return s;}; const fgSel=uiSelect(face,'fg'),bgSel=uiSelect(face,'bg'); const cF=document.createElement('td');cF.appendChild(fgSel); const cB=document.createElement('td');cB.appendChild(bgSel); const cS=document.createElement('td'); - const stCtls=mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)}); - const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster); + const stCtls=cursorOnly?[]:mkStyleControls(UIMAP[face],()=>{paintUI(face);buildMockFrame();},{defaultHex:effFg(UIMAP[face].fg)}); + if(cursorOnly){cS.appendChild(naCell('Emacs ignores weight/slant/underline/strike on the cursor face'));} + else{const uiCluster=document.createElement('div');uiCluster.className='stylecluster';stCtls.forEach(c=>uiCluster.appendChild(c));cS.appendChild(uiCluster);} const cC=document.createElement('td');cC.id='uicr-'+face;cC.style.whiteSpace='nowrap';cC.style.fontSize='10pt'; const cP=document.createElement('td');cP.className='ex';cP.id='uiprev-'+face;cP.textContent=ex;cP.style.padding='4px 10px';cP.style.borderRadius='4px'; - const cX=document.createElement('td');const boxCtl=mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true});cX.appendChild(boxCtl); - const cL=mkLockCell('ui:'+face,[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]); + const cX=document.createElement('td');const boxCtl=cursorOnly?null:mkBoxControl(()=>UIMAP[face].box,b=>{UIMAP[face].box=b;paintUI(face);buildMockFrame();},{compact:true}); + if(cursorOnly){cX.appendChild(naCell('Emacs ignores the box attribute on the cursor face'));}else{cX.appendChild(boxCtl);} + const cL=mkLockCell('ui:'+face,cursorOnly?[fgSel,bgSel,...exp.locks]:[fgSel,bgSel,...stCtls,boxCtl,...exp.locks]); tr.appendChild(cL);tr.appendChild(c0);tr.appendChild(cF);tr.appendChild(cB);tr.appendChild(cS);tr.appendChild(cX);tr.appendChild(cC);tr.appendChild(cP);tb.appendChild(tr);tb.appendChild(exp.detail);paintUI(face); } applyTableSort('uibody'); @@ -3364,11 +3359,11 @@ function initApp(){ paletteShowFull=false; // open collapsed to base colors; the arrow expands the spans buildLangSel();buildViewSel();renderPalette();rebuildColorTables();renderCode();applyGround(); initGeneratorControls(); - updateTitle();initPicker();buildPkgPreview();syncMockHeight();syncPkgHeight(); + updateTitle();initPicker();buildPkgPreview();syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview'); onViewChange(); } initApp(); -addEventListener('resize',()=>{syncMockHeight();syncPkgHeight();}); +addEventListener('resize',()=>{syncPaneHeight('uitable','mockframe');syncPaneHeight('pkgtable','pkgpreview');}); // Shared gate harness. Each call site keeps its literal location.hash==='#NAMEtest' // check (run-tests.sh greps it); gate() owns the ok/notes/A setup and the verdict // postamble. Note format standardized to ' fails=note1,note2'. @@ -3592,6 +3587,21 @@ if(location.hash==='#mocktest')gate('mocktest',A=>withSavedState(['UIMAP','PKGMA pickEnum(pkgWeight(),'heavy'); A(PKGMAP[app][face].weight==='heavy'&&PKGMAP[app][face].source==='user','pkg weight dropdown writes the model and marks the face edited'); })); +// Cursor-row gate (open with #cursorrowtest): the cursor face honors only fg +// (the glyph on it) and bg (the cursor color); weight/slant/underline/strike and +// box are no-ops, so the row mutes them to a dash while non-cursor rows keep them. +if(location.hash==='#cursorrowtest')gate('cursorrowtest',A=>{ + buildUITable(); + const rows=[...document.querySelectorAll('#uibody tr')]; + const cur=rows.find(r=>r.dataset.face==='cursor'); + A(!!cur,'cursor row present'); + A(!!cur.cells[2].querySelector('.cdd'),'cursor keeps the fg swatch'); + A(!!cur.cells[3].querySelector('.cdd'),'cursor keeps the bg swatch'); + A(!cur.cells[4].querySelector('.enumdd')&&cur.cells[4].textContent.includes('—'),'cursor mutes the style controls'); + A(cur.cells[5].textContent.includes('—'),'cursor mutes the box control'); + const ml=rows.find(r=>r.dataset.face==='mode-line'); + A(!!ml.cells[4].querySelector('.enumdd'),'non-cursor rows keep the style controls'); +}); // Palette-generator gate (open with #generatortest): previewing is non-mutating, // clicking a generated tile loads the existing selector, adding creates a normal // singleton base column, and appending a preview column commits all span members @@ -4308,7 +4318,7 @@ if(location.hash==='#pickertest')gate('pickertest',A=>{ // four radio buttons (none / line / pressed / raised); the color swatch shows // only while a box style is active. if(location.hash==='#boxtest')gate('boxtest',A=>{ - LOCKED.clear();const f=UI_FACES[0][0];const saveBox=UIMAP[f].box; + LOCKED.clear();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor');const saveBox=UIMAP[f].box; // cursor has no box control by design UIMAP[f].box=null;buildUITable(); const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[5]; A(!!cell.querySelector('.boxcluster'),'box-cluster-present'); @@ -4327,7 +4337,7 @@ if(location.hash==='#boxtest')gate('boxtest',A=>{ // Style-cluster gate (open with #styletest): the style cell holds a weight // selector, a slant selector, and box-like underline and strike controls. if(location.hash==='#styletest')gate('styletest',A=>{ - buildUITable();const f=UI_FACES[0][0]; + buildUITable();const f=UI_FACES.map(x=>x[0]).find(x=>x!=='cursor'); // cursor row has no style cluster by design const cell=document.querySelector('#uibody tr[data-face="'+f+'"]').cells[4]; const cluster=cell.querySelector('.stylecluster'); A(!!cluster,'style-cluster-present'); |
