@trading-game/design-intelligence-layer 1.0.5 → 1.0.6

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.
@@ -74,8 +74,10 @@ Padding-x = height/2 − 4; gap fixed at 8px and padding does not change when ic
74
74
 
75
75
  - All shapes are `rounded-full`; icon sizes are perfect circles.
76
76
  - Focus: `focus-visible:ring-[3px] ring-ring-focus-strong` + focus border.
77
- - Disabled is **per variant**, never an element `opacity` — that dims the fill and its label together, which is why a disabled `primary` used to lose its label entirely. Each variant keeps its own construction:
78
- - filled page variants (`primary`, `secondary`, `frosted`) take the disabled glaze (`background-disabled-default` on `background-image`, so the fill stays underneath) plus `text-disabled-default`; `secondary` also drops to `border-disabled-default`
77
+ - Disabled is **per variant and per theme**. Each cell is a deliberate choice, not a rule the others follow. Current decisions:
78
+ - `primary` — 24% opacity in both themes, via `disabled:opacity-(--opacity-disabled)`. The whole control drains together, so the label goes with the fill: it lands at 1.14:1 in light and 2.13:1 in dark against its own washed fill and is effectively unreadable. WCAG 1.4.3 exempts inactive controls, so this is an accepted legibility trade rather than a compliance break
79
+ - `secondary` — 24% opacity in light; in dark it holds full opacity and takes a black-24% glaze plus `border-disabled-default` and `text-disabled-default`, so it recedes with its label still legible. The theme split lives in the stylesheet (`.button-disabled-secondary`) because the package ships no `dark:` variant
80
+ - `frosted` — 40% opacity via `disabled:opacity-(--opacity-disabled-frosted)`, in both themes, keeping `text-brand-selected`. It has its own strength because its fill is only a 10–24% brand tint, so the shared 24% faded it to near-invisible; the previous glaze plus `text-disabled-default` was worse still, since the ink left the brand family for neutral grey while the fill stayed blue
79
81
  - fill-less variants (`tertiary`, `tertiary-on-brand`, `secondary-on-brand`) take the ink only — a glaze would paint a surface the variant deliberately doesn't have
80
82
  - on-brand variants stay on the static white ladder: `primary-on-brand`'s white pill becomes `white-alpha-24`, `frosted-on-brand` drops from `alpha-24` to `alpha-8`, and the ink is `text-on-brand-disabled-default`
81
83
  - `loading` still uses `opacity-24` — a different state, deliberately.
@@ -22,7 +22,7 @@ Binary (or indeterminate) form control that fills brand blue when checked.
22
22
  | `checked` | `boolean \| "indeterminate"` | — | Controlled state. |
23
23
  | `defaultChecked` | `boolean` | — | Uncontrolled initial state. |
24
24
  | `onCheckedChange` | `(checked) => void` | — | Radix change callback. |
25
- | `disabled` | `boolean` | `false` | `cursor-not-allowed`, opacity 50%. |
25
+ | `disabled` | `boolean` | `false` | `cursor-not-allowed` + 24% opacity (`--opacity-disabled`). The whole control fades, including a checked box's brand fill — previously the fill was untreated, so disabled-and-checked was indistinguishable from enabled. |
26
26
  | `required`, `name`, `value` | form props | — | Radix renders a hidden input inside forms. |
27
27
 
28
28
  ## Variants & sizes
@@ -13,13 +13,20 @@ Edge-anchored panel built on vaul — the mobile-first modal that slides from th
13
13
 
14
14
  - `Drawer` / `DrawerTrigger` / `DrawerPortal` / `DrawerClose` — vaul plumbing with `data-slot` names (`drawer`, `drawer-trigger`, `drawer-portal`, `drawer-close`).
15
15
  - `DrawerOverlay` — scrim in `background-overlay-default`; `data-slot="drawer-overlay"`.
16
- - `DrawerContent` — the panel; direction-aware chrome via `data-vaul-drawer-direction`; `data-slot="drawer-content"`. Bottom drawers show the grab handle: a 100×8px `rounded-full` bar in `bg-border-default-default`.
16
+ - `DrawerContent` — the panel; direction-aware chrome via `data-vaul-drawer-direction`; `data-slot="drawer-content"`. Takes `dismiss`: `"handle"` (default) shows the grab handle, a 100×8px `rounded-full` bar in `bg-border-default-default`, on bottom drawers only; `"close"` shows a close button instead, in every direction.
17
17
  - `DrawerHeader` — `p-4`, centred for top/bottom, left from `md`; `data-slot="drawer-header"`.
18
18
  - `DrawerTitle` / `DrawerDescription` — `type-drawer-title` / `-description`.
19
19
  - `DrawerFooter` — bottom-pinned (`mt-auto`), stacked full-width children; `data-slot="drawer-footer"`.
20
20
 
21
21
  ## API
22
22
 
23
+ ### DrawerContent
24
+
25
+ | Prop | Type | Default | Notes |
26
+ |---|---|---|---|
27
+ | `dismiss` | `"handle" \| "close"` | `"handle"` | `"handle"` is the drag pill, unchanged, bottom direction only. `"close"` swaps it for the same control Dialog and Sheet use — `NavigationButton size="sm"` around a `size-4` icon, a 32px circle on a 16px glyph — wrapped in `Drawer.Close` and shown in every direction. It sits in flow on its own row rather than absolutely, so nothing shares its line and the header always starts below it; `p-4 pb-0` puts it 16px from the top and right edges, matching the header's own padding. `data-slot="drawer-close"`. |
28
+ | …rest | vaul `Content` props | — | Pass through untouched. |
29
+
23
30
  ### Drawer (root — vaul props)
24
31
 
25
32
  | Prop | Type | Default | Notes |
@@ -61,8 +68,9 @@ import {
61
68
 
62
69
  | Direction | Chrome |
63
70
  | --- | --- |
64
- | `bottom` | `rounded-t-2xl`, top hairline, `max-h-[80vh]`, grab handle |
71
+ | `bottom` | `rounded-t-2xl`, top hairline, `max-h-[80vh]`, grab handle under `dismiss="handle"` |
65
72
  | `top` | `rounded-b-2xl`, bottom hairline, `max-h-[80vh]`, no handle |
73
+ | any | `dismiss="close"` shows the close button regardless of direction |
66
74
  | `left` / `right` | square corners, side hairline, `w-3/4` capped at `sm:max-w-sm` |
67
75
 
68
76
  ## Tokens
@@ -79,7 +87,8 @@ import {
79
87
 
80
88
  - vaul provides the drag/velocity dismissal, snap points, and background scaling; direction is read from `data-vaul-drawer-direction` at render.
81
89
  - Only the detached edge is rounded (`rounded-t-2xl` bottom, `rounded-b-2xl` top — the 18px card radius); side drawers stay square because they meet two screen edges.
82
- - The grab handle renders only on bottom drawers — the direction users can actually drag from comfortably.
90
+ - The grab handle renders only on bottom drawers — the direction users can actually drag from comfortably. The `dismiss="close"` button has no such limit, since a tap target works from any edge.
91
+ - Pick `dismiss="close"` when the drawer is not swipe-dismissible, or on a side drawer where there is no handle at all.
83
92
  - Top/bottom drawers cap at 80vh and reserve 96px (`mb-24`/`mt-24`) of visible page.
84
93
  - `DrawerFooter` forces every child full-width (`[&>*]:w-full`) — the mobile button stack.
85
94
  - Header text centres on top/bottom drawers and left-aligns from `md`.
@@ -94,4 +103,5 @@ import {
94
103
  **Don't**
95
104
  - Don't add your own rounding to side drawers — square is deliberate.
96
105
  - Don't recolour the grab handle; it is `bg-border-default-default` by contract.
106
+ - Don't hand-roll a close button in `DrawerContent`'s children; `dismiss="close"` already wires `Drawer.Close` and matches Dialog and Sheet.
97
107
  - Don't exceed 80vh with content — let the body scroll inside the panel instead.
@@ -55,7 +55,7 @@ import {
55
55
 
56
56
  - Joins the [Field](./field.md) cascade: with no `size` prop it takes the Field's size; an explicit prop wins. `size="default"` always resolves to `md` regardless of context.
57
57
  - `appearance-none` + the injected chevron replace the platform arrow; right padding (`pr-9`, lg `pr-10`) reserves its space.
58
- - Disabled sets `text-disabled-default` on the wrapper via `has-[select:disabled]:`, lays the disabled glaze (`background-disabled-default` on `background-image`, so the fill stays underneath) on the select, and kills pointer events. The wrapper is no longer dimmed as a group.
58
+ - Disabled lays the disabled glaze (`background-disabled-default` on `background-image`, so the fill stays underneath) on the select and kills pointer events. The wrapper is no longer dimmed as a group. The select carries `disabled:text-disabled-default` **itself** — the wrapper's `has-[select:disabled]:` colour was being overridden by the select's own `text-prominent-default`, so the value stayed at full strength. The chevron takes `group-has-[select:disabled]/native-select:text-icon-disabled-default`, since it is a sibling with its own colour and would not inherit either.
59
59
  - The wrapper is `w-fit` and `className` lands on the inner `<select>` — for full-width layouts you must widen the wrapper (e.g. a `w-full` parent with `[&>[data-slot=native-select-wrapper]]:w-full`), not just the select.
60
60
 
61
61
  ## Do / Don't
@@ -66,7 +66,7 @@ import {
66
66
  - Single-select with roving tabindex: Tab reaches the group once, arrows move and select within it (Radix).
67
67
  - Controlled (`value` + `onValueChange`) or uncontrolled (`defaultValue`).
68
68
  - Checked state swaps only border and dot — the background never fills; there is no shadow in any state.
69
- - Disabled items get `cursor-not-allowed` + 50% opacity.
69
+ - Disabled items get `cursor-not-allowed` + 24% opacity (`--opacity-disabled`), which fades the selected indicator with the control. Previously the indicator was untreated.
70
70
  - Inside a Field choice card, the item's `data-state=checked` is what flips the surrounding `FieldLabel` to the brand-selected card treatment.
71
71
 
72
72
  ## Do / Don't
@@ -12,7 +12,8 @@ The mobile amount tray — a centred amount with frosted − / + circles either
12
12
 
13
13
  - Container — full-width row, `data-slot="stepper"`, `data-disabled`.
14
14
  - − / + — composed `<Button variant="frosted" size="icon-lg">` (48px brand-selected tint circles); each disables at its bound.
15
- - Centre — optional uppercase `label` over the editable amount (`role="spinbutton"`, tabular semibold figure).
15
+ - Two rows — the optional uppercase `label` spans the whole control, then a 4px gap, then the `−` / amount / `+` row. The label is deliberately **not** nested in the value column: doing so made the buttons centre against the label-plus-amount stack, which dropped them off the amount's centre line.
16
+ - Centre of the row — the editable amount (`role="spinbutton"`, tabular semibold figure), flanked by the two circles.
16
17
 
17
18
  ## API
18
19
 
@@ -20,7 +20,7 @@ Binary on/off toggle (Radix Switch) with a sliding white thumb.
20
20
  | `size` | `"sm" \| "default"` | `"default"` | default: 24px tall (`h-6 w-11`, 18px thumb); sm: 20px tall (`h-5 w-9`, 14px thumb). |
21
21
  | `checked` / `defaultChecked` | `boolean` | — | Controlled / uncontrolled. |
22
22
  | `onCheckedChange` | `(checked: boolean) => void` | — | Radix callback. |
23
- | `disabled` | `boolean` | — | `cursor-not-allowed` + `text-disabled-default`. The track fill is left alone so a disabled-checked switch still reads as on. |
23
+ | `disabled` | `boolean` | — | `cursor-not-allowed` + 24% opacity (`--opacity-disabled`). Track and thumb fade together; previously both were untreated, so a disabled-checked switch was indistinguishable from enabled. |
24
24
 
25
25
  ## Variants & sizes
26
26
 
@@ -77,7 +77,7 @@ import { Star } from "lucide-react"
77
77
  - Radius is `rounded-md` (8px, the row radius); icons default to `size-4`.
78
78
  - `spacing={0}` (default) joins segments: items lose their own radius (`rounded-none`), the first/last regain `rounded-l-md`/`rounded-r-md`, and segments share hairlines by dropping `border-l` on all but the first; any positive `spacing` separates them and each keeps `rounded-md`.
79
79
  - Items are `w-auto min-w-0 shrink-0 px-3` and raise `z-10` on focus so the ring is never clipped by neighbours.
80
- - Disabled: `pointer-events-none` + `text-disabled-default`. The pressed fill is left alone so a disabled-pressed toggle still reads as pressed.
80
+ - Disabled: `pointer-events-none` + 24% opacity (`--opacity-disabled`). The pressed fill fades with the control; previously it was untreated, so a disabled-pressed toggle read as pressed-and-active.
81
81
 
82
82
  ## Do / Don't
83
83
 
@@ -46,7 +46,7 @@ All scales run 50→1000 (mono 50→1200; blue adds 25 and 1000 as the two canva
46
46
  | `background-tinted-canvas` | blue-600 12%→4% mixed into the canvas + a blue-400 aurora | blue-400 14% into the canvas + aurora | The Refined screen background — a layered gradient, consume via `background: var(--background-tinted-canvas)` on the page root only; ends on `primary-canvas` in both themes so long pages melt in seamlessly |
47
47
  | `background-primary-surface` | mono-50 `#FFFFFF` | white 6% over canvas ≈ `#0F0F2C` | Cards, panels, menus, inputs, sheets — the default solid surface |
48
48
  | `background-secondary-surface` | mono-100 `#F1F1F1` | white 12% over canvas ≈ `#1F1F3A` | One step up: muted fills, tracks, icon tiles, read-only fields |
49
- | `background-disabled-default` | white-alpha-24 | black-alpha-50 | **The disabled glaze** — an alpha layer laid *over* whatever fill a control already has, so every variant keeps its identity while draining. Consume it on `background-image` (`[background-image:linear-gradient(var(--background-disabled-default),var(--background-disabled-default))]`), never `background-color`, or it replaces the fill instead of washing it. Polarity is the inverse of hover — hover advances (black in light, white in dark), disabled recedes (white in light, black in dark) — and the strengths differ because the ground does: white moves fast against a near-white page, black slowly against a near-black canvas |
49
+ | `background-disabled-default` | white-alpha-24 | black-alpha-24 | **The disabled glaze** — an alpha layer laid *over* whatever fill a control already has, so every variant keeps its identity while draining. Consume it on `background-image` (`[background-image:linear-gradient(var(--background-disabled-default),var(--background-disabled-default))]`), never `background-color`, or it replaces the fill instead of washing it. Polarity is the inverse of hover — hover advances (black in light, white in dark), disabled recedes (white in light, black in dark) — and the strengths differ because the ground does: white moves fast against a near-white page, black slowly against a near-black canvas. Button's `primary` and `secondary` no longer consume it — they were moved to `opacity`/black-24% by explicit decision, see AGENTS rule 13 |
50
50
  | `background-hover-default` | black-alpha-4 | white-alpha-12 | The hover glaze — laid over any solid surface |
51
51
  | `background-pressed-default` | black-alpha-8 | white-alpha-20 | The pressed glaze |
52
52
  | `background-brand-default` | blue-600 `#2323FF` | blue-400 `#4D6BFF` | Primary CTAs, checked controls, solid selection chips |
@@ -85,7 +85,7 @@ All scales run 50→1000 (mono 50→1200; blue adds 25 and 1000 as the two canva
85
85
  | `text-success-default` / `-error-` / `-warning-` / `-information-` | status 600 | **static — same in both themes** (deliberate call) | Status text |
86
86
  | `text-on-status-static` | mono-50 | static | White on solid status fills (below AA on green/amber — deliberate style call) |
87
87
  | `text-demo-default` | ember-500 | static | Demo badge ink |
88
- | `icon-prominent-default` … `icon-information-default` | aliases of the matching text tokens | — | Separate namespace so icons (3:1 requirement) can diverge later |
88
+ | `icon-prominent-default` … `icon-information-default` | aliases of the matching text tokens | — | Separate namespace so icons (3:1 requirement) can diverge later. `icon-prominent/subtle/disabled-default` are **re-declared in the dark block**: a custom property resolves its `var()` where it is declared, so the `:root` aliases froze at the light values and every `text-icon-*` utility painted a light-theme colour in dark. The status and on-brand members need no override — the text tokens they alias are static |
89
89
 
90
90
  ## Semantic tokens — Border & Ring
91
91
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trading-game/design-intelligence-layer",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Trading Game Design System — shadcn/ui components with Tailwind CSS v4",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
package/src/styles.css CHANGED
@@ -554,6 +554,16 @@
554
554
  differ because the ground does — white moves fast against a near-white
555
555
  page, black moves slowly against a near-black canvas. */
556
556
  --background-disabled-default: var(--primitive-white-alpha-24);
557
+ /* Disabled fade — the 24% opacity treatment. Not a colour, so it sits
558
+ outside the semantic colour namespaces. Which variants use it, and in
559
+ which theme, is a per-case decision (see the button rules below), not a
560
+ blanket rule. */
561
+ --opacity-disabled: 0.24;
562
+ /* Frosted drains to 40%, not 24%. Its fill is already a 10%/24% brand
563
+ tint, so a 24% fade left almost nothing on screen. Static in both
564
+ themes. Separate token, not a shared one — disabled strength is a
565
+ per-variant decision. */
566
+ --opacity-disabled-frosted: 0.4;
557
567
  --background-hover-default: var(--primitive-black-alpha-4);
558
568
  --background-pressed-default: var(--primitive-black-alpha-8);
559
569
  --background-brand-default: var(--primitive-blue-600);
@@ -812,7 +822,10 @@
812
822
  --background-primary-canvas: var(--primitive-blue-1000);
813
823
  --background-primary-surface: color-mix(in srgb, white 6%, var(--primitive-blue-1000));
814
824
  --background-secondary-surface: color-mix(in srgb, white 12%, var(--primitive-blue-1000));
815
- --background-disabled-default: var(--primitive-black-alpha-50);
825
+ /* 24%, not 50%. At 50% the glaze stripped most of the blue out of a navy
826
+ surface, so a disabled field landed darker and greyer than the canvas
827
+ and read as a hole punched in the page rather than a drained control. */
828
+ --background-disabled-default: var(--primitive-black-alpha-24);
816
829
  /* solid #1F1F3A — one glaze step above the card (6%) */
817
830
  /* solid #0F0F2C — base + white glaze, flattened */
818
831
  --background-hover-default: var(--primitive-white-alpha-12);
@@ -846,6 +859,17 @@
846
859
  --text-prominent-default: var(--primitive-mono-50);
847
860
  --text-subtle-default: var(--primitive-mono-600);
848
861
  --text-disabled-default: var(--primitive-mono-900);
862
+ /* The icon namespace aliases the text ladder, and a custom property
863
+ resolves its var() where it is DECLARED, not where it is used — so the
864
+ aliases declared in :root froze at the light values and never flipped
865
+ here. Every text-icon-* utility in dark was painting a light-theme
866
+ colour: subtle icons at mono-800 instead of mono-600, prominent at
867
+ mono-1100 on a near-black canvas. Re-declaring them re-resolves against
868
+ the dark ink above. Status and on-brand icons need no override — the
869
+ text tokens they alias are static. */
870
+ --icon-prominent-default: var(--text-prominent-default);
871
+ --icon-subtle-default: var(--text-subtle-default);
872
+ --icon-disabled-default: var(--text-disabled-default);
849
873
  --text-on-brand-disabled-default: var(--primitive-white-alpha-24);
850
874
  /* Inverse mirrors the light page ladder here — see the note in the light
851
875
  block. mono 1100 · 800 · 400, same values light's PAGE row carries. */
@@ -1346,50 +1370,158 @@
1346
1370
  }
1347
1371
  }
1348
1372
 
1349
- /* ── GameLoadingBlock ────────────────────────────────────────────────
1350
- Brand-blue dotted splash backdrop. The dot colour is derived from the
1351
- --on-primary token (not a raw white) so it tracks the palette; the dot
1352
- grid is a background-image, which Tailwind utilities can't express
1353
- cleanly, so it lives here as a class. */
1354
- .game-loader-backdrop {
1355
- background-color: var(--primary);
1356
- background-image: radial-gradient(
1357
- color-mix(in oklch, var(--on-primary) 16%, transparent) 1px,
1358
- transparent 1px
1373
+ /* ─────────────────────────────────────────────────────────────────
1374
+ Disabled — Primary and Secondary buttons
1375
+
1376
+ Disabled is picked per variant and per theme, not from one rule. The
1377
+ four decisions currently are:
1378
+
1379
+ Primary · light — 24% opacity
1380
+ Primary · dark — 24% opacity
1381
+ Secondary · light — 24% opacity
1382
+ Secondary · dark — black 24% glaze over the fill
1383
+
1384
+ Primary is the same in both themes, so it takes the opacity utility in
1385
+ the component. Secondary differs by theme, and the package ships no
1386
+ `dark:` variant, so its split lives here where the themes are declared.
1387
+ Changing any one cell is expected; they are not derived from each other.
1388
+ ───────────────────────────────────────────────────────────────── */
1389
+ .button-disabled-secondary:disabled {
1390
+ opacity: var(--opacity-disabled);
1391
+ }
1392
+
1393
+ .dark .button-disabled-secondary:disabled,
1394
+ [data-theme="dark"] .button-disabled-secondary:disabled {
1395
+ opacity: 1;
1396
+ /* Rides the alpha ladder directly: a fixed recede step, not a themed
1397
+ colour, and it is laid over the fill so the variant keeps its identity. */
1398
+ background-image: linear-gradient(
1399
+ var(--primitive-black-alpha-24),
1400
+ var(--primitive-black-alpha-24)
1359
1401
  );
1360
- background-size: 14px 14px;
1402
+ border-color: var(--border-disabled-default);
1403
+ color: var(--text-disabled-default);
1404
+ }
1405
+
1406
+ /* ─────────────────────────────────────────────────────────────────
1407
+ Game loading splash
1408
+
1409
+ The backdrop is the tinted canvas, so the splash reads as the product's
1410
+ own surface in both themes and already lands on the page canvas the app
1411
+ is about to paint. The dot grid is a background-image, which Tailwind
1412
+ utilities can't express cleanly, so it lives here as a class.
1413
+ ───────────────────────────────────────────────────────────────── */
1414
+ .game-loader-field {
1415
+ position: absolute;
1416
+ inset: 0;
1417
+ background-color: var(--background-primary-canvas);
1418
+ background-image:
1419
+ radial-gradient(
1420
+ color-mix(in oklch, var(--text-prominent-default) 10%, transparent) 1px,
1421
+ transparent 1px
1422
+ ),
1423
+ var(--background-tinted-canvas);
1424
+ background-size: 14px 14px, auto, auto;
1425
+ }
1426
+
1427
+ /* The product mark, subtracted from the field so it is a window rather than
1428
+ ink. `--game-loader-mark` is the logo's url(), set by the component from
1429
+ the same prop that draws the resting mark, so the two always agree. */
1430
+ .game-loader-punch {
1431
+ mask-image: linear-gradient(#000, #000), var(--game-loader-mark);
1432
+ mask-size: 100% 100%, 120px 113px;
1433
+ mask-position: center, center;
1434
+ mask-repeat: no-repeat, no-repeat;
1435
+ mask-composite: exclude;
1436
+ -webkit-mask-image: linear-gradient(#000, #000), var(--game-loader-mark);
1437
+ -webkit-mask-size: 100% 100%, 120px 113px;
1438
+ -webkit-mask-position: center, center;
1439
+ -webkit-mask-repeat: no-repeat, no-repeat;
1440
+ -webkit-mask-composite: xor;
1361
1441
  }
1362
1442
 
1363
- /* Exit reveal — the monogram hangs, snaps outward, and the app is revealed
1364
- behind it (X/Twitter-style). Suppressed under prefers-reduced-motion by
1365
- the component, which falls back to a plain fade. */
1366
- @keyframes game-loader-zoom {
1443
+ .game-loader-mark {
1444
+ position: absolute;
1445
+ top: 50%;
1446
+ left: 50%;
1447
+ width: 120px;
1448
+ height: auto;
1449
+ transform: translate(-50%, -50%);
1450
+ transform-origin: center;
1451
+ }
1452
+
1453
+ /* Exit reveal — ONLY the hole grows. The field keeps its position, its
1454
+ gradient and its dot grid, so nothing behind the hole appears to zoom;
1455
+ scaling the field instead is what made the old reveal read as a camera
1456
+ push. The field fades across the same beat, so the app bleeds through
1457
+ from about a third of the way in rather than arriving all at once.
1458
+ Suppressed under prefers-reduced-motion by the component, which falls
1459
+ back to a plain fade. */
1460
+ @keyframes game-loader-reveal {
1367
1461
  0% {
1368
- transform: scale(1);
1369
- animation-timing-function: cubic-bezier(0.3, 0, 0.5, 1);
1462
+ mask-size: 100% 100%, 120px 113px;
1463
+ -webkit-mask-size: 100% 100%, 120px 113px;
1464
+ animation-timing-function: cubic-bezier(0.33, 0, 0.55, 1);
1370
1465
  }
1371
1466
 
1372
- 22% {
1373
- transform: scale(0.85);
1374
- animation-timing-function: cubic-bezier(0.7, 0, 0.85, 0.35);
1467
+ 26% {
1468
+ mask-size: 100% 100%, 108px 102px;
1469
+ -webkit-mask-size: 100% 100%, 108px 102px;
1470
+ animation-timing-function: cubic-bezier(0.72, 0, 0.86, 0.3);
1375
1471
  }
1376
1472
 
1377
1473
  100% {
1378
- transform: scale(22);
1474
+ mask-size: 100% 100%, 420vmax 396vmax;
1475
+ -webkit-mask-size: 100% 100%, 420vmax 396vmax;
1379
1476
  }
1380
1477
  }
1381
1478
 
1382
- .game-loader-mark {
1383
- transform-origin: center;
1384
- will-change: transform;
1479
+ @keyframes game-loader-field-fade {
1480
+ to {
1481
+ opacity: 0;
1482
+ }
1483
+ }
1484
+
1485
+ .game-loader-field-exit {
1486
+ will-change: mask-size, opacity;
1487
+ animation:
1488
+ game-loader-reveal 660ms forwards,
1489
+ game-loader-field-fade 480ms 180ms ease-in forwards;
1490
+ }
1491
+
1492
+ /* The resting mark tracks the same anticipation dip, then hands over to the
1493
+ hole at 27%. The two shapes coincide there, so the handover is invisible. */
1494
+ @keyframes game-loader-plug-dip {
1495
+ 0% {
1496
+ transform: translate(-50%, -50%) scale(1);
1497
+ opacity: 1;
1498
+ animation-timing-function: cubic-bezier(0.33, 0, 0.55, 1);
1499
+ }
1500
+
1501
+ 26% {
1502
+ transform: translate(-50%, -50%) scale(0.9);
1503
+ opacity: 1;
1504
+ }
1505
+
1506
+ 27% {
1507
+ transform: translate(-50%, -50%) scale(0.9);
1508
+ opacity: 0;
1509
+ }
1510
+
1511
+ 100% {
1512
+ transform: translate(-50%, -50%) scale(0.9);
1513
+ opacity: 0;
1514
+ }
1385
1515
  }
1386
1516
 
1387
1517
  .game-loader-mark-exit {
1388
- animation: game-loader-zoom 0.34s forwards;
1518
+ will-change: transform, opacity;
1519
+ animation: game-loader-plug-dip 660ms forwards;
1389
1520
  }
1390
1521
 
1391
1522
  /* Progress-bar shimmer — sweeps across the filled portion while a stage is
1392
- still loading, so a held bar still reads as "working". */
1523
+ still loading, so a held bar still reads as "working". The fill is now the
1524
+ brand colour on a canvas track, so the sweep is the on-brand white. */
1393
1525
  @keyframes game-loader-sweep {
1394
1526
  0% {
1395
1527
  left: -60%;
@@ -1404,7 +1536,7 @@
1404
1536
  background: linear-gradient(
1405
1537
  90deg,
1406
1538
  transparent,
1407
- color-mix(in oklch, var(--primary) 35%, transparent),
1539
+ color-mix(in oklch, var(--icon-on-brand-static) 35%, transparent),
1408
1540
  transparent
1409
1541
  );
1410
1542
  animation: game-loader-sweep 1.4s ease-in-out infinite;