diff options
| -rw-r--r-- | docs/theme-studio-color-families-spec.org | 202 | ||||
| -rw-r--r-- | docs/theme-studio-palette-ramps-spec.org | 12 | ||||
| -rw-r--r-- | scripts/theme-studio/README.md | 70 | ||||
| -rw-r--r-- | scripts/theme-studio/app-core.js | 133 | ||||
| -rw-r--r-- | scripts/theme-studio/app.js | 314 | ||||
| -rw-r--r-- | scripts/theme-studio/generate.py | 14 | ||||
| -rwxr-xr-x | scripts/theme-studio/run-tests.sh | 2 | ||||
| -rw-r--r-- | scripts/theme-studio/styles.css | 19 | ||||
| -rw-r--r-- | scripts/theme-studio/test-families.mjs | 213 | ||||
| -rw-r--r-- | scripts/theme-studio/theme-studio.html | 478 | ||||
| -rw-r--r-- | todo.org | 256 |
11 files changed, 1307 insertions, 406 deletions
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 index f9105595..849fea0f 100644 --- a/docs/theme-studio-palette-ramps-spec.org +++ b/docs/theme-studio-palette-ramps-spec.org @@ -3,10 +3,10 @@ #+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]] | +| 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 @@ -213,3 +213,7 @@ Only modified and rejected recommendations are listed; everything else from the - 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. diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md index a2eb59b2..caee7b24 100644 --- a/scripts/theme-studio/README.md +++ b/scripts/theme-studio/README.md @@ -42,8 +42,9 @@ The runner regenerates the page, runs the Python templating tests (`test-colormath.mjs`, including the inline-integrity check), a syntax check of the spliced page script, and the browser hash gates in headless Chrome (`#selftest`, `#cursortest`, `#readouttest`, `#deltatest`, `#oklchtest`, -`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#ramptest`, -`#contrasttest`, `#safetest`). It exits non-zero on any failure. The browser gates need a +`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#contrasttest`, +`#safetest`, `#healtest`, `#familytest`, `#counttest`, `#baseedittest`, +`#roundtriptest`). It exits non-zero on any failure. The browser gates need a Chromium-family browser; without one they report SKIPPED rather than passing silently. The pure color math and the extracted picker logic (`planeCell`, `paletteWarnings`) live in `colormath.js` so they are unit-tested directly in @@ -66,10 +67,11 @@ Node; the DOM glue is covered by the browser hash gates. Three tiers of faces, plus the palette: -- **Palette** — named colors. Add by hex or with the in-page color picker +- **Palette** — named colors, shown grouped into hue *families* (see Color + families below). Add by hex or with the in-page color picker (saturation/value square, hue slider, palette reuse chips, live contrast - readout, and an any / AA+ / AAA legibility mask). Remove, rename, reorder with - arrows or drag. The colors serving as background and foreground are locked. + readout, and an any / AA+ / AAA legibility mask). Remove and rename per chip; + the colors serving as background and foreground are locked. The picker also shows perceptual readouts beside the WCAG ratio: the OKLCH coordinates (lightness, chroma, hue°) and the APCA Lc contrast against the @@ -93,28 +95,42 @@ Three tiers of faces, plus the palette: per face, shown in a live mock Emacs buffer. - **Package faces** — per-package face tables with a live preview (below). -## Ramps and background-contrast safety - -Two coupled features help build a harmonized palette and keep background tints -readable. Both work in OKLCH, where lightness, chroma, and hue move -independently. The pure math is in `app-core.js` (`ramp`, `fgSetFor`, `floor`, -`lMax`); the DOM is in `app.js`. - -**Ramps.** The "ramp" button on the palette controls generates a tonal ramp from -the current color: lighter and darker steps on a held hue, with the chroma easing -out toward the extremes. Three controls set the shape, with defaults that produce -a sensible first ramp: - -- `steps` — how many steps each direction (default 2, range 1-4). -- `stepL` — the OKLCH lightness delta per step (default 0.08, range 0.04-0.12). -- `chroma ease` — how much chroma drops at the farthest step (default 0.5, range - 0-1; 0 holds chroma flat, 1 fully desaturates the last step). - -Each previewed step is named after the source swatch (`blue` gives `blue+1`, -`blue-1`) and shows a clamp badge when it left the sRGB gamut. Click a step to -add it, or "add all"; steps insert next to the source in order. A name that -already exists is skipped (never overwritten); a generated hex that matches -another entry is added but flagged as a duplicate. +## Color families + +The palette is displayed as **families**: colors grouped into vertical columns by +their actual color, dark at the top and light at the bottom, columns arranged left +to right. Grouping is derived from the hex on every render — never from the name — +so renaming a color to anything never moves it between columns. The flat palette +underneath is unchanged (export stays a flat `[hex, name]` list); families are a +view over it, and the per-chip rename/remove still work. + +- **Grouping.** Chromatic colors bucket by their nearest perceptual hue (red, + orange, yellow, green, teal, blue, purple, pink). Near-neutrals — grays, the + background and foreground ramps — collapse into one neutral column ordered by + lightness, using a lightness-scaled chroma threshold so a faint pale tint keeps + its hue while a faint mid gray reads as neutral. Columns sort by hue; the ground + strip (the `bg` and `fg` assignments) pins first, neutrals next. (Hue-adjacent + warm colors like olive-greens and golds can still share a column — a known + limitation, since by hue they really are adjacent.) +- **The count control** under each chromatic column sets how many steps sit on + each side of the family's base (its most-saturated color). Setting N regenerates + the family as a symmetric base ±N tonal ramp via `ramp()` — lighter and darker + steps on the base's hue with chroma easing toward the extremes — *replacing* the + column's current colors. N=0 collapses to the base alone. +- **Editing a base** recolors the whole family: change a base color and the family + regenerates from it at the same count. +- **References follow.** When a regenerate changes a step's hex, any face assigned + to that step is re-pointed to the new hex. A step *removed* by lowering the count + leaves its references showing "(gone)" — visible and recoverable, never a silent + jump to a different color. + +The standalone ramp generator is gone; fanning a color into a ramp is now "add the +color, then raise its column's count." + +## Background-contrast safety + +Keep background tints readable. Works in OKLCH; the pure math is in `app-core.js` +(`fgSetFor`, `floor`, `lMax`), the DOM in `app.js`. **Worst-case contrast.** A background overlay sits behind many foregrounds at once, so one fg/bg contrast pair is the wrong number. For the covered overlay diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js index 83f8402d..60ee1410 100644 --- a/scripts/theme-studio/app-core.js +++ b/scripts/theme-studio/app-core.js @@ -121,4 +121,135 @@ function lMax(hue,chroma,fgSet,target){ return {L:loL,status:at(loL).clamped?'clamp':'ok'}; } -export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES }; +// --- color families (color-families spec, Phase 1) --------------------------- +// Families are a display grouping derived from the hex every render — never from +// names — so renaming a color can't move it. The flat palette stays the editable +// truth; these pure functions group it, regenerate a family's ramp, and plan the +// assignment re-point across a regenerate. + +function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));} +function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;} +function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);} + +// A color reads as neutral below this chroma. Lightness-scaled (the Munsell +// insight): the mid-tones need more chroma to read as a hue. Floored at both ends +// rather than tapering to zero, so pale warm grays stay neutral (and pure white, +// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay +// colored. Tuned on real palettes (Codex + Fable color-sorting reviews). +function neutralThreshold(L){ + if(L<=0.2)return 0.020; + if(L<0.6)return 0.020+0.015*(L-0.2)/0.4; + if(L<0.85)return 0.035-0.017*(L-0.6)/0.25; + return 0.018; +} +// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL): +// hue must match tightly at equal lightness and may drift across a lightness gap, +// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise +// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma +// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is +// compatible. Source: ~/color-sorting-fable.org. +function pairRatio(a,b){ + const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H); + const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI); + return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL)); +} +// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two +// clusters whose worst cross-pair is most compatible, stopping when no merge has +// every cross-pair compatible. Complete linkage makes single-linkage chaining +// structurally impossible — two ramps can't fuse through their converging pale +// ends because their mid-lightness members stay far apart. +function clusterChromatic(ms){ + let cl=ms.map(m=>[m]); + const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b)))); + for(;;){ + let best=null; + for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};} + if(!best||best.d>1)break; + cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1); + } + return cl; +} +// A family from its members: base is the most-saturated member (tie toward +// mid-lightness), the anchor for a generated ramp. +function makeFamily(ms,neutral){ + let base=ms[0]; + for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m; + return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))}; +} +// Group a flat palette into the ground strip plus families. ground is {bg,fg}: +// those two hexes form the pinned ground strip even when absent from the palette, +// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals +// (chroma below the lightness-scaled threshold) form one neutral family; the rest +// cluster by lightness-conditioned complete linkage (clusterChromatic). +function familiesFromPalette(palette,ground){ + const bg=ground&&ground.bg,fg=ground&&ground.fg; + const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase())); + const groundStrip=[]; + if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)}); + if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)}); + const neutrals=[],chromatic=[]; + for(const [hex,name] of palette){ + if(gset.has(hex.toLowerCase()))continue; + const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H}; + (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m); + } + const families=[]; + if(neutrals.length)families.push(makeFamily(neutrals,true)); + for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false)); + return {ground:groundStrip,families}; +} +// Regenerate a family's members as a symmetric ramp around the base: n=0 is the +// base alone (without ramp()'s 1-4 clamp), n>=1 is base plus ramp() steps, sorted +// by offset. {members:[{hex,offset,clamped}]} or {members:[],error:'bad-hex'}. +function regenFamily(baseHex,n,opts){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + if(!hex)return {members:[],error:'bad-hex'}; + const k=Math.min(4,Math.max(0,Math.round(n||0))); + if(k===0)return {members:[{hex,offset:0,clamped:false}]}; + const r=ramp(hex,Object.assign({},opts,{n:k})); + if(r.error)return {members:[],error:r.error}; + const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset); + return {members}; +} +// Rank a family's current member hexes by lightness and give each a signed offset +// from the base (the matching hex, or the nearest by lightness if the base isn't +// present). Lets a regenerate match old positions to new ramp offsets. +function rankByLightness(memberHexes,baseHex){ + const items=memberHexes.map(h=>({hex:h,L:oklchOf(h).L})).sort((a,b)=>a.L-b.L); + let bi=items.findIndex(m=>m.hex.toLowerCase()===(baseHex||'').toLowerCase()); + if(bi<0){const bl=oklchOf(baseHex).L;let best=Infinity;items.forEach((m,i)=>{const d=Math.abs(m.L-bl);if(d<best){best=d;bi=i;}});} + return items.map((m,i)=>({hex:m.hex,offset:i-bi})); +} +// Plan the assignment re-point for a regenerate: for each old ranked member, the +// new member at the same offset is the same position. {map:[[old,new]]} for +// positions whose hex changed; {removed:[hex]} for positions with no new +// counterpart (the caller leaves their references a visible "(gone)"). +function stepRepointPlan(oldRanked,newMembers){ + const byOff=new Map(newMembers.map(m=>[m.offset,m.hex])),map=[],removed=[]; + for(const o of oldRanked){ + const nh=byOff.get(o.offset); + if(nh===undefined)removed.push(o.hex); + else if(nh.toLowerCase()!==o.hex.toLowerCase())map.push([o.hex,nh]); + } + return {map,removed}; +} + +// Order a family's members dark to light by OKLCH lightness. +function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});} +// Order families for display: neutrals first (by base lightness), then chromatic +// by base hue, ties broken by base lightness then base hex. Each family's members +// are lightness-sorted. Display-only — the stored palette order is untouched. +function sortFamilies(families){ + const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};}); + keyed.sort((a,b)=>{ + if(a.neutral!==b.neutral)return a.neutral?-1:1; + if(a.neutral&&b.neutral)return a.L-b.L; + const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness + if(ah!==bh)return ah-bh; + if(a.L!==b.L)return a.L-b.L; + return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0; + }); + return keyed.map(k=>sortFamilyMembers(k.f)); +} + +export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES, familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies, sortFamilyMembers }; diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js index 44a2ee74..0b6663ee 100644 --- a/scripts/theme-studio/app.js +++ b/scripts/theme-studio/app.js @@ -138,7 +138,23 @@ function buildTable(){ tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(stTd);tr.appendChild(crTd);tr.appendChild(exTd); tb.appendChild(tr);} } -let dragFrom=null,selectedIdx=null; +let selectedIdx=null; +// When a named palette color is deleted, remember its hex keyed by name so that +// recreating a color with the same name can re-bind the assignments still pointing +// at the old (now "(gone)") hex. Consumed once per name; cleared on import. +let lastGone={}; +// Re-point every assignment — syntax map, UI faces, package faces — from one hex +// to another. Used when a palette color's value is edited and when a deleted name +// is recreated. +function repointHex(oldHex,newHex){ + if(oldHex===newHex)return; + for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} + for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} + for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} +} +// On adding a color, if its name matches a recently-deleted one, re-bind the +// stranded assignments to the new hex. Returns true when a heal context existed. +function healGone(name,newHex){const k=name.toLowerCase();if(!(k in lastGone))return false;const g=lastGone[k];delete lastGone[k];repointHex(g,newHex);return true;} // Pairwise OKLab ΔE over the palette. Returns the sub-threshold pairs (sorted // closest-first) and each color's nearest-neighbor distance for its chip title. // Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it. @@ -150,35 +166,90 @@ function renderPaletteWarnings(warnings,overflow){ if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`; w.innerHTML=html;w.style.display='block'; } +// One palette chip for PALETTE[i], with its remove / rename / select handlers. +// Families sort deterministically, so the old move-arrow / drag reordering is gone. +function paletteChip(i,nearest){ + const [hex,name]=PALETTE[i],tc=textOn(hex),nde=nearest[i]; + const locked=(hex===MAP['bg']||hex===MAP['p']); + const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex; + d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3)); + const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">🔒</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`; + d.innerHTML=`${rm}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`; + if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; + d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; + d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm'))return;selectColor(i);}; + return d; +} +// Render the palette as hue families: the pinned ground strip, then hue-sorted +// family strips, each dark to light. Grouping is derived from the hex by +// familiesFromPalette every render, so renaming a color never moves it. The flat +// PALETTE stays the editable truth; chips keep their per-chip controls. function renderPalette(){ const p=document.getElementById('pals');p.innerHTML=''; const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); - PALETTE.forEach((pc,i)=>{const [hex,name]=pc;const tc=textOn(hex); - const nde=nearest[i]; - const locked=(hex===MAP['bg']||hex===MAP['p']); - const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;d.draggable=true; - d.title=name+' '+hex+(nde===Infinity?'':' — nearest \u0394E '+nde.toFixed(3)); - const lft=i>0?`<button class="mv l" title="move left" style="color:${tc}">‹</button>`:''; - const rgt=i<PALETTE.length-1?`<button class="mv r" title="move right" style="color:${tc}">›</button>`:''; - const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">🔒</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`; - d.innerHTML=`${rm}${lft}${rgt}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`; - if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; - if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);}; - if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);}; - d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; - d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm')||e.target.closest('.mv'))return;selectColor(i);}; - d.ondragstart=()=>{dragFrom=i;d.classList.add('drag');}; - d.ondragend=()=>{d.classList.remove('drag');document.querySelectorAll('.pchip.over').forEach(x=>x.classList.remove('over'));}; - d.ondragover=(e)=>{e.preventDefault();if(dragFrom!==null&&dragFrom!==i)d.classList.add('over');}; - d.ondragleave=()=>d.classList.remove('over'); - d.ondrop=(e)=>{e.preventDefault();d.classList.remove('over');if(dragFrom===null||dragFrom===i)return;const m=PALETTE.splice(dragFrom,1)[0];PALETTE.splice(i,0,m);dragFrom=null;selectedIdx=null;renderPalette();buildTable();buildUITable();}; - p.appendChild(d);}); + const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const used=new Set(); + const idxOf=(hex,name)=>{for(let i=0;i<PALETTE.length;i++)if(!used.has(i)&&PALETTE[i][0]===hex&&PALETTE[i][1]===name){used.add(i);return i;}return -1;}; + const strip=(cls)=>{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;}; + const gs=strip(' ground');gs.dataset.family='ground'; + ground.forEach(g=>{ + const i=PALETTE.findIndex((pp,k)=>!used.has(k)&&pp[0]===g.hex); + if(i>=0){used.add(i);gs.appendChild(paletteChip(i,nearest));} + else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex; + sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);} + }); + // The too-similar warning stays on the full flat palette: a generated ramp's + // steps are a stepL apart (well above the warning's ΔE threshold), so they never + // trigger it, and any pair that does is a genuine near-duplicate worth flagging. + sortFamilies(families).forEach(f=>{ + const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base; + f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); + if(!f.neutral)s.appendChild(familyCountControl(f)); + }); renderPaletteWarnings(warnings,overflow); buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); } +// The per-family count control under a chromatic strip. Its value is the family's +// current per-side reach; setting N regenerates the family as base ±N. +function familyCountControl(f){ + const per=Math.max(0,...rankByLightness(f.members.map(m=>m.hex),f.base).map(m=>Math.abs(m.offset))); + const d=document.createElement('div');d.className='fcount'; + d.innerHTML=`<span title="generate a symmetric ramp of N steps each side of this family's base — this replaces the family">± <input type="number" min="0" max="4" value="${per}"></span>`; + d.querySelector('input').onchange=(e)=>setFamilyCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0))); + return d; +} +// Regenerate a family as a symmetric base ±N ramp, replacing its current members. +// References to a surviving position (matched by signed lightness rank) follow the +// new hex; references to a position removed by lowering N leave their old hex, +// which is no longer in the palette and so renders as "(gone)". +// Replace oldHexes in the palette with a fresh base ±n ramp, repointing surviving +// references and leaving removed ones on their now-gone hex. Returns the removed +// count, or null on a bad base. Shared by the count control and the base edit. +function regenFamilyInPlace(oldHexes,baseHex,baseName,n){ + const r=regenFamily(baseHex,n,{}); + if(r.error){notify('cannot regenerate from '+baseHex,true);return null;} + const plan=stepRepointPlan(rankByLightness(oldHexes,baseHex),r.members); + const oldSet=new Set(oldHexes.map(h=>h.toLowerCase())); + let at=PALETTE.length; + for(let i=0;i<PALETTE.length;i++)if(oldSet.has(PALETTE[i][0].toLowerCase())){at=i;break;} + for(let i=PALETTE.length-1;i>=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); + const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset))]); + PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); + for(const [o,nw] of plan.map)repointHex(o,nw); + return plan.removed.length; +} +function setFamilyCount(baseHex,n){ + const {families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const fam=families.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); + if(!fam)return; + const baseName=(fam.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; + const removed=regenFamilyInPlace(fam.members.map(m=>m.hex),baseHex,baseName,n); + if(removed===null)return; + selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); + notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false); +} function notify(msg,err){const m=document.getElementById('palmsg');if(!m)return;m.textContent=msg;m.style.color=err?'#cb6b4d':'#8a9496';m.style.opacity='1';clearTimeout(m._t);m._t=setTimeout(()=>{m.style.opacity='0';},err?4000:2800);} function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();} -function moveColor(i,dir){const j=i+dir;if(j<0||j>=PALETTE.length)return;const t=PALETTE[i];PALETTE[i]=PALETTE[j];PALETTE[j]=t;if(selectedIdx===i)selectedIdx=j;else if(selectedIdx===j)selectedIdx=i;renderPalette();buildTable();buildUITable();} function selectColor(i){selectedIdx=i;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();notify('editing "'+name+'" — change the value, then Enter (or Update selected) to save',false);} function updateColor(){ if(selectedIdx===null){notify('click a palette color to select it first',true);return;} @@ -186,10 +257,17 @@ function updateColor(){ const newHex=curHex(); const newName=(document.getElementById('newname').value.trim())||PALETTE[i][1]; if(PALETTE.some((p,j)=>j!==i&&p[1].toLowerCase()===newName.toLowerCase())){notify('another color is already named "'+newName+'" — names must be unique',true);return;} + // If the edited color is a family base with a ramp, recolor the whole family: regenerate from the new base at the same count. + const fams=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families; + const fam=fams.find(f=>!f.neutral&&f.base.toLowerCase()===oldHex.toLowerCase()); + const count=fam?Math.max(0,...rankByLightness(fam.members.map(m=>m.hex),fam.base).map(m=>Math.abs(m.offset))):0; PALETTE[i]=[newHex,newName]; - for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} - for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} - for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} + repointHex(oldHex,newHex); + if(fam&&count>0){ + const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex); + regenFamilyInPlace(oldHexes,newHex,newName,count); + closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return; + } closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} @@ -285,61 +363,10 @@ function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;s function addColor(){const h=curHex();const name=document.getElementById('newname').value.trim(); if(!name){notify('name the color before adding it',true);return;} if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} - PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} -// --- ramp generator UI (palette-ramps spec, Phase 2) ------------------------- -// Generate a tonal ramp from the current color, preview the steps, add the ones -// you want as named palette entries. The pure ramp() lives in app-core.js; this -// is the DOM around it. Names derive from the source swatch (blue -> blue+1). -let rampBase=null; // {hex,name} of the last previewed base (refreshed from the tile on preview) -// The base the ramp generates from is whatever sits on the color-selection tile -// right now: the selected palette color, or a typed hex and name. Reading it at -// preview time means selecting a new palette color then pressing preview just -// works, the same as reopening the panel. -function rampBaseFromTile(){const hex=curHex(),name=(selectedIdx!=null?PALETTE[selectedIdx][1]:document.getElementById('newname').value.trim())||'ramp';return {hex,name};} -function openRamp(){document.getElementById('ramp').style.display='block';renderRamp();} -function closeRamp(){const r=document.getElementById('ramp');if(r)r.style.display='none';} -function rampOpts(){return {n:parseInt(document.getElementById('rampn').value,10),stepL:parseFloat(document.getElementById('rampstepl').value),chromaEase:parseFloat(document.getElementById('rampce').value)};} -function rampStepName(off){return rampBase.name+(off>0?'+'+off:String(off));} -function rampNote(msg,err){const m=document.getElementById('rampmsg');if(!m)return;m.textContent=msg||'';m.style.color=err?'#cb6b4d':'#8a9496';} -function rampNameTaken(nm){return PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase());} -function renderRamp(){ - rampBase=rampBaseFromTile(); - document.getElementById('rampname').textContent=rampBase.name+' '+rampBase.hex; - const r=ramp(rampBase.hex,rampOpts()),prev=document.getElementById('rampprev');prev.innerHTML=''; - if(r.error){rampNote('not a valid base color',true);return;} - const dups=[]; - r.steps.forEach(s=>{const nm=rampStepName(s.offset),taken=rampNameTaken(nm);if(taken)dups.push(nm); - const c=document.createElement('div');c.className='rchip'+(taken?' dup':'');c.style.background=s.hex;c.style.color=textOn(s.hex); - c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'')+(taken?' — a palette color is already named this; it will be skipped on add':''); - c.innerHTML=`<span>${esc(nm)}</span><span class="rhex">${s.hex}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}${taken?'<span class="rdup" title="name already in the palette">⊘</span>':''}`; - c.onclick=()=>addRampStep(s);prev.appendChild(c);}); - const parts=[]; - if(r.adjusted.length)parts.push('adjusted: '+r.adjusted.join(', ')); - if(dups.length)parts.push('name already in palette, will be skipped on add: '+dups.join(', ')); - rampNote(parts.join(' | '),dups.length>0); -} -// Insert a step adjacent to the source swatch, keeping the ramp siblings in -// -n..+n order. A name collision is flagged and skipped (never overwrites); a -// hex that already exists under another name is added but flagged as a duplicate. -function rampInsertIndex(off){ - const bn=rampBase.name,re=new RegExp('^'+bn.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([+-]\\d+)$'); - let src=PALETTE.findIndex(p=>p[1]===bn);if(src<0)src=PALETTE.length-1; - let idx=src+1;while(idx<PALETTE.length){const m=PALETTE[idx][1].match(re);if(m&&parseInt(m[1],10)<off){idx++;continue;}break;} - return idx; -} -function addRampStep(s){ - const nm=rampStepName(s.offset); - if(PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase())){rampNote('"'+nm+'" already exists — rename or skip',true);return false;} - const dup=PALETTE.find(p=>p[0].toLowerCase()===s.hex.toLowerCase()); - PALETTE.splice(rampInsertIndex(s.offset),0,[s.hex,nm]);renderPalette();buildTable();buildUITable(); - rampNote(dup?('added "'+nm+'" (same hex as "'+dup[1]+'")'):('added "'+nm+'"'),false);return true; -} -function addAllRampSteps(){ - if(!rampBase)return;const r=ramp(rampBase.hex,rampOpts()); - if(r.error){rampNote('not a valid base color',true);return;} - let added=0;const skipped=[];r.steps.forEach(s=>{addRampStep(s)?added++:skipped.push(rampStepName(s.offset));}); - rampNote('added '+added+(skipped.length?(' | skipped (name already in palette): '+skipped.join(', ')):''),skipped.length>0); -} + PALETTE.push([h,name]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker(); + renderPalette();buildTable();buildUITable(); + if(healed){renderCode();applyGround();if(document.getElementById('pkgbody'))buildPkgTable();buildPkgPreview();} + notify(healed?('added "'+name+'" and reconnected its assignments'):('added "'+name+'"'),false);} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} @@ -353,7 +380,7 @@ async function saveTheme(){const data=JSON.stringify(exportObj(),null,1); try{if(!fileHandle)fileHandle=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]}); const w=await fileHandle.createWritable();await w.write(data);await w.close();notify('saved "'+themeName()+'"',false);updateTitle(); }catch(e){if(e&&e.name!=='AbortError')notify('save failed: '+e.message,true);}} -function applyImported(text){const d=JSON.parse(text);if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); +function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); BOLD={};(d.bold||[]).forEach(k=>BOLD[k]=true);ITALIC={};(d.italic||[]).forEach(k=>ITALIC[k]=true); LOCKED=new Set(d.locks||[]); if(d.ui)Object.assign(UIMAP,d.ui); @@ -1010,30 +1037,6 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById(' const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2; const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);} -// Ramp UI gate (open with #ramptest): generation count, ordered insertion after -// the source swatch, name-collision skip, and a clamp badge on an out-of-gamut step. -if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const save=PALETTE.slice(); - PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];renderPalette(); - selectedIdx=PALETTE.findIndex(p=>p[1]==='blue');document.getElementById('newhexstr').value='#67809c';document.getElementById('newname').value='blue'; - openRamp();document.getElementById('rampn').value='2';document.getElementById('rampstepl').value='0.08';document.getElementById('rampce').value='0.5';renderRamp(); - A(document.querySelectorAll('#rampprev .rchip').length===4,'expected 4 step chips, got '+document.querySelectorAll('#rampprev .rchip').length); - A(document.querySelectorAll('#rampprev .rchip .rhex').length===4,'each step tile shows its hex'); - addAllRampSteps(); - const names=PALETTE.map(p=>p[1]),bi=names.indexOf('blue'); - A(names.slice(bi,bi+5).join(',')==='blue,blue-2,blue-1,blue+1,blue+2','order after blue: '+names.slice(bi,bi+5).join(',')); - const before=PALETTE.length;addAllRampSteps();A(PALETTE.length===before,'re-add should skip existing names'); - A(/skipped \(name already in palette\): blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'add-all names the skipped collisions: '+document.getElementById('rampmsg').textContent); - renderRamp(); - A(document.querySelectorAll('#rampprev .rchip.dup').length===4,'re-preview marks the now-existing names as dup'); - A(/already in palette.*blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'preview names the colliding tiles: '+document.getElementById('rampmsg').textContent); - // preview re-reads the color-selection tile: change the tile, press preview, the base follows - document.getElementById('newhexstr').value='#2040e0';document.getElementById('newname').value='vivid';selectedIdx=null;document.getElementById('rampce').value='0';renderRamp(); - A(/^vivid #2040e0/.test(document.getElementById('rampname').textContent),'preview reads the tile: '+document.getElementById('rampname').textContent); - A(document.querySelectorAll('#rampprev .rclamp').length>0,'vivid base at chroma-ease 0 should clamp an extreme step'); - PALETTE=save;selectedIdx=null;renderPalette();closeRamp(); - document.title='RAMPTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} // Worst-case readout gate (open with #contrasttest): a covered overlay face shows // the floor over its foreground set and names the limiting foreground, an // out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set". @@ -1074,3 +1077,104 @@ if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c setPkModel('hsv');closePicker(); document.title='SAFETEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='safetest';d.textContent='SAFETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Gone-rebind gate (open with #healtest): deleting a named color then recreating +// the name re-points the assignments stranded on the old hex to the new color. +if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx; + PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];MAP['kw']='#67809c';lastGone={};selectedIdx=null;renderPalette();buildTable(); + const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue'); + A(!!(blue&&blue.querySelector('.rm')),'blue chip has a remove button'); + if(blue&&blue.querySelector('.rm'))blue.querySelector('.rm').click(); + A(!PALETTE.some(p=>p[1]==='blue'),'blue was deleted'); + A(lastGone['blue']==='#67809c','delete recorded the gone name->hex'); + document.getElementById('newhexstr').value='#5a7a9a';document.getElementById('newname').value='blue';selectedIdx=null;addColor(); + A(MAP['kw']==='#5a7a9a','assignment re-bound to the recreated name, got '+MAP['kw']); + A(!('blue' in lastGone),'heal consumed the gone entry'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel; + renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); + document.title='HEALTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Family-strip gate (open with #familytest): the palette renders as the pinned +// ground strip plus hue families, chips keep their controls, and renaming a color +// to anything leaves it in the same strip (grouping is by hex, not name). +if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette(); + const strips=[...document.querySelectorAll('#pals .fstrip')]; + A(strips.length&&strips[0].classList.contains('ground'),'ground strip is pinned first'); + A(strips[0].querySelectorAll('.pchip').length===2,'ground strip carries bg + fg'); + A(strips.length>=4,'ground + neutral + red + blue strips, got '+strips.length); + const redChip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='red'); + A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a family chip keeps remove + rename controls'); + const redFamily=redChip&&redChip.closest('.fstrip').dataset.family; + const ri=PALETTE.findIndex(p=>p[1]==='red');PALETTE[ri][1]='zztop-absurd';renderPalette(); + const renamed=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='zztop-absurd'); + A(!!renamed&&renamed.closest('.fstrip').dataset.family===redFamily,'a renamed color stays in the same strip'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette(); + document.title='FAMILYTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='familytest';d.textContent='FAMILYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Count-control gate (open with #counttest): the per-family count regenerates the +// family — count up adds symmetric steps, count down drops the extremes, a +// reference to a surviving step follows the new hex, a reference to a removed step +// is left on its old (now-gone) hex. +if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx; + MAP['bg']='#000000';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + const innerOld=regenFamily('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change + const outerOld=regenFamily('#67809c',2).members.find(m=>m.offset===2).hex; // dropped on count-down + UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false}; + UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false}; + selectedIdx=null;renderPalette(); + setFamilyCount('#67809c',1); + const palHexes=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(!palHexes.has(outerOld.toLowerCase()),'outer step removed from palette on count down'); + A(UIMAP['highlight'].bg.toLowerCase()===outerOld.toLowerCase(),'a removed-step reference stays on its old (gone) hex'); + const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex; + A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg); + setFamilyCount('#67809c',3); + const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase()); + const have=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); + document.title='COUNTTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Base-edit + ground-edit gate (open with #baseedittest): editing a family base +// recolors the whole family at the same count and references follow; editing a +// ground swatch writes the bg/fg assignment. +if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + renderPalette();buildUITable(); + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c'); + document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal'; + updateColor(); + const fam=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>!f.neutral); + A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex'); + A(fam&&fam.members.length===5,'count preserved (±2 → 5 members), got '+(fam&&fam.members.length)); + A(!new Set(PALETTE.map(p=>p[0].toLowerCase())).has('#67809c'),'old base removed from palette'); + A(UIMAP['region'].bg.toLowerCase()==='#3a8a8a','a reference to the base followed to the new base hex'); + // ground edit: select bg, change hex, MAP.bg follows + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#0d0b0a'); + document.getElementById('newhexstr').value='#101010';document.getElementById('newname').value='ground'; + updateColor(); + A(MAP['bg'].toLowerCase()==='#101010','editing the bg swatch wrote the bg assignment, got '+MAP['bg']); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); + document.title='BASEEDITTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Round-trip gate (open with #roundtriptest): export stays a flat palette and +// import needs no family reconstruction, so export → import → export is identical. +if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const before=JSON.stringify(exportObj()); + applyImported(before); + const after=JSON.stringify(exportObj()); + A(before===after,'export → import → export is byte-identical'); + const obj=JSON.parse(after); + A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list'); + document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py index a8cda815..b6e2fc73 100644 --- a/scripts/theme-studio/generate.py +++ b/scripts/theme-studio/generate.py @@ -448,21 +448,7 @@ STYLES_CSS</style> <input type="text" id="newname" placeholder="name" onkeydown="if(event.key==='Enter')applyEdit()"> <button onclick="addColor()">+ add color</button> <button onclick="updateColor()">↻ update selected</button> - <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">⛰ ramp</button> <span id="palmsg"></span> - <div id="ramp" class="ramp" style="display:none"> - <div class="ramprow"> - <label>ramp from <b id="rampname">—</b></label> - <label title="steps each direction (1-4)">steps <input type="number" id="rampn" min="1" max="4" step="1" value="2" style="width:48px"></label> - <label title="OKLCH lightness delta per step (0.04-0.12)">stepL <input type="number" id="rampstepl" min="0.04" max="0.12" step="0.01" value="0.08" style="width:62px"></label> - <label title="how much chroma eases out toward the extremes (0-1)">chroma ease <input type="number" id="rampce" min="0" max="1" step="0.1" value="0.5" style="width:58px"></label> - <button onclick="renderRamp()">preview</button> - <button onclick="addAllRampSteps()">+ add all</button> - <button onclick="closeRamp()">close</button> - </div> - <div id="rampprev" class="rampprev"></div> - <div id="rampmsg"></div> - </div> <div id="picker" class="picker"> <div class="prow"> <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div> diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh index 4cdcd383..2f46602c 100755 --- a/scripts/theme-studio/run-tests.sh +++ b/scripts/theme-studio/run-tests.sh @@ -53,7 +53,7 @@ CHROME="" for c in google-chrome-stable google-chrome chromium chromium-browser; do if command -v "$c" >/dev/null 2>&1; then CHROME="$c"; break; fi done -HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest contrasttest safetest" +HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest contrasttest safetest healtest familytest counttest baseedittest roundtriptest" if [ "$NO_BROWSER" = 1 ]; then skip_msg "browser hash gates (--no-browser)" elif [ -z "$CHROME" ]; then diff --git a/scripts/theme-studio/styles.css b/scripts/theme-studio/styles.css index 79a7efe2..cc074dac 100644 --- a/scripts/theme-studio/styles.css +++ b/scripts/theme-studio/styles.css @@ -23,13 +23,16 @@ .cat{color:#b4b1a2} .ex{font-size:17px} .sbtn{width:26px;height:24px;border:1px solid #3a3a3a;border-radius:3px;background:#eaeaea;color:#111;cursor:pointer;font-size:15px;margin-right:2px;padding:0} .sbtn.on{background:#0d0b0a;color:#cdced1;border-color:#8a9496} - .pals{display:flex;gap:8px;flex-wrap:wrap} + .pals{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;align-items:flex-start} + .fstrip{display:flex;flex-direction:column;gap:6px;padding:5px;border-radius:7px;border:1px solid transparent} + .fstrip.ground{border-color:#252321;background:#161412} + .fcount{margin-top:3px;font:9pt monospace;color:#8a9496;text-align:center} + .fcount input{width:40px;background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:2px 4px;font:9pt monospace;text-align:center} .palwarn{display:none;margin-top:8px;font:10pt monospace;color:#cb6b4d} .palwarn .pwh{font-weight:bold;margin-bottom:2px} .palwarn .pwl{opacity:.92} .pchip{width:128px;height:58px;border-radius:6px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:grab} - .pchip.drag{opacity:.4} .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip.over{outline:2px dashed #e8bd30;outline-offset:1px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} - .pchip .mv{position:absolute;bottom:-1px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;font-weight:bold;opacity:.5;padding:0 5px} .pchip .mv:hover{opacity:1} .pchip .mv.l{left:0} .pchip .mv.r{right:0} + .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} .pchip .hx{font-size:10pt;opacity:.8} .pchip .rm{position:absolute;top:2px;right:5px;background:none;border:none;cursor:pointer;font-size:14px;font-weight:bold;opacity:.7} .pchip .lock{position:absolute;top:3px;right:5px;font-size:10px;opacity:.6} .palctl{margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap} @@ -62,16 +65,6 @@ .pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad} .pinfo2 span{cursor:default} .pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer} - .ramp{flex-basis:100%;margin-top:8px;padding:10px;border:1px solid #252321;border-radius:6px;background:#161412} - .ramprow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font:10pt monospace;color:#b4b1a2} - .ramprow input[type=number]{background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:4px 6px;font:10pt monospace} - .rampprev{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px} - .rchip{width:128px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace;line-height:1.3} - .rchip .rhex{font-weight:normal;font-size:8pt;opacity:.85} - .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px} - .rchip.dup{outline:2px dashed #e8bd30;outline-offset:-2px} - .rchip .rdup{position:absolute;top:2px;left:4px;color:#e8bd30;font-weight:bold;font-size:12px} - #rampmsg{font:10pt monospace;margin-top:6px;min-height:14px;color:#8a9496} .svsafe{position:absolute;left:0;width:100%;background:rgba(203,107,77,0.30);border-bottom:2px solid #cb6b4d;pointer-events:none;z-index:2} .palctl button,.filebar button,.fbtn{background:#252321;color:#e8bd30;border:1px solid #3a3a3a;border-radius:4px;padding:6px 12px;font:10pt monospace;cursor:pointer} #palmsg{font:10pt monospace;opacity:0;transition:opacity .35s;margin-left:6px} diff --git a/scripts/theme-studio/test-families.mjs b/scripts/theme-studio/test-families.mjs new file mode 100644 index 00000000..c6602aeb --- /dev/null +++ b/scripts/theme-studio/test-families.mjs @@ -0,0 +1,213 @@ +// Unit tests for the color-families model (app-core.js): grouping a flat palette +// into hue families, regenerating a family's ramp, ranking members by lightness, +// and planning the assignment re-point across a regenerate. Phase 1 of the +// color-families spec. Pure, no DOM. Run: node --test scripts/theme-studio/ + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { familiesFromPalette, regenFamily, rankByLightness, stepRepointPlan, sortFamilies } from './app-core.js'; +import { oklch2hex, srgb2oklab, oklab2oklch } from './colormath.js'; + +// Build a palette entry at a controlled OKLCH hue so clustering is deterministic. +const at = (L, C, H, name) => [oklch2hex(L, C, H).hex, name || ('c' + H)]; + +// --- familiesFromPalette ---------------------------------------------------- + +test('familiesFromPalette: Normal — separated hues split into one family each', () => { + const pal = [at(0.6, 0.1, 30, 'red'), at(0.6, 0.1, 150, 'green'), at(0.6, 0.1, 270, 'blue')]; + const { ground, families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 3, 'three separated hues -> three families'); + assert.equal(ground.length, 2, 'ground strip carries bg and fg'); + for (const f of families) assert.equal(f.members.length, 1); +}); + +test('familiesFromPalette: Boundary — near hues at the same lightness stay one family', () => { + const pal = [at(0.55, 0.1, 250, 'b1'), at(0.6, 0.1, 256, 'b2')]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 1, 'a near hue-pair is one family'); + assert.equal(families[0].members.length, 2); +}); + +test('familiesFromPalette: Boundary — well-separated hues split', () => { + const pal = [at(0.6, 0.1, 255, 'b'), at(0.6, 0.1, 200, 'c')]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 2); +}); + +test('familiesFromPalette: Boundary — an intermediate chain does not merge gold into green', () => { + // complete linkage requires every cross-pair compatible, so the far endpoints (90° vs 150°) keep the chain from fusing + const pal = [at(0.7, 0.1, 90, 'gold'), at(0.65, 0.1, 110, 'olive'), at(0.6, 0.1, 130, 'yg'), at(0.55, 0.1, 150, 'green')]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.equal(families.length, 2, 'not one chained family'); +}); + +test('familiesFromPalette: Boundary — a pale tint keeps its hue while a mid gray goes neutral', () => { + const paleBlue = oklch2hex(0.9, 0.03, 255).hex; // light, faint -> still blue + const midGray = oklch2hex(0.6, 0.025, 100).hex; // mid, faint -> reads neutral + const { families } = familiesFromPalette([[paleBlue, 'paleblue'], [midGray, 'graytone']], { bg: '#000000', fg: '#ffffff' }); + const neutral = families.find(f => f.neutral); + assert.ok(neutral && neutral.members.some(m => m.name === 'graytone'), 'mid faint color is neutral'); + assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'paleblue')), 'pale tint stays chromatic'); +}); + +test('familiesFromPalette: Boundary — near-neutral colors form a separate family', () => { + const pal = [at(0.6, 0.1, 250, 'blue'), at(0.5, 0.004, 250, 'gray')]; // gray below the chroma threshold + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + const neutral = families.find(f => f.neutral); + assert.ok(neutral, 'a neutral family exists'); + assert.ok(neutral.members.some(m => m.name === 'gray')); + assert.ok(families.some(f => !f.neutral && f.members.some(m => m.name === 'blue'))); +}); + +// --- real-palette grouping (the hard cases the color-sorting reviews measured) --- + +// The contested region of the distinguished/sterling palette: the gold ramp and +// the olive ramp whose hue ranges nearly touch but whose mid-tones are far apart. +const GOLD = [['#875f00', 'yellow-2'], ['#8e784c', 'yellow-1'], ['#d7af5f', 'yellow'], ['#ffd75f', 'yellow+1']]; +const OLIVE = [['#646d14', 'green-2'], ['#869038', 'green-1'], ['#a4ac64', 'green'], ['#ccc768', 'green+1']]; +const famOf = (families, name) => families.find(f => f.members.some(m => m.name === name)); + +test('familiesFromPalette: Normal — the gold and olive ramps separate', () => { + const { families } = familiesFromPalette([...GOLD, ...OLIVE], { bg: '#000000', fg: '#ffffff' }); + const gold = famOf(families, 'yellow'), olive = famOf(families, 'green'); + assert.notEqual(gold, olive, 'gold and olive are different families'); + assert.ok(!gold.members.some(m => m.name.startsWith('green')), 'gold family has no greens'); + assert.ok(!olive.members.some(m => m.name.startsWith('yellow')), 'olive family has no yellows'); +}); + +test('familiesFromPalette: Normal — the blue ramp stays whole despite pale-tint hue drift', () => { + // blue (H 252), blue+1 (H 231), blue+2 (H 272): low-chroma pale tints swing in hue but belong together + const pal = [['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#d9e2ff', 'blue+2']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + const blue = famOf(families, 'blue'); + assert.equal(blue.members.length, 3, 'all three blues in one family'); +}); + +test('familiesFromPalette: Boundary — pale warm grays and pure white read as neutral', () => { + const pal = [['#b4b1a2', 'gray+1'], ['#d0cbc0', 'gray+2'], ['#ffffff', 'white'], ['#67809c', 'blue']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#f0fef0' }); // fg distinct from the white swatch + const neutral = families.find(f => f.neutral); + for (const n of ['gray+1', 'gray+2', 'white']) assert.ok(neutral.members.some(m => m.name === n), n + ' is neutral'); + assert.ok(famOf(families, 'blue') && !famOf(families, 'blue').neutral, 'blue stays chromatic'); +}); + +test('familiesFromPalette: Boundary — a vivid accent stays out of a soft same-hue family', () => { + // intense-red (C 0.246) vs red (C 0.120) at similar lightness: the chroma clause keeps them apart + const pal = [['#ff2a00', 'intense-red'], ['#d47c59', 'red'], ['#a7502d', 'red-1']]; + const { families } = familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }); + assert.notEqual(famOf(families, 'intense-red'), famOf(families, 'red'), 'intense-red is its own family'); +}); + +test('familiesFromPalette: Boundary — grouping is independent of palette order', () => { + const base = [...GOLD, ...OLIVE, ['#67809c', 'blue'], ['#b2c3cc', 'blue+1'], ['#969385', 'gray']]; + const key = pal => familiesFromPalette(pal, { bg: '#000000', fg: '#ffffff' }).families + .map(f => f.members.map(m => m.name).sort().join(',')).sort().join(' | '); + const ref = key(base); + for (const seed of [1, 2, 3]) { // a few deterministic shuffles + const shuffled = base.map((e, i) => [e, ((i + 1) * seed * 7) % base.length]).sort((a, b) => a[1] - b[1]).map(x => x[0]); + assert.equal(key(shuffled), ref, 'shuffle ' + seed + ' yields the same grouping'); + } +}); + +test('familiesFromPalette: Boundary — ground hex absent from the palette still forms the strip', () => { + const pal = [at(0.6, 0.1, 250, 'blue')]; + const { ground } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.equal(ground.length, 2); + assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a' && g.role === 'bg')); + assert.ok(ground.some(g => g.role === 'fg')); +}); + +test('familiesFromPalette: Boundary — a chip at a ground hex is not duplicated into a family', () => { + const pal = [['#0d0b0a', 'ground'], at(0.6, 0.1, 250, 'blue')]; + const { ground, families } = familiesFromPalette(pal, { bg: '#0d0b0a', fg: '#f0fef0' }); + assert.ok(ground.some(g => g.hex.toLowerCase() === '#0d0b0a')); + assert.ok(!families.some(f => f.members.some(m => m.hex.toLowerCase() === '#0d0b0a')), 'ground chip stays out of families'); +}); + +// --- regenFamily ------------------------------------------------------------ + +test('regenFamily: Normal — n steps each side plus the base, ordered by offset', () => { + const r = regenFamily('#67809c', 2); + assert.equal(r.members.length, 5); + assert.deepEqual(r.members.map(m => m.offset), [-2, -1, 0, 1, 2]); + assert.equal(r.members.find(m => m.offset === 0).hex, '#67809c'); +}); + +test('regenFamily: Boundary — n=0 is the base alone, no ramp() clamp to 1', () => { + const r = regenFamily('#67809c', 0); + assert.deepEqual(r.members, [{ hex: '#67809c', offset: 0, clamped: false }]); +}); + +test('regenFamily: Error — a malformed base returns a structured bad-hex', () => { + assert.deepEqual(regenFamily('nope', 2), { members: [], error: 'bad-hex' }); +}); + +// --- rankByLightness -------------------------------------------------------- + +test('rankByLightness: Normal — offsets are signed distance from the base by lightness', () => { + const members = regenFamily('#67809c', 2).members.map(m => m.hex); + const ranked = rankByLightness(members, '#67809c'); + const base = ranked.find(m => m.hex === '#67809c'); + assert.equal(base.offset, 0); + const sorted = [...ranked].sort((a, b) => a.offset - b.offset); + assert.deepEqual(sorted.map(m => m.offset), [-2, -1, 0, 1, 2]); +}); + +test('rankByLightness: Boundary — a base not among the members ranks by nearest lightness', () => { + const members = ['#222222', '#888888', '#dddddd']; + const ranked = rankByLightness(members, '#8a8a8a'); // near the mid member + const mid = ranked.find(m => m.hex === '#888888'); + assert.equal(mid.offset, 0, 'nearest-lightness member is the base rank'); +}); + +// --- stepRepointPlan -------------------------------------------------------- + +test('stepRepointPlan: Normal — surviving offsets map old->new, changed hex only', () => { + const oldR = [{ hex: '#111111', offset: -1 }, { hex: '#222222', offset: 0 }, { hex: '#333333', offset: 1 }]; + const neu = [{ hex: '#111111', offset: -1 }, { hex: '#aaaaaa', offset: 0 }, { hex: '#444444', offset: 1 }]; + const { map, removed } = stepRepointPlan(oldR, neu); + assert.deepEqual(removed, []); + assert.deepEqual(map, [['#222222', '#aaaaaa'], ['#333333', '#444444']]); // -1 unchanged, skipped +}); + +test('stepRepointPlan: Boundary — an offset with no new counterpart is removed, not repointed', () => { + const oldR = [{ hex: '#000033', offset: -3 }, { hex: '#222222', offset: 0 }]; + const neu = [{ hex: '#222222', offset: 0 }]; // count dropped, -3 gone + const { map, removed } = stepRepointPlan(oldR, neu); + assert.deepEqual(map, []); + assert.deepEqual(removed, ['#000033']); +}); + +// --- sortFamilies ----------------------------------------------------------- + +const fam = (baseHex, neutral, members) => ({ base: baseHex, neutral: !!neutral, members: (members || [baseHex]).map(h => ({ hex: h, name: h })) }); + +test('sortFamilies: Normal — chromatic families order by base hue', () => { + const fams = [fam(oklch2hex(0.6, 0.1, 270).hex), fam(oklch2hex(0.6, 0.1, 30).hex), fam(oklch2hex(0.6, 0.1, 150).hex)]; + const sorted = sortFamilies(fams); + const hues = sorted.map(f => Math.round(oklab2oklch(srgb2oklab(f.base)).H)); + for (let i = 1; i < hues.length; i++) assert.ok(hues[i] > hues[i - 1], 'ascending hue: ' + hues.join(',')); +}); + +test('sortFamilies: Boundary — neutral families pin ahead of chromatic ones', () => { + const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false), fam('#808080', true)]); + assert.equal(sorted[0].neutral, true, 'neutral first'); + assert.equal(sorted[1].neutral, false); +}); + +test('sortFamilies: Normal — members within a family sort dark to light', () => { + const members = ['#dddddd', '#222222', '#888888']; + const sorted = sortFamilies([fam(oklch2hex(0.6, 0.1, 200).hex, false, members)]); + const ls = sorted[0].members.map(m => oklab2oklch(srgb2oklab(m.hex)).L); + for (let i = 1; i < ls.length; i++) assert.ok(ls[i] > ls[i - 1], 'ascending lightness'); +}); + +test('sortFamilies: Boundary — order is (hue, then lightness); a hue tie falls to lightness', () => { + const bases = [oklch2hex(0.6, 0.1, 200).hex, oklch2hex(0.5, 0.1, 200).hex, oklch2hex(0.6, 0.1, 40).hex]; + const sorted = sortFamilies(bases.map(b => fam(b, false))); + const key = h => { const c = oklab2oklch(srgb2oklab(h)); return [Math.round(c.H), c.L]; }; + for (let i = 1; i < sorted.length; i++) { + const [h0, l0] = key(sorted[i - 1].base), [h1, l1] = key(sorted[i].base); + assert.ok(h0 < h1 || (h0 === h1 && l0 <= l1), `order at ${i}: hue ${h0}/${h1} L ${l0.toFixed(3)}/${l1.toFixed(3)}`); + } +}); diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html index a9b030f9..33358704 100644 --- a/scripts/theme-studio/theme-studio.html +++ b/scripts/theme-studio/theme-studio.html @@ -25,13 +25,16 @@ .cat{color:#b4b1a2} .ex{font-size:17px} .sbtn{width:26px;height:24px;border:1px solid #3a3a3a;border-radius:3px;background:#eaeaea;color:#111;cursor:pointer;font-size:15px;margin-right:2px;padding:0} .sbtn.on{background:#0d0b0a;color:#cdced1;border-color:#8a9496} - .pals{display:flex;gap:8px;flex-wrap:wrap} + .pals{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;align-items:flex-start} + .fstrip{display:flex;flex-direction:column;gap:6px;padding:5px;border-radius:7px;border:1px solid transparent} + .fstrip.ground{border-color:#252321;background:#161412} + .fcount{margin-top:3px;font:9pt monospace;color:#8a9496;text-align:center} + .fcount input{width:40px;background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:2px 4px;font:9pt monospace;text-align:center} .palwarn{display:none;margin-top:8px;font:10pt monospace;color:#cb6b4d} .palwarn .pwh{font-weight:bold;margin-bottom:2px} .palwarn .pwl{opacity:.92} .pchip{width:128px;height:58px;border-radius:6px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:grab} - .pchip.drag{opacity:.4} .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip.over{outline:2px dashed #e8bd30;outline-offset:1px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} - .pchip .mv{position:absolute;bottom:-1px;background:none;border:none;cursor:pointer;font-size:22px;line-height:1;font-weight:bold;opacity:.5;padding:0 5px} .pchip .mv:hover{opacity:1} .pchip .mv.l{left:0} .pchip .mv.r{right:0} + .pchip.sel{outline:3px solid #e8bd30;outline-offset:2px} .pchip input.nm{background:transparent;border:none;text-align:center;font:bold 10pt monospace;width:108px;outline:none} .pchip .hx{font-size:10pt;opacity:.8} .pchip .rm{position:absolute;top:2px;right:5px;background:none;border:none;cursor:pointer;font-size:14px;font-weight:bold;opacity:.7} .pchip .lock{position:absolute;top:3px;right:5px;font-size:10px;opacity:.6} .palctl{margin-top:12px;display:flex;gap:8px;align-items:center;flex-wrap:wrap} @@ -64,16 +67,6 @@ .pinfo2{display:flex;justify-content:space-between;margin:0 2px 9px;font:10pt monospace;color:#9aa3ad} .pinfo2 span{cursor:default} .pkchips{display:flex;flex-wrap:wrap;gap:5px} .pkchips .pc{width:28px;height:28px;border-radius:3px;border:1px solid #555;cursor:pointer} - .ramp{flex-basis:100%;margin-top:8px;padding:10px;border:1px solid #252321;border-radius:6px;background:#161412} - .ramprow{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font:10pt monospace;color:#b4b1a2} - .ramprow input[type=number]{background:#0d0b0a;border:1px solid #252321;color:#cdced1;border-radius:4px;padding:4px 6px;font:10pt monospace} - .rampprev{display:flex;gap:6px;flex-wrap:wrap;margin-top:10px} - .rchip{width:128px;height:48px;border-radius:5px;border:1px solid #555;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:pointer;font:bold 9pt monospace;line-height:1.3} - .rchip .rhex{font-weight:normal;font-size:8pt;opacity:.85} - .rchip .rclamp{position:absolute;top:2px;right:4px;color:#cb6b4d;font-weight:bold;font-size:12px} - .rchip.dup{outline:2px dashed #e8bd30;outline-offset:-2px} - .rchip .rdup{position:absolute;top:2px;left:4px;color:#e8bd30;font-weight:bold;font-size:12px} - #rampmsg{font:10pt monospace;margin-top:6px;min-height:14px;color:#8a9496} .svsafe{position:absolute;left:0;width:100%;background:rgba(203,107,77,0.30);border-bottom:2px solid #cb6b4d;pointer-events:none;z-index:2} .palctl button,.filebar button,.fbtn{background:#252321;color:#e8bd30;border:1px solid #3a3a3a;border-radius:4px;padding:6px 12px;font:10pt monospace;cursor:pointer} #palmsg{font:10pt monospace;opacity:0;transition:opacity .35s;margin-left:6px} @@ -111,21 +104,7 @@ <input type="text" id="newname" placeholder="name" onkeydown="if(event.key==='Enter')applyEdit()"> <button onclick="addColor()">+ add color</button> <button onclick="updateColor()">↻ update selected</button> - <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">⛰ ramp</button> <span id="palmsg"></span> - <div id="ramp" class="ramp" style="display:none"> - <div class="ramprow"> - <label>ramp from <b id="rampname">—</b></label> - <label title="steps each direction (1-4)">steps <input type="number" id="rampn" min="1" max="4" step="1" value="2" style="width:48px"></label> - <label title="OKLCH lightness delta per step (0.04-0.12)">stepL <input type="number" id="rampstepl" min="0.04" max="0.12" step="0.01" value="0.08" style="width:62px"></label> - <label title="how much chroma eases out toward the extremes (0-1)">chroma ease <input type="number" id="rampce" min="0" max="1" step="0.1" value="0.5" style="width:58px"></label> - <button onclick="renderRamp()">preview</button> - <button onclick="addAllRampSteps()">+ add all</button> - <button onclick="closeRamp()">close</button> - </div> - <div id="rampprev" class="rampprev"></div> - <div id="rampmsg"></div> - </div> <div id="picker" class="picker"> <div class="prow"> <div id="sv" class="sv"><canvas id="svmask" class="svmask"></canvas><div id="svsafe" class="svsafe" style="display:none"></div><div id="svcur" class="svcur"></div></div> @@ -526,6 +505,137 @@ function lMax(hue,chroma,fgSet,target){ for(let i=0;i<20;i++){const mid=(loL+hiL)/2;if(at(mid).r>=target)loL=mid;else hiL=mid;} return {L:loL,status:at(loL).clamped?'clamp':'ok'}; } + +// --- color families (color-families spec, Phase 1) --------------------------- +// Families are a display grouping derived from the hex every render — never from +// names — so renaming a color can't move it. The flat palette stays the editable +// truth; these pure functions group it, regenerate a family's ramp, and plan the +// assignment re-point across a regenerate. + +function oklchOf(hex){return oklab2oklch(srgb2oklab(hex));} +function nameOfHex(palette,hex){const p=palette.find(p=>p[0].toLowerCase()===hex.toLowerCase());return p?p[1]:null;} +function hueDist(a,b){const d=Math.abs(a-b);return Math.min(d,360-d);} + +// A color reads as neutral below this chroma. Lightness-scaled (the Munsell +// insight): the mid-tones need more chroma to read as a hue. Floored at both ends +// rather than tapering to zero, so pale warm grays stay neutral (and pure white, +// C=0 at L=1, doesn't evade a zero threshold) while pale chromatic tints stay +// colored. Tuned on real palettes (Codex + Fable color-sorting reviews). +function neutralThreshold(L){ + if(L<=0.2)return 0.020; + if(L<0.6)return 0.020+0.015*(L-0.2)/0.4; + if(L<0.85)return 0.035-0.017*(L-0.6)/0.25; + return 0.018; +} +// Lightness-conditioned compatibility of two chromatic colors (Fable's LCCL): +// hue must match tightly at equal lightness and may drift across a lightness gap, +// because a tonal ramp drifts in hue with lightness by design. The low-chroma noise +// term widens the hue tolerance where hue is ill-defined (pale tints). A chroma +// clause keeps a vivid accent out of a soft family at the same lightness. <=1 is +// compatible. Source: ~/color-sorting-fable.org. +function pairRatio(a,b){ + const dL=Math.abs(a.L-b.L),dH=hueDist(a.H,b.H); + const noise=Math.min(45,Math.atan(0.015/Math.max(Math.min(a.C,b.C),1e-6))*180/Math.PI); + return Math.max(dH/(12+60*dL+noise),Math.abs(a.C-b.C)/(0.08+0.3*dL)); +} +// Complete-linkage agglomerative clustering on pairRatio: greedily merge the two +// clusters whose worst cross-pair is most compatible, stopping when no merge has +// every cross-pair compatible. Complete linkage makes single-linkage chaining +// structurally impossible — two ramps can't fuse through their converging pale +// ends because their mid-lightness members stay far apart. +function clusterChromatic(ms){ + let cl=ms.map(m=>[m]); + const cd=(A,B)=>Math.max(...A.flatMap(a=>B.map(b=>pairRatio(a,b)))); + for(;;){ + let best=null; + for(let i=0;i<cl.length;i++)for(let j=i+1;j<cl.length;j++){const d=cd(cl[i],cl[j]);if(!best||d<best.d)best={d,i,j};} + if(!best||best.d>1)break; + cl[best.i]=cl[best.i].concat(cl[best.j]);cl.splice(best.j,1); + } + return cl; +} +// A family from its members: base is the most-saturated member (tie toward +// mid-lightness), the anchor for a generated ramp. +function makeFamily(ms,neutral){ + let base=ms[0]; + for(const m of ms)if(m.C>base.C||(m.C===base.C&&Math.abs(m.L-0.5)<Math.abs(base.L-0.5)))base=m; + return {base:base.hex,neutral:!!neutral,members:ms.map(m=>({hex:m.hex,name:m.name}))}; +} +// Group a flat palette into the ground strip plus families. ground is {bg,fg}: +// those two hexes form the pinned ground strip even when absent from the palette, +// and a palette chip at a ground hex is not duplicated into a family. Near-neutrals +// (chroma below the lightness-scaled threshold) form one neutral family; the rest +// cluster by lightness-conditioned complete linkage (clusterChromatic). +function familiesFromPalette(palette,ground){ + const bg=ground&&ground.bg,fg=ground&&ground.fg; + const gset=new Set([bg,fg].filter(Boolean).map(h=>h.toLowerCase())); + const groundStrip=[]; + if(bg)groundStrip.push({hex:bg,role:'bg',name:nameOfHex(palette,bg)}); + if(fg)groundStrip.push({hex:fg,role:'fg',name:nameOfHex(palette,fg)}); + const neutrals=[],chromatic=[]; + for(const [hex,name] of palette){ + if(gset.has(hex.toLowerCase()))continue; + const c=oklchOf(hex),m={hex,name,L:c.L,C:c.C,H:c.H}; + (c.C<neutralThreshold(c.L)?neutrals:chromatic).push(m); + } + const families=[]; + if(neutrals.length)families.push(makeFamily(neutrals,true)); + for(const cl of clusterChromatic(chromatic))families.push(makeFamily(cl,false)); + return {ground:groundStrip,families}; +} +// Regenerate a family's members as a symmetric ramp around the base: n=0 is the +// base alone (without ramp()'s 1-4 clamp), n>=1 is base plus ramp() steps, sorted +// by offset. {members:[{hex,offset,clamped}]} or {members:[],error:'bad-hex'}. +function regenFamily(baseHex,n,opts){ + const hex=typeof baseHex==='string'?normHex(baseHex):null; + if(!hex)return {members:[],error:'bad-hex'}; + const k=Math.min(4,Math.max(0,Math.round(n||0))); + if(k===0)return {members:[{hex,offset:0,clamped:false}]}; + const r=ramp(hex,Object.assign({},opts,{n:k})); + if(r.error)return {members:[],error:r.error}; + const members=[...r.steps,{hex,offset:0,clamped:false}].sort((a,b)=>a.offset-b.offset); + return {members}; +} +// Rank a family's current member hexes by lightness and give each a signed offset +// from the base (the matching hex, or the nearest by lightness if the base isn't +// present). Lets a regenerate match old positions to new ramp offsets. +function rankByLightness(memberHexes,baseHex){ + const items=memberHexes.map(h=>({hex:h,L:oklchOf(h).L})).sort((a,b)=>a.L-b.L); + let bi=items.findIndex(m=>m.hex.toLowerCase()===(baseHex||'').toLowerCase()); + if(bi<0){const bl=oklchOf(baseHex).L;let best=Infinity;items.forEach((m,i)=>{const d=Math.abs(m.L-bl);if(d<best){best=d;bi=i;}});} + return items.map((m,i)=>({hex:m.hex,offset:i-bi})); +} +// Plan the assignment re-point for a regenerate: for each old ranked member, the +// new member at the same offset is the same position. {map:[[old,new]]} for +// positions whose hex changed; {removed:[hex]} for positions with no new +// counterpart (the caller leaves their references a visible "(gone)"). +function stepRepointPlan(oldRanked,newMembers){ + const byOff=new Map(newMembers.map(m=>[m.offset,m.hex])),map=[],removed=[]; + for(const o of oldRanked){ + const nh=byOff.get(o.offset); + if(nh===undefined)removed.push(o.hex); + else if(nh.toLowerCase()!==o.hex.toLowerCase())map.push([o.hex,nh]); + } + return {map,removed}; +} + +// Order a family's members dark to light by OKLCH lightness. +function sortFamilyMembers(fam){return Object.assign({},fam,{members:[...fam.members].sort((a,b)=>oklchOf(a.hex).L-oklchOf(b.hex).L)});} +// Order families for display: neutrals first (by base lightness), then chromatic +// by base hue, ties broken by base lightness then base hex. Each family's members +// are lightness-sorted. Display-only — the stored palette order is untouched. +function sortFamilies(families){ + const keyed=families.map(f=>{const c=oklchOf(f.base);return {f,neutral:!!f.neutral,H:c.H,L:c.L,base:f.base};}); + keyed.sort((a,b)=>{ + if(a.neutral!==b.neutral)return a.neutral?-1:1; + if(a.neutral&&b.neutral)return a.L-b.L; + const ah=Math.round(a.H),bh=Math.round(b.H); // a hue hair shouldn't outrank lightness + if(ah!==bh)return ah-bh; + if(a.L!==b.L)return a.L-b.L; + return a.base.toLowerCase()<b.base.toLowerCase()?-1:a.base.toLowerCase()>b.base.toLowerCase()?1:0; + }); + return keyed.map(k=>sortFamilyMembers(k.f)); +} // Pure color/UI-boundary helpers (normHex, ratingColor, textOn), inlined from // app-util.js. textOn uses rl from the colormath core above. // Pure color/UI-boundary helpers: hex-input parsing, the contrast-rating status @@ -664,7 +774,23 @@ function buildTable(){ tr.appendChild(c2);tr.appendChild(lkTd);tr.appendChild(c0);tr.appendChild(stTd);tr.appendChild(crTd);tr.appendChild(exTd); tb.appendChild(tr);} } -let dragFrom=null,selectedIdx=null; +let selectedIdx=null; +// When a named palette color is deleted, remember its hex keyed by name so that +// recreating a color with the same name can re-bind the assignments still pointing +// at the old (now "(gone)") hex. Consumed once per name; cleared on import. +let lastGone={}; +// Re-point every assignment — syntax map, UI faces, package faces — from one hex +// to another. Used when a palette color's value is edited and when a deleted name +// is recreated. +function repointHex(oldHex,newHex){ + if(oldHex===newHex)return; + for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} + for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} + for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} +} +// On adding a color, if its name matches a recently-deleted one, re-bind the +// stranded assignments to the new hex. Returns true when a heal context existed. +function healGone(name,newHex){const k=name.toLowerCase();if(!(k in lastGone))return false;const g=lastGone[k];delete lastGone[k];repointHex(g,newHex);return true;} // Pairwise OKLab ΔE over the palette. Returns the sub-threshold pairs (sorted // closest-first) and each color's nearest-neighbor distance for its chip title. // Pure pairwise ΔE analysis lives in colormath.js (paletteWarnings); this renders it. @@ -676,35 +802,90 @@ function renderPaletteWarnings(warnings,overflow){ if(overflow>0)html+=`<div class="pwl">and ${overflow} more</div>`; w.innerHTML=html;w.style.display='block'; } +// One palette chip for PALETTE[i], with its remove / rename / select handlers. +// Families sort deterministically, so the old move-arrow / drag reordering is gone. +function paletteChip(i,nearest){ + const [hex,name]=PALETTE[i],tc=textOn(hex),nde=nearest[i]; + const locked=(hex===MAP['bg']||hex===MAP['p']); + const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex; + d.title=name+' '+hex+(nde===Infinity||nde===undefined?'':' — nearest ΔE '+nde.toFixed(3)); + const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">🔒</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`; + d.innerHTML=`${rm}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`; + if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();if(name)lastGone[name.toLowerCase()]=hex;PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; + d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; + d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm'))return;selectColor(i);}; + return d; +} +// Render the palette as hue families: the pinned ground strip, then hue-sorted +// family strips, each dark to light. Grouping is derived from the hex by +// familiesFromPalette every render, so renaming a color never moves it. The flat +// PALETTE stays the editable truth; chips keep their per-chip controls. function renderPalette(){ const p=document.getElementById('pals');p.innerHTML=''; const {warnings,overflow,nearest}=paletteWarnings(PALETTE,DELTAE_MIN,5); - PALETTE.forEach((pc,i)=>{const [hex,name]=pc;const tc=textOn(hex); - const nde=nearest[i]; - const locked=(hex===MAP['bg']||hex===MAP['p']); - const d=document.createElement('div');d.className='pchip'+(i===selectedIdx?' sel':'');d.style.background=hex;d.draggable=true; - d.title=name+' '+hex+(nde===Infinity?'':' — nearest \u0394E '+nde.toFixed(3)); - const lft=i>0?`<button class="mv l" title="move left" style="color:${tc}">‹</button>`:''; - const rgt=i<PALETTE.length-1?`<button class="mv r" title="move right" style="color:${tc}">›</button>`:''; - const rm=locked?`<span class="lock" title="${hex===MAP['bg']?'background':'foreground'} — can't remove" style="color:${tc}">🔒</span>`:`<button class="rm" title="remove" style="color:${tc}">×</button>`; - d.innerHTML=`${rm}${lft}${rgt}<input class="nm" value="${name}" style="color:${tc}"><div class="hx" style="color:${tc}">${hex}</div>`; - if(!locked)d.querySelector('.rm').onclick=(e)=>{e.stopPropagation();PALETTE.splice(i,1);if(selectedIdx===i)selectedIdx=null;renderPalette();buildTable();buildUITable();}; - if(lft)d.querySelector('.mv.l').onclick=(e)=>{e.stopPropagation();moveColor(i,-1);}; - if(rgt)d.querySelector('.mv.r').onclick=(e)=>{e.stopPropagation();moveColor(i,1);}; - d.querySelector('.nm').onchange=(e)=>{PALETTE[i][1]=e.target.value;buildTable();buildUITable();}; - d.onclick=(e)=>{if(e.target.closest('.rm')||e.target.closest('.nm')||e.target.closest('.mv'))return;selectColor(i);}; - d.ondragstart=()=>{dragFrom=i;d.classList.add('drag');}; - d.ondragend=()=>{d.classList.remove('drag');document.querySelectorAll('.pchip.over').forEach(x=>x.classList.remove('over'));}; - d.ondragover=(e)=>{e.preventDefault();if(dragFrom!==null&&dragFrom!==i)d.classList.add('over');}; - d.ondragleave=()=>d.classList.remove('over'); - d.ondrop=(e)=>{e.preventDefault();d.classList.remove('over');if(dragFrom===null||dragFrom===i)return;const m=PALETTE.splice(dragFrom,1)[0];PALETTE.splice(i,0,m);dragFrom=null;selectedIdx=null;renderPalette();buildTable();buildUITable();}; - p.appendChild(d);}); + const {ground,families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const used=new Set(); + const idxOf=(hex,name)=>{for(let i=0;i<PALETTE.length;i++)if(!used.has(i)&&PALETTE[i][0]===hex&&PALETTE[i][1]===name){used.add(i);return i;}return -1;}; + const strip=(cls)=>{const s=document.createElement('div');s.className='fstrip'+(cls||'');p.appendChild(s);return s;}; + const gs=strip(' ground');gs.dataset.family='ground'; + ground.forEach(g=>{ + const i=PALETTE.findIndex((pp,k)=>!used.has(k)&&pp[0]===g.hex); + if(i>=0){used.add(i);gs.appendChild(paletteChip(i,nearest));} + else{const tc=textOn(g.hex),sw=document.createElement('div');sw.className='pchip';sw.style.background=g.hex;sw.title=(g.role||'')+' '+g.hex; + sw.innerHTML=`<input class="nm" value="${g.role||''}" disabled style="color:${tc}"><div class="hx" style="color:${tc}">${g.hex}</div>`;gs.appendChild(sw);} + }); + // The too-similar warning stays on the full flat palette: a generated ramp's + // steps are a stepL apart (well above the warning's ΔE threshold), so they never + // trigger it, and any pair that does is a genuine near-duplicate worth flagging. + sortFamilies(families).forEach(f=>{ + const s=strip(f.neutral?' neutral':'');s.dataset.family=f.base; + f.members.forEach(m=>{const i=idxOf(m.hex,m.name);if(i>=0)s.appendChild(paletteChip(i,nearest));}); + if(!f.neutral)s.appendChild(familyCountControl(f)); + }); renderPaletteWarnings(warnings,overflow); buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); } +// The per-family count control under a chromatic strip. Its value is the family's +// current per-side reach; setting N regenerates the family as base ±N. +function familyCountControl(f){ + const per=Math.max(0,...rankByLightness(f.members.map(m=>m.hex),f.base).map(m=>Math.abs(m.offset))); + const d=document.createElement('div');d.className='fcount'; + d.innerHTML=`<span title="generate a symmetric ramp of N steps each side of this family's base — this replaces the family">± <input type="number" min="0" max="4" value="${per}"></span>`; + d.querySelector('input').onchange=(e)=>setFamilyCount(f.base,Math.max(0,Math.min(4,parseInt(e.target.value,10)||0))); + return d; +} +// Regenerate a family as a symmetric base ±N ramp, replacing its current members. +// References to a surviving position (matched by signed lightness rank) follow the +// new hex; references to a position removed by lowering N leave their old hex, +// which is no longer in the palette and so renders as "(gone)". +// Replace oldHexes in the palette with a fresh base ±n ramp, repointing surviving +// references and leaving removed ones on their now-gone hex. Returns the removed +// count, or null on a bad base. Shared by the count control and the base edit. +function regenFamilyInPlace(oldHexes,baseHex,baseName,n){ + const r=regenFamily(baseHex,n,{}); + if(r.error){notify('cannot regenerate from '+baseHex,true);return null;} + const plan=stepRepointPlan(rankByLightness(oldHexes,baseHex),r.members); + const oldSet=new Set(oldHexes.map(h=>h.toLowerCase())); + let at=PALETTE.length; + for(let i=0;i<PALETTE.length;i++)if(oldSet.has(PALETTE[i][0].toLowerCase())){at=i;break;} + for(let i=PALETTE.length-1;i>=0;i--)if(oldSet.has(PALETTE[i][0].toLowerCase()))PALETTE.splice(i,1); + const entries=r.members.map(m=>[m.hex,m.offset===0?baseName:baseName+(m.offset>0?'+'+m.offset:String(m.offset))]); + PALETTE.splice(Math.min(at,PALETTE.length),0,...entries); + for(const [o,nw] of plan.map)repointHex(o,nw); + return plan.removed.length; +} +function setFamilyCount(baseHex,n){ + const {families}=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}); + const fam=families.find(f=>f.base.toLowerCase()===baseHex.toLowerCase()); + if(!fam)return; + const baseName=(fam.members.find(m=>m.hex.toLowerCase()===baseHex.toLowerCase())||{}).name||'color'; + const removed=regenFamilyInPlace(fam.members.map(m=>m.hex),baseHex,baseName,n); + if(removed===null)return; + selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround(); + notify('regenerated "'+baseName+'" to ±'+n+(removed?(' — '+removed+' removed step(s) show "(gone)" where used'):''),false); +} function notify(msg,err){const m=document.getElementById('palmsg');if(!m)return;m.textContent=msg;m.style.color=err?'#cb6b4d':'#8a9496';m.style.opacity='1';clearTimeout(m._t);m._t=setTimeout(()=>{m.style.opacity='0';},err?4000:2800);} function applyEdit(){if(selectedIdx!==null)updateColor();else addColor();} -function moveColor(i,dir){const j=i+dir;if(j<0||j>=PALETTE.length)return;const t=PALETTE[i];PALETTE[i]=PALETTE[j];PALETTE[j]=t;if(selectedIdx===i)selectedIdx=j;else if(selectedIdx===j)selectedIdx=i;renderPalette();buildTable();buildUITable();} function selectColor(i){selectedIdx=i;const [hex,name]=PALETTE[i];setHex(hex);document.getElementById('newname').value=name;renderPalette();notify('editing "'+name+'" — change the value, then Enter (or Update selected) to save',false);} function updateColor(){ if(selectedIdx===null){notify('click a palette color to select it first',true);return;} @@ -712,10 +893,17 @@ function updateColor(){ const newHex=curHex(); const newName=(document.getElementById('newname').value.trim())||PALETTE[i][1]; if(PALETTE.some((p,j)=>j!==i&&p[1].toLowerCase()===newName.toLowerCase())){notify('another color is already named "'+newName+'" — names must be unique',true);return;} + // If the edited color is a family base with a ramp, recolor the whole family: regenerate from the new base at the same count. + const fams=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families; + const fam=fams.find(f=>!f.neutral&&f.base.toLowerCase()===oldHex.toLowerCase()); + const count=fam?Math.max(0,...rankByLightness(fam.members.map(m=>m.hex),fam.base).map(m=>Math.abs(m.offset))):0; PALETTE[i]=[newHex,newName]; - for(const k in MAP){if(MAP[k]===oldHex)MAP[k]=newHex;} - for(const f in UIMAP){if(UIMAP[f].fg===oldHex)UIMAP[f].fg=newHex;if(UIMAP[f].bg===oldHex)UIMAP[f].bg=newHex;} - for(const ap in PKGMAP)for(const fc in PKGMAP[ap]){const o=PKGMAP[ap][fc];if(o.fg===oldHex)o.fg=newHex;if(o.bg===oldHex)o.bg=newHex;} + repointHex(oldHex,newHex); + if(fam&&count>0){ + const oldHexes=fam.members.map(m=>m.hex.toLowerCase()===oldHex.toLowerCase()?newHex:m.hex); + regenFamilyInPlace(oldHexes,newHex,newName,count); + closePicker();selectedIdx=null;renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('recolored "'+newName+'" family from the new base',false);return; + } closePicker();renderPalette();buildTable();buildUITable();renderCode();applyGround();notify('updated "'+newName+'"',false); } function curHex(){return normHex(document.getElementById('newhexstr').value)||'#888888';} @@ -811,61 +999,10 @@ function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;s function addColor(){const h=curHex();const name=document.getElementById('newname').value.trim(); if(!name){notify('name the color before adding it',true);return;} if(PALETTE.some(p=>p[1].toLowerCase()===name.toLowerCase())){notify('a color named "'+name+'" already exists — select it and use Update selected to change its value',true);return;} - PALETTE.push([h,name]);document.getElementById('newname').value='';selectedIdx=null;closePicker();renderPalette();buildTable();notify('added "'+name+'"',false);} -// --- ramp generator UI (palette-ramps spec, Phase 2) ------------------------- -// Generate a tonal ramp from the current color, preview the steps, add the ones -// you want as named palette entries. The pure ramp() lives in app-core.js; this -// is the DOM around it. Names derive from the source swatch (blue -> blue+1). -let rampBase=null; // {hex,name} of the last previewed base (refreshed from the tile on preview) -// The base the ramp generates from is whatever sits on the color-selection tile -// right now: the selected palette color, or a typed hex and name. Reading it at -// preview time means selecting a new palette color then pressing preview just -// works, the same as reopening the panel. -function rampBaseFromTile(){const hex=curHex(),name=(selectedIdx!=null?PALETTE[selectedIdx][1]:document.getElementById('newname').value.trim())||'ramp';return {hex,name};} -function openRamp(){document.getElementById('ramp').style.display='block';renderRamp();} -function closeRamp(){const r=document.getElementById('ramp');if(r)r.style.display='none';} -function rampOpts(){return {n:parseInt(document.getElementById('rampn').value,10),stepL:parseFloat(document.getElementById('rampstepl').value),chromaEase:parseFloat(document.getElementById('rampce').value)};} -function rampStepName(off){return rampBase.name+(off>0?'+'+off:String(off));} -function rampNote(msg,err){const m=document.getElementById('rampmsg');if(!m)return;m.textContent=msg||'';m.style.color=err?'#cb6b4d':'#8a9496';} -function rampNameTaken(nm){return PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase());} -function renderRamp(){ - rampBase=rampBaseFromTile(); - document.getElementById('rampname').textContent=rampBase.name+' '+rampBase.hex; - const r=ramp(rampBase.hex,rampOpts()),prev=document.getElementById('rampprev');prev.innerHTML=''; - if(r.error){rampNote('not a valid base color',true);return;} - const dups=[]; - r.steps.forEach(s=>{const nm=rampStepName(s.offset),taken=rampNameTaken(nm);if(taken)dups.push(nm); - const c=document.createElement('div');c.className='rchip'+(taken?' dup':'');c.style.background=s.hex;c.style.color=textOn(s.hex); - c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'')+(taken?' — a palette color is already named this; it will be skipped on add':''); - c.innerHTML=`<span>${esc(nm)}</span><span class="rhex">${s.hex}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}${taken?'<span class="rdup" title="name already in the palette">⊘</span>':''}`; - c.onclick=()=>addRampStep(s);prev.appendChild(c);}); - const parts=[]; - if(r.adjusted.length)parts.push('adjusted: '+r.adjusted.join(', ')); - if(dups.length)parts.push('name already in palette, will be skipped on add: '+dups.join(', ')); - rampNote(parts.join(' | '),dups.length>0); -} -// Insert a step adjacent to the source swatch, keeping the ramp siblings in -// -n..+n order. A name collision is flagged and skipped (never overwrites); a -// hex that already exists under another name is added but flagged as a duplicate. -function rampInsertIndex(off){ - const bn=rampBase.name,re=new RegExp('^'+bn.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')+'([+-]\\d+)$'); - let src=PALETTE.findIndex(p=>p[1]===bn);if(src<0)src=PALETTE.length-1; - let idx=src+1;while(idx<PALETTE.length){const m=PALETTE[idx][1].match(re);if(m&&parseInt(m[1],10)<off){idx++;continue;}break;} - return idx; -} -function addRampStep(s){ - const nm=rampStepName(s.offset); - if(PALETTE.some(p=>p[1].toLowerCase()===nm.toLowerCase())){rampNote('"'+nm+'" already exists — rename or skip',true);return false;} - const dup=PALETTE.find(p=>p[0].toLowerCase()===s.hex.toLowerCase()); - PALETTE.splice(rampInsertIndex(s.offset),0,[s.hex,nm]);renderPalette();buildTable();buildUITable(); - rampNote(dup?('added "'+nm+'" (same hex as "'+dup[1]+'")'):('added "'+nm+'"'),false);return true; -} -function addAllRampSteps(){ - if(!rampBase)return;const r=ramp(rampBase.hex,rampOpts()); - if(r.error){rampNote('not a valid base color',true);return;} - let added=0;const skipped=[];r.steps.forEach(s=>{addRampStep(s)?added++:skipped.push(rampStepName(s.offset));}); - rampNote('added '+added+(skipped.length?(' | skipped (name already in palette): '+skipped.join(', ')):''),skipped.length>0); -} + PALETTE.push([h,name]);const healed=healGone(name,h);document.getElementById('newname').value='';selectedIdx=null;closePicker(); + renderPalette();buildTable();buildUITable(); + if(healed){renderCode();applyGround();if(document.getElementById('pkgbody'))buildPkgTable();buildPkgPreview();} + notify(healed?('added "'+name+'" and reconnected its assignments'):('added "'+name+'"'),false);} function themeName(){return (document.getElementById('themename').value||'theme').trim()||'theme';} function fileSlug(){return slugify(themeName());} function exportObj(){const a={};CATS.forEach(c=>a[c[0]]=MAP[c[0]]);const o={name:themeName(),palette:PALETTE,assignments:a,bold:Object.keys(BOLD).filter(k=>BOLD[k]),italic:Object.keys(ITALIC).filter(k=>ITALIC[k]),ui:UIMAP};if(LOCKED.size)o.locks=[...LOCKED];const pk=packagesForExport(PKGMAP);if(Object.keys(pk).length)o.packages=pk;return o;} @@ -879,7 +1016,7 @@ async function saveTheme(){const data=JSON.stringify(exportObj(),null,1); try{if(!fileHandle)fileHandle=await window.showSaveFilePicker({suggestedName:fileSlug()+'.json',types:[{description:'theme JSON',accept:{'application/json':['.json']}}]}); const w=await fileHandle.createWritable();await w.write(data);await w.close();notify('saved "'+themeName()+'"',false);updateTitle(); }catch(e){if(e&&e.name!=='AbortError')notify('save failed: '+e.message,true);}} -function applyImported(text){const d=JSON.parse(text);if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); +function applyImported(text){const d=JSON.parse(text);lastGone={};if(d.name)document.getElementById('themename').value=d.name;if(d.palette)PALETTE=d.palette;if(d.assignments)Object.assign(MAP,d.assignments); BOLD={};(d.bold||[]).forEach(k=>BOLD[k]=true);ITALIC={};(d.italic||[]).forEach(k=>ITALIC[k]=true); LOCKED=new Set(d.locks||[]); if(d.ui)Object.assign(UIMAP,d.ui); @@ -1536,30 +1673,6 @@ if(location.hash==='#readouttest'){const hex='#67809c';document.getElementById(' const sane=Math.abs(lch.L-0.591)<0.01&&Math.abs(lch.C-0.052)<0.01&&Math.abs(lch.H-251.6)<2; const ok=wired&&sane;document.title='READOUTTEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='readouttest';d.textContent='READOUTTEST '+(ok?'PASS':'FAIL')+' oklch='+o+' | apca='+a+' | wcag='+w;document.body.appendChild(d);} -// Ramp UI gate (open with #ramptest): generation count, ordered insertion after -// the source swatch, name-collision skip, and a clamp badge on an out-of-gamut step. -if(location.hash==='#ramptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; - const save=PALETTE.slice(); - PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];renderPalette(); - selectedIdx=PALETTE.findIndex(p=>p[1]==='blue');document.getElementById('newhexstr').value='#67809c';document.getElementById('newname').value='blue'; - openRamp();document.getElementById('rampn').value='2';document.getElementById('rampstepl').value='0.08';document.getElementById('rampce').value='0.5';renderRamp(); - A(document.querySelectorAll('#rampprev .rchip').length===4,'expected 4 step chips, got '+document.querySelectorAll('#rampprev .rchip').length); - A(document.querySelectorAll('#rampprev .rchip .rhex').length===4,'each step tile shows its hex'); - addAllRampSteps(); - const names=PALETTE.map(p=>p[1]),bi=names.indexOf('blue'); - A(names.slice(bi,bi+5).join(',')==='blue,blue-2,blue-1,blue+1,blue+2','order after blue: '+names.slice(bi,bi+5).join(',')); - const before=PALETTE.length;addAllRampSteps();A(PALETTE.length===before,'re-add should skip existing names'); - A(/skipped \(name already in palette\): blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'add-all names the skipped collisions: '+document.getElementById('rampmsg').textContent); - renderRamp(); - A(document.querySelectorAll('#rampprev .rchip.dup').length===4,'re-preview marks the now-existing names as dup'); - A(/already in palette.*blue-2, blue-1, blue\+1, blue\+2/.test(document.getElementById('rampmsg').textContent),'preview names the colliding tiles: '+document.getElementById('rampmsg').textContent); - // preview re-reads the color-selection tile: change the tile, press preview, the base follows - document.getElementById('newhexstr').value='#2040e0';document.getElementById('newname').value='vivid';selectedIdx=null;document.getElementById('rampce').value='0';renderRamp(); - A(/^vivid #2040e0/.test(document.getElementById('rampname').textContent),'preview reads the tile: '+document.getElementById('rampname').textContent); - A(document.querySelectorAll('#rampprev .rclamp').length>0,'vivid base at chroma-ease 0 should clamp an extreme step'); - PALETTE=save;selectedIdx=null;renderPalette();closeRamp(); - document.title='RAMPTEST '+(ok?'PASS':'FAIL'); - const d=document.createElement('div');d.id='ramptest';d.textContent='RAMPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} // Worst-case readout gate (open with #contrasttest): a covered overlay face shows // the floor over its foreground set and names the limiting foreground, an // out-of-scope face keeps the single-pair readout, and an empty set reads "no fg set". @@ -1600,4 +1713,105 @@ if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c setPkModel('hsv');closePicker(); document.title='SAFETEST '+(ok?'PASS':'FAIL'); const d=document.createElement('div');d.id='safetest';d.textContent='SAFETEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Gone-rebind gate (open with #healtest): deleting a named color then recreating +// the name re-points the assignments stranded on the old hex to the new color. +if(location.hash==='#healtest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),savePK=JSON.parse(JSON.stringify(PKGMAP)),saveG=Object.assign({},lastGone),saveSel=selectedIdx; + PALETTE=[['#0d0b0a','ground'],['#cdced1','fg'],['#67809c','blue']];MAP['kw']='#67809c';lastGone={};selectedIdx=null;renderPalette();buildTable(); + const blue=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='blue'); + A(!!(blue&&blue.querySelector('.rm')),'blue chip has a remove button'); + if(blue&&blue.querySelector('.rm'))blue.querySelector('.rm').click(); + A(!PALETTE.some(p=>p[1]==='blue'),'blue was deleted'); + A(lastGone['blue']==='#67809c','delete recorded the gone name->hex'); + document.getElementById('newhexstr').value='#5a7a9a';document.getElementById('newname').value='blue';selectedIdx=null;addColor(); + A(MAP['kw']==='#5a7a9a','assignment re-bound to the recreated name, got '+MAP['kw']); + A(!('blue' in lastGone),'heal consumed the gone entry'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);PKGMAP=savePK;lastGone=saveG;selectedIdx=saveSel; + renderPalette();buildTable();buildUITable();if(document.getElementById('pkgbody'))buildPkgTable(); + document.title='HEALTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='healtest';d.textContent='HEALTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Family-strip gate (open with #familytest): the palette renders as the pinned +// ground strip plus hue families, chips keep their controls, and renaming a color +// to anything leaves it in the same strip (grouping is by hex, not name). +if(location.hash==='#familytest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg'],['#c0402a','red'],['#3a6ea5','blue'],['#808080','gray']];selectedIdx=null;renderPalette(); + const strips=[...document.querySelectorAll('#pals .fstrip')]; + A(strips.length&&strips[0].classList.contains('ground'),'ground strip is pinned first'); + A(strips[0].querySelectorAll('.pchip').length===2,'ground strip carries bg + fg'); + A(strips.length>=4,'ground + neutral + red + blue strips, got '+strips.length); + const redChip=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='red'); + A(!!redChip&&!!redChip.querySelector('.rm')&&!!redChip.querySelector('.nm'),'a family chip keeps remove + rename controls'); + const redFamily=redChip&&redChip.closest('.fstrip').dataset.family; + const ri=PALETTE.findIndex(p=>p[1]==='red');PALETTE[ri][1]='zztop-absurd';renderPalette(); + const renamed=[...document.querySelectorAll('#pals .pchip')].find(c=>c.querySelector('.nm')&&c.querySelector('.nm').value==='zztop-absurd'); + A(!!renamed&&renamed.closest('.fstrip').dataset.family===redFamily,'a renamed color stays in the same strip'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);selectedIdx=saveSel;renderPalette(); + document.title='FAMILYTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='familytest';d.textContent='FAMILYTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Count-control gate (open with #counttest): the per-family count regenerates the +// family — count up adds symmetric steps, count down drops the extremes, a +// reference to a surviving step follows the new hex, a reference to a removed step +// is left on its old (now-gone) hex. +if(location.hash==='#counttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx; + MAP['bg']='#000000';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + const innerOld=regenFamily('#67809c',2).members.find(m=>m.offset===1).hex; // survives a count change + const outerOld=regenFamily('#67809c',2).members.find(m=>m.offset===2).hex; // dropped on count-down + UIMAP['region']={fg:null,bg:innerOld,bold:false,italic:false,underline:false,strike:false}; + UIMAP['highlight']={fg:null,bg:outerOld,bold:false,italic:false,underline:false,strike:false}; + selectedIdx=null;renderPalette(); + setFamilyCount('#67809c',1); + const palHexes=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(!palHexes.has(outerOld.toLowerCase()),'outer step removed from palette on count down'); + A(UIMAP['highlight'].bg.toLowerCase()===outerOld.toLowerCase(),'a removed-step reference stays on its old (gone) hex'); + const newInner=regenFamily('#67809c',1).members.find(m=>m.offset===1).hex; + A(UIMAP['region'].bg.toLowerCase()===newInner.toLowerCase(),'a surviving-step reference followed the regenerate, got '+UIMAP['region'].bg); + setFamilyCount('#67809c',3); + const want3=regenFamily('#67809c',3).members.map(m=>m.hex.toLowerCase()); + const have=new Set(PALETTE.map(p=>p[0].toLowerCase())); + A(want3.every(h=>have.has(h)),'count up to 3 adds all 7 ramp colors to the palette'); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); + document.title='COUNTTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='counttest';d.textContent='COUNTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Base-edit + ground-edit gate (open with #baseedittest): editing a family base +// recolors the whole family at the same count and references follow; editing a +// ground swatch writes the bg/fg assignment. +if(location.hash==='#baseedittest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const saveP=PALETTE.slice(),saveM=Object.assign({},MAP),saveU=JSON.parse(JSON.stringify(UIMAP)),saveSel=selectedIdx; + MAP['bg']='#0d0b0a';MAP['p']='#f0fef0'; + PALETTE=[['#0d0b0a','ground'],['#f0fef0','fg']]; + regenFamily('#67809c',2).members.forEach(m=>PALETTE.push([m.hex,m.offset===0?'blue':'blue'+(m.offset>0?'+'+m.offset:m.offset)])); + UIMAP['region']={fg:null,bg:'#67809c',bold:false,italic:false,underline:false,strike:false}; + renderPalette();buildUITable(); + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#67809c'); + document.getElementById('newhexstr').value='#3a8a8a';document.getElementById('newname').value='teal'; + updateColor(); + const fam=familiesFromPalette(PALETTE,{bg:MAP['bg'],fg:MAP['p']}).families.find(f=>!f.neutral); + A(fam&&fam.members.some(m=>m.hex.toLowerCase()==='#3a8a8a'),'family base recolored to the new hex'); + A(fam&&fam.members.length===5,'count preserved (±2 → 5 members), got '+(fam&&fam.members.length)); + A(!new Set(PALETTE.map(p=>p[0].toLowerCase())).has('#67809c'),'old base removed from palette'); + A(UIMAP['region'].bg.toLowerCase()==='#3a8a8a','a reference to the base followed to the new base hex'); + // ground edit: select bg, change hex, MAP.bg follows + selectedIdx=PALETTE.findIndex(p=>p[0].toLowerCase()==='#0d0b0a'); + document.getElementById('newhexstr').value='#101010';document.getElementById('newname').value='ground'; + updateColor(); + A(MAP['bg'].toLowerCase()==='#101010','editing the bg swatch wrote the bg assignment, got '+MAP['bg']); + PALETTE=saveP;for(const k in MAP)delete MAP[k];Object.assign(MAP,saveM);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveU);selectedIdx=saveSel;renderPalette(); + document.title='BASEEDITTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='baseedittest';d.textContent='BASEEDITTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} +// Round-trip gate (open with #roundtriptest): export stays a flat palette and +// import needs no family reconstruction, so export → import → export is identical. +if(location.hash==='#roundtriptest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}}; + const before=JSON.stringify(exportObj()); + applyImported(before); + const after=JSON.stringify(exportObj()); + A(before===after,'export → import → export is byte-identical'); + const obj=JSON.parse(after); + A(Array.isArray(obj.palette)&&obj.palette.every(e=>Array.isArray(e)&&e.length===2),'exported palette is still a flat [hex,name] list'); + document.title='ROUNDTRIPTEST '+(ok?'PASS':'FAIL'); + const d=document.createElement('div');d.id='roundtriptest';d.textContent='ROUNDTRIPTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);} </script>
\ No newline at end of file @@ -92,36 +92,32 @@ Phase 5 (commit =843bbf08=). The OKLCH picker gets a "safe for" selector over th *** 2026-06-09 Tue @ 19:06:46 -0500 README + test-surface close-out landed Commit =23926837=. README documents the ramp controls and defaults, the worst-case floor / limiting foreground, the five covered faces, the safe-lightness guidance, and WCAG-drives-PASS-FAIL with APCA as a diagnostic; the browser-gate list is updated. =make theme-studio-test= carries all new node tests and the #ramptest/#contrasttest/#safetest gates. All acceptance criteria met. +** TODO [#B] theme-studio color families :feature:theme-studio: +Show the palette as hue-grouped strips (dark→light) over the existing flat, individually-editable palette. Grouping is by OKLCH hue from the hex, so renaming a color never moves it. A per-strip count control generates a symmetric ramp (N → base ±N) from the strip's most-saturated color; regenerate is authoritative, repointing surviving-step references by lightness rank and leaving removed-step references a visible "(gone)". The ground strip is synthesized from the bg/fg assignments and pinned first; the standalone ramp panel is removed. Designed in [[file:docs/theme-studio-color-families-spec.org][docs/theme-studio-color-families-spec.org]]. Codex-reviewed Ready 2026-06-10 after response folded: pivoted from name-derived families to hex-derived families over a flat palette, which designs out the name-grammar/import-inference and chip-ownership blockers. All review findings dispositioned; both open decisions resolved. Builds on and supersedes the palette-ramps v1 ramp UI. + +All six phases landed 2026-06-10 (commits ebe18d51, 74db9a52, 111687b0, e7ae18c4, 77783126, f6ab0001, 9daeff15, and the Phase 6 commit); =make theme-studio-test= green (98 node tests, 16 browser gates). Code-complete and self-verified. The hue-adjacent warm-color grouping limitation is filed as a separate research task (=~/color-sorting.org=). Remaining: the manual aesthetic/fidelity sign-off under the Manual testing parent (hue grouping reads right, regenerate-replace reads as deliberate, removed-step "(gone)" is clear). Mark this DONE once that passes. +*** 2026-06-10 Wed @ 01:17:45 -0500 Family model core landed +Phase 1 (commit =ebe18d51=, grouping reworked in =77783126=). =familiesFromPalette=, =regenFamily=, =rankByLightness=, =stepRepointPlan= in app-core.js, pure and hex-derived. Grouping started as gap-clustering + flat neutral threshold; after the design discussion it became nearest-hue-anchor bucketing (no single-linkage chaining) + a lightness-scaled neutral threshold (pale tints keep their hue, mid grays go neutral). regenFamily handles n=0 without ramp()'s clamp; stepRepointPlan maps survivors / lists removed by signed lightness rank. 20 node tests including the green/yellow split and the no-chaining case. Open: hue-adjacent warm colors still merge — research task above (=~/color-sorting.org=). +*** 2026-06-10 Wed @ 01:17:45 -0500 Family sort core landed +Phase 2 (commit =74db9a52=). =sortFamilies=/=sortFamilyMembers=: neutrals first, then chromatic by base hue (rounded so a hue hair doesn't outrank lightness), ties by base lightness then hex; members dark→light. Display-only; stored palette order untouched. 4 node tests. +*** 2026-06-10 Wed @ 01:17:45 -0500 Family-strip rendering landed +Phase 3 (commit =111687b0=, columns =e7ae18c4=). renderPalette restructured into the pinned ground strip + hue-sorted family columns (top→bottom dark→light), chips keep per-chip rename/remove/select, move-arrows/drag dropped. #familytest gate locks the structure + rename-stays-in-strip. Existing palette flows stay green. +*** 2026-06-10 Wed @ 01:17:45 -0500 Count control + regenerate landed +Phase 4 (commit =f6ab0001=). Per-chromatic-strip count input (0-4); setting N regenerates the family as base ±N, repointing survivor references by lightness rank and leaving removed-step references on their now-gone hex. Also fixed the neutral-threshold curve to taper at both lightness ends (symmetric Munsell) so chroma-eased dark/light extremes keep their hue. #counttest gate covers count up/down + the survivor/removed reference behavior. +*** 2026-06-10 Wed @ 01:17:45 -0500 Base edit + retire ramp panel landed +Phase 5 (commit =9daeff15=). Editing a family base recolors the whole family (shared =regenFamilyInPlace= with the count control); editing a ground swatch writes the bg/fg assignment. The standalone ramp panel (button, panel, JS, CSS, #ramptest) is removed — fan a color via its column's count instead. #baseedittest gate covers base-edit recolor + reference follow + the bg-swatch edit. +*** 2026-06-10 Wed @ 01:17:45 -0500 Warnings, seeding, export, README close-out landed +Phase 6 (commit =c175e2be=). Export stays a flat palette and import needs no reconstruction (#roundtriptest: export→import→export byte-identical). =seedPkgmap= reads the flat palette unchanged. The too-similar warning stays on the full palette — the planned ramp-step exemption was dropped after analysis: ramp steps are a stepL apart (well above the ΔE threshold) so they never warn, and exempting same-family pairs would hide genuine near-duplicates (caught by #deltatest). README documents families, the ground strip, the count control/regenerate, removed-step references, and the ramp-panel removal. + +** TODO [#C] Color-family per-hex hint override :feature:theme-studio: +For the ~1 color per palette that sits on a ramp-collision point (e.g. yellow+2 on the distinguished palette, which by every hex signal belongs to the olive ramp though its name says gold), automatic grouping cannot recover the designer's intent. Add a per-hex family override: drag a swatch to a different column, store the override keyed by hex (never the name, so renaming is still free), consult it after the LCCL clustering, and drop/mark-stale it when the hex changes substantially. Export stays mostly flat; only overrides are extra metadata. Both reviews recommend this exact shape; details in =~/color-sorting-fable.org= (§ "The irreducible case") and =~/color-sorting-codex.org= (§ "What to store"). + ** TODO [#C] Internet radio now-playing song :feature:music:emms: Show the currently-playing song while streaming an internet radio station. Lives in =modules/music-config.el= (EMMS + MPV backend, M3U radio stations). The track title comes from the stream's ICY metadata — EMMS exposes it via =emms-track-description= / =emms-playing-time= and updates it on the metadata-change hook; MPV reports the ICY title too. Add an option to show the song in the minibuffer (e.g. echo on track change, or an on-demand command). Consider also a mode-line indicator as a second surface. ** TODO [#C] Evaluate jamescherti essential-emacs-packages list :packages:research: Review [[https://www.jamescherti.com/essential-emacs-packages/][James Cherti's essential Emacs packages]] for anything worth installing. Cross-check each candidate against what is already in the config (=modules/= + =init.el=), skip the ones already present, and shortlist the genuinely new ones with a one-line rationale. Future-installation research, not a commitment to install. -** DONE [#B] theme-studio comprehensive previews (org/magit/elfeed/ghostel/mu4e/dashboard) :feature:theme:theme-studio: -CLOSED: [2026-06-08 Mon] -Expanded the bespoke previews to near-complete face coverage and added three new ones. org now exercises 83/88 faces (document + agenda; the 5 skipped are non-visual: org-hide, org-indent, org-clock-overlay, org-default, org-date-selected). magit 97/98 (status buffer + blame/reflog/sequence/bisect/signature sampler rows). elfeed 13/13. New bespoke previews: ghostel 19/19 (mock terminal, 16 ANSI colors + default + fake cursor), mu4e 37/37 (curated face list, not in the generated inventory; headers list + message view + compose), dashboard 8/8. So clicking a face row flashes a real preview element for nearly every face. Originally filed as just the org preview. - -** DONE [#A] theme-studio theme.json -> dupre-*.el converter :feature:theme:theme-studio: -CLOSED: [2026-06-08 Mon] -Built as scripts/theme-studio/build-theme.el (sibling to build-inventory.el), emitting a single self-contained themes/<name>-theme.el deftheme (not the palette/faces/theme trio — a theme.json carries resolved per-face hex, not dupre's semantic layer). All four tiers convert: default from assignments.bg/.p, syntax categories -> font-lock/tree-sitter faces with bold/italic sets, UI passthrough, packages with :inherit/:height/weight/slant. 20 ERT tests in tests/test-build-theme.el (Normal/Boundary/Error + an end-to-end load + a WCAG-AA assertion on the round-tripped result). One mapping limitation documented: the dec (decorator) key has no independent Emacs face (Emacs renders decorators with font-lock-type-face, which ty owns), so dec is omitted and decorators follow the type color. - -The last link in the pipeline: turn a theme.json exported by the theme-studio into a real loadable Emacs theme. Elisp (per Craig), TDD — this is the correctness-sensitive piece. - -Inputs (all on disk; no chat history needed): -- theme.json contract: =scripts/theme-studio/README.md= (theme.json section) and =docs/design/theme-studio-package-faces-spec.org= (State and export policy, Relative height, Inheritance). -- Reference face layout: existing =themes/dupre-palette.el= + =themes/dupre-faces.el= + =themes/dupre-theme.el=, and =tests/test-dupre-theme.el= (WCAG-contrast helper to reuse). -- Conventions: =.claude/rules/elisp.md=, =.claude/rules/elisp-testing.md=. - -Scope: -1. Read theme.json. Set =default= from =assignments.bg= / =assignments.p=. -2. Author the syntax category -> font-lock face map (~21 keys: kw->font-lock-keyword-face, str->font-lock-string-face, fnd->font-lock-function-name-face, fnc->font-lock-function-call-face, op->font-lock-operator-face, punc->font-lock-punctuation-face, etc. incl. the Emacs-29 tree-sitter additions). Apply =bold= / =italic= sets. -3. UI faces: the =ui= keys are already real face names (region, cursor, mode-line, ...) -> near 1:1 passthrough of fg/bg. -4. Package faces: =packages= -> each face spec, writing =:inherit PARENT= for inherited faces + only the overridden attrs, =:height= when != 1.0, weight/slant. -5. Emit a deftheme file (or palette+faces+theme trio mirroring dupre's layout). - -TDD targets: old-JSON (no packages) loads; every category maps; round-trip of fg/bg/bold/italic/inherit/height into valid face specs; WCAG-contrast assertion on the result. Decide whether the converter lives under =scripts/theme-studio/= (emits to =themes/=) or =themes/=. - ** TODO [#B] Dupre diff-changed / diff-refine-changed legibility :bug:dupre: Surfaced 2026-06-07 from a pearl session designing its modified-ticket indicator (pearl marks a changed field by inheriting =diff-changed=). dupre's =diff-refine-changed= is bright gold (#ffd700) under near-white text (#f0fef0) -- WCAG contrast ~1.35, unreadable as a plain background. It only looks fine inside diff-mode because diff-mode overlays its own dark foreground. =diff-changed= (#875f00 amber) is ~5.49, readable but off the modus model. Every modus variant keeps both faces legible (contrast 9-16) by pairing a dark low-saturation background with a hue-matched foreground. @@ -144,49 +140,6 @@ A full =make test= run (2026-06-07) is green across 516 of 517 files; the only f Build a new theme (working name "dupre-clear", final name TBD) that takes dupre's color identity and rebuilds it Prot's way: contrast-first, targeting WCAG AAA (~7:1 on the ground), where the in-progress dupre revision is mood/depth-first and lands at AA. Same hues (dupre blue, emerald, gold, terracotta, regal violet, mint) brightened to clear the AAA floor; same modus-style role mapping (blue keywords bold, gold functions, violet types, emerald strings, terracotta constants, silver default, warm-grey comments, metallic greys, navy + regal fills). Build the dupre revision first; this reuses its hue choices as the starting point. Full design + methodology + starting palette + open questions in the spec: [[file:docs/design/dupre-clear-theme.org][docs/design/dupre-clear-theme.org]]. Key prerequisite/context: the dupre-redesign entry in =.ai/session-context.org= (the AA palette this brightens). Hardest slot: blue keywords (a deep dupre blue can't be AAA on near-black — decide brighten vs keep-AA-exception vs lift-the-ground). -** DONE [#B] theme-studio tier-3 package faces :feature:theme:theme-studio: -CLOSED: [2026-06-08 Mon] -Package-specific face editing in the theme-studio: org/magit/elfeed bespoke (complete face tables + live previews) plus a generated all-package inventory so every installed package is themeable. Spec is Ready, all opens resolved: [[file:docs/design/theme-studio-package-faces-spec.org][docs/design/theme-studio-package-faces-spec.org]]. Phases below run in dependency order; phases 1-5 deliver the three high-value apps, phase 6 opens the long tail, phase 7 documents. The =theme.json= -> =dupre-*.el= converter (Elisp) is a separate downstream task. - -*** 2026-06-08 Mon @ 00:17:41 -0500 Phase 1 — package state + schema landed -Added =APPS= (org starter) and =PKGMAP= ({app:{face:{fg,bg,bold,italic,inherit,height,source}}}), pure helpers (=seedPkgmap= / =packagesForExport= / =mergePackagesInto=), and wired export/import for the =packages= key with old-JSON compat. The =height= float (relative size, read off the face not cascaded through inherit) and the fixed-pitch inherits are seeded in the org starter. No UI yet (Phase 3). Verified: node-check, plus a guarded =#selftest= harness (headless Chrome) confirming seed->export->import round-trip, old-JSON merge, and inherit/height/source survival — all PASS. - -*** 2026-06-08 Mon @ 02:16:24 -0500 Phase 2 — curated app data (org/magit/elfeed) landed -Filled =APPS= with the complete own-defface sets built from embedded face-name lists + a curated seed-color map: org 88 (85 seeded, incl. org-agenda, heading heights, fixed-pitch inherits), magit 98 (64 seeded), elfeed 13 (all seeded). Long-tail faces seed to default fg. Verified: 199 faces total, no seed typos / no dupes, schema self-test PASS seeding all of them. Seeded-default aesthetics still go to Manual testing once the Phase 3 UI lands. - -*** 2026-06-08 Mon @ 02:23:56 -0500 Phase 3 — package face table UI landed -Added the "package faces" section: app selector (org/magit/elfeed), per-app face table with fg/bg dropdowns, bold/italic toggles, inherit dropdown (base faces + the app's own faces), relative-height stepper, live contrast readout on the effective (inherit-resolved) color, per-face and per-app reset, and a text filter. Refactored the fg/bg dropdown into a shared =colorDropdown= helper the ui-faces table now also uses (no =uiSelect= fork). Palette edits propagate to package faces; import/export carry them. Right pane is the generic preview (face names in their own resolved colors) until the bespoke org/magit/elfeed previews land (phases 4-5). Verified: node, headless screenshot, schema self-test PASS. - -*** 2026-06-08 Mon @ 02:27:51 -0500 Phase 4 — org preview landed -Added =renderOrgPreview()=: a mock org document painted live from the org package faces (title, headings with heights, TODO/DONE, tag, scheduled date, property drawer, inline code/verbatim, link, checkbox, quote, src block, header-row table). The preview pane dispatches on the app's preview key; org-mode gets this, others keep the generic list. Verified: node, headless screenshot, self-test PASS. - -*** 2026-06-08 Mon @ 02:30:42 -0500 Phase 5 — magit + elfeed previews landed -Bespoke =renderMagitPreview()= (status buffer: head/branches, untracked, a diff hunk with context/added/removed, recent commits with hashes/authors/keyword/tag) and =renderElfeedPreview()= (search list: filter, dated entries with feed/unread-title/read-title/tags, log lines by level). The preview label now names the app and notes generic vs bespoke. Verified: node, headless screenshots, self-test PASS. - -*** 2026-06-08 Mon @ 02:32:44 -0500 Phase 6 — generated all-package inventory landed -=build-inventory.el= (loaded into a running Emacs) groups every installed package's faces by the defining package and writes =package-inventory.json=. =generate.py= embeds it and merges each package into the dropdown as an editable generic app, leaving org/magit/elfeed bespoke. 40 apps now (3 bespoke + 37 inventory, 643 faces). Committed data artifact, refreshed by reloading the .el; never browser-side discovery. Verified: node, self-test PASS, app count + bespoke-preserved checks. - -*** 2026-06-08 Mon @ 02:34:01 -0500 Phase 7 — docs landed -Rewrote =README.md= for the full tool: three face tiers + palette, the in-page picker (with the AA/AAA mask), package faces (bespoke vs generic previews), modeled inheritance + relative height (family stays in font-config.el), the packages schema with inherit/height/source, export-vs-save, and the inventory-refresh command (=build-inventory.el=) + its loaded-config dependency. Notes =theme-studio.html= is generated. Test-surface fixtures tracked separately below. - -*** 2026-06-08 Mon @ 02:40:00 -0500 theme-studio tier 3 — test surface landed -Extended the guarded =#selftest= harness (headless Chrome) to assert the acceptance criteria against the real emitted code: old-JSON import (no =packages=), full round-trip (fg/bg/bold/italic/inherit/height/source), cleared-state export, unknown-package/face preservation, and inheritance-cycle termination — all PASS. The two DOM-coupled regressions are handled structurally: =updateColor= remaps =PKGMAP= on a palette-color edit, and =PKGMAP= stores hexes so a deleted palette color leaves package refs in the "(gone)" recoverable state. =generate.py= rebuilds =theme-studio.html= each run. - -** DONE [#B] theme-studio perceptual color metrics :feature:theme:theme-studio: -CLOSED: [2026-06-08 Mon] -Spec (Ready, opens confirmed 2026-06-08): [[file:docs/design/theme-studio-perceptual-color-metrics-spec.org][docs/design/theme-studio-perceptual-color-metrics-spec.org]]. OKLCH model + perceptual-L/APCA readouts + pairwise ΔE, for building low-contrast themes by metric rather than by eye. All five phases shipped 2026-06-08 (commits 49342bf5, 78260018, 77c7f126, 163d3730, 22605426, 582d8a6a): colormath.js core inlined + WCAG/HSV helpers migrated; picker OKLCH/APCA readouts; palette ΔE warnings; OKLCH edit-model dials; C×L gamut plane. 17 Node tests (colormath 100/93.75/100), six browser hash gates green, inline-integrity guard. vNext deferrals (low-contrast preset, CIEDE2000) remain the two [#D] tasks below. Manual eyeballs tracked under Manual testing. -*** 2026-06-08 Mon @ 19:43:50 -0500 Color-math foundation + Node tests landed -Pure color core in =scripts/theme-studio/colormath.js= (OKLab/OKLCH, APCA-W3 0.1.9 exact constants, ΔE-OK, binary-search gamut clamp returning ={hex,clamped}=) shipped in 49342bf5; this phase finished the integration in 78260018. =generate.py= now inlines the colormath.js body into the page script (export-stripped, =COLORMATH_J= placeholder), and the page's lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex copies moved into the module — =rl= reuses the canonical =lin= (0.04045 cutoff), byte-identical to the old 0.03928 form on every #rrggbb (no 8-bit channel falls between the cutoffs; verified over 200k pairs, zero contrast change). =test-colormath.mjs= gained Normal/Boundary/Error cases for the migrated helpers, a seeded hsv-rgb round-trip property test, and an inline-integrity check that the generated page carries the module body verbatim. Gate met: =node --test scripts/theme-studio/*.mjs= 15 pass, colormath.js 100% line / 93.75% branch / 100% func; =node --check= on the spliced script clean; =#selftest= + =#cursortest= PASS in headless Chrome. NOTE: =node --test <dir>= directory-globbing is broken on Node v26 (tries to load the dir as a module) — use the =*.mjs= glob form. -*** 2026-06-08 Mon @ 19:55:53 -0500 Picker OKLCH/APCA readouts landed -Phase 2 shipped in 77c7f126. Second readout row (=.pinfo2=) under the WCAG ratio: OKLCH L/C/H + signed APCA Lc against the ground color, always shown; sign convention in the APCA tooltip + README. Tables unchanged (APCA picker-only per Agreed-decision #3). =pkReadout= drives the spans from the inlined colormath functions. Gate met: =#readouttest= asserts the spans match the live computation AND the known dupre-blue OKLCH reference (L 0.591 / C 0.052 / H 252°, APCA Lc -34 on ground) with WCAG unchanged; =#selftest= + =#cursortest= still PASS; 15 Node tests green. Headless-rendered values verified against a node cross-check. Visual eyeball is the open "Perceptual readouts read well in the picker" item under Manual testing. -*** 2026-06-08 Mon @ 20:44:39 -0500 Palette ΔE warnings landed -Phase 3 shipped in 163d3730. =renderPalette= runs a pairwise OKLab ΔE over PALETTE via the pure =paletteDeltas()= (one pass → sub-threshold pairs + per-color nearest distance); warns on pairs below the named =DELTAE_MIN= (0.02), sorted closest-first, capped at 5 with "and N more"; each chip's tooltip gains its nearest-neighbor ΔE. Names go through =esc= before the warning markup. Gate met: =#deltatest= PASS (near pair fires + names itself; spread palette quiet; 7-color cluster caps at 5 ascending + overflow suffix). #readouttest/#selftest/#cursortest + 15 Node tests still green. Screenshot-verified the warning render (terracotta "too-similar colors" header + "blue / blue2 — ΔE 0.007, hard to distinguish", placed between palette and add-color controls). Pushed below. -*** 2026-06-08 Mon @ 21:05:28 -0500 OKLCH sliders + color-model control landed -Phase 4a shipped in 22605426. Picker gains an edit-model toggle (HSV/OKLCH) in its own =pkModel= state, orthogonal to =pkMode= (AA/AAA mask) — separate handlers, distinct toggle colors (blue vs gold). OKLCH mode shows L/C/H as paired range+number inputs driving =oklch2hex= → hex/swatch/readouts/HSV-cursor; out-of-gamut chroma snaps the dials to the reachable color + shows "chroma clamped to sRGB". HSV stays default; SV square still edits HSV (C×L plane is 4b); SV drag in OKLCH mode refreshes the dials. =openPicker= re-asserts the model via =setPkModel= so the toggle highlight can't drift (caught on screenshot). Gate met: =#oklchtest= PASS (color preserved on model switch; mask toggle leaves pkModel; model switch leaves pkMode; dials drive color to a known OKLCH target; out-of-gamut C raises clamp status). All 5 browser gates + 15 Node tests green; screenshot-verified the dials + toggle highlight. -*** 2026-06-08 Mon @ 21:41:49 -0500 Chroma×Lightness plane landed -Phase 4b shipped in 582d8a6a. OKLCH mode renders the SV square as a C(x)×L(y) plane at the current hue; crosshair maps to (C,L), hue strip selects H. Out-of-gamut region greyed (#15120f), AA/AAA contrast mask overlays the reachable colors. Per-cell gamut test is forward-only (=oklch2oklab=→=oklab2lrgb=→=inGamut=), never the binary search (that stays in =oklch2hex= for committing). colormath.js exports =oklab2lrgb=/=inGamut=/=lrgb2hex= with direct Node tests (one pins inGamut to oklch2hex's clamped flag). Bitmap cached on (hue+dims+mask+bg) so C/L drags reuse it; hue drags ride browser pointermove-to-frame coalescing (synchronous render measured ~7ms math/5600 cells — no explicit rAF defer; flagged if jank appears). HSV path untouched. Gate met: =#planetest= (crosshair at C/L; OOG cell grey; in-gamut cell colored). Screenshot-verified the plane (gamut-boundary shape, crosshair at C=0 for grey). NOTE for Craig: OKLCH_CMAX=0.4 matches the C dial domain, so much of the plane is gamut-grey at low-chroma hues — a tighter max fills more area but desyncs the crosshair scale from the dial; your eyeball call. -*** 2026-06-08 Mon @ 21:41:49 -0500 Test surface green across the feature -Final state: 17 Node unit tests (colormath.js 100% line / 93.75% branch / 100% func), six browser hash gates (=#cursortest=/=#readouttest=/=#deltatest=/=#oklchtest=/=#planetest=/=#selftest=), inline-integrity check, =node --check= on the spliced page, README updated. All green. NOTE: =node --test <dir>= directory-globbing is broken on Node v26 — use =node --test scripts/theme-studio/*.mjs=. ** TODO Manual testing and validation :verify:theme-studio: Exercised once the phases above land. *** TODO Seeded package-face defaults look right @@ -234,6 +187,25 @@ What we're verifying: a background tint the tool calls safe really keeps every t - Build the theme and load it in Emacs, open a code buffer with varied syntax, and select a region spanning many token colors - Read every token through the region highlight, paying attention to the limiting foreground the tool named Expected: every token stays readable over the tint, including the limiting one; a tint pushed just past L_max (readout FAIL) shows a visibly strained or unreadable token, confirming the floor matches reality. +*** TODO Color families group the way the eye reads them +What we're verifying: the OKLCH hue clustering (25° gap) splits and merges families the way you'd expect, and renaming never moves a color. +- Open =scripts/theme-studio/theme-studio.html= in Chrome and load a real theme (e.g. sterling) +- Read the strips top to bottom: are "the blues" one strip, "the greens" another, neutrals and ground pinned at the top +- Find a pair you'd consider one family that landed in two strips (or two you'd consider separate that merged) +- Rename any swatch to something absurd and confirm it stays in the same strip +Expected: families match your mental grouping; the few that don't are the cue to revisit the 25° gap; renaming never regroups. +*** TODO Regenerate-replace reads as deliberate +What we're verifying: the count control clearly signals it rewrites the whole family, so replacing hand-added same-hue colors isn't a surprise. +- Add two unrelated colors at a similar hue so they share a strip +- Set that strip's count to 2 +- Watch what happens to the two colors +Expected: the strip becomes a clean base±2 ramp, the two loose colors are gone, and the control made it obvious that's what it would do before you committed. +*** TODO Removed-step references read clearly as "(gone)" +What we're verifying: lowering a family's count leaves a referencing face visibly stale, not silently re-pointed. +- Assign a UI or syntax element to an outer step of a family (e.g. region = a blue+3) +- Lower that family's count to 2 so blue+3 disappears +- Read the assignment's dropdown +Expected: the dropdown shows "(gone)" for the removed step, never a silent jump to a different color; re-pointing it is a deliberate choice. ** TODO [#B] theme-studio guide-support features :feature:theme-studio: From the color-assignment guide work (2026-06-08): make the tool support the guide without mandating it — everything a seed, an advisory, or a view, never a gate. Two specs to write, both deriving from the rewritten guide and its seed table ([[file:scripts/theme-studio/theme-coloring-guide.org][theme-coloring-guide.org]]). *** 2026-06-08 Mon @ 19:08:00 -0500 Seeding-engine spec written and Ready @@ -248,35 +220,6 @@ Phase 1. Palette anchors + OKLCH shade generation (reusing colormath.js), the RO Phase 2. Initial state from seed() plus seedPkgmap for the non-org packages; all-tier reseed button with a scope-named overwrite warning, resetting non-org to their APPS defaults; regenerate dupre-revised.json. Gate: #selftest PASS; default-on-open equals seed(); artifact round-trip (regenerated dupre-revised.json imports back to the same seeded state); Chrome eyeball. *** TODO Seeding-engine test surface :solo:tests: Keep #seedtest, #selftest, the default-on-open check, the dupre-revised round-trip, node --check, and Chrome validation green. -** DONE [#B] theme-studio refactor — extract app from generate.py :feature:theme-studio:refactor: -CLOSED: [2026-06-09 Tue] -Examined 2026-06-09. generate.py is 1378 lines, ~1300 of them a single triple-quoted string holding the whole app (CSS + HTML + ~1000+ lines of JS). That string is the root of every refactor here: the app logic can't be unit-tested (only =colormath.js= is, because it is the one extracted module); backslash-doubling in the string caused real bugs this session (the multi-line export strip, the =#deltatest= regex); and there is no lint, highlight, or brace-check until Chrome runs it. The rest of the directory is healthy: =colormath.js= (pure, 100/96 tested) and =build-theme.el= (13 small functions) are the model. - -Run the whole set in NO-APPROVALS mode: TDD per stage (characterization hash tests before each behavior-preserving move; node unit tests as extraction makes logic importable), commit + push at each green stage. Tooling committed at c7518d6f before starting. Order: - -DONE (2026-06-09): Stages 1-5 + 7 landed and pushed (origin/main tip dd90eca9); Stage 6 deliberately skipped (optional, works today). generate.py went 1378→~500 lines; the app now lives in real files (styles.css, app.js, app-core.js) inlined at generate time. The escaping-bug class is gone (str.replace is literal), the dedup is done (unified dropdowns/sort/clear-unlocked, shared crHtml/mkStyleButtons/effFg helpers), and the pure app logic is unit-tested (app-core.js, 18 node tests). Three new permanent gates added along the way: =#locktest=, =#sorttest=, and the app-core integrity + node suite. =make theme-studio-test= = 13 python + 43 node + spliced-check + 8 hash gates, all green. -*** 2026-06-09 Tue @ 05:01:11 -0500 Stage 1 — #locktest net + extracted styles.css/app.js -Added the =#locktest= browser gate first (commit d04f44dd): it pins, across all three tiers, that mkLockCell disables a row's control (syntax swatch div via data-locked, UI select via .disabled) and that clear-unlocked wipes unlocked rows while skipping locked ones. Proved it goes red when a lock guard is removed. - -Then extracted the =<style>= block to =styles.css= and the =<script>= body to =app.js= (commit eaf16904), inlined by =generate.py= through STYLES_CSS / APP_JS placeholders the same way =colormath.js= is. Used =ast= to pull the resolved string value so the escapes (single vs doubled backslashes) survive the move — the generated page is byte-identical to before. =generate.py= dropped 1378 → ~500 lines (the remaining bulk is the package face-data dicts; Stage 6 may data-file those). Two integrity tests guard the splice: styles.css inlines verbatim, app.js reaches the page as =fill_data= renders it; both go red if the wiring is dropped. - -Gate green: 12 python templating tests, 25 node tests, spliced-script =node --check=, all 7 hash gates. =node --check app.js= passes standalone (placeholders are valid JS identifiers). The escaping-bug class is gone — =str.replace= is literal, so the JS no longer lives inside a Python string. -*** 2026-06-09 Tue @ 05:07:10 -0500 Stage 2 — unified color dropdowns on the swatch picker -Deleted native =colorDropdown=; routed UI + package fg/bg through =mkColorDropdown= so all three tiers show real swatches (commit aee14bff). The inherit column stays a select — it picks a face name, not a color. Pulled the option-list build into a shared =ddList= helper (default + palette + "(gone)" entry), replacing the inline copy in the syntax table. Preserved value-based sort: the swatch dropdown now exposes =data-val= and =cellVal= reads it. Updated =#locktest='s UI assertion to the div lock path (data-locked). Verified via headless DOM: legbody 21 cdd / 0 select, uibody 40 cdd / 0 select, pkgbody 176 cdd + 88 inherit selects. All gates green. -*** 2026-06-09 Tue @ 05:12:00 -0500 Stage 3 — extracted crHtml + mkStyleButtons -Extracted =crHtml(r)= (the contrast→ratingColor→rating span, was copy-pasted at 5 sites; now syntax/UI/pkg cells share it — the picker readout renders differently and stays) and =mkStyleButtons(isOn,onToggle)= (the B/I/U/S loop, was near-identical in the UI + pkg tables; returns the button list for mkLockCell). Commit 62b53bc5. - -Deliberately NOT done: the syntax bold/italic buttons (2 buttons, BOLD/ITALIC dicts, in-place refresh closure — poor fit for the same helper), and a shared row scaffold (the three tables differ enough in columns/order that one would leak — premature abstraction). Node-unit-testing the pure pieces deferred to Stage 7, where app.js is made importable. - -Verified behavior-preserving by diffing the runtime-rendered DOM (Stage 2 page vs Stage 3 page in headless Chrome): the only differences are inside the inline =<script>= source, never a built tr/td/button/span — the tables build identically. All hash gates + node + python green. -*** 2026-06-09 Tue @ 05:16:33 -0500 Stage 4 — unified syntax table onto the shared sort -Deleted =srt= + =D{}= (the syntax table's own sort); pointed its headers at =srtTable('legbody',col)= so all three tables share =srtTable=/=cellVal=/=applyTableSort= (commit d947944b). Mapping is exact: the legtable color cell is a swatch dropdown whose =data-val= is the hex (what =srt= sorted on via MAP[kind]); elements cell is text; first-click stays ascending. Syntax sorts on click only — it doesn't opt into the cross-rebuild persistence the UI/pkg tables get, preserving its prior behavior. Added a =#sorttest= gate (sort was untested): syntax sorts by color asc, reverses on re-click, sorts by element name; UI + pkg still sort. asc/desc pair is self-validating. -*** 2026-06-09 Tue @ 05:20:22 -0500 Stage 5 — parameterized clear-unlocked + added effFg/effBg -Collapsed the three clear-unlocked functions into =clearUnlockedRows(items,keyFn,resetFn)= (keyFn returns a row's lock key or null to skip; resetFn does the tier-specific clear) — #locktest already guards clear-unlocked-skips-locked per tier. Replaced the 9x =||MAP['p']= / =||MAP['bg']= effective-fg/bg fallback with =effFg(v)=/=effBg(v)= across syntax/UI/pkg render paths (commit 89d079fe). Behavior-preserving: rendered DOM (script stripped) byte-identical; all gates green. Node-unit-testing the pure pieces (effFg/effBg, clearUnlockedRows) deferred to Stage 7 with the rest of the importable-app-logic suite. -*** 2026-06-09 Tue @ 06:03:04 -0500 Stage 6 — skipped (optional, deferred) -Left undone deliberately. Grouping the free module-level state into a state object is churn with no functional gain (works today), and data-filing the inline face dicts is a generate.py size win unrelated to the refactor's goal (testable logic), which Stage 7 already achieved. Can be revived from this entry + the original plan if the generate.py face dicts ever need to become data. Not blocking anything. -*** 2026-06-09 Tue @ 06:03:04 -0500 Stage 7 — extracted app-core.js + unit-tested the app logic -The coverage payoff. Pulled the pure package-face model + dropdown option list into app-core.js (nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList — every dep a parameter, no DOM/globals), inlined like colormath.js (strip + placeholder + integrity). app.js keeps thin wrappers (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) passing live PALETTE/APPS/PKGMAP, so no call site changed and the built DOM is byte-identical. Added test-app-core.mjs: 18 Normal/Boundary/Error tests (name resolution, seed/export/merge round trip, inherit chain incl. a cycle terminating at null, "(gone)" entry) + inline-integrity. Node suite 25→43; python +1 integrity. Commit dd90eca9. GOTCHA found+fixed pre-commit: a code comment that contained the literal token "APP_CORE_J" got inlined by str.replace too (placeholder tokens must not appear in prose that gets templated). ** TODO [#C] theme-studio terminal/ANSI colors :feature:theme-studio: theme-studio represents GUI faces only; terminal colors aren't surfaced at all. Scope decided 2026-06-09: GUI-first faces, NOT full per-face display-class fallback. Two pieces: @@ -2723,19 +2666,6 @@ They should have the same UI paradigms and patters for consistency. :END: Display slack.el message and thread buffers in a dedicated popup window (side or bottom) and reuse that one window instead of spawning a new window per buffer. Likely a =display-buffer-alist= rule (or popper integration) in =modules/slack-config.el=. -** DONE [#C] M-F9 ai-vterm close removes the window split :quick:solo: -CLOSED: [2026-06-06 Sat] -:PROPERTIES: -:LAST_REVIEWED: 2026-06-02 -:END: -Closing the ai-vterm with M-F9 while its window is in a split deletes the split too (the sibling window goes away) instead of just closing the vterm and leaving the rest of the layout intact. - -*** 2026-06-02 Tue @ 14:12:48 -0500 Audit: still a bug, distinct from the F9 collapse -The F9 toggle-off rework (38dad92) made F9 collapse the split by design, but that's the toggle path. This is M-F9 close (kills the agent process): close should leave the surrounding layout intact, not delete the sibling window. Craig confirmed it's still a bug. cj/--ai-vterm-close-buffer still calls delete-window. - -*** 2026-06-06 Sat @ 18:18:17 -0500 Fixed: close swaps the window to a non-agent buffer instead of deleting it -=cj/--ai-term-close-buffer= no longer calls =delete-window=; it swaps the agent's window to the working buffer (=cj/--ai-term-most-recent-non-agent-buffer=), then kills the agent buffer, so the split survives. F9 hide still collapses the split by design; close no longer does. Regression test =test-ai-term--close-buffer-keeps-window-split=. Commit =1a097b7e=. - ** TODO [#C] Implement EMMS-free music-config architecture :refactor: :PROPERTIES: :LAST_REVIEWED: 2026-06-01 @@ -7329,3 +7259,111 @@ Expected: F12 excludes agent buffers and keeps saved geometry; the dashboard lau What we're verifying: the aiv- tmux session survives an Emacs crash and reattaches. - with a live agent, kill Emacs (not the tmux session); restart Emacs; F9 → project picker Expected: the project shows "[detached]" and reattaches to the surviving tmux session. +** DONE [#B] Color-family grouping for hue-adjacent warm colors :feature:theme-studio:research: +CLOSED: [2026-06-10 Wed] +Resolved by two independent reviews of =~/color-sorting.org= (=~/color-sorting-codex.org=, =~/color-sorting-fable.org=, Fable's harness at =~/working/color-sorting-fable/=). Both converged on lightness-conditioned complete-linkage clustering + a floored neutral threshold; implemented in commit =04b82bbe= (replacing the hue anchors). Measured F1 0.63→0.96 on the real palette: gold and olive separate, red/blue ramps stay whole, intense-red isolates, all grays/steels consolidate, and the gray+1/gray+2/white neutral-leak bugs are fixed. The only residual (pale yellow+2 lands on the olive ramp) is geometrically irreducible from the hex — see the hint-override task below. +** DONE [#B] theme-studio comprehensive previews (org/magit/elfeed/ghostel/mu4e/dashboard) :feature:theme:theme-studio: +CLOSED: [2026-06-08 Mon] +Expanded the bespoke previews to near-complete face coverage and added three new ones. org now exercises 83/88 faces (document + agenda; the 5 skipped are non-visual: org-hide, org-indent, org-clock-overlay, org-default, org-date-selected). magit 97/98 (status buffer + blame/reflog/sequence/bisect/signature sampler rows). elfeed 13/13. New bespoke previews: ghostel 19/19 (mock terminal, 16 ANSI colors + default + fake cursor), mu4e 37/37 (curated face list, not in the generated inventory; headers list + message view + compose), dashboard 8/8. So clicking a face row flashes a real preview element for nearly every face. Originally filed as just the org preview. +** DONE [#A] theme-studio theme.json -> dupre-*.el converter :feature:theme:theme-studio: +CLOSED: [2026-06-08 Mon] +Built as scripts/theme-studio/build-theme.el (sibling to build-inventory.el), emitting a single self-contained themes/<name>-theme.el deftheme (not the palette/faces/theme trio — a theme.json carries resolved per-face hex, not dupre's semantic layer). All four tiers convert: default from assignments.bg/.p, syntax categories -> font-lock/tree-sitter faces with bold/italic sets, UI passthrough, packages with :inherit/:height/weight/slant. 20 ERT tests in tests/test-build-theme.el (Normal/Boundary/Error + an end-to-end load + a WCAG-AA assertion on the round-tripped result). One mapping limitation documented: the dec (decorator) key has no independent Emacs face (Emacs renders decorators with font-lock-type-face, which ty owns), so dec is omitted and decorators follow the type color. + +The last link in the pipeline: turn a theme.json exported by the theme-studio into a real loadable Emacs theme. Elisp (per Craig), TDD — this is the correctness-sensitive piece. + +Inputs (all on disk; no chat history needed): +- theme.json contract: =scripts/theme-studio/README.md= (theme.json section) and =docs/design/theme-studio-package-faces-spec.org= (State and export policy, Relative height, Inheritance). +- Reference face layout: existing =themes/dupre-palette.el= + =themes/dupre-faces.el= + =themes/dupre-theme.el=, and =tests/test-dupre-theme.el= (WCAG-contrast helper to reuse). +- Conventions: =.claude/rules/elisp.md=, =.claude/rules/elisp-testing.md=. + +Scope: +1. Read theme.json. Set =default= from =assignments.bg= / =assignments.p=. +2. Author the syntax category -> font-lock face map (~21 keys: kw->font-lock-keyword-face, str->font-lock-string-face, fnd->font-lock-function-name-face, fnc->font-lock-function-call-face, op->font-lock-operator-face, punc->font-lock-punctuation-face, etc. incl. the Emacs-29 tree-sitter additions). Apply =bold= / =italic= sets. +3. UI faces: the =ui= keys are already real face names (region, cursor, mode-line, ...) -> near 1:1 passthrough of fg/bg. +4. Package faces: =packages= -> each face spec, writing =:inherit PARENT= for inherited faces + only the overridden attrs, =:height= when != 1.0, weight/slant. +5. Emit a deftheme file (or palette+faces+theme trio mirroring dupre's layout). + +TDD targets: old-JSON (no packages) loads; every category maps; round-trip of fg/bg/bold/italic/inherit/height into valid face specs; WCAG-contrast assertion on the result. Decide whether the converter lives under =scripts/theme-studio/= (emits to =themes/=) or =themes/=. +** DONE [#B] theme-studio tier-3 package faces :feature:theme:theme-studio: +CLOSED: [2026-06-08 Mon] +Package-specific face editing in the theme-studio: org/magit/elfeed bespoke (complete face tables + live previews) plus a generated all-package inventory so every installed package is themeable. Spec is Ready, all opens resolved: [[file:docs/design/theme-studio-package-faces-spec.org][docs/design/theme-studio-package-faces-spec.org]]. Phases below run in dependency order; phases 1-5 deliver the three high-value apps, phase 6 opens the long tail, phase 7 documents. The =theme.json= -> =dupre-*.el= converter (Elisp) is a separate downstream task. + +*** 2026-06-08 Mon @ 00:17:41 -0500 Phase 1 — package state + schema landed +Added =APPS= (org starter) and =PKGMAP= ({app:{face:{fg,bg,bold,italic,inherit,height,source}}}), pure helpers (=seedPkgmap= / =packagesForExport= / =mergePackagesInto=), and wired export/import for the =packages= key with old-JSON compat. The =height= float (relative size, read off the face not cascaded through inherit) and the fixed-pitch inherits are seeded in the org starter. No UI yet (Phase 3). Verified: node-check, plus a guarded =#selftest= harness (headless Chrome) confirming seed->export->import round-trip, old-JSON merge, and inherit/height/source survival — all PASS. + +*** 2026-06-08 Mon @ 02:16:24 -0500 Phase 2 — curated app data (org/magit/elfeed) landed +Filled =APPS= with the complete own-defface sets built from embedded face-name lists + a curated seed-color map: org 88 (85 seeded, incl. org-agenda, heading heights, fixed-pitch inherits), magit 98 (64 seeded), elfeed 13 (all seeded). Long-tail faces seed to default fg. Verified: 199 faces total, no seed typos / no dupes, schema self-test PASS seeding all of them. Seeded-default aesthetics still go to Manual testing once the Phase 3 UI lands. + +*** 2026-06-08 Mon @ 02:23:56 -0500 Phase 3 — package face table UI landed +Added the "package faces" section: app selector (org/magit/elfeed), per-app face table with fg/bg dropdowns, bold/italic toggles, inherit dropdown (base faces + the app's own faces), relative-height stepper, live contrast readout on the effective (inherit-resolved) color, per-face and per-app reset, and a text filter. Refactored the fg/bg dropdown into a shared =colorDropdown= helper the ui-faces table now also uses (no =uiSelect= fork). Palette edits propagate to package faces; import/export carry them. Right pane is the generic preview (face names in their own resolved colors) until the bespoke org/magit/elfeed previews land (phases 4-5). Verified: node, headless screenshot, schema self-test PASS. + +*** 2026-06-08 Mon @ 02:27:51 -0500 Phase 4 — org preview landed +Added =renderOrgPreview()=: a mock org document painted live from the org package faces (title, headings with heights, TODO/DONE, tag, scheduled date, property drawer, inline code/verbatim, link, checkbox, quote, src block, header-row table). The preview pane dispatches on the app's preview key; org-mode gets this, others keep the generic list. Verified: node, headless screenshot, self-test PASS. + +*** 2026-06-08 Mon @ 02:30:42 -0500 Phase 5 — magit + elfeed previews landed +Bespoke =renderMagitPreview()= (status buffer: head/branches, untracked, a diff hunk with context/added/removed, recent commits with hashes/authors/keyword/tag) and =renderElfeedPreview()= (search list: filter, dated entries with feed/unread-title/read-title/tags, log lines by level). The preview label now names the app and notes generic vs bespoke. Verified: node, headless screenshots, self-test PASS. + +*** 2026-06-08 Mon @ 02:32:44 -0500 Phase 6 — generated all-package inventory landed +=build-inventory.el= (loaded into a running Emacs) groups every installed package's faces by the defining package and writes =package-inventory.json=. =generate.py= embeds it and merges each package into the dropdown as an editable generic app, leaving org/magit/elfeed bespoke. 40 apps now (3 bespoke + 37 inventory, 643 faces). Committed data artifact, refreshed by reloading the .el; never browser-side discovery. Verified: node, self-test PASS, app count + bespoke-preserved checks. + +*** 2026-06-08 Mon @ 02:34:01 -0500 Phase 7 — docs landed +Rewrote =README.md= for the full tool: three face tiers + palette, the in-page picker (with the AA/AAA mask), package faces (bespoke vs generic previews), modeled inheritance + relative height (family stays in font-config.el), the packages schema with inherit/height/source, export-vs-save, and the inventory-refresh command (=build-inventory.el=) + its loaded-config dependency. Notes =theme-studio.html= is generated. Test-surface fixtures tracked separately below. + +*** 2026-06-08 Mon @ 02:40:00 -0500 theme-studio tier 3 — test surface landed +Extended the guarded =#selftest= harness (headless Chrome) to assert the acceptance criteria against the real emitted code: old-JSON import (no =packages=), full round-trip (fg/bg/bold/italic/inherit/height/source), cleared-state export, unknown-package/face preservation, and inheritance-cycle termination — all PASS. The two DOM-coupled regressions are handled structurally: =updateColor= remaps =PKGMAP= on a palette-color edit, and =PKGMAP= stores hexes so a deleted palette color leaves package refs in the "(gone)" recoverable state. =generate.py= rebuilds =theme-studio.html= each run. +** DONE [#B] theme-studio perceptual color metrics :feature:theme:theme-studio: +CLOSED: [2026-06-08 Mon] +Spec (Ready, opens confirmed 2026-06-08): [[file:docs/design/theme-studio-perceptual-color-metrics-spec.org][docs/design/theme-studio-perceptual-color-metrics-spec.org]]. OKLCH model + perceptual-L/APCA readouts + pairwise ΔE, for building low-contrast themes by metric rather than by eye. All five phases shipped 2026-06-08 (commits 49342bf5, 78260018, 77c7f126, 163d3730, 22605426, 582d8a6a): colormath.js core inlined + WCAG/HSV helpers migrated; picker OKLCH/APCA readouts; palette ΔE warnings; OKLCH edit-model dials; C×L gamut plane. 17 Node tests (colormath 100/93.75/100), six browser hash gates green, inline-integrity guard. vNext deferrals (low-contrast preset, CIEDE2000) remain the two [#D] tasks below. Manual eyeballs tracked under Manual testing. +*** 2026-06-08 Mon @ 19:43:50 -0500 Color-math foundation + Node tests landed +Pure color core in =scripts/theme-studio/colormath.js= (OKLab/OKLCH, APCA-W3 0.1.9 exact constants, ΔE-OK, binary-search gamut clamp returning ={hex,clamped}=) shipped in 49342bf5; this phase finished the integration in 78260018. =generate.py= now inlines the colormath.js body into the page script (export-stripped, =COLORMATH_J= placeholder), and the page's lin/rl/contrast/rating/hsv2rgb/rgb2hsv/hex2rgb/rgb2hex copies moved into the module — =rl= reuses the canonical =lin= (0.04045 cutoff), byte-identical to the old 0.03928 form on every #rrggbb (no 8-bit channel falls between the cutoffs; verified over 200k pairs, zero contrast change). =test-colormath.mjs= gained Normal/Boundary/Error cases for the migrated helpers, a seeded hsv-rgb round-trip property test, and an inline-integrity check that the generated page carries the module body verbatim. Gate met: =node --test scripts/theme-studio/*.mjs= 15 pass, colormath.js 100% line / 93.75% branch / 100% func; =node --check= on the spliced script clean; =#selftest= + =#cursortest= PASS in headless Chrome. NOTE: =node --test <dir>= directory-globbing is broken on Node v26 (tries to load the dir as a module) — use the =*.mjs= glob form. +*** 2026-06-08 Mon @ 19:55:53 -0500 Picker OKLCH/APCA readouts landed +Phase 2 shipped in 77c7f126. Second readout row (=.pinfo2=) under the WCAG ratio: OKLCH L/C/H + signed APCA Lc against the ground color, always shown; sign convention in the APCA tooltip + README. Tables unchanged (APCA picker-only per Agreed-decision #3). =pkReadout= drives the spans from the inlined colormath functions. Gate met: =#readouttest= asserts the spans match the live computation AND the known dupre-blue OKLCH reference (L 0.591 / C 0.052 / H 252°, APCA Lc -34 on ground) with WCAG unchanged; =#selftest= + =#cursortest= still PASS; 15 Node tests green. Headless-rendered values verified against a node cross-check. Visual eyeball is the open "Perceptual readouts read well in the picker" item under Manual testing. +*** 2026-06-08 Mon @ 20:44:39 -0500 Palette ΔE warnings landed +Phase 3 shipped in 163d3730. =renderPalette= runs a pairwise OKLab ΔE over PALETTE via the pure =paletteDeltas()= (one pass → sub-threshold pairs + per-color nearest distance); warns on pairs below the named =DELTAE_MIN= (0.02), sorted closest-first, capped at 5 with "and N more"; each chip's tooltip gains its nearest-neighbor ΔE. Names go through =esc= before the warning markup. Gate met: =#deltatest= PASS (near pair fires + names itself; spread palette quiet; 7-color cluster caps at 5 ascending + overflow suffix). #readouttest/#selftest/#cursortest + 15 Node tests still green. Screenshot-verified the warning render (terracotta "too-similar colors" header + "blue / blue2 — ΔE 0.007, hard to distinguish", placed between palette and add-color controls). Pushed below. +*** 2026-06-08 Mon @ 21:05:28 -0500 OKLCH sliders + color-model control landed +Phase 4a shipped in 22605426. Picker gains an edit-model toggle (HSV/OKLCH) in its own =pkModel= state, orthogonal to =pkMode= (AA/AAA mask) — separate handlers, distinct toggle colors (blue vs gold). OKLCH mode shows L/C/H as paired range+number inputs driving =oklch2hex= → hex/swatch/readouts/HSV-cursor; out-of-gamut chroma snaps the dials to the reachable color + shows "chroma clamped to sRGB". HSV stays default; SV square still edits HSV (C×L plane is 4b); SV drag in OKLCH mode refreshes the dials. =openPicker= re-asserts the model via =setPkModel= so the toggle highlight can't drift (caught on screenshot). Gate met: =#oklchtest= PASS (color preserved on model switch; mask toggle leaves pkModel; model switch leaves pkMode; dials drive color to a known OKLCH target; out-of-gamut C raises clamp status). All 5 browser gates + 15 Node tests green; screenshot-verified the dials + toggle highlight. +*** 2026-06-08 Mon @ 21:41:49 -0500 Chroma×Lightness plane landed +Phase 4b shipped in 582d8a6a. OKLCH mode renders the SV square as a C(x)×L(y) plane at the current hue; crosshair maps to (C,L), hue strip selects H. Out-of-gamut region greyed (#15120f), AA/AAA contrast mask overlays the reachable colors. Per-cell gamut test is forward-only (=oklch2oklab=→=oklab2lrgb=→=inGamut=), never the binary search (that stays in =oklch2hex= for committing). colormath.js exports =oklab2lrgb=/=inGamut=/=lrgb2hex= with direct Node tests (one pins inGamut to oklch2hex's clamped flag). Bitmap cached on (hue+dims+mask+bg) so C/L drags reuse it; hue drags ride browser pointermove-to-frame coalescing (synchronous render measured ~7ms math/5600 cells — no explicit rAF defer; flagged if jank appears). HSV path untouched. Gate met: =#planetest= (crosshair at C/L; OOG cell grey; in-gamut cell colored). Screenshot-verified the plane (gamut-boundary shape, crosshair at C=0 for grey). NOTE for Craig: OKLCH_CMAX=0.4 matches the C dial domain, so much of the plane is gamut-grey at low-chroma hues — a tighter max fills more area but desyncs the crosshair scale from the dial; your eyeball call. +*** 2026-06-08 Mon @ 21:41:49 -0500 Test surface green across the feature +Final state: 17 Node unit tests (colormath.js 100% line / 93.75% branch / 100% func), six browser hash gates (=#cursortest=/=#readouttest=/=#deltatest=/=#oklchtest=/=#planetest=/=#selftest=), inline-integrity check, =node --check= on the spliced page, README updated. All green. NOTE: =node --test <dir>= directory-globbing is broken on Node v26 — use =node --test scripts/theme-studio/*.mjs=. +** DONE [#B] theme-studio refactor — extract app from generate.py :feature:theme-studio:refactor: +CLOSED: [2026-06-09 Tue] +Examined 2026-06-09. generate.py is 1378 lines, ~1300 of them a single triple-quoted string holding the whole app (CSS + HTML + ~1000+ lines of JS). That string is the root of every refactor here: the app logic can't be unit-tested (only =colormath.js= is, because it is the one extracted module); backslash-doubling in the string caused real bugs this session (the multi-line export strip, the =#deltatest= regex); and there is no lint, highlight, or brace-check until Chrome runs it. The rest of the directory is healthy: =colormath.js= (pure, 100/96 tested) and =build-theme.el= (13 small functions) are the model. + +Run the whole set in NO-APPROVALS mode: TDD per stage (characterization hash tests before each behavior-preserving move; node unit tests as extraction makes logic importable), commit + push at each green stage. Tooling committed at c7518d6f before starting. Order: + +DONE (2026-06-09): Stages 1-5 + 7 landed and pushed (origin/main tip dd90eca9); Stage 6 deliberately skipped (optional, works today). generate.py went 1378→~500 lines; the app now lives in real files (styles.css, app.js, app-core.js) inlined at generate time. The escaping-bug class is gone (str.replace is literal), the dedup is done (unified dropdowns/sort/clear-unlocked, shared crHtml/mkStyleButtons/effFg helpers), and the pure app logic is unit-tested (app-core.js, 18 node tests). Three new permanent gates added along the way: =#locktest=, =#sorttest=, and the app-core integrity + node suite. =make theme-studio-test= = 13 python + 43 node + spliced-check + 8 hash gates, all green. +*** 2026-06-09 Tue @ 05:01:11 -0500 Stage 1 — #locktest net + extracted styles.css/app.js +Added the =#locktest= browser gate first (commit d04f44dd): it pins, across all three tiers, that mkLockCell disables a row's control (syntax swatch div via data-locked, UI select via .disabled) and that clear-unlocked wipes unlocked rows while skipping locked ones. Proved it goes red when a lock guard is removed. + +Then extracted the =<style>= block to =styles.css= and the =<script>= body to =app.js= (commit eaf16904), inlined by =generate.py= through STYLES_CSS / APP_JS placeholders the same way =colormath.js= is. Used =ast= to pull the resolved string value so the escapes (single vs doubled backslashes) survive the move — the generated page is byte-identical to before. =generate.py= dropped 1378 → ~500 lines (the remaining bulk is the package face-data dicts; Stage 6 may data-file those). Two integrity tests guard the splice: styles.css inlines verbatim, app.js reaches the page as =fill_data= renders it; both go red if the wiring is dropped. + +Gate green: 12 python templating tests, 25 node tests, spliced-script =node --check=, all 7 hash gates. =node --check app.js= passes standalone (placeholders are valid JS identifiers). The escaping-bug class is gone — =str.replace= is literal, so the JS no longer lives inside a Python string. +*** 2026-06-09 Tue @ 05:07:10 -0500 Stage 2 — unified color dropdowns on the swatch picker +Deleted native =colorDropdown=; routed UI + package fg/bg through =mkColorDropdown= so all three tiers show real swatches (commit aee14bff). The inherit column stays a select — it picks a face name, not a color. Pulled the option-list build into a shared =ddList= helper (default + palette + "(gone)" entry), replacing the inline copy in the syntax table. Preserved value-based sort: the swatch dropdown now exposes =data-val= and =cellVal= reads it. Updated =#locktest='s UI assertion to the div lock path (data-locked). Verified via headless DOM: legbody 21 cdd / 0 select, uibody 40 cdd / 0 select, pkgbody 176 cdd + 88 inherit selects. All gates green. +*** 2026-06-09 Tue @ 05:12:00 -0500 Stage 3 — extracted crHtml + mkStyleButtons +Extracted =crHtml(r)= (the contrast→ratingColor→rating span, was copy-pasted at 5 sites; now syntax/UI/pkg cells share it — the picker readout renders differently and stays) and =mkStyleButtons(isOn,onToggle)= (the B/I/U/S loop, was near-identical in the UI + pkg tables; returns the button list for mkLockCell). Commit 62b53bc5. + +Deliberately NOT done: the syntax bold/italic buttons (2 buttons, BOLD/ITALIC dicts, in-place refresh closure — poor fit for the same helper), and a shared row scaffold (the three tables differ enough in columns/order that one would leak — premature abstraction). Node-unit-testing the pure pieces deferred to Stage 7, where app.js is made importable. + +Verified behavior-preserving by diffing the runtime-rendered DOM (Stage 2 page vs Stage 3 page in headless Chrome): the only differences are inside the inline =<script>= source, never a built tr/td/button/span — the tables build identically. All hash gates + node + python green. +*** 2026-06-09 Tue @ 05:16:33 -0500 Stage 4 — unified syntax table onto the shared sort +Deleted =srt= + =D{}= (the syntax table's own sort); pointed its headers at =srtTable('legbody',col)= so all three tables share =srtTable=/=cellVal=/=applyTableSort= (commit d947944b). Mapping is exact: the legtable color cell is a swatch dropdown whose =data-val= is the hex (what =srt= sorted on via MAP[kind]); elements cell is text; first-click stays ascending. Syntax sorts on click only — it doesn't opt into the cross-rebuild persistence the UI/pkg tables get, preserving its prior behavior. Added a =#sorttest= gate (sort was untested): syntax sorts by color asc, reverses on re-click, sorts by element name; UI + pkg still sort. asc/desc pair is self-validating. +*** 2026-06-09 Tue @ 05:20:22 -0500 Stage 5 — parameterized clear-unlocked + added effFg/effBg +Collapsed the three clear-unlocked functions into =clearUnlockedRows(items,keyFn,resetFn)= (keyFn returns a row's lock key or null to skip; resetFn does the tier-specific clear) — #locktest already guards clear-unlocked-skips-locked per tier. Replaced the 9x =||MAP['p']= / =||MAP['bg']= effective-fg/bg fallback with =effFg(v)=/=effBg(v)= across syntax/UI/pkg render paths (commit 89d079fe). Behavior-preserving: rendered DOM (script stripped) byte-identical; all gates green. Node-unit-testing the pure pieces (effFg/effBg, clearUnlockedRows) deferred to Stage 7 with the rest of the importable-app-logic suite. +*** 2026-06-09 Tue @ 06:03:04 -0500 Stage 6 — skipped (optional, deferred) +Left undone deliberately. Grouping the free module-level state into a state object is churn with no functional gain (works today), and data-filing the inline face dicts is a generate.py size win unrelated to the refactor's goal (testable logic), which Stage 7 already achieved. Can be revived from this entry + the original plan if the generate.py face dicts ever need to become data. Not blocking anything. +*** 2026-06-09 Tue @ 06:03:04 -0500 Stage 7 — extracted app-core.js + unit-tested the app logic +The coverage payoff. Pulled the pure package-face model + dropdown option list into app-core.js (nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList — every dep a parameter, no DOM/globals), inlined like colormath.js (strip + placeholder + integrity). app.js keeps thin wrappers (pname/seedPkgmap/ddList/pkgEffFg/pkgEffBg) passing live PALETTE/APPS/PKGMAP, so no call site changed and the built DOM is byte-identical. Added test-app-core.mjs: 18 Normal/Boundary/Error tests (name resolution, seed/export/merge round trip, inherit chain incl. a cycle terminating at null, "(gone)" entry) + inline-integrity. Node suite 25→43; python +1 integrity. Commit dd90eca9. GOTCHA found+fixed pre-commit: a code comment that contained the literal token "APP_CORE_J" got inlined by str.replace too (placeholder tokens must not appear in prose that gets templated). +** DONE [#C] M-F9 ai-vterm close removes the window split :quick:solo: +CLOSED: [2026-06-06 Sat] +:PROPERTIES: +:LAST_REVIEWED: 2026-06-02 +:END: +Closing the ai-vterm with M-F9 while its window is in a split deletes the split too (the sibling window goes away) instead of just closing the vterm and leaving the rest of the layout intact. + +*** 2026-06-02 Tue @ 14:12:48 -0500 Audit: still a bug, distinct from the F9 collapse +The F9 toggle-off rework (38dad92) made F9 collapse the split by design, but that's the toggle path. This is M-F9 close (kills the agent process): close should leave the surrounding layout intact, not delete the sibling window. Craig confirmed it's still a bug. cj/--ai-vterm-close-buffer still calls delete-window. + +*** 2026-06-06 Sat @ 18:18:17 -0500 Fixed: close swaps the window to a non-agent buffer instead of deleting it +=cj/--ai-term-close-buffer= no longer calls =delete-window=; it swaps the agent's window to the working buffer (=cj/--ai-term-most-recent-non-agent-buffer=), then kills the agent buffer, so the split survives. F9 hide still collapses the split by design; close no longer does. Regression test =test-ai-term--close-buffer-keeps-window-split=. Commit =1a097b7e=. |
