aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/design/ai-kb-shared-roam-brainstorm.org2
-rw-r--r--docs/design/module-inventory.org4
-rw-r--r--docs/design/music-config-without-emms-review.org2
-rw-r--r--docs/design/signal-client-review.org2
-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/utility-inventory.org6
-rw-r--r--docs/design/vamp-music-player.org340
-rw-r--r--docs/native-comp-subr-mocking.org159
-rw-r--r--docs/specs/ai-kb-spec.org (renamed from docs/design/ai-kb.org)4
-rw-r--r--docs/specs/ai-vterm-spec-superseded.org (renamed from docs/design/ai-vterm.org)4
-rw-r--r--docs/specs/cache-helper-design-spec-implemented.org (renamed from docs/design/cache-helper-design.org)6
-rw-r--r--docs/specs/company-to-corfu-migration-spec.org (renamed from docs/design/company-to-corfu-migration.org)4
-rw-r--r--docs/specs/coverage-spec-implemented.org (renamed from docs/design/coverage.org)4
-rw-r--r--docs/specs/debug-profiling-spec.org (renamed from docs/design/debug-profiling.org)4
-rw-r--r--docs/specs/dev-setup-project-spec.org (renamed from docs/design/dev-setup-project.org)4
-rw-r--r--docs/specs/dupre-clear-theme-spec.org (renamed from docs/design/dupre-clear-theme.org)6
-rw-r--r--docs/specs/face-font-diagnostic-popup-spec-implemented.org197
-rw-r--r--docs/specs/flycheck-modeline-customization-spec-implemented.org (renamed from docs/design/flycheck-modeline-customization.org)4
-rw-r--r--docs/specs/gloss-spec-doing.org (renamed from docs/design/gloss.org)4
-rw-r--r--docs/specs/gptel-gh-tool-spec.org (renamed from docs/design/gptel-gh-tool.org)6
-rw-r--r--docs/specs/gptel-git-tools-magit-backend-spec.org (renamed from docs/design/gptel-git-tools-magit-backend.org)4
-rw-r--r--docs/specs/gptel-network-tools-spec.org (renamed from docs/design/gptel-network-tools.org)6
-rw-r--r--docs/specs/init-load-graph-spec-doing.org (renamed from docs/design/init-load-graph.org)12
-rw-r--r--docs/specs/keybinding-console-safety-spec-doing.org943
-rw-r--r--docs/specs/mcp-el-gptel-integration-spec-doing.org (renamed from docs/design/mcp-el-gptel-integration.org)6
-rw-r--r--docs/specs/messenger-unification-spec.org350
-rw-r--r--docs/specs/music-config-without-emms-spec.org (renamed from docs/design/music-config-without-emms.org)4
-rw-r--r--docs/specs/org-faces-spec-implemented.org154
-rw-r--r--docs/specs/signal-client-spec-doing.org (renamed from docs/design/signal-client.org)26
-rw-r--r--docs/specs/theme-studio-package-faces-spec-doing.org (renamed from docs/design/theme-studio-package-faces-spec.org)4
-rw-r--r--docs/specs/theme-studio-palette-generator-spec-doing.org298
-rw-r--r--docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org (renamed from docs/design/theme-studio-perceptual-color-metrics-spec.org)4
-rw-r--r--docs/specs/theme-studio-preview-locate-spec.org148
-rw-r--r--docs/specs/theme-studio-seeding-engine-spec-doing.org (renamed from docs/design/theme-studio-seeding-engine-spec.org)6
-rw-r--r--docs/specs/theme-studio-semantic-theme-architecture-spec.org266
-rw-r--r--docs/specs/theme-studio-structured-output-spec.org157
-rw-r--r--docs/specs/utility-consolidation-spec-doing.org (renamed from docs/design/utility-consolidation.org)14
-rw-r--r--docs/specs/vterm-to-ghostel-migration-spec-implemented.org (renamed from docs/design/vterm-to-ghostel-migration-spec.org)6
-rw-r--r--docs/theme-studio-color-families-spec.org202
-rw-r--r--docs/theme-studio-palette-columns-spec.org119
-rw-r--r--docs/theme-studio-palette-ramps-spec.org219
42 files changed, 3810 insertions, 24 deletions
diff --git a/docs/design/ai-kb-shared-roam-brainstorm.org b/docs/design/ai-kb-shared-roam-brainstorm.org
index e42e2b006..c2467380b 100644
--- a/docs/design/ai-kb-shared-roam-brainstorm.org
+++ b/docs/design/ai-kb-shared-roam-brainstorm.org
@@ -410,7 +410,7 @@ This layer answers:
* Possible next task
Convert this brainstorm into a concrete design delta for the existing
-=docs/design/ai-kb.org= and the open =Implement ai-kb= task:
+=docs/specs/ai-kb-spec.org= and the open =Implement ai-kb= task:
- add agent query triggers;
- specify personal-roam access boundaries;
diff --git a/docs/design/module-inventory.org b/docs/design/module-inventory.org
index 2d4baf81a..fb883d701 100644
--- a/docs/design/module-inventory.org
+++ b/docs/design/module-inventory.org
@@ -4,7 +4,7 @@
* Purpose
-Living per-module inventory for the [[file:init-load-graph.org][init.el load-graph refactor]]. The
+Living per-module inventory for the [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init.el load-graph refactor]]. The
spec's module-category table is the seed; this file is the per-module truth as
each module is inspected and classified. A module moves from [[*Pending
classification][Pending classification]] into [[*Classified modules][Classified
@@ -205,7 +205,7 @@ flyspell-and-abbrev is the one Core-UX member (text-mode hooks).
| =eshell-config= | 3 | D/P | eager | command | system-utils | add-hook, advice-add, package config | yes |
| =eww-config= | 3 | D/P | eager | command | cl-lib | package config | yes |
| =flyspell-and-abbrev= | 2 | C/P | eager | hook | cl-lib | mode-hook package config | yes |
-| =games-config= | 4 | O | eager | command | none | package config | yes |
+| =games-config= | 4 | O | command | command | user-constants | package config | yes |
| =gloss-config= | 4 | O/D/P | eager | command | none | package config | yes |
| =httpd-config= | 4 | O/D/P | eager | command | none | package config | yes |
| =jumper= | 4 | O/L | eager | command | cl-lib | jumper keymap | yes |
diff --git a/docs/design/music-config-without-emms-review.org b/docs/design/music-config-without-emms-review.org
index 67ef0d1b8..1aae670d6 100644
--- a/docs/design/music-config-without-emms-review.org
+++ b/docs/design/music-config-without-emms-review.org
@@ -3,7 +3,7 @@
#+DATE: 2026-05-15
#+OPTIONS: toc:nil num:nil
-Spec reviewed: =docs/design/music-config-without-emms.org=
+Spec reviewed: =docs/specs/music-config-without-emms-spec.org=
Third pass. This review answers the single question: is the spec in
shape to start =/start-work= against?
diff --git a/docs/design/signal-client-review.org b/docs/design/signal-client-review.org
index 34b4bbda4..7e8a73e91 100644
--- a/docs/design/signal-client-review.org
+++ b/docs/design/signal-client-review.org
@@ -5,7 +5,7 @@
* Scope reviewed
- =.ai/workflows/spec-review.org=.
-- =docs/design/signal-client.org=, including the base design, open-question dispositions, initiate-message workflow, architecture additions, accepted caveats, test plan, scope summary, and readiness rubric.
+- =docs/specs/signal-client-spec-doing.org=, including the base design, open-question dispositions, initiate-message workflow, architecture additions, accepted caveats, test plan, scope summary, and readiness rubric.
- =modules/signal-config.el=, including =cj/signal--parse-contacts=, notify-suppression helpers, private config loading, and current =use-package signel= wiring.
- =~/code/signel/signel.el=, including =signel-start=, =signel--send-rpc=, =signel--dispatch=, =signel--handle-error=, =signel--handle-receive=, =signel--insert-msg=, =signel--insert-system-msg=, =signel--send-input=, =signel-chat=, and dashboard commands.
- =tests/test-signal-config.el=, covering contact parsing and notify-suppression helpers.
diff --git a/docs/design/theme-studio-color-harmony.org b/docs/design/theme-studio-color-harmony.org
new file mode 100644
index 000000000..b07ccb420
--- /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 000000000..4eb3e1b3d
--- /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/utility-inventory.org b/docs/design/utility-inventory.org
index cf4c13bd3..8438a5924 100644
--- a/docs/design/utility-inventory.org
+++ b/docs/design/utility-inventory.org
@@ -4,7 +4,7 @@
* Status
-Living inventory. Phase 1 of [[file:utility-consolidation.org][utility-consolidation.org]]. Records the current state of helpers identified in the spec's Candidate Extraction Table plus any new candidates discovered during module walkthroughs. Decisions become concrete tasks in =todo.org= for Phase 2+.
+Living inventory. Phase 1 of [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]]. Records the current state of helpers identified in the spec's Candidate Extraction Table plus any new candidates discovered during module walkthroughs. Decisions become concrete tasks in =todo.org= for Phase 2+.
* Scope
@@ -82,7 +82,7 @@ Caller counts in the inventory below reflect grep results from 2026-05-10. The c
| Symbol | File | Vis | Deps | Side effects | Proposed home / name | Callers (modules) | Tests | Pri | Decision | Rationale |
|--------+------+-----+------+--------------+----------------------+-------------------+-------+-----+----------+-----------|
-| =cj/modeline-vc-cache-*= helpers (key/get/put/clear/valid-p) | =modeline-config.el:108-140= | private | buffer-local vars | mutates buffer-local state | =cj-cache.el= / =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | 1 (within file) | =test-modeline-config-vc-cache.el= | Medium | Defer | Good pattern, but variable-local cache shape differs from the agenda/refile caches. Needs design before extraction. Spec calls out a Phase 5 design addendum at =docs/design/cache-helper-design.org=. |
+| =cj/modeline-vc-cache-*= helpers (key/get/put/clear/valid-p) | =modeline-config.el:108-140= | private | buffer-local vars | mutates buffer-local state | =cj-cache.el= / =cj/cache-valid-p=, =cj/cache-get=, =cj/cache-put=, =cj/cache-clear= | 1 (within file) | =test-modeline-config-vc-cache.el= | Medium | Defer | Good pattern, but variable-local cache shape differs from the agenda/refile caches. Needs design before extraction. Spec calls out a Phase 5 design addendum at =docs/specs/cache-helper-design-spec-implemented.org=. |
| agenda/refile cache vars and build flags | =org-agenda-config.el=, =org-refile-config.el= | n/a | timers, file scans | scans filesystem, sets vars | =cj-cache.el= / =cj/cache-value-or-rebuild= | 2 | none | Medium | Defer | TTL/build/invalidate lifecycle; higher risk than the modeline cache. Same Phase 5 work. |
** Logging / Warnings
@@ -144,7 +144,7 @@ These become =todo.org= entries (or update existing ones) as Phase 2 starts.
** Deferred (track in =todo.org= but no commit yet)
-- Cache abstraction (modeline + agenda/refile) -- needs Phase 5 design addendum at =docs/design/cache-helper-design.org=.
+- Cache abstraction (modeline + agenda/refile) -- needs Phase 5 design addendum at =docs/specs/cache-helper-design-spec-implemented.org=.
- =cj/--open-with-is-launcher-p= -- move when external-open ownership is finalized.
- =cj/log-silently= rename -- low value; do during incidental =system-lib= work.
- HTML/text helpers (=strip-html=, =clean-text=) -- defer until a second consumer.
diff --git a/docs/design/vamp-music-player.org b/docs/design/vamp-music-player.org
new file mode 100644
index 000000000..12b92443b
--- /dev/null
+++ b/docs/design/vamp-music-player.org
@@ -0,0 +1,340 @@
+#+TITLE: Design: VAMP — a standalone Emacs music player
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-22
+#+OPTIONS: toc:nil num:nil
+
+Status: Draft
+
+VAMP = "VAMP Audio Music Player" (recursive backronym; /vamp/ is itself a
+musical term — a short repeated passage). Namespace =vamp-=, repo =~/code/vamp=.
+
+This design came out of a 2026-06-22 brainstorm. It supersedes parts of the
+earlier EMMS-removal work and confirms others — see "Relationship to Prior
+Work" below.
+
+* Problem
+
+=modules/music-config.el= (925 lines) is an EMMS configuration layer welded
+into =.emacs.d=: it mixes genuinely reusable logic (M3U management, fuzzy add,
+random-history navigation, radio-station creation, consume mode) with personal
+config (ncmpcpp-aligned keybindings, paths, dashboard wiring), and it depends
+on the EMMS package for its playlist model, player backend, and track info.
+The goal is a standalone, publishable Emacs music player — derived from a
+maintained subset of EMMS, depending on the EMMS package not at all — that
+Craig uses as his primary player, launchable from Hyprland like dirvish.
+
+* Relationship to Prior Work
+
+A spec and a detailed review already exist and remain partly authoritative:
+
+- =docs/specs/music-config-without-emms-spec.org= — the EMMS-removal spec.
+- =docs/design/music-config-without-emms-review.org= — third-pass review
+ (2026-05-15) with a go/no-go and a 14-item decision punch list.
+
+This brainstorm *confirms* four of that review's decisions, independently
+re-derived: long-running MPV + JSON IPC from day one (B1); a state-change hook
+contract firing STARTED/STOPPED/PAUSED/RESUMED/FINISHED (B2); a fake-backend
+testutil with an events ledger (B4); metadata via MPV IPC on the STARTED event
+(S3).
+
+It *pivots* the direction in four ways the prior work assumed otherwise:
+
+- Publishable from the start (old spec: personal-first, public "later").
+- Two adapters — MPV and mpd — behind a generalized adapter API (old: MPV-only,
+ a single backend protocol). This is the largest change; it turns their single
+ "backend protocol" into a real multi-backend seam.
+- Name VAMP (old candidate: =cadenza=).
+- Desktop integration as a first-class concern: a Hyprland Super+/ launcher, a
+ daemon-singleton instance model, q-closes-frame-while-playback-continues, and
+ an m3u MIME association — none of which the prior work addressed.
+
+The prior review's cross-platform decision is absorbed unchanged: Linux + macOS
+ship full-feature; Windows is best-effort (play/stop/next/previous only) per
+Craig's 2026-05-15 call.
+
+Next step (tracked below): revise the spec to this direction before
+=/start-work=.
+
+* Non-Goals
+
+- No music-library database, tag index, or browser UI (light metadata only).
+- No mid-track position resume on backend switch (v1 re-cues from track start).
+- No persisted session state across daemon restarts (M3U save/load is the only
+ way a playlist comes back).
+- The package does not install OS wiring — the Hyprland bind, the launcher
+ script, window rules, the =.desktop= file, and =xdg-mime= defaults all live
+ in archsetup.
+- No full tag-reading in v1 (deferred to the first post-v1 enhancement).
+
+* Assumptions
+
+Researched facts (verified this session):
+
+- EMMS is GPLv3+ (read from =emms.el=); any code derived from it makes VAMP
+ GPLv3+. Fine for MELPA, which prefers GPL.
+- The EMMS core subset VAMP would draw from is ~6–7k lines: =emms.el= (1741),
+ =emms-playlist-mode.el= (685), =emms-player-mpv.el= (772),
+ =emms-player-mpd.el= (1367), the M3U sources (~800), native tag readers
+ (~1080), playing-time (258). The ~16k excluded surface is browser, filters,
+ tag-editor, lyrics, mpris, scrobblers, musicbrainz.
+- The dirvish-popup / quick-capture launcher pattern (emacsclient named frame +
+ Hyprland window rules + q-to-close, single-instance focus-existing) is the
+ established model on Craig's machine.
+- mpd is installed and running; Craig will use it to test the second adapter.
+
+Assumptions to confirm before/early in build:
+
+- mpd driven as a "dumb" single-file player (clear queue → add one file → play
+ → idle for end-of-track) behaves cleanly. mpd is designed to own a queue;
+ the dumb-player contract must be validated against real mpd behavior.
+- The m3u XDG MIME association works on Craig's exact Hyprland/xdg setup
+ (mechanism is standard; prototype the =.desktop= early per Craig's request).
+
+* Approaches Considered
+
+** Recommended: B/A hybrid — clean core, ported adapter internals
+
+Write a small, fresh playlist/playback/navigation core with an adapter API of
+VAMP's own design (B); port only the fiddly MPV-IPC and mpd-protocol internals
+from EMMS as reference (A), since that protocol handling is the hard-won part
+not worth reinventing. The core owns the queue and all play-modes; adapters are
+thin single-file players.
+
+Pros: a small core Craig fully understands and can maintain solo; a clean
+adapter API shaped for the two-backend goal; reuses EMMS's proven IPC/protocol
+code without inheriting its whole design.
+
+Cons: GPLv3+ (from the ported adapter code); real upfront design effort on the
+core + adapter API before any feature lands; risk of missing subtle
+player-process lifecycle behavior EMMS already handles.
+
+What it trades away: the option of a non-GPL license, and a fast feature-first
+start.
+
+** Rejected: vendor-and-trim (A alone)
+
+Copy the ~8 core EMMS files, delete the rest, renamespace, keep EMMS's backend
+pattern as VAMP's own. Fastest to feature-complete, but inherits 6–7k lines of
+someone else's idioms to "maintain yourself" — works against the maintainability
+goal that motivated the project.
+
+** Rejected: thin core, delegate to backends (C)
+
+Lean hard on the backend (mpd owns its queue; MPV gets a minimal one). Least
+code, but backend asymmetry leaks into inconsistent behavior and fat adapters —
+and the brainstorm chose core-owns-queue precisely for uniform behavior and
+seamless backend switching. C survives only as an adapter-capability detail
+(let mpd do server-side work as a future optimization).
+
+** Rejected: wrap existing client libraries (D)
+
+Build on mpdel + mpv.el as a thin UX layer. Directly contradicts the
+"depend on nothing / maintain it myself" goal.
+
+** Rejected: MPRIS/D-Bus as the one universal adapter (E)
+
+Drive any MPRIS player over D-Bus. "Many players" almost free, but MPRIS is
+control-only — it can't reliably own playlists or load arbitrary files across
+players. Kept in the back pocket as a possible future adapter class, not a v1
+foundation.
+
+** Rejected: external daemon + JSON-RPC (F)
+
+Move player logic to an external process, Emacs as thin client. Ships a
+non-Elisp component — packaging burden, not a pure-Elisp MELPA package. Overkill
+for local playback.
+
+* Design
+
+** Architecture
+
+Standalone repo at =~/code/vamp=, Eask-based like pearl (Eask, Makefile,
+autoloads, =tests/=, README, LICENSE — GPLv3+). Three layers:
+
+- *Core* (backend-agnostic, owns all stateful logic): the queue model (track
+ list, current index, play-modes — shuffle, repeat-playlist, repeat-track,
+ random + history ring, consume); the playback controller (orchestrates
+ load + play on the current track, handles end-of-track, advances per mode);
+ sources (add files/dirs/recursive, URLs, M3U load/save/clear/reload/edit;
+ radio-station creation); the playlist-mode buffer + window toggle/show;
+ light metadata.
+- *Adapter layer* (the extensibility seam): a =cl-defgeneric= protocol every
+ backend implements. Ships with MPV (spawned subprocess + JSON IPC socket) and
+ mpd (daemon connection, driven as a dumb single-file player).
+- *=.emacs.d= glue* (=vamp-config.el=): keybindings (C-; m map, playlist-mode
+ keys), music-root path, dashboard wiring, customize values. No logic.
+
+Three-project split: VAMP ships the elisp + entry points; =.emacs.d= keeps
+keybindings/glue; archsetup owns the OS wiring (Super+/ bind, launcher script,
+window rules, =.desktop=, =xdg-mime=).
+
+** Adapter API
+
+A backend is a class implementing generic methods. The contract is deliberately
+narrow (transport + metadata), because the core owns the queue and modes:
+
+- =load-file= — load a track URL/path (do not advance anything)
+- =play= / =pause= / =stop=
+- =seek= — to an absolute or relative position
+- =position= — current playback position
+- =report-metadata= — title/artist/album/duration the backend knows about
+- an *end-of-track notification* — each adapter translates its native
+ "track finished" signal (MPV: the IPC =end-file= event; mpd: the idle
+ =player= subsystem) into one uniform core callback
+
+This is the review's B2 state-change contract, generalized across backends. A
+new backend is a new class + method implementations; nothing in the core
+changes.
+
+** Backend switching
+
+The payoff of core-owns-queue: the queue and current track are backend-agnostic
+state in the core, so a runtime switch is just — stop the outgoing adapter
+(kill the MPV subprocess / drop the mpd connection), set the active adapter,
+and the next play re-issues =load-file= to the new backend on the same current
+track. Nothing in the queue moves; the selected song stays selected. An
+interactive =vamp-switch-backend= command (completing-read over self-registered
+adapters) is bound under C-; m and in playlist-mode. v1 re-cues from track
+start on switch; mid-track position-resume is a post-v1 addition (the contract
+already has =seek=, so it's additive).
+
+** Data flow / control loop
+
+A user action (play / next / previous) updates queue state in the core (current
+index advanced per the active play-mode), then the core calls the active
+adapter's =load-file= + =play=. End-of-track is the one hard cross-backend
+signal: the adapter fires the uniform callback, and the core's handler consults
+the play-mode and advances — repeat-track replays, repeat-playlist wraps, random
+pushes history and picks next, consume drops the finished track, normal advances
+or stops at the end. A track is a struct (url/path + type slot + cached light
+metadata); the queue is an ordered track list + current index + mode flags + the
+random-history ring.
+
+** Presentation / faces
+
+Every stateful UI surface gets a named =defface=, so status is shown by face,
+not hardcoded color: playlist current/played/consumed lines and metadata
+columns; play state (playing/paused/stopped); each play-mode with a lit (on) and
+dimmed (off) face; the active-backend indicator (MPV vs mpd); backend
+health (e.g. mpd-disconnected as an error face). These render in a header-line
+status strip in the playlist buffer (and feed the mode-line); the mode/transport
+indicators light via their on-face and dim via their off-face.
+
+Base palette: faces ship with defaults that *inherit from standard Emacs faces*
+(=success=, =warning=, =error=, =shadow=, =highlight=, =font-lock-*=) so they
+look right under any user theme out of the box and adapt automatically. A
+separate, optional =vamp-theme.el= carries the opinionated palette. Every face
+stays individually overridable. The selected-track line uses a single reused
+overlay repositioned on each STARTED event (review B3).
+
+Testing the palette: because they're standard deffaces, theme studio (the
+=.emacs.d= tool) renders them directly — load the VAMP faces, preview the
+playlist buffer + status strip, check legibility against the modus contrast
+targets, iterate.
+
+** Desktop integration + instance model
+
+Launcher: a =vamp-popup= script (mirror of dirvish-popup) bound to Super+/ in
+Hyprland; ncmpcpp moves to Shift+Super+/. The script focuses an existing
+"vamp" frame if one is open, else spawns a floating frame running
+=(vamp-popup)=; Hyprland window rules float/size/center the "vamp"-named frame.
+
+Instance model: one player instance = the daemon's global state (queue, active
+adapter, the live MPV subprocess / mpd connection). Super+/ attaches a view
+frame to it. q closes that frame but *playback continues in the daemon* — close
+the window, music plays on, reopen to see it again. A separate command (or Q)
+fully stops and tears down the player. The launcher's focus-existing behavior
+enforces an at-most-one view frame, so there are no competing instances. The
+non-daemon case (standalone Emacs) is its own instance — an edge case, since
+Craig runs the daemon.
+
+m3u MIME association: a =.desktop= file with
+=MimeType=audio/x-mpegurl;audio/mpegurl;application/x-mpegurl;application/vnd.apple.mpegurl=
+and =Exec=music-open %f= (wrapper → emacsclient … =(vamp-open-m3u "%f")=), then
+=xdg-mime default=. Opening any =.m3u= from a file manager or =xdg-open= then
+launches/raises VAMP and loads that playlist. The package only needs the
+=vamp-open-m3u FILE= entry point; the =.desktop= + =xdg-mime= live in archsetup.
+
+** Persistence
+
+Playlists: M3U save/load/clear/reload/edit, file-based, same as today. No
+session state — each daemon start is empty (today's behavior).
+
+** Metadata
+
+v1: adapter-reported metadata for the playing track only (MPV =get_property
+metadata= on STARTED; mpd reports tags from its DB). The playlist shows
+filename/path-derived labels (today's =track-description= behavior); the current
+track shows the real title/duration the backend reports. Post-v1: vendor
+=emms-info-native= (~1080 lines; mp3/ogg/flac) for real artist/album tags across
+the whole playlist, which is what unlocks sort-by-tag.
+
+** Error handling
+
+Failures surface via =user-error= / =message=, never silently — the
+music-config history (the silent Slack-notify and lock-screen bugs) is the
+cautionary tale. A missing/dead backend (mpd not running, mpv binary absent)
+reports clearly and is reflected in the header-line health face.
+
+** Testing
+
+The adapter is the system boundary (subprocess / IPC / network), so that is the
+only thing mocked — never the core. A *test adapter* (null backend, review B4's
+=testutil-music-backend.el=) implements the protocol, records
+=load-file=/=play=/=stop= calls in an events ledger, and lets a test fire the
+end-of-track callback on demand. With it, the entire control loop and every
+play-mode is testable as pure logic — no MPV, no mpd, no audio. This is
+dependency-injection rather than primitive-mocking, which also sidesteps the
+native-comp subr-mock trap the suite recently fought (see
+=docs/native-comp-subr-mocking.org=): a fake adapter is injected, not a subr
+=cl-letf='d. Core-logic tests (queue, navigation, M3U parse/write, fuzzy add,
+source expansion) are largely the existing ~193 music-config tests, ported with
+renamed symbols. Per-adapter tests mock the IPC socket / protocol connection and
+assert the native-event → uniform-callback translation. One or two integration
+tests drive the real core through the test adapter.
+
+** Observability
+
+A debug log buffer captures raw adapter I/O (the IPC/protocol traffic) for
+diagnosing backend issues (EMMS has this for mpv; worth keeping). State changes
+surface in the header-line + mode-line. A =vamp-doctor= command reports backend
+availability and, on Windows, the degraded-mode limitation.
+
+** Cross-platform stance
+
+Linux + macOS ship full-feature (IPC over unix domain sockets). Windows is
+best-effort — play/stop/next/previous only, no pause/seek/volume — via
+=start-process= + stdin or one-shot =call-process=, because Emacs's
+=make-network-process= doesn't natively support Windows named pipes. Documented
+in the README and surfaced by =vamp-doctor=. (Craig, 2026-05-15.)
+
+* Open Questions
+
+- [ ] mpd dumb-single-file-player contract — validate that clear-queue → add →
+ play → idle-for-end behaves cleanly against real mpd; decide the exact command
+ sequence. Candidate for an early spike.
+- [ ] Exact mpd end-of-track signal handling (idle =player= vs polling) and how
+ it maps to the uniform callback without races.
+- [ ] =.desktop= + =xdg-mime= prototype on Craig's Hyprland setup — confirm m3u
+ opens VAMP early (Craig asked to de-risk this first).
+- [ ] Floating-frame geometry / Hyprland window rules for the "vamp" frame
+ (archsetup detail).
+- [ ] v1 parity catalog — confirm the 13 EMMS features from the review's S1 all
+ carry into the playlist-mode keymap (seek, volume, one-shot shuffle, info,
+ center, kill-track, bury, append-to-M3U, active-window tint, dired/dirvish
+ add).
+
+* Next Steps
+
+- *Reconcile the spec.* Revise =docs/specs/music-config-without-emms-spec.org=
+ to this direction — publishable-now, two adapters + generalized adapter API,
+ VAMP name, desktop integration + instance model — keeping the review's
+ confirmed B1/B2/B4/S3 decisions and the 14-item punch list still relevant.
+- *Spike the risky assumptions* (the two mpd open questions, the m3u =.desktop=)
+ before committing the adapter API shape.
+- Open questions that are genuine decisions → =arch-decide= as ADRs.
+- Implementation → =/start-work= against the revised spec; pure-helper
+ extraction (review's Migration Plan step 1) is the safe first phase and can
+ start independently.
+- Link this doc from the =todo.org= task "Extract music-config into a standalone
+ plugin."
diff --git a/docs/native-comp-subr-mocking.org b/docs/native-comp-subr-mocking.org
new file mode 100644
index 000000000..f66e5d102
--- /dev/null
+++ b/docs/native-comp-subr-mocking.org
@@ -0,0 +1,159 @@
+#+TITLE: Native Compilation vs. Mocking C Primitives in Tests
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-21
+
+* What this is
+
+A reference for a real, recurring trap: tests that redefine an Emacs C
+primitive (a "subr") with =cl-letf=, =fset=, =setf=, or =advice-add= behave
+differently once native compilation is enabled, and the failures are
+intermittent. We hit it head-on after re-enabling native-comp config-wide
+(early-init.el, commit 3fd28987, 2026-06-20). This document records the
+mechanism, the research, and the decision so we don't re-derive it.
+
+* The symptom
+
+After native-comp was re-enabled, tests that had been green for months started
+failing, with no change to their source. The errors looked like:
+
+: wrong-number-of-arguments #[nil (nil) (t)] 1
+
+That is a zero-argument mock lambda being called with one argument. The 8 tests
+that first tripped were in =test-dirvish-config-wrappers.el= and
+=test-calibredb-epub-config.el=, all mocking window primitives
+(=current-window-configuration=, =window-body-width=, =window-margins=,
+=get-buffer-window=).
+
+The failures were intermittent across the session: the same test passed, then
+crashed, then passed again. That non-determinism is the tell.
+
+* The mechanism
+
+Native-comp emits *direct* calls to primitives for speed. So when Lisp code
+redefines or advises a primitive (which is exactly what a test mock does),
+natively-compiled callers would normally bypass the redefinition entirely. To
+prevent that, Emacs generates a small per-primitive *trampoline* (a =.eln=
+under =eln-cache/=) the first time a primitive is redefined. The trampoline
+reroutes calls to the primitive through its Lisp function cell, where the mock
+lives.
+
+The trampoline is generated lazily and cached on disk, and that is the source
+of the non-determinism: whether a given mock "works" depends on whether the
+trampoline for that primitive has been compiled into the eln-cache yet. As
+native-comp compiles more in the background, more mocks start routing through
+trampolines.
+
+** Three distinct failure modes
+
+Because behavior depends on trampoline state, the same mock can fail three
+different ways:
+
+1. *Generation failure.* The trampoline =.eln= can't be built or loaded
+ (notably under =emacs --batch=), giving
+ =native-lisp-load-failed "... subr--trampoline-*.eln"=. This is the mode our
+ older CLAUDE.md insight first documented.
+2. *Silent bypass.* When a trampoline isn't available and can't be generated,
+ the manual states natively-compiled callers *ignore* the redefinition and
+ call the real primitive. The mock does nothing, so the test passes for the
+ wrong reason or asserts against real behavior.
+3. *Arity mismatch.* The trampoline *is* built and routes to the mock, but
+ calls it with the primitive's *maximum* arity (filling optionals with nil),
+ not the arity the source used. A fixed-arity mock narrower than the
+ primitive then throws =wrong-number-of-arguments=. This is the mode that bit
+ us this session (every one of the 8 was this).
+
+* Important: this is a test-only artifact
+
+Production code never redefines a C primitive, so these trampolines are never
+generated for this reason in normal use. Nothing here is a defect in the
+config. It is an incompatibility between *mocking primitives in tests* and
+native-comp, confined to the test suite.
+
+* What the wider community has found
+
+This is well known and genuinely hard. It is not us doing something wrong.
+
+- [[https://lists.gnu.org/archive/html/bug-gnu-emacs/2021-10/msg00971.html][bug#51140 (emacs-devel)]] — "cl-letf appears not to work with native-comp."
+ Redefining a built-in like =process-exit-status= via =cl-letf= breaks under
+ native compilation. Confirms the core problem.
+- [[https://github.com/jorgenschaefer/emacs-buttercup/issues/230][buttercup issue #230]] — the buttercup test framework's =spy-on= on primitives
+ (=file-exists-p=, =buffer-file-name=) fails with the
+ =native-lisp-load-failed ... subr--trampoline-*.eln= error (failure mode 1).
+ Our scenario exactly, in a mainstream test framework.
+- [[https://groups.google.com/g/linux.debian.bugs.dist/c/n9P2xhpruDE][Debian bug#1021842]] — buttercup's *own self-tests* hit the trampoline
+ compilation error. Even the test framework's maintainers run into it.
+- [[https://lists.gnu.org/archive/html/bug-gnu-emacs/2023-03/msg00076.html][bug#61880 (emacs-devel)]] — native compilation fails to generate trampolines
+ in certain sequential cases (failure mode 1, deterministic variant).
+- [[https://lists.gnu.org/archive/html/emacs-diffs/2023-03/msg00145.html][emacs-29 commit (bug-fix)]] — Emacs added a warning when you redefine a
+ primitive that the trampoline machinery itself depends on
+ ("Redefining '%s' might break trampoline native compilation"). Shows the
+ maintainers' stance: redefining primitives is discouraged.
+- [[https://www.gnu.org/software/emacs/manual/html_node/elisp/Native_002dCompilation-Variables.html][ELisp Manual: Native-Compilation Variables]] — documents
+ =native-comp-enable-subr-trampolines=. Default on; generates trampolines on
+ the fly. When *off* and no cached trampoline exists, "calls to that primitive
+ from natively-compiled Lisp will ignore redefinitions and advices" (this is
+ failure mode 2, and the catch in the common workaround below).
+
+** The two commonly-cited workarounds, and their costs
+
+- *Disable subr trampolines for tests* (=native-comp-enable-subr-trampolines
+ nil=). The most-cited quick fix. One line. But per the manual it makes
+ natively-compiled callers *ignore* the mock (failure mode 2). It only works
+ reliably when the code under test runs interpreted, not natively compiled.
+ With native-comp aggressively compiling our modules, the code under test is
+ increasingly native, so this risks silent mock-bypass: tests that pass while
+ asserting against the real primitive. Worse than a loud failure.
+- *Don't mock primitives at all.* The maintainers' and our own
+ =elisp-testing.md='s position: inject dependencies or test pure helpers
+ instead. The only fix immune to all three failure modes. Also the most work.
+
+* Our decision (2026-06-21)
+
+We chose a pragmatic middle path with a clear long-term direction.
+
+1. *Make subr mocks variadic.* The arity mode (3) is the only one we have
+ actually suffered. A mock written =(lambda (&rest _) VALUE)= tolerates the
+ trampoline's full-arity call. We swept every arity-narrow subr mock in the
+ suite to append =&rest _= to its arglist (preserving any named args the
+ body uses). This is deterministic and keeps trampolines on, so mocks still
+ route correctly (no silent bypass).
+2. *Enforce it with a meta-test.* =tests/test-meta-subr-mock-arity.el= statically
+ scans every test file for =symbol-function= / =fset= redefinitions of a
+ subr and fails =make test= if any mock can't accept the primitive's maximum
+ arity (=func-arity=). It is deterministic (a pure source read; no dependence
+ on eln-cache state), so a new arity-narrow mock can't merge silently. The
+ rule it enforces is NOT "never mock a subr" (the suite mocks subrs like
+ =message= and =completing-read= hundreds of times, all fine) but "a subr
+ mock must accept the primitive's arity."
+3. *Treat "migrate off primitive-mocking" as a long-term test-quality project.*
+ The variadic sweep fixes the mode we hit but leaves modes 1 and 2 latent
+ (we haven't hit them, but they exist). The durable fix the ecosystem points
+ to is restructuring tests to not redefine primitives at all. Filed as a
+ standalone TODO rather than forced now.
+
+** Why not just disable trampolines for tests?
+
+Because of failure mode 2 (silent bypass) above. In our native-comp-heavy
+setup, disabling trampolines would let natively-compiled code under test ignore
+the mocks, producing tests that pass while testing nothing. A loud
+=wrong-number-of-arguments= that the meta-test prevents up front is strictly
+safer than a quiet false pass.
+
+* Practical rule for writing tests (today)
+
+When you mock a C primitive (subr) in a test, make the replacement variadic:
+
+: (cl-letf (((symbol-function 'window-body-width) (lambda (&rest _) 200)))
+: ...)
+
+not
+
+: (cl-letf (((symbol-function 'window-body-width) (lambda (_) 200))) ; breaks under native-comp
+: ...)
+
+If the body needs the argument, keep it and append =&rest _=:
+
+: (lambda (cmd &rest _) (member cmd allowed))
+
+The meta-test will catch you if you forget. Better still, when practical, don't
+mock the primitive: pass the value in as a parameter, or test a pure helper.
diff --git a/docs/design/ai-kb.org b/docs/specs/ai-kb-spec.org
index 22a9cb9cf..fbd35ca55 100644
--- a/docs/design/ai-kb.org
+++ b/docs/specs/ai-kb-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 03742426-35ce-41c5-aed7-d4e248e91833
+:STATUS: not-started
+:END:
#+TITLE: Design: AI Knowledge Base (ai-kb)
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-24
diff --git a/docs/design/ai-vterm.org b/docs/specs/ai-vterm-spec-superseded.org
index 99526b632..0b6bfb86c 100644
--- a/docs/design/ai-vterm.org
+++ b/docs/specs/ai-vterm-spec-superseded.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 3abd0270-e87c-42b7-9b3a-ef60300db99d
+:STATUS: superseded
+:END:
#+TITLE: Design: ai-vterm — in-Emacs Claude launcher
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-07
diff --git a/docs/design/cache-helper-design.org b/docs/specs/cache-helper-design-spec-implemented.org
index 5de0f348c..27c818dcb 100644
--- a/docs/design/cache-helper-design.org
+++ b/docs/specs/cache-helper-design-spec-implemented.org
@@ -1,10 +1,14 @@
+:PROPERTIES:
+:ID: 647c5101-21c2-47bb-aaa7-72c757f45fb7
+:STATUS: implemented
+:END:
#+TITLE: Cache Helper Design Addendum
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-10
* Status
-Phase 5 design addendum to [[file:utility-consolidation.org][utility-consolidation.org]]. Specifies the cache API to extract before any code moves.
+Phase 5 design addendum to [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]]. Specifies the cache API to extract before any code moves.
* Problem
diff --git a/docs/design/company-to-corfu-migration.org b/docs/specs/company-to-corfu-migration-spec.org
index 55da081c8..a7b059a3b 100644
--- a/docs/design/company-to-corfu-migration.org
+++ b/docs/specs/company-to-corfu-migration-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 68733ba2-37a7-4a7b-bfaa-b845d82ff1e7
+:STATUS: not-started
+:END:
#+TITLE: Design: Migrate from Company to Corfu (with prescient integration)
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-15
diff --git a/docs/design/coverage.org b/docs/specs/coverage-spec-implemented.org
index acd8b4c43..65734fb3d 100644
--- a/docs/design/coverage.org
+++ b/docs/specs/coverage-spec-implemented.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 7d7f4486-fad7-4f0a-bd9a-775bd4cd8f7e
+:STATUS: implemented
+:END:
#+TITLE: Design: Coverage Reporting
#+AUTHOR: Craig Jennings
#+DATE: 2026-04-22
diff --git a/docs/design/debug-profiling.org b/docs/specs/debug-profiling-spec.org
index 3f5792501..5961071b8 100644
--- a/docs/design/debug-profiling.org
+++ b/docs/specs/debug-profiling-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: c713b431-ae14-498d-aba9-b84d52f981b6
+:STATUS: not-started
+:END:
#+TITLE: Design: debug-profiling.el module
#+AUTHOR: Craig Jennings
#+DATE: 2026-04-26
diff --git a/docs/design/dev-setup-project.org b/docs/specs/dev-setup-project-spec.org
index 280b015b2..5d64f368f 100644
--- a/docs/design/dev-setup-project.org
+++ b/docs/specs/dev-setup-project-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 596fce5d-1bab-46e7-8567-d4a2e0923091
+:STATUS: not-started
+:END:
#+TITLE: Design: cj/dev-setup-project
#+AUTHOR: Craig Jennings
#+DATE: 2026-04-22
diff --git a/docs/design/dupre-clear-theme.org b/docs/specs/dupre-clear-theme-spec.org
index 3b88a7d0f..578eb240e 100644
--- a/docs/design/dupre-clear-theme.org
+++ b/docs/specs/dupre-clear-theme-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 20df7f50-4759-47ba-9782-8dd25a2e173e
+:STATUS: not-started
+:END:
#+TITLE: dupre-clear — a contrast-first AAA sibling theme
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-07
@@ -78,7 +82,7 @@ The hardest slot is *blue keywords*: a deep dupre blue (#67809c) is intrinsicall
- 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=.
+- dupre lineage: dupre ← distinguished (emacs, Kim Silkebaekken) ← vim-distinguished. The dupre theme has since been retired from the tree (recoverable from git history); WIP, the active theme-studio theme, descends from its palette.
- 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
diff --git a/docs/specs/face-font-diagnostic-popup-spec-implemented.org b/docs/specs/face-font-diagnostic-popup-spec-implemented.org
new file mode 100644
index 000000000..3e8fadcd8
--- /dev/null
+++ b/docs/specs/face-font-diagnostic-popup-spec-implemented.org
@@ -0,0 +1,197 @@
+:PROPERTIES:
+:ID: 98f065cf-8bd5-46a0-ac24-da94d66855ad
+:STATUS: implemented
+:END:
+#+TITLE: Face and Font Diagnostic Popup — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-14
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+
+| Status | implemented |
+|----------+---------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+---------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: Face and font diagnostic popup at point]] |
+
+* Summary
+
+A read-only command that, for the character at point in an ordinary buffer, pops up everything that determines how that character is painted: the full face stack, the effective merged attributes, the real font versus the declared family, and where each attribute came from (theme, config, or inheritance). It exists to answer one question fast — "why does this text look wrong under the theme, and is the fault the theme, my config, or a fallback?"
+
+* Problem / Context
+
+Theme work in this config keeps hitting the same wall: a glyph renders in the wrong color and there's no quick way to see why. The cursor showed gold in auto-dimmed buffers; elfeed rendered all-white ignoring its theme assignments. Each of those is a different layer failing — a face remap, an overlay, an unspecified attribute falling through to the default — and the built-in tools don't separate those layers or trace provenance.
+
+What paints a character is a merge of several sources resolved by the redisplay engine: the default face, then text-property faces (=face= / =font-lock-face=), then overlay faces stacked by priority, all rewritten by any =face-remapping-alist= entries, and finally a font chosen by the fontset that can differ from the face's declared =:family=. To debug a theme issue you need to see each layer, the merged result, and — for each face — whether its current attributes came from the active theme, from config, or from an =:inherit= chain that bottoms out at the default.
+
+=describe-char= and =C-u C-x ​== show the character, its faces as links, and the font, but they don't separate the stack by source, don't surface active remaps, and don't trace attribute provenance. The gap is exactly the part that distinguishes a theme bug from a config bug.
+
+* Goals and Non-Goals
+
+** Goals
+- For the character at point, show the face stack separated by source (text-property, overlay-by-priority, active remaps, default).
+- Show the effective merged attributes — the value that wins for each attribute.
+- Show the real font (=font-at=) next to the face's declared =:family=, to expose fontset substitution.
+- For each face, trace provenance: which theme(s) and/or config set each attribute, the =:inherit= chain, and the unspecified→fallback resolution.
+- Present it in a read-only, navigable help-style buffer obeying the project's unified popup placement and dismissal rules.
+- Degrade gracefully in out-of-scope buffers: show what can be read plus a banner naming the foreign color source — never a bare refusal.
+
+** Non-Goals
+- No editing of faces, themes, or attributes. This is a diagnostic, not an editor; theme-studio owns editing.
+- No reimplementation of =describe-char='s general character report (display tables, composition, char properties beyond faces).
+- No coverage of color sources outside the theme/face system as first-class (terminal ANSI palettes, document HTML/CSS, image buffers) — surfaced, not analyzed.
+- No persistence, history, or export of diagnostic output.
+
+** Scope tiers
+- v1: char-at-point diagnosis with an optional region-scan mode; the five info groups below; the help-style popup; graceful out-of-scope handling.
+- Out of scope: terminal-ANSI buffers, image/PDF buffers, and shr/document-rendered buffers as analyzed targets (they get the banner + best-effort dump).
+- vNext: interactivity — "send this face to theme-studio", jump-to-theme-spec actions, and any write path. Logged to todo.org.
+
+* Design
+
+The command — call it =cj/describe-face-at-point= (final name an open detail) — reads the character at point and builds a report buffer in five groups. It never mutates buffer or frame state.
+
+** For the user (what the popup shows)
+
+1. *Character context.* The character, its codepoint and Unicode name, and its script. Script is what explains fontset routing, so it earns its place even though it's one line.
+
+2. *Face stack, by source.* The layers that contribute, in merge order, each labeled by where it comes from:
+ - text-property faces: the =face= and =font-lock-face= properties at point, in list order, anonymous specs shown inline;
+ - overlay faces: every overlay covering point that carries a =face=, sorted by overlay priority, with a best-effort owner label;
+ - active remaps: the =face-remapping-alist= entries that apply to faces in the stack (this is the auto-dim layer);
+ - the default face underneath.
+ Source separation is the diagnostic — "is this from a text property, an overlay, or a remap?" is half the answer.
+
+3. *Effective merged attributes.* The winning value per attribute (family, height, weight, slant, foreground, background, underline, overline, strike-through, box, inverse-video). This is what actually paints.
+
+4. *Real font vs declared family.* The font =font-at= reports as actually used, next to the merged =:family=. A mismatch means the fontset substituted (emoji, CJK, a missing glyph) — a common "why is this one character different" cause.
+
+5. *Per-face provenance.* For each named face in the stack: which theme(s) set its attributes (=theme-face= property), whether config overrode it (=saved-face= / =customized-face= / a runtime =set-face-attribute=), the =:inherit= chain, and for each unspecified attribute the resolution trace — "=:foreground= unspecified → not set by any theme → no inherit → default fg." That last trace is the direct read on the elfeed-white class of bug.
+
+The report is a read-only buffer in a dedicated mode, with named faces rendered as buttons that re-run the command's per-face section or call =describe-face= (navigation only — no edits in v1).
+
+** For the implementer (how it's built)
+
+A pure core plus a thin interactive wrapper, per the project's interactive/internal split:
+
+- =cj/--face-diagnosis-at (pos &optional buffer)= → a plist describing the five groups. No prompts, no display. This is the testable unit.
+- =cj/describe-face-at-point= (interactive) → calls the core at point, renders the plist into the help buffer, places the window per the unified popup rules.
+- Region mode → maps the core over the distinct face-runs in the active region and concatenates.
+
+Data sources, by group:
+- Stack: =get-text-property= for =face= / =font-lock-face=; =overlays-at= filtered to those with a =face=, sorted by =overlay-get … 'priority=; =face-remapping-alist= (buffer-local) intersected with the stack; =get-char-property= as a cross-check on the merged text-prop+overlay face.
+- Merged attributes: see the open decision below — Emacs exposes no single "final merged plist" call, so the core folds the ordered stack itself.
+- Real font: =font-at=, then =query-font= / =font-info= for its family and name; nil under =--batch=, handled as "unavailable".
+- Provenance: =(get FACE 'theme-face)= for theme spec history, =saved-face= / =customized-face= / =face--attribute-from-frame= comparisons for config overrides, and =face-attribute= with the inherit-following argument to produce the resolution trace.
+
+Buffer classification (group 0, decides scope handling): a predicate inspects =major-mode= derivation and known markers to bucket the buffer as theme-faced (analyze fully), terminal-ANSI, document-shr, or image/no-text. Out-of-scope buckets still render groups 1–2 best-effort and prepend a banner naming the color source.
+
+* Alternatives Considered
+
+** Presentation: childframe / posframe popup
+- Good, because it floats near point and looks modern.
+- Bad, because the report is tall and structured; a childframe is cramped, doesn't scroll naturally, and fights the existing unified-popup policy.
+- Neutral, because a posframe could wrap the same render function later if wanted.
+
+** Presentation: which-key-style transient strip
+- Good, because it's lightweight.
+- Bad, because it can't hold five groups of structured, navigable, copyable text. Wrong tool for a report.
+
+** Reuse: extend describe-char instead of a new command
+- Good, because describe-char already resolves faces and the font and renders links.
+- Bad, because its output is fixed and character-report-shaped; the value here is source-separation and provenance, which would mean rewriting most of its body anyway. Better to study =descr-text.el= for the font/face resolution mechanics and build a focused command than to graft onto a general one.
+- Neutral, because we still reuse the same primitives it uses (=font-at=, =get-char-property=).
+
+** Scope: analyze every buffer uniformly
+- Good, because no classifier to write.
+- Bad, because in a terminal or an shr buffer the provenance trace is misleading — the color isn't from the theme, so "theme didn't set it" reads as a theme bug when it isn't. The banner exists precisely to stop that false read.
+
+* Decisions [7/7]
+** DONE Granularity: char-at-point with optional region scan
+- Context: precise diagnosis wants one character; occasionally you want a whole region surveyed.
+- Decision: We will default to the character at point and offer a region-scan mode over the distinct face-runs when a region is active.
+- Consequences: easier — the common case is one precise report; harder — region mode must dedupe face-runs and concatenate without flooding the buffer.
+
+** DONE Provenance is core v1
+- Context: provenance (theme vs config vs inherit, unspecified→fallback) is the whole reason to build this over describe-char.
+- Decision: We will treat the per-face provenance trace as required v1 content, not a follow-up.
+- Consequences: easier — the tool actually answers theme-vs-config; harder — provenance extraction is the most intricate part and carries Emacs-version risk on the =theme-face= / =saved-face= internals.
+
+** DONE Include the real-font (fontset) layer
+- Context: a face's =:family= can differ from the font actually chosen for a glyph.
+- Decision: We will show =font-at='s real font next to the declared family.
+- Consequences: easier — catches substitution bugs; harder — =font-at= is nil in batch, so tests must tolerate "unavailable".
+
+** DONE Presentation: read-only help-style buffer under the unified popup rules
+- Context: the report is tall and structured and benefits from scrolling, copy, and face links.
+- Decision: We will render into a dedicated read-only buffer and place/dismiss it via the project's unified popup placement and dismissal rules.
+- Consequences: easier — idiomatic, navigable, consistent with other popups; harder — depends on the unified-popup policy, whose placement thresholds are still being settled in its own task.
+
+** DONE Interactivity is vNext
+- Context: a "send face to theme-studio" bridge is attractive but is editing-adjacent.
+- Decision: We will ship v1 read-only; the theme-studio bridge and any write path are vNext.
+- Consequences: easier — v1 stays a safe pure diagnostic; harder — users must round-trip through theme-studio by hand until vNext.
+
+** DONE Out-of-scope buffers: classify and show everything, with a banner
+- Context: a hard refuse in a terminal/shr/image buffer is unhelpful and hides information.
+- Decision: We will classify the buffer, render what we can, and prepend a banner naming the foreign color source instead of refusing.
+- Consequences: easier — maximal information always, and the boundary teaches itself; harder — the classifier must recognize the buffer buckets reliably enough that the banner isn't wrong.
+
+** DONE Effective-attribute computation approach
+- Owner / by-when: Claude / before Phase 2 implementation.
+- Context: Emacs exposes no public call returning the final merged attribute plist for a position (text props + overlays + remaps as the C redisplay merges them). The tool has to produce the "what actually paints" values itself.
+- Decision: We fold the ordered, remap-expanded stack manually with =face-attribute=, treating overlays-over-text-props-over-default and applying remaps, and label the merged result as "computed" — accepting that exotic edge cases (relative heights, deep =:inherit= ordering) may diverge slightly from the engine. Alternative under consideration: lift the resolution mechanics from =descr-text.el= / =face-at-point= rather than hand-rolling.
+- Consequences: easier — a single explicit merge we can unit-test; harder — fidelity to the real engine isn't guaranteed for corner cases, so the spec stays "Ready with caveats" until the approach is pinned.
+
+*** Discussion
+- Resolved 2026-06-15: implemented as the hand-fold in =cj/--face-diag-merged-attributes= (overlays over text-props over default, remaps expanded ahead of their base), labeled "computed". Pinned by fixtures in =test-face-diagnostic.el= -- overlay-over-text-prop, a default remap, and a face-symbol attribute all resolve correctly. Exotic relative-height / deep-inherit cases may still diverge, accepted per the decision.
+
+* Implementation phases
+
+** Phase 1 — Core read model + buffer classifier
+=cj/--face-diagnosis-at= returns the plist for groups 0–2 (classification, character context, face stack by source). Pure, no display. Unit-tested against temp-buffer fixtures with planted text properties, overlays, and remaps. Tree stays green.
+
+** Phase 2 — Merged attributes + real font
+Extend the core with group 3 (effective merged attributes, per the resolved computation decision) and group 4 (=font-at= vs declared family, "unavailable" under batch). Unit-tested on the merge fixtures.
+
+** Phase 3 — Provenance trace
+Add group 5: theme/config/inherit provenance and the unspecified→fallback resolution per face. Tested with fixtures that set a face via a loaded theme, via =set-face-attribute=, and leave one attribute unspecified.
+
+** Phase 4 — Render + popup wiring
+The interactive =cj/describe-face-at-point=, the read-only mode with face buttons, region-scan mode, and placement/dismissal via the unified popup rules. Smoke-tested live; the render function tested on a captured plist.
+
+* Acceptance criteria
+- [ ] On a normal prog/text buffer, the popup shows all five groups for the character at point.
+- [ ] An overlay face (e.g. region) at point appears in the stack, labeled as an overlay, above the text-property faces.
+- [ ] An active =face-remapping-alist= remap (e.g. under auto-dim) appears as the remap layer and is reflected in the merged result.
+- [ ] A face with an unspecified =:foreground= shows the resolution trace down to its actual fallback.
+- [ ] A glyph using a substituted font (e.g. an emoji) shows a real-font ≠ declared-family mismatch.
+- [ ] In a terminal/shr/image buffer, the popup shows a banner naming the color source and still renders what it can.
+- [ ] The core (=cj/--face-diagnosis-at=) returns its plist with no prompts and no display side effects, and passes under =make test= (=--batch=).
+
+* Readiness dimensions
+- Data model & ownership: all data is read live from buffer/overlay/face/font state; nothing user-authored, generated, or persisted. The report plist is ephemeral.
+- Errors, empty states & failure: no character at point (empty buffer / eob) → a clear "nothing at point" message; =font-at= nil under batch → "font: unavailable (batch)"; out-of-scope buffer → banner, not error. No silent data loss (read-only tool).
+- Security & privacy: N/A — reads visible buffer text and face metadata; logs nothing; no credentials.
+- Observability: the tool *is* the observability surface. Its own failures surface as in-buffer messages naming the missing piece (e.g. "font backend unavailable").
+- Performance & scale: single character is trivial; region mode is bounded by distinct face-runs in the region — cap or warn past a threshold so a whole-buffer region doesn't generate a huge report. No live/remote dependency.
+- Reuse & lost opportunities: reuses =font-at=, =get-char-property=, =face-attribute=, =theme-face=/=saved-face= internals, and the project's unified-popup policy and interactive/internal split. Studies =descr-text.el= rather than forking it.
+- Architecture fit & weak points: integration points are the unified-popup placement policy (in flux) and the face/theme internals (=theme-face=, =saved-face=) which are version-sensitive — isolate them behind small accessors so an Emacs-version change touches one place.
+- Config surface: the region-run cap is the one likely knob, with a safe default. Possibly a toggle for whether out-of-scope buffers render best-effort or just the banner.
+- Documentation plan: a docstring on the command, the keybinding noted in the keybinding map, and a CLAUDE.md/notes pointer only if a non-obvious gotcha surfaces. No user manual needed.
+- Dev tooling: existing =make test= / byte-compile / live-reload loop; no new targets.
+- Rollout, compatibility & rollback: additive new command + one keybinding; nothing persisted or migrated; rollback is removing the module. No compatibility surface.
+- External APIs & deps: N/A — pure Emacs primitives, no external API or package dependency.
+
+* Risks, Rabbit Holes, and Drawbacks
+- *Merge fidelity* (the open decision): a hand-folded attribute merge may diverge from the redisplay engine on exotic cases. Dodge: validate against =describe-char= on a handful of fixtures; label the result "computed"; don't claim pixel-exactness.
+- *Provenance internals*: =theme-face= / =saved-face= are not a stable public contract. Dodge: isolate behind accessors; tolerate missing properties as "unknown source" rather than erroring.
+- *Unified-popup dependency*: that policy's placement thresholds aren't settled. Dodge: code to the policy's interface, accept whatever defaults it lands on; don't invent a parallel placement scheme here.
+- *Overlay owner labeling*: overlays don't record their creator. Dodge: best-effort label from known marker properties; fall back to "(overlay)" without guessing.
+
+* Review and iteration history
+** 2026-06-14 Sun @ 22:30:00 -0500 — Claude (for Craig) — author
+- What: initial draft.
+- Why: theme debugging keeps hitting layered face/font issues with no tool that separates the layers or traces provenance; agreed to spec before building.
+- Artifacts: [[file:../../todo.org][todo.org: Face and font diagnostic popup at point]]; motivating bugs — gold-text-in-auto-dim, elfeed-ignores-theme.
diff --git a/docs/design/flycheck-modeline-customization.org b/docs/specs/flycheck-modeline-customization-spec-implemented.org
index 25e2c7854..59567be60 100644
--- a/docs/design/flycheck-modeline-customization.org
+++ b/docs/specs/flycheck-modeline-customization-spec-implemented.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 76979608-956e-474f-90a8-8d0c958101a0
+:STATUS: implemented
+:END:
#+TITLE: Design: Flycheck modeline customization
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-15
diff --git a/docs/design/gloss.org b/docs/specs/gloss-spec-doing.org
index 04efc38bf..320b83ebf 100644
--- a/docs/design/gloss.org
+++ b/docs/specs/gloss-spec-doing.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 295f9969-ccef-4df9-945b-9e08d8069daf
+:STATUS: doing
+:END:
#+TITLE: Design — gloss (Glossary Lookup with Online-Sourced Selection)
#+DATE: 2026-04-28
#+STATUS: Draft
diff --git a/docs/design/gptel-gh-tool.org b/docs/specs/gptel-gh-tool-spec.org
index a9ba22bb1..80ecc0ab6 100644
--- a/docs/design/gptel-gh-tool.org
+++ b/docs/specs/gptel-gh-tool-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: a124dd0f-1f40-4533-aeb8-595d93e20865
+:STATUS: not-started
+:END:
#+TITLE: Design: Wrap the gh CLI as a GPTel tool
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-16
@@ -1052,7 +1056,7 @@ Items intentionally deferred:
- [[file:gptel-agentic-tool-ideas.org][gptel-agentic-tool-ideas.org]] -- broader agentic-tool design;
=gh= sits alongside the MCP integration as the
collaboration tier.
-- [[file:mcp-el-gptel-integration.org][mcp-el-gptel-integration.org]] -- sibling design; same
+- [[id:b4c274c5-8572-4a7b-b657-d315712bd6af][mcp-el-gptel-integration-spec-doing.org]] -- sibling design; same
confirm-on-write pattern for safety.
- [[https://cli.github.com/manual/][gh CLI manual]] -- subcommand reference.
- =gh --version 2.92.0= help output -- verified flag semantics
diff --git a/docs/design/gptel-git-tools-magit-backend.org b/docs/specs/gptel-git-tools-magit-backend-spec.org
index 94fbb0cec..bd84b0595 100644
--- a/docs/design/gptel-git-tools-magit-backend.org
+++ b/docs/specs/gptel-git-tools-magit-backend-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: bd47c9a8-aae1-4a3d-ad5b-b8767f2fd580
+:STATUS: not-started
+:END:
#+TITLE: Design: gptel git tools on a magit backend
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-16
diff --git a/docs/design/gptel-network-tools.org b/docs/specs/gptel-network-tools-spec.org
index aae2cc2a8..c28d54694 100644
--- a/docs/design/gptel-network-tools.org
+++ b/docs/specs/gptel-network-tools-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 6388588c-dac2-4c52-97ad-2343ba1443fc
+:STATUS: not-started
+:END:
#+TITLE: Design: gptel network tools
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-16
@@ -7,7 +11,7 @@
Draft. Brainstorm output captured from a =/brainstorm= session on
2026-05-16. Sibling to
-=docs/design/gptel-git-tools-magit-backend.org= and the broader theme
+=docs/specs/gptel-git-tools-magit-backend-spec.org= and the broader theme
hierarchy under =** TODO [#B] GPTel Tool Work= in =todo.org=.
The conventional vs tail-sample exploration covered three categories
diff --git a/docs/design/init-load-graph.org b/docs/specs/init-load-graph-spec-doing.org
index 3db2fe854..05dd9e0a3 100644
--- a/docs/design/init-load-graph.org
+++ b/docs/specs/init-load-graph-spec-doing.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: e1fd137e-e164-42f4-a658-f4d32fbe3228
+:STATUS: doing
+:END:
#+TITLE: Design: Untangle the init.el Load Graph
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-04
@@ -174,7 +178,7 @@ Foundation modules should be able to load in batch mode without package,
network, timer, or UI-package side effects.
Adding a new Layer 1 module requires a coordinated update to the
-=system-lib.el= dependency budget in [[file:utility-consolidation.org][utility-consolidation.org]].
+=system-lib.el= dependency budget in [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]].
Topic libraries introduced by the utility project join Layer 1 only when their
first consumer is foundation-eager. Otherwise they are Layer 2 and loaded by an
@@ -391,7 +395,7 @@ Worked example:
;; Runtime requires: user-constants, seq, subr-x.
;; Direct test load: yes (batch-safe; private config is optional).
;;
-;; See also: docs/design/init-load-graph.org, tests/test-calendar-sync.el.
+;; See also: docs/specs/init-load-graph-spec-doing.org, tests/test-calendar-sync.el.
;;
;;; Code:
#+end_src
@@ -448,7 +452,7 @@ Inventory rules:
- Every module required by =init.el= must be represented before Phase 2 starts.
- Discoveries during later phases update the inventory.
- This inventory is independent from the helper inventory owned by
- [[file:utility-consolidation.org][utility-consolidation.org]].
+ [[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]].
Exit criteria:
@@ -643,7 +647,7 @@ rollback shapes.
This sibling project can run beside Phase 2. When explicit-dependency work finds
a generic duplicated helper, the sibling project owns the extraction commit when
the helper is in scope for that project. See
-[[file:utility-consolidation.org][utility-consolidation.org]] for candidate
+[[id:fc2e3926-b4a1-4b45-92eb-20841e13f655][utility-consolidation-spec-doing.org]] for candidate
helpers, naming rules, dependency budgets, migration phases, and test policy.
* Testing Strategy
diff --git a/docs/specs/keybinding-console-safety-spec-doing.org b/docs/specs/keybinding-console-safety-spec-doing.org
new file mode 100644
index 000000000..4a1dec813
--- /dev/null
+++ b/docs/specs/keybinding-console-safety-spec-doing.org
@@ -0,0 +1,943 @@
+:PROPERTIES:
+:ID: 540bf06b-16b8-46c6-b459-c40d1b9c795d
+:STATUS: doing
+:END:
+#+TITLE: Keymap Consolidation — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-12
+
+* Metadata
+| Status | doing |
+|----------+--------------------------------------------------------------------|
+| 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/specs/keybinding-console-safety-spec-doing.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/mcp-el-gptel-integration.org b/docs/specs/mcp-el-gptel-integration-spec-doing.org
index 6bac77c54..f22e91959 100644
--- a/docs/design/mcp-el-gptel-integration.org
+++ b/docs/specs/mcp-el-gptel-integration-spec-doing.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: b4c274c5-8572-4a7b-b657-d315712bd6af
+:STATUS: doing
+:END:
#+TITLE: Design: Wire mcp.el into GPTel for MCP server access
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-16
@@ -1420,7 +1424,7 @@ C= so existing GPTel keys aren't disturbed (rev 3).
- [[file:gptel-tools-shortlist.org][gptel-tools-shortlist.org]] -- local-tools shortlist; MCP servers
slot in as the "external" tier.
- [[file:gptel-agentic-tool-ideas.org][gptel-agentic-tool-ideas.org]] -- broader agentic-tool design.
-- [[file:gptel-gh-tool.org][gptel-gh-tool.org]] -- sibling design; same confirm-on-write
+- [[id:a124dd0f-1f40-4533-aeb8-595d93e20865][gptel-gh-tool-spec.org]] -- sibling design; same confirm-on-write
pattern.
- [[https://github.com/lizqwerscott/mcp.el][lizqwerscott/mcp.el]] -- upstream.
- [[https://github.com/lizqwerscott/gptel-mcp.el][lizqwerscott/gptel-mcp.el]] -- considered and declined; see §
diff --git a/docs/specs/messenger-unification-spec.org b/docs/specs/messenger-unification-spec.org
new file mode 100644
index 000000000..92985f596
--- /dev/null
+++ b/docs/specs/messenger-unification-spec.org
@@ -0,0 +1,350 @@
+:PROPERTIES:
+:ID: 4bfc2011-8ffc-4765-8886-91df12141171
+:STATUS: not-started
+:END:
+#+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.
+ - /Smoke-first parity (Craig 2026-06-16)./ Signal is the least-built backend
+ and the only one whose UX Craig fully controls (no upstream package fighting
+ back), so the smoke rebuild is where the shape gets dialed in first: build
+ smoke to implement every core leaf (=j a u m k C Q=) and the in-buffer
+ chords natively, tune the feel against real use, and only then adapt telega
+ and slack to the now-proven contract. The guardrail: design the contract to
+ the /capability floor/, not to smoke's ceiling. Smoke can do anything, which
+ makes it the least representative backend — validate each core leaf against
+ the others' known limits as it is built (telega keeps its modal root buffer;
+ ERC has no threads/reactions/files; slack has no file upload or search), so
+ the reference does not bake in something a thinner backend can never match.
+ Rich verbs (=r e f /=) stay optional per-backend extensions, never core.
+- *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.
+
+* Global Prefix Keybinding Alphabet (per-app)
+
+/DRAFT addition, Claude 2026-06-16, for Craig's review (his request: "put this/
+/in the spec and I'll review it"). Folds the per-action prefix-key analysis/
+/into the held-open spec./
+
+This is the third of three keybinding surfaces, and the only one the rest of the
+spec doesn't already cover. The first is the in-chat buffer chords (C-c C-c
+confirm, C-c C-k cancel, C-c C-a attach) owned by =cj/messenger-mode=. The second
+is the cross-app aggregate verb (decision 6's jump-to-unread, one global chord
+that raises the newest unread conversation in /any/ backend). This third surface
+is the per-app global prefix: each messenger hangs off =C-;= with its own second
+key (=S= Slack, =M= Signal, =T= Telega, =E= ERC), and today the third key, the
+action leaf, is ad hoc per app. The goal: one leaf alphabet so the same action
+is the same final keystroke under every messenger.
+
+** The problem: the same key means different things today
+
+| Action | Slack (C-; S) | Signal (C-; M) | Telega (C-; T) | ERC (C-; E) |
+|----------------------+---------------+----------------+----------------+-------------|
+| Open a chat by name | C | m | unbound | c |
+|----------------------+---------------+----------------+----------------+-------------|
+| Directory: all | C | d | root buffer | b |
+|----------------------+---------------+----------------+----------------+-------------|
+| Directory: unread | c | none | unbound | unbound |
+|----------------------+---------------+----------------+----------------+-------------|
+| New DM / message | d | m | unbound | unbound |
+|----------------------+---------------+----------------+----------------+-------------|
+| Close this chat | unbound | none | C-x k | q |
+|----------------------+---------------+----------------+----------------+-------------|
+| Mark read + bury | q | none | unbound | n/a |
+|----------------------+---------------+----------------+----------------+-------------|
+| Connect / start | s | SPC | T = launch | C |
+|----------------------+---------------+----------------+----------------+-------------|
+| Disconnect / stop | S | q | Q (in-buffer) | Q |
+|----------------------+---------------+----------------+----------------+-------------|
+
+Read down a column and the leaves are arbitrary; read across a row and they
+disagree. Worse, the same letter collides on meaning: =C= opens Slack's roster
+but connects an ERC server; =q= disconnects Signal, marks-read in Slack, and
+parts a channel in ERC. There is no muscle-memory transfer between messengers.
+
+** Canonical per-app actions (spelled out)
+
+Daily verbs (per-conversation): open a specific chat by name; view the directory
+of all chats/channels; view the directory of only unread / reply-needed chats;
+message someone new; reply in a thread; close the current chat window; mark the
+current chat read and bury it; jump to next / previous unread chat; add a
+reaction; send an attachment; search messages.
+
+Session verbs (lifecycle): connect / bring online; disconnect / take offline;
+open the dashboard / roster overview.
+
+** Proposed unified leaf alphabet
+
+Keep each app's second key (=S M T E=); make the third key identical across all
+four so the action is the same tail-keystroke regardless of app.
+
+| Leaf | Action | Backends that can bind it today |
+|-------+------------------------------+--------------------------------------|
+| j | jump to / open a chat | Slack, Signal, Telega*, ERC |
+|-------+------------------------------+--------------------------------------|
+| a | directory: all chats | Slack, Signal, Telega, ERC |
+|-------+------------------------------+--------------------------------------|
+| u | directory: unread only | Slack, Telega*, ERC* (signel: gap) |
+|-------+------------------------------+--------------------------------------|
+| m | message someone new / DM | Slack, Signal, Telega, ERC* |
+|-------+------------------------------+--------------------------------------|
+| k | close (bury) this chat | all four (thin wrappers) |
+|-------+------------------------------+--------------------------------------|
+| SPC | mark read + bury | Slack, Telega* (others: gap) |
+|-------+------------------------------+--------------------------------------|
+| n / p | next / previous unread | Telega, ERC* (others: gap) |
+|-------+------------------------------+--------------------------------------|
+| r | reply in thread | Slack (others: protocol N/A) |
+|-------+------------------------------+--------------------------------------|
+| e | reaction / emoji | Slack, Telega (others: protocol N/A) |
+|-------+------------------------------+--------------------------------------|
+| f | attach file | Telega (others: protocol N/A) |
+|-------+------------------------------+--------------------------------------|
+| / | search | Telega in-chat (others: gap) |
+|-------+------------------------------+--------------------------------------|
+| C | connect / start | all four |
+|-------+------------------------------+--------------------------------------|
+| Q | disconnect / stop | all four |
+|-------+------------------------------+--------------------------------------|
+
+(* = the package command exists but needs a global wrapper or a binding added.)
+
+The core seven (=j a u m k C Q=) are the unifiable floor: every backend can
+support them (once signel's gaps are filled). The richer verbs (=r e f /=) bind
+only where the protocol and package allow; which-key then shows fewer options
+under a thinner backend, which is honest rather than confusing. An unsupported
+leaf is simply absent under that app's prefix, the same "nil verb = not
+supported" stance the registry already takes for the in-buffer chords.
+
+** How this rides the registry
+
+These leaves are the global-prefix face of the same verb set decision 6 is
+already growing. jump-to-unread, jump-to-chat-picker, and mark-read-and-bury in
+decision 6 map directly to =u= (or the cross-app aggregate), =j=, and =SPC=
+here. The registry should carry an optional per-backend command for each leaf
+(=:open=, =:roster=, =:unread=, =:message=, =:close= ...), and the library
+builds each app's =C-; <key>= submap from whatever the backend registered, so a
+new verb lands everywhere in one place, exactly as the in-buffer verbs do. A
+backend that registers nil for a leaf gets no binding for it.
+
+** A caveat on visual UX (keys unify cleanly; rendering does not)
+
+The leaf can be identical, but "open the directory" still /looks/ different per
+backend, because the packages have different display models: Slack and ERC pop a
+minibuffer completing-read picker; Telega and (smoke) Signal show a persistent
+roster buffer. The bottom-drawer placement rule unifies where a /chat/ lands;
+it does not make Slack grow a persistent roster. Unify the keys and the action
+vocabulary; accept that the roster rendering differs per backend rather than
+fighting each package's design.
+
+** Open questions for Craig
+
+1. The leaf letters: are =j a u m k C Q= (+ =r e f / n p SPC=) right, or do you
+ want different mnemonics (=o= open, =l= list, ...)? This is the muscle-memory
+ commitment, so it is yours to set before any binding lands.
+2. Signal parity (now in scope per Craig 2026-06-16): the smoke rebuild is the
+ place to hit every core leaf natively. See the smoke-first note added to the
+ Phases section.
+
+* 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/music-config-without-emms.org b/docs/specs/music-config-without-emms-spec.org
index 929423df6..32fd67367 100644
--- a/docs/design/music-config-without-emms.org
+++ b/docs/specs/music-config-without-emms-spec.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 423bc355-18d3-4e39-9e7a-f768b865d95b
+:STATUS: not-started
+:END:
#+TITLE: Design: music-config Without EMMS
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-15
diff --git a/docs/specs/org-faces-spec-implemented.org b/docs/specs/org-faces-spec-implemented.org
new file mode 100644
index 000000000..c88559061
--- /dev/null
+++ b/docs/specs/org-faces-spec-implemented.org
@@ -0,0 +1,154 @@
+:PROPERTIES:
+:ID: 35578114-8c29-43af-97a2-fdfea01a802e
+:STATUS: implemented
+:END:
+#+TITLE: Org Header-Row Faces — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-15
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | implemented |
+|----------+----------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: org-faces module + theme-studio app]] |
+|----------+----------------------------------------------------------------|
+
+* Summary
+
+A small config module, =org-faces-config.el=, that defines named, theme-agnostic faces for org's TODO keywords and priority cookies, wires them through =org-todo-keyword-faces= and =org-priority-faces=, and a matching theme-studio app titled "org-faces" so each is an editable, previewable element. It makes the agenda header row (keyword plus priority) a per-element themeable layer that reads as clearly custom, not built-in org.
+
+* Problem / Context
+
+Per-keyword and per-priority coloring was stripped from =org-config.el= earlier, so today every keyword (TODO, DOING, …) renders in org's built-in =org-todo= / =org-done=, and every priority cookie ([#A] through [#D]) renders in the single =org-priority= face — one color for all four. There's no way to color them individually.
+
+The dupre theme does carry a parallel custom set (=dupre-org-todo=, =dupre-org-priority-a..d=, with =-dim= variants), but it's theme-specific and currently unwired, so it colors nothing. And theme-studio has no surface for these at all: its org-mode app exposes only the built-in =org-priority=, and the preview shows a single [#A].
+
+The immediate driver is theme-testing the agenda: the header row is the densest, most-scanned part of the agenda, and right now its keyword/priority colors can't be tuned in theme-studio or distinguished from built-in org faces. The user wants each keyword and priority to be its own themeable element, in its own clearly-custom namespace, surfaced in its own theme-studio section.
+
+* Goals and Non-Goals
+
+** Goals
+- Each keyword (TODO, PROJECT, DOING, WAITING, VERIFY, STALLED, DELEGATED, FAILED, DONE, CANCELLED) and each priority (A-D) gets its own named face.
+- The faces live in one module (=org-faces-config.el=), in their own prefix, wired through =org-todo-keyword-faces= and =org-priority-faces=.
+- theme-studio surfaces them as a dedicated "org-faces" app (own section, alongside elfeed and mu4e) with one editable row per face and a header-row preview.
+- They render correctly on any theme (sensible defaults) and are overridden by the generated theme.
+
+** Non-Goals
+- Not editing the built-in org faces — the org-mode app keeps those.
+- Not a general org face overhaul; only the header-row keyword + priority set.
+- Not retiring or deleting the legacy =dupre-org-*= faces from dupre-faces.el in v1 — auto-dim is only repointed away from them (decision below).
+
+** Scope tiers
+- v1: =org-faces-config.el= (base + =-dim= faces, wiring, init load); =org-faces-*-dim= wired into auto-dim (replacing the orphaned dupre-org-* entries); the theme-studio "org-faces" app (faces, preview, seeds).
+- Out of scope: built-in org faces; non-keyword/priority org elements; terminal/document-rendered color sources.
+- vNext (log to todo.org): retire or migrate the legacy =dupre-org-*= faces in dupre-faces.el; a grouped-subsection preview.
+
+* Design
+
+A new bespoke app, "org-faces", joins theme-studio's application list next to elfeed and mu4e. Its faces are the config's custom header-row set, not org's built-ins, and the =org-faces-= prefix says so on every row.
+
+** For the user
+
+Pick "org-faces" in theme-studio's application dropdown. The face table lists each keyword and each priority as a normal editable row — foreground, background, style toggles, box, lock — exactly like every other package face. The preview renders agenda-style lines: each keyword shown in its own face, then a =[#A] [#B] [#C] [#D]= row in the four priority faces, so the ramp is visible and any cookie is click-to-select. Setting a color there writes it into the generated theme; once that theme loads, the real agenda's keywords and priorities pick it up. Because the faces are named =org-faces-todo=, =org-faces-priority-a=, and so on, it's obvious this is the config's layer rather than built-in org.
+
+** For the implementer
+
+=org-faces-config.el= defines one =defface= per keyword and per priority (=org-faces-todo= … =org-faces-cancelled=, =org-faces-priority-a= … =org-faces-priority-d=), each with a default foreground so the row is colored out of the box. It then sets =org-todo-keyword-faces= to map each keyword string to its face and =org-priority-faces= to map =?A..?D= to the priority faces. The module is required after org so the faces exist before org applies the maps; the =defface=​s themselves load eagerly, which is what org needs.
+
+theme-studio side, all mechanical against the existing bespoke-app machinery:
+- =face_data.py=: =ORGFACES_FACES= (the face-name list) and =ORGFACES_SEED= (default colors mirroring =org-faces-config.el=).
+- =generate.py=: one row in the =_BESPOKE_APPS= spec, =("org-faces","org-faces","orgfaces",ORGFACES_FACES,"org-faces-",ORGFACES_SEED)=.
+- =app_inventory.py=: add =org-faces= to =BESPOKE_APPS=.
+- =app.js=: =renderOrgFacesPreview= building the keyword lines and the priority-cookie row with =os('org-faces', face, text)=, registered under =orgfaces= in =PACKAGE_PREVIEWS=.
+- =build-theme.el= needs no change — the package tier already emits these faces.
+
+The =org-faces-= prefix is also theme-studio's label-strip prefix, so rows read as "todo", "priority a", etc.
+
+* Alternatives Considered
+
+** Reuse the existing dupre-org-* names
+- Good, because no new faces are defined.
+- Bad, because the names are theme-specific (dupre) and the goal is a theme-agnostic, clearly-custom namespace; they'd still need all the theme-studio wiring.
+- Neutral, because the auto-dim mappings already reference them, which both helps (dim variants exist) and hurts (couples the new layer to one theme).
+
+** Inline specs in org-todo-keyword-faces (no named faces)
+- Good, because it's the least code and needs no defface.
+- Bad, because theme-studio can only theme *named* faces; inline specs can't be edited or previewed there, which defeats the whole point.
+- Neutral, because org supports both forms equally at runtime.
+
+** Put these in the existing org-mode app rather than a new app
+- Good, because one fewer app in the dropdown.
+- Bad, because it blurs the built-in-vs-custom line the user explicitly wants drawn.
+- Neutral, because the preview would grow rather than a new one being added.
+
+* Decisions [4/4]
+
+** DONE Face prefix
+- Context: the prefix is the public face namespace and theme-studio's label-strip; it must read as custom, not built-in org.
+- Decision: We will use =org-faces-= (e.g. =org-faces-todo=, =org-faces-priority-a=).
+- Consequences: easier — cohesive with the module and section name, and a clean label-strip prefix; harder — it still begins with "org", so a newcomer could misread it as built-in.
+
+** DONE defface defaults vs inherit-only
+- Context: should the header row be colored on any theme, or only once a theme sets these faces?
+- Decision: We will give each face a real default =:foreground= so it's colored out of the box, overridable by the theme.
+- Consequences: easier — works on stock Emacs and any theme, and the theme-studio seed matches the live default; harder — the defaults are theme-blind, so a theme that doesn't override them shows the generic colors rather than its own palette.
+
+** DONE Auto-dim dim variants
+- Context: dupre defines =dupre-org-*= and auto-dim remaps them to =-dim= variants in unfocused windows; rewiring to =org-faces-*= would drop that dim treatment unless it's carried over.
+- Decision: We will include =org-faces-*-dim= variants in v1 and wire them into auto-dim, replacing the now-orphaned =dupre-org-*= entries in =auto-dim-config.el=, so keywords and priorities stay legible when a window is dimmed. The legacy =dupre-org-*= faces in =dupre-faces.el= are left defined-but-unused; retiring them stays vNext.
+- Consequences: easier — the dim treatment carries onto the new layer and dupre's mapping stops referencing orphaned faces; harder — doubles the face count (base + dim), touches =auto-dim-config.el=, and under the dupre theme the new faces use their defaults until dupre themes =org-faces-*=.
+
+** DONE Keyword coverage
+- Context: the vocabulary has 10 keywords; dupre only ever defined faces for 8.
+- Decision: We will give all 10 keywords (including DELEGATED and CANCELLED) their own face.
+- Consequences: easier — full control, no surprise fallbacks; harder — two more faces to seed and maintain.
+
+* Implementation phases
+
+** Phase 1 — org-faces.el module
+Define the base and =-dim= =defface=​s (all 10 keywords + A-D, each with real default foregrounds) and the =org-todo-keyword-faces= / =org-priority-faces= wiring, require it after org. Done when the agenda colors each keyword and priority through the new base faces. Verify with a full Emacs launch (the wiring is :config-adjacent).
+
+** Phase 2 — auto-dim integration
+In =auto-dim-config.el=, replace the =dupre-org-*= → =dupre-org-*-dim= entries with =org-faces-*= → =org-faces-*-dim=, so dimmed windows render the new layer through its dim variants. Done when an unfocused window shows keywords/priorities in their dim colors.
+
+** Phase 3 — theme-studio org-faces app
+Add the =face_data.py= face list and seed (base + dim), the =generate.py= spec row, the =app_inventory.py= entry, and the =app.js= preview plus registration. Done when "org-faces" appears in the dropdown next to elfeed/mu4e, the rows edit, the preview renders, and =make theme-studio-test= is green.
+
+** Phase 4 — generated-theme round-trip
+Set a color for an =org-faces-*= face in theme-studio, =make deploy-wip=, and confirm the real agenda picks it up. Done when the round-trip lands the color in Emacs.
+
+* Acceptance criteria
+- [ ] Each keyword (TODO … CANCELLED) renders in a distinct =org-faces-*= face in the agenda.
+- [ ] [#A]-[#D] render in distinct =org-faces-priority-*= faces.
+- [ ] theme-studio shows an "org-faces" app beside elfeed/mu4e, one row per face, with a header-row preview.
+- [ ] A color set in theme-studio for an =org-faces-*= face appears on the real agenda after =deploy-wip=.
+- [ ] =org-faces-config.el= byte-compiles clean and the theme-studio suite is green.
+
+* Readiness dimensions
+- Data model & ownership: faces are user-authored defaults (=org-faces-config.el=) overridden by the generated theme; theme-studio edits the package-tier specs; =org-todo-keyword-faces= / =org-priority-faces= own the keyword/priority wiring.
+- Errors, empty states & failure: a keyword with no mapping falls back to org's built-in =org-todo= / =org-done= — acceptable, not silent data loss. N/A otherwise (no I/O).
+- Security & privacy: N/A — faces only.
+- Observability: the live agenda and the theme-studio preview are the visible surface; a wrong color is self-evident.
+- Performance & scale: N/A — about a dozen faces.
+- Reuse & lost opportunities: rides org's built-in keyword/priority face hooks and build-theme's existing package tier; no converter changes.
+- Architecture fit & weak points: same bespoke-app pattern as elfeed/mu4e. Weak point is load order — faces must exist before org applies the map; eager =defface= at module load covers it.
+- Config surface: =org-todo-keyword-faces=, =org-priority-faces= (set by the module), plus the faces themselves; all overridable.
+- Documentation plan: a header comment in =org-faces-config.el= and this spec; no user-facing docs needed.
+- Dev tooling: existing =make theme-studio-test= and =make deploy-wip= cover build and round-trip.
+- Rollout, compatibility & rollback: additive; rollback is removing the module and the theme-studio app. It re-introduces keyword/priority coloring that was deliberately stripped earlier, now as a named themeable layer.
+- External APIs & deps: =org-todo-keyword-faces= and =org-priority-faces= are standard org defcustoms (verified); build-theme's package tier already emits inherit/box/style (verified this session).
+
+* Risks, Rabbit Holes, and Drawbacks
+- Poor default colors fight unset themes. Dodge: seed conservatively, lean on theme override.
+- Load order: the faces must be defined before org renders the agenda. Dodge: eager defface, require before/with org.
+- Scope creep into the dupre/auto-dim migration. Dodge: it's explicitly vNext with its own decision.
+
+* Review and iteration history
+** 2026-06-15 Mon @ 01:51:57 -0500 — Craig — author
+- What: initial draft.
+- Why: the A-D priority request generalized into a clearly-custom, theme-studio-surfaced header-row face layer; the prefix, default-color policy, and dupre/auto-dim reconciliation are real trade-offs worth settling on paper first.
+- Artifacts: [[file:../../todo.org][todo.org org-faces task]]; theme-studio bespoke-app machinery (face_data.py, generate.py, app.js).
diff --git a/docs/design/signal-client.org b/docs/specs/signal-client-spec-doing.org
index 24503ec03..beee0acf1 100644
--- a/docs/design/signal-client.org
+++ b/docs/specs/signal-client-spec-doing.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 0cabd6ee-c458-47b5-a8af-3ee054b25821
+:STATUS: doing
+:END:
#+TITLE: Design: Signal client in Emacs (forked signel)
#+DATE: 2026-05-26
#+STATUS: Draft
@@ -226,3 +230,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-package-faces-spec.org b/docs/specs/theme-studio-package-faces-spec-doing.org
index 7f00b3279..566f34db0 100644
--- a/docs/design/theme-studio-package-faces-spec.org
+++ b/docs/specs/theme-studio-package-faces-spec-doing.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 8f37a1fd-cfd3-4b25-92e5-772468092bdc
+:STATUS: doing
+:END:
#+TITLE: theme-studio — package faces (tier 3), starting with org-mode
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-07
diff --git a/docs/specs/theme-studio-palette-generator-spec-doing.org b/docs/specs/theme-studio-palette-generator-spec-doing.org
new file mode 100644
index 000000000..b98e10789
--- /dev/null
+++ b/docs/specs/theme-studio-palette-generator-spec-doing.org
@@ -0,0 +1,298 @@
+:PROPERTIES:
+:ID: 2df157b8-c7c1-47a9-b080-d9586c6f424c
+:STATUS: doing
+:END:
+#+TITLE: Theme Studio Palette Generator -- Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-14
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | doing |
+|----------+-------|
+| Owner | Craig |
+|----------+-------|
+| Reviewer | Craig |
+|----------+-------|
+| Related | [[file:../../todo.org::*theme-studio palette generator][theme-studio palette generator task]] |
+|----------+-------|
+
+* Summary
+Theme Studio should grow a constraint-first palette generator that creates preview palette columns from the current theme context, then lets the user explicitly commit individual colors or whole columns. It should reuse the existing color selector as the single-color workbench instead of adding a second picker.
+
+The v1 feature generates palette columns only. It does not assign faces automatically; once generated colors are applied, they become normal editable palette colors with stable column ids.
+
+* Problem / Context
+Theme Studio now has stable color columns, span controls, OKLCH editing, contrast metrics, DeltaE warnings, locks, package faces, and a live preview. The missing workflow is generating a coherent set of candidate base colors without manually choosing each hue, checking contrast, spanning, and then adjusting by eye.
+
+Generic palette generators are not a good fit by themselves. They optimize visual harmony before text readability, while Emacs themes need dense syntax colors, UI overlays, selections, search hits, diagnostics, and package faces to remain legible over a fixed ground.
+
+There is also UI overlap with the existing color selector. The selector already edits one active color, shows a swatch, drives the hex field and OKLCH picker, and adds or updates palette colors. A generator should feed that selector, not duplicate it.
+
+* Goals and Non-Goals
+** Goals
+- Generate candidate palette columns from explicit source modes and current theme constraints.
+- Default to OKLCH generation so lightness and chroma are predictable.
+- Default to a syntax-balanced scheme designed for readable code themes.
+- Preview generated columns without mutating the real palette.
+- Reuse the current color selector to inspect and tune one generated tile at a time.
+- Allow adding a generated tile as a new base color column.
+- Allow appending a whole generated column.
+- Preserve stable column ids and existing assignments where possible when applying proposals.
+- Expose diagnostics for contrast, DeltaE, gamut clamp, and rejected candidates.
+
+** Non-Goals
+- Automatically assign generated colors to syntax/UI/package faces in v1.
+- Replace the existing manual picker, swatch, or per-column span controls.
+- Import external palettes, CSS colors, image colors, or theme files. Import organization remains separate.
+- Generate terminal/ANSI palettes in v1.
+- Add OKHSL/OKHSV generation modes in v1.
+- Rewrite bg/fg automatically in v1.
+
+** Scope tiers
+- v1: generator panel, pure planner, preview columns, existing color selector integration, add generated tile as base column, append whole generated column, diagnostics, tests, README docs.
+- Out of scope: automatic face assignment, external imports, image extraction, terminal colors, rewriting bg/fg.
+- vNext: replace selected columns, regenerate selected spans, regenerate generator-owned columns, OKHSL/OKHSV controls, whole-palette harmonization, CVD-aware scoring, named style presets, terminal palette derivation.
+
+* Design
+The generator is a panel above the real palette columns. It contains generation controls and a preview area, but no separate color picker. The existing color selector remains the place where one color is inspected, tuned, named, and committed.
+
+For the user, the workflow is:
+
+1. Choose a generator source mode and scheme from compact controls in the generator panel.
+2. Click preview.
+3. Inspect temporary generated columns above the real palette.
+4. Click a generated tile to load it into the existing color selector.
+5. Optionally tune that color with the existing hex/OKLCH picker.
+6. Commit either one generated tile as a new base column, or a whole generated column as normal palette entries.
+
+The preview layer is separate from =PALETTE=. Rendering preview columns must not create real palette entries, move existing columns, or change assignments. Preview tiles should look like palette tiles, but have a distinct preview treatment such as a dashed border/header or subtle "preview" label.
+
+The generator controls should be compact and explicit:
+
+- =Source= is a segmented control in v1: =bg/fg= and, if implemented in v1, =selected=. =bg/fg= is the default. Each option has a short tooltip: =bg/fg= means "generate from the current ground/foreground constraints"; =selected= means "harmonize around selected palette columns."
+- =Scheme= can be a select/dropdown because it has more choices. A scheme is the hue-placement strategy used to propose candidate accents. The choices are not final color decisions; they are starting layouts that the planner filters through contrast, lightness, chroma, gamut, and DeltaE constraints.
+- The scheme dropdown should include one-line help in the option title or adjacent help text. For example: =syntax-balanced= means "spread readable code accents across the hue wheel"; =analogous= means "nearby hues"; =triadic= means "three evenly-spaced hue families"; =manual= means "use the hues entered by the user."
+- =Accent count= controls how many base columns the proposal tries to generate. The default is 8, with a v1 range of 3-12.
+- =Span count= controls how many generated span steps each proposed column includes. The default should be conservative, likely 0 or the current preferred span setting, with a v1 range of 0-4.
+
+Therefore, the number of generated columns is configurable: it is the =accent count=, subject to rejected candidates. If the user asks for 8 accents and 2 are rejected by constraints, the preview should show 6 generated columns and report 2 rejected candidates in the summary. It should not silently backfill unrelated colors unless that behavior is explicitly added later.
+
+The current color selector gains a third selection mode:
+
+- No selection: =+ add color= creates a new manual base column from the selector value.
+- Real palette tile selected: =update selected= changes that palette tile or recolors its column as today.
+- Generated preview tile selected: the selector shows the generated hex/name, and =+ add color= commits that generated color as a new base color column. =update selected= is disabled unless a real palette tile is selected.
+
+A small status label near the selector should make this state explicit:
+
+#+begin_example
+editing: new color
+editing: palette color blue
+editing: generated color blue-2
+#+end_example
+
+Generated columns are proposed from a source mode:
+
+- =bg/fg only= is the v1 default. It uses the ground and foreground as constraints, plus the chosen scheme/base hue.
+- =selected columns= is v1 optional if cheap; otherwise vNext. It harmonizes around columns the user explicitly selected.
+- =whole palette= is vNext. It should not be automatic in v1 because imported, experimental, or throwaway colors could make generation unpredictable.
+
+Generation is constraint-first. The planner chooses hue candidates from the scheme, then searches for useful OKLCH lightness/chroma values that satisfy the current background, contrast target, DeltaE separation, and gamut constraints. Classical harmony schemes are input layouts, not the final authority.
+
+For implementers, the feature is a pure planning layer plus DOM rendering/application:
+
+- The planner receives current palette, ground, source mode, generator config, and locks.
+- The planner returns a proposal object and never mutates global state.
+- The DOM layer renders proposal columns as preview columns.
+- Applying one tile or one column converts proposal members into normal palette entries using existing column-id, span, name-collision, and gone/repoint behavior.
+
+** Generator config
+V1 config fields:
+
+- sourceMode: bg-fg, selected-columns
+- scheme: syntax-balanced, analogous, split-complementary, triadic, tetradic, warm-cool, manual
+- baseHue: degrees, used by non-manual schemes
+- manualHues: list of degrees, used by manual mode
+- accentCount: integer, default 8, range 3-12
+- spanCount: integer, default 0 or current preferred span, range 0-4 in v1
+- textLightnessBand: min/max OKLCH L for text accents
+- chromaBias: subdued, balanced, vivid
+- contrastTarget: none, WCAG AA, WCAG AAA
+- deltaEMin: default to the existing palette warning threshold
+- locks: respect locked columns and assignments where apply modes touch existing data
+
+** Proposal object
+The planner returns a proposal object with the generator config, proposed columns, rejected candidates, and a summary. Each proposed column carries a stable column id, display name, base hex, member colors with offsets and clamp/metric data, and column-level diagnostics. Rejected candidates carry the attempted hue, rejection reason, and nearest conflicting column when relevant. The summary includes generated count, clamped count, rejected count, minimum contrast, and minimum DeltaE.
+
+This shape is intentionally close to the existing palette-column model. Preview rendering should not need a second color model.
+
+** Applying generated colors
+The first v1 apply actions are deliberately small:
+
+- =Add generated tile as base column=: creates a new normal palette column from the selected preview tile. The new column id is derived from the tile name and suffixes on collision.
+- =Append generated column=: adds every member of that preview column after existing real columns. Members keep a stable shared column id.
+- =Clear preview=: discards proposal state without changing the real palette.
+
+The following apply actions are deferred unless v1 implementation is already straightforward:
+
+- replace selected columns
+- regenerate spans only
+- regenerate generator-owned columns
+
+When a generated tile is committed as a base column, it starts as a one-tile column. The user can then span it using the existing column span widget. This keeps the one-color action easy to understand and avoids surprising multi-tile commits from a tile-level button.
+
+** Display
+The panel sits between the color selector row and the committed palette columns. It has:
+
+- source mode segmented control
+- scheme segmented control or select
+- base hue/manual hue controls
+- accent count and span count numeric controls
+- chroma bias control
+- contrast target control
+- preview and clear-preview buttons
+- summary row: min contrast, min DeltaE, clamped count, rejected count
+
+Preview columns render below the controls and above committed columns. A generated tile click loads the existing selector. A generated column header click loads the column base into the selector. A column-level =append column= button commits the whole preview column.
+
+* Alternatives Considered
+** Generic harmony wheel that writes directly into the palette
+- Good, because it is familiar and visually compact.
+- Bad, because it mutates real palette state before the user can inspect results, and it optimizes hue harmony before readability.
+- Neutral, because a hue wheel can still be useful as an input control inside a preview-first generator.
+
+** Separate generator-specific color picker
+- Good, because preview tuning could be isolated from committed palette editing.
+- Bad, because Theme Studio already has a capable single-color selector, and a second picker would duplicate hex, OKLCH, contrast, swatch, and add/update semantics.
+- Neutral, because a future advanced generator could add a small detail panel, but v1 should not.
+
+** Generate from the whole palette by default
+- Good, because it can harmonize with everything already on screen.
+- Bad, because the palette may contain experiments, imported colors, temporary colors, or intentionally clashing accents; using all of them makes results hard to predict.
+- Neutral, because whole-palette harmonization is valuable as an explicit vNext mode.
+
+** Full automatic face assignment
+- Good, because it could produce a near-complete theme in one action.
+- Bad, because it crosses into seeding, locks, role maps, and package face behavior that already have separate product decisions.
+- Neutral, because the palette generator can feed a later seeding workflow.
+
+** Add OKHSL/OKHSV now
+- Good, because those controls may feel friendlier than raw OKLCH.
+- Bad, because v1 already has OKLCH math and the feature risk is workflow/state, not another color model.
+- Neutral, because OKHSL/OKHSV remain good vNext candidates.
+
+* Decisions [5/5]
+** DONE Default to preview-first generation
+- Context: Generator output can disrupt a carefully tuned palette if it mutates immediately.
+- Decision: We will render generated colors as temporary preview columns and require explicit add/append actions.
+- Consequences: Easier to inspect and avoid accidental data loss; harder because the UI needs proposal state and apply semantics.
+
+** DONE Reuse the current color selector
+- Context: The existing selector already edits one color, shows metrics, opens the picker, and adds/updates palette colors.
+- Decision: We will make generated tile clicks load the existing selector instead of adding a second generator picker.
+- Consequences: Easier to keep editing behavior consistent; harder because the selector now needs clear state for new, palette, and generated selections.
+
+** DONE Keep v1 palette-only
+- Context: Automatic assignment would touch syntax, UI, package faces, locks, and seeding rules.
+- Decision: We will generate palette columns only in v1 and leave face assignment to existing/manual workflows.
+- Consequences: Easier to ship a focused generator; harder because the user still maps colors onto faces.
+
+** DONE Default generation source to bg/fg only
+- Context: Existing palette colors may be experimental or imported; using all of them by default makes generation unpredictable.
+- Decision: We will default to bg/fg constraints plus explicit scheme/base hue. Selected-column source can be included if scoped; whole-palette source is vNext.
+- Consequences: Easier to understand why a proposal was generated; harder because matching an existing palette requires an explicit source mode.
+
+** DONE Defer OKHSL/OKHSV to vNext
+- Context: OKHSL/OKHSV may be friendlier interaction models, but OKLCH already supports the required perceptual generation math.
+- Decision: We will ship OKLCH generation first and consider OKHSL/OKHSV after v1 is usable.
+- Consequences: Easier to keep v1 small and rigorous; harder because some users may find OKLCH controls less familiar.
+
+* Implementation phases
+** Phase 1 -- Planner core
+Add pure generator functions in =app-core.js= or a new generator module. Inputs are current palette, ground, generator config, source selection, and locks. Outputs are proposal objects. Unit tests cover scheme hue placement, OKLCH candidate generation, gamut clamp reporting, name/id collision handling, and no mutation of input state.
+
+** Phase 2 -- Candidate scoring
+Add bounded scoring/adjustment for contrast target, DeltaE separation, chroma bias, and text lightness band. Unit tests cover rejected candidates, clamped colors, low-chroma distinguishability, and deterministic output for fixed config.
+
+** Phase 3 -- Generator panel and preview rendering
+Add the panel, controls, proposal state, preview columns, summary metrics, and clear-preview behavior. Browser gate: preview creates temporary columns without changing committed =PALETTE=.
+
+** Phase 4 -- Existing selector integration
+Add generated-preview selection state to the color selector. Clicking a preview tile loads its hex/name. The selector status label shows generated-vs-palette-vs-new. =+ add color= commits the selected preview tile as a new one-tile base column. Browser gates cover generated tile selection and add-as-column behavior.
+
+** Phase 5 -- Append generated column
+Add column-level append. The generated column becomes normal palette entries with one stable column id. Collisions suffix names/ids deterministically. Browser gates cover append, collision suffixing, and unchanged existing assignments.
+
+** Phase 6 -- Persistence and metadata
+Round-trip optional generator metadata for applied columns without requiring it for normal palette behavior. Existing palettes without metadata continue to work. Browser gate extends roundtrip coverage.
+
+** Phase 7 -- Documentation and cleanup
+Document generator controls, source modes, preview behavior, selector integration, and limits in =scripts/theme-studio/README.md=. Remove prototype code and keep =make -C scripts/theme-studio test= green.
+
+* Acceptance criteria
+- [ ] Previewing a generated palette does not mutate committed =PALETTE=.
+- [ ] Preview columns appear above committed columns and are visually distinct.
+- [ ] Clicking a generated tile loads that color into the existing selector.
+- [ ] The selector clearly says whether it is editing a new color, palette color, or generated color.
+- [ ] =+ add color= on a selected generated tile creates a new one-tile base column.
+- [ ] Appending a generated column creates normal editable palette entries with one stable column id.
+- [ ] Name and column-id collisions are suffixed deterministically.
+- [ ] Generated colors report clamp, contrast, and DeltaE diagnostics.
+- [ ] Existing manual palette workflows still work without opening the generator panel.
+- [ ] Theme Studio tests cover planner functions, preview rendering, selector integration, apply behavior, and round-trip metadata.
+
+* Readiness dimensions
+- Data model & ownership: Proposal state is transient and generator-owned. Applied colors become normal user-owned palette entries. Optional generator metadata is advisory and must not override manual edits.
+- Errors, empty states & failure: Invalid config disables preview with an inline message naming the bad field. No preview shows an empty-state line. Rejected candidates appear in the summary. Apply failures must not partially mutate committed palette state.
+- Security & privacy: N/A because generation is local deterministic color math with no credentials, network calls, or private external data.
+- Observability: The preview summary shows generated, clamped, rejected, min contrast, and min DeltaE. Tile titles or details expose per-color diagnostics.
+- Performance & scale: Expected accent counts are 3-12 bases with 0-4 span steps. Candidate search should remain synchronous and bounded. Broader search, if added later, needs progress/cancel.
+- Reuse & lost opportunities: Reuse OKLCH, gamut clamp, contrast, DeltaE, palette columns, span generation, locks, existing selector, and existing browser gates. Do not add a second color math stack or picker.
+- Architecture fit & weak points: The weak point is proposal/apply state in the DOM app. Keep planner pure and DOM code limited to rendering/applying proposal objects.
+- Config surface: Public knobs are source mode, scheme, base/manual hue, accent count, span count, chroma bias, contrast target, DeltaE threshold, and lightness band. Defaults favor readable dark-theme syntax.
+- Documentation plan: Update the Theme Studio README with generator controls, source modes, selector integration, preview/apply behavior, and v1 limits.
+- Dev tooling: Use =make -C scripts/theme-studio test= as the primary gate and =make -C scripts/theme-studio coverage= for instrumented JS/generator coverage.
+- Rollout, compatibility & rollback: The generator is additive. Existing palettes load unchanged. Preview can be cleared. Applied generated columns can be deleted manually.
+- External APIs & deps: N/A because v1 has no external APIs or new dependencies.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Candidate search can become an optimization rabbit hole. V1 should use deterministic bounded search around target OKLCH bands.
+- "Syntax-balanced" is subjective. Keep it documented as a heuristic, not a claim of universal taste.
+- Selector state can become confusing. The status label and disabled update button are required, not polish.
+- Whole-palette harmonization is tempting but should wait until preview/apply basics feel good.
+- Optional metadata can drift after manual edits. Treat it as advisory only.
+
+* Testing / Verification / Rollout
+Use the existing Theme Studio test stack:
+
+- Node tests for planner/scoring/collision/immutability.
+- Browser hash gate for preview-only non-mutation.
+- Browser hash gate for generated tile -> selector state.
+- Browser hash gate for add-generated-tile-as-column.
+- Browser hash gate for append generated column.
+- Round-trip gate for optional generator metadata.
+- Manual Chrome pass on at least one dark palette and one light palette.
+
+* References / Appendix
+- [[file:design/theme-studio-color-harmony.org][theme-studio color harmony explainer]]
+- [[id:15db8ae3-fc14-49f3-9ed5-d5ff59790904][perceptual color metrics spec]]
+- [[file:theme-studio-palette-ramps-spec.org][palette ramps and contrast safety spec]]
+- [[file:theme-studio-palette-columns-spec.org][palette columns spec]]
+- [[file:../../todo.org::*theme-studio import organization workflow needs a spec][import organization task]]
+
+* Review and iteration history
+** 2026-06-13 Saturday @ 16:31:01 -0500 -- Craig -- author
+- What: Initial draft using the spec-create workflow.
+- Why: Palette generation has real design trade-offs around color space, preview/apply behavior, assignment boundaries, and how much generator state should persist.
+- Artifacts: [[file:../../todo.org::*theme-studio palette generator][theme-studio palette generator task]].
+
+** 2026-06-14 Sunday @ 00:44:00 -0500 -- Craig -- author
+- What: Reworked the draft around preview-first generation, existing color selector integration, generated tile add-as-column behavior, and source-mode defaults.
+- Why: Craig clarified the desired UX: generated colors should be inspectable/tunable through the existing selector, and committing one generated color should create a normal base column.
+- Artifacts: [[file:../../todo.org::*theme-studio palette generator][theme-studio palette generator task]].
+
+** 2026-06-14 Sunday @ 01:07:00 -0500 -- Craig -- author
+- What: Folded Craig's inline comments into the design, clarifying source/scheme controls, guidance text, the meaning of schemes, configurable accent count, and rejected-candidate behavior. Removed the comment/source blocks.
+- Why: The generator UI needed to say what the user actually sees before implementation can proceed.
+- Artifacts: [[file:../../todo.org::*theme-studio palette generator][theme-studio palette generator task]].
diff --git a/docs/design/theme-studio-perceptual-color-metrics-spec.org b/docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org
index 7e7dedb22..57a4c70bc 100644
--- a/docs/design/theme-studio-perceptual-color-metrics-spec.org
+++ b/docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: 15db8ae3-fc14-49f3-9ed5-d5ff59790904
+:STATUS: implemented
+:END:
#+TITLE: theme-studio — perceptual color metrics (OKLCH, APCA, ΔE)
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-08
diff --git a/docs/specs/theme-studio-preview-locate-spec.org b/docs/specs/theme-studio-preview-locate-spec.org
new file mode 100644
index 000000000..bb77a2248
--- /dev/null
+++ b/docs/specs/theme-studio-preview-locate-spec.org
@@ -0,0 +1,148 @@
+:PROPERTIES:
+:ID: fbcf0e20-1328-42b4-aa36-3401509e7816
+:STATUS: not-started
+:END:
+#+TITLE: Theme Studio Preview Element Locate — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-15
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | not-started |
+|----------+----------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: theme-studio preview locate + org-agenda app]] |
+|----------+----------------------------------------------------------------|
+
+* Summary
+
+A general theme-studio preview interaction: turn every rendered preview element into a map back to the assignment that controls it. Hovering an element shows where its color comes from -- the owning app (section), the face (element), and the current value and attributes. Clicking an element whose face is assigned on the current pane flashes it and scrolls to its assignment row. Elements styled by faces that belong to other panes are not clickable; the hover still names their section and element, so the user navigates there manually.
+
+* Problem / Context
+
+Previews render representative text styled by many faces, but there is no way to go from "this rendered bit looks wrong" to "which assignment controls it." You scan the swatch grid and guess. The gap widens as previews get richer: the planned org-agenda preview renders a realistic agenda whose lines mix scheduling and deadline faces (org-agenda's own) with TODO keywords and priorities (org-faces) and tags (org-mode). A colored substring there could belong to any of three panes, and nothing tells you which, or what its current value is.
+
+* Goals and Non-Goals
+
+** Goals
+- Hover any previewed element to see its section (owning app), element (face name), and current value/attributes.
+- Click an element whose face is assigned on the current pane to flash it and scroll/select its assignment row.
+- Leave off-pane elements non-clickable, with the hover still naming section and element so the user can navigate there.
+- A face -> (owning app, value, attributes) registry built from the app/face model, read by previews for both hover content and the clickable test.
+- Let previews render substrings in faces owned by other panes live from the shared state (the org-agenda agenda mock is the first to need this); those render correctly and are the hover-only, non-clickable elements.
+
+** Non-Goals
+- No editing from the preview -- the assignment row still owns edits.
+- No auto-switching panes on click; off-pane elements are deliberately inert (decided).
+- No change to the face data model; the registry is derived from existing app/face assignments.
+- Not a search/filter over faces; this is point-and-identify, not query.
+
+** Scope tiers
+- v1: the face registry; hover tooltips (section + element + value/attributes) on every previewed element; click-to-row for current-pane faces; off-pane elements non-clickable; the os() preview tag that carries each element's face; one existing preview wired as the showcase.
+- Out of scope: cross-pane click navigation; editing from the preview; persisting any of this.
+- vNext: a "reveal in pane" affordance for off-pane elements (switch pane + scroll) if the hover-only model proves too manual.
+
+* Design
+
+** For the user
+Move the pointer over any colored element in a preview. A tooltip names the section it belongs to (the app, e.g. "org-faces"), the element (the face, e.g. "org-faces-todo"), and its current value and attributes (e.g. foreground #8fbf73, bold). If that face is one you can edit on the pane you are looking at, click it: the element flashes and the pane scrolls to and highlights its assignment row, ready to tune. If the element is colored by a face from another pane -- a keyword in the agenda is owned by org-faces, a tag by org-mode -- clicking does nothing, but the tooltip has already told you which section and element to go find. Every preview becomes a legend you can interrogate.
+
+** For the implementer
+- Registry: a derived map from face name to its owning app, current value, and resolved attributes, built from the same app/face assignment state the editor already holds. One source for hover content and the is-on-this-pane test. Rebuilt when assignments change.
+- Preview tagging: the os(app, face, text) preview helper already wraps each element in a span keyed by face; extend it to also carry the face name and owning app as data attributes so hover and click can resolve without re-parsing.
+- Hover: a tooltip (or info strip) that reads the registry for the hovered element's face and shows section + element + value/attributes.
+- Click: if the element's face is owned by the current pane, flash the element and scroll/select its assignment row; otherwise the element is non-interactive (cursor and affordance reflect that).
+- Cross-pane rendering: os() resolves a face's current color from the registry regardless of which pane owns it, so a preview can faithfully render off-pane faces (the agenda mock's keywords/priorities/tags) while marking them non-clickable.
+
+* Alternatives Considered
+
+** Click off-pane elements and auto-switch panes to their row
+- Good, because one click takes you straight to any assignment.
+- Bad, because it makes a click yank you out of the pane you are studying, and the cross-pane jump is disorienting in a dense preview; the hover already names where to go.
+- Rejected: off-pane elements are non-clickable (decided); the hover carries the wayfinding.
+
+** Hover shows only the face name, not the value
+- Good, because it is the least to build.
+- Bad, because "which face" without "what value" still makes you go look it up; the value/attributes in the tooltip is what closes the loop.
+- Rejected: hover includes value and attributes (decided).
+
+** Scope it to the org-agenda preview only
+- Good, because no general machinery.
+- Bad, because every preview has the same go-from-render-to-assignment gap; baking it into one preview means rebuilding it for the next.
+- Rejected: general feature (decided); org-agenda is the first showcase.
+
+* Decisions [5/5]
+
+** DONE General feature, not org-agenda-scoped
+- Context: the interaction was raised for the agenda preview but applies to every preview.
+- Decision: We build it as a general theme-studio preview capability; the org-agenda app is its first showcase.
+- Consequences: easier -- every preview gains it; harder -- the registry and tagging must be general, not hand-fitted to one preview.
+
+** DONE Click scope: current-pane faces only
+- Context: a previewed element may be colored by a face owned by another pane.
+- Decision: Only elements whose face is assigned on the current pane are clickable (flash + scroll to its row). Off-pane elements are non-clickable.
+- Consequences: easier -- click always has a local target, no cross-pane jump; harder -- off-pane elements rely on the hover for wayfinding.
+
+** DONE Hover content: section + element + value/attributes
+- Context: identifying a face is only half the answer; you also want its current value.
+- Decision: The hover tooltip shows the owning app (section), the face (element), and the current value and attributes.
+- Consequences: easier -- closes the loop without a trip to the row; harder -- the registry must resolve value/attributes, not just the name.
+
+** DONE Face registry as the single source
+- Context: hover content and the clickable test both need face -> owning-app + value.
+- Decision: A derived registry maps each face to its owning app, value, and attributes, rebuilt from the assignment state; previews read it.
+- Consequences: easier -- one place feeds hover, click, and cross-pane rendering; harder -- it must stay in sync with edits.
+
+** DONE Cross-pane live rendering
+- Context: a faithful agenda renders keywords/priorities/tags owned by other panes.
+- Decision: os() resolves any face's current color from the registry, so previews render off-pane faces live; those elements are the non-clickable, hover-only ones.
+- Consequences: easier -- previews read real and the agenda mock is possible; harder -- the preview must distinguish own-pane (clickable) from off-pane (hover-only) elements.
+
+* Implementation phases
+
+** Phase 1 — Face registry
+Build the derived face -> (owning app, value, attributes) map from the app/face assignment state, rebuilt on change. Unit-tested against a constructed assignment model.
+
+** Phase 2 — Preview tagging + cross-pane resolution
+Extend os() so each previewed element carries its face name and owning app, and resolves its color from the registry regardless of owning pane. Tested on a preview that references an off-pane face.
+
+** Phase 3 — Hover
+Tooltip showing section + element + value/attributes for the hovered element, from the registry. Browser-gate tested.
+
+** Phase 4 — Click
+Flash + scroll/select the assignment row for a current-pane element; off-pane elements non-clickable (cursor/affordance reflects it). Browser-gate tested; one existing preview wired as the showcase.
+
+* Acceptance criteria
+- [ ] Hovering any previewed element shows its section, element, and current value/attributes.
+- [ ] Clicking a current-pane element flashes it and scrolls/selects its assignment row.
+- [ ] Clicking an off-pane element does nothing and is visibly non-interactive.
+- [ ] A preview can render an off-pane face's real color (cross-pane resolution) and that element is hover-only.
+- [ ] The registry resolves every previewed face to its owning app, and updates when an assignment changes.
+
+* Readiness dimensions
+- Data model & ownership: the registry is derived, not authoritative; the app/face assignment state remains the source of truth.
+- Errors, empty states & failure: an element whose face resolves to no owning app falls back to hover-only with an "unassigned" note rather than a dead click.
+- Security & privacy: N/A -- browser-local theme editor state.
+- Observability: the tooltip is the surface; a face that fails to resolve shows that in the tooltip.
+- Performance & scale: the registry is rebuilt on assignment change, not per hover; hover/click read it in O(1) by face name.
+- Reuse & lost opportunities: reuses the existing os() preview helper and the assignment state; every current and future preview benefits.
+- Architecture fit & weak points: the registry is the new shared structure; the weak point is keeping it in sync with edits, addressed by rebuilding on change.
+- Config surface: none beyond the existing theme-studio build.
+- Documentation plan: the theme-studio test suite and this spec; a note in the tool's help if the interaction isn't self-evident.
+- Dev tooling: existing make theme-studio-test and the browser-gate harness.
+- Rollout, compatibility & rollback: additive; rollback removes the tagging and the registry. Existing previews keep working without the interaction.
+- External APIs & deps: none -- browser JS plus the existing generate pipeline.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Registry staleness: a stale registry mislabels a hover. Dodge: rebuild on assignment change; derive, never cache independently.
+- Tooltip noise on dense previews: hovering everything could be busy. Dodge: tooltip on deliberate hover only, not a persistent overlay.
+- Distinguishing own-pane vs off-pane reliably: the click affordance depends on it. Dodge: the registry's owning-app field is the single test.
+
+* Review and iteration history
+** 2026-06-15 Mon — Craig — author
+- What: initial draft.
+- Why: breaking the org-agenda faces into their own pane surfaced the need to go from a rendered preview element back to its assignment; the interaction (hover-identifies, click-locates-on-pane, off-pane-inert) is general and worth settling before the agenda preview consumes it.
+- Artifacts: the org-faces app (the bespoke-app pattern); the planned org-agenda app (first consumer); os() preview helper.
diff --git a/docs/design/theme-studio-seeding-engine-spec.org b/docs/specs/theme-studio-seeding-engine-spec-doing.org
index bcbf43db4..baf9f5b01 100644
--- a/docs/design/theme-studio-seeding-engine-spec.org
+++ b/docs/specs/theme-studio-seeding-engine-spec-doing.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: b70b37f2-37df-4c8e-ac2f-1f20d12e33dd
+:STATUS: doing
+:END:
#+TITLE: theme-studio — seeding engine (role table to guide-correct defaults)
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-08
@@ -313,7 +317,7 @@ response resolved; everything else was woven into the body as written.
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
+- =docs/specs/theme-studio-perceptual-color-metrics-spec-implemented.org= — the
=colormath.js= core that v1 OKLCH shade generation uses.
* Review and iteration history
diff --git a/docs/specs/theme-studio-semantic-theme-architecture-spec.org b/docs/specs/theme-studio-semantic-theme-architecture-spec.org
new file mode 100644
index 000000000..01ef1902c
--- /dev/null
+++ b/docs/specs/theme-studio-semantic-theme-architecture-spec.org
@@ -0,0 +1,266 @@
+:PROPERTIES:
+:ID: fe980b12-451a-4d8b-a550-d99f9ec49f45
+:STATUS: not-started
+:END:
+#+TITLE: Theme Studio Semantic Theme Architecture -- Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-14
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | not-started |
+|----------+-------|
+| Owner | Craig |
+|----------+-------|
+| Reviewer | Craig |
+|----------+-------|
+| Related | [[file:../../todo.org::*theme-studio semantic theme architecture][theme-studio semantic theme architecture task]] |
+|----------+-------|
+
+* Summary
+Theme Studio currently exports JSON into a flat generated Emacs theme: every face receives resolved hex colors and attributes directly. That is faithful to the current UI state, but it loses the middle layer that makes themes like Modus easy to reason about: named palette entries, semantic color roles, reusable face templates, and a thin generated wrapper.
+
+This spec proposes a future Theme Studio output architecture that can preserve those layers. The v1 goal is not to replace the current flat exporter immediately; it is to define the model, converter shape, migration path, and test surface needed to support a more maintainable theme system.
+
+* Problem / Context
+Theme Studio has grown from a color picker into a full theme workbench: palette columns, syntax assignments, UI faces, package faces, generated candidates, contrast checks, locks, and a JSON-to-Emacs-theme converter. The converter currently emits direct =custom-theme-set-faces= specs. That works, but it makes the generated theme hard to maintain outside Theme Studio because the meaning of each color is already flattened away.
+
+The design discussion around Modus showed a different structure. Modus keeps concrete theme files small and pushes the durable logic into a shared engine: palettes define available colors, semantic mappings define what those colors mean, face specs use semantic names, and a theme constructor resolves everything when the theme loads.
+
+Theme Studio does not need to become Modus, but it can borrow the architecture. This would make generated themes easier to inspect, easier to customize by hand, and more able to support rules such as "comments and comment delimiters should normally share a color" without forcing every rule into the UI.
+
+* Goals and Non-Goals
+** Goals
+- Define a layered Theme Studio theme architecture: palette data, semantic role mapping, face templates, and generated theme wrapper.
+- Preserve the current flat exporter as a compatibility path until the layered output is proven.
+- Allow semantic roles to group multiple faces under one design decision.
+- Allow advisory semantic rules that detect inconsistent mappings, such as comment and comment delimiter diverging.
+- Keep the generated output loadable by normal Emacs =load-theme=.
+- Make the design testable with pure converter tests and real Emacs theme-load tests.
+- Make future Theme Studio features, such as face-seeding and role-aware palette generation, easier to build.
+
+** Non-Goals
+- Do not enforce any specific semantic design rule in v1. The comment/comment-delim rule is an example of what the layer can express, not a required policy.
+- Do not replace Theme Studio's current JSON format in one step.
+- Do not require users to edit generated Elisp by hand.
+- Do not implement a full Modus-compatible theme engine.
+- Do not automatically reassign faces based on semantic rules.
+- Do not remove direct per-face overrides from Theme Studio.
+
+** Scope tiers
+- v1: Specify the layered model, add optional export fields or an intermediate converter model, generate a loadable layered theme file, preserve current flat output, add tests, document the architecture.
+- Out of scope: UI for editing all semantic roles, automatic role inference from arbitrary imported themes, rule enforcement, Modus compatibility.
+- vNext: Role editor UI, advisory rule panel, role-aware palette generator integration, semantic imports, user-defined rule packs, derivative theme wrappers.
+
+* Design
+The architecture has four layers.
+
+Palette data is the color inventory. It answers "what named colors exist?" A Theme Studio palette entry already has most of this information: hex, display name, and stable column id. The layered exporter should preserve that as named Elisp data rather than immediately substituting every hex into every face.
+
+Semantic role mapping assigns design meaning to palette entries. It answers "what does this color do?" Examples include =syntax-keyword=, =syntax-comment=, =ui-region-bg=, =org-todo-fg=, and =mode-line-bg=. A role may point to a palette color, a literal hex, or another role. The important shift is that faces stop depending directly on "blue" or "#67809c"; they depend on "keyword" or "org-todo foreground".
+
+Face templates map Emacs faces to semantic roles and structural attributes. They answer "which faces use which roles?" A template can say =font-lock-comment-face= uses =syntax-comment=, =font-lock-comment-delimiter-face= uses =syntax-comment-delimiter=, and =org-todo= uses =org-todo-fg= plus =org-todo-bg= with bold and box attributes. Templates are where syntax, UI, and package face coverage live.
+
+The generated theme wrapper ties the layers together. It declares the theme, binds palette and semantic role values, expands the face templates, calls =custom-theme-set-faces= and =custom-theme-set-variables= where needed, then provides the theme. For users, the result is still a normal loadable Emacs theme.
+
+For the user, this does not need to introduce a new workflow immediately. Theme Studio can still export JSON and build a theme. The difference is that the generated Elisp becomes more intelligible:
+
+#+begin_src emacs-lisp
+(defconst theme-palette
+ '((bg "#000000")
+ (fg "#e0e0e0")
+ (blue "#67809c")))
+
+(defconst theme-semantics
+ '((syntax-keyword blue)
+ (syntax-comment fg-muted)
+ (ui-region-bg blue)))
+
+(custom-theme-set-faces
+ 'theme
+ `(font-lock-keyword-face ((,c :foreground ,syntax-keyword)))
+ `(region ((,c :background ,ui-region-bg))))
+#+end_src
+
+For implementers, the converter gains an intermediate representation:
+
+#+begin_src text
+theme.json
+ -> palette model
+ -> semantic role model
+ -> face template model
+ -> generated theme file
+#+end_src
+
+The current JSON can be projected into this model conservatively. If a face has no semantic role, the converter can synthesize a private role for that face, or fall back to a direct literal. That lets the layered exporter coexist with the current state format while the UI catches up.
+
+** Palette data
+Palette data should be owned by the palette panel. It persists user-authored color names, hexes, and column ids. Generated span members remain palette entries, but the exporter may choose whether to expose every span member as a public named color or treat some as generated/private names.
+
+The palette layer should avoid face knowledge. It should not know that =blue= is a keyword or that =gold= is an Org title. That meaning belongs to the semantic layer.
+
+** Semantic role mapping
+Semantic roles are owned by the theme design layer. Some roles can be generated from existing Theme Studio assignments:
+
+- syntax rows become roles such as =syntax-keyword= and =syntax-string=.
+- UI rows become roles such as =ui-region-fg=, =ui-region-bg=, and =ui-mode-line-bg=.
+- package rows become roles such as =org-todo-fg= or =magit-branch-fg= when those roles are worth naming.
+
+The role map should support three value kinds:
+
+- palette reference: =syntax-keyword -> blue=
+- role reference: =syntax-comment-delimiter -> syntax-comment=
+- literal: =warning-underline -> "#ff0000"=
+
+Role references are what make rules and shared intent useful. If comment and comment delimiter should move together, =syntax-comment-delimiter= can point at =syntax-comment=. If the user intentionally separates them, the mapping can become explicit and the advisory can report that it was intentionally diverged.
+
+** Semantic rules
+The semantic layer can support rules because it knows relationships that the flat face table does not. A rule can be advisory, enforced, or ignored, but v1 should only define the mechanism and ship rules as documentation/test fixtures unless a specific rule is agreed later.
+
+Example advisory rules:
+
+- =syntax-comment= and =syntax-comment-delimiter= normally use the same foreground.
+- =org-headline-done= should normally be no brighter than active headline faces.
+- =region= with explicit foreground/background should be checked as its own pair, while bg-only region should be checked against covered text.
+- warning/error/success roles should stay distinguishable by DeltaE and not only by text label.
+
+The comment/comment-delim rule is a note, not a v1 requirement. The spec deliberately does not mandate it because Theme Studio should preserve deliberate design choices even when they differ from the guide.
+
+** Face templates
+Face templates should be data, not ad hoc emitted strings. They need to represent:
+
+- target face symbol
+- foreground role or literal
+- background role or literal
+- inherit face or role
+- structural attributes: bold, italic, underline, strike, height, box
+- optional conditions such as display class, if the project later supports them
+
+The first implementation can keep one template table inside =build-theme.el=. Later, this can move into generated files or a shared Theme Studio runtime.
+
+Templates should support both semantic and direct output. This keeps migration safe:
+
+#+begin_src emacs-lisp
+(font-lock-keyword-face :foreground syntax-keyword)
+(some-face :foreground "#aabbcc") ; direct fallback
+#+end_src
+
+** Generated theme wrapper
+The wrapper is the loadable artifact. It should be self-contained unless we intentionally introduce a shared runtime.
+
+Two wrapper styles are viable:
+
+- Single-file layered output: every generated theme contains its palette, semantic map, resolver, templates, and =custom-theme-set-faces= call.
+- Shared runtime plus thin generated wrapper: a common =theme-studio-theme.el= library resolves roles and expands templates, while each generated theme mostly supplies data.
+
+V1 should prefer single-file layered output. It is easier to test, easier to share, and avoids requiring a generated theme user to install Theme Studio runtime files. A shared runtime can be introduced later if duplication becomes painful.
+
+* Alternatives Considered
+** Keep only the flat exporter
+- Good, because it is simple, already works, and exactly reflects the current UI state.
+- Bad, because it loses role intent and makes hand inspection or downstream customization harder.
+- Neutral, because it should remain the compatibility and debugging baseline.
+
+** Generate a full Modus-style engine
+- Good, because it is a proven architecture for serious Emacs themes.
+- Bad, because Modus solves a broader package-quality problem than Theme Studio needs, and copying its engine would add complexity before we know the required seams.
+- Neutral, because Modus remains the reference for structuring palette, semantics, and wrappers.
+
+** Add semantic roles only in JSON, still emit flat Elisp
+- Good, because it lets Theme Studio reason about roles without changing the generated theme format.
+- Bad, because users inspecting the generated theme still see only flattened direct specs.
+- Neutral, because this may be a useful intermediate phase.
+
+** Make semantic rules mandatory
+- Good, because it can preserve consistency and prevent accidental drift.
+- Bad, because theme design often has deliberate exceptions, and hard rules can fight the user's eye.
+- Neutral, because some rules may later become opt-in enforcement once proven.
+
+* Decisions [3/3]
+** DONE Output layered themes without replacing flat output immediately
+- Context: The current flat exporter is useful and working. The layered architecture is broader and should not block current Theme Studio iteration.
+- Decision: We will add layered output as an additional converter path or mode before considering it the default.
+- Consequences: Easier rollback and comparison; harder because tests must cover two output paths during the transition.
+
+** DONE Treat semantic rules as advisory in v1
+- Context: The semantic layer can express design relationships, but not every relationship should be forced.
+- Decision: We will model rules as advisory validation data in v1 and avoid enforcement unless a separate product decision promotes a rule.
+- Consequences: Easier to preserve user intent; harder because inconsistent themes can still be exported.
+
+** DONE Prefer self-contained generated layered themes for v1
+- Context: A shared runtime would reduce generated file size, but it creates another dependency for loading a generated theme.
+- Decision: We will prefer a self-contained layered theme file in v1.
+- Consequences: Easier sharing and loading; harder because each generated theme duplicates resolver/template code.
+
+* Implementation phases
+** Phase 1 -- Intermediate model
+Add a pure intermediate representation in the converter: palette entries, semantic roles, face templates, and resolved face specs. Keep existing flat output unchanged.
+
+** Phase 2 -- Semantic projection
+Project current Theme Studio JSON into semantic roles. Start with syntax and core UI roles, then package roles only where names are clear and useful. Preserve direct literals as fallback.
+
+** Phase 3 -- Layered renderer
+Generate a self-contained layered =*-theme.el= from the intermediate model. The generated file should still load with normal =load-theme= and should produce the same effective faces as the flat renderer for covered data.
+
+** Phase 4 -- Advisory rule surface
+Add pure rule checks over the semantic map. Output diagnostics in tests or converter warnings first; defer UI display.
+
+** Phase 5 -- Tests and comparison tooling
+Add tests that convert the same JSON through flat and layered paths, load both themes in isolated Emacs sessions, and compare representative effective face attributes.
+
+** Phase 6 -- Documentation and rollout
+Document the architecture, Makefile target, and compatibility story. Keep flat output as the default until manual inspection and tests show the layered output is trustworthy.
+
+* Acceptance criteria
+- [ ] The current flat JSON-to-theme converter still works.
+- [ ] A layered converter can generate a self-contained loadable =*-theme.el=.
+- [ ] The layered generated theme preserves default, syntax, UI, and package face attributes for representative fixtures.
+- [ ] The layered generated file exposes palette data and semantic mappings in readable Elisp.
+- [ ] Semantic role references resolve deterministically, including role-to-role references.
+- [ ] Cycles in semantic role references fail with an actionable converter error.
+- [ ] Advisory rules can report findings without blocking output.
+- [ ] Documentation explains the four layers and how they relate to the current flat exporter.
+
+* Readiness dimensions
+- Data model & ownership: Palette data remains owned by Theme Studio's palette model. Semantic roles are owned by the theme design layer. Face templates are owned by the converter/runtime. Generated theme wrappers are build artifacts.
+- Errors, empty states & failure: Missing roles, invalid palette references, and role cycles must name the role and input file. Unknown faces should fall back to direct specs or fail only when a strict mode is explicitly requested.
+- Security & privacy: N/A because theme files contain colors and face names, not credentials or private content.
+- Observability: Converter output should say whether it wrote flat or layered output and list advisory rule findings. Later UI can display semantic findings.
+- Performance & scale: Expected scale is hundreds to low thousands of faces. Role resolution should be linear with cycle detection; tests should include a large package-face fixture.
+- Reuse & lost opportunities: Reuse =build-theme.el= parsing, face attr construction, box conversion, and Makefile targets. Do not duplicate Emacs face attribute rules.
+- Architecture fit & weak points: The converter is the right first integration point because it can prove the model without changing the browser UI. Weak point: projecting semantics from flat assignments may create artificial roles; mitigate by marking generated/private roles distinctly.
+- Config surface: New converter mode or Make target, likely =theme-studio-theme-layered= or =MODE=layered=. Defaults keep current flat output.
+- Documentation plan: Update Theme Studio README and add a short architecture note. Cross-link this spec from the theme converter task.
+- Dev tooling: Extend existing =make theme-studio-theme= or add a sibling target. Add ERT coverage for converter behavior and a comparison test between flat/layered outputs.
+- Rollout, compatibility & rollback: Keep flat output as default. Layered output is opt-in until proven. Rollback is switching the Make target/mode back to flat.
+- External APIs & deps: No external APIs. Emacs =custom-theme-set-faces= and =load-theme= semantics are the only runtime dependency.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Role projection may invent names that look meaningful but are only direct mappings from one face. Keep generated/private role names visibly distinct.
+- A role editor UI could become a second Theme Studio. Defer UI until the converter model proves useful.
+- Advisory rules can become noisy. Start with a small list, no enforcement, and clear wording.
+- Self-contained output duplicates helper code. Accept that cost until sharing/runtime needs are clearer.
+
+* Testing / Verification / Rollout
+The test surface should start in =tests/test-build-theme.el= or a sibling converter test. Required tests:
+
+- role reference resolution
+- cycle detection
+- layered output loads in Emacs
+- flat/layered equivalence for representative fixture faces
+- advisory rule returns findings but does not block output
+- generated file remains self-contained
+
+Rollout should keep the current flat output path as the default and add a separate layered target. Manual verification should compare a real Theme Studio JSON through both outputs in a current Emacs session.
+
+* References / Appendix
+- Modus Themes source: [[https://github.com/protesilaos/modus-themes][github.com/protesilaos/modus-themes]]
+- Current converter: [[file:../scripts/theme-studio/build-theme.el][scripts/theme-studio/build-theme.el]]
+- Current Theme Studio README: [[file:../scripts/theme-studio/README.md][scripts/theme-studio/README.md]]
+- Package-face model spec: [[id:8f37a1fd-cfd3-4b25-92e5-772468092bdc][theme-studio-package-faces-spec-doing.org]]
+
+* Review and iteration history
+** 2026-06-14 Sunday @ 14:37:00 -0500 -- Craig -- author
+- What: Initial draft for a Modus-inspired layered Theme Studio output architecture.
+- Why: Craig asked how palette data, semantic role mappings, face templates, and generated wrappers could work together, including whether the semantic layer can support advisory consistency rules.
+- Artifacts: [[file:theme-studio-semantic-theme-architecture-spec.org][this spec]].
diff --git a/docs/specs/theme-studio-structured-output-spec.org b/docs/specs/theme-studio-structured-output-spec.org
new file mode 100644
index 000000000..ad189b7eb
--- /dev/null
+++ b/docs/specs/theme-studio-structured-output-spec.org
@@ -0,0 +1,157 @@
+:PROPERTIES:
+:ID: eaac7707-ed05-43df-9e51-b17c1d672531
+:STATUS: not-started
+:END:
+#+TITLE: Theme-Studio Structured Theme Output — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-15
+#+TODO: TODO | DONE SUPERSEDED CANCELLED
+
+* Metadata
+| Status | not-started |
+|----------+----------------------------------------------------------------|
+| Owner | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Reviewer | Craig Jennings |
+|----------+----------------------------------------------------------------|
+| Related | [[file:../../todo.org][todo.org: theme-studio output + dupre retirement]] |
+|----------+----------------------------------------------------------------|
+
+* Summary
+
+Replace build-theme.el's flat deftheme (literal hex on every face) with a structured two-file output: a palette file naming each color, and a theme file whose face assignments reference the palette through a binding. A hue change becomes one edit that propagates to every face on that color, the output reads meaningfully, and the same assignments paired with a different palette make a variant. The hand-authored dupre theme is retired in the same effort: it survives only as the fallback and a structural reference now that a theme-studio export (WIP) is the active theme.
+
+* Problem / Context
+
+build-theme.el converts a theme-studio theme.json into a deftheme, and it does so flat: one =custom-theme-set-faces= with a literal hex per face and no color layer above it (the converter's own header says "Do not hand-edit; re-run the converter"). It is faithful but unreadable, and a single hue change touches every face that used that color, scattered across the file. The structure that made the hand-authored dupre theme maintainable — a palette of named colors, faces referencing those names, organized by category — is discarded at generation time.
+
+dupre carried that structure in a three-file split (theme / palette / faces), and that structure was the value. But it is theme-specific source the user no longer wants to hand-maintain. WIP, a theme-studio export, is already the active theme (=persist/emacs-theme= reads "WIP"); dupre is now only =fallback-theme-name= and a reference. theme.json already carries a named palette — a list of =[hex name family]= triples — so the data needed to generate a structured theme already exists; only the converter throws it away.
+
+The driver: make generated themes inherit dupre's structural virtues in a generated-appropriate shape, and remove dupre.
+
+* Goals and Non-Goals
+
+** Goals
+- build-theme emits two files: =NAME-palette.el= (named colors) and =NAME-theme.el= (deftheme entry plus assignments that reference palette names).
+- A hue change is one edit in theme.json's palette, re-exported, and reaches every face on that color.
+- Assignments are separable from palette, so a palette swap yields a variant theme.
+- Faces are grouped and commented by tier (default, syntax, ui, packages) for readability.
+- Output stays one-way generated (do-not-hand-edit banner); theme.json is canonical.
+- dupre is removed: the three theme files and its test deleted, =fallback-theme-name= moved to a built-in, references and comments updated.
+
+** Non-Goals
+- No semantic-role layer (accent/err/keyword → palette) in v1 — deferred, but the format leaves room for it.
+- No OKLCH ramps, perceptual palette renaming, or auto light/dark variants.
+- No change to theme-studio's editing UI.
+- Not changing the theme-studio model: the palette already exists in theme.json; v1 reads it, it does not redesign it.
+
+** Scope tiers
+- v1: build-theme two-file structured output; the palette file from theme.json's palette list; face assignments referencing palette names via a binding, one-off hexes left literal; tier organization; regenerate the active theme in the new format; retire dupre and move the fallback.
+- vNext: a semantic-role layer; per-face palette-name carriage in theme-studio (preserve intent when two roles share a hex); palette-swap variant tooling.
+
+* Design
+
+** For the user
+Tune in theme-studio and export, as today. The generated theme is now two files. The palette file lists every named color once. The theme file maps each face to a color by name, grouped by area (syntax, UI, packages) so it reads like a description of the theme rather than a hex dump. To shift a hue, change it in theme-studio and re-export; every face on that color moves together. The same theme file paired with a different palette file is a variant — the lineage that took distinguished to dupre, made explicit.
+
+** For the implementer
+build-theme/--render splits into two emitters fed by the parsed theme.json:
+- Palette emitter: from theme.json's =palette= list of =[hex name family]=, write =NAME-palette.el= — a =defconst NAME-palette= (or a set of named constants) mapping name to hex, optionally grouped by family with section comments, ending in =(provide 'NAME-palette)=.
+- Theme emitter: write =NAME-theme.el= — the =deftheme=, =(require 'NAME-palette)=, then =custom-theme-set-faces= wrapped in a binding over the palette names (a =let= built from the palette, mirroring dupre-with-colors) so face specs reference names. Each face's stored hex is reverse-mapped to a palette name by exact match; a hex absent from the palette stays a literal string. Faces grouped by tier with comments. End =(provide-theme 'NAME)=.
+Both files carry the generated/do-not-hand-edit banner. =NAME-theme.el= requires =NAME-palette.el=, so the themes directory must be on the load path at theme-load time (the existing dupre arrangement already does this for the themes dir).
+
+* Alternatives Considered
+
+** Keep the flat per-face-hex output
+- Good: no converter change; the output is trivially correct.
+- Bad: unreadable, and a hue change is scattered across every face — the maintainability problem this spec exists to fix.
+- Neutral: it is generated, so "unreadable" matters only when a human reads or hand-tweaks it, which the structured format is meant to enable.
+
+** Three-file split (theme / palette / faces), exactly like dupre
+- Good: maximal separation; the deftheme boilerplate is isolated.
+- Bad: a generated theme's deftheme wrapper is a few lines — a third file is more ceremony than generated output needs.
+- Neutral: could become warranted in vNext if the assignments file grows unwieldy.
+
+** Carry a palette-name reference per face in theme.json (no reverse-map)
+- Good: preserves the designer's intended name even when two roles share a hex.
+- Bad: a theme-studio model and export change, larger than v1; the reverse-map gets the same readable output from data that already exists.
+- Neutral: the better long-term design; logged as vNext.
+
+* Decisions [6/6]
+
+** DONE Two-file output shape
+- Context: an Emacs theme needs =NAME-theme.el= for discovery; the palette wants to be independently swappable.
+- Decision: Two files — =NAME-palette.el= (named colors) and =NAME-theme.el= (deftheme entry plus assignments). The assignments ride with the deftheme rather than getting a third file.
+- Consequences: easier — palette swaps for variants, one place to retune hues, less ceremony than dupre's three files; harder — the theme file still mixes deftheme boilerplate with assignments.
+
+** DONE Faces reference the palette via a binding
+- Context: faces must name colors, not inline hex, for the one-edit-propagates property.
+- Decision: The theme file wraps =custom-theme-set-faces= in a =let= over the palette names (mirroring dupre-with-colors) and face specs reference the names.
+- Consequences: easier — readable specs, single source of color truth; harder — the converter must build the binding and reverse-map face hexes to names.
+
+** DONE Derive the palette layer by reverse-mapping face hex to palette names
+- Context: theme.json stores resolved hex per face but already carries a named palette list (=[hex name family]=).
+- Decision: build-theme reads the palette list to emit the palette file and reverse-maps each face's hex to a palette name by exact match; a hex with no palette entry stays a literal string.
+- Consequences: easier — no theme-studio model change, uses data that already exists; harder — a hex shared by two intended roles collapses to one name (intent loss), which per-face name carriage would fix in vNext.
+
+** DONE Semantic-role layer deferred
+- Context: dupre had roles (accent/err/keyword → palette) above the palette; the user may want them later.
+- Decision: No role layer in v1. The format keeps the palette binding so a role binding can slot above it later without reshaping the output.
+- Consequences: easier — smaller v1, fewer indirection layers to reason about; harder — role intent is not captured yet, so a role rename is a vNext addition.
+
+** DONE Retire dupre, move the fallback to a built-in
+- Context: WIP (a theme-studio export) is already active; dupre is only =fallback-theme-name= and a reference; the fallback has no further fallback, so it must be guaranteed present.
+- Decision: Delete the three dupre files and =test-dupre-theme.el=; set =fallback-theme-name= to "modus-vivendi" (built-in, always available); update the persistence/commands tests and the stale comments in auto-dim-config.el and org-config.el.
+- Consequences: easier — removes hand-maintained theme source, retires the four already-failing dupre palette tests; harder — the fallback loses chosen dimming colors (acceptable for a rare last resort), and dupre's look survives only in git and in WIP's lineage.
+
+** DONE Generated files stay one-way; theme.json is canonical
+- Context: the current converter already declares its output do-not-hand-edit.
+- Decision: Both generated files keep the generated banner; hue changes and palette swaps happen in theme-studio (or by generating from another theme.json), not by editing the output.
+- Consequences: easier — no source-of-truth ambiguity, regeneration is always safe; harder — a quick hand-tweak to the palette file is overwritten on the next export, so experiments route through theme-studio.
+
+* Implementation phases
+
+** Phase 1 — palette emitter
+Emit =NAME-palette.el= from theme.json's palette list: name→hex constants (grouped by family with comments), =(provide 'NAME-palette)=, generated banner. Done when the palette file loads and exposes every named color.
+
+** Phase 2 — theme emitter with palette references
+Rewrite build-theme/--render to emit =NAME-theme.el=: deftheme, require the palette, =custom-theme-set-faces= inside a =let= over the palette, face specs referencing names (reverse-mapped from hex; literals for one-offs), tier grouping and comments, =provide-theme=. Done when a theme.json round-trips to a loading theme whose faces render identically to the old flat output. Update test-build-theme.el to the two-file shape.
+
+** Phase 3 — regenerate the active theme
+Regenerate WIP (the active theme) in the new format via deploy-wip; confirm it loads and looks unchanged in the live daemon. Done when the round-trip lands with no visible difference.
+
+** Phase 4 — retire dupre
+Set =fallback-theme-name= to "modus-vivendi"; update test-ui-theme-commands.el and test-ui-theme-persistence.el; fix the stale comments in auto-dim-config.el and org-config.el; delete themes/dupre-theme.el, dupre-palette.el, dupre-faces.el and tests/test-dupre-theme.el. Done when the suite is green, startup uses WIP, and the fallback resolves to modus-vivendi. (Independent of Phases 1-3 — can land first since WIP is already active in the old format.)
+
+* Acceptance criteria
+- [ ] build-theme produces =NAME-palette.el= and =NAME-theme.el= for a given theme.json.
+- [ ] The generated theme loads and its faces render identically to the prior flat output for the same theme.json.
+- [ ] Changing one palette color in theme.json and re-exporting updates every face that used it.
+- [ ] The palette file names every distinct palette color; one-off face hexes remain literal.
+- [ ] dupre's files and test are gone; startup uses WIP; =fallback-theme-name= resolves to a present theme; suite green.
+
+* Readiness dimensions
+- Data model & ownership: theme.json (theme-studio) is canonical; the palette list is the color source; build-theme owns the generated files; both are one-way output.
+- Errors, empty states & failure: a face hex absent from the palette falls back to a literal — no failure, just an unnamed color. A missing palette file fails the theme load loudly (require error) rather than silently mis-coloring.
+- Security & privacy: N/A — color data only.
+- Observability: the live theme and theme-studio preview are the visible surface; a wrong reverse-map shows as a wrong color.
+- Performance & scale: N/A — tens of colors, ~150 faces, generated once per export.
+- Reuse & lost opportunities: rides the existing palette list and build-theme tiers; sets up palette-swap variants and a future role layer.
+- Architecture fit & weak points: mirrors dupre's proven palette/faces separation. Weak point is the hex→name reverse-map collapsing shared hexes — bounded by leaving one-offs literal and deferring name carriage to vNext.
+- Config surface: =fallback-theme-name= changes value; the themes load-path must include the generated palette file.
+- Documentation plan: the generated banner plus this spec; no user-facing docs.
+- Dev tooling: existing =make theme-studio-theme=, =deploy-wip=, and the build-theme test suite cover build and round-trip.
+- Rollout, compatibility & rollback: Phase 4 (dupre removal) is independent and reversible via git; Phases 1-3 change only generated output, rollback is reverting build-theme. The active theme (WIP) keeps working in the old format until regenerated.
+- External APIs & deps: =deftheme=, =custom-theme-set-faces=, =provide-theme=, =custom-theme-load-path= — all standard; modus-vivendi is built in.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Reverse-map ambiguity: one hex, several intended roles, collapses to one name. Dodge: leave one-offs literal; defer per-face name carriage to vNext.
+- Identical render is the bar: the structured output must produce the same face attributes as the flat output. Dodge: a converter test that diffs resolved face specs old-vs-new for a fixture theme.json.
+- Load-path for the palette file: =NAME-theme.el= requiring =NAME-palette.el= needs the themes dir on the path at load time. Dodge: reuse dupre's existing arrangement.
+- Scope creep into the role layer or OKLCH work. Dodge: both are explicit Non-Goals / vNext.
+
+* Review and iteration history
+** 2026-06-15 Mon — Craig — author
+- What: initial draft.
+- Why: the dupre retirement turned into a question of what shape theme-studio's generated themes should take; the palette-vs-flat format, the file split, and the reverse-map approach are real trade-offs worth settling before touching build-theme.el.
+- Artifacts: scripts/theme-studio/build-theme.el (current flat renderer), scripts/theme-studio/theme.json (palette list already present), themes/dupre-* (the structural reference being retired).
diff --git a/docs/design/utility-consolidation.org b/docs/specs/utility-consolidation-spec-doing.org
index b84283804..b0a5fe2bd 100644
--- a/docs/design/utility-consolidation.org
+++ b/docs/specs/utility-consolidation-spec-doing.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: fc2e3926-b4a1-4b45-92eb-20841e13f655
+:STATUS: doing
+:END:
#+TITLE: Design: Consolidate Shared Utility Helpers
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-04
@@ -6,7 +10,7 @@
Draft. Specification only. No helper extraction is part of this document.
-This is the sibling project to [[file:init-load-graph.org][Untangle the init.el Load Graph]]. The load-graph
+This is the sibling project to [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][Untangle the init.el Load Graph]]. The load-graph
project decides when modules load and what dependencies they declare. This
project decides which module should own reusable helper behavior.
@@ -291,7 +295,7 @@ Worked =system-lib.el= header:
;; Private helpers rename without alias when all call sites change in the
;; same commit.
;;
-;; See also: docs/design/utility-consolidation.org for design rationale.
+;; See also: docs/specs/utility-consolidation-spec-doing.org for design rationale.
;;
;;; Code:
#+end_src
@@ -328,7 +332,7 @@ Load shape:
- =cj-cache.el= follows the first real cache consumer's layer, likely Layer 2 if
modeline/agenda/refile remain eager or near-eager.
- Coordinate every new topic library with
- [[file:init-load-graph.org][init-load-graph.org]] before migrating its first consumer.
+ [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec-doing.org]] before migrating its first consumer.
* Naming Rules
@@ -781,7 +785,7 @@ Recommendation:
design addendum proves the API can drive the alignment.
- Then decide whether modeline's buffer-local cache can use the same library or
should remain specialized.
-- Phase 5 step 1 produces =docs/design/cache-helper-design.org=. Until that
+- Phase 5 step 1 produces =docs/specs/cache-helper-design-spec-implemented.org=. Until that
file exists, =cj-cache.el= must not be created. The addendum is the
prerequisite for any cache extraction commit.
@@ -902,7 +906,7 @@ Inventory artifact:
- Treat the inventory as living documentation. Cleared high-priority candidates
may move to Phase 2 before the whole inventory is complete.
- This inventory is independent from the module-shape inventory maintained by
- [[file:init-load-graph.org][init-load-graph.org]]. The two projects may walk the same files, but they
+ [[id:e1fd137e-e164-42f4-a658-f4d32fbe3228][init-load-graph-spec-doing.org]]. The two projects may walk the same files, but they
record different facts in separate artifacts.
For each helper record:
diff --git a/docs/design/vterm-to-ghostel-migration-spec.org b/docs/specs/vterm-to-ghostel-migration-spec-implemented.org
index 5974445ad..1be4fe227 100644
--- a/docs/design/vterm-to-ghostel-migration-spec.org
+++ b/docs/specs/vterm-to-ghostel-migration-spec-implemented.org
@@ -1,3 +1,7 @@
+:PROPERTIES:
+:ID: b54c94a0-d762-4b41-afd7-cf5593ce6675
+:STATUS: implemented
+:END:
#+TITLE: Migration: vterm → ghostel (single terminal engine)
#+AUTHOR: Craig Jennings
#+DATE: 2026-06-04
@@ -171,7 +175,7 @@ Audited file set.
** Docs (active references only — historical notes stay)
- =todo.org= current task link (already updated to this -spec path).
-- =docs/design/module-inventory.org=, =docs/design/init-load-graph.org= —
+- =docs/design/module-inventory.org=, =docs/specs/init-load-graph-spec-doing.org= —
update active =vterm-config= / =ai-vterm= references to the new names.
** Tests (~35 files)
diff --git a/docs/theme-studio-color-families-spec.org b/docs/theme-studio-color-families-spec.org
new file mode 100644
index 000000000..ce3b7a9fb
--- /dev/null
+++ b/docs/theme-studio-color-families-spec.org
@@ -0,0 +1,202 @@
+#+TITLE: theme-studio Color Families — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-09
+
+* Metadata
+| Status | Ready (Craig confirmed 2026-06-10); review incorporated, hex grouping |
+| Owner | Craig |
+| Reviewer | Codex |
+| Related | [[file:../todo.org][todo.org: theme-studio color families]] |
+
+* Summary
+
+Show the palette as color families: colors grouped into horizontal strips by their actual color (OKLCH hue), each strip ordered dark to light, with a per-strip control to generate a symmetric tonal ramp (N gives base ±N) from the strip's most-saturated color. Grouping is derived from the hex every render, so renaming a color to anything never moves it between strips. The flat palette underneath stays exactly what it is today — an editable list — and families are a view over it, not a new owner of the colors.
+
+* Problem / Context
+
+The ramp generator (palette-ramps v1) can produce blue-2, blue-1, blue, blue+1, blue+2, but the moment they land in the palette the relationship is gone: five chips in a wrapping row, sorted by nothing. To widen or narrow the ramp you delete and regenerate; to recolor it you edit each chip. Nothing shows the structure the ramp math produced.
+
+The designer thinks in families — "the blues", "the warm grays" — and wants to grow or shrink a ramp in place. Critically, the family a color belongs to is a fact about the *color*, not its label: renaming blue+1 to "azure" or "my favorite" must never change which group it sorts into. So grouping has to come from the hex, not from a naming convention.
+
+* Goals and Non-Goals
+
+** Goals
+- Group the palette into families by OKLCH hue (from the hex), and render each as a horizontal strip, dark to light.
+- Per-strip control to generate a symmetric ramp (N gives base ±N) from the strip's base; regenerate is authoritative.
+- Renaming any color never changes its family or sort position.
+- Sort families by hue across the panel and colors by lightness within; pin the fg/bg ground strip and neutral grays to the front.
+
+** Non-Goals
+- No name-based grouping, no step-name grammar, no import inference from names — grouping is purely by hex.
+- No theme.json format change — the palette stays a flat, ordered, individually-editable list.
+- No preserving hand-edits to generated steps across a regenerate (regenerate overwrites — Craig's call).
+- No asymmetric ramps, no per-family stepL/chroma-ease, no harmonic fill, no manual family ordering in v1.
+
+** Scope tiers
+- v1: hue-grouped family strips over the existing flat palette; per-strip ramp-generate control; ground strip from the bg/fg assignments; hue/lightness sort with neutrals pinned; the family control replaces the standalone ramp panel.
+- Out of scope: per-family stepL/chroma-ease, harmonic fill, asymmetric ramps, manual ordering.
+- vNext: tunable clustering, per-family knobs, asymmetric ramps.
+
+* Design
+
+*The palette stays flat.* This is the pivot from the first draft. The palette remains the flat =[[hex,name]]= list it is today, with the same per-chip operations — edit hex, rename, remove. Families do *not* own the colors. A "family" is a derived grouping computed from the hexes on every render, like sorting. Because it's derived from the hex, renaming a color can't change its group, there's no structure to store, and there's nothing to reconstruct on import. Most of what the first-draft review flagged (name grammar, import inference, chip-ownership transfer) simply doesn't exist in this model.
+
+*Grouping by hue.* Convert each palette color to OKLCH. A color whose chroma is below a neutral threshold (C < 0.02) has no meaningful hue; it joins the neutral group. The rest cluster by hue *proximity*, not fixed bins: sort by hue, walk the circle, and start a new family wherever the gap to the previous color exceeds a gap threshold (25°), wrapping at 360. Two blues at 250° and 256° stay together; a blue and a green a hue-gap apart split. Each family's *base* is its most-saturated member (tie-break toward mid-lightness), the natural anchor for a ramp.
+
+*The ground strip.* The foreground and background are the =bg= and =p= (plain/default-fg) assignments — =MAP.bg= and =MAP.p= — not palette names. The ground strip is synthesized from those two hexes and pinned first, even if neither hex is a palette entry (an imported theme can set them to colors absent from the palette). Editing a ground swatch writes the assignment hex (and the matching palette entry, if one exists at that hex). Neutral grays (low chroma, not the ground pair) form their own strip(s) pinned after the ground.
+
+*The ramp-generate control.* Each strip carries a count input. Its value reflects the family's current per-side reach (the count of members on the busier side of the base by lightness). Setting it to N regenerates the family as a clean symmetric ramp: =ramp(base, {n:N, stepL, chromaEase})= for N≥1, or just the base for N=0, *replacing* the family's current members. Regenerate is authoritative — same-hue colors that were there, hand-added or hand-edited, are replaced. This is the one family-level action; everything else is still per-chip.
+
+*References across a regenerate.* A regenerate changes member hexes, and assignments point at hexes. Map old members to new steps by lightness rank: an old member and the new step at the same signed offset are the "same" position, so repoint references old→new for every surviving position. A position *removed* by lowering N (its old hex has no new counterpart) leaves its references showing "(gone)" — a visible stale reference the designer can re-point, never a silent jump to a surprise color (the review's robustness point). =repointHex= already does the old→new sweep across syntax/UI/package assignments.
+
+*Sorting is display-only.* Strips order by base hue (ground first, then neutrals, then chromatic families by hue; ties by base lightness then hex). Within a strip, colors order by OKLCH lightness. The stored palette keeps its existing order — export emits it unchanged — so theme.json diffs stay deterministic and the sort never rewrites the file.
+
+The two altitudes:
+- *For the designer:* the palette is a stack of strips, one per hue family, each dark to light with the base marked, ground pinned at the top. A count input on each strip fans it to ±N or collapses it to the base. Rename a swatch to anything — it stays put. Find "the blues" by finding the blue strip.
+- *For the implementer:* pure functions in app-core.js — =familiesFromPalette(palette, groundHexes)= returns the ground strip plus hue-clustered families with a base each; =sortFamilies= orders them; =regenFamily(baseHex, n, opts)= returns the ramp members (handling n=0 without calling =ramp()=); =stepRepointPlan(oldMembers, newMembers)= returns the old→new map and the removed set. The DOM (strip rendering, the count control, calling =repointHex= per the plan) stays in app.js. The existing =ramp()= and =repointHex()= are reused unchanged.
+
+* Alternatives Considered
+
+** Hex-derived families over a still-flat palette (chosen)
+- Good, because grouping from the hex is rename-proof, needs no stored structure, and requires no import inference — the first draft's hardest contracts vanish.
+- Good, because the palette stays individually editable, so per-chip rename/remove/edit keep working unchanged.
+- Bad, because hue clustering has a threshold; an awkward gap can split a family the eye reads as one, or merge two.
+- Neutral, because regenerate is still authoritative, so generating a ramp over a hue cluster replaces whatever was there.
+
+** Name-derived families (first draft, rejected)
+- Bad, because the group would depend on the label; renaming blue+1 would move it, which Craig explicitly ruled out. It also needs a brittle step-name grammar and import inference (the review's top blocker).
+
+** Families as a stored structure in theme.json
+- Good, because base/N round-trip exactly.
+- Bad, because it's a format change with a migration, and every flat-palette consumer (build-theme.el, the assignments) would need the new shape.
+
+** Hue clustering by fixed bins vs. proximity gaps (gaps chosen)
+- Good (gaps), because nearby hues group regardless of where they fall, with no arbitrary bin edge splitting a near-pair.
+- Bad (gaps), because a chain of evenly-spaced hues never breaks; mitigated by the gap threshold and the small palette size.
+
+** Removed-step references: visible "(gone)" vs. silent repoint (gone chosen)
+- Good (gone), because a stale reference is visible and recoverable; a silent jump to an unexpected swatch is worse (the review's robustness note).
+- Bad (gone), because the designer must re-point manually after shrinking a family that something referenced.
+
+* Decisions
+
+** Group by OKLCH hue from the hex, never by name
+- State: accepted
+- Context: the family is a fact about the color; renaming must not move it. The first-draft name convention failed this and needed a grammar + import inference.
+- Decision: We will derive families by clustering palette colors on OKLCH hue every render; names are labels only and never affect grouping or sort.
+- Consequences: easier — rename-proof, no grammar, no import inference, no stored structure; harder — clustering needs a tuned hue-gap threshold.
+
+** The palette stays flat and individually editable; families are a derived view
+- State: accepted
+- Context: making families own the colors would transfer ownership away from the per-chip controls and force an import/edit/delete contract.
+- Decision: We will keep =PALETTE= a flat editable list with its current per-chip rename/remove/edit; families are computed for display, not stored.
+- Consequences: easier — existing controls and export are untouched, no migration; harder — the count control is the only family-level action, so "regenerate" must reconcile with loose hand-added colors (it replaces them).
+
+** A family generates a symmetric ramp: base ±N (0-4)
+- State: accepted
+- Context: the designer asked for a per-side count where 2 means ±2 and 3 means ±3.
+- Decision: We will give each strip a count input; N=0 is the base alone, N=k generates base-k..base+k via =ramp()=; =regenFamily= handles N=0 without calling =ramp()= (which clamps to 1-4).
+- Consequences: easier — one control, predictable; harder — asymmetric reach isn't expressible in v1.
+
+** Regenerate is authoritative; repoint survivors by lightness rank, removed steps go "(gone)"
+- State: accepted
+- Context: regenerate changes member hexes; references point at hexes; lowering N drops the extremes.
+- Decision: We will replace the family's members on regenerate, repoint references for each surviving position (matched by signed lightness rank) old hex → new hex via =repointHex=, and leave references to removed positions as a visible "(gone)" rather than reassigning them.
+- Consequences: easier — surviving references follow, removed ones are recoverable; harder — a hand-edited step hex and a reference to a dropped step are both lost/stale (accepted; the latter is visible).
+
+** The ground strip is synthesized from the bg/fg assignments
+- State: accepted
+- Context: fg/bg are the =MAP.bg= / =MAP.p= assignments, which may be hexes not present in the palette.
+- Decision: We will synthesize a fixed two-swatch ground strip from =MAP.bg= and =MAP.p=, pinned first, no count control; editing a ground swatch writes the assignment hex (and the matching palette entry if one exists). A palette chip whose hex equals the ground hex is shown in the ground strip, not duplicated into a family.
+- Consequences: easier — the ground is explicit and protected, imported themes with assignment-only ground colors work; harder — the strip is a special case, and ground-vs-family de-duplication is by hex.
+
+** Sort families by hue, colors by lightness, neutrals pinned; thresholds pinned
+- State: accepted
+- Context: ordering should come from the hex; clustering and neutral detection need fixed values so tests don't bake accidental ones.
+- Decision: We will sort strips ground-first, then neutral (C < 0.02) strips, then chromatic families by base hue (ties by base lightness then hex); colors within a strip by OKLCH lightness; hue clustering splits on a 25° adjacent-hue gap. Sorting is display-only; the stored palette order is unchanged on export.
+- Consequences: easier — deterministic spectrum order, stable theme.json diffs; harder — manual drag-ordering is dropped, and the thresholds are tuning the eye may want to revisit (vNext).
+
+** The family count control replaces the standalone ramp panel
+- State: accepted
+- Context: the v1 ramp panel and the per-strip count do the same job two ways.
+- Decision: We will remove the standalone ramp panel; fanning a color into a ramp happens from its strip (add a color → it appears as a singleton strip → raise its count).
+- Consequences: easier — one way to ramp, less UI; harder — churn over the few-day-old ramp panel, and "ramp from an arbitrary typed hex" now means "add the color first, then fan it".
+
+* Implementation phases
+
+** Phase 1 — Family model (pure)
+=familiesFromPalette(palette, groundHexes)=, =regenFamily(baseHex, n, opts)=, =stepRepointPlan(oldMembers, newMembers)= in app-core.js. =familiesFromPalette= returns the ground strip (from groundHexes, de-duped by hex) plus hue-clustered families each with a base. Tests: a spectrum splits into families, a near-pair stays together, neutrals separate, ground hexes absent from the palette still form the strip, =regenFamily= handles n=0 (base only) and n≥1 (ramp), =stepRepointPlan= maps survivors and lists removed. No UI.
+
+** Phase 2 — Sort (pure)
+=sortFamilies= orders ground-first, neutrals (C<0.02) next, chromatic by hue with the lightness/hex tie-breakers; a helper orders colors within a strip by lightness. Tests cover a spectrum, an all-neutral set, ties, and the 25° gap boundary.
+
+** Phase 3 — Strip rendering (read-only)
+Render the palette panel as the pinned ground strip plus hue-sorted family strips, base marked, dark to light. Reuse chip styling; the existing per-chip controls (rename/remove/edit) keep working since the palette is still flat. No count control yet.
+
+** Phase 4 — Count control + regenerate
+A per-strip count input (0-4). On change, =regenFamily=, apply =stepRepointPlan= (repoint survivors via =repointHex=, leave removed references "(gone)"), update =PALETTE=, re-render. Browser gate: count up adds symmetric steps, count down drops the extremes and a reference to a dropped step reads "(gone)", a reference to a surviving step follows the new hex.
+
+** Phase 5 — Ground strip + base edit + retire the ramp panel
+Synthesize the ground strip from =MAP.bg= / =MAP.p=, editable, pinned, de-duped. Editing a family's base regenerates it (same repoint plan). Remove the standalone ramp panel and its gate; adding a color yields a singleton strip that fans via its count. Gate the ground-strip derivation (including assignment-only ground hexes) and the base-edit repoint.
+
+** Phase 6 — Warnings, seeding, export, README
+Keep =paletteWarnings= on the flattened palette but exempt adjacent same-family ramp steps from the too-similar warning (they're intentionally close). Confirm package seeding still reads the flat palette (families are display-only, so =seedPkgmap= is unchanged). Confirm export emits the flat palette unchanged and import needs no reconstruction. Update README. Gate an import → render → export round-trip leaving the palette JSON identical.
+
+* Acceptance criteria
+- [ ] The palette renders as hue-grouped strips, base marked, dark to light, ground pinned first.
+- [ ] Renaming any color to any string never changes its strip or sort position.
+- [ ] A per-strip count control sets base ±N live; raising it adds symmetric steps, lowering removes the extremes.
+- [ ] On regenerate, references to surviving steps follow the new hex; references to removed steps read "(gone)", never a silent reassignment.
+- [ ] The ground strip is synthesized from the bg/fg assignments (even when those hexes aren't palette entries), pinned first, with no count control; a palette chip at a ground hex isn't duplicated.
+- [ ] Families sort by base hue (ground and neutrals pinned); colors sort dark to light; the stored palette order is unchanged on export.
+- [ ] Existing flat theme.json files load and re-export byte-stable through the family view.
+- [ ] Unit tests cover the family model, regen, repoint plan, and sort; browser gates cover the count control (up/down + removed-step "(gone)"), the ground strip, the base-edit repoint, and the round-trip.
+- [ ] README documents families, the ground strip, regenerate behavior, removed-step references, and the removal of the standalone ramp panel.
+
+* Readiness dimensions
+- Data model & ownership: the flat palette stays the persisted, individually-editable truth; families are a derived display grouping computed from the hex each render; nothing new on disk; the ground strip reads/writes the bg/p assignments.
+- Errors, empty states & failure: a malformed chip hex is excluded from clustering (no OKLCH) and shown as a loose/neutral entry rather than crashing the panel; a regenerate of a bad base yields nothing for that family (per =ramp='s bad-hex); an empty palette shows only the ground strip; references to removed steps degrade to a visible "(gone)", never silent.
+- Security & privacy: N/A — local color math.
+- Observability: the strips are the observability — grouping, ramp reach, and the base are visible; "(gone)" surfaces stranded references.
+- Performance & scale: tens of colors, a few families; clustering is one sort + a linear pass, regenerate is one =ramp()= + a repoint sweep. Instant.
+- Reuse & lost opportunities: reuse =ramp()=, =repointHex()=, the chip styling, =optList= (dropdowns still read the flat palette), and colormath OKLCH. Don't reimplement the ramp or the re-point.
+- Architecture fit & weak points: pure family logic in app-core.js (importable, tested like the ramp core); strip DOM in app.js; integration points are the palette panel, the assignment dropdowns (unchanged, still read the flat palette), the bg/p assignments (ground strip), and export/import (unchanged). Weak point: the hue-gap and neutral thresholds — pinned defaults, tunable in vNext.
+- Config surface: per-family N (0-4); hue-gap (25°) and neutral-chroma (0.02) thresholds and stepL/chroma-ease (0.08/0.5) are pinned constants in v1.
+- Documentation plan: the README grows a "color families" section; the color-harmony explainer carries the why.
+- Dev tooling: =make theme-studio-test= covers it via new node tests + browser gates; no new tooling.
+- Rollout, compatibility & rollback: additive and display-only over the existing palette; theme.json unchanged, so old themes load and re-export stable; rollback is reverting the panel; no migration.
+- External APIs & deps: none — pure color math.
+
+* Risks, Rabbit Holes, and Drawbacks
+- The hue-gap threshold is the fuzzy core now (not name inference): an awkward gap can split a family the eye reads as one, or merge two adjacent hues. Dodge: a sane 25° default, small palettes, and a vNext tuning knob.
+- Regenerate-authoritative replaces loose same-hue colors the designer may have hand-placed in that hue band. Accepted per the regenerate decision, but the strip UI should make "this control rewrites the family" obvious before it's used.
+- Removed-step references going "(gone)" is the deliberate, recoverable choice; the risk is forgetting to also repoint survivors on the base-edit path (not just the count path). Both paths run the same repoint plan.
+- Retiring the just-shipped ramp panel is churn; the count control must cover the same discovery path (add a color, fan it from its strip).
+
+* Review dispositions
+
+Only modified and rejected review recommendations are listed; everything else from the Codex review is accepted and folded into the body above.
+
+- *Rejected as framed — "family inference from flat palette names" / the name-grammar section.* Craig's directive is to group by hex, never by name, so renaming is free. There is no step-name grammar and no import inference to specify; the review's underlying need (a deterministic grouping contract) is met instead by the hue-clustering contract (25° gap, 0.02 neutral threshold). The whole name-parsing surface is designed out.
+- *Modified — "chip-level edits need a new ownership contract".* Accepted the concern, changed the resolution: rather than defining transfer of ownership to family objects, the palette stays flat and individually editable, so per-chip rename/remove/edit keep working as today. Only drag/move-reorder is dropped (the sort is deterministic). The count control is the sole family-level action.
+- *Modified — "two Decisions open/proposed".* Both resolved per Craig: flat persistence accepted, the standalone ramp panel is removed (not left for Phase 6).
+- *Accepted with a change of mechanism — ground strip, removed-step policy, n=0, neutral threshold, sort tie-breakers, palette warnings, package seeding, README in acceptance criteria.* All folded as written, adapted to the flat-palette/hex-grouping model (e.g. seeding is unchanged because the palette is still flat).
+- *Deferred — the review's implementation-task drop-in block.* Per spec-response, tasks are created only after the author confirms Ready (Phase 6); they are not yet logged.
+
+* Review and iteration history
+** 2026-06-09 Tue @ 22:50:43 -0500 — Craig — author
+- What: initial draft (name-derived families owning the palette).
+- Why: live color families restructure the palette panel and supersede part of the shipped ramp UI — design-uncertain and cross-cutting.
+- Artifacts: this spec; the palette-ramps v1 build it builds on.
+** 2026-06-09 Tue @ 23:31:28 -0500 — Codex — reviewer
+- What changed or was recommended: marked =Not ready=; flagged two open decisions, an underspecified name/import inference contract, the ground-strip source of truth, chip-level ownership in family mode, and removed-step assignment behavior.
+- Why: the first draft moved palette ownership to name-derived families without pinning import, edit, delete, and reference-healing behavior.
+- Artifacts: theme-studio-color-families-spec-review.org (consumed and deleted on response).
+** 2026-06-09 Tue @ 23:40:35 -0500 — Claude Code (dotemacs) — responder
+- What changed or was recommended: folded the Codex review and Craig's three clarifications. Resolved both open decisions (flat persistence; remove the standalone ramp panel). Pivoted the whole model from name-derived families to hex-derived families over a still-flat, still-editable palette — which designs out the name-grammar/import-inference blocker and the chip-ownership blocker. Pinned the ground strip to the bg/p assignments, the removed-step references to a visible "(gone)", n=0 handled without =ramp()=, the 0.02 neutral and 25° hue-gap thresholds, the sort tie-breakers, the warnings exemption, and the unchanged package seeding. See Review dispositions for the two reframed findings.
+- Why: Craig's "group by hex, never by name; rename anything freely" both answers the review's hardest questions and simplifies the design — the palette no longer changes ownership, so most of the missing contracts disappear.
+- Artifacts: review file deleted; Review dispositions section; this entry. Remaining fuzzy area is the hue-clustering threshold (pinned default, vNext tuning).
+** 2026-06-10 Wed @ 00:03:39 -0500 — Codex — reviewer
+- What changed or was recommended: re-reviewed the updated spec and found it implementation-ready. No new blocking review notes; the prior blockers are resolved by the hex-derived grouping model, accepted flat-palette persistence, accepted ramp-panel removal, explicit ground-strip source, retained per-chip flat-palette ownership, removed-step "(gone)" policy, pinned clustering/sort thresholds, and README/test acceptance criteria.
+- Why: the updated design now gives the implementer stable behavior for grouping, regeneration, references, ground colors, import/export, and UI ownership while fitting the current =app-core.js= / =app.js= split.
+- Artifacts: no new review file; this Ready verification entry.
diff --git a/docs/theme-studio-palette-columns-spec.org b/docs/theme-studio-palette-columns-spec.org
new file mode 100644
index 000000000..d1dba0356
--- /dev/null
+++ b/docs/theme-studio-palette-columns-spec.org
@@ -0,0 +1,119 @@
+#+TITLE: Theme-Studio Spec: Declared Palette Columns
+#+DATE: 2026-06-10
+
+* Metadata
+
+- Status: Draft (Ready after Craig's read)
+- Source: design discussion 2026-06-10 (Craig + session), folding in the shipped ramps v1 and color-families work it supersedes
+- Related: [[file:theme-studio-palette-ramps-spec.org][palette-ramps spec]] (ramp math, floor/L_max safety — still in force), [[file:theme-studio-color-families-spec.org][color-families spec]] (superseded in its grouping half), [[file:design/theme-studio-color-harmony.org][color-harmony explainer]]
+
+* Summary
+
+The palette stops being a flat bag of colors grouped by hex inference and becomes a declared structure: a ground pair (bg, fg) plus user-created columns, each column a parametric ramp (name, base hex, count, knobs) whose steps are always computed, never stored. Membership is known by construction, naming is mechanical (name+N / name-N), ordering is canonical everywhere, and the file format saves the structure explicitly. Theme-studio launches empty (bg and fg only); legacy flat palettes import through a one-time LCCL shim. The declared structure is the substrate the future generate-palette (harmonic fill) feature needs.
+
+* Problem / Context
+
+The shipped color-families feature derives groups from hexes every render (LCCL clustering). That solved display grouping for arbitrary flat palettes, but membership stays inferred: the geometrically irreducible cases (yellow+2 on the distinguished palette) need a per-hex override layer, renames and regenerates need repoint machinery to keep names and groups coherent, and a generate-palette feature would have to emit colors that the inference then re-groups, hoping it agrees with the generator's intent.
+
+When the user creates every group themselves, inference is the wrong tool. A column created by hitting + and ramping a chosen base is declared; nothing needs deriving, and the LCCL machinery retires to one job it is actually right for: proposing columns when importing a legacy flat palette.
+
+* Goals and Non-Goals
+
+** Goals
+
+1. Palette = ground pair + declared columns; column steps parametric (computed from base + count + knobs, never stored).
+2. Mechanical naming: column name + offset (=blue+1=, =blue-2=); renaming a column renames its steps atomically.
+3. Canonical ordering everywhere (strips, dropdowns, export): bg column, fg column, then user columns left to right.
+4. Explicit v2 file format; old flat files import through a confirm-gated LCCL shim.
+5. Launch state: bg and fg only, no columns, packages and UI faces at inherit/ground defaults.
+6. bg and fg are themselves columns (pinned first), ramp-able like any other.
+7. Foundation for generate-palette: "N columns, here are my chosen colors, fill the rest harmoniously."
+
+** Non-Goals
+
+- Harmonic fill itself (vNext; see the color-harmony explainer).
+- Per-step hand-tweaks. Parametric is provisional by agreement: if it fights the actual workflow, we revisit (stored steps or per-step deltas).
+- Symbolic references (column+offset identity for assignments). References stay hexes with the existing repoint machinery; symbolic refs are a possible vNext.
+- Any change to the ramp math, contrast floor, L_max, or picker safety machinery — all carried forward as-is.
+
+* Design
+
+** Data model
+
+#+begin_example
+palette = {
+ bg: {name: 'bg', base: '#141210', count: 0, stepL: 0.08, chromaEase: 0.5},
+ fg: {name: 'fg', base: '#f2efe9', count: 0, stepL: 0.08, chromaEase: 0.5},
+ columns: [{name, base, count, stepL, chromaEase}, ...]
+}
+#+end_example
+
+A column's swatches are =steps(column)= → =ramp(base, {n: count, stepL, chromaEase})= plus the base, ordered and named mechanically. =count= 0 is a single-swatch column (a one-off accent). bg and fg are columns with fixed identity (cannot be removed, names fixed) but ramp like any other: =bg+1=/=bg+2= are the hl-line/mode-line tint slots, =fg-1=/=fg-2= the dimmed-text slots; steps that fall outside the gamut clamp with the existing badge.
+
+Column identity is positional (the array); the display name is the only name. Renaming changes =name= and thereby every step label. Step hexes are deterministic from (base, count, knobs), so two renders never disagree.
+
+** References and regenerate
+
+Assignments (syntax MAP, UI faces, package faces) keep storing hexes. Editing a base or count regenerates the column and runs the existing repoint plan: surviving steps repoint by signed lightness rank, removed steps leave the reference on its now-gone hex with the visible "(gone)" dropdown entry. This is the shipped =stepRepointPlan= behavior, re-grounded on declared columns.
+
+** UI
+
+- The palette area renders the pinned bg column, the pinned fg column, the user columns left to right, then a trailing empty column whose header is "+".
+- Hitting + opens the existing picker to choose a base. The new column lands with the single base swatch (count 0) and the default hue-word name (red, orange, gold, green, cyan, blue, purple, pink, gray from the base's OKLCH hue; collision-suffixed). The count control then expands it to base ±N.
+- Each column keeps: editable name (header), count control (0-4), per-chip select for assignment, base editable through the picker. Per-chip rename/remove disappear for step chips (names are mechanical, removal happens via count); a user column with count 0 can be removed whole.
+- Display order within a column: lightest at top to darkest at bottom, matching the dropdown order (DECISION 9 below — this flips the current dark-to-light strip direction).
+- Dropdown chooser order: default entry, then bg and its steps (lightest to darkest), fg and its steps, then each user column lightest to darkest, left to right (DECISION 10 below places the base within its column run).
+
+** Launch state and the baked page
+
+theme-studio.html bakes an empty v2 theme: bg #141210 (warm near-black), fg #f2efe9 (warm near-white), no columns. The dupre palette no longer ships baked in; it loads through import like any saved theme. Package-face seeds already degrade gracefully (=pname= on an unknown name returns null), so seeded faces keep their structural attrs (bold/box/etc.) and inherit ground colors until the designer assigns; the seeding-engine task later makes seeding palette-aware.
+
+** Export / import
+
+- Export writes the v2 structure verbatim (plus assignments, locks, packages as today). Round-trip is byte-identical, same gate discipline as the flat format had.
+- Import detects the shape: v2 structure loads directly; a legacy flat palette triggers the shim — run LCCL once to propose columns (each proposed column: inferred base = most-saturated member, count from member span, name from the longest-common name prefix or hue word), show the proposal, and only commit on confirm. Members the clustering can't reconcile with a parametric ramp land as count-0 single columns rather than being silently bent.
+- The shim is the LCCL code's retirement home: it runs at import only, never at render.
+
+* Decisions
+
+1. Parametric columns (2026-06-10, Craig): steps computed, never stored. Provisional — abandoned or amended if it fights real use.
+2. bg and fg ramp too (Craig): one bg column and one fg column, pinned first.
+3. Explicit structure storage (Craig): no name-grammar parsing; breaking format change accepted.
+4. Legacy import via LCCL shim (Craig): confirm-gated, propose-then-commit.
+5. + lands a single-swatch column (Craig): picker chooses the base; count expands it. No surprise default ramp.
+6. Empty launch (Craig): bg and fg only; packages/UI at defaults until assigned.
+7. References stay hexes + repoint (session, unvetoed): least churn, machinery already gate-covered.
+8. Default column name = hue word from the base's OKLCH hue (session, unvetoed).
+9. Column strips display lightest→darkest top→bottom, matching the dropdown (session — flips the current strip direction; flag to Craig on read).
+10. Dropdown: each column appears as one run, lightest to darkest, with the base in its natural lightness position within the run (session; Craig said "bg color, fg color, then lightest to darkest each column" — flag on read whether the bg/fg BASES should instead lead before any steps).
+
+* Alternatives Considered
+
+** Flat ordered list with name-grammar membership (rejected)
+
+Saving a flat list where =blue+2..blue-2= adjacency and suffix parsing carry membership honors "saved in order" but resurrects the name-grammar inference the families spec already rejected. Explicit structure is strictly simpler; names become output.
+
+** Stored steps with hand-tweaks (deferred, not rejected)
+
+Keeping steps as real palette entries preserves per-step nudging but re-opens every coherence question (drifted steps vs mechanical names, regenerate vs hand edits). Parametric first; revisit on real-use pain per Decision 1.
+
+** Keep LCCL at render with declared hints (rejected)
+
+The hint-override task taken to its conclusion. Declaring membership at creation makes both the inference and its overrides unnecessary.
+
+* Implementation Phases
+
+Each phase lands TDD with the usual commit-per-green-phase; =make theme-studio-test= green throughout.
+
+1. *Core model.* Column type, =steps()=, mechanical naming, canonical ordering, v2 export/import round-trip. Pure functions in app-core.js, node tests.
+2. *Renderer.* Pinned bg/fg columns, user columns, the + column, picker-for-base flow, count expansion, column rename/remove. Gate: #columnstest.
+3. *References.* Dropdown canonical ordering, repoint on base/count edits re-grounded on columns, "(gone)" behavior. Adapt #counttest/#baseedittest.
+4. *Import shim.* Flat-shape detection, LCCL proposal, confirm gate, conversion. Gate: #shimtest (a fixture flat palette converts to the expected columns).
+5. *Launch + bake.* Empty v2 default in generate.py, seeds degrade verified, README rewrite for the new model. Adapt #roundtriptest, #familytest retires or becomes #columnstest coverage.
+6. *Test-surface reconciliation.* Sweep remaining gates and node tests for flat-palette assumptions; retire dead LCCL render paths (clustering stays, callable from the shim only).
+
+* Task fallout (todo.org, at breakdown time)
+
+- The [#C] per-hex family-hint override task dies (CANCELLED — membership is declared now).
+- The color-families manual sign-off items change shape: grouping-reads-right becomes moot (nothing is inferred); regenerate/"(gone)" checks carry over.
+- The seeding-engine task gains a dependency note (palette-aware seeding presumes columns exist to reference).
diff --git a/docs/theme-studio-palette-ramps-spec.org b/docs/theme-studio-palette-ramps-spec.org
new file mode 100644
index 000000000..849fea0f0
--- /dev/null
+++ b/docs/theme-studio-palette-ramps-spec.org
@@ -0,0 +1,219 @@
+#+TITLE: theme-studio Palette Ramps & Background-Contrast Safety — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-09
+
+* Metadata
+| Status | draft — review incorporated (Codex, 2026-06-09) |
+| Owner | Craig |
+| Reviewer | Codex |
+| Related | [[file:../todo.org][todo.org: theme-studio color-harmony explainer + ramp/fill features]] |
+
+* Summary
+
+Give theme-studio two things it lacks: a generator that turns one base color into a harmonized tonal ramp (base, plus lighter and darker steps), and a readout that tells the designer whether a color is safe to use as a background behind editor text. Both rest on the same OKLCH math worked out 2026-06-09, and they're coupled: the dark end of a ramp is where background tints come from, and a background tint is only usable if it keeps every foreground on it readable.
+
+* Problem / Context
+
+Building a theme, the designer needs a family of related shades per hue (blue, blue+1, blue-1, and so on) and a set of dim tinted backgrounds for effects like highlight, region, isearch, and hl-line. Today every shade is hand-picked by eye. Getting them to harmonize is guesswork, and there's no signal for the harder half of the problem: a background effect is an overlay, so the same tint sits behind many different foreground colors at once. A tint that looks fine against the default text can fail against the darkest token. The designer can't see that failure, because the picker shows one fg-on-bg pair, which is the wrong number.
+
+The cost is concrete. sterling's keyword blue (#67809c, OKLCH-L 0.59) is the darkest foreground; it barely clears WCAG AA on pure black and drops below AA the instant any highlight background lifts off black. So one color silently caps every highlight to near-black, and nothing in the tool surfaces it. The designer either over-darkens everything defensively or ships unreadable highlights without knowing.
+
+* Goals and Non-Goals
+
+** Goals
+- From a base color, generate its tonal ramp (base, +1..+N lighter, -1..-N darker) that harmonizes by construction.
+- For any face used as a background, show the worst-case contrast across the foregrounds that can land on it, and name the limiting foreground.
+- Guide the usable background lightness so a generated or chosen background keeps all text readable.
+
+** Non-Goals
+- Not auto-assigning ramp steps to faces — the designer picks which step goes where.
+- Not the harmonic-fill feature (generate a whole palette from a few seed colors). Separate, deferred.
+- Not a new color model or palette format — OKLCH mode and the palette already exist; this extends them.
+
+** Scope tiers
+- v1: ramp generation from a base; worst-case-contrast readout + safe-lightness guidance for background-effect faces.
+- Out of scope: harmonic fill; auto-assignment of steps to faces.
+- vNext: harmonic fill (palette from seeds); richer per-face foreground-set detection.
+
+* Design
+
+The two features share OKLCH (perceptually uniform, so lightness / chroma / hue move independently) and reuse colormath.js, which already has =oklch2hex=, =contrast=, =apca=, and =deltaE=.
+
+*Ramp generation.* A ramp is one hue at many lightnesses. Convert the base to OKLCH, hold the hue fixed, and step lightness by a fixed perceptual delta per stop — lighter for +N, darker for -N. Chroma eases toward zero at the extremes (a near-white or near-black step carries almost no color), and every step is clamped back into sRGB. The harmony is structural: the steps share a hue and sit on an even lightness ladder, so they read as one family rather than a grab-bag. The output is a row of hexes the designer can name (=blue+1=, =blue-2=) and drop into the palette.
+
+*Background-contrast safety.* A background tint is only as good as its worst case. For a face used as a background, define its foreground set — the colors that actually render on top of it. For a code-context face (region, hl-line, isearch, highlight, lazy-highlight), that set is the syntax token colors plus the default foreground. The floor is the minimum, over that set, of =contrast(fg, candidate-bg)=; the limiting foreground is the argmin. From the floor we derive L_max: the lightest background, at the chosen hue and chroma, whose floor still clears the target (WCAG AA/AAA, or an APCA Lc). Sweep lightness to find it — deterministic.
+
+The two altitudes:
+- *For the designer:* pick a base swatch, see its ramp, add the steps you want. When editing a background-effect face, the contrast cell shows the worst case — "worst: keyword #67809c — 3.3 FAIL" — not a misleading single pair, and OKLCH mode marks the safe-lightness ceiling so you can't unknowingly cross it.
+- *For the implementer:* =ramp(baseHex, {n, stepL, chromaEase})= → =[hex]=; =fgSetFor(face, state)= → =[hex]=; =floor(bgHex, fgSet)= → ={ratio, limitingHex}=; =lMax(hue, chroma, fgSet, target)= → =L=. All pure, all in app-core.js / colormath.js, all unit-tested. Function contracts (inputs, outputs, validation, edge cases) are pinned below.
+
+* v1 covered faces
+
+v1 computes a worst-case floor for a *closed, enumerated* set of code-overlay faces — backgrounds the live buffer renders syntax-colored text over:
+
+- =region=
+- =hl-line=
+- =highlight=
+- =lazy-highlight=
+- =isearch=
+
+That set is exhaustive for v1. Other overlay faces (=secondary-selection=, =isearch-fail=, and any future built-in) are vNext, added explicitly rather than matched by a heuristic. The deliberately closed list is what makes the contract testable: an open "any face the buffer renders as text-over-syntax" rule would hand the implementer the same invent-the-behavior problem the foreground-set decision exists to close.
+
+Everything outside this set keeps its existing single-pair contrast cell:
+
+- *Package contrast cells* stay single-pair in v1. Package faces own their foregrounds through inheritance, which needs a per-preview foreground-set model that doesn't exist yet (vNext).
+- *Non-overlay UI rows* (mode-line, fringe, and the rest) stay single-pair — their foreground set isn't a syntax palette.
+
+A covered face whose foreground set resolves empty (no syntax assignments yet) shows the no-set readout (below), not a bogus ratio.
+
+* Ramp defaults and palette insertion
+
+*Generation defaults* (all exposed as controls, these are the starting values):
+- =n= (steps each direction): default =2= → base plus +1,+2,-1,-2. Safe range 1-4.
+- =stepL= (OKLCH-L delta per step): default =0.08=. Safe range 0.04-0.12.
+- =chromaEase= (fraction of chroma removed at the farthest step, eased toward the extremes): default =0.5=. Range 0-1; 0 holds chroma flat, 1 fully desaturates the last step.
+
+*Preview and insertion:*
+- The ramp previews as a row ordered darkest → lightest (=-n .. base .. +n=), the base marked.
+- Each generated swatch shows a clamp badge when =oklch2hex= reports =clamped= true, so the designer sees an out-of-gamut step before adding it.
+- The base is preview-only by default (it's the source swatch, already in the palette); the designer may opt to add it.
+- Selected steps insert adjacent to the source swatch, in =-n .. +n= order.
+
+*Naming:* step names derive from the source swatch name — base =blue= → =blue+1=, =blue+2=, =blue-1=, =blue-2=. If the source swatch is unnamed, names fall back to a hex-based label the designer can edit before add.
+
+*Collisions — never silent:*
+- *Name collision* (=blue+1= already exists): the row is flagged and the designer renames before add; no overwrite.
+- *Hex collision* (the generated hex already exists under another name): flagged as a duplicate, add still allowed (two names for one hex is legal in the palette).
+
+* Function contracts
+
+All four are pure, live in app-core.js (or colormath.js for the color math), take explicit state — never read globals — and validate user input by *returning a structured result*, not throwing. Throwing is reserved for genuine programmer error (wrong argument arity/type), not for malformed user-entered values.
+
+- =ramp(baseHex, {n, stepL, chromaEase})= → ={steps: [{hex, clamped}], error?}=. Validates: malformed =baseHex= (not a parseable hex — can't be clamped) → ={steps: [], error: 'bad-hex'}=; =n= outside 1-4 or non-integer → clamped into range with a flag; =stepL=/=chromaEase= outside range → clamped with a flag. Holds hue, steps OKLCH-L by =stepL=, eases chroma toward the extremes, gamut-clamps every step and reports per-step =clamped=.
+- =fgSetFor(face, state)= → ={set: [hex], error?}=, where =state= is explicit slices ={syntaxAssignments, palette, defaultFg, locks}=. Returns the distinct syntax-assignment hexes plus =defaultFg=, excluding locked background-only roles. A face outside the v1 covered set → ={set: [], reason: 'out-of-scope'}=. No syntax assignments → ={set: [], reason: 'empty'}=.
+- =floor(bgHex, fgSet)= → ={ratio, limitingHex, limitingLabel}=. =ratio= is the minimum WCAG contrast over =fgSet= against =bgHex=; =limitingHex= is the argmin; =limitingLabel= is its role/palette/hex name. Empty =fgSet= → ={ratio: null, limitingHex: null}= (caller shows the no-set readout).
+- =lMax(hue, chroma, fgSet, target)= → ={L, status}=. Binary-searches OKLCH-L (tolerance 0.001) for the lightest background at =hue=/=chroma= whose =floor= still clears =target=. =status= is =ok= (an L found), =none= (no L satisfies the target — every background fails), =all= (every L satisfies it — no ceiling needed), or =clamp= (the chroma clamps before the target is reached; returns the L at the clamp boundary).
+
+* Alternatives Considered
+
+** Ramp stepping in OKLCH lightness (chosen)
+- Good, because steps are perceptually even and the hue holds, so the family looks deliberate.
+- Bad, because extreme steps can leave the sRGB gamut and need clamping.
+- Neutral, because it depends on colormath's OKLCH path, which already exists.
+
+** Ramp stepping in HSL/HSV
+- Bad, because HSL/HSV aren't perceptually uniform — equal numeric steps give uneven visual steps and the hue drifts at the light/dark ends.
+
+** Ramp by interpolating base↔white and base↔black
+- Good, because it's trivial to implement.
+- Bad, because lightness and chroma drift together unpredictably and the hue can shift, so the steps don't harmonize reliably.
+
+** Worst-case shown as one readout + limiting fg (chosen), with an OKLCH picker mask
+- Good, because one honest number (the floor) plus the name of the bottleneck is the actionable lever, and the mask shows the safe band visually.
+- Neutral, because it replaces the existing single-pair contrast cell for background faces only.
+
+** Worst-case shown as a full per-foreground heatmap
+- Bad, because it's noise — the designer needs the floor and the one color setting it, not twenty ratios.
+
+* Decisions
+
+** Work in OKLCH for ramps and tints
+- State: accepted
+- Context: ramps and background tints both need even, hue-stable steps; the tool already exposes OKLCH.
+- Decision: We will compute ramps and the contrast floor in OKLCH via colormath.js.
+- Consequences: easier — even families, reuse of existing math; harder — must gamut-clamp every generated step.
+
+** Ramp = fixed lightness step + chroma ease on a held hue
+- State: accepted
+- Context: alternatives drift hue/chroma; uniform L steps read as a ladder.
+- Decision: We will step lightness by a fixed delta, ease chroma toward the extremes, and hold the hue.
+- Consequences: easier — predictable families; harder — chroma easing needs tuning so mid steps don't go muddy.
+
+** Background safety = worst-case floor over a per-face foreground set
+- State: accepted
+- Context: a single fg-on-bg pair misleads; overlays carry many foregrounds.
+- Decision: We will compute the floor over a face's foreground set and surface the floor + limiting foreground.
+- Consequences: easier — the real constraint is visible; harder — we must define each face's foreground set.
+
+** v1 foreground set for code-overlay faces = syntax tokens + default fg
+- State: accepted
+- Context: the exact face→context mapping is fuzzy; code-overlay faces clearly carry the syntax palette. The reviewer flagged that an open ("proposed") contract forces the Phase 3 implementer to invent what counts as foreground text.
+- Decision: We will scope v1 to the closed code-overlay face set (see "v1 covered faces"), and define the foreground set as the distinct hexes of the syntax-assignment colors plus the default foreground (=fg=). Duplicate hexes collapse to one entry. Locked structural colors (=bg= and any palette entry flagged as a background-only role) are excluded from the set. The limiting foreground is labeled by its syntax role name when one exists, else its palette name, else its hex.
+- Consequences: easier — a pinned, enumerable contract the implementer and tests can rely on; harder — UI/package overlays need a later per-preview foreground-set model (vNext).
+
+** Contrast target for the floor = WCAG AA default, AAA optional, APCA diagnostic
+- State: accepted
+- Context: AA (4.5) is the floor most reach for; AAA (7) is the stricter option; APCA models text-on-color better than WCAG but needs a chosen Lc and signed/absolute handling. The reviewer recommended a WCAG-only v1 so =floor=, =lMax=, labels, masks, and tests all key off one model.
+- Decision: We will drive v1 PASS/FAIL and =L_max= off WCAG contrast: default target =AA= (4.5), with =AAA= (7) selectable. APCA Lc is shown as a displayed diagnostic only and does not drive PASS/FAIL or the safe band in v1; an APCA-driven safety mode is vNext.
+- Consequences: easier — one metric for every safety surface and stable tests; harder — APCA's better text-on-color model is deferred, so a color that reads fine under APCA may still be flagged by WCAG.
+- Owner note: this and the foreground-set decision were Craig-owned open decisions; resolved here per the reviewer's recommendation to keep the spec converging. Craig can override either before implementation starts.
+
+* Implementation phases
+
+** Phase 1 — Ramp generator (pure)
+=ramp(baseHex, opts)= in app-core.js with Normal/Boundary/Error tests (mid base, near-white/near-black base, out-of-gamut request). Leaves the tree green; no UI yet.
+
+** Phase 2 — Ramp UI in the palette
+A base swatch → preview the ramp → add chosen steps as named palette entries. Reuses the palette panel and the OKLCH picker.
+
+** Phase 3 — Foreground-set + floor (pure)
+=fgSetFor=, =floor=, =lMax= in app-core.js with tests, including the keyword-blue worst case as a fixture.
+
+** Phase 4 — Worst-case readout
+For the v1 covered faces, the contrast cell shows the floor + the limiting foreground name instead of a single pair. Pinned readout shape, so =#contrasttest= asserts fields not punctuation: =worst: <limitingLabel> <limitingHex> — <ratio> <PASS|FAIL>= (example: =worst: keyword #67809c — 3.3 FAIL=). The no-foreground-set readout is exactly =no fg set=. Add a hash-gate (#contrasttest-style) pinning floor-over-set and the no-set string.
+
+** Phase 5 — Safe-lightness in OKLCH mode
+When a v1 covered face is open in the picker, mark L_max on the lightness slider and shade the unsafe band above it. v1 renders this as a *single L_max marker plus a one-band shade* (safe below, unsafe above) computed once per =(hue, chroma, fgSet, target)= via =lMax= — not a full per-pixel foreground-set contrast mask over the plane. The per-pixel AA/AAA plane mask stays single-foreground; extending it to a full foreground-set sweep is vNext if profiling ever shows the marker is insufficient.
+
+* Acceptance criteria
+- [ ] From a base hex, the tool produces N lighter + N darker steps, perceptually even, all in sRGB gamut.
+- [ ] Generated steps can be added to the palette as named entries.
+- [ ] A background-effect face shows the worst-case contrast and names the limiting foreground.
+- [ ] OKLCH mode marks the maximum safe lightness for the chosen hue/chroma given the foreground set + target.
+- [ ] Unit tests cover ramp generation, the floor, and L_max; a browser gate pins the worst-case readout.
+- [ ] =scripts/theme-studio/README.md= documents ramp controls and defaults, the worst-case-floor / limiting-foreground meaning, the v1 covered faces, and that WCAG drives PASS/FAIL with APCA shown as a diagnostic.
+
+* Readiness dimensions
+- Data model & ownership: ramp steps and tints are user-authored palette entries the designer adds; floor and L_max are computed live, not stored. Nothing new persists.
+- Errors, empty states & failure: an out-of-gamut step clamps and flags (a real but unrepresentable color); a malformed base hex can't be clamped, so =ramp= returns a structured =bad-hex= error and the row produces nothing rather than a garbage swatch; a covered face with no foreground set shows =no fg set= rather than a bogus ratio. All four core functions return structured results for bad user input instead of throwing. No silent data loss.
+- Security & privacy: N/A — local color math, no credentials or sensitive data.
+- Observability: the worst-case readout *is* the observability — the designer sees the floor and the bottleneck color directly.
+- Performance & scale: N/A meaningfully — tens of colors, instant; no long-running ops.
+- Reuse & lost opportunities: reuse colormath.js (=oklch2hex=/=contrast=/=apca=/=deltaE=), the OKLCH picker, and the AA/AAA mask. Don't reimplement color math.
+- Architecture fit & weak points: pure logic in app-core.js (tested, importable like the Stage-7 split); UI in app.js; integration points are the palette panel, the OKLCH picker, and the contrast cells of the v1 covered faces only (region, hl-line, highlight, lazy-highlight, isearch). Package and non-overlay UI cells are out of v1 scope and keep their single-pair behavior. Weak point: defining each face's foreground set — mitigated by the closed v1 covered-face set and explicit-state =fgSetFor=.
+- Config surface: =n= (steps each direction, default 2, range 1-4), =stepL= (OKLCH-L delta, default 0.08, range 0.04-0.12), =chromaEase= (default 0.5, range 0-1), and the contrast target (default WCAG AA 4.5, AAA 7 selectable). Defaults and safe ranges pinned in "Ramp defaults and palette insertion."
+- Documentation plan: the color-harmony explainer (=docs/design/theme-studio-color-harmony.org=, already a task) carries the method; this spec carries the build; =scripts/theme-studio/README.md= is the operational doc and gets the ramp controls, contrast-target semantics, covered faces, and worst-case-floor meaning (an acceptance-criteria item).
+- Dev tooling: =make theme-studio-test= covers it via new node tests + a browser gate; no new tooling.
+- Rollout, compatibility & rollback: additive — no change to the theme.json format or existing themes. The worst-case readout replaces a misleading single-pair number for background faces (a strict improvement). No migration, nothing to roll back.
+- External APIs & deps: none — pure color math, no external schema.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Chroma easing at the ramp extremes can go muddy or out-of-gamut — dodge by clamping and previewing every step before it's added.
+- The foreground-set definition is the fuzzy core. v1 limits it to code-context faces (syntax tokens + default fg); an over-broad set would over-constrain backgrounds that those foregrounds never actually touch.
+- A very dark foreground (sterling's keyword blue) can collapse the safe band to near-black. That's a true finding, not a tool bug — the readout should make the designer fix the foreground's lightness, not silently absorb it.
+
+* Review dispositions
+
+Only modified and rejected recommendations are listed; everything else from the Codex review was accepted as written and is woven into the body above.
+
+- *Modified — v1 covered-face set closed, not open-ended.* The review's suggested list ended with "and any other named built-in face the live buffer renders as text-over-syntax." Adopted the five named faces (region, hl-line, highlight, lazy-highlight, isearch) but dropped the open-ended trailing clause: an open set hands the implementer the same invent-the-behavior problem the foreground-set decision exists to close. The v1 set is exhaustive; new faces are added explicitly in vNext.
+- *Modified — invalid-input handling distinguishes user error from programmer error.* The review said "prefer pure functions returning structured errors rather than throwing." Accepted for user-entered values (malformed hex, out-of-range knobs) which return structured results; kept throwing for genuine programmer error (wrong argument arity/type), matching colormath.js's existing idiom rather than blanket no-throw.
+- *Deferred (not rejected) — implementation-task drop-in block.* The review supplied six ready TODOs. Per the spec-response workflow, implementation tasks are created only after the author confirms the spec is Ready (Phase 6), so they are not yet logged to todo.org. They land on the Ready go.
+
+* Review and iteration history
+** 2026-06-09 Tue @ 17:18:54 -0500 — Craig — author
+- What: initial draft.
+- Why: ramp generation and background-contrast safety are coupled, design-uncertain, and worth solving on paper before code.
+- Artifacts: this spec; the color-harmony explainer task in todo.org.
+** 2026-06-09 Tue @ 17:34:17 -0500 — Codex — reviewer
+- What changed or was recommended: reviewed the spec against the implementation context and marked it =Not ready=. Recommended pinning the v1 contrast target, accepting the foreground-set contract, narrowing the exact covered background faces, and specifying ramp defaults / palette insertion behavior.
+- Why: the current code architecture supports the feature, but the open metric and foreground-scope decisions would make implementation invent user-visible behavior and produce unstable tests.
+- Artifacts: theme-studio-palette-ramps-spec-review.org (consumed and deleted on response).
+** 2026-06-09 Tue @ 17:53:02 -0500 — Claude Code (dotemacs) — responder
+- What changed or was recommended: dispositioned every Codex finding. Resolved both open Decisions to accepted — contrast target = WCAG AA default (AAA selectable, APCA diagnostic only), v1 foreground set = distinct syntax hexes + default fg with locked roles excluded. Added "v1 covered faces" (closed five-face set), "Ramp defaults and palette insertion" (n/stepL/chromaEase defaults, naming, collision, clamp display), and "Function contracts" (explicit-state, structured-error signatures for ramp/fgSetFor/floor/lMax with edge cases). Pinned the worst-case readout string and no-set message, clarified Phase 5 as a single L_max marker + band shade, added a README acceptance-criteria item, and corrected the architecture/config readiness lines. Two findings modified, one deferred — see Review dispositions.
+- Why: the review's blockers were product-contract gaps (open metric, proposed foreground set, conflicting scope, underspecified ramp write) that would have made two correct implementations diverge. Pinning them converges the spec to implementation-ready.
+- Artifacts: review file deleted; Review dispositions section; this entry. Two Craig-owned open decisions resolved per reviewer recommendation, override available before implementation.
+** 2026-06-09 Tue @ 18:10:48 -0500 — Codex — reviewer
+- What changed or was recommended: re-reviewed the updated spec and found it implementation-ready. No new blocking review notes; the prior blockers are resolved in the accepted WCAG target, closed v1 face list, explicit foreground-set contract, ramp defaults/insertion rules, function contracts, readout strings, Phase 5 marker behavior, and README acceptance criterion.
+- Why: the updated spec now gives an implementer stable product behavior, testable pure-function contracts, and integration boundaries that match the current =scripts/theme-studio= architecture.
+- Artifacts: no new review file; this Ready verification entry.