@brignano/design 0.2.0 → 0.3.1

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/DECISIONS.md CHANGED
@@ -248,3 +248,136 @@ over the existing state hues. No new hue enters the system.
248
248
  status share hues in ADR-0004.
249
249
  - Standalone exports (SVG files, Mermaid in a pull request) take the light theme's
250
250
  values, resolved from `tokens.css` by the consumer at build time.
251
+
252
+ ---
253
+
254
+ ## ADR-0006 — One declaration per colour, via `light-dark()`
255
+
256
+ **Status:** accepted · 2026-10-25
257
+
258
+ ### Context
259
+
260
+ Dark mode lived in three lists: a `prefers-color-scheme` block (the OS default),
261
+ a `[data-theme='dark']` block (the toggle, which has to win), and a copy in
262
+ `previews/_preview.css` so a preview could show both themes at once. CSS can't
263
+ share one declaration block between a media query and a selector, so the
264
+ duplication was structural, and the file's own rule — "edit one, edit the other"
265
+ — was enforced by remembering. The preview copy had already fallen behind
266
+ (`--i-selection`, `--scrim` and `--on-dark` were missing).
267
+
268
+ ### Decision
269
+
270
+ Every colour is declared once, on `:root`, as `light-dark(<light>, <dark>)`. The
271
+ theme is `color-scheme`: `light dark` by default, pinned by `[data-theme]`.
272
+
273
+ Arguments are always concrete colours, never a `var()` to another
274
+ theme-dependent token. Nested `light-dark()` — directly, or inside
275
+ `color-mix()` — is valid CSS, but support across engines couldn't be verified
276
+ when this was written, and getting it wrong would blank colours in one browser.
277
+ Where that forces a repeat (a wash repeats its hue, `--bg` repeats a ramp step),
278
+ `npm run check` holds the copy to its source.
279
+
280
+ Shadows differ in blur as well as colour, and `light-dark()` only switches
281
+ colour, so the two themes' shadows are separate layers, each transparent in the
282
+ other theme.
283
+
284
+ ### Rationale
285
+
286
+ - **Drift becomes impossible rather than checked.** There's no second list.
287
+ - **Theming becomes scoped for free.** The value is picked where a colour is
288
+ used, so `data-theme="dark"` on a `<section>` themes that section. The preview's
289
+ forced-dark panel is now `color-scheme: dark` and nothing else.
290
+ - **No change in output.** Verified in Chromium against the previous file: all
291
+ 119 tokens are identical in five setups (OS light, OS dark, a toggle forcing
292
+ each way, and both agreeing). The shadows and every preview page are
293
+ identical pixel for pixel.
294
+
295
+ ### Consequences
296
+
297
+ - Browser floor: Chrome/Edge 123, Firefox 120, Safari 17.5.
298
+ - `getPropertyValue('--x')` returns the `light-dark(…)` text. JavaScript that
299
+ needs a colour resolves it through `getComputedStyle` on an element
300
+ (DESIGN.md §11). ADR-0002's advice that `chart-colors.ts` read the custom
301
+ properties still stands, but by that route.
302
+ - Consumers shouldn't wrap tokens in `color-mix()`; washes are tokens. The
303
+ first consumer that needed one (`life`'s sticky bars) got `--surface-sticky`
304
+ in 0.3.1 rather than a local mix.
305
+ - A build that lowers `light-dark()` for older browsers (Lightning CSS, inside
306
+ Next.js and Vite) fixes the theme at `:root`, losing scoped theming. Found in
307
+ `brignano.io`; consumers set `browserslist` to the floor (DESIGN.md §11).
308
+ - Not a token rename and not a visual change, so a patch release.
309
+
310
+
311
+ ---
312
+
313
+ ## ADR-0007 — The neutral ramp is one temperature: OKLCH hue 286°
314
+
315
+ **Status:** accepted · 2026-10-25
316
+
317
+ ### Context
318
+
319
+ `tokens.css` called the ramp "cool-leaning", and most of it was: light 300–800
320
+ and dark 0–800 sit at OKLCH hue 286°. But light 50, 100 and 200 — the hairline,
321
+ `--line-soft`, the hover and pressed surfaces — sat at 106°, a faint yellow, and
322
+ dark 900, the primary ink, did too. A border and the label beside it disagreed
323
+ on temperature. It was too slight to notice as a colour, but enough to make a
324
+ surface read slightly dirty next to a cool one.
325
+
326
+ ### Decision
327
+
328
+ Every tinted step sits at hue 286°. The off-hue steps (light 25, 50, 100, 200,
329
+ 900; dark 800, 900) are regenerated at **their existing OKLCH lightness**, so
330
+ contrast stays within 0.04 of its old value. Their chroma fits the curve
331
+ already set by their neighbours: near zero at the ends, peaking in the
332
+ mid-tones. Steps already at 286° are untouched. Pure white stays pure white.
333
+ `npm run check` fails any step that leaves 286° ±3°.
334
+
335
+ ### Consequences
336
+
337
+ - A visible change, if a quiet one, so the release that carries it is a minor.
338
+ - The cool ground does the job the original comment gave it: the warm mark
339
+ and the amber attention state are the only warm things on a page.
340
+
341
+ ---
342
+
343
+ ## ADR-0008 — Cobalt is our own: OKLCH hue 256°, not Tailwind's blue
344
+
345
+ **Status:** accepted · 2026-10-25
346
+ **Amends:** ADR-0001 (the hue stays cobalt; the values change)
347
+
348
+ ### Context
349
+
350
+ ADR-0001 chose cobalt for two reasons: it's semantically inert, and it's
351
+ complementary to larch, so identity and affordance don't compete. The values
352
+ it shipped, `#2563eb` / `#1d4ed8` / `#1e40af` / `#1e3a8a` and `#60a5fa` /
353
+ `#93c5fd` / `#bfdbfe`, are exactly Tailwind's `blue-600…900` and `blue-300…400`,
354
+ and the sequential chart ramp was Tailwind's blues too. That undercut both the
355
+ second reason and DESIGN.md §1's "nothing here should read as a template":
356
+
357
+ - Tailwind's blue is at OKLCH 263–266°, leaning violet. Larch is ≈68°, so its
358
+ opposite is ≈248°. The stock values were 15° further from that than they
359
+ needed to be.
360
+ - It isn't even one hue. Across the ramp the stock steps wander from 251.8°
361
+ to 265.6°, so hover and pressed shift colour as well as depth.
362
+ - It is the single most recognisable colour in the Tailwind default theme.
363
+
364
+ ### Decision
365
+
366
+ Every cobalt step, interactive and sequential, sits at **OKLCH hue 256°**, and
367
+ `npm run check` holds it there ±3°. 256° is as near to larch's opposite as sRGB
368
+ allows while keeping cobalt's chroma (≈0.18 at the fill). At 248–252° the gamut
369
+ caps chroma near 0.14–0.16, which reads as steel or ocean blue, and links lose
370
+ some of the "you can act on this" that ADR-0001 chose blue for.
371
+
372
+ Each step's lightness was **solved for the contrast its predecessor met**, so
373
+ every ratio is within 0.07 of before: fill 5.18:1 under a white label, link
374
+ 6.78:1 on white, dark fill 7.07:1 on card. The sequential ramp keeps each
375
+ step's OKLCH lightness and stays monotonic.
376
+
377
+ ### Consequences
378
+
379
+ - The blue is deeper and calmer than Tailwind's electric violet-blue, and it is
380
+ one colour from fill to pressed. It still reads unmistakably as a link.
381
+ - A visible change, so the release that carries it is a minor.
382
+ - `--chart-1` is a separately validated categorical slot (ADR-0002) and is
383
+ unchanged.
package/DESIGN.md CHANGED
@@ -106,13 +106,18 @@ The part that makes an interface feel built rather than styled.
106
106
  - Focus is always visible: 2px `--i-ink` outline, 2px offset. Never remove it.
107
107
  - Hover and pressed surfaces on neutral elements come from `--surface-hover` and
108
108
  `--surface-active`, not from opacity tricks.
109
+ - A control's own edge — an input, a secondary button — is `--line-control`
110
+ (≥ 3:1). `--line-strong` is a divider, too faint to tell you a control is there.
111
+ - Native checkboxes, radios and ranges inherit `accent-color: var(--i)` from
112
+ `tokens.css`; don't restyle them just to recolour them.
109
113
 
110
114
  ## 5. Type
111
115
 
112
116
  Three roles, no more:
113
117
 
114
- - **`--sans` (Geist)** — body and headings, both tiers. Tabular figures (`tnum`) on
115
- body copy so dates, prices and countdowns align in a column.
118
+ - **`--sans` (Geist)** — body and headings, both tiers. Tabular figures
119
+ (`font-variant-numeric: tabular-nums`) on body copy so dates, prices and
120
+ countdowns align in a column.
116
121
  - **`--mono` (IBM Plex Mono)** — data that must read *precise*: confirmation codes,
117
122
  timestamps, stat numbers, eyebrow labels.
118
123
  - **`--display` (Silkscreen)** — marketing tier only. The `A|B` monogram and hero
@@ -130,6 +135,10 @@ for cards, `--radius-lg` for hero surfaces.
130
135
  a shadow (`--shadow-1/2/3`). Dark mode does not use borders — surfaces get
131
136
  *lighter* with elevation. This is why `--line` can stay nearly invisible in dark.
132
137
 
138
+ **Sticky chrome** — a header or toolbar that content scrolls under — is
139
+ `--surface-sticky` with `backdrop-filter: blur(8px)`: 92% of the card in light,
140
+ of the page ground in dark, so it reads as the surface it sits on, not a new layer.
141
+
133
142
  ## 7. Motion
134
143
 
135
144
  **Content is visible by default. Motion is progressive enhancement layered on top
@@ -193,22 +202,74 @@ is thirty coloured things, and the one that is down disappears among them.
193
202
  picture.
194
203
  - **Diagrams follow the theme.** On a page they use the tokens like everything
195
204
  else. A diagram exported as a standalone file (an SVG, a Mermaid block in a pull
196
- request) cannot use `var()`; it takes the **light** theme's values, resolved from
197
- this file at build time, never typed in.
205
+ request) cannot use `var()`; it takes the **light** theme's values — the first
206
+ argument of each `light-dark()` — resolved from this file at build time, never
207
+ typed in.
198
208
 
199
209
  ## 10. Accessibility floor
200
210
 
201
211
  Non-negotiable in both themes:
202
212
 
203
- - Body text ≥ 4.5:1; large text and UI marks ≥ 3:1.
213
+ - Body text ≥ 4.5:1; large text and UI marks ≥ 3:1. `--slate` is the quietest
214
+ text step and clears 4.5:1 in both themes; `--n-500` in light does not, so it
215
+ is never text.
204
216
  - Touch targets ≥ 44px on coarse pointers, with a `.touch-hit` opt-out for controls
205
217
  whose shape is meaningful (a 22px circular checkbox forced to 44px becomes an
206
218
  oval — it expands its hit area instead).
207
219
  - Every state carries an icon and a word.
208
- - Measured ratios sit next to their values in `tokens.css`. Changing a value means
209
- re-measuring it.
220
+ - Measured ratios sit next to their values in `tokens.css`, and `npm run check`
221
+ (run in CI on every pull request) fails if any floor above is missed, if a colour
222
+ is declared anywhere but once, or if a hex appears outside the token files.
223
+
224
+ ## 11. Themes, and integrating with a Tailwind consumer
225
+
226
+ ### How the theme is chosen
227
+
228
+ Every colour is one declaration holding both values, `light-dark(<light>, <dark>)`,
229
+ and the browser picks a side from the element's `color-scheme`:
230
+
231
+ - **No attribute** — `color-scheme: light dark`, so the page follows the OS.
232
+ - **`data-theme="light"` / `"dark"`** — the explicit choice, which wins over the OS.
233
+
234
+ The pick happens where a colour is *used*, so **`data-theme` works on any element,
235
+ not just `<html>`.** A dark hero band on a light marketing page is
236
+ `<section data-theme="dark">`. Give that element its own
237
+ `background: var(--bg); color: var(--ink)` — backgrounds don't inherit.
238
+
239
+ Two consequences for code that touches tokens directly:
240
+
241
+ - **Don't read a token's value with `getPropertyValue`.** It returns the
242
+ declaration text, `light-dark(#076bd3, #6ba5f3)`, not a colour. To get the
243
+ colour a chart library needs, resolve it on an element in the right subtree:
244
+ set `el.style.color = 'var(--chart-1)'` and read `getComputedStyle(el).color`.
245
+ Re-read it when the theme changes.
246
+ - **Don't wrap a token in `color-mix()` or `light-dark()`.** Nesting is valid CSS,
247
+ but not every engine has been verified to resolve it. If you need a wash, it's
248
+ a token (`--*-surface`, `--*-line`, `--surface-sticky`); if it isn't, add it here.
249
+
250
+ Needs Chrome/Edge 123, Firefox 120, Safari 17.5 or later (all shipped by May 2024).
251
+ An older browser drops every colour token and renders unstyled rather than wrong.
252
+
253
+ ### Build tools that lower `light-dark()`
254
+
255
+ A CSS pipeline that targets browsers older than that floor rewrites
256
+ `light-dark()` into a fallback. Lightning CSS (inside Next.js, Vite, Parcel)
257
+ turns each token into `var(--lightningcss-light, …) var(--lightningcss-dark, …)`
258
+ and flips those two variables wherever `color-scheme` changes. The tokens
259
+ resolve on `:root`, so the side is fixed there. Page-wide theming still works,
260
+ but **`data-theme` on an inner element stops re-theming that section.**
261
+
262
+ The fix is to target the same floor the tokens need, which leaves `light-dark()`
263
+ alone. In the consumer's `package.json`:
264
+
265
+ ```json
266
+ "browserslist": ["chrome >= 123", "edge >= 123", "firefox >= 120", "safari >= 17.5"]
267
+ ```
268
+
269
+ Verified with Lightning CSS: older targets lower the tokens; these targets
270
+ pass `light-dark(#076bd3, #6ba5f3)` through untouched.
210
271
 
211
- ## 11. Integrating with a Tailwind consumer
272
+ ### Tailwind
212
273
 
213
274
  Tailwind's dark variant keys off a `.dark` class; these tokens key off
214
275
  `[data-theme]`. **Both must be set together**, by the same code, in both places
@@ -223,11 +284,11 @@ Setting only the class is a silent, half-broken state: Tailwind utilities flip
223
284
  but the tokens do not, and the page renders one theme's tokens under the other
224
285
  theme's utilities. Setting only the attribute leaves every `dark:` utility dead.
225
286
 
226
- The reason the attribute is required rather than relying on
227
- `prefers-color-scheme`: a site with a manual toggle has already *resolved* the
228
- OS preference into an explicit choice. If the tokens also consulted the media
229
- query, a user on a dark OS who chose light would get dark tokens under light
230
- utilities. `[data-theme]` is what makes the choice authoritative.
287
+ The reason the attribute is required rather than leaving the OS to decide: a
288
+ site with a manual toggle has already *resolved* the OS preference into an
289
+ explicit choice. Without the attribute, the tokens would still follow the OS, and
290
+ a user on a dark OS who chose light would get dark tokens under light utilities.
291
+ `[data-theme]` is what makes the choice authoritative.
231
292
 
232
293
  Import order matters — Tailwind first, then tokens, then the bridge:
233
294
 
package/README.md CHANGED
@@ -15,11 +15,11 @@ The shared design system for every brignano surface — `brignano.io`, `life`,
15
15
 
16
16
  Three layers, so no colour does two jobs:
17
17
 
18
- - **`--brand-*`** — identity. Larch amber. The trail, the monogram. Never a control.
18
+ - **`--mark`** — identity. Larch amber. Inks the trail and the monogram. Never a control.
19
19
  - **`--i-*`** — affordance. Cobalt. Links, buttons, focus, selection. Semantically inert.
20
- - **`--ok / --warn / --err / --info`** — state. Conventional hues, always with an icon and a word.
20
+ - **`--success / --attention / --danger`** — state. Conventional hues, always with an icon and a word.
21
21
 
22
- Distinctiveness lives in the brand layer; conventionality lives in the control layer.
22
+ Distinctiveness lives in the mark; conventionality lives in the control layer.
23
23
 
24
24
  ## Consuming it
25
25
 
@@ -32,6 +32,11 @@ npm install @brignano/design
32
32
  @import '@brignano/design/tokens.chart.css'; /* only if the surface has charts */
33
33
  ```
34
34
 
35
+ Light and dark follow the OS until something sets `data-theme="light"` or
36
+ `"dark"` — on `<html>` for the whole page, or on any element to theme just that
37
+ part (DESIGN.md §11). Each colour is a single `light-dark()` declaration, so this
38
+ needs a browser from mid-2024 or later.
39
+
35
40
  The tool tier is the default. A marketing surface opts in with
36
41
  `class="tier-marketing"` on `<html>`, which unlocks the display face and opens the
37
42
  type scale. Colour is identical across tiers.
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@brignano/design",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Shared design system for brignano surfaces — colour, type, space, shape, motion.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/brignano/design.git"
9
9
  },
10
+ "scripts": {
11
+ "check": "node scripts/check.mjs"
12
+ },
10
13
  "publishConfig": {
11
14
  "access": "public",
12
15
  "provenance": true
package/tailwind.css CHANGED
@@ -7,7 +7,10 @@
7
7
 
8
8
  `@theme inline` is load-bearing: it makes the generated utilities emit
9
9
  `var(--i)` rather than baking a literal at build time, so `bg-interactive`
10
- follows the light/dark switch instead of freezing one theme's value. */
10
+ follows the light/dark switch instead of freezing one theme's value.
11
+
12
+ The bare `--radius` key is what drives `rounded`. Tailwind v4 has no
13
+ `-DEFAULT` suffix; with one, `rounded` silently stayed at 0.25rem. */
11
14
  @theme inline {
12
15
  --color-ink: var(--ink);
13
16
  --color-ink-soft: var(--ink-soft);
@@ -15,26 +18,48 @@
15
18
  --color-bg: var(--bg);
16
19
  --color-card: var(--card);
17
20
  --color-line: var(--line);
21
+ --color-line-soft: var(--line-soft);
18
22
  --color-line-strong: var(--line-strong);
23
+ --color-line-control: var(--line-control);
19
24
  --color-surface-hover: var(--surface-hover);
20
25
  --color-surface-active: var(--surface-active);
26
+ --color-surface-sticky: var(--surface-sticky);
27
+ --color-on-dark: var(--on-dark);
21
28
 
22
29
  --color-interactive: var(--i);
23
30
  --color-interactive-ink: var(--i-ink);
24
31
  --color-interactive-hover: var(--i-hover);
25
32
  --color-interactive-pressed: var(--i-pressed);
26
33
  --color-interactive-surface: var(--i-surface);
34
+ --color-interactive-line: var(--i-line);
27
35
  --color-on-interactive: var(--on-i);
28
36
 
29
37
  --color-success: var(--success);
30
38
  --color-success-ink: var(--success-ink);
31
39
  --color-success-surface: var(--success-surface);
40
+ --color-success-line: var(--success-line);
32
41
  --color-attention: var(--attention);
33
42
  --color-attention-ink: var(--attention-ink);
34
43
  --color-attention-surface: var(--attention-surface);
44
+ --color-attention-line: var(--attention-line);
35
45
  --color-danger: var(--danger);
36
46
  --color-danger-ink: var(--danger-ink);
37
47
  --color-danger-surface: var(--danger-surface);
48
+ --color-danger-line: var(--danger-line);
49
+ --color-info: var(--info);
50
+ --color-info-ink: var(--info-ink);
51
+ --color-info-surface: var(--info-surface);
52
+ --color-info-line: var(--info-line);
53
+
54
+ --color-diff-added: var(--diff-added);
55
+ --color-diff-added-ink: var(--diff-added-ink);
56
+ --color-diff-added-surface: var(--diff-added-surface);
57
+ --color-diff-changed: var(--diff-changed);
58
+ --color-diff-changed-ink: var(--diff-changed-ink);
59
+ --color-diff-changed-surface: var(--diff-changed-surface);
60
+ --color-diff-removed: var(--diff-removed);
61
+ --color-diff-removed-ink: var(--diff-removed-ink);
62
+ --color-diff-removed-surface: var(--diff-removed-surface);
38
63
 
39
64
  --color-mark: var(--mark);
40
65
  --color-mark-ink: var(--mark-ink);
@@ -44,7 +69,7 @@
44
69
  --font-display: var(--display);
45
70
 
46
71
  --radius-sm: var(--radius-sm);
47
- --radius-DEFAULT: var(--radius);
72
+ --radius: var(--radius);
48
73
  --radius-lg: var(--radius-lg);
49
74
 
50
75
  --shadow-1: var(--shadow-1);
package/tokens.chart.css CHANGED
@@ -4,10 +4,10 @@
4
4
  WHY THIS IS A SEPARATE FILE FROM THE BRAND ACCENT
5
5
  -------------------------------------------------
6
6
  A chart's colours do a different job from a brand's colours, so they are
7
- not derived from the accent and they are not the status ramp.
7
+ not derived from the mark and they are not the state hues.
8
8
 
9
- brand accent (--accent) identity. One hue. "This is Anthony."
10
- status (--forest/rust) state. RESERVED — never a series colour.
9
+ mark (--mark) identity. One hue. "This is Anthony."
10
+ state (--success/…) state. RESERVED — never a series colour.
11
11
  categorical (this file) IDENTITY OF A SERIES. Needs 8 hues that a
12
12
  human can tell apart at a glance, including
13
13
  the ~8% of men with a colour-vision
@@ -19,6 +19,7 @@
19
19
 
20
20
  THESE VALUES ARE VALIDATED, NOT CHOSEN BY EYE
21
21
  ----------------------------------------------
22
+ Values are light-dark(<light>, <dark>), one declaration each.
22
23
  Both modes pass every hard gate of the six-check colour formula:
23
24
 
24
25
  lightness band PASS (all 8 inside the mode's band)
@@ -53,14 +54,14 @@
53
54
  :root {
54
55
  --chart-surface: var(--card);
55
56
 
56
- --chart-1: #2a78d6; /* blue */
57
- --chart-2: #eb6834; /* orange */
58
- --chart-3: #1baf7a; /* aqua — relief required (2.74:1) */
59
- --chart-4: #eda100; /* yellow — relief required (2.11:1) */
60
- --chart-5: #e87ba4; /* magenta — relief required (2.62:1) */
61
- --chart-6: #008300; /* green */
62
- --chart-7: #4a3aa7; /* violet */
63
- --chart-8: #e34948; /* red */
57
+ --chart-1: light-dark(#2a78d6, #3987e5); /* blue */
58
+ --chart-2: light-dark(#eb6834, #d95926); /* orange */
59
+ --chart-3: light-dark(#1baf7a, #199e70); /* aqua — relief required (2.74:1) */
60
+ --chart-4: light-dark(#eda100, #c98500); /* yellow — relief required (2.11:1) */
61
+ --chart-5: light-dark(#e87ba4, #d55181); /* magenta — relief required (2.62:1) */
62
+ --chart-6: #008300; /* green — same in both */
63
+ --chart-7: light-dark(#4a3aa7, #9085e9); /* violet */
64
+ --chart-8: light-dark(#e34948, #e66767); /* red */
64
65
 
65
66
  /* Everything past slot 8 aggregates here. Deliberately neutral: "Other"
66
67
  is not an entity, so it does not earn a hue. */
@@ -79,9 +80,9 @@
79
80
  Rules that differ from the categorical palette above:
80
81
  - ONE hue. Never a rainbow — a rainbow implies categories where there
81
82
  are only degrees.
82
- - Lightness must be MONOTONIC. Verified: light L 0.838 -> 0.107
83
- descending, dark L 0.015 -> 0.532 ascending, every adjacent step
84
- separated by dL >= 0.037.
83
+ - Lightness must be MONOTONIC. Verified in OKLCH: light L 0.888 ->
84
+ 0.487 descending, dark L 0.380 -> 0.808 ascending, every adjacent
85
+ step separated by dL >= 0.107.
85
86
  - `--seq-0` is NEUTRAL on purpose. Zero contributions is not a small
86
87
  amount of the hue, it is the absence of a value — colouring it a pale
87
88
  blue would claim data that isn't there.
@@ -91,44 +92,8 @@
91
92
  it its own hue would add a meaning the system doesn't need.
92
93
  ───────────────────────────────────────────────────────────────────────── */
93
94
  --seq-0: var(--line);
94
- --seq-1: #c7dcfb;
95
- --seq-2: #8ab4f4;
96
- --seq-3: #3b82f6;
97
- --seq-4: #1d4ed8;
98
- }
99
-
100
- @media (prefers-color-scheme: dark) {
101
- :root:not([data-theme='light']) {
102
- --chart-1: #3987e5;
103
- --chart-2: #d95926;
104
- --chart-3: #199e70;
105
- --chart-4: #c98500;
106
- --chart-5: #d55181;
107
- --chart-6: #008300;
108
- --chart-7: #9085e9;
109
- --chart-8: #e66767;
110
-
111
- --seq-0: var(--line);
112
- --seq-1: #1e3a8a;
113
- --seq-2: #1d4ed8;
114
- --seq-3: #3b82f6;
115
- --seq-4: #93c5fd;
116
- }
117
- }
118
-
119
- :root[data-theme='dark'] {
120
- --chart-1: #3987e5;
121
- --chart-2: #d95926;
122
- --chart-3: #199e70;
123
- --chart-4: #c98500;
124
- --chart-5: #d55181;
125
- --chart-6: #008300;
126
- --chart-7: #9085e9;
127
- --chart-8: #e66767;
128
-
129
- --seq-0: var(--line);
130
- --seq-1: #1e3a8a;
131
- --seq-2: #1d4ed8;
132
- --seq-3: #3b82f6;
133
- --seq-4: #93c5fd;
95
+ --seq-1: light-dark(#c6dcfb, #014084);
96
+ --seq-2: light-dark(#87b5f3, #015cbb);
97
+ --seq-3: #2885f5;
98
+ --seq-4: light-dark(#015cbb, #98c3fe);
134
99
  }
package/tokens.css CHANGED
@@ -36,49 +36,107 @@
36
36
  1. NEVER hardcode a hex outside this file. A literal survives a theme
37
37
  change silently — that is how `life`'s status pills sat light grey in
38
38
  dark mode for months.
39
- 2. Every colour is declared in BOTH theme scopes. CSS cannot share a
40
- declaration block between a media query and a plain selector, so the
41
- dark list is duplicated on purpose. Edit one, edit the other.
39
+ 2. Every colour is ONE declaration holding both themes:
40
+ `light-dark(<light>, <dark>)`. There is no dark list to keep in step.
41
+ The arguments are always concrete colours, never a var() to another
42
+ theme-dependent token — nested light-dark() is legal CSS, but not
43
+ every engine has been verified to resolve it. Where that forces a
44
+ value to repeat (a wash repeats its hue), `npm run check` holds the
45
+ copies to their source.
42
46
  3. Ratios in the comments are MEASURED. Change a value, re-measure it.
43
47
 
44
48
  See DESIGN.md for the rules that govern use, DECISIONS.md for why.
45
49
  ───────────────────────────────────────────────────────────────────────── */
46
50
 
51
+ /* ── Themes ─────────────────────────────────────────────────────────────
52
+ One declaration per colour, light value first: light-dark(<light>, <dark>).
53
+ The browser picks the side from the element's color-scheme:
54
+
55
+ default follows the OS (light dark below)
56
+ [data-theme] the in-app toggle, which must WIN over the OS
57
+ — `light` forces light under a dark OS
58
+
59
+ Because the pick happens where a colour is USED, not where it's declared,
60
+ [data-theme] (or color-scheme) on ANY element themes that subtree — a dark
61
+ hero band on a light page is one attribute. Give that element its own
62
+ `background: var(--bg)`; it doesn't inherit the page's.
63
+
64
+ Dark is selected, not inverted:
65
+ 1. Not pure black. #0d0d0f — pure black behind bright text causes
66
+ halation and is fatiguing at night, which is when a personal tool
67
+ actually gets opened.
68
+ 2. Surfaces LIGHTEN with elevation instead of gaining borders.
69
+ 3. Accents lift in luminance rather than shifting hue, and washes carry
70
+ a higher mix — 12% over #0d0d0f is invisible where it reads on white.
71
+
72
+ Ratios are given light / dark. */
47
73
  :root {
74
+ color-scheme: light dark;
75
+ }
76
+ [data-theme='light'] {
48
77
  color-scheme: light;
78
+ }
79
+ [data-theme='dark'] {
80
+ color-scheme: dark;
81
+ }
49
82
 
83
+ :root {
50
84
  /* ── Neutral ramp ─────────────────────────────────────────────────────
51
85
  Twelve steps, up from five. The gap that mattered most: there was no
52
86
  hover or pressed background token, so every interactive surface in
53
87
  both repos was improvising.
54
88
 
55
- Deliberately COOL-leaning — a cool grey lets the warm mark and the
56
- amber attention state read as warm, instead of muddying into them. */
57
- --n-0: #ffffff;
58
- --n-25: #fafafa;
59
- --n-50: #f3f3f1;
60
- --n-100: #ececea; /* hairline */
61
- --n-200: #e2e2e0; /* hover surface */
62
- --n-300: #cbcbce; /* strong border, pressed surface */
63
- --n-400: #a9a9ae;
64
- --n-500: #8a8a8f; /* muted label 3.44:1 — large text / UI marks only */
65
- --n-600: #6b6b73; /* 5.28:1 */
66
- --n-700: #52525b; /* secondary 7.73:1 */
67
- --n-800: #33333a;
68
- --n-900: #111111; /* primary 18.88:1 */
69
-
70
- /* Semantic aliases. Components use THESE, not the raw steps. */
71
- --bg: var(--n-25);
72
- --card: var(--n-0);
89
+ Deliberately COOL — a cool grey lets the warm mark and the amber
90
+ attention state read as warm, instead of muddying into them.
91
+
92
+ ONE TEMPERATURE, top to bottom, both themes: every tinted step sits at
93
+ OKLCH hue 286° (±3°, `npm run check` holds it). Chroma is a whisper at
94
+ the ends (~0.0015 at n-25) and peaks in the mid-tones (~0.015 at
95
+ n-700), where a grey reads most neutral-flat without it. Only pure
96
+ white (light n-0) is untinted. An earlier ramp mixed warm hairlines
97
+ (106°) with cool text, so a border and the label beside it disagreed.
98
+
99
+ Changing a step: keep its OKLCH lightness, keep the hue, re-run the
100
+ check. */
101
+ --n-0: light-dark(#ffffff, #0d0d0f);
102
+ --n-25: light-dark(#fafafb, #131316);
103
+ --n-50: light-dark(#f3f3f4, #17171a);
104
+ --n-100: light-dark(#ececee, #202024); /* hairline */
105
+ --n-200: light-dark(#e1e1e4, #2a2a2f); /* hover surface */
106
+ --n-300: light-dark(#cbcbce, #3a3a41); /* strong border, pressed surface */
107
+ --n-400: light-dark(#a9a9ae, #55555e);
108
+ --n-500: light-dark(#8a8a8f, #8b8b93); /* 3.44:1 — UI marks only, never text / 5.29:1 */
109
+ --n-600: light-dark(#6b6b73, #9c9ca4); /* muted label 5.28:1 */
110
+ --n-700: light-dark(#52525b, #a8a8b0); /* secondary 7.73:1 / 7.58:1 */
111
+ --n-800: light-dark(#33333a, #d0d0d5);
112
+ --n-900: light-dark(#111114, #f2f2f4); /* primary 18.85:1 / 16.00:1 */
113
+
114
+ /* Semantic aliases. Components use THESE, not the raw steps.
115
+ Where a role sits on a different step in each theme, the step's two
116
+ hexes are written out (rule 2) and named in the comment; the check
117
+ holds them to the ramp. */
118
+ --bg: light-dark(#fafafb, #0d0d0f); /* n-25 / n-0 */
119
+ --card: light-dark(#ffffff, #17171a); /* n-0 / n-50 */
73
120
  --surface-hover: var(--n-100);
74
121
  --surface-active: var(--n-200);
122
+ /* Sticky chrome — a header or toolbar that content scrolls under, usually
123
+ with `backdrop-filter: blur()`. 92% of the surface it sits on: the card
124
+ in light, the page ground in dark, where cards are lighter than the
125
+ page. A token so consumers never color-mix() one (DESIGN.md §11). */
126
+ --surface-sticky: light-dark(color-mix(in srgb, #ffffff 92%, transparent), color-mix(in srgb, #0d0d0f 92%, transparent)); /* card / bg */
75
127
  --ink: var(--n-900);
76
128
  --ink-soft: var(--n-700);
77
- --slate: var(--n-500);
129
+ --slate: light-dark(#6b6b73, #8b8b93); /* n-600 / n-500 — text-safe: eyebrows, axis labels, captions */
78
130
  --line: var(--n-100);
79
131
  --line-soft: var(--n-50);
80
- --line-strong: var(--n-300);
81
- --on-dark: var(--n-25);
132
+ --line-strong: var(--n-300); /* 1.62:1 — decoration, not a boundary */
133
+ /* The edge that tells you a control is there: inputs, secondary buttons.
134
+ WCAG 1.4.11 asks 3:1 of a boundary you need to find the control, which
135
+ --line-strong is nowhere near. Light is n-500 (3.44:1 on card, 3.29:1
136
+ on bg); dark sits between n-400 and n-500, which the ramp has no step
137
+ for (3.39:1 on card, 3.68:1 on bg). */
138
+ --line-control: light-dark(#8a8a8f, #6b6b73);
139
+ --on-dark: light-dark(#fafafb, #0d0d0f); /* n-25 / n-0 */
82
140
 
83
141
  /* ── Interactive — cobalt ─────────────────────────────────────────────
84
142
  Everything you can act on: links, buttons, focus, selection. Chosen to
@@ -86,21 +144,28 @@
86
144
  green and red do — and complementary to the mark hue, so identity and
87
145
  affordance never compete for the same attention.
88
146
 
89
- --i fill 5.17:1 (white label 5.17:1)
90
- --i-ink link/icon/focus 6.70:1 on #fff
91
- --i-hover hover fill 10.36:1
92
- --i-pressed pressed fill 12.65:1
147
+ Every step sits at OKLCH hue 256° (`npm run check` holds it), as near
148
+ to larch's opposite (~248°) as sRGB allows while keeping cobalt's
149
+ chroma. It is deliberately NOT Tailwind's blue-600 (263°, a touch
150
+ violet and electric): same affordance, our own colour (ADR-0008).
151
+
152
+ --i fill 5.18:1 / 7.07:1 (label 5.18 / 7.46)
153
+ --i-ink link/icon/focus 6.78:1 / 7.07:1
154
+ --i-hover hover fill 8.65:1 / 10.50:1 (label)
155
+ --i-pressed pressed fill 10.44:1 / 13.32:1 (label)
156
+
157
+ In dark, fill and ink converge, and hover/pressed get LIGHTER.
93
158
 
94
159
  DISABLED IS NEUTRAL, NEVER HUED — a greyed-out control must stop
95
160
  signalling "click me". */
96
- --i: #2563eb;
97
- --i-ink: #1d4ed8;
98
- --i-hover: #1e40af;
99
- --i-pressed: #1e3a8a;
100
- --i-surface: color-mix(in srgb, var(--i) 12%, transparent);
101
- --i-line: color-mix(in srgb, var(--i) 32%, transparent);
102
- --i-selection: color-mix(in srgb, var(--i) 26%, transparent);
103
- --on-i: #ffffff;
161
+ --i: light-dark(#076bd3, #6ba5f3);
162
+ --i-ink: light-dark(#0159b5, #6ba5f3);
163
+ --i-hover: light-dark(#014a97, #98c4ff);
164
+ --i-pressed: light-dark(#003e82, #c0dbff);
165
+ --i-surface: light-dark(color-mix(in srgb, #076bd3 12%, transparent), color-mix(in srgb, #6ba5f3 20%, transparent));
166
+ --i-line: light-dark(color-mix(in srgb, #076bd3 32%, transparent), color-mix(in srgb, #6ba5f3 44%, transparent));
167
+ --i-selection: light-dark(color-mix(in srgb, #076bd3 26%, transparent), color-mix(in srgb, #6ba5f3 30%, transparent));
168
+ --on-i: light-dark(#ffffff, #111111);
104
169
  --i-disabled-bg: var(--n-200);
105
170
  --i-disabled-ink: var(--n-400);
106
171
 
@@ -109,31 +174,32 @@
109
174
  tiers. Always shipped with an ICON AND A WORD — colour is never the
110
175
  only signal.
111
176
 
112
- --success settled · confirmed · done · succeeded 6.29:1
113
- --attention now · next · warning · needs you 6.08:1
114
- --danger overdue · failed · invalid 6.10:1
177
+ --success settled · confirmed · done · succeeded 6.29:1 / 8.15:1
178
+ --attention now · next · warning · needs you 6.08:1 / 8.17:1 (ink)
179
+ --danger overdue · failed · invalid 6.10:1 / 6.06:1
115
180
 
116
181
  `--info` is the interactive hue on purpose: an informational message
117
182
  is the same "neutral, no action implied" register as a link.
118
183
 
119
184
  `-ink` is the text-safe step; the bare token is for marks and fills.
120
- `-surface` and `-line` are the wash and its edge. */
121
- --success: #2f6b4f;
122
- --success-ink: #245740;
123
- --attention: #b4741f; /* 3.85:1 — mark/fill only, NOT text */
124
- --attention-ink: #8a5714; /* 6.08:1 */
125
- --danger: #a4442c;
126
- --danger-ink: #8a3722;
185
+ `-surface` and `-line` are the wash and its edge: 12% / 32% of the hue
186
+ in light, 20% / 44% in dark. */
187
+ --success: light-dark(#2f6b4f, #6fbf95);
188
+ --success-ink: light-dark(#245740, #8fd4ae);
189
+ --attention: light-dark(#b4741f, #e0a44f); /* 3.85:1 in light — mark/fill only, NOT text */
190
+ --attention-ink: light-dark(#8a5714, #e0a44f); /* 6.08:1 / 8.17:1 — mark and label converge in dark */
191
+ --danger: light-dark(#a4442c, #e07a5f);
192
+ --danger-ink: light-dark(#8a3722, #ec9880);
127
193
  --info: var(--i);
128
194
  --info-ink: var(--i-ink);
129
195
 
130
- --success-surface: color-mix(in srgb, var(--success) 12%, transparent);
131
- --attention-surface: color-mix(in srgb, var(--attention) 12%, transparent);
132
- --danger-surface: color-mix(in srgb, var(--danger) 12%, transparent);
196
+ --success-surface: light-dark(color-mix(in srgb, #2f6b4f 12%, transparent), color-mix(in srgb, #6fbf95 20%, transparent));
197
+ --attention-surface: light-dark(color-mix(in srgb, #b4741f 12%, transparent), color-mix(in srgb, #e0a44f 20%, transparent));
198
+ --danger-surface: light-dark(color-mix(in srgb, #a4442c 12%, transparent), color-mix(in srgb, #e07a5f 20%, transparent));
133
199
  --info-surface: var(--i-surface);
134
- --success-line: color-mix(in srgb, var(--success) 32%, transparent);
135
- --attention-line: color-mix(in srgb, var(--attention) 32%, transparent);
136
- --danger-line: color-mix(in srgb, var(--danger) 32%, transparent);
200
+ --success-line: light-dark(color-mix(in srgb, #2f6b4f 32%, transparent), color-mix(in srgb, #6fbf95 44%, transparent));
201
+ --attention-line: light-dark(color-mix(in srgb, #b4741f 32%, transparent), color-mix(in srgb, #e0a44f 44%, transparent));
202
+ --danger-line: light-dark(color-mix(in srgb, #a4442c 32%, transparent), color-mix(in srgb, #e07a5f 44%, transparent));
137
203
  --info-line: var(--i-line);
138
204
 
139
205
  /* ── Diff ─────────────────────────────────────────────────────────────
@@ -144,8 +210,7 @@
144
210
  --diff-changed modified by this change interactive — informational
145
211
  --diff-removed removed by this change danger — can lose data
146
212
 
147
- Always with a symbol and a word (+ added, ~ changed, − removed). Aliased
148
- in this block only: these resolve against whichever theme block wins. */
213
+ Always with a symbol and a word (+ added, ~ changed, − removed). */
149
214
  --diff-added: var(--success);
150
215
  --diff-added-ink: var(--success-ink);
151
216
  --diff-added-surface: var(--success-surface);
@@ -168,21 +233,21 @@
168
233
  the attention state are the same warmth, and because --mark never
169
234
  appears as a UI surface the two can never be mistaken for each other
170
235
  in the same context. */
171
- --mark: #b4741f;
172
- --mark-ink: #8a5714; /* when the mark carries a label, 6.08:1 */
236
+ --mark: light-dark(#b4741f, #e0a44f);
237
+ --mark-ink: light-dark(#8a5714, #e0a44f); /* when the mark carries a label, 6.08:1 / 8.17:1 */
173
238
 
174
239
  /* ── Legacy aliases ───────────────────────────────────────────────────
175
240
  Kept so existing `life` components keep compiling. Do not use in new
176
241
  work — reach for the semantic tokens above.
177
242
 
178
- --larch-deep is declared explicitly in BOTH themes rather than
179
- aliased, because "deep" inverts: darker in light, BRIGHTER in dark. An
180
- alias to either role silently changes one theme — it moved
181
- .pill-active from #f0bd74 to #e0a44f before this was caught. */
243
+ --larch-deep is written out rather than aliased, because "deep"
244
+ inverts: darker in light, BRIGHTER in dark. An alias to either role
245
+ silently changes one theme — it moved .pill-active from #f0bd74 to
246
+ #e0a44f before this was caught. */
182
247
  --forest: var(--success);
183
248
  --forest-deep: var(--success-ink);
184
249
  --larch: var(--attention);
185
- --larch-deep: #8a5714;
250
+ --larch-deep: light-dark(#8a5714, #f0bd74);
186
251
  --rust: var(--danger);
187
252
  --rust-deep: var(--danger-ink);
188
253
  --gold: var(--slate);
@@ -204,153 +269,36 @@
204
269
  --s6: 32px; --s7: 48px; --s8: 64px; --s9: 96px;
205
270
 
206
271
  /* ── Shape & elevation ────────────────────────────────────────────────
207
- In LIGHT these are shadows; in DARK the surface lightens instead. */
272
+ In LIGHT these are shadows; in DARK the surface lightens instead, and
273
+ the shadow only has to separate it from a near-black ground.
274
+
275
+ light-dark() switches colours, not geometry, and the two themes' blur
276
+ differs. So shadows 2 and 3 carry one layer per theme, with the other
277
+ theme's layer transparent — it draws nothing. */
208
278
  --radius-sm: 6px;
209
279
  --radius: 12px;
210
280
  --radius-lg: 18px;
211
- --shadow-1: 0 1px 2px rgba(17, 17, 17, 0.04);
212
- --shadow-2: 0 2px 8px rgba(17, 17, 17, 0.07);
213
- --shadow-3: 0 8px 24px rgba(17, 17, 17, 0.10);
214
- --scrim: rgba(17, 17, 17, 0.55);
281
+ --shadow-1: 0 1px 2px light-dark(rgba(17, 17, 17, 0.04), rgba(0, 0, 0, 0.45));
282
+ --shadow-2:
283
+ 0 2px 8px light-dark(rgba(17, 17, 17, 0.07), transparent),
284
+ 0 2px 10px light-dark(transparent, rgba(0, 0, 0, 0.55));
285
+ --shadow-3:
286
+ 0 8px 24px light-dark(rgba(17, 17, 17, 0.10), transparent),
287
+ 0 10px 28px light-dark(transparent, rgba(0, 0, 0, 0.65));
288
+ --scrim: light-dark(rgba(17, 17, 17, 0.55), rgba(0, 0, 0, 0.68));
215
289
  --shadow: var(--shadow-1); /* legacy alias */
216
290
 
217
291
  /* ── Motion ───────────────────────────────────────────────────────────
218
292
  Content is ALWAYS visible by default; motion is enhancement layered on
219
293
  top, never the thing that makes content appear. brignano.io shipped a
220
294
  homepage whose contact CTA was invisible on 100% of loads because an
221
- animation library owned the visible state. See DESIGN.md §6. */
295
+ animation library owned the visible state. See DESIGN.md §7. */
222
296
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
223
297
  --dur-fast: 150ms;
224
298
  --dur: 250ms;
225
299
  --dur-slow: 600ms;
226
300
  }
227
301
 
228
- /* ── Dark ───────────────────────────────────────────────────────────────
229
- Selected, not inverted:
230
- 1. Not pure black. #0d0d0f — pure black behind bright text causes
231
- halation and is fatiguing at night, which is when a personal tool
232
- actually gets opened.
233
- 2. Surfaces LIGHTEN with elevation instead of gaining borders.
234
- 3. Accents lift in luminance rather than shifting hue, and washes carry
235
- a higher mix — 12% over #0d0d0f is invisible where it reads on white.
236
-
237
- Declared twice: the media query is the default and no-JS fallback;
238
- [data-theme] is the in-app toggle and must WIN over the system.
239
- `:not([data-theme='light'])` lets the toggle force light under a dark OS. */
240
- @media (prefers-color-scheme: dark) {
241
- :root:not([data-theme='light']) {
242
- color-scheme: dark;
243
-
244
- --n-0: #0d0d0f;
245
- --n-25: #131316;
246
- --n-50: #17171a;
247
- --n-100: #202024;
248
- --n-200: #2a2a2f;
249
- --n-300: #3a3a41;
250
- --n-400: #55555e;
251
- --n-500: #8b8b93; /* 5.29:1 on card */
252
- --n-600: #9c9ca4;
253
- --n-700: #a8a8b0; /* 7.58:1 */
254
- --n-800: #d0d0d2;
255
- --n-900: #f2f2f0; /* 15.96:1 */
256
-
257
- --bg: var(--n-0);
258
- --card: var(--n-50);
259
- --surface-hover: var(--n-100);
260
- --surface-active: var(--n-200);
261
- --on-dark: var(--n-0);
262
-
263
- --i: #60a5fa; /* 7.04:1 */
264
- --i-ink: #60a5fa;
265
- --i-hover: #93c5fd;
266
- --i-pressed: #bfdbfe;
267
- --i-surface: color-mix(in srgb, var(--i) 20%, transparent);
268
- --i-line: color-mix(in srgb, var(--i) 44%, transparent);
269
- --i-selection: color-mix(in srgb, var(--i) 30%, transparent);
270
- --on-i: #111111;
271
-
272
- --success: #6fbf95;
273
- --success-ink: #8fd4ae;
274
- --attention: #e0a44f;
275
- --attention-ink: #e0a44f; /* 8.17:1 — mark and label converge in dark */
276
- --danger: #e07a5f;
277
- --danger-ink: #ec9880;
278
-
279
- --success-surface: color-mix(in srgb, var(--success) 20%, transparent);
280
- --attention-surface: color-mix(in srgb, var(--attention) 20%, transparent);
281
- --danger-surface: color-mix(in srgb, var(--danger) 20%, transparent);
282
- --success-line: color-mix(in srgb, var(--success) 44%, transparent);
283
- --attention-line: color-mix(in srgb, var(--attention) 44%, transparent);
284
- --danger-line: color-mix(in srgb, var(--danger) 44%, transparent);
285
-
286
- --mark: #e0a44f;
287
- --mark-ink: #e0a44f;
288
-
289
- --larch-deep: #f0bd74; /* brighter step in dark — see the light block */
290
-
291
- --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
292
- --shadow-2: 0 2px 10px rgba(0, 0, 0, 0.55);
293
- --shadow-3: 0 10px 28px rgba(0, 0, 0, 0.65);
294
- --scrim: rgba(0, 0, 0, 0.68);
295
- }
296
- }
297
-
298
- :root[data-theme='dark'] {
299
- color-scheme: dark;
300
-
301
- --n-0: #0d0d0f;
302
- --n-25: #131316;
303
- --n-50: #17171a;
304
- --n-100: #202024;
305
- --n-200: #2a2a2f;
306
- --n-300: #3a3a41;
307
- --n-400: #55555e;
308
- --n-500: #8b8b93;
309
- --n-600: #9c9ca4;
310
- --n-700: #a8a8b0;
311
- --n-800: #d0d0d2;
312
- --n-900: #f2f2f0;
313
-
314
- --bg: var(--n-0);
315
- --card: var(--n-50);
316
- --surface-hover: var(--n-100);
317
- --surface-active: var(--n-200);
318
- --on-dark: var(--n-0);
319
-
320
- --i: #60a5fa;
321
- --i-ink: #60a5fa;
322
- --i-hover: #93c5fd;
323
- --i-pressed: #bfdbfe;
324
- --i-surface: color-mix(in srgb, var(--i) 20%, transparent);
325
- --i-line: color-mix(in srgb, var(--i) 44%, transparent);
326
- --i-selection: color-mix(in srgb, var(--i) 30%, transparent);
327
- --on-i: #111111;
328
-
329
- --success: #6fbf95;
330
- --success-ink: #8fd4ae;
331
- --attention: #e0a44f;
332
- --attention-ink: #e0a44f;
333
- --danger: #e07a5f;
334
- --danger-ink: #ec9880;
335
-
336
- --success-surface: color-mix(in srgb, var(--success) 20%, transparent);
337
- --attention-surface: color-mix(in srgb, var(--attention) 20%, transparent);
338
- --danger-surface: color-mix(in srgb, var(--danger) 20%, transparent);
339
- --success-line: color-mix(in srgb, var(--success) 44%, transparent);
340
- --attention-line: color-mix(in srgb, var(--attention) 44%, transparent);
341
- --danger-line: color-mix(in srgb, var(--danger) 44%, transparent);
342
-
343
- --mark: #e0a44f;
344
- --mark-ink: #e0a44f;
345
-
346
- --larch-deep: #f0bd74;
347
-
348
- --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
349
- --shadow-2: 0 2px 10px rgba(0, 0, 0, 0.55);
350
- --shadow-3: 0 10px 28px rgba(0, 0, 0, 0.65);
351
- --scrim: rgba(0, 0, 0, 0.68);
352
- }
353
-
354
302
  :root {
355
303
  /* Tool-tier scale — compact, because scanning beats reading.
356
304
  Declared BEFORE the marketing tier on purpose: `:root` and
@@ -381,7 +329,7 @@
381
329
  --text-xl: 1.5rem;
382
330
  --text-2xl: 2rem;
383
331
  --text-3xl: 2.75rem;
384
- --text-4xl: clamp(2.75rem, 6vw, 4rem);
332
+ --text-4xl: clamp(2.75rem, 1rem + 4.5vw, 4rem); /* rem term keeps it zoomable (WCAG 1.4.4) */
385
333
  --section-gap: var(--s9);
386
334
  --measure: 68ch;
387
335
  }
@@ -390,6 +338,12 @@
390
338
  background: var(--i-selection);
391
339
  }
392
340
 
341
+ /* Native checkboxes, radios, range and progress take the control hue
342
+ instead of the browser's own blue. */
343
+ :root {
344
+ accent-color: var(--i);
345
+ }
346
+
393
347
  /* Ships here so no surface can forget it. */
394
348
  @media (prefers-reduced-motion: reduce) {
395
349
  *,