aboutsummaryrefslogtreecommitdiff
path: root/docs/design
diff options
context:
space:
mode:
Diffstat (limited to 'docs/design')
-rw-r--r--docs/design/dupre-clear-theme.org89
-rw-r--r--docs/design/keybinding-console-safety-spec.org939
-rw-r--r--docs/design/messenger-unification-spec.org208
-rw-r--r--docs/design/module-inventory.org2
-rw-r--r--docs/design/signal-client.org22
-rw-r--r--docs/design/theme-studio-color-harmony.org77
-rw-r--r--docs/design/theme-studio-face-rules.org47
-rw-r--r--docs/design/theme-studio-package-faces-spec.org586
-rw-r--r--docs/design/theme-studio-perceptual-color-metrics-spec.org576
-rw-r--r--docs/design/theme-studio-seeding-engine-spec.org350
10 files changed, 2895 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.