aboutsummaryrefslogtreecommitdiff
path: root/docs
diff options
context:
space:
mode:
Diffstat (limited to 'docs')
-rw-r--r--docs/design/theme-studio-face-rules.org47
-rw-r--r--docs/theme-studio-color-families-spec.org202
-rw-r--r--docs/theme-studio-palette-ramps-spec.org219
3 files changed, 468 insertions, 0 deletions
diff --git a/docs/design/theme-studio-face-rules.org b/docs/design/theme-studio-face-rules.org
new file mode 100644
index 00000000..4eb3e1b3
--- /dev/null
+++ b/docs/design/theme-studio-face-rules.org
@@ -0,0 +1,47 @@
+#+TITLE: theme-studio face rules
+#+DATE: 2026-06-09
+
+Two kinds of rules govern a theme's face structure. They are different in kind and must be kept separate: Design Rules are the designer's taste and may change per theme; Fidelity Rules come from the principles and never change. A face's final structure is its defface baseline (Fidelity), with Design Rules applied deliberately on top.
+
+* Design Rules (personal, optional, per-theme)
+
+Aesthetic choices the designer makes. They override package/Emacs defaults on purpose and are applied consistently across a whole face family. They can change from theme to theme. The tool should let the designer declare them and flag where the theme breaks one (these are not bugs — they are the rule being enforced).
+
+Structural only (weight/slant/underline/box/overline/height). Color is the palette, decided separately.
+
+** D1 — Headings and titles are bold
+
+Every heading/title face carries =:weight bold=, overriding per-package size-only or plain conventions: =org-level-*=, =shr-h1=..=shr-h6=, =magit-*-heading=, =*-title=, =org-document-title=, =dashboard-heading=, =telega-*-title= / =telega-*-heading=, etc.
+
+Open question for dupre: does the rule mean *all* headings bold, or *headings get emphasis via bold OR descending size*? org-level-2..8 use size, not weight.
+
+dupre faces that break D1 (heading/title but not bold):
+- size-based (intentional? — org distinguishes levels by height): org-level-2, org-level-3, org-level-4, org-level-5, org-level-6, org-level-7, org-level-8
+- genuinely plain (no bold, no height): magit-blame-heading, magit-diff-hunk-heading, telega-msg-heading, telega-describe-subsection-title, telega-secret-title
+
+** D2 — Hyperlinks are underlined
+
+Every hyperlink face carries =:underline=, applied across packages: =link=, =org-link=, =shr-link=, =shr-selected-link=, =mu4e-link-face=, =telega-link*=, etc. (Symlinks and link-count faces are not hyperlinks and are exempt.)
+
+dupre faces that break D2 (hyperlink but not underlined):
+- telega-link, telega-link-preview-sitename, telega-link-preview-title, telega-webpage-chat-link
+
+* Fidelity Rules (principle-derived, mandatory, theme-independent)
+
+Correctness and honesty invariants. They do not change between themes. A violation is a bug, not a preference.
+
+** F1 — Preview only what the theme controls
+
+Every element a preview draws must correspond to a real face the generated theme exports. No hardcoded decoration that implies theme control (this is why the mode-line box became a real =:box= attribute instead of a painted-on bevel, and why the fg/bg contrast cell must rate the face's own pair). Representational stand-ins are allowed only for theme-controlled *colors* whose shape/presence Emacs controls elsewhere — e.g. the cursor drawn as a box (the color is the =cursor= face; the shape is =cursor-type=), the fringe indicator (the color is the =fringe= face; the arrow's presence is truncation state).
+
+** F2 — Render the way Emacs renders
+
+A face is drawn the way Emacs would draw it. Overlay-style faces (region, highlight, isearch, lazy-highlight) merge like Emacs: the background applies and the foreground falls through to the underlying syntax colors unless the face sets its own. The block cursor sits on a glyph in the frame background over the cursor color. Every modeled attribute (weight/slant/underline/strike/box/height) actually renders, in both the table preview and the live buffer.
+
+** F3 — Preserve each face's defface structural baseline
+
+A face's own defface structural attributes (weight/slant/underline/box/overline/height/inherit) carry through into the theme's default for that face, except where a Design Rule deliberately overrides. An accidental drop — e.g. replacing =:inherit link= with a bare foreground and losing the underline — is a bug. For Emacs's built-in faces the baseline is verified against =emacs -Q= (error/warning/success bold; link, lazy-highlight, show-paren-match underline); for package faces, against the package's defface source.
+
+** F4 — Reference only real faces
+
+Every face the theme sets or previews must exist in Emacs. A face the theme defines that no package defines (a typo, a renamed/obsolete face) controls nothing and shows a phantom sample in the preview; it is removed. (This took out 11 dead mu4e faces.)
diff --git a/docs/theme-studio-color-families-spec.org b/docs/theme-studio-color-families-spec.org
new file mode 100644
index 00000000..ce3b7a9f
--- /dev/null
+++ b/docs/theme-studio-color-families-spec.org
@@ -0,0 +1,202 @@
+#+TITLE: theme-studio Color Families — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-09
+
+* Metadata
+| Status | Ready (Craig confirmed 2026-06-10); review incorporated, hex grouping |
+| Owner | Craig |
+| Reviewer | Codex |
+| Related | [[file:../todo.org][todo.org: theme-studio color families]] |
+
+* Summary
+
+Show the palette as color families: colors grouped into horizontal strips by their actual color (OKLCH hue), each strip ordered dark to light, with a per-strip control to generate a symmetric tonal ramp (N gives base ±N) from the strip's most-saturated color. Grouping is derived from the hex every render, so renaming a color to anything never moves it between strips. The flat palette underneath stays exactly what it is today — an editable list — and families are a view over it, not a new owner of the colors.
+
+* Problem / Context
+
+The ramp generator (palette-ramps v1) can produce blue-2, blue-1, blue, blue+1, blue+2, but the moment they land in the palette the relationship is gone: five chips in a wrapping row, sorted by nothing. To widen or narrow the ramp you delete and regenerate; to recolor it you edit each chip. Nothing shows the structure the ramp math produced.
+
+The designer thinks in families — "the blues", "the warm grays" — and wants to grow or shrink a ramp in place. Critically, the family a color belongs to is a fact about the *color*, not its label: renaming blue+1 to "azure" or "my favorite" must never change which group it sorts into. So grouping has to come from the hex, not from a naming convention.
+
+* Goals and Non-Goals
+
+** Goals
+- Group the palette into families by OKLCH hue (from the hex), and render each as a horizontal strip, dark to light.
+- Per-strip control to generate a symmetric ramp (N gives base ±N) from the strip's base; regenerate is authoritative.
+- Renaming any color never changes its family or sort position.
+- Sort families by hue across the panel and colors by lightness within; pin the fg/bg ground strip and neutral grays to the front.
+
+** Non-Goals
+- No name-based grouping, no step-name grammar, no import inference from names — grouping is purely by hex.
+- No theme.json format change — the palette stays a flat, ordered, individually-editable list.
+- No preserving hand-edits to generated steps across a regenerate (regenerate overwrites — Craig's call).
+- No asymmetric ramps, no per-family stepL/chroma-ease, no harmonic fill, no manual family ordering in v1.
+
+** Scope tiers
+- v1: hue-grouped family strips over the existing flat palette; per-strip ramp-generate control; ground strip from the bg/fg assignments; hue/lightness sort with neutrals pinned; the family control replaces the standalone ramp panel.
+- Out of scope: per-family stepL/chroma-ease, harmonic fill, asymmetric ramps, manual ordering.
+- vNext: tunable clustering, per-family knobs, asymmetric ramps.
+
+* Design
+
+*The palette stays flat.* This is the pivot from the first draft. The palette remains the flat =[[hex,name]]= list it is today, with the same per-chip operations — edit hex, rename, remove. Families do *not* own the colors. A "family" is a derived grouping computed from the hexes on every render, like sorting. Because it's derived from the hex, renaming a color can't change its group, there's no structure to store, and there's nothing to reconstruct on import. Most of what the first-draft review flagged (name grammar, import inference, chip-ownership transfer) simply doesn't exist in this model.
+
+*Grouping by hue.* Convert each palette color to OKLCH. A color whose chroma is below a neutral threshold (C < 0.02) has no meaningful hue; it joins the neutral group. The rest cluster by hue *proximity*, not fixed bins: sort by hue, walk the circle, and start a new family wherever the gap to the previous color exceeds a gap threshold (25°), wrapping at 360. Two blues at 250° and 256° stay together; a blue and a green a hue-gap apart split. Each family's *base* is its most-saturated member (tie-break toward mid-lightness), the natural anchor for a ramp.
+
+*The ground strip.* The foreground and background are the =bg= and =p= (plain/default-fg) assignments — =MAP.bg= and =MAP.p= — not palette names. The ground strip is synthesized from those two hexes and pinned first, even if neither hex is a palette entry (an imported theme can set them to colors absent from the palette). Editing a ground swatch writes the assignment hex (and the matching palette entry, if one exists at that hex). Neutral grays (low chroma, not the ground pair) form their own strip(s) pinned after the ground.
+
+*The ramp-generate control.* Each strip carries a count input. Its value reflects the family's current per-side reach (the count of members on the busier side of the base by lightness). Setting it to N regenerates the family as a clean symmetric ramp: =ramp(base, {n:N, stepL, chromaEase})= for N≥1, or just the base for N=0, *replacing* the family's current members. Regenerate is authoritative — same-hue colors that were there, hand-added or hand-edited, are replaced. This is the one family-level action; everything else is still per-chip.
+
+*References across a regenerate.* A regenerate changes member hexes, and assignments point at hexes. Map old members to new steps by lightness rank: an old member and the new step at the same signed offset are the "same" position, so repoint references old→new for every surviving position. A position *removed* by lowering N (its old hex has no new counterpart) leaves its references showing "(gone)" — a visible stale reference the designer can re-point, never a silent jump to a surprise color (the review's robustness point). =repointHex= already does the old→new sweep across syntax/UI/package assignments.
+
+*Sorting is display-only.* Strips order by base hue (ground first, then neutrals, then chromatic families by hue; ties by base lightness then hex). Within a strip, colors order by OKLCH lightness. The stored palette keeps its existing order — export emits it unchanged — so theme.json diffs stay deterministic and the sort never rewrites the file.
+
+The two altitudes:
+- *For the designer:* the palette is a stack of strips, one per hue family, each dark to light with the base marked, ground pinned at the top. A count input on each strip fans it to ±N or collapses it to the base. Rename a swatch to anything — it stays put. Find "the blues" by finding the blue strip.
+- *For the implementer:* pure functions in app-core.js — =familiesFromPalette(palette, groundHexes)= returns the ground strip plus hue-clustered families with a base each; =sortFamilies= orders them; =regenFamily(baseHex, n, opts)= returns the ramp members (handling n=0 without calling =ramp()=); =stepRepointPlan(oldMembers, newMembers)= returns the old→new map and the removed set. The DOM (strip rendering, the count control, calling =repointHex= per the plan) stays in app.js. The existing =ramp()= and =repointHex()= are reused unchanged.
+
+* Alternatives Considered
+
+** Hex-derived families over a still-flat palette (chosen)
+- Good, because grouping from the hex is rename-proof, needs no stored structure, and requires no import inference — the first draft's hardest contracts vanish.
+- Good, because the palette stays individually editable, so per-chip rename/remove/edit keep working unchanged.
+- Bad, because hue clustering has a threshold; an awkward gap can split a family the eye reads as one, or merge two.
+- Neutral, because regenerate is still authoritative, so generating a ramp over a hue cluster replaces whatever was there.
+
+** Name-derived families (first draft, rejected)
+- Bad, because the group would depend on the label; renaming blue+1 would move it, which Craig explicitly ruled out. It also needs a brittle step-name grammar and import inference (the review's top blocker).
+
+** Families as a stored structure in theme.json
+- Good, because base/N round-trip exactly.
+- Bad, because it's a format change with a migration, and every flat-palette consumer (build-theme.el, the assignments) would need the new shape.
+
+** Hue clustering by fixed bins vs. proximity gaps (gaps chosen)
+- Good (gaps), because nearby hues group regardless of where they fall, with no arbitrary bin edge splitting a near-pair.
+- Bad (gaps), because a chain of evenly-spaced hues never breaks; mitigated by the gap threshold and the small palette size.
+
+** Removed-step references: visible "(gone)" vs. silent repoint (gone chosen)
+- Good (gone), because a stale reference is visible and recoverable; a silent jump to an unexpected swatch is worse (the review's robustness note).
+- Bad (gone), because the designer must re-point manually after shrinking a family that something referenced.
+
+* Decisions
+
+** Group by OKLCH hue from the hex, never by name
+- State: accepted
+- Context: the family is a fact about the color; renaming must not move it. The first-draft name convention failed this and needed a grammar + import inference.
+- Decision: We will derive families by clustering palette colors on OKLCH hue every render; names are labels only and never affect grouping or sort.
+- Consequences: easier — rename-proof, no grammar, no import inference, no stored structure; harder — clustering needs a tuned hue-gap threshold.
+
+** The palette stays flat and individually editable; families are a derived view
+- State: accepted
+- Context: making families own the colors would transfer ownership away from the per-chip controls and force an import/edit/delete contract.
+- Decision: We will keep =PALETTE= a flat editable list with its current per-chip rename/remove/edit; families are computed for display, not stored.
+- Consequences: easier — existing controls and export are untouched, no migration; harder — the count control is the only family-level action, so "regenerate" must reconcile with loose hand-added colors (it replaces them).
+
+** A family generates a symmetric ramp: base ±N (0-4)
+- State: accepted
+- Context: the designer asked for a per-side count where 2 means ±2 and 3 means ±3.
+- Decision: We will give each strip a count input; N=0 is the base alone, N=k generates base-k..base+k via =ramp()=; =regenFamily= handles N=0 without calling =ramp()= (which clamps to 1-4).
+- Consequences: easier — one control, predictable; harder — asymmetric reach isn't expressible in v1.
+
+** Regenerate is authoritative; repoint survivors by lightness rank, removed steps go "(gone)"
+- State: accepted
+- Context: regenerate changes member hexes; references point at hexes; lowering N drops the extremes.
+- Decision: We will replace the family's members on regenerate, repoint references for each surviving position (matched by signed lightness rank) old hex → new hex via =repointHex=, and leave references to removed positions as a visible "(gone)" rather than reassigning them.
+- Consequences: easier — surviving references follow, removed ones are recoverable; harder — a hand-edited step hex and a reference to a dropped step are both lost/stale (accepted; the latter is visible).
+
+** The ground strip is synthesized from the bg/fg assignments
+- State: accepted
+- Context: fg/bg are the =MAP.bg= / =MAP.p= assignments, which may be hexes not present in the palette.
+- Decision: We will synthesize a fixed two-swatch ground strip from =MAP.bg= and =MAP.p=, pinned first, no count control; editing a ground swatch writes the assignment hex (and the matching palette entry if one exists). A palette chip whose hex equals the ground hex is shown in the ground strip, not duplicated into a family.
+- Consequences: easier — the ground is explicit and protected, imported themes with assignment-only ground colors work; harder — the strip is a special case, and ground-vs-family de-duplication is by hex.
+
+** Sort families by hue, colors by lightness, neutrals pinned; thresholds pinned
+- State: accepted
+- Context: ordering should come from the hex; clustering and neutral detection need fixed values so tests don't bake accidental ones.
+- Decision: We will sort strips ground-first, then neutral (C < 0.02) strips, then chromatic families by base hue (ties by base lightness then hex); colors within a strip by OKLCH lightness; hue clustering splits on a 25° adjacent-hue gap. Sorting is display-only; the stored palette order is unchanged on export.
+- Consequences: easier — deterministic spectrum order, stable theme.json diffs; harder — manual drag-ordering is dropped, and the thresholds are tuning the eye may want to revisit (vNext).
+
+** The family count control replaces the standalone ramp panel
+- State: accepted
+- Context: the v1 ramp panel and the per-strip count do the same job two ways.
+- Decision: We will remove the standalone ramp panel; fanning a color into a ramp happens from its strip (add a color → it appears as a singleton strip → raise its count).
+- Consequences: easier — one way to ramp, less UI; harder — churn over the few-day-old ramp panel, and "ramp from an arbitrary typed hex" now means "add the color first, then fan it".
+
+* Implementation phases
+
+** Phase 1 — Family model (pure)
+=familiesFromPalette(palette, groundHexes)=, =regenFamily(baseHex, n, opts)=, =stepRepointPlan(oldMembers, newMembers)= in app-core.js. =familiesFromPalette= returns the ground strip (from groundHexes, de-duped by hex) plus hue-clustered families each with a base. Tests: a spectrum splits into families, a near-pair stays together, neutrals separate, ground hexes absent from the palette still form the strip, =regenFamily= handles n=0 (base only) and n≥1 (ramp), =stepRepointPlan= maps survivors and lists removed. No UI.
+
+** Phase 2 — Sort (pure)
+=sortFamilies= orders ground-first, neutrals (C<0.02) next, chromatic by hue with the lightness/hex tie-breakers; a helper orders colors within a strip by lightness. Tests cover a spectrum, an all-neutral set, ties, and the 25° gap boundary.
+
+** Phase 3 — Strip rendering (read-only)
+Render the palette panel as the pinned ground strip plus hue-sorted family strips, base marked, dark to light. Reuse chip styling; the existing per-chip controls (rename/remove/edit) keep working since the palette is still flat. No count control yet.
+
+** Phase 4 — Count control + regenerate
+A per-strip count input (0-4). On change, =regenFamily=, apply =stepRepointPlan= (repoint survivors via =repointHex=, leave removed references "(gone)"), update =PALETTE=, re-render. Browser gate: count up adds symmetric steps, count down drops the extremes and a reference to a dropped step reads "(gone)", a reference to a surviving step follows the new hex.
+
+** Phase 5 — Ground strip + base edit + retire the ramp panel
+Synthesize the ground strip from =MAP.bg= / =MAP.p=, editable, pinned, de-duped. Editing a family's base regenerates it (same repoint plan). Remove the standalone ramp panel and its gate; adding a color yields a singleton strip that fans via its count. Gate the ground-strip derivation (including assignment-only ground hexes) and the base-edit repoint.
+
+** Phase 6 — Warnings, seeding, export, README
+Keep =paletteWarnings= on the flattened palette but exempt adjacent same-family ramp steps from the too-similar warning (they're intentionally close). Confirm package seeding still reads the flat palette (families are display-only, so =seedPkgmap= is unchanged). Confirm export emits the flat palette unchanged and import needs no reconstruction. Update README. Gate an import → render → export round-trip leaving the palette JSON identical.
+
+* Acceptance criteria
+- [ ] The palette renders as hue-grouped strips, base marked, dark to light, ground pinned first.
+- [ ] Renaming any color to any string never changes its strip or sort position.
+- [ ] A per-strip count control sets base ±N live; raising it adds symmetric steps, lowering removes the extremes.
+- [ ] On regenerate, references to surviving steps follow the new hex; references to removed steps read "(gone)", never a silent reassignment.
+- [ ] The ground strip is synthesized from the bg/fg assignments (even when those hexes aren't palette entries), pinned first, with no count control; a palette chip at a ground hex isn't duplicated.
+- [ ] Families sort by base hue (ground and neutrals pinned); colors sort dark to light; the stored palette order is unchanged on export.
+- [ ] Existing flat theme.json files load and re-export byte-stable through the family view.
+- [ ] Unit tests cover the family model, regen, repoint plan, and sort; browser gates cover the count control (up/down + removed-step "(gone)"), the ground strip, the base-edit repoint, and the round-trip.
+- [ ] README documents families, the ground strip, regenerate behavior, removed-step references, and the removal of the standalone ramp panel.
+
+* Readiness dimensions
+- Data model & ownership: the flat palette stays the persisted, individually-editable truth; families are a derived display grouping computed from the hex each render; nothing new on disk; the ground strip reads/writes the bg/p assignments.
+- Errors, empty states & failure: a malformed chip hex is excluded from clustering (no OKLCH) and shown as a loose/neutral entry rather than crashing the panel; a regenerate of a bad base yields nothing for that family (per =ramp='s bad-hex); an empty palette shows only the ground strip; references to removed steps degrade to a visible "(gone)", never silent.
+- Security & privacy: N/A — local color math.
+- Observability: the strips are the observability — grouping, ramp reach, and the base are visible; "(gone)" surfaces stranded references.
+- Performance & scale: tens of colors, a few families; clustering is one sort + a linear pass, regenerate is one =ramp()= + a repoint sweep. Instant.
+- Reuse & lost opportunities: reuse =ramp()=, =repointHex()=, the chip styling, =optList= (dropdowns still read the flat palette), and colormath OKLCH. Don't reimplement the ramp or the re-point.
+- Architecture fit & weak points: pure family logic in app-core.js (importable, tested like the ramp core); strip DOM in app.js; integration points are the palette panel, the assignment dropdowns (unchanged, still read the flat palette), the bg/p assignments (ground strip), and export/import (unchanged). Weak point: the hue-gap and neutral thresholds — pinned defaults, tunable in vNext.
+- Config surface: per-family N (0-4); hue-gap (25°) and neutral-chroma (0.02) thresholds and stepL/chroma-ease (0.08/0.5) are pinned constants in v1.
+- Documentation plan: the README grows a "color families" section; the color-harmony explainer carries the why.
+- Dev tooling: =make theme-studio-test= covers it via new node tests + browser gates; no new tooling.
+- Rollout, compatibility & rollback: additive and display-only over the existing palette; theme.json unchanged, so old themes load and re-export stable; rollback is reverting the panel; no migration.
+- External APIs & deps: none — pure color math.
+
+* Risks, Rabbit Holes, and Drawbacks
+- The hue-gap threshold is the fuzzy core now (not name inference): an awkward gap can split a family the eye reads as one, or merge two adjacent hues. Dodge: a sane 25° default, small palettes, and a vNext tuning knob.
+- Regenerate-authoritative replaces loose same-hue colors the designer may have hand-placed in that hue band. Accepted per the regenerate decision, but the strip UI should make "this control rewrites the family" obvious before it's used.
+- Removed-step references going "(gone)" is the deliberate, recoverable choice; the risk is forgetting to also repoint survivors on the base-edit path (not just the count path). Both paths run the same repoint plan.
+- Retiring the just-shipped ramp panel is churn; the count control must cover the same discovery path (add a color, fan it from its strip).
+
+* Review dispositions
+
+Only modified and rejected review recommendations are listed; everything else from the Codex review is accepted and folded into the body above.
+
+- *Rejected as framed — "family inference from flat palette names" / the name-grammar section.* Craig's directive is to group by hex, never by name, so renaming is free. There is no step-name grammar and no import inference to specify; the review's underlying need (a deterministic grouping contract) is met instead by the hue-clustering contract (25° gap, 0.02 neutral threshold). The whole name-parsing surface is designed out.
+- *Modified — "chip-level edits need a new ownership contract".* Accepted the concern, changed the resolution: rather than defining transfer of ownership to family objects, the palette stays flat and individually editable, so per-chip rename/remove/edit keep working as today. Only drag/move-reorder is dropped (the sort is deterministic). The count control is the sole family-level action.
+- *Modified — "two Decisions open/proposed".* Both resolved per Craig: flat persistence accepted, the standalone ramp panel is removed (not left for Phase 6).
+- *Accepted with a change of mechanism — ground strip, removed-step policy, n=0, neutral threshold, sort tie-breakers, palette warnings, package seeding, README in acceptance criteria.* All folded as written, adapted to the flat-palette/hex-grouping model (e.g. seeding is unchanged because the palette is still flat).
+- *Deferred — the review's implementation-task drop-in block.* Per spec-response, tasks are created only after the author confirms Ready (Phase 6); they are not yet logged.
+
+* Review and iteration history
+** 2026-06-09 Tue @ 22:50:43 -0500 — Craig — author
+- What: initial draft (name-derived families owning the palette).
+- Why: live color families restructure the palette panel and supersede part of the shipped ramp UI — design-uncertain and cross-cutting.
+- Artifacts: this spec; the palette-ramps v1 build it builds on.
+** 2026-06-09 Tue @ 23:31:28 -0500 — Codex — reviewer
+- What changed or was recommended: marked =Not ready=; flagged two open decisions, an underspecified name/import inference contract, the ground-strip source of truth, chip-level ownership in family mode, and removed-step assignment behavior.
+- Why: the first draft moved palette ownership to name-derived families without pinning import, edit, delete, and reference-healing behavior.
+- Artifacts: theme-studio-color-families-spec-review.org (consumed and deleted on response).
+** 2026-06-09 Tue @ 23:40:35 -0500 — Claude Code (dotemacs) — responder
+- What changed or was recommended: folded the Codex review and Craig's three clarifications. Resolved both open decisions (flat persistence; remove the standalone ramp panel). Pivoted the whole model from name-derived families to hex-derived families over a still-flat, still-editable palette — which designs out the name-grammar/import-inference blocker and the chip-ownership blocker. Pinned the ground strip to the bg/p assignments, the removed-step references to a visible "(gone)", n=0 handled without =ramp()=, the 0.02 neutral and 25° hue-gap thresholds, the sort tie-breakers, the warnings exemption, and the unchanged package seeding. See Review dispositions for the two reframed findings.
+- Why: Craig's "group by hex, never by name; rename anything freely" both answers the review's hardest questions and simplifies the design — the palette no longer changes ownership, so most of the missing contracts disappear.
+- Artifacts: review file deleted; Review dispositions section; this entry. Remaining fuzzy area is the hue-clustering threshold (pinned default, vNext tuning).
+** 2026-06-10 Wed @ 00:03:39 -0500 — Codex — reviewer
+- What changed or was recommended: re-reviewed the updated spec and found it implementation-ready. No new blocking review notes; the prior blockers are resolved by the hex-derived grouping model, accepted flat-palette persistence, accepted ramp-panel removal, explicit ground-strip source, retained per-chip flat-palette ownership, removed-step "(gone)" policy, pinned clustering/sort thresholds, and README/test acceptance criteria.
+- Why: the updated design now gives the implementer stable behavior for grouping, regeneration, references, ground colors, import/export, and UI ownership while fitting the current =app-core.js= / =app.js= split.
+- Artifacts: no new review file; this Ready verification entry.
diff --git a/docs/theme-studio-palette-ramps-spec.org b/docs/theme-studio-palette-ramps-spec.org
new file mode 100644
index 00000000..849fea0f
--- /dev/null
+++ b/docs/theme-studio-palette-ramps-spec.org
@@ -0,0 +1,219 @@
+#+TITLE: theme-studio Palette Ramps & Background-Contrast Safety — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-09
+
+* Metadata
+| Status | draft — review incorporated (Codex, 2026-06-09) |
+| Owner | Craig |
+| Reviewer | Codex |
+| Related | [[file:../todo.org][todo.org: theme-studio color-harmony explainer + ramp/fill features]] |
+
+* Summary
+
+Give theme-studio two things it lacks: a generator that turns one base color into a harmonized tonal ramp (base, plus lighter and darker steps), and a readout that tells the designer whether a color is safe to use as a background behind editor text. Both rest on the same OKLCH math worked out 2026-06-09, and they're coupled: the dark end of a ramp is where background tints come from, and a background tint is only usable if it keeps every foreground on it readable.
+
+* Problem / Context
+
+Building a theme, the designer needs a family of related shades per hue (blue, blue+1, blue-1, and so on) and a set of dim tinted backgrounds for effects like highlight, region, isearch, and hl-line. Today every shade is hand-picked by eye. Getting them to harmonize is guesswork, and there's no signal for the harder half of the problem: a background effect is an overlay, so the same tint sits behind many different foreground colors at once. A tint that looks fine against the default text can fail against the darkest token. The designer can't see that failure, because the picker shows one fg-on-bg pair, which is the wrong number.
+
+The cost is concrete. sterling's keyword blue (#67809c, OKLCH-L 0.59) is the darkest foreground; it barely clears WCAG AA on pure black and drops below AA the instant any highlight background lifts off black. So one color silently caps every highlight to near-black, and nothing in the tool surfaces it. The designer either over-darkens everything defensively or ships unreadable highlights without knowing.
+
+* Goals and Non-Goals
+
+** Goals
+- From a base color, generate its tonal ramp (base, +1..+N lighter, -1..-N darker) that harmonizes by construction.
+- For any face used as a background, show the worst-case contrast across the foregrounds that can land on it, and name the limiting foreground.
+- Guide the usable background lightness so a generated or chosen background keeps all text readable.
+
+** Non-Goals
+- Not auto-assigning ramp steps to faces — the designer picks which step goes where.
+- Not the harmonic-fill feature (generate a whole palette from a few seed colors). Separate, deferred.
+- Not a new color model or palette format — OKLCH mode and the palette already exist; this extends them.
+
+** Scope tiers
+- v1: ramp generation from a base; worst-case-contrast readout + safe-lightness guidance for background-effect faces.
+- Out of scope: harmonic fill; auto-assignment of steps to faces.
+- vNext: harmonic fill (palette from seeds); richer per-face foreground-set detection.
+
+* Design
+
+The two features share OKLCH (perceptually uniform, so lightness / chroma / hue move independently) and reuse colormath.js, which already has =oklch2hex=, =contrast=, =apca=, and =deltaE=.
+
+*Ramp generation.* A ramp is one hue at many lightnesses. Convert the base to OKLCH, hold the hue fixed, and step lightness by a fixed perceptual delta per stop — lighter for +N, darker for -N. Chroma eases toward zero at the extremes (a near-white or near-black step carries almost no color), and every step is clamped back into sRGB. The harmony is structural: the steps share a hue and sit on an even lightness ladder, so they read as one family rather than a grab-bag. The output is a row of hexes the designer can name (=blue+1=, =blue-2=) and drop into the palette.
+
+*Background-contrast safety.* A background tint is only as good as its worst case. For a face used as a background, define its foreground set — the colors that actually render on top of it. For a code-context face (region, hl-line, isearch, highlight, lazy-highlight), that set is the syntax token colors plus the default foreground. The floor is the minimum, over that set, of =contrast(fg, candidate-bg)=; the limiting foreground is the argmin. From the floor we derive L_max: the lightest background, at the chosen hue and chroma, whose floor still clears the target (WCAG AA/AAA, or an APCA Lc). Sweep lightness to find it — deterministic.
+
+The two altitudes:
+- *For the designer:* pick a base swatch, see its ramp, add the steps you want. When editing a background-effect face, the contrast cell shows the worst case — "worst: keyword #67809c — 3.3 FAIL" — not a misleading single pair, and OKLCH mode marks the safe-lightness ceiling so you can't unknowingly cross it.
+- *For the implementer:* =ramp(baseHex, {n, stepL, chromaEase})= → =[hex]=; =fgSetFor(face, state)= → =[hex]=; =floor(bgHex, fgSet)= → ={ratio, limitingHex}=; =lMax(hue, chroma, fgSet, target)= → =L=. All pure, all in app-core.js / colormath.js, all unit-tested. Function contracts (inputs, outputs, validation, edge cases) are pinned below.
+
+* v1 covered faces
+
+v1 computes a worst-case floor for a *closed, enumerated* set of code-overlay faces — backgrounds the live buffer renders syntax-colored text over:
+
+- =region=
+- =hl-line=
+- =highlight=
+- =lazy-highlight=
+- =isearch=
+
+That set is exhaustive for v1. Other overlay faces (=secondary-selection=, =isearch-fail=, and any future built-in) are vNext, added explicitly rather than matched by a heuristic. The deliberately closed list is what makes the contract testable: an open "any face the buffer renders as text-over-syntax" rule would hand the implementer the same invent-the-behavior problem the foreground-set decision exists to close.
+
+Everything outside this set keeps its existing single-pair contrast cell:
+
+- *Package contrast cells* stay single-pair in v1. Package faces own their foregrounds through inheritance, which needs a per-preview foreground-set model that doesn't exist yet (vNext).
+- *Non-overlay UI rows* (mode-line, fringe, and the rest) stay single-pair — their foreground set isn't a syntax palette.
+
+A covered face whose foreground set resolves empty (no syntax assignments yet) shows the no-set readout (below), not a bogus ratio.
+
+* Ramp defaults and palette insertion
+
+*Generation defaults* (all exposed as controls, these are the starting values):
+- =n= (steps each direction): default =2= → base plus +1,+2,-1,-2. Safe range 1-4.
+- =stepL= (OKLCH-L delta per step): default =0.08=. Safe range 0.04-0.12.
+- =chromaEase= (fraction of chroma removed at the farthest step, eased toward the extremes): default =0.5=. Range 0-1; 0 holds chroma flat, 1 fully desaturates the last step.
+
+*Preview and insertion:*
+- The ramp previews as a row ordered darkest → lightest (=-n .. base .. +n=), the base marked.
+- Each generated swatch shows a clamp badge when =oklch2hex= reports =clamped= true, so the designer sees an out-of-gamut step before adding it.
+- The base is preview-only by default (it's the source swatch, already in the palette); the designer may opt to add it.
+- Selected steps insert adjacent to the source swatch, in =-n .. +n= order.
+
+*Naming:* step names derive from the source swatch name — base =blue= → =blue+1=, =blue+2=, =blue-1=, =blue-2=. If the source swatch is unnamed, names fall back to a hex-based label the designer can edit before add.
+
+*Collisions — never silent:*
+- *Name collision* (=blue+1= already exists): the row is flagged and the designer renames before add; no overwrite.
+- *Hex collision* (the generated hex already exists under another name): flagged as a duplicate, add still allowed (two names for one hex is legal in the palette).
+
+* Function contracts
+
+All four are pure, live in app-core.js (or colormath.js for the color math), take explicit state — never read globals — and validate user input by *returning a structured result*, not throwing. Throwing is reserved for genuine programmer error (wrong argument arity/type), not for malformed user-entered values.
+
+- =ramp(baseHex, {n, stepL, chromaEase})= → ={steps: [{hex, clamped}], error?}=. Validates: malformed =baseHex= (not a parseable hex — can't be clamped) → ={steps: [], error: 'bad-hex'}=; =n= outside 1-4 or non-integer → clamped into range with a flag; =stepL=/=chromaEase= outside range → clamped with a flag. Holds hue, steps OKLCH-L by =stepL=, eases chroma toward the extremes, gamut-clamps every step and reports per-step =clamped=.
+- =fgSetFor(face, state)= → ={set: [hex], error?}=, where =state= is explicit slices ={syntaxAssignments, palette, defaultFg, locks}=. Returns the distinct syntax-assignment hexes plus =defaultFg=, excluding locked background-only roles. A face outside the v1 covered set → ={set: [], reason: 'out-of-scope'}=. No syntax assignments → ={set: [], reason: 'empty'}=.
+- =floor(bgHex, fgSet)= → ={ratio, limitingHex, limitingLabel}=. =ratio= is the minimum WCAG contrast over =fgSet= against =bgHex=; =limitingHex= is the argmin; =limitingLabel= is its role/palette/hex name. Empty =fgSet= → ={ratio: null, limitingHex: null}= (caller shows the no-set readout).
+- =lMax(hue, chroma, fgSet, target)= → ={L, status}=. Binary-searches OKLCH-L (tolerance 0.001) for the lightest background at =hue=/=chroma= whose =floor= still clears =target=. =status= is =ok= (an L found), =none= (no L satisfies the target — every background fails), =all= (every L satisfies it — no ceiling needed), or =clamp= (the chroma clamps before the target is reached; returns the L at the clamp boundary).
+
+* Alternatives Considered
+
+** Ramp stepping in OKLCH lightness (chosen)
+- Good, because steps are perceptually even and the hue holds, so the family looks deliberate.
+- Bad, because extreme steps can leave the sRGB gamut and need clamping.
+- Neutral, because it depends on colormath's OKLCH path, which already exists.
+
+** Ramp stepping in HSL/HSV
+- Bad, because HSL/HSV aren't perceptually uniform — equal numeric steps give uneven visual steps and the hue drifts at the light/dark ends.
+
+** Ramp by interpolating base↔white and base↔black
+- Good, because it's trivial to implement.
+- Bad, because lightness and chroma drift together unpredictably and the hue can shift, so the steps don't harmonize reliably.
+
+** Worst-case shown as one readout + limiting fg (chosen), with an OKLCH picker mask
+- Good, because one honest number (the floor) plus the name of the bottleneck is the actionable lever, and the mask shows the safe band visually.
+- Neutral, because it replaces the existing single-pair contrast cell for background faces only.
+
+** Worst-case shown as a full per-foreground heatmap
+- Bad, because it's noise — the designer needs the floor and the one color setting it, not twenty ratios.
+
+* Decisions
+
+** Work in OKLCH for ramps and tints
+- State: accepted
+- Context: ramps and background tints both need even, hue-stable steps; the tool already exposes OKLCH.
+- Decision: We will compute ramps and the contrast floor in OKLCH via colormath.js.
+- Consequences: easier — even families, reuse of existing math; harder — must gamut-clamp every generated step.
+
+** Ramp = fixed lightness step + chroma ease on a held hue
+- State: accepted
+- Context: alternatives drift hue/chroma; uniform L steps read as a ladder.
+- Decision: We will step lightness by a fixed delta, ease chroma toward the extremes, and hold the hue.
+- Consequences: easier — predictable families; harder — chroma easing needs tuning so mid steps don't go muddy.
+
+** Background safety = worst-case floor over a per-face foreground set
+- State: accepted
+- Context: a single fg-on-bg pair misleads; overlays carry many foregrounds.
+- Decision: We will compute the floor over a face's foreground set and surface the floor + limiting foreground.
+- Consequences: easier — the real constraint is visible; harder — we must define each face's foreground set.
+
+** v1 foreground set for code-overlay faces = syntax tokens + default fg
+- State: accepted
+- Context: the exact face→context mapping is fuzzy; code-overlay faces clearly carry the syntax palette. The reviewer flagged that an open ("proposed") contract forces the Phase 3 implementer to invent what counts as foreground text.
+- Decision: We will scope v1 to the closed code-overlay face set (see "v1 covered faces"), and define the foreground set as the distinct hexes of the syntax-assignment colors plus the default foreground (=fg=). Duplicate hexes collapse to one entry. Locked structural colors (=bg= and any palette entry flagged as a background-only role) are excluded from the set. The limiting foreground is labeled by its syntax role name when one exists, else its palette name, else its hex.
+- Consequences: easier — a pinned, enumerable contract the implementer and tests can rely on; harder — UI/package overlays need a later per-preview foreground-set model (vNext).
+
+** Contrast target for the floor = WCAG AA default, AAA optional, APCA diagnostic
+- State: accepted
+- Context: AA (4.5) is the floor most reach for; AAA (7) is the stricter option; APCA models text-on-color better than WCAG but needs a chosen Lc and signed/absolute handling. The reviewer recommended a WCAG-only v1 so =floor=, =lMax=, labels, masks, and tests all key off one model.
+- Decision: We will drive v1 PASS/FAIL and =L_max= off WCAG contrast: default target =AA= (4.5), with =AAA= (7) selectable. APCA Lc is shown as a displayed diagnostic only and does not drive PASS/FAIL or the safe band in v1; an APCA-driven safety mode is vNext.
+- Consequences: easier — one metric for every safety surface and stable tests; harder — APCA's better text-on-color model is deferred, so a color that reads fine under APCA may still be flagged by WCAG.
+- Owner note: this and the foreground-set decision were Craig-owned open decisions; resolved here per the reviewer's recommendation to keep the spec converging. Craig can override either before implementation starts.
+
+* Implementation phases
+
+** Phase 1 — Ramp generator (pure)
+=ramp(baseHex, opts)= in app-core.js with Normal/Boundary/Error tests (mid base, near-white/near-black base, out-of-gamut request). Leaves the tree green; no UI yet.
+
+** Phase 2 — Ramp UI in the palette
+A base swatch → preview the ramp → add chosen steps as named palette entries. Reuses the palette panel and the OKLCH picker.
+
+** Phase 3 — Foreground-set + floor (pure)
+=fgSetFor=, =floor=, =lMax= in app-core.js with tests, including the keyword-blue worst case as a fixture.
+
+** Phase 4 — Worst-case readout
+For the v1 covered faces, the contrast cell shows the floor + the limiting foreground name instead of a single pair. Pinned readout shape, so =#contrasttest= asserts fields not punctuation: =worst: <limitingLabel> <limitingHex> — <ratio> <PASS|FAIL>= (example: =worst: keyword #67809c — 3.3 FAIL=). The no-foreground-set readout is exactly =no fg set=. Add a hash-gate (#contrasttest-style) pinning floor-over-set and the no-set string.
+
+** Phase 5 — Safe-lightness in OKLCH mode
+When a v1 covered face is open in the picker, mark L_max on the lightness slider and shade the unsafe band above it. v1 renders this as a *single L_max marker plus a one-band shade* (safe below, unsafe above) computed once per =(hue, chroma, fgSet, target)= via =lMax= — not a full per-pixel foreground-set contrast mask over the plane. The per-pixel AA/AAA plane mask stays single-foreground; extending it to a full foreground-set sweep is vNext if profiling ever shows the marker is insufficient.
+
+* Acceptance criteria
+- [ ] From a base hex, the tool produces N lighter + N darker steps, perceptually even, all in sRGB gamut.
+- [ ] Generated steps can be added to the palette as named entries.
+- [ ] A background-effect face shows the worst-case contrast and names the limiting foreground.
+- [ ] OKLCH mode marks the maximum safe lightness for the chosen hue/chroma given the foreground set + target.
+- [ ] Unit tests cover ramp generation, the floor, and L_max; a browser gate pins the worst-case readout.
+- [ ] =scripts/theme-studio/README.md= documents ramp controls and defaults, the worst-case-floor / limiting-foreground meaning, the v1 covered faces, and that WCAG drives PASS/FAIL with APCA shown as a diagnostic.
+
+* Readiness dimensions
+- Data model & ownership: ramp steps and tints are user-authored palette entries the designer adds; floor and L_max are computed live, not stored. Nothing new persists.
+- Errors, empty states & failure: an out-of-gamut step clamps and flags (a real but unrepresentable color); a malformed base hex can't be clamped, so =ramp= returns a structured =bad-hex= error and the row produces nothing rather than a garbage swatch; a covered face with no foreground set shows =no fg set= rather than a bogus ratio. All four core functions return structured results for bad user input instead of throwing. No silent data loss.
+- Security & privacy: N/A — local color math, no credentials or sensitive data.
+- Observability: the worst-case readout *is* the observability — the designer sees the floor and the bottleneck color directly.
+- Performance & scale: N/A meaningfully — tens of colors, instant; no long-running ops.
+- Reuse & lost opportunities: reuse colormath.js (=oklch2hex=/=contrast=/=apca=/=deltaE=), the OKLCH picker, and the AA/AAA mask. Don't reimplement color math.
+- Architecture fit & weak points: pure logic in app-core.js (tested, importable like the Stage-7 split); UI in app.js; integration points are the palette panel, the OKLCH picker, and the contrast cells of the v1 covered faces only (region, hl-line, highlight, lazy-highlight, isearch). Package and non-overlay UI cells are out of v1 scope and keep their single-pair behavior. Weak point: defining each face's foreground set — mitigated by the closed v1 covered-face set and explicit-state =fgSetFor=.
+- Config surface: =n= (steps each direction, default 2, range 1-4), =stepL= (OKLCH-L delta, default 0.08, range 0.04-0.12), =chromaEase= (default 0.5, range 0-1), and the contrast target (default WCAG AA 4.5, AAA 7 selectable). Defaults and safe ranges pinned in "Ramp defaults and palette insertion."
+- Documentation plan: the color-harmony explainer (=docs/design/theme-studio-color-harmony.org=, already a task) carries the method; this spec carries the build; =scripts/theme-studio/README.md= is the operational doc and gets the ramp controls, contrast-target semantics, covered faces, and worst-case-floor meaning (an acceptance-criteria item).
+- Dev tooling: =make theme-studio-test= covers it via new node tests + a browser gate; no new tooling.
+- Rollout, compatibility & rollback: additive — no change to the theme.json format or existing themes. The worst-case readout replaces a misleading single-pair number for background faces (a strict improvement). No migration, nothing to roll back.
+- External APIs & deps: none — pure color math, no external schema.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Chroma easing at the ramp extremes can go muddy or out-of-gamut — dodge by clamping and previewing every step before it's added.
+- The foreground-set definition is the fuzzy core. v1 limits it to code-context faces (syntax tokens + default fg); an over-broad set would over-constrain backgrounds that those foregrounds never actually touch.
+- A very dark foreground (sterling's keyword blue) can collapse the safe band to near-black. That's a true finding, not a tool bug — the readout should make the designer fix the foreground's lightness, not silently absorb it.
+
+* Review dispositions
+
+Only modified and rejected recommendations are listed; everything else from the Codex review was accepted as written and is woven into the body above.
+
+- *Modified — v1 covered-face set closed, not open-ended.* The review's suggested list ended with "and any other named built-in face the live buffer renders as text-over-syntax." Adopted the five named faces (region, hl-line, highlight, lazy-highlight, isearch) but dropped the open-ended trailing clause: an open set hands the implementer the same invent-the-behavior problem the foreground-set decision exists to close. The v1 set is exhaustive; new faces are added explicitly in vNext.
+- *Modified — invalid-input handling distinguishes user error from programmer error.* The review said "prefer pure functions returning structured errors rather than throwing." Accepted for user-entered values (malformed hex, out-of-range knobs) which return structured results; kept throwing for genuine programmer error (wrong argument arity/type), matching colormath.js's existing idiom rather than blanket no-throw.
+- *Deferred (not rejected) — implementation-task drop-in block.* The review supplied six ready TODOs. Per the spec-response workflow, implementation tasks are created only after the author confirms the spec is Ready (Phase 6), so they are not yet logged to todo.org. They land on the Ready go.
+
+* Review and iteration history
+** 2026-06-09 Tue @ 17:18:54 -0500 — Craig — author
+- What: initial draft.
+- Why: ramp generation and background-contrast safety are coupled, design-uncertain, and worth solving on paper before code.
+- Artifacts: this spec; the color-harmony explainer task in todo.org.
+** 2026-06-09 Tue @ 17:34:17 -0500 — Codex — reviewer
+- What changed or was recommended: reviewed the spec against the implementation context and marked it =Not ready=. Recommended pinning the v1 contrast target, accepting the foreground-set contract, narrowing the exact covered background faces, and specifying ramp defaults / palette insertion behavior.
+- Why: the current code architecture supports the feature, but the open metric and foreground-scope decisions would make implementation invent user-visible behavior and produce unstable tests.
+- Artifacts: theme-studio-palette-ramps-spec-review.org (consumed and deleted on response).
+** 2026-06-09 Tue @ 17:53:02 -0500 — Claude Code (dotemacs) — responder
+- What changed or was recommended: dispositioned every Codex finding. Resolved both open Decisions to accepted — contrast target = WCAG AA default (AAA selectable, APCA diagnostic only), v1 foreground set = distinct syntax hexes + default fg with locked roles excluded. Added "v1 covered faces" (closed five-face set), "Ramp defaults and palette insertion" (n/stepL/chromaEase defaults, naming, collision, clamp display), and "Function contracts" (explicit-state, structured-error signatures for ramp/fgSetFor/floor/lMax with edge cases). Pinned the worst-case readout string and no-set message, clarified Phase 5 as a single L_max marker + band shade, added a README acceptance-criteria item, and corrected the architecture/config readiness lines. Two findings modified, one deferred — see Review dispositions.
+- Why: the review's blockers were product-contract gaps (open metric, proposed foreground set, conflicting scope, underspecified ramp write) that would have made two correct implementations diverge. Pinning them converges the spec to implementation-ready.
+- Artifacts: review file deleted; Review dispositions section; this entry. Two Craig-owned open decisions resolved per reviewer recommendation, override available before implementation.
+** 2026-06-09 Tue @ 18:10:48 -0500 — Codex — reviewer
+- What changed or was recommended: re-reviewed the updated spec and found it implementation-ready. No new blocking review notes; the prior blockers are resolved in the accepted WCAG target, closed v1 face list, explicit foreground-set contract, ramp defaults/insertion rules, function contracts, readout strings, Phase 5 marker behavior, and README acceptance criterion.
+- Why: the updated spec now gives an implementer stable product behavior, testable pure-function contracts, and integration boundaries that match the current =scripts/theme-studio= architecture.
+- Artifacts: no new review file; this Ready verification entry.