diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/design/dupre-clear-theme.org | 89 | ||||
| -rw-r--r-- | docs/design/keybinding-console-safety-spec.org | 939 | ||||
| -rw-r--r-- | docs/design/messenger-unification-spec.org | 208 | ||||
| -rw-r--r-- | docs/design/module-inventory.org | 2 | ||||
| -rw-r--r-- | docs/design/signal-client.org | 22 | ||||
| -rw-r--r-- | docs/design/theme-studio-color-harmony.org | 77 | ||||
| -rw-r--r-- | docs/design/theme-studio-face-rules.org | 47 | ||||
| -rw-r--r-- | docs/design/theme-studio-package-faces-spec.org | 586 | ||||
| -rw-r--r-- | docs/design/theme-studio-perceptual-color-metrics-spec.org | 576 | ||||
| -rw-r--r-- | docs/design/theme-studio-seeding-engine-spec.org | 350 | ||||
| -rw-r--r-- | docs/theme-studio-color-families-spec.org | 202 | ||||
| -rw-r--r-- | docs/theme-studio-palette-columns-spec.org | 119 | ||||
| -rw-r--r-- | docs/theme-studio-palette-generator-spec.org | 241 | ||||
| -rw-r--r-- | docs/theme-studio-palette-ramps-spec.org | 219 |
14 files changed, 3676 insertions, 1 deletions
diff --git a/docs/design/dupre-clear-theme.org b/docs/design/dupre-clear-theme.org new file mode 100644 index 00000000..3b88a7d0 --- /dev/null +++ b/docs/design/dupre-clear-theme.org @@ -0,0 +1,89 @@ +#+TITLE: dupre-clear — a contrast-first AAA sibling theme +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-07 + +* Status + +Spec / not started. Working name *dupre-clear* (final name TBD — see Open Questions). Sibling to the in-progress *dupre revision* (see "Relationship" below). Linked from a task in =todo.org= under Emacs Open Work. + +* One-line concept + +Take dupre's color identity and rebuild it the way Prot built modus: *contrast-first*. Where the dupre revision optimizes for mood and depth (lands at WCAG AA), dupre-clear optimizes for legibility (targets WCAG AAA, ~7:1 on the ground) — the same soul, dialed for maximum clarity. + +* Motivation + +This came out of a long 2026-06-07 design session that produced the *dupre revision* (an elegant, AA-level theme — see the dupre-redesign entry in =.ai/session-context.org= for the full palette + mapping). Near the end we analyzed how Prot actually generated the modus palette, and the finding reframes the whole approach: + +- We built our palette *aesthetics-first*: pick a beautiful/deep/dusty color, accept whatever contrast falls out. Result: a rich palette that mostly lands at AA (4.5–6.5:1), with a couple of colors needing a nudge to clear AA at all. +- Prot built modus *contrast-first*: the ~7:1 AAA floor is the non-negotiable starting constraint; he then hand-picks the nicest color that clears it for each role. + +dupre-clear is the "what if we applied Prot's discipline to dupre's colors" theme. It's not a fix to the dupre revision — both are valid, they're just tuned for different priorities. Some people (and some lighting / monitor / eyesight conditions) want the maximally-legible version; this is that version, without abandoning dupre's character. + +* The Prot methodology (evidence, so we don't re-derive it) + +Pulled from =/usr/share/emacs/30.2/etc/themes/modus-vivendi-theme.el= on 2026-06-07. modus has 6 hue families (red, green, yellow, blue, magenta, cyan), each with base + =-warmer= / =-cooler= / =-faint= / =-intense=, plus bg/fg roles and a large semantic-mapping layer (~128 named colors, ~177 mappings). + +Key finding: *the variants are NOT algorithmically derived from the base.* If they were (e.g. warmer = hue rotate by N, faint = saturation × 0.5), the HSL numbers inside each family would move regularly. They don't: + +- "faint" is not a consistent saturation cut: =red-faint= is S100 (fully saturated, just lighter), =green-faint= is S38 (heavily desaturated), yellow/blue/magenta/cyan faint land at S48/74/47/53. +- "cooler" is not a consistent lightness move: =red-cooler= is lighter (L67→75), =green-cooler= is darker (L50→38). + +What IS systematic is *contrast*. Every modus-vivendi color clears roughly 7:1 on the =#000000= background (red family 7.0–9.9, green 8.5–11.9, cyan 11–14, the lowest being blue-intense at 6.5). So the invariant is the AAA contrast floor; the colors are individually hand-curated to (a) read as their named relationship and (b) clear the floor. The variant names are *descriptions of perceptual roles*, not outputs of a formula. + +Implication for dupre-clear: don't write an HSL-transform generator. Set the 7:1 floor, then hand-pick (or constraint-solve) the richest dupre-flavored color that clears it for each role. + +* Design principles for dupre-clear + +1. *Keep dupre's identity*: the warm near-black ground =#0d0b0a=, the warm-grey/metallic neutrals, and the hue families (the dupre blue, emerald, gold, terracotta, regal violet, mint). The HUES stay recognizably dupre; the brightness/saturation change to meet contrast. +2. *Contrast-first*: target ~7:1 AAA on the ground for all foreground syntax text. Comments may sit at AA-large (de-emphasis is intentional). Fills (navy, regal purple) are exempt — they carry light text, so their own ground-contrast is irrelevant. +3. *Accept the cost*: the deep/dusty choices from the dupre revision will have to brighten to reach AAA. dupre-clear is allowed to be more vivid than the revision — that's the point. Don't try to keep both depth and AAA on the same black; they pull opposite ways (proven repeatedly in the session). +4. *Same mapping, brighter values*: reuse the dupre revision's role assignments (below); only the color values move. +5. *Same modus two-layer structure*: a raw palette + a semantic-mapping layer, so it can retarget cleanly and read like a real systematized theme. + +* Starting point: the dupre revision palette + mapping (the AA version to brighten) + +These are the dupre-revision (AA) values as of 2026-06-07. dupre-clear keeps the roles, brightens the values to AAA. Ground =#0d0b0a=, default/fg silver =#d8d8d8=. + +| role | dupre revision (AA) | contrast | dupre-clear target | +|------+---------------------+----------+--------------------| +| keyword (BOLD) | blue #67809c | 4.8 | a dupre-blue bright enough to clear ~7:1 as bold text (note: a deep blue can't be AAA on near-black — may need to lighten meaningfully, or keep bold + accept ~AA for blue as the one exception, OR lift the ground; this is the hardest slot) | +| function | gold metallic #e8bd30 | 11.0 | already AAA — keep | +| type | regal violet #9b5fd0 | 4.6 | brighten toward ~7:1 (the L57→L66 sweep showed #ab79d8 ≈ 6.0; go a touch brighter for 7) | +| string | emerald dusty #2ba178 | 6.1 | brighten/saturate to ~7:1 (the vivid #1bb17d was 7.1) | +| constant/number | terracotta #cb6b4d | 5.4 | brighten toward 7:1 (toward the lighter terracotta #d19475 ≈ 7.7, or re-pick) | +| default / vars / punct | silver #d8d8d8 | 13.8 | already AAA — keep | +| comment | warm-dim #6f655a | 3.4 | intentionally recessive; AA-large is fine even in the clear theme | +| docstring | muted emerald #5d9b86 | 6.1 | brighten to ~7 | +| spare | mint #8dc4af | 10.0 | already AAA | + +Structural / fills (unchanged role): metallic greyscale ramp (gunmetal #2f343a → pewter #5e6770 → steel #838d97 → silver), navy fill #264364, regal-purple fill #562d76. Silver text on navy/regal both clear ~7:1. + +The hardest slot is *blue keywords*: a deep dupre blue (#67809c) is intrinsically sub-AAA on near-black (depth and AAA are mutually exclusive there — proven in-session). Options to decide at build time: (a) brighten the blue toward a lighter steel (loses depth), (b) keep blue bold at AA as the single deliberate exception (modus-vivendi itself has blue-intense at 6.5), or (c) lift the ground slightly so a deeper blue clears AAA (changes dupre's signature warm near-black). Worth a focused decision. + +* Build approach + +1. Decide whether dupre-clear is its own =themes/dupre-clear-*.el= (palette + faces + theme) or shares structure with the dupre revision. Likely its own files: a =dupre-clear-palette.el= + =dupre-clear-faces.el= + =dupre-clear-theme.el=, mirroring the dupre file layout. +2. Pick each color contrast-first per the table above; verify every foreground color clears the AAA floor with the WCAG helper. +3. Wire the same semantic mapping (keyword=blue bold, function=gold, type=violet, string=emerald, const=terracotta, comment=warm-grey, default=silver, structural=metallic/navy/regal). +4. TDD via a =tests/test-dupre-clear-theme.el=, including a WCAG-contrast assertion that every syntax face clears 7:1 (the inverse of the AA test the dupre revision gets — here it's a hard AAA gate). Reuse the contrast helper pattern from =tests/test-dupre-theme.el= (=dupre-test--contrast= etc.). +5. Live-reload + screenshot to verify per =emacs.md=. + +* Relationship to the dupre revision + +- *dupre revision* (in progress, 2026-06-07): the elegant-AA reinterpretation of the current dupre theme — mood/depth first. This is the one being built first. Full design in =.ai/session-context.org= (the dupre-redesign entry). +- *dupre-clear* (this spec): the contrast-first AAA sibling — legibility first, same hues brightened. +- They share the hue identity and the role mapping; they differ in brightness/saturation and in the contrast target (AA vs AAA). Build the revision first; dupre-clear reuses its hue choices as the starting point and brightens them. + +* Tooling + references (so this is resumable cold) + +- The session's exploration tooling was a set of throwaway =/tmp/gen-*.py= scripts that render palette + 4-language code samples to HTML and open them in a browser; they include WCAG-contrast and CIEDE2000 (perceptual distance) helpers. Those /tmp files won't survive a reboot — re-derive the helpers (WCAG: relative luminance with the sRGB linearization, contrast = (L1+0.05)/(L2+0.05); CIEDE2000 for separation). The math is also embedded in =tests/test-dupre-theme.el= (the WCAG half). +- modus reference palette: =/usr/share/emacs/30.2/etc/themes/modus-vivendi-theme.el= (and the operandi/tinted variants alongside it). +- dupre lineage: dupre ← distinguished (emacs, Kim Silkebaekken) ← vim-distinguished. The dupre palette lives in =themes/dupre-palette.el= + =themes/dupre-faces.el=; swatch PNG at =themes/dupre-palette.png=. +- The key perceptual lessons from the session (also in the anchor): thin colored text desaturates (muted hues grey out as glyphs — bold helps); a near-black ground forces depth-vs-AAA as a hard tradeoff; Hyprland inactive-window dimming silently shifts colors (disable with =hyprctl keyword decoration:dim_inactive false= during color work). + +* Open questions + +1. *Name.* "dupre-clear" is a working placeholder; Craig wants a different final name. Candidates to brainstorm: something in the distinguished/dupre lineage that signals "the legible/clear one." Decide at build start. +2. *Blue keywords at AAA.* The depth-vs-AAA conflict on the dupre blue (see the build-approach note) — pick (a) brighten, (b) keep AA as the deliberate exception, or (c) lift the ground. +3. *File sharing vs separate.* Whether dupre-clear shares any palette/faces machinery with the dupre revision or stands fully alone. +4. *Light variant?* Modus ships both vivendi (dark) and operandi (light). Out of scope for v1, but worth noting whether a dupre-clear-light is ever wanted. diff --git a/docs/design/keybinding-console-safety-spec.org b/docs/design/keybinding-console-safety-spec.org new file mode 100644 index 00000000..d06c5a27 --- /dev/null +++ b/docs/design/keybinding-console-safety-spec.org @@ -0,0 +1,939 @@ +#+TITLE: Keymap Consolidation — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-12 + +* Metadata +| Status | draft | +|----------+--------------------------------------------------------------------| +| Owner | Craig Jennings | +|----------+--------------------------------------------------------------------| +| Reviewer | TBD (multi-reviewer cycle) | +|----------+--------------------------------------------------------------------| +| Related | [[file:../../todo.org][todo.org]] — "M-S- launcher keys" task (to be reclassified, Phase 0) | +|----------+--------------------------------------------------------------------| + +* Summary + +Some commonly-used window/layout commands are bound to =M-S-<letter>= chords that only work in GUI frames, via a fragile =key-translation-map= layer that already caused a regression. + +The primary work consolidates the common commands under the =cj/custom-keymap= personal keymap and retires the fragile translation layer — independent of any prefix choice. Console reachability is then a one-line, *optional* follow-on: bind that one keymap to a single console-safe prefix (a =Control=+key, or a free =M-<punctuation>=; candidates in Appendix C), used everywhere. Per Path 2 (2026-06-13), the work proceeds up to the point of assigning that prefix and stops there; the assignment is a deferred phase Craig takes when he picks the key. + +The aim: consolidate the common commands into one keymap and retire the translation block now, leaving a single, optional console-safe prefix to switch on later. + +* Problem / Context + +A subset of common commands is bound to =M-S-<letter>= chords (Meta + Shift + lowercase letter). Pressing Meta+Shift+e emits the event =M-E= (uppercase Meta), but the command is bound to =M-S-e=; the bridge between them is a =key-translation-map= entry that =modules/keyboard-compat.el= installs *only* in GUI frames (=env-gui-p=). So these chords are dead in terminal frames and dead in the Linux console. + +Craig does not use terminal or console Emacs often, but falls back to the console in emergencies (a broken graphical session). When common keys are unavailable there, the editor stops being usable for the emergency and he has to switch tools. For *uncommon* commands, =M-x= is an acceptable fallback; for *common* ones it is not. + +How each key family actually behaves across the three contexts (the facts the design turns on): + +| Context | Meta sent as | =M-S-e= (as bound) | =M-E= (uppercase Meta) | =C-;= | +|-------------------------+--------------+-------------------------+-------------------------+-------------------------| +| GUI frame | native event | reached only via the | intercepted by the | works natively | +| | | GUI translation map | translation map | | +|-------------------------+--------------+-------------------------+-------------------------+-------------------------| +| Terminal emulator | ESC prefix | dead (keypress emits | works (ESC E), if no | works if the emulator | +| (xterm-family) | | =M-E=, binding is on | translation intercepts | speaks | +| | | =M-S-e=) | | modifyOtherKeys/kitty | +| | | | | (recent Emacs | +| | | | | auto-enables for | +| | | | | xterm-family) | +|-------------------------+--------------+-------------------------+-------------------------+-------------------------| +| Linux console | ESC prefix | dead (same reason) | works (ESC E) | DEAD — semicolon is not | +| (TERM=linux) | | | | a control char; cannot | +| | | | | be transmitted | +|-------------------------+--------------+-------------------------+-------------------------+-------------------------| + +Three consequences: =M-S-e= is dead outside GUI by construction; =C-;= is solid in GUI, conditional in terminal emulators, and dead in the Linux console (so it cannot be the *only* home for console-critical commands); and =M-E= plus function keys and =C-c= sequences are transmittable everywhere, which is the material to build a console-safe path from. + +** The regression that triggered this + +Commit =4a1ecf64= "fixed" three launcher keys (=eww=/=elfeed=/=calibredb=) by rebinding them from =M-S-e/r/b= to =M-E/M-R/M-B=. It was wrong, and three review passes missed it because they all used =key-binding=, which consults keymaps only and ignores =key-translation-map=. The original audit "verified dead in the live daemon" with that blind check (false positive); the fix bound =M-E= but left the =M-E -> M-S-e= translation entry in place, so in GUI the keypress is rewritten to the now-unbound =M-S-e= and the launchers break on the next restart; and the new test asserted =(key-binding (kbd "M-E"))=, passing against a configuration broken at the keyboard. It only appears to work in the running daemon because the pre-fix binding is still loaded as stale state — the stale-daemon trap. + +The lesson is encoded into the acceptance criteria: real reachability is not =key-binding= when a translation map participates. + +* Goals and Non-Goals + +** Goals +- Every *commonly used* command is reachable in GUI, terminal emulators, and the Linux console. +- One canonical personal command surface, so console-reachability is solved once at the prefix level rather than per command. +- Retire the =keyboard-compat.el= =M-uppercase -> M-S-lowercase= translation block, the root of the fragility. +- Keep daily ergonomics: high-frequency commands keep a fast chord in GUI. + +** Non-Goals +- Making *every* binding console-safe. Uncommon commands may live on =M-x= only. +- A ground-up keymap redesign. This is about reachability and retiring one fragile mechanism. +- Defeating the Linux virtual console's hard limits (it cannot transmit =C-;=, and Meta+Shift behaviour varies). The design routes around them. + +** Scope tiers +- *v1 (primary — Phases 0–2):* + - revert =4a1ecf64= (Phase 0, unblocks the push); + - prune the forgotten keybindings Craig marks in Appendix D; + - migrate the common window/layout =M-S-= commands into =cj/custom-keymap=; + - drop the uncommon chords to =M-x=; + - retire the translation block; + - translation-aware tests. +- *Deferred / optional (Phase 3):* + - bind =cj/custom-keymap= to a single console-safe prefix (D1/D3) once Craig picks the key — the console-reachability payoff, switched on later. +- *Out of scope:* + - enabling modifyOtherKeys/kitty for terminal emulators (helps terminals, not the console; orthogonal). +- *vNext:* auditing non-=M-S-= GUI-only chords (modified function keys, =C-+/C-==) for console behaviour. + +* Design + +Craig's first choice (review, 2026-06-12): instead of two prefixes, retrain muscle memory onto *one* console-safe prefix that works everywhere — a =Control= + home-row key that is lightly used or easily/intuitively rebound. =cj/custom-keymap= moves from =C-;= to that single prefix (=C-;= may stay bound during the transition since one keymap can carry many prefixes). The candidate list is Appendix C; the standout home-row candidate is =C-l=. The two-prefix design below is the documented fallback if no single prefix proves acceptable. + +The personal command surface is already a single keymap object, =cj/custom-keymap= (=modules/keybindings.el=), bound to =C-;=. The whole design rests on one Emacs fact: a keymap is an object and can be bound to more than one prefix. So console-reachability is a *prefix* problem, not a per-command problem. + +For a user (single-prefix path): you reach your personal commands with the one console-safe prefix in GUI, terminal, and console alike — same menu, same keys after the prefix, nothing per-context to remember. (Fallback two-prefix path: =C-;= in the GUI as today, plus a second console-safe prefix anywhere.) + +For the implementer: add one line — =(keymap-global-set "<console-safe-prefix>" cj/custom-keymap)= — and the entire tree in Appendix A becomes reachable through it; nothing per-command. The work then is to move the *common* console-dead commands (the window/layout =M-S-= subset, Appendix B) *into* =cj/custom-keymap= so they inherit that reachability, drop the *uncommon* =M-S-= chords to =M-x=, and delete the now-unused translation block. High-frequency window commands additionally keep a fast chord so daily GUI use doesn't regress to a 3-key sequence (Decision D4). + +The console-dead common set is window/layout work, which has no =C-;= sub-prefix today, so v1 adds one (a new window sub-map; letter is Decision D5). The =C-c=/=C-h=/=C-z=/=C-x= and plain function-key bindings already work in the console and stay where they are. + +* Alternatives Considered + +** A — Revert 4a1ecf64 and keep the translation layer as the end state +- Good, because it is the smallest change and restores correctness immediately. +- Bad, because it keeps 18 keys on the GUI-only mechanism that already bit us and + leaves the console-dead problem unsolved. +- Neutral, because the revert itself is still needed as Phase 0; this option just + stops there. + +** B — Migrate the whole family to direct uppercase-Meta, delete the translation block, no C-; move +- Good, because it preserves every single-chord and =M-E= (ESC + uppercase) is + transmittable in GUI, terminal, and console alike. +- Bad, because it bets the emergency-console guarantee on Meta+Shift behaving + cleanly on every console keyboard, which is probable but not certain, and it + gives the common commands no robust prefix-based fallback. +- Neutral, because it still deletes the translation block (shared with the chosen + design) and could be layered onto the frequent-chord subset (see D4 Option B). + +** C — Enable an enhanced keyboard protocol (modifyOtherKeys / kitty) so C-; works in terminals +- Good, because it makes =C-;= itself work in capable terminal emulators. +- Bad, because it does nothing for the Linux virtual console (a hard limit), and + adds a terminal-capability dependency. +- Neutral, because it is orthogonal and could be added later without conflicting. + +** Chosen — one map, two prefixes (consolidate common commands under C-;, add a console-safe alt prefix) +- Good, because console-reachability is solved once at the prefix; it depends on + exactly one prefix working, and that prefix is chosen to be bulletproof. +- Bad, because moved commands cost a muscle-memory transition, and a pure + sub-prefix path is 3 keys (mitigated by D4 for the frequent ones). +- Neutral, because it still requires the revert (Phase 0) and the translation- + block deletion (shared with B). + +* Decisions [3/5] + +Each decision is a TODO task. It flips to DONE when Craig agrees with the call; if +he doesn't, it stays TODO and the discussion continues under its =*** Discussion= +child header. + +*Gate (Path 2).* The decisions split by which work they block. D2, D4, and D5 gate +the *primary* work (Phases 0–2: revert, prune, consolidate, retire the translation +block); the spec is implementation-ready for that work once those three are DONE. D1 +and D3 (the console-safe prefix) gate *only* the optional Phase 3 — they can stay +TODO indefinitely without blocking the consolidation. So yes: the work proceeds all +the way to the point of assigning the prefix and stops there, exactly as Craig asked, +even if D1/D3 are never decided. The =[n/5]= cookie tracks the overall tally; full +=ready= (including Phase 3) still needs all five. + +** DONE D1 — One map, one console-safe prefix (single-prefix primary; two-prefix fallback) +CLOSED: [2026-06-13 Sat 00:20] +- Owner / by-when: Craig / review cycle +- Context: the common console-dead commands need to be reachable in the console; + =C-;= alone is dead there; per-command console bindings would not scale. +- Decision (revised): We keep =cj/custom-keymap= as the single personal surface. + *Primary (Craig's first choice):* rebind it to ONE console-safe prefix — a + =Control= + lightly-used home-row key (Appendix C; standout =C-l=) — used in GUI, + terminal, and console alike, retraining muscle memory off =C-;=. =C-;= may stay + bound during the transition. *Fallback:* if no single prefix is acceptable, bind + the map to both =C-;= (GUI) and one console-safe alternate prefix (D3). +- Consequences: easier — one prefix to make console-safe, whole tree travels, and + the single-prefix path needs no per-context mnemonic; harder — every + console-critical command must actually live under =cj/custom-keymap= (so the + common =M-S-= set is still migrated in), and the single-prefix path costs a + full =C-;= → new-prefix muscle-memory transition. +*** Discussion +- Direction agreed by Craig 2026-06-12: single-prefix primary, two-prefix fallback. +- Deferred by Craig 2026-06-13 (Path 2): the console-safe prefix becomes the optional + Phase 3, not part of the primary work. The consolidation (Phases 0–2) lands without + it, so D1 no longer blocks anything until Craig chooses to do Phase 3. It stays TODO + as the marker for "decide the prefix later." The phases are rewritten accordingly, + and the keybinding audit Craig asked for lives in Appendix D. + +** DONE D2 — Migrate only the common (window/layout) M-S- set; drop the uncommon to M-x +CLOSED: [2026-06-13 Sat 00:22] +- Owner / by-when: Craig / review cycle +- Context: of the 18 =M-S-= commands, only window/layout control is plausibly + needed in an emergency console session; apps and one-off tools are not. +- Decision: We will move the window/layout subset (=M-S-o/m/v/h/t/u/z=, and + =M-S-k= pending review) into =C-;=, and remove the other ten =M-S-= chords, + leaving those commands on =M-x=. +- Consequences: easier — shrinks the translation block to nothing, focuses the + console surface on essentials; harder — the dropped commands lose a chord; + =show-kill-ring='s classification is a judgment call. +*** Discussion +- Not yet reviewed by Craig. Open: confirm the window/layout subset to migrate + (incl. =M-S-k= show-kill-ring's common/uncommon call) and that the other ten + drop to =M-x=. Flip to DONE on Craig's sign-off. + + +** TODO D3 — The console-safe prefix (pick from Appendix C) +- Owner / by-when: Craig / review cycle +- Context: under D1's single-prefix primary, this prefix is THE personal-keymap + prefix; under the two-prefix fallback it is the second (alternate) binding. It + must transmit in the Linux console, where only =Control= + letter chords carry + (and TAB/RET/LF/ESC/DEL collisions and =C-g= are excluded). Full candidate + analysis is Appendix C. +- Decision: For the single-prefix path, =C-l= is the standout (home-row, + console-safe, default =recenter-top-bottom= is light and trivially relocatable); + =C-q= / =C-o= / =C-t= are off-home-row runners-up. For the two-prefix fallback, + =C-c ;= (=C-c= transmits; =;= is a plain key; mnemonic mirror of =C-;=) stays the + recommendation. Craig picks the prefix. +- Consequences: easier — solves console reachability for the whole tree at one + binding; harder — a single =Control=+letter prefix displaces its default command + (relocate =recenter-top-bottom= if =C-l=), and =C-l= is also bound to + =vertico-insert= inside the minibuffer (=selection-framework.el:42=) — minibuffer- + local, so no conflict with a global prefix, but worth noting. +*** Discussion +- Open: Craig picks the prefix. Recommendation =C-l= (only clean home-row option); + runners-up =C-q= / =C-o= / =C-t=. Flip to DONE on the pick. D1 closes with it. + + +#+begin_src cj: comment +it's not going to be C-l. That's too hard of a habit for me to kick right now. I'd rather go C-c ; altogether -- even in GUI -- than have C-l do the wrong thing when I hit it. We'll find something. But it's not decided yet. Change the status of this decision to waiting. +#+end_src + +** DONE D4 — Fast-chord strategy for high-frequency window ops +CLOSED: [2026-06-13 Sat 00:25] +- Owner / by-when: Craig / review cycle +- Context: =split-and-follow-right/below= and =undo-kill-buffer= are pressed + constantly; a 3-key =C-; <w> v= sequence is a real downgrade. +- Decision: We will (Option A) keep a fast GUI chord for the frequent commands in + addition to their =C-;= entry, OR (Option B) bind them to direct uppercase-Meta + single chords and retire the translation block. Review picks. +- Consequences: A — preserves speed, but the fast chord may itself be GUI-only + unless it is a function key; B — single chord works in all three contexts but + leans on console Meta+Shift. +*** Discussion +- Open: Craig picks Option A (keep fast GUI chord) vs Option B (direct + uppercase-Meta single chords). Note: if D3 lands a single console-safe prefix, + Option B's console rationale weakens. Flip to DONE on the pick. + +#+begin_src cj: comment +we can simply revert +#+end_src + +** TODO D5 — Window sub-prefix and apps disposition +- Owner / by-when: Craig / review cycle +- Context: window/layout has no =C-;= sub-prefix. Free single lowercase letters are + =i q u y z= (=g= is calendar, =h= is Hugo — both taken); most uppercase is free. + =C-; L= is reserved for the Pearl/Linear package — do NOT reuse it. The four apps + (=eww=/=elfeed=/=calibredb=/=wttrin=) could go to =M-x= or a launcher sub-prefix. + + #+begin_src cj: comment + add a listing of the keybindings we're discussing. I don't know what the window/layout keybindings you're discussing. It's not shift arrow keys, is it? + #+end_src +- Decision: We will add a window sub-prefix under =C-;= (letter TBD from the free + set). Apps: Craig decided the launcher commands get real keys under a launcher + sub-prefix (=e/f/b/w= leaves), NOT =M-x=. Sub-prefix letter TBD; the freed + =C-; a t= (ai-assistant toggle, see Phase 0) is one candidate location if the + apps belong nearer the AI tools. Both sub-prefix letters are Craig's pick. +- Consequences: easier — groups window ops and launcher apps discoverably under + which-key, and the launcher apps inherit console reachability for free; harder — + spends two scarce top-level =C-;= letters from the small free set. +*** Discussion +- Apps half agreed by Craig 2026-06-12: launcher sub-prefix, not =M-x=. Open: the + window sub-prefix letter and the launcher sub-prefix letter, both from the free + set {=i q u y z=} + uppercase (NOT =L= — Pearl). Flip to DONE once both letters + are chosen. + +* Implementation phases + +Path 2 (Craig, 2026-06-13): Phase 0 is a *pure revert* that unblocks the held push; the migration follows, and the console-safe prefix is an *optional, deferred* phase. Everything proceeds up to the point of assigning the prefix (end of Phase 2) and stops there; Phase 3 is the optional assignment once Craig picks the prefix. So the consolidation does not wait on the prefix decision (D1/D3); only Phase 3 does. + +** Phase 0 — Revert the regression (unblocks the push) +Revert =4a1ecf64= and nothing more: restore =M-S-e/r/b= in the three modules and delete the flawed test (=tests/test-launcher-meta-shift-keys.el=), leaving a clean, correct baseline. Reclassify the "M-S- launcher keys" task as not-a-bug — the keys worked via the GUI translation layer. This is the only step the held 12-commit stack needs before it can push. Per Path 2, the launchers get reverted to =M-S-= here and move to their new homes in Phase 2 — the accepted small throwaway (3 bindings) of not waiting on the full move-map. + +The flawed test asserts the launcher bindings with =key-binding= alone: + +#+begin_src emacs-lisp +(should (eq (key-binding (kbd "M-E")) 'eww)) +(should (eq (key-binding (kbd "M-R")) 'cj/elfeed-open)) +(should (eq (key-binding (kbd "M-B")) 'calibredb)) +#+end_src + +=key-binding= consults keymaps only and ignores =key-translation-map=, so the test passes even though the GUI translation entry =M-E -> M-S-e= rewrites the keypress back to the now-unbound =M-S-e=. It cannot see the rewrite, so it certifies a configuration that is broken at the keyboard. Phase 2's translation-aware assertion replaces it. + +** Phase 1 — Audit and prune forgotten keybindings (Appendix D) +Appendix D inventories every keybinding Craig has set outside the =C-;= tree and the =M-S-= family — the place to catch chords set-and-forgotten. Craig checks the boxes for the bindings to retire; remove those. Independent cleanup, and a good moment to clear cruft before the migration. Tree working. + +** Phase 2 — Consolidate: migrate the common set, retire the translation block +The primary deliverable, needing *no* console-safe-prefix decision. Migrate the window/layout =M-S-= subset into =cj/custom-keymap= under a new window sub-prefix (D5); add the launcher sub-prefix (D5) with the =eww=/=elfeed=/=calibredb=/=wttrin= leaves (freeing =C-; a t= — the =cj/toggle-gptel= ai-assistant toggle, =ai-config.el:541=, unfinished and far less used than the =ai-term= F9 launcher — if the letter is tight); apply the fast-chord strategy (D4); drop the ten uncommon =M-S-= chords to =M-x= (D2); delete =keyboard-compat.el='s translation block and its hook (keep the arrow-key =input-decode-map= setup); add the translation-aware tests (see Acceptance criteria) and update the docs. At the end of Phase 2 the work is "done" per Craig's stop point. Tree working. + +** Phase 3 — (OPTIONAL, deferred) Bind the console-safe prefix +Only once Craig picks the prefix (D1/D3, Appendix C). Bind =cj/custom-keymap= to it — =(keymap-global-set "<prefix>" cj/custom-keymap)= — and if the pick is =C-l=, relocate its default =recenter-top-bottom= first. This is the console-reachability payoff: the whole tree becomes reachable in =emacs -nw= and the Linux console through one prefix. Verify in a *fresh* session, not the live daemon. May be deferred indefinitely; the consolidation stands on its own without it. + +* Acceptance criteria +- [ ] The whole =cj/custom-keymap= tree is reachable in a GUI frame, an =emacs -nw= xterm-family terminal, and the Linux virtual console via the alt prefix. +- [ ] The final "common" commands are reachable in all three contexts. +- [ ] =keyboard-compat.el='s translation block is gone; no command depends on it. +- [ ] For any chord claimed to run command X, tests assert BOTH =(key-binding (kbd CHORD))= AND =(lookup-key key-translation-map (kbd CHORD))= are consistent (the latter =nil=, or pointing where intended). =key-binding= alone is insufficient — it is what let =4a1ecf64= through. +- [ ] Reachability is verified in a *fresh* frame/session, not the live daemon (the stale-daemon trap masks results). +- [ ] =make test= fully green (the 4 pre-existing =test-dupre-theme= failures are tracked separately and out of scope). + +* Readiness dimensions +- Data model & ownership: keybindings are user-authored code in =modules/=; + =cj/custom-keymap= is the owned surface. Nothing generated/cached/remote; + nothing persists. +- Errors, empty states & failure: N/A — a missing command symbol surfaces as a + load-time =void-function=, caught by byte-compile and the launch smoke test. +- Security & privacy: N/A — no credentials or sensitive data. +- Observability: which-key shows each prefix's menu; =C-h k= / =describe-bindings= + report the live binding; the translation-aware test reports reachability. +- Performance & scale: N/A — keymap lookup is constant-time; one extra prefix + binding has no measurable cost. +- Reuse & lost opportunities: reuse Emacs's native multi-prefix keymap binding + (one keymap object, two prefix keys) instead of duplicating bindings; reuse + which-key and the existing =cj/register-prefix-map= / =cj/register-command= + helpers. Deletes (does not wrap) the bespoke translation layer. +- Architecture fit & weak points: integration points are =keybindings.el= + (=cj/custom-keymap=, the register helpers), =keyboard-compat.el= (translation + block to delete; arrow-key decode to keep), and the per-module =:bind= / + register calls for the migrated commands. Weak point: the stale-daemon trap can + mask whether a change actually works — mitigated by verifying in a fresh + =-nw=/console session (acceptance criterion). +- Config surface: the console-safe alt prefix (D3) and the window sub-prefix + letter (D5) are the only new knobs; both are constants set once in config. +- Documentation plan: update the =keyboard-compat.el= header (it documents the + retired translation table); note the moved/dropped keys wherever keybindings + are documented. No user-facing migration doc beyond that. +- Dev tooling: existing =make test= / byte-compile / launch smoke cover it; the + new translation-aware assertion is an ERT test like the others. +- Rollout, compatibility & rollback: user-facing keybinding change; rollback is + =git revert=. No persisted data, no public API, no external state. The only + compatibility cost is Craig's muscle memory for the moved/dropped keys — + a transition note, not a migration. +- External APIs & deps: N/A — no external APIs; no new dependencies. + +* Risks, Rabbit Holes, and Drawbacks +- *Muscle-memory disruption* for moved/dropped keys. Dodge: keep fast chords for the highest-frequency commands (D4); accept =M-x= only for genuinely uncommon ones. +- *Console Meta+Shift uncertainty* if D4 Option B is chosen. Dodge: the prefix path (D1/D3) does not depend on it, so the emergency guarantee holds regardless of the fast-chord choice. +- *Stale-daemon trap* masking test results — the exact failure mode behind the regression. Dodge: the acceptance criteria mandate verification in a fresh frame/session and a translation-aware assertion. + +* References / Appendix + +** Appendix A — Full C-; keybinding tree (live, 2026-06-12) + +Dumped from the running daemon by walking =cj/custom-keymap= recursively. +Format: chord — command — what it does. + +*** Top-level leaves (directly on C-;) +- C-; ) — cj/jump-to-matching-paren — jump to the matching paren +- C-; / — cj/replace-fraction-glyphs — replace 1/2-style fractions with glyphs +- C-; ? — cj/flycheck-list-errors — list flycheck errors for the buffer +- C-; A — align-regexp — align region by a regexp +- C-; B — cj/choose-browser — pick the default browser +- C-; f — cj/format-region-or-buffer — format region or whole buffer +- C-; k — cj/org-babel-toggle-confirm — toggle the org-babel eval confirmation +- C-; P — cj/projectile-reset-cmds — reset projectile's cached project commands +- C-; SPC — cj/switch-to-previous-buffer — toggle to the previous buffer +- C-; T — cj/telega — open Telegram (telega) +- C-; | — display-fill-column-indicator-mode — toggle the fill-column rule +- C-; # c — cj/count-characters-buffer-or-region — count characters +- C-; # w — cj/count-words-buffer-or-region — count words + +*** C-; ! — System commands +- C-; ! ! — cj/system-command-menu — the system-command transient menu +- C-; ! e — cj/system-cmd-restart-emacs — restart Emacs +- C-; ! E — cj/system-cmd-exit-emacs — exit Emacs +- C-; ! l — cj/system-cmd-lock — lock the screen +- C-; ! L — cj/system-cmd-logout — log out of the session +- C-; ! r — cj/system-cmd-reboot — reboot +- C-; ! s — cj/system-cmd-shutdown — shut down +- C-; ! S — cj/system-cmd-suspend — suspend + +*** C-; a — AI / gptel +- C-; a . — cj/gptel-add-this-buffer — add current buffer to the gptel context +- C-; a A — cj/gptel-autosave-toggle — toggle conversation autosave +- C-; a b — cj/gptel-browse-conversations — browse saved conversations +- C-; a B — cj/gptel-switch-backend — switch the LLM backend +- C-; a c — cj/gptel-context-clear — clear the gptel context +- C-; a d — cj/gptel-delete-conversation — delete a saved conversation +- C-; a f — cj/gptel-add-file — add a file to the context +- C-; a l — cj/gptel-load-conversation — load a saved conversation +- C-; a m — cj/gptel-change-model — change the model +- C-; a M — gptel-menu — the gptel transient menu +- C-; a p — gptel-system-prompt — edit the system prompt +- C-; a q — cj/gptel-quick-ask — quick one-off ask +- C-; a r — cj/gptel-rewrite-with-directive — rewrite region with a directive +- C-; a R — cj/gptel-rewrite-redo-with-different-directive — redo rewrite, new directive +- C-; a s — cj/gptel-save-conversation — save the conversation +- C-; a t — cj/toggle-gptel — toggle the gptel chat buffer +- C-; a x — cj/gptel-clear-buffer — clear the chat buffer + +*** C-; b — Buffer & file operations +- C-; b <arrows> — cj/window-resize-sticky — sticky window resize (arrow keys) +- C-; b b — cj/clear-to-bottom-of-buffer — clear from point to end +- C-; b c b — cj/copy-to-bottom-of-buffer — copy point-to-end +- C-; b c t — cj/copy-to-top-of-buffer — copy point-to-start +- C-; b c w — cj/copy-whole-buffer — copy the whole buffer +- C-; b d — cj/delete-buffer-and-file — delete the buffer and its file +- C-; b D — cj/diff-buffer-with-file — diff buffer against its file on disk +- C-; b e — eval-buffer — eval the buffer +- C-; b E — cj/view-email-in-buffer — view the buffer as email +- C-; b g — revert-buffer — revert from disk +- C-; b k — cj/kill-buffer-and-window — kill buffer and close its window +- C-; b K — cj/kill-other-window-buffer — kill the other window's buffer +- C-; b l — cj/copy-link-to-buffer-file — copy an org link to the file +- C-; b m — cj/move-buffer-and-file — move/rename buffer + file +- C-; b n — cj/copy-buffer-name — copy the buffer name +- C-; b o — cj/xdg-open — open the file with the system handler +- C-; b O — cj/open-this-file-with — open with a chosen program +- C-; b p — cj/copy-buffer-source-as-kill — copy buffer source +- C-; b P — cj/print-buffer-ps — print the buffer (PostScript) +- C-; b r — cj/rename-buffer-and-file — rename buffer + file +- C-; b s — mark-whole-buffer — select all +- C-; b S — write-file — write/save-as +- C-; b t — cj/clear-to-top-of-buffer — clear from start to point +- C-; b w — cj/view-buffer-in-eww — render the buffer in EWW +- C-; b x — erase-buffer — erase the buffer + +*** C-; c — Case +- C-; c l — cj/downcase-dwim — downcase (dwim) +- C-; c t — cj/title-case-region — title-case the region +- C-; c u — cj/upcase-dwim — upcase (dwim) + +*** C-; C — Comments +- C-; C - — cj/comment-hyphen — hyphen divider comment +- C-; C b — cj/comment-box — boxed comment +- C-; C c — cj/comment-inline-border — inline bordered comment +- C-; C d — cj/delete-buffer-comments — delete all comments in the buffer +- C-; C h — cj/comment-heavy-box — heavy box comment +- C-; C n — cj/comment-block-banner — block banner comment +- C-; C p — cj/comment-padded-divider — padded divider comment +- C-; C r — cj/comment-reformat — reformat a comment +- C-; C s — cj/comment-simple-divider — simple divider comment +- C-; C u — cj/comment-unicode-box — unicode box comment + +*** C-; d — Date / time insertion +- C-; d d — cj/insert-sortable-date — insert YYYY-MM-DD +- C-; d D — cj/insert-readable-date — insert a human-readable date +- C-; d r — cj/insert-readable-date-time — readable date + time +- C-; d s — cj/insert-sortable-date-time — sortable date + time +- C-; d t — cj/insert-sortable-time — sortable time +- C-; d T — cj/insert-readable-time — readable time + +*** C-; D — Org-drill (flashcards) +- C-; D c — cj/drill-capture — capture a drill question +- C-; D e — cj/drill-edit — open a drill file to edit +- C-; D f — cj/drill-this-file — drill the current file +- C-; D r — cj/drill-refile — refile into a drill file +- C-; D R — org-drill-resume — resume a drill session +- C-; D s — cj/drill-start — start a drill session + +*** C-; e — Email (mu4e) +- C-; e s — cj/mu4e-save-attachment-here — save attachment to current dir +- C-; e S — cj/mu4e-save-all-attachments — save all attachments +- C-; e m — cj/mu4e-save-some-attachments — save selected attachments +- C-; e {c,d,g} {i,l,s,u} — mu4e maildir searches: account {c=cmail, d=dmail, + g=gmail} x view {i=inbox, l=large >5M, s=starred/flagged, u=unread} + +*** C-; E — ERC (IRC) +- C-; E b — cj/erc-switch-to-buffer-with-completion — switch ERC buffer +- C-; E c — cj/erc-join-channel-with-completion — join a channel +- C-; E C — cj/erc-connect-server-with-completion — connect to a server +- C-; E l — cj/erc-connected-servers — list connected servers +- C-; E q — erc-part-from-channel — leave a channel +- C-; E Q — erc-quit-server — quit a server + +*** C-; g — Calendar sync (Google Calendar) +- C-; g s — calendar-sync-now — sync now +- C-; g S — calendar-sync-start — start auto-sync +- C-; g x — calendar-sync-stop — stop auto-sync +- C-; g t — calendar-sync-toggle — toggle auto-sync +- C-; g i — calendar-sync-status — sync status + +*** C-; h — Hugo (website/blog) +- C-; h n — cj/hugo-new-post — new post +- C-; h d — cj/hugo-open-draft — open a draft +- C-; h D — cj/hugo-toggle-draft — toggle a post's draft flag +- C-; h e — cj/hugo-export-post — export a post +- C-; h p — cj/hugo-preview — preview the site +- C-; h P — cj/hugo-publish — publish the site +- C-; h o — cj/hugo-open-blog-dir — open the blog dir in Emacs +- C-; h O — cj/hugo-open-blog-dir-external — open the blog dir externally + +*** C-; j — Jump to files +- C-; j c — cj/jump-to-contacts ; C-; j g — cj/jump-to-gcal +- C-; j i — cj/jump-to-inbox ; C-; j I — cj/jump-to-emacs-init +- C-; j m — cj/jump-to-macros ; C-; j n — cj/jump-to-reading-notes +- C-; j r — cj/jump-to-reference ; C-; j s — cj/jump-to-schedule +- C-; j w — cj/jump-to-webclipped + +*** C-; L — Pearl (Linear tickets) [RESERVED — do not reuse] +- C-; L … — pearl-prefix-map — Pearl/Linear ticket commands (lazy sub-map) +- =C-; L= is reserved as the Pearl (Linear integration) leader key. Sub-prefix + letter picks (D5) must avoid it. + +*** C-; l — Line & paragraph +- C-; l c — duplicate line/region (comment variant) ; C-; l d — cj/duplicate-line-or-region +- C-; l j — cj/join-line-or-region ; C-; l J — cj/join-paragraph +- C-; l r — cj/remove-lines-containing ; C-; l R — cj/remove-duplicate-lines-region-or-buffer +- C-; l u — cj/underscore-line + +*** C-; m — Music (EMMS) +- C-; m m — cj/music-playlist-toggle ; C-; m M — cj/music-playlist-show +- C-; m SPC — emms-pause ; C-; m s — emms-stop +- C-; m n — cj/music-next ; C-; m p — cj/music-previous +- C-; m a — cj/music-fuzzy-select-and-add ; C-; m g — emms-playlist-mode-go +- C-; m r — emms-toggle-repeat-playlist ; C-; m t — emms-toggle-repeat-track +- C-; m x — cj/music-toggle-consume ; C-; m z — emms-toggle-random-playlist +- C-; m Z — emms-shuffle ; C-; m R — cj/music-create-radio-station + +*** C-; M — Signal (signel) +- C-; M m — cj/signel-message — message a contact +- C-; M s — cj/signel-message-self — note to self +- C-; M SPC — cj/signel-connect — start/connect the daemon +- C-; M d — signel-dashboard — the Signal dashboard +- C-; M q — signel-stop — stop the daemon + +*** C-; n — Org-noter +- C-; n t — cj/org-noter-start — start noter on the document +- C-; n n — cj/org-noter-insert-note-dwim — insert a note (dwim) + +*** C-; o — Ordering / text transforms +- C-; o a — cj/arrayify ; C-; o j — cj/arrayify-json ; C-; o p — cj/arrayify-python +- C-; o u — cj/unarrayify ; C-; o l — cj/listify ; C-; o L — cj/comma-separated-text-to-lines +- C-; o A — cj/alphabetize-region ; C-; o r — cj/reverse-lines ; C-; o n — cj/number-lines +- C-; o q — cj/toggle-quotes ; C-; o o — cj/org-sort-by-todo-and-priority + +*** C-; p — reveal.js presentations +- C-; p n — cj/reveal-new ; C-; p h — cj/reveal-insert-header ; C-; p H — cj/reveal-remove-headers +- C-; p e — cj/reveal-export ; C-; p SPC — cj/reveal-present +- C-; p p — cj/reveal-preview-start ; C-; p s — cj/reveal-preview-stop + +*** C-; r — Recording (audio/video) +- C-; r a — cj/audio-recording-toggle ; C-; r v — cj/video-recording-toggle +- C-; r s — cj/recording-quick-setup ; C-; r S — cj/recording-select-devices +- C-; r d — cj/recording-list-devices ; C-; r l — cj/recording-adjust-volumes +- C-; r w — cj/recording-show-active-audio +- C-; r t b/m/s — cj/recording-test-both / -mic / -monitor + +*** C-; R — restclient +- C-; R n — cj/restclient-new-buffer ; C-; R o — cj/restclient-open-file + +*** C-; s — Enclose / surround / indent +- C-; s s — cj/surround-word-or-region ; C-; s u — cj/unwrap-word-or-region +- C-; s w — cj/wrap-word-or-region ; C-; s i — cj/indent-lines-in-region-or-buffer +- C-; s d — cj/dedent-lines-in-region-or-buffer ; C-; s a — cj/append-to-lines-in-region-or-buffer +- C-; s p — cj/prepend-to-lines-in-region-or-buffer +- C-; s I — change-inner ; C-; s O — change-outer + +*** C-; t — Test runner +- C-; t r — cj/test-run-smart ; C-; t R — cj/test-run-all ; C-; t . — cj/run-test-at-point +- C-; t a — cj/test-focus-add ; C-; t b — cj/test-focus-add-this-buffer-file +- C-; t c — cj/test-focus-clear ; C-; t v — cj/test-view-focused +- C-; t L — cj/test-load-all ; C-; t t — cj/test-toggle-mode + +*** C-; v — Version control (git / forge) +- C-; v c — cj/git-clone-clipboard-url ; C-; v d — cj/goto-git-gutter-diff-hunks +- C-; v t — cj/git-timemachine ; C-; v f — forge-pull ; C-; v r — forge-list-pullreqs +- C-; v i c — cj/forge-create-issue ; C-; v i l — forge-list-issues + +*** C-; w — Whitespace +- C-; w c — cj/collapse-whitespace-line-or-region ; C-; w d — cj/delete-all-whitespace +- C-; w l — cj/delete-blank-lines-region-or-buffer ; C-; w 1 — cj/ensure-single-blank-line +- C-; w r — cj/remove-leading-trailing-whitespace ; C-; w - — cj/hyphenate-whitespace-in-region +- C-; w t — untabify ; C-; w T — tabify + +*** C-; x — Terminal (ghostel) +- C-; x t — cj/term-toggle ; C-; x N — ghostel (new) ; C-; x c — cj/term-copy-mode-dwim +- C-; x h — cj/term-tmux-history ; C-; x l — ghostel-clear-scrollback +- C-; x n — ghostel-next-prompt ; C-; x p — ghostel-previous-prompt +- C-; x q — ghostel-send-next-key + +** Appendix B — The M-S- family (18 keys) + +All bound as =M-S-<letter>= and reachable in GUI only, via the +=keyboard-compat.el= translation layer. Format: chord — command — what it does — +source module. + +- M-S-o — cj/kill-other-window — kill the other window's buffer and close it — undead-buffers.el +- M-S-m — cj/kill-all-other-buffers-and-windows — close all other windows, kill their buffers — undead-buffers.el +- M-S-y — yank-media — paste an image/media object from the clipboard — keybindings.el +- M-S-f — fontaine-set-preset — switch the font preset — font-config.el +- M-S-w — wttrin — show the weather report — weather-config.el +- M-S-e — eww — open the EWW web browser — eww-config.el +- M-S-l — cj/switch-themes — select/cycle the theme — ui-theme.el +- M-S-r — cj/elfeed-open — open the Elfeed RSS reader — elfeed-config.el +- M-S-v — cj/split-and-follow-right — split window right and move focus there — ui-navigation.el +- M-S-h — cj/split-and-follow-below — split window below and move focus there — ui-navigation.el +- M-S-t — toggle-window-split — toggle horizontal/vertical split — ui-navigation.el +- M-S-z — cj/undo-kill-buffer — reopen the most-recently-killed file buffer — ui-navigation.el +- M-S-u — winner-undo — undo the last window-configuration change — ui-navigation.el +- M-S-d — dwim-shell-commands-menu — DWIM shell-command menu on marked files — dwim-shell-config.el +- M-S-i — edit-indirect-region — edit the region in an indirect buffer — text-config.el +- M-S-c — time-zones — show the world-clock / time-zones view — chrono-tools.el +- M-S-b — calibredb — open the Calibre ebook library — calibredb-epub-config.el +- M-S-k — show-kill-ring — browse the kill ring — show-kill-ring.el + +Note: =4a1ecf64= (in-flight, reverted in Phase 0) currently leaves +=eww=/=elfeed=/=calibredb= mis-bound to =M-E=/=M-R=/=M-B=; the table lists the +intended/original =M-S-= bindings. + +** Appendix C — Console-safe single-prefix candidates (D1/D3) + +Craig's first choice (D1) is one =Control=+key prefix that works in GUI, terminal, +and the Linux console, ideally a lightly-used home-row key. Console transmittability +is the gate. Two classes of chord transmit in =TERM=linux=: + +1. =Control= + letter (ASCII 1–26). Several collide with terminal control characters + and so cannot serve as a distinct prefix — =C-i=/TAB, =C-j=/LF, =C-m=/RET, + =C-[=/ESC, =C-h=/DEL — and =C-g= (=keyboard-quit=) is sacred and excluded. +2. =Meta= + key, which the console sends as an *ESC prefix* (=M-x= = ESC then x). + This is why the Problem table above shows =M-E= working as "ESC E" in the console. + So a plain =M-<key>= prefix is console-safe too — and unlike the broken =M-S-= + family, an unshifted =M-<key>= binds directly with no =key-translation-map= in the + path. The catch is finding a free one: the Meta namespace is crowded (Appendix D + shows =M-*=, =M-+=, =M-#=, =M-P=, =M-t=, and the whole =M-g=/=M-s=/=M-e=/=M-r= + consult family taken), so a free Meta prefix would be punctuation (=M-\\=, =M-/=…), + not a letter, and it carries the usual ESC-prefix timing caveat in terminals. + +=Control= + *non-letter* punctuation (=C-;=, =C-'=, =C-.=…) does NOT transmit in the +console — the character isn't a control code. So =C-'= is a non-starter on two counts: +dead in the console like =C-;=, and already bound (=cj/flyspell-then-abbrev=, globally +at =flyspell-and-abbrev.el:253= and in =org-mode-map= at =:258=). =Control=+letter +(the table below) stays the cleanest path; a free =M-<punctuation>= is the viable +runner-up class if Craig prefers Meta. + +| Candidate | Home-row | Console-safe | Default binding | Verdict + note | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-l | yes | yes | recenter-top-bottom | TOP. Home-row, light default, | +| | | | | trivially relocated. Also | +| | | | | vertico-insert in the minibuffer | +| | | | | (selection-framework.el:42) — | +| | | | | minibuffer-local, no global | +| | | | | conflict. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-q | no | yes | quoted-insert | Strong runner-up. Very light | +| | | | | default; trivial rebind. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-o | no | yes | open-line | Strong runner-up. Light default; | +| | | | | easy rebind. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-t | no | yes | transpose-chars | Strong runner-up. Light default; | +| | | | | easy rebind. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-k | yes | yes | kill-line | Possible. Home-row, but kill-to-eol | +| | | | | is muscle memory — medium retrain | +| | | | | friction. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-s | yes | yes | cj/consult-line-or-repeat | Possible, but already a useful | +| | | | (selection-framework.el:265) | rebind; using it as a prefix | +| | | | | reverses that. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-a | yes | yes | move-beginning-of-line | Reject. Essential editing reflex. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-d | yes | yes | delete-char | Reject. Essential. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-f | yes | yes | forward-char | Reject. Essential. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-h | yes | collides (DEL) | help-command | Reject. Console DEL collision; help | +| | | | | is frequent. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-j | yes | collides (LF) | newline | Reject. LF control char; cannot | +| | | | | transmit distinctly. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-g | yes | sacred | keyboard-quit | Reject. Universal escape; never | +| | | | | reuse. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-z | no | yes | suspend-frame (live prefix; C-z F = | Reject. Already an extended prefix. | +| | | | fonts, font-config.el:300) | | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| C-' | no | no | cj/flyspell-then-abbrev | Reject. Punctuation — dead in the | +| | | | (flyspell-and-abbrev.el:253) | console like C-;; and already bound | +| | | | | (also org-mode-map :258). | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| +| M-<punct> | n/a | yes (ESC-prefix) | — (Meta namespace crowded; see | Viable runner-up class. Console-safe | +| | | | Appendix D) | via ESC-prefix, no translation | +| | | | | layer, distinct from the broken | +| | | | | M-S-. Needs a free M-punctuation | +| | | | | (M-\\, M-/); ESC-timing caveat in | +| | | | | terminals. | +|-----------+----------+------------------+-------------------------------------+--------------------------------------| + +Recommendation: =C-l= is the single best fit — the only clean home-row option (every +other home-row letter is essential, a collision, sacred, or already repurposed), +console-safe, and its default =recenter-top-bottom= is light and trivially relocated. +=C-q= / =C-o= / =C-t= are equally console-safe and lightly bound if Craig prefers to +keep all home-row defaults; they cost a right-hand reach off home row. If Craig would +rather a Meta prefix, a free =M-<punctuation>= (=M-\\=, =M-/=) is the viable runner-up +class — console-safe via ESC-prefix and free of the translation layer — at the cost of +the ESC-timing caveat. =C-'= is out (console-dead and already taken). Craig picks. + +** Appendix D — Personal keybindings set outside C-; (audit for pruning) + +Every keybinding Craig has set *outside* the =C-;= tree (Appendix A) and the =M-S-= +family (Appendix B), grouped by context. Check a box to mark that binding — or a +whole group — for removal in Phase 1. Boxes start unchecked; Craig marks them. +Inventoried 2026-06-13. Some =:bind (:map …)= package-integration maps (lsp-mode, +c-mode-base, python-ts, json-ts, outline-minor, magit-blame, quick-sdcv, cj/vc-map) +have large package-managed binding lists not enumerated here. + +- [ ] Global bindings + - [ ] C-+ — text-scale-increase — (font-config.el:306) + - [ ] C-= — text-scale-increase — (font-config.el:307) + - [ ] C-_ — text-scale-decrease — (font-config.el:308) + - [ ] C-- — text-scale-decrease — (font-config.el:309) + - [ ] C-x C-f — find-file — (keybindings.el:147) + - [ ] C-x \ — sort-lines — (keybindings.el:160) + - [ ] C-x u — undo-reminder-message — (keybindings.el:164) + - [ ] <escape> — keyboard-escape-quit — (keybindings.el:156) + - [ ] <remap> <capitalize-region> — cj/title-case-region — (custom-case.el:124) + - [ ] <remap> <kill-buffer> — cj/kill-buffer-or-bury-alive — (undead-buffers.el:55) + - [ ] <remap> <list-buffers> — ibuffer — (system-utils.el:147) + - [ ] <remap> <mouse-wheel-text-scale> — cj/disabled — (system-defaults.el:191) + - [ ] C-z — prefix map (suspend-frame replacement) — (keybindings.el:148) + - [ ] C-z F — cj/display-available-fonts — (font-config.el:300) + - [ ] C-h A — cj/local-arch-wiki-search — (help-utils.el:82) + - [ ] C-h D s — devdocs-search — (help-utils.el:40) + - [ ] C-h D b — devdocs-peruse — (help-utils.el:41) + - [ ] C-h D l — devdocs-lookup — (help-utils.el:42) + - [ ] C-h D i — devdocs-install — (help-utils.el:43) + - [ ] C-h D d — devdocs-delete — (help-utils.el:44) + - [ ] C-h D u — devdocs-update-all — (help-utils.el:45) + - [ ] C-h P — list-packages — (help-config.el:31) + - [ ] C-h i — cj/browse-info-files — (help-config.el:90) + - [ ] C-c b — cj/eval-buffer-with-confirmation-or-error-message — (system-utils.el:57) + - [ ] C-c C — cj/org-contacts-map prefix — (org-contacts-config.el:271) + - [ ] C-c d — cj/debug-config-keymap prefix — (config-utilities.el:28) + - [ ] C-c f — cj/flyspell-toggle — (flyspell-and-abbrev.el:252) + - [ ] C-c l — org-store-link — (org-config.el:58) + - [ ] C-c m — mu4e — (mail-config.el:125) + - [ ] C-c M — mouse-trap-mode — (mousetrap-mode.el:275) + - [ ] C-' — cj/flyspell-then-abbrev — (flyspell-and-abbrev.el:253) + - [ ] C-s — cj/consult-line-or-repeat — (selection-framework.el:265) + - [ ] M-* — calculator — (keybindings.el:152) + - [ ] M-+ — balance-windows — (ui-navigation.el:67) + - [ ] M-P — cj/check-for-open-work — (reconcile-open-repos.el:221) + - [ ] C-c n d — org-roam-dailies-map prefix — (org-roam-config.el:94) + - [ ] C-c n I — cj/org-roam-node-insert-immediate — (org-roam-config.el:131) +- [ ] Function keys + - [ ] <f1> — cj/dashboard-only — (dashboard-config.el:158) + - [ ] <f3> — call-last-kbd-macro — (keyboard-macros.el:131) + - [ ] C-<f3> — cj/kbd-macro-start-or-end — (keyboard-macros.el:130) + - [ ] M-<f3> — cj/save-maybe-edit-macro — (keyboard-macros.el:132) + - [ ] s-<f3> — cj/open-macros-file — (keyboard-macros.el:133) + - [ ] <f4> — cj/f4-compile-and-run — (dev-fkeys.el:535) + - [ ] C-<f4> — cj/f4-compile-only — (dev-fkeys.el:536) + - [ ] M-<f4> — cj/f4-clean-rebuild — (dev-fkeys.el:537) + - [ ] S-<f4> — recompile — (dev-fkeys.el:538) + - [ ] <f6> — cj/f6-test-runner — (dev-fkeys.el:539) + - [ ] C-<f6> — cj/f6-current-file-tests — (dev-fkeys.el:540) + - [ ] S-<f5> (Python) — cj/python-mypy — (prog-python.el:103) + - [ ] S-<f5> (Shell) — cj/shell-run-shellcheck — (prog-shell.el:98) + - [ ] S-<f5> (Go) — cj/go-staticcheck — (prog-go.el:102) + - [ ] S-<f5> (C) — cj/disabled — (prog-c.el:158) + - [ ] S-<f6> (Python) — cj/python-debug — (prog-python.el:106) + - [ ] S-<f6> (Shell) — cj/disabled — (prog-shell.el:101) + - [ ] S-<f6> (Go) — cj/go-debug — (prog-go.el:105) + - [ ] S-<f6> (C) — gdb — (prog-c.el:161) + - [ ] <f7> — cj/coverage-report — (coverage-core.el:537) + - [ ] <f8> — cj/main-agenda-display — (org-agenda-config.el:418) + - [ ] C-<f8> — cj/todo-list-single-project — (org-agenda-config.el:269) + - [ ] M-<f8> — cj/todo-list-from-this-buffer — (org-agenda-config.el:283) + - [ ] s-<f8> — cj/todo-list-all-agenda-files — (org-agenda-config.el:244) + - [ ] <f9> — cj/ai-term — (ai-term.el:920) + - [ ] C-<f9> — cj/ai-term-pick-project — (ai-term.el:921) + - [ ] M-<f9> — cj/ai-term-close — (ai-term.el:922) + - [ ] C-S-<f9> — cj/ai-term-close — (ai-term.el:923) + - [ ] <f10> — cj/music-playlist-toggle — (music-config.el:910) + - [ ] C-<f10> — cj/server-shutdown — (system-utils.el:105) + - [ ] <f12> — cj/term-toggle — (term-config.el:383) + - [ ] C-<f12> — eshell-toggle — (eshell-config.el:161) +- [ ] use-package :bind (global) + - [ ] C-c L — slime — (prog-lisp.el:151) + - [ ] C-c G — geiser-guile — (prog-lisp.el:172) + - [ ] C-h L — leetcode — (prog-training.el:35) + - [ ] C-h M — man — (help-config.el:49) + - [ ] C-h T — tldr — (help-utils.el:53) + - [ ] C-h W — wiki-summary — (help-utils.el:58) + - [ ] C-` — accent-company — (text-config.el:122) + - [ ] C-x M-f — sudo-edit — (system-utils.el:66) + - [ ] C-x g — magit-status — (vc-config.el:34) + - [ ] C-c s i — consult-yasnippet — (selection-framework.el:191) + - [ ] M-# — calendar — (chrono-tools.el:38) + - [ ] M-t — tmr-prefix-map — (chrono-tools.el:110) + - [ ] C-M-p — proced — (system-utils.el:183) +- [ ] Vertico / selection framework + - [ ] C-h C-k — free-keys — (keybindings.el:129) + - [ ] C-j (vertico-map) — vertico-next — (selection-framework.el:40) + - [ ] C-k (vertico-map) — vertico-previous — (selection-framework.el:41) + - [ ] C-l (vertico-map) — vertico-insert — (selection-framework.el:42) + - [ ] RET (vertico-map) — vertico-exit — (selection-framework.el:43) + - [ ] C-RET (vertico-map) — vertico-exit-input — (selection-framework.el:44) + - [ ] M-RET (vertico-map) — minibuffer-force-complete-and-exit — (selection-framework.el:45) + - [ ] TAB (vertico-map) — minibuffer-complete — (selection-framework.el:46) +- [ ] Consult (global) + - [ ] C-c h — consult-history — (selection-framework.el:64) + - [ ] C-x M-: — consult-complex-command — (selection-framework.el:66) + - [ ] C-x b — consult-buffer — (selection-framework.el:67) + - [ ] C-x 4 b — consult-buffer-other-window — (selection-framework.el:68) + - [ ] C-x 5 b — consult-buffer-other-frame — (selection-framework.el:69) + - [ ] C-x r b — consult-bookmark — (selection-framework.el:70) + - [ ] C-x p b — consult-project-buffer — (selection-framework.el:71) + - [ ] M-g e — consult-compile-error — (selection-framework.el:73) + - [ ] M-g f — consult-flymake — (selection-framework.el:74) + - [ ] M-g g — consult-goto-line — (selection-framework.el:75) + - [ ] M-g M-g — consult-goto-line — (selection-framework.el:76) + - [ ] M-g o — consult-outline — (selection-framework.el:77) + - [ ] M-g m — consult-mark — (selection-framework.el:78) + - [ ] M-g k — consult-global-mark — (selection-framework.el:79) + - [ ] M-g i — consult-imenu — (selection-framework.el:80) + - [ ] M-g I — consult-imenu-multi — (selection-framework.el:81) + - [ ] M-s d — consult-find — (selection-framework.el:83) + - [ ] M-s D — consult-locate — (selection-framework.el:84) + - [ ] M-s g — consult-grep — (selection-framework.el:85) + - [ ] M-s G — consult-git-grep — (selection-framework.el:86) + - [ ] M-s r — consult-ripgrep — (selection-framework.el:87) + - [ ] M-s l — consult-line — (selection-framework.el:88) + - [ ] M-s L — consult-line-multi — (selection-framework.el:89) + - [ ] M-s k — consult-keep-lines — (selection-framework.el:90) + - [ ] M-s u — consult-focus-lines — (selection-framework.el:91) + - [ ] M-s e — consult-isearch-history — (selection-framework.el:93) +- [ ] Isearch / minibuffer search + - [ ] M-e (isearch-mode-map) — consult-isearch-history — (selection-framework.el:95) + - [ ] M-s e (isearch-mode-map) — consult-isearch-history — (selection-framework.el:96) + - [ ] M-s l (isearch-mode-map) — consult-line — (selection-framework.el:97) + - [ ] M-s L (isearch-mode-map) — consult-line-multi — (selection-framework.el:98) + - [ ] M-s (minibuffer-local-map) — consult-history — (selection-framework.el:101) + - [ ] M-r (minibuffer-local-map) — consult-history — (selection-framework.el:102) +- [ ] PDF view mode + - [ ] M — pdf-view-midnight-minor-mode — (pdf-config.el:49) + - [ ] m — bookmark-set — (pdf-config.el:50) + - [ ] C-= — pdf-view-enlarge — (pdf-config.el:51) + - [ ] C-- — pdf-view-shrink — (pdf-config.el:52) + - [ ] C-c l — org-store-link — (pdf-config.el:53) + - [ ] z — cj/open-file-with-command zathura — (pdf-config.el:54) + - [ ] j — image-next-line — (pdf-config.el:56) + - [ ] k — image-previous-line — (pdf-config.el:57) + - [ ] <down> — image-next-line — (pdf-config.el:58) + - [ ] <up> — image-previous-line — (pdf-config.el:59) + - [ ] i — cj/org-noter-insert-note-dwim — (pdf-config.el:61) + - [ ] C-<down> — pdf-view-next-page-command + image-bob — (pdf-config.el:63) + - [ ] C-<up> — pdf-view-previous-page-command + image-eob — (pdf-config.el:65) +- [ ] Ediff mode + - [ ] j (ediff-mode-map) — ediff-next-difference — (diff-config.el:54) + - [ ] k (ediff-mode-map) — ediff-previous-difference — (diff-config.el:55) +- [ ] Org / org-related + - [ ] C-' (org-mode-map) — cj/flyspell-then-abbrev — (flyspell-and-abbrev.el:258) + - [ ] S-<mouse-1> (org-mouse-map) — cj/org-follow-link-at-mouse-same-window — (org-config.el:338) + - [ ] <mouse-3> (org-mouse-map) — cj/org-follow-link-at-mouse-same-window — (org-config.el:339) +- [ ] Dired / dirvish + - [ ] G (dired-mode-map) — cj/deadgrep-here — (prog-general.el:277) + - [ ] M-D (dirvish-mode-map) — dwim-shell-commands-menu — (dwim-shell-config.el:934) + - [ ] + (dirvish-mode-map) — cj/music-add-dired-selection — (music-config.el:597) + - [ ] T (dired/dirvish-mode-map) — cj/transcribe-media-at-point — (transcription-config.el:463/467) + - [ ] <f11> (dirvish-mode-map) — dirvish-side — (dirvish-config.el:481) +- [ ] Shell / terminal + - [ ] C-r (eshell-mode-map) — cj/eshell-history-search — (eshell-config.el:202) + - [ ] <up> (eshell-hist-mode-map) — previous-line — (eshell-config.el:99) + - [ ] <down> (eshell-hist-mode-map) — next-line — (eshell-config.el:100) +- [ ] Ghostel terminal + - [ ] <f9> (ghostel-mode-map) — cj/ai-term — (ai-term.el:932) + - [ ] C-<f9> (ghostel-mode-map) — cj/ai-term-pick-project — (ai-term.el:933) + - [ ] M-<f9> (ghostel-mode-map) — cj/ai-term-close — (ai-term.el:934) + - [ ] C-S-<f9> (ghostel-mode-map) — cj/ai-term-close — (ai-term.el:935) + - [ ] <f12> (ghostel-mode-map) — cj/term-toggle — (term-config.el:415) + - [ ] C-SPC (ghostel-mode-map) — cj/term-send-C-SPC — (term-config.el:416) +- [ ] Version control / magit + - [ ] M-g (git-commit-mode-map) — gptel-magit-generate-message — (ai-config.el:498) + - [ ] N (magit-mode-map) — forge-pull — (vc-config.el:125) +- [ ] Help / docs modes + - [ ] b (devdocs-mode-map) — devdocs-go-back — (help-utils.el:47) + - [ ] f (devdocs-mode-map) — devdocs-go-forward — (help-utils.el:48) +- [ ] Org-roam dailies + - [ ] Y (org-roam-dailies-map) — org-roam-dailies-capture-yesterday — (org-roam-config.el:92) + - [ ] T (org-roam-dailies-map) — org-roam-dailies-capture-tomorrow — (org-roam-config.el:93) +- [ ] Other mode maps + - [ ] C-<return> (slack-message-compose-buffer-mode-map) — slack-message-send-from-buffer — (slack-config.el:297) + - [ ] q (dashboard-mode-map) — nil (unbound) — (dashboard-config.el:223) + - [ ] q (show-kill-ring-mode-map) — show-kill-ring-exit — (show-kill-ring.el:67) + - [ ] <f2> (markdown-mode-map) — markdown-preview — (markdown-config.el:24) + - [ ] <remap> <shell-command> — dwim-shell-command — (dwim-shell-config.el:204) +- [ ] key-translation-map / input-decode-map + - [ ] input-decode-map ESC [ A — [up] — (keyboard-compat.el:109) + - [ ] input-decode-map ESC [ B — [down] — (keyboard-compat.el:110) + - [ ] input-decode-map ESC [ C — [right] — (keyboard-compat.el:111) + - [ ] input-decode-map ESC [ D — [left] — (keyboard-compat.el:112) + - [ ] input-decode-map ESC O A — [up] — (keyboard-compat.el:115) + - [ ] input-decode-map ESC O B — [down] — (keyboard-compat.el:116) + - [ ] input-decode-map ESC O C — [right] — (keyboard-compat.el:117) + - [ ] input-decode-map ESC O D — [left] — (keyboard-compat.el:118) +- [ ] Jumper + - [ ] jumper-prefix-key (computed at runtime) — jumper-map — (jumper.el:270) [computed key — exact binding depends on the variable value] + +Note: the global =M-S-<letter>= family is intentionally excluded (Appendix B). The +arrow-key =input-decode-map= entries are the terminal setup the spec keeps (not the +translation block being retired). =C-l= appears only minibuffer-local in +=vertico-map=, consistent with Appendix C. + +* Review and iteration history +** 2026-06-12 Fri @ 11:21:56 -0500 — Craig Jennings — author +- What: initial draft. Problem, three-context analysis, the 4a1ecf64 regression + as motivating evidence, the one-map/two-prefix design, alternatives, five + open decisions, phased plan, acceptance criteria, readiness dimensions, and the + full C-; tree + M-S- family appendices. +- Why: a touched key family broke in GUI and is dead in console; the fix path is + cross-cutting (18 keys, a translation layer to retire, a console-safety + architecture) with real trade-offs, so it clears the spec bar. +- Artifacts: docs/design/keybinding-console-safety-spec.org; supersedes the + pre-template draft docs/design/keybinding-console-safety.org. +** 2026-06-12 Fri @ 18:30:30 -0500 — Craig Jennings — review response +- What: processed Craig's four review comments. Recorded his first-choice + direction — one console-safe =Control=+key prefix used everywhere (single-prefix + primary; the two-prefix design is now the documented fallback) — in the Summary, + Design, and D1. Added Appendix C, the console-safe single-prefix candidate table + (standout =C-l=; runners-up =C-q=/=C-o=/=C-t=). Reframed D3 around that pick. + Named the flawed test (=tests/test-launcher-meta-shift-keys.el=) and quoted its + =key-binding=-only assertion in Phase 0. Recorded Craig's decision that the + launcher apps (=eww=/=elfeed=/=calibredb=/=wttrin=) get new keys under a launcher + sub-prefix, not =M-x= (D2/D5, Phases 0/2), with =C-; a t= (=cj/toggle-gptel=, + =ai-config.el:541=) flagged as freeable space. Reserved =C-; L= for Pearl in + Appendix A and D5. +- Why: Craig's review shifted the architecture from two-prefix to a single unified + console-safe prefix and resolved the apps disposition; the spec had to carry the + candidate data he asked for and reflect the choices through the phases. +- Open: the specific prefix (Appendix C), the window and launcher sub-prefix + letters (D5) remain Craig's picks. D1–D5 still State: proposed pending those. +** 2026-06-12 Fri @ 18:43:25 -0500 — Craig Jennings — decisions-as-TODO convention +- What: switched the Decisions section to org TODO tasks. Each decision is =** TODO + Dn=, flips to =DONE= when Craig agrees, stays TODO with a =*** Discussion= child + thread when not. Added a =[0/5]= statistics cookie and a gate: spec Status cannot + reach =ready= while any decision is TODO. Current status: all 5 TODO (none fully + agreed — D1 awaits the prefix lock, D2 unreviewed, D3 awaits the prefix pick, D4 + awaits the A/B pick, D5's apps half agreed but both sub-prefix letters open). +- Why: replaces the inline =State: proposed/accepted= field with an org-native, + agenda-visible task + discussion-thread workflow, and makes the + all-decisions-resolved gate explicit and machine-checkable. +** 2026-06-13 Sat @ 00:18:09 -0500 — Craig Jennings — Path 2 restructure + audit appendix +- What: processed three more review comments. Restructured the phases to Path 2: + Phase 0 is a pure revert that unblocks the held push; Phase 1 prunes forgotten + keybindings (Appendix D); Phase 2 is the consolidation (migrate the common set, + retire the translation block) — the primary deliverable; Phase 3 (bind the + console-safe prefix) is now OPTIONAL and deferred until Craig picks the key. The + Decisions gate split accordingly: D2/D4/D5 gate the primary work, D1/D3 gate only + Phase 3, so the work runs to the prefix-assignment point and stops there. + Corrected Appendix C's premise (Meta transmits in the console as an ESC prefix, so + =M-<punctuation>= is a viable console-safe class); added the =C-'= row (rejected — + console-dead and already bound to flyspell) and the =M-<punct>= row. Added Appendix + D: every personal keybinding set outside the =C-;= tree and the =M-S-= family, as a + checkbox pruning tree (~190 bindings, inventoried by a read-only sweep). +- Why: Craig pivoted to landing the consolidation first and treating the + console-safe prefix as a later switch-on, and wanted a one-time audit of his + set-and-forgotten keybindings while the keymap work was open. +- Open: D1–D5 still TODO; the prefix (D1/D3) is now non-blocking. Phase 0 revert + pending so the push can proceed. diff --git a/docs/design/messenger-unification-spec.org b/docs/design/messenger-unification-spec.org new file mode 100644 index 00000000..7e878030 --- /dev/null +++ b/docs/design/messenger-unification-spec.org @@ -0,0 +1,208 @@ +#+TITLE: Messenger Unification — Shared Window Placement and Key Conventions +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-06-11 +#+STATUS: Draft — decisions 1-9 settled (Craig, 2026-06-11/12); held open for further ideas before Ready + +* Problem + +Three messengers live in this config — Signel (Signal), telega (Telegram), and +emacs-slack — and each invented its own window placement and its own send/cancel +chords. Switching between them means re-learning the same two gestures three +times. The goal: chat windows rise from the bottom of the frame under one rule, +C-c C-c acts as the okay button, C-c C-k cancels, and a messenger joins the +convention with one registration call instead of bespoke config. The same +registration should carry a shared verb set (attach now; next-unread, +jump-to-chat later) so future chords land everywhere at once. + +* Current State (surveyed 2026-06-11) + +** Signel (fork at ~/code/signel, =signel-chat-mode=) + +- Placement: bottom 30% via a private =display-buffer-alist= entry + (=modules/signal-config.el:184=, matches =\`\*Signel: =). +- Keys (bound in the fork, =signel.el:493=): RET and C-c C-c send + (=signel--send-input=), C-c C-k clears input (=signel--cancel-input=), + C-c C-a attaches a file. +- Verdict: already the proposed convention. Becomes the reference backend. + +** telega (=telega-chat-mode=) + +- Placement: none configured — falls to display-buffer's defaults. +- Keys (upstream =telega-chat.el=): RET sends + (=telega-chatbuf-newline-or-input-send=, line 1796); C-c C-k already cancels + (=telega-chatbuf-cancel-dwim=, line 1790 — also on C-M-c and ESC ESC); + C-c C-c is taken by =telega-chatbuf-filter-cancel= (line 1832). +- Verdict: half-conformant. Cancel matches; confirm needs the chord, which + shadows filter-cancel (decision 4). + +** emacs-slack + +- Placement: room buffers route through =cj/slack--display-buffer= + (=modules/slack-config.el:105=) — reuse / some-window / pop-up, deliberately + landing beside current work in a split. +- Keys: compose/edit buffers derive from =slack-edit-message-mode=, which + already binds C-c C-c send (=slack-message-send-from-buffer=) and C-c C-k + cancel (=slack-message-cancel-edit=) upstream (=slack-message-editor.el:46=). + Config adds C-<return> send (=slack-config.el:297=). Room buffers are + read-only; composing happens in the separate compose buffer. +- Verdict: keys already conform in compose. The open question is placement + (decision 5). + +** ERC + +Present (=modules/erc-config.el=) but out of scope for v1; joins later with one +registration call (decision 7). + +* Design + +Two cooperating mechanisms in one new library, =modules/cj-messenger-lib.el=. +Each messenger's =*-config.el= makes a single registration call; the library +owns the display rule and the keymap. + +** The registry + +#+begin_src elisp +(cj/messenger-register 'signel + :buffer-match "\\`\\*Signel: " ; regexp, or a list of major modes + :chat-modes '(signel-chat-mode) ; hooks that enable the minor mode + :confirm #'signel--send-input + :cancel #'signel--cancel-input + :attach #'signel-attach-file) +#+end_src + +- =:buffer-match= feeds the window-placement predicate. +- =:chat-modes= names the major-mode hooks where =cj/messenger-mode= turns on. +- The verb keys (=:confirm=, =:cancel=, =:attach=, future verbs) populate + buffer-local dispatch variables when the minor mode enables. A nil verb means + "not supported here" — the dispatcher reports it instead of erroring. + +** Window placement + +One =display-buffer-alist= entry, installed by the library: + +- Condition: =cj/messenger-buffer-p= — true when the buffer matches any + registered =:buffer-match=. +- Action: =(display-buffer-reuse-window display-buffer-at-bottom)= with + =window-height= from a shared defcustom =cj/messenger-window-height= + (default 0.3) and =reusable-frames nil= — the exact shape signel uses today. + Signel's private entry in =signal-config.el= is removed in favor of this one. +- A registration may override the height for one backend if a real need + appears; the default is the convention. + +Deliberately a normal bottom window (=display-buffer-at-bottom=), not a side +window: side windows are atomic, refuse splits, and fight other display +commands. The signel entry has proven the at-bottom shape in daily use. The +geometry capture/replay helpers in =cj-window-toggle-lib.el= can be layered on +later if remembered sizing is wanted (out of scope for v1). + +** The minor mode and dispatch + +=cj/messenger-mode=, a buffer-local minor mode whose keymap outranks the major +mode's: + +- C-c C-c → =cj/messenger-confirm= +- C-c C-k → =cj/messenger-cancel= +- C-c C-a → =cj/messenger-attach= + +Each command funcalls its buffer-local dispatch variable +(=cj/messenger--confirm-fn= etc.), set from the registry when the mode enables +via the registered =:chat-modes= hooks. Unset verb → =user-error= naming the +messenger and the missing verb. RET is untouched — every backend keeps its +native RET behavior; the convention adds chords, it never removes keys. + +This is the established Emacs-wide C-c C-c / C-c C-k convention (org-capture, +message-mode, with-editor/git-commit), so the muscle memory transfers in both +directions. + +** Backend wiring (per messenger, in its existing config module) + +- Smoke (the ground-up signel replacement at =~/code/smoke=, decided + 2026-06-12): implements the conventions natively from day one — bottom + drawer, dismiss-preserving C-c C-k per decision 3, unread tracking feeding + jump-to-unread — per its architecture spec. Signel remains the running + reference until smoke reaches parity; =signal-config.el='s private display + entry retires at the switchover. Registration stays one call; smoke is the + reference backend. (Tracked in the smoke project's todo.) +- telega: =:confirm #'telega-chatbuf-input-send=, =:cancel= wraps + =telega-chatbuf-cancel-dwim= (decision 3 ladder), =:buffer-match + '(telega-chat-mode)=. +- Slack: compose modes get the minor mode for uniformity (shadowing upstream's + identical bindings — a no-op in practice); room-buffer placement per + decision 5. + +* Decisions + +1. Placement engine is =display-buffer-at-bottom= in a normal window, shared + height defcustom 0.3. Proven by signel. (Proposed.) +2. One registry call per messenger is the entire integration surface; the + library owns the display rule and keymap. (Proposed.) +3. Cancel semantics (Craig, 2026-06-11; superseded 2026-06-12): C-c C-k + dismisses, never destroys — (a) backend pending state (telega + edit/reply/forward) → the backend's own dwim cancel; (b) otherwise → + =quit-window=. Typed drafts are not cancel's business: input survives the + burial and is waiting at the prompt on the next visit (signel's + pending-input machinery, generalized). Where a backend wants an explicit + clear-draft, it kills to the kill-ring so the text is recoverable. + /Superseded version (2026-06-11):/ a three-rung ladder whose first rung + cleared typed input before a second press closed the window — dropped + because the first press destroyed text while dismissing nothing, and it + broke the org-capture/git-commit muscle memory where C-c C-k means + "abandon and dismiss" in one press. +4. Telega shadow accepted (Craig, 2026-06-11): the minor mode's C-c C-c hides + =telega-chatbuf-filter-cancel= in telega chats. Craig doesn't use chat + filters; the command stays reachable via M-x and the C-c / filter flow. +5. Slack joins the bottom convention (Craig, 2026-06-11): room buffers move + from the beside-work split to the shared bottom rule; =cj/slack--display-buffer= + is retired in favor of the library's placement entry. Compose buffers + conform via the minor mode as planned. +6. v1 verb set: confirm, cancel, attach. Revised 2026-06-12 (Craig): + jump-to-unread is promoted from candidate to committed verb — a global + chord that raises the most recent unread conversation in the bottom + window, completing the pull flow (toast → chord → chat). Backends supply + an unread source at registration (=:unread=). Still candidates: + next/prev-unread, jump-to-chat picker, mark-read-and-bury. + Addendum from the 2026-06 config audit: the notification path is the same + unification shape on the inbound side — four messengers, four mechanisms + (signel hardened with truncation/sound-gating/fallback; slack unhardened; + ERC double-notifying; telega notifying not at all). A shared + =cj/messenger-notify= (title prefix, truncation, sound flag, + script-with-fallback) belongs in this library, registered per backend like + the verbs. Details in the audit's messengers findings in =todo.org=. +7. ERC deferred; one registration call when wanted. (Proposed.) Google Voice + (SMS + dialer) is a future backend candidate behind its own [#C] + investigation task in =todo.org= — if it goes, it joins through the same + registration surface. +8. RET is never rebound or removed. (Proposed.) +9. No auto-open, ever (Craig, 2026-06-12): no backend claims the bottom slot + unbidden — awareness is pull-based (hardened notifications + + jump-to-unread). =signel-auto-open-buffer= stays nil and equivalent knobs + in other backends are configured off. The drawer is summoned by the user, + not by traffic. + +* Phases + +- *Phase 1 — library + signel (reference backend).* =cj-messenger-lib.el= + (registry, predicate, display rule, minor mode, dispatchers), TDD: ERT over + the pure parts (registration shape, buffer matching, dispatch with stub + fns, nil-verb error). Wire signel; retire its private display entry. +- *Phase 2 — telega.* Registration + the decision-3 cancel ladder; audit what + else the minor-mode map hides in =telega-chat-mode-map=. +- *Phase 3 — slack.* Per decision 5; conform compose buffers either way. +- *Phase 4 — shared verbs + ERC.* jump-to-unread first (committed per the + decision-6 revision), then remaining decision-6 candidates, each verb + landing in every backend at once. ERC joins when wanted. + +Each phase ends with a manual-test checklist filed under the +"Manual testing and validation" parent in =todo.org= (placement, each chord, +the not-supported message), per the verification discipline. + +* Risks + +- Minor-mode shadowing in telega beyond C-c C-c — Phase 2 audits the C-c + prefix in =telega-chat-mode-map= before shipping. +- Slack's many buffer modes: room buffers derive from =slack-buffer-mode=, + compose from =slack-edit-message-mode= — =:buffer-match= must name the right + ancestors or the placement rule over- or under-matches. +- Live-daemon rollout: the display-buffer-alist swap and mode hooks need a + module reload plus re-opening existing chat buffers (already-open buffers + won't have the minor mode until their mode hook reruns). diff --git a/docs/design/module-inventory.org b/docs/design/module-inventory.org index 385bdbd5..2d4baf81 100644 --- a/docs/design/module-inventory.org +++ b/docs/design/module-inventory.org @@ -220,7 +220,7 @@ owns the intentional end-of-startup buffer-bury timer. | Module | Layer | Cat | Current | Target | Runtime requires | Top-level side effects | Direct load | |--------+-------+-----+---------+--------+------------------+------------------------+-------------| -| =linear-config= | 3 | D/P | eager | command | system-lib | package config | yes | +| =pearl-config= | 3 | D/P | eager | command | system-lib | package config | yes | | =local-repository= | 4 | O/D/P | eager | command | elpa-mirror | none | yes | | =lorem-optimum= | 4 | O/L | eager | command | cl-lib | none | yes | | =mail-config= | 3 | D/P | eager | command | user-constants, system-lib, mu4e-attachments, keybindings | cj/email-map under cj/custom-keymap, add-hook, 2 advice, 1 global key | yes | diff --git a/docs/design/signal-client.org b/docs/design/signal-client.org index 24503ec0..ef946b80 100644 --- a/docs/design/signal-client.org +++ b/docs/design/signal-client.org @@ -226,3 +226,25 @@ Notification-slice forward-flag: the existing Design notes route notifications t ** Readiness rubric *Ready* (2026-05-27 spec-review). All three Codex blockers folded in (Architecture additions); the final sync/async decision resolved as pre-warm + bounded block; three minor caveats stated for build time, none blocking. Implementation order follows the Pieces-to-build list — the JSON-RPC result-dispatch fork edit is step 1, everything else builds on it. + +* Notification slice (spec addendum — 2026-06-11) + +Resolves the four details the forward-flag in Scope summary required before this slice starts. Craig accepted all four recommendations 2026-06-11. + +** The four decisions + +1. *Command shape.* =notify info "Signal: <sender>" "<body>"=, launched via =start-process= so the receive filter never blocks on the script. Type =info= (blue icon, confident tone). No =--persist=: messages arrive as a stream and persistent toasts pile up; a missed toast is still in the chat buffer, unlike an alarm. Sound follows =cj/signel-notify-sound= (defcustom, default nil): pass =--silent= unless it is non-nil. +2. *Fallback when =notify= is missing.* Warn once at module load via =cj/executable-find-or-warn= (the established external-tool pattern), and at runtime fall back to the fork's =notifications-notify= call so an incoming message is never silently dropped on a machine without the script. +3. *Body truncation.* Collapse whitespace runs (including newlines) to single spaces and truncate to 120 characters with an ellipsis. Long messages flood the toast and the daemon clips unpredictably; the full text is always in the chat buffer. +4. *Verbatim text.* Show the (truncated) message text verbatim. Same accept-and-state stance as the =*signel-log*= caveat — single-user local machine. Revisit if notifications ever route off-machine. + +** Wiring architecture + +The fork stays generic; the policy lives in =signal-config.el=: + +- *Fork edit* — =signel.el= gains =signel-notify-function= (defvar, default =signel--notify-default= which preserves today's bare =notifications-notify= behavior). =signel--handle-receive='s notify block becomes =(funcall signel-notify-function chat-id sender notify-body)= — chat-id added so a custom function can make per-chat decisions. The fork never references cj/ symbols and remains loadable standalone. +- *cj layer* — =cj/signel--notify= (set as =signel-notify-function= in the use-package =:config=) gates on =cj/signal--should-notify-p= (the existing suppression predicate), formats the body via a pure helper (=cj/signal--format-notify-body=: whitespace collapse + 120-char truncation), and routes: =notify= script when =executable-find= sees it, =notifications-notify= fallback otherwise. + +** Testing + +ERT, no live account: the body formatter (Normal/Boundary — passthrough, newline collapse, exact-limit, over-limit + ellipsis, empty, unicode); =cj/signel--notify= routing (suppressed when the predicate says no; script path args including =--silent= by default and its absence when sound is enabled; fallback path when the script is missing); the fork dispatch (a text message calls =signel-notify-function= with chat-id/sender/body; sticker → "[Sticker]"; attachment → "[Attachment]"; default function preserved). Manual: a real incoming message raises the toast through the script; no toast while viewing that chat in a focused frame. diff --git a/docs/design/theme-studio-color-harmony.org b/docs/design/theme-studio-color-harmony.org new file mode 100644 index 00000000..b07ccb42 --- /dev/null +++ b/docs/design/theme-studio-color-harmony.org @@ -0,0 +1,77 @@ +#+TITLE: Theme-Studio Color Harmony: the OKLCH Method +#+DATE: 2026-06-10 + +* Summary + +Color harmony in a theme palette is mostly calculable. Work in OKLCH, borrow the hue from a semantic accent, fix lightness and chroma across a tier, and let three hard constraints bound the free dials: contrast (WCAG, with APCA as a diagnostic), ΔE separation between palette entries, and the sRGB gamut. What remains for taste is small and deliberate: which hues carry which semantic roles, and how warm or cool the whole page should sit. + +This document captures the method worked out on 2026-06-09. The ramp generator and background-contrast safety shipped in theme-studio implement the first two applications (see [[file:../theme-studio-palette-ramps-spec.org][the palette-ramps spec]]); harmonic fill remains a future feature. + +* Why OKLCH + +OKLCH is a perceptually uniform color space: equal numeric steps read as equal visual steps. Its three axes separate the jobs cleanly. + +- L (lightness, 0-1) carries legibility. Contrast against a background is almost entirely an L question. +- C (chroma) carries intensity. High C shouts, low C recedes. +- H (hue angle) carries identity. Two colors with the same H read as kin regardless of how light or saturated they are. + +HSL and HSV fail at exactly this: their lightness axis is not perceptual (yellow at HSL L=0.5 is far brighter than blue at L=0.5), so "step the lightness evenly" produces uneven, muddy ramps. Stepping OKLCH-L produces even ladders by construction. + +* The method + +The recipe for a harmonious tier of colors: + +1. Borrow the hue. Take H from a semantic accent already in the palette (the keyword blue, the string green). New colors inherit identity from colors the theme already committed to, so nothing arrives as a stranger. +2. Fix L and C across the tier. Every member of a tier (all the dim background tints, all the bright text accents) shares one lightness and one chroma. Hue varies; weight does not. This is what makes a row of chips read as one family. +3. Let the constraints bound the dials. The free choices left after steps 1-2 are checked, not felt: + - Contrast: the tier's L must clear the WCAG target against whatever it pairs with (text on bg, or bg under text). APCA Lc is a useful diagnostic alongside, since it models polarity and font weight. + - ΔE separation: two palette entries closer than the just-noticeable threshold are duplicates in disguise. The tool's too-similar warning enforces this. + - sRGB gamut: not every OKLCH point is displayable. Clamp back into gamut and surface the clamp, because a clamped step has silently changed its C or L and may have left its tier. + +Harmony, in this framing, is structural: shared hue within a ramp, shared L/C within a tier, even spacing between steps. It is not a mystery of taste; taste picks the hues and the overall register, arithmetic does the rest. + +* Terminology + +The whole family generated from one base is a ramp (or tonal scale). Darker steps are shades, lighter steps are tints, gray-mixed variants are tones. "Ramp" or "scale" is the precise word for the family; "shades" names only the dark half. + +* Worked example: the background-tint tier + +The problem: per-hue dim backgrounds (a red-tinted bg for errors, a green-tinted bg for diff additions) that stay readable under normal text. + +The recipe applied: + +- Borrow H from each semantic accent (the error red, the diff green). +- Fix L ≈ 0.28 and C ≈ 0.045 across the tier. + +L ≈ 0.28 keeps the tint dim enough that light foregrounds clear AA over it. C ≈ 0.045 is enough chroma to read the hue ("this block is reddish") without the background competing with the text. Each accent hue dropped into that fixed L/C slot yields a dim, readable, hue-identified background, and the whole tier reads as one system because every member carries the same weight. + +* The fg-vs-bg role split + +A palette color is built for one side of the text/background divide, and the sides want opposite settings: + +- Text accents: bright (high L against a dark theme) and chromatic (C high enough to carry identity at small sizes). Legibility comes from the L gap to the background. +- Background tints: dim and low-chroma (the fixed slot above). A background's job is to mark a region while every foreground stays readable on it. + +Reusing a text accent as a background (or the reverse) is the classic mistake this split prevents; the dupre diff-refine-changed legibility bug (bright gold as a background under near-white text, ratio ~1.35) is exactly that failure. + +* The worst-case background problem + +A background-over-text effect (region, hl-line, highlight, lazy-highlight, isearch) does not pair with one foreground. Any syntax color can land inside a selection, so the background must stay readable under every foreground that can appear on it. The single-pair contrast number is the wrong question. + +The right question: define the face's foreground set (the distinct syntax hexes plus the default fg), and rate the background by its floor, the minimum contrast over that set. The limiting foreground (the argmin) names which color caps you. From the floor follows L_max: at a chosen hue and chroma, the lightest background whose floor still clears the target. The usable background lightness is capped by the darkest or nearest foreground in the set, not by the average. + +This shipped in theme-studio: the five covered overlay faces show the worst-case floor and name the limiting foreground, and the OKLCH picker shades the lightness band that is too light for the selected face (the lMax ceiling). Contracts and defaults live in the [[file:../theme-studio-palette-ramps-spec.org][ramps spec]]. + +* Ramp generation (shipped) + +From one base color, the generator holds H, steps L by a fixed delta per stop (default 0.08), and eases C quadratically toward zero at the extremes (default 0.5 at the farthest step), clamping each step into sRGB with a visible badge on clamp. Defaults: 2 steps each direction, named base+1/+2/-1/-2 from the source swatch. The chroma ease matters: a near-white or near-black step carries almost no color, and holding C flat out there just produces clamping, not color. + +In the current tool the ramp lives in the color-families view: each hue column has a count control that regenerates the family as base ±N. + +* Harmonic fill (future) + +The unshipped second application: from a few chosen colors (say the slate-blue accent plus the bg), generate a table of harmonic candidates to fill the missing palette slots. Hue-angle schemes (complementary, split-complementary, triadic, analogous) applied at matched L/C give candidate hues; the same three constraints (contrast, ΔE, gamut) filter them. The designer picks from a table of pre-validated candidates instead of free-wheeling in a picker. Tracked as vNext in the ramps spec. + +* What stays taste + +The method does not pick: which hue means "error" versus "string", how warm the ground should be, whether the theme reads austere or lush. Those are the design. Everything downstream of those calls (the ramp steps, the tint weights, the safe lightness band) is arithmetic the tool now does. diff --git a/docs/design/theme-studio-face-rules.org b/docs/design/theme-studio-face-rules.org new file mode 100644 index 00000000..4eb3e1b3 --- /dev/null +++ b/docs/design/theme-studio-face-rules.org @@ -0,0 +1,47 @@ +#+TITLE: theme-studio face rules +#+DATE: 2026-06-09 + +Two kinds of rules govern a theme's face structure. They are different in kind and must be kept separate: Design Rules are the designer's taste and may change per theme; Fidelity Rules come from the principles and never change. A face's final structure is its defface baseline (Fidelity), with Design Rules applied deliberately on top. + +* Design Rules (personal, optional, per-theme) + +Aesthetic choices the designer makes. They override package/Emacs defaults on purpose and are applied consistently across a whole face family. They can change from theme to theme. The tool should let the designer declare them and flag where the theme breaks one (these are not bugs — they are the rule being enforced). + +Structural only (weight/slant/underline/box/overline/height). Color is the palette, decided separately. + +** D1 — Headings and titles are bold + +Every heading/title face carries =:weight bold=, overriding per-package size-only or plain conventions: =org-level-*=, =shr-h1=..=shr-h6=, =magit-*-heading=, =*-title=, =org-document-title=, =dashboard-heading=, =telega-*-title= / =telega-*-heading=, etc. + +Open question for dupre: does the rule mean *all* headings bold, or *headings get emphasis via bold OR descending size*? org-level-2..8 use size, not weight. + +dupre faces that break D1 (heading/title but not bold): +- size-based (intentional? — org distinguishes levels by height): org-level-2, org-level-3, org-level-4, org-level-5, org-level-6, org-level-7, org-level-8 +- genuinely plain (no bold, no height): magit-blame-heading, magit-diff-hunk-heading, telega-msg-heading, telega-describe-subsection-title, telega-secret-title + +** D2 — Hyperlinks are underlined + +Every hyperlink face carries =:underline=, applied across packages: =link=, =org-link=, =shr-link=, =shr-selected-link=, =mu4e-link-face=, =telega-link*=, etc. (Symlinks and link-count faces are not hyperlinks and are exempt.) + +dupre faces that break D2 (hyperlink but not underlined): +- telega-link, telega-link-preview-sitename, telega-link-preview-title, telega-webpage-chat-link + +* Fidelity Rules (principle-derived, mandatory, theme-independent) + +Correctness and honesty invariants. They do not change between themes. A violation is a bug, not a preference. + +** F1 — Preview only what the theme controls + +Every element a preview draws must correspond to a real face the generated theme exports. No hardcoded decoration that implies theme control (this is why the mode-line box became a real =:box= attribute instead of a painted-on bevel, and why the fg/bg contrast cell must rate the face's own pair). Representational stand-ins are allowed only for theme-controlled *colors* whose shape/presence Emacs controls elsewhere — e.g. the cursor drawn as a box (the color is the =cursor= face; the shape is =cursor-type=), the fringe indicator (the color is the =fringe= face; the arrow's presence is truncation state). + +** F2 — Render the way Emacs renders + +A face is drawn the way Emacs would draw it. Overlay-style faces (region, highlight, isearch, lazy-highlight) merge like Emacs: the background applies and the foreground falls through to the underlying syntax colors unless the face sets its own. The block cursor sits on a glyph in the frame background over the cursor color. Every modeled attribute (weight/slant/underline/strike/box/height) actually renders, in both the table preview and the live buffer. + +** F3 — Preserve each face's defface structural baseline + +A face's own defface structural attributes (weight/slant/underline/box/overline/height/inherit) carry through into the theme's default for that face, except where a Design Rule deliberately overrides. An accidental drop — e.g. replacing =:inherit link= with a bare foreground and losing the underline — is a bug. For Emacs's built-in faces the baseline is verified against =emacs -Q= (error/warning/success bold; link, lazy-highlight, show-paren-match underline); for package faces, against the package's defface source. + +** F4 — Reference only real faces + +Every face the theme sets or previews must exist in Emacs. A face the theme defines that no package defines (a typo, a renamed/obsolete face) controls nothing and shows a phantom sample in the preview; it is removed. (This took out 11 dead mu4e faces.) diff --git a/docs/design/theme-studio-package-faces-spec.org b/docs/design/theme-studio-package-faces-spec.org new file mode 100644 index 00000000..7f00b327 --- /dev/null +++ b/docs/design/theme-studio-package-faces-spec.org @@ -0,0 +1,586 @@ +#+TITLE: theme-studio — package faces (tier 3), starting with org-mode +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-07 + +* Status + +Spec / Craig's first-round answers folded in (2026-06-07). Proposes a third tier +for the theme-studio (scripts/theme-studio/) that lets a theme colorize +package-specific faces, built one application at a time. v1 apps: org-mode +(incl. org-agenda), magit, elfeed. Codex review incorporated (2026-06-07): added +implementation phases, acceptance criteria, the package-face inventory source +(hybrid, split), and state/export semantics. Rubric now =Ready=. +All opens resolved (Craig, 2026-06-07/08): inheritance is modeled (show each +face's resolved color in the table + preview, override what looks bad); inventory +is hybrid-and-split (org/magit/elfeed bespoke first, generated all-package +inventory as a later phase); the custom color picker is built after tier 3. +Implementation tasks live in =todo.org=. + +* Background — the three tiers + +The theme-studio already models two tiers of faces: + +1. *Syntax* — the font-lock / tree-sitter categories (keyword, string, type, + comment, etc.), in the "code/color assignments" table. +2. *UI* — Emacs's built-in interface faces (cursor, region, mode-line, fringe, + line numbers, isearch, and the rest), in the "ui faces" table with the live + mock-frame preview. + +Tier 3 is *package faces*: faces a package declares with =defface= so a theme +can color the package as it wishes. The running config has 1,146 such faces +across 186 packages (magit 111, lsp-mode 97, telega 91, web-mode 82, org ~30 +core, and a long tail). No theme colors all of them; quality themes hand-pick +the packages the user actually lives in and theme those. + +This spec adds a tier-3 section to the tool, structured so applications are +added one at a time. org-mode ships first. + +* Goal + +A new "package faces" section with: + +1. An *application dropdown* — pick which package's faces to edit. v1 ships + org-mode (including org-agenda), magit, and elfeed; the rest of Craig's + packages (calibredb, ghostel, mu4e, IRC, org-drill, dirvish + dired, slack) + follow one at a time. +2. A *face table* for the selected app — one row per face in the app's complete + set, each with a foreground dropdown, a background dropdown, bold / italic + toggles, an optional inherit, and a relative-height stepper, all drawing from + the same palette as the other tables. Grouped, with a text filter for the + large apps. +3. A *preview pane* for the selected app — a realistic mock of that package + rendered with the live theme, the way the ui-faces mock-frame shows the UI + faces in a buffer. org-mode gets a mock org document. + +The export (=theme.json=) gains a =packages= object so the build step can set +these faces too. + +* UI placement + +A new top-level section under the ui-faces row: + +#+begin_example +<h1>package faces</h1> +[ application: (org-mode v) ] +<div class="cols stretch"> + left = the selected app's face table (fg / bg / B / I per face) + right = the selected app's preview pane (e.g. the org document mock) +</div> +#+end_example + +Same two-column stretch layout as the ui-faces row, so the preview matches the +table's height. + +* Data model + +A single data structure drives everything, keyed by application: + +#+begin_src js +APPS = { + "org-mode": { + label: "org-mode", + faces: [ + // face, human label, default {fg, bg, bold, italic} + ["org-document-title", "document title", {fg:"gold", bold:true}], + ["org-level-1", "heading 1", {fg:"blue", bold:true}], + ["org-level-2", "heading 2", {fg:"gold"}], + ["org-level-3", "heading 3", {fg:"regal"}], + ["org-todo", "TODO keyword", {fg:"terracotta", bold:true}], + ["org-done", "DONE keyword", {fg:"sage", bold:true}], + ["org-link", "link", {fg:"blue"}], // base `link` + ["org-code", "inline code", {fg:"terracotta"}], + ["org-verbatim", "verbatim", {fg:"steel"}], + ["org-block", "src block body", {fg:"white", bg:"bg-dim"}], + ["org-block-begin-line","block delim", {fg:"pewter", bg:"bg-dim"}], + ["org-table", "table", {fg:"steel"}], + ["org-date", "timestamp", {fg:"steel"}], + ["org-tag", "tag", {fg:"tan"}], + ["org-special-keyword","keyword/drawer", {fg:"pewter"}], + ["org-meta-line", "#+meta line", {fg:"pewter"}], + ["org-checkbox", "checkbox", {fg:"gold"}], + ["org-headline-done", "done headline", {fg:"pewter"}], + ], + preview: "org" // names the preview renderer + }, + // magit, elfeed, ... added later with the same shape +} +#+end_src + +Defaults reference palette *names* (blue, gold, ...) resolved to hexes at load, +so a curated app seeds sensibly from the current palette. The user reassigns +any face from the palette dropdowns exactly like the other tables. + +State mirrors the other tiers: a =PKGMAP= of +={app: {face: {fg, bg, bold, italic, inherit, height, source}}}=, edited live, rendered into +the table and the preview. The =APPS= block above shows ~18 org faces only as a +shape illustration; the real org entry is the complete set below. + +** Data model — org face set (complete) + +Per the completeness decision, org's table lists org's entire own =defface= set +(org-faces.el + org-agenda.el), ~88 faces, grouped. Seed defaults for the +prominent groups; the long tail seeds to fg or an =inherit= of its group base, +which the user overrides. The groups (face names verbatim from the running +Emacs): + +- *Document:* org-document-title, org-document-info, org-document-info-keyword +- *Headings:* org-level-1 .. org-level-8, org-headline-todo, org-headline-done +- *Status / keywords:* org-todo, org-done, org-priority, org-tag, org-tag-group, + org-special-keyword, org-drawer, org-property-value, org-checkbox, + org-checkbox-statistics-todo, org-checkbox-statistics-done, org-warning +- *Links / dates / refs:* org-link, org-footnote, org-date, org-sexp-date, + org-date-selected, org-target, org-macro, org-cite, org-cite-key +- *Blocks / code / quote:* org-block, org-block-begin-line, org-block-end-line, + org-code, org-verbatim, org-inline-src-block, org-quote, org-verse, + org-latex-and-related +- *Tables / columns:* org-table, org-table-header, org-table-row, org-formula, + org-column, org-column-title +- *Lists / meta / structure:* org-list-dt, org-meta-line, org-ellipsis, + org-hide, org-indent, org-archived, org-default, org-dispatcher-highlight +- *Agenda — structure & dates:* org-agenda-structure, + org-agenda-structure-secondary, org-agenda-structure-filter, org-agenda-date, + org-agenda-date-today, org-agenda-date-weekend, org-agenda-date-weekend-today, + org-agenda-current-time, org-agenda-done, org-agenda-dimmed-todo-face +- *Agenda — calendar & filters:* org-agenda-calendar-event, + org-agenda-calendar-sexp, org-agenda-calendar-daterange, org-agenda-diary, + org-agenda-clocking, org-agenda-column-dateline, org-agenda-restriction-lock, + org-agenda-filter-category, org-agenda-filter-effort, org-agenda-filter-regexp, + org-agenda-filter-tags +- *Scheduling / deadlines / clock:* org-scheduled, org-scheduled-today, + org-scheduled-previously, org-upcoming-deadline, org-upcoming-distant-deadline, + org-imminent-deadline, org-time-grid, org-clock-overlay, org-mode-line-clock, + org-mode-line-clock-overrun + +The org *preview* below stays a curated document exercising the prominent +faces; the *table* carries the complete set so every face is assignable, even +the ones the preview doesn't draw. magit and elfeed get the same treatment +(complete own-defface set in the table, a bespoke preview for the common faces). + +* The org preview + +A mock org document painted from PKGMAP["org-mode"] plus the palette ground/fg. +One bespoke renderer (=renderOrgPreview()=) drawing a representative document: + +#+begin_example +#+TITLE: Project Notes <- org-document-title +#+AUTHOR: ... <- org-meta-line / document-info + +* Inbox :work: <- org-level-1 + org-tag +** TODO Draft the spec <- org-level-2 + org-todo + SCHEDULED: <2026-06-08 Sun> <- org-special-keyword + org-date +** DONE Ship the tool <- org-level-2 + org-done (headline-done) +*** Heading three <- org-level-3 + A line with =inline code=, <- org-code + ~verbatim~, and a [[link]]. <- org-verbatim + org-link + - [X] a checkbox item <- org-checkbox + + #+begin_src elisp <- org-block-begin-line + (message "hi") <- org-block + #+end_src <- org-block-end-line + + | name | hex | <- org-table (header row org-table-header) + |------+---------| + | blue | #67809c | +#+end_example + +Each marked element is a span colored from the corresponding PKGMAP face. The +preview rebuilds whenever a package face or the palette changes, same as the +mock frame. + +org, magit, and elfeed get bespoke preview renderers (magit -> a status buffer +mock, elfeed -> a search-list mock). Every *other* package is still fully +themeable: its face *table* is always present and editable, only the rich +*preview* is replaced by a generic fallback — each face's name rendered in its +own colors on the ground. So a user can theme every package they have the +moment its face list is added; the bespoke preview is a polish layer on top, not +a gate. This is the v1 answer to "some will want to touch every package." + +* Export schema + +=theme.json= gains a =packages= key: + +#+begin_src json +{ + "name": "dupre", + "palette": [...], + "assignments": {...}, + "bold": [...], "italic": [...], + "ui": {...}, + "packages": { + "org-mode": { + "org-level-1": {"fg":"#67809c","bg":null,"bold":true,"italic":false,"inherit":null,"height":1.3}, + "org-level-2": {"fg":"#e8bd30","bg":null,"bold":false,"italic":false,"inherit":"org-level-1","height":1.2}, + "org-todo": {"fg":"#cb6b4d","bg":null,"bold":true,"italic":false,"inherit":null} + } + } +} +#+end_src + +=inherit= is optional and =null= when absent. When set, the converter writes +=:inherit PARENT= plus only the overridden attributes. + +Only faces the user actually touched (or the curated defaults) are written. The +build step's converter sets each as a normal face. Backward compatible: a file +without =packages= loads fine. + +* Build-step consumption + +The eventual =theme.json= -> =dupre-*.el= converter already owns tiers 1 and 2. +Tier 3 adds, per package face: + +#+begin_src elisp +(org-level-1 ((t (:foreground "#67809c" :weight bold)))) +(org-todo ((t (:foreground "#cb6b4d" :weight bold)))) +#+end_src + +No new converter machinery — package faces are just more faces. This is the +TDD-worthy part (JSON in, valid faces out), same as the rest of the converter. + +* Scope for v1 + +- Build the section, the app dropdown, and the face tables + previews for the + three v1 apps: org-mode (incl. org-agenda), magit, elfeed. +- org's table carries its complete own-defface set (~88 faces, grouped above), + seeded with defaults; the org preview draws the prominent ones. +- Every other installed package is reachable in the dropdown with an editable + face table and the generic fallback preview, so any package can be themed. +- Wire export/import of the =packages= key (with the optional =inherit= and + =height= fields). +- Leave the converter for the separate build-step task (Elisp, per Craig); the + spec only needs the schema to be right. + +* Implementation phases + +Phased so each step ships without a broken intermediate, and the three bespoke +apps don't wait on the all-package inventory. + +1. *State + schema.* Add =PKGMAP= + ({app:{face:{fg,bg,bold,italic,inherit,height,source}}}) and the =APPS= + registry. Extend export/import with the =packages= key; old JSON (no + =packages=) still imports cleanly. No UI yet. +2. *Curated app data.* Complete own-defface face lists + seeded defaults for org + (incl. org-agenda), magit, elfeed, in =APPS= — including heading heights and + the fixed-pitch inherits. Pure data. +3. *Package face table UI.* App selector; grouped rows; fg/bg dropdowns + bold / + italic toggles + optional inherit + a relative-height stepper; per-face and + per-app reset; a text filter (org/magit are large); a contrast readout per + fg/bg. Built on a generalized face-control helper shared with the ui-faces + table, not a fork of =uiSelect=. +4. *Org preview.* =renderOrgPreview()=, live, refreshing on palette/face change. +5. *Magit + elfeed previews.* Bespoke mocks (magit status buffer, elfeed search + list). +6. *Generated all-package inventory* (the "theme every package" path). A build + step queries Emacs for installed packages' faces grouped by package, writes a + data file =generate.py= embeds; the dropdown then lists every package with an + editable table + the generic fallback preview. Lands after phases 1-5 without + blocking the three bespoke apps. +7. *Docs + validation.* README =packages= schema + inventory-refresh command; + regenerate HTML; fixtures + manual checklist. + +Phases 1-5 deliver the three high-value apps fully; phase 6 opens the long tail; +phase 7 documents. + +* Package face inventory source + +*Hybrid, split across phases.* Curated app metadata (org/magit/elfeed: complete +face lists, seeded defaults, bespoke previews) is hand-maintained in =APPS= and +ships in phases 2-5. A *generated* =PACKAGE_FACE_INVENTORY= — produced by a build +step that asks the running Emacs for each installed package's faces grouped by +package, written to a JSON/Python data file =generate.py= embeds — supplies the +generic fallback packages and ships in phase 6. + +Why hybrid and split: the static generator can't discover packages at runtime in +the browser, so "theme every package" needs a generated inventory; but making the +full inventory a prerequisite for the three bespoke apps invites the scope +explosion the review flagged. Splitting it lets v1's core ship first; the +inventory is additive. + +The generated inventory is an *input artifact* to =generate.py= (a committed data +file refreshed by an explicit command), never browser-side discovery. The refresh +command's dependency on a loaded Emacs config is documented. + +Decided (Craig, 2026-06-08): hybrid-and-split, as above. + +* State and export policy + +Each package face object carries a =source= marker so export can tell a seeded +default from a user edit from a deliberate clear: + +#+begin_src js +{ fg:"#67809c", bg:null, bold:true, italic:false, underline:false, strike:false, inherit:null, height:1.0, source:"default" } +// underline / strike: booleans -> the converter writes :underline t / :strike-through t +// height: float multiplier off the base font (1.0 = unchanged); see Relative height +// source: "default" (seeded) | "user" (edited) | "cleared" (user removed a default) +#+end_src + +Export policy: + +- Write =default= and =user= entries. +- Write =cleared= entries — they must suppress a curated default on reload. +- Omit untouched faces that have no default. +- When =inherit= is set, write =inherit= plus only the explicit overrides. +- Write =height= only when it differs from 1.0. +- Preserve package faces present in an imported file but absent from the current + inventory (or warn) — don't silently drop them. + +Import tolerates a missing =packages= key, unknown app keys, unknown face keys, +a missing =inherit=, and a missing =height= (defaults 1.0). A deleted palette +color leaves package face references in the same "(gone)" recoverable state +syntax colors use. Inheritance cycles are rejected (treated as no inheritance) +during preview resolution. + +* Relative height + +Some faces want to be bigger than body text — org headings above all, also +=org-document-title=. A face's =height= field is a *float multiplier* off the +base font (=1.3= = 1.3× the running font, whatever it is), never an absolute +point size, so it stays portable across fonts and machines. =1.0= means +unchanged. The base monospace family is *not* a theme/tool concern — it lives in +=modules/font-config.el=; the tool owns only relative size. + +*Height does not cascade through =inherit=.* This is the one attribute resolved +directly off the face, not through its inherit chain. Emacs multiplies float +heights along an inherit chain, so a level-2 that inherits level-1 (1.3) and +also sets 1.1 would render at 1.43 — almost never what's wanted. Headings should +each size off the *body*, so the seeded defaults set =org-level-1= 1.3, +=org-level-2= 1.2, =org-level-3= 1.15, etc., each independent, and the tool reads +=height= from the face while still resolving *color* through inherit. + +- *Schema:* the =height= float on the face object (above), default 1.0, omitted + from export when 1.0. +- *UI:* a small numeric stepper in the face row (range ~0.8–2.0, step 0.05); + meaningful only for the size-bearing faces but shown on every row at 1.0. +- *Preview:* the row renders at the scaled =font-size= so a heading visibly + grows in the mock. +- *Converter:* writes =:height 1.3= into the face spec when ≠ 1.0. + +Related, same mechanism: org's mixed-pitch faces (=org-block=, =org-code=, +=org-verbatim=, =org-table=, =org-meta-line=, =org-date=) seed =inherit: +"fixed-pitch"= so they stay monospace when a buffer switches to a proportional +font via =variable-pitch-mode= / =mixed-pitch=. The proportional family itself +stays in =font-config.el= (the presets already carry =:variable-pitch-family=); +the tool only carries the fixed-pitch inherit relationship, shown like any other +inherited value. + +* Acceptance criteria + +- Existing =dupre.json= (no =packages= key) imports cleanly. +- Export includes =packages= once defaults or edits exist; + =fg/bg/bold/italic/inherit/height/source= round-trip through import/export. +- A face =height= renders as a scaled font-size in the preview (heading visibly + grows) and is read off the face, not cascaded through =inherit=. +- org, magit, elfeed appear in the app selector with complete grouped face tables. +- (phase 6) generic inventory packages appear with editable tables + fallback + previews, the fallback visibly labeled as generic. +- A palette color update propagates to package faces the same way it does to + syntax / ui faces. +- =python3 scripts/theme-studio/generate.py= rebuilds =theme-studio.html=. +- README documents the =packages= schema, inheritance, and the inventory source. + +* Extensibility (adding the next app) + +1. Add an entry to =APPS= (label, curated face list with palette-name defaults, + preview key). +2. Optionally write a bespoke preview renderer; until then the generic fallback + renders. +3. Nothing else changes — the dropdown, table, export, and import are all + data-driven off =APPS= / =PKGMAP=. + +* Agreed decisions + +Craig's answers to the first review round, baked in (the body sections above +reflect these; this records the decisions): + +1. *Curated set is complete, not iterative.* For org, list its *entire* own + defface set (org-faces.el + org-agenda.el), ~88 faces, not a hand-picked + ~18. The user wants every choice present, not a set that grows on demand. + See "Data model — org face set" for the full grouped list. +2. *Seed curated defaults.* Seed sensible fg/bg and weight per face (headings, + title, TODO/DONE bold; agenda dates and deadlines colored by role). The user + reassigns from there. +3. *App order: org, magit, elfeed for v1.* Then the rest one at a time, drawn + from the packages Craig actually runs: calibredb, ghostel, mu4e, the IRC + client, org-drill, dirvish + dired, slack. A finite "most-used" list gets + picked later; we do not try to do everything at once. +4. *Generic fallback is real, not display-only.* Any package not given a + bespoke preview still gets a fully editable face table (so a user can theme + *every* package they have); only the rich preview is missing, replaced by a + swatch-in-context fallback. Bespoke previews ship for org, magit, elfeed. + +* Inheritance representation (decided) + +Each face carries an optional =inherit= field naming another face (or =null=). +The face's own =fg/bg/bold/italic= are *overrides* layered on top of what it +inherits. + +#+begin_src js +["org-level-2", "heading 2", {inherit:"org-level-1", fg:"gold"}] +// exports as: (org-level-2 ((t (:inherit org-level-1 :foreground "#e8bd30")))) +["org-agenda-date-today", "agenda today", {inherit:"org-agenda-date", bold:true}] +// exports as: (org-agenda-date-today ((t (:inherit org-agenda-date :weight bold)))) +#+end_src + +*Decision (Craig, 2026-06-07): model inheritance, show the resolved result, +override what looks bad.* The point is to see what a face ends up looking like +when it inherits, judge it in the preview, and fix only the ones that look +wrong: + +- Each face's *effective* color is resolved through its inherit chain and shown + in its table row, visibly marked "inherited from <face>" so it reads as + not-explicitly-set. The face's own =fg/bg/bold/italic= are overrides layered + on top. +- The mock preview on the right renders every face with its effective color, so + inherited faces are judged in context, not in the abstract. +- Overriding is one action: assign a color (or toggle weight) and the row flips + from inherited to explicit (=source: "user"=), shown at once in the table and + preview. +- Export writes =:inherit PARENT= for faces left inherited (carrying the + relationship, so they follow the parent the theme also sets) and explicit + attributes for the ones overridden — never a frozen copy of an inherited + color. + +Seeded defaults express the inherit relationships org itself uses out of the box +(heading levels off a base, =org-agenda-date= variants off =org-agenda-date=, +=org-code= / =org-verbatim= off =fixed-pitch=), so the table opens showing +org's real cascade, which the user then tunes. Inheritance cycles resolve to no +inheritance. + +* Custom color picker (proposal) + +Craig wants a custom in-page color picker to replace the native browser swatch. +The native =<input type=color>= opens the OS color chooser, which the page +cannot size or restyle; a custom picker is the only way to get a larger, +on-theme picker and to show the palette/contrast in the picker itself. + +Proposed widget — a popup anchored to the swatch, drawn in-page: + +- A *saturation/value square* (click or drag to set S and V) plus a *hue + slider* down the side. Standard HSV picker geometry. +- A *hex field* synced both ways with the square/slider (already exists in the + add-color row; the picker writes to it). +- The current *palette* shown as clickable chips along the bottom, so picking + an existing color is one click and the overlap problem (many roles, one + color) is visible while choosing. +- A live *contrast readout* against the current background (ratio + AAA / AA / + FAIL) updating as the color moves, so a color is judged for legibility at + pick time, not after assignment. +- Sized generously (the native popup's size was the original complaint); opens + on click of the swatch, closes on pick or click-away. + +Implementation: ~120 lines of vanilla JS/canvas (or CSS gradients) for the +square + slider, reusing the existing =rl()= / =contrast()= / =rating()= +helpers for the readout and =normHex()= for the field sync. No dependency. It +replaces the =<input type=color>= in the add-color row and, later, becomes the +picker the package-face dropdowns can also invoke. + +It stays *off* the tier-3 critical path: a separate task before or after the +package-face build, not folded into it, since folding it in widens the blast +radius for no dependency benefit. Build it only sooner if package-face editing +proves painful with the native swatch. + +Decided (Craig, 2026-06-08): after tier 3, as its own task. + +* Files touched + +- =scripts/theme-studio/generate.py= — the section, =APPS= data, the package + face table, =renderOrgPreview()=, export/import of =packages=. +- =scripts/theme-studio/theme-studio.html= — regenerated. +- (later) the =theme.json= -> =dupre-*.el= converter (Elisp) — consumes + =packages=. + +* Review dispositions + +Codex review (2026-06-07), =Not ready=. Findings processed: + +- *Modified — generated inventory (high, blocking).* Codex recommended a hybrid + inventory so every installed package is reachable. Accepted the hybrid, but + *split* it: the generated all-package inventory is its own phase (6), after the + three bespoke apps (phases 1-5), rather than a v1 prerequisite. Reason: Codex + named scope explosion as the main risk, and gating org/magit/elfeed on a + full-inventory mechanism is exactly that. The split keeps v1's core shippable + and makes "theme every package" additive. Confirm-with-Craig flagged as an + open. +- *Modified — preview depth (UX obs).* Codex suggested level 4-8 examples in the + org preview. The preview stays a curated document drawing the prominent faces + (incl. a couple of deeper levels as representative); the complete level set + lives in the *table*, which is where every face is assignable. A full 8-level + preview block would bloat the mock without adding assignability. + +Everything else in the review accepted as written: implementation phases, +acceptance criteria, the =source= state field + export policy, curated-vs-complete +wording, keeping the custom picker off the critical path, unknown-import +preservation, the test-strategy fixtures, and the UX/architecture/robustness +observations (grouping + filter, reset controls, package-fg/bg contrast readout, +generalized face-control helper, package style kept inside the package object, +"(gone)" recoverable state, inheritance-cycle rejection). + +* Review and iteration history + +** 2026-06-07 Sun @ 18:17:14 -0500 — Claude Code (emacs-d) — author + responder +- *What:* Folded Craig's first-round cj-comment answers into the body. Curated + org set changed from ~18 to org's complete own-defface set (~88, grouped, incl. + org-agenda). v1 apps fixed to org/magit/elfeed with the rest deferred to a + one-at-a-time list. Generic fallback clarified as a fully editable table for + every package (only the rich preview is bespoke). Answered the inheritance + question with an optional =inherit= field (absolute-default, opt-in cascade) + and added it to the schema. Added a custom-color-picker proposal at Craig's + request. Converted Open questions to Agreed decisions; two genuine opens + remain (inheritance confirm, picker timing). +- *Why:* Craig answered the five open questions inline and asked for the + spec-response fold plus a custom-picker proposal. Completeness over iterative + growth was his explicit call on the curated set. +- *Artifacts:* This spec. The converter language (Elisp) confirmed by Craig. + Note: Craig referenced a Codex spec review, but no + =theme-studio-package-faces-review.org= (or any =*-review.org=) exists in the + repo — the review file could not be found, so only the author-side cj-comment + fold was done; the Codex review remains to be processed when its file is + located. + +** 2026-06-07 Sun @ 18:20:16 -0500 — Codex — reviewer +- *What changed or was recommended:* Created the missing formal review and + marked the spec =Not ready=. The review recommends adding implementation + phases, acceptance criteria, an explicit package-face inventory source, and + default/user/cleared/inherited export semantics. +- *Why:* The direction is coherent and the first-round decisions are folded in, + but v1 now depends on behavior that is not yet implementable from the current + static generator without a defined inventory and state/export contract. +- *Artifacts:* [[file:theme-studio-package-faces-spec-review.org][theme-studio-package-faces-spec-review.org]] + +** 2026-06-07 Sun @ 18:28:02 -0500 — Claude Code (emacs-d) — responder +- *What:* Ran spec-response against the Codex review. Added Implementation + phases (7), Package face inventory source (hybrid, split), State and export + policy (=source= field + export rules), and Acceptance criteria. Wove in the + UX/architecture/robustness observations. Two reasoned modifies (generated + inventory split into its own phase; org preview stays curated rather than + all-8-levels), everything else accepted — see Review dispositions. Status + moved to =Ready with caveats=. Deleted the review file. +- *Why:* The four blocking findings were real implementation-contract gaps; the + inventory split answers Codex's own scope-explosion warning while still + reaching "theme every package." +- *Artifacts:* This spec (Review dispositions section); review file deleted per + the spec-response close-out. Three opens remain for Craig (inheritance confirm, + hybrid-inventory confirm, picker timing). + +** 2026-06-08 Mon @ 00:12:38 -0500 — Codex — reviewer +- *What changed or was recommended:* Reran spec-review after the response pass. + Assigned rubric =Ready= and did not create a new review file. The previous + blockers are now addressed: implementation phases, acceptance criteria, + hybrid/split inventory source, package-face state/export semantics, task + tracking, and the open inheritance/inventory/picker decisions are resolved. +- *Why:* The spec now gives an implementer concrete behavior, phase boundaries, + validation criteria, and deferred-work handling without forcing product + decisions during implementation. +- *Artifacts:* This spec; implementation tasks in [[file:../../todo.org][todo.org]]. + +** 2026-06-08 Mon @ 00:38:23 -0500 — Claude Code (emacs-d) — author +- *What:* Added a relative =height= field to the face schema (float multiplier + off the base font, default 1.0, omitted at 1.0), a new "Relative height" + section, a per-face stepper in the table, preview scaling, and converter + output. Established the rule that =height= is read off the face and does *not* + cascade through =inherit= (Emacs multiplies float heights along the chain). + Noted the mixed-pitch =fixed-pitch= inherits as the same-mechanism companion. + Brought Phase 1's shipped schema plumbing in line with the new field. +- *Why:* Craig asked to fold height in — it matters for org headings above all. + Font *family* stays in =modules/font-config.el=; the theme owns relative size + and the fixed-pitch inherit relationships only. +- *Artifacts:* This spec; =scripts/theme-studio/generate.py= phase-1 plumbing. diff --git a/docs/design/theme-studio-perceptual-color-metrics-spec.org b/docs/design/theme-studio-perceptual-color-metrics-spec.org new file mode 100644 index 00000000..7e7dedb2 --- /dev/null +++ b/docs/design/theme-studio-perceptual-color-metrics-spec.org @@ -0,0 +1,576 @@ +#+TITLE: theme-studio — perceptual color metrics (OKLCH, APCA, ΔE) +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-08 + +* Status + +Spec / review incorporated (Codex, 2026-06-08, two passes). Adds perceptual color metrics to +the theme-studio (=scripts/theme-studio/=) so it can build deliberately +low-contrast themes (Solarized / Zenburn class) with the same rigor it already +brings to high-contrast WCAG checking. Four additions: an OKLCH color model, a +per-color perceptual-lightness readout, an APCA contrast score alongside the +existing WCAG ratio, and a pairwise ΔE distinguishability check across the +palette. + +Came out of a design conversation comparing the low-contrast school (Solarized, +Zenburn) against Prot's high-contrast Modus themes. The conclusion: a theme has +three independent dials — contrast ratio, overall luminance, and chroma — and +the low-contrast camp turns down the first while Modus leaves it high and turns +down the other two. The current tool only measures the first (WCAG contrast) and +edits color in HSV, whose "lightness" is not perceptually uniform. To build +low-contrast themes by metric rather than by eye, the tool needs +perceptually-uniform lightness and chroma controls plus distinguishability and +polarity-aware contrast measures. + +Rubric: *Ready.* The four v1 product questions are decided in "Agreed decisions +(v1)" below and confirmed by Craig (2026-06-08); the testing strategy was +revised on his direction to a layered pyramid (Node-unit-tested color core + +thin UI hash tests + measured coverage). No remaining blocking ambiguity — the +implementer no longer has to invent product behavior while coding. Implementation +is sequenced into five phases, each independently shippable and tested. Tasks +filed in =todo.org=. + +* Background — the current color model + +The tool today works entirely in sRGB hex, HSV, and WCAG luminance. The relevant +cluster in =generate.py=: + +- =rl(hex)= (line 517) — WCAG relative luminance, via the existing =lin()= + sRGB-linearization helper. +- =contrast(a,b)= (line 519) — the WCAG 2.x ratio =(L1+0.05)/(L2+0.05)=. +- =rating(r)= / =ratingColor(r)= (lines 520-521) — AA (≥4.5) / AAA (≥7) verdict + and its display color. +- =hsv2rgb=, =rgb2hsv=, =hex2rgb=, =rgb2hex=, =normHex= (lines 604-609). +- The picker holds state as =pkH, pkS, pkV= (HSV) and renders an SV box (=#sv=), + a hue strip (=#hue=), crosshairs (=#svcur=, =#huecur=), a hex+contrast readout + (=#pkhex= / =#pkcon=, line 451), the contrast-mask buttons (=.pmode=, state + =pkMode=, values =any= / =aa= / =aaa=), and palette chips (=#pkchips=). +- =drawMask()= (line 613) greys SV-box regions whose contrast against the + background falls below the selected mask threshold (=pkThresh()=). +- Per-face contrast readouts appear across *three* tables — syntax (line 548), + UI (line 1064), and package faces (line 752) — each via =contrast()= + + =rating()=. The package-face tier has grown large since the tool's early + versions (51 packages in the current inventory), so any "add a column to the + table readouts" change now touches that whole surface, not just the two + original tables. + +Two limitations this spec addresses: + +1. *HSV lightness lies.* Two colors at the same HSV =V= can differ markedly in + perceived brightness, so the SV box cannot hold perceived lightness constant + while hue changes — exactly the operation a calm, even palette needs. +2. *WCAG 2.x is a known-flawed contrast model in the low-contrast / dark band.* + Its ratio misjudges contrast most where this work operates, and it is not + polarity-aware: it scores light-on-dark and dark-on-light identically, which + perception does not. WCAG 3 is reworking contrast but is years out — still a + Working Draft in 2026, with the final Recommendation not expected until + roughly 2028–2030 — and its contrast algorithm is undetermined: APCA was moved + *out* of the WCAG 3 draft in 2023 for further evaluation. So APCA enters here + as a well-regarded independent perceptual model used as an additional + diagnostic, not as a coming standard. WCAG 2.x stays the baseline precisely + because nothing has replaced it yet. + +* Goal + +Add four metrics, each a discrete increment: + +1. *OKLCH color model* — perceptually-uniform Lightness / Chroma / Hue, so the + editor can move one axis without disturbing the others, plus a gamut clamp + for OKLCH values outside sRGB. +2. *Perceptual-lightness readout* — show each color's OKLCH L (and C, H) in the + picker, so "low, even lightness steps" becomes a number rather than a guess. +3. *APCA score* — the Accessible Perceptual Contrast Algorithm Lc value + displayed next to the WCAG ratio, as the more trustworthy contrast metric in + the low-contrast band. +4. *Pairwise ΔE check* — perceptual color-difference between every pair of + palette entries, flagging pairs too similar to tell apart, which is the + constraint that keeps a low-chroma / low-lightness-spread palette from + collapsing into mush. + +Non-goals: replacing WCAG (it stays as the compatibility baseline, shown +alongside APCA, which is an additional perceptual diagnostic, not a +replacement); replacing the HSV picker outright (OKLCH is added as a parallel +color model, HSV remains the default); CIEDE2000 in v1 (ΔE-OK is the v1 +difference metric — see vNext). + +* Agreed decisions (v1) + +Settled on author + reviewer alignment and confirmed by Craig (2026-06-08). + +1. *ΔE metric and threshold.* Use ΔE-OK (Euclidean distance in OKLab) on its + native scale (OKLab L is 0..1). Default "too similar" warning threshold is + *0.02* — the just-noticeable-difference floor, so the warning fires only when + two palette colors are genuinely hard to tell apart. The threshold is a named + constant, calibratable in one place. CIEDE2000 (the CIE's 2000 perceptual + color-difference standard — more accurate than plain Euclidean distance, but + ~40 lines of trigonometric lightness/chroma/hue corrections plus a blue-region + rotation term) is deferred to vNext: ΔE-OK is accurate enough to flag + indistinguishable pairs, which is all this check needs, and it is five lines. +2. *Low-contrast preset.* v1 ships *readouts only* (OKLCH, APCA, ΔE). No named + low-contrast preset / mask mode yet. No such preset exists anywhere today — it + would be a new feature: a saved low-contrast target (e.g. an APCA Lc band, or + a contrast ceiling as well as a floor) that masks the palette to a comfortable + range in one click, the way the current any/AA+/AAA buttons mask by a contrast + floor. It is deferred until the raw readouts are in use, because only then is + it clear which band is worth presetting. v1 gives the numbers; the preset + would automate a judgment the numbers first have to inform. +3. *APCA placement.* v1 shows APCA *only in the picker* readout, not in the + syntax/UI/package table contrast cells. Adding it to the tables is + low-complexity once =apca()= exists — the same pattern as the existing + =contrast()= + =rating()= cells, repeated across the three tables — so the + deferral is about table *density*, not difficulty: the package table alone is + 51 packages wide, and a second contrast number per row risks clutter before + it is clear anyone reads it there. Table-wide APCA is a vNext candidate if + picker-only proves too hidden. +4. *Picker default model.* HSV stays the *default* picker model; OKLCH is + opt-in via a color-model control. The reason: HSV is the familiar 2D SV-box + the picker already has, and OKLCH is slider-only until the C×L plane (Phase + 4b) lands — so defaulting to OKLCH before 4b would hand users a worse default + editing surface than they have now. Once 4b ships the C×L plane, making OKLCH + the default becomes a real option worth revisiting; until then, HSV default + keeps the current editing experience intact and makes OKLCH an additive + choice, not a regression. + +* Color-math foundation (Phase 1, prerequisite) + +The pure color math is *extracted into its own importable module* rather than +inlined as loose functions in the page. This is the core architectural change +this spec makes to the test surface: the math is logic, so it gets tested as +logic — directly, in Node, with exhaustive fixtures — and the picker becomes a +thin UI layer over a tested core, not the only way to exercise the math. + +- New file: =scripts/theme-studio/colormath.js= — the pure, side-effect-free + conversion + metric functions, written as an ES module (each =export=-ed), + with a small guard so the same source loads both ways: =import=-ed by the Node + tests and spliced into the page by the generator. +- =generate.py= inlines =colormath.js= into the page's =<script>= the same way + it already inlines =samples.py='s data, so there is *one source of truth* — the + exact code the browser runs is the code the tests import. An inline-integrity + check (see Verification strategy) asserts the page contains the module verbatim + so the two can never drift. +- The existing inline helpers it supersedes (=lin=, =rl=, =contrast=, =rating=, + =hsv2rgb=, =rgb2hsv=, =hex2rgb=, =rgb2hex=) move into =colormath.js= too, so the + whole color core lives and is tested in one place. =normHex= stays at the UI + boundary; module functions assume a normalized =#rrggbb= and the Node tests + cover their edges directly. + +The functions (standard published algorithms): + +- =srgb2oklab(hex)= / =oklab2srgb(L,a,b)= — Björn Ottosson's OKLab matrices + (2020). sRGB → linear (reuse =lin()=) → LMS → cube-root → OKLab, and the + inverse. ~20 lines. +- =oklab2oklch(L,a,b)= / =oklch2oklab(L,C,H)= — Cartesian↔polar: =C=√(a²+b²)=, + =H=atan2(b,a)=. Trivial. +- =oklch2hex(L,C,H)= with the *gamut clamp* (see below). Returns + ={hex, clamped}= — the in-gamut hex plus a boolean flag. +- =apca(textHex, bgHex)= — the APCA-W3 algorithm. Returns a signed Lc (positive + for dark-text-on-light, negative for light-text-on-dark; magnitude ~0–107). +- =deltaE(aHex, bHex)= — ΔE-OK: Euclidean distance in OKLab, + =√((ΔL)²+(Δa)²+(Δb)²)=. Five lines. + +** Gamut clamp policy (v1, fixed) + +OKLCH can express colors sRGB cannot show (high C at some L/H). The v1 policy is +*binary-search chroma reduction*: hold L and H fixed, reduce C until the color +is in sRGB gamut. This preserves the two perceptual axes the user is reasoning +about and only sacrifices saturation. Component clipping (which can shift all +three axes and make a slider feel broken) is explicitly *not* used. + +=oklch2hex= returns ={hex, clamped}= where =clamped= is true when chroma was +reduced. The picker keeps its sliders and readouts on the *actual clamped color* +after conversion, and shows a short status ("chroma clamped to sRGB") when +=clamped= is true — so the user never sees an axis silently move. + +** APCA source (pinned) + +Implement against *APCA-W3 0.1.9* (Myndex), transcribing the constants verbatim: + +- Source: =https://github.com/Myndex/apca-w3= (the =apca-w3= package, version + 0.1.9). The implementation puts this URL + version in a code comment beside + =apca()=. +- Screen luminance per color uses the *exact* APCA-W3 0.1.9 =colorSpace= + constants, not rounded values: =Ys = 0.2126729·R^2.4 + 0.7151522·G^2.4 + + 0.0721750·B^2.4= on the 0..1 sRGB channels (straight 2.4 power, not the WCAG + piecewise). All remaining APCA constants — the black soft-clamp + (=blkThrs=/=blkClmp=), the polarity-specific text/background exponents + (=normBG=/=normTXT=/=revTXT=/=revBG=), the low-contrast roll-off + (=loBoThresh=/=loBoFactor=/=loClip=), =deltaYmin=, and =scaleBoW= — are + likewise transcribed verbatim from the pinned source. The spec does not restate + those numbers, to avoid becoming a second, drift-prone source: the pinned + =apca-w3= 0.1.9 is the single authority. +- Fixture values asserted by the Node unit tests: =apca('#000000','#ffffff')= + Lc ≈ *106.0* (dark on light, positive); =apca('#ffffff','#000000')= Lc ≈ + *-107.9* (light on dark, negative); plus at least one *chromatic* APCA fixture + (e.g. =apca('#67809c','#ffffff')=) computed from the pinned reference — + black/white alone cannot reveal rounded-coefficient drift, since the rounding + error is near zero at the channel extremes. + +The tool ships as a single self-contained generated HTML file with no runtime +build step or package manager, so the APCA algorithm is transcribed into +=colormath.js= (inlined into the page) rather than vendored as an npm dependency. +The Node test harness is dev-only — it imports =colormath.js= to assert against +fixtures — and does not make the shipped artifact depend on Node or any package. + +** Verification — Node unit tests (=test-colormath.mjs=) + +The math is tested *directly*, not through the browser: =scripts/theme-studio/test-colormath.mjs= +imports =colormath.js= and asserts against fixtures under =node --test=. No DOM, +no Chrome, sub-second, and not capped by what the UI happens to exercise — this +is where the bulk of the feature's test value lives, and it can be far more +exhaustive than a hash test. It must include *chromatic* fixtures and properties, +because many incorrect matrix/sign implementations still pass black, white, and +round-trip: + +- =srgb2oklab('#ffffff')= L ≈ 1.0, a ≈ 0, b ≈ 0; =srgb2oklab('#000000')= L ≈ 0. +- chromatic fixture 1 — saturated red =#ff0000=: OKLab/OKLCH within epsilon of + the reference (L ≈ 0.628, C ≈ 0.258, H ≈ 29.2°). +- chromatic fixture 2 — the dupre blue =#67809c=: OKLCH ≈ (L 0.591, C 0.052, + H 252°), epsilon ~0.005 on L/C and ~1° on H. Computed from the Ottosson + reference; the implementation verifies against the same reference it + transcribes. +- round-trip *property*: for a generated sample of hexes, + =oklch2hex(oklab2oklch(srgb2oklab(h))).hex= ≈ =h= within epsilon. A property + test over random inputs, not a fixed list — it explores corners a hand-written + list would miss. +- =apca= both polarities against the pinned fixtures above (assert sign and + magnitude), plus the chromatic APCA fixture. +- =deltaE(h,h)= = 0; =deltaE('#000000','#ffffff')= > 0; ordering: a near pair + scores below the 0.02 threshold, a well-separated pair above it. +- gamut clamp: a known out-of-gamut OKLCH (very high C) returns a valid + =#rrggbb= with L and H preserved within epsilon, C reduced, and =clamped= + true; an in-gamut input returns =clamped= false unchanged. + +Pure-function TDD with no rendering dependency: write the failing Node test, +confirm it FAILs (e.g. with a deliberately wrong constant), then make it pass. +There is no =#mathtest= browser hash — the math is not a UI concern, so it is not +tested through the UI. + +* Phase 2 — perceptual L and APCA readouts + +Smallest visible change; validates Phase 1 by eye. + +- Extend =pkReadout(hex)= (line 615) to populate new spans for OKLCH L / C / H + and APCA Lc, alongside the existing WCAG ratio in the =.pinfo= bar (line 451). + Add the spans to the picker DOM (lines 448-451) and minimal CSS. +- The APCA span carries a compact polarity-aware label (e.g. =APCA Lc -58=); the + sign convention (positive = dark-on-light, negative = light-on-dark) is + documented in its tooltip and in the README. +- WCAG remains exactly as-is in the picker and in all three table contrast cells. + Per "Agreed decisions" #3, no APCA in the tables for v1. + +Pure additions; no behavior changes. Headless guard: =#readouttest= loads a +known hex and asserts the OKLCH L/C/H and APCA Lc spans carry the expected +values and the WCAG readout is unchanged. + +* Phase 3 — pairwise ΔE across the palette + +Self-contained, high value for low-contrast work. + +- On =renderPalette()=, compute =deltaE= for every unordered pair of =PALETTE= + entries. Flag any pair below the threshold (0.02, the named constant). +- Warning copy and ordering: sort failing pairs ascending by ΔE (closest first), + show the first *5*, and append "and N more" when capped — so a noisy palette + never silently hides the count. Format: "blue / steel — ΔE 0.014, hard to + distinguish". +- Each palette chip's =title= gains its nearest-neighbor ΔE. +- Reuses the chip rendering already in =renderPalette= / =buildPkChips= (line + 619). No new rendering surface. + +Headless guard: =#deltatest= seeds two near-identical palette colors and asserts +the warning fires (and names the pair); seeds a well-spread palette and asserts +it does not; if the cap triggers, asserts the "and N more" suffix and ascending +order. + +* Phase 4 — the OKLCH editor + +The largest piece, and the one that delivers "hold lightness while changing +chroma." Two shippable sub-phases, in order. + +** Phase 4a — OKLCH sliders + color-model control + +- Add a *separate color-model control* — a segmented =HSV= / =OKLCH= toggle with + its *own* state variable =pkModel= — distinct from the existing contrast-mask + control (=.pmode= / =pkMode=, values =any= / =aa= / =aaa=). The two are + orthogonal concepts: =pkModel= is "how I edit the color," =pkMode= is "what + constraint I mask." They must not share state. +- In OKLCH mode, show L / C / H as numeric + range inputs that drive the color + through =oklch2hex=, updating =#newhexstr=, the swatch, and the readouts. On + clamp, the sliders snap to the clamped color and the status text appears. +- No canvas work; delivers the independent-dials benefit immediately. + +Headless guard: =#oklchtest= asserts that switching =pkModel= to OKLCH preserves +the selected color, that toggling the AA/AAA mask does *not* reset =pkModel=, and +that switching =pkModel= does *not* reset =pkMode=. + +** Phase 4b — Chroma×Lightness plane + +- When =pkModel= is OKLCH, render the SV box (=#sv=, line 448) as a Chroma (x) by + Lightness (y) plane at the current fixed hue; the hue strip is unchanged. The + crosshair maps to (C, L) instead of (S, V). +- *Gamut masking*: high chroma is unreachable at some L/H, so grey out the + out-of-gamut region of the plane — reuse the =drawMask()= pattern (line 613), + swapping the per-pixel test from "contrast < threshold" to "OKLCH(C,L,H) not + in sRGB gamut." The existing AA/AAA contrast mask can overlay on top. +- *Render cost*: =drawMask()= already samples at =step=4= and runs =contrast()= + per cell; the gamut test adds an OKLCH→sRGB conversion per cell, and a naive + per-cell binary search on top would be expensive while dragging. Bound it: use + a coarse sampling step, cache the rendered plane on a key of + (hue + dimensions + mask mode + background hex) so it only recomputes when one + changes, and defer the redraw until pointer movement settles. The background + hex is in the key because when the AA/AAA contrast overlay is active the mask + depends on =MAP['bg']=, so a background edit must invalidate the cached plane. + The in-gamut test per cell + needs only a forward conversion + channel-range check, not the full binary + search (that is reserved for committing a chosen color). +- This per-pixel gamut render is the only genuinely new rendering logic in the + spec, which is why it is sequenced last. + +Headless guard: open the picker in OKLCH mode on a known hex via a hash; assert +the C×L crosshair lands at the expected plane coordinates and that a known +out-of-gamut coordinate is masked. + +* Verification strategy (whole feature) + +The test surface is *layered* — a proper pyramid, broad and fast at the bottom, +thin and DOM-bound at the top: + +1. *Unit (Node, the core)* — =test-colormath.mjs= imports =colormath.js= and + asserts the math directly under =node --test=. No browser. This is the bulk of + the coverage and the place exhaustive testing lives (every conversion, both + APCA polarities + chromatic, gamut clamp, ΔE ordering, round-trip property + over random hexes). *Coverage is measured here* with Node's built-in reporter + (no extra dependency): =node --test --experimental-test-coverage scripts/theme-studio/=. + Target for =colormath.js= is ≥90% line/branch (testing.md's utility-code bar); + in practice a pure, fully-fixtured module should land at or near 100%, and a + gap points at an untested branch worth a case. Coverage of the *core* is a + gate; coverage of the browser-executed UI code is out of scope for v1 (it + needs CDP/c8 instrumentation and the UI is verified by assertion, not line + count). +2. *UI wiring (browser hash tests)* — only the things that genuinely need a DOM + or layout, now that the math is tested below them: =#cursortest= (crosshair + pixel position — needs real layout), =#readouttest= (Phase 2, spans populated), + =#deltatest= (Phase 3, warning list rendered), =#oklchtest= (Phase 4a, + =pkModel= / =pkMode= independence + color preserved across mode switch), the 4b + plane test (canvas render + gamut mask). Each appends a =PASS/FAIL= node; + command shape: + =google-chrome-stable --headless=new --dump-dom 'file://…/theme-studio.html#readouttest'=. +3. *Integration smoke* — =#selftest= (data roundtrip), re-run every phase to + confirm no regression. +4. *Inline-integrity* — a check (Node or grep) that the generated + =theme-studio.html= contains the =colormath.js= source verbatim, so the + tested module and the shipped inline copy cannot drift. + +Per-phase loop: edit the source (=colormath.js= for math, =generate.py= for the +page — never hand-edit =theme-studio.html=); =python3 generate.py= to regenerate; +=node --check= the emitted =<script>=; run the phase's tests (Node unit tests for +Phase 1, the matching hash test for UI phases); re-run =#selftest= and the +inline-integrity check; Chrome eyeball for the visible phases (2, 3, 4). + +On coverage and why this shape: =generate.py= (~1120 lines) and =samples.py= +(~269) are the templating/assembly + data layer — string-emission and a sample +corpus — so Python unit tests there are low value and stay out of scope. The +logic worth hammering is the color *math*, which is JavaScript; extracting it to +=colormath.js= makes it directly unit-testable in Node instead of only reachable +through the rendered app. That is the correction this revision makes: the earlier +draft tested the math through browser hash tests, which coupled math correctness +to the DOM and capped coverage at what the UI exercises. With the core extracted, +the math gets exhaustive direct unit tests and the browser tests shrink to UI +wiring — the thin-UI-over-tested-core shape an API-first build would have +produced. The separate =build-theme.el= converter keeps its 22 ERT tests. + +* Documentation + +Folded into the phases, landing with the code each describes: + +- README (=scripts/theme-studio/README.md=): document OKLCH, APCA, and ΔE; the + meaning of the signed APCA value; that WCAG remains the compatibility baseline + and APCA is an additional perceptual diagnostic, not a replacement. +- Add the exact commands beside the existing run instructions: the Node unit run + with coverage (=node --test --experimental-test-coverage scripts/theme-studio/=) + and the headless hash tests (=#readouttest=, =#deltatest=, =#oklchtest=, the 4b + plane test). + +* Acceptance criteria + +- *Phase 1*: =colormath.js= extracted and inlined by =generate.py=; + =node --test= green — achromatic, chromatic, and round-trip conversions within + epsilon; APCA matches the pinned fixtures (magnitude and sign, both polarities, + plus a chromatic fixture); gamut clamp preserves L/H within epsilon, reduces C, + returns =clamped= true on out-of-gamut and false unchanged on in-gamut; + inline-integrity check confirms the page contains =colormath.js= verbatim; + =node --test --experimental-test-coverage= reports =colormath.js= at ≥90% + line/branch. +- *Phase 2*: picker shows OKLCH L/C/H and APCA Lc (with polarity label) next to + the WCAG ratio; values match the Node-test references for hand-checked colors; + no behavior change to existing flows; tables unchanged; =#selftest= still PASS; + =#readouttest= PASS. +- *Phase 3*: a palette with two near-identical colors raises a visible warning + naming the pair and ΔE, sorted closest-first, capped at 5 with "and N more"; a + well-spread palette raises none; chip titles carry nearest-neighbor ΔE; + =#deltatest= PASS. +- *Phase 4a*: dragging L changes only lightness (C and H readouts hold); same for + C and H independently; =pkModel= and =pkMode= are independent (=#oklchtest= + PASS); clamp shows status text. +- *Phase 4b*: the C×L plane crosshair opens on the current color's (C, L); + out-of-gamut regions are masked; the plane render stays responsive while + dragging (cached on hue/dims/mask key). + +* Implementation phases + +One shippable phase per increment, in dependency order, each gated on its own +headless test plus a clean =#selftest=. These map to the drop-in =todo.org= +tasks (filed in workflow Phase 6, after Craig confirms Ready): + +1. *Math foundation* — extract the color core into =colormath.js= (OKLab/OKLCH, + APCA-W3 0.1.9, ΔE-OK, gamut clamp, plus the migrated lin/rl/contrast/hsv + helpers); =generate.py= inlines it; =test-colormath.mjs= unit tests + the + inline-integrity check; gate =node --test= green. +2. *Picker readouts* — OKLCH L/C/H + APCA Lc spans beside WCAG; gate + =#readouttest= + =#selftest=. +3. *Palette ΔE warnings* — pairwise ΔE, sorted/capped warning, chip-title + nearest-neighbor; gate =#deltatest=. +4a. *OKLCH sliders + color-model control* — =pkModel= separate from =pkMode=, + L/C/H inputs, clamp status; gate =#oklchtest=. +4b. *Chroma×Lightness plane* — gamut-masked C×L render with caching; gate the 4b + plane test. + +A test-surface task keeps the Node unit tests, the UI hash tests, the +inline-integrity check, =#selftest=, the script syntax check, and manual Chrome +validation green across the feature. + +* Review dispositions + +Modified or rejected recommendations only; everything else in the Codex review +(2026-06-08) was accepted as written and woven into the body above. + +- *Modified — APCA "transcribe vs vendor" question (high-priority finding 4).* + The review asked the spec to "state whether the project is comfortable + transcribing the algorithm rather than vendoring a package." Reframed as + settled rather than open: the tool is a single self-contained generated HTML + file with no build step or package manager, so transcription is the only path + consistent with the architecture. The source URL + version pin and the fixture + values were accepted in full. +- *Modified — ΔE-OK default threshold (high-priority finding 1 / open question).* + The review accepted ΔE-OK on its native scale "with a named default threshold" + but left the number to the author. Concretized to 0.02 (the OKLab + just-noticeable-difference floor) as a named, calibratable constant, so the + warning fires only on genuinely indistinguishable pairs rather than deferring + the choice to implementation. + +* Review and iteration history + +** 2026-06-08 Mon @ 13:00:34 -0500 — Codex — reviewer +- *What changed or was recommended:* Created the implementation-readiness review + and marked the spec =Not ready=. The review recommends resolving the listed + open questions, pinning the APCA source/fixtures, specifying OKLCH gamut clamp + semantics, separating color-model mode from contrast-mask mode, and converting + the increments into todo-ready implementation phases. +- *Why:* The feature direction fits the current theme-studio architecture, but + coding from this draft would force the implementer to decide product behavior + around DeltaE thresholds, APCA placement, OKLCH UI state, and out-of-gamut + colors. +- *Artifacts:* the review file (consumed and deleted on response). + +** 2026-06-08 Mon @ 13:08:00 -0500 — Claude Code — responder +- *What changed:* Processed every Codex recommendation. Accepted all five + high-priority findings — resolved the four open questions into "Agreed + decisions (v1)", fixed the gamut clamp as binary-search chroma reduction + returning a =clamped= flag with picker status text, separated =pkModel= + (HSV/OKLCH) from =pkMode= (mask), pinned APCA-W3 0.1.9 with black/white + fixtures, and added an "Implementation phases" decomposition (1, 2, 3, 4a, 4b) + with per-phase test gates. Accepted the medium findings: chromatic =#mathtest= + fixtures, ΔE warning sort/cap copy, C×L render-cost bounds, refreshed + background to include the package-face table surface, the named hash tests + (=#readouttest=, =#oklchtest=), and the README/doc additions. Two modifications + recorded in "Review dispositions": the APCA transcribe-vs-vendor question + (settled by the no-build architecture) and the ΔE-OK default threshold (set to + 0.02). +- *Why:* Converge to implementation-ready — remove every product decision from + the coding path. Rubric raised Draft → Ready pending Craig's confirm on the + four baked-in v1 decisions. +- *Artifacts:* consumed and deleted =theme-studio-perceptual-color-metrics-spec-review.org=; + see "Review dispositions" and "Agreed decisions (v1)". Implementation tasks to + be filed in =todo.org= on confirm (workflow Phase 6). + +** 2026-06-08 Mon @ 13:15:26 -0500 — Codex — reviewer +- *What changed or was recommended:* Re-reviewed the updated spec and marked it + =Not ready= on one remaining blocker: the APCA section pins =apca-w3= 0.1.9 and + says to transcribe constants verbatim, but still shows rounded luminance + coefficients instead of the exact published =colorSpace= constants. Also noted + two non-blocking edits: include background hex in the 4b mask cache key when + contrast masking is active, and either provide the =#67809c= OKLCH fixture + values or make that second chromatic fixture optional. +- *Why:* The implementation is otherwise ready-shaped, but APCA math and numeric + fixtures need a single authoritative source before coding starts. +- *Artifacts:* [[file:theme-studio-perceptual-color-metrics-spec-review.org][theme-studio-perceptual-color-metrics-spec-review.org]] + +** 2026-06-08 Mon @ 13:19:15 -0500 — Claude Code — responder +- *What changed:* Processed Codex's second pass. Accepted all three findings, no + modify/reject. Blocking: replaced the rounded APCA luminance coefficients + (=0.2126=/=0.7152=/=0.0722=) with the exact APCA-W3 0.1.9 =colorSpace= + constants (=0.2126729=/=0.7151522=/=0.0721750=), and stated the remaining APCA + constants are transcribed verbatim from the pinned source rather than restated + in the spec, so there is one authority. Non-blocking: added =MAP['bg']= to the + Phase 4b mask cache key (the AA/AAA overlay depends on background); added + concrete OKLCH fixture values for =#67809c= (L 0.591, C 0.052, H 252°); added a + chromatic APCA fixture to =#mathtest= so black/white can't hide coefficient + drift. +- *Why:* Remove the last numeric ambiguity so =#mathtest= fixtures and the + implementation agree on one exact APCA algorithm. Reviewer confirmed no design + gaps remain — only the author-confirm caveat. +- *Artifacts:* consumed and deleted the second + =theme-studio-perceptual-color-metrics-spec-review.org=. Rubric stays =Ready, + pending author confirm= on the four v1 decisions; Phase 6 task-filing still + holds for that confirm. + +** 2026-06-08 Mon @ 13:21:11 -0500 — Codex — reviewer +- *What changed or was recommended:* Re-read the spec-review workflow and + critically reviewed the updated spec against the current theme-studio code, + README, prior review dispositions, and task tracking. Assigned =Ready with + caveats=: no blocking implementation ambiguity remains, and the prior APCA + coefficient issue is resolved by the exact =apca-w3= =colorSpace= constants. + The only caveat is the spec's explicit author-confirm step before Phase 1. +- *Why:* The spec now defines v1 scope, APCA source/fixtures, OKLCH gamut policy, + picker state ownership, DeltaE threshold/copy, performance guardrails, docs, + acceptance criteria, and shippable phases well enough for implementation. +- *Artifacts:* No review file written; no blocking findings. + +** 2026-06-08 Mon @ 15:21:55 -0500 — Craig Jennings (review) + Claude Code (responder) — final review +- *What changed:* Craig's final-review questions (six cj comments) answered and + the load-bearing ones folded into the body. Factual correction: WCAG 3 is still + a Working Draft (final Recommendation ~2028–2030) and APCA was moved *out* of + the WCAG 3 draft in 2023 — so the Background no longer frames APCA as a coming + standard, only as an independent diagnostic, with WCAG 2.x kept as baseline + because nothing has replaced it. Clarifications added to the Agreed decisions: + what CIEDE2000 is and why ΔE-OK suffices (#1); that no low-contrast preset + exists today and what one would add (#2); that table APCA is low-complexity and + the deferral is about density not difficulty (#3); the reason HSV stays default + until the 4b plane lands (#4). The Verification section now states Python + coverage is ~0% by design (the generator is output-tested via hash tests) and + the target is per-behavior hash-test gating, not a line-coverage number. +- *Why:* Craig interrogated the decisions before confirming; the answers either + reinforce them or give him what he needs to override. No decision changed. +- *Artifacts:* WCAG 3 status via W3C/industry sources (June 2026); repo check + confirmed no Python test files / pytest config for =scripts/theme-studio/=. + +** 2026-06-08 Mon @ 15:34:36 -0500 — Craig Jennings (direction) + Claude Code (responder) — testing-strategy override +- *What changed:* Craig overrode the v1 testing approach. The prior draft tested + the color math through browser hash tests (=#mathtest=), which coupled math + correctness to the DOM and capped coverage at what the UI exercises. Corrected + to a layered pyramid: the pure math is extracted into =scripts/theme-studio/colormath.js= + (an importable ES module that =generate.py= inlines into the page), unit-tested + directly in Node (=test-colormath.mjs=, =node --test=) with exhaustive fixtures + + a round-trip property test; the browser hash tests shrink to UI wiring only + (=#cursortest=/=#readouttest=/=#deltatest=/=#oklchtest=/4b-plane); =#selftest= + stays as integration smoke; an inline-integrity check guards the module and the + inlined copy against drift. =#mathtest= is removed — the math is no longer a UI + concern. Updated Phase 1, Verification, Acceptance, Implementation phases, and + Documentation to match. Language correction: the math is JavaScript (emitted by + the Python), so the "Python unit tests" instinct lands as Node unit tests on the + extracted JS core; the Python stays templating/data and is out of test scope. +- *Why:* Test the core directly, keep the UI thin — the API-first shape this + app grew past. Direct unit tests on the math are faster, more exhaustive, and + not limited by the UI surface. +- *Decisions 1-4 confirmed* as written (4: OKLCH readouts always shown; only the + editing model is opt-in until 4b). Phase 6 task-filing + commit still pending + Craig's go. diff --git a/docs/design/theme-studio-seeding-engine-spec.org b/docs/design/theme-studio-seeding-engine-spec.org new file mode 100644 index 00000000..bcbf43db --- /dev/null +++ b/docs/design/theme-studio-seeding-engine-spec.org @@ -0,0 +1,350 @@ +#+TITLE: theme-studio — seeding engine (role table to guide-correct defaults) +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-08 + +* Status + +Spec / review incorporated (Codex, 2026-06-08). Turns the color-assignment guide's seed table +and shade budget into an executable seeding engine: the tool opens with every +tier (syntax, UI faces, org-mode package faces) already colored to the guide's +defaults, so the user retunes hues with the picker rather than building a theme +from blank. Also reseeds the bundled =dupre= theme to the canonical compact +mapping (it currently diverges on two roles). + +Derives directly from =scripts/theme-studio/theme-coloring-guide.org= — the seed +table (role to palette-family / weight / channel) and the Shade budget (how many +shades each hue family carries). This spec encodes that table as data, classifies +each tier's faces into roles, and applies the table to produce the defaults. + +Rubric: *Ready.* Craig answered the four open questions (folded into Agreed +decisions) and Codex's review is incorporated. One decision reshapes the plan: v1 +generates shades with OKLCH (Craig's call), reusing the perceptual-metrics +=colormath.js= core, so this feature sequences after that spec's Phase 1. Two v1 +phases, each headless-testable. + +* Background — how the tool seeds today + +=scripts/theme-studio/generate.py= holds three face inventories, each with its +own ad-hoc default source: + +- *Syntax* — =CATS=, 21 categories keyed =bg p kw bi pp fnd fnc dec ty prop con + num str esc re doc cm cmd var op punc=. Defaults come from =COLS= (in + =samples.py=) into =MAP= and =BOLD=. There is no role layer; each category + carries a hand-set color. +- *UI faces* — =UI_FACES= (20 faces) with defaults in =UIMAP=, hand-authored. + This map already follows the guide closely (state faces are background-only, + active louder than idle, error/warning/success on the conventional hues), which + is the validation that the guide's principles describe a good UI tier rather + than invent one. +- *Package faces* — =APPS[app].faces=, each row =[face, label, default-dict]=. + =seedPkgmap()= reads the per-face default-dict. About twenty bespoke packages + (org, magit, elfeed, mu4e, ghostel, dashboard, lsp-mode, flycheck, dired, + dirvish, calibredb, erc, signel, pearl, slack, telega, shr, and more) carry + curated seed colors; generic inventory packages (from =package-inventory.json=) + seed to the default foreground. + +Three problems this spec addresses: + +1. *No role layer.* Each tier's defaults are set face-by-face by hand. There is + no single place that says "definitions are the warm anchor, bold" and projects + it onto syntax, UI, and org at once. The guide now states that table; the tool + does not consume it. +2. *dupre diverges from its own guide.* The compact mapping says builtins are + blue-grey and function definitions are gold; =dupre= assigns builtins to blue + (=bi= shares =kw='s hue) and definitions to silver (=fnd=). The guide records + this as a known divergence to be reseeded. +3. *Tiers do not open guide-correct.* UI is close by luck of hand-tuning; syntax + carries dupre's divergence; org's long tail is unseeded. Opening seeded across + all three is the goal. + +* Goal + +A seeding engine with three parts and one surfacing rule: + +1. *The seed model as data* — a named palette with the shade budget, a + role-to-treatment table, and a face-to-role map per tier. The guide's table, + made executable. +2. *A =seed()= operation* — applies the role table through each tier's + face-to-role map to produce the default assignments (=MAP=/=BOLD= for syntax, + =UIMAP= for UI, =PKGMAP= defaults for packages). +3. *Reseed dupre* — regenerate =dupre-revised.json= from the engine so it matches + the compact mapping (builtins blue-grey, definitions gold). + +Surfacing rule (Craig): the tool *opens seeded*. The syntax tier is already +guide-correct on load, so the user adjusts hues with the picker, then scrolls to +the UI faces. A "reseed from guide" button restores the defaults on demand. + +Non-goals: role-mapping the non-org bespoke packages (org is the one document +package worth a role map; the other ~20 keep their existing curated =APPS= seeds, +and reseed resets them to those defaults rather than flattening them — see +Package scope); per-tier reseed controls (v1 reseeds all three owned tiers at +once). + +* The seed model + +** Palette and shade budget + +A named swatch set, one to three shades per hue family, per the guide's Shade +budget. The names are the contract. v1 *generates* the shades with OKLCH (Craig's +call): each family is anchored by a base hue (the dupre anchors — blue, gold, +regal, sage, terracotta), and its quieter or brighter shades are derived by +stepping OKLCH lightness/chroma from that anchor, using the perceptual-metrics +=colormath.js= core. Generation is a first guess; any hue that reads wrong gets a +hand-authored override swatch. Rough shape: + +- *Neutrals:* =ground= (bg), =bg-dim=, =fg=, =muted-fg=, =comment=. +- *Blue:* =blue= (keyword), =blue-grey= (builtin — blue at lower chroma/lightness). +- *Gold:* =gold= (definition), =gold-quiet= (call). +- *Violet:* =regal= (types/decorators). +- *Green:* =sage= (string), =sage-muted= (docstring), =sage-bright= (escape). +- *Teal:* =teal= (regexp). +- *Terracotta:* =terracotta= (numbers/constants). +- *Signal:* =red=, =amber=, =green=, =blue= (reused) for error/warning/success/link. + +Roughly fifteen swatches across seven or eight hues. The builtin =blue-grey= and +the call =gold-quiet= are the swatches dupre is missing today and gains on +reseed. + +** Role-to-treatment table + +The guide's seed table as data: each role maps to a swatch, a weight, an optional +slant/underline, and a channel (foreground or background). One literal object, +e.g. + +#+begin_src js +ROLES = { + base: {swatch:'fg', weight:'normal', channel:'fg'}, + structure: {swatch:'muted-fg', weight:'normal', channel:'fg'}, + control: {swatch:'blue', weight:'bold', channel:'fg'}, + builtin: {swatch:'blue-grey', weight:'normal', channel:'fg'}, + def: {swatch:'gold', weight:'bold', channel:'fg'}, + call: {swatch:'gold-quiet', weight:'normal', channel:'fg'}, + type: {swatch:'regal', weight:'normal', channel:'fg'}, + string: {swatch:'sage', weight:'normal', channel:'fg'}, + docstring: {swatch:'sage-muted', slant:'italic', channel:'fg'}, + escape: {swatch:'sage-bright',weight:'normal', channel:'fg'}, + literal: {swatch:'terracotta', weight:'normal', channel:'fg'}, + comment: {swatch:'comment', slant:'italic', channel:'fg'}, + state: {swatch:'tint', channel:'bg'}, + sig_error: {swatch:'red', channel:'fg'}, + sig_warn: {swatch:'amber', channel:'fg'}, + sig_ok: {swatch:'green', channel:'fg'}, + sig_link: {swatch:'blue', underline:true, channel:'fg'}, + heading: {swatch:'ramp', channel:'fg'}, // see heading ramp +} +#+end_src + +** Face-to-role maps + +*** Syntax (CATS key to role) + +=p=, =var= to base; =op=, =punc=, =cmd= to structure; =kw= to control; =pp= to +control (shared, optionally muted); =bi= to builtin; =fnd= to def; =fnc= to call; +=dec=, =ty=, =prop= to type; =con=, =num= to literal; =str= to string; =doc= to +docstring; =esc=, =re= to escape (=re= to a teal variant if present); =cm= to +comment; =cmd= to structure (delimiter, dimmer). =bg= is the ground, set +directly. + +*** UI faces (UI_FACES to role) + +=region=, =hl-line=, =highlight=, =show-paren-match= to state (background tint, +no fg); =isearch= to an active match chip (may invert); =lazy-highlight= to a +quieter match; =isearch-fail=, =show-paren-mismatch= to sig_error; =error= to +sig_error, =warning= to sig_warn, =success= to sig_ok; =link= to sig_link; +=mode-line= to active chrome, =mode-line-inactive=, =line-number=, =fringe=, +=vertical-border= to idle/receding chrome; =line-number-current-line= to active +chrome; =cursor= to its own; =minibuffer-prompt= to control. + +*** Org-mode (face to one of six roles) + +=org-level-1..8= to heading ramp; =org-meta-line=, =org-drawer=, +=org-special-keyword=, =org-property-value=, =org-block-begin-line= / +=org-block-end-line=, =org-ellipsis=, =org-tag=, =org-date=, +=org-document-info-keyword= to markup-recede; =org-block=, =org-code=, +=org-verbatim=, =org-inline-src-block= to code-like (reuse the syntax literal +lane); =org-todo= / imminent deadlines to sig (warm), =org-upcoming-deadline= to +sig_warn, =org-scheduled= / =org-done= to receded/cool (with =org-done= taking +strikethrough); =org-link= to sig_link; =org-quote=, =org-verse= to emphasis +(italic). The org long tail that does not classify seeds to base, as today. + +** Package scope + +The role engine owns three default sources: syntax, UI, and the *org-mode* +package faces. It does not touch the other ~20 bespoke packages in =APPS= (magit, +elfeed, mu4e, and the rest): their curated seed colors stay exactly as today, and +the reseed button *resets them to their existing =APPS= defaults* rather than +role-generating or flattening them to foreground. Generic inventory packages keep +seeding empty/default. So =seed(model)= returns =packages.org-mode= only; the +non-org defaults continue to flow from =seedPkgmap()= over the curated =APPS= +dicts, and reseed re-runs =seedPkgmap()= for them. A =#seedtest= asserts a non-org +bespoke package (e.g. magit) keeps its curated seed after open and after reseed. + +Reseeding preserves the package-face import guarantees already established by +=mergePackagesInto= / =packagesForExport= (unknown app/face preservation, old-JSON +compatibility, recoverable references to deleted palette colors); this spec does +not re-decide them. + +** Heading ramp + +=org-level-1..8= share one hue across three or four lightness steps (the guide +does not spend eight distinct shades). v1 generates the steps with OKLCH: from a +base hue, step lightness down per level (level 1 strongest and bold, deeper levels +quieter), cycling the steps past level 4. This uses the same =colormath.js= shade +generation as the palette above. + +* The seed() operation + +A pure function, =seed(model)= returns ={syntax, ui, packages}= default +assignments: + +- *syntax*: for each =CATS= key, look up its role, resolve the role's swatch to a + hex and its weight, produce =MAP[key]= and =BOLD[key]=. +- *ui*: for each =UI_FACES= face, resolve its role to =UIMAP[face]= ({fg, bg, + bold, italic, underline}), honoring the channel (state roles set bg only). +- *packages.org-mode*: for each org face, resolve its role to a default-dict + ({fg, bg, bold, italic, strike, inherit, height}). + +The output is exactly the shape =exportObj()= already emits (=assignments=, +=ui=, =packages=), so =seed()= produces a =theme.json= the existing import path +loads unchanged. =packages= carries only =org-mode= (Package scope); the non-org +curated defaults flow through =seedPkgmap()= as today. Reseeding dupre is +=seed(model)= combined with the curated package seeds, written to +=dupre-revised.json= (the canonical package-aware artifact — see Surfacing). + +* Surfacing in the tool + +- *Open seeded.* The page's initial =MAP=/=UIMAP= come from =seed(model)= (inlined + defaults), not from hand-set =COLS=/=UIMAP=; =PKGMAP= comes from =seed(model)='s + org defaults plus =seedPkgmap()= over the curated =APPS= dicts for the rest. On + load the syntax tier is guide-correct; the user retunes hues and scrolls to UI. +- *Reseed button.* A "reseed from guide" control reapplies the seeds to all three + owned tiers and resets the non-org packages to their curated =APPS= defaults. It + warns first, naming the scope: "Reseed syntax, UI, and package defaults from the + guide? This discards current color assignments." +- *Canonical artifact.* The reseeded bundle is written to =dupre-revised.json=, + the full package-aware file the README and =build-theme.el= example use. + =dupre.json= stays a legacy minimal import fixture (no =packages= key) unless + deliberately migrated. Importing the reseeded =dupre-revised.json= and opening + fresh land on the same state. + +* Implementation phases + +1. *Seed model + seed() + tests.* Add the palette anchors + OKLCH shade + generation (reusing =colormath.js=), the =ROLES= table, and the three + face-to-role maps as data in =generate.py= (or a sibling inlined like + =samples.py=); write the pure =seed()=. Gate: =#seedtest= asserts representative + faces land on the right swatch/weight/channel in each tier (=bi= blue-grey, + =fnd= gold + bold, =var= base, =op= / =punc= muted, =doc= italic; =region= / + =hl-line= bg-only, =link= underlined, =error= / =warning= / =success= on signal + hues, active vs inactive chrome differentiated; =org-level-1= strongest, + =org-code= the fixed-pitch literal lane, =org-done= receded/struck) AND that a + non-org bespoke package (e.g. magit) keeps its curated seed. +2. *Open seeded + reseed + dupre-revised regen.* Wire the initial state to + =seed(model)= (plus =seedPkgmap()= for the non-org packages); add the all-tier + reseed button with the scope-named overwrite warning, resetting non-org + packages to their =APPS= defaults; regenerate =dupre-revised.json= from the + engine. Gate: =#selftest= still PASS; a headless check that default-on-open + equals =seed(model)=; an *artifact round-trip* check that the regenerated + =dupre-revised.json= imports back to the same seeded state (package defaults and + source markers included); a Chrome eyeball that the seeded syntax tier reads as + a coherent dupre. + +Dependency: v1 reuses the perceptual-metrics =colormath.js= core for OKLCH shade +generation, so it sequences after that spec's Phase 1 (the math foundation). No +second color-math implementation. + +* vNext candidates + +- Per-tier reseed controls (reseed just syntax, just UI, just org) after the + all-at-once v1 button. +- Role-mapping selected non-org bespoke packages beyond org, if their curated + defaults prove worth regenerating from the table. +- The guide-support views and advisories already tracked in =todo.org=. + +* Acceptance criteria + +- *Phase 1*: =seed()= is pure and table-driven; representative faces in all three + tiers resolve to the guide's seed-table treatment; a non-org bespoke package + keeps its curated seed; OKLCH generation produces the family shades and the + heading ramp; =#seedtest= PASS. +- *Phase 2*: the tool opens with syntax/UI/org seeded from =seed(model)= and the + non-org packages on their curated =APPS= defaults; the reseed button restores + all three owned tiers (and resets non-org to curated defaults) behind a + scope-named warning; =dupre-revised.json= is regenerated, matches the compact + mapping (=bi= blue-grey, =fnd= gold), and round-trips back to =seed(model)= on + import; =#selftest= PASS; a Chrome eyeball confirms a coherent dupre. + +* Agreed decisions (v1) + +Answered by Craig (2026-06-08), folded in. + +1. *Palette swatch source.* Generate the shades with OKLCH and fix hues that read + wrong by hand override (Craig overrode the hand-authored recommendation). This + moves OKLCH generation into v1 and makes the feature reuse the + perceptual-metrics =colormath.js= core, sequencing after that spec's Phase 1. +2. *Heading ramp depth.* Three or four distinct lightness steps, cycled across + levels 1-8. +3. *Converter sharing.* Tool-only for v1; =build-theme.el= consumes the exported + =theme.json= regardless. +4. *Reseed scope.* All three owned tiers at once; per-tier reseed is vNext. + +* Review dispositions + +Codex's review (2026-06-08) was accepted in full. The items below note the two +findings that corrected factual errors in the draft and the one open choice this +response resolved; everything else was woven into the body as written. + +- *Corrected — package scope (high-priority finding 2).* The draft said non-org + packages "seed to the default foreground." Wrong: =APPS= carries curated seeds + for ~20 bespoke packages. Rewritten so the role engine owns only org among + packages and the rest keep their curated =APPS= defaults, with reseed resetting + to those (see Package scope). +- *Corrected — canonical artifact (high-priority finding 3).* The draft named + =dupre.json=; the package-aware bundle is =dupre-revised.json=. Replaced + throughout, with =dupre.json= noted as the legacy minimal fixture. +- *Resolved — OKLCH dependency (high-priority finding 1).* The review offered two + routes to OKLCH-in-v1 (depend on the perceptual-metrics core, or build a local + minimal helper). Chose the dependency, to avoid a second color-math + implementation. + +* Sources + +- =scripts/theme-studio/theme-coloring-guide.org= — the seed table and Shade + budget this engine executes. +- =scripts/theme-studio/generate.py= — =CATS=, =UI_FACES=/=UIMAP=, =APPS= / + =seedPkgmap=, =exportObj= (the target shape). +- =docs/design/theme-studio-perceptual-color-metrics-spec.org= — the + =colormath.js= core that v1 OKLCH shade generation uses. + +* Review and iteration history + +** 2026-06-08 Mon @ 19:00:17 -0500 — Codex — reviewer +- *What changed or was recommended:* Created the implementation-readiness review + and marked the spec =Not ready=. The review treats the inline =cj: comment= + blocks as answered product input, then flags three remaining implementation + blockers: the palette-source answer moves OKLCH generation into v1 while the + phase plan still says vNext; the package seeding scope conflicts with the + current many-bespoke-app =APPS= surface; and the spec names =dupre.json= even + though the current README/build path uses =dupre-revised.json= as the full + package-aware artifact. +- *Why:* The role-table direction is sound, but those conflicts would force the + implementer to decide dependency order, package-default preservation, and + which bundled JSON file to write. +- *Artifacts:* the review file (consumed and deleted on response; see Review dispositions). + +** 2026-06-08 Mon @ 19:06:06 -0500 — Claude Code — responder +- *What changed:* Folded Craig's four answers into Agreed decisions and accepted every Codex finding. OKLCH shade generation moved into v1 (reusing =colormath.js=, sequenced after perceptual-metrics Phase 1); package scope rewritten so the engine owns only org and the ~20 non-org bespoke packages keep their curated =APPS= seeds (reseed resets to those); =dupre.json= references replaced with =dupre-revised.json= (canonical, package-aware) and =dupre.json= noted legacy; phases collapsed to two v1 phases with a vNext section; added the non-org-preserved and artifact-round-trip tests, the scope-named reseed warning, and the import-guarantee citation. +- *Why:* The draft described an older, narrower package surface and the wrong dupre artifact; left uncorrected, an implementer could wipe curated package defaults or write the wrong file. Two of the three blockers fixed errors in my draft. Rubric Draft to Ready. +- *Artifacts:* consumed and deleted the review file; see Review dispositions and Agreed decisions. + +** 2026-06-08 Mon @ 19:11:06 -0500 — Codex — reviewer +- *What changed or was recommended:* Re-reviewed the incorporated spec against + the current generator, README, task tracking, and previous findings. Assigned + =Ready=: the OKLCH dependency, non-org package seed preservation, and + =dupre-revised.json= artifact story are now explicit. Fixed one stale + non-blocking source note that still referred to Phase 3. +- *Why:* The spec now gives an implementer a coherent v1: two phases, explicit + dependency on perceptual-metrics Phase 1, table-driven =seed()=, open-seeded + and reseed behavior, package preservation rules, artifact round-trip tests, + and vNext boundaries. +- *Artifacts:* No review file written; no blocking findings. diff --git a/docs/theme-studio-color-families-spec.org b/docs/theme-studio-color-families-spec.org new file mode 100644 index 00000000..ce3b7a9f --- /dev/null +++ b/docs/theme-studio-color-families-spec.org @@ -0,0 +1,202 @@ +#+TITLE: theme-studio Color Families — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-09 + +* Metadata +| Status | Ready (Craig confirmed 2026-06-10); review incorporated, hex grouping | +| Owner | Craig | +| Reviewer | Codex | +| Related | [[file:../todo.org][todo.org: theme-studio color families]] | + +* Summary + +Show the palette as color families: colors grouped into horizontal strips by their actual color (OKLCH hue), each strip ordered dark to light, with a per-strip control to generate a symmetric tonal ramp (N gives base ±N) from the strip's most-saturated color. Grouping is derived from the hex every render, so renaming a color to anything never moves it between strips. The flat palette underneath stays exactly what it is today — an editable list — and families are a view over it, not a new owner of the colors. + +* Problem / Context + +The ramp generator (palette-ramps v1) can produce blue-2, blue-1, blue, blue+1, blue+2, but the moment they land in the palette the relationship is gone: five chips in a wrapping row, sorted by nothing. To widen or narrow the ramp you delete and regenerate; to recolor it you edit each chip. Nothing shows the structure the ramp math produced. + +The designer thinks in families — "the blues", "the warm grays" — and wants to grow or shrink a ramp in place. Critically, the family a color belongs to is a fact about the *color*, not its label: renaming blue+1 to "azure" or "my favorite" must never change which group it sorts into. So grouping has to come from the hex, not from a naming convention. + +* Goals and Non-Goals + +** Goals +- Group the palette into families by OKLCH hue (from the hex), and render each as a horizontal strip, dark to light. +- Per-strip control to generate a symmetric ramp (N gives base ±N) from the strip's base; regenerate is authoritative. +- Renaming any color never changes its family or sort position. +- Sort families by hue across the panel and colors by lightness within; pin the fg/bg ground strip and neutral grays to the front. + +** Non-Goals +- No name-based grouping, no step-name grammar, no import inference from names — grouping is purely by hex. +- No theme.json format change — the palette stays a flat, ordered, individually-editable list. +- No preserving hand-edits to generated steps across a regenerate (regenerate overwrites — Craig's call). +- No asymmetric ramps, no per-family stepL/chroma-ease, no harmonic fill, no manual family ordering in v1. + +** Scope tiers +- v1: hue-grouped family strips over the existing flat palette; per-strip ramp-generate control; ground strip from the bg/fg assignments; hue/lightness sort with neutrals pinned; the family control replaces the standalone ramp panel. +- Out of scope: per-family stepL/chroma-ease, harmonic fill, asymmetric ramps, manual ordering. +- vNext: tunable clustering, per-family knobs, asymmetric ramps. + +* Design + +*The palette stays flat.* This is the pivot from the first draft. The palette remains the flat =[[hex,name]]= list it is today, with the same per-chip operations — edit hex, rename, remove. Families do *not* own the colors. A "family" is a derived grouping computed from the hexes on every render, like sorting. Because it's derived from the hex, renaming a color can't change its group, there's no structure to store, and there's nothing to reconstruct on import. Most of what the first-draft review flagged (name grammar, import inference, chip-ownership transfer) simply doesn't exist in this model. + +*Grouping by hue.* Convert each palette color to OKLCH. A color whose chroma is below a neutral threshold (C < 0.02) has no meaningful hue; it joins the neutral group. The rest cluster by hue *proximity*, not fixed bins: sort by hue, walk the circle, and start a new family wherever the gap to the previous color exceeds a gap threshold (25°), wrapping at 360. Two blues at 250° and 256° stay together; a blue and a green a hue-gap apart split. Each family's *base* is its most-saturated member (tie-break toward mid-lightness), the natural anchor for a ramp. + +*The ground strip.* The foreground and background are the =bg= and =p= (plain/default-fg) assignments — =MAP.bg= and =MAP.p= — not palette names. The ground strip is synthesized from those two hexes and pinned first, even if neither hex is a palette entry (an imported theme can set them to colors absent from the palette). Editing a ground swatch writes the assignment hex (and the matching palette entry, if one exists at that hex). Neutral grays (low chroma, not the ground pair) form their own strip(s) pinned after the ground. + +*The ramp-generate control.* Each strip carries a count input. Its value reflects the family's current per-side reach (the count of members on the busier side of the base by lightness). Setting it to N regenerates the family as a clean symmetric ramp: =ramp(base, {n:N, stepL, chromaEase})= for N≥1, or just the base for N=0, *replacing* the family's current members. Regenerate is authoritative — same-hue colors that were there, hand-added or hand-edited, are replaced. This is the one family-level action; everything else is still per-chip. + +*References across a regenerate.* A regenerate changes member hexes, and assignments point at hexes. Map old members to new steps by lightness rank: an old member and the new step at the same signed offset are the "same" position, so repoint references old→new for every surviving position. A position *removed* by lowering N (its old hex has no new counterpart) leaves its references showing "(gone)" — a visible stale reference the designer can re-point, never a silent jump to a surprise color (the review's robustness point). =repointHex= already does the old→new sweep across syntax/UI/package assignments. + +*Sorting is display-only.* Strips order by base hue (ground first, then neutrals, then chromatic families by hue; ties by base lightness then hex). Within a strip, colors order by OKLCH lightness. The stored palette keeps its existing order — export emits it unchanged — so theme.json diffs stay deterministic and the sort never rewrites the file. + +The two altitudes: +- *For the designer:* the palette is a stack of strips, one per hue family, each dark to light with the base marked, ground pinned at the top. A count input on each strip fans it to ±N or collapses it to the base. Rename a swatch to anything — it stays put. Find "the blues" by finding the blue strip. +- *For the implementer:* pure functions in app-core.js — =familiesFromPalette(palette, groundHexes)= returns the ground strip plus hue-clustered families with a base each; =sortFamilies= orders them; =regenFamily(baseHex, n, opts)= returns the ramp members (handling n=0 without calling =ramp()=); =stepRepointPlan(oldMembers, newMembers)= returns the old→new map and the removed set. The DOM (strip rendering, the count control, calling =repointHex= per the plan) stays in app.js. The existing =ramp()= and =repointHex()= are reused unchanged. + +* Alternatives Considered + +** Hex-derived families over a still-flat palette (chosen) +- Good, because grouping from the hex is rename-proof, needs no stored structure, and requires no import inference — the first draft's hardest contracts vanish. +- Good, because the palette stays individually editable, so per-chip rename/remove/edit keep working unchanged. +- Bad, because hue clustering has a threshold; an awkward gap can split a family the eye reads as one, or merge two. +- Neutral, because regenerate is still authoritative, so generating a ramp over a hue cluster replaces whatever was there. + +** Name-derived families (first draft, rejected) +- Bad, because the group would depend on the label; renaming blue+1 would move it, which Craig explicitly ruled out. It also needs a brittle step-name grammar and import inference (the review's top blocker). + +** Families as a stored structure in theme.json +- Good, because base/N round-trip exactly. +- Bad, because it's a format change with a migration, and every flat-palette consumer (build-theme.el, the assignments) would need the new shape. + +** Hue clustering by fixed bins vs. proximity gaps (gaps chosen) +- Good (gaps), because nearby hues group regardless of where they fall, with no arbitrary bin edge splitting a near-pair. +- Bad (gaps), because a chain of evenly-spaced hues never breaks; mitigated by the gap threshold and the small palette size. + +** Removed-step references: visible "(gone)" vs. silent repoint (gone chosen) +- Good (gone), because a stale reference is visible and recoverable; a silent jump to an unexpected swatch is worse (the review's robustness note). +- Bad (gone), because the designer must re-point manually after shrinking a family that something referenced. + +* Decisions + +** Group by OKLCH hue from the hex, never by name +- State: accepted +- Context: the family is a fact about the color; renaming must not move it. The first-draft name convention failed this and needed a grammar + import inference. +- Decision: We will derive families by clustering palette colors on OKLCH hue every render; names are labels only and never affect grouping or sort. +- Consequences: easier — rename-proof, no grammar, no import inference, no stored structure; harder — clustering needs a tuned hue-gap threshold. + +** The palette stays flat and individually editable; families are a derived view +- State: accepted +- Context: making families own the colors would transfer ownership away from the per-chip controls and force an import/edit/delete contract. +- Decision: We will keep =PALETTE= a flat editable list with its current per-chip rename/remove/edit; families are computed for display, not stored. +- Consequences: easier — existing controls and export are untouched, no migration; harder — the count control is the only family-level action, so "regenerate" must reconcile with loose hand-added colors (it replaces them). + +** A family generates a symmetric ramp: base ±N (0-4) +- State: accepted +- Context: the designer asked for a per-side count where 2 means ±2 and 3 means ±3. +- Decision: We will give each strip a count input; N=0 is the base alone, N=k generates base-k..base+k via =ramp()=; =regenFamily= handles N=0 without calling =ramp()= (which clamps to 1-4). +- Consequences: easier — one control, predictable; harder — asymmetric reach isn't expressible in v1. + +** Regenerate is authoritative; repoint survivors by lightness rank, removed steps go "(gone)" +- State: accepted +- Context: regenerate changes member hexes; references point at hexes; lowering N drops the extremes. +- Decision: We will replace the family's members on regenerate, repoint references for each surviving position (matched by signed lightness rank) old hex → new hex via =repointHex=, and leave references to removed positions as a visible "(gone)" rather than reassigning them. +- Consequences: easier — surviving references follow, removed ones are recoverable; harder — a hand-edited step hex and a reference to a dropped step are both lost/stale (accepted; the latter is visible). + +** The ground strip is synthesized from the bg/fg assignments +- State: accepted +- Context: fg/bg are the =MAP.bg= / =MAP.p= assignments, which may be hexes not present in the palette. +- Decision: We will synthesize a fixed two-swatch ground strip from =MAP.bg= and =MAP.p=, pinned first, no count control; editing a ground swatch writes the assignment hex (and the matching palette entry if one exists). A palette chip whose hex equals the ground hex is shown in the ground strip, not duplicated into a family. +- Consequences: easier — the ground is explicit and protected, imported themes with assignment-only ground colors work; harder — the strip is a special case, and ground-vs-family de-duplication is by hex. + +** Sort families by hue, colors by lightness, neutrals pinned; thresholds pinned +- State: accepted +- Context: ordering should come from the hex; clustering and neutral detection need fixed values so tests don't bake accidental ones. +- Decision: We will sort strips ground-first, then neutral (C < 0.02) strips, then chromatic families by base hue (ties by base lightness then hex); colors within a strip by OKLCH lightness; hue clustering splits on a 25° adjacent-hue gap. Sorting is display-only; the stored palette order is unchanged on export. +- Consequences: easier — deterministic spectrum order, stable theme.json diffs; harder — manual drag-ordering is dropped, and the thresholds are tuning the eye may want to revisit (vNext). + +** The family count control replaces the standalone ramp panel +- State: accepted +- Context: the v1 ramp panel and the per-strip count do the same job two ways. +- Decision: We will remove the standalone ramp panel; fanning a color into a ramp happens from its strip (add a color → it appears as a singleton strip → raise its count). +- Consequences: easier — one way to ramp, less UI; harder — churn over the few-day-old ramp panel, and "ramp from an arbitrary typed hex" now means "add the color first, then fan it". + +* Implementation phases + +** Phase 1 — Family model (pure) +=familiesFromPalette(palette, groundHexes)=, =regenFamily(baseHex, n, opts)=, =stepRepointPlan(oldMembers, newMembers)= in app-core.js. =familiesFromPalette= returns the ground strip (from groundHexes, de-duped by hex) plus hue-clustered families each with a base. Tests: a spectrum splits into families, a near-pair stays together, neutrals separate, ground hexes absent from the palette still form the strip, =regenFamily= handles n=0 (base only) and n≥1 (ramp), =stepRepointPlan= maps survivors and lists removed. No UI. + +** Phase 2 — Sort (pure) +=sortFamilies= orders ground-first, neutrals (C<0.02) next, chromatic by hue with the lightness/hex tie-breakers; a helper orders colors within a strip by lightness. Tests cover a spectrum, an all-neutral set, ties, and the 25° gap boundary. + +** Phase 3 — Strip rendering (read-only) +Render the palette panel as the pinned ground strip plus hue-sorted family strips, base marked, dark to light. Reuse chip styling; the existing per-chip controls (rename/remove/edit) keep working since the palette is still flat. No count control yet. + +** Phase 4 — Count control + regenerate +A per-strip count input (0-4). On change, =regenFamily=, apply =stepRepointPlan= (repoint survivors via =repointHex=, leave removed references "(gone)"), update =PALETTE=, re-render. Browser gate: count up adds symmetric steps, count down drops the extremes and a reference to a dropped step reads "(gone)", a reference to a surviving step follows the new hex. + +** Phase 5 — Ground strip + base edit + retire the ramp panel +Synthesize the ground strip from =MAP.bg= / =MAP.p=, editable, pinned, de-duped. Editing a family's base regenerates it (same repoint plan). Remove the standalone ramp panel and its gate; adding a color yields a singleton strip that fans via its count. Gate the ground-strip derivation (including assignment-only ground hexes) and the base-edit repoint. + +** Phase 6 — Warnings, seeding, export, README +Keep =paletteWarnings= on the flattened palette but exempt adjacent same-family ramp steps from the too-similar warning (they're intentionally close). Confirm package seeding still reads the flat palette (families are display-only, so =seedPkgmap= is unchanged). Confirm export emits the flat palette unchanged and import needs no reconstruction. Update README. Gate an import → render → export round-trip leaving the palette JSON identical. + +* Acceptance criteria +- [ ] The palette renders as hue-grouped strips, base marked, dark to light, ground pinned first. +- [ ] Renaming any color to any string never changes its strip or sort position. +- [ ] A per-strip count control sets base ±N live; raising it adds symmetric steps, lowering removes the extremes. +- [ ] On regenerate, references to surviving steps follow the new hex; references to removed steps read "(gone)", never a silent reassignment. +- [ ] The ground strip is synthesized from the bg/fg assignments (even when those hexes aren't palette entries), pinned first, with no count control; a palette chip at a ground hex isn't duplicated. +- [ ] Families sort by base hue (ground and neutrals pinned); colors sort dark to light; the stored palette order is unchanged on export. +- [ ] Existing flat theme.json files load and re-export byte-stable through the family view. +- [ ] Unit tests cover the family model, regen, repoint plan, and sort; browser gates cover the count control (up/down + removed-step "(gone)"), the ground strip, the base-edit repoint, and the round-trip. +- [ ] README documents families, the ground strip, regenerate behavior, removed-step references, and the removal of the standalone ramp panel. + +* Readiness dimensions +- Data model & ownership: the flat palette stays the persisted, individually-editable truth; families are a derived display grouping computed from the hex each render; nothing new on disk; the ground strip reads/writes the bg/p assignments. +- Errors, empty states & failure: a malformed chip hex is excluded from clustering (no OKLCH) and shown as a loose/neutral entry rather than crashing the panel; a regenerate of a bad base yields nothing for that family (per =ramp='s bad-hex); an empty palette shows only the ground strip; references to removed steps degrade to a visible "(gone)", never silent. +- Security & privacy: N/A — local color math. +- Observability: the strips are the observability — grouping, ramp reach, and the base are visible; "(gone)" surfaces stranded references. +- Performance & scale: tens of colors, a few families; clustering is one sort + a linear pass, regenerate is one =ramp()= + a repoint sweep. Instant. +- Reuse & lost opportunities: reuse =ramp()=, =repointHex()=, the chip styling, =optList= (dropdowns still read the flat palette), and colormath OKLCH. Don't reimplement the ramp or the re-point. +- Architecture fit & weak points: pure family logic in app-core.js (importable, tested like the ramp core); strip DOM in app.js; integration points are the palette panel, the assignment dropdowns (unchanged, still read the flat palette), the bg/p assignments (ground strip), and export/import (unchanged). Weak point: the hue-gap and neutral thresholds — pinned defaults, tunable in vNext. +- Config surface: per-family N (0-4); hue-gap (25°) and neutral-chroma (0.02) thresholds and stepL/chroma-ease (0.08/0.5) are pinned constants in v1. +- Documentation plan: the README grows a "color families" section; the color-harmony explainer carries the why. +- Dev tooling: =make theme-studio-test= covers it via new node tests + browser gates; no new tooling. +- Rollout, compatibility & rollback: additive and display-only over the existing palette; theme.json unchanged, so old themes load and re-export stable; rollback is reverting the panel; no migration. +- External APIs & deps: none — pure color math. + +* Risks, Rabbit Holes, and Drawbacks +- The hue-gap threshold is the fuzzy core now (not name inference): an awkward gap can split a family the eye reads as one, or merge two adjacent hues. Dodge: a sane 25° default, small palettes, and a vNext tuning knob. +- Regenerate-authoritative replaces loose same-hue colors the designer may have hand-placed in that hue band. Accepted per the regenerate decision, but the strip UI should make "this control rewrites the family" obvious before it's used. +- Removed-step references going "(gone)" is the deliberate, recoverable choice; the risk is forgetting to also repoint survivors on the base-edit path (not just the count path). Both paths run the same repoint plan. +- Retiring the just-shipped ramp panel is churn; the count control must cover the same discovery path (add a color, fan it from its strip). + +* Review dispositions + +Only modified and rejected review recommendations are listed; everything else from the Codex review is accepted and folded into the body above. + +- *Rejected as framed — "family inference from flat palette names" / the name-grammar section.* Craig's directive is to group by hex, never by name, so renaming is free. There is no step-name grammar and no import inference to specify; the review's underlying need (a deterministic grouping contract) is met instead by the hue-clustering contract (25° gap, 0.02 neutral threshold). The whole name-parsing surface is designed out. +- *Modified — "chip-level edits need a new ownership contract".* Accepted the concern, changed the resolution: rather than defining transfer of ownership to family objects, the palette stays flat and individually editable, so per-chip rename/remove/edit keep working as today. Only drag/move-reorder is dropped (the sort is deterministic). The count control is the sole family-level action. +- *Modified — "two Decisions open/proposed".* Both resolved per Craig: flat persistence accepted, the standalone ramp panel is removed (not left for Phase 6). +- *Accepted with a change of mechanism — ground strip, removed-step policy, n=0, neutral threshold, sort tie-breakers, palette warnings, package seeding, README in acceptance criteria.* All folded as written, adapted to the flat-palette/hex-grouping model (e.g. seeding is unchanged because the palette is still flat). +- *Deferred — the review's implementation-task drop-in block.* Per spec-response, tasks are created only after the author confirms Ready (Phase 6); they are not yet logged. + +* Review and iteration history +** 2026-06-09 Tue @ 22:50:43 -0500 — Craig — author +- What: initial draft (name-derived families owning the palette). +- Why: live color families restructure the palette panel and supersede part of the shipped ramp UI — design-uncertain and cross-cutting. +- Artifacts: this spec; the palette-ramps v1 build it builds on. +** 2026-06-09 Tue @ 23:31:28 -0500 — Codex — reviewer +- What changed or was recommended: marked =Not ready=; flagged two open decisions, an underspecified name/import inference contract, the ground-strip source of truth, chip-level ownership in family mode, and removed-step assignment behavior. +- Why: the first draft moved palette ownership to name-derived families without pinning import, edit, delete, and reference-healing behavior. +- Artifacts: theme-studio-color-families-spec-review.org (consumed and deleted on response). +** 2026-06-09 Tue @ 23:40:35 -0500 — Claude Code (dotemacs) — responder +- What changed or was recommended: folded the Codex review and Craig's three clarifications. Resolved both open decisions (flat persistence; remove the standalone ramp panel). Pivoted the whole model from name-derived families to hex-derived families over a still-flat, still-editable palette — which designs out the name-grammar/import-inference blocker and the chip-ownership blocker. Pinned the ground strip to the bg/p assignments, the removed-step references to a visible "(gone)", n=0 handled without =ramp()=, the 0.02 neutral and 25° hue-gap thresholds, the sort tie-breakers, the warnings exemption, and the unchanged package seeding. See Review dispositions for the two reframed findings. +- Why: Craig's "group by hex, never by name; rename anything freely" both answers the review's hardest questions and simplifies the design — the palette no longer changes ownership, so most of the missing contracts disappear. +- Artifacts: review file deleted; Review dispositions section; this entry. Remaining fuzzy area is the hue-clustering threshold (pinned default, vNext tuning). +** 2026-06-10 Wed @ 00:03:39 -0500 — Codex — reviewer +- What changed or was recommended: re-reviewed the updated spec and found it implementation-ready. No new blocking review notes; the prior blockers are resolved by the hex-derived grouping model, accepted flat-palette persistence, accepted ramp-panel removal, explicit ground-strip source, retained per-chip flat-palette ownership, removed-step "(gone)" policy, pinned clustering/sort thresholds, and README/test acceptance criteria. +- Why: the updated design now gives the implementer stable behavior for grouping, regeneration, references, ground colors, import/export, and UI ownership while fitting the current =app-core.js= / =app.js= split. +- Artifacts: no new review file; this Ready verification entry. diff --git a/docs/theme-studio-palette-columns-spec.org b/docs/theme-studio-palette-columns-spec.org new file mode 100644 index 00000000..d1dba035 --- /dev/null +++ b/docs/theme-studio-palette-columns-spec.org @@ -0,0 +1,119 @@ +#+TITLE: Theme-Studio Spec: Declared Palette Columns +#+DATE: 2026-06-10 + +* Metadata + +- Status: Draft (Ready after Craig's read) +- Source: design discussion 2026-06-10 (Craig + session), folding in the shipped ramps v1 and color-families work it supersedes +- Related: [[file:theme-studio-palette-ramps-spec.org][palette-ramps spec]] (ramp math, floor/L_max safety — still in force), [[file:theme-studio-color-families-spec.org][color-families spec]] (superseded in its grouping half), [[file:design/theme-studio-color-harmony.org][color-harmony explainer]] + +* Summary + +The palette stops being a flat bag of colors grouped by hex inference and becomes a declared structure: a ground pair (bg, fg) plus user-created columns, each column a parametric ramp (name, base hex, count, knobs) whose steps are always computed, never stored. Membership is known by construction, naming is mechanical (name+N / name-N), ordering is canonical everywhere, and the file format saves the structure explicitly. Theme-studio launches empty (bg and fg only); legacy flat palettes import through a one-time LCCL shim. The declared structure is the substrate the future generate-palette (harmonic fill) feature needs. + +* Problem / Context + +The shipped color-families feature derives groups from hexes every render (LCCL clustering). That solved display grouping for arbitrary flat palettes, but membership stays inferred: the geometrically irreducible cases (yellow+2 on the distinguished palette) need a per-hex override layer, renames and regenerates need repoint machinery to keep names and groups coherent, and a generate-palette feature would have to emit colors that the inference then re-groups, hoping it agrees with the generator's intent. + +When the user creates every group themselves, inference is the wrong tool. A column created by hitting + and ramping a chosen base is declared; nothing needs deriving, and the LCCL machinery retires to one job it is actually right for: proposing columns when importing a legacy flat palette. + +* Goals and Non-Goals + +** Goals + +1. Palette = ground pair + declared columns; column steps parametric (computed from base + count + knobs, never stored). +2. Mechanical naming: column name + offset (=blue+1=, =blue-2=); renaming a column renames its steps atomically. +3. Canonical ordering everywhere (strips, dropdowns, export): bg column, fg column, then user columns left to right. +4. Explicit v2 file format; old flat files import through a confirm-gated LCCL shim. +5. Launch state: bg and fg only, no columns, packages and UI faces at inherit/ground defaults. +6. bg and fg are themselves columns (pinned first), ramp-able like any other. +7. Foundation for generate-palette: "N columns, here are my chosen colors, fill the rest harmoniously." + +** Non-Goals + +- Harmonic fill itself (vNext; see the color-harmony explainer). +- Per-step hand-tweaks. Parametric is provisional by agreement: if it fights the actual workflow, we revisit (stored steps or per-step deltas). +- Symbolic references (column+offset identity for assignments). References stay hexes with the existing repoint machinery; symbolic refs are a possible vNext. +- Any change to the ramp math, contrast floor, L_max, or picker safety machinery — all carried forward as-is. + +* Design + +** Data model + +#+begin_example +palette = { + bg: {name: 'bg', base: '#141210', count: 0, stepL: 0.08, chromaEase: 0.5}, + fg: {name: 'fg', base: '#f2efe9', count: 0, stepL: 0.08, chromaEase: 0.5}, + columns: [{name, base, count, stepL, chromaEase}, ...] +} +#+end_example + +A column's swatches are =steps(column)= → =ramp(base, {n: count, stepL, chromaEase})= plus the base, ordered and named mechanically. =count= 0 is a single-swatch column (a one-off accent). bg and fg are columns with fixed identity (cannot be removed, names fixed) but ramp like any other: =bg+1=/=bg+2= are the hl-line/mode-line tint slots, =fg-1=/=fg-2= the dimmed-text slots; steps that fall outside the gamut clamp with the existing badge. + +Column identity is positional (the array); the display name is the only name. Renaming changes =name= and thereby every step label. Step hexes are deterministic from (base, count, knobs), so two renders never disagree. + +** References and regenerate + +Assignments (syntax MAP, UI faces, package faces) keep storing hexes. Editing a base or count regenerates the column and runs the existing repoint plan: surviving steps repoint by signed lightness rank, removed steps leave the reference on its now-gone hex with the visible "(gone)" dropdown entry. This is the shipped =stepRepointPlan= behavior, re-grounded on declared columns. + +** UI + +- The palette area renders the pinned bg column, the pinned fg column, the user columns left to right, then a trailing empty column whose header is "+". +- Hitting + opens the existing picker to choose a base. The new column lands with the single base swatch (count 0) and the default hue-word name (red, orange, gold, green, cyan, blue, purple, pink, gray from the base's OKLCH hue; collision-suffixed). The count control then expands it to base ±N. +- Each column keeps: editable name (header), count control (0-4), per-chip select for assignment, base editable through the picker. Per-chip rename/remove disappear for step chips (names are mechanical, removal happens via count); a user column with count 0 can be removed whole. +- Display order within a column: lightest at top to darkest at bottom, matching the dropdown order (DECISION 9 below — this flips the current dark-to-light strip direction). +- Dropdown chooser order: default entry, then bg and its steps (lightest to darkest), fg and its steps, then each user column lightest to darkest, left to right (DECISION 10 below places the base within its column run). + +** Launch state and the baked page + +theme-studio.html bakes an empty v2 theme: bg #141210 (warm near-black), fg #f2efe9 (warm near-white), no columns. The dupre palette no longer ships baked in; it loads through import like any saved theme. Package-face seeds already degrade gracefully (=pname= on an unknown name returns null), so seeded faces keep their structural attrs (bold/box/etc.) and inherit ground colors until the designer assigns; the seeding-engine task later makes seeding palette-aware. + +** Export / import + +- Export writes the v2 structure verbatim (plus assignments, locks, packages as today). Round-trip is byte-identical, same gate discipline as the flat format had. +- Import detects the shape: v2 structure loads directly; a legacy flat palette triggers the shim — run LCCL once to propose columns (each proposed column: inferred base = most-saturated member, count from member span, name from the longest-common name prefix or hue word), show the proposal, and only commit on confirm. Members the clustering can't reconcile with a parametric ramp land as count-0 single columns rather than being silently bent. +- The shim is the LCCL code's retirement home: it runs at import only, never at render. + +* Decisions + +1. Parametric columns (2026-06-10, Craig): steps computed, never stored. Provisional — abandoned or amended if it fights real use. +2. bg and fg ramp too (Craig): one bg column and one fg column, pinned first. +3. Explicit structure storage (Craig): no name-grammar parsing; breaking format change accepted. +4. Legacy import via LCCL shim (Craig): confirm-gated, propose-then-commit. +5. + lands a single-swatch column (Craig): picker chooses the base; count expands it. No surprise default ramp. +6. Empty launch (Craig): bg and fg only; packages/UI at defaults until assigned. +7. References stay hexes + repoint (session, unvetoed): least churn, machinery already gate-covered. +8. Default column name = hue word from the base's OKLCH hue (session, unvetoed). +9. Column strips display lightest→darkest top→bottom, matching the dropdown (session — flips the current strip direction; flag to Craig on read). +10. Dropdown: each column appears as one run, lightest to darkest, with the base in its natural lightness position within the run (session; Craig said "bg color, fg color, then lightest to darkest each column" — flag on read whether the bg/fg BASES should instead lead before any steps). + +* Alternatives Considered + +** Flat ordered list with name-grammar membership (rejected) + +Saving a flat list where =blue+2..blue-2= adjacency and suffix parsing carry membership honors "saved in order" but resurrects the name-grammar inference the families spec already rejected. Explicit structure is strictly simpler; names become output. + +** Stored steps with hand-tweaks (deferred, not rejected) + +Keeping steps as real palette entries preserves per-step nudging but re-opens every coherence question (drifted steps vs mechanical names, regenerate vs hand edits). Parametric first; revisit on real-use pain per Decision 1. + +** Keep LCCL at render with declared hints (rejected) + +The hint-override task taken to its conclusion. Declaring membership at creation makes both the inference and its overrides unnecessary. + +* Implementation Phases + +Each phase lands TDD with the usual commit-per-green-phase; =make theme-studio-test= green throughout. + +1. *Core model.* Column type, =steps()=, mechanical naming, canonical ordering, v2 export/import round-trip. Pure functions in app-core.js, node tests. +2. *Renderer.* Pinned bg/fg columns, user columns, the + column, picker-for-base flow, count expansion, column rename/remove. Gate: #columnstest. +3. *References.* Dropdown canonical ordering, repoint on base/count edits re-grounded on columns, "(gone)" behavior. Adapt #counttest/#baseedittest. +4. *Import shim.* Flat-shape detection, LCCL proposal, confirm gate, conversion. Gate: #shimtest (a fixture flat palette converts to the expected columns). +5. *Launch + bake.* Empty v2 default in generate.py, seeds degrade verified, README rewrite for the new model. Adapt #roundtriptest, #familytest retires or becomes #columnstest coverage. +6. *Test-surface reconciliation.* Sweep remaining gates and node tests for flat-palette assumptions; retire dead LCCL render paths (clustering stays, callable from the shim only). + +* Task fallout (todo.org, at breakdown time) + +- The [#C] per-hex family-hint override task dies (CANCELLED — membership is declared now). +- The color-families manual sign-off items change shape: grouping-reads-right becomes moot (nothing is inferred); regenerate/"(gone)" checks carry over. +- The seeding-engine task gains a dependency note (palette-aware seeding presumes columns exist to reference). diff --git a/docs/theme-studio-palette-generator-spec.org b/docs/theme-studio-palette-generator-spec.org new file mode 100644 index 00000000..b4814706 --- /dev/null +++ b/docs/theme-studio-palette-generator-spec.org @@ -0,0 +1,241 @@ +#+TITLE: Theme Studio Palette Generator -- Spec +#+AUTHOR: Codex +#+DATE: 2026-06-13 +#+TODO: TODO | DONE SUPERSEDED CANCELLED + +* Metadata +| Status | draft | +|----------+-------| +| Owner | Codex | +|----------+-------| +| Reviewer | Craig | +|----------+-------| +| Related | [[file:../todo.org::*theme-studio palette generator][theme-studio palette generator task]] | +|----------+-------| + +* Summary +Theme Studio needs a palette generator designed for dense Emacs themes, not a generic graphic-design palette toy. It should start from bg/fg, generate editable color columns in OKLCH, constrain candidate colors for readable text and UI use, and let the user preview/apply changes without losing existing assignments. + +The generator is a panel over the existing palette-column model. It proposes columns, spans, and lightness/chroma bands; the user chooses whether to append, replace, or regenerate selected unlocked columns. + +* Problem / Context +Theme Studio now has stable palette columns, spans, contrast metrics, OKLCH editing, and default face inventories. That gives enough substrate to generate palettes, but the workflow is still manual: each accent must be chosen, spanned, checked for contrast, checked for distinguishability, assigned, and then adjusted by eye. + +Most palette generators optimize for attractive swatches, posters, or branding. Emacs themes have different constraints. The colors are mostly foreground text over a fixed dark or light ground, often shown in dense code, with UI backgrounds, selections, diffs, search hits, diagnostics, and package faces layered on top. A pretty palette can still be unusable if several accents collapse in low chroma, miss the bg contrast target, or produce harsh UI tints. + +The generator should therefore treat color theory as a candidate source, then filter candidates through Theme Studio's theme-specific constraints. + +* Goals and Non-Goals +** Goals +- Generate editable palette columns from bg/fg, a base hue, an accent count, span count, chroma controls, and contrast targets. +- Keep OKLCH as the default generation space so lightness and chroma behave perceptually. +- Offer harmony modes that are useful for syntax themes: syntax-balanced, analogous, split-complementary, triadic, tetradic, warm/cool balanced, and manual hues. +- Preview proposed palettes before applying them. +- Apply proposals in scoped ways: append as new columns, replace selected columns, regenerate spans only, or regenerate unlocked generator-owned columns. +- Preserve stable column ids and existing assignments where possible. +- Expose enough metrics to explain why generated colors were clamped, muted, rejected, or adjusted. + +** Non-Goals +- Automatically assign every face in the theme. Seeding and advisory features remain separate. +- Import palettes from external files. Import organization has its own spec/task. +- Replace the existing manual picker or per-column span controls. +- Generate terminal/ANSI palettes in v1. +- Support advanced appearance models like CAM16-UCS or Jzazbz in v1. + +** Scope tiers +- v1: OKLCH-based generator panel, candidate preview, apply modes, generator-owned column metadata, tests, and README docs. +- Out of scope: fully automatic theme assignment, image/screenshot extraction, CSS/theme import, terminal colors. +- vNext: OKHSL/OKHSV editing/generation mode, low-contrast preset bands, CVD-aware candidate scoring, named style presets, terminal/ANSI palette derivation. + +* Design +For the user, the generator is a compact panel above the palette columns. The user sets the ground pair, picks a scheme, picks a base hue or manual hues, chooses accent count and span count, adjusts chroma/style bias, chooses a contrast target, then clicks preview. The preview renders proposed columns as temporary strips with metric badges. Applying the preview either appends columns, replaces selected columns, regenerates spans on selected columns, or updates generator-owned unlocked columns. + +For the implementer, the generator is a pure planning layer plus a thin DOM panel. The planner accepts the current palette state and a generator config, returns a proposal object, and never mutates global state. The proposal contains column plans with stable ids, base hexes, generated member hexes, names, clamp flags, contrast/readability diagnostics, and rejected candidate notes. The apply step converts an accepted proposal into the existing palette-column entries and uses the existing repoint behavior where a column is being replaced. + +The generator should use OKLCH for v1. It chooses hue positions from a scheme, then finds a useful lightness/chroma pair for each accent against the current bg. A "syntax-balanced" mode should be the default because it matches the product better than classical harmony. It spaces hues around the wheel, but it keeps colors in a text-safe lightness band and reduces chroma when needed to preserve readability and distinguishability. + +The panel should not feel like a separate app. The proposed columns should look like columns, with the same span direction and tile affordances as real palette columns, but visually marked as a preview. The user should be able to click a proposed tile to inspect it in the picker before applying. + +** Generator config +The v1 config fields: + +- scheme: syntax-balanced, analogous, split-complementary, triadic, tetradic, warm-cool, manual +- baseHue: degrees, used by non-manual schemes +- manualHues: list of degrees, used by manual mode +- accentCount: integer, default 8, range 3-12 +- spanCount: integer, default current column span default, range 0-4 +- textLightnessBand: min/max OKLCH L for text accents +- uiTintLightnessBand: min/max OKLCH L for background/highlight tints +- chroma: global target chroma +- chromaBias: subdued, balanced, vivid +- contrastTarget: none, WCAG AA, WCAG AAA, APCA target +- deltaEMin: default to the existing palette warning threshold unless overridden +- locks: respect locked columns, respect locked assignments + +** Proposal object +The planner returns: + +#+begin_src js +{ + config, + columns: [ + { + columnId, + name, + baseHex, + members: [{ hex, name, offset, clamped, metrics }], + diagnostics: [{ level, message }] + } + ], + rejected: [{ hue, reason, nearestColumnId }], + summary: { generated, clamped, rejected, minContrast, minDeltaE } +} +#+end_src + +This shape is intentionally close to existing column/ramp data. It should be easy to unit test and easy to render as a preview. + +** Display +The generator panel sits between the palette controls and the real color columns. It has: + +- scheme segmented control +- base hue control +- accent count and span count numeric controls +- chroma/style controls +- contrast target control +- preview, append, replace selected, regenerate spans, and clear preview buttons +- summary row showing min contrast, min DeltaE, clamped count, and rejected count + +The preview renders as temporary columns. They are visually distinct from committed columns but use the same tile size, naming, and lightness order. Applying the preview re-renders the real palette and clears the preview. + +** Apply modes +- Append: add all proposed columns after existing columns, suffixing names/ids on collision. +- Replace selected: replace selected normal columns one-for-one by visual order; extra proposed columns append, extra selected columns are removed only after confirmation. +- Regenerate spans only: keep selected column ids and base colors, update span count/knobs. +- Regenerate generator-owned: update only columns marked as generator-owned and unlocked. + +The ground column remains pinned. The generator may read bg/fg and recommend ground-tint spans, but v1 does not rewrite bg/fg unless the user explicitly includes that later. + +** Persistence +Generated columns become normal palette columns after apply, but they carry optional metadata: + +#+begin_src js +{ source: "generator", generator: { scheme, version, generatedAt } } +#+end_src + +The metadata is advisory. Editing or renaming a generated column should not break the palette. A later regenerate-generator-owned action can use the metadata, but normal manual editing always wins. + +* Alternatives Considered +** Drag a generic harmony wheel into the palette +- Good, because it is familiar from design tools and visually appealing. +- Bad, because it optimizes hue relationships before text readability, which is backwards for Theme Studio. +- Neutral, because a hue wheel can still be a useful input control inside a constraint-first generator. + +** Classical palette generator only +- Good, because analogous/complementary/triadic/tetradic modes are easy to explain. +- Bad, because they do not know about bg/fg, contrast, syntax density, UI tints, or low-chroma distinguishability. +- Neutral, because those modes are still useful as candidate hue layouts. + +** Full automatic theme seeding +- Good, because it could produce a near-complete theme in one action. +- Bad, because it crosses into role assignment, package defaults, and guide-support behavior that already have separate tasks/specs. +- Neutral, because the palette generator can become one input to the seeding engine later. + +** Add many color spaces now +- Good, because OKHSL/OKHSV may be friendlier than raw OKLCH sliders for some users. +- Bad, because v1 already has the right perceptual foundation, and extra spaces would increase UI and test surface before the generation workflow is proven. +- Neutral, because OKHSL/OKHSV are good vNext candidates. + +* Decisions [0/4] +** TODO Default to syntax-balanced OKLCH generation +- Owner / by-when: Craig / spec review +- Context: Generic harmony modes produce attractive swatches but do not optimize for readable code text. +- Decision: We will make syntax-balanced the default scheme and OKLCH the default generation space. +- Consequences: Easier to generate useful Emacs themes first; harder to present the feature as a conventional color-wheel generator. + +** TODO Keep generation separate from face assignment +- Owner / by-when: Craig / spec review +- Context: Automatic assignment would touch syntax, UI, package faces, seeding, locks, and guide-support rules. +- Decision: We will generate palette columns only in v1 and leave assignment/seeding to existing or separate workflows. +- Consequences: Easier to ship a focused generator; harder because the user still must map colors onto faces. + +** TODO Use preview-first apply modes +- Owner / by-when: Craig / spec review +- Context: Generator output can disrupt a carefully tuned palette if it mutates immediately. +- Decision: We will render proposals as temporary preview columns and require an explicit append/replace/regenerate apply action. +- Consequences: Easier to inspect and avoid destructive changes; harder because the UI needs a proposal state and apply semantics. + +** TODO Defer OKHSL/OKHSV to vNext +- Owner / by-when: Craig / spec review +- Context: OKHSL/OKHSV may be friendlier interaction models, but OKLCH already supports the required perceptual generation math. +- Decision: We will ship OKLCH generation first and consider OKHSL/OKHSV after v1 is usable. +- Consequences: Easier to keep v1 small and rigorous; harder because some users may find OKLCH controls less familiar. + +* Implementation phases +** Phase 1 -- Planner core +Add pure generator functions in app-core.js or a new generator module. Inputs are current palette, ground, generator config, and locks. Outputs are proposal objects. Unit tests cover scheme hue placement, OKLCH candidate generation, gamut clamp reporting, naming/id collision handling, and no mutation of input state. + +** Phase 2 -- Candidate scoring +Add scoring and adjustment for contrast target, DeltaE separation, chroma bias, and text lightness band. Unit tests cover rejected candidates, clamped colors, low-chroma distinguishability, and deterministic output for fixed config. + +** Phase 3 -- Generator panel and preview rendering +Add the panel, controls, preview columns, summary metrics, and clear-preview behavior. Browser gate: preview creates temporary columns without changing the committed palette. + +** Phase 4 -- Apply modes +Implement append, replace selected, regenerate spans only, and regenerate generator-owned modes. Browser gates cover collision suffixing, locked column preservation, selected-column replacement, and generator-owned regeneration. + +** Phase 5 -- Persistence and import/export +Round-trip optional generator metadata without requiring it for normal palette behavior. Existing imports without metadata continue to work. Browser gate extends roundtrip coverage. + +** Phase 6 -- Documentation and cleanup +Document the generator in README and note the limits: palette only, no automatic face assignment. Remove any dead prototype code and keep make test green. + +* Acceptance criteria +- [ ] A user can preview a syntax-balanced palette from current bg/fg without mutating the real palette. +- [ ] A user can append generated columns and then edit/reorder/span them like normal columns. +- [ ] A user can replace selected columns with a generated proposal without losing locked columns. +- [ ] Generated colors report clamp, contrast, and DeltaE diagnostics. +- [ ] Export/import preserves committed generated columns and optional generator metadata. +- [ ] Existing manual palette workflows still work without opening the generator panel. +- [ ] Theme Studio tests cover planner functions, preview rendering, apply modes, and round-trip behavior. + +* Readiness dimensions +- Data model & ownership: The proposal is transient and generator-owned until applied. Applied columns become normal user-editable palette columns with optional advisory generator metadata. +- Errors, empty states & failure: Invalid config disables preview with an inline message naming the bad field. Rejected candidates appear in the summary rather than silently disappearing. Replace-selected asks for confirmation before removing unmatched selected columns. +- Security & privacy: N/A because generation is local, deterministic color math with no credentials or external requests. +- Observability: The preview summary shows generated, clamped, rejected, min contrast, and min DeltaE counts. Each tile exposes diagnostics in its title or detail panel. +- Performance & scale: Expected accent counts are small, 3-12 bases with up to 4 steps each. Candidate search should stay synchronous; if broader searches are added later, add progress/cancel. +- Reuse & lost opportunities: Reuse OKLCH, gamut clamp, ramp, contrast, APCA, DeltaE, locks, column ids, and existing browser gates. Do not invent a second color math stack. +- Architecture fit & weak points: The weak point is proposal/apply state in the DOM app. Keep planner pure and make the DOM layer only render/apply proposal objects. +- Config surface: Public knobs are scheme, hue/manual hues, accent count, span count, lightness bands, chroma bias, contrast target, DeltaE threshold, and lock behavior. Defaults should favor readable dark-theme syntax. +- Documentation plan: Update scripts/theme-studio/README.md with generator controls, apply modes, limits, and how generated columns become normal columns. +- Dev tooling: Existing scripts/theme-studio make test remains the gate. Add node tests for planner/scoring and browser hash gates for preview/apply. +- Rollout, compatibility & rollback: The generator is additive. Existing palettes load unchanged. Applied columns can be manually deleted or replaced; optional metadata can be ignored by old code. +- External APIs & deps: N/A for v1. No network or external package dependency is needed. + +* Risks, Rabbit Holes, and Drawbacks +- Candidate search can become a rabbit hole. V1 should use deterministic bounded search around target OKLCH bands, not open-ended optimization. +- "Syntax-balanced" could become subjective. Keep it documented as a default heuristic, not a claim of universal taste. +- Preview/apply modes can overcomplicate the UI. If the panel feels heavy, hide advanced knobs behind a disclosure and keep preview/apply visible. +- Optional generator metadata could drift from manual edits. Treat it as advisory only. + +* Testing / Verification / Rollout +Use the existing Theme Studio test stack: + +- Node tests for planner/scoring/collision/immutability. +- Browser hash gate for preview-only non-mutation. +- Browser hash gate for append/replace/regenerate apply modes. +- Round-trip gate for generator metadata. +- Manual Chrome pass on a dark palette and a light palette. + +* References / Appendix +- [[file:design/theme-studio-color-harmony.org][theme-studio color harmony explainer]] +- [[file:design/theme-studio-perceptual-color-metrics-spec.org][perceptual color metrics spec]] +- [[file:theme-studio-palette-ramps-spec.org][palette ramps and contrast safety spec]] +- [[file:theme-studio-palette-columns-spec.org][palette columns spec]] +- [[file:../todo.org::*theme-studio import organization workflow needs a spec][import organization task]] + +* Review and iteration history +** 2026-06-13 Saturday @ 16:31:01 -0500 -- Codex -- author +- What: Initial draft using the spec-create workflow. +- Why: Palette generation has real design trade-offs around color space, preview/apply behavior, assignment boundaries, and how much generator state should persist. +- Artifacts: [[file:../todo.org::*theme-studio palette generator][theme-studio palette generator task]]. diff --git a/docs/theme-studio-palette-ramps-spec.org b/docs/theme-studio-palette-ramps-spec.org new file mode 100644 index 00000000..849fea0f --- /dev/null +++ b/docs/theme-studio-palette-ramps-spec.org @@ -0,0 +1,219 @@ +#+TITLE: theme-studio Palette Ramps & Background-Contrast Safety — Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-09 + +* Metadata +| Status | draft — review incorporated (Codex, 2026-06-09) | +| Owner | Craig | +| Reviewer | Codex | +| Related | [[file:../todo.org][todo.org: theme-studio color-harmony explainer + ramp/fill features]] | + +* Summary + +Give theme-studio two things it lacks: a generator that turns one base color into a harmonized tonal ramp (base, plus lighter and darker steps), and a readout that tells the designer whether a color is safe to use as a background behind editor text. Both rest on the same OKLCH math worked out 2026-06-09, and they're coupled: the dark end of a ramp is where background tints come from, and a background tint is only usable if it keeps every foreground on it readable. + +* Problem / Context + +Building a theme, the designer needs a family of related shades per hue (blue, blue+1, blue-1, and so on) and a set of dim tinted backgrounds for effects like highlight, region, isearch, and hl-line. Today every shade is hand-picked by eye. Getting them to harmonize is guesswork, and there's no signal for the harder half of the problem: a background effect is an overlay, so the same tint sits behind many different foreground colors at once. A tint that looks fine against the default text can fail against the darkest token. The designer can't see that failure, because the picker shows one fg-on-bg pair, which is the wrong number. + +The cost is concrete. sterling's keyword blue (#67809c, OKLCH-L 0.59) is the darkest foreground; it barely clears WCAG AA on pure black and drops below AA the instant any highlight background lifts off black. So one color silently caps every highlight to near-black, and nothing in the tool surfaces it. The designer either over-darkens everything defensively or ships unreadable highlights without knowing. + +* Goals and Non-Goals + +** Goals +- From a base color, generate its tonal ramp (base, +1..+N lighter, -1..-N darker) that harmonizes by construction. +- For any face used as a background, show the worst-case contrast across the foregrounds that can land on it, and name the limiting foreground. +- Guide the usable background lightness so a generated or chosen background keeps all text readable. + +** Non-Goals +- Not auto-assigning ramp steps to faces — the designer picks which step goes where. +- Not the harmonic-fill feature (generate a whole palette from a few seed colors). Separate, deferred. +- Not a new color model or palette format — OKLCH mode and the palette already exist; this extends them. + +** Scope tiers +- v1: ramp generation from a base; worst-case-contrast readout + safe-lightness guidance for background-effect faces. +- Out of scope: harmonic fill; auto-assignment of steps to faces. +- vNext: harmonic fill (palette from seeds); richer per-face foreground-set detection. + +* Design + +The two features share OKLCH (perceptually uniform, so lightness / chroma / hue move independently) and reuse colormath.js, which already has =oklch2hex=, =contrast=, =apca=, and =deltaE=. + +*Ramp generation.* A ramp is one hue at many lightnesses. Convert the base to OKLCH, hold the hue fixed, and step lightness by a fixed perceptual delta per stop — lighter for +N, darker for -N. Chroma eases toward zero at the extremes (a near-white or near-black step carries almost no color), and every step is clamped back into sRGB. The harmony is structural: the steps share a hue and sit on an even lightness ladder, so they read as one family rather than a grab-bag. The output is a row of hexes the designer can name (=blue+1=, =blue-2=) and drop into the palette. + +*Background-contrast safety.* A background tint is only as good as its worst case. For a face used as a background, define its foreground set — the colors that actually render on top of it. For a code-context face (region, hl-line, isearch, highlight, lazy-highlight), that set is the syntax token colors plus the default foreground. The floor is the minimum, over that set, of =contrast(fg, candidate-bg)=; the limiting foreground is the argmin. From the floor we derive L_max: the lightest background, at the chosen hue and chroma, whose floor still clears the target (WCAG AA/AAA, or an APCA Lc). Sweep lightness to find it — deterministic. + +The two altitudes: +- *For the designer:* pick a base swatch, see its ramp, add the steps you want. When editing a background-effect face, the contrast cell shows the worst case — "worst: keyword #67809c — 3.3 FAIL" — not a misleading single pair, and OKLCH mode marks the safe-lightness ceiling so you can't unknowingly cross it. +- *For the implementer:* =ramp(baseHex, {n, stepL, chromaEase})= → =[hex]=; =fgSetFor(face, state)= → =[hex]=; =floor(bgHex, fgSet)= → ={ratio, limitingHex}=; =lMax(hue, chroma, fgSet, target)= → =L=. All pure, all in app-core.js / colormath.js, all unit-tested. Function contracts (inputs, outputs, validation, edge cases) are pinned below. + +* v1 covered faces + +v1 computes a worst-case floor for a *closed, enumerated* set of code-overlay faces — backgrounds the live buffer renders syntax-colored text over: + +- =region= +- =hl-line= +- =highlight= +- =lazy-highlight= +- =isearch= + +That set is exhaustive for v1. Other overlay faces (=secondary-selection=, =isearch-fail=, and any future built-in) are vNext, added explicitly rather than matched by a heuristic. The deliberately closed list is what makes the contract testable: an open "any face the buffer renders as text-over-syntax" rule would hand the implementer the same invent-the-behavior problem the foreground-set decision exists to close. + +Everything outside this set keeps its existing single-pair contrast cell: + +- *Package contrast cells* stay single-pair in v1. Package faces own their foregrounds through inheritance, which needs a per-preview foreground-set model that doesn't exist yet (vNext). +- *Non-overlay UI rows* (mode-line, fringe, and the rest) stay single-pair — their foreground set isn't a syntax palette. + +A covered face whose foreground set resolves empty (no syntax assignments yet) shows the no-set readout (below), not a bogus ratio. + +* Ramp defaults and palette insertion + +*Generation defaults* (all exposed as controls, these are the starting values): +- =n= (steps each direction): default =2= → base plus +1,+2,-1,-2. Safe range 1-4. +- =stepL= (OKLCH-L delta per step): default =0.08=. Safe range 0.04-0.12. +- =chromaEase= (fraction of chroma removed at the farthest step, eased toward the extremes): default =0.5=. Range 0-1; 0 holds chroma flat, 1 fully desaturates the last step. + +*Preview and insertion:* +- The ramp previews as a row ordered darkest → lightest (=-n .. base .. +n=), the base marked. +- Each generated swatch shows a clamp badge when =oklch2hex= reports =clamped= true, so the designer sees an out-of-gamut step before adding it. +- The base is preview-only by default (it's the source swatch, already in the palette); the designer may opt to add it. +- Selected steps insert adjacent to the source swatch, in =-n .. +n= order. + +*Naming:* step names derive from the source swatch name — base =blue= → =blue+1=, =blue+2=, =blue-1=, =blue-2=. If the source swatch is unnamed, names fall back to a hex-based label the designer can edit before add. + +*Collisions — never silent:* +- *Name collision* (=blue+1= already exists): the row is flagged and the designer renames before add; no overwrite. +- *Hex collision* (the generated hex already exists under another name): flagged as a duplicate, add still allowed (two names for one hex is legal in the palette). + +* Function contracts + +All four are pure, live in app-core.js (or colormath.js for the color math), take explicit state — never read globals — and validate user input by *returning a structured result*, not throwing. Throwing is reserved for genuine programmer error (wrong argument arity/type), not for malformed user-entered values. + +- =ramp(baseHex, {n, stepL, chromaEase})= → ={steps: [{hex, clamped}], error?}=. Validates: malformed =baseHex= (not a parseable hex — can't be clamped) → ={steps: [], error: 'bad-hex'}=; =n= outside 1-4 or non-integer → clamped into range with a flag; =stepL=/=chromaEase= outside range → clamped with a flag. Holds hue, steps OKLCH-L by =stepL=, eases chroma toward the extremes, gamut-clamps every step and reports per-step =clamped=. +- =fgSetFor(face, state)= → ={set: [hex], error?}=, where =state= is explicit slices ={syntaxAssignments, palette, defaultFg, locks}=. Returns the distinct syntax-assignment hexes plus =defaultFg=, excluding locked background-only roles. A face outside the v1 covered set → ={set: [], reason: 'out-of-scope'}=. No syntax assignments → ={set: [], reason: 'empty'}=. +- =floor(bgHex, fgSet)= → ={ratio, limitingHex, limitingLabel}=. =ratio= is the minimum WCAG contrast over =fgSet= against =bgHex=; =limitingHex= is the argmin; =limitingLabel= is its role/palette/hex name. Empty =fgSet= → ={ratio: null, limitingHex: null}= (caller shows the no-set readout). +- =lMax(hue, chroma, fgSet, target)= → ={L, status}=. Binary-searches OKLCH-L (tolerance 0.001) for the lightest background at =hue=/=chroma= whose =floor= still clears =target=. =status= is =ok= (an L found), =none= (no L satisfies the target — every background fails), =all= (every L satisfies it — no ceiling needed), or =clamp= (the chroma clamps before the target is reached; returns the L at the clamp boundary). + +* Alternatives Considered + +** Ramp stepping in OKLCH lightness (chosen) +- Good, because steps are perceptually even and the hue holds, so the family looks deliberate. +- Bad, because extreme steps can leave the sRGB gamut and need clamping. +- Neutral, because it depends on colormath's OKLCH path, which already exists. + +** Ramp stepping in HSL/HSV +- Bad, because HSL/HSV aren't perceptually uniform — equal numeric steps give uneven visual steps and the hue drifts at the light/dark ends. + +** Ramp by interpolating base↔white and base↔black +- Good, because it's trivial to implement. +- Bad, because lightness and chroma drift together unpredictably and the hue can shift, so the steps don't harmonize reliably. + +** Worst-case shown as one readout + limiting fg (chosen), with an OKLCH picker mask +- Good, because one honest number (the floor) plus the name of the bottleneck is the actionable lever, and the mask shows the safe band visually. +- Neutral, because it replaces the existing single-pair contrast cell for background faces only. + +** Worst-case shown as a full per-foreground heatmap +- Bad, because it's noise — the designer needs the floor and the one color setting it, not twenty ratios. + +* Decisions + +** Work in OKLCH for ramps and tints +- State: accepted +- Context: ramps and background tints both need even, hue-stable steps; the tool already exposes OKLCH. +- Decision: We will compute ramps and the contrast floor in OKLCH via colormath.js. +- Consequences: easier — even families, reuse of existing math; harder — must gamut-clamp every generated step. + +** Ramp = fixed lightness step + chroma ease on a held hue +- State: accepted +- Context: alternatives drift hue/chroma; uniform L steps read as a ladder. +- Decision: We will step lightness by a fixed delta, ease chroma toward the extremes, and hold the hue. +- Consequences: easier — predictable families; harder — chroma easing needs tuning so mid steps don't go muddy. + +** Background safety = worst-case floor over a per-face foreground set +- State: accepted +- Context: a single fg-on-bg pair misleads; overlays carry many foregrounds. +- Decision: We will compute the floor over a face's foreground set and surface the floor + limiting foreground. +- Consequences: easier — the real constraint is visible; harder — we must define each face's foreground set. + +** v1 foreground set for code-overlay faces = syntax tokens + default fg +- State: accepted +- Context: the exact face→context mapping is fuzzy; code-overlay faces clearly carry the syntax palette. The reviewer flagged that an open ("proposed") contract forces the Phase 3 implementer to invent what counts as foreground text. +- Decision: We will scope v1 to the closed code-overlay face set (see "v1 covered faces"), and define the foreground set as the distinct hexes of the syntax-assignment colors plus the default foreground (=fg=). Duplicate hexes collapse to one entry. Locked structural colors (=bg= and any palette entry flagged as a background-only role) are excluded from the set. The limiting foreground is labeled by its syntax role name when one exists, else its palette name, else its hex. +- Consequences: easier — a pinned, enumerable contract the implementer and tests can rely on; harder — UI/package overlays need a later per-preview foreground-set model (vNext). + +** Contrast target for the floor = WCAG AA default, AAA optional, APCA diagnostic +- State: accepted +- Context: AA (4.5) is the floor most reach for; AAA (7) is the stricter option; APCA models text-on-color better than WCAG but needs a chosen Lc and signed/absolute handling. The reviewer recommended a WCAG-only v1 so =floor=, =lMax=, labels, masks, and tests all key off one model. +- Decision: We will drive v1 PASS/FAIL and =L_max= off WCAG contrast: default target =AA= (4.5), with =AAA= (7) selectable. APCA Lc is shown as a displayed diagnostic only and does not drive PASS/FAIL or the safe band in v1; an APCA-driven safety mode is vNext. +- Consequences: easier — one metric for every safety surface and stable tests; harder — APCA's better text-on-color model is deferred, so a color that reads fine under APCA may still be flagged by WCAG. +- Owner note: this and the foreground-set decision were Craig-owned open decisions; resolved here per the reviewer's recommendation to keep the spec converging. Craig can override either before implementation starts. + +* Implementation phases + +** Phase 1 — Ramp generator (pure) +=ramp(baseHex, opts)= in app-core.js with Normal/Boundary/Error tests (mid base, near-white/near-black base, out-of-gamut request). Leaves the tree green; no UI yet. + +** Phase 2 — Ramp UI in the palette +A base swatch → preview the ramp → add chosen steps as named palette entries. Reuses the palette panel and the OKLCH picker. + +** Phase 3 — Foreground-set + floor (pure) +=fgSetFor=, =floor=, =lMax= in app-core.js with tests, including the keyword-blue worst case as a fixture. + +** Phase 4 — Worst-case readout +For the v1 covered faces, the contrast cell shows the floor + the limiting foreground name instead of a single pair. Pinned readout shape, so =#contrasttest= asserts fields not punctuation: =worst: <limitingLabel> <limitingHex> — <ratio> <PASS|FAIL>= (example: =worst: keyword #67809c — 3.3 FAIL=). The no-foreground-set readout is exactly =no fg set=. Add a hash-gate (#contrasttest-style) pinning floor-over-set and the no-set string. + +** Phase 5 — Safe-lightness in OKLCH mode +When a v1 covered face is open in the picker, mark L_max on the lightness slider and shade the unsafe band above it. v1 renders this as a *single L_max marker plus a one-band shade* (safe below, unsafe above) computed once per =(hue, chroma, fgSet, target)= via =lMax= — not a full per-pixel foreground-set contrast mask over the plane. The per-pixel AA/AAA plane mask stays single-foreground; extending it to a full foreground-set sweep is vNext if profiling ever shows the marker is insufficient. + +* Acceptance criteria +- [ ] From a base hex, the tool produces N lighter + N darker steps, perceptually even, all in sRGB gamut. +- [ ] Generated steps can be added to the palette as named entries. +- [ ] A background-effect face shows the worst-case contrast and names the limiting foreground. +- [ ] OKLCH mode marks the maximum safe lightness for the chosen hue/chroma given the foreground set + target. +- [ ] Unit tests cover ramp generation, the floor, and L_max; a browser gate pins the worst-case readout. +- [ ] =scripts/theme-studio/README.md= documents ramp controls and defaults, the worst-case-floor / limiting-foreground meaning, the v1 covered faces, and that WCAG drives PASS/FAIL with APCA shown as a diagnostic. + +* Readiness dimensions +- Data model & ownership: ramp steps and tints are user-authored palette entries the designer adds; floor and L_max are computed live, not stored. Nothing new persists. +- Errors, empty states & failure: an out-of-gamut step clamps and flags (a real but unrepresentable color); a malformed base hex can't be clamped, so =ramp= returns a structured =bad-hex= error and the row produces nothing rather than a garbage swatch; a covered face with no foreground set shows =no fg set= rather than a bogus ratio. All four core functions return structured results for bad user input instead of throwing. No silent data loss. +- Security & privacy: N/A — local color math, no credentials or sensitive data. +- Observability: the worst-case readout *is* the observability — the designer sees the floor and the bottleneck color directly. +- Performance & scale: N/A meaningfully — tens of colors, instant; no long-running ops. +- Reuse & lost opportunities: reuse colormath.js (=oklch2hex=/=contrast=/=apca=/=deltaE=), the OKLCH picker, and the AA/AAA mask. Don't reimplement color math. +- Architecture fit & weak points: pure logic in app-core.js (tested, importable like the Stage-7 split); UI in app.js; integration points are the palette panel, the OKLCH picker, and the contrast cells of the v1 covered faces only (region, hl-line, highlight, lazy-highlight, isearch). Package and non-overlay UI cells are out of v1 scope and keep their single-pair behavior. Weak point: defining each face's foreground set — mitigated by the closed v1 covered-face set and explicit-state =fgSetFor=. +- Config surface: =n= (steps each direction, default 2, range 1-4), =stepL= (OKLCH-L delta, default 0.08, range 0.04-0.12), =chromaEase= (default 0.5, range 0-1), and the contrast target (default WCAG AA 4.5, AAA 7 selectable). Defaults and safe ranges pinned in "Ramp defaults and palette insertion." +- Documentation plan: the color-harmony explainer (=docs/design/theme-studio-color-harmony.org=, already a task) carries the method; this spec carries the build; =scripts/theme-studio/README.md= is the operational doc and gets the ramp controls, contrast-target semantics, covered faces, and worst-case-floor meaning (an acceptance-criteria item). +- Dev tooling: =make theme-studio-test= covers it via new node tests + a browser gate; no new tooling. +- Rollout, compatibility & rollback: additive — no change to the theme.json format or existing themes. The worst-case readout replaces a misleading single-pair number for background faces (a strict improvement). No migration, nothing to roll back. +- External APIs & deps: none — pure color math, no external schema. + +* Risks, Rabbit Holes, and Drawbacks +- Chroma easing at the ramp extremes can go muddy or out-of-gamut — dodge by clamping and previewing every step before it's added. +- The foreground-set definition is the fuzzy core. v1 limits it to code-context faces (syntax tokens + default fg); an over-broad set would over-constrain backgrounds that those foregrounds never actually touch. +- A very dark foreground (sterling's keyword blue) can collapse the safe band to near-black. That's a true finding, not a tool bug — the readout should make the designer fix the foreground's lightness, not silently absorb it. + +* Review dispositions + +Only modified and rejected recommendations are listed; everything else from the Codex review was accepted as written and is woven into the body above. + +- *Modified — v1 covered-face set closed, not open-ended.* The review's suggested list ended with "and any other named built-in face the live buffer renders as text-over-syntax." Adopted the five named faces (region, hl-line, highlight, lazy-highlight, isearch) but dropped the open-ended trailing clause: an open set hands the implementer the same invent-the-behavior problem the foreground-set decision exists to close. The v1 set is exhaustive; new faces are added explicitly in vNext. +- *Modified — invalid-input handling distinguishes user error from programmer error.* The review said "prefer pure functions returning structured errors rather than throwing." Accepted for user-entered values (malformed hex, out-of-range knobs) which return structured results; kept throwing for genuine programmer error (wrong argument arity/type), matching colormath.js's existing idiom rather than blanket no-throw. +- *Deferred (not rejected) — implementation-task drop-in block.* The review supplied six ready TODOs. Per the spec-response workflow, implementation tasks are created only after the author confirms the spec is Ready (Phase 6), so they are not yet logged to todo.org. They land on the Ready go. + +* Review and iteration history +** 2026-06-09 Tue @ 17:18:54 -0500 — Craig — author +- What: initial draft. +- Why: ramp generation and background-contrast safety are coupled, design-uncertain, and worth solving on paper before code. +- Artifacts: this spec; the color-harmony explainer task in todo.org. +** 2026-06-09 Tue @ 17:34:17 -0500 — Codex — reviewer +- What changed or was recommended: reviewed the spec against the implementation context and marked it =Not ready=. Recommended pinning the v1 contrast target, accepting the foreground-set contract, narrowing the exact covered background faces, and specifying ramp defaults / palette insertion behavior. +- Why: the current code architecture supports the feature, but the open metric and foreground-scope decisions would make implementation invent user-visible behavior and produce unstable tests. +- Artifacts: theme-studio-palette-ramps-spec-review.org (consumed and deleted on response). +** 2026-06-09 Tue @ 17:53:02 -0500 — Claude Code (dotemacs) — responder +- What changed or was recommended: dispositioned every Codex finding. Resolved both open Decisions to accepted — contrast target = WCAG AA default (AAA selectable, APCA diagnostic only), v1 foreground set = distinct syntax hexes + default fg with locked roles excluded. Added "v1 covered faces" (closed five-face set), "Ramp defaults and palette insertion" (n/stepL/chromaEase defaults, naming, collision, clamp display), and "Function contracts" (explicit-state, structured-error signatures for ramp/fgSetFor/floor/lMax with edge cases). Pinned the worst-case readout string and no-set message, clarified Phase 5 as a single L_max marker + band shade, added a README acceptance-criteria item, and corrected the architecture/config readiness lines. Two findings modified, one deferred — see Review dispositions. +- Why: the review's blockers were product-contract gaps (open metric, proposed foreground set, conflicting scope, underspecified ramp write) that would have made two correct implementations diverge. Pinning them converges the spec to implementation-ready. +- Artifacts: review file deleted; Review dispositions section; this entry. Two Craig-owned open decisions resolved per reviewer recommendation, override available before implementation. +** 2026-06-09 Tue @ 18:10:48 -0500 — Codex — reviewer +- What changed or was recommended: re-reviewed the updated spec and found it implementation-ready. No new blocking review notes; the prior blockers are resolved in the accepted WCAG target, closed v1 face list, explicit foreground-set contract, ramp defaults/insertion rules, function contracts, readout strings, Phase 5 marker behavior, and README acceptance criterion. +- Why: the updated spec now gives an implementer stable product behavior, testable pure-function contracts, and integration boundaries that match the current =scripts/theme-studio= architecture. +- Artifacts: no new review file; this Ready verification entry. |
