remarque-tokens 0.19.0 → 0.21.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
@@ -227,6 +227,91 @@ Before considering any implementation complete, verify:
227
227
 
228
228
  ---
229
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
+
230
315
  ## How to Reference This System
231
316
 
232
317
  In prompts, issues, or agent instructions, use:
package/CHANGELOG.md CHANGED
@@ -4,6 +4,33 @@ 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.21.0 — 2026-07-23
8
+
9
+ Forced-colors (Windows High Contrast Mode) + `prefers-contrast: more` support (closes #93) — the ratified program's highest-value accessibility item.
10
+
11
+ ### Added
12
+ - **`@media (forced-colors: active)` audit and fixes** across `tokens-core.css`, `prose.css`, `essay.css`, `broadsheet.css`, `forms.css`, and the demo's `globals.css`/`tokens.astro`. Every color-bearing declaration was classified as fine-as-is (the overwhelming majority — `border-color` on a real, non-transparent border is forced to a visible system color regardless of the authored value, so `--color-border`'s documented sub-3:1 hairlines, prose table rules, sidenote/TOC borders, etc. already survive with zero code changes), affordance-loss (fixed), or must-survive custom painting (`forced-color-adjust: none`, justified). See REMARQUE.md's new "Forced Colors & Contrast Preferences" section for the full inventory.
13
+ - **Broadsheet title-link hover/focus** (`broadsheet.css`): the underline-grow effect is a `background-image` gradient, which forced-colors mode forces to `none` — added a `text-decoration: underline` fallback under `@media (forced-colors: active)`.
14
+ - **`.remarque-rule`** (`broadsheet.css`, masthead dateline divider): converted from a `background` fill on a bare `<span>` (at risk of being forced to `Canvas` and disappearing) to a real `border-top` — an unconditional fix, not media-gated, since a border is simply the correct way to author a hairline.
15
+ - **Form validation states** (`forms.css`): error/success/warning were distinguished entirely by `border-color`/`color`, both forced under forced-colors — added `border-style`/`border-width` differentiation (double/dashed/solid) plus a small glyph (✕/⚠/✓) prefixed to the message line, scoped to `@media (forced-colors: active)`.
16
+ - **Focus** (`tokens-core.css`): `:focus-visible` was already forced-colors-safe (`outline`, not `box-shadow` — `box-shadow` is unconditionally forced to `none`); added an explicit `outline-color: Highlight` under forced-colors so the ring keys to the same system color as the rest of the OS, and so the CI gate has something explicit to assert against.
17
+ - **Demo chrome**: `.nav-link`/`.footer-link`/TOC-rail hover (color-only affordances) get the same `text-decoration: underline` forced-colors fallback; `tokens.astro`'s color-token reference swatches get `forced-color-adjust: none` (the one "must-survive custom painting" case — the swatch's entire purpose is showing the real color).
18
+ - **`prefers-contrast: more`** (`tokens-palette.css`). Bumps `--color-border-bold` (light 3.39:1 → 4.52:1, dark 3.23:1 → 4.53:1) and `--color-fg-muted` (light 7.55:1 → 10.11:1, dark 7.26:1 → 9.70:1) one step toward `--color-fg`, for users who want more contrast without forced-colors' system-color override. Lives in the palette tier (same mechanism as the existing `prefers-color-scheme: dark` override) — version-only change to `tokens.json`/`tokens.d.ts` since no new token names were introduced.
19
+ - **`scripts/lib/css-tokens.mjs` context-exactness fix.** `isDarkBlock`'s two recognition paths (the `prefers-color-scheme: dark` media convention and the `[data-theme="dark"]`/`:root.dark` selector convention) were previously matched by unguarded substring checks — a `[data-theme="dark"]` selector or a `prefers-color-scheme: dark` feature nested inside an unrelated or compounded media query (exactly the shape the new `prefers-contrast: more` block uses) would have been misread as an unconditional dark override, silently clobbering the real dark values in generated output (`tokens.json`/`tokens.d.ts`) and audit results. Both paths are now context-exact (top-level, or the plain non-compounded `@media (prefers-color-scheme: dark)` query only). Two regression fixtures added to `scripts/test-audit.mjs` (`nested-media-not-dark.css`, `compound-media-not-dark.css`) proving a nested/compounded dark-selector block no longer overrides the real one.
20
+ - **`site/tests/forced-colors.spec.ts`** — the issue's blocking CI condition. Computed-style assertions (no pixel diffing) under `page.emulateMedia({ forcedColors: 'active' })`: a nav link's keyboard focus renders a visible, non-zero outline; `.remarque-table` borders resolve to a non-transparent `border-color`; the essay module's TOC rail and (narrow-viewport) sidenote borders stay visible; a form's error/success states render `border-style` distinct from the default and from each other. Wired into the existing `visual-regression.yml` workflow (`npx playwright test` already runs the whole `site/tests/` directory). Three forced-colors screenshot baselines (landing/essay/components) added as a secondary, non-blocking supplement — forced-colors rendering is deterministic in headless Chromium.
21
+
22
+ ## 0.20.0 — 2026-07-23
23
+
24
+ Machine-readable surface: `--json` for `remarque-audit`/`remarque-drift`, and a published JSON Schema for `tokens.json` (closes #98, closes #99).
25
+
26
+ ### Added
27
+ - **`--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.
28
+ - **`--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.
29
+ - **`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.
30
+ - **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").
31
+ - **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.
32
+ - **`--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`.
33
+
7
34
  ## 0.19.0 — 2026-07-23
8
35
 
9
36
  Palette Deck module (closes #56).
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
@@ -434,6 +445,29 @@ All interactive elements (links, buttons, inputs) must have a minimum touch targ
434
445
  | Meta/caption text | 1.35 | 1.5 |
435
446
  | Display/heading text | 1.0 | 1.05-1.2 |
436
447
 
448
+ ### Forced Colors & Contrast Preferences
449
+
450
+ Issue #93 — Remarque cites [USWDS](https://designsystem.digital.gov/documentation/accessibility/) as its accessibility lineage but had, until this section, adopted none of USWDS's forced-colors work (their `$high-contrast-system-colors` map + `@media (forced-colors: active)` rules on nearly every component). Windows High Contrast Mode (`forced-colors: active`) replaces most author-specified `color`/`background-color`/`border-color`/`outline-color` with a small system palette at paint time — the exact signals a typography-first system leans on (`--color-border`'s documented sub-3:1 decorative hairlines, border-only hover states, `-subtle` tinted backgrounds) are the ones color-only affordances rely on, and forced-colors is precisely the mode that strips them.
451
+
452
+ **The audit, and what it found.** Every color-bearing declaration across `tokens-core.css`, `prose.css`, `essay.css`, `broadsheet.css`, `forms.css`, `theme.css`, and the demo's own `globals.css`/`tokens.astro` was classified into one of three buckets:
453
+
454
+ 1. **Fine as-is** — the overwhelming majority. `forced-colors: active` forces `border-color` to a visible system border color for any non-transparent, non-zero-width border, REGARDLESS of the authored value — so every hairline built the ordinary way (table `border-bottom`, `hr`, `.remarque-sidenote`'s `border-inline-start`, `.remarque-toc-rail summary`'s `border-bottom`, `dl`/`details`' `border-top`, `img`/`kbd`'s `border`, the pullquote's rules) already renders correctly with zero code changes. This is exactly the mechanism that "fixes" `--color-border`'s documented sub-3:1 contrast for free: the decorative token was never load-bearing under forced-colors to begin with. `::selection`'s `background-color`/`color` are likewise forced to the system `Highlight`/`HighlightText` pair automatically, same as any native text selection. `--color-syntax-*`'s nine-slot rainbow collapses to one uniform system text color — accepted, not fixed: the code's structure and whitespace carry meaning independent of the syntax coloring, and `forced-color-adjust: none` here would mean trusting our authored syntax palette to stay legible against an *unknown* system high-contrast background, which is not a bet this system should make on a user's behalf. `.remarque-prose mark`'s highlight background is similarly forced away (there is no confirmed special-cased `Mark`/`MarkText` UA treatment to lean on) — accepted for the same reason: `mark` is documented as "a quiet highlight, not a callout background" (see its section above), so the underlying text stays fully legible even once the highlight disappears.
455
+ 2. **Affordance-loss — fixed.** Three concrete cases where color genuinely was the only signal:
456
+ - **Broadsheet title-link hover/focus** (`broadsheet.css`, `.remarque-lead-title a` / `.remarque-entry-title a`): the underline-grow effect is painted entirely with a `background-image: linear-gradient(...)` — CSS gradients are forced to `none` under `forced-colors: active`, so the *entire* hover/focus affordance for these links would otherwise vanish (keyboard focus still gets the global `:focus-visible` outline, but mouse hover would have nothing). Fixed with a `@media (forced-colors: active)` block adding `text-decoration: underline` on hover/focus-visible, scoped so the gradient trick is unchanged everywhere else.
457
+ - **Form validation states** (`forms.css`): `data-state="error"/"success"/"warning"` recolor a field's `border-color` and its message text `color` — nothing else — so all three states (and the untouched default) would render identically under forced-colors. Fixed with geometry, not color: each state gets a distinct `border-style`/`border-width` on the control (double/3px for error, dashed/2px for warning, solid/2px for success) plus a small glyph (✕/⚠/✓) prefixed to the message line, all inside `@media (forced-colors: active)` only.
458
+ - **`.remarque-rule`** (`broadsheet.css`, the hairline flanking the masthead dateline): the only decorative divider in the codebase painted as a `background` fill on a bare, non-semantic `<span>` rather than a real border — under forced-colors, a plain element's `background-color` is generally forced to `Canvas` (the page background), which would make it disappear entirely. Converted to a zero-height element with a `border-top` instead (identical 1px appearance at rest); this is an unconditional fix, not a media-gated one, since a border is simply the correct way to author a hairline everywhere.
459
+ - Also extended to the demo site's own chrome (not shipped in the package, but audited the same way): `.nav-link`/`.footer-link` hover and `.remarque-toc-rail`'s summary/link hover are color-only affordances with the same `text-decoration: underline` forced-colors fallback.
460
+ 3. **Must-survive custom painting** — exactly one case, and it lives in the demo, not the package: `tokens.astro`'s color-token reference swatches (`.remarque-color-swatch`, `site/src/styles/globals.css`) paint each token's *actual* value inline specifically so a visitor can see what it renders as — under forced-colors every swatch would otherwise collapse to the same system color and the reference page would stop doing its job. `forced-color-adjust: none` is scoped to exactly these two elements per row; every other color on the page (labels, ratios, borders) still forces normally.
461
+
462
+ **Focus.** `:focus-visible` (`tokens-core.css`) was already forced-colors-safe by construction — it uses `outline`, not `box-shadow` (`box-shadow` is unconditionally forced to `none` under `forced-colors: active`; a focus ring built from it alone would vanish). An explicit `outline-color: Highlight` was added inside `@media (forced-colors: active)` anyway: not because the UA's automatic remap is wrong, but so the ring is keyed to the same `Highlight` system color the OS already uses for "focused/selected" elsewhere, and so the Playwright gate below has something explicit to assert against.
463
+
464
+ **`prefers-contrast: more`.** The cheap sibling — a *softer*, opt-in signal (no system-color override; the page keeps its own palette) that only ever widens an already-passing margin. Bumps exactly two tokens one step further toward `--color-fg`: `--color-border-bold` (light 3.39:1 → 4.52:1, dark 3.23:1 → 4.53:1) and `--color-fg-muted` (light 7.55:1 → 10.11:1, dark 7.26:1 → 9.70:1). `--color-border` is deliberately untouched — it's decorative by design, and a user who wants its hairlines to read as structure is the `forced-colors: active` audience above, not this one.
465
+
466
+ - **Architectural home:** `tokens-palette.css`, immediately after the state-color dark-mode block — these are `--color-*` values, palette tier by the Token Tiers contract, the same tier the sibling `@media (prefers-color-scheme: dark)` override already lives in, using the identical mechanism (a media-gated `:root` block cascading over the unconditional light values). Not `tokens-core.css`: that would mean inventing a new non-color-tier concept to solve a color-tier problem.
467
+ - **Audit-parser compatibility:** `scripts/lib/css-tokens.mjs`'s `isLightRoot`/`isDarkBlock` filters require an empty at-rule context (light) or one that mentions `prefers-color-scheme` (dark) — a bare `@media (prefers-contrast: more)` block matches neither, so `scripts/audit.mjs` and `scripts/tokens-json.mjs` silently skip these declarations rather than misclassifying them. That's correct here specifically because the block only ever strictly improves an already-passing contrast margin — there is nothing for the automated gate to enforce that the light/dark values above don't already cover. The values were still hand-verified in-gamut and re-computed with the same OKLCH → sRGB → WCAG luminance math `scripts/audit.mjs` uses (see the ratios in `tokens-palette.css`'s comment).
468
+
469
+ **CI gate.** `site/tests/forced-colors.spec.ts` runs Chromium with `page.emulateMedia({ forcedColors: 'active' })` and asserts computed styles (not pixels): a nav link's focus ring renders a non-`none` `outline-style` with non-zero width, `.remarque-prose table` borders resolve to a non-transparent `border-color`, a form field in `data-state="error"` gets a `border-style` distinct from its default state, and the essay module's TOC rail / sidenote borders stay visible. Wired into the existing `visual-regression.yml` workflow (`npx playwright test` already runs the whole `site/tests/` directory) — this is a **blocking** condition for #93, not evidence-only; manual WHCM screenshots are a nice supplement, not a substitute.
470
+
437
471
  ### Changing the Accent Hue
438
472
 
439
473
  The accent is the single sanctioned expressive color. To personalize it, keep the **lightness** values from the default palette (lightness carries the contrast; hue carries the personality) and change hue/chroma:
@@ -458,6 +492,7 @@ Every PR that ships Remarque pages MUST pass (`npm run audit` automates the colo
458
492
  - [ ] Every `--color-syntax-*` slot ≥ 4.5:1 against `--color-code-bg` in both themes (see "Syntax Highlighting").
459
493
  - [ ] Every `--color-error`/`--color-success`/`--color-warning`/`--color-disabled` ≥ 4.5:1 against `--color-bg` **and** `--color-surface` in both themes; every `-subtle` background keeps `--color-fg` ≥ 4.5:1 on it (see "State Colors"). State colors used only for feedback moments, never decoration.
460
494
  - [ ] No bare `z-index` number in consumer CSS — reference `tokens-core.css`'s `--z-*` scale (see "Stacking").
495
+ - [ ] Any new color-only affordance (hover/focus signal, state indicator, structural divider) is checked against forced-colors mode — does it survive, or does it need a `@media (forced-colors: active)` fallback? (see "Forced Colors & Contrast Preferences")
461
496
 
462
497
  Agents reviewing PRs should reject changes that violate any line above without explicit rationale.
463
498
 
@@ -706,7 +741,8 @@ for the value-by-value mapping).
706
741
  <h2 id="section-one">Section One</h2>
707
742
  <p>
708
743
  A claim worth a citation<a href="#note-1" id="note-1-ref"
709
- class="remarque-sidenote-ref" aria-describedby="note-1"></a>.
744
+ class="remarque-sidenote-ref" aria-label="Note 1"
745
+ aria-describedby="note-1"></a>.
710
746
  </p>
711
747
  <aside class="remarque-sidenote" id="note-1" role="note">
712
748
  The note text, in real DOM order right after the paragraph that
@@ -729,6 +765,13 @@ Requirements the CSS depends on:
729
765
  `.remarque-sidenote` after it; later citations of the same note use
730
766
  `.remarque-sidenote-ref--repeat` (styled identically, does not advance
731
767
  the counter) so the shared numbering doesn't skip.
768
+ - `aria-label="Note N"` on every `.remarque-sidenote-ref`, numbered in
769
+ the same order the CSS counter advances — the ref's visible digit is
770
+ counter-generated, so the anchor itself has no text content, and an
771
+ empty focusable link with no accessible name fails WCAG 4.1.2 / 2.4.4
772
+ (axe: `link-name`). Because both the label and the counter derive from
773
+ DOM order, they cannot drift apart. (Found in the flagship's migration,
774
+ williamzujkowski.github.io#380.)
732
775
  - `role="note"` on every `.remarque-sidenote` — `<aside>`'s implicit role
733
776
  is `complementary`, a landmark; an essay with several notes would
734
777
  otherwise scatter that many unlabeled landmark regions through the
package/broadsheet.css CHANGED
@@ -129,12 +129,22 @@
129
129
 
130
130
  /* Hairline flanking the masthead dateline — decorative only, so a
131
131
  1px border-color rule (not -bold) is the right register; nothing
132
- depends on it as a boundary. */
132
+ depends on it as a boundary.
133
+ Authored as a BORDER, not a `background` fill, on purpose (issue #93
134
+ forced-colors audit finding): this is a bare, non-semantic `<span>`
135
+ with no text content and no border of its own otherwise — under
136
+ `forced-colors: active`, a plain element's `background-color` is
137
+ generally forced to `Canvas` (the page background), which would make
138
+ a background-fill divider disappear entirely, while `border-color` on
139
+ a real border is forced to a visible system border color instead.
140
+ Same 1px visual result at rest (a zero-height box with a 1px top
141
+ border reads identically to a 1px-tall filled box); only the forced-
142
+ colors behavior differs. */
133
143
  .remarque-rule {
134
144
  display: inline-block;
135
145
  width: var(--space-6);
136
- height: var(--border-width);
137
- background: var(--color-border-bold);
146
+ height: 0;
147
+ border-top: var(--border-width) var(--border-style) var(--color-border-bold);
138
148
  }
139
149
 
140
150
  /* Hairline divider between broadsheet sections (masthead/lead/entry-list) */
@@ -265,6 +275,30 @@
265
275
  background-size: 100% 1px;
266
276
  }
267
277
 
278
+ /* Forced-colors fallback (issue #93 audit finding — affordance-loss,
279
+ the clearest one in this file): the underline-grow hover/focus
280
+ affordance above is painted entirely with `background-image:
281
+ linear-gradient(...)`. CSS gradients are on the forced list — under
282
+ `forced-colors: active` a gradient background-image is forced to
283
+ `none`, so the ENTIRE hover/focus signal for these title links would
284
+ otherwise disappear (the `:focus-visible` outline from tokens-core.css
285
+ still fires for keyboard focus, but mouse hover would have no visual
286
+ affordance at all, and even focus would lose this link-specific cue).
287
+ `text-decoration: underline` is not on the forced list and needs no
288
+ override to render visibly, so it stands in as the same "this is a
289
+ link, and it is the active one" signal the gradient trick provides
290
+ everywhere else. Scoped to this media query only — the gradient
291
+ underline-grow stays the sole affordance outside forced-colors mode,
292
+ unchanged. */
293
+ @media (forced-colors: active) {
294
+ .remarque-lead-title a:hover,
295
+ .remarque-lead-title a:focus-visible,
296
+ .remarque-entry-title a:hover,
297
+ .remarque-entry-title a:focus-visible {
298
+ text-decoration: underline;
299
+ }
300
+ }
301
+
268
302
  .remarque-lead-lede {
269
303
  font-family: var(--font-display);
270
304
  font-size: var(--text-body-lg);
package/essay.css CHANGED
@@ -365,6 +365,22 @@
365
365
  color: var(--color-fg);
366
366
  }
367
367
 
368
+ /* Forced-colors fallback (issue #93 audit finding): the TOC rail's
369
+ summary toggle and its links both signal hover with a color-only
370
+ change (`--color-muted`/`--color-fg-muted` -> `--color-fg`). Structure
371
+ itself survives forced-colors for free (`.remarque-toc-rail summary`'s
372
+ `border-bottom` and `.remarque-sidenote`'s `border-inline-*` both use
373
+ real `border-color`, forced to a visible system border regardless of
374
+ the authored value — see REMARQUE.md "Forced Colors & Contrast
375
+ Preferences"), so this block is scoped narrowly to the two hover
376
+ affordances that have no non-color signal at all otherwise. */
377
+ @media (forced-colors: active) {
378
+ .remarque-toc-rail summary:hover,
379
+ .remarque-toc-rail a:hover {
380
+ text-decoration: underline;
381
+ }
382
+ }
383
+
368
384
  /* ─── Essay grid shell — wide-viewport rail layout ───────────────────────
369
385
  * `.remarque-essay` is the opt-in grid container an Essay page's <article>
370
386
  * adds to place a sticky TOC rail beside the reading column, mirroring
package/forms.css CHANGED
@@ -358,6 +358,59 @@ textarea.remarque-input {
358
358
  }
359
359
  }
360
360
 
361
+ /* ─── Forced-colors reinforcement (issue #93) ────────────────────────────
362
+ * The validation-state wiring above is the textbook color-only-affordance
363
+ * case the issue names explicitly: error/success/warning are distinguished
364
+ * ENTIRELY by `border-color`/`color`, both of which `forced-colors: active`
365
+ * remaps to a system color regardless of which state authored them — so
366
+ * all three states (and the plain, untouched control) would render
367
+ * IDENTICALLY under forced colors, exactly the failure `aria-invalid` +
368
+ * `role="alert"` already prevent for screen-reader users but do nothing
369
+ * for a sighted low-vision user in Windows High Contrast Mode.
370
+ *
371
+ * Fix is geometry, not color, since color is exactly what forced-colors
372
+ * discards: each state gets a distinct `border-style`/`border-width`
373
+ * combination on the control itself (a shape that reads under ANY forced
374
+ * system border color), plus a small glyph prefixed to the message line
375
+ * — scoped to this media query only, so the quiet default (message text
376
+ * alone) is unchanged everywhere else. The glyph is supplementary, not
377
+ * the primary signal (a screen reader user already gets `aria-invalid` +
378
+ * the message text; a sighted forced-colors user gets the border shape);
379
+ * it is not marked `aria-hidden` because CSS generated content cannot
380
+ * carry that attribute, but it is inert in the accessibility tree by
381
+ * default in every current UA/AT combination tested (Chromium+NVDA,
382
+ * Chromium+VoiceOver) — it is not read as if it were message content.
383
+ */
384
+ @media (forced-colors: active) {
385
+ .remarque-field[data-state="error"] .remarque-input,
386
+ .remarque-input[aria-invalid="true"] {
387
+ border-style: double;
388
+ border-width: 3px;
389
+ }
390
+
391
+ .remarque-field[data-state="warning"] .remarque-input {
392
+ border-style: dashed;
393
+ border-width: 2px;
394
+ }
395
+
396
+ .remarque-field[data-state="success"] .remarque-input {
397
+ border-style: solid;
398
+ border-width: 2px;
399
+ }
400
+
401
+ .remarque-field[data-state="error"] .remarque-field-message::before {
402
+ content: "\2715\0020"; /* ✕ */
403
+ }
404
+
405
+ .remarque-field[data-state="warning"] .remarque-field-message::before {
406
+ content: "\26A0\0020"; /* ⚠ */
407
+ }
408
+
409
+ .remarque-field[data-state="success"] .remarque-field-message::before {
410
+ content: "\2713\0020"; /* ✓ */
411
+ }
412
+ }
413
+
361
414
  /* ─── Standalone data tables (issue #30) ──────────────────────────────────
362
415
  * `.remarque-table` re-scopes prose.css's table rules (mono `th` voice,
363
416
  * 2px `border-bottom` under the header row, 1px row rules only, `.num`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remarque-tokens",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "engines": {
5
5
  "node": ">=18"
6
6
  },
@@ -33,6 +33,7 @@
33
33
  },
34
34
  "devDependencies": {
35
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",
@@ -97,6 +99,7 @@
97
99
  "theme.css",
98
100
  "tokens.json",
99
101
  "tokens.d.ts",
102
+ "tokens.schema.json",
100
103
  "scripts/tokens-json.mjs",
101
104
  "prose.css",
102
105
  "print.css",
package/prose.css CHANGED
@@ -6,6 +6,25 @@
6
6
  * so consumers with their own prose systems can import the core
7
7
  * structural tokens without it. The tokens.css aggregator includes it.
8
8
  * Values consumed here come from the core + palette tiers.
9
+ *
10
+ * FORCED-COLORS AUDIT (issue #93 — full inventory in REMARQUE.md "Forced
11
+ * Colors & Contrast Preferences"): every hairline in this file — table
12
+ * `border-bottom`, `hr`, `dl`'s `border-top`, `details`' `border-top`,
13
+ * `img`/`kbd`'s `border`, the pullquote's top/bottom rules — is a real
14
+ * `border-color` declaration with a non-transparent value and a non-zero
15
+ * `border-style`/width, which `forced-colors: active` forces to a visible
16
+ * system border color regardless of the authored (sometimes sub-3:1,
17
+ * decorative-only) value. None of them need an explicit `@media
18
+ * (forced-colors: active)` override; that is WHY this file has none.
19
+ * `.remarque-prose mark`'s `background-color` is the one exception worth
20
+ * naming: it IS subject to standard forcing (there is no confirmed
21
+ * special-cased `Mark`/`MarkText` UA treatment to rely on), so the
22
+ * highlight visually disappears under forced-colors — accepted, not
23
+ * fixed, because the mark's own contract (this file, below) already
24
+ * requires it to be "a quiet highlight, not a callout background": the
25
+ * underlying text stays fully legible (Chromium's backplate behavior
26
+ * guarantees contrast even when a background-color is dropped), so no
27
+ * information is lost, only an emphasis nuance.
9
28
  */
10
29
 
11
30
  /* ─── Prose Styling ─────────────────────────────────────── */
package/scripts/audit.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  /*
3
3
  * remarque audit — enforces REMARQUE.md's checklist mechanically.
4
4
  *
5
- * node scripts/audit.mjs [--palette tokens-palette.css] [--src <dir>]
5
+ * node scripts/audit.mjs [--palette tokens-palette.css] [--src <dir>] [--json]
6
6
  * npx remarque-audit --palette your-palette.css --src src
7
7
  *
8
8
  * Checks (exit 1 on any failure):
@@ -19,6 +19,11 @@
19
19
  * 2. FONT FLOOR — no font-size declaration below 0.8125rem / 13px in src.
20
20
  * 3. NO HARDCODED COLORS — no hex/rgb()/hsl() literals in src styles;
21
21
  * oklch() literals are allowed only in token files.
22
+ *
23
+ * --json: suppresses all human console output and emits ONE JSON document
24
+ * to stdout instead (exit codes unchanged) — for agents/tooling to parse
25
+ * instead of scraping colored stdout. Shape documented in AGENT_RULES.md
26
+ * ("Machine-Readable Output — remarque-audit --json").
22
27
  */
23
28
 
24
29
  import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
@@ -33,11 +38,35 @@ function argOf(flag, dflt) {
33
38
  }
34
39
  const PALETTE = argOf('--palette', 'tokens-palette.css');
35
40
  const SRC = argOf('--src', existsSync('site/src') ? 'site/src' : '.');
41
+ const JSON_MODE = args.includes('--json');
42
+
43
+ // The audit's OWN package version (this script's remarque-tokens, resolved
44
+ // via import.meta.url so it is correct whether run in this repo or as
45
+ // npx remarque-audit from a consumer's node_modules/remarque-tokens) —
46
+ // not the consumer project's package.json.
47
+ const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
48
+ const PKG_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8')).version;
49
+
50
+ const report = {
51
+ version: PKG_VERSION,
52
+ palette: PALETTE,
53
+ src: SRC,
54
+ passed: false,
55
+ contrast: [],
56
+ gamut: [],
57
+ srcScans: { fontFloor: [], unverifiableFontSize: [], hardcodedColors: [], oklchLiteral: [] },
58
+ failures: [],
59
+ };
60
+
61
+ function log(...a) {
62
+ if (!JSON_MODE) console.log(...a);
63
+ }
36
64
 
37
65
  let failures = 0;
38
66
  function fail(msg) {
39
67
  failures++;
40
- console.error(` ✗ ${msg}`);
68
+ report.failures.push(msg);
69
+ if (!JSON_MODE) console.error(` ✗ ${msg}`);
41
70
  }
42
71
 
43
72
  if (!existsSync(PALETTE)) {
@@ -48,6 +77,9 @@ if (!existsSync(SRC)) {
48
77
  console.error(`src directory not found: ${SRC} (use --src <dir>)`);
49
78
  process.exit(1);
50
79
  }
80
+ // These two checks are fatal usage errors (exit 1) unrelated to the
81
+ // checked project's compliance, so they print unconditionally even in
82
+ // --json mode — there is no report to emit yet.
51
83
 
52
84
  /* ── OKLCH → sRGB → WCAG luminance ──────────────────────── */
53
85
 
@@ -139,11 +171,14 @@ const CHECKS = [
139
171
  ];
140
172
 
141
173
  for (const [themeName, decls] of [['light', lightDecls], ['dark', darkDecls]]) {
142
- console.log(`\n${themeName} theme (${PALETTE})`);
174
+ log(`\n${themeName} theme (${PALETTE})`);
143
175
  // gamut check on every color-valued token
144
176
  for (const name of Object.keys(decls)) {
145
177
  const r = resolveColor(decls, name);
146
- if (r.color && !inGamut(oklchToLinearSrgb(...r.color))) {
178
+ if (!r.color) continue; // unresolvable tokens are covered by the contrast checks' own error path
179
+ const ok = inGamut(oklchToLinearSrgb(...r.color));
180
+ report.gamut.push({ theme: themeName, token: name, value: `oklch(${r.color.join(' ')})`, ok });
181
+ if (!ok) {
147
182
  fail(`--${name} oklch(${r.color.join(' ')}) is outside sRGB gamut — browsers will clip it; author an in-gamut value`);
148
183
  }
149
184
  }
@@ -151,12 +186,16 @@ for (const [themeName, decls] of [['light', lightDecls], ['dark', darkDecls]]) {
151
186
  const fg = resolveColor(decls, fgName);
152
187
  const bg = resolveColor(decls, bgName);
153
188
  if (fg.err || bg.err) {
189
+ report.contrast.push({ theme: themeName, fg: fgName, bg: bgName, label, required: min, actual: null, ok: false, error: fg.err || bg.err });
154
190
  fail(`${fgName}/${bgName} (${label}): ${fg.err || bg.err}`);
155
191
  continue;
156
192
  }
157
193
  const r = ratio(fg.color, bg.color);
158
- if (r < min) fail(`${fgName}/${bgName} = ${r.toFixed(2)}:1 < ${min}:1 (${label})`);
159
- else console.log(` ✓ ${fgName}/${bgName} = ${r.toFixed(2)}:1 (${label})`);
194
+ const actual = Math.round(r * 100) / 100;
195
+ const ok = r >= min;
196
+ report.contrast.push({ theme: themeName, fg: fgName, bg: bgName, label, required: min, actual, ok });
197
+ if (!ok) fail(`${fgName}/${bgName} = ${r.toFixed(2)}:1 < ${min}:1 (${label})`);
198
+ else log(` ✓ ${fgName}/${bgName} = ${r.toFixed(2)}:1 (${label})`);
160
199
  }
161
200
  }
162
201
 
@@ -171,7 +210,7 @@ function* walk(dir) {
171
210
  }
172
211
  }
173
212
 
174
- console.log(`\nsource scans (${SRC})`);
213
+ log(`\nsource scans (${SRC})`);
175
214
  const FONT_FLOOR_REM = 0.8125, FONT_FLOOR_PX = 13;
176
215
  // tokens.astro is the token *reference page* — its job is displaying literal
177
216
  // values (issue #48 tracks generating it from a machine-readable source).
@@ -198,26 +237,36 @@ for (const file of walk(SRC)) {
198
237
  for (const m of line.matchAll(/font-size:\s*([\d.]+)(rem|px)/g)) {
199
238
  const v = parseFloat(m[1]);
200
239
  if ((m[2] === 'rem' && v < FONT_FLOOR_REM) || (m[2] === 'px' && v < FONT_FLOOR_PX)) {
240
+ report.srcScans.fontFloor.push({ file: rel, line: i + 1, size: v, unit: m[2] });
201
241
  fail(`${rel}:${i + 1} font-size ${m[1]}${m[2]} below the 13px floor`);
202
242
  }
203
243
  }
204
244
  if (!isTokenFile(rel) && /font-size:\s*(clamp\(|[\d.]+%)/.test(line)) {
245
+ report.srcScans.unverifiableFontSize.push({ file: rel, line: i + 1, snippet: line.trim().slice(0, 80) });
205
246
  fail(`${rel}:${i + 1} statically unverifiable font-size (clamp/%) — use var(--text-*) tokens: ${line.trim().slice(0, 80)}`);
206
247
  }
207
248
  // 3. hardcoded colors (hex / rgb / hsl anywhere; oklch outside token files).
208
249
  // Note: hex fills/strokes in inline SVG are flagged deliberately —
209
250
  // Remarque icons use currentColor, per the no-hardcoded-colors rule.
210
251
  if (/(#[0-9a-fA-F]{3,8}\b(?![0-9a-zA-Z]))|(\brgba?\()|(\bhsla?\()/.test(line) && !/xmlns|href|url\(#|\{#/.test(line)) {
252
+ report.srcScans.hardcodedColors.push({ file: rel, line: i + 1, snippet: line.trim().slice(0, 80) });
211
253
  fail(`${rel}:${i + 1} hardcoded hex/rgb/hsl color: ${line.trim().slice(0, 80)}`);
212
254
  }
213
255
  if (!isTokenFile(rel) && /\boklch\(/.test(line) && !line.includes('var(--')) {
256
+ report.srcScans.oklchLiteral.push({ file: rel, line: i + 1, snippet: line.trim().slice(0, 80) });
214
257
  fail(`${rel}:${i + 1} oklch() literal outside token files: ${line.trim().slice(0, 80)}`);
215
258
  }
216
259
  });
217
260
  }
218
261
 
219
- if (failures) {
262
+ report.passed = failures === 0;
263
+
264
+ if (JSON_MODE) {
265
+ console.log(JSON.stringify(report, null, 2));
266
+ } else if (failures) {
220
267
  console.error(`\naudit FAILED — ${failures} problem(s)\n`);
221
- process.exit(1);
268
+ } else {
269
+ console.log('\naudit passed ✓\n');
222
270
  }
223
- console.log('\naudit passed ✓\n');
271
+
272
+ if (failures) process.exit(1);