#+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.