@brignano/design 0.1.2 → 0.3.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/DECISIONS.md CHANGED
@@ -215,3 +215,164 @@ with it; `--mark` does not.
215
215
  separates them; a form error is not "rust means overdue", but they are the same
216
216
  register of message.
217
217
  - Nothing amber is clickable anywhere in the system.
218
+
219
+ ---
220
+
221
+ ## ADR-0005 — Diagrams are not charts: neutral nodes, colour for state only
222
+
223
+ **Status:** accepted · 2026-10-13
224
+
225
+ ### Context
226
+
227
+ `driftwood` draws architecture diagrams and was the first consumer to need them.
228
+ Its first pass on these tokens took the chart exception (§8, ADR-0002) and tinted
229
+ each node by family (edge, compute, data, messaging…) with `--chart-1…6`. On a
230
+ real 46-resource platform that put six hues on screen before anything was wrong,
231
+ and the database that was down was one more tinted box among them.
232
+
233
+ ### Decision
234
+
235
+ A diagram follows the restraint rule. The chart exception exists because a chart
236
+ has nothing *but* colour to tell series apart; a diagram node has an icon, a name
237
+ and a kind label, so family is already carried three times. Nodes are neutral.
238
+ Colour is reserved for state: health (`--danger`, `--attention`) and what a change
239
+ did, via three new aliases — `--diff-added`, `--diff-changed`, `--diff-removed` —
240
+ over the existing state hues. No new hue enters the system.
241
+
242
+ ### Consequences
243
+
244
+ - On a healthy, unchanged system the diagram is grey, and that is correct:
245
+ nothing is asking for you.
246
+ - `--diff-changed` shares cobalt with links. Context separates them (a node edge
247
+ with a `~ changed` tag is not underlined text), the same way feedback and domain
248
+ status share hues in ADR-0004.
249
+ - Standalone exports (SVG files, Mermaid in a pull request) take the light theme's
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.
303
+ - Not a token rename and not a visual change, so a patch release.
304
+
305
+
306
+ ---
307
+
308
+ ## ADR-0007 — The neutral ramp is one temperature: OKLCH hue 286°
309
+
310
+ **Status:** accepted · 2026-10-25
311
+
312
+ ### Context
313
+
314
+ `tokens.css` called the ramp "cool-leaning", and most of it was: light 300–800
315
+ and dark 0–800 sit at OKLCH hue 286°. But light 50, 100 and 200 — the hairline,
316
+ `--line-soft`, the hover and pressed surfaces — sat at 106°, a faint yellow, and
317
+ dark 900, the primary ink, did too. A border and the label beside it disagreed
318
+ on temperature. It was too slight to notice as a colour, but enough to make a
319
+ surface read slightly dirty next to a cool one.
320
+
321
+ ### Decision
322
+
323
+ Every tinted step sits at hue 286°. The off-hue steps (light 25, 50, 100, 200,
324
+ 900; dark 800, 900) are regenerated at **their existing OKLCH lightness**, so
325
+ contrast stays within 0.04 of its old value. Their chroma fits the curve
326
+ already set by their neighbours: near zero at the ends, peaking in the
327
+ mid-tones. Steps already at 286° are untouched. Pure white stays pure white.
328
+ `npm run check` fails any step that leaves 286° ±3°.
329
+
330
+ ### Consequences
331
+
332
+ - A visible change, if a quiet one, so the release that carries it is a minor.
333
+ - The cool ground does the job the original comment gave it: the warm mark
334
+ and the amber attention state are the only warm things on a page.
335
+
336
+ ---
337
+
338
+ ## ADR-0008 — Cobalt is our own: OKLCH hue 256°, not Tailwind's blue
339
+
340
+ **Status:** accepted · 2026-10-25
341
+ **Amends:** ADR-0001 (the hue stays cobalt; the values change)
342
+
343
+ ### Context
344
+
345
+ ADR-0001 chose cobalt for two reasons: it's semantically inert, and it's
346
+ complementary to larch, so identity and affordance don't compete. The values
347
+ it shipped, `#2563eb` / `#1d4ed8` / `#1e40af` / `#1e3a8a` and `#60a5fa` /
348
+ `#93c5fd` / `#bfdbfe`, are exactly Tailwind's `blue-600…900` and `blue-300…400`,
349
+ and the sequential chart ramp was Tailwind's blues too. That undercut both the
350
+ second reason and DESIGN.md §1's "nothing here should read as a template":
351
+
352
+ - Tailwind's blue is at OKLCH 263–266°, leaning violet. Larch is ≈68°, so its
353
+ opposite is ≈248°. The stock values were 15° further from that than they
354
+ needed to be.
355
+ - It isn't even one hue. Across the ramp the stock steps wander from 251.8°
356
+ to 265.6°, so hover and pressed shift colour as well as depth.
357
+ - It is the single most recognisable colour in the Tailwind default theme.
358
+
359
+ ### Decision
360
+
361
+ Every cobalt step, interactive and sequential, sits at **OKLCH hue 256°**, and
362
+ `npm run check` holds it there ±3°. 256° is as near to larch's opposite as sRGB
363
+ allows while keeping cobalt's chroma (≈0.18 at the fill). At 248–252° the gamut
364
+ caps chroma near 0.14–0.16, which reads as steel or ocean blue, and links lose
365
+ some of the "you can act on this" that ADR-0001 chose blue for.
366
+
367
+ Each step's lightness was **solved for the contrast its predecessor met**, so
368
+ every ratio is within 0.07 of before: fill 5.18:1 under a white label, link
369
+ 6.78:1 on white, dark fill 7.07:1 on card. The sequential ramp keeps each
370
+ step's OKLCH lightness and stays monotonic.
371
+
372
+ ### Consequences
373
+
374
+ - The blue is deeper and calmer than Tailwind's electric violet-blue, and it is
375
+ one colour from fill to pressed. It still reads unmistakably as a link.
376
+ - A visible change, so the release that carries it is a minor.
377
+ - `--chart-1` is a separately validated categorical slot (ADR-0002) and is
378
+ 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
@@ -159,19 +164,89 @@ rationale in [`tokens.chart.css`](./tokens.chart.css). The short version:
159
164
  - Text wears text tokens, never the series colour.
160
165
  - **Never a dual-axis chart.** Two measures of different scale → two charts.
161
166
 
162
- ## 9. Accessibility floor
167
+ ## 9. Diagrams and diffs
168
+
169
+ An architecture diagram (`driftwood`, `homelab`) is a picture of a system, read to
170
+ answer "what changed, and what is broken". It follows the restraint rule, **not**
171
+ the chart exception: node *type* is a category like event type, and is carried by
172
+ an icon and a label, never a hue. A diagram where every node is tinted by family
173
+ is thirty coloured things, and the one that is down disappears among them.
174
+
175
+ - **Nodes are neutral.** `--card` fill, `--line-strong` edge, `--radius` corners,
176
+ an icon in `--ink-soft`, the name in `--ink`, the kind in `--mono` at
177
+ `--text-xs` in `--ink-soft`. Grouping is a zone: `--line` hairline, no fill or
178
+ a faint one, a `--mono` eyebrow label.
179
+ - **Edges are `--slate`.** They are UI marks (3:1), never text.
180
+ - **Only state gets colour**, and always as a tag with a symbol and a word as well:
181
+
182
+ | Token | Means | Tag |
183
+ |---|---|---|
184
+ | `--diff-added` | this change created it | `+ added` |
185
+ | `--diff-changed` | this change modified it | `~ changed` |
186
+ | `--diff-removed` | this change removes it — dashed, struck through | `− removed` |
187
+ | `--danger` | it is down | `✕ down` |
188
+ | `--attention` | degraded, or differs from the design | `▲ degraded` |
189
+
190
+ The diff tokens alias the state hues on purpose (added is *settled*, a change is
191
+ *informational*, a removal is the change that can lose data), so a diff adds no
192
+ hue to the system. Health wins the fill (`-surface`); a diff only takes the edge.
193
+ - **Selection** is the focus treatment: a 2px `--i-ink` outline, offset. Asking
194
+ "what depends on this?" dims everything unrelated to ~30% opacity and inks the
195
+ connecting edges in `--ink`; it never introduces a colour.
196
+ - **Lead with the verdict.** A page about a deploy opens with one sentence saying
197
+ what happened and what needs you, then the pills that back it up, then the
198
+ picture.
199
+ - **Diagrams follow the theme.** On a page they use the tokens like everything
200
+ else. A diagram exported as a standalone file (an SVG, a Mermaid block in a pull
201
+ request) cannot use `var()`; it takes the **light** theme's values — the first
202
+ argument of each `light-dark()` — resolved from this file at build time, never
203
+ typed in.
204
+
205
+ ## 10. Accessibility floor
163
206
 
164
207
  Non-negotiable in both themes:
165
208
 
166
- - Body text ≥ 4.5:1; large text and UI marks ≥ 3:1.
209
+ - Body text ≥ 4.5:1; large text and UI marks ≥ 3:1. `--slate` is the quietest
210
+ text step and clears 4.5:1 in both themes; `--n-500` in light does not, so it
211
+ is never text.
167
212
  - Touch targets ≥ 44px on coarse pointers, with a `.touch-hit` opt-out for controls
168
213
  whose shape is meaningful (a 22px circular checkbox forced to 44px becomes an
169
214
  oval — it expands its hit area instead).
170
215
  - Every state carries an icon and a word.
171
- - Measured ratios sit next to their values in `tokens.css`. Changing a value means
172
- re-measuring it.
216
+ - Measured ratios sit next to their values in `tokens.css`, and `npm run check`
217
+ (run in CI on every pull request) fails if any floor above is missed, if a colour
218
+ is declared anywhere but once, or if a hex appears outside the token files.
173
219
 
174
- ## 10. Integrating with a Tailwind consumer
220
+ ## 11. Themes, and integrating with a Tailwind consumer
221
+
222
+ ### How the theme is chosen
223
+
224
+ Every colour is one declaration holding both values, `light-dark(<light>, <dark>)`,
225
+ and the browser picks a side from the element's `color-scheme`:
226
+
227
+ - **No attribute** — `color-scheme: light dark`, so the page follows the OS.
228
+ - **`data-theme="light"` / `"dark"`** — the explicit choice, which wins over the OS.
229
+
230
+ The pick happens where a colour is *used*, so **`data-theme` works on any element,
231
+ not just `<html>`.** A dark hero band on a light marketing page is
232
+ `<section data-theme="dark">`. Give that element its own
233
+ `background: var(--bg); color: var(--ink)` — backgrounds don't inherit.
234
+
235
+ Two consequences for code that touches tokens directly:
236
+
237
+ - **Don't read a token's value with `getPropertyValue`.** It returns the
238
+ declaration text, `light-dark(#076bd3, #6ba5f3)`, not a colour. To get the
239
+ colour a chart library needs, resolve it on an element in the right subtree:
240
+ set `el.style.color = 'var(--chart-1)'` and read `getComputedStyle(el).color`.
241
+ Re-read it when the theme changes.
242
+ - **Don't wrap a token in `color-mix()` or `light-dark()`.** Nesting is valid CSS,
243
+ but not every engine has been verified to resolve it. If you need a wash, it's
244
+ a token (`--*-surface`, `--*-line`); if it isn't, add it here.
245
+
246
+ Needs Chrome/Edge 123, Firefox 120, Safari 17.5 or later (all shipped by May 2024).
247
+ An older browser drops every colour token and renders unstyled rather than wrong.
248
+
249
+ ### Tailwind
175
250
 
176
251
  Tailwind's dark variant keys off a `.dark` class; these tokens key off
177
252
  `[data-theme]`. **Both must be set together**, by the same code, in both places
@@ -186,11 +261,11 @@ Setting only the class is a silent, half-broken state: Tailwind utilities flip
186
261
  but the tokens do not, and the page renders one theme's tokens under the other
187
262
  theme's utilities. Setting only the attribute leaves every `dark:` utility dead.
188
263
 
189
- The reason the attribute is required rather than relying on
190
- `prefers-color-scheme`: a site with a manual toggle has already *resolved* the
191
- OS preference into an explicit choice. If the tokens also consulted the media
192
- query, a user on a dark OS who chose light would get dark tokens under light
193
- utilities. `[data-theme]` is what makes the choice authoritative.
264
+ The reason the attribute is required rather than leaving the OS to decide: a
265
+ site with a manual toggle has already *resolved* the OS preference into an
266
+ explicit choice. Without the attribute, the tokens would still follow the OS, and
267
+ a user on a dark OS who chose light would get dark tokens under light utilities.
268
+ `[data-theme]` is what makes the choice authoritative.
194
269
 
195
270
  Import order matters — Tailwind first, then tokens, then the bridge:
196
271
 
@@ -200,7 +275,7 @@ Import order matters — Tailwind first, then tokens, then the bridge:
200
275
  @import "@brignano/design/tailwind.css";
201
276
  ```
202
277
 
203
- ## 11. The one rule that keeps this true
278
+ ## 12. The one rule that keeps this true
204
279
 
205
280
  **Never hardcode a hex outside `tokens.css`.**
206
281
 
package/README.md CHANGED
@@ -9,16 +9,17 @@ The shared design system for every brignano surface — `brignano.io`, `life`,
9
9
  | [`DECISIONS.md`](./DECISIONS.md) | Why the values are what they are. ADRs. |
10
10
  | [`tokens.css`](./tokens.css) | Single source of truth for colour, type, space, shape, motion. |
11
11
  | [`tokens.chart.css`](./tokens.chart.css) | Validated categorical palette for data viz. |
12
+ | [`previews/`](./previews) | Live swatches. Open one in a browser to see the tokens render. |
12
13
 
13
14
  ## The shape of it
14
15
 
15
16
  Three layers, so no colour does two jobs:
16
17
 
17
- - **`--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.
18
19
  - **`--i-*`** — affordance. Cobalt. Links, buttons, focus, selection. Semantically inert.
19
- - **`--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.
20
21
 
21
- Distinctiveness lives in the brand layer; conventionality lives in the control layer.
22
+ Distinctiveness lives in the mark; conventionality lives in the control layer.
22
23
 
23
24
  ## Consuming it
24
25
 
@@ -31,10 +32,40 @@ npm install @brignano/design
31
32
  @import '@brignano/design/tokens.chart.css'; /* only if the surface has charts */
32
33
  ```
33
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
+
34
40
  The tool tier is the default. A marketing surface opts in with
35
41
  `class="tier-marketing"` on `<html>`, which unlocks the display face and opens the
36
42
  type scale. Colour is identical across tiers.
37
43
 
44
+ ## Previews
45
+
46
+ `previews/` holds a page per area — colour, type, elevation, controls, feedback, diagrams,
47
+ status. Each renders its subject twice, once in the viewer's theme and once on a
48
+ forced-dark ground, so drift between the two shows up without toggling anything.
49
+ No build step: open the file.
50
+
51
+ Every preview opens with a card marker:
52
+
53
+ ```html
54
+ <!-- @dsCard group="Foundations" -->
55
+ ```
56
+
57
+ That first line is what a [claude.ai/design][ds] design-system project reads to
58
+ build its card index — `group` becomes the section heading in the Design System
59
+ pane. The markers are comments, so they cost nothing when the page is opened
60
+ locally.
61
+
62
+ **A sync to such a project keeps this layout** — `tokens.css` at the project
63
+ root, `previews/` one level below it. `_preview.css` reaches it with
64
+ `@import '../tokens.css'` rather than carrying its own copy, because a second
65
+ copy of the tokens is the drift this repo exists to prevent. The trade is that
66
+ the parent reference is load-bearing: flatten the uploaded bundle and every
67
+ preview renders unstyled.
68
+
38
69
  ## For agents
39
70
 
40
71
  Read `DESIGN.md` before styling anything. Use the tokens. **Never hardcode a hex
@@ -84,4 +115,5 @@ and repo, workflow filename `publish.yml`, and **environment name blank**, since
84
115
  these workflows declare no `environment:`. Configure it before the first tag, or
85
116
  the first publish fails the way this one did.
86
117
 
118
+ [ds]: https://claude.ai/design
87
119
  [tp]: https://docs.npmjs.com/trusted-publishers/
package/package.json CHANGED
@@ -1,12 +1,15 @@
1
1
  {
2
2
  "name": "@brignano/design",
3
- "version": "0.1.2",
4
- "description": "Shared design system for brignano surfaces \u2014 colour, type, space, shape, motion.",
3
+ "version": "0.3.0",
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,47 @@
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-on-dark: var(--on-dark);
21
27
 
22
28
  --color-interactive: var(--i);
23
29
  --color-interactive-ink: var(--i-ink);
24
30
  --color-interactive-hover: var(--i-hover);
25
31
  --color-interactive-pressed: var(--i-pressed);
26
32
  --color-interactive-surface: var(--i-surface);
33
+ --color-interactive-line: var(--i-line);
27
34
  --color-on-interactive: var(--on-i);
28
35
 
29
36
  --color-success: var(--success);
30
37
  --color-success-ink: var(--success-ink);
31
38
  --color-success-surface: var(--success-surface);
39
+ --color-success-line: var(--success-line);
32
40
  --color-attention: var(--attention);
33
41
  --color-attention-ink: var(--attention-ink);
34
42
  --color-attention-surface: var(--attention-surface);
43
+ --color-attention-line: var(--attention-line);
35
44
  --color-danger: var(--danger);
36
45
  --color-danger-ink: var(--danger-ink);
37
46
  --color-danger-surface: var(--danger-surface);
47
+ --color-danger-line: var(--danger-line);
48
+ --color-info: var(--info);
49
+ --color-info-ink: var(--info-ink);
50
+ --color-info-surface: var(--info-surface);
51
+ --color-info-line: var(--info-line);
52
+
53
+ --color-diff-added: var(--diff-added);
54
+ --color-diff-added-ink: var(--diff-added-ink);
55
+ --color-diff-added-surface: var(--diff-added-surface);
56
+ --color-diff-changed: var(--diff-changed);
57
+ --color-diff-changed-ink: var(--diff-changed-ink);
58
+ --color-diff-changed-surface: var(--diff-changed-surface);
59
+ --color-diff-removed: var(--diff-removed);
60
+ --color-diff-removed-ink: var(--diff-removed-ink);
61
+ --color-diff-removed-surface: var(--diff-removed-surface);
38
62
 
39
63
  --color-mark: var(--mark);
40
64
  --color-mark-ink: var(--mark-ink);
@@ -44,7 +68,7 @@
44
68
  --font-display: var(--display);
45
69
 
46
70
  --radius-sm: var(--radius-sm);
47
- --radius-DEFAULT: var(--radius);
71
+ --radius: var(--radius);
48
72
  --radius-lg: var(--radius-lg);
49
73
 
50
74
  --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,102 @@
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);
75
122
  --ink: var(--n-900);
76
123
  --ink-soft: var(--n-700);
77
- --slate: var(--n-500);
124
+ --slate: light-dark(#6b6b73, #8b8b93); /* n-600 / n-500 — text-safe: eyebrows, axis labels, captions */
78
125
  --line: var(--n-100);
79
126
  --line-soft: var(--n-50);
80
- --line-strong: var(--n-300);
81
- --on-dark: var(--n-25);
127
+ --line-strong: var(--n-300); /* 1.62:1 — decoration, not a boundary */
128
+ /* The edge that tells you a control is there: inputs, secondary buttons.
129
+ WCAG 1.4.11 asks 3:1 of a boundary you need to find the control, which
130
+ --line-strong is nowhere near. Light is n-500 (3.44:1 on card, 3.29:1
131
+ on bg); dark sits between n-400 and n-500, which the ramp has no step
132
+ for (3.39:1 on card, 3.68:1 on bg). */
133
+ --line-control: light-dark(#8a8a8f, #6b6b73);
134
+ --on-dark: light-dark(#fafafb, #0d0d0f); /* n-25 / n-0 */
82
135
 
83
136
  /* ── Interactive — cobalt ─────────────────────────────────────────────
84
137
  Everything you can act on: links, buttons, focus, selection. Chosen to
@@ -86,21 +139,28 @@
86
139
  green and red do — and complementary to the mark hue, so identity and
87
140
  affordance never compete for the same attention.
88
141
 
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
142
+ Every step sits at OKLCH hue 256° (`npm run check` holds it), as near
143
+ to larch's opposite (~248°) as sRGB allows while keeping cobalt's
144
+ chroma. It is deliberately NOT Tailwind's blue-600 (263°, a touch
145
+ violet and electric): same affordance, our own colour (ADR-0008).
146
+
147
+ --i fill 5.18:1 / 7.07:1 (label 5.18 / 7.46)
148
+ --i-ink link/icon/focus 6.78:1 / 7.07:1
149
+ --i-hover hover fill 8.65:1 / 10.50:1 (label)
150
+ --i-pressed pressed fill 10.44:1 / 13.32:1 (label)
151
+
152
+ In dark, fill and ink converge, and hover/pressed get LIGHTER.
93
153
 
94
154
  DISABLED IS NEUTRAL, NEVER HUED — a greyed-out control must stop
95
155
  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;
156
+ --i: light-dark(#076bd3, #6ba5f3);
157
+ --i-ink: light-dark(#0159b5, #6ba5f3);
158
+ --i-hover: light-dark(#014a97, #98c4ff);
159
+ --i-pressed: light-dark(#003e82, #c0dbff);
160
+ --i-surface: light-dark(color-mix(in srgb, #076bd3 12%, transparent), color-mix(in srgb, #6ba5f3 20%, transparent));
161
+ --i-line: light-dark(color-mix(in srgb, #076bd3 32%, transparent), color-mix(in srgb, #6ba5f3 44%, transparent));
162
+ --i-selection: light-dark(color-mix(in srgb, #076bd3 26%, transparent), color-mix(in srgb, #6ba5f3 30%, transparent));
163
+ --on-i: light-dark(#ffffff, #111111);
104
164
  --i-disabled-bg: var(--n-200);
105
165
  --i-disabled-ink: var(--n-400);
106
166
 
@@ -109,33 +169,53 @@
109
169
  tiers. Always shipped with an ICON AND A WORD — colour is never the
110
170
  only signal.
111
171
 
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
172
+ --success settled · confirmed · done · succeeded 6.29:1 / 8.15:1
173
+ --attention now · next · warning · needs you 6.08:1 / 8.17:1 (ink)
174
+ --danger overdue · failed · invalid 6.10:1 / 6.06:1
115
175
 
116
176
  `--info` is the interactive hue on purpose: an informational message
117
177
  is the same "neutral, no action implied" register as a link.
118
178
 
119
179
  `-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;
180
+ `-surface` and `-line` are the wash and its edge: 12% / 32% of the hue
181
+ in light, 20% / 44% in dark. */
182
+ --success: light-dark(#2f6b4f, #6fbf95);
183
+ --success-ink: light-dark(#245740, #8fd4ae);
184
+ --attention: light-dark(#b4741f, #e0a44f); /* 3.85:1 in light — mark/fill only, NOT text */
185
+ --attention-ink: light-dark(#8a5714, #e0a44f); /* 6.08:1 / 8.17:1 — mark and label converge in dark */
186
+ --danger: light-dark(#a4442c, #e07a5f);
187
+ --danger-ink: light-dark(#8a3722, #ec9880);
127
188
  --info: var(--i);
128
189
  --info-ink: var(--i-ink);
129
190
 
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);
191
+ --success-surface: light-dark(color-mix(in srgb, #2f6b4f 12%, transparent), color-mix(in srgb, #6fbf95 20%, transparent));
192
+ --attention-surface: light-dark(color-mix(in srgb, #b4741f 12%, transparent), color-mix(in srgb, #e0a44f 20%, transparent));
193
+ --danger-surface: light-dark(color-mix(in srgb, #a4442c 12%, transparent), color-mix(in srgb, #e07a5f 20%, transparent));
133
194
  --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);
195
+ --success-line: light-dark(color-mix(in srgb, #2f6b4f 32%, transparent), color-mix(in srgb, #6fbf95 44%, transparent));
196
+ --attention-line: light-dark(color-mix(in srgb, #b4741f 32%, transparent), color-mix(in srgb, #e0a44f 44%, transparent));
197
+ --danger-line: light-dark(color-mix(in srgb, #a4442c 32%, transparent), color-mix(in srgb, #e07a5f 44%, transparent));
137
198
  --info-line: var(--i-line);
138
199
 
200
+ /* ── Diff ─────────────────────────────────────────────────────────────
201
+ What a change did to a thing: a deploy, a plan, a pull request. Aliases
202
+ of the state hues, so a diff adds no hue of its own (DESIGN.md §9):
203
+
204
+ --diff-added created by this change success — settled
205
+ --diff-changed modified by this change interactive — informational
206
+ --diff-removed removed by this change danger — can lose data
207
+
208
+ Always with a symbol and a word (+ added, ~ changed, − removed). */
209
+ --diff-added: var(--success);
210
+ --diff-added-ink: var(--success-ink);
211
+ --diff-added-surface: var(--success-surface);
212
+ --diff-changed: var(--i);
213
+ --diff-changed-ink: var(--i-ink);
214
+ --diff-changed-surface: var(--i-surface);
215
+ --diff-removed: var(--danger);
216
+ --diff-removed-ink: var(--danger-ink);
217
+ --diff-removed-surface: var(--danger-surface);
218
+
139
219
  /* ── Mark — identity ──────────────────────────────────────────────────
140
220
  Inks a GRAPHIC: the trail line, the A|B monogram, a hero rule. That is
141
221
  the entire permitted use.
@@ -148,21 +228,21 @@
148
228
  the attention state are the same warmth, and because --mark never
149
229
  appears as a UI surface the two can never be mistaken for each other
150
230
  in the same context. */
151
- --mark: #b4741f;
152
- --mark-ink: #8a5714; /* when the mark carries a label, 6.08:1 */
231
+ --mark: light-dark(#b4741f, #e0a44f);
232
+ --mark-ink: light-dark(#8a5714, #e0a44f); /* when the mark carries a label, 6.08:1 / 8.17:1 */
153
233
 
154
234
  /* ── Legacy aliases ───────────────────────────────────────────────────
155
235
  Kept so existing `life` components keep compiling. Do not use in new
156
236
  work — reach for the semantic tokens above.
157
237
 
158
- --larch-deep is declared explicitly in BOTH themes rather than
159
- aliased, because "deep" inverts: darker in light, BRIGHTER in dark. An
160
- alias to either role silently changes one theme — it moved
161
- .pill-active from #f0bd74 to #e0a44f before this was caught. */
238
+ --larch-deep is written out rather than aliased, because "deep"
239
+ inverts: darker in light, BRIGHTER in dark. An alias to either role
240
+ silently changes one theme — it moved .pill-active from #f0bd74 to
241
+ #e0a44f before this was caught. */
162
242
  --forest: var(--success);
163
243
  --forest-deep: var(--success-ink);
164
244
  --larch: var(--attention);
165
- --larch-deep: #8a5714;
245
+ --larch-deep: light-dark(#8a5714, #f0bd74);
166
246
  --rust: var(--danger);
167
247
  --rust-deep: var(--danger-ink);
168
248
  --gold: var(--slate);
@@ -184,151 +264,51 @@
184
264
  --s6: 32px; --s7: 48px; --s8: 64px; --s9: 96px;
185
265
 
186
266
  /* ── Shape & elevation ────────────────────────────────────────────────
187
- In LIGHT these are shadows; in DARK the surface lightens instead. */
267
+ In LIGHT these are shadows; in DARK the surface lightens instead, and
268
+ the shadow only has to separate it from a near-black ground.
269
+
270
+ light-dark() switches colours, not geometry, and the two themes' blur
271
+ differs. So shadows 2 and 3 carry one layer per theme, with the other
272
+ theme's layer transparent — it draws nothing. */
188
273
  --radius-sm: 6px;
189
274
  --radius: 12px;
190
275
  --radius-lg: 18px;
191
- --shadow-1: 0 1px 2px rgba(17, 17, 17, 0.04);
192
- --shadow-2: 0 2px 8px rgba(17, 17, 17, 0.07);
193
- --shadow-3: 0 8px 24px rgba(17, 17, 17, 0.10);
194
- --scrim: rgba(17, 17, 17, 0.55);
276
+ --shadow-1: 0 1px 2px light-dark(rgba(17, 17, 17, 0.04), rgba(0, 0, 0, 0.45));
277
+ --shadow-2:
278
+ 0 2px 8px light-dark(rgba(17, 17, 17, 0.07), transparent),
279
+ 0 2px 10px light-dark(transparent, rgba(0, 0, 0, 0.55));
280
+ --shadow-3:
281
+ 0 8px 24px light-dark(rgba(17, 17, 17, 0.10), transparent),
282
+ 0 10px 28px light-dark(transparent, rgba(0, 0, 0, 0.65));
283
+ --scrim: light-dark(rgba(17, 17, 17, 0.55), rgba(0, 0, 0, 0.68));
195
284
  --shadow: var(--shadow-1); /* legacy alias */
196
285
 
197
286
  /* ── Motion ───────────────────────────────────────────────────────────
198
287
  Content is ALWAYS visible by default; motion is enhancement layered on
199
288
  top, never the thing that makes content appear. brignano.io shipped a
200
289
  homepage whose contact CTA was invisible on 100% of loads because an
201
- animation library owned the visible state. See DESIGN.md §6. */
290
+ animation library owned the visible state. See DESIGN.md §7. */
202
291
  --ease: cubic-bezier(0.22, 1, 0.36, 1);
203
292
  --dur-fast: 150ms;
204
293
  --dur: 250ms;
205
294
  --dur-slow: 600ms;
206
295
  }
207
296
 
208
- /* ── Dark ───────────────────────────────────────────────────────────────
209
- Selected, not inverted:
210
- 1. Not pure black. #0d0d0f — pure black behind bright text causes
211
- halation and is fatiguing at night, which is when a personal tool
212
- actually gets opened.
213
- 2. Surfaces LIGHTEN with elevation instead of gaining borders.
214
- 3. Accents lift in luminance rather than shifting hue, and washes carry
215
- a higher mix — 12% over #0d0d0f is invisible where it reads on white.
216
-
217
- Declared twice: the media query is the default and no-JS fallback;
218
- [data-theme] is the in-app toggle and must WIN over the system.
219
- `:not([data-theme='light'])` lets the toggle force light under a dark OS. */
220
- @media (prefers-color-scheme: dark) {
221
- :root:not([data-theme='light']) {
222
- color-scheme: dark;
223
-
224
- --n-0: #0d0d0f;
225
- --n-25: #131316;
226
- --n-50: #17171a;
227
- --n-100: #202024;
228
- --n-200: #2a2a2f;
229
- --n-300: #3a3a41;
230
- --n-400: #55555e;
231
- --n-500: #8b8b93; /* 5.29:1 on card */
232
- --n-600: #9c9ca4;
233
- --n-700: #a8a8b0; /* 7.58:1 */
234
- --n-800: #d0d0d2;
235
- --n-900: #f2f2f0; /* 15.96:1 */
236
-
237
- --bg: var(--n-0);
238
- --card: var(--n-50);
239
- --surface-hover: var(--n-100);
240
- --surface-active: var(--n-200);
241
- --on-dark: var(--n-0);
242
-
243
- --i: #60a5fa; /* 7.04:1 */
244
- --i-ink: #60a5fa;
245
- --i-hover: #93c5fd;
246
- --i-pressed: #bfdbfe;
247
- --i-surface: color-mix(in srgb, var(--i) 20%, transparent);
248
- --i-line: color-mix(in srgb, var(--i) 44%, transparent);
249
- --i-selection: color-mix(in srgb, var(--i) 30%, transparent);
250
- --on-i: #111111;
251
-
252
- --success: #6fbf95;
253
- --success-ink: #8fd4ae;
254
- --attention: #e0a44f;
255
- --attention-ink: #e0a44f; /* 8.17:1 — mark and label converge in dark */
256
- --danger: #e07a5f;
257
- --danger-ink: #ec9880;
258
-
259
- --success-surface: color-mix(in srgb, var(--success) 20%, transparent);
260
- --attention-surface: color-mix(in srgb, var(--attention) 20%, transparent);
261
- --danger-surface: color-mix(in srgb, var(--danger) 20%, transparent);
262
- --success-line: color-mix(in srgb, var(--success) 44%, transparent);
263
- --attention-line: color-mix(in srgb, var(--attention) 44%, transparent);
264
- --danger-line: color-mix(in srgb, var(--danger) 44%, transparent);
265
-
266
- --mark: #e0a44f;
267
- --mark-ink: #e0a44f;
268
-
269
- --larch-deep: #f0bd74; /* brighter step in dark — see the light block */
270
-
271
- --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
272
- --shadow-2: 0 2px 10px rgba(0, 0, 0, 0.55);
273
- --shadow-3: 0 10px 28px rgba(0, 0, 0, 0.65);
274
- --scrim: rgba(0, 0, 0, 0.68);
275
- }
276
- }
277
-
278
- :root[data-theme='dark'] {
279
- color-scheme: dark;
280
-
281
- --n-0: #0d0d0f;
282
- --n-25: #131316;
283
- --n-50: #17171a;
284
- --n-100: #202024;
285
- --n-200: #2a2a2f;
286
- --n-300: #3a3a41;
287
- --n-400: #55555e;
288
- --n-500: #8b8b93;
289
- --n-600: #9c9ca4;
290
- --n-700: #a8a8b0;
291
- --n-800: #d0d0d2;
292
- --n-900: #f2f2f0;
293
-
294
- --bg: var(--n-0);
295
- --card: var(--n-50);
296
- --surface-hover: var(--n-100);
297
- --surface-active: var(--n-200);
298
- --on-dark: var(--n-0);
299
-
300
- --i: #60a5fa;
301
- --i-ink: #60a5fa;
302
- --i-hover: #93c5fd;
303
- --i-pressed: #bfdbfe;
304
- --i-surface: color-mix(in srgb, var(--i) 20%, transparent);
305
- --i-line: color-mix(in srgb, var(--i) 44%, transparent);
306
- --i-selection: color-mix(in srgb, var(--i) 30%, transparent);
307
- --on-i: #111111;
308
-
309
- --success: #6fbf95;
310
- --success-ink: #8fd4ae;
311
- --attention: #e0a44f;
312
- --attention-ink: #e0a44f;
313
- --danger: #e07a5f;
314
- --danger-ink: #ec9880;
315
-
316
- --success-surface: color-mix(in srgb, var(--success) 20%, transparent);
317
- --attention-surface: color-mix(in srgb, var(--attention) 20%, transparent);
318
- --danger-surface: color-mix(in srgb, var(--danger) 20%, transparent);
319
- --success-line: color-mix(in srgb, var(--success) 44%, transparent);
320
- --attention-line: color-mix(in srgb, var(--attention) 44%, transparent);
321
- --danger-line: color-mix(in srgb, var(--danger) 44%, transparent);
322
-
323
- --mark: #e0a44f;
324
- --mark-ink: #e0a44f;
325
-
326
- --larch-deep: #f0bd74;
327
-
328
- --shadow-1: 0 1px 2px rgba(0, 0, 0, 0.45);
329
- --shadow-2: 0 2px 10px rgba(0, 0, 0, 0.55);
330
- --shadow-3: 0 10px 28px rgba(0, 0, 0, 0.65);
331
- --scrim: rgba(0, 0, 0, 0.68);
297
+ :root {
298
+ /* Tool-tier scale — compact, because scanning beats reading.
299
+ Declared BEFORE the marketing tier on purpose: `:root` and
300
+ `.tier-marketing` have the same specificity, so on <html> whichever
301
+ comes later wins. With this block last, the class did nothing. */
302
+ --text-xs: 0.6875rem;
303
+ --text-sm: 0.8125rem;
304
+ --text-base: 0.9375rem;
305
+ --text-lg: 1.0625rem;
306
+ --text-xl: 1.25rem;
307
+ --text-2xl: 1.5rem;
308
+ --text-3xl: 1.875rem;
309
+ --text-4xl: 2.25rem;
310
+ --section-gap: var(--s6);
311
+ --measure: 780px;
332
312
  }
333
313
 
334
314
  /* ── Marketing tier ─────────────────────────────────────────────────────
@@ -344,29 +324,21 @@
344
324
  --text-xl: 1.5rem;
345
325
  --text-2xl: 2rem;
346
326
  --text-3xl: 2.75rem;
347
- --text-4xl: clamp(2.75rem, 6vw, 4rem);
327
+ --text-4xl: clamp(2.75rem, 1rem + 4.5vw, 4rem); /* rem term keeps it zoomable (WCAG 1.4.4) */
348
328
  --section-gap: var(--s9);
349
329
  --measure: 68ch;
350
330
  }
351
331
 
352
- :root {
353
- /* Tool-tier scale — compact, because scanning beats reading. */
354
- --text-xs: 0.6875rem;
355
- --text-sm: 0.8125rem;
356
- --text-base: 0.9375rem;
357
- --text-lg: 1.0625rem;
358
- --text-xl: 1.25rem;
359
- --text-2xl: 1.5rem;
360
- --text-3xl: 1.875rem;
361
- --text-4xl: 2.25rem;
362
- --section-gap: var(--s6);
363
- --measure: 780px;
364
- }
365
-
366
332
  ::selection {
367
333
  background: var(--i-selection);
368
334
  }
369
335
 
336
+ /* Native checkboxes, radios, range and progress take the control hue
337
+ instead of the browser's own blue. */
338
+ :root {
339
+ accent-color: var(--i);
340
+ }
341
+
370
342
  /* Ships here so no surface can forget it. */
371
343
  @media (prefers-reduced-motion: reduce) {
372
344
  *,