remarque-tokens 0.8.0 → 0.10.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
@@ -41,6 +41,7 @@ These are not guidelines. Agents must follow them literally.
41
41
  - NEVER change a value in `tokens-core.css` (or its tokens' values anywhere) — core tokens are Remarque's identity; changing them means the project has forked the system
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
+ - 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
44
45
 
45
46
  ### Typography
46
47
 
package/CHANGELOG.md CHANGED
@@ -4,6 +4,77 @@ 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.10.0 — 2026-07-23
8
+
9
+ Color-provider Phase 2 groundwork: the upstream dataset now pairs its own
10
+ themes, so the bridge no longer needs to be told what goes with what.
11
+
12
+ ### Changed
13
+ - **`remarque-theme`: `--dark` is now optional.** Dataset 0.2.0 of
14
+ `@williamzujkowski/oklch-terminal-themes` ships a `counterpart` field
15
+ (canonical light↔dark pairing, curated for ambiguous families —
16
+ upstream #128); when the light theme declares one, the bridge uses it.
17
+ A counterpart slug is validated exactly like a user-supplied `--dark`
18
+ (index membership before any path resolution). Lights without a
19
+ counterpart still require `--dark`; nothing is guessed from names.
20
+ - **Corpus test now enumerates pairs from the `counterpart` field**
21
+ instead of the name-stem heuristic it shipped with — the dataset is
22
+ authoritative; the heuristic is gone.
23
+ - Dev-pinned dataset bumped `0.1.0` → `0.2.0` (485 → 547 themes; the
24
+ corpus now includes deriving Remarque's palette from its own upstreamed
25
+ `remarque-light`/`remarque-dark` terminal themes, upstream #127).
26
+ `peerDependencies` widened to `>=0.1.0 <0.3.0`.
27
+
28
+ ## 0.9.0 — 2026-07-22
29
+
30
+ Color-provider bridge release (consensus-ratified 7-0, higher_order panel;
31
+ closes #75 — Phase 1 of the color-provider integration; Phase 2/3 tracked
32
+ in #76).
33
+
34
+ ### Added
35
+ - **`remarque-theme` bin** (#75): `npx remarque-theme <light-slug> --dark
36
+ <dark-slug> [-o out.css]` derives a full 15-slot palette-tier override
37
+ from an `@williamzujkowski/oklch-terminal-themes` light+dark pair.
38
+ Terminal themes carry only `background/foreground/cursor/selection` +
39
+ 16 ANSI slots — most of Remarque's semantic slots don't map directly,
40
+ and most themes fail the AAA `fg-muted` 7:1 line as authored. The bridge
41
+ derives instead: hue + chroma come from the theme, lightness is solved
42
+ per slot by binary search against the exact Enforcement Checklist
43
+ targets (in-gamut chroma clamping inside the solver, round-then-verify
44
+ to avoid float rounding pushing a borderline value back out of gamut).
45
+ Output passes `remarque-audit` **by construction** — the script
46
+ self-verifies the same contrast + gamut checks in-process before it
47
+ will emit anything.
48
+ - **Security hardening** (#75, panel security review): both slugs are
49
+ validated against the package's own `index.json` *before* any path is
50
+ built from them (the actual guard against path traversal, not the slug
51
+ regex, which is defense-in-depth); every OKLCH triplet read from theme
52
+ JSON is validated as finite numbers before use; CSS is never built by
53
+ string-interpolating raw JSON — every emitted number has passed through
54
+ our own rounding + validation. Light/dark polarity (`isDark`) is
55
+ enforced against the requested slugs.
56
+ - **`scripts/test-theme.mjs`** (#75): a corpus property test enumerating
57
+ every light+dark pair in the installed `oklch-terminal-themes` package
58
+ by name-stem (47 pairs at the pinned 0.1.0 dataset) — every pair must
59
+ derive AND pass the real `audit.mjs`, not a sample. Also covers unknown
60
+ slugs, polarity mismatches, traversal-looking slugs, and that the
61
+ output declares only palette-tier custom properties. Wired into CI
62
+ alongside `test-audit.mjs`/`test-drift.mjs`.
63
+ - **`@williamzujkowski/oklch-terminal-themes`** pinned exact at `0.1.0` in
64
+ `devDependencies`, and listed as an optional `^0.1.0` peerDependency —
65
+ consumers who want `remarque-theme` install it themselves; it is never
66
+ required to use the rest of the package.
67
+ - REMARQUE.md: new "Color Providers" section positioning the palette tier
68
+ as theme-suppliable, with `oklch-terminal-themes` as the reference
69
+ provider and the pairing contract stated explicitly (`--dark` is
70
+ required; upstream pairing metadata tracked in
71
+ `oklch-terminal-themes#128`). AGENT_RULES.md: a rule against hand-editing
72
+ a generated palette — regenerate it instead.
73
+
74
+ No core-tier token values changed in this release. `tokens-palette.css`
75
+ (the shipped default palette) is also unchanged — `remarque-theme` is an
76
+ opt-in alternate source for the palette tier, not a replacement for it.
77
+
7
78
  ## 0.8.0 — 2026-07-21
8
79
 
9
80
  Archetype-vocabulary and AI-agent-packaging release (design review; closes
package/REMARQUE.md CHANGED
@@ -70,6 +70,24 @@ This makes compliance mechanical: a site that overrides only palette-tier tokens
70
70
 
71
71
  ---
72
72
 
73
+ ## Color Providers
74
+
75
+ The palette tier (all `--color-*` tokens) can be *supplied* rather than hand-authored — Remarque focuses on typography, and a color-provider package can own the personalization surface it already sanctions.
76
+
77
+ [`@williamzujkowski/oklch-terminal-themes`](https://www.npmjs.com/package/@williamzujkowski/oklch-terminal-themes) is the reference provider: a dataset of terminal color schemes converted to OKLCH. Run it through the bundled bridge:
78
+
79
+ ```
80
+ npx remarque-theme <light-slug> [--dark <dark-slug>] [-o palette-override.css]
81
+ ```
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.
84
+
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
+
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
+
89
+ ---
90
+
73
91
  ## Font Slots
74
92
 
75
93
  Remarque uses a three-slot font system. Each slot has a strict role, and each slot accepts a small set of approved faces — swap the face, keep the role.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remarque-tokens",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },
@@ -23,12 +23,24 @@
23
23
  "main": "tokens.css",
24
24
  "bin": {
25
25
  "remarque-audit": "scripts/audit.mjs",
26
- "remarque-drift": "scripts/drift-check.mjs"
26
+ "remarque-drift": "scripts/drift-check.mjs",
27
+ "remarque-theme": "scripts/theme.mjs"
27
28
  },
28
29
  "scripts": {
29
30
  "audit": "node scripts/audit.mjs",
30
31
  "drift": "node scripts/drift-check.mjs"
31
32
  },
33
+ "devDependencies": {
34
+ "@williamzujkowski/oklch-terminal-themes": "0.2.0"
35
+ },
36
+ "peerDependencies": {
37
+ "@williamzujkowski/oklch-terminal-themes": ">=0.1.0 <0.3.0"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "@williamzujkowski/oklch-terminal-themes": {
41
+ "optional": true
42
+ }
43
+ },
32
44
  "exports": {
33
45
  ".": "./tokens.css",
34
46
  "./tokens": "./tokens.css",
@@ -59,6 +71,7 @@
59
71
  "fonts/",
60
72
  "scripts/audit.mjs",
61
73
  "scripts/drift-check.mjs",
74
+ "scripts/theme.mjs",
62
75
  "tailwind.config.js",
63
76
  "REMARQUE.md",
64
77
  "AGENT_RULES.md",
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * remarque-theme — derive a Remarque palette-tier override from an
4
+ * @williamzujkowski/oklch-terminal-themes light+dark pair.
5
+ *
6
+ * node scripts/theme.mjs <light-slug> --dark <dark-slug> [-o out.css]
7
+ * npx remarque-theme <light-slug> --dark <dark-slug> [-o out.css]
8
+ *
9
+ * Terminal themes carry only background/foreground/cursor/selection + 16
10
+ * ANSI slots — most of Remarque's 15 semantic slots don't map directly,
11
+ * and most themes fail the AAA fg-muted 7:1 line as authored. So this
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
16
+ * remarque-audit BY CONSTRUCTION — this script self-verifies the same
17
+ * pairings before it will emit anything.
18
+ *
19
+ * Security (non-negotiable, per the panel security review on #75):
20
+ * - Both slugs are checked against the package's own index.json BEFORE
21
+ * any path is built from them — an unknown slug never reaches a
22
+ * require.resolve() or fs call. This is the actual guard against
23
+ * path traversal; the slug regex below is defense-in-depth, not the
24
+ * primary control.
25
+ * - Every OKLCH triplet read from theme JSON is validated as finite
26
+ * numbers (l in [0,1], c >= 0, h in [0,360)) before it touches any
27
+ * math. Malformed/hostile theme JSON fails loudly, never silently.
28
+ * - CSS is never built by string-interpolating raw JSON. Every number
29
+ * emitted has passed through our own rounding + validation first.
30
+ */
31
+
32
+ import { createRequire } from 'node:module';
33
+ import { pathToFileURL, fileURLToPath } from 'node:url';
34
+ import { dirname, join } from 'node:path';
35
+ import { readFileSync, writeFileSync } from 'node:fs';
36
+
37
+ const PKG_SPEC = '@williamzujkowski/oklch-terminal-themes';
38
+ const HERE = dirname(fileURLToPath(import.meta.url));
39
+ let OWN_VERSION = 'unknown';
40
+ try {
41
+ OWN_VERSION = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8')).version;
42
+ } catch { /* provenance is best-effort */ }
43
+
44
+ const USAGE = `usage: remarque-theme <light-slug> [--dark <dark-slug>] [-o out.css]
45
+
46
+ <light-slug> slug of a theme with isDark=false in ${PKG_SPEC}
47
+ --dark <slug> slug of a theme with isDark=true; defaults to the light
48
+ theme's "counterpart" from the dataset (0.2.0+) when it
49
+ has one, and is required when it doesn't
50
+ -o, --output write the derived palette here instead of stdout`;
51
+
52
+ function die(msg) {
53
+ console.error(msg);
54
+ process.exit(1);
55
+ }
56
+
57
+ /* ── CLI args ─────────────────────────────────────────────────────── */
58
+
59
+ const argv = process.argv.slice(2);
60
+ let darkSlug, outFile;
61
+ const positional = [];
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ if (a === '--dark') darkSlug = argv[++i];
65
+ else if (a === '-o' || a === '--output') outFile = argv[++i];
66
+ else if (a === '-h' || a === '--help') { console.log(USAGE); process.exit(0); }
67
+ else positional.push(a);
68
+ }
69
+ const lightSlug = positional[0];
70
+ if (!lightSlug) die(USAGE);
71
+
72
+ /* ── Resolve @williamzujkowski/oklch-terminal-themes ─────────────────
73
+ * Tried from the consumer's cwd first (a project that installs the
74
+ * themes package at its own top level), then from this script's own
75
+ * location (covers hoisting layouts where only remarque-tokens itself
76
+ * declares the peerDependency). First resolvable wins. */
77
+
78
+ function tryResolveFrom(base) {
79
+ try {
80
+ const req = createRequire(base);
81
+ const indexPath = req.resolve(`${PKG_SPEC}/index.json`);
82
+ return { req, indexPath };
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ const resolved =
89
+ tryResolveFrom(pathToFileURL(join(process.cwd(), 'package.json')).href) ||
90
+ tryResolveFrom(import.meta.url);
91
+
92
+ if (!resolved) {
93
+ die(
94
+ `${PKG_SPEC} is not installed (checked from the current project and from remarque-tokens itself).\n\n` +
95
+ `Install it and retry:\n npm install ${PKG_SPEC}@0.1.0\n`
96
+ );
97
+ }
98
+
99
+ const index = JSON.parse(readFileSync(resolved.indexPath, 'utf8'));
100
+ const bySlug = new Map((index.themes || []).map((t) => [t.slug, t]));
101
+
102
+ // Package root sits two directories above data/index.json; read its
103
+ // package.json for the provenance header directly via fs (not through
104
+ // "exports" — the package does not publish that subpath).
105
+ const themesPkgRoot = dirname(dirname(resolved.indexPath));
106
+ let themesVersion = 'unknown';
107
+ try {
108
+ themesVersion = JSON.parse(readFileSync(join(themesPkgRoot, 'package.json'), 'utf8')).version;
109
+ } catch { /* provenance is best-effort */ }
110
+
111
+ /* ── Slug validation — the actual security boundary ──────────────────
112
+ * A slug is only ever used to build a require specifier AFTER it has
113
+ * been matched, byte-for-byte, against an entry in the package's own
114
+ * index. Nothing user-supplied reaches require.resolve()/fs otherwise. */
115
+
116
+ const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
117
+
118
+ function requireSlug(slug, label) {
119
+ if (typeof slug !== 'string' || !SLUG_RE.test(slug) || !bySlug.has(slug)) {
120
+ die(`${label} slug "${slug}" is not a known theme in ${PKG_SPEC} (see its index.json for valid slugs)`);
121
+ }
122
+ return bySlug.get(slug);
123
+ }
124
+
125
+ const lightMeta = requireSlug(lightSlug, 'light');
126
+ /* --dark defaults to the dataset's counterpart field (0.2.0+). The value
127
+ * still goes through requireSlug like a user-supplied one — a dataset
128
+ * entry is not trusted further than the CLI arg it replaces. */
129
+ if (!darkSlug) {
130
+ darkSlug = lightMeta.counterpart;
131
+ if (!darkSlug) {
132
+ die(
133
+ `"${lightSlug}" has no counterpart in the installed ${PKG_SPEC} dataset — ` +
134
+ `pass --dark <slug> explicitly (or upgrade the dataset; pairing metadata landed in 0.2.0)`
135
+ );
136
+ }
137
+ }
138
+ const darkMeta = requireSlug(darkSlug, 'dark');
139
+ if (lightMeta.isDark !== false) die(`light slug "${lightSlug}" has isDark=${lightMeta.isDark}, expected false`);
140
+ if (darkMeta.isDark !== true) die(`dark slug "${darkSlug}" has isDark=${darkMeta.isDark}, expected true`);
141
+
142
+ function loadTheme(slug) {
143
+ // Safe: slug has already been matched against the package's index above.
144
+ const path = resolved.req.resolve(`${PKG_SPEC}/themes/${slug}.json`);
145
+ return JSON.parse(readFileSync(path, 'utf8'));
146
+ }
147
+ const themeLight = loadTheme(lightSlug);
148
+ const themeDark = loadTheme(darkSlug);
149
+
150
+ /* ── Numeric validation ───────────────────────────────────────────── */
151
+
152
+ const isNum = (x) => typeof x === 'number' && Number.isFinite(x);
153
+
154
+ function validateOklch(o, where) {
155
+ if (!o || !isNum(o.l) || o.l < 0 || o.l > 1) die(`${where}: invalid or missing OKLCH l (${o && o.l})`);
156
+ if (!isNum(o.c) || o.c < 0) die(`${where}: invalid or missing OKLCH c (${o && o.c})`);
157
+ if (!isNum(o.h) || o.h < 0 || o.h >= 360) die(`${where}: invalid or missing OKLCH h (${o && o.h})`);
158
+ return [o.l, o.c, o.h];
159
+ }
160
+
161
+ function slotLch(theme, slot, label) {
162
+ const entry = theme.colors && theme.colors[slot];
163
+ if (!entry || !entry.oklch) die(`${label}: missing colors.${slot}.oklch`);
164
+ return validateOklch(entry.oklch, `${label} colors.${slot}`);
165
+ }
166
+
167
+ /* ── OKLCH → sRGB → WCAG luminance (same Ottosson matrices as audit.mjs) */
168
+
169
+ function oklchToLinearSrgb(L, C, H) {
170
+ const h = (H * Math.PI) / 180;
171
+ const a = C * Math.cos(h), b = C * Math.sin(h);
172
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
173
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
174
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
175
+ const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
176
+ return [
177
+ +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
178
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
179
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
180
+ ];
181
+ }
182
+ const inGamut = (rgb) => rgb.every((x) => x >= -0.0005 && x <= 1.0005);
183
+ const clip = (x) => Math.min(1, Math.max(0, x));
184
+ const luminance = (rgb) => {
185
+ const [r, g, b] = rgb.map(clip);
186
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
187
+ };
188
+ function ratio(c1, c2) {
189
+ const l1 = luminance(oklchToLinearSrgb(...c1));
190
+ const l2 = luminance(oklchToLinearSrgb(...c2));
191
+ const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
192
+ return (hi + 0.05) / (lo + 0.05);
193
+ }
194
+
195
+ const r3 = (x) => +x.toFixed(3);
196
+ const r1 = (x) => +x.toFixed(1);
197
+
198
+ /* Largest chroma <= C that stays in sRGB gamut at (L, H).
199
+ * Round-then-verify (fixes the spike's bug, see #75): stepping in
200
+ * unrounded space and only rounding the final answer can round a
201
+ * borderline-in-gamut value back OUT of gamut (float rounding is not
202
+ * gamut-aware). So after the coarse search we round, then keep backing
203
+ * off in already-rounded steps until the ROUNDED value itself verifies. */
204
+ function fitChroma(L, C, H) {
205
+ let c = Math.max(0, C);
206
+ while (c > 0 && !inGamut(oklchToLinearSrgb(L, c, H))) c -= 0.001;
207
+ c = Math.max(0, c);
208
+ let rounded = r3(c);
209
+ while (rounded > 0 && !inGamut(oklchToLinearSrgb(L, rounded, H))) rounded = r3(rounded - 0.001);
210
+ return Math.max(0, rounded);
211
+ }
212
+
213
+ /* Solve lightness so [L, C', H] hits >= target ratio against bg.
214
+ * dir 'darker': text on a light bg — find the MAX L that still passes
215
+ * (stays closest to the theme's own feel). dir 'lighter': text on a
216
+ * dark bg — find the MIN L. Chroma is clamped in-gamut INSIDE the
217
+ * solver at every candidate L, and the final [L, C, H] is rounded, then
218
+ * re-verified against the real (non-margin) target — nudging L further
219
+ * in the safe direction if rounding cost just enough ratio to fail. */
220
+ function solveLOnce(C, H, bg, target, dir) {
221
+ const searchTarget = target * 1.03; // margin over the audit threshold
222
+ let lo = 0, hi = 1;
223
+ for (let i = 0; i < 40; i++) {
224
+ const mid = (lo + hi) / 2;
225
+ const c = fitChroma(mid, C, H);
226
+ const passes = ratio([mid, c, H], bg) >= searchTarget;
227
+ if (dir === 'darker') { if (passes) lo = mid; else hi = mid; }
228
+ else { if (passes) hi = mid; else lo = mid; }
229
+ }
230
+ let L = r3(dir === 'darker' ? lo : hi);
231
+ let c = fitChroma(L, C, H);
232
+ let guard = 0;
233
+ while (ratio([L, c, H], bg) < target && guard < 100) {
234
+ const next = dir === 'darker' ? r3(L - 0.001) : r3(L + 0.001);
235
+ if (next < 0 || next > 1 || next === L) break;
236
+ L = next;
237
+ c = fitChroma(L, C, H);
238
+ guard++;
239
+ }
240
+ const ok = ratio([L, c, H], bg) >= target;
241
+ return { L, C: c, H: r1(H), ok };
242
+ }
243
+
244
+ function solveL(C, H, bg, target, dir) {
245
+ let r = solveLOnce(C, H, bg, target, dir);
246
+ if (r.ok) return [r.L, r.C, r.H];
247
+ // Chroma-collapse fallback: retry as a neutral if the chromatic
248
+ // solve can't reach the target ratio at any lightness.
249
+ r = solveLOnce(0, H, bg, target, dir);
250
+ if (r.ok) return [r.L, r.C, r.H];
251
+ die(
252
+ `internal error: cannot solve lightness for a ${target}:1 target ` +
253
+ `(hue ${H.toFixed(1)}, direction ${dir}) even with chroma collapsed to 0 — pathological theme colors`
254
+ );
255
+ }
256
+
257
+ /* Accent hue: cursor if it's chromatic, else the most-chromatic classic ANSI. */
258
+ function accentHue(theme, label) {
259
+ const cursor = theme.colors && theme.colors.cursor && theme.colors.cursor.oklch;
260
+ if (cursor) {
261
+ const [, c, h] = validateOklch(cursor, `${label} colors.cursor`);
262
+ if (c >= 0.05) return { h, c, from: 'cursor' };
263
+ }
264
+ const names = ['blue', 'purple', 'red', 'green', 'cyan', 'yellow'];
265
+ const cands = [];
266
+ for (const name of names) {
267
+ const o = theme.colors && theme.colors[name] && theme.colors[name].oklch;
268
+ if (!o) continue;
269
+ const [, c, h] = validateOklch(o, `${label} colors.${name}`);
270
+ cands.push({ name, c, h });
271
+ }
272
+ if (!cands.length) die(`${label}: no classic ANSI colors available to derive an accent hue`);
273
+ cands.sort((a, b) => b.c - a.c);
274
+ return { h: cands[0].h, c: cands[0].c, from: cands[0].name };
275
+ }
276
+
277
+ function fmt(triple) {
278
+ const [L, C, H] = triple;
279
+ if (!isNum(L) || !isNum(C) || !isNum(H)) die('internal error: attempted to emit a non-finite color component');
280
+ return `oklch(${L} ${C} ${H})`;
281
+ }
282
+
283
+ /* ── LIGHT derivation ─────────────────────────────────────────────── */
284
+
285
+ function deriveLight(t) {
286
+ const [bgL0, bgC0, bgH0] = slotLch(t, 'background', 'light');
287
+ const bgH = r1(bgH0);
288
+ const bgL = r3(Math.max(bgL0, 0.93));
289
+ const bgC = fitChroma(bgL, Math.min(bgC0, 0.02), bgH);
290
+ const bg = [bgL, bgC, bgH];
291
+ const surface = [r3(bgL - 0.01), bgC, bgH];
292
+ const [, fgC0, fgH] = slotLch(t, 'foreground', 'light');
293
+ 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');
296
+ const muted = solveL(Math.min(fgC0, 0.02), fgH, surface, 4.5, 'darker');
297
+ const borderBold = solveL(0.01, bgH, bg, 3.0, 'darker');
298
+ const ac = accentHue(t, 'light');
299
+ 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)];
302
+ const raw = {
303
+ 'color-bg': bg,
304
+ 'color-bg-subtle': [r3(bg[0] - 0.02), bg[1], bg[2]],
305
+ 'color-fg': fg,
306
+ 'color-fg-muted': fgMuted,
307
+ 'color-muted': muted,
308
+ 'color-border': [r3(bg[0] - 0.095), bg[1], bg[2]],
309
+ 'color-border-bold': borderBold,
310
+ 'color-surface': surface,
311
+ 'color-accent': accent,
312
+ 'color-accent-hover': accentHover,
313
+ 'color-accent-subtle': [0.95, fitChroma(0.95, 0.02, ac.h), r1(ac.h)],
314
+ 'color-selection-bg': [0.92, fitChroma(0.92, 0.04, ac.h), r1(ac.h)],
315
+ 'color-selection-fg': { ref: 'color-fg' },
316
+ 'color-code-bg': [r3(bg[0] - 0.03), bg[1], bg[2]],
317
+ 'color-code-fg': { ref: 'color-fg' },
318
+ };
319
+ const tokens = {};
320
+ for (const [name, v] of Object.entries(raw)) tokens[name] = v.ref ? `var(--${v.ref})` : fmt(v);
321
+ return { accentFrom: ac.from, accentH: ac.h, raw, tokens };
322
+ }
323
+
324
+ /* ── DARK derivation ──────────────────────────────────────────────── */
325
+
326
+ function deriveDark(t, accentHueLight) {
327
+ const [bgL0, bgC0, bgH0] = slotLch(t, 'background', 'dark');
328
+ const bgH = r1(bgH0);
329
+ const bgL = r3(Math.min(Math.max(bgL0, 0.14), 0.22));
330
+ const bgC = fitChroma(bgL, Math.min(bgC0, 0.03), bgH);
331
+ const bg = [bgL, bgC, bgH];
332
+ const surface = [r3(bg[0] + 0.03), bgC, bgH];
333
+ const [, fgC0, fgH] = slotLch(t, 'foreground', 'dark');
334
+ 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');
337
+ const muted = solveL(fgC, fgH, surface, 4.5, 'lighter');
338
+ const borderBold = solveL(0.01, bgH, bg, 3.0, 'lighter');
339
+ const ac = accentHue(t, 'dark');
340
+ const h = accentHueLight ?? ac.h; // keep hue consistent across the pair
341
+ 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)];
344
+ 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');
346
+ const codeBg = [r3(bg[0] + 0.04), bgC, bgH];
347
+ const codeFg = solveL(0.005, fgH, codeBg, 7, 'lighter');
348
+ const raw = {
349
+ 'color-bg': bg,
350
+ 'color-bg-subtle': surface,
351
+ 'color-fg': fg,
352
+ 'color-fg-muted': fgMuted,
353
+ 'color-muted': muted,
354
+ 'color-border': [r3(bg[0] + 0.09), bg[1], bg[2]],
355
+ 'color-border-bold': borderBold,
356
+ 'color-surface': surface,
357
+ 'color-accent': accent,
358
+ 'color-accent-hover': accentHover,
359
+ 'color-accent-subtle': [r3(bg[0] + 0.06), fitChroma(r3(bg[0] + 0.06), 0.04, h), r1(h)],
360
+ 'color-selection-bg': selBg,
361
+ 'color-selection-fg': selFg,
362
+ 'color-code-bg': codeBg,
363
+ 'color-code-fg': codeFg,
364
+ };
365
+ const tokens = {};
366
+ for (const [name, v] of Object.entries(raw)) tokens[name] = fmt(v);
367
+ tokens['weight-display'] = '500';
368
+ return { raw, tokens };
369
+ }
370
+
371
+ const light = deriveLight(themeLight);
372
+ const dark = deriveDark(themeDark, light.accentH);
373
+
374
+ /* ── Self-verify — the same pairings + gamut rule as scripts/audit.mjs,
375
+ * run in-process against the raw [L,C,H] triples (mirrored here rather
376
+ * than reparsing the CSS we're about to emit). This should be
377
+ * impossible to fail; a failure means a bug in the derivation above,
378
+ * not a bad input theme, so it errors as an internal error. */
379
+
380
+ const CHECKS = [
381
+ ['color-fg', 'color-bg', 4.5, 'primary text'],
382
+ ['color-fg', 'color-surface', 4.5, 'primary text on surface'],
383
+ ['color-fg-muted', 'color-bg', 7.0, 'secondary text (spec AAA line)'],
384
+ ['color-muted', 'color-bg', 4.5, 'tertiary text'],
385
+ ['color-muted', 'color-surface', 4.5, 'tertiary text on surface'],
386
+ ['color-accent', 'color-bg', 4.5, 'links'],
387
+ ['color-accent-hover', 'color-bg', 4.5, 'link hover'],
388
+ ['color-code-fg', 'color-code-bg', 4.5, 'code text'],
389
+ ['color-border-bold', 'color-bg', 3.0, 'functional borders (WCAG 1.4.11)'],
390
+ ['color-selection-fg', 'color-selection-bg', 4.5, 'selected text'],
391
+ ];
392
+
393
+ function resolveRaw(raw, name, seen = new Set()) {
394
+ if (seen.has(name)) die(`internal error: circular var() chain at --${name}`);
395
+ seen.add(name);
396
+ const v = raw[name];
397
+ if (v === undefined) die(`internal error: --${name} was never derived`);
398
+ return v.ref ? resolveRaw(raw, v.ref, seen) : v;
399
+ }
400
+
401
+ function selfVerify(themeName, raw) {
402
+ for (const [name, v] of Object.entries(raw)) {
403
+ const triple = v.ref ? resolveRaw(raw, name) : v;
404
+ if (!inGamut(oklchToLinearSrgb(...triple))) {
405
+ die(`internal error: derived --${name} (${themeName}) oklch(${triple.join(' ')}) is outside sRGB gamut`);
406
+ }
407
+ }
408
+ for (const [fgName, bgName, min, label] of CHECKS) {
409
+ const fg = resolveRaw(raw, fgName);
410
+ const bg = resolveRaw(raw, bgName);
411
+ const r = ratio(fg, bg);
412
+ if (r < min) {
413
+ die(`internal error: derived --${fgName}/--${bgName} (${themeName}) = ${r.toFixed(2)}:1 < ${min}:1 (${label}) — this should be impossible; the derivation has a bug`);
414
+ }
415
+ }
416
+ }
417
+ selfVerify('light', light.raw);
418
+ selfVerify('dark', dark.raw);
419
+
420
+ /* ── Emit ─────────────────────────────────────────────────────────── */
421
+
422
+ const decls = (tokens, indent = ' ') => Object.entries(tokens).map(([k, v]) => `${indent}--${k}: ${v};`).join('\n');
423
+
424
+ const css = `/*
425
+ * Generated by remarque-theme (remarque-tokens ${OWN_VERSION}) — DO NOT HAND-EDIT.
426
+ * Regenerate instead: npx remarque-theme ${lightSlug} --dark ${darkSlug}
427
+ *
428
+ * Source themes (${PKG_SPEC}@${themesVersion}):
429
+ * light = "${lightSlug}" dark = "${darkSlug}"
430
+ * accent hue derived from: ${light.accentFrom} (H=${light.accentH.toFixed(1)})
431
+ *
432
+ * Palette tier only (--color-*, --weight-display) — see REMARQUE.md
433
+ * "Token Tiers" / "Color Providers". Self-verified against the same
434
+ * contrast + gamut checks as remarque-audit before being emitted.
435
+ */
436
+
437
+ :root {
438
+ ${decls(light.tokens)}
439
+ --weight-display: 400;
440
+ }
441
+
442
+ [data-theme="dark"] {
443
+ ${decls(dark.tokens)}
444
+ }
445
+ `;
446
+
447
+ if (outFile) {
448
+ writeFileSync(outFile, css);
449
+ console.error(`remarque-theme: wrote ${outFile}`);
450
+ } else {
451
+ process.stdout.write(css);
452
+ }
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.8.0",
5
+ "version": "0.10.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"