aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/theme-studio-palette-ramps-spec.org215
-rw-r--r--scripts/theme-studio/README.md45
-rw-r--r--scripts/theme-studio/app-core.js94
-rw-r--r--scripts/theme-studio/app.js156
-rw-r--r--scripts/theme-studio/generate.py17
-rwxr-xr-xscripts/theme-studio/run-tests.sh2
-rw-r--r--scripts/theme-studio/styles.css11
-rw-r--r--scripts/theme-studio/test-app-core.mjs2
-rw-r--r--scripts/theme-studio/test-contrast.mjs111
-rw-r--r--scripts/theme-studio/test-ramp.mjs105
-rw-r--r--scripts/theme-studio/theme-studio.html274
-rw-r--r--todo.org52
12 files changed, 1070 insertions, 14 deletions
diff --git a/docs/theme-studio-palette-ramps-spec.org b/docs/theme-studio-palette-ramps-spec.org
new file mode 100644
index 00000000..f9105595
--- /dev/null
+++ b/docs/theme-studio-palette-ramps-spec.org
@@ -0,0 +1,215 @@
+#+TITLE: theme-studio Palette Ramps & Background-Contrast Safety — Spec
+#+AUTHOR: Craig Jennings
+#+DATE: 2026-06-09
+
+* Metadata
+| Status | draft — review incorporated (Codex, 2026-06-09) |
+| Owner | Craig |
+| Reviewer | Codex |
+| Related | [[file:../todo.org][todo.org: theme-studio color-harmony explainer + ramp/fill features]] |
+
+* Summary
+
+Give theme-studio two things it lacks: a generator that turns one base color into a harmonized tonal ramp (base, plus lighter and darker steps), and a readout that tells the designer whether a color is safe to use as a background behind editor text. Both rest on the same OKLCH math worked out 2026-06-09, and they're coupled: the dark end of a ramp is where background tints come from, and a background tint is only usable if it keeps every foreground on it readable.
+
+* Problem / Context
+
+Building a theme, the designer needs a family of related shades per hue (blue, blue+1, blue-1, and so on) and a set of dim tinted backgrounds for effects like highlight, region, isearch, and hl-line. Today every shade is hand-picked by eye. Getting them to harmonize is guesswork, and there's no signal for the harder half of the problem: a background effect is an overlay, so the same tint sits behind many different foreground colors at once. A tint that looks fine against the default text can fail against the darkest token. The designer can't see that failure, because the picker shows one fg-on-bg pair, which is the wrong number.
+
+The cost is concrete. sterling's keyword blue (#67809c, OKLCH-L 0.59) is the darkest foreground; it barely clears WCAG AA on pure black and drops below AA the instant any highlight background lifts off black. So one color silently caps every highlight to near-black, and nothing in the tool surfaces it. The designer either over-darkens everything defensively or ships unreadable highlights without knowing.
+
+* Goals and Non-Goals
+
+** Goals
+- From a base color, generate its tonal ramp (base, +1..+N lighter, -1..-N darker) that harmonizes by construction.
+- For any face used as a background, show the worst-case contrast across the foregrounds that can land on it, and name the limiting foreground.
+- Guide the usable background lightness so a generated or chosen background keeps all text readable.
+
+** Non-Goals
+- Not auto-assigning ramp steps to faces — the designer picks which step goes where.
+- Not the harmonic-fill feature (generate a whole palette from a few seed colors). Separate, deferred.
+- Not a new color model or palette format — OKLCH mode and the palette already exist; this extends them.
+
+** Scope tiers
+- v1: ramp generation from a base; worst-case-contrast readout + safe-lightness guidance for background-effect faces.
+- Out of scope: harmonic fill; auto-assignment of steps to faces.
+- vNext: harmonic fill (palette from seeds); richer per-face foreground-set detection.
+
+* Design
+
+The two features share OKLCH (perceptually uniform, so lightness / chroma / hue move independently) and reuse colormath.js, which already has =oklch2hex=, =contrast=, =apca=, and =deltaE=.
+
+*Ramp generation.* A ramp is one hue at many lightnesses. Convert the base to OKLCH, hold the hue fixed, and step lightness by a fixed perceptual delta per stop — lighter for +N, darker for -N. Chroma eases toward zero at the extremes (a near-white or near-black step carries almost no color), and every step is clamped back into sRGB. The harmony is structural: the steps share a hue and sit on an even lightness ladder, so they read as one family rather than a grab-bag. The output is a row of hexes the designer can name (=blue+1=, =blue-2=) and drop into the palette.
+
+*Background-contrast safety.* A background tint is only as good as its worst case. For a face used as a background, define its foreground set — the colors that actually render on top of it. For a code-context face (region, hl-line, isearch, highlight, lazy-highlight), that set is the syntax token colors plus the default foreground. The floor is the minimum, over that set, of =contrast(fg, candidate-bg)=; the limiting foreground is the argmin. From the floor we derive L_max: the lightest background, at the chosen hue and chroma, whose floor still clears the target (WCAG AA/AAA, or an APCA Lc). Sweep lightness to find it — deterministic.
+
+The two altitudes:
+- *For the designer:* pick a base swatch, see its ramp, add the steps you want. When editing a background-effect face, the contrast cell shows the worst case — "worst: keyword #67809c — 3.3 FAIL" — not a misleading single pair, and OKLCH mode marks the safe-lightness ceiling so you can't unknowingly cross it.
+- *For the implementer:* =ramp(baseHex, {n, stepL, chromaEase})= → =[hex]=; =fgSetFor(face, state)= → =[hex]=; =floor(bgHex, fgSet)= → ={ratio, limitingHex}=; =lMax(hue, chroma, fgSet, target)= → =L=. All pure, all in app-core.js / colormath.js, all unit-tested. Function contracts (inputs, outputs, validation, edge cases) are pinned below.
+
+* v1 covered faces
+
+v1 computes a worst-case floor for a *closed, enumerated* set of code-overlay faces — backgrounds the live buffer renders syntax-colored text over:
+
+- =region=
+- =hl-line=
+- =highlight=
+- =lazy-highlight=
+- =isearch=
+
+That set is exhaustive for v1. Other overlay faces (=secondary-selection=, =isearch-fail=, and any future built-in) are vNext, added explicitly rather than matched by a heuristic. The deliberately closed list is what makes the contract testable: an open "any face the buffer renders as text-over-syntax" rule would hand the implementer the same invent-the-behavior problem the foreground-set decision exists to close.
+
+Everything outside this set keeps its existing single-pair contrast cell:
+
+- *Package contrast cells* stay single-pair in v1. Package faces own their foregrounds through inheritance, which needs a per-preview foreground-set model that doesn't exist yet (vNext).
+- *Non-overlay UI rows* (mode-line, fringe, and the rest) stay single-pair — their foreground set isn't a syntax palette.
+
+A covered face whose foreground set resolves empty (no syntax assignments yet) shows the no-set readout (below), not a bogus ratio.
+
+* Ramp defaults and palette insertion
+
+*Generation defaults* (all exposed as controls, these are the starting values):
+- =n= (steps each direction): default =2= → base plus +1,+2,-1,-2. Safe range 1-4.
+- =stepL= (OKLCH-L delta per step): default =0.08=. Safe range 0.04-0.12.
+- =chromaEase= (fraction of chroma removed at the farthest step, eased toward the extremes): default =0.5=. Range 0-1; 0 holds chroma flat, 1 fully desaturates the last step.
+
+*Preview and insertion:*
+- The ramp previews as a row ordered darkest → lightest (=-n .. base .. +n=), the base marked.
+- Each generated swatch shows a clamp badge when =oklch2hex= reports =clamped= true, so the designer sees an out-of-gamut step before adding it.
+- The base is preview-only by default (it's the source swatch, already in the palette); the designer may opt to add it.
+- Selected steps insert adjacent to the source swatch, in =-n .. +n= order.
+
+*Naming:* step names derive from the source swatch name — base =blue= → =blue+1=, =blue+2=, =blue-1=, =blue-2=. If the source swatch is unnamed, names fall back to a hex-based label the designer can edit before add.
+
+*Collisions — never silent:*
+- *Name collision* (=blue+1= already exists): the row is flagged and the designer renames before add; no overwrite.
+- *Hex collision* (the generated hex already exists under another name): flagged as a duplicate, add still allowed (two names for one hex is legal in the palette).
+
+* Function contracts
+
+All four are pure, live in app-core.js (or colormath.js for the color math), take explicit state — never read globals — and validate user input by *returning a structured result*, not throwing. Throwing is reserved for genuine programmer error (wrong argument arity/type), not for malformed user-entered values.
+
+- =ramp(baseHex, {n, stepL, chromaEase})= → ={steps: [{hex, clamped}], error?}=. Validates: malformed =baseHex= (not a parseable hex — can't be clamped) → ={steps: [], error: 'bad-hex'}=; =n= outside 1-4 or non-integer → clamped into range with a flag; =stepL=/=chromaEase= outside range → clamped with a flag. Holds hue, steps OKLCH-L by =stepL=, eases chroma toward the extremes, gamut-clamps every step and reports per-step =clamped=.
+- =fgSetFor(face, state)= → ={set: [hex], error?}=, where =state= is explicit slices ={syntaxAssignments, palette, defaultFg, locks}=. Returns the distinct syntax-assignment hexes plus =defaultFg=, excluding locked background-only roles. A face outside the v1 covered set → ={set: [], reason: 'out-of-scope'}=. No syntax assignments → ={set: [], reason: 'empty'}=.
+- =floor(bgHex, fgSet)= → ={ratio, limitingHex, limitingLabel}=. =ratio= is the minimum WCAG contrast over =fgSet= against =bgHex=; =limitingHex= is the argmin; =limitingLabel= is its role/palette/hex name. Empty =fgSet= → ={ratio: null, limitingHex: null}= (caller shows the no-set readout).
+- =lMax(hue, chroma, fgSet, target)= → ={L, status}=. Binary-searches OKLCH-L (tolerance 0.001) for the lightest background at =hue=/=chroma= whose =floor= still clears =target=. =status= is =ok= (an L found), =none= (no L satisfies the target — every background fails), =all= (every L satisfies it — no ceiling needed), or =clamp= (the chroma clamps before the target is reached; returns the L at the clamp boundary).
+
+* Alternatives Considered
+
+** Ramp stepping in OKLCH lightness (chosen)
+- Good, because steps are perceptually even and the hue holds, so the family looks deliberate.
+- Bad, because extreme steps can leave the sRGB gamut and need clamping.
+- Neutral, because it depends on colormath's OKLCH path, which already exists.
+
+** Ramp stepping in HSL/HSV
+- Bad, because HSL/HSV aren't perceptually uniform — equal numeric steps give uneven visual steps and the hue drifts at the light/dark ends.
+
+** Ramp by interpolating base↔white and base↔black
+- Good, because it's trivial to implement.
+- Bad, because lightness and chroma drift together unpredictably and the hue can shift, so the steps don't harmonize reliably.
+
+** Worst-case shown as one readout + limiting fg (chosen), with an OKLCH picker mask
+- Good, because one honest number (the floor) plus the name of the bottleneck is the actionable lever, and the mask shows the safe band visually.
+- Neutral, because it replaces the existing single-pair contrast cell for background faces only.
+
+** Worst-case shown as a full per-foreground heatmap
+- Bad, because it's noise — the designer needs the floor and the one color setting it, not twenty ratios.
+
+* Decisions
+
+** Work in OKLCH for ramps and tints
+- State: accepted
+- Context: ramps and background tints both need even, hue-stable steps; the tool already exposes OKLCH.
+- Decision: We will compute ramps and the contrast floor in OKLCH via colormath.js.
+- Consequences: easier — even families, reuse of existing math; harder — must gamut-clamp every generated step.
+
+** Ramp = fixed lightness step + chroma ease on a held hue
+- State: accepted
+- Context: alternatives drift hue/chroma; uniform L steps read as a ladder.
+- Decision: We will step lightness by a fixed delta, ease chroma toward the extremes, and hold the hue.
+- Consequences: easier — predictable families; harder — chroma easing needs tuning so mid steps don't go muddy.
+
+** Background safety = worst-case floor over a per-face foreground set
+- State: accepted
+- Context: a single fg-on-bg pair misleads; overlays carry many foregrounds.
+- Decision: We will compute the floor over a face's foreground set and surface the floor + limiting foreground.
+- Consequences: easier — the real constraint is visible; harder — we must define each face's foreground set.
+
+** v1 foreground set for code-overlay faces = syntax tokens + default fg
+- State: accepted
+- Context: the exact face→context mapping is fuzzy; code-overlay faces clearly carry the syntax palette. The reviewer flagged that an open ("proposed") contract forces the Phase 3 implementer to invent what counts as foreground text.
+- Decision: We will scope v1 to the closed code-overlay face set (see "v1 covered faces"), and define the foreground set as the distinct hexes of the syntax-assignment colors plus the default foreground (=fg=). Duplicate hexes collapse to one entry. Locked structural colors (=bg= and any palette entry flagged as a background-only role) are excluded from the set. The limiting foreground is labeled by its syntax role name when one exists, else its palette name, else its hex.
+- Consequences: easier — a pinned, enumerable contract the implementer and tests can rely on; harder — UI/package overlays need a later per-preview foreground-set model (vNext).
+
+** Contrast target for the floor = WCAG AA default, AAA optional, APCA diagnostic
+- State: accepted
+- Context: AA (4.5) is the floor most reach for; AAA (7) is the stricter option; APCA models text-on-color better than WCAG but needs a chosen Lc and signed/absolute handling. The reviewer recommended a WCAG-only v1 so =floor=, =lMax=, labels, masks, and tests all key off one model.
+- Decision: We will drive v1 PASS/FAIL and =L_max= off WCAG contrast: default target =AA= (4.5), with =AAA= (7) selectable. APCA Lc is shown as a displayed diagnostic only and does not drive PASS/FAIL or the safe band in v1; an APCA-driven safety mode is vNext.
+- Consequences: easier — one metric for every safety surface and stable tests; harder — APCA's better text-on-color model is deferred, so a color that reads fine under APCA may still be flagged by WCAG.
+- Owner note: this and the foreground-set decision were Craig-owned open decisions; resolved here per the reviewer's recommendation to keep the spec converging. Craig can override either before implementation starts.
+
+* Implementation phases
+
+** Phase 1 — Ramp generator (pure)
+=ramp(baseHex, opts)= in app-core.js with Normal/Boundary/Error tests (mid base, near-white/near-black base, out-of-gamut request). Leaves the tree green; no UI yet.
+
+** Phase 2 — Ramp UI in the palette
+A base swatch → preview the ramp → add chosen steps as named palette entries. Reuses the palette panel and the OKLCH picker.
+
+** Phase 3 — Foreground-set + floor (pure)
+=fgSetFor=, =floor=, =lMax= in app-core.js with tests, including the keyword-blue worst case as a fixture.
+
+** Phase 4 — Worst-case readout
+For the v1 covered faces, the contrast cell shows the floor + the limiting foreground name instead of a single pair. Pinned readout shape, so =#contrasttest= asserts fields not punctuation: =worst: <limitingLabel> <limitingHex> — <ratio> <PASS|FAIL>= (example: =worst: keyword #67809c — 3.3 FAIL=). The no-foreground-set readout is exactly =no fg set=. Add a hash-gate (#contrasttest-style) pinning floor-over-set and the no-set string.
+
+** Phase 5 — Safe-lightness in OKLCH mode
+When a v1 covered face is open in the picker, mark L_max on the lightness slider and shade the unsafe band above it. v1 renders this as a *single L_max marker plus a one-band shade* (safe below, unsafe above) computed once per =(hue, chroma, fgSet, target)= via =lMax= — not a full per-pixel foreground-set contrast mask over the plane. The per-pixel AA/AAA plane mask stays single-foreground; extending it to a full foreground-set sweep is vNext if profiling ever shows the marker is insufficient.
+
+* Acceptance criteria
+- [ ] From a base hex, the tool produces N lighter + N darker steps, perceptually even, all in sRGB gamut.
+- [ ] Generated steps can be added to the palette as named entries.
+- [ ] A background-effect face shows the worst-case contrast and names the limiting foreground.
+- [ ] OKLCH mode marks the maximum safe lightness for the chosen hue/chroma given the foreground set + target.
+- [ ] Unit tests cover ramp generation, the floor, and L_max; a browser gate pins the worst-case readout.
+- [ ] =scripts/theme-studio/README.md= documents ramp controls and defaults, the worst-case-floor / limiting-foreground meaning, the v1 covered faces, and that WCAG drives PASS/FAIL with APCA shown as a diagnostic.
+
+* Readiness dimensions
+- Data model & ownership: ramp steps and tints are user-authored palette entries the designer adds; floor and L_max are computed live, not stored. Nothing new persists.
+- Errors, empty states & failure: an out-of-gamut step clamps and flags (a real but unrepresentable color); a malformed base hex can't be clamped, so =ramp= returns a structured =bad-hex= error and the row produces nothing rather than a garbage swatch; a covered face with no foreground set shows =no fg set= rather than a bogus ratio. All four core functions return structured results for bad user input instead of throwing. No silent data loss.
+- Security & privacy: N/A — local color math, no credentials or sensitive data.
+- Observability: the worst-case readout *is* the observability — the designer sees the floor and the bottleneck color directly.
+- Performance & scale: N/A meaningfully — tens of colors, instant; no long-running ops.
+- Reuse & lost opportunities: reuse colormath.js (=oklch2hex=/=contrast=/=apca=/=deltaE=), the OKLCH picker, and the AA/AAA mask. Don't reimplement color math.
+- Architecture fit & weak points: pure logic in app-core.js (tested, importable like the Stage-7 split); UI in app.js; integration points are the palette panel, the OKLCH picker, and the contrast cells of the v1 covered faces only (region, hl-line, highlight, lazy-highlight, isearch). Package and non-overlay UI cells are out of v1 scope and keep their single-pair behavior. Weak point: defining each face's foreground set — mitigated by the closed v1 covered-face set and explicit-state =fgSetFor=.
+- Config surface: =n= (steps each direction, default 2, range 1-4), =stepL= (OKLCH-L delta, default 0.08, range 0.04-0.12), =chromaEase= (default 0.5, range 0-1), and the contrast target (default WCAG AA 4.5, AAA 7 selectable). Defaults and safe ranges pinned in "Ramp defaults and palette insertion."
+- Documentation plan: the color-harmony explainer (=docs/design/theme-studio-color-harmony.org=, already a task) carries the method; this spec carries the build; =scripts/theme-studio/README.md= is the operational doc and gets the ramp controls, contrast-target semantics, covered faces, and worst-case-floor meaning (an acceptance-criteria item).
+- Dev tooling: =make theme-studio-test= covers it via new node tests + a browser gate; no new tooling.
+- Rollout, compatibility & rollback: additive — no change to the theme.json format or existing themes. The worst-case readout replaces a misleading single-pair number for background faces (a strict improvement). No migration, nothing to roll back.
+- External APIs & deps: none — pure color math, no external schema.
+
+* Risks, Rabbit Holes, and Drawbacks
+- Chroma easing at the ramp extremes can go muddy or out-of-gamut — dodge by clamping and previewing every step before it's added.
+- The foreground-set definition is the fuzzy core. v1 limits it to code-context faces (syntax tokens + default fg); an over-broad set would over-constrain backgrounds that those foregrounds never actually touch.
+- A very dark foreground (sterling's keyword blue) can collapse the safe band to near-black. That's a true finding, not a tool bug — the readout should make the designer fix the foreground's lightness, not silently absorb it.
+
+* Review dispositions
+
+Only modified and rejected recommendations are listed; everything else from the Codex review was accepted as written and is woven into the body above.
+
+- *Modified — v1 covered-face set closed, not open-ended.* The review's suggested list ended with "and any other named built-in face the live buffer renders as text-over-syntax." Adopted the five named faces (region, hl-line, highlight, lazy-highlight, isearch) but dropped the open-ended trailing clause: an open set hands the implementer the same invent-the-behavior problem the foreground-set decision exists to close. The v1 set is exhaustive; new faces are added explicitly in vNext.
+- *Modified — invalid-input handling distinguishes user error from programmer error.* The review said "prefer pure functions returning structured errors rather than throwing." Accepted for user-entered values (malformed hex, out-of-range knobs) which return structured results; kept throwing for genuine programmer error (wrong argument arity/type), matching colormath.js's existing idiom rather than blanket no-throw.
+- *Deferred (not rejected) — implementation-task drop-in block.* The review supplied six ready TODOs. Per the spec-response workflow, implementation tasks are created only after the author confirms the spec is Ready (Phase 6), so they are not yet logged to todo.org. They land on the Ready go.
+
+* Review and iteration history
+** 2026-06-09 Tue @ 17:18:54 -0500 — Craig — author
+- What: initial draft.
+- Why: ramp generation and background-contrast safety are coupled, design-uncertain, and worth solving on paper before code.
+- Artifacts: this spec; the color-harmony explainer task in todo.org.
+** 2026-06-09 Tue @ 17:34:17 -0500 — Codex — reviewer
+- What changed or was recommended: reviewed the spec against the implementation context and marked it =Not ready=. Recommended pinning the v1 contrast target, accepting the foreground-set contract, narrowing the exact covered background faces, and specifying ramp defaults / palette insertion behavior.
+- Why: the current code architecture supports the feature, but the open metric and foreground-scope decisions would make implementation invent user-visible behavior and produce unstable tests.
+- Artifacts: theme-studio-palette-ramps-spec-review.org (consumed and deleted on response).
+** 2026-06-09 Tue @ 17:53:02 -0500 — Claude Code (dotemacs) — responder
+- What changed or was recommended: dispositioned every Codex finding. Resolved both open Decisions to accepted — contrast target = WCAG AA default (AAA selectable, APCA diagnostic only), v1 foreground set = distinct syntax hexes + default fg with locked roles excluded. Added "v1 covered faces" (closed five-face set), "Ramp defaults and palette insertion" (n/stepL/chromaEase defaults, naming, collision, clamp display), and "Function contracts" (explicit-state, structured-error signatures for ramp/fgSetFor/floor/lMax with edge cases). Pinned the worst-case readout string and no-set message, clarified Phase 5 as a single L_max marker + band shade, added a README acceptance-criteria item, and corrected the architecture/config readiness lines. Two findings modified, one deferred — see Review dispositions.
+- Why: the review's blockers were product-contract gaps (open metric, proposed foreground set, conflicting scope, underspecified ramp write) that would have made two correct implementations diverge. Pinning them converges the spec to implementation-ready.
+- Artifacts: review file deleted; Review dispositions section; this entry. Two Craig-owned open decisions resolved per reviewer recommendation, override available before implementation.
diff --git a/scripts/theme-studio/README.md b/scripts/theme-studio/README.md
index 044ccc2e..a2eb59b2 100644
--- a/scripts/theme-studio/README.md
+++ b/scripts/theme-studio/README.md
@@ -42,7 +42,8 @@ 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`). It exits non-zero on any failure. The browser gates need a
+`#planetest`, `#locktest`, `#sorttest`, `#mocktest`, `#ramptest`,
+`#contrasttest`, `#safetest`). 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
@@ -92,6 +93,48 @@ 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.
+
+**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
+faces — `region`, `hl-line`, `highlight`, `lazy-highlight`, `isearch` — the
+contrast cell shows the *worst-case floor*: the lowest contrast over the face's
+foreground set (the syntax-token colors plus the default foreground), naming the
+*limiting foreground* that sets it. A tint that clears the default text but fails
+the darkest token reads FAIL, with that token named. Package faces and the other
+UI rows keep their single-pair readout.
+
+The verdict is WCAG: AA (4.5) by default, AAA (7) selectable. APCA Lc stays a
+picker-only diagnostic and does not drive PASS/FAIL.
+
+**Safe lightness.** In the OKLCH picker, the "safe for" selector picks one
+covered face. The Chroma×Lightness plane then shades the lightness band too light
+to keep that face readable over its foreground set, with the L_max ceiling as the
+band's lower edge. If even pure black can't satisfy the target (a foreground is
+too dark), the whole plane shades; that is a true finding about the foreground,
+not a tool bug.
+
## Package faces
Pick an application from the dropdown to edit its faces. Each row has a
diff --git a/scripts/theme-studio/app-core.js b/scripts/theme-studio/app-core.js
index 0e3cfe49..83f8402d 100644
--- a/scripts/theme-studio/app-core.js
+++ b/scripts/theme-studio/app-core.js
@@ -4,6 +4,12 @@
// the browser runs the same code the tests import. The app.js wrappers (pname,
// seedPkgmap, ddList, pkgEffFg, pkgEffBg) are thin delegators that pass the
// live PALETTE / APPS / PKGMAP into these.
+//
+// The imports below are for the Node tests; generate.py strips them on inline,
+// where normHex (app-util.js) and the colormath helpers are already present from
+// the bodies inlined above this one.
+import { normHex } from './app-util.js';
+import { oklch2hex, srgb2oklab, oklab2oklch, contrast } from './colormath.js';
// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
@@ -29,4 +35,90 @@ function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);r
// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
-export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify };
+// Generate a tonal ramp from one base color: 2n steps at offsets -n..-1 and
+// +1..+n (the base itself is excluded — it already lives in the palette),
+// ordered darkest -> lightest. Holds the OKLCH hue, steps lightness by stepL per
+// stop, and eases chroma toward the extremes (quadratic in |offset|/n, so only
+// the farthest step loses most of its color). Every step is gamut-clamped and
+// carries its own clamped flag. Returns {steps:[{hex,clamped,offset}], adjusted}
+// where adjusted names any knob clamped/rounded into range, or {steps:[],
+// error:'bad-hex'} for an unparseable base. Pure — opts are clamped, never thrown.
+function ramp(baseHex,opts){
+ const hex=typeof baseHex==='string'?normHex(baseHex):null;
+ if(!hex)return {steps:[],error:'bad-hex'};
+ const o=opts||{},adjusted=[];
+ const knob=(name,def,lo,hi,isInt)=>{
+ const v=o[name];
+ if(typeof v!=='number'||!isFinite(v))return def;
+ const r=isInt?Math.round(v):v,c=Math.min(hi,Math.max(lo,r));
+ if(c!==v)adjusted.push(name);
+ return c;
+ };
+ const n=knob('n',2,1,4,true),stepL=knob('stepL',0.08,0.04,0.12,false),chromaEase=knob('chromaEase',0.5,0,1,false);
+ const {L:L0,C:C0,H:H0}=oklab2oklch(srgb2oklab(hex));
+ const steps=[];
+ for(let off=-n;off<=n;off++){
+ if(off===0)continue;
+ const L=Math.min(1,Math.max(0,L0+off*stepL));
+ const t=Math.abs(off)/n,C=C0*(1-chromaEase*t*t);
+ const {hex:h,clamped}=oklch2hex(L,C,H0);
+ steps.push({hex:h,clamped,offset:off});
+ }
+ return {steps,adjusted};
+}
+
+// --- background-contrast safety (palette-ramps spec, Phase 3) ----------------
+// An overlay background sits behind many foregrounds at once, so its real
+// constraint is the worst-case contrast over the whole set, not one fg/bg pair.
+
+// The closed v1 set of code-overlay faces whose worst-case floor we compute.
+// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added
+// explicitly rather than by a heuristic. Shared by app.js and the tests.
+const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch'];
+
+// A covered face's foreground set: the distinct syntax-token colors plus the
+// default foreground, each labeled (syntax role preferred, else 'default').
+// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns
+// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the
+// face isn't in the covered set) or 'empty' (no syntax assignments constrain it).
+function fgSetFor(face,state){
+ const covered=(state&&state.covered)||COVERED_FACES;
+ if(!covered.includes(face))return {set:[],reason:'out-of-scope'};
+ const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex);
+ if(!syn.length)return {set:[],reason:'empty'};
+ const byHex=new Map();
+ const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;};
+ if(state&&state.defaultFg)add(state.defaultFg,'default',false);
+ for(const a of syn)add(a.hex,a.role||a.hex,true);
+ return {set:[...byHex.values()]};
+}
+
+// Worst-case (minimum) WCAG contrast of a background against a foreground set,
+// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty
+// set returns nulls so the caller can show the no-set readout instead of a floor.
+function floor(bgHex,fgSet){
+ if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null};
+ let best=Infinity,lh=null,ll=null;
+ for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}}
+ return {ratio:best,limitingHex:lh,limitingLabel:ll};
+}
+
+// The lightest background at (hue, chroma) whose worst-case floor over fgSet still
+// clears target (a WCAG ratio). Scans L up from black to bracket the first
+// dark-side crossing, then binary-searches it to tol 0.001. status:
+// 'ok' - a ceiling L was found
+// 'none' - even pure black fails (a foreground is too dark for the target)
+// 'all' - no foreground set to constrain (vacuously safe everywhere)
+// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there)
+function lMax(hue,chroma,fgSet,target){
+ if(!fgSet||!fgSet.length)return {L:1,status:'all'};
+ const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};};
+ if(at(0).r<target)return {L:null,status:'none'};
+ let loL=0,hiL=null;
+ for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;}
+ if(hiL===null)return {L:1,status:'all'};
+ 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'};
+}
+
+export { nameToHex, buildPkgmap, packagesForExport, mergePackagesInto, effResolve, optList, slugify, ramp, fgSetFor, floor, lMax, COVERED_FACES };
diff --git a/scripts/theme-studio/app.js b/scripts/theme-studio/app.js
index d0fed81c..ff6d7c47 100644
--- a/scripts/theme-studio/app.js
+++ b/scripts/theme-studio/app.js
@@ -121,7 +121,7 @@ function buildTable(){
const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt';
function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';}
function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);}
- const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}});
+ const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();});
styleEx();styleCr();
const lkTd=mkLockCell(kind,[dd]);
// style buttons
@@ -217,12 +217,30 @@ function paintOklchPlane(H){
if(T&&contrast(cell.hex,MAP['bg'])<T){ctx.fillStyle='rgba(8,7,6,0.66)';ctx.fillRect(x,y,step,step);}}}
_planeCache={key,data:ctx.getImageData(0,0,w,h)};
}
+// --- safe-lightness guidance (spec Phase 5) ----------------------------------
+let pkSafeFace=''; // covered overlay face the picker's lightness is checked against (or '')
+function setSafeFace(f){pkSafeFace=f;if(pickerOn)paintPicker();}
+// Shade the band of the C×L plane whose lightness is too light to keep pkSafeFace
+// readable over its foreground set, with the L_max ceiling as the band's lower
+// edge. One marker computed via lMax at the current chroma, not a per-pixel mask.
+function paintSafeBand(C,H){
+ const el=document.getElementById('svsafe');if(!el)return;
+ if(!pkSafeFace||pkModel!=='oklch'){el.style.display='none';return;}
+ const fs=fgSetForFace(pkSafeFace);
+ if(fs.reason||!fs.set.length){el.style.display='none';return;}
+ const sv=document.getElementById('sv'),h=sv.clientHeight,res=lMax(H,C,fs.set,WORST_TARGET);
+ if(res.status==='all'){el.style.display='none';return;}
+ el.style.display='block';el.style.top='0px';
+ el.style.height=(res.status==='none'?h:Math.max(0,(1-res.L)*h))+'px';
+ el.title='safe-lightness ceiling for '+pkSafeFace+' ('+(res.status==='none'?'no safe lightness — a foreground is too dark':'L_max '+res.L.toFixed(3)+(res.status==='clamp'?', chroma-clamped':''))+')';
+}
function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;
const w=sv.clientWidth,h=sv.clientHeight,hh=document.getElementById('hue').clientHeight;
if(pkModel==='oklch'){const [L,C,H]=readOklch();sv.style.background='#15120f';paintOklchPlane(H);
document.getElementById('svcur').style.left=(Math.min(1,C/OKLCH_CMAX)*w)+'px';
document.getElementById('svcur').style.top=((1-L)*h)+'px';
- document.getElementById('huecur').style.top=((H/360)*hh)+'px';return;}
+ document.getElementById('huecur').style.top=((H/360)*hh)+'px';paintSafeBand(C,H);return;}
+ const sb=document.getElementById('svsafe');if(sb)sb.style.display='none';
sv.style.background=`linear-gradient(to top,#000,rgba(0,0,0,0)),linear-gradient(to right,#fff,rgba(255,255,255,0)),hsl(${pkH},100%,50%)`;
document.getElementById('svcur').style.left=(pkS*w)+'px';document.getElementById('svcur').style.top=((1-pkV)*h)+'px';document.getElementById('huecur').style.top=((pkH/360)*hh)+'px';drawMask();}
function pkReadout(h){const e=document.getElementById('pkhex');if(e)e.textContent=h;const c=document.getElementById('pkcon');if(c){const r=contrast(h,MAP['bg']);c.textContent=r.toFixed(1)+' '+rating(r);c.style.color=ratingColor(r);}
@@ -253,6 +271,7 @@ function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getEl
function pkOutside(e){if(!e.target.closest('#picker')&&!e.target.closest('#swatch'))closePicker();}
function pkDrag(el,fn){el.addEventListener('pointerdown',e=>{e.preventDefault();fn(e);const mv=ev=>fn(ev),up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);};document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);});}
function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;sw.style.background=curHex();sw.onclick=()=>pickerOn?closePicker():openPicker();
+ const sf=document.getElementById('safefor');if(sf&&sf.options.length<=1)COVERED_FACES.forEach(f=>{const o=document.createElement('option');o.value=f;o.textContent=f;sf.appendChild(o);});
pkDrag(document.getElementById('sv'),e=>{const r=document.getElementById('sv').getBoundingClientRect();const fx=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
if(pkModel==='oklch'){setOklchInputs(1-fy,fx*OKLCH_CMAX,readOklch()[2]);pkOklchSet();}else{pkS=fx;pkV=1-fy;pkSet();}});
pkDrag(document.getElementById('hue'),e=>{const r=document.getElementById('hue').getBoundingClientRect();const fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
@@ -267,6 +286,54 @@ function addColor(){const h=curHex();const name=document.getElementById('newname
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 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;}
+ rampNote(r.adjusted.length?('adjusted: '+r.adjusted.join(', ')):'',false);
+ r.steps.forEach(s=>{const nm=rampStepName(s.offset);const c=document.createElement('div');c.className='rchip';c.style.background=s.hex;c.style.color=textOn(s.hex);
+ c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'');
+ c.innerHTML=`<span>${esc(nm)}</span><span class="rhex">${s.hex}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}`;
+ c.onclick=()=>addRampStep(s);prev.appendChild(c);});
+}
+// 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,skipped=0;r.steps.forEach(s=>{addRampStep(s)?added++:skipped++;});
+ rampNote('added '+added+(skipped?(', skipped '+skipped+' (name exists)'):''),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;}
@@ -736,8 +803,31 @@ function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8
function buildPkgPreview(){const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return;const pv=APPS[app].preview;const bespoke=['org','magit','elfeed','ghostel','dashboard','mu4e','lsp','gitgutter','flycheck','dired','dirvish','calibredb','erc','orgdrill','orgnoter','signel','pearl','slack','telega','shr'].includes(pv);p.innerHTML=pv==='org'?renderOrgPreview():pv==='magit'?renderMagitPreview():pv==='elfeed'?renderElfeedPreview():pv==='ghostel'?renderGhostelPreview():pv==='dashboard'?renderDashboardPreview():pv==='mu4e'?renderMu4ePreview():pv==='lsp'?renderLspPreview():pv==='gitgutter'?renderGitGutterPreview():pv==='flycheck'?renderFlycheckPreview():pv==='dired'?renderDiredPreview():pv==='dirvish'?renderDirvishPreview():pv==='calibredb'?renderCalibredbPreview():pv==='erc'?renderErcPreview():pv==='orgdrill'?renderOrgdrillPreview():pv==='orgnoter'?renderOrgnoterPreview():pv==='signel'?renderSignelPreview():pv==='pearl'?renderPearlPreview():pv==='slack'?renderSlackPreview():pv==='telega'?renderTelegaPreview():pv==='shr'?renderShrPreview():genericPreview(app);p.style.background=MAP['bg'];p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);};const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=bespoke?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)';}
function resetApp(){const app=curApp();PKGMAP[app]={};for(const [face,label,d] of APPS[app].faces)PKGMAP[app][face]=seedFace(d);pkgChanged();}
function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
+// --- worst-case readout for the covered overlay faces (spec Phase 4) ---------
+// Default WCAG target for the worst-case verdict (AA). AAA is selectable.
+let WORST_TARGET=4.5;
+// The live v1 foreground set for a covered overlay face: the syntax-token colors
+// (every assignable category except the ground) plus the default foreground.
+function fgSetForFace(face){
+ const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(MAP[c[0]])}));
+ return fgSetFor(face,{covered:COVERED_FACES,syntaxAssignments,defaultFg:MAP['p']});
+}
+// The worst-case contrast cell for a covered face: the floor over its foreground
+// set against its effective background, naming the limiting foreground. Returns
+// null for an out-of-scope face so the caller keeps the single-pair readout.
+function worstCellHtml(face){
+ const r=fgSetForFace(face);
+ if(r.reason==='out-of-scope')return null;
+ if(r.reason==='empty'||!r.set.length)return '<span title="this overlay has no syntax foreground set yet">no fg set</span>';
+ const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL';
+ const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict;
+ return `<span style="color:${ratingColor(fl.ratio)}" title="${esc(s)}">${esc(s)}</span>`;
+}
+// Repaint every covered overlay face (their floors depend on the syntax palette,
+// so a syntax-color edit has to refresh them even though it doesn't rebuild the table).
+function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getElementById('uicr-'+f))paintUI(f);});}
function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=o.bold?'bold':'normal';pv.style.fontStyle=o.italic?'italic':'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box);
- const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}
+ const cr=document.getElementById('uicr-'+face);if(cr){const w=worstCellHtml(face);if(w!==null){cr.innerHTML=w;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
@@ -914,3 +1004,63 @@ 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');
+ // 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".
+if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP));
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000';
+ UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false};
+ buildUITable();
+ const cell=document.getElementById('uicr-region');
+ A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent));
+ A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent));
+ A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict');
+ const fl=floor('#202830',fgSetForFace('region').set);
+ A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex);
+ A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg');
+ const ml=document.getElementById('uicr-mode-line');
+ A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)');
+ A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent));
+ MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[c[0]]='';});buildUITable();
+ const empty=document.getElementById('uicr-region');
+ A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent));
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();
+ document.title='CONTRASTTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
+// lightness band for a selected covered face and hides it when no face is selected.
+if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP);
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['bg']='#000000';
+ document.getElementById('newhexstr').value='#202830';openPicker();setPkModel('oklch');
+ setSafeFace('region');
+ const band=document.getElementById('svsafe');
+ A(band&&band.style.display==='block','safe band shows for an in-scope face');
+ A(band&&parseFloat(band.style.height)>0,'safe band has a positive height: '+(band&&band.style.height));
+ setSafeFace('');
+ A(band&&band.style.display==='none','safe band hidden when no face is selected');
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);
+ 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);}
diff --git a/scripts/theme-studio/generate.py b/scripts/theme-studio/generate.py
index 9315faad..a8cda815 100644
--- a/scripts/theme-studio/generate.py
+++ b/scripts/theme-studio/generate.py
@@ -448,13 +448,28 @@ 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()">&#8635; update selected</button>
+ <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">&#9968; ramp</button>
<span id="palmsg"></span>
+ <div id="ramp" class="ramp" style="display:none">
+ <div class="ramprow">
+ <label>ramp from <b id="rampname">&mdash;</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="svcur" class="svcur"></div></div>
+ <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>
<div id="hue" class="hue"><div id="huecur" class="huecur"></div></div>
</div>
<div class="pmodel">edit <button data-pm="hsv" class="on">HSV</button><button data-pm="oklch">OKLCH</button></div>
+ <div class="pmodel" title="in OKLCH mode, shade the lightness too light to keep this overlay face readable over its foreground set">safe for <select id="safefor" onchange="setSafeFace(this.value)"><option value="">none</option></select></div>
<div class="oklchctl" id="oklchctl">
<div class="ocrow"><label title="perceptual lightness">L</label><input type="range" id="okL" min="0" max="1" step="0.001"><input type="number" id="okLn" min="0" max="1" step="0.001"></div>
<div class="ocrow"><label title="chroma (colorfulness)">C</label><input type="range" id="okC" min="0" max="0.4" step="0.001"><input type="number" id="okCn" min="0" max="0.4" step="0.001"></div>
diff --git a/scripts/theme-studio/run-tests.sh b/scripts/theme-studio/run-tests.sh
index e364d431..4cdcd383 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"
+HASHES="selftest cursortest readouttest deltatest oklchtest planetest locktest sorttest mocktest ramptest contrasttest safetest"
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 72541ca0..f2fc6b4b 100644
--- a/scripts/theme-studio/styles.css
+++ b/scripts/theme-studio/styles.css
@@ -55,13 +55,22 @@
.oklchctl .ocrow input[type=number]{width:62px;background:#252321;color:#cdced1;border:1px solid #3a3a3a;border-radius:3px;font:10pt monospace;padding:1px 3px}
.oklchctl .pclamp{display:none;color:#cb6b4d;margin-top:3px}
.oklchctl .pclamp.show{display:block}
- .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none}
+ .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none;z-index:3}
.hue{position:relative;width:34px;height:320px;border-radius:4px;cursor:ns-resize;background:linear-gradient(to bottom,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)}
.huecur{position:absolute;left:-2px;right:-2px;height:4px;background:#fff;border:1px solid #0008;transform:translateY(-50%);pointer-events:none}
.pinfo{display:flex;justify-content:space-between;margin:10px 2px 4px;font:12pt monospace;color:#cdced1}
.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}
+ #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}
#export{width:100%;height:180px;margin-top:10px;background:#0d0b0a;color:#a4ac64;border:1px solid #252321;border-radius:6px;font:10pt monospace;padding:10px}
diff --git a/scripts/theme-studio/test-app-core.mjs b/scripts/theme-studio/test-app-core.mjs
index 16202525..39a44967 100644
--- a/scripts/theme-studio/test-app-core.mjs
+++ b/scripts/theme-studio/test-app-core.mjs
@@ -148,7 +148,7 @@ test('slugify: Error — an all-disallowed name falls back to "theme"', () => {
// the page must carry app-core.js's body (sans exports) verbatim. Requires
// `python3 generate.py` to have run first.
const stripExports = (s) =>
- s.split('\n').filter((l) => !l.startsWith('export')).join('\n').replace(/\s+$/, '');
+ s.split('\n').filter((l) => !(l.startsWith('export') || l.startsWith('import'))).join('\n').replace(/\s+$/, '');
test('inline-integrity: theme-studio.html contains the app-core.js body verbatim', () => {
const body = stripExports(readFileSync(here + 'app-core.js', 'utf8'));
diff --git a/scripts/theme-studio/test-contrast.mjs b/scripts/theme-studio/test-contrast.mjs
new file mode 100644
index 00000000..9baf5bcc
--- /dev/null
+++ b/scripts/theme-studio/test-contrast.mjs
@@ -0,0 +1,111 @@
+// Unit tests for the background-contrast safety core (app-core.js): fgSetFor,
+// floor, and lMax. Phase 3 of the palette-ramps spec. A background overlay sits
+// behind many foregrounds at once, so its real constraint is the worst-case
+// (minimum) contrast over that foreground set, and the lightest background that
+// keeps the floor above the target. Pure, no DOM. Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { fgSetFor, floor, lMax, COVERED_FACES } from './app-core.js';
+import { contrast, oklch2hex } from './colormath.js';
+
+const DEFAULT_FG = '#f0fef0';
+const stateWith = (syntaxAssignments) => ({ covered: COVERED_FACES, syntaxAssignments, defaultFg: DEFAULT_FG });
+
+// --- fgSetFor ---------------------------------------------------------------
+
+test('fgSetFor: Normal — covered face gets default fg plus the distinct syntax colors', () => {
+ const r = fgSetFor('region', stateWith([
+ { role: 'keyword', hex: '#67809c' },
+ { role: 'string', hex: '#a3b18a' },
+ ]));
+ assert.equal(r.reason, undefined);
+ assert.equal(r.set.length, 3); // default + 2 syntax
+ const hexes = r.set.map(e => e.hex);
+ assert.ok(hexes.includes('#f0fef0') && hexes.includes('#67809c') && hexes.includes('#a3b18a'));
+ assert.equal(r.set.find(e => e.hex === '#67809c').label, 'keyword');
+ assert.equal(r.set.find(e => e.hex === '#f0fef0').label, 'default');
+});
+
+test('fgSetFor: Boundary — a syntax hex equal to the default collapses, role label wins', () => {
+ const r = fgSetFor('region', { covered: COVERED_FACES, syntaxAssignments: [{ role: 'keyword', hex: '#f0fef0' }], defaultFg: '#f0fef0' });
+ assert.equal(r.set.length, 1);
+ assert.equal(r.set[0].label, 'keyword'); // role preferred over 'default'
+});
+
+test('fgSetFor: Boundary — null/blank syntax hexes are dropped', () => {
+ const r = fgSetFor('isearch', stateWith([{ role: 'a', hex: null }, { role: 'b', hex: '#112233' }]));
+ assert.equal(r.set.length, 2); // default + the one real hex
+ assert.ok(r.set.some(e => e.hex === '#112233'));
+});
+
+test('fgSetFor: Error — a face outside the covered set is out-of-scope', () => {
+ const r = fgSetFor('mode-line', stateWith([{ role: 'keyword', hex: '#67809c' }]));
+ assert.deepEqual(r, { set: [], reason: 'out-of-scope' });
+});
+
+test('fgSetFor: Error — a covered face with no syntax assignments is empty', () => {
+ const r = fgSetFor('hl-line', stateWith([]));
+ assert.deepEqual(r, { set: [], reason: 'empty' });
+});
+
+test('fgSetFor: Normal — every covered face is in scope', () => {
+ for (const f of COVERED_FACES) {
+ const r = fgSetFor(f, stateWith([{ role: 'kw', hex: '#67809c' }]));
+ assert.equal(r.reason, undefined, `${f} should be covered`);
+ }
+});
+
+// --- floor ------------------------------------------------------------------
+
+test('floor: Normal — the keyword-blue worst case sets the floor and is named', () => {
+ // sterling's keyword blue is the darkest foreground; against a lifted highlight
+ // background it is the limiting color while the light default still clears.
+ const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }];
+ const bg = '#202830';
+ const r = floor(bg, fgSet);
+ assert.equal(r.limitingHex, '#67809c');
+ assert.equal(r.limitingLabel, 'keyword');
+ assert.ok(Math.abs(r.ratio - contrast('#67809c', bg)) < 1e-9);
+ assert.ok(r.ratio < contrast('#f0fef0', bg), 'the floor is below the default-fg contrast');
+});
+
+test('floor: Boundary — a single-entry set makes that entry the limit', () => {
+ const r = floor('#000000', [{ hex: '#67809c', label: 'keyword' }]);
+ assert.equal(r.limitingHex, '#67809c');
+ assert.ok(Math.abs(r.ratio - contrast('#67809c', '#000000')) < 1e-9);
+});
+
+test('floor: Error — an empty set returns nulls, not a bogus ratio', () => {
+ assert.deepEqual(floor('#000000', []), { ratio: null, limitingHex: null, limitingLabel: null });
+});
+
+// --- lMax -------------------------------------------------------------------
+
+const F = (L, chroma, hue, fgSet) => floor(oklch2hex(L, chroma, hue).hex, fgSet).ratio;
+
+test('lMax: Normal — finds the lightest safe background; the floor brackets the target', () => {
+ const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }];
+ const r = lMax(0, 0, fgSet, 4.5);
+ assert.equal(r.status, 'ok');
+ assert.ok(r.L > 0 && r.L < 1);
+ assert.ok(F(r.L, 0, 0, fgSet) >= 4.5 - 0.05, 'floor at L_max clears the target');
+ assert.ok(F(Math.min(1, r.L + 0.05), 0, 0, fgSet) < 4.5, 'just above L_max the floor fails');
+});
+
+test('lMax: Boundary — no L satisfies the target when a foreground is too dark', () => {
+ const r = lMax(0, 0, [{ hex: '#1a1a1a', label: 'dim' }], 4.5);
+ assert.equal(r.status, 'none'); // even pure-black background can't lift #1a1a1a to AA
+});
+
+test('lMax: Boundary — an empty foreground set is vacuously safe everywhere', () => {
+ const r = lMax(0, 0, [], 4.5);
+ assert.deepEqual(r, { L: 1, status: 'all' });
+});
+
+test('lMax: Boundary — requesting an unreachable chroma at the ceiling reports clamp', () => {
+ const fgSet = [{ hex: '#f0fef0', label: 'default' }, { hex: '#67809c', label: 'keyword' }];
+ const r = lMax(250, 0.3, fgSet, 4.5); // 0.3 chroma is out of gamut at the dark ceiling
+ assert.equal(r.status, 'clamp');
+ assert.ok(r.L > 0 && r.L < 1);
+});
diff --git a/scripts/theme-studio/test-ramp.mjs b/scripts/theme-studio/test-ramp.mjs
new file mode 100644
index 00000000..0c447ff4
--- /dev/null
+++ b/scripts/theme-studio/test-ramp.mjs
@@ -0,0 +1,105 @@
+// Unit tests for the ramp generator (app-core.js `ramp`). Phase 1 of the
+// palette-ramps spec: one base color -> a harmonized tonal ramp by stepping
+// OKLCH lightness on a held hue, easing chroma toward the extremes, and
+// gamut-clamping each step. Pure, no DOM. Run: node --test scripts/theme-studio/
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { ramp } from './app-core.js';
+import { srgb2oklab, oklab2oklch, rl } from './colormath.js';
+
+const HEXRE = /^#[0-9a-f]{6}$/;
+const baseLCH = (hex) => oklab2oklch(srgb2oklab(hex));
+
+test('ramp: Normal — default opts give 2n steps, darkest-to-lightest, base excluded', () => {
+ const r = ramp('#67809c'); // mid blue
+ assert.deepEqual(r.adjusted, []);
+ assert.equal(r.steps.length, 4); // n=2 -> -2,-1,+1,+2
+ assert.deepEqual(r.steps.map(s => s.offset), [-2, -1, 1, 2]);
+ for (const s of r.steps) assert.match(s.hex, HEXRE, `${s.hex} is a 6-digit hex`);
+ // Lightness rises monotonically across the ordered steps.
+ const ls = r.steps.map(s => rl(s.hex));
+ for (let i = 1; i < ls.length; i++) assert.ok(ls[i] > ls[i - 1], 'each step lighter than the last');
+ // Base sits between -1 and +1 in lightness.
+ const baseL = rl('#67809c');
+ assert.ok(rl(r.steps[1].hex) < baseL && baseL < rl(r.steps[2].hex), 'base brackets the inner steps');
+});
+
+test('ramp: Normal — holds the hue across in-gamut steps', () => {
+ const base = '#67809c';
+ const H0 = baseLCH(base).H;
+ // chromaEase 0 keeps chroma up so the recovered hue is well-defined (near-gray
+ // steps have an ill-defined hue that 8-bit quantization can swing).
+ const r = ramp(base, { chromaEase: 0 });
+ for (const s of r.steps) {
+ if (s.clamped) continue; // a clamped step may drift hue; only assert on clean ones
+ const dH = Math.abs(baseLCH(s.hex).H - H0);
+ assert.ok(Math.min(dH, 360 - dH) < 3.0, `step ${s.offset} holds hue (${dH.toFixed(2)} deg off)`);
+ }
+});
+
+test('ramp: Normal — chroma eases toward the extremes (outer step less chromatic than inner)', () => {
+ const base = '#67809c';
+ const r = ramp(base, { n: 2, chromaEase: 0.8 });
+ const inner = baseLCH(r.steps[1].hex).C; // offset -1
+ const outer = baseLCH(r.steps[0].hex).C; // offset -2
+ assert.ok(outer < inner, 'the farther step carries less chroma');
+});
+
+test('ramp: Normal — chromaEase 0 holds chroma flat', () => {
+ const base = '#67809c';
+ const C0 = baseLCH(base).C;
+ const r = ramp(base, { n: 1, stepL: 0.06, chromaEase: 0 });
+ for (const s of r.steps) {
+ if (s.clamped) continue;
+ assert.ok(Math.abs(baseLCH(s.hex).C - C0) < 0.01, 'chroma held within tolerance');
+ }
+});
+
+test('ramp: Boundary — near-white base clamps the lighter steps at L=1', () => {
+ const r = ramp('#f6f6f6', { n: 2, stepL: 0.08 });
+ assert.equal(r.steps.length, 4);
+ const lightest = r.steps[r.steps.length - 1];
+ assert.match(lightest.hex, HEXRE);
+ assert.ok(rl(lightest.hex) > 0.9, 'lightest step is near white');
+});
+
+test('ramp: Boundary — near-black base clamps the darker steps at L=0', () => {
+ const r = ramp('#0b0b0b', { n: 2, stepL: 0.08 });
+ assert.equal(r.steps.length, 4);
+ const darkest = r.steps[0];
+ assert.match(darkest.hex, HEXRE);
+ assert.ok(rl(darkest.hex) < 0.05, 'darkest step is near black');
+});
+
+test('ramp: Boundary — n clamps to [1,4] and reports the adjustment', () => {
+ const lo = ramp('#67809c', { n: 0 });
+ assert.equal(lo.steps.length, 2); // clamped to n=1
+ assert.ok(lo.adjusted.includes('n'));
+ const hi = ramp('#67809c', { n: 9 });
+ assert.equal(hi.steps.length, 8); // clamped to n=4
+ assert.ok(hi.adjusted.includes('n'));
+ const frac = ramp('#67809c', { n: 2.7 });
+ assert.equal(frac.steps.length, 6); // rounded to 3, in range, still flagged as adjusted
+ assert.ok(frac.adjusted.includes('n'));
+});
+
+test('ramp: Boundary — stepL and chromaEase clamp to range and report', () => {
+ const r = ramp('#67809c', { stepL: 0.5, chromaEase: 2 });
+ assert.ok(r.adjusted.includes('stepL'));
+ assert.ok(r.adjusted.includes('chromaEase'));
+ assert.equal(r.steps.length, 4);
+});
+
+test('ramp: Error — malformed hex returns a structured bad-hex, not a throw', () => {
+ for (const bad of ['nope', '#xyz', '#12', '12345g', null, undefined, '']) {
+ const r = ramp(bad);
+ assert.deepEqual(r, { steps: [], error: 'bad-hex' }, `${String(bad)} -> bad-hex`);
+ }
+});
+
+test('ramp: Boundary — a six-digit hex without the leading # is accepted', () => {
+ const r = ramp('67809c');
+ assert.equal(r.steps.length, 4);
+ assert.ok(!r.error);
+});
diff --git a/scripts/theme-studio/theme-studio.html b/scripts/theme-studio/theme-studio.html
index 50e0a519..39a2a2ae 100644
--- a/scripts/theme-studio/theme-studio.html
+++ b/scripts/theme-studio/theme-studio.html
@@ -57,13 +57,22 @@
.oklchctl .ocrow input[type=number]{width:62px;background:#252321;color:#cdced1;border:1px solid #3a3a3a;border-radius:3px;font:10pt monospace;padding:1px 3px}
.oklchctl .pclamp{display:none;color:#cb6b4d;margin-top:3px}
.oklchctl .pclamp.show{display:block}
- .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none}
+ .svcur{position:absolute;width:16px;height:16px;border:2px solid #fff;border-radius:50%;transform:translate(-50%,-50%);box-shadow:0 0 0 1px #0008;pointer-events:none;z-index:3}
.hue{position:relative;width:34px;height:320px;border-radius:4px;cursor:ns-resize;background:linear-gradient(to bottom,#f00,#ff0,#0f0,#0ff,#00f,#f0f,#f00)}
.huecur{position:absolute;left:-2px;right:-2px;height:4px;background:#fff;border:1px solid #0008;transform:translateY(-50%);pointer-events:none}
.pinfo{display:flex;justify-content:space-between;margin:10px 2px 4px;font:12pt monospace;color:#cdced1}
.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}
+ #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}
#export{width:100%;height:180px;margin-top:10px;background:#0d0b0a;color:#a4ac64;border:1px solid #252321;border-radius:6px;font:10pt monospace;padding:10px}
@@ -100,13 +109,28 @@
<input type="text" id="newname" placeholder="name" onkeydown="if(event.key==='Enter')applyEdit()">
<button onclick="addColor()">+ add color</button>
<button onclick="updateColor()">&#8635; update selected</button>
+ <button onclick="openRamp()" title="generate a tonal ramp (lighter/darker steps) from the current color">&#9968; ramp</button>
<span id="palmsg"></span>
+ <div id="ramp" class="ramp" style="display:none">
+ <div class="ramprow">
+ <label>ramp from <b id="rampname">&mdash;</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="svcur" class="svcur"></div></div>
+ <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>
<div id="hue" class="hue"><div id="huecur" class="huecur"></div></div>
</div>
<div class="pmodel">edit <button data-pm="hsv" class="on">HSV</button><button data-pm="oklch">OKLCH</button></div>
+ <div class="pmodel" title="in OKLCH mode, shade the lightness too light to keep this overlay face readable over its foreground set">safe for <select id="safefor" onchange="setSafeFace(this.value)"><option value="">none</option></select></div>
<div class="oklchctl" id="oklchctl">
<div class="ocrow"><label title="perceptual lightness">L</label><input type="range" id="okL" min="0" max="1" step="0.001"><input type="number" id="okLn" min="0" max="1" step="0.001"></div>
<div class="ocrow"><label title="chroma (colorfulness)">C</label><input type="range" id="okC" min="0" max="0.4" step="0.001"><input type="number" id="okCn" min="0" max="0.4" step="0.001"></div>
@@ -386,6 +410,10 @@ function paletteWarnings(palette, threshold = 0.02, cap = 5) {
// the browser runs the same code the tests import. The app.js wrappers (pname,
// seedPkgmap, ddList, pkgEffFg, pkgEffBg) are thin delegators that pass the
// live PALETTE / APPS / PKGMAP into these.
+//
+// The imports below are for the Node tests; generate.py strips them on inline,
+// where normHex (app-util.js) and the colormath helpers are already present from
+// the bodies inlined above this one.
// Resolve a palette name (or a raw #hex) to a hex; null when the name is unknown.
function nameToHex(n,palette){if(!n)return null;if(/^#/.test(n))return n;const p=palette.find(p=>p[1]===n);return p?p[0]:null;}
@@ -410,6 +438,92 @@ function optList(cur,palette){const have=cur===''||palette.some(p=>p[0]===cur);r
// Turn a theme name into a safe filename slug: collapse runs of disallowed
// characters to a single dash, trim leading/trailing dashes, fall back to 'theme'.
function slugify(name){return name.replace(/[^A-Za-z0-9._-]+/g,'-').replace(/^-+|-+$/g,'')||'theme';}
+
+// Generate a tonal ramp from one base color: 2n steps at offsets -n..-1 and
+// +1..+n (the base itself is excluded — it already lives in the palette),
+// ordered darkest -> lightest. Holds the OKLCH hue, steps lightness by stepL per
+// stop, and eases chroma toward the extremes (quadratic in |offset|/n, so only
+// the farthest step loses most of its color). Every step is gamut-clamped and
+// carries its own clamped flag. Returns {steps:[{hex,clamped,offset}], adjusted}
+// where adjusted names any knob clamped/rounded into range, or {steps:[],
+// error:'bad-hex'} for an unparseable base. Pure — opts are clamped, never thrown.
+function ramp(baseHex,opts){
+ const hex=typeof baseHex==='string'?normHex(baseHex):null;
+ if(!hex)return {steps:[],error:'bad-hex'};
+ const o=opts||{},adjusted=[];
+ const knob=(name,def,lo,hi,isInt)=>{
+ const v=o[name];
+ if(typeof v!=='number'||!isFinite(v))return def;
+ const r=isInt?Math.round(v):v,c=Math.min(hi,Math.max(lo,r));
+ if(c!==v)adjusted.push(name);
+ return c;
+ };
+ const n=knob('n',2,1,4,true),stepL=knob('stepL',0.08,0.04,0.12,false),chromaEase=knob('chromaEase',0.5,0,1,false);
+ const {L:L0,C:C0,H:H0}=oklab2oklch(srgb2oklab(hex));
+ const steps=[];
+ for(let off=-n;off<=n;off++){
+ if(off===0)continue;
+ const L=Math.min(1,Math.max(0,L0+off*stepL));
+ const t=Math.abs(off)/n,C=C0*(1-chromaEase*t*t);
+ const {hex:h,clamped}=oklch2hex(L,C,H0);
+ steps.push({hex:h,clamped,offset:off});
+ }
+ return {steps,adjusted};
+}
+
+// --- background-contrast safety (palette-ramps spec, Phase 3) ----------------
+// An overlay background sits behind many foregrounds at once, so its real
+// constraint is the worst-case contrast over the whole set, not one fg/bg pair.
+
+// The closed v1 set of code-overlay faces whose worst-case floor we compute.
+// Other overlay faces (secondary-selection, isearch-fail, ...) are vNext, added
+// explicitly rather than by a heuristic. Shared by app.js and the tests.
+const COVERED_FACES=['region','hl-line','highlight','lazy-highlight','isearch'];
+
+// A covered face's foreground set: the distinct syntax-token colors plus the
+// default foreground, each labeled (syntax role preferred, else 'default').
+// state = {covered:[face], syntaxAssignments:[{role,hex}], defaultFg}. Returns
+// {set:[{hex,label}]}, or {set:[],reason} where reason is 'out-of-scope' (the
+// face isn't in the covered set) or 'empty' (no syntax assignments constrain it).
+function fgSetFor(face,state){
+ const covered=(state&&state.covered)||COVERED_FACES;
+ if(!covered.includes(face))return {set:[],reason:'out-of-scope'};
+ const syn=((state&&state.syntaxAssignments)||[]).filter(a=>a&&a.hex);
+ if(!syn.length)return {set:[],reason:'empty'};
+ const byHex=new Map();
+ const add=(hex,label,isRole)=>{const k=hex.toLowerCase(),cur=byHex.get(k);if(!cur)byHex.set(k,{hex:k,label});else if(isRole&&cur.label==='default')cur.label=label;};
+ if(state&&state.defaultFg)add(state.defaultFg,'default',false);
+ for(const a of syn)add(a.hex,a.role||a.hex,true);
+ return {set:[...byHex.values()]};
+}
+
+// Worst-case (minimum) WCAG contrast of a background against a foreground set,
+// with the limiting foreground's hex and label. fgSet is fgSetFor's set. An empty
+// set returns nulls so the caller can show the no-set readout instead of a floor.
+function floor(bgHex,fgSet){
+ if(!fgSet||!fgSet.length)return {ratio:null,limitingHex:null,limitingLabel:null};
+ let best=Infinity,lh=null,ll=null;
+ for(const f of fgSet){const r=contrast(f.hex,bgHex);if(r<best){best=r;lh=f.hex;ll=f.label;}}
+ return {ratio:best,limitingHex:lh,limitingLabel:ll};
+}
+
+// The lightest background at (hue, chroma) whose worst-case floor over fgSet still
+// clears target (a WCAG ratio). Scans L up from black to bracket the first
+// dark-side crossing, then binary-searches it to tol 0.001. status:
+// 'ok' - a ceiling L was found
+// 'none' - even pure black fails (a foreground is too dark for the target)
+// 'all' - no foreground set to constrain (vacuously safe everywhere)
+// 'clamp' - the ceiling L can't hold the requested chroma (gamut-clamped there)
+function lMax(hue,chroma,fgSet,target){
+ if(!fgSet||!fgSet.length)return {L:1,status:'all'};
+ const at=(L)=>{const {hex,clamped}=oklch2hex(L,chroma,hue);return {r:floor(hex,fgSet).ratio,clamped};};
+ if(at(0).r<target)return {L:null,status:'none'};
+ let loL=0,hiL=null;
+ for(let L=0.01;L<=1+1e-9;L+=0.01){const c=Math.min(L,1);if(at(c).r<target){hiL=c;break;}loL=c;}
+ if(hiL===null)return {L:1,status:'all'};
+ 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'};
+}
// 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
@@ -531,7 +645,7 @@ function buildTable(){
const crTd=document.createElement('td');crTd.style.whiteSpace='nowrap';crTd.style.fontSize='10pt';
function styleEx(){exTd.style.color=(kind==='bg'?MAP['p']:effFg(MAP[kind]));exTd.style.background=MAP['bg'];exTd.style.fontWeight=BOLD[kind]?'bold':'normal';exTd.style.fontStyle=ITALIC[kind]?'italic':'normal';}
function styleCr(){const r=contrast((kind==='bg'?MAP['p']:effFg(MAP[kind])),MAP['bg']);crTd.innerHTML=crHtml(r);}
- const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}});
+ const dd=mkColorDropdown(list,cur,(hex)=>{MAP[kind]=hex;styleEx();styleCr();renderCode();if(kind==='bg'){applyGround();buildTable();}repaintCovered();});
styleEx();styleCr();
const lkTd=mkLockCell(kind,[dd]);
// style buttons
@@ -627,12 +741,30 @@ function paintOklchPlane(H){
if(T&&contrast(cell.hex,MAP['bg'])<T){ctx.fillStyle='rgba(8,7,6,0.66)';ctx.fillRect(x,y,step,step);}}}
_planeCache={key,data:ctx.getImageData(0,0,w,h)};
}
+// --- safe-lightness guidance (spec Phase 5) ----------------------------------
+let pkSafeFace=''; // covered overlay face the picker's lightness is checked against (or '')
+function setSafeFace(f){pkSafeFace=f;if(pickerOn)paintPicker();}
+// Shade the band of the C×L plane whose lightness is too light to keep pkSafeFace
+// readable over its foreground set, with the L_max ceiling as the band's lower
+// edge. One marker computed via lMax at the current chroma, not a per-pixel mask.
+function paintSafeBand(C,H){
+ const el=document.getElementById('svsafe');if(!el)return;
+ if(!pkSafeFace||pkModel!=='oklch'){el.style.display='none';return;}
+ const fs=fgSetForFace(pkSafeFace);
+ if(fs.reason||!fs.set.length){el.style.display='none';return;}
+ const sv=document.getElementById('sv'),h=sv.clientHeight,res=lMax(H,C,fs.set,WORST_TARGET);
+ if(res.status==='all'){el.style.display='none';return;}
+ el.style.display='block';el.style.top='0px';
+ el.style.height=(res.status==='none'?h:Math.max(0,(1-res.L)*h))+'px';
+ el.title='safe-lightness ceiling for '+pkSafeFace+' ('+(res.status==='none'?'no safe lightness — a foreground is too dark':'L_max '+res.L.toFixed(3)+(res.status==='clamp'?', chroma-clamped':''))+')';
+}
function paintPicker(){const sv=document.getElementById('sv');if(!sv)return;
const w=sv.clientWidth,h=sv.clientHeight,hh=document.getElementById('hue').clientHeight;
if(pkModel==='oklch'){const [L,C,H]=readOklch();sv.style.background='#15120f';paintOklchPlane(H);
document.getElementById('svcur').style.left=(Math.min(1,C/OKLCH_CMAX)*w)+'px';
document.getElementById('svcur').style.top=((1-L)*h)+'px';
- document.getElementById('huecur').style.top=((H/360)*hh)+'px';return;}
+ document.getElementById('huecur').style.top=((H/360)*hh)+'px';paintSafeBand(C,H);return;}
+ const sb=document.getElementById('svsafe');if(sb)sb.style.display='none';
sv.style.background=`linear-gradient(to top,#000,rgba(0,0,0,0)),linear-gradient(to right,#fff,rgba(255,255,255,0)),hsl(${pkH},100%,50%)`;
document.getElementById('svcur').style.left=(pkS*w)+'px';document.getElementById('svcur').style.top=((1-pkV)*h)+'px';document.getElementById('huecur').style.top=((pkH/360)*hh)+'px';drawMask();}
function pkReadout(h){const e=document.getElementById('pkhex');if(e)e.textContent=h;const c=document.getElementById('pkcon');if(c){const r=contrast(h,MAP['bg']);c.textContent=r.toFixed(1)+' '+rating(r);c.style.color=ratingColor(r);}
@@ -663,6 +795,7 @@ function closePicker(){if(!pickerOn)return;pickerOn=false;const p=document.getEl
function pkOutside(e){if(!e.target.closest('#picker')&&!e.target.closest('#swatch'))closePicker();}
function pkDrag(el,fn){el.addEventListener('pointerdown',e=>{e.preventDefault();fn(e);const mv=ev=>fn(ev),up=()=>{document.removeEventListener('pointermove',mv);document.removeEventListener('pointerup',up);};document.addEventListener('pointermove',mv);document.addEventListener('pointerup',up);});}
function initPicker(){const sw=document.getElementById('swatch');if(!sw)return;sw.style.background=curHex();sw.onclick=()=>pickerOn?closePicker():openPicker();
+ const sf=document.getElementById('safefor');if(sf&&sf.options.length<=1)COVERED_FACES.forEach(f=>{const o=document.createElement('option');o.value=f;o.textContent=f;sf.appendChild(o);});
pkDrag(document.getElementById('sv'),e=>{const r=document.getElementById('sv').getBoundingClientRect();const fx=Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
if(pkModel==='oklch'){setOklchInputs(1-fy,fx*OKLCH_CMAX,readOklch()[2]);pkOklchSet();}else{pkS=fx;pkV=1-fy;pkSet();}});
pkDrag(document.getElementById('hue'),e=>{const r=document.getElementById('hue').getBoundingClientRect();const fy=Math.max(0,Math.min(1,(e.clientY-r.top)/r.height));
@@ -677,6 +810,54 @@ function addColor(){const h=curHex();const name=document.getElementById('newname
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 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;}
+ rampNote(r.adjusted.length?('adjusted: '+r.adjusted.join(', ')):'',false);
+ r.steps.forEach(s=>{const nm=rampStepName(s.offset);const c=document.createElement('div');c.className='rchip';c.style.background=s.hex;c.style.color=textOn(s.hex);
+ c.title=nm+' '+s.hex+(s.clamped?' (gamut-clamped)':'');
+ c.innerHTML=`<span>${esc(nm)}</span><span class="rhex">${s.hex}</span>${s.clamped?'<span class="rclamp" title="clamped to sRGB">!</span>':''}`;
+ c.onclick=()=>addRampStep(s);prev.appendChild(c);});
+}
+// 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,skipped=0;r.steps.forEach(s=>{addRampStep(s)?added++:skipped++;});
+ rampNote('added '+added+(skipped?(', skipped '+skipped+' (name exists)'):''),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;}
@@ -1146,8 +1327,31 @@ function genericPreview(app){let h='<div style="padding:10px 14px;font:12pt/1.8
function buildPkgPreview(){const app=curApp(),p=document.getElementById('pkgpreview');if(!p)return;const pv=APPS[app].preview;const bespoke=['org','magit','elfeed','ghostel','dashboard','mu4e','lsp','gitgutter','flycheck','dired','dirvish','calibredb','erc','orgdrill','orgnoter','signel','pearl','slack','telega','shr'].includes(pv);p.innerHTML=pv==='org'?renderOrgPreview():pv==='magit'?renderMagitPreview():pv==='elfeed'?renderElfeedPreview():pv==='ghostel'?renderGhostelPreview():pv==='dashboard'?renderDashboardPreview():pv==='mu4e'?renderMu4ePreview():pv==='lsp'?renderLspPreview():pv==='gitgutter'?renderGitGutterPreview():pv==='flycheck'?renderFlycheckPreview():pv==='dired'?renderDiredPreview():pv==='dirvish'?renderDirvishPreview():pv==='calibredb'?renderCalibredbPreview():pv==='erc'?renderErcPreview():pv==='orgdrill'?renderOrgdrillPreview():pv==='orgnoter'?renderOrgnoterPreview():pv==='signel'?renderSignelPreview():pv==='pearl'?renderPearlPreview():pv==='slack'?renderSlackPreview():pv==='telega'?renderTelegaPreview():pv==='shr'?renderShrPreview():genericPreview(app);p.style.background=MAP['bg'];p.onclick=(e)=>{const u=e.target.closest('[data-face]');if(u)flashPkg(u.dataset.face);};const lbl=document.getElementById('pkgprevlabel');if(lbl)lbl.textContent=bespoke?(APPS[app].label+' preview'):'preview (generic — face names in their own colors)';}
function resetApp(){const app=curApp();PKGMAP[app]={};for(const [face,label,d] of APPS[app].faces)PKGMAP[app][face]=seedFace(d);pkgChanged();}
function syncPkgHeight(){const t=document.getElementById('pkgtable'),m=document.getElementById('pkgpreview');if(!t||!m)return;const lb=m.previousElementSibling,lbh=lb?lb.getBoundingClientRect().height+10:30;m.style.height=Math.max(t.getBoundingClientRect().height-lbh,220)+'px';}
+// --- worst-case readout for the covered overlay faces (spec Phase 4) ---------
+// Default WCAG target for the worst-case verdict (AA). AAA is selectable.
+let WORST_TARGET=4.5;
+// The live v1 foreground set for a covered overlay face: the syntax-token colors
+// (every assignable category except the ground) plus the default foreground.
+function fgSetForFace(face){
+ const syntaxAssignments=CATS.filter(c=>c[0]!=='bg'&&c[0]!=='p').map(c=>({role:c[0],hex:effFg(MAP[c[0]])}));
+ return fgSetFor(face,{covered:COVERED_FACES,syntaxAssignments,defaultFg:MAP['p']});
+}
+// The worst-case contrast cell for a covered face: the floor over its foreground
+// set against its effective background, naming the limiting foreground. Returns
+// null for an out-of-scope face so the caller keeps the single-pair readout.
+function worstCellHtml(face){
+ const r=fgSetForFace(face);
+ if(r.reason==='out-of-scope')return null;
+ if(r.reason==='empty'||!r.set.length)return '<span title="this overlay has no syntax foreground set yet">no fg set</span>';
+ const bg=effBg(uf(face).bg),fl=floor(bg,r.set),verdict=fl.ratio>=WORST_TARGET?'PASS':'FAIL';
+ const s='worst: '+fl.limitingLabel+' '+fl.limitingHex+' — '+fl.ratio.toFixed(1)+' '+verdict;
+ return `<span style="color:${ratingColor(fl.ratio)}" title="${esc(s)}">${esc(s)}</span>`;
+}
+// Repaint every covered overlay face (their floors depend on the syntax palette,
+// so a syntax-color edit has to refresh them even though it doesn't rebuild the table).
+function repaintCovered(){COVERED_FACES.forEach(f=>{if(UIMAP[f]&&document.getElementById('uicr-'+f))paintUI(f);});}
function paintUI(face){const pv=document.getElementById('uiprev-'+face);if(!pv)return;const o=UIMAP[face];pv.style.color=effFg(o.fg);pv.style.background=effBg(o.bg);pv.style.fontWeight=o.bold?'bold':'normal';pv.style.fontStyle=o.italic?'italic':'normal';pv.style.textDecoration=(o.underline?'underline ':'')+(o.strike?'line-through':'')||'none';pv.style.boxShadow=boxCss(o.box);
- const cr=document.getElementById('uicr-'+face);if(cr){const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}
+ const cr=document.getElementById('uicr-'+face);if(cr){const w=worstCellHtml(face);if(w!==null){cr.innerHTML=w;}else{const efg=effFg(o.fg),ebg=effBg(o.bg),r=contrast(efg,ebg);cr.innerHTML=crHtml(r);}}}
function buildUITable(){
const tb=document.getElementById('uibody');tb.innerHTML='';
for(const [face,label,ex] of UI_FACES){
@@ -1324,4 +1528,64 @@ 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');
+ // 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".
+if(location.hash==='#contrasttest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP),saveUI=JSON.parse(JSON.stringify(UIMAP));
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['str']='#a3b18a';MAP['bg']='#000000';
+ UIMAP['region']={fg:null,bg:'#202830',bold:false,italic:false,underline:false,strike:false};
+ buildUITable();
+ const cell=document.getElementById('uicr-region');
+ A(cell&&/^worst:/.test(cell.textContent),'region shows the worst-case readout: '+(cell&&cell.textContent));
+ A(cell&&cell.textContent.includes('#67809c'),'limiting fg is keyword blue: '+(cell&&cell.textContent));
+ A(cell&&/\b(PASS|FAIL)\b/.test(cell.textContent),'readout carries a verdict');
+ const fl=floor('#202830',fgSetForFace('region').set);
+ A(fl.limitingHex==='#67809c','floor limiting is blue, got '+fl.limitingHex);
+ A(Math.abs(fl.ratio-contrast('#67809c','#202830'))<1e-9,'floor ratio matches blue-on-bg');
+ const ml=document.getElementById('uicr-mode-line');
+ A(worstCellHtml('mode-line')===null,'mode-line is out of scope (single-pair)');
+ A(ml&&/^\d/.test(ml.textContent.trim()),'mode-line cell is a numeric ratio: '+(ml&&ml.textContent));
+ MAP['p']='';CATS.forEach(c=>{if(c[0]!=='bg')MAP[c[0]]='';});buildUITable();
+ const empty=document.getElementById('uicr-region');
+ A(empty&&empty.textContent.trim()==='no fg set','empty set reads the no-set message: '+(empty&&empty.textContent));
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);for(const f in UIMAP)delete UIMAP[f];Object.assign(UIMAP,saveUI);buildUITable();
+ document.title='CONTRASTTEST '+(ok?'PASS':'FAIL');
+ const d=document.createElement('div');d.id='contrasttest';d.textContent='CONTRASTTEST '+(ok?'PASS':'FAIL')+(notes.length?' | '+notes.join(' ; '):'');document.body.appendChild(d);}
+// Safe-lightness gate (open with #safetest): the OKLCH picker shades the unsafe
+// lightness band for a selected covered face and hides it when no face is selected.
+if(location.hash==='#safetest'){let ok=true;const notes=[];const A=(c,n)=>{if(!c){ok=false;notes.push(n);}};
+ const saveMAP=Object.assign({},MAP);
+ MAP['p']='#f0fef0';MAP['kw']='#67809c';MAP['bg']='#000000';
+ document.getElementById('newhexstr').value='#202830';openPicker();setPkModel('oklch');
+ setSafeFace('region');
+ const band=document.getElementById('svsafe');
+ A(band&&band.style.display==='block','safe band shows for an in-scope face');
+ A(band&&parseFloat(band.style.height)>0,'safe band has a positive height: '+(band&&band.style.height));
+ setSafeFace('');
+ A(band&&band.style.display==='none','safe band hidden when no face is selected');
+ for(const k in MAP)delete MAP[k];Object.assign(MAP,saveMAP);
+ 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);}
</script> \ No newline at end of file
diff --git a/todo.org b/todo.org
index 4956d315..ab4f5d04 100644
--- a/todo.org
+++ b/todo.org
@@ -46,6 +46,12 @@ The contrast readout on every item with two color selections (a fg AND a bg —
Investigation start: the two-color contrast cells are =paintUI= (UI faces, app.js ~line 740) and =buildPkgTable= (package faces, app.js ~line 430), both currently calling =contrast(effFg(fg), effBg(bg))= where =effFg(v)=v||MAP['p']= and =effBg(v)=v||MAP['bg']=. Reproduce a face that has BOTH a fg and a bg set, confirm the displayed ratio, and check whether it's actually evaluating selected-fg vs selected-bg or falling through to the ground bg. Fix so a two-color face always rates its own fg-on-bg. (Single-color contexts — the picker/palette-chip/plane checks that rate a color against the ground — are correct and out of scope.) Add a characterization gate (a #contrasttest hash gate) pinning fg-vs-bg for a two-color face.
+** TODO [#B] theme-studio UI-faces preview cell ignores the face bg :bug:theme-studio:
+In the UI faces table, the preview cell for a face with its own bg renders with the ground bg instead. Repro: set mode-line fg=black, bg=blue — the preview cell should be black text on blue, but shows black on black (the live buffer mode-line is fine). Root cause: =applyGround= (app.js:300) blankets EVERY =.ex= element's background to =MAP['bg']=, and the preview cell =cP= shares =className='ex'= (app.js:753), so it clobbers the per-face bg =paintUI= sets (app.js:739) — runs on load and on every ground change. Fix: stop applyGround from touching the UI-face preview cells (scope its =.ex= selector to the code/example cells, give the preview cell its own class, or re-run paintUI after). The contrast cell shares the same staleness, so confirm both.
+
+** TODO [#B] Split window opens the dashboard in the other window :feature:ux:windows:
+When splitting with C-x 2 (=split-window-below=) or C-x 3 (=split-window-right=), the new/other window should default to the =*dashboard*= buffer instead of mirroring the current buffer. Advise =split-window-below= / =split-window-right= (or rebind the keys) to select the dashboard in the freshly-created window. Keep point in the original window.
+
** TODO [#B] theme-studio live-preview bevel thinner than Emacs :bug:theme-studio:
The mode-line box (3D released-button bevel) in the live buffer preview renders slimmer than the bevel Emacs actually draws. Make them match. The bevel comes from =boxCss= in app.js (~line 307), currently =inset 1px 1px 0 #ffffff33,inset -1px -1px 0 #00000066= for the released style — a 1px inset with faint translucent highlight/shadow. Emacs's released-button box is wider/stronger (it shades the highlight and shadow from the actual background color, not a flat translucent white/black). Fix: widen the bevel and derive the highlight/shadow from the box's background so it reads like Emacs. Verify side-by-side against a real Emacs mode-line.
@@ -58,6 +64,34 @@ Rule taxonomy captured in [[file:docs/design/theme-studio-face-rules.org][docs/d
Bake into the tool (a lint surfaced in the UI) or run as a build-time check (seeds vs live deffaces via emacsclient).
+** TODO [#B] theme-studio color-harmony explainer + ramp/fill features :feature:theme-studio:docs:
+Write an explainer in =docs/design/theme-studio-color-harmony.org= capturing the OKLCH harmony method worked out 2026-06-09: harmony is mostly calculable — work in OKLCH, borrow the hue from a semantic accent, fix lightness + chroma across a tier, and let contrast (WCAG/APCA), ΔE separation, and the sRGB gamut bound the free dials. Include the worked background-tint tier (borrow accent hue, fix L≈0.28 C≈0.045 → dim readable bg per hue) and the fg-vs-bg role split (bright accents for text, dim low-chroma tints for backgrounds).
+
+Two features it enables (both worth building):
+1. Ramp generation (focus first): from a base color, generate its tonal ramp — base, +1/+2/+3 (lighter) and -1/-2/-3 (darker) — by stepping OKLCH lightness (and easing chroma) on a fixed hue. Term note: the whole family is a "ramp"/"tonal scale"; darker steps are "shades", lighter are "tints", gray-mixed are "tones" — so "ramp" or "scale" is the precise word, not "shades".
+2. Harmonic fill: from a few chosen colors (e.g. slate blue + bg), generate a table of harmonic candidates (hue-angle schemes at matched L/C) to fill the missing palette slots.
+
+Open design problem to address in the explainer + the ramp feature: a background-over-text effect (highlight/region/isearch/hl-line) must stay readable for EVERY foreground that can appear on it — i.e. the worst-case (lowest) contrast across the whole set of element fg colors, not a single pair. The usable background lightness is therefore capped by the darkest/closest fg in that set.
+
+The v1 feature (ramp generation + background-contrast safety, with the worst-case-floor UX) is designed in [[file:docs/theme-studio-palette-ramps-spec.org][docs/theme-studio-palette-ramps-spec.org]]. Codex review incorporated 2026-06-09: both open decisions resolved (WCAG AA default target; v1 foreground set = distinct syntax hexes + default fg), v1 covered faces closed to region/hl-line/highlight/lazy-highlight/isearch, ramp defaults + function contracts pinned. Spec is Ready (Craig confirmed 2026-06-09); the v1 build is tracked under the sibling parent below. Harmonic fill (feature 2) stays vNext. This task is the explainer doc itself (=docs/design/theme-studio-color-harmony.org=, the methodology).
+
+** TODO [#B] theme-studio palette ramps + contrast safety v1 :feature:theme-studio:
+The v1 build from [[file:docs/theme-studio-palette-ramps-spec.org][theme-studio-palette-ramps-spec.org]] (Ready, Codex-reviewed). Two coupled features: a ramp generator (one base color → harmonized tonal ramp) and background-contrast safety (worst-case floor over a face's foreground set + safe-lightness guidance).
+
+All five phases + the README close-out landed 2026-06-09 (commits 1d51a332, 9da6c663, e7021bfe, 1d8b9f9e, 843bbf08, 23926837); =make theme-studio-test= green (78 node tests, 12 browser gates). Code-complete and self-verified. Remaining: the aesthetic and real-Emacs-fidelity sign-off under the Manual testing parent below (does a ramp harmonize, does the safe band read clearly, does a "safe" tint actually read behind real syntax). Mark this DONE once that passes.
+*** 2026-06-09 Tue @ 18:40:20 -0500 Ramp generator core landed
+Phase 1 (commit =1d51a332=). =ramp(baseHex, {n, stepL, chromaEase})= in app-core.js → ={steps: [{hex, clamped, offset}], adjusted}= or ={steps: [], error: 'bad-hex'}=. Holds the OKLCH hue, steps lightness by =stepL=, quadratic chroma-ease toward the extremes, gamut-clamps each step; knobs clamp to range with the clamped knob named in =adjusted= (n=2/stepL=0.08/chromaEase=0.5 defaults). 10 node tests (mid/near-white/near-black bases, hue-hold, chroma ease, knob clamping, malformed hex), suite 55→65, =make theme-studio-test= green. The app-core integrity stripper now drops =import= lines too.
+*** 2026-06-09 Tue @ 19:06:46 -0500 Ramp UI in palette landed
+Phase 2 (commit =9da6c663=). A "ramp" button opens a panel that generates from the current color and previews the steps (named per source swatch, clamp badge on out-of-gamut steps); the n/stepL/chroma-ease controls default to 2/0.08/0.5. Click a step or "add all" to insert adjacent to the source in -n..+n order; name collisions skip (no overwrite), hex duplicates add with a flag. New #ramptest gate pins count, ordered insertion, collision skip, and the clamp badge. Verified headless + screenshot.
+*** 2026-06-09 Tue @ 18:53:16 -0500 Foreground-set + floor + L_max core landed
+Phase 3 (commit =e7021bfe=). =fgSetFor=, =floor=, =lMax= and the =COVERED_FACES= constant in app-core.js, all pure and explicit-state. fgSetFor returns {set:[{hex,label}]} or a structured reason ('out-of-scope'/'empty'); floor returns {ratio, limitingHex, limitingLabel}; lMax scans L from black to bracket the dark-side crossing then binary-searches it (tol 0.001), status ok/none/all/clamp. 13 node tests including the keyword-blue #67809c fixture and lMax's none/all/clamp branches. Suite 65→78, =make theme-studio-test= green.
+*** 2026-06-09 Tue @ 19:06:46 -0500 Worst-case contrast readout landed
+Phase 4 (commit =1d8b9f9e=). The five covered overlay faces show the worst-case floor over their foreground set (live syntax colors + default fg) and name the limiting foreground; a syntax-color edit repaints them. Out-of-scope faces keep the single-pair cell; an empty set reads "no fg set". Verdict is WCAG AA by default. New #contrasttest gate pins the readout, the keyword-blue limiting case, the single-pair fallback, and the no-set string.
+*** 2026-06-09 Tue @ 19:06:46 -0500 Safe-lightness picker guidance landed
+Phase 5 (commit =843bbf08=). The OKLCH picker gets a "safe for" selector over the covered faces; the C×L plane shades the lightness band too light to keep that face readable, with the L_max ceiling (via =lMax= at the current chroma) as the band's lower edge. A too-dark foreground shades the whole plane. New #safetest gate pins band-shows-for-covered-face and hides-when-none. Verified headless + screenshot.
+*** 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 [#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.
@@ -182,6 +216,24 @@ What we're verifying: the OKLCH sliders / C×L plane edit cleanly and clamping i
- Switch the picker to OKLCH mode and drag L, then C, then H
- Push chroma past the sRGB gamut, then toggle the AA/AAA mask
Expected: each axis moves independently; the C×L plane (once 4b lands) opens on the current color; "chroma clamped to sRGB" shows on clamp; toggling the mask does not reset OKLCH mode.
+*** TODO Generated ramp harmonizes
+What we're verifying: a ramp generated from a base color reads as one family, not a grab-bag (the aesthetic the math is meant to produce).
+- Open =scripts/theme-studio/theme-studio.html= in Chrome
+- Pick a mid-lightness base swatch (e.g. a blue) and generate its ramp at the defaults
+- Read the row of steps left to right, then try a near-black and a near-white base
+Expected: the steps share an obvious hue and step evenly in lightness; the chroma-ease keeps the extreme steps from going muddy or garish; nothing looks like it belongs to a different color.
+*** TODO Safe-lightness guidance reads clearly
+What we're verifying: the L_max marker and unsafe-band shade are legible and land in the right place when editing a covered face.
+- Open the picker in OKLCH mode on region (or hl-line), with syntax colors assigned
+- Read the L_max marker and the shaded unsafe band on the lightness slider
+- Drag lightness up toward and past the marker
+Expected: the marker is visible and correctly placed, the band above it reads as "unsafe," and crossing it is obvious; an out-of-scope face shows no marker.
+*** TODO Safe tint actually reads in real Emacs
+What we're verifying: a background tint the tool calls safe really keeps every token readable behind real syntax-colored text — the whole point of the worst-case floor.
+- In the tool, set a covered face (e.g. region) to a tint at or just below its L_max with the worst-case readout showing PASS
+- 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 [#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