remarque-tokens 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENT_RULES.md CHANGED
@@ -42,6 +42,7 @@ These are not guidelines. Agents must follow them literally.
42
42
  - Site personalization happens ONLY by overriding `tokens-palette.css` tokens (font slots from the approved pairings, colors, accent, `--content-reading` per the measure table) in a stylesheet loaded after the tokens
43
43
  - After ANY palette change, run the audit and fix every failure before shipping — `npm run audit` inside this repo; `npx remarque-audit --palette <file> --src <dir>` in a consumer project (the npm script only exists here)
44
44
  - A generated palette override (from `npx remarque-theme <light> --dark <dark>`, see REMARQUE.md "Color Providers") is regenerated, never hand-edited — if it needs to change, change the source theme slugs and re-run `remarque-theme`, don't patch the emitted CSS directly
45
+ - The DEFAULT palette in `tokens-palette.css` is bound to the upstream `remarque-light`/`remarque-dark` themes (REMARQUE.md "Color Providers" identity/serialization contract) — `node scripts/palette-golden.mjs` must stay green. Changing a default color means changing the upstream themes first (or in lockstep with the hand file), never editing `tokens-palette.css` alone to chase a design change; a value edited only on this side is drift, not personalization, and the golden gate exists to catch it
45
46
 
46
47
  ### Typography
47
48
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,76 @@ All notable changes to `remarque-tokens` are documented here. Token value
4
4
  changes always state the design rationale — downstream sites pin against
5
5
  these entries when syncing.
6
6
 
7
+ ## 0.11.0 — 2026-07-23
8
+
9
+ Palette golden gate: the default palette is now bound, by CI, to the
10
+ upstream `remarque-light`/`remarque-dark` themes (Phase 3 of the
11
+ color-provider integration, closes #76; consensus-ratified 3-0,
12
+ 2026-07-23).
13
+
14
+ ### Changed
15
+ - **`remarque-theme`: lightness is now KEEP-IF-PASSING, not always
16
+ solved.** For `fg`, `accent`, `accent-hover`, and (in dark)
17
+ `selection-fg`/`code-fg`, the bridge now keeps the theme's own
18
+ authored lightness when it already clears the slot's contrast target,
19
+ and only solves (binary search) when it doesn't. Previously every one
20
+ of these slots was solved unconditionally — e.g. `fg` always landed
21
+ exactly on the 10:1 threshold, flattening a well-designed theme's own
22
+ ~13:1+ contrast down to the floor. The `fg` target itself also drops
23
+ from 10:1 to 7:1 (the actual AAA line this slot exists to clear) when
24
+ a solve is needed at all. **This changes derived output for every
25
+ existing theme pairing** — most of the 61-pair corpus shifts at least
26
+ one slot (see the PR for the full before/after) — hence the minor
27
+ bump rather than a patch. Every pair still passes `remarque-audit`;
28
+ the corpus property test (`test-theme.mjs`) stays green.
29
+ - **`fg-muted` and `border-bold` gain contrast headroom over their solved
30
+ minimums** (−0.01 L and −0.02 L further from the background,
31
+ respectively; mirrored in dark). Slots that exist to clear a legal line
32
+ shouldn't sit exactly on it: borders at 3.0:1 are fragile against
33
+ antialiasing, AAA text at 7.0:1 has no margin for rendering variance.
34
+ This encodes the same headroom the hand-authored default has always
35
+ carried (border-bold at 3.39:1, fg-muted at 7.55:1) into the
36
+ derivation itself — it's what reconciled the golden gate's last two
37
+ outliers without loosening the ΔE threshold.
38
+ - All other slots (`muted`, the bg ladder, `selection-bg`,
39
+ `accent-subtle`) are unaffected — solved/derived unconditionally, same
40
+ as before.
41
+ - **Accent selection now prefers the dataset's `accent` field** (dataset
42
+ 0.3.0+, upstream #133) — the same cursor-else-most-chromatic-ANSI
43
+ heuristic, computed upstream where it can be curated per theme. The
44
+ local heuristic remains as fallback for older datasets. Dev pin bumped
45
+ `0.2.0` → `0.3.0`, whose native themes are OKLCH-authored (upstream
46
+ #132): the Remarque themes now carry the design palette's exact
47
+ values, which is what lets the golden gate below hold at ΔE ≤ 2.0
48
+ with most tokens exactly 0.
49
+
50
+ ### Added
51
+ - **`scripts/palette-golden.mjs`** (new CI gate, wired into
52
+ `deploy.yml` right after the `remarque-theme` corpus test): derives
53
+ the reference palette from the pinned `remarque-light`/`remarque-dark`
54
+ themes and compares every `--color-*` token in both themes against
55
+ the hand-authored `tokens-palette.css`, resolving `var()` chains,
56
+ via CIEDE2000 (`culori`'s `differenceCiede2000` — not hand-rolled ΔE
57
+ math). Asserts ΔE2000 ≤ 2.0 per token and an exact `weight-display`
58
+ match; the full per-token table prints unconditionally (not just on
59
+ failure) so drift stays visible before it reaches the threshold. A
60
+ breach means the upstream themes and the hand file have diverged —
61
+ reconcile one against the other, the gate does not loosen.
62
+ - **Site mirror check**, same script: asserts every `--color-*` value in
63
+ `site/src/styles/globals.css`'s `[data-theme="light"]` block is
64
+ string-identical (whitespace-normalized) to `tokens-palette.css`'s
65
+ `:root` light value — the known drift trap called out in #76.
66
+ - `culori` added as a devDependency (already present transitively via
67
+ `@williamzujkowski/oklch-terminal-themes`; now pinned directly since
68
+ `palette-golden.mjs` imports it).
69
+ - REMARQUE.md "Color Providers": documents the identity/serialization
70
+ contract — the upstream themes are the source of truth for the
71
+ default palette's identity, `tokens-palette.css` is its human-authored
72
+ serialization, and the golden gate is what keeps them from drifting
73
+ apart. AGENT_RULES.md: palette color changes must keep the golden
74
+ gate green; changing a default color means changing the upstream
75
+ themes first (or in lockstep), not hand-editing the CSS alone.
76
+
7
77
  ## 0.10.0 — 2026-07-23
8
78
 
9
79
  Color-provider Phase 2 groundwork: the upstream dataset now pairs its own
package/REMARQUE.md CHANGED
@@ -80,12 +80,14 @@ The palette tier (all `--color-*` tokens) can be *supplied* rather than hand-aut
80
80
  npx remarque-theme <light-slug> [--dark <dark-slug>] [-o palette-override.css]
81
81
  ```
82
82
 
83
- Terminal themes carry only `background/foreground/cursor/selection` + 16 ANSI slots — a few of Remarque's 15 semantic slots map directly, and most themes fail the AAA `fg-muted` 7:1 line as authored. So `remarque-theme` *derives* rather than maps: **hue and chroma carry the theme's personality; lightness is solved per slot** (binary search against the same contrast targets as the Enforcement Checklist below, with in-gamut chroma clamping) so the output passes `remarque-audit` by construction. The accent hue comes from the theme's cursor color if it's chromatic, otherwise the most saturated classic ANSI color, and is kept consistent between the light and dark half of a pair.
83
+ Terminal themes carry only `background/foreground/cursor/selection` + 16 ANSI slots — a few of Remarque's 15 semantic slots map directly, and most themes fail the AAA `fg-muted` 7:1 line as authored. So `remarque-theme` *derives* rather than maps: **hue and chroma carry the theme's personality; lightness is KEEP-IF-PASSING**. For the slots that have a meaningful "theme's own value" — `fg`, `accent`, `accent-hover`, and (in dark) `selection-fg`/`code-fg` the theme's own authored lightness is kept as-is when it already clears the slot's contrast target; lightness is solved (binary search, same targets as the Enforcement Checklist below, with in-gamut chroma clamping) *only* when it doesn't. Every other slot (`fg-muted`, `muted`, `border-bold`, the bg ladder, `selection-bg`, `accent-subtle`) is solved/derived unconditionally, as it always was — those don't carry an independent authored value to preserve. This means a well-designed input theme comes through close to its own feel instead of being flattened to the exact threshold; a theme that fails a target still gets a value that passes, exactly as before. The accent hue comes from the theme's cursor color if it's chromatic, otherwise the most saturated classic ANSI color, and is kept consistent between the light and dark half of a pair.
84
84
 
85
85
  The audit remains the gate regardless of provenance — `remarque-theme` self-verifies its own output against the same checks before it will emit anything, but a site that hand-edits a generated palette (or points at a provider with pathological input colors) still runs through `remarque-audit` in CI like any other palette.
86
86
 
87
87
  **Pairing contract:** dataset 0.2.0+ carries a `counterpart` field linking each paired theme to its canonical opposite-polarity variant ([`oklch-terminal-themes#128`](https://github.com/williamzujkowski/oklch-terminal-themes/issues/128)), and `--dark` defaults to it — `npx remarque-theme rose-pine-dawn` alone derives the rosé-pine pair. For a light theme without a counterpart (or an older dataset), `--dark <slug>` remains required; nothing is ever guessed from names. `remarque-theme` also rejects a light slug that isn't tagged `isDark: false` and a dark slug that isn't tagged `isDark: true`.
88
88
 
89
+ **Identity/serialization contract (#76):** the default palette shipped in `tokens-palette.css` is not an independent hand-design — its **identity** is bound to the upstream `remarque-light`/`remarque-dark` native themes in `@williamzujkowski/oklch-terminal-themes` (pinned exact devDependency), derived through the same `remarque-theme` bridge as any other consumer's palette. `tokens-palette.css` itself is that identity's **serialization**: a stable, human-authored file with designed round numbers and hand comments, kept perceptually identical to what the bridge derives. `scripts/palette-golden.mjs` is the CI gate that proves this — it derives the reference palette from the pinned themes, compares every `--color-*` token in both files via CIEDE2000 (ΔE2000 ≤ 2.0, reported per token, always printed so drift stays visible before it reaches the threshold), and separately asserts that `site/src/styles/globals.css`'s `[data-theme="light"]` mirror block stays string-identical to `tokens-palette.css`'s `:root` light value (a known drift trap). A breach means the upstream themes and the hand file have diverged — reconcile one against the other; the gate does not loosen.
90
+
89
91
  ---
90
92
 
91
93
  ## Font Slots
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remarque-tokens",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },
@@ -31,7 +31,8 @@
31
31
  "drift": "node scripts/drift-check.mjs"
32
32
  },
33
33
  "devDependencies": {
34
- "@williamzujkowski/oklch-terminal-themes": "0.2.0"
34
+ "@williamzujkowski/oklch-terminal-themes": "0.3.0",
35
+ "culori": "^4.0.2"
35
36
  },
36
37
  "peerDependencies": {
37
38
  "@williamzujkowski/oklch-terminal-themes": ">=0.1.0 <0.3.0"
package/scripts/theme.mjs CHANGED
@@ -10,9 +10,16 @@
10
10
  * ANSI slots — most of Remarque's 15 semantic slots don't map directly,
11
11
  * and most themes fail the AAA fg-muted 7:1 line as authored. So this
12
12
  * DERIVES rather than maps: hue + chroma come from the theme (its
13
- * personality), lightness is solved per slot to hit the exact ratio
14
- * targets from REMARQUE.md's Enforcement Checklist (binary search over
15
- * L, with in-gamut chroma clamping inside the solver). Output passes
13
+ * personality); lightness is KEEP-IF-PASSING a few load-bearing slots
14
+ * (fg, accent, accent-hover, and dark's selection-fg/code-fg) keep the
15
+ * theme's own authored lightness when it already clears the slot's
16
+ * ratio target, and are solved by binary search ONLY when it doesn't.
17
+ * A well-designed input theme comes through close to its own feel
18
+ * instead of being flattened to the exact threshold; a theme that fails
19
+ * still gets a value that passes, same as before (#76). Every other
20
+ * slot (fg-muted, muted, border-bold, the bg ladder, selection-bg,
21
+ * accent-subtle) is solved/derived as before — those don't have a
22
+ * meaningful "theme's own value" to preserve. Output passes
16
23
  * remarque-audit BY CONSTRUCTION — this script self-verifies the same
17
24
  * pairings before it will emit anything.
18
25
  *
@@ -254,24 +261,51 @@ function solveL(C, H, bg, target, dir) {
254
261
  );
255
262
  }
256
263
 
257
- /* Accent hue: cursor if it's chromatic, else the most-chromatic classic ANSI. */
264
+ /* Accent source: cursor if it's chromatic, else the most-chromatic classic
265
+ * ANSI. Carries l too (not just h/c) — keep-if-passing needs the source's
266
+ * own lightness as the candidate to test before falling back to solveL. */
258
267
  function accentHue(theme, label) {
268
+ // Dataset 0.3.0+ publishes a curated/computed accent per theme — prefer
269
+ // it (it's this same heuristic, computed upstream where it can be
270
+ // curated per-theme). Validated like any other theme color; the local
271
+ // heuristic below stays as the fallback for older datasets.
272
+ if (theme.accent && theme.accent.oklch) {
273
+ const [l, c, h] = validateOklch(theme.accent.oklch, `${label} accent`);
274
+ return { l, c, h, from: `accent:${String(theme.accent.source)}` };
275
+ }
259
276
  const cursor = theme.colors && theme.colors.cursor && theme.colors.cursor.oklch;
260
277
  if (cursor) {
261
- const [, c, h] = validateOklch(cursor, `${label} colors.cursor`);
262
- if (c >= 0.05) return { h, c, from: 'cursor' };
278
+ const [l, c, h] = validateOklch(cursor, `${label} colors.cursor`);
279
+ if (c >= 0.05) return { l, h, c, from: 'cursor' };
263
280
  }
264
281
  const names = ['blue', 'purple', 'red', 'green', 'cyan', 'yellow'];
265
282
  const cands = [];
266
283
  for (const name of names) {
267
284
  const o = theme.colors && theme.colors[name] && theme.colors[name].oklch;
268
285
  if (!o) continue;
269
- const [, c, h] = validateOklch(o, `${label} colors.${name}`);
270
- cands.push({ name, c, h });
286
+ const [l, c, h] = validateOklch(o, `${label} colors.${name}`);
287
+ cands.push({ name, l, c, h });
271
288
  }
272
289
  if (!cands.length) die(`${label}: no classic ANSI colors available to derive an accent hue`);
273
290
  cands.sort((a, b) => b.c - a.c);
274
- return { h: cands[0].h, c: cands[0].c, from: cands[0].name };
291
+ return { l: cands[0].l, h: cands[0].h, c: cands[0].c, from: cands[0].name };
292
+ }
293
+
294
+ /* Keep-if-passing: if the theme's own (quantized) lightness at this slot
295
+ * already clears `target` against `bg`, keep it (chroma gamut-clamped,
296
+ * hue rounded) — no margin, this is the theme's own value, not a solve.
297
+ * Only when it fails do we fall back to solveL, which keeps its existing
298
+ * ×1.03 search margin. `dir` only matters for that fallback path. */
299
+ function keepOrSolve(L0, C, H, bg, target, dir) {
300
+ // Clamp into the valid domain first — an offset candidate (accent-hover,
301
+ // dark selection-fg/code-fg) can walk L past 0/1 for a theme whose own
302
+ // slot already sits near an extreme; ratio()'s luminance clip() would
303
+ // otherwise let an out-of-[0,1] L "pass" here only to fail the raw
304
+ // gamut check downstream in selfVerify.
305
+ const L = Math.min(1, Math.max(0, r3(L0)));
306
+ const c = fitChroma(L, C, H);
307
+ if (ratio([L, c, H], bg) >= target) return [L, c, r1(H)];
308
+ return solveL(C, H, bg, target, dir);
275
309
  }
276
310
 
277
311
  function fmt(triple) {
@@ -280,6 +314,17 @@ function fmt(triple) {
280
314
  return `oklch(${L} ${C} ${H})`;
281
315
  }
282
316
 
317
+ /* Push a solved triple further from the background by delta L — headroom
318
+ * over the legal minimum for slots that shouldn't sit at it (functional
319
+ * borders at exactly 3:1 are fragile against antialiasing; AAA text at
320
+ * exactly 7:1 has no margin for rendering variance). Direction is always
321
+ * away from bg, so the ratio only increases — never re-verified against
322
+ * the target, only re-clamped for gamut. */
323
+ function withHeadroom([L0, , H], delta, dir, C) {
324
+ const L = r3(Math.min(1, Math.max(0, dir === 'darker' ? L0 - delta : L0 + delta)));
325
+ return [L, fitChroma(L, C, H), r1(H)];
326
+ }
327
+
283
328
  /* ── LIGHT derivation ─────────────────────────────────────────────── */
284
329
 
285
330
  function deriveLight(t) {
@@ -289,16 +334,23 @@ function deriveLight(t) {
289
334
  const bgC = fitChroma(bgL, Math.min(bgC0, 0.02), bgH);
290
335
  const bg = [bgL, bgC, bgH];
291
336
  const surface = [r3(bgL - 0.01), bgC, bgH];
292
- const [, fgC0, fgH] = slotLch(t, 'foreground', 'light');
337
+ const [fgL0, fgC0, fgH] = slotLch(t, 'foreground', 'light');
293
338
  const fgC = Math.min(fgC0, 0.03);
294
- const fg = solveL(fgC, fgH, surface, 10, 'darker');
295
- const fgMuted = solveL(fgC, fgH, bg, 7.0, 'darker');
339
+ // fg: keep the theme's own foreground lightness if it already clears the
340
+ // AAA floor (7:1) against surface; solve only if it doesn't (#76 — this
341
+ // used to always solve to 10:1, flattening well-designed themes).
342
+ const fg = keepOrSolve(fgL0, fgC, fgH, surface, 7.0, 'darker');
343
+ const fgMuted = withHeadroom(solveL(fgC, fgH, bg, 7.0, 'darker'), 0.01, 'darker', fgC);
296
344
  const muted = solveL(Math.min(fgC0, 0.02), fgH, surface, 4.5, 'darker');
297
- const borderBold = solveL(0.01, bgH, bg, 3.0, 'darker');
345
+ const borderBold = withHeadroom(solveL(0.01, bgH, bg, 3.0, 'darker'), 0.02, 'darker', 0.01);
298
346
  const ac = accentHue(t, 'light');
299
347
  const acC = Math.min(ac.c, 0.14);
300
- const accent = solveL(acC, ac.h, bg, 4.5, 'darker');
301
- const accentHover = [r3(accent[0] - 0.08), fitChroma(r3(accent[0] - 0.08), acC * 0.8, ac.h), r1(ac.h)];
348
+ // accent: keep the accent-source's own lightness (cursor or chosen ANSI
349
+ // color) if it already clears 4.5:1 on bg; solve only if it doesn't.
350
+ const accent = keepOrSolve(ac.l, acC, ac.h, bg, 4.5, 'darker');
351
+ // accent-hover: still the designed -0.08 offset from accent, but that
352
+ // offset is verified against 4.5:1 on bg and solve-adjusted if it breaks.
353
+ const accentHover = keepOrSolve(accent[0] - 0.08, acC * 0.8, ac.h, bg, 4.5, 'darker');
302
354
  const raw = {
303
355
  'color-bg': bg,
304
356
  'color-bg-subtle': [r3(bg[0] - 0.02), bg[1], bg[2]],
@@ -330,21 +382,33 @@ function deriveDark(t, accentHueLight) {
330
382
  const bgC = fitChroma(bgL, Math.min(bgC0, 0.03), bgH);
331
383
  const bg = [bgL, bgC, bgH];
332
384
  const surface = [r3(bg[0] + 0.03), bgC, bgH];
333
- const [, fgC0, fgH] = slotLch(t, 'foreground', 'dark');
385
+ const [fgL0, fgC0, fgH] = slotLch(t, 'foreground', 'dark');
334
386
  const fgC = Math.min(fgC0, 0.02);
335
- const fg = solveL(fgC, fgH, surface, 10, 'lighter');
336
- const fgMuted = solveL(fgC, fgH, bg, 7.0, 'lighter');
387
+ // fg: keep the theme's own foreground lightness if it already clears the
388
+ // AAA floor (7:1) against surface; solve only if it doesn't (mirrors
389
+ // light — direction flipped, see #76).
390
+ const fg = keepOrSolve(fgL0, fgC, fgH, surface, 7.0, 'lighter');
391
+ const fgMuted = withHeadroom(solveL(fgC, fgH, bg, 7.0, 'lighter'), 0.01, 'lighter', fgC);
337
392
  const muted = solveL(fgC, fgH, surface, 4.5, 'lighter');
338
- const borderBold = solveL(0.01, bgH, bg, 3.0, 'lighter');
393
+ const borderBold = withHeadroom(solveL(0.01, bgH, bg, 3.0, 'lighter'), 0.02, 'lighter', 0.01);
339
394
  const ac = accentHue(t, 'dark');
340
395
  const h = accentHueLight ?? ac.h; // keep hue consistent across the pair
341
396
  const acC = Math.min(ac.c, 0.12);
342
- const accent = solveL(acC, h, bg, 4.5, 'lighter');
343
- const accentHover = [r3(accent[0] + 0.07), fitChroma(r3(accent[0] + 0.07), acC, h), r1(h)];
397
+ // accent: keep the accent-source's own lightness if it already clears
398
+ // 4.5:1 on bg; solve only if it doesn't.
399
+ const accent = keepOrSolve(ac.l, acC, h, bg, 4.5, 'lighter');
400
+ // accent-hover: still the designed +0.07 offset from accent, verified
401
+ // against 4.5:1 on bg and solve-adjusted if the offset breaks it.
402
+ const accentHover = keepOrSolve(accent[0] + 0.07, acC, h, bg, 4.5, 'lighter');
344
403
  const selBg = [r3(bg[0] + 0.14), fitChroma(r3(bg[0] + 0.14), 0.06, h), r1(h)];
345
- const selFg = solveL(0.005, fgH, selBg, 4.5, 'lighter');
404
+ // selection-fg: "slightly brighter than fg on selection" — derived from
405
+ // the (kept-or-solved) dark fg's own L/C/H, +0.02 lightness, verified
406
+ // against 4.5:1 on selection-bg and solved only if that breaks it.
407
+ const selFg = keepOrSolve(fg[0] + 0.02, fg[1], fg[2], selBg, 4.5, 'lighter');
346
408
  const codeBg = [r3(bg[0] + 0.04), bgC, bgH];
347
- const codeFg = solveL(0.005, fgH, codeBg, 7, 'lighter');
409
+ // code-fg: "slightly dimmer than fg" — same pattern, -0.02, verified
410
+ // against the AAA 7:1 line on code-bg.
411
+ const codeFg = keepOrSolve(fg[0] - 0.02, fg[1], fg[2], codeBg, 7.0, 'lighter');
348
412
  const raw = {
349
413
  'color-bg': bg,
350
414
  'color-bg-subtle': surface,
package/tokens.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "$description": "Remarque design tokens — GENERATED from tokens-core.css + tokens-palette.css by scripts/tokens-json.mjs. Do not edit; the CSS is the source of truth.",
3
3
  "$extensions": {
4
4
  "remarque": {
5
- "version": "0.10.0",
5
+ "version": "0.11.0",
6
6
  "tiers": {
7
7
  "core": "immutable identity — overriding forks the system",
8
8
  "palette": "sanctioned personalization surface — override freely, then run remarque-audit"