remarque-tokens 0.18.0 → 0.20.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
@@ -146,6 +146,7 @@ project/
146
146
  ├── essay.css # Optional Essay module: sidenotes + sticky TOC rail (own subpath, NOT aggregated — import explicitly)
147
147
  ├── broadsheet.css # Optional Broadsheet pattern: masthead, lead, entry list, post kicker (own subpath, NOT aggregated — import explicitly)
148
148
  ├── forms.css # Optional form control primitives: field/input/checkbox/radio/button, state-color wiring (own subpath, NOT aggregated — import explicitly)
149
+ ├── deck.js # Optional Palette Deck module: switch/persist/restore between remarque-theme-generated palettes (own subpath, NOT aggregated — dependency-free ESM, no CSS of its own)
149
150
  ├── print.css # Print stylesheet (own subpath, NOT aggregated — import explicitly)
150
151
  ├── theme.css # Tailwind v4 adapter (@theme inline) — import after tailwindcss + tokens
151
152
  ├── tailwind.config.js # Tailwind v3 ONLY — v4 projects use theme.css instead
@@ -209,6 +210,7 @@ Before considering any implementation complete, verify:
209
210
  - [ ] If the Essay uses sidenotes/a TOC rail (`remarque-tokens/essay`): `.remarque-sidenote-ref`/`.remarque-sidenote` alternate in strict DOM order, the rail never intrudes into `.remarque-prose`'s own measure, and the page renders correctly with `essay.css`'s `@media` block deleted (the narrow-viewport/no-JS fallback)
210
211
  - [ ] If a Landing/archive page uses the Broadsheet pattern (`remarque-tokens/broadsheet`): entry numerals are generated from `data-entry-number` via `attr()` (not `counter()`), the entry list stays a `<ul>` (not `<ol>` — the numeral is `aria-hidden` and decorative), and every kicker/dateline row uses `font-variant-caps: all-small-caps`, never `text-transform: uppercase`
211
212
  - [ ] If a page uses form controls (`remarque-tokens/forms`): checkboxes/radios are styled with `accent-color` only — never `appearance: none` plus a hand-drawn replacement; every control (input, button, checkbox/radio label) is ≥44×44px; validation state lives on `.remarque-field[data-state]` AND a real `aria-invalid`/`aria-describedby` pair on the input, not `data-state` alone; disabled controls use `--color-disabled`, never a state color
213
+ - [ ] If a page uses the Palette Deck (`remarque-tokens/deck`): `data-palette` is set/cleared on the same root element as `data-theme` (they compose, never conflict); the FOUC-safe restore is a synchronous classic `<script>` physically in `<head>` (never `type="module"`, which defers); the switcher control itself is a native, keyboard-operable element (e.g. `.remarque-input` `<select>`) at ≥44×44px, not a hand-rolled widget; every registered palette was generated via `remarque-theme --scope`, never hand-authored
212
214
  - [ ] Mobile version is roomy — not a compressed desktop layout
213
215
  - [ ] Mobile nav links have ≥44px touch targets
214
216
  - [ ] No pure white or pure black backgrounds
@@ -225,6 +227,91 @@ Before considering any implementation complete, verify:
225
227
 
226
228
  ---
227
229
 
230
+ ## Machine-Readable Output
231
+
232
+ Two tools emit structured JSON for agents/tooling instead of colored
233
+ stdout — pass `--json` and parse stdout as a single JSON document. Exit
234
+ codes are unchanged in both cases; `--json` only changes what stdout
235
+ contains (all human progress lines, including error lines normally on
236
+ stderr, are suppressed — nothing but the JSON document is written).
237
+
238
+ ### `remarque-audit --json`
239
+
240
+ ```
241
+ node scripts/audit.mjs --palette <file> --src <dir> --json
242
+ npx remarque-audit --palette <file> --src <dir> --json
243
+ ```
244
+
245
+ ```ts
246
+ {
247
+ version: string; // the remarque-tokens package version running the audit
248
+ palette: string; // the --palette path as given
249
+ src: string; // the --src path as given
250
+ passed: boolean; // true iff failures.length === 0
251
+ contrast: Array<{
252
+ theme: 'light' | 'dark';
253
+ fg: string; // fg token name, no leading --
254
+ bg: string; // bg token name, no leading --
255
+ label: string; // human label from the CHECKS table (e.g. "primary text")
256
+ required: number; // minimum ratio (e.g. 4.5, 7.0, 3.0)
257
+ actual: number | null; // computed ratio rounded to 2dp, or null if fg/bg was unresolvable
258
+ ok: boolean;
259
+ error?: string; // present only when actual is null (unresolved var()/missing token)
260
+ }>;
261
+ gamut: Array<{
262
+ theme: 'light' | 'dark';
263
+ token: string; // token name, no leading --
264
+ value: string; // e.g. "oklch(0.5 0.14 250)"
265
+ ok: boolean; // false = outside sRGB gamut (browsers will clip it)
266
+ }>;
267
+ srcScans: {
268
+ fontFloor: Array<{ file: string; line: number; size: number; unit: 'rem' | 'px' }>;
269
+ unverifiableFontSize: Array<{ file: string; line: number; snippet: string }>;
270
+ hardcodedColors: Array<{ file: string; line: number; snippet: string }>;
271
+ oklchLiteral: Array<{ file: string; line: number; snippet: string }>;
272
+ };
273
+ failures: string[]; // every failure as a human-readable message, same text the non-JSON run prints
274
+ }
275
+ ```
276
+
277
+ `contrast` and `gamut` list EVERY check performed (passing and failing);
278
+ only `srcScans` and `failures` are violation-only lists, since a source
279
+ scan that finds nothing has nothing to report per line.
280
+
281
+ ### `remarque-drift --json`
282
+
283
+ ```
284
+ node scripts/drift-check.mjs --css-file <path> --package-dir <dir> --json
285
+ npx remarque-drift --css-file <path> --package-dir <dir> --json
286
+ ```
287
+
288
+ ```ts
289
+ {
290
+ cssFile: string;
291
+ packageDir: string; // resolved node_modules/remarque-tokens path
292
+ installedVersion: string; // version of the INSTALLED remarque-tokens being checked against
293
+ deviationDoc: string | null; // path to DESIGN-DEVIATIONS.md / DESIGN-NOTES.md, or null if none found
294
+ passed: boolean; // true iff fail.length === 0
295
+ fail: Array<{ name: string; theme: 'light' | 'dark'; canonical: string; value: string }>;
296
+ warn: Array<{ name: string; theme: 'light' | 'dark'; canonical: string; value: string; doc: string }>;
297
+ info: Array<{ name: string; theme: 'light' | 'dark'; canonical: string; value: string }>;
298
+ summary: { fail: number; warn: number; info: number }; // counts of distinct TOKENS with an entry, not records (one token can produce two records — light + dark)
299
+ }
300
+ ```
301
+
302
+ `fail`/`warn`/`info` are one record per (token, theme) mismatch — see
303
+ `scripts/drift-check.mjs`'s doc comment and REMARQUE.md's "Token Tiers"
304
+ for the FAIL/WARN/INFO classification these mirror.
305
+
306
+ ### `tokens.json` / `tokens.schema.json`
307
+
308
+ `remarque-tokens/tokens.json` is always machine-readable (no flag
309
+ needed) and now ships with a published JSON Schema — see REMARQUE.md
310
+ "DTCG Conformance" for the schema's location, the `$schema` pointer, and
311
+ the documented DTCG divergences.
312
+
313
+ ---
314
+
228
315
  ## How to Reference This System
229
316
 
230
317
  In prompts, issues, or agent instructions, use:
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.20.0 — 2026-07-23
8
+
9
+ Machine-readable surface: `--json` for `remarque-audit`/`remarque-drift`, and a published JSON Schema for `tokens.json` (closes #98, closes #99).
10
+
11
+ ### Added
12
+ - **`--json` on `remarque-audit`** (issue #98). Suppresses all human console output (both the progress lines and the error lines normally on stderr) and emits ONE JSON document to stdout instead — exit codes unchanged. Shape: `{ version, palette, src, passed, contrast: [{theme,fg,bg,label,required,actual,ok,error?}], gamut: [{theme,token,value,ok}], srcScans: {fontFloor,unverifiableFontSize,hardcodedColors,oklchLiteral}, failures: [] }`. `contrast`/`gamut` list every check performed (pass and fail); `srcScans`/`failures` are violation-only. Documented in `AGENT_RULES.md` ("Machine-Readable Output") — agents are the intended audience, parsing structure instead of scraping colored stdout.
13
+ - **`--json` on `remarque-drift`** (issue #98). Same suppression contract; emits `{ cssFile, packageDir, installedVersion, deviationDoc, passed, fail: [], warn: [], info: [], summary: {fail,warn,info} }` — one record per (token, theme) mismatch, mirroring the FAIL/WARN/INFO classification the human report already prints.
14
+ - **`tokens.schema.json`** (issue #99). JSON Schema (draft 2020-12) describing `tokens.json`'s actual shape, generated by `scripts/tokens-json.mjs` in the same pass as `tokens.json`/`tokens.d.ts`, same `--check` freshness gate. Token *names* are open (`patternProperties` on the kebab-case grammar); the `$value`/`$type`/light-dark VALUE shapes are fixed. Published as the `remarque-tokens/tokens.schema.json` export, shipped in `files`, and served by the demo site at `/tokens.schema.json` (alongside the existing `/tokens.json`) via an extended `site/scripts/copy-tokens-json.mjs` — shadcn's schema-URL precedent. `tokens.json` now carries a `"$schema"` pointer to that URL.
15
+ - **DTCG conformance note** (issue #99, ratified option ii). `tokens.json`'s `$extensions.remarque.dtcg` documents that the file is conformant in spirit with the Design Tokens Community Group format (`$value`/`$type` on every token) with two **deliberate** divergences — color values as `oklch()` CSS strings (not DTCG's structured color object) and per-token `light`/`dark` nesting (DTCG has no ratified multi-mode/resolver mechanism yet) — each with a named ratification trigger, so a future agent can't "fix" `tokens.json` toward an unratified target. Same argument restated for humans in REMARQUE.md's new "DTCG Conformance" section (under "Token Tiers").
16
+ - **Schema validation in CI** (`scripts/test-types.mjs`). Real JSON-Schema validation of `tokens.json` against `tokens.schema.json` via `ajv` (new devDependency, pinned to a version with zero known advisories; the `$data` option that the one applicable ajv CVE requires is never used) — plus three negative fixtures (unknown `$extensions` key, invalid `$type` enum value, missing required `dark` side) proving the schema actually rejects malformed input rather than accepting anything.
17
+ - **`--json` fixture coverage** (`scripts/test-audit.mjs`, `scripts/test-drift.mjs`). Parses the JSON output of a passing and a must-fail fixture for each tool; asserts the documented shape and that the must-fail fixture's offending pairing/token is actually present with `ok`/`passed: false`.
18
+
19
+ ## 0.19.0 — 2026-07-23
20
+
21
+ Palette Deck module (closes #56).
22
+
23
+ ### Added
24
+ - **`deck.js` — palette-deck module (issue #56, re-scoped by 2026-07-23
25
+ consensus panel).** New own-subpath export (`remarque-tokens/deck`,
26
+ `remarque-tokens/deck.js`) — a dependency-free ~60-line ESM file, NOT
27
+ aggregated into `tokens.css` (it ships no CSS at all). Deliberately
28
+ THIN: the original theme-deck graduation proposal (12 terminal
29
+ palettes as `:root[data-theme-deck]` overrides) predates
30
+ `remarque-theme`; the panel re-scoped it to cover only what that
31
+ pipeline doesn't already provide — switching, persisting, and
32
+ FOUC-safely restoring a choice among already-generated palettes.
33
+ Generation, contrast solving, and hue/pairing remain entirely
34
+ `remarque-theme`'s job.
35
+ - `createDeck(names, opts?)` — registers a set of palette names,
36
+ returns `{ names, current, apply, restore }`. `apply(name)` sets/
37
+ clears `data-palette` on the root element and persists the choice to
38
+ `localStorage` (default key `remarque-palette`); `apply(null)`
39
+ reverts to the unscoped default. `restore()` re-applies a persisted
40
+ choice without re-persisting it (for the post-paint sync step — the
41
+ actual FOUC guard is a duplicated inline snippet, documented in
42
+ REMARQUE.md, matching how the light/dark theme toggle's own `<head>`
43
+ script already works). Unknown names throw rather than setting an
44
+ unvalidated attribute value.
45
+ - **`--scope <name>` on `remarque-theme`.** Emits the derived palette
46
+ under `[data-palette="<name>"]` / `[data-palette="<name>"][data-theme="dark"]`
47
+ instead of `:root` / `[data-theme="dark"]`, so several generated
48
+ palettes can coexist in one stylesheet. `<name>` is validated with the
49
+ same slug grammar as `<light-slug>`/`--dark` before it is interpolated
50
+ into the attribute selector (security review precedent from #75/#76 —
51
+ it is the only control here, since a scope name has no upstream index
52
+ to check against). Derivation and self-verification are unaffected by
53
+ scoping — a scoped and unscoped run of the same pair emit
54
+ byte-identical declarations, differing only in the wrapping selector
55
+ (fixture-tested).
56
+ - **`remarque-audit` recognizes scoped palettes directly.**
57
+ `scripts/lib/css-tokens.mjs`'s `isLightRoot` now treats a bare
58
+ `[data-palette="name"]` block as that scope's light root (one more
59
+ exact-match case, symmetric with the existing `[data-theme="light"]`
60
+ special case) — `isDarkBlock` needed no change, since
61
+ `[data-palette="name"][data-theme="dark"]` already matched its
62
+ existing rule. A `--scope`'d file is auditable exactly like any other
63
+ palette file; no "audit the unscoped version first" workaround is
64
+ needed. See REMARQUE.md "Palette Deck" for the full audit story and
65
+ why this was the smaller correct fix over a bespoke attribute-selector
66
+ grammar.
67
+ - **Demo (`site/`):** a Palette Deck section on `/tokens` with a native
68
+ `<select class="remarque-input">` switcher (gruvbox / rosé-pine,
69
+ pinned slugs `gruvbox-light`/`gruvbox-dark` and
70
+ `rose-pine-dawn`/`rose-pine`), generated at build time by
71
+ `site/scripts/build-palette-deck.mjs` into `public/palettes/*.css`
72
+ (gitignored, regenerated every build — same pattern as
73
+ `public/tokens.json`). FOUC-safe restore snippet added to
74
+ `BaseLayout.astro`'s `<head>`, alongside the existing theme-toggle
75
+ script. VR baselines: `tokens.astro` only (new section on that page);
76
+ every other page is untouched by this release.
77
+
7
78
  ## 0.18.0 — 2026-07-23
8
79
 
9
80
  Form control primitives + reference components (closes #27, closes #30).
package/README.md CHANGED
@@ -44,6 +44,8 @@ Copy the `fonts/` woff2 files from `node_modules/remarque-tokens/fonts/` into yo
44
44
  npx remarque-audit --palette src/styles/my-palette.css --src src
45
45
  ```
46
46
 
47
+ Add `--json` to either `remarque-audit` or `remarque-drift` for a single structured JSON document on stdout instead of colored console output (exit codes unchanged) — for agents and CI tooling to parse rather than scrape. Shape documented in `AGENT_RULES.md` ("Machine-Readable Output").
48
+
47
49
  **Tailwind v4** projects add the shipped adapter — utilities that track the tokens through every theme switch, no value duplication:
48
50
 
49
51
  ```css
@@ -52,7 +54,7 @@ npx remarque-audit --palette src/styles/my-palette.css --src src
52
54
  @import "remarque-tokens/theme.css";
53
55
  ```
54
56
 
55
- **Tailwind v3** projects use the shipped config (`remarque-tokens/tailwind`) instead. A machine-readable token inventory ships as `remarque-tokens/tokens.json` (generated from the CSS — core/palette tiers, light+dark values) for tooling and AI agents. Prefer copy-paste? Grab `fonts.css`, `tokens.css`, `tokens-core.css`, `tokens-palette.css`, and `fonts/` directly — `tokens.css` aggregates the two tier files, so all three CSS files travel together. Use string-form `@import './tokens.css'` only (some bundlers silently drop `@import url(...)` for local files).
57
+ **Tailwind v3** projects use the shipped config (`remarque-tokens/tailwind`) instead. A machine-readable token inventory ships as `remarque-tokens/tokens.json` (generated from the CSS — core/palette tiers, light+dark values) for tooling and AI agents, with a published JSON Schema at `remarque-tokens/tokens.schema.json` (also referenced by tokens.json's own `$schema` field) so tooling can validate it structurally instead of hand-parsing. tokens.json is conformant in spirit with the [Design Tokens Community Group](https://www.designtokens.org/) format (`$value`/`$type` on every token), with two deliberate divergences — see REMARQUE.md's "DTCG Conformance" section. Prefer copy-paste? Grab `fonts.css`, `tokens.css`, `tokens-core.css`, `tokens-palette.css`, and `fonts/` directly — `tokens.css` aggregates the two tier files, so all three CSS files travel together. Use string-form `@import './tokens.css'` only (some bundlers silently drop `@import url(...)` for local files).
56
58
 
57
59
  ## For AI Agents
58
60
 
@@ -68,7 +70,7 @@ The agent rules define build order, non-negotiable rules, disallowed patterns, a
68
70
  Packaging for agent tooling:
69
71
  - **npm exports:** `remarque-tokens/agent-rules` (→ `AGENT_RULES.md`) and `remarque-tokens/spec` (→ `REMARQUE.md`), alongside the existing `remarque-tokens/tokens.json`, so a project can point an agent at `node_modules/remarque-tokens/AGENT_RULES.md` without hardcoding a filename.
70
72
  - **Claude Code skill:** [`.claude/skills/remarque/SKILL.md`](.claude/skills/remarque/SKILL.md) — triggers on "remarque" / "design system" / new-page work, loads all three files, and states the tier rules, the audit command, and the two build-time pitfalls (unlayered-token-import, string-form `@import`) that pass a green build while silently breaking.
71
- - **Live tokens endpoint:** the demo site serves the current `tokens.json` at **https://williamzujkowski.github.io/remarque/tokens.json** — a remote agent can fetch current token values directly instead of trusting training data.
73
+ - **Live tokens endpoint:** the demo site serves the current `tokens.json` at **https://williamzujkowski.github.io/remarque/tokens.json**, and its schema at **https://williamzujkowski.github.io/remarque/tokens.schema.json** — a remote agent can fetch current token values (and validate their shape) directly instead of trusting training data.
72
74
 
73
75
  ## Files
74
76
 
@@ -80,7 +82,10 @@ Packaging for agent tooling:
80
82
  | `tokens-core.css` | Core tier — type scale, spacing, widths, radius, motion, prose styling. Never overridden |
81
83
  | `tokens-palette.css` | Palette tier — font slots, colors, accent, reading measure. The sanctioned personalization surface |
82
84
  | `prose.css` | `.remarque-prose` long-form styling — own subpath so sites with their own prose system can skip it |
83
- | `scripts/audit.mjs` | `npm run audit` — enforces the spec's contrast/gamut/font-floor/no-hardcoded-color checklist |
85
+ | `scripts/audit.mjs` | `npm run audit` — enforces the spec's contrast/gamut/font-floor/no-hardcoded-color checklist (`--json` for structured output) |
86
+ | `scripts/drift-check.mjs` | `npx remarque-drift` — token drift check for consumers (`--json` for structured output) |
87
+ | `tokens.json` + `tokens.d.ts` | Generated machine-readable token inventory + TypeScript types (`scripts/tokens-json.mjs`) |
88
+ | `tokens.schema.json` | Generated JSON Schema (draft 2020-12) for `tokens.json`, published alongside it |
84
89
  | `fonts.css` + `fonts/` | Self-hosted @font-face declarations and woff2 files (no CDN requests) |
85
90
  | `tailwind.config.js` | Tailwind CSS **v3** configuration (v4 projects use an `@theme` block instead) |
86
91
  | `package.json` | npm package manifest for `remarque-tokens` |
package/REMARQUE.md CHANGED
@@ -68,6 +68,17 @@ Package subpaths for consumers: `remarque-tokens` (aggregator), `remarque-tokens
68
68
 
69
69
  This makes compliance mechanical: a site that overrides only palette-tier tokens is *authored*; one that touches core-tier tokens has *forked*. `tokens.css` imports both tiers, so existing consumers are unaffected.
70
70
 
71
+ ### DTCG Conformance
72
+
73
+ `tokens-core.css` and `tokens-palette.css` remain the single source of truth; `scripts/tokens-json.mjs` regenerates `tokens.json`, `tokens.d.ts`, and `tokens.schema.json` from them in one pass (`node scripts/tokens-json.mjs`; `--check` gates CI freshness). `tokens.json` carries a `$schema` pointer to the published schema (served by the demo site at `/tokens.schema.json`, alongside `/tokens.json` — also exported as `remarque-tokens/tokens.schema.json`).
74
+
75
+ `tokens.json` is **conformant in spirit** with the [Design Tokens Community Group (DTCG)](https://www.designtokens.org/) format — every token carries `$value`/`$type` — with two **deliberate** structural divergences, documented in the generated file itself (`$extensions.remarque.dtcg`) so a future regeneration can't lose the rationale:
76
+
77
+ 1. **Color values are `oklch()` CSS strings**, not the DTCG structured color object (`{ colorSpace, components, alpha }`). Remarque's color pipeline (audit, drift-check, `remarque-theme`) is built entirely on parsing/emitting `oklch(L C H)` strings; adopting the structured form would mean carrying two color representations in parallel for no present benefit. **Gated on:** the DTCG color `$type` structured-value format ratifying.
78
+ 2. **Palette-tier tokens nest per-token `light`/`dark` groups**, each with its own `$value`, rather than a single `$value` plus a modes/resolver mechanism. The DTCG spec has no ratified multi-mode/theming primitive yet; this repo's own theming (`@media (prefers-color-scheme: dark)`, `[data-theme="dark"]`, the Palette Deck's `[data-palette]` scoping) predates any such draft. **Gated on:** the DTCG multi-mode / resolver draft ratifying.
79
+
80
+ Full DTCG conformance is gated on those two drafts ratifying — a named, checkable trigger, not "someday." Until then, do not "fix" `tokens.json` toward either unratified draft; the CSS is the source of truth, and both divergences are load-bearing for the tooling that already depends on this shape.
81
+
71
82
  ---
72
83
 
73
84
  ## Color Providers
@@ -706,7 +717,8 @@ for the value-by-value mapping).
706
717
  <h2 id="section-one">Section One</h2>
707
718
  <p>
708
719
  A claim worth a citation<a href="#note-1" id="note-1-ref"
709
- class="remarque-sidenote-ref" aria-describedby="note-1"></a>.
720
+ class="remarque-sidenote-ref" aria-label="Note 1"
721
+ aria-describedby="note-1"></a>.
710
722
  </p>
711
723
  <aside class="remarque-sidenote" id="note-1" role="note">
712
724
  The note text, in real DOM order right after the paragraph that
@@ -729,6 +741,13 @@ Requirements the CSS depends on:
729
741
  `.remarque-sidenote` after it; later citations of the same note use
730
742
  `.remarque-sidenote-ref--repeat` (styled identically, does not advance
731
743
  the counter) so the shared numbering doesn't skip.
744
+ - `aria-label="Note N"` on every `.remarque-sidenote-ref`, numbered in
745
+ the same order the CSS counter advances — the ref's visible digit is
746
+ counter-generated, so the anchor itself has no text content, and an
747
+ empty focusable link with no accessible name fails WCAG 4.1.2 / 2.4.4
748
+ (axe: `link-name`). Because both the label and the counter derive from
749
+ DOM order, they cannot drift apart. (Found in the flagship's migration,
750
+ williamzujkowski.github.io#380.)
732
751
  - `role="note"` on every `.remarque-sidenote` — `<aside>`'s implicit role
733
752
  is `complementary`, a landmark; an essay with several notes would
734
753
  otherwise scatter that many unlabeled landmark regions through the
@@ -1121,6 +1140,133 @@ instead and keep Remarque to the page's editorial chrome around it.
1121
1140
 
1122
1141
  ---
1123
1142
 
1143
+ ## Palette Deck
1144
+
1145
+ A tiny runtime (`remarque-tokens/deck`, `deck.js`) for switching between
1146
+ several `remarque-theme`-generated palettes on one site — register a set
1147
+ of palette names, switch the active one, persist the choice, restore it
1148
+ before first paint. Issue #56.
1149
+
1150
+ **What this module deliberately does NOT do.** The flagship site's
1151
+ `theme-deck.css` (12 terminal palettes as `:root[data-theme-deck=…]`
1152
+ overrides) was originally proposed for upstreaming as-is. A 2026-07-23
1153
+ consensus panel (3-0) re-scoped it: generation, contrast solving, and
1154
+ hue/pairing all now come from `remarque-theme` (`scripts/theme.mjs`,
1155
+ "Color Providers" above) — that pipeline makes deriving a new palette
1156
+ nearly free, so graduating a second, parallel generator would just
1157
+ duplicate it. What's left — and what this module is *only* — is the part
1158
+ `remarque-theme` doesn't provide: switching between already-generated
1159
+ palettes at runtime. No color math, no contrast solving, no palette
1160
+ authoring lives in `deck.js`; it is a dependency-free ~60-line ESM file
1161
+ that sets an attribute, reads/writes `localStorage`, and hands back
1162
+ enough to build a FOUC-safe restore. If you need to *generate* a palette,
1163
+ see "Color Providers"; this section is exclusively about *switching*
1164
+ between ones you already have.
1165
+
1166
+ ### The mechanism: `--scope`
1167
+
1168
+ `remarque-theme` gained a `--scope <name>` flag for this module. Without
1169
+ it, output owns `:root` / `[data-theme="dark"]` outright — the normal,
1170
+ single-palette case. With `--scope <name>`, the same derivation emits
1171
+ under `[data-palette="<name>"]` / `[data-palette="<name>"][data-theme="dark"]`
1172
+ instead, so several generated palettes can coexist in one stylesheet
1173
+ (or several stylesheets) without fighting over `:root`:
1174
+
1175
+ ```
1176
+ npx remarque-theme gruvbox-light --dark gruvbox-dark --scope gruvbox -o palettes/gruvbox.css
1177
+ npx remarque-theme rose-pine-dawn --dark rose-pine --scope rose-pine -o palettes/rose-pine.css
1178
+ ```
1179
+
1180
+ `--scope`'s value is interpolated into a CSS attribute selector, so it is
1181
+ validated with the exact same slug grammar as `<light-slug>`/`--dark`
1182
+ (lowercase alphanumeric segments joined by single hyphens) before it
1183
+ touches any output — a scope name is a caller-supplied label with no
1184
+ upstream index to check it against, which is exactly why the regex
1185
+ carries the full weight here rather than acting as defense-in-depth for
1186
+ a separate lookup. Derivation, self-verification, and every contrast/
1187
+ gamut guarantee in "Color Providers" are **completely unaffected by
1188
+ scoping** — `theme.mjs` self-verifies the derived `[L, C, H]` values
1189
+ before it ever chooses a selector to emit them under, so a scoped and
1190
+ unscoped run of the same light/dark pair produce byte-identical
1191
+ declarations, differing only in which selector wraps them (fixture-
1192
+ tested in `scripts/test-theme.mjs`).
1193
+
1194
+ ### The audit story for scoped palettes
1195
+
1196
+ `remarque-audit`'s selector classification (`scripts/lib/css-tokens.mjs`,
1197
+ `isLightRoot`/`isDarkBlock`) recognizes `[data-palette="name"]` directly:
1198
+ a bare `[data-palette="name"]` block counts as that scope's light root
1199
+ (the same kind of exact-match special case the file already carries for
1200
+ `[data-theme="light"]`), and `[data-palette="name"][data-theme="dark"]`
1201
+ was already recognized as dark by the existing rule (it contains the
1202
+ `[data-theme="dark"]` substring and starts with `[`) — no change needed
1203
+ there. This was the smaller correct fix, not a bespoke new
1204
+ attribute-selector grammar: one exact-match regex, symmetric with a
1205
+ pattern the file already used, rather than teaching the parser to
1206
+ understand arbitrary compound attribute selectors. The result: a
1207
+ `--scope`'d file is auditable exactly like any other palette file —
1208
+ `npx remarque-audit --palette palettes/gruvbox.css --src <dir>` — no
1209
+ "generate the unscoped version first" workaround required.
1210
+
1211
+ ### Markup/wiring contract
1212
+
1213
+ Set (or clear) `data-palette` on `<html>`, alongside the existing
1214
+ `data-theme` attribute — the two compose independently. A deck palette
1215
+ carries *both* light and dark halves, so the light/dark toggle keeps
1216
+ working no matter which palette (or none) is active:
1217
+
1218
+ ```html
1219
+ <html data-theme="dark" data-palette="gruvbox">
1220
+ ```
1221
+
1222
+ ```js
1223
+ import { createDeck } from 'remarque-tokens/deck';
1224
+
1225
+ const deck = createDeck(['gruvbox', 'rose-pine']); // registered names
1226
+ deck.restore(); // re-apply a persisted choice, if any
1227
+ deck.apply('rose-pine'); // switch + persist
1228
+ deck.apply(null); // back to the unscoped default palette
1229
+ deck.current(); // 'rose-pine' | null
1230
+ ```
1231
+
1232
+ `createDeck` validates every `apply()` call against the registered name
1233
+ list and throws on an unknown one — the deck never sets an attribute
1234
+ value it wasn't told about. Persistence (`localStorage`, default key
1235
+ `remarque-palette`) degrades to in-memory-only if storage is unavailable
1236
+ (private browsing, SSR) rather than throwing.
1237
+
1238
+ **FOUC-safe restore.** Exactly like the light/dark theme toggle's own
1239
+ `<head>` script (see the demo's `BaseLayout.astro`), the deck's restore
1240
+ must run as a synchronous, render-blocking **classic** inline script
1241
+ physically in `<head>` — an ESM `<script type="module">` is deferred by
1242
+ the HTML spec and would let the default palette flash before the stored
1243
+ one applies. So the head snippet duplicates the ~3-line read-and-apply
1244
+ logic directly rather than importing `deck.js` (the same reason the
1245
+ theme toggle's head script doesn't import anything either):
1246
+
1247
+ ```html
1248
+ <script is:inline>
1249
+ (function() {
1250
+ var p = localStorage.getItem('remarque-palette');
1251
+ if (p) document.documentElement.setAttribute('data-palette', p);
1252
+ })();
1253
+ </script>
1254
+ ```
1255
+
1256
+ `deck.js` itself is loaded later (deferred/module, after first paint) to
1257
+ wire the switcher UI's change handler and to keep `restore()` available
1258
+ for pages that need to re-derive `current()` after the fact.
1259
+
1260
+ ### Provenance
1261
+
1262
+ Re-scoped from the flagship site's theme-deck on the way up (issue #56,
1263
+ consensus panel, 2026-07-23) — see the root README's "Graduation"
1264
+ section. The original proposal predated `remarque-theme`; once that
1265
+ pipeline existed, generating N palettes stopped being the hard part and
1266
+ switching between them became the only piece worth a shared module.
1267
+
1268
+ ---
1269
+
1124
1270
  ## Signature Moves
1125
1271
 
1126
1272
  These are the repeatable visual tells that make a Remarque site recognizable:
package/deck.js ADDED
@@ -0,0 +1,70 @@
1
+ /*
2
+ * Remarque — Palette Deck (runtime switching between remarque-theme
3
+ * generated palettes)
4
+ * ───────────────────────────────────────────────────────────────────
5
+ * PROVENANCE — re-scoped, not graduated as-is. The flagship site's
6
+ * theme-deck (12 terminal palettes, `[data-theme-deck]`) was proposed for
7
+ * upstreaming whole in issue #56; a 2026-07-23 consensus panel (3-0)
8
+ * re-scoped it: generation, contrast solving, and pairing all now come
9
+ * from `remarque-theme` (`scripts/theme.mjs`, "Color Providers"), so this
10
+ * module is deliberately THIN — it does none of that. All it does:
11
+ *
12
+ * 1. register the set of valid palette names,
13
+ * 2. switch the active one (`data-palette` on the root element),
14
+ * 3. persist the choice (localStorage), and
15
+ * 4. hand back enough to build a FOUC-safe restore (see REMARQUE.md
16
+ * "Palette Deck" for the inline <head> snippet — that snippet does
17
+ * NOT import this module; it duplicates the ~3 lines of restore
18
+ * logic directly, the same way the light/dark theme toggle's own
19
+ * head script duplicates rather than imports, because a FOUC guard
20
+ * must be a synchronous classic script and this file is an ESM
21
+ * module).
22
+ *
23
+ * No framework, no build step, no CSS authored here — `--scope`'d palette
24
+ * files (npx remarque-theme <slug> --scope <name>) are the caller's job.
25
+ */
26
+
27
+ const ATTR = 'data-palette';
28
+ const DEFAULT_STORAGE_KEY = 'remarque-palette';
29
+
30
+ /**
31
+ * @param {(string | { name: string })[]} palettes registered palette names
32
+ * (or `{ name }` objects, for callers that also carry a label/slug pair)
33
+ * @param {{ root?: HTMLElement, storageKey?: string }} [opts]
34
+ */
35
+ export function createDeck(palettes, opts = {}) {
36
+ const root = opts.root || document.documentElement;
37
+ const storageKey = opts.storageKey || DEFAULT_STORAGE_KEY;
38
+ const names = palettes.map((p) => (typeof p === 'string' ? p : p.name));
39
+
40
+ function isValid(name) {
41
+ return name == null || names.includes(name);
42
+ }
43
+
44
+ function current() {
45
+ return root.getAttribute(ATTR);
46
+ }
47
+
48
+ /** Switch palettes. `name === null` reverts to the unscoped default. */
49
+ function apply(name, { persist = true } = {}) {
50
+ if (!isValid(name)) throw new Error(`remarque palette-deck: unknown palette "${name}" (registered: ${names.join(', ') || '(none)'})`);
51
+ if (name) root.setAttribute(ATTR, name);
52
+ else root.removeAttribute(ATTR);
53
+ if (persist) {
54
+ try {
55
+ if (name) localStorage.setItem(storageKey, name);
56
+ else localStorage.removeItem(storageKey);
57
+ } catch { /* storage unavailable (private mode) — in-memory only for this load */ }
58
+ }
59
+ }
60
+
61
+ /** Re-apply a previously persisted choice (no-op if none / unknown). */
62
+ function restore() {
63
+ let stored = null;
64
+ try { stored = localStorage.getItem(storageKey); } catch { /* ignore */ }
65
+ if (stored && isValid(stored)) apply(stored, { persist: false });
66
+ return current();
67
+ }
68
+
69
+ return { names, current, apply, restore };
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remarque-tokens",
3
- "version": "0.18.0",
3
+ "version": "0.20.0",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },
@@ -32,7 +32,8 @@
32
32
  "drift": "node scripts/drift-check.mjs"
33
33
  },
34
34
  "devDependencies": {
35
- "@williamzujkowski/oklch-terminal-themes": "0.3.0",
35
+ "@williamzujkowski/oklch-terminal-themes": "0.4.0",
36
+ "ajv": "^8.20.0",
36
37
  "culori": "^4.0.2"
37
38
  },
38
39
  "peerDependencies": {
@@ -64,6 +65,7 @@
64
65
  "default": "./tokens.json"
65
66
  },
66
67
  "./tokens.d.ts": "./tokens.d.ts",
68
+ "./tokens.schema.json": "./tokens.schema.json",
67
69
  "./prose": "./prose.css",
68
70
  "./prose.css": "./prose.css",
69
71
  "./print": "./print.css",
@@ -74,6 +76,8 @@
74
76
  "./broadsheet.css": "./broadsheet.css",
75
77
  "./forms": "./forms.css",
76
78
  "./forms.css": "./forms.css",
79
+ "./deck": "./deck.js",
80
+ "./deck.js": "./deck.js",
77
81
  "./agent-rules": "./AGENT_RULES.md",
78
82
  "./spec": "./REMARQUE.md",
79
83
  "./package.json": "./package.json"
@@ -95,12 +99,14 @@
95
99
  "theme.css",
96
100
  "tokens.json",
97
101
  "tokens.d.ts",
102
+ "tokens.schema.json",
98
103
  "scripts/tokens-json.mjs",
99
104
  "prose.css",
100
105
  "print.css",
101
106
  "essay.css",
102
107
  "broadsheet.css",
103
108
  "forms.css",
109
+ "deck.js",
104
110
  "scripts/lib/css-tokens.mjs"
105
111
  ]
106
112
  }