#+TITLE: Emacs Config #+AUTHOR: Craig Jennings #+ARCHIVE: %s::* Emacs Resolved * Emacs Priority Scheme Use priority to express impact and urgency, not task type. Bugs, refactors, tests, chores, and features can all be high or low priority. - =[#A]= Urgent risk or current workflow blocker. Use for credential exposure, security/privacy leaks, data loss, destructive behavior, startup breakage, failing tests that block work, or a feature/refactor that unblocks a core daily workflow. - =[#B]= Important planned work. Use for concrete bugs, high-leverage architecture cleanup, brittle load-order/test gaps, dependency failures, or feature work with a clear design and expected near-term use. - =[#C]= Useful but optional. Use for low-risk cleanup, ergonomics, smoke tests, investigations with limited current impact, or feature work that would improve the setup but is not yet a committed workflow. - =[#D]= Someday/maybe or watchlist. Use for speculative features, tiny polish, upstream/package tracking, optimizations without current pain, or deferred ideas that should not compete with active maintenance. For =PROJECT= headings, use the highest priority of the meaningful child work inside the project. If a project only contains exploration or review, assign the priority by the expected decision value rather than the number of files touched. Use tags to describe the work shape: - =:bug:= means the current behavior is wrong or likely broken. - =:feature:= means the task adds a new user-visible capability or workflow. - =:refactor:= means the task changes structure/ownership without primarily changing behavior. - =:quick:= means the task appears low effort and localized. It is a planning hint, not a promise; remove it if the task grows during implementation. - =:solo:= means Claude can do the task end to end with no input from Craig: bounded scope, no design or preference call, and verifiable in the local setup (tests, byte-compile, launch). Tasks needing a policy/preference decision, visual judgment, or a live remote do not get =:solo:=. Tags are additive. For example, a small wrong-behavior fix can be =:bug:quick:=, and a feature that requires internal restructuring can be =:feature:refactor:=. * Emacs Open Work ** TODO [#A] theme-studio contrast cell uses the wrong fg/bg pair :bug:theme-studio: The contrast readout on every item with two color selections (a fg AND a bg — the UI faces table and the package faces table) is computing the wrong pair. It needs to contrast the face's selected fg against the face's selected bg, not how the bg contrasts with the currently-selected (ground) bg. Investigation start: the two-color contrast cells are =paintUI= (UI faces, app.js ~line 740) and =buildPkgTable= (package faces, app.js ~line 430), both currently calling =contrast(effFg(fg), effBg(bg))= where =effFg(v)=v||MAP['p']= and =effBg(v)=v||MAP['bg']=. Reproduce a face that has BOTH a fg and a bg set, confirm the displayed ratio, and check whether it's actually evaluating selected-fg vs selected-bg or falling through to the ground bg. Fix so a two-color face always rates its own fg-on-bg. (Single-color contexts — the picker/palette-chip/plane checks that rate a color against the ground — are correct and out of scope.) Add a characterization gate (a #contrasttest hash gate) pinning fg-vs-bg for a two-color face. ** TODO [#B] theme-studio UI-faces preview cell ignores the face bg :bug:theme-studio: In the UI faces table, the preview cell for a face with its own bg renders with the ground bg instead. Repro: set mode-line fg=black, bg=blue — the preview cell should be black text on blue, but shows black on black (the live buffer mode-line is fine). Root cause: =applyGround= (app.js:300) blankets EVERY =.ex= element's background to =MAP['bg']=, and the preview cell =cP= shares =className='ex'= (app.js:753), so it clobbers the per-face bg =paintUI= sets (app.js:739) — runs on load and on every ground change. Fix: stop applyGround from touching the UI-face preview cells (scope its =.ex= selector to the code/example cells, give the preview cell its own class, or re-run paintUI after). The contrast cell shares the same staleness, so confirm both. ** TODO [#B] Split window opens the dashboard in the other window :feature:ux:windows: When splitting with C-x 2 (=split-window-below=) or C-x 3 (=split-window-right=), the new/other window should default to the =*dashboard*= buffer instead of mirroring the current buffer. Advise =split-window-below= / =split-window-right= (or rebind the keys) to select the dashboard in the freshly-created window. Keep point in the original window. ** TODO [#B] theme-studio live-preview bevel thinner than Emacs :bug:theme-studio: The mode-line box (3D released-button bevel) in the live buffer preview renders slimmer than the bevel Emacs actually draws. Make them match. The bevel comes from =boxCss= in app.js (~line 307), currently =inset 1px 1px 0 #ffffff33,inset -1px -1px 0 #00000066= for the released style — a 1px inset with faint translucent highlight/shadow. Emacs's released-button box is wider/stronger (it shades the highlight and shadow from the actual background color, not a flat translucent white/black). Fix: widen the bevel and derive the highlight/shadow from the box's background so it reads like Emacs. Verify side-by-side against a real Emacs mode-line. ** TODO [#C] theme-studio face-consistency check :feature:theme-studio: Rule taxonomy captured in [[file:docs/design/theme-studio-face-rules.org][docs/design/theme-studio-face-rules.org]] (Design Rules vs Fidelity Rules). The two checks below map to those two rule kinds. Both surface structural-attribute (weight/slant/underline/box/overline/height) issues; color is the theme's design and out of scope. 1. Theme cross-cutting consistency (primary, per Craig 2026-06-09): the theme has deliberate cross-cutting rules — e.g. headings/titles are bold, links are underlined, errors/warnings/success are bold. Flag where the theme BREAKS ITS OWN rule (a heading that isn't bold, a link that isn't underlined). The designer declares the rules; the check finds the violators. This is the "tell me where I broke the rule" guardrail. 2. defface-baseline divergence (secondary): flag where a face's structural attrs differ from its package =defface= so each divergence is deliberate, not an accidental drop. Would have caught the dropped underline/bold defaults and the contradictions (shr-h3 bold-vs-italic, erc-action italic-vs-bold) from the package-face audit as they were introduced. Bake into the tool (a lint surfaced in the UI) or run as a build-time check (seeds vs live deffaces via emacsclient). ** TODO [#B] theme-studio color-harmony explainer + ramp/fill features :feature:theme-studio:docs: Write an explainer in =docs/design/theme-studio-color-harmony.org= capturing the OKLCH harmony method worked out 2026-06-09: harmony is mostly calculable — work in OKLCH, borrow the hue from a semantic accent, fix lightness + chroma across a tier, and let contrast (WCAG/APCA), ΔE separation, and the sRGB gamut bound the free dials. Include the worked background-tint tier (borrow accent hue, fix L≈0.28 C≈0.045 → dim readable bg per hue) and the fg-vs-bg role split (bright accents for text, dim low-chroma tints for backgrounds). Two features it enables (both worth building): 1. Ramp generation (focus first): from a base color, generate its tonal ramp — base, +1/+2/+3 (lighter) and -1/-2/-3 (darker) — by stepping OKLCH lightness (and easing chroma) on a fixed hue. Term note: the whole family is a "ramp"/"tonal scale"; darker steps are "shades", lighter are "tints", gray-mixed are "tones" — so "ramp" or "scale" is the precise word, not "shades". 2. Harmonic fill: from a few chosen colors (e.g. slate blue + bg), generate a table of harmonic candidates (hue-angle schemes at matched L/C) to fill the missing palette slots. Open design problem to address in the explainer + the ramp feature: a background-over-text effect (highlight/region/isearch/hl-line) must stay readable for EVERY foreground that can appear on it — i.e. the worst-case (lowest) contrast across the whole set of element fg colors, not a single pair. The usable background lightness is therefore capped by the darkest/closest fg in that set. The v1 feature (ramp generation + background-contrast safety, with the worst-case-floor UX) is designed in [[file:docs/theme-studio-palette-ramps-spec.org][docs/theme-studio-palette-ramps-spec.org]]. Codex review incorporated 2026-06-09: both open decisions resolved (WCAG AA default target; v1 foreground set = distinct syntax hexes + default fg), v1 covered faces closed to region/hl-line/highlight/lazy-highlight/isearch, ramp defaults + function contracts pinned. Spec is Ready (Craig confirmed 2026-06-09); the v1 build is tracked under the sibling parent below. Harmonic fill (feature 2) stays vNext. This task is the explainer doc itself (=docs/design/theme-studio-color-harmony.org=, the methodology). ** TODO [#B] theme-studio palette ramps + contrast safety v1 :feature:theme-studio: The v1 build from [[file:docs/theme-studio-palette-ramps-spec.org][theme-studio-palette-ramps-spec.org]] (Ready, Codex-reviewed). Two coupled features: a ramp generator (one base color → harmonized tonal ramp) and background-contrast safety (worst-case floor over a face's foreground set + safe-lightness guidance). All five phases + the README close-out landed 2026-06-09 (commits 1d51a332, 9da6c663, e7021bfe, 1d8b9f9e, 843bbf08, 23926837); =make theme-studio-test= green (78 node tests, 12 browser gates). Code-complete and self-verified. Remaining: the aesthetic and real-Emacs-fidelity sign-off under the Manual testing parent below (does a ramp harmonize, does the safe band read clearly, does a "safe" tint actually read behind real syntax). Mark this DONE once that passes. *** 2026-06-09 Tue @ 18:40:20 -0500 Ramp generator core landed Phase 1 (commit =1d51a332=). =ramp(baseHex, {n, stepL, chromaEase})= in app-core.js → ={steps: [{hex, clamped, offset}], adjusted}= or ={steps: [], error: 'bad-hex'}=. Holds the OKLCH hue, steps lightness by =stepL=, quadratic chroma-ease toward the extremes, gamut-clamps each step; knobs clamp to range with the clamped knob named in =adjusted= (n=2/stepL=0.08/chromaEase=0.5 defaults). 10 node tests (mid/near-white/near-black bases, hue-hold, chroma ease, knob clamping, malformed hex), suite 55→65, =make theme-studio-test= green. The app-core integrity stripper now drops =import= lines too. *** 2026-06-09 Tue @ 19:06:46 -0500 Ramp UI in palette landed Phase 2 (commit =9da6c663=). A "ramp" button opens a panel that generates from the current color and previews the steps (named per source swatch, clamp badge on out-of-gamut steps); the n/stepL/chroma-ease controls default to 2/0.08/0.5. Click a step or "add all" to insert adjacent to the source in -n..+n order; name collisions skip (no overwrite), hex duplicates add with a flag. New #ramptest gate pins count, ordered insertion, collision skip, and the clamp badge. Verified headless + screenshot. *** 2026-06-09 Tue @ 18:53:16 -0500 Foreground-set + floor + L_max core landed Phase 3 (commit =e7021bfe=). =fgSetFor=, =floor=, =lMax= and the =COVERED_FACES= constant in app-core.js, all pure and explicit-state. fgSetFor returns {set:[{hex,label}]} or a structured reason ('out-of-scope'/'empty'); floor returns {ratio, limitingHex, limitingLabel}; lMax scans L from black to bracket the dark-side crossing then binary-searches it (tol 0.001), status ok/none/all/clamp. 13 node tests including the keyword-blue #67809c fixture and lMax's none/all/clamp branches. Suite 65→78, =make theme-studio-test= green. *** 2026-06-09 Tue @ 19:06:46 -0500 Worst-case contrast readout landed Phase 4 (commit =1d8b9f9e=). The five covered overlay faces show the worst-case floor over their foreground set (live syntax colors + default fg) and name the limiting foreground; a syntax-color edit repaints them. Out-of-scope faces keep the single-pair cell; an empty set reads "no fg set". Verdict is WCAG AA by default. New #contrasttest gate pins the readout, the keyword-blue limiting case, the single-pair fallback, and the no-set string. *** 2026-06-09 Tue @ 19:06:46 -0500 Safe-lightness picker guidance landed Phase 5 (commit =843bbf08=). The OKLCH picker gets a "safe for" selector over the covered faces; the C×L plane shades the lightness band too light to keep that face readable, with the L_max ceiling (via =lMax= at the current chroma) as the band's lower edge. A too-dark foreground shades the whole plane. New #safetest gate pins band-shows-for-covered-face and hides-when-none. Verified headless + screenshot. *** 2026-06-09 Tue @ 19:06:46 -0500 README + test-surface close-out landed Commit =23926837=. README documents the ramp controls and defaults, the worst-case floor / limiting foreground, the five covered faces, the safe-lightness guidance, and WCAG-drives-PASS-FAIL with APCA as a diagnostic; the browser-gate list is updated. =make theme-studio-test= carries all new node tests and the #ramptest/#contrasttest/#safetest gates. All acceptance criteria met. ** TODO [#B] theme-studio color families :feature:theme-studio: Show the palette as hue-grouped strips (dark→light) over the existing flat, individually-editable palette. Grouping is by OKLCH hue from the hex, so renaming a color never moves it. A per-strip count control generates a symmetric ramp (N → base ±N) from the strip's most-saturated color; regenerate is authoritative, repointing surviving-step references by lightness rank and leaving removed-step references a visible "(gone)". The ground strip is synthesized from the bg/fg assignments and pinned first; the standalone ramp panel is removed. Designed in [[file:docs/theme-studio-color-families-spec.org][docs/theme-studio-color-families-spec.org]]. Codex-reviewed Ready 2026-06-10 after response folded: pivoted from name-derived families to hex-derived families over a flat palette, which designs out the name-grammar/import-inference and chip-ownership blockers. All review findings dispositioned; both open decisions resolved. Builds on and supersedes the palette-ramps v1 ramp UI. All six phases landed 2026-06-10 (commits ebe18d51, 74db9a52, 111687b0, e7ae18c4, 77783126, f6ab0001, 9daeff15, and the Phase 6 commit); =make theme-studio-test= green (98 node tests, 16 browser gates). Code-complete and self-verified. The hue-adjacent warm-color grouping limitation is filed as a separate research task (=~/color-sorting.org=). Remaining: the manual aesthetic/fidelity sign-off under the Manual testing parent (hue grouping reads right, regenerate-replace reads as deliberate, removed-step "(gone)" is clear). Mark this DONE once that passes. *** 2026-06-10 Wed @ 01:17:45 -0500 Family model core landed Phase 1 (commit =ebe18d51=, grouping reworked in =77783126=). =familiesFromPalette=, =regenFamily=, =rankByLightness=, =stepRepointPlan= in app-core.js, pure and hex-derived. Grouping started as gap-clustering + flat neutral threshold; after the design discussion it became nearest-hue-anchor bucketing (no single-linkage chaining) + a lightness-scaled neutral threshold (pale tints keep their hue, mid grays go neutral). regenFamily handles n=0 without ramp()'s clamp; stepRepointPlan maps survivors / lists removed by signed lightness rank. 20 node tests including the green/yellow split and the no-chaining case. Open: hue-adjacent warm colors still merge — research task above (=~/color-sorting.org=). *** 2026-06-10 Wed @ 01:17:45 -0500 Family sort core landed Phase 2 (commit =74db9a52=). =sortFamilies=/=sortFamilyMembers=: neutrals first, then chromatic by base hue (rounded so a hue hair doesn't outrank lightness), ties by base lightness then hex; members dark→light. Display-only; stored palette order untouched. 4 node tests. *** 2026-06-10 Wed @ 01:17:45 -0500 Family-strip rendering landed Phase 3 (commit =111687b0=, columns =e7ae18c4=). renderPalette restructured into the pinned ground strip + hue-sorted family columns (top→bottom dark→light), chips keep per-chip rename/remove/select, move-arrows/drag dropped. #familytest gate locks the structure + rename-stays-in-strip. Existing palette flows stay green. *** 2026-06-10 Wed @ 01:17:45 -0500 Count control + regenerate landed Phase 4 (commit =f6ab0001=). Per-chromatic-strip count input (0-4); setting N regenerates the family as base ±N, repointing survivor references by lightness rank and leaving removed-step references on their now-gone hex. Also fixed the neutral-threshold curve to taper at both lightness ends (symmetric Munsell) so chroma-eased dark/light extremes keep their hue. #counttest gate covers count up/down + the survivor/removed reference behavior. *** 2026-06-10 Wed @ 01:17:45 -0500 Base edit + retire ramp panel landed Phase 5 (commit =9daeff15=). Editing a family base recolors the whole family (shared =regenFamilyInPlace= with the count control); editing a ground swatch writes the bg/fg assignment. The standalone ramp panel (button, panel, JS, CSS, #ramptest) is removed — fan a color via its column's count instead. #baseedittest gate covers base-edit recolor + reference follow + the bg-swatch edit. *** 2026-06-10 Wed @ 01:17:45 -0500 Warnings, seeding, export, README close-out landed Phase 6 (commit =c175e2be=). Export stays a flat palette and import needs no reconstruction (#roundtriptest: export→import→export byte-identical). =seedPkgmap= reads the flat palette unchanged. The too-similar warning stays on the full palette — the planned ramp-step exemption was dropped after analysis: ramp steps are a stepL apart (well above the ΔE threshold) so they never warn, and exempting same-family pairs would hide genuine near-duplicates (caught by #deltatest). README documents families, the ground strip, the count control/regenerate, removed-step references, and the ramp-panel removal. ** TODO [#B] Color-family grouping for hue-adjacent warm colors :feature:theme-studio:research: The hue-anchor + lightness-scaled-threshold grouping (shipped in color families) fixed the neutrals and pale tints but can't cleanly separate hue-adjacent warm colors: this palette's olive-greens (~110-120° OKLCH) sit right on the golds (~85-95°), so by hue they merge, and a ramp that drifts in hue can split across an anchor boundary. The problem, the four approaches tried, why each failed, and directions to research (2D chromaticity clustering, lightness-aware hue grouping, ramp detection, perceptual color-naming models, an optional hex-derived family hint) are written up in =~/color-sorting.org=. Craig is finding someone to comment. Pick the work back up from that doc. ** TODO [#C] Internet radio now-playing song :feature:music:emms: Show the currently-playing song while streaming an internet radio station. Lives in =modules/music-config.el= (EMMS + MPV backend, M3U radio stations). The track title comes from the stream's ICY metadata — EMMS exposes it via =emms-track-description= / =emms-playing-time= and updates it on the metadata-change hook; MPV reports the ICY title too. Add an option to show the song in the minibuffer (e.g. echo on track change, or an on-demand command). Consider also a mode-line indicator as a second surface. ** TODO [#C] Evaluate jamescherti essential-emacs-packages list :packages:research: Review [[https://www.jamescherti.com/essential-emacs-packages/][James Cherti's essential Emacs packages]] for anything worth installing. Cross-check each candidate against what is already in the config (=modules/= + =init.el=), skip the ones already present, and shortlist the genuinely new ones with a one-line rationale. Future-installation research, not a commitment to install. ** DONE [#B] theme-studio comprehensive previews (org/magit/elfeed/ghostel/mu4e/dashboard) :feature:theme:theme-studio: CLOSED: [2026-06-08 Mon] Expanded the bespoke previews to near-complete face coverage and added three new ones. org now exercises 83/88 faces (document + agenda; the 5 skipped are non-visual: org-hide, org-indent, org-clock-overlay, org-default, org-date-selected). magit 97/98 (status buffer + blame/reflog/sequence/bisect/signature sampler rows). elfeed 13/13. New bespoke previews: ghostel 19/19 (mock terminal, 16 ANSI colors + default + fake cursor), mu4e 37/37 (curated face list, not in the generated inventory; headers list + message view + compose), dashboard 8/8. So clicking a face row flashes a real preview element for nearly every face. Originally filed as just the org preview. ** DONE [#A] theme-studio theme.json -> dupre-*.el converter :feature:theme:theme-studio: CLOSED: [2026-06-08 Mon] Built as scripts/theme-studio/build-theme.el (sibling to build-inventory.el), emitting a single self-contained themes/-theme.el deftheme (not the palette/faces/theme trio — a theme.json carries resolved per-face hex, not dupre's semantic layer). All four tiers convert: default from assignments.bg/.p, syntax categories -> font-lock/tree-sitter faces with bold/italic sets, UI passthrough, packages with :inherit/:height/weight/slant. 20 ERT tests in tests/test-build-theme.el (Normal/Boundary/Error + an end-to-end load + a WCAG-AA assertion on the round-tripped result). One mapping limitation documented: the dec (decorator) key has no independent Emacs face (Emacs renders decorators with font-lock-type-face, which ty owns), so dec is omitted and decorators follow the type color. The last link in the pipeline: turn a theme.json exported by the theme-studio into a real loadable Emacs theme. Elisp (per Craig), TDD — this is the correctness-sensitive piece. Inputs (all on disk; no chat history needed): - theme.json contract: =scripts/theme-studio/README.md= (theme.json section) and =docs/design/theme-studio-package-faces-spec.org= (State and export policy, Relative height, Inheritance). - Reference face layout: existing =themes/dupre-palette.el= + =themes/dupre-faces.el= + =themes/dupre-theme.el=, and =tests/test-dupre-theme.el= (WCAG-contrast helper to reuse). - Conventions: =.claude/rules/elisp.md=, =.claude/rules/elisp-testing.md=. Scope: 1. Read theme.json. Set =default= from =assignments.bg= / =assignments.p=. 2. Author the syntax category -> font-lock face map (~21 keys: kw->font-lock-keyword-face, str->font-lock-string-face, fnd->font-lock-function-name-face, fnc->font-lock-function-call-face, op->font-lock-operator-face, punc->font-lock-punctuation-face, etc. incl. the Emacs-29 tree-sitter additions). Apply =bold= / =italic= sets. 3. UI faces: the =ui= keys are already real face names (region, cursor, mode-line, ...) -> near 1:1 passthrough of fg/bg. 4. Package faces: =packages= -> each face spec, writing =:inherit PARENT= for inherited faces + only the overridden attrs, =:height= when != 1.0, weight/slant. 5. Emit a deftheme file (or palette+faces+theme trio mirroring dupre's layout). TDD targets: old-JSON (no packages) loads; every category maps; round-trip of fg/bg/bold/italic/inherit/height into valid face specs; WCAG-contrast assertion on the result. Decide whether the converter lives under =scripts/theme-studio/= (emits to =themes/=) or =themes/=. ** TODO [#B] Dupre diff-changed / diff-refine-changed legibility :bug:dupre: Surfaced 2026-06-07 from a pearl session designing its modified-ticket indicator (pearl marks a changed field by inheriting =diff-changed=). dupre's =diff-refine-changed= is bright gold (#ffd700) under near-white text (#f0fef0) -- WCAG contrast ~1.35, unreadable as a plain background. It only looks fine inside diff-mode because diff-mode overlays its own dark foreground. =diff-changed= (#875f00 amber) is ~5.49, readable but off the modus model. Every modus variant keeps both faces legible (contrast 9-16) by pairing a dark low-saturation background with a hue-matched foreground. Ask: 1. Rework dupre's =diff-changed= and =diff-refine-changed= on modus lines: dark low-saturation background, legible foreground (plain default fg for simplicity, or hue-tinted per modus -- decide), and keep refine slightly stronger than changed (refine is the word-level emphasis inside a changed region; modus keeps them distinct). 2. While there, audit dupre's broader diff/palette faces against modus conventions (background/foreground tinting, contrast targets) and flag where it diverges. Reference values -- modus-vivendi: refine-changed bg #4a4a00 fg #efef80, changed bg #363300 fg #efef80. modus-operandi: refine-changed bg #fac090 fg #553d00, changed bg #ffdfa9 fg #553d00. Side-by-side legibility render: [[file:assets/2026-06-07-dupre-diff-face-legibility-compare.png][assets/2026-06-07-dupre-diff-face-legibility-compare.png]]. ** TODO [#B] dupre-theme test failures :bug:dupre:tests: A full =make test= run (2026-06-07) is green across 516 of 517 files; the only failures are 4 tests in =tests/test-dupre-theme.el=, long pre-existing. Two root causes. For each, decide whether the palette or the test assertion is canonical, then fix the loser so =make test= goes fully green. *** TODO Background drift: 3 tests expect #151311, palette bg is #0d0b0a =dupre-get-color-base= (test:46), =dupre-theme-default-face= (test:84), and =dupre-with-colors-binds-values= (test:62) all assert the default background is "#151311", but =themes/dupre-palette.el= defines =bg= as "#0d0b0a". The committed palette looks intentional, so the three assertions are likely just stale -- confirm #0d0b0a is the wanted background, then update the tests. *** TODO org-todo color mismatch: test expects #ff2a00, theme renders #a7502d =dupre-theme-org-todo= (test:130) asserts the org-todo foreground is "#ff2a00" (intense-red), but the theme renders "#a7502d" (red-1). Design call: should org-todo be the bright intense-red or the muted red-1? Fix whichever side loses the decision. ** TODO [#C] dupre-clear theme — contrast-first AAA sibling :feature:theme:dupre: Build a new theme (working name "dupre-clear", final name TBD) that takes dupre's color identity and rebuilds it Prot's way: contrast-first, targeting WCAG AAA (~7:1 on the ground), where the in-progress dupre revision is mood/depth-first and lands at AA. Same hues (dupre blue, emerald, gold, terracotta, regal violet, mint) brightened to clear the AAA floor; same modus-style role mapping (blue keywords bold, gold functions, violet types, emerald strings, terracotta constants, silver default, warm-grey comments, metallic greys, navy + regal fills). Build the dupre revision first; this reuses its hue choices as the starting point. Full design + methodology + starting palette + open questions in the spec: [[file:docs/design/dupre-clear-theme.org][docs/design/dupre-clear-theme.org]]. Key prerequisite/context: the dupre-redesign entry in =.ai/session-context.org= (the AA palette this brightens). Hardest slot: blue keywords (a deep dupre blue can't be AAA on near-black — decide brighten vs keep-AA-exception vs lift-the-ground). ** DONE [#B] theme-studio tier-3 package faces :feature:theme:theme-studio: CLOSED: [2026-06-08 Mon] Package-specific face editing in the theme-studio: org/magit/elfeed bespoke (complete face tables + live previews) plus a generated all-package inventory so every installed package is themeable. Spec is Ready, all opens resolved: [[file:docs/design/theme-studio-package-faces-spec.org][docs/design/theme-studio-package-faces-spec.org]]. Phases below run in dependency order; phases 1-5 deliver the three high-value apps, phase 6 opens the long tail, phase 7 documents. The =theme.json= -> =dupre-*.el= converter (Elisp) is a separate downstream task. *** 2026-06-08 Mon @ 00:17:41 -0500 Phase 1 — package state + schema landed Added =APPS= (org starter) and =PKGMAP= ({app:{face:{fg,bg,bold,italic,inherit,height,source}}}), pure helpers (=seedPkgmap= / =packagesForExport= / =mergePackagesInto=), and wired export/import for the =packages= key with old-JSON compat. The =height= float (relative size, read off the face not cascaded through inherit) and the fixed-pitch inherits are seeded in the org starter. No UI yet (Phase 3). Verified: node-check, plus a guarded =#selftest= harness (headless Chrome) confirming seed->export->import round-trip, old-JSON merge, and inherit/height/source survival — all PASS. *** 2026-06-08 Mon @ 02:16:24 -0500 Phase 2 — curated app data (org/magit/elfeed) landed Filled =APPS= with the complete own-defface sets built from embedded face-name lists + a curated seed-color map: org 88 (85 seeded, incl. org-agenda, heading heights, fixed-pitch inherits), magit 98 (64 seeded), elfeed 13 (all seeded). Long-tail faces seed to default fg. Verified: 199 faces total, no seed typos / no dupes, schema self-test PASS seeding all of them. Seeded-default aesthetics still go to Manual testing once the Phase 3 UI lands. *** 2026-06-08 Mon @ 02:23:56 -0500 Phase 3 — package face table UI landed Added the "package faces" section: app selector (org/magit/elfeed), per-app face table with fg/bg dropdowns, bold/italic toggles, inherit dropdown (base faces + the app's own faces), relative-height stepper, live contrast readout on the effective (inherit-resolved) color, per-face and per-app reset, and a text filter. Refactored the fg/bg dropdown into a shared =colorDropdown= helper the ui-faces table now also uses (no =uiSelect= fork). Palette edits propagate to package faces; import/export carry them. Right pane is the generic preview (face names in their own resolved colors) until the bespoke org/magit/elfeed previews land (phases 4-5). Verified: node, headless screenshot, schema self-test PASS. *** 2026-06-08 Mon @ 02:27:51 -0500 Phase 4 — org preview landed Added =renderOrgPreview()=: a mock org document painted live from the org package faces (title, headings with heights, TODO/DONE, tag, scheduled date, property drawer, inline code/verbatim, link, checkbox, quote, src block, header-row table). The preview pane dispatches on the app's preview key; org-mode gets this, others keep the generic list. Verified: node, headless screenshot, self-test PASS. *** 2026-06-08 Mon @ 02:30:42 -0500 Phase 5 — magit + elfeed previews landed Bespoke =renderMagitPreview()= (status buffer: head/branches, untracked, a diff hunk with context/added/removed, recent commits with hashes/authors/keyword/tag) and =renderElfeedPreview()= (search list: filter, dated entries with feed/unread-title/read-title/tags, log lines by level). The preview label now names the app and notes generic vs bespoke. Verified: node, headless screenshots, self-test PASS. *** 2026-06-08 Mon @ 02:32:44 -0500 Phase 6 — generated all-package inventory landed =build-inventory.el= (loaded into a running Emacs) groups every installed package's faces by the defining package and writes =package-inventory.json=. =generate.py= embeds it and merges each package into the dropdown as an editable generic app, leaving org/magit/elfeed bespoke. 40 apps now (3 bespoke + 37 inventory, 643 faces). Committed data artifact, refreshed by reloading the .el; never browser-side discovery. Verified: node, self-test PASS, app count + bespoke-preserved checks. *** 2026-06-08 Mon @ 02:34:01 -0500 Phase 7 — docs landed Rewrote =README.md= for the full tool: three face tiers + palette, the in-page picker (with the AA/AAA mask), package faces (bespoke vs generic previews), modeled inheritance + relative height (family stays in font-config.el), the packages schema with inherit/height/source, export-vs-save, and the inventory-refresh command (=build-inventory.el=) + its loaded-config dependency. Notes =theme-studio.html= is generated. Test-surface fixtures tracked separately below. *** 2026-06-08 Mon @ 02:40:00 -0500 theme-studio tier 3 — test surface landed Extended the guarded =#selftest= harness (headless Chrome) to assert the acceptance criteria against the real emitted code: old-JSON import (no =packages=), full round-trip (fg/bg/bold/italic/inherit/height/source), cleared-state export, unknown-package/face preservation, and inheritance-cycle termination — all PASS. The two DOM-coupled regressions are handled structurally: =updateColor= remaps =PKGMAP= on a palette-color edit, and =PKGMAP= stores hexes so a deleted palette color leaves package refs in the "(gone)" recoverable state. =generate.py= rebuilds =theme-studio.html= each run. ** DONE [#B] theme-studio perceptual color metrics :feature:theme:theme-studio: CLOSED: [2026-06-08 Mon] Spec (Ready, opens confirmed 2026-06-08): [[file:docs/design/theme-studio-perceptual-color-metrics-spec.org][docs/design/theme-studio-perceptual-color-metrics-spec.org]]. OKLCH model + perceptual-L/APCA readouts + pairwise ΔE, for building low-contrast themes by metric rather than by eye. All five phases shipped 2026-06-08 (commits 49342bf5, 78260018, 77c7f126, 163d3730, 22605426, 582d8a6a): colormath.js core inlined + WCAG/HSV helpers migrated; picker OKLCH/APCA readouts; palette ΔE warnings; OKLCH edit-model dials; C×L gamut plane. 17 Node tests (colormath 100/93.75/100), six browser hash gates green, inline-integrity guard. vNext deferrals (low-contrast preset, CIEDE2000) remain the two [#D] tasks below. Manual eyeballs tracked under Manual testing. *** 2026-06-08 Mon @ 19:43:50 -0500 Color-math foundation + Node tests landed Pure color core in =scripts/theme-studio/colormath.js= (OKLab/OKLCH, APCA-W3 0.1.9 exact constants, ΔE-OK, binary-search gamut clamp returning ={hex,clamped}=) shipped in 49342bf5; this phase finished the integration in 78260018. =generate.py= now inlines the colormath.js body into the page script (export-stripped, =COLORMATH_J= placeholder), and the page's lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex copies moved into the module — =rl= reuses the canonical =lin= (0.04045 cutoff), byte-identical to the old 0.03928 form on every #rrggbb (no 8-bit channel falls between the cutoffs; verified over 200k pairs, zero contrast change). =test-colormath.mjs= gained Normal/Boundary/Error cases for the migrated helpers, a seeded hsv-rgb round-trip property test, and an inline-integrity check that the generated page carries the module body verbatim. Gate met: =node --test scripts/theme-studio/*.mjs= 15 pass, colormath.js 100% line / 93.75% branch / 100% func; =node --check= on the spliced script clean; =#selftest= + =#cursortest= PASS in headless Chrome. NOTE: =node --test = directory-globbing is broken on Node v26 (tries to load the dir as a module) — use the =*.mjs= glob form. *** 2026-06-08 Mon @ 19:55:53 -0500 Picker OKLCH/APCA readouts landed Phase 2 shipped in 77c7f126. Second readout row (=.pinfo2=) under the WCAG ratio: OKLCH L/C/H + signed APCA Lc against the ground color, always shown; sign convention in the APCA tooltip + README. Tables unchanged (APCA picker-only per Agreed-decision #3). =pkReadout= drives the spans from the inlined colormath functions. Gate met: =#readouttest= asserts the spans match the live computation AND the known dupre-blue OKLCH reference (L 0.591 / C 0.052 / H 252°, APCA Lc -34 on ground) with WCAG unchanged; =#selftest= + =#cursortest= still PASS; 15 Node tests green. Headless-rendered values verified against a node cross-check. Visual eyeball is the open "Perceptual readouts read well in the picker" item under Manual testing. *** 2026-06-08 Mon @ 20:44:39 -0500 Palette ΔE warnings landed Phase 3 shipped in 163d3730. =renderPalette= runs a pairwise OKLab ΔE over PALETTE via the pure =paletteDeltas()= (one pass → sub-threshold pairs + per-color nearest distance); warns on pairs below the named =DELTAE_MIN= (0.02), sorted closest-first, capped at 5 with "and N more"; each chip's tooltip gains its nearest-neighbor ΔE. Names go through =esc= before the warning markup. Gate met: =#deltatest= PASS (near pair fires + names itself; spread palette quiet; 7-color cluster caps at 5 ascending + overflow suffix). #readouttest/#selftest/#cursortest + 15 Node tests still green. Screenshot-verified the warning render (terracotta "too-similar colors" header + "blue / blue2 — ΔE 0.007, hard to distinguish", placed between palette and add-color controls). Pushed below. *** 2026-06-08 Mon @ 21:05:28 -0500 OKLCH sliders + color-model control landed Phase 4a shipped in 22605426. Picker gains an edit-model toggle (HSV/OKLCH) in its own =pkModel= state, orthogonal to =pkMode= (AA/AAA mask) — separate handlers, distinct toggle colors (blue vs gold). OKLCH mode shows L/C/H as paired range+number inputs driving =oklch2hex= → hex/swatch/readouts/HSV-cursor; out-of-gamut chroma snaps the dials to the reachable color + shows "chroma clamped to sRGB". HSV stays default; SV square still edits HSV (C×L plane is 4b); SV drag in OKLCH mode refreshes the dials. =openPicker= re-asserts the model via =setPkModel= so the toggle highlight can't drift (caught on screenshot). Gate met: =#oklchtest= PASS (color preserved on model switch; mask toggle leaves pkModel; model switch leaves pkMode; dials drive color to a known OKLCH target; out-of-gamut C raises clamp status). All 5 browser gates + 15 Node tests green; screenshot-verified the dials + toggle highlight. *** 2026-06-08 Mon @ 21:41:49 -0500 Chroma×Lightness plane landed Phase 4b shipped in 582d8a6a. OKLCH mode renders the SV square as a C(x)×L(y) plane at the current hue; crosshair maps to (C,L), hue strip selects H. Out-of-gamut region greyed (#15120f), AA/AAA contrast mask overlays the reachable colors. Per-cell gamut test is forward-only (=oklch2oklab=→=oklab2lrgb=→=inGamut=), never the binary search (that stays in =oklch2hex= for committing). colormath.js exports =oklab2lrgb=/=inGamut=/=lrgb2hex= with direct Node tests (one pins inGamut to oklch2hex's clamped flag). Bitmap cached on (hue+dims+mask+bg) so C/L drags reuse it; hue drags ride browser pointermove-to-frame coalescing (synchronous render measured ~7ms math/5600 cells — no explicit rAF defer; flagged if jank appears). HSV path untouched. Gate met: =#planetest= (crosshair at C/L; OOG cell grey; in-gamut cell colored). Screenshot-verified the plane (gamut-boundary shape, crosshair at C=0 for grey). NOTE for Craig: OKLCH_CMAX=0.4 matches the C dial domain, so much of the plane is gamut-grey at low-chroma hues — a tighter max fills more area but desyncs the crosshair scale from the dial; your eyeball call. *** 2026-06-08 Mon @ 21:41:49 -0500 Test surface green across the feature Final state: 17 Node unit tests (colormath.js 100% line / 93.75% branch / 100% func), six browser hash gates (=#cursortest=/=#readouttest=/=#deltatest=/=#oklchtest=/=#planetest=/=#selftest=), inline-integrity check, =node --check= on the spliced page, README updated. All green. NOTE: =node --test = directory-globbing is broken on Node v26 — use =node --test scripts/theme-studio/*.mjs=. ** TODO Manual testing and validation :verify:theme-studio: Exercised once the phases above land. *** TODO Seeded package-face defaults look right What we're verifying: the seeded org/magit/elfeed default colors read well before any tuning. - Open =scripts/theme-studio/theme-studio.html= in Chrome - Switch the app selector to org-mode, then magit, then elfeed - Read each preview pane against the dupre ground Expected: each package's seeded defaults look coherent and legible; nothing is unreadable or jarring. Override anything off in the tool. *** TODO Large face tables stay usable What we're verifying: org's ~88-face and magit's ~111-face tables stay navigable. - Select org-mode and scroll the grouped face table - Type "agenda" in the filter - Reassign one face and watch the preview Expected: rows are grouped, the filter narrows them, and a reassignment updates the preview live. *** TODO Perceptual readouts read well in the picker What we're verifying: the OKLCH L/C/H and APCA Lc readouts are legible and correctly placed beside the WCAG number. - Open =scripts/theme-studio/theme-studio.html= in Chrome and open the picker on a few colors - Read the OKLCH and APCA values against the WCAG ratio Expected: the new readouts are clear, the APCA sign/polarity is understandable, and nothing crowds the readout bar. *** TODO ΔE warnings read clearly What we're verifying: the too-similar-pair warning is legible and the cap behaves. - Build a palette with two near-identical colors, then a well-spread one - Read the warning line each time Expected: the close pair is named with its ΔE, sorted closest-first, capped at 5 with "and N more"; the well-spread palette shows no warning. *** TODO OKLCH editor feels right What we're verifying: the OKLCH sliders / C×L plane edit cleanly and clamping is visible. - Switch the picker to OKLCH mode and drag L, then C, then H - Push chroma past the sRGB gamut, then toggle the AA/AAA mask Expected: each axis moves independently; the C×L plane (once 4b lands) opens on the current color; "chroma clamped to sRGB" shows on clamp; toggling the mask does not reset OKLCH mode. *** TODO Generated ramp harmonizes What we're verifying: a ramp generated from a base color reads as one family, not a grab-bag (the aesthetic the math is meant to produce). - Open =scripts/theme-studio/theme-studio.html= in Chrome - Pick a mid-lightness base swatch (e.g. a blue) and generate its ramp at the defaults - Read the row of steps left to right, then try a near-black and a near-white base Expected: the steps share an obvious hue and step evenly in lightness; the chroma-ease keeps the extreme steps from going muddy or garish; nothing looks like it belongs to a different color. *** TODO Safe-lightness guidance reads clearly What we're verifying: the L_max marker and unsafe-band shade are legible and land in the right place when editing a covered face. - Open the picker in OKLCH mode on region (or hl-line), with syntax colors assigned - Read the L_max marker and the shaded unsafe band on the lightness slider - Drag lightness up toward and past the marker Expected: the marker is visible and correctly placed, the band above it reads as "unsafe," and crossing it is obvious; an out-of-scope face shows no marker. *** TODO Safe tint actually reads in real Emacs What we're verifying: a background tint the tool calls safe really keeps every token readable behind real syntax-colored text — the whole point of the worst-case floor. - In the tool, set a covered face (e.g. region) to a tint at or just below its L_max with the worst-case readout showing PASS - Build the theme and load it in Emacs, open a code buffer with varied syntax, and select a region spanning many token colors - Read every token through the region highlight, paying attention to the limiting foreground the tool named Expected: every token stays readable over the tint, including the limiting one; a tint pushed just past L_max (readout FAIL) shows a visibly strained or unreadable token, confirming the floor matches reality. *** TODO Color families group the way the eye reads them What we're verifying: the OKLCH hue clustering (25° gap) splits and merges families the way you'd expect, and renaming never moves a color. - Open =scripts/theme-studio/theme-studio.html= in Chrome and load a real theme (e.g. sterling) - Read the strips top to bottom: are "the blues" one strip, "the greens" another, neutrals and ground pinned at the top - Find a pair you'd consider one family that landed in two strips (or two you'd consider separate that merged) - Rename any swatch to something absurd and confirm it stays in the same strip Expected: families match your mental grouping; the few that don't are the cue to revisit the 25° gap; renaming never regroups. *** TODO Regenerate-replace reads as deliberate What we're verifying: the count control clearly signals it rewrites the whole family, so replacing hand-added same-hue colors isn't a surprise. - Add two unrelated colors at a similar hue so they share a strip - Set that strip's count to 2 - Watch what happens to the two colors Expected: the strip becomes a clean base±2 ramp, the two loose colors are gone, and the control made it obvious that's what it would do before you committed. *** TODO Removed-step references read clearly as "(gone)" What we're verifying: lowering a family's count leaves a referencing face visibly stale, not silently re-pointed. - Assign a UI or syntax element to an outer step of a family (e.g. region = a blue+3) - Lower that family's count to 2 so blue+3 disappears - Read the assignment's dropdown Expected: the dropdown shows "(gone)" for the removed step, never a silent jump to a different color; re-pointing it is a deliberate choice. ** TODO [#B] theme-studio guide-support features :feature:theme-studio: From the color-assignment guide work (2026-06-08): make the tool support the guide without mandating it — everything a seed, an advisory, or a view, never a gate. Two specs to write, both deriving from the rewritten guide and its seed table ([[file:scripts/theme-studio/theme-coloring-guide.org][theme-coloring-guide.org]]). *** 2026-06-08 Mon @ 19:08:00 -0500 Seeding-engine spec written and Ready [[file:docs/design/theme-studio-seeding-engine-spec.org][theme-studio-seeding-engine-spec.org]] — role table + face→role maps for syntax/UI/org, OKLCH shade generation, reseed dupre-revised to the compact mapping. Codex-reviewed, Ready. Implementation tracked under the seeding-engine parent below. *** TODO Guide-support views and advisories spec :solo: Five optional surfaces, all dismissible and non-blocking, in one collapsible panel where they advise: (1) CVD-simulation toggle on previews (deuteranopia/protanopia/tritanopia); (2) squint/blur preview toggle; (3) lightness-ramp view + palette advisories (accent count over 6-8, roles separated only by red/green) — depends on the OKLCH/ΔE core; (4) definition-vs-call / weight advisories; (5) state-over-syntax preview (region/search/diff tint over real syntax-colored text). Sequence: rewritten guide reviewed → seeding-engine spec → this. Advisories (3, 4) layer on the perceptual-metrics feature. ** TODO [#B] theme-studio seeding engine :feature:theme:theme-studio: Spec (Ready): [[file:docs/design/theme-studio-seeding-engine-spec.org][spec]]. Role table → guide-correct defaults for syntax/UI/org; reseed dupre-revised.json to the compact mapping; opens seeded with an all-tier reseed button. Depends on the perceptual-metrics colormath.js core for OKLCH shade generation, so it runs after that feature's Phase 1. *** TODO Seed model + seed() + #seedtest :solo: Phase 1. Palette anchors + OKLCH shade generation (reusing colormath.js), the ROLES table, and the three face→role maps as data; pure seed(). Gate: #seedtest asserts representative syntax/UI/org faces resolve correctly (bi→blue-grey, fnd→gold+bold, region bg-only, link underlined, org-level-1 strongest, org-code literal lane) and a non-org bespoke package (magit) keeps its curated seed. *** TODO Open-seeded + reseed + dupre-revised regen :solo: Phase 2. Initial state from seed() plus seedPkgmap for the non-org packages; all-tier reseed button with a scope-named overwrite warning, resetting non-org to their APPS defaults; regenerate dupre-revised.json. Gate: #selftest PASS; default-on-open equals seed(); artifact round-trip (regenerated dupre-revised.json imports back to the same seeded state); Chrome eyeball. *** TODO Seeding-engine test surface :solo:tests: Keep #seedtest, #selftest, the default-on-open check, the dupre-revised round-trip, node --check, and Chrome validation green. ** DONE [#B] theme-studio refactor — extract app from generate.py :feature:theme-studio:refactor: CLOSED: [2026-06-09 Tue] Examined 2026-06-09. generate.py is 1378 lines, ~1300 of them a single triple-quoted string holding the whole app (CSS + HTML + ~1000+ lines of JS). That string is the root of every refactor here: the app logic can't be unit-tested (only =colormath.js= is, because it is the one extracted module); backslash-doubling in the string caused real bugs this session (the multi-line export strip, the =#deltatest= regex); and there is no lint, highlight, or brace-check until Chrome runs it. The rest of the directory is healthy: =colormath.js= (pure, 100/96 tested) and =build-theme.el= (13 small functions) are the model. Run the whole set in NO-APPROVALS mode: TDD per stage (characterization hash tests before each behavior-preserving move; node unit tests as extraction makes logic importable), commit + push at each green stage. Tooling committed at c7518d6f before starting. Order: DONE (2026-06-09): Stages 1-5 + 7 landed and pushed (origin/main tip dd90eca9); Stage 6 deliberately skipped (optional, works today). generate.py went 1378→~500 lines; the app now lives in real files (styles.css, app.js, app-core.js) inlined at generate time. The escaping-bug class is gone (str.replace is literal), the dedup is done (unified dropdowns/sort/clear-unlocked, shared crHtml/mkStyleButtons/effFg helpers), and the pure app logic is unit-tested (app-core.js, 18 node tests). Three new permanent gates added along the way: =#locktest=, =#sorttest=, and the app-core integrity + node suite. =make theme-studio-test= = 13 python + 43 node + spliced-check + 8 hash gates, all green. *** 2026-06-09 Tue @ 05:01:11 -0500 Stage 1 — #locktest net + extracted styles.css/app.js Added the =#locktest= browser gate first (commit d04f44dd): it pins, across all three tiers, that mkLockCell disables a row's control (syntax swatch div via data-locked, UI select via .disabled) and that clear-unlocked wipes unlocked rows while skipping locked ones. Proved it goes red when a lock guard is removed. Then extracted the =