@tangle-network/brand 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 532a6d8: Add `--shadow-overlay`, the elevation step above `--shadow-dropdown` for a surface that covers the page rather than sitting beside it — a drawer, a modal, a floating dock.
8
+
9
+ It is derived from `--hsl-foreground` rather than a fixed `rgba`, so it inverts with the theme: dark ink in light, a soft halo in dark. That is what makes it work on a dark canvas, where a black shadow renders as nothing. One declaration serves both themes, and the token is declared in the `@theme` block as well so Tailwind emits a `shadow-overlay` utility.
10
+
11
+ ## 1.4.0
12
+
13
+ ### Minor Changes
14
+
15
+ - e67a809: Raise the dark surface ladder to AA and add the page-level primitives.
16
+
17
+ In the dark spine, `--md3-surface-container-high` and `--md3-surface-container-highest` sat close enough to the ink ramp that `--text-dim` fell under 4.5:1 on them. The ladder is re-spaced and the ink ramp moves with it, so every ink tier clears AA on all five planes.
18
+
19
+ Dark status chips keep their hue, drop 14% chroma, and lift the fill to 1.50:1 from the card. A new `--run-mix-*` ramp carries proportional bars, spaced in relative luminance so adjacent segments clear the 3:1 floor for non-text contrast.
20
+
21
+ One light token moves: `--surface-warning-text` goes `#b45309` to `#ab4f09`. It is a contrast fix, not a hue change. The old value measured 4.16:1 on the light page canvas, under the 4.5:1 body floor, whenever the colour was used as text away from its own pill background; the new value measures 4.51:1 there and 5.25:1 on the pill. Every other light value is unchanged.
22
+
23
+ `@tangle-network/ui` gains four primitives: `PageHeader`, `StatusPill`, `MetricStrip`, and `Toolbar`/`FilterField`. All additive; no existing export changes.
24
+
3
25
  ## 1.3.0
4
26
 
5
27
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Tangle brand system — design tokens, logo, Tailwind v4 theme. Single source of truth for every Tangle app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  [data-theme="arena-light"],
53
53
  [data-theme="tangle-light"] {
54
54
  --surface-success-bg: #ecfdf5; --surface-success-border: #a7f3d0; --surface-success-text: #047857;
55
- --surface-warning-bg: #fffbeb; --surface-warning-border: #fde68a; --surface-warning-text: #b45309;
55
+ --surface-warning-bg: #fffbeb; --surface-warning-border: #fde68a; --surface-warning-text: #ab4f09;
56
56
  --surface-danger-bg: #fff1f2; --surface-danger-border: #fecdd3; --surface-danger-text: #b91c1c;
57
57
  --surface-info-bg: #f0f9ff; --surface-info-border: #bae6fd; --surface-info-text: #0369a1;
58
58
  /* Light-tuned interactive tokens — without these the named light scopes
@@ -184,23 +184,23 @@
184
184
  [data-theme="tangle-dark"] {
185
185
  --hsl-background: 240 24% 11%; --hsl-foreground: 225 22% 91%;
186
186
  --hsl-card: 238 20% 22%; --hsl-card-foreground: 225 22% 91%;
187
- --hsl-popover: 240 22% 27%; --hsl-popover-foreground: 225 22% 91%;
187
+ --hsl-popover: 239 22% 25%; --hsl-popover-foreground: 225 22% 91%;
188
188
  --hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
189
- --hsl-secondary: 240 22% 27%; --hsl-secondary-foreground: 225 22% 91%;
190
- --hsl-muted: 240 22% 27%; --hsl-muted-foreground: 223 14% 69%;
191
- --hsl-accent: 240 22% 27%; --hsl-accent-foreground: 225 22% 91%;
189
+ --hsl-secondary: 239 22% 25%; --hsl-secondary-foreground: 225 22% 91%;
190
+ --hsl-muted: 239 22% 25%; --hsl-muted-foreground: 234 16% 74%;
191
+ --hsl-accent: 239 22% 25%; --hsl-accent-foreground: 225 22% 91%;
192
192
  --hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
193
193
  --hsl-border: 236 20% 35%; --hsl-input: 240 24% 15%;
194
194
  --hsl-ring: 233 100% 87%;
195
- --depth-1: #151523; --depth-2: #2d2e43; --depth-3: #363654; --depth-4: #434365;
195
+ --depth-1: #151523; --depth-2: #2d2e43; --depth-3: #32324e; --depth-4: #393957;
196
196
  --bg-root: #151523; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
197
197
  --md3-surface-container-lowest: #0f0f1a; --md3-surface-container-low: #222235;
198
- --md3-surface-container: #2d2e43; --md3-surface-container-high: #363654;
199
- --md3-surface-container-highest: #434365; --md3-outline-variant: #474a6b;
198
+ --md3-surface-container: #2d2e43; --md3-surface-container-high: #32324e;
199
+ --md3-surface-container-highest: #393957; --md3-outline-variant: #474a6b;
200
200
  --border-subtle: rgba(165, 180, 252, 0.10); --border-default: rgba(165, 180, 252, 0.16);
201
201
  --border-hover: rgba(165, 180, 252, 0.28); --accent-text: #A5B4FC;
202
202
  --brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
203
- --text-primary: #e3e6ed; --text-secondary: #bec3d0; --text-muted: #a5abbb; --text-dim: #9299ab;
203
+ --text-primary: #e3e6ed; --text-secondary: #c2c7d4; --text-muted: #b2b4c7; --text-dim: #9ea5b9;
204
204
  }
205
205
  /* Its light mode. */
206
206
  [data-theme="tangle-light"] {
@@ -0,0 +1,86 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { blockIn } from "./css-test-utils";
5
+
6
+ /**
7
+ * A token declared in both files has to carry the same value in both.
8
+ *
9
+ * `tokens.css` is what a consumer reads at runtime through `var(--x)`.
10
+ * `theme.css` is the Tailwind v4 `@theme` block, and it exists because Tailwind
11
+ * only emits a utility for a token it can see declared there — so the shared
12
+ * ones are necessarily written twice.
13
+ *
14
+ * That duplication is structural and cannot be factored out: `@theme` needs a
15
+ * literal, and a self-reference would be circular. What it can be is GATED. An
16
+ * edit to one file that misses the other would leave `shadow-overlay` (the
17
+ * utility, from theme.css) rendering a different shadow from
18
+ * `shadow-[var(--shadow-overlay)]` (the variable, from tokens.css), and the
19
+ * mismatch would show up as an inconsistency no one could reproduce from
20
+ * either file alone.
21
+ */
22
+ const read = (name: string) =>
23
+ readFileSync(path.resolve(import.meta.dirname, name), "utf8");
24
+
25
+ /** `--token: value;` pairs in one CSS block, whitespace collapsed. */
26
+ function declarationsIn(css: string): Map<string, string> {
27
+ const out = new Map<string, string>();
28
+ for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
29
+ out.set(m[1] as string, (m[2] as string).split(/\s+/).join(" ").trim());
30
+ }
31
+ return out;
32
+ }
33
+
34
+ // The default spine, which is the one `@theme` mirrors. Anchor on the last
35
+ // selector of the multi-selector header, as the other suites here do.
36
+ const tokensCss = read("tokens.css");
37
+ const runtime = declarationsIn(blockIn(tokensCss, ".dark"));
38
+ const light = declarationsIn(blockIn(tokensCss, ".light"));
39
+ const theme = declarationsIn(blockIn(read("theme.css"), "@theme"));
40
+
41
+ const shared = [...theme.keys()].filter((t) => runtime.has(t)).sort();
42
+
43
+ describe("tokens.css and theme.css agree on every token they both declare", () => {
44
+ it("finds the shared tokens to check", () => {
45
+ // Guards the parse itself: a selector rename that silently emptied one
46
+ // block would otherwise make every assertion below vacuously pass.
47
+ expect(shared.length).toBeGreaterThan(5);
48
+ });
49
+
50
+ for (const token of shared) {
51
+ it(`${token} carries one value`, () => {
52
+ expect(theme.get(token)).toBe(runtime.get(token));
53
+ });
54
+ }
55
+ });
56
+
57
+ /**
58
+ * A token written in terms of `--hsl-foreground` already inverts, because that
59
+ * ramp is what each spine redefines. Restating it on the light spine would pin
60
+ * it to one theme's ink and defeat the derivation — the failure would be silent,
61
+ * since both declarations are individually valid CSS.
62
+ *
63
+ * So the omission is the contract, and this asserts it rather than trusting the
64
+ * comment that states it.
65
+ */
66
+ const selfInverting = [...runtime.entries()]
67
+ .filter(([, value]) => value.includes("var(--hsl-foreground)"))
68
+ .map(([token]) => token)
69
+ .sort();
70
+
71
+ describe("a self-inverting token is declared once, not per spine", () => {
72
+ it("finds the self-inverting tokens to check", () => {
73
+ expect(selfInverting.length).toBeGreaterThan(0);
74
+ // The assertions below are absence checks, so they hold trivially against
75
+ // an empty map. A selector rename that stopped matching the light spine
76
+ // would otherwise read as "nothing is restated" — the strongest possible
77
+ // pass from the weakest possible parse.
78
+ expect(light.size).toBeGreaterThan(5);
79
+ });
80
+
81
+ for (const token of selfInverting) {
82
+ it(`${token} is not restated on the light spine`, () => {
83
+ expect(light.has(token)).toBe(false);
84
+ });
85
+ }
86
+ });
@@ -104,4 +104,10 @@
104
104
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
105
105
  --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
106
106
  --shadow-glow: none;
107
+ /* Theme-derived, so it inverts — see tokens.css. Declared here as well so
108
+ Tailwind emits the `shadow-overlay` utility, which is how a consumer
109
+ reaches it without writing `shadow-[var(--shadow-overlay)]`. */
110
+ --shadow-overlay:
111
+ 0 2px 6px hsl(var(--hsl-foreground) / 0.1),
112
+ 0 16px 40px hsl(var(--hsl-foreground) / 0.16);
107
113
  }
@@ -37,7 +37,7 @@
37
37
  .dark {
38
38
  --md3-surface: #151523; /* L0 page canvas */
39
39
  --md3-surface-dim: #0f0f1a; /* recessed page */
40
- --md3-surface-bright: #434365;
40
+ --md3-surface-bright: #393957;
41
41
  /* Same value as --md3-surface-dim by design, not by drift: both name the floor
42
42
  * of the ladder, and dark has no plane below it to give them. They stay two
43
43
  * tokens because the roles diverge in light, where the well is paper (#ffffff)
@@ -46,11 +46,11 @@
46
46
  --md3-surface-container-lowest: #0f0f1a; /* L-1 terminal/code well */
47
47
  --md3-surface-container-low: #222235; /* L1 chrome, field wells */
48
48
  --md3-surface-container: #2d2e43; /* L2 resting card */
49
- --md3-surface-container-high: #363654; /* L3 nested / hover */
50
- --md3-surface-container-highest: #434365; /* L4 overlay */
51
- --md3-surface-variant: #363654;
49
+ --md3-surface-container-high: #32324e; /* L3 nested / hover */
50
+ --md3-surface-container-highest: #393957; /* L4 overlay */
51
+ --md3-surface-variant: #32324e;
52
52
  --md3-on-surface: #e3e6ed;
53
- --md3-on-surface-variant: #a5abbb;
53
+ --md3-on-surface-variant: #b2b4c7;
54
54
 
55
55
  --md3-primary: #A5B4FC;
56
56
  --md3-primary-dim: #7C6CF6;
@@ -89,20 +89,23 @@
89
89
  --hsl-foreground: 225 22% 91%; /* #e3e6ed */
90
90
  --hsl-card: 238 20% 22%; /* L2 card #2d2e43 */
91
91
  --hsl-card-foreground: 225 22% 91%;
92
- --hsl-popover: 240 22% 27%; /* L4 overlay */
92
+ --hsl-popover: 239 22% 25%; /* L3 #32324e — the NESTED plane despite
93
+ the name; the overlay plane is --md3-surface-container-highest. A popover
94
+ carries body text, and the ladder above L3 is reserved for planes that lift
95
+ by shadow, so it sits on L3 and the ink ramp reaches it. */
93
96
  --hsl-popover-foreground: 225 22% 91%;
94
97
  /* The fill indigo, NOT the bright one: `.btn-primary` puts white on this, and
95
98
  * #6366F1 carries white at only 4.47:1. This clears the floor at 7.4:1. The
96
99
  * bright indigo stays available for accent TEXT via --accent-text. */
97
100
  --hsl-primary: 246 54% 51%; /* #4c3fc6 */
98
101
  --hsl-primary-foreground: 0 0% 100%;
99
- --hsl-secondary: 240 22% 27%; /* L3 #363654 */
102
+ --hsl-secondary: 239 22% 25%; /* L3 #32324e */
100
103
  --hsl-secondary-foreground: 225 22% 91%;
101
- --hsl-muted: 240 22% 27%; /* L3 hover well */
102
- --hsl-muted-foreground: 223 14% 69%; /* #a5abbb — high enough that the
104
+ --hsl-muted: 239 22% 25%; /* L3 hover well */
105
+ --hsl-muted-foreground: 234 16% 74%; /* #b2b4c7 — high enough that the
103
106
  reduced-opacity caption derivatives (/70, /60) still clear the large-text
104
107
  contrast floor against the L2 card. */
105
- --hsl-accent: 240 22% 27%; /* neutral hover (indigo lives in accent-surface-strong) */
108
+ --hsl-accent: 239 22% 25%; /* neutral hover (indigo lives in accent-surface-strong) */
106
109
  --hsl-accent-foreground: 225 22% 91%;
107
110
  --hsl-destructive: 348 74% 48%;
108
111
  --hsl-destructive-foreground: 0 0% 100%;
@@ -154,7 +157,7 @@
154
157
  --sidebar-foreground: 225 22% 91%;
155
158
  --sidebar-primary: 246 54% 51%;
156
159
  --sidebar-primary-foreground: 0 0% 100%;
157
- --sidebar-accent: 240 22% 27%; /* L3 hover well */
160
+ --sidebar-accent: 239 22% 25%; /* L3 hover well */
158
161
  --sidebar-accent-foreground: 225 22% 91%;
159
162
  --sidebar-border: 236 20% 35%;
160
163
  --sidebar-ring: 233 100% 87%;
@@ -162,8 +165,8 @@
162
165
  /* Depth scale — tracks the MD3 ladder (canvas / card / nested / overlay) */
163
166
  --depth-1: #151523;
164
167
  --depth-2: #2d2e43;
165
- --depth-3: #363654;
166
- --depth-4: #434365;
168
+ --depth-3: #32324e;
169
+ --depth-4: #393957;
167
170
 
168
171
  /* Status colors */
169
172
  --status-running: #10B981;
@@ -175,18 +178,18 @@
175
178
  --status-deleted: #6B7094;
176
179
 
177
180
  /* Status surface tokens — bg / border / text for each semantic color */
178
- --surface-success-bg: #022c22;
179
- --surface-success-border: #14532d;
180
- --surface-success-text: #34D399;
181
- --surface-warning-bg: #451a03;
182
- --surface-warning-border: #78350f;
183
- --surface-warning-text: #fbbf24;
184
- --surface-danger-bg: #450a0a;
185
- --surface-danger-border: #7f1d1d;
186
- --surface-danger-text: #f87171;
187
- --surface-info-bg: #082f49;
188
- --surface-info-border: #0c4a6e;
189
- --surface-info-text: #38bdf8;
181
+ --surface-success-bg: #2f5146;
182
+ --surface-success-border: #3c7760;
183
+ --surface-success-text: #5ed7a4;
184
+ --surface-warning-bg: #6a402c;
185
+ --surface-warning-border: #916436;
186
+ --surface-warning-text: #f4c14f;
187
+ --surface-danger-bg: #733b36;
188
+ --surface-danger-border: #9a5b56;
189
+ --surface-danger-text: #ffada9;
190
+ --surface-info-bg: #2d4e67;
191
+ --surface-info-border: #3d7292;
192
+ --surface-info-text: #67cdff;
190
193
  --surface-teal-bg: #042f2e;
191
194
  --surface-teal-border: #134e4a;
192
195
  --surface-teal-text: #2dd4bf;
@@ -196,6 +199,31 @@
196
199
  --surface-orange-bg: #431407;
197
200
  --surface-orange-border: #7c2d12;
198
201
  --surface-orange-text: #fb923c;
202
+
203
+ /* Run-mix ramp — the fills of a proportional status bar.
204
+ *
205
+ * Separate from the `--surface-*-text` tones because a 6px bar is read
206
+ * differently from a chip: it is told by hue alone, at a size too small to
207
+ * carry a glyph or a label, so its slices must ALSO separate from each other
208
+ * in greyscale. These five are spaced at least 1.18:1 apart in relative
209
+ * luminance — 1.30 in dark, 1.20 in light, where the ramp has less room
210
+ * before it runs into the paper — and each clears 3:1 against the track, so
211
+ * the mix stays countable with any form of colour blindness and in a
212
+ * monochrome screenshot. 1.18 is the floor the gate holds them to.
213
+ *
214
+ * Ranked by how much the reader needs to tell them apart: succeeded is
215
+ * brightest and cancelled quietest, so the two that matter — succeeded and
216
+ * failed — sit at opposite ends and can never be confused.
217
+ *
218
+ * Spacing them by OKLCH lightness does NOT work: that is perceptual, and the
219
+ * contrast ratio is computed from relative luminance, so an even perceptual
220
+ * ramp left every pair at 1.1:1. Solved in luminance space instead. */
221
+ --run-mix-succeeded: #76edb9;
222
+ --run-mix-waiting: #e9b642;
223
+ --run-mix-failed: #e1918e;
224
+ --run-mix-in-flight: #2d9bca;
225
+ --run-mix-cancelled: #767d90;
226
+
199
227
  --surface-neutral-bg: var(--depth-3);
200
228
  --surface-neutral-border: var(--border-subtle);
201
229
  --surface-neutral-text: var(--text-muted);
@@ -234,15 +262,22 @@
234
262
  --bg-selection: rgba(124, 108, 246, 0.28);
235
263
 
236
264
  /* Text — neutral */
237
- /* Four steps the eye can actually separate: 10.6 / 7.5 / 5.8 / 4.7 against the
265
+ /* Four steps the eye can actually separate: 10.6 / 7.8 / 6.5 / 5.4 against the
238
266
  * L2 card. --text-dim is the faintest tier but it is still TEXT, so it clears
239
- * the 4.5:1 body floor rather than only the 3:1 large-text one — asserted in
240
- * tokens.test.ts against both the card and the canvas, since the tier is
241
- * solved against one surface and rendered on both. */
267
+ * the 4.5:1 body floor rather than only the 3:1 large-text one.
268
+ *
269
+ * Solved against the WHOLE ladder, not against one plane. A faint tier tuned
270
+ * on the card alone still has to clear the floor on the nested and overlay
271
+ * planes above it, which carry the modals, the drawers and every dropdown
272
+ * menu — so both ends meet in the middle: the faint tiers sit high enough for
273
+ * the overlay, and the planes above the card sit low enough for them. The
274
+ * card, the chrome and the canvas are fixed points. tokens.test.ts asserts
275
+ * every tier on every plane, so a retune cannot open a gap on one plane while
276
+ * looking correct on another. */
242
277
  --text-primary: #e3e6ed;
243
- --text-secondary: #bec3d0;
244
- --text-muted: #a5abbb;
245
- --text-dim: #9299ab;
278
+ --text-secondary: #c2c7d4;
279
+ --text-muted: #b2b4c7;
280
+ --text-dim: #9ea5b9;
246
281
 
247
282
  /* Brand accents — Tangle */
248
283
  --brand-primary: #7C6CF6;
@@ -284,6 +319,18 @@
284
319
  --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
285
320
  --shadow-glow: none;
286
321
  --shadow-accent: 0 1px 2px rgba(0, 0, 0, 0.3);
322
+ /* The lift above --shadow-dropdown, for a surface that covers the page
323
+ rather than sitting beside it: a drawer, a modal, a floating dock.
324
+ Two layers — a tight contact shadow plus a wide ambient one.
325
+ Alone among the shadows this is derived from --hsl-foreground instead of
326
+ a fixed rgba, so it INVERTS with the theme. That is what makes it work in
327
+ dark: a black shadow on a dark canvas renders as nothing (see the ladder
328
+ note at the top of this file), while the foreground is near-white there,
329
+ so the same declaration reads as a soft halo. One definition therefore
330
+ serves both themes and the light block does not restate it. */
331
+ --shadow-overlay:
332
+ 0 2px 6px hsl(var(--hsl-foreground) / 0.1),
333
+ 0 16px 40px hsl(var(--hsl-foreground) / 0.16);
287
334
  --shadow-status-running: none;
288
335
  --shadow-status-creating: none;
289
336
  --shadow-status-error: none;
@@ -485,7 +532,7 @@
485
532
  --surface-success-text: #047857;
486
533
  --surface-warning-bg: #fffbeb;
487
534
  --surface-warning-border: #fde68a;
488
- --surface-warning-text: #b45309;
535
+ --surface-warning-text: #ab4f09;
489
536
  --surface-danger-bg: #fff1f2;
490
537
  --surface-danger-border: #fecdd3;
491
538
  --surface-danger-text: #b91c1c;
@@ -501,6 +548,31 @@
501
548
  --surface-orange-bg: #fff7ed;
502
549
  --surface-orange-border: #fed7aa;
503
550
  --surface-orange-text: #c2410c;
551
+
552
+ /* Run-mix ramp — the fills of a proportional status bar.
553
+ *
554
+ * Separate from the `--surface-*-text` tones because a 6px bar is read
555
+ * differently from a chip: it is told by hue alone, at a size too small to
556
+ * carry a glyph or a label, so its slices must ALSO separate from each other
557
+ * in greyscale. These five are spaced at least 1.18:1 apart in relative
558
+ * luminance — 1.30 in dark, 1.20 in light, where the ramp has less room
559
+ * before it runs into the paper — and each clears 3:1 against the track, so
560
+ * the mix stays countable with any form of colour blindness and in a
561
+ * monochrome screenshot. 1.18 is the floor the gate holds them to.
562
+ *
563
+ * Ranked by how much the reader needs to tell them apart: succeeded is
564
+ * brightest and cancelled quietest, so the two that matter — succeeded and
565
+ * failed — sit at opposite ends and can never be confused.
566
+ *
567
+ * Spacing them by OKLCH lightness does NOT work: that is perceptual, and the
568
+ * contrast ratio is computed from relative luminance, so an even perceptual
569
+ * ramp left every pair at 1.1:1. Solved in luminance space instead. */
570
+ --run-mix-succeeded: #3c9b78;
571
+ --run-mix-waiting: #c16227;
572
+ --run-mix-failed: #cd342f;
573
+ --run-mix-in-flight: #00679e;
574
+ --run-mix-cancelled: #515367;
575
+
504
576
  --surface-neutral-bg: var(--depth-3);
505
577
  --surface-neutral-border: var(--border-subtle);
506
578
  --surface-neutral-text: var(--text-muted);
@@ -185,34 +185,179 @@ describe("the faint ink tiers are still TEXT, on every plane they land on", () =
185
185
  // surface, so any move in the ladder — lifting the card, softening the canvas,
186
186
  // recasting the hue — silently drops them under the floor, and nothing about
187
187
  // the result LOOKS wrong: it renders as slightly faint captions that happen to
188
- // be unreadable. Every plane is asserted, not just the one each was tuned on.
188
+ // be unreadable.
189
+ //
190
+ // EVERY plane, not just the two a tier is easiest to tune on. Asserting the
191
+ // canvas and the card alone would leave the nested and overlay planes
192
+ // unguarded while this test's name still claimed the whole ladder — and those
193
+ // are the planes that carry a modal, a drawer, a w-80 dashboard panel and four
194
+ // dropdown menus, so a tier that fails only there fails where the reading
195
+ // actually happens.
189
196
  //
190
197
  // Compared hex-to-hex through the MD3 ladder rather than the HSL bridge,
191
- // because these tokens ARE hexes; `--md3-surface` and `--md3-surface-container`
192
- // are the canvas and the card at the same positions.
198
+ // because these tokens ARE hexes, and the ladder names each plane at the same
199
+ // position in both themes.
193
200
  const ratioOf = (a: number, b: number) =>
194
201
  (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
195
202
 
203
+ /** Every plane the spine paints resting content on, floor to overlay. */
204
+ const PLANES = [
205
+ ["canvas", "md3-surface"],
206
+ ["chrome / field well", "md3-surface-container-low"],
207
+ ["card", "md3-surface-container"],
208
+ ["nested / hover", "md3-surface-container-high"],
209
+ ["overlay", "md3-surface-container-highest"],
210
+ ] as const;
211
+
196
212
  for (const [theme, spine] of [
197
213
  ["dark", DARK],
198
214
  ["light", LIGHT],
199
215
  ] as const) {
200
216
  for (const tier of ["text-dim", "text-muted"] as const) {
201
- it(`${theme}: --${tier} clears 4.5:1 on both the card and the canvas`, () => {
217
+ it(`${theme}: --${tier} clears 4.5:1 on every plane in the ladder`, () => {
202
218
  expect(spine, `--${tier} must be defined`).toContain(`--${tier}:`);
203
219
  const ink = hexRelativeLuminanceIn(spine, tier);
204
- for (const [plane, token] of [
205
- ["card", "md3-surface-container"],
206
- ["canvas", "md3-surface"],
207
- ] as const) {
220
+ for (const [plane, token] of PLANES) {
208
221
  expect(
209
222
  ratioOf(ink, hexRelativeLuminanceIn(spine, token)),
210
- `--${tier} on the ${plane}`,
223
+ `--${tier} on the ${plane} (--${token})`,
211
224
  ).toBeGreaterThanOrEqual(4.5);
212
225
  }
213
226
  });
214
227
  }
215
228
  }
229
+
230
+ it("keeps a real STEP between the ink tiers, not merely an order", () => {
231
+ // A tier that has to clear AA on a lighter plane can always be brightened
232
+ // until it does — but once it sits within a hair of the tier above it, the
233
+ // rung has been deleted rather than repaired, and both an ordering check
234
+ // and a pure contrast check call that a pass.
235
+ //
236
+ // 1.2:1 is the floor because that is roughly where two greys on the same
237
+ // surface stop being separable side by side. The ramp is solved to it, so a
238
+ // retune that compresses one end reds here instead of quietly flattening.
239
+ const MIN_TIER_STEP = 1.2;
240
+ const ratioOf = (a: number, b: number) =>
241
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
242
+ for (const [theme, spine] of [
243
+ ["dark", DARK],
244
+ ["light", LIGHT],
245
+ ] as const) {
246
+ const tiers = ["text-dim", "text-muted", "text-secondary", "text-primary"];
247
+ const lums = tiers.map((t) => hexRelativeLuminanceIn(spine, t));
248
+ for (let i = 1; i < tiers.length; i++) {
249
+ expect(
250
+ ratioOf(lums[i], lums[i - 1]),
251
+ `${theme}: --${tiers[i - 1]} and --${tiers[i]} are too close to read as different tiers`,
252
+ ).toBeGreaterThanOrEqual(MIN_TIER_STEP);
253
+ }
254
+ // Monotonic in the direction the theme recedes: dark quiets by darkening,
255
+ // light by lightening.
256
+ const rising = lums.every((l, i) => i === 0 || l > lums[i - 1]);
257
+ const falling = lums.every((l, i) => i === 0 || l < lums[i - 1]);
258
+ expect(
259
+ theme === "dark" ? rising : falling,
260
+ `${theme}: the ink ramp must move in one direction from dim to primary`,
261
+ ).toBe(true);
262
+ }
263
+ });
264
+
265
+ it("keeps a status chip a TINT in light and a RAISED FILL in dark", () => {
266
+ // The two themes want opposite things from a chip's fill, and treating them
267
+ // the same is how this got broken once already: a dark-mode rule — "the fill
268
+ // must separate from the card so the chip reads as a plane" — was applied to
269
+ // light, where the card is white, so 1.5:1 from it IS a dark grey. Every
270
+ // contrast floor still passed and every chip turned to mud.
271
+ //
272
+ // Light: the fill stays a WASH near paper and the border does the
273
+ // separating. Dark: the fill is a raised tint that has to lift off the card,
274
+ // because a dark chip with a near-card fill is just coloured text.
275
+ const ratioOf = (a: number, b: number) =>
276
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
277
+ const TONES = ["success", "warning", "danger", "info"] as const;
278
+
279
+ for (const tone of TONES) {
280
+ const lightFill = ratioOf(
281
+ hexRelativeLuminanceIn(LIGHT, `surface-${tone}-bg`),
282
+ hexRelativeLuminanceIn(LIGHT, "md3-surface-container"),
283
+ );
284
+ expect(
285
+ lightFill,
286
+ `light --surface-${tone}-bg must stay a tint near paper — the border separates the chip, not the fill`,
287
+ ).toBeLessThanOrEqual(1.25);
288
+
289
+ const darkFill = ratioOf(
290
+ hexRelativeLuminanceIn(DARK, `surface-${tone}-bg`),
291
+ hexRelativeLuminanceIn(DARK, "md3-surface-container"),
292
+ );
293
+ expect(
294
+ darkFill,
295
+ `dark --surface-${tone}-bg must lift off the card, or the chip is only coloured text`,
296
+ ).toBeGreaterThanOrEqual(1.3);
297
+ }
298
+ });
299
+
300
+ it("keeps the run-mix ramp countable without colour", () => {
301
+ // The bar is 6px tall and told by hue alone, so its slices must separate in
302
+ // GREYSCALE too. Spacing them evenly by OKLCH lightness does not do it —
303
+ // that is perceptual, while the ratio is computed from relative luminance —
304
+ // so the ramp is solved in luminance space and pinned here.
305
+ const RAMP = [
306
+ "run-mix-succeeded",
307
+ "run-mix-waiting",
308
+ "run-mix-failed",
309
+ "run-mix-in-flight",
310
+ "run-mix-cancelled",
311
+ ] as const;
312
+ const ratioOf = (a: number, b: number) =>
313
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
314
+ // The bar's track is `bg-background-tertiary` -> `--muted`, which is an HSL
315
+ // bridge token. `--md3-surface-container-high` is the same plane expressed
316
+ // as a hex, which is what this helper can read.
317
+ for (const [theme, spine, track] of [
318
+ ["dark", DARK, "md3-surface-container-high"],
319
+ ["light", LIGHT, "md3-surface-container-high"],
320
+ ] as const) {
321
+ const lums = RAMP.map((t) => hexRelativeLuminanceIn(spine, t));
322
+ const trackLum = hexRelativeLuminanceIn(spine, track);
323
+ for (const [i, l] of lums.entries()) {
324
+ expect(
325
+ ratioOf(l, trackLum),
326
+ `${theme}: --${RAMP[i]} against the bar's track`,
327
+ ).toBeGreaterThanOrEqual(2.9);
328
+ }
329
+ for (let i = 1; i < lums.length; i++) {
330
+ expect(
331
+ ratioOf(lums[i], lums[i - 1]),
332
+ `${theme}: --${RAMP[i - 1]} and --${RAMP[i]} are indistinguishable in greyscale`,
333
+ ).toBeGreaterThanOrEqual(1.18);
334
+ }
335
+ }
336
+ });
337
+
338
+ it("keeps every status TEXT legible on the bare page canvas", () => {
339
+ // Each status text token is solved against its own paired background, and
340
+ // that pairing is what `StatusPill` guarantees. But the tokens also get used
341
+ // as bare text — a "required" hint, a warning headline — where the plane
342
+ // behind them is the page itself, and nothing about that use announces it
343
+ // has left the pairing. So every tone is held to the body floor on the
344
+ // canvas as well, and the set stands or falls together rather than one tone
345
+ // being quietly scoped tighter than its siblings.
346
+ const ratioOf = (a: number, b: number) =>
347
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
348
+ for (const [theme, spine] of [
349
+ ["dark", DARK],
350
+ ["light", LIGHT],
351
+ ] as const) {
352
+ const canvas = hexRelativeLuminanceIn(spine, "md3-surface");
353
+ for (const tone of ["success", "warning", "danger", "info"] as const) {
354
+ expect(
355
+ ratioOf(hexRelativeLuminanceIn(spine, `surface-${tone}-text`), canvas),
356
+ `${theme}: --surface-${tone}-text on the page canvas`,
357
+ ).toBeGreaterThanOrEqual(4.5);
358
+ }
359
+ }
360
+ });
216
361
  });
217
362
 
218
363
  describe("input tokens carry two DIFFERENT roles and must not be conflated", () => {