@fiestaboard/ui 3.3.0 → 4.0.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.
Files changed (79) hide show
  1. package/dist/components/board/board-backdrop.d.ts +60 -0
  2. package/dist/components/board/board-backdrop.js +71 -0
  3. package/dist/components/board/board-backdrop.js.map +1 -0
  4. package/dist/components/board/board-display.js +123 -123
  5. package/dist/components/board/board-display.js.map +1 -1
  6. package/dist/components/board/static-board-display.js +32 -32
  7. package/dist/components/chrome/fiesta-logo.js +2 -2
  8. package/dist/components/chrome/fiesta-logo.js.map +1 -1
  9. package/dist/components/chrome/page-header.d.ts +66 -4
  10. package/dist/components/chrome/page-header.js +31 -11
  11. package/dist/components/chrome/page-header.js.map +1 -1
  12. package/dist/components/chrome/page-layout.js +1 -1
  13. package/dist/components/chrome/page-layout.js.map +1 -1
  14. package/dist/components/chrome/sidebar.d.ts +2 -5
  15. package/dist/components/chrome/sidebar.js +152 -173
  16. package/dist/components/chrome/sidebar.js.map +1 -1
  17. package/dist/components/chrome/theme-toggle.d.ts +37 -10
  18. package/dist/components/chrome/theme-toggle.js +25 -12
  19. package/dist/components/chrome/theme-toggle.js.map +1 -1
  20. package/dist/components/containment/json-tree.js +2 -2
  21. package/dist/components/containment/json-tree.js.map +1 -1
  22. package/dist/components/editor/node-views/wrapped-text-view.js +1 -1
  23. package/dist/components/editor/node-views/wrapped-text-view.js.map +1 -1
  24. package/dist/components/editor/variable-picker-content.js +4 -4
  25. package/dist/components/feedback/alert.js +4 -4
  26. package/dist/components/feedback/alert.js.map +1 -1
  27. package/dist/components/feedback/badge.js +4 -4
  28. package/dist/components/feedback/badge.js.map +1 -1
  29. package/dist/components/forms/button.js +5 -5
  30. package/dist/components/forms/button.js.map +1 -1
  31. package/dist/components/layout/flex.d.ts +1 -1
  32. package/dist/components/layout/grid.d.ts +1 -1
  33. package/dist/components/layout/stack.d.ts +1 -1
  34. package/dist/components/plugin/board-showcase.js +2 -2
  35. package/dist/components/plugin/board-showcase.js.map +1 -1
  36. package/dist/components/plugin/plugin-category-badge.d.ts +27 -1
  37. package/dist/components/plugin/plugin-category-badge.js +18 -14
  38. package/dist/components/plugin/plugin-category-badge.js.map +1 -1
  39. package/dist/components/typography/text.js +1 -1
  40. package/dist/components/typography/text.js.map +1 -1
  41. package/dist/components/wizard/wizard-progress.d.ts +29 -0
  42. package/dist/components/wizard/wizard-progress.js +27 -0
  43. package/dist/components/wizard/wizard-progress.js.map +1 -0
  44. package/dist/components/wizard/wizard-shell.d.ts +63 -0
  45. package/dist/components/wizard/wizard-shell.js +69 -0
  46. package/dist/components/wizard/wizard-shell.js.map +1 -0
  47. package/dist/index.d.ts +3 -5
  48. package/dist/index.js +90 -92
  49. package/dist/theme.css +621 -503
  50. package/package.json +2 -4
  51. package/dist/components/effects/aurora.d.ts +0 -9
  52. package/dist/components/effects/aurora.js +0 -121
  53. package/dist/components/effects/aurora.js.map +0 -1
  54. package/dist/components/effects/react-bits/decrypted-text.d.ts +0 -14
  55. package/dist/components/effects/react-bits/decrypted-text.js +0 -88
  56. package/dist/components/effects/react-bits/decrypted-text.js.map +0 -1
  57. package/dist/components/seasons/aurora-canvas.d.ts +0 -22
  58. package/dist/components/seasons/aurora-canvas.js +0 -91
  59. package/dist/components/seasons/aurora-canvas.js.map +0 -1
  60. package/dist/components/seasons/sidebar-aurora-horizontal.d.ts +0 -3
  61. package/dist/components/seasons/sidebar-aurora-horizontal.js +0 -47
  62. package/dist/components/seasons/sidebar-aurora-horizontal.js.map +0 -1
  63. package/dist/components/seasons/sidebar-aurora.d.ts +0 -3
  64. package/dist/components/seasons/sidebar-aurora.js +0 -57
  65. package/dist/components/seasons/sidebar-aurora.js.map +0 -1
  66. package/dist/lib/seasons-drafts.d.ts +0 -26
  67. package/dist/lib/seasons-drafts.js +0 -106
  68. package/dist/lib/seasons-drafts.js.map +0 -1
  69. package/dist/lib/seasons.d.ts +0 -52
  70. package/dist/lib/seasons.js +0 -59
  71. package/dist/lib/seasons.js.map +0 -1
  72. package/dist/seasons/christmas.css +0 -64
  73. package/dist/seasons/easter.css +0 -62
  74. package/dist/seasons/fathers-day.css +0 -62
  75. package/dist/seasons/halloween.css +0 -69
  76. package/dist/seasons/mothers-day.css +0 -63
  77. package/dist/seasons/new-year.css +0 -66
  78. package/dist/seasons/pride.css +0 -68
  79. package/dist/seasons/thanksgiving.css +0 -64
package/dist/theme.css CHANGED
@@ -15,15 +15,6 @@
15
15
  * token values here without a parity check on the consumer side.
16
16
  */
17
17
  @import "tw-animate-css";
18
- /* Seasonal themes — one file per season, inert unless the season class
19
- is stamped on <html>. Only the LIVE season (pride) is inlined here so
20
- consumers get it automatically; the design drafts are NOT imported —
21
- they'd ship ~18 KB of rule families keyed on classes the app never
22
- stamps. A consumer that promotes a draft opts in with:
23
- @import "@fiestaboard/ui/seasons/<id>.css";
24
- (see README "Seasonal theming"). Storybook imports every draft
25
- directly in storybook.css so the Season toolbar can preview them. */
26
- @import "./seasons/pride.css";
27
18
 
28
19
  @custom-variant dark (&:is(.dark *));
29
20
 
@@ -104,6 +95,14 @@
104
95
  --color-category-home: var(--category-home);
105
96
  --color-category-home-foreground: var(--category-home-foreground);
106
97
 
98
+ /* The six brand hues at an ink lightness — icons, rules, text. */
99
+ --color-hue-red: var(--hue-red);
100
+ --color-hue-orange: var(--hue-orange);
101
+ --color-hue-yellow: var(--hue-yellow);
102
+ --color-hue-green: var(--hue-green);
103
+ --color-hue-blue: var(--hue-blue);
104
+ --color-hue-violet: var(--hue-violet);
105
+
107
106
  /* Overlay */
108
107
  --color-overlay: var(--overlay);
109
108
 
@@ -111,6 +110,13 @@
111
110
  --color-brand: var(--brand);
112
111
  --color-brand-foreground: var(--brand-foreground);
113
112
  --color-brand-emphasis: var(--brand-emphasis);
113
+ /* The literal tile. Field only — see the brand note in :root. */
114
+ --color-brand-tile: var(--brand-tile);
115
+ --color-primary-hover: var(--primary-hover);
116
+ --color-primary-active: var(--primary-active);
117
+ --color-ring-edge: var(--ring-edge);
118
+ --color-brand-tile-foreground: var(--brand-tile-foreground);
119
+ --color-brand-hover: var(--brand-hover);
114
120
 
115
121
  /* Navigation active state (indigo) */
116
122
  --color-nav-active: var(--nav-active);
@@ -208,12 +214,31 @@
208
214
  Declared next to the palette it belongs to, once per theme. */
209
215
  color-scheme: light;
210
216
  --radius: 0.625rem;
211
- --background: oklch(0.985 0 0);
212
- --foreground: oklch(0.145 0 0);
213
- --card: oklch(1 0 0);
214
- --card-foreground: oklch(0.145 0 0);
215
- --popover: oklch(1 0 0);
216
- --popover-foreground: oklch(0.145 0 0);
217
+ /* ============================================================
218
+ * HUE LOCK (#231). Every chromatic token in this file resolves to one
219
+ * of the SIX hues the hardware can physically flip, converted from
220
+ * lib/board-colors.ts:
221
+ *
222
+ * red 29 · orange 73 · yellow 104 · green 133 · blue 251 · violet 315
223
+ *
224
+ * Hue is theme-invariant: a semantic token keeps its hue in light and
225
+ * dark, and only LIGHTNESS moves, set by the contrast target. Before
226
+ * this, the palette carried ~20 hue angles with no ramp behind them and
227
+ * --warning swung 25deg between themes, so a token could re-encode its
228
+ * own meaning on a theme toggle.
229
+ *
230
+ * Surfaces are no longer chroma-0: they carry a 0.003-0.016 warm cast at
231
+ * hue 73 (the board's orange) so the neutral 90% of the product reads as
232
+ * chosen rather than inherited from stock shadcn.
233
+ *
234
+ * Do not add a seventh hue. token-registry.ts asserts this in CI.
235
+ * ============================================================ */
236
+ --background: oklch(0.965 0.003 73);
237
+ --foreground: oklch(0.16 0.008 73);
238
+ --card: oklch(0.995 0.003 73);
239
+ --card-foreground: oklch(0.16 0.008 73);
240
+ --popover: oklch(0.995 0.003 73);
241
+ --popover-foreground: oklch(0.16 0.008 73);
217
242
  /* ============================================================
218
243
  * INVARIANT — "one token means 'this control is on'" (#156/#159).
219
244
  * --primary is the single pigment that says a control is the primary
@@ -224,18 +249,48 @@
224
249
  * into separate hues, and do not let --primary drift to a neutral —
225
250
  * that is what made the dark theme brandless (see .dark below).
226
251
  * --primary carries the FiestaBoard warm orange in BOTH themes.
252
+ *
253
+ * --primary IS THE TILE, and the label on it is board ink (#231).
254
+ *
255
+ * It was oklch(0.52 0.111 73) = #8f5d00 — the board's orange darkened until
256
+ * a WHITE label cleared AA on it. But maxC(0.52, 73) is exactly 0.111, so
257
+ * that value was not a badly-tuned orange, it was the most orange anything
258
+ * can be at that lightness: a mustard. The brand had been WCAG-darkened into
259
+ * a different colour, which is the failure the audit opened with.
260
+ *
261
+ * The white label was the only reason it had to be dark, and the white label
262
+ * was never designed — it is stock shadcn's --primary-foreground that nobody
263
+ * re-asked when the FiestaBoard palette landed. The dark theme had already
264
+ * worked this out and used board ink.
265
+ *
266
+ * So: --primary is the literal #f5a623, theme-invariant, with board ink on
267
+ * it at 8.84:1 — better than white on #8f5d00 ever managed (5.63:1). The
268
+ * accessible option was the MORE saturated one the whole time. It also makes
269
+ * the most-clicked pixel in the product the exact colour the hardware flips.
270
+ *
271
+ * White on this field is 2.03:1 and is banned; there is exactly one
272
+ * on-primary foreground token so that is enforceable.
227
273
  * ============================================================ */
228
- --primary: oklch(0.52 0.16 60);
229
- --primary-foreground: oklch(0.985 0 0);
230
- --secondary: oklch(0.96 0 0);
231
- --secondary-foreground: oklch(0.205 0 0);
232
- --muted: oklch(0.955 0 0);
233
- /* Muted text needs >=4.5:1 contrast vs --background to meet WCAG AA. */
234
- --muted-foreground: oklch(0.4 0 0);
235
- --accent: oklch(0.95 0 0);
236
- --accent-foreground: oklch(0.205 0 0);
237
- --destructive: oklch(0.43 0.245 27.325);
238
- --destructive-foreground: oklch(0.985 0 0);
274
+ --primary: #f5a623;
275
+ --primary-foreground: oklch(0.205 0.008 73);
276
+ /* Hover RAISES label contrast (8.84 -> 10.78); press lowers it to 7.04. */
277
+ --primary-hover: oklch(0.84 0.128 73);
278
+ --primary-active: oklch(0.72 0.153 73);
279
+ /* Board ink hairline that bounds the focus ring. The ring is --primary, and
280
+ an orange band is only 1.83:1 against a light page, so the INK carries the
281
+ boundary and the orange carries the identity. */
282
+ --ring-edge: oklch(0.205 0.008 73);
283
+ --secondary: oklch(0.925 0.003 73);
284
+ --secondary-foreground: oklch(0.16 0.008 73);
285
+ --muted: oklch(0.935 0.003 73);
286
+ /* Muted text needs >=4.5:1 contrast vs --background to meet WCAG AA.
287
+ 8.32:1 on --background, 9.07:1 on --card, 7.61:1 on --muted. */
288
+ --muted-foreground: oklch(0.4 0.006 73);
289
+ --accent: oklch(0.945 0.003 73);
290
+ --accent-foreground: oklch(0.16 0.008 73);
291
+ /* Board red. 5.36:1 on --background; white label 5.93:1. */
292
+ --destructive: oklch(0.52 0.16 29);
293
+ --destructive-foreground: oklch(1 0 0);
239
294
  /* ============================================================
240
295
  * --border vs --input: TWO DIFFERENT INTENTS, NOT ONE VALUE (#161).
241
296
  *
@@ -255,64 +310,231 @@
255
310
  * Derived from --foreground as an alpha fraction so the boundary
256
311
  * tracks the text color in every theme and season instead of being a
257
312
  * hand-tuned grey per palette. Percentages are the smallest that clear
258
- * 3:1 on both surfaces with headroom (light 48% -> 3.45/3.48,
259
- * dark 38% -> 3.37/3.44); they differ because contrast ramps faster
260
- * for light-on-dark than dark-on-light.
313
+ * 3:1 on both surfaces with headroom (light 50% -> 3.55/3.62,
314
+ * dark 40% -> 3.51/3.56); they differ because contrast ramps faster
315
+ * for light-on-dark than dark-on-light. They moved up 2 points with the
316
+ * hue lock because --foreground itself moved.
317
+ *
318
+ * --border now joins the same derivation instead of being a hand-tuned
319
+ * grey, so decoration tracks the text through every theme and season too.
261
320
  *
262
321
  * DO NOT collapse these two tokens into one value in either direction.
263
322
  * ============================================================ */
264
- --border: oklch(0.905 0 0);
265
- --input: color-mix(in oklch, var(--foreground) 48%, transparent);
266
- --ring: oklch(0.52 0.16 60); /* = --primary, see the invariant above */
267
- --sidebar: oklch(0.975 0 0);
268
- --sidebar-foreground: oklch(0.145 0 0);
269
- --sidebar-accent: oklch(0.92 0 0);
270
- --sidebar-accent-foreground: oklch(0.205 0 0);
271
- --sidebar-border: oklch(0.915 0 0);
272
- --sidebar-ring: oklch(0.52 0.16 60); /* = --primary, see the invariant above */
273
- --info: oklch(0.45 0.15 250);
274
- --info-foreground: oklch(0.985 0 0);
275
- --success: oklch(0.4 0.16 150);
276
- --success-foreground: oklch(0.985 0 0);
277
- /* --warning must never collide with --brand (#157). In light they share a
278
- near hue (70 vs 75) but are pulled apart by lightness1.61:1 between
279
- them so caution still reads as caution next to a brand accent. The
280
- dark pair is separated on hue instead; see .dark. */
281
- --warning: oklch(0.45 0.16 70);
282
- --warning-foreground: oklch(0.985 0 0);
283
- --tag-variable: oklch(0.45 0.2 280);
284
- --tag-variable-foreground: oklch(0.35 0.1 280);
285
- --tag-success: oklch(0.45 0.2 160);
286
- --tag-success-foreground: oklch(0.35 0.1 160);
287
- --tag-formula: oklch(0.45 0.15 80);
288
- --tag-formula-foreground: oklch(0.35 0.1 80);
289
- --category-weather: oklch(0.55 0.14 245);
290
- --category-weather-foreground: oklch(0.42 0.11 245);
291
- --category-transit: oklch(0.55 0.15 145);
292
- --category-transit-foreground: oklch(0.42 0.12 145);
293
- --category-data: oklch(0.55 0.16 305);
294
- --category-data-foreground: oklch(0.42 0.13 305);
295
- --category-entertainment: oklch(0.55 0.14 75);
296
- --category-entertainment-foreground: oklch(0.42 0.11 75);
297
- --category-art: oklch(0.55 0.16 30);
298
- --category-art-foreground: oklch(0.42 0.13 30);
299
- --category-utility: oklch(0.55 0.03 255);
300
- --category-utility-foreground: oklch(0.42 0.03 255);
301
- --category-home: oklch(0.55 0.12 180);
302
- --category-home-foreground: oklch(0.42 0.1 180);
303
- --overlay: oklch(0 0 0 / 80%);
304
- /* Brand accent - darkened for WCAG 2.2 AA (≥4.5:1 on light bg) */
305
- --brand: oklch(0.56 0.15 75);
306
- --brand-emphasis: oklch(0.52 0.16 60);
307
- --brand-foreground: oklch(1 0 0);
308
- /* Navigation active state indigo palette */
309
- --nav-active: oklch(0.44 0.19 265); /* indigo-600 */
310
- --nav-active-foreground: oklch(1 0 0); /* white */
311
- --nav-active-hover: oklch(0.5 0.17 265 / 15%); /* indigo tint hover */
312
- /* Fiesta gradient palette — used by sidebar and page icon gradient */
313
- --fiesta-red: #c97a72;
314
- --fiesta-orange: #c99662;
315
- --fiesta-purple: #9b7bb0;
323
+ --border: color-mix(in oklch, var(--foreground) 11%, transparent);
324
+ --input: color-mix(in oklch, var(--foreground) 50%, transparent);
325
+ --ring: var(--primary); /* = --primary, see the invariant above */
326
+ /* ============================================================
327
+ * THE RAIL FOLLOWS THE THEME (#231).
328
+ *
329
+ * It was theme-invariant for a while — one warm graphite rail in both
330
+ * modes, argued as "a fixture, like the board bezel". That argument was
331
+ * measured and it does not hold: the same hex is 14.87:1 against the
332
+ * light page and 1.20:1 against the dark one. It was never invariant in
333
+ * the way that matters. In light mode it was the single highest-contrast
334
+ * object on the screen, which is the exact "the chrome out-shouts the
335
+ * product" fault that got the gradient deleted.
336
+ *
337
+ * So light mode gets a light rail at L 0.93a step DOWN from the page,
338
+ * not up. Chrome separation in light mode is always a low ratio (1.11:1
339
+ * here) and that is correct: the edge is carried by the derived border,
340
+ * not by a luminance cliff. Going lighter than the page instead lands at
341
+ * 1.03-1.05:1, which is genuinely invisible.
342
+ *
343
+ * The fork is deliberately ONE PAIR — this token and its foreground.
344
+ * Everything else on the rail derives from --sidebar-foreground (see
345
+ * .sidebar-gradient), which is what stops this from re-becoming the
346
+ * many-valued fork that shipped black labels onto a dark rail.
347
+ * ============================================================ */
348
+ --sidebar: oklch(0.93 0.003 73);
349
+ --sidebar-foreground: oklch(0.16 0.008 73);
350
+ --sidebar-accent: oklch(1 0 0 / 14%);
351
+ --sidebar-accent-foreground: oklch(0.985 0.002 73);
352
+ --sidebar-border: oklch(1 0 0 / 10%);
353
+ --sidebar-ring: var(--primary); /* = --primary, see the invariant above */
354
+ /* Board blue. 5.01:1 on --background; white label 5.54:1. */
355
+ --info: oklch(0.52 0.151 251);
356
+ --info-foreground: oklch(1 0 0);
357
+ /* Board green. 4.76:1 on --background; white label 5.27:1. */
358
+ --success: oklch(0.52 0.147 133);
359
+ --success-foreground: oklch(1 0 0);
360
+ /* Board yellow. --warning and --brand used to sit 5deg apart on the hue
361
+ wheel and were held apart by lightness alone (1.61:1). Under the hue
362
+ lock they are 31deg apart — genuinely different pigments — so the
363
+ separation no longer depends on a lightness budget that light mode
364
+ could not afford. 1.56:1 of luminance remains between them.
365
+ NOTE: yellow cannot be both dark enough to read as text on white and
366
+ light enough to read as yellow. --warning is therefore a FILL, ICON
367
+ and BORDER color, never a text color: its label is board ink at 4.93:1.
368
+ Alert enforces this status color never touches a sentence. */
369
+ --warning: oklch(0.62 0.131 104);
370
+ --warning-foreground: oklch(0.205 0.008 73);
371
+ --tag-variable: oklch(0.52 0.16 315);
372
+ --tag-variable-foreground: oklch(0.4 0.1 315);
373
+ --tag-success: oklch(0.52 0.147 133);
374
+ --tag-success-foreground: oklch(0.4 0.1 133);
375
+ --tag-formula: oklch(0.52 0.151 251);
376
+ --tag-formula-foreground: oklch(0.4 0.1 251);
377
+ /* ============================================================
378
+ * PLUGIN CATEGORIES ARE LITERAL TILES (#231), theme-invariant.
379
+ *
380
+ * These are the exact hardware hexes from --color-board-* above, not an
381
+ * approximation of them: a physical tile does not have a theme, and it
382
+ * does not have a rounding error either. They are consumed ONLY by
383
+ * PluginCategoryBadge, which renders them as a tile square on the board's
384
+ * own dark surface — never as a text color, never as a page fill.
385
+ *
386
+ * The old set shared ONE lightness across all seven, so the badges
387
+ * measured 1.001:1 against each other and collapsed completely under
388
+ * deuteranopia. The tiles span 4.21:1 to 19.65:1 against board black, so
389
+ * six of the seven separate on lightness alone with hue removed.
390
+ * The exception is home/orange vs transit/green (dL 0.001) — that pair is
391
+ * told apart by the word beside the tile, not by the tile.
392
+ *
393
+ * The mapping is NOT claimed to come from the hardware docs, which
394
+ * publish a temperature-and-status vocabulary (red = alerts and hot),
395
+ * not a content taxonomy. It is argued on its own terms: transit is go,
396
+ * weather is sky, art is the brightest pigment the board owns, and
397
+ * utility is the white tile because a utility should be the quietest
398
+ * loud thing in the set.
399
+ * ============================================================ */
400
+ --category-entertainment: #eb4034; /* board red */
401
+ --category-entertainment-foreground: oklch(0.52 0.16 29);
402
+ --category-home: #f5a623; /* board orange */
403
+ --category-home-foreground: oklch(0.52 0.111 73);
404
+ --category-art: #f8e71c; /* board yellow */
405
+ --category-art-foreground: oklch(0.52 0.11 104);
406
+ --category-transit: #7ed321; /* board green */
407
+ --category-transit-foreground: oklch(0.52 0.147 133);
408
+ --category-weather: #4a90d9; /* board blue */
409
+ --category-weather-foreground: oklch(0.52 0.151 251);
410
+ --category-data: #9b59b6; /* board violet */
411
+ --category-data-foreground: oklch(0.52 0.16 315);
412
+ --category-utility: #ffffff; /* board white */
413
+ --category-utility-foreground: oklch(0.4 0.014 73);
414
+ /* ============================================================
415
+ * THE SIX HUES AT AN INK LIGHTNESS (#231).
416
+ *
417
+ * The --category-* tokens above are the literal tiles: correct on the
418
+ * board's black surface, useless anywhere else. Board yellow on light
419
+ * paper is 1.15:1 and board orange is 1.83:1 — neither can draw a 2px
420
+ * icon stroke, let alone text.
421
+ *
422
+ * These are the same six hues moved to the ink plateau, where they can.
423
+ * Every one clears 4.7:1 on --background and 5.1:1 on --card in light,
424
+ * and 9.2:1 / 8.5:1 in dark — so they are legal for icons, rules, and
425
+ * text alike. Hue is identical to the tile; only lightness differs.
426
+ *
427
+ * This is what a surface reaches for when it wants "one of the brand's
428
+ * colours" and is not a board.
429
+ * ============================================================ */
430
+ --hue-red: oklch(0.52 0.16 29);
431
+ --hue-orange: oklch(0.52 0.111 73);
432
+ --hue-yellow: oklch(0.52 0.11 104);
433
+ --hue-green: oklch(0.52 0.147 133);
434
+ --hue-blue: oklch(0.52 0.151 251);
435
+ --hue-violet: oklch(0.52 0.16 315);
436
+ /* Scrim. In LIGHT a dialog reads 5.39:1 against the scrimmed page.
437
+ In DARK that number is meaningless — WCAG ratio compresses to ~1.3:1
438
+ between any two near-black surfaces no matter how they are tuned — so
439
+ modality in dark is carried by --elevation-modal's ring and shadow,
440
+ not by a surface contrast ratio. Do not "fix" the dark figure; it is
441
+ not measuring what it looks like it is measuring. */
442
+ --overlay: oklch(0.16 0.012 73 / 60%);
443
+ /* ============================================================
444
+ * BRAND: ONE PIGMENT, THREE JOBS (#231).
445
+ *
446
+ * --brand is an INVARIANT ALIAS of --primary, declared the same way
447
+ * --ring is. They used to be two tokens that were byte-identical in
448
+ * light and 0.17 lightness apart in dark, so a consumer picked a variant
449
+ * in light mode and the theme toggle silently changed their mind. One
450
+ * pigment, stated as a rule, is the honest version of that.
451
+ *
452
+ * --brand-tile is the LITERAL #f5a623 tile, identical in both themes,
453
+ * and is a FIELD ONLY. It is 1.83:1 against a light page, so it can be a
454
+ * button face but can never be a link or a text color; its label must be
455
+ * --brand-tile-foreground (board ink, 8.83:1), never white (2.03:1).
456
+ *
457
+ * --brand-hover always moves TOWARD --foreground, so hover increases
458
+ * contrast in both themes (light 5.08 -> 6.51, dark 8.34 -> 9.51)
459
+ * instead of decreasing it, which is what the old --brand-emphasis
460
+ * hover did on the docs site (9.86 -> 3.99, below AA).
461
+ * ============================================================ */
462
+ /* --brand is the TEXT step and is no longer an alias of --primary. Now that
463
+ --primary is the literal tile, it is 1.83:1 on a light page — legal as a
464
+ field, illegal as a link. --hue-orange is the same hue at the ink plateau
465
+ (5.08:1 light / 9.63:1 dark), which is what body copy and links need. */
466
+ --brand: var(--hue-orange);
467
+ --brand-foreground: oklch(0.205 0.008 73);
468
+ --brand-tile: var(--primary); /* DEPRECATED alias — same pigment now */
469
+ /* ============================================================
470
+ * THE WORDMARK IS A LOGOTYPE, and WCAG 1.4.3 exempts logotypes from
471
+ * contrast outright: "text that is part of a logo or brand name has no
472
+ * minimum contrast requirement."
473
+ *
474
+ * It was reading --brand, which is --hue-orange — the token that exists
475
+ * SPECIFICALLY to clear AA as body text. So the one element the spec
476
+ * excuses was the one held to the strictest version of the rule, and it
477
+ * paid in saturation: #8f5d00, a dark ochre, sitting directly beside a
478
+ * taco mark full of bright orange. The wordmark looked duller than the
479
+ * icon it is locked up with.
480
+ *
481
+ * Freed from a floor it never had, it goes where it belongs — in the
482
+ * middle of the mark's own shell tones. 2.60:1 on the light rail.
483
+ * ============================================================ */
484
+ --brand-wordmark: oklch(0.66 0.141 73);
485
+ /* The backdrop is a DARK board in both themes, so light mode needs the
486
+ lower number: a full-strength wall of black tiles on paper stops being a
487
+ background and becomes the subject, with the card marooned in a hole
488
+ punched through it.
489
+ At this strength the field reads as TEXTURE rather than as text, and that
490
+ is the right call once it covers the whole viewport — the grid carries the
491
+ brand on its own, and legible words wallpapered edge to edge would compete
492
+ with the form the screen exists to get filled in. */
493
+ --board-backdrop-opacity: 0.16;
494
+ --brand-tile-foreground: oklch(0.205 0.008 73);
495
+ --brand-hover: color-mix(in oklch, var(--brand) 82%, var(--foreground));
496
+ /* DEPRECATED alias, removed in the next minor. Kept so consumers pinned
497
+ to an older minor — and the docs site's build canary, which greps the
498
+ compiled CSS for `bg-brand-emphasis` and exits(1) if it is missing —
499
+ do not break on the same commit that changes every value. */
500
+ --brand-emphasis: var(--brand);
501
+ /* Navigation active state — board violet, theme-invariant.
502
+ Was indigo-600, the only cool accent in a gold-and-terracotta brand
503
+ and the one token in the file carrying no rationale. White label
504
+ 9.11:1. The fill is only 1.81:1 against the rail, so the 3:1 state
505
+ indicator SC 1.4.11 asks for is carried by the gold left marker on
506
+ .nav-active, not by the fill.
507
+
508
+ NO LONGER TRUE, and the marker is gone with it. The pill was dark violet
509
+ because it carried a WHITE label, which forced it down to 1.81:1 against
510
+ the rail — under SC 1.4.11's 3:1 for a state — and the gold bar was a
511
+ patch for that number, not a design. Flipping the label to board ink frees
512
+ the fill to go bright — and the fill it goes to is --primary itself, so
513
+ "the current page" becomes the same pigment as "the focused control" and
514
+ "the primary action". That is the #156/#159 invariant finally covering the
515
+ thing it always claimed to. 8.12:1 against the rail, ink label 8.84:1, so
516
+ the fill carries the state on its own: 1.4.11 asks for 3:1, not for a
517
+ marker, and does not care which pixels supply it.
518
+
519
+ A bright violet also cleared the floor (5.66:1) and was rejected on
520
+ hierarchy, not on arithmetic: it became the loudest object on the screen,
521
+ louder than the primary action, which is backwards for a nav state. */
522
+ /* THE ONE PLACE THE PILL CANNOT BE --primary. The tile is L 0.78, so it
523
+ needs a dark surround: against a light rail it tops out at 2.00:1 even
524
+ if the rail were pure white, under 1.4.11's 3:1 for a state. On a light
525
+ rail the pill inverts instead — ink fill, page-coloured label, 16.78:1
526
+ against the rail. The dark rail keeps the tile (8.12:1), so "one pigment
527
+ means on" still holds everywhere it physically can. */
528
+ --nav-active: var(--foreground);
529
+ --nav-active-foreground: var(--background);
530
+ --nav-active-hover: oklch(1 0 0 / 14%);
531
+ /* Fiesta gradient palette — three board hues at ONE lightness (0.46),
532
+ identical in both themes. Because that surface never changes luminance
533
+ across its 117s loop, the contrast of anything sitting on it is a
534
+ constant rather than a function of wall-clock time. */
535
+ --fiesta-red: oklch(0.46 0.15 29);
536
+ --fiesta-orange: oklch(0.46 0.098 73);
537
+ --fiesta-purple: oklch(0.46 0.15 315);
316
538
  /* Page icon gradient stops — 6 vars so pride month can expand to a full rainbow */
317
539
  --icon-g1: var(--fiesta-red);
318
540
  --icon-g2: var(--fiesta-orange);
@@ -320,10 +542,12 @@
320
542
  --icon-g4: var(--fiesta-orange);
321
543
  --icon-g5: var(--fiesta-purple);
322
544
  --icon-g6: var(--fiesta-purple);
323
- /* Elevation - subtle card and modal shadows */
324
- --elevation-card: 0 1px 2px 0 rgb(0 0 0 / 0.04), 0 2px 8px -2px rgb(0 0 0 / 0.06);
325
- --elevation-card-hover: 0 2px 8px -2px rgb(0 0 0 / 0.08), 0 4px 16px -4px rgb(0 0 0 / 0.06);
326
- --elevation-modal: 0 4px 12px -2px rgb(0 0 0 / 0.08), 0 4px 6px -4px rgb(0 0 0 / 0.04);
545
+ /* Elevation - subtle card and modal shadows.
546
+ Warm-tinted rather than neutral black: a grey shadow under a warm card
547
+ on a warm page reads as a rendering artifact, not as depth. */
548
+ --elevation-card: 0 1px 2px 0 oklch(0.3 0.03 73 / 0.05), 0 2px 8px -2px oklch(0.3 0.03 73 / 0.07);
549
+ --elevation-card-hover: 0 2px 8px -2px oklch(0.3 0.03 73 / 0.09), 0 4px 16px -4px oklch(0.3 0.03 73 / 0.07);
550
+ --elevation-modal: 0 16px 40px -12px oklch(0.3 0.03 73 / 0.2), 0 4px 10px -4px oklch(0.3 0.03 73 / 0.1);
327
551
 
328
552
  /* ============================================================
329
553
  * Motion tokens (B4) — one duration scale + named easings.
@@ -379,15 +603,15 @@
379
603
  * surface | --radius-surface | rounded-lg | 10px | modal surfaces (dialog, alert-dialog), alert, sidebar nav pills
380
604
  * card | --radius-card | rounded-xl | 12px | card, page-header icon tile
381
605
  * pill | --radius-pill | rounded-full | ∞ | badge, switch, slider, overlay close (dialog + sheet), scroll-area thumb, sidebar edge toggle, empty-state icon
382
- * chrome | --radius-chrome | (css/inline) | 14px | desktop sidebar shell + its aurora inset
383
- * chrome-mobile | --radius-chrome-mobile | (css/inline) | 16px | mobile header/menu shell, its aurora inset, menu clip-path reveal
606
+ * chrome | --radius-chrome | (css/inline) | 14px | desktop sidebar shell
607
+ * chrome-mobile | --radius-chrome-mobile | (css/inline) | 16px | mobile header/menu shell, menu clip-path reveal
384
608
  *
385
609
  * Components KEEP their Tailwind rounded-* classes — those already
386
610
  * resolve through the --radius calc chain, so each role token below
387
611
  * is defined with the same math and stays in lockstep with them.
388
612
  * New components pick a ROLE from this table instead of a raw size.
389
613
  * The chrome radii are deliberately fixed px (not on the rem-based
390
- * --radius chain): they match the aurora canvas insets exactly.
614
+ * --radius chain).
391
615
  *
392
616
  * Deliberately off-scale (left as-is): pride-burst particle 2px,
393
617
  * scroll-area viewport rounded-[inherit]. Both are arbitrary-value
@@ -411,7 +635,7 @@
411
635
  * --z-sidebar 50 desktop sidebar rail
412
636
  * --z-sidebar-toggle 51 edge collapse button — LOCAL value: the
413
637
  * fixed z-50 aside is its own stacking
414
- * context, so 51 only beats the aurora
638
+ * context, so 51 only beats the rail's own
415
639
  * canvas / z-[1] content column inside it
416
640
  * --z-mobile-backdrop 90 dimming layer behind the mobile menu
417
641
  * --z-mobile-menu 95 mobile slide-down menu panel
@@ -443,7 +667,7 @@
443
667
  *
444
668
  * Not on the scale (local stacking / one-offs): select scroll
445
669
  * buttons z-10 (sticky inside the popup), sidebar/header content
446
- * z-[1] (above the aurora canvas), board-switch view-transition
670
+ * z-[1] (above the rail surface), board-switch view-transition
447
671
  * groups 0/1 (::view-transition pseudo-tree only).
448
672
  * ============================================================ */
449
673
  --z-popover: 50;
@@ -462,12 +686,16 @@
462
686
 
463
687
  .dark {
464
688
  color-scheme: dark; /* see the note in :root (#160) */
465
- --background: oklch(0.13 0 0);
466
- --foreground: oklch(0.985 0 0);
467
- --card: oklch(0.16 0 0);
468
- --card-foreground: oklch(0.985 0 0);
469
- --popover: oklch(0.16 0 0);
470
- --popover-foreground: oklch(0.985 0 0);
689
+ /* Hue lock applies here too: same six hues, same hue angles as :root.
690
+ Only lightness moves. Surfaces carry the same warm cast at hue 73, and
691
+ the card lift went 0.030 -> 0.050 so a dark card reads as an object
692
+ rather than an outline drawn on a void. */
693
+ --background: oklch(0.145 0.004 73);
694
+ --foreground: oklch(0.965 0.003 73);
695
+ --card: oklch(0.195 0.004 73);
696
+ --card-foreground: oklch(0.965 0.003 73);
697
+ --popover: oklch(0.235 0.004 73);
698
+ --popover-foreground: oklch(0.965 0.003 73);
471
699
  /* Brand-bearing primary (#156). This used to be oklch(0.922 0 0) — stock
472
700
  shadcn's achromatic dark primary, never re-designed when the FiestaBoard
473
701
  palette landed. Dark is the DEFAULT theme, so every default Button,
@@ -476,67 +704,94 @@
476
704
  surface (light 0.52 -> dark 0.72) and held on the same hue family.
477
705
  Upholds the "one token means 'this control is on'" invariant declared in
478
706
  :root: --ring and --sidebar-ring below repeat this exact value. */
479
- --primary: oklch(0.72 0.15 62);
480
- /* Near-black on-accent foreground, matching every other dark accent
481
- foreground in this block (destructive/info/success/warning all 0.15). */
482
- --primary-foreground: oklch(0.15 0 0);
483
- --secondary: oklch(0.215 0 0);
484
- --secondary-foreground: oklch(0.985 0 0);
485
- --muted: oklch(0.2 0 0);
486
- --muted-foreground: oklch(0.83 0 0);
487
- --accent: oklch(0.19 0 0);
488
- --accent-foreground: oklch(0.985 0 0);
489
- --destructive: oklch(0.8 0.191 22.216);
490
- --destructive-foreground: oklch(0.15 0 0);
707
+ /* Theme-invariant: a physical tile does not have a theme. Identical to
708
+ :root see the note there. 9.77:1 on --background here. */
709
+ --primary: #f5a623;
710
+ --primary-foreground: oklch(0.205 0.008 73);
711
+ --primary-hover: oklch(0.84 0.128 73);
712
+ --primary-active: oklch(0.72 0.153 73);
713
+ --ring-edge: oklch(0.205 0.008 73);
714
+ --secondary: oklch(0.255 0.004 73);
715
+ --secondary-foreground: oklch(0.965 0.003 73);
716
+ --muted: oklch(0.235 0.004 73);
717
+ /* 8.10:1 on --background, 7.51:1 on --card, 6.86:1 on --muted. */
718
+ --muted-foreground: oklch(0.725 0.004 73);
719
+ --accent: oklch(0.225 0.004 73);
720
+ --accent-foreground: oklch(0.965 0.003 73);
721
+ /* Board red. 6.88:1 on --background; board-ink label 6.23:1. */
722
+ --destructive: oklch(0.7 0.17 29);
723
+ --destructive-foreground: oklch(0.205 0.008 73);
491
724
  /* --border stays decorative-subtle; --input is the 3:1 control boundary.
492
725
  Full rationale in the :root block — do not merge the two. */
493
- --border: oklch(1 0 0 / 10%);
494
- --input: color-mix(in oklch, var(--foreground) 38%, transparent);
495
- --ring: oklch(0.72 0.15 62); /* = --primary, see the invariant in :root */
496
- --sidebar: oklch(0.145 0 0);
497
- --sidebar-foreground: oklch(0.985 0 0);
498
- --sidebar-accent: oklch(0.28 0 0);
499
- --sidebar-accent-foreground: oklch(0.985 0 0);
500
- --sidebar-border: oklch(1 0 0 / 8%);
501
- --sidebar-ring: oklch(0.72 0.15 62); /* = --primary, see the invariant in :root */
502
- --info: oklch(0.75 0.18 250);
503
- --info-foreground: oklch(0.15 0 0);
504
- --success: oklch(0.68 0.18 145);
505
- --success-foreground: oklch(0.15 0 0);
506
- /* Amber caution, deliberately OFF the brand hue (#157). This was
507
- oklch(0.78 0.14 75) byte-identical to --brand below, so "this is
508
- FiestaBoard" and "be careful" rendered as the same pigment and neither
509
- could be learned. Moved +20deg toward yellow and +0.08 lighter, which
510
- puts 1.34:1 of luminance and a clear hue break between the two. The
511
- 22deg drift from the light --warning (hue 70) is intentional and small:
512
- light must separate warning from brand on LIGHTNESS (it has to stay dark
513
- enough to be readable text on white), dark separates them on HUE. */
514
- --warning: oklch(0.86 0.16 95);
515
- --warning-foreground: oklch(0.15 0 0);
516
- --tag-variable: oklch(0.65 0.15 280);
517
- --tag-variable-foreground: oklch(0.85 0.08 280);
518
- --tag-success: oklch(0.65 0.15 160);
519
- --tag-success-foreground: oklch(0.85 0.08 160);
520
- --tag-formula: oklch(0.65 0.15 80);
521
- --tag-formula-foreground: oklch(0.85 0.08 80);
522
- --category-weather: oklch(0.68 0.13 245);
523
- --category-weather-foreground: oklch(0.85 0.08 245);
524
- --category-transit: oklch(0.68 0.14 145);
525
- --category-transit-foreground: oklch(0.85 0.09 145);
526
- --category-data: oklch(0.68 0.14 305);
527
- --category-data-foreground: oklch(0.85 0.09 305);
528
- --category-entertainment: oklch(0.68 0.13 75);
529
- --category-entertainment-foreground: oklch(0.85 0.08 75);
530
- --category-art: oklch(0.68 0.14 30);
531
- --category-art-foreground: oklch(0.85 0.09 30);
532
- --category-utility: oklch(0.68 0.03 255);
533
- --category-utility-foreground: oklch(0.85 0.02 255);
534
- --category-home: oklch(0.68 0.11 180);
535
- --category-home-foreground: oklch(0.85 0.07 180);
536
- --overlay: oklch(0 0 0 / 80%);
537
- --brand: oklch(0.78 0.14 75);
538
- --brand-emphasis: oklch(0.55 0.16 60);
539
- --brand-foreground: oklch(1 0 0);
726
+ --border: color-mix(in oklch, var(--foreground) 12%, transparent);
727
+ --input: color-mix(in oklch, var(--foreground) 40%, transparent);
728
+ --ring: var(--primary); /* = --primary, see the invariant in :root */
729
+ /* Warm graphite, one step up from --card so the rail reads as housing
730
+ rather than as more page. See the note in :root. */
731
+ --sidebar: oklch(0.24 0.005 73);
732
+ --sidebar-foreground: oklch(0.985 0.002 73);
733
+ --sidebar-accent: oklch(1 0 0 / 14%);
734
+ --sidebar-accent-foreground: oklch(0.985 0.002 73);
735
+ --sidebar-border: oklch(1 0 0 / 10%);
736
+ --sidebar-ring: var(--primary); /* = --primary, see the invariant in :root */
737
+ /* Board blue. 8.57:1 on --background; board-ink label 7.76:1. */
738
+ --info: oklch(0.74 0.139 251);
739
+ --info-foreground: oklch(0.205 0.008 73);
740
+ /* Board green. 10.40:1 on --background; board-ink label 9.42:1. */
741
+ --success: oklch(0.78 0.17 133);
742
+ --success-foreground: oklch(0.205 0.008 73);
743
+ /* Board yellow, the SAME hue as the light --warning (104). The old pair
744
+ drifted 25deg between themes — the only hue-unstable token in the file
745
+ — because light had to separate warning from brand on lightness while
746
+ dark separated them on hue. Under the hue lock the separation is
747
+ structural in both themes: warning is yellow, brand is orange, 31deg
748
+ apart, and neither has to move to stay legible.
749
+ 13.05:1 on --background; board-ink label 11.81:1. */
750
+ --warning: oklch(0.86 0.17 104);
751
+ --warning-foreground: oklch(0.205 0.008 73);
752
+ --tag-variable: oklch(0.78 0.164 315);
753
+ --tag-variable-foreground: oklch(0.86 0.1 315);
754
+ --tag-success: oklch(0.78 0.17 133);
755
+ --tag-success-foreground: oklch(0.86 0.1 133);
756
+ --tag-formula: oklch(0.78 0.116 251);
757
+ --tag-formula-foreground: oklch(0.86 0.09 251);
758
+ /* Categories are literal tiles and therefore theme-invariant these are
759
+ byte-identical to :root on purpose. See the note there. */
760
+ --category-entertainment: #eb4034; /* board red */
761
+ --category-entertainment-foreground: oklch(0.78 0.129 29);
762
+ --category-home: #f5a623; /* board orange */
763
+ --category-home-foreground: oklch(0.78 0.166 73);
764
+ --category-art: #f8e71c; /* board yellow */
765
+ --category-art-foreground: oklch(0.78 0.164 104);
766
+ --category-transit: #7ed321; /* board green */
767
+ --category-transit-foreground: oklch(0.78 0.17 133);
768
+ --category-weather: #4a90d9; /* board blue */
769
+ --category-weather-foreground: oklch(0.78 0.116 251);
770
+ --category-data: #9b59b6; /* board violet */
771
+ --category-data-foreground: oklch(0.78 0.164 315);
772
+ --category-utility: #ffffff; /* board white */
773
+ --category-utility-foreground: oklch(0.725 0.012 73);
774
+ /* Same six hues, lifted to the dark ink plateau. See :root. */
775
+ --hue-red: oklch(0.78 0.129 29);
776
+ --hue-orange: oklch(0.78 0.166 73);
777
+ --hue-yellow: oklch(0.78 0.164 104);
778
+ --hue-green: oklch(0.78 0.17 133);
779
+ --hue-blue: oklch(0.78 0.116 251);
780
+ --hue-violet: oklch(0.78 0.164 315);
781
+ /* See the :root note: the dark figure here is not a contrast ratio worth
782
+ quoting. Modality in dark is carried by --elevation-modal. */
783
+ --overlay: oklch(0.08 0.006 73 / 72%);
784
+ --brand: var(--hue-orange); /* text step — see :root */
785
+ --brand-foreground: oklch(0.205 0.008 73);
786
+ --brand-tile: var(--primary); /* DEPRECATED alias — same pigment now */
787
+ /* On a dark rail the wordmark can be the literal tile — the exact pigment
788
+ the hardware flips, and the one the icon beside it already uses. 8.12:1. */
789
+ --brand-wordmark: var(--primary);
790
+ /* Dark tiles on a dark page need far less to read. See :root. */
791
+ --board-backdrop-opacity: 0.28;
792
+ --brand-tile-foreground: oklch(0.205 0.008 73);
793
+ --brand-hover: color-mix(in oklch, var(--brand) 82%, var(--foreground));
794
+ --brand-emphasis: var(--brand); /* DEPRECATED — see :root */
540
795
  /* Navigation active state — indigo palette.
541
796
  NOT "lighter for dark mode" (#162): --nav-active is the solid FALLBACK
542
797
  fill for the active nav pill, used whenever the frosted-glass treatment
@@ -547,17 +802,29 @@
547
802
  one. Darkened to clear AAA (7.47:1) instead. Kept as light as AAA allows
548
803
  rather than the 0.42 the audit suggested (8.91:1), because darkening the
549
804
  pill trades text contrast against the pill's own separation from the
550
- surface behind it — see the fallback rules near .nav-active. */
551
- --nav-active: oklch(0.46 0.19 265);
552
- --nav-active-foreground: oklch(1 0 0); /* white */
553
- --nav-active-hover: oklch(0.59 0.19 265 / 20%); /* indigo tint hover */
554
- /* Fiesta gradient palette lighter for visibility on dark card backgrounds */
555
- --fiesta-red: #e08a82;
556
- --fiesta-orange: #d4a870;
557
- --fiesta-purple: #b08ec8;
558
- --elevation-card: 0 1px 3px 0 rgb(0 0 0 / 0.2), 0 0 0 1px oklch(1 0 0 / 6%);
559
- --elevation-card-hover: 0 2px 8px -2px rgb(0 0 0 / 0.3), 0 0 0 1px oklch(1 0 0 / 10%);
560
- --elevation-modal: 0 8px 24px -4px rgb(0 0 0 / 0.25), 0 4px 8px -4px rgb(0 0 0 / 0.15);
805
+ surface behind it — see the fallback rules near .nav-active.
806
+
807
+ Now board violet and theme-invariant identical to :root. Because the
808
+ rail is a flat, known surface, the pill no
809
+ longer needs a per-theme value, and its contrast no longer depends on
810
+ where a 117-second animation happens to be. */
811
+ --nav-active: var(--primary); /* see :root */
812
+ --nav-active-foreground: var(--primary-foreground);
813
+ --nav-active-hover: oklch(1 0 0 / 14%);
814
+ /* Identical to :root. See the note there. */
815
+ --fiesta-red: oklch(0.46 0.15 29);
816
+ --fiesta-orange: oklch(0.46 0.098 73);
817
+ --fiesta-purple: oklch(0.46 0.15 315);
818
+ /* A dark card is an object, not an outline. This is the same lit-top /
819
+ shadowed-base recipe that makes a character tile read as a raised flap
820
+ (see components/board/board-surfaces.ts charLeafBoxShadow.black), so
821
+ depth in the chrome and depth on the board are one idea. */
822
+ --elevation-card:
823
+ inset 0 1px 0 rgb(255 255 255 / 0.1), inset 0 -1px 0 rgb(0 0 0 / 0.55), 0 2px 8px -3px rgb(0 0 0 / 0.55);
824
+ --elevation-card-hover:
825
+ inset 0 1px 0 rgb(255 255 255 / 0.14), inset 0 -1px 0 rgb(0 0 0 / 0.55), 0 4px 14px -4px rgb(0 0 0 / 0.6);
826
+ /* Carries modality in dark, where a surface contrast ratio cannot. */
827
+ --elevation-modal: 0 24px 48px -12px rgb(0 0 0 / 0.75), inset 0 0 0 1px oklch(1 0 0 / 0.14);
561
828
  }
562
829
 
563
830
  @layer base {
@@ -668,54 +935,6 @@
668
935
  * Consumed by the chrome components (Sidebar, PageHeader, …).
669
936
  * ============================================================ */
670
937
 
671
- /* Pride styling lives in seasons/pride.css alongside the draft seasons. */
672
-
673
- /* Season easter egg — confetti burst particles spawned by JS on logo click
674
- (class name kept as pride-burst-* for app compatibility; every season's
675
- fireSeasonBurst() uses it) */
676
- @keyframes pride-burst {
677
- 0% {
678
- transform: translate(0, 0) rotate(0deg) scale(1);
679
- opacity: 1;
680
- }
681
- 100% {
682
- transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(0.2);
683
- opacity: 0;
684
- }
685
- }
686
- /* Wrapper so the 48 particles append to the live tree in a single mutation
687
- and share one animationend listener. Fixed (out of flow) so the empty
688
- container never affects body layout; no transform/filter/contain here, so
689
- the fixed particles still resolve against the viewport. */
690
- .pride-burst {
691
- position: fixed;
692
- top: 0;
693
- left: 0;
694
- /* The fixed container forms a stacking context, trapping the particles'
695
- z-index inside it — so the container itself must sit at confetti level
696
- or the burst paints under sheets/modals/tooltips. */
697
- z-index: var(--z-confetti);
698
- pointer-events: none;
699
- }
700
- .pride-burst-particle {
701
- position: fixed;
702
- width: 7px;
703
- height: 7px;
704
- border-radius: 2px;
705
- pointer-events: none;
706
- z-index: var(--z-confetti);
707
- /* 0.8s default is a one-off (JS overrides via --dur), so it stays inline */
708
- animation: pride-burst var(--dur, 0.8s) var(--motion-ease-out) forwards;
709
- }
710
-
711
- /* fireSeasonBurst() already skips the burst under reduced motion, but mirror
712
- every other motion surface in this file with a CSS gate as a backstop. */
713
- @media (prefers-reduced-motion: reduce) {
714
- .pride-burst-particle {
715
- animation: none !important;
716
- }
717
- }
718
-
719
938
  /* Board switch transition — the app-wide board selector wraps its state
720
939
  update in document.startViewTransition() (see current-board-context.tsx),
721
940
  stamping html[data-board-switch="forward"|"backward"]. Only the page
@@ -880,118 +1099,84 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
880
1099
  /* Sidebar & mobile header: Fiesta-brand gradient (red, orange, purple).
881
1100
  Repeating gradient for seamless loop (no flash).
882
1101
  Static when prefers-reduced-motion. */
883
- @keyframes sidebar-gradient-flow {
884
- /* Runs on .sidebar-gradient-layer::before (see below): translate exactly one
885
- repeat period (300% of the container = 75% of the 400% layer) for a
886
- seamless loop — no flash at end. transform-only so the loop stays on the
887
- compositor with zero per-frame repaint (issue #57). */
888
- 0% {
889
- transform: translateY(0);
890
- }
891
- 100% {
892
- transform: translateY(-75%);
893
- }
894
- }
895
1102
 
896
- /* Logo in light mode: deep warm brown Fiesta + black Board for contrast across gradient */
897
- .sidebar-gradient:not(.dark) .logo-on-gradient,
898
- .sidebar-gradient-horizontal:not(.dark) .logo-on-gradient {
899
- --brand: oklch(0.28 0.08 50);
900
- --sidebar-foreground: oklch(0 0 0);
901
- }
1103
+ /* One logo treatment, both themes. These used to fork — near-black "Board" on
1104
+ a light coral rail in light mode, white on a dark rail in dark mode. The
1105
+ rail is now a fixture that does not change with the theme, so the fork is
1106
+ not just unnecessary, it was actively wrong: it painted black text onto a
1107
+ dark rail the moment the light stops were darkened. */
1108
+ /* The `.logo-on-gradient` override is gone: FiestaLogo now reads
1109
+ --brand-wordmark and --foreground directly, so it is correct on the rail, on
1110
+ a wizard card and anywhere else without a scoped rule pinning it. That rule
1111
+ had already caused two bugs by pinning what should have been a token — white
1112
+ "Board" that vanished on a light rail, then a tile-orange "Fiesta" that would
1113
+ have. */
902
1114
 
903
- /* Logo in dark mode: white Board text, brighter brand for contrast on dark gradient */
904
- .dark .sidebar-gradient .logo-on-gradient,
905
- .dark .sidebar-gradient-horizontal .logo-on-gradient {
906
- --brand: oklch(0.78 0.14 75);
907
- --sidebar-foreground: oklch(1 0 0);
908
- }
909
-
910
- /* Light mode: soft gradient (coral peach lavender).
911
- Stops derive from the canonical --fiesta-* brand gradient tokens
912
- never fork the hexes here; seasons retint via --sidebar-fiesta-*. */
1115
+ /* ============================================================
1116
+ * THE RAIL IS QUIET; THE LOCKUP CARRIES THE BRAND (#231).
1117
+ *
1118
+ * This used to be a full-height animated gradient in two hand-forked
1119
+ * palettes — a light coral/peach ramp in light mode, deep oxblood-to-olive
1120
+ * in dark. Three things were wrong with it, and they compounded:
1121
+ *
1122
+ * 1. On an empty install the rail was the brightest, most saturated
1123
+ * object on the screen. The chrome out-shouted the product: the board
1124
+ * is the thing to look at, and it was losing to the navigation.
1125
+ * 2. A red → orange → violet ramp spends its middle third in the browns
1126
+ * and olives between those hues. Over 800px of rail that reads as mud,
1127
+ * and no amount of retuning the stops fixes an interpolation problem.
1128
+ * 3. Nothing sitting on it could be designed, because its background was
1129
+ * a function of wall-clock time.
1130
+ *
1131
+ * The gradient is now confined to the LOCKUP BAND at the top — behind the
1132
+ * logo and the board switcher, where identity belongs and where 88px of
1133
+ * colour reads as a brand mark rather than as a wall. Below that it fades
1134
+ * into the flat rail. The nav body sits on one predictable surface, which
1135
+ * is what lets the active pill and the labels be tuned once instead of
1136
+ * sampled per frame.
1137
+ *
1138
+ * The rail is the same warm graphite in both themes (see --sidebar), so the
1139
+ * whole light/dark fork below it is gone — along with the class of bug where
1140
+ * darkening the light stops left black labels on a dark rail.
1141
+ * ============================================================ */
1142
+ /* Every local override here is DERIVED from --sidebar-foreground rather than
1143
+ hardcoded to white. That is the whole reason the rail can follow the theme
1144
+ again: the previous set assumed a dark rail, so the moment the rail went
1145
+ light it painted white labels on a light surface. Deriving means the rail
1146
+ has exactly one value to fork (--sidebar and its foreground) and everything
1147
+ sitting on it follows for free. */
913
1148
  .sidebar-gradient {
914
- --sidebar-fiesta-red: var(--fiesta-red);
915
- --sidebar-fiesta-orange: var(--fiesta-orange);
916
- --sidebar-fiesta-purple: var(--fiesta-purple);
917
- background: repeating-linear-gradient(
918
- 170deg,
919
- var(--sidebar-fiesta-red) 0%,
920
- var(--sidebar-fiesta-orange) 33.33%,
921
- var(--sidebar-fiesta-purple) 66.66%,
922
- var(--sidebar-fiesta-red) 100%
923
- );
924
- background-size: 100% 300%;
925
- background-position: 50% 0%;
926
- background-repeat: repeat-y;
927
- color: oklch(0 0 0);
1149
+ background: var(--sidebar);
1150
+ color: var(--sidebar-foreground);
928
1151
  border-radius: var(--radius-chrome);
929
- --sidebar-foreground: oklch(0 0 0);
930
- --sidebar-accent: oklch(1 0 0 / 35%);
931
- --sidebar-accent-foreground: oklch(0 0 0);
932
- --sidebar-border: oklch(0 0 0 / 12%);
933
- --foreground: oklch(0 0 0);
934
- --muted-foreground: oklch(0.15 0 0);
935
- --accent: oklch(1 0 0 / 28%);
936
- --accent-foreground: oklch(0 0 0);
937
- --border: oklch(0 0 0 / 12%);
938
- }
939
- /* Dark mode: rich Fiesta gradient — deep-toned stops defined ONCE for both
940
- the vertical sidebar and the mobile horizontal header. */
941
- .dark .sidebar-gradient,
942
- .dark .sidebar-gradient-horizontal {
943
- --sidebar-fiesta-red: #7a2a24;
944
- --sidebar-fiesta-orange: #6d5014;
945
- --sidebar-fiesta-purple: #4e3070; /* board violet, darker for dark theme */
946
- }
947
- .dark .sidebar-gradient {
948
- color: oklch(1 0 0);
949
- --sidebar-foreground: oklch(1 0 0);
950
- --sidebar-accent: oklch(1 0 0 / 24%);
951
- --sidebar-accent-foreground: oklch(1 0 0);
952
- --sidebar-border: oklch(1 0 0 / 18%);
953
- --foreground: oklch(1 0 0);
954
- --muted-foreground: oklch(1 0 0 / 85%);
955
- --accent: oklch(1 0 0 / 20%);
956
- --accent-foreground: oklch(1 0 0);
957
- --border: oklch(1 0 0 / 18%);
1152
+ --sidebar-accent: color-mix(in oklch, var(--sidebar-foreground) 10%, transparent);
1153
+ --sidebar-accent-foreground: var(--sidebar-foreground);
1154
+ --sidebar-border: color-mix(in oklch, var(--sidebar-foreground) 14%, transparent);
1155
+ --foreground: var(--sidebar-foreground);
1156
+ --muted-foreground: color-mix(in oklch, var(--sidebar-foreground) 72%, transparent);
1157
+ --accent: color-mix(in oklch, var(--sidebar-foreground) 10%, transparent);
1158
+ --accent-foreground: var(--sidebar-foreground);
1159
+ --border: color-mix(in oklch, var(--sidebar-foreground) 14%, transparent);
958
1160
  }
959
1161
 
1162
+ /* No CSS gradient paints here any more (#231). The rail is a flat surface;
1163
+ colour arrives through the wordmark, the active pill, and the per-page hue
1164
+ on the page icon. */
1165
+
960
1166
  .sidebar-gradient-horizontal {
961
- /* Light mode: identical stops to the vertical sidebar one brand gradient
962
- on every viewport (previously the mobile header ran subtly warmer). */
963
- --sidebar-fiesta-red: var(--fiesta-red);
964
- --sidebar-fiesta-orange: var(--fiesta-orange);
965
- --sidebar-fiesta-purple: var(--fiesta-purple);
966
- background: repeating-linear-gradient(
967
- 135deg,
968
- var(--sidebar-fiesta-red) 0,
969
- var(--sidebar-fiesta-orange) 33.33%,
970
- var(--sidebar-fiesta-purple) 66.66%,
971
- var(--sidebar-fiesta-red) 100%
972
- );
973
- background-size: 400% 100%;
974
- background-position: 0% 50%;
975
- color: oklch(0 0 0);
1167
+ /* Flat, like the desktop rail (#231). The mobile bar and the rail are the
1168
+ same object at two widths, so they get the same treatment a gradient on
1169
+ one and not the other is how two chromes start disagreeing. */
1170
+ background: var(--sidebar);
1171
+ color: var(--sidebar-foreground);
976
1172
  border-radius: var(--radius-chrome-mobile);
977
- --foreground: oklch(0 0 0);
978
- --sidebar-accent: oklch(1 0 0 / 35%);
979
- --sidebar-accent-foreground: oklch(0 0 0);
980
- --muted-foreground: oklch(0.15 0 0);
981
- --accent: oklch(1 0 0 / 28%);
982
- --accent-foreground: oklch(0 0 0);
983
- --border: oklch(0 0 0 / 12%);
984
- }
985
- @keyframes sidebar-gradient-flow-h {
986
- /* Runs on .sidebar-gradient-layer::before: one period (33.33% of the 400%
987
- background box = 100% of the container = 50% of the 200% layer) per loop —
988
- same wrap as the old background-position scroll, compositor-only. */
989
- 0% {
990
- transform: translateX(0);
991
- }
992
- 100% {
993
- transform: translateX(-50%);
994
- }
1173
+ --foreground: var(--sidebar-foreground);
1174
+ --sidebar-accent: color-mix(in oklch, var(--sidebar-foreground) 12%, transparent);
1175
+ --sidebar-accent-foreground: var(--sidebar-foreground);
1176
+ --muted-foreground: color-mix(in oklch, var(--sidebar-foreground) 78%, transparent);
1177
+ --accent: color-mix(in oklch, var(--sidebar-foreground) 12%, transparent);
1178
+ --accent-foreground: var(--sidebar-foreground);
1179
+ --border: color-mix(in oklch, var(--sidebar-foreground) 16%, transparent);
995
1180
  }
996
1181
  .dark .sidebar-gradient-horizontal {
997
1182
  color: oklch(1 0 0);
@@ -1004,67 +1189,12 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1004
1189
  --border: oklch(1 0 0 / 18%);
1005
1190
  }
1006
1191
 
1007
- /* Compositor-friendly gradient flow (issue #57).
1008
- The .sidebar-gradient(-horizontal) containers above keep a STATIC copy of
1009
- the gradient as their background the single surface seasons override
1010
- (light retint via --sidebar-fiesta-*, dark seasons flat-hex via
1011
- `background` + `animation: none`) and the graceful static fallback for
1012
- markup without the layer element. The moving copy lives on
1013
- .sidebar-gradient-layer::before: it re-inherits that same background
1014
- (container → layer → ::before), overscans it by exactly one repeat
1015
- period, and translates with a transform loop — so the flow runs on the
1016
- compositor with no per-frame rasterization, and the .nav-active
1017
- backdrop-filter pills get a cacheable backdrop (#93). Dark seasons stay
1018
- correct for free: the inherited background is their flat hex, so the
1019
- layer paints the same flat color. The layer clips itself to the rounded
1020
- corners because the desktop <aside> cannot take overflow:hidden (the
1021
- edge toggle button overhangs it). */
1022
- .sidebar-gradient,
1023
- .sidebar-gradient-horizontal {
1024
- /* Keep the z-index:-1 layer above this element's own background even when
1025
- the container isn't otherwise a stacking context (e.g. story wrappers). */
1026
- isolation: isolate;
1027
- }
1028
- .sidebar-gradient-layer {
1029
- position: absolute;
1030
- inset: 0;
1031
- z-index: -1; /* under the container's content, above its background */
1032
- overflow: hidden;
1033
- border-radius: inherit;
1034
- background: inherit; /* inheritance link: ::before re-inherits from here */
1035
- pointer-events: none;
1036
- }
1037
- .sidebar-gradient-layer::before {
1038
- content: "";
1039
- position: absolute;
1040
- top: 0;
1041
- left: 0;
1042
- background: inherit;
1043
- will-change: transform;
1044
- }
1045
- /* Vertical: the container's gradient box is 100% × 300%. The ::before
1046
- overscans to 400% height (100% visible + one 300% travel period), so
1047
- translateY(-75%) [= -300% of the container] wraps onto the repeat. */
1048
- .sidebar-gradient .sidebar-gradient-layer::before {
1049
- width: 100%;
1050
- height: 400%;
1051
- background-size: 100% 75%; /* same box as the container's 100% 300% */
1052
- background-position: 50% 0%; /* pinned to the container's t=0 anchor */
1053
- background-repeat: repeat-y;
1054
- animation: sidebar-gradient-flow 117s linear infinite;
1055
- }
1056
- /* Horizontal: the container's gradient box is 400% × 100%; one loop travels
1057
- 33.33% background-position = 100% of the container, so the ::before
1058
- overscans to 200% width and translateX(-50%) reproduces one period. */
1059
- .sidebar-gradient-horizontal .sidebar-gradient-layer::before {
1060
- width: 200%;
1061
- height: 100%;
1062
- background-size: 200% 100%; /* same box as the container's 400% 100% */
1063
- background-position: 0% 50%;
1064
- background-repeat: repeat;
1065
- animation: sidebar-gradient-flow-h 117s linear 0.35s infinite;
1066
- }
1067
-
1192
+ /* .sidebar-gradient-layer is gone (#57 is closed). It existed to host a
1193
+ compositor-only copy of the scrolling gradient, and later to give the
1194
+ seasonal WebGL aurora a stacking context under the rail's content. Both of
1195
+ those are gone now, and a flat background clips to its own
1196
+ border-radius, so the element, the `isolation: isolate` it required, and the
1197
+ will-change hint it carried all had nothing left to do. */
1068
1198
  /* Navigation entrance animations */
1069
1199
  @keyframes nav-slide-in-left {
1070
1200
  from {
@@ -1086,17 +1216,10 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1086
1216
  transform: translateY(0);
1087
1217
  }
1088
1218
  }
1089
- /* Desktop: entrance on the container; the gradient flow runs on
1090
- .sidebar-gradient-layer::before and starts after 400ms. */
1091
- /* 117s gradient loop is an ambient one-off; its 0.4s/0.35s start delays
1092
- mirror the entrance durations (slowest/slower) and stay literal with them */
1093
1219
  @media (min-width: 1024px) {
1094
1220
  .sidebar-gradient {
1095
1221
  animation: nav-slide-in-left var(--motion-duration-slowest) var(--motion-ease-out) both;
1096
1222
  }
1097
- .sidebar-gradient .sidebar-gradient-layer::before {
1098
- animation-delay: 0.4s;
1099
- }
1100
1223
  }
1101
1224
  .sidebar-gradient-horizontal {
1102
1225
  animation: nav-slide-in-top var(--motion-duration-slower) var(--motion-ease-out) both;
@@ -1132,157 +1255,149 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1132
1255
  .content-shift {
1133
1256
  transition: none;
1134
1257
  }
1135
- .sidebar-gradient {
1136
- animation: none;
1137
- background-position: 50% 25%;
1138
- }
1258
+ .sidebar-gradient,
1139
1259
  .sidebar-gradient-horizontal {
1140
1260
  animation: none;
1141
- background-position: 25% 50%;
1142
- }
1143
- /* Freeze the compositor layer at the same intended mid-gradient pose
1144
- (equivalent to background-position 50% 25% / 25% 50% on the container). */
1145
- .sidebar-gradient .sidebar-gradient-layer::before {
1146
- animation: none;
1147
- transform: translateY(-12.5%); /* -50% of the container = bg-position 50% 25% */
1148
- }
1149
- .sidebar-gradient-horizontal .sidebar-gradient-layer::before {
1150
- animation: none;
1151
- transform: translateX(-37.5%); /* -75% of the container = bg-position 25% 50% */
1152
1261
  }
1153
1262
  }
1154
1263
  .reduce-motion .sidebar-transition,
1155
1264
  .reduce-motion .content-shift {
1156
1265
  transition: none;
1157
1266
  }
1158
- .reduce-motion .sidebar-gradient {
1159
- animation: none;
1160
- background-position: 50% 25%;
1161
- }
1267
+ .reduce-motion .sidebar-gradient,
1162
1268
  .reduce-motion .sidebar-gradient-horizontal {
1163
1269
  animation: none;
1164
- background-position: 25% 50%;
1165
- }
1166
- /* Same freeze pose as the prefers-reduced-motion block above. */
1167
- .reduce-motion .sidebar-gradient .sidebar-gradient-layer::before {
1168
- animation: none;
1169
- transform: translateY(-12.5%);
1170
- }
1171
- .reduce-motion .sidebar-gradient-horizontal .sidebar-gradient-layer::before {
1172
- animation: none;
1173
- transform: translateX(-37.5%);
1174
1270
  }
1175
1271
 
1176
- /* Navigation active state — solid fallback (non-gradient surfaces, no backdrop support) */
1272
+ /* ============================================================
1273
+ * Navigation active state — ONE treatment, every surface (#231).
1274
+ *
1275
+ * This used to be five: a frosted backdrop-filter pill over the gradient,
1276
+ * a darker frosted variant for dark mode, and three fallback tiers
1277
+ * (@supports, prefers-reduced-transparency, prefers-contrast) that each
1278
+ * re-declared the solid fill. All five existed because the pill sat on an
1279
+ * animated gradient whose luminance changed over a 117-second loop, so its
1280
+ * contrast could not be computed — only sampled at runtime.
1281
+ *
1282
+ * The rail is now one flat lightness, so the surface
1283
+ * behind the pill is a known quantity and the pill can simply be opaque.
1284
+ * A solid board-violet fill carries a white label at 9.11:1 in both themes.
1285
+ *
1286
+ * NO MARKER. There was a 3px gold bar on the leading edge, and it existed for
1287
+ * one reason: the pill was dark violet with a white label, which put it at
1288
+ * 1.81:1 against the rail, under SC 1.4.11's 3:1 for a state. The bar was a
1289
+ * patch for a number, and a left accent rail is a stock device besides.
1290
+ *
1291
+ * Flipping the label to board ink lets the fill go bright — 5.66:1 against the
1292
+ * rail — so the state carries itself. 1.4.11 asks for 3:1; it does not ask for
1293
+ * a marker, and it does not care which pixels supply it. `font-semibold` on
1294
+ * the label remains as the non-colour second signal.
1295
+ * ============================================================ */
1177
1296
  .nav-active {
1178
1297
  background: var(--nav-active);
1179
1298
  color: var(--nav-active-foreground);
1180
1299
  }
1181
- /* The dark --nav-active fill is deliberately dark enough for AAA white labels
1182
- (#162), which necessarily brings it closer in luminance to the near-black
1183
- surfaces it sits on. A 1px light inset keeps the pill's OWN boundary
1184
- identifiable (SC 1.4.11 — "state" of a component) without reintroducing the
1185
- transparency these fallbacks exist to avoid. Light mode needs no help: its
1186
- pill already sits 7.6:1 off --sidebar. */
1187
- .dark .nav-active {
1188
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.22);
1189
- }
1300
+
1190
1301
  .nav-active-hover:hover {
1191
1302
  background: var(--nav-active-hover);
1192
1303
  }
1193
1304
 
1194
- /*
1195
- * Fiesta sidebar active pill: frosted glass over the animated gradient.
1196
- * backdrop-filter samples the colors behind the row so the highlight moves with the gradient.
1197
- * A minimum-opacity cool veil keeps white text WCAG 2.2 AA on the lightest gradient stops.
1198
- */
1199
- .sidebar-gradient .nav-active,
1200
- .sidebar-gradient-horizontal .nav-active {
1201
- background: oklch(0.26 0.09 285 / 0.62);
1202
- color: oklch(1 0 0);
1203
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.18);
1204
- backdrop-filter: blur(14px) saturate(1.35);
1205
- -webkit-backdrop-filter: blur(14px) saturate(1.35);
1305
+ /* The hover tint is the only chrome token left carrying alpha, so this is
1306
+ the one transparency branch that still has work to do. */
1307
+ /* A literal, not a color-mix. Mixing hue 315 into the hue-73 rail takes the
1308
+ shorter arc and lands around hue 47 a seventh hue, and a brown one, inside
1309
+ a palette whose whole rule is six. */
1310
+ @media (prefers-reduced-transparency: reduce) {
1311
+ .nav-active-hover:hover {
1312
+ background: oklch(0.34 0.02 73);
1313
+ }
1206
1314
  }
1207
1315
 
1208
- .dark .sidebar-gradient .nav-active,
1209
- .dark .sidebar-gradient-horizontal .nav-active {
1210
- background: oklch(0.22 0.08 285 / 0.48);
1211
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.14);
1212
- backdrop-filter: blur(16px) saturate(1.5) brightness(1.06);
1213
- -webkit-backdrop-filter: blur(16px) saturate(1.5) brightness(1.06);
1316
+ /* Users who ask for more contrast get the most legible path, not the least
1317
+ (#162) a heavier marker and a white hairline around the pill. */
1318
+ /* More contrast means MORE CONTRAST, not more chrome: the same pigment, lifted.
1319
+ Fill vs rail 8.12 -> 10.60:1, ink label 8.84 -> 11.54:1. No rim, no marker. */
1320
+ @media (prefers-contrast: more) {
1321
+ .nav-active {
1322
+ --nav-active: oklch(0.86 0.115 73);
1323
+ }
1324
+ .nav-active-hover:hover {
1325
+ background: oklch(1 0 0 / 24%);
1326
+ }
1214
1327
  }
1215
1328
 
1216
- .sidebar-gradient .nav-active-hover:hover,
1217
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1218
- background: oklch(1 0 0 / 0.18);
1219
- backdrop-filter: blur(10px) saturate(1.15);
1220
- -webkit-backdrop-filter: blur(10px) saturate(1.15);
1329
+ /* ============================================================
1330
+ * FOCUS RING — two-tone, because --ring is now the tile.
1331
+ *
1332
+ * The old recipe was `focus-visible:ring-ring/50 focus-visible:ring-[3px]`.
1333
+ * Two problems. The alpha: composited in gamma space a 50% band measures
1334
+ * 2.04-2.19:1 in light and 2.63-2.76:1 in dark against the surfaces it lands
1335
+ * on — under SC 2.4.11's 3:1, on every control in the system, and that defect
1336
+ * predates this palette. And the pigment: --ring follows --primary, which is
1337
+ * now #f5a623 at 1.83:1 on a light page, so a solid orange band would not fix
1338
+ * it either.
1339
+ *
1340
+ * So the ring is bounded by board ink. The ink hairlines carry the boundary
1341
+ * (16:1 on light surfaces) and the orange band carries the identity, which is
1342
+ * the same division of labour the tile chip uses. In dark the outer hairline
1343
+ * merges into the page (1.11:1) and the orange band — 9.77:1 there — carries
1344
+ * the indicator directly, which is why the recipe is safe in both themes for
1345
+ * different reasons.
1346
+ * ============================================================ */
1347
+ .focus-ring {
1348
+ outline: none;
1221
1349
  }
1222
-
1223
- .dark .sidebar-gradient .nav-active-hover:hover,
1224
- .dark .sidebar-gradient-horizontal .nav-active-hover:hover {
1225
- background: oklch(1 0 0 / 0.12);
1226
- backdrop-filter: blur(12px) saturate(1.25);
1227
- -webkit-backdrop-filter: blur(12px) saturate(1.25);
1350
+ .focus-ring:focus-visible {
1351
+ outline: none;
1352
+ box-shadow:
1353
+ 0 0 0 1px var(--ring-edge),
1354
+ 0 0 0 3px var(--ring),
1355
+ 0 0 0 4px var(--ring-edge);
1228
1356
  }
1229
1357
 
1230
- @supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
1231
- .sidebar-gradient .nav-active,
1232
- .sidebar-gradient-horizontal .nav-active {
1233
- background: var(--nav-active);
1234
- /* Keep the hairline (was `none`): the solid fill is only ~1.2-1.7:1 off
1235
- the gradient stops behind it in either theme, so the ring is what makes
1236
- the pill an identifiable shape (#162). */
1237
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.22);
1238
- backdrop-filter: none;
1239
- -webkit-backdrop-filter: none;
1358
+ /* ============================================================
1359
+ * BOARD BACKDROP — the one-shot settle (#231).
1360
+ *
1361
+ * Each row drops and settles once on mount, like a row of flaps landing, and
1362
+ * then STOPS. That is the whole performance contract: a first-run screen is
1363
+ * disproportionately likely to be on a phone, and an ambient loop there costs
1364
+ * battery for as long as the screen is open. The WebGL aurora this replaces
1365
+ * held a GL context and ran a shader every frame; this runs for ~700ms and
1366
+ * then the field is inert DOM.
1367
+ *
1368
+ * transform and opacity only, so it stays on the compositor: no layout, no
1369
+ * paint, no main-thread work per frame.
1370
+ * ============================================================ */
1371
+ @keyframes board-backdrop-settle {
1372
+ from {
1373
+ opacity: 0;
1374
+ transform: translateY(-8px) scaleY(0.72);
1240
1375
  }
1241
- .sidebar-gradient .nav-active-hover:hover,
1242
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1243
- background: var(--nav-active-hover);
1244
- backdrop-filter: none;
1245
- -webkit-backdrop-filter: none;
1376
+ to {
1377
+ opacity: var(--board-backdrop-opacity, 0.28);
1378
+ transform: translateY(0) scaleY(1);
1246
1379
  }
1247
1380
  }
1248
1381
 
1249
- @media (prefers-reduced-transparency: reduce) {
1250
- .sidebar-gradient .nav-active,
1251
- .sidebar-gradient-horizontal .nav-active {
1252
- background: var(--nav-active);
1253
- /* Same reasoning as the @supports fallback above (#162). */
1254
- box-shadow: inset 0 0 0 1px oklch(1 0 0 / 0.22);
1255
- backdrop-filter: none;
1256
- -webkit-backdrop-filter: none;
1257
- }
1258
- .sidebar-gradient .nav-active-hover:hover,
1259
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1260
- background: var(--nav-active-hover);
1261
- backdrop-filter: none;
1262
- -webkit-backdrop-filter: none;
1263
- }
1382
+ .board-backdrop-row {
1383
+ opacity: var(--board-backdrop-opacity, 0.28);
1384
+ animation: board-backdrop-settle 0.42s var(--motion-ease-standard) both;
1264
1385
  }
1265
1386
 
1266
- /* This branch is where the old fallback was most wrong: users who explicitly
1267
- asked for MORE contrast got 4.26:1 in dark, below the 4.5:1 the default
1268
- frosted path achieved. --nav-active now targets AAA (7.47:1) so this path
1269
- is deliberately the most legible one, not the least (#162). */
1270
- @media (prefers-contrast: more) {
1271
- .sidebar-gradient .nav-active,
1272
- .sidebar-gradient-horizontal .nav-active {
1273
- background: var(--nav-active);
1274
- color: var(--nav-active-foreground);
1275
- box-shadow: inset 0 0 0 2px oklch(1 0 0 / 0.35);
1276
- backdrop-filter: none;
1277
- -webkit-backdrop-filter: none;
1278
- }
1279
- .sidebar-gradient .nav-active-hover:hover,
1280
- .sidebar-gradient-horizontal .nav-active-hover:hover {
1281
- background: var(--nav-active-hover);
1282
- backdrop-filter: none;
1283
- -webkit-backdrop-filter: none;
1387
+ /* Already settled, nothing declared. Not `animation: none` on top of a rule
1388
+ that also sets the from-state the row must not be left at opacity 0. */
1389
+ @media (prefers-reduced-motion: reduce) {
1390
+ .board-backdrop-row {
1391
+ animation: none;
1392
+ opacity: var(--board-backdrop-opacity, 0.28);
1393
+ transform: none;
1284
1394
  }
1285
1395
  }
1396
+ .reduce-motion .board-backdrop-row {
1397
+ animation: none;
1398
+ opacity: var(--board-backdrop-opacity, 0.28);
1399
+ transform: none;
1400
+ }
1286
1401
 
1287
1402
  /* Card fade-in animation (inspired by reactbits CardNav) */
1288
1403
  @keyframes card-fade-in {
@@ -1303,8 +1418,11 @@ html[data-board-switch="backward"]::view-transition-new(board-page) {
1303
1418
 
1304
1419
  /* Type scale: ratio 1.25; body 1rem (16px). Tailwind text-xs/sm/base/lg/xl/2xl/3xl/4xl align. */
1305
1420
  /* Page typography - consistent titles and descriptions */
1421
+ /* The page's H1 has to outrank CardTitle, which is text-base/600. At
1422
+ text-lg/500 it did not — a section heading inside a sibling card read as
1423
+ more important than the name of the page (#231). Now two full steps up. */
1306
1424
  .page-title {
1307
- @apply text-lg sm:text-xl font-medium tracking-tight;
1425
+ @apply text-2xl sm:text-3xl font-semibold tracking-tight;
1308
1426
  text-wrap: balance;
1309
1427
  }
1310
1428
  .page-description {