remarque-tokens 0.7.0 → 0.9.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 +5 -2
- package/CHANGELOG.md +96 -0
- package/README.md +10 -2
- package/REMARQUE.md +104 -2
- package/package.json +17 -2
- package/scripts/theme.mjs +440 -0
- package/tokens.json +1 -1
package/AGENT_RULES.md
CHANGED
|
@@ -26,7 +26,7 @@ Execute in this exact order. Do not skip steps. Do not reorder.
|
|
|
26
26
|
|
|
27
27
|
8. **Validate readability on mobile** before adding any visual polish.
|
|
28
28
|
|
|
29
|
-
9. **Verify every page conforms** to one of the
|
|
29
|
+
9. **Verify every page conforms** to one of the seven archetypes (Essay, Project Dossier, Notebook, Landing, Reference/Docs, Changelog, Gallery).
|
|
30
30
|
|
|
31
31
|
**Why this order matters:** Most agents build polished components first and bolt on typography later. That produces the exact aesthetic Remarque exists to prevent. Typography and prose are the foundation. Everything else is furniture.
|
|
32
32
|
|
|
@@ -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
|
|
|
@@ -160,6 +161,8 @@ project/
|
|
|
160
161
|
└── notes # Notebook archetype
|
|
161
162
|
```
|
|
162
163
|
|
|
164
|
+
Three further archetypes — Reference/Docs, Changelog, Gallery — are specified in REMARQUE.md but have no reference page in this repo's demo site yet; build them per spec, they follow the same file-structure conventions as the four above (e.g. `docs/[slug]`, `changelog`, `gallery` or `browse`).
|
|
165
|
+
|
|
163
166
|
### Implementation Pitfalls (learned from reference implementation)
|
|
164
167
|
|
|
165
168
|
1. **Tailwind spacing collision:** Never map Remarque's `--space-N` values onto Tailwind's default numeric spacing keys — in v4 `@theme` that means no `--spacing-9`…`--spacing-12`, and in the v3 config no `spacing: { "5"…"12" }` overrides — or `mt-12` produces 192px instead of 48px. Both shipped artifacts namespace instead: the v3 config exposes `mt-remarque-9` etc., the reference v4 `@theme` uses `--spacing-remarque-N`. Use those, or `var(--space-N)` in arbitrary values.
|
|
@@ -195,7 +198,7 @@ Before considering any implementation complete, verify:
|
|
|
195
198
|
- [ ] Accent color appears in ≤2 roles per viewport
|
|
196
199
|
- [ ] No scroll-triggered or entrance animations exist
|
|
197
200
|
- [ ] Every image has a 1px border and mono caption
|
|
198
|
-
- [ ] Every page maps to an archetype (Essay, Dossier, Notebook, Landing)
|
|
201
|
+
- [ ] Every page maps to an archetype (Essay, Dossier, Notebook, Landing, Reference/Docs, Changelog, Gallery)
|
|
199
202
|
- [ ] Mobile version is roomy — not a compressed desktop layout
|
|
200
203
|
- [ ] Mobile nav links have ≥44px touch targets
|
|
201
204
|
- [ ] No pure white or pure black backgrounds
|
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,102 @@ 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.9.0 — 2026-07-22
|
|
8
|
+
|
|
9
|
+
Color-provider bridge release (consensus-ratified 7-0, higher_order panel;
|
|
10
|
+
closes #75 — Phase 1 of the color-provider integration; Phase 2/3 tracked
|
|
11
|
+
in #76).
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
- **`remarque-theme` bin** (#75): `npx remarque-theme <light-slug> --dark
|
|
15
|
+
<dark-slug> [-o out.css]` derives a full 15-slot palette-tier override
|
|
16
|
+
from an `@williamzujkowski/oklch-terminal-themes` light+dark pair.
|
|
17
|
+
Terminal themes carry only `background/foreground/cursor/selection` +
|
|
18
|
+
16 ANSI slots — most of Remarque's semantic slots don't map directly,
|
|
19
|
+
and most themes fail the AAA `fg-muted` 7:1 line as authored. The bridge
|
|
20
|
+
derives instead: hue + chroma come from the theme, lightness is solved
|
|
21
|
+
per slot by binary search against the exact Enforcement Checklist
|
|
22
|
+
targets (in-gamut chroma clamping inside the solver, round-then-verify
|
|
23
|
+
to avoid float rounding pushing a borderline value back out of gamut).
|
|
24
|
+
Output passes `remarque-audit` **by construction** — the script
|
|
25
|
+
self-verifies the same contrast + gamut checks in-process before it
|
|
26
|
+
will emit anything.
|
|
27
|
+
- **Security hardening** (#75, panel security review): both slugs are
|
|
28
|
+
validated against the package's own `index.json` *before* any path is
|
|
29
|
+
built from them (the actual guard against path traversal, not the slug
|
|
30
|
+
regex, which is defense-in-depth); every OKLCH triplet read from theme
|
|
31
|
+
JSON is validated as finite numbers before use; CSS is never built by
|
|
32
|
+
string-interpolating raw JSON — every emitted number has passed through
|
|
33
|
+
our own rounding + validation. Light/dark polarity (`isDark`) is
|
|
34
|
+
enforced against the requested slugs.
|
|
35
|
+
- **`scripts/test-theme.mjs`** (#75): a corpus property test enumerating
|
|
36
|
+
every light+dark pair in the installed `oklch-terminal-themes` package
|
|
37
|
+
by name-stem (47 pairs at the pinned 0.1.0 dataset) — every pair must
|
|
38
|
+
derive AND pass the real `audit.mjs`, not a sample. Also covers unknown
|
|
39
|
+
slugs, polarity mismatches, traversal-looking slugs, and that the
|
|
40
|
+
output declares only palette-tier custom properties. Wired into CI
|
|
41
|
+
alongside `test-audit.mjs`/`test-drift.mjs`.
|
|
42
|
+
- **`@williamzujkowski/oklch-terminal-themes`** pinned exact at `0.1.0` in
|
|
43
|
+
`devDependencies`, and listed as an optional `^0.1.0` peerDependency —
|
|
44
|
+
consumers who want `remarque-theme` install it themselves; it is never
|
|
45
|
+
required to use the rest of the package.
|
|
46
|
+
- REMARQUE.md: new "Color Providers" section positioning the palette tier
|
|
47
|
+
as theme-suppliable, with `oklch-terminal-themes` as the reference
|
|
48
|
+
provider and the pairing contract stated explicitly (`--dark` is
|
|
49
|
+
required; upstream pairing metadata tracked in
|
|
50
|
+
`oklch-terminal-themes#128`). AGENT_RULES.md: a rule against hand-editing
|
|
51
|
+
a generated palette — regenerate it instead.
|
|
52
|
+
|
|
53
|
+
No core-tier token values changed in this release. `tokens-palette.css`
|
|
54
|
+
(the shipped default palette) is also unchanged — `remarque-theme` is an
|
|
55
|
+
opt-in alternate source for the palette tier, not a replacement for it.
|
|
56
|
+
|
|
57
|
+
## 0.8.0 — 2026-07-21
|
|
58
|
+
|
|
59
|
+
Archetype-vocabulary and AI-agent-packaging release (design review; closes
|
|
60
|
+
#55, closes #57).
|
|
61
|
+
|
|
62
|
+
### Added
|
|
63
|
+
- **Three new page archetypes** (#55): Reference/Docs (persistent nav
|
|
64
|
+
rail, breadcrumb kicker, prev/next footer — reuses Essay's three-column
|
|
65
|
+
shape), Changelog (mono version/date headline, grouped Added/Changed/
|
|
66
|
+
Fixed lists — built from Notebook's entry structure), and Gallery
|
|
67
|
+
(content-wide cover grids, covers exempt from the reading-width cap,
|
|
68
|
+
border-only hover — formalized from tsundoku's documented reference
|
|
69
|
+
implementation, cited directly in REMARQUE.md). Seven archetypes total;
|
|
70
|
+
agents must not invent an eighth.
|
|
71
|
+
- **Plate** (#55): a screenshot-heavy-page subsection of Image Treatment —
|
|
72
|
+
numbered mono captions, a narrow 2-up exception for terminal captures.
|
|
73
|
+
- **CLI-tool landing guidance** (#55): a scoped, rationale-argued exception
|
|
74
|
+
to Landing's no-CTA rule for a single mono install command block.
|
|
75
|
+
- **Dataviz Tokens** (#55): chart grid/axis/categorical-ramp mapped onto
|
|
76
|
+
existing tokens and required to pass the audit — citing tsundoku's
|
|
77
|
+
orthogonal category-color system as precedent and naming the one gap
|
|
78
|
+
(never run through `remarque-audit`'s `CHECKS` array) this guidance
|
|
79
|
+
closes.
|
|
80
|
+
- **`remarque-tokens/agent-rules` and `remarque-tokens/spec` exports**
|
|
81
|
+
(#57): both files were already in `files`; this ships them as named
|
|
82
|
+
subpaths so a consumer can `require.resolve('remarque-tokens/agent-rules')`
|
|
83
|
+
instead of hardcoding a `node_modules` path.
|
|
84
|
+
- **`.claude/skills/remarque/SKILL.md`** (#57): a Claude Code skill
|
|
85
|
+
triggering on "remarque" / "design system" / new-page work. Loads
|
|
86
|
+
`AGENT_RULES.md` + `REMARQUE.md` + `tokens.json`, states the token-tier
|
|
87
|
+
rules, the audit command, and Pitfalls #6/#7 (string-form `@import`;
|
|
88
|
+
unlayered token import under Tailwind v4) inline — both pass a green
|
|
89
|
+
build while silently breaking, so they're worth surfacing without a
|
|
90
|
+
file round-trip.
|
|
91
|
+
- **`/tokens.json` on the demo site** (#57): `site/scripts/copy-tokens-json.mjs`
|
|
92
|
+
runs as a prebuild step (`npm run build` in `site/`), copying the
|
|
93
|
+
installed package's `tokens.json` into `public/` so it ships at
|
|
94
|
+
`https://williamzujkowski.github.io/remarque/tokens.json` — a remote
|
|
95
|
+
agent can fetch current token values instead of trusting training data.
|
|
96
|
+
The copy is generated, not a source file (`site/public/tokens.json` is
|
|
97
|
+
gitignored).
|
|
98
|
+
- Root README's "For AI Agents" section documents all three packaging
|
|
99
|
+
additions above.
|
|
100
|
+
|
|
101
|
+
No core-tier token values changed in this release.
|
|
102
|
+
|
|
7
103
|
## 0.7.0 — 2026-07-21
|
|
8
104
|
|
|
9
105
|
Typographic-completeness release (design review, #50/#51/#54): the
|
package/README.md
CHANGED
|
@@ -58,6 +58,11 @@ See: REMARQUE.md for specification, AGENT_RULES.md for implementation contract,
|
|
|
58
58
|
|
|
59
59
|
The agent rules define build order, non-negotiable rules, disallowed patterns, and a quality checklist. Every decision is specified — agents don't need to guess.
|
|
60
60
|
|
|
61
|
+
Packaging for agent tooling:
|
|
62
|
+
- **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.
|
|
63
|
+
- **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.
|
|
64
|
+
- **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.
|
|
65
|
+
|
|
61
66
|
## Files
|
|
62
67
|
|
|
63
68
|
| File | Purpose |
|
|
@@ -83,14 +88,17 @@ The agent rules define build order, non-negotiable rules, disallowed patterns, a
|
|
|
83
88
|
|
|
84
89
|
## Page Archetypes
|
|
85
90
|
|
|
86
|
-
Every page conforms to one of
|
|
91
|
+
Every page conforms to one of seven archetypes:
|
|
87
92
|
|
|
88
93
|
- **Essay** — long-form writing with serif title, mono metadata, narrow reading column
|
|
89
94
|
- **Project Dossier** — structured project page with metadata block and architecture section
|
|
90
95
|
- **Notebook** — short-form notes with mono timestamps, no cards
|
|
91
96
|
- **Landing** — identity statement, content navigation, generous whitespace
|
|
97
|
+
- **Reference/Docs** — persistent nav rail, breadcrumb kicker, prev/next footer (reuses Essay's three-column shape)
|
|
98
|
+
- **Changelog** — mono version/date headlines, grouped Added/Changed/Fixed lists (built from Notebook's entry structure)
|
|
99
|
+
- **Gallery** — cover-grid pages at `--content-wide`, covers exempt from the reading-width cap (formalized from tsundoku's reference implementation)
|
|
92
100
|
|
|
93
|
-
See the [live demo](https://williamzujkowski.github.io/remarque/) for
|
|
101
|
+
See the [live demo](https://williamzujkowski.github.io/remarque/) for the original four archetypes in action — Reference/Docs, Changelog, and Gallery are specified in REMARQUE.md but don't yet have a demo page in this repo.
|
|
94
102
|
|
|
95
103
|
## Design Decisions
|
|
96
104
|
|
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:** the dataset does not yet carry light/dark pairing metadata (tracked upstream: [`oklch-terminal-themes#128`](https://github.com/williamzujkowski/oklch-terminal-themes/issues/128)), so `--dark <slug>` is explicitly required — there is no implicit or guessed dark counterpart. `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.
|
|
@@ -172,7 +190,7 @@ Never mix the two: a numeral inside running prose should never be tabular, and a
|
|
|
172
190
|
|
|
173
191
|
## Content Density Rule
|
|
174
192
|
|
|
175
|
-
No page should show more than three distinct content sections above the fold. The first screen of any page contains only: title, metadata, and the opening of content. Hero sections with CTAs, feature grids, and social proof are not part of Remarque's vocabulary.
|
|
193
|
+
No page should show more than three distinct content sections above the fold. The first screen of any page contains only: title, metadata, and the opening of content. Hero sections with CTAs, feature grids, and social proof are not part of Remarque's vocabulary. (The CLI-tool landing install block, see the Landing archetype, is a single command — not a CTA — and does not count against this rule.)
|
|
176
194
|
|
|
177
195
|
---
|
|
178
196
|
|
|
@@ -187,6 +205,28 @@ Remarque is typography-first, but projects include screenshots, architecture dia
|
|
|
187
205
|
- No rounded corners on images beyond `--radius-sm`
|
|
188
206
|
- Images are never decorative — every image must serve the content
|
|
189
207
|
|
|
208
|
+
### Plate (screenshot-heavy pages)
|
|
209
|
+
|
|
210
|
+
Reference/Docs and Project Dossier pages often need to show several screenshots — six or more UI captures walking through a workflow — where "one image at a time" doesn't scale. Plate is the sanctioned pattern; everything above still applies, it only adds:
|
|
211
|
+
|
|
212
|
+
- Default to single-column figures at `--content-reading` width, one screenshot per figure
|
|
213
|
+
- Every figure gets a numbered mono caption in the form `Fig. 01 — description` (`--font-mono`, `--text-meta`) — the number is mandatory once a page has more than one figure, so prose can cross-reference a specific plate
|
|
214
|
+
- A 2-up grid is permitted only for terminal/CLI captures, and only at `--content-standard` width — the one sanctioned exception to "images never wider than `--content-reading`," reserved for this narrow case
|
|
215
|
+
- A page with six or more plates gets prev/next figure navigation (mono, matching Reference/Docs' footer convention) rather than an unbroken scroll
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
## Dataviz Tokens
|
|
220
|
+
|
|
221
|
+
Charts are not yet a first-class Remarque surface, but tools built with the system inevitably need them. Rather than inventing chart-specific tokens, map dataviz elements onto vocabulary that already exists, so a chart survives a palette swap the same way prose and chrome do:
|
|
222
|
+
|
|
223
|
+
- **Grid lines** use `--color-border` — never a separate "chart gray." Grid lines are structural, not decorative, and already have a sanctioned quiet role.
|
|
224
|
+
- **Axis text and tick labels** use `--font-mono` at `--text-meta`, `--color-muted` — the same register as metadata rows and captions, never the body or display face.
|
|
225
|
+
- **Categorical color ramp** is derived, not hand-picked: rotate hue at the accent's lightness/chroma steps — the same discipline "Changing the Accent Hue" already prescribes (fix lightness, vary hue, reduce chroma until every value clears the gamut) — producing a small hue family distinct from `--color-accent` itself, so chart color is never mistaken for the system's one interactive signal.
|
|
226
|
+
- That ramp must be **audit-validated**, exactly like a palette override: every ramp color needs to clear gamut and 4.5:1 contrast against `--color-bg`/`--color-surface` in both themes before it ships.
|
|
227
|
+
|
|
228
|
+
**Precedent:** [tsundoku](https://github.com/williamzujkowski/tsundoku) built the underlying pattern already — its `DESIGN-NOTES.md` documents an 8-hue "orthogonal category system" (`--pop-pink`/`--pop-blue`/`--pop-green`/etc.), kept deliberately separate from `--color-accent` and used for content taxonomy (book categories, reading status) rather than interactivity. It held those hues to roughly the same lightness/contrast discipline as its audited palette, but by its own admission the hues were **never run through `remarque-audit`'s `CHECKS` array** — that is the one gap this guidance closes. A dataviz ramp should follow tsundoku's separation-from-accent instinct, but unlike its precedent, it must pass the audit before it ships.
|
|
229
|
+
|
|
190
230
|
---
|
|
191
231
|
|
|
192
232
|
## Motion Rules
|
|
@@ -324,7 +364,7 @@ This order is not a suggestion. It is the tiebreaker for every design decision.
|
|
|
324
364
|
|
|
325
365
|
## Page Archetypes
|
|
326
366
|
|
|
327
|
-
Every page built with Remarque must conform to one of these
|
|
367
|
+
Every page built with Remarque must conform to one of these seven archetypes: Essay, Project Dossier, Notebook, Landing, Reference/Docs, Changelog, Gallery. Agents and implementers should not invent new page structures — the last three exist precisely because real downstream projects (tsundoku, and the multi-site review that produced this revision) needed shapes the first four actively fought, and inventing an unsanctioned workaround is worse than extending the vocabulary here.
|
|
328
368
|
|
|
329
369
|
### Essay
|
|
330
370
|
|
|
@@ -396,6 +436,7 @@ The homepage or entry point. Sets the tone.
|
|
|
396
436
|
**Optionally includes:**
|
|
397
437
|
- Recent entries or updates (3–5 max)
|
|
398
438
|
- A brief "about" line
|
|
439
|
+
- **CLI-tool landing only:** a single mono install command block (styled as prose `<pre>`, copy button allowed) — the one sanctioned interactive element on an otherwise CTA-free page — and an optional `--help`-output figure (follows the Plate convention, see Image Treatment)
|
|
399
440
|
|
|
400
441
|
**Never includes:**
|
|
401
442
|
- Hero images or banners
|
|
@@ -404,6 +445,67 @@ The homepage or entry point. Sets the tone.
|
|
|
404
445
|
- Testimonial carousels
|
|
405
446
|
- Newsletter signup forms above the fold
|
|
406
447
|
|
|
448
|
+
The CLI install block is not a CTA exception in disguise — it is the single piece of information a CLI tool's landing page cannot omit (how do I get this), same status as a book's title. One block, one command, no surrounding marketing copy. A page with an install block *and* a separate "Sign up now" button has smuggled in a second CTA and fails this archetype.
|
|
449
|
+
|
|
450
|
+
### Reference/Docs
|
|
451
|
+
|
|
452
|
+
Persistent technical documentation: API references, config guides, multi-page manuals. Reuses the Essay archetype's existing three-column shape (nav rail outside the reading column · reading column · optional desktop ToC outside the reading column) — this is not a new layout engine, only a different left rail.
|
|
453
|
+
|
|
454
|
+
**Always includes:**
|
|
455
|
+
- A persistent left nav rail listing every doc page (mono, quiet, `--text-meta` — a list of links, not a component), sitting outside the reading column exactly like Essay's optional ToC sits outside it on the other side
|
|
456
|
+
- A breadcrumb kicker above the title (mono, `--text-meta`, e.g. `Reference / Configuration`)
|
|
457
|
+
- Content constrained to `--content-reading`, matching Essay's prose column
|
|
458
|
+
- A low-noise prev/next footer nav between adjacent doc pages (mono, matching Essay's footer nav)
|
|
459
|
+
|
|
460
|
+
**Optionally includes:**
|
|
461
|
+
- Tables and code blocks widening past the reading column to `--content-standard` when the content genuinely needs the room (a config reference table, a wide code sample) — the surrounding prose stays at `--content-reading`
|
|
462
|
+
- A desktop-only "on this page" ToC, reusing Essay's optional side-ToC convention, on the side opposite the nav rail
|
|
463
|
+
|
|
464
|
+
**Never includes:**
|
|
465
|
+
- Sidebar content mixed *into* the reading column itself (the nav rail is a fourth structural region, not reading-column content — same rule Essay already enforces for its own ToC)
|
|
466
|
+
- Card grids for the page listing (the nav rail is a list, not a grid of cards)
|
|
467
|
+
- More than one accent color
|
|
468
|
+
- CTAs, feature grids, or any Landing-archetype vocabulary
|
|
469
|
+
|
|
470
|
+
### Changelog
|
|
471
|
+
|
|
472
|
+
A tool or project's primary log of released changes. Built from the Notebook archetype's entry structure, not a new component: compact, mono-timestamped, content-forward entries, applied to version history instead of notes.
|
|
473
|
+
|
|
474
|
+
**Always includes:**
|
|
475
|
+
- A mono version + date headline per entry (e.g. `0.7.0 — 2026-07-21`) — semver renders as plain mono text, never a colored pill or badge
|
|
476
|
+
- Grouped category lists per entry (Added / Changed / Fixed, or whatever categories the project uses), reusing Notebook's compact entry rhythm
|
|
477
|
+
- Reverse-chronological order, most recent release first
|
|
478
|
+
|
|
479
|
+
**Optionally includes:**
|
|
480
|
+
- An inline mono link to the relevant issue/PR per line item
|
|
481
|
+
- A "compare" or diff link per version, mono, in the entry headline row
|
|
482
|
+
|
|
483
|
+
**Never includes:**
|
|
484
|
+
- Semver rendered as a pill/badge (`.rounded-full` chips, colored status pills) — plain mono text only, same rule as tags elsewhere in the system
|
|
485
|
+
- Card-based per-version layout
|
|
486
|
+
- Marketing framing of a change ("Exciting new feature!") — changelog prose stays as flat and factual as commit messages
|
|
487
|
+
|
|
488
|
+
A changelog embedded as an optional subsection of a Project Dossier (already sanctioned) remains valid for short update logs. Promote to the standalone Changelog archetype once the log is long enough, or important enough, to be the page rather than a section of one — typically once a tool ships its own versioned releases.
|
|
489
|
+
|
|
490
|
+
### Gallery
|
|
491
|
+
|
|
492
|
+
Cover-grid and catalog pages — a page whose primary content unit is a grid of images with a caption, not prose. None of the other archetypes are shaped for this: they are all fundamentally text/document-shaped. **Reference implementation: [tsundoku](https://github.com/williamzujkowski/tsundoku)**, which needed this shape immediately for its book-catalog `/browse` grid and documented it as an informal fifth archetype in its `DESIGN-NOTES.md` before this spec existed; Gallery formalizes that implementation rather than inventing a new one.
|
|
493
|
+
|
|
494
|
+
**Always includes:**
|
|
495
|
+
- A grid container widened beyond `--content-standard` (72rem) to `--content-wide` (88rem) — Gallery is the only archetype permitted to exceed `--content-standard`, because a grid of cover-aspect thumbnails needs more horizontal room before it feels cramped than an essay or a dossier ever would
|
|
496
|
+
- Cover/thumbnail images exempt from the `--content-reading` width cap that Image Treatment otherwise mandates — cover art *is* the content here, not illustrative decoration of prose. Every other Image Treatment rule still applies without exception: 1px `--color-border`, no drop shadow, mono captions
|
|
497
|
+
- Grid item hover restricted to a border-color shift only (`--motion-fast`) — no scale, no lift, no shadow growth, matching Motion Rules' general prohibition on attention-seeking animation
|
|
498
|
+
- Grid density driven by content (`repeat(auto-fill, minmax(...))` or breakpoint-based column counts), never a fixed card size that fights the image's native aspect ratio
|
|
499
|
+
|
|
500
|
+
**Optionally includes:**
|
|
501
|
+
- Prose-bearing sections within a Gallery page (an "about" blurb, an item description) narrowed back down to `--content-reading` — the widened container serves the grid only, not every element on the page
|
|
502
|
+
- Mono captions under each cover (title, author, or other item metadata)
|
|
503
|
+
|
|
504
|
+
**Never includes:**
|
|
505
|
+
- Card shadows or scale/lift-on-hover
|
|
506
|
+
- Masonry layouts (Notebook's existing prohibition applies here too — a fixed-ratio grid only)
|
|
507
|
+
- The `--content-wide` container used for anything other than the grid itself
|
|
508
|
+
|
|
407
509
|
---
|
|
408
510
|
|
|
409
511
|
## Signature Moves
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remarque-tokens",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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.1.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@williamzujkowski/oklch-terminal-themes": "^0.1.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",
|
|
@@ -47,6 +59,8 @@
|
|
|
47
59
|
"./prose.css": "./prose.css",
|
|
48
60
|
"./print": "./print.css",
|
|
49
61
|
"./print.css": "./print.css",
|
|
62
|
+
"./agent-rules": "./AGENT_RULES.md",
|
|
63
|
+
"./spec": "./REMARQUE.md",
|
|
50
64
|
"./package.json": "./package.json"
|
|
51
65
|
},
|
|
52
66
|
"files": [
|
|
@@ -57,6 +71,7 @@
|
|
|
57
71
|
"fonts/",
|
|
58
72
|
"scripts/audit.mjs",
|
|
59
73
|
"scripts/drift-check.mjs",
|
|
74
|
+
"scripts/theme.mjs",
|
|
60
75
|
"tailwind.config.js",
|
|
61
76
|
"REMARQUE.md",
|
|
62
77
|
"AGENT_RULES.md",
|
|
@@ -0,0 +1,440 @@
|
|
|
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 (required — the
|
|
48
|
+
dataset has no light/dark pairing metadata yet, see
|
|
49
|
+
oklch-terminal-themes#128)
|
|
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 || !darkSlug) 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
|
+
const darkMeta = requireSlug(darkSlug, 'dark');
|
|
127
|
+
if (lightMeta.isDark !== false) die(`light slug "${lightSlug}" has isDark=${lightMeta.isDark}, expected false`);
|
|
128
|
+
if (darkMeta.isDark !== true) die(`dark slug "${darkSlug}" has isDark=${darkMeta.isDark}, expected true`);
|
|
129
|
+
|
|
130
|
+
function loadTheme(slug) {
|
|
131
|
+
// Safe: slug has already been matched against the package's index above.
|
|
132
|
+
const path = resolved.req.resolve(`${PKG_SPEC}/themes/${slug}.json`);
|
|
133
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
134
|
+
}
|
|
135
|
+
const themeLight = loadTheme(lightSlug);
|
|
136
|
+
const themeDark = loadTheme(darkSlug);
|
|
137
|
+
|
|
138
|
+
/* ── Numeric validation ───────────────────────────────────────────── */
|
|
139
|
+
|
|
140
|
+
const isNum = (x) => typeof x === 'number' && Number.isFinite(x);
|
|
141
|
+
|
|
142
|
+
function validateOklch(o, where) {
|
|
143
|
+
if (!o || !isNum(o.l) || o.l < 0 || o.l > 1) die(`${where}: invalid or missing OKLCH l (${o && o.l})`);
|
|
144
|
+
if (!isNum(o.c) || o.c < 0) die(`${where}: invalid or missing OKLCH c (${o && o.c})`);
|
|
145
|
+
if (!isNum(o.h) || o.h < 0 || o.h >= 360) die(`${where}: invalid or missing OKLCH h (${o && o.h})`);
|
|
146
|
+
return [o.l, o.c, o.h];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function slotLch(theme, slot, label) {
|
|
150
|
+
const entry = theme.colors && theme.colors[slot];
|
|
151
|
+
if (!entry || !entry.oklch) die(`${label}: missing colors.${slot}.oklch`);
|
|
152
|
+
return validateOklch(entry.oklch, `${label} colors.${slot}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/* ── OKLCH → sRGB → WCAG luminance (same Ottosson matrices as audit.mjs) */
|
|
156
|
+
|
|
157
|
+
function oklchToLinearSrgb(L, C, H) {
|
|
158
|
+
const h = (H * Math.PI) / 180;
|
|
159
|
+
const a = C * Math.cos(h), b = C * Math.sin(h);
|
|
160
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
161
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
162
|
+
const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
163
|
+
const l = l_ ** 3, m = m_ ** 3, s = s_ ** 3;
|
|
164
|
+
return [
|
|
165
|
+
+4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
166
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
167
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
const inGamut = (rgb) => rgb.every((x) => x >= -0.0005 && x <= 1.0005);
|
|
171
|
+
const clip = (x) => Math.min(1, Math.max(0, x));
|
|
172
|
+
const luminance = (rgb) => {
|
|
173
|
+
const [r, g, b] = rgb.map(clip);
|
|
174
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
175
|
+
};
|
|
176
|
+
function ratio(c1, c2) {
|
|
177
|
+
const l1 = luminance(oklchToLinearSrgb(...c1));
|
|
178
|
+
const l2 = luminance(oklchToLinearSrgb(...c2));
|
|
179
|
+
const [hi, lo] = l1 > l2 ? [l1, l2] : [l2, l1];
|
|
180
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const r3 = (x) => +x.toFixed(3);
|
|
184
|
+
const r1 = (x) => +x.toFixed(1);
|
|
185
|
+
|
|
186
|
+
/* Largest chroma <= C that stays in sRGB gamut at (L, H).
|
|
187
|
+
* Round-then-verify (fixes the spike's bug, see #75): stepping in
|
|
188
|
+
* unrounded space and only rounding the final answer can round a
|
|
189
|
+
* borderline-in-gamut value back OUT of gamut (float rounding is not
|
|
190
|
+
* gamut-aware). So after the coarse search we round, then keep backing
|
|
191
|
+
* off in already-rounded steps until the ROUNDED value itself verifies. */
|
|
192
|
+
function fitChroma(L, C, H) {
|
|
193
|
+
let c = Math.max(0, C);
|
|
194
|
+
while (c > 0 && !inGamut(oklchToLinearSrgb(L, c, H))) c -= 0.001;
|
|
195
|
+
c = Math.max(0, c);
|
|
196
|
+
let rounded = r3(c);
|
|
197
|
+
while (rounded > 0 && !inGamut(oklchToLinearSrgb(L, rounded, H))) rounded = r3(rounded - 0.001);
|
|
198
|
+
return Math.max(0, rounded);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/* Solve lightness so [L, C', H] hits >= target ratio against bg.
|
|
202
|
+
* dir 'darker': text on a light bg — find the MAX L that still passes
|
|
203
|
+
* (stays closest to the theme's own feel). dir 'lighter': text on a
|
|
204
|
+
* dark bg — find the MIN L. Chroma is clamped in-gamut INSIDE the
|
|
205
|
+
* solver at every candidate L, and the final [L, C, H] is rounded, then
|
|
206
|
+
* re-verified against the real (non-margin) target — nudging L further
|
|
207
|
+
* in the safe direction if rounding cost just enough ratio to fail. */
|
|
208
|
+
function solveLOnce(C, H, bg, target, dir) {
|
|
209
|
+
const searchTarget = target * 1.03; // margin over the audit threshold
|
|
210
|
+
let lo = 0, hi = 1;
|
|
211
|
+
for (let i = 0; i < 40; i++) {
|
|
212
|
+
const mid = (lo + hi) / 2;
|
|
213
|
+
const c = fitChroma(mid, C, H);
|
|
214
|
+
const passes = ratio([mid, c, H], bg) >= searchTarget;
|
|
215
|
+
if (dir === 'darker') { if (passes) lo = mid; else hi = mid; }
|
|
216
|
+
else { if (passes) hi = mid; else lo = mid; }
|
|
217
|
+
}
|
|
218
|
+
let L = r3(dir === 'darker' ? lo : hi);
|
|
219
|
+
let c = fitChroma(L, C, H);
|
|
220
|
+
let guard = 0;
|
|
221
|
+
while (ratio([L, c, H], bg) < target && guard < 100) {
|
|
222
|
+
const next = dir === 'darker' ? r3(L - 0.001) : r3(L + 0.001);
|
|
223
|
+
if (next < 0 || next > 1 || next === L) break;
|
|
224
|
+
L = next;
|
|
225
|
+
c = fitChroma(L, C, H);
|
|
226
|
+
guard++;
|
|
227
|
+
}
|
|
228
|
+
const ok = ratio([L, c, H], bg) >= target;
|
|
229
|
+
return { L, C: c, H: r1(H), ok };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function solveL(C, H, bg, target, dir) {
|
|
233
|
+
let r = solveLOnce(C, H, bg, target, dir);
|
|
234
|
+
if (r.ok) return [r.L, r.C, r.H];
|
|
235
|
+
// Chroma-collapse fallback: retry as a neutral if the chromatic
|
|
236
|
+
// solve can't reach the target ratio at any lightness.
|
|
237
|
+
r = solveLOnce(0, H, bg, target, dir);
|
|
238
|
+
if (r.ok) return [r.L, r.C, r.H];
|
|
239
|
+
die(
|
|
240
|
+
`internal error: cannot solve lightness for a ${target}:1 target ` +
|
|
241
|
+
`(hue ${H.toFixed(1)}, direction ${dir}) even with chroma collapsed to 0 — pathological theme colors`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* Accent hue: cursor if it's chromatic, else the most-chromatic classic ANSI. */
|
|
246
|
+
function accentHue(theme, label) {
|
|
247
|
+
const cursor = theme.colors && theme.colors.cursor && theme.colors.cursor.oklch;
|
|
248
|
+
if (cursor) {
|
|
249
|
+
const [, c, h] = validateOklch(cursor, `${label} colors.cursor`);
|
|
250
|
+
if (c >= 0.05) return { h, c, from: 'cursor' };
|
|
251
|
+
}
|
|
252
|
+
const names = ['blue', 'purple', 'red', 'green', 'cyan', 'yellow'];
|
|
253
|
+
const cands = [];
|
|
254
|
+
for (const name of names) {
|
|
255
|
+
const o = theme.colors && theme.colors[name] && theme.colors[name].oklch;
|
|
256
|
+
if (!o) continue;
|
|
257
|
+
const [, c, h] = validateOklch(o, `${label} colors.${name}`);
|
|
258
|
+
cands.push({ name, c, h });
|
|
259
|
+
}
|
|
260
|
+
if (!cands.length) die(`${label}: no classic ANSI colors available to derive an accent hue`);
|
|
261
|
+
cands.sort((a, b) => b.c - a.c);
|
|
262
|
+
return { h: cands[0].h, c: cands[0].c, from: cands[0].name };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function fmt(triple) {
|
|
266
|
+
const [L, C, H] = triple;
|
|
267
|
+
if (!isNum(L) || !isNum(C) || !isNum(H)) die('internal error: attempted to emit a non-finite color component');
|
|
268
|
+
return `oklch(${L} ${C} ${H})`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/* ── LIGHT derivation ─────────────────────────────────────────────── */
|
|
272
|
+
|
|
273
|
+
function deriveLight(t) {
|
|
274
|
+
const [bgL0, bgC0, bgH0] = slotLch(t, 'background', 'light');
|
|
275
|
+
const bgH = r1(bgH0);
|
|
276
|
+
const bgL = r3(Math.max(bgL0, 0.93));
|
|
277
|
+
const bgC = fitChroma(bgL, Math.min(bgC0, 0.02), bgH);
|
|
278
|
+
const bg = [bgL, bgC, bgH];
|
|
279
|
+
const surface = [r3(bgL - 0.01), bgC, bgH];
|
|
280
|
+
const [, fgC0, fgH] = slotLch(t, 'foreground', 'light');
|
|
281
|
+
const fgC = Math.min(fgC0, 0.03);
|
|
282
|
+
const fg = solveL(fgC, fgH, surface, 10, 'darker');
|
|
283
|
+
const fgMuted = solveL(fgC, fgH, bg, 7.0, 'darker');
|
|
284
|
+
const muted = solveL(Math.min(fgC0, 0.02), fgH, surface, 4.5, 'darker');
|
|
285
|
+
const borderBold = solveL(0.01, bgH, bg, 3.0, 'darker');
|
|
286
|
+
const ac = accentHue(t, 'light');
|
|
287
|
+
const acC = Math.min(ac.c, 0.14);
|
|
288
|
+
const accent = solveL(acC, ac.h, bg, 4.5, 'darker');
|
|
289
|
+
const accentHover = [r3(accent[0] - 0.08), fitChroma(r3(accent[0] - 0.08), acC * 0.8, ac.h), r1(ac.h)];
|
|
290
|
+
const raw = {
|
|
291
|
+
'color-bg': bg,
|
|
292
|
+
'color-bg-subtle': [r3(bg[0] - 0.02), bg[1], bg[2]],
|
|
293
|
+
'color-fg': fg,
|
|
294
|
+
'color-fg-muted': fgMuted,
|
|
295
|
+
'color-muted': muted,
|
|
296
|
+
'color-border': [r3(bg[0] - 0.095), bg[1], bg[2]],
|
|
297
|
+
'color-border-bold': borderBold,
|
|
298
|
+
'color-surface': surface,
|
|
299
|
+
'color-accent': accent,
|
|
300
|
+
'color-accent-hover': accentHover,
|
|
301
|
+
'color-accent-subtle': [0.95, fitChroma(0.95, 0.02, ac.h), r1(ac.h)],
|
|
302
|
+
'color-selection-bg': [0.92, fitChroma(0.92, 0.04, ac.h), r1(ac.h)],
|
|
303
|
+
'color-selection-fg': { ref: 'color-fg' },
|
|
304
|
+
'color-code-bg': [r3(bg[0] - 0.03), bg[1], bg[2]],
|
|
305
|
+
'color-code-fg': { ref: 'color-fg' },
|
|
306
|
+
};
|
|
307
|
+
const tokens = {};
|
|
308
|
+
for (const [name, v] of Object.entries(raw)) tokens[name] = v.ref ? `var(--${v.ref})` : fmt(v);
|
|
309
|
+
return { accentFrom: ac.from, accentH: ac.h, raw, tokens };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* ── DARK derivation ──────────────────────────────────────────────── */
|
|
313
|
+
|
|
314
|
+
function deriveDark(t, accentHueLight) {
|
|
315
|
+
const [bgL0, bgC0, bgH0] = slotLch(t, 'background', 'dark');
|
|
316
|
+
const bgH = r1(bgH0);
|
|
317
|
+
const bgL = r3(Math.min(Math.max(bgL0, 0.14), 0.22));
|
|
318
|
+
const bgC = fitChroma(bgL, Math.min(bgC0, 0.03), bgH);
|
|
319
|
+
const bg = [bgL, bgC, bgH];
|
|
320
|
+
const surface = [r3(bg[0] + 0.03), bgC, bgH];
|
|
321
|
+
const [, fgC0, fgH] = slotLch(t, 'foreground', 'dark');
|
|
322
|
+
const fgC = Math.min(fgC0, 0.02);
|
|
323
|
+
const fg = solveL(fgC, fgH, surface, 10, 'lighter');
|
|
324
|
+
const fgMuted = solveL(fgC, fgH, bg, 7.0, 'lighter');
|
|
325
|
+
const muted = solveL(fgC, fgH, surface, 4.5, 'lighter');
|
|
326
|
+
const borderBold = solveL(0.01, bgH, bg, 3.0, 'lighter');
|
|
327
|
+
const ac = accentHue(t, 'dark');
|
|
328
|
+
const h = accentHueLight ?? ac.h; // keep hue consistent across the pair
|
|
329
|
+
const acC = Math.min(ac.c, 0.12);
|
|
330
|
+
const accent = solveL(acC, h, bg, 4.5, 'lighter');
|
|
331
|
+
const accentHover = [r3(accent[0] + 0.07), fitChroma(r3(accent[0] + 0.07), acC, h), r1(h)];
|
|
332
|
+
const selBg = [r3(bg[0] + 0.14), fitChroma(r3(bg[0] + 0.14), 0.06, h), r1(h)];
|
|
333
|
+
const selFg = solveL(0.005, fgH, selBg, 4.5, 'lighter');
|
|
334
|
+
const codeBg = [r3(bg[0] + 0.04), bgC, bgH];
|
|
335
|
+
const codeFg = solveL(0.005, fgH, codeBg, 7, 'lighter');
|
|
336
|
+
const raw = {
|
|
337
|
+
'color-bg': bg,
|
|
338
|
+
'color-bg-subtle': surface,
|
|
339
|
+
'color-fg': fg,
|
|
340
|
+
'color-fg-muted': fgMuted,
|
|
341
|
+
'color-muted': muted,
|
|
342
|
+
'color-border': [r3(bg[0] + 0.09), bg[1], bg[2]],
|
|
343
|
+
'color-border-bold': borderBold,
|
|
344
|
+
'color-surface': surface,
|
|
345
|
+
'color-accent': accent,
|
|
346
|
+
'color-accent-hover': accentHover,
|
|
347
|
+
'color-accent-subtle': [r3(bg[0] + 0.06), fitChroma(r3(bg[0] + 0.06), 0.04, h), r1(h)],
|
|
348
|
+
'color-selection-bg': selBg,
|
|
349
|
+
'color-selection-fg': selFg,
|
|
350
|
+
'color-code-bg': codeBg,
|
|
351
|
+
'color-code-fg': codeFg,
|
|
352
|
+
};
|
|
353
|
+
const tokens = {};
|
|
354
|
+
for (const [name, v] of Object.entries(raw)) tokens[name] = fmt(v);
|
|
355
|
+
tokens['weight-display'] = '500';
|
|
356
|
+
return { raw, tokens };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const light = deriveLight(themeLight);
|
|
360
|
+
const dark = deriveDark(themeDark, light.accentH);
|
|
361
|
+
|
|
362
|
+
/* ── Self-verify — the same pairings + gamut rule as scripts/audit.mjs,
|
|
363
|
+
* run in-process against the raw [L,C,H] triples (mirrored here rather
|
|
364
|
+
* than reparsing the CSS we're about to emit). This should be
|
|
365
|
+
* impossible to fail; a failure means a bug in the derivation above,
|
|
366
|
+
* not a bad input theme, so it errors as an internal error. */
|
|
367
|
+
|
|
368
|
+
const CHECKS = [
|
|
369
|
+
['color-fg', 'color-bg', 4.5, 'primary text'],
|
|
370
|
+
['color-fg', 'color-surface', 4.5, 'primary text on surface'],
|
|
371
|
+
['color-fg-muted', 'color-bg', 7.0, 'secondary text (spec AAA line)'],
|
|
372
|
+
['color-muted', 'color-bg', 4.5, 'tertiary text'],
|
|
373
|
+
['color-muted', 'color-surface', 4.5, 'tertiary text on surface'],
|
|
374
|
+
['color-accent', 'color-bg', 4.5, 'links'],
|
|
375
|
+
['color-accent-hover', 'color-bg', 4.5, 'link hover'],
|
|
376
|
+
['color-code-fg', 'color-code-bg', 4.5, 'code text'],
|
|
377
|
+
['color-border-bold', 'color-bg', 3.0, 'functional borders (WCAG 1.4.11)'],
|
|
378
|
+
['color-selection-fg', 'color-selection-bg', 4.5, 'selected text'],
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
function resolveRaw(raw, name, seen = new Set()) {
|
|
382
|
+
if (seen.has(name)) die(`internal error: circular var() chain at --${name}`);
|
|
383
|
+
seen.add(name);
|
|
384
|
+
const v = raw[name];
|
|
385
|
+
if (v === undefined) die(`internal error: --${name} was never derived`);
|
|
386
|
+
return v.ref ? resolveRaw(raw, v.ref, seen) : v;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function selfVerify(themeName, raw) {
|
|
390
|
+
for (const [name, v] of Object.entries(raw)) {
|
|
391
|
+
const triple = v.ref ? resolveRaw(raw, name) : v;
|
|
392
|
+
if (!inGamut(oklchToLinearSrgb(...triple))) {
|
|
393
|
+
die(`internal error: derived --${name} (${themeName}) oklch(${triple.join(' ')}) is outside sRGB gamut`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const [fgName, bgName, min, label] of CHECKS) {
|
|
397
|
+
const fg = resolveRaw(raw, fgName);
|
|
398
|
+
const bg = resolveRaw(raw, bgName);
|
|
399
|
+
const r = ratio(fg, bg);
|
|
400
|
+
if (r < min) {
|
|
401
|
+
die(`internal error: derived --${fgName}/--${bgName} (${themeName}) = ${r.toFixed(2)}:1 < ${min}:1 (${label}) — this should be impossible; the derivation has a bug`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
selfVerify('light', light.raw);
|
|
406
|
+
selfVerify('dark', dark.raw);
|
|
407
|
+
|
|
408
|
+
/* ── Emit ─────────────────────────────────────────────────────────── */
|
|
409
|
+
|
|
410
|
+
const decls = (tokens, indent = ' ') => Object.entries(tokens).map(([k, v]) => `${indent}--${k}: ${v};`).join('\n');
|
|
411
|
+
|
|
412
|
+
const css = `/*
|
|
413
|
+
* Generated by remarque-theme (remarque-tokens ${OWN_VERSION}) — DO NOT HAND-EDIT.
|
|
414
|
+
* Regenerate instead: npx remarque-theme ${lightSlug} --dark ${darkSlug}
|
|
415
|
+
*
|
|
416
|
+
* Source themes (${PKG_SPEC}@${themesVersion}):
|
|
417
|
+
* light = "${lightSlug}" dark = "${darkSlug}"
|
|
418
|
+
* accent hue derived from: ${light.accentFrom} (H=${light.accentH.toFixed(1)})
|
|
419
|
+
*
|
|
420
|
+
* Palette tier only (--color-*, --weight-display) — see REMARQUE.md
|
|
421
|
+
* "Token Tiers" / "Color Providers". Self-verified against the same
|
|
422
|
+
* contrast + gamut checks as remarque-audit before being emitted.
|
|
423
|
+
*/
|
|
424
|
+
|
|
425
|
+
:root {
|
|
426
|
+
${decls(light.tokens)}
|
|
427
|
+
--weight-display: 400;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
[data-theme="dark"] {
|
|
431
|
+
${decls(dark.tokens)}
|
|
432
|
+
}
|
|
433
|
+
`;
|
|
434
|
+
|
|
435
|
+
if (outFile) {
|
|
436
|
+
writeFileSync(outFile, css);
|
|
437
|
+
console.error(`remarque-theme: wrote ${outFile}`);
|
|
438
|
+
} else {
|
|
439
|
+
process.stdout.write(css);
|
|
440
|
+
}
|
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.
|
|
5
|
+
"version": "0.9.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"
|