@tangle-network/brand 1.15.6 → 1.16.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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e012234: The canonical surface ladder is GTM's. Dark is indigo-lifted (`#0a0a14` canvas, `#191826` card, `#221f33` nested, `#2c2942` overlay, `#2a293d` hairline); light is a cool `#eceef3` canvas with white paper, `#f1f2f7` wells and a `#c7c6d6` hairline; light syntax uses GTM's palette. Every product on the default theme now renders GTM's surfaces without restating them, and `tangle-dark` repeats the new spine. Ink stays achromatic and surfaces stay below 0.1 chroma, so the accent still signals interaction. Charts regenerates its CSS from the new tokens.
8
+
9
+ ## 1.15.7
10
+
11
+ ### Patch Changes
12
+
13
+ - cfc08fb: Accent text now reads at 4.5:1 on a selected item's primary tint (10–20%) over the canvas, card and muted surfaces in every theme scope. Darkened light `--accent-text`: canonical, legacy-light, agents-light and website light #5047eb → #3c32e9; aubergine-light #6d28d9 → #6423c8; arena-light #047857 → #03674b; tangle-light #4f46e5 → #453be3; system light iris-11 (#5753c6) → #4e4ac3. Lightened hospitality dark #76b791 → #99caad, with its `--md3-primary` kept equal to the accent text. A new `accent-on-tint.test.ts` asserts the pair for every scope, sharing the scope list with the Switch contrast suite through `theme-scopes.ts`. Agent App projects these values from Brand, so apps pick them up with the next Agent App release built on this version.
14
+
3
15
  ## 1.15.6
4
16
 
5
17
  ### Patch Changes
package/README.md CHANGED
@@ -101,7 +101,7 @@ import { Logo, TangleKnot } from "@tangle-network/brand";
101
101
 
102
102
  ## Palette and typography
103
103
 
104
- The base dark surface ladder is neutral `#161616` / `#303030` / `#353535` / `#3b3b3b`. Light uses a neutral `#eaeaea` canvas, white paper cards and overlays, and `#f2f2f2` nested wells. Indigo is the interaction accent, not the base field. Existing named products may supply deliberate surface overrides.
104
+ The base surface ladder is the one GTM proved out: dark is indigo-lifted `#0a0a14` / `#191826` / `#221f33` / `#2c2942`; light is a cool `#eceef3` canvas, white paper cards and overlays, and `#f1f2f7` nested wells. The cast stays below 0.1 chroma and ink stays achromatic, so indigo still reads as the interaction accent, not the base field. Existing named products may supply deliberate surface overrides.
105
105
 
106
106
  Chrome radii are 6/8/10/12px in both base modes; the composer has its own 26px role. Inter leads body and display stacks, with Geist fallbacks; Geist Mono leads code with JetBrains Mono and system fallbacks. Runtime font/radius registrations retain their public override names; tests keep Tailwind registration defaults aligned with the canonical declarations.
107
107
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.15.6",
3
+ "version": "1.16.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",
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { compositeOver, contrastRatio } from "./css-test-utils";
3
+ import { resolve, scopes } from "./theme-scopes";
4
+
5
+ /**
6
+ * Selected and active states draw accent text on a primary tint. In apps on
7
+ * the Agent App preset, `text-primary` resolves to `--accent-text`, and a
8
+ * selected filter chip, tab or count badge is `text-primary` on `bg-primary/10`
9
+ * to `bg-primary/20`. That text is body-sized, so WCAG 1.4.3 asks 4.5:1 against
10
+ * the tinted fill, not only against the bare surface the other suites check.
11
+ * Brand's own theme maps `text-primary` to the primary fill instead; this
12
+ * suite does not cover that pairing, and stacked tints are out of range.
13
+ *
14
+ * This suite composites the primary fill at each opacity products use over
15
+ * every surface a selected item sits on, in every scope a consumer can select,
16
+ * and holds the accent text to 4.5:1 on each result.
17
+ */
18
+ const TINTS = [0.1, 0.15, 0.2] as const;
19
+ const SURFACES = [
20
+ ["canvas", "hsl-background"],
21
+ ["card", "hsl-card"],
22
+ ["muted", "hsl-muted"],
23
+ ] as const;
24
+
25
+ describe("accent text reads on the selected tint in every theme scope", () => {
26
+ for (const { name, sources } of scopes) {
27
+ it(name, () => {
28
+ const accentText = resolve(sources, "accent-text");
29
+ const primary = resolve(sources, "hsl-primary");
30
+ for (const [surfaceName, token] of SURFACES) {
31
+ const surface = resolve(sources, token);
32
+ for (const alpha of TINTS) {
33
+ const tint = compositeOver(primary, surface, alpha);
34
+ expect(
35
+ contrastRatio(accentText, tint),
36
+ `accent text on primary/${alpha * 100} over ${surfaceName}`,
37
+ ).toBeGreaterThanOrEqual(4.5);
38
+ }
39
+ }
40
+ });
41
+ }
42
+ });
@@ -1,6 +1,6 @@
1
1
  /* GENERATED by scripts/gen-compat.mjs. Do not edit.
2
2
  * Source: tokens.css + named-themes.css
3
- * SHA-256: 9d5eee8a83628c74a74b439891d4fdff80ebc80e178fd009e0c0e54191c0a211
3
+ * SHA-256: 6376234ba229d9bd8fc0be766a9998b61146de56ee8dc9d5bb5a0ba2206951f0
4
4
  * Light-default compatibility; all explicit modes remain canonical.
5
5
  */
6
6
  :where(:root),
@@ -24,15 +24,15 @@
24
24
  .light,
25
25
  .dark {
26
26
  color-scheme: dark;
27
- --md3-surface: #161616;
28
- --md3-surface-dim: #101010;
29
- --md3-surface-bright: #3b3b3b;
30
- --md3-surface-container-lowest: #101010;
31
- --md3-surface-container-low: #242424;
32
- --md3-surface-container: #303030;
33
- --md3-surface-container-high: #353535;
34
- --md3-surface-container-highest: #3b3b3b;
35
- --md3-surface-variant: #353535;
27
+ --md3-surface: #0a0a14;
28
+ --md3-surface-dim: #060610;
29
+ --md3-surface-bright: #232238;
30
+ --md3-surface-container-lowest: #050509;
31
+ --md3-surface-container-low: #12111f;
32
+ --md3-surface-container: #191826;
33
+ --md3-surface-container-high: #221f33;
34
+ --md3-surface-container-highest: #2c2942;
35
+ --md3-surface-variant: #221f33;
36
36
  --md3-on-surface: #e6e6e6;
37
37
  --md3-on-surface-variant: #b5b5b5;
38
38
  --md3-primary: #A5B4FC;
@@ -50,7 +50,7 @@
50
50
  --md3-tertiary: #10B981;
51
51
  --md3-tertiary-container: var(--surface-success-bg);
52
52
  --md3-outline: #8e8e8e;
53
- --md3-outline-variant: #4c4c4c;
53
+ --md3-outline-variant: #2a293d;
54
54
  --md3-error: #ff5277;
55
55
  --md3-error-dim: #eb2855;
56
56
  --md3-error-container: #82042b;
@@ -60,24 +60,24 @@
60
60
  --md3-inverse-primary: #4C3FC5;
61
61
  --md3-inverse-on-surface: #000000;
62
62
  --md3-surface-tint: #A5B4FC;
63
- --hsl-background: 0 0% 8.6275%;
63
+ --hsl-background: 240 33% 6%;
64
64
  --hsl-foreground: 0 0% 90.1961%;
65
- --hsl-card: 0 0% 18.8235%;
65
+ --hsl-card: 244 23% 12%;
66
66
  --hsl-card-foreground: 0 0% 90.1961%;
67
- --hsl-popover: 0 0% 20.7843%;
67
+ --hsl-popover: 247 23% 21%;
68
68
  --hsl-popover-foreground: 0 0% 90.1961%;
69
69
  --hsl-primary: 246 54% 51%;
70
70
  --hsl-primary-foreground: 0 0% 100%;
71
- --hsl-secondary: 0 0% 20.7843%;
71
+ --hsl-secondary: 249 24% 16%;
72
72
  --hsl-secondary-foreground: 0 0% 90.1961%;
73
- --hsl-muted: 0 0% 20.7843%;
74
- --hsl-muted-foreground: 0 0% 70.9804%;
75
- --hsl-accent: 0 0% 20.7843%;
73
+ --hsl-muted: 249 24% 16%;
74
+ --hsl-muted-foreground: 240 5% 68%;
75
+ --hsl-accent: 249 24% 16%;
76
76
  --hsl-accent-foreground: 0 0% 90.1961%;
77
77
  --hsl-destructive: 348 74% 48%;
78
78
  --hsl-destructive-foreground: 0 0% 100%;
79
- --hsl-border: 0 0% 29.8039%;
80
- --hsl-input: 0 0% 12.1569%;
79
+ --hsl-border: 243 20% 20%;
80
+ --hsl-input: 244 29% 9%;
81
81
  --hsl-ring: 233 100% 87%;
82
82
  --hsl-success: 160 84% 39%;
83
83
  --hsl-warning: 40 94% 56%;
@@ -105,7 +105,7 @@
105
105
  --warning: var(--hsl-warning);
106
106
  --error: var(--hsl-destructive);
107
107
  --info: var(--hsl-info);
108
- --sidebar-background: 0 0% 6.2745%;
108
+ --sidebar-background: 244 29% 9%;
109
109
  --sidebar-foreground: 0 0% 90.1961%;
110
110
  --sidebar-primary: 246 54% 51%;
111
111
  --sidebar-primary-foreground: 0 0% 100%;
@@ -113,10 +113,10 @@
113
113
  --sidebar-accent-foreground: 0 0% 90.1961%;
114
114
  --sidebar-border: 0 0% 29.8039%;
115
115
  --sidebar-ring: 233 100% 87%;
116
- --depth-1: #161616;
117
- --depth-2: #303030;
118
- --depth-3: #353535;
119
- --depth-4: #3b3b3b;
116
+ --depth-1: #0a0a14;
117
+ --depth-2: #191826;
118
+ --depth-3: #221f33;
119
+ --depth-4: #2c2942;
120
120
  --status-running: #10B981;
121
121
  --status-creating: #A5B4FC;
122
122
  --status-stopped: #FFB800;
@@ -136,69 +136,69 @@
136
136
  --surface-info-bg: #2d4e67;
137
137
  --surface-info-border: #3d7292;
138
138
  --surface-info-text: #67cdff;
139
- --tone-violet-bg: #4e3a60;
140
- --tone-violet-bg-hover: #583d71;
141
- --tone-violet-bg-selected: #624080;
139
+ --tone-violet-bg: #392751;
140
+ --tone-violet-bg-hover: #432b60;
141
+ --tone-violet-bg-selected: #4c306c;
142
142
  --tone-violet-border: #8457aa;
143
143
  --tone-violet-border-selected: #d19dff;
144
144
  --tone-violet-text: #ecd9fa;
145
145
  --tone-violet-icon: #d19dff;
146
- --tone-orange-bg: #583c2b;
147
- --tone-orange-bg-hover: #664029;
148
- --tone-orange-bg-selected: #724327;
146
+ --tone-orange-bg: #432823;
147
+ --tone-orange-bg-hover: #512d22;
148
+ --tone-orange-bg-selected: #5e3221;
149
149
  --tone-orange-border: #a35829;
150
- --tone-orange-border-selected: #ff801f;
150
+ --tone-orange-border-selected: #f76b15;
151
151
  --tone-orange-text: #ffe0c2;
152
- --tone-orange-icon: #ff801f;
153
- --tone-teal-bg: #2a4744;
154
- --tone-teal-bg-hover: #28504b;
155
- --tone-teal-bg-selected: #265751;
152
+ --tone-orange-icon: #f76b15;
153
+ --tone-teal-bg: #18333b;
154
+ --tone-teal-bg-hover: #173b42;
155
+ --tone-teal-bg-selected: #174448;
156
156
  --tone-teal-border: #207e73;
157
- --tone-teal-border-selected: #0eb39e;
157
+ --tone-teal-border-selected: #12a594;
158
158
  --tone-teal-text: #adf0dd;
159
- --tone-teal-icon: #0eb39e;
160
- --tone-blue-bg: #25455e;
161
- --tone-blue-bg-hover: #224c6c;
162
- --tone-blue-bg-selected: #1f537b;
159
+ --tone-teal-icon: #12a594;
160
+ --tone-blue-bg: #143051;
161
+ --tone-blue-bg-hover: #123861;
162
+ --tone-blue-bg-selected: #113e6b;
163
163
  --tone-blue-border: #2870bd;
164
- --tone-blue-border-selected: #70b8ff;
164
+ --tone-blue-border-selected: #0090ff;
165
165
  --tone-blue-text: #c2e6ff;
166
- --tone-blue-icon: #70b8ff;
167
- --tone-pink-bg: #5e344f;
168
- --tone-pink-bg-hover: #6d3659;
169
- --tone-pink-bg-selected: #7b3762;
166
+ --tone-blue-icon: #0090ff;
167
+ --tone-pink-bg: #462243;
168
+ --tone-pink-bg-hover: #55254d;
169
+ --tone-pink-bg-selected: #612754;
170
170
  --tone-pink-border: #a84885;
171
- --tone-pink-border-selected: #ff8dcc;
171
+ --tone-pink-border-selected: #de51a8;
172
172
  --tone-pink-text: #fdd1ea;
173
- --tone-pink-icon: #ff8dcc;
174
- --tone-brown-bg: #494038;
175
- --tone-brown-bg-hover: #54473c;
176
- --tone-brown-bg-selected: #5c4c3e;
173
+ --tone-pink-icon: #de51a8;
174
+ --tone-brown-bg: #372d30;
175
+ --tone-brown-bg-hover: #413434;
176
+ --tone-brown-bg-selected: #4a3a37;
177
177
  --tone-brown-border: #7c5f46;
178
- --tone-brown-border-selected: #dbb594;
178
+ --tone-brown-border-selected: #ad7f58;
179
179
  --tone-brown-text: #f2e1ca;
180
- --tone-brown-icon: #dbb594;
181
- --tone-cyan-bg: #27464d;
182
- --tone-cyan-bg-hover: #234f59;
183
- --tone-cyan-bg-selected: #205662;
180
+ --tone-brown-icon: #ad7f58;
181
+ --tone-cyan-bg: #143245;
182
+ --tone-cyan-bg-hover: #133b4e;
183
+ --tone-cyan-bg-selected: #114358;
184
184
  --tone-cyan-border: #11809c;
185
- --tone-cyan-border-selected: #23afd0;
185
+ --tone-cyan-border-selected: #00a2c7;
186
186
  --tone-cyan-text: #b6ecf7;
187
- --tone-cyan-icon: #23afd0;
188
- --tone-lime-bg: #404536;
189
- --tone-lime-bg-hover: #444b37;
190
- --tone-lime-bg-selected: #495239;
187
+ --tone-cyan-icon: #00a2c7;
188
+ --tone-lime-bg: #2b302d;
189
+ --tone-lime-bg-hover: #32382f;
190
+ --tone-lime-bg-selected: #384132;
191
191
  --tone-lime-border: #577538;
192
192
  --tone-lime-border-selected: #bdee63;
193
193
  --tone-lime-text: #e3f7ba;
194
194
  --tone-lime-icon: #bdee63;
195
- --surface-violet-bg: #4e3a60;
195
+ --surface-violet-bg: #392751;
196
196
  --surface-violet-border: #8457aa;
197
197
  --surface-violet-text: #d19dff;
198
- --surface-orange-bg: #583c2b;
198
+ --surface-orange-bg: #432823;
199
199
  --surface-orange-border: #a35829;
200
200
  --surface-orange-text: #ffa057;
201
- --surface-teal-bg: #2a4744;
201
+ --surface-teal-bg: #18333b;
202
202
  --surface-teal-border: #207e73;
203
203
  --surface-teal-text: #0bd8b6;
204
204
  --run-mix-succeeded: #76edb9;
@@ -223,7 +223,7 @@
223
223
  --accent-surface-soft: rgba(165, 180, 252, 0.07);
224
224
  --accent-surface-strong: rgba(76, 63, 197, 0.30);
225
225
  --accent-text: #A5B4FC;
226
- --bg-root: #161616;
226
+ --bg-root: #0a0a14;
227
227
  --bg-dark: var(--depth-1);
228
228
  --bg-card: var(--depth-2);
229
229
  --bg-elevated: var(--depth-4);
@@ -372,24 +372,24 @@
372
372
  :where(:root) {
373
373
  color-scheme: light;
374
374
  --code-header-bg: color-mix(in srgb, hsl(var(--hsl-muted)) 50%, transparent);
375
- --syntax-foreground: #24292f;
376
- --syntax-comment: #57606a;
377
- --syntax-keyword: #6d28d9;
375
+ --syntax-foreground: #24243a;
376
+ --syntax-comment: #6b7280;
377
+ --syntax-keyword: #7c3aed;
378
378
  --syntax-string: #047857;
379
- --syntax-function: #1d4ed8;
380
- --syntax-number: #92400e;
381
- --syntax-meta: #7c3aed;
382
- --syntax-variable: #24292f;
383
- --syntax-error: #b91c1c;
384
- --md3-surface: #eaeaea;
385
- --md3-surface-dim: #dadada;
379
+ --syntax-function: #2563eb;
380
+ --syntax-number: #b45309;
381
+ --syntax-meta: #6d28d9;
382
+ --syntax-variable: #334155;
383
+ --syntax-error: #dc2626;
384
+ --md3-surface: #eceef3;
385
+ --md3-surface-dim: #dcdee6;
386
386
  --md3-surface-bright: #ffffff;
387
387
  --md3-surface-container-lowest: #ffffff;
388
- --md3-surface-container-low: #f7f7f7;
388
+ --md3-surface-container-low: #f6f7fa;
389
389
  --md3-surface-container: #ffffff;
390
- --md3-surface-container-high: #f2f2f2;
390
+ --md3-surface-container-high: #f1f2f7;
391
391
  --md3-surface-container-highest: #ffffff;
392
- --md3-surface-variant: #f2f2f2;
392
+ --md3-surface-variant: #f1f2f7;
393
393
  --md3-on-surface: #2d2d2d;
394
394
  --md3-on-surface-variant: #4c4c4c;
395
395
  --md3-primary: #3E349E;
@@ -407,7 +407,7 @@
407
407
  --md3-tertiary: #065F46;
408
408
  --md3-tertiary-container: #D1FAE5;
409
409
  --md3-outline: #787878;
410
- --md3-outline-variant: #d0d0d0;
410
+ --md3-outline-variant: #c7c6d6;
411
411
  --md3-error: #ba1a1a;
412
412
  --md3-error-dim: #93000a;
413
413
  --md3-error-container: #ffdad6;
@@ -417,7 +417,7 @@
417
417
  --md3-inverse-primary: #A5AAFC;
418
418
  --md3-inverse-on-surface: #f0f1f4;
419
419
  --md3-surface-tint: #6366F1;
420
- --hsl-background: 0 0% 91.7647%;
420
+ --hsl-background: 223 23% 94%;
421
421
  --hsl-foreground: 0 0% 17.6471%;
422
422
  --hsl-card: 0 0% 100%;
423
423
  --hsl-card-foreground: 0 0% 17.6471%;
@@ -425,23 +425,23 @@
425
425
  --hsl-popover-foreground: 0 0% 17.6471%;
426
426
  --hsl-primary: 246 54% 51%;
427
427
  --hsl-primary-foreground: 0 0% 100%;
428
- --hsl-secondary: 0 0% 94.902%;
428
+ --hsl-secondary: 230 27% 96%;
429
429
  --hsl-secondary-foreground: 0 0% 17.6471%;
430
- --hsl-muted: 0 0% 94.902%;
430
+ --hsl-muted: 230 27% 96%;
431
431
  --hsl-muted-foreground: 0 0% 29.8039%;
432
- --hsl-accent: 0 0% 94.902%;
432
+ --hsl-accent: 230 27% 96%;
433
433
  --hsl-accent-foreground: 0 0% 17.6471%;
434
434
  --hsl-destructive: 0 72% 41%;
435
435
  --hsl-destructive-foreground: 0 0% 100%;
436
- --hsl-border: 0 0% 81.5686%;
437
- --hsl-input: 0 0% 96.0784%;
436
+ --hsl-border: 244 16% 81%;
437
+ --hsl-input: 225 29% 97%;
438
438
  --hsl-ring: 246 54% 51%;
439
439
  --hsl-success: 160 84% 30%;
440
440
  --hsl-warning: 41 96% 50%;
441
441
  --hsl-info: 212 80% 50%;
442
- --depth-1: #eaeaea;
442
+ --depth-1: #eceef3;
443
443
  --depth-2: #ffffff;
444
- --depth-3: #f2f2f2;
444
+ --depth-3: #f1f2f7;
445
445
  --depth-4: #ffffff;
446
446
  --status-running: #059669;
447
447
  --status-creating: #3E349E;
@@ -550,7 +550,7 @@
550
550
  --mesh-teal: transparent;
551
551
  --mesh-violet: transparent;
552
552
  --mesh-blue: transparent;
553
- --bg-root: #eaeaea;
553
+ --bg-root: #eceef3;
554
554
  --bg-dark: var(--depth-1);
555
555
  --bg-card: var(--depth-2);
556
556
  --bg-elevated: var(--depth-4);
@@ -598,7 +598,7 @@
598
598
  --accent-gradient-strong: linear-gradient(135deg, #4F46E5, #5B4ED4);
599
599
  --accent-surface-soft: rgba(76, 63, 198, 0.05);
600
600
  --accent-surface-strong: rgba(76, 63, 198, 0.13);
601
- --accent-text: #5047eb;
601
+ --accent-text: #3c32e9;
602
602
  --font-sans: "Inter", "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
603
603
  --font-display: "Inter", "Geist", sans-serif;
604
604
  --font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, monospace;
@@ -893,7 +893,7 @@
893
893
  --border-subtle: rgba(60,32,110,0.10);
894
894
  --border-default: rgba(60,32,110,0.16);
895
895
  --border-hover: rgba(60,32,110,0.30);
896
- --accent-text: #6d28d9;
896
+ --accent-text: #6423c8;
897
897
  --brand-primary: #6d28d9;
898
898
  --brand-glow: #8b5cf6;
899
899
  --text-primary: #160f22;
@@ -985,7 +985,7 @@
985
985
  --border-subtle: rgba(6,95,70,0.10);
986
986
  --border-default: rgba(6,95,70,0.16);
987
987
  --border-hover: rgba(6,95,70,0.30);
988
- --accent-text: #047857;
988
+ --accent-text: #03674b;
989
989
  --brand-primary: #059669;
990
990
  --brand-glow: #10b981;
991
991
  --text-primary: #06231a;
@@ -1118,38 +1118,38 @@
1118
1118
  --md3-on-surface-variant: var(--text-muted);
1119
1119
  }
1120
1120
  [data-theme="tangle-dark"] {
1121
- --hsl-background: 0 0% 8.6275%;
1121
+ --hsl-background: 240 33% 6%;
1122
1122
  --hsl-foreground: 0 0% 90.1961%;
1123
- --hsl-card: 0 0% 18.8235%;
1123
+ --hsl-card: 244 23% 12%;
1124
1124
  --hsl-card-foreground: 0 0% 90.1961%;
1125
- --hsl-popover: 0 0% 20.7843%;
1125
+ --hsl-popover: 247 23% 21%;
1126
1126
  --hsl-popover-foreground: 0 0% 90.1961%;
1127
1127
  --hsl-primary: 246 54% 51%;
1128
1128
  --hsl-primary-foreground: 0 0% 100%;
1129
- --hsl-secondary: 0 0% 20.7843%;
1129
+ --hsl-secondary: 249 24% 16%;
1130
1130
  --hsl-secondary-foreground: 0 0% 90.1961%;
1131
- --hsl-muted: 0 0% 20.7843%;
1132
- --hsl-muted-foreground: 0 0% 70.9804%;
1133
- --hsl-accent: 0 0% 20.7843%;
1131
+ --hsl-muted: 249 24% 16%;
1132
+ --hsl-muted-foreground: 240 5% 68%;
1133
+ --hsl-accent: 249 24% 16%;
1134
1134
  --hsl-accent-foreground: 0 0% 90.1961%;
1135
1135
  --hsl-destructive: 348 74% 48%;
1136
1136
  --hsl-destructive-foreground: 0 0% 100%;
1137
- --hsl-border: 0 0% 29.8039%;
1138
- --hsl-input: 0 0% 12.1569%;
1137
+ --hsl-border: 243 20% 20%;
1138
+ --hsl-input: 244 29% 9%;
1139
1139
  --hsl-ring: 233 100% 87%;
1140
- --depth-1: #161616;
1141
- --depth-2: #303030;
1142
- --depth-3: #353535;
1143
- --depth-4: #3b3b3b;
1144
- --bg-root: #161616;
1140
+ --depth-1: #0a0a14;
1141
+ --depth-2: #191826;
1142
+ --depth-3: #221f33;
1143
+ --depth-4: #2c2942;
1144
+ --bg-root: #0a0a14;
1145
1145
  --bg-card: var(--depth-2);
1146
1146
  --bg-input: hsl(var(--hsl-input));
1147
- --md3-surface-container-lowest: #101010;
1148
- --md3-surface-container-low: #242424;
1149
- --md3-surface-container: #303030;
1150
- --md3-surface-container-high: #353535;
1151
- --md3-surface-container-highest: #3b3b3b;
1152
- --md3-outline-variant: #4c4c4c;
1147
+ --md3-surface-container-lowest: #050509;
1148
+ --md3-surface-container-low: #12111f;
1149
+ --md3-surface-container: #191826;
1150
+ --md3-surface-container-high: #221f33;
1151
+ --md3-surface-container-highest: #2c2942;
1152
+ --md3-outline-variant: #2a293d;
1153
1153
  --border-subtle: rgba(255, 255, 255, 0.10);
1154
1154
  --border-default: rgba(255, 255, 255, 0.16);
1155
1155
  --border-hover: rgba(255, 255, 255, 0.28);
@@ -1193,7 +1193,7 @@
1193
1193
  --border-subtle: rgba(20,20,40,0.08);
1194
1194
  --border-default: rgba(20,20,40,0.14);
1195
1195
  --border-hover: rgba(20,20,40,0.26);
1196
- --accent-text: #4f46e5;
1196
+ --accent-text: #453be3;
1197
1197
  --brand-primary: #6366f1;
1198
1198
  --brand-glow: #818cf8;
1199
1199
  --text-primary: #15131f;
@@ -1353,7 +1353,7 @@
1353
1353
  --md3-on-surface-variant: #afbbb4;
1354
1354
  --md3-outline: #8a938e;
1355
1355
  --md3-outline-variant: #48504c;
1356
- --md3-primary: #76b791;
1356
+ --md3-primary: #99caad;
1357
1357
  --md3-primary-dim: #396a52;
1358
1358
  --md3-primary-container: #396a52;
1359
1359
  --md3-on-primary: #11221a;
@@ -1375,7 +1375,7 @@
1375
1375
  --border-hover: rgba(227, 233, 229, 0.28);
1376
1376
  --border-accent: rgba(118, 183, 145, 0.35);
1377
1377
  --border-accent-hover: rgba(118, 183, 145, 0.50);
1378
- --accent-text: #76b791;
1378
+ --accent-text: #99caad;
1379
1379
  --accent-surface-soft: rgba(118, 183, 145, 0.07);
1380
1380
  --accent-surface-strong: rgba(57, 106, 82, 0.40);
1381
1381
  --accent-gradient-strong: linear-gradient(135deg, #396a52, #76b791);
@@ -106,7 +106,7 @@
106
106
  --md3-surface-container: #f4f1fc; --md3-surface-container-high: #ece6f8;
107
107
  --md3-surface-container-highest: #e4dcf4; --md3-outline-variant: #e0d8f0;
108
108
  --border-subtle: rgba(60,32,110,0.10); --border-default: rgba(60,32,110,0.16);
109
- --border-hover: rgba(60,32,110,0.30); --accent-text: #6d28d9;
109
+ --border-hover: rgba(60,32,110,0.30); --accent-text: #6423c8;
110
110
  --brand-primary: #6d28d9; --brand-glow: #8b5cf6;
111
111
  --text-primary: #160f22; --text-secondary: #3f3460; --text-muted: #585070; --text-dim: #655d82;
112
112
  --md3-surface: var(--bg-root); --md3-surface-dim: var(--depth-2);
@@ -152,7 +152,7 @@
152
152
  --md3-surface-container: #f0f6f2; --md3-surface-container-high: #e6f0ea;
153
153
  --md3-surface-container-highest: #dcebe2; --md3-outline-variant: #d7e8de;
154
154
  --border-subtle: rgba(6,95,70,0.10); --border-default: rgba(6,95,70,0.16);
155
- --border-hover: rgba(6,95,70,0.30); --accent-text: #047857;
155
+ --border-hover: rgba(6,95,70,0.30); --accent-text: #03674b;
156
156
  --brand-primary: #059669; --brand-glow: #10b981;
157
157
  --text-primary: #06231a; --text-secondary: #285048; --text-muted: #455f52; --text-dim: #4e6d62;
158
158
  --md3-surface: var(--bg-root); --md3-surface-dim: var(--depth-2);
@@ -220,28 +220,28 @@
220
220
  --md3-on-surface: var(--text-primary); --md3-on-surface-variant: var(--text-muted);
221
221
  }
222
222
 
223
- /* ───────────────────────── Tangle — neutral dark spine ─────────────────────
223
+ /* ───────────────────────── Tangle — canonical dark spine ───────────────────
224
224
  * tangle-dark names the default :root/.dark spine explicitly. The named block
225
- * repeats its neutral surfaces and ink so a dark island remains self-sufficient
225
+ * repeats its surfaces and ink so a dark island remains self-sufficient
226
226
  * inside a light-named wrapper. Indigo stays in the accent; status tokens inherit
227
227
  * the dark-tuned root palette.
228
228
  */
229
229
  [data-theme="tangle-dark"] {
230
- --hsl-background: 0 0% 8.6275%; --hsl-foreground: 0 0% 90.1961%;
231
- --hsl-card: 0 0% 18.8235%; --hsl-card-foreground: 0 0% 90.1961%;
232
- --hsl-popover: 0 0% 20.7843%; --hsl-popover-foreground: 0 0% 90.1961%;
230
+ --hsl-background: 240 33% 6%; --hsl-foreground: 0 0% 90.1961%;
231
+ --hsl-card: 244 23% 12%; --hsl-card-foreground: 0 0% 90.1961%;
232
+ --hsl-popover: 247 23% 21%; --hsl-popover-foreground: 0 0% 90.1961%;
233
233
  --hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
234
- --hsl-secondary: 0 0% 20.7843%; --hsl-secondary-foreground: 0 0% 90.1961%;
235
- --hsl-muted: 0 0% 20.7843%; --hsl-muted-foreground: 0 0% 70.9804%;
236
- --hsl-accent: 0 0% 20.7843%; --hsl-accent-foreground: 0 0% 90.1961%;
234
+ --hsl-secondary: 249 24% 16%; --hsl-secondary-foreground: 0 0% 90.1961%;
235
+ --hsl-muted: 249 24% 16%; --hsl-muted-foreground: 240 5% 68%;
236
+ --hsl-accent: 249 24% 16%; --hsl-accent-foreground: 0 0% 90.1961%;
237
237
  --hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
238
- --hsl-border: 0 0% 29.8039%; --hsl-input: 0 0% 12.1569%;
238
+ --hsl-border: 243 20% 20%; --hsl-input: 244 29% 9%;
239
239
  --hsl-ring: 233 100% 87%;
240
- --depth-1: #161616; --depth-2: #303030; --depth-3: #353535; --depth-4: #3b3b3b;
241
- --bg-root: #161616; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
242
- --md3-surface-container-lowest: #101010; --md3-surface-container-low: #242424;
243
- --md3-surface-container: #303030; --md3-surface-container-high: #353535;
244
- --md3-surface-container-highest: #3b3b3b; --md3-outline-variant: #4c4c4c;
240
+ --depth-1: #0a0a14; --depth-2: #191826; --depth-3: #221f33; --depth-4: #2c2942;
241
+ --bg-root: #0a0a14; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
242
+ --md3-surface-container-lowest: #050509; --md3-surface-container-low: #12111f;
243
+ --md3-surface-container: #191826; --md3-surface-container-high: #221f33;
244
+ --md3-surface-container-highest: #2c2942; --md3-outline-variant: #2a293d;
245
245
  --border-subtle: rgba(255, 255, 255, 0.10); --border-default: rgba(255, 255, 255, 0.16);
246
246
  --border-hover: rgba(255, 255, 255, 0.28); --accent-text: #A5B4FC;
247
247
  --brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
@@ -263,7 +263,7 @@
263
263
  --md3-surface-container: #f4f4f9; --md3-surface-container-high: #edecf4;
264
264
  --md3-surface-container-highest: #e5e4ee; --md3-outline-variant: #e2e1ec;
265
265
  --border-subtle: rgba(20,20,40,0.08); --border-default: rgba(20,20,40,0.14);
266
- --border-hover: rgba(20,20,40,0.26); --accent-text: #4f46e5;
266
+ --border-hover: rgba(20,20,40,0.26); --accent-text: #453be3;
267
267
  --brand-primary: #6366f1; --brand-glow: #818cf8;
268
268
  --text-primary: #15131f; --text-secondary: #3f3a52; --text-muted: #59556b; --text-dim: #67627b;
269
269
  }
@@ -393,7 +393,7 @@
393
393
  --md3-surface-container-highest: #383e3b; --md3-surface-variant: #323835;
394
394
  --md3-on-surface: #e3e9e5; --md3-on-surface-variant: #afbbb4;
395
395
  --md3-outline: #8a938e; --md3-outline-variant: #48504c;
396
- --md3-primary: #76b791; --md3-primary-dim: #396a52; --md3-primary-container: #396a52;
396
+ --md3-primary: #99caad; --md3-primary-dim: #396a52; --md3-primary-container: #396a52;
397
397
  --md3-on-primary: #11221a; --md3-surface-tint: #76b791;
398
398
  --depth-1: #141816; --depth-2: #2d3330; --depth-3: #323835; --depth-4: #383e3b;
399
399
  --bg-root: #141816; --bg-dark: #141816; --bg-section: #141816;
@@ -402,7 +402,7 @@
402
402
  --border-subtle: rgba(227, 233, 229, 0.10); --border-default: rgba(227, 233, 229, 0.16);
403
403
  --border-hover: rgba(227, 233, 229, 0.28);
404
404
  --border-accent: rgba(118, 183, 145, 0.35); --border-accent-hover: rgba(118, 183, 145, 0.50);
405
- --accent-text: #76b791;
405
+ --accent-text: #99caad;
406
406
  --accent-surface-soft: rgba(118, 183, 145, 0.07); --accent-surface-strong: rgba(57, 106, 82, 0.40);
407
407
  --accent-gradient-strong: linear-gradient(135deg, #396a52, #76b791);
408
408
  --brand-primary: #529873; --brand-cool: #76b791; --brand-glow: #94c7a9; --brand-vibrant: #529873;
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { contrastRatio } from "./css-test-utils";
3
+ import { resolve, scopes, themes } from "./theme-scopes";
4
+
5
+ /**
6
+ * The token pairs @tangle-network/ui's Switch is drawn with.
7
+ *
8
+ * A switch is non-text content, so WCAG 1.4.11 asks 3:1 for the parts that show
9
+ * its state: the track's edge against the surface it sits on, and the thumb
10
+ * against the track. Off is a muted-foreground outline and thumb on the
11
+ * surface. On is an accent-text track carrying a card-coloured thumb, so the
12
+ * thumb-on-track pair is the same pair as track-on-card, inverted. The primary
13
+ * fill cannot do this job: it is tuned to carry white text and measures about
14
+ * 2.4:1 on the dark card and canvas.
15
+ *
16
+ * These are text tokens, held to 4.5:1 elsewhere for SOME themes; this suite
17
+ * asserts the 3:1 the Switch depends on in EVERY scope a consumer can select.
18
+ */
19
+ describe("the Switch's token pairs clear 3:1 in every theme scope", () => {
20
+ it("covers every named theme", () => {
21
+ const rules = themes.replace(/\/\*[\s\S]*?\*\//g, "");
22
+ const declared = new Set([...rules.matchAll(/\[data-theme="([a-z-]+)"\]/g)].map((m) => m[1]));
23
+ // Hospitality and website take their mode from .dark, so one name covers two scopes.
24
+ const covered = new Set(scopes.map((s) => s.name.replace(/ (light|dark)$/, "")));
25
+ for (const name of declared) {
26
+ expect(covered.has(name), `[data-theme="${name}"] needs a scope here`).toBe(true);
27
+ }
28
+ });
29
+
30
+ for (const { name, sources } of scopes) {
31
+ it(name, () => {
32
+ const card = resolve(sources, "hsl-card");
33
+ const canvas = resolve(sources, "hsl-background");
34
+ const accentText = resolve(sources, "accent-text");
35
+ const mutedFg = resolve(sources, "hsl-muted-foreground");
36
+
37
+ // On: the accent-text track against the surface, and the card thumb on it.
38
+ expect(contrastRatio(accentText, card), "on track on card / card thumb on track").toBeGreaterThanOrEqual(3);
39
+ expect(contrastRatio(accentText, canvas), "on track on canvas").toBeGreaterThanOrEqual(3);
40
+ // Off: the muted-foreground outline and thumb against the surface.
41
+ expect(contrastRatio(mutedFg, card), "off outline on card").toBeGreaterThanOrEqual(3);
42
+ expect(contrastRatio(mutedFg, canvas), "off outline on canvas").toBeGreaterThanOrEqual(3);
43
+ });
44
+ }
45
+ });
@@ -221,7 +221,7 @@
221
221
  --on-ink: #ffffff;
222
222
  --accent-solid: var(--iris-9);
223
223
  --accent-solid-hover: var(--iris-10);
224
- --accent-text: var(--iris-11);
224
+ --accent-text: #4e4ac3;
225
225
  --accent-soft: var(--iris-3);
226
226
  --accent-soft-hover: var(--iris-4);
227
227
  --accent-line: var(--iris-7);
@@ -0,0 +1,105 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { blockIn, blocksIn, hslToRgb } from "./css-test-utils";
4
+
5
+ /**
6
+ * Every theme scope a consumer can select, each with its CSS sources listed
7
+ * from the most specific to the baseline it inherits from, and a resolver that
8
+ * follows `var()` and `hsl(var())` through them the way the cascade does.
9
+ * Contrast suites iterate these so a new named theme cannot skip a check.
10
+ */
11
+ const read = (name: string) =>
12
+ readFileSync(path.resolve(import.meta.dirname, name), "utf8");
13
+ const tokens = read("tokens.css");
14
+ export const themes = read("named-themes.css");
15
+ const legacy = read("legacy-light.css");
16
+ const system = read("system.css");
17
+ const ladders = read("ladders.css");
18
+
19
+ /** The spine block of a selector: the rule that declares the surface ladder. */
20
+ function spine(source: string, selector: string): string {
21
+ const block = blocksIn(source, selector).find((b) => b.includes("--hsl-background:"));
22
+ if (!block) throw new Error(`missing spine block: ${selector}`);
23
+ return block;
24
+ }
25
+
26
+ const DARK = spine(tokens, ".dark");
27
+ const LIGHT = spine(tokens, ".light");
28
+ // The shared light-ink group that also lists every named light theme.
29
+ const LIGHT_INK = blocksIn(themes, '[data-theme="agents-light"]').find(
30
+ (b) => !b.includes("--hsl-background:"),
31
+ );
32
+ if (!LIGHT_INK) throw new Error("missing the shared named-light ink group");
33
+
34
+ const named = (selector: string) => spine(themes, selector);
35
+
36
+ /**
37
+ * Each scope lists its sources from the most specific to the baseline it
38
+ * inherits from, as the cascade resolves them.
39
+ */
40
+ export const scopes: Array<{ name: string; sources: string[] }> = [
41
+ { name: "canonical dark", sources: [DARK] },
42
+ { name: "canonical light", sources: [LIGHT, DARK] },
43
+ {
44
+ name: "legacy-light default",
45
+ sources: [spine(legacy, ":where(:root)"), spine(legacy, ".dark")],
46
+ },
47
+ {
48
+ name: "system dark",
49
+ sources: [spine(system, ".dark"), blockIn(ladders, ".dark")],
50
+ },
51
+ {
52
+ name: "system light",
53
+ sources: [
54
+ spine(system, ".light"),
55
+ blockIn(ladders, ".light"),
56
+ spine(system, ".dark"),
57
+ blockIn(ladders, ".dark"),
58
+ ],
59
+ },
60
+ ...["aubergine", "arena", "super", "tangle-dark", "agents"].map((name) => ({
61
+ name,
62
+ sources: [named(`[data-theme="${name}"]`), DARK],
63
+ })),
64
+ ...["aubergine-light", "arena-light", "super-light", "tangle-light", "agents-light"].map(
65
+ (name) => ({
66
+ name,
67
+ sources: [named(`[data-theme="${name}"]`), LIGHT_INK, LIGHT, DARK],
68
+ }),
69
+ ),
70
+ { name: "intelligence", sources: [named('.dark[data-theme="intelligence"]'), DARK] },
71
+ {
72
+ name: "hospitality light",
73
+ sources: [named('[data-theme="hospitality"]:where(:not(.dark))'), LIGHT, DARK],
74
+ },
75
+ { name: "hospitality dark", sources: [named('.dark[data-theme="hospitality"]'), DARK] },
76
+ {
77
+ name: "website light",
78
+ sources: [named('[data-theme="website"]:where(:not(.dark))'), LIGHT, DARK],
79
+ },
80
+ { name: "website dark", sources: [named('.dark[data-theme="website"]'), DARK] },
81
+ ];
82
+
83
+ export type Rgb = [number, number, number];
84
+
85
+ /**
86
+ * Resolve a token to RGB through the scope's sources, following `var()` and
87
+ * `hsl(var())` references the way the cascade would: from the first source that
88
+ * declares each name.
89
+ */
90
+ export function resolve(sources: string[], token: string): Rgb {
91
+ const raw = sources
92
+ .map((css) => css.match(new RegExp(`(?<![\\w-])--${token}:\\s*([^;]+);`))?.[1]?.trim())
93
+ .find((value) => value !== undefined);
94
+ if (raw === undefined) throw new Error(`--${token} resolves to nothing`);
95
+
96
+ const ref = /^(?:hsl\()?var\(--([a-z0-9-]+)\)\)?$/.exec(raw);
97
+ if (ref) return resolve(sources, ref[1]);
98
+ const hex = /^#([0-9a-fA-F]{6})\b/.exec(raw);
99
+ if (hex) {
100
+ return [0, 2, 4].map((i) => Number.parseInt(hex[1].slice(i, i + 2), 16)) as Rgb;
101
+ }
102
+ const hsl = /^([\d.]+)\s+([\d.]+)%\s+([\d.]+)%/.exec(raw);
103
+ if (hsl) return hslToRgb({ h: Number(hsl[1]), s: Number(hsl[2]), l: Number(hsl[3]) });
104
+ throw new Error(`--${token} has an unreadable value: ${raw}`);
105
+ }
@@ -22,7 +22,7 @@
22
22
  * an indigo page, and the eye stops finding them. Neutral planes give the brand
23
23
  * a field to sit on, so a single accent does all the signalling.
24
24
  *
25
- * The canvas stays above pure black (#161616). Near-white ink on it is the
25
+ * The canvas stays above pure black (#0a0a14, lifted toward indigo). Near-white ink on it is the
26
26
  * glare end of the range, so its luminance leaves room for distinct text tiers.
27
27
  *
28
28
  * Elevation is carried by surface lightness plus a hairline that can actually be
@@ -56,20 +56,20 @@
56
56
  .light,
57
57
  .dark {
58
58
  color-scheme: dark;
59
- --md3-surface: #161616; /* L0 page canvas */
60
- --md3-surface-dim: #101010; /* recessed page */
61
- --md3-surface-bright: #3b3b3b;
59
+ --md3-surface: #0a0a14; /* L0 page canvas — indigo-lifted off black */
60
+ --md3-surface-dim: #060610; /* recessed page */
61
+ --md3-surface-bright: #232238;
62
62
  /* Same value as --md3-surface-dim by design, not by drift: both name the floor
63
63
  * of the ladder, and dark has no plane below it to give them. They stay two
64
64
  * tokens because the roles diverge in light, where the well is paper (#ffffff)
65
- * and the recessed page is grey (#dadada) — a consumer binding to the wrong one
65
+ * and the recessed page is grey (#dcdee6) — a consumer binding to the wrong one
66
66
  * there gets the wrong surface. */
67
- --md3-surface-container-lowest: #101010; /* L-1 terminal/code well */
68
- --md3-surface-container-low: #242424; /* L1 chrome, field wells */
69
- --md3-surface-container: #303030; /* L2 resting card */
70
- --md3-surface-container-high: #353535; /* L3 nested / hover */
71
- --md3-surface-container-highest: #3b3b3b; /* L4 overlay */
72
- --md3-surface-variant: #353535;
67
+ --md3-surface-container-lowest: #050509; /* L-1 terminal/code well */
68
+ --md3-surface-container-low: #12111f; /* L1 chrome, field wells */
69
+ --md3-surface-container: #191826; /* L2 resting card */
70
+ --md3-surface-container-high: #221f33; /* L3 nested / hover */
71
+ --md3-surface-container-highest: #2c2942; /* L4 overlay */
72
+ --md3-surface-variant: #221f33;
73
73
  --md3-on-surface: #e6e6e6;
74
74
  --md3-on-surface-variant: #b5b5b5;
75
75
 
@@ -90,7 +90,7 @@
90
90
  --md3-tertiary: #10B981;
91
91
  --md3-tertiary-container: var(--surface-success-bg);
92
92
  --md3-outline: #8e8e8e;
93
- --md3-outline-variant: #4c4c4c;
93
+ --md3-outline-variant: #2a293d;
94
94
 
95
95
  --md3-error: #ff5277;
96
96
  --md3-error-dim: #eb2855;
@@ -103,15 +103,15 @@
103
103
  --md3-inverse-on-surface: #000000;
104
104
  --md3-surface-tint: #A5B4FC;
105
105
 
106
- /* Semantic HSL bridge — the neutral-grey surface ladder */
107
- --hsl-background: 0 0% 8.6275%; /* L0 canvas #161616 */
106
+ /* Semantic HSL bridge — the indigo-lifted surface ladder (GTM's, now every product's) */
107
+ --hsl-background: 240 33% 6%; /* L0 canvas #0a0a14 */
108
108
  /* Near-white ink on the canvas is the glare end of the range, and the tiers
109
109
  * below it need room to separate. This neutral ink keeps the original 10.6:1
110
110
  * contrast on the card and leaves four steps the eye can tell apart. */
111
111
  --hsl-foreground: 0 0% 90.1961%; /* #e6e6e6 */
112
- --hsl-card: 0 0% 18.8235%; /* L2 card #303030 */
112
+ --hsl-card: 244 23% 12%; /* L2 card #191826 */
113
113
  --hsl-card-foreground: 0 0% 90.1961%;
114
- --hsl-popover: 0 0% 20.7843%; /* L3 #353535 — the NESTED plane despite
114
+ --hsl-popover: 247 23% 21%; /* #2c2942 — the NESTED plane despite
115
115
  the name; the overlay plane is --md3-surface-container-highest. A popover
116
116
  carries body text, and the ladder above L3 is reserved for planes that lift
117
117
  by shadow, so it sits on L3 and the ink ramp reaches it. */
@@ -121,20 +121,20 @@
121
121
  * bright indigo stays available for accent TEXT via --accent-text. */
122
122
  --hsl-primary: 246 54% 51%; /* #4c3fc6 */
123
123
  --hsl-primary-foreground: 0 0% 100%;
124
- --hsl-secondary: 0 0% 20.7843%; /* L3 #353535 */
124
+ --hsl-secondary: 249 24% 16%; /* L3 #221f33 */
125
125
  --hsl-secondary-foreground: 0 0% 90.1961%;
126
- --hsl-muted: 0 0% 20.7843%; /* L3 hover well */
127
- --hsl-muted-foreground: 0 0% 70.9804%; /* #b5b5b5 — high enough that the
126
+ --hsl-muted: 249 24% 16%; /* L3 hover well */
127
+ --hsl-muted-foreground: 240 5% 68%; /* #a9a9b1 — high enough that the
128
128
  reduced-opacity caption derivatives (/70, /60) still clear the large-text
129
129
  contrast floor against the L2 card. */
130
- --hsl-accent: 0 0% 20.7843%; /* neutral hover (indigo lives in accent-surface-strong) */
130
+ --hsl-accent: 249 24% 16%; /* neutral hover (indigo lives in accent-surface-strong) */
131
131
  --hsl-accent-foreground: 0 0% 90.1961%;
132
132
  --hsl-destructive: 348 74% 48%;
133
133
  --hsl-destructive-foreground: 0 0% 100%;
134
134
  /* Quiet on purpose: 1.5:1 against the resting card. Shadow does no work on a
135
135
  * dark canvas, so the hairline is what states where a plane ends. */
136
- --hsl-border: 0 0% 29.8039%; /* #4c4c4c = --md3-outline-variant */
137
- --hsl-input: 0 0% 12.1569%; /* recessed field well #1f1f1f */
136
+ --hsl-border: 243 20% 20%; /* #2a293d = --md3-outline-variant */
137
+ --hsl-input: 244 29% 9%; /* recessed field well #11101e */
138
138
  /* Brighter than --primary on purpose: the ring is NON-TEXT and has to clear
139
139
  * 3:1 against BOTH canvas and card. The fill indigo is tuned to carry white
140
140
  * text instead, so the two cannot be the same value. */
@@ -175,7 +175,7 @@
175
175
 
176
176
  /* Sidebar tokens — L1 chrome: one step BELOW the canvas here, so the nav reads
177
177
  * as chrome the content sits in front of rather than as another card. */
178
- --sidebar-background: 0 0% 6.2745%; /* L1 chrome #101010 */
178
+ --sidebar-background: 244 29% 9%; /* L1 chrome #11101e */
179
179
  --sidebar-foreground: 0 0% 90.1961%;
180
180
  --sidebar-primary: 246 54% 51%;
181
181
  --sidebar-primary-foreground: 0 0% 100%;
@@ -185,10 +185,10 @@
185
185
  --sidebar-ring: 233 100% 87%;
186
186
 
187
187
  /* Depth scale — tracks the MD3 ladder (canvas / card / nested / overlay) */
188
- --depth-1: #161616;
189
- --depth-2: #303030;
190
- --depth-3: #353535;
191
- --depth-4: #3b3b3b;
188
+ --depth-1: #0a0a14;
189
+ --depth-2: #191826;
190
+ --depth-3: #221f33;
191
+ --depth-4: #2c2942;
192
192
 
193
193
  /* Status colors */
194
194
  --status-running: #10B981;
@@ -213,69 +213,69 @@
213
213
  --surface-info-border: #3d7292;
214
214
  --surface-info-text: #67cdff;
215
215
  /* tones:begin — generated by scripts/gen-ladders.mjs; edit its CATEGORY map, not these lines. */
216
- --tone-violet-bg: #4e3a60;
217
- --tone-violet-bg-hover: #583d71;
218
- --tone-violet-bg-selected: #624080;
216
+ --tone-violet-bg: #392751;
217
+ --tone-violet-bg-hover: #432b60;
218
+ --tone-violet-bg-selected: #4c306c;
219
219
  --tone-violet-border: #8457aa;
220
220
  --tone-violet-border-selected: #d19dff;
221
221
  --tone-violet-text: #ecd9fa;
222
222
  --tone-violet-icon: #d19dff;
223
- --tone-orange-bg: #583c2b;
224
- --tone-orange-bg-hover: #664029;
225
- --tone-orange-bg-selected: #724327;
223
+ --tone-orange-bg: #432823;
224
+ --tone-orange-bg-hover: #512d22;
225
+ --tone-orange-bg-selected: #5e3221;
226
226
  --tone-orange-border: #a35829;
227
- --tone-orange-border-selected: #ff801f;
227
+ --tone-orange-border-selected: #f76b15;
228
228
  --tone-orange-text: #ffe0c2;
229
- --tone-orange-icon: #ff801f;
230
- --tone-teal-bg: #2a4744;
231
- --tone-teal-bg-hover: #28504b;
232
- --tone-teal-bg-selected: #265751;
229
+ --tone-orange-icon: #f76b15;
230
+ --tone-teal-bg: #18333b;
231
+ --tone-teal-bg-hover: #173b42;
232
+ --tone-teal-bg-selected: #174448;
233
233
  --tone-teal-border: #207e73;
234
- --tone-teal-border-selected: #0eb39e;
234
+ --tone-teal-border-selected: #12a594;
235
235
  --tone-teal-text: #adf0dd;
236
- --tone-teal-icon: #0eb39e;
237
- --tone-blue-bg: #25455e;
238
- --tone-blue-bg-hover: #224c6c;
239
- --tone-blue-bg-selected: #1f537b;
236
+ --tone-teal-icon: #12a594;
237
+ --tone-blue-bg: #143051;
238
+ --tone-blue-bg-hover: #123861;
239
+ --tone-blue-bg-selected: #113e6b;
240
240
  --tone-blue-border: #2870bd;
241
- --tone-blue-border-selected: #70b8ff;
241
+ --tone-blue-border-selected: #0090ff;
242
242
  --tone-blue-text: #c2e6ff;
243
- --tone-blue-icon: #70b8ff;
244
- --tone-pink-bg: #5e344f;
245
- --tone-pink-bg-hover: #6d3659;
246
- --tone-pink-bg-selected: #7b3762;
243
+ --tone-blue-icon: #0090ff;
244
+ --tone-pink-bg: #462243;
245
+ --tone-pink-bg-hover: #55254d;
246
+ --tone-pink-bg-selected: #612754;
247
247
  --tone-pink-border: #a84885;
248
- --tone-pink-border-selected: #ff8dcc;
248
+ --tone-pink-border-selected: #de51a8;
249
249
  --tone-pink-text: #fdd1ea;
250
- --tone-pink-icon: #ff8dcc;
251
- --tone-brown-bg: #494038;
252
- --tone-brown-bg-hover: #54473c;
253
- --tone-brown-bg-selected: #5c4c3e;
250
+ --tone-pink-icon: #de51a8;
251
+ --tone-brown-bg: #372d30;
252
+ --tone-brown-bg-hover: #413434;
253
+ --tone-brown-bg-selected: #4a3a37;
254
254
  --tone-brown-border: #7c5f46;
255
- --tone-brown-border-selected: #dbb594;
255
+ --tone-brown-border-selected: #ad7f58;
256
256
  --tone-brown-text: #f2e1ca;
257
- --tone-brown-icon: #dbb594;
258
- --tone-cyan-bg: #27464d;
259
- --tone-cyan-bg-hover: #234f59;
260
- --tone-cyan-bg-selected: #205662;
257
+ --tone-brown-icon: #ad7f58;
258
+ --tone-cyan-bg: #143245;
259
+ --tone-cyan-bg-hover: #133b4e;
260
+ --tone-cyan-bg-selected: #114358;
261
261
  --tone-cyan-border: #11809c;
262
- --tone-cyan-border-selected: #23afd0;
262
+ --tone-cyan-border-selected: #00a2c7;
263
263
  --tone-cyan-text: #b6ecf7;
264
- --tone-cyan-icon: #23afd0;
265
- --tone-lime-bg: #404536;
266
- --tone-lime-bg-hover: #444b37;
267
- --tone-lime-bg-selected: #495239;
264
+ --tone-cyan-icon: #00a2c7;
265
+ --tone-lime-bg: #2b302d;
266
+ --tone-lime-bg-hover: #32382f;
267
+ --tone-lime-bg-selected: #384132;
268
268
  --tone-lime-border: #577538;
269
269
  --tone-lime-border-selected: #bdee63;
270
270
  --tone-lime-text: #e3f7ba;
271
271
  --tone-lime-icon: #bdee63;
272
- --surface-violet-bg: #4e3a60;
272
+ --surface-violet-bg: #392751;
273
273
  --surface-violet-border: #8457aa;
274
274
  --surface-violet-text: #d19dff;
275
- --surface-orange-bg: #583c2b;
275
+ --surface-orange-bg: #432823;
276
276
  --surface-orange-border: #a35829;
277
277
  --surface-orange-text: #ffa057;
278
- --surface-teal-bg: #2a4744;
278
+ --surface-teal-bg: #18333b;
279
279
  --surface-teal-border: #207e73;
280
280
  --surface-teal-text: #0bd8b6;
281
281
  /* tones:end */
@@ -332,7 +332,7 @@
332
332
  --accent-text: #A5B4FC;
333
333
 
334
334
  /* Surfaces — solid fills off the ladder (no radial mesh) */
335
- --bg-root: #161616;
335
+ --bg-root: #0a0a14;
336
336
  --bg-dark: var(--depth-1);
337
337
  --bg-card: var(--depth-2);
338
338
  --bg-elevated: var(--depth-4);
@@ -549,15 +549,15 @@
549
549
  color-scheme: light;
550
550
  /* Light theme — neutral surfaces with purple accents */
551
551
  --code-header-bg: color-mix(in srgb, hsl(var(--hsl-muted)) 50%, transparent);
552
- --syntax-foreground: #24292f;
553
- --syntax-comment: #57606a;
554
- --syntax-keyword: #6d28d9;
552
+ --syntax-foreground: #24243a;
553
+ --syntax-comment: #6b7280;
554
+ --syntax-keyword: #7c3aed;
555
555
  --syntax-string: #047857;
556
- --syntax-function: #1d4ed8;
557
- --syntax-number: #92400e;
558
- --syntax-meta: #7c3aed;
559
- --syntax-variable: #24292f;
560
- --syntax-error: #b91c1c;
556
+ --syntax-function: #2563eb;
557
+ --syntax-number: #b45309;
558
+ --syntax-meta: #6d28d9;
559
+ --syntax-variable: #334155;
560
+ --syntax-error: #dc2626;
561
561
 
562
562
  /* MD3 surface hierarchy */
563
563
  /* The light ladder ALTERNATES; it is not monotonic, and that is deliberate.
@@ -566,15 +566,15 @@
566
566
  * by shadow and ring, not by fill — which is how light-mode elevation actually
567
567
  * reads. Forcing a strictly-darkening ladder here is what produced the
568
568
  * white-on-white flatness this palette replaced. Pinned in tokens.test.ts. */
569
- --md3-surface: #eaeaea; /* L0 page canvas — neutral grey, BELOW the wells */
570
- --md3-surface-dim: #dadada;
569
+ --md3-surface: #eceef3; /* L0 page canvas — cool grey, BELOW the wells */
570
+ --md3-surface-dim: #dcdee6;
571
571
  --md3-surface-bright: #ffffff;
572
572
  --md3-surface-container-lowest: #ffffff; /* insets read as paper */
573
- --md3-surface-container-low: #f7f7f7; /* L1 chrome, field wells — well */
573
+ --md3-surface-container-low: #f6f7fa; /* L1 chrome, field wells — well */
574
574
  --md3-surface-container: #ffffff; /* L2 resting card — paper */
575
- --md3-surface-container-high: #f2f2f2; /* L3 nested / hover — well */
575
+ --md3-surface-container-high: #f1f2f7; /* L3 nested / hover — well */
576
576
  --md3-surface-container-highest: #ffffff; /* L4 overlay — paper + shadow */
577
- --md3-surface-variant: #f2f2f2;
577
+ --md3-surface-variant: #f1f2f7;
578
578
  --md3-on-surface: #2d2d2d;
579
579
  --md3-on-surface-variant: #4c4c4c;
580
580
 
@@ -595,7 +595,7 @@
595
595
  --md3-tertiary: #065F46;
596
596
  --md3-tertiary-container: #D1FAE5;
597
597
  --md3-outline: #787878;
598
- --md3-outline-variant: #d0d0d0;
598
+ --md3-outline-variant: #c7c6d6;
599
599
 
600
600
  --md3-error: #ba1a1a;
601
601
  --md3-error-dim: #93000a;
@@ -609,7 +609,7 @@
609
609
  --md3-surface-tint: #6366F1;
610
610
 
611
611
  /* Semantic HSL bridge — white paper on an achromatic canvas */
612
- --hsl-background: 0 0% 91.7647%; /* L0 canvas #eaeaea */
612
+ --hsl-background: 223 23% 94%; /* L0 canvas #eceef3 */
613
613
  /* Neutral ink rather than pure black keeps the original 13.7:1 contrast on
614
614
  * white paper and 11.4:1 on the canvas: AAA with headroom in both places. */
615
615
  --hsl-foreground: 0 0% 17.6471%; /* #2d2d2d */
@@ -619,19 +619,19 @@
619
619
  --hsl-popover-foreground: 0 0% 17.6471%;
620
620
  --hsl-primary: 246 54% 51%; /* #4c3fc6 — carries white at 7.4:1 */
621
621
  --hsl-primary-foreground: 0 0% 100%;
622
- --hsl-secondary: 0 0% 94.902%; /* L3 #f2f2f2 */
622
+ --hsl-secondary: 230 27% 96%; /* L3 #f2f3f8 */
623
623
  --hsl-secondary-foreground: 0 0% 17.6471%;
624
- --hsl-muted: 0 0% 94.902%; /* L3 hover well */
624
+ --hsl-muted: 230 27% 96%; /* L3 hover well */
625
625
  /* The shared components fade captions with /70 and /60, so a translucent
626
626
  * foreground renders composited over the surface behind it. This neutral value
627
627
  * keeps 3.0:1 at /60 and 8.6:1 at full strength on paper. */
628
628
  --hsl-muted-foreground: 0 0% 29.8039%; /* #4c4c4c text-muted */
629
- --hsl-accent: 0 0% 94.902%; /* neutral hover */
629
+ --hsl-accent: 230 27% 96%; /* neutral hover */
630
630
  --hsl-accent-foreground: 0 0% 17.6471%;
631
631
  --hsl-destructive: 0 72% 41%;
632
632
  --hsl-destructive-foreground: 0 0% 100%;
633
- --hsl-border: 0 0% 81.5686%; /* #d0d0d0 = --md3-outline-variant */
634
- --hsl-input: 0 0% 96.0784%; /* field well #f5f5f5 */
633
+ --hsl-border: 244 16% 81%; /* #c8c7d6, the --md3-outline-variant hairline */
634
+ --hsl-input: 225 29% 97%; /* field well #f5f6fa */
635
635
  --hsl-ring: 246 54% 51%;
636
636
  --hsl-success: 160 84% 30%;
637
637
  --hsl-warning: 41 96% 50%;
@@ -640,9 +640,9 @@
640
640
  /* depth-2 (card) and depth-4 (elevated) are BOTH paper, on purpose: in light,
641
641
  * a popover does not out-lighten a white card — it lifts by shadow. See the
642
642
  * ladder note above. */
643
- --depth-1: #eaeaea; /* canvas */
643
+ --depth-1: #eceef3; /* canvas */
644
644
  --depth-2: #ffffff; /* card — paper */
645
- --depth-3: #f2f2f2; /* nested well */
645
+ --depth-3: #f1f2f7; /* nested well */
646
646
  --depth-4: #ffffff; /* elevated — paper, separated by shadow */
647
647
 
648
648
  --status-running: #059669;
@@ -782,7 +782,7 @@
782
782
  --mesh-blue: transparent;
783
783
 
784
784
  /* Surfaces — flat single-neutral light */
785
- --bg-root: #eaeaea;
785
+ --bg-root: #eceef3;
786
786
  --bg-dark: var(--depth-1);
787
787
  --bg-card: var(--depth-2);
788
788
  /* Paper, exactly like --bg-card: in light you cannot raise a plane by making it
@@ -799,7 +799,7 @@
799
799
  /* Mirrors the dark ramp's spacing against white paper. The ink uses neutral
800
800
  * grays so purple stays reserved for interaction accents.
801
801
  *
802
- * --text-dim is pinned from the CANVAS (#eaeaea), not the white card: it is
802
+ * --text-dim is pinned from the CANVAS (#eceef3), not the white card: it is
803
803
  * the faintest tier, so the lighter L0 is the surface that binds it. Its 4.6:1
804
804
  * canvas and 5.6:1 card contrasts clear the body-text floor, as asserted in
805
805
  * tokens.test.ts, because a ratio quoted in a comment drifts the moment a
@@ -852,7 +852,7 @@
852
852
  --accent-gradient-strong: linear-gradient(135deg, #4F46E5, #5B4ED4);
853
853
  --accent-surface-soft: rgba(76, 63, 198, 0.05);
854
854
  --accent-surface-strong: rgba(76, 63, 198, 0.13);
855
- --accent-text: #5047eb;
855
+ --accent-text: #3c32e9;
856
856
 
857
857
  /* Typography — unified on Inter */
858
858
  --font-sans: "Inter", "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
@@ -924,7 +924,7 @@
924
924
  * draws as a --chart-muted outline, the same mark as an unsolved attempt. No
925
925
  * categorical palette ships: identity is carried by direct labels.
926
926
  *
927
- * Contrast against the dark canvas (#161616) and white paper, checked with the
927
+ * Contrast against the dark canvas (#0a0a14) and white paper, checked with the
928
928
  * dataviz palette validator and WCAG ratios:
929
929
  * ink 14.45 / 13.70, ink-muted 7.34 / 5.56, accent 6.05 / 7.41,
930
930
  * muted 5.54 / 4.41, grid 1.46 / 1.12 (recessive by design).
@@ -14,7 +14,9 @@ import {
14
14
  /**
15
15
  * The canonical spine's structural contract.
16
16
  *
17
- * The canonical surface and ink spine is neutral so purple can signal interaction.
17
+ * The canonical ink is neutral and the surfaces carry one faint indigo cast (the
18
+ * ladder GTM proved out), kept low in chroma so the saturated accent still
19
+ * signals interaction.
18
20
  * Each surface remains distinct by fill and elevation. These assertions pin that
19
21
  * structure without fixing every shade, so the ladder can be retuned safely.
20
22
  */
@@ -29,29 +31,31 @@ const tokens = readFileSync(
29
31
  const DARK = blockIn(tokens, ".dark");
30
32
  const LIGHT = blockIn(tokens, ".light");
31
33
 
32
- describe("canonical surfaces, ink, and chrome are neutral", () => {
33
- const neutralHslRoles = [
34
- "hsl-background",
34
+ describe("canonical ink is neutral; surfaces share one faint indigo cast", () => {
35
+ const inkHslRoles = [
35
36
  "hsl-foreground",
36
- "hsl-card",
37
37
  "hsl-card-foreground",
38
- "hsl-popover",
39
38
  "hsl-popover-foreground",
40
- "hsl-secondary",
41
39
  "hsl-secondary-foreground",
40
+ "hsl-accent-foreground",
41
+ "sidebar-foreground",
42
+ "sidebar-accent-foreground",
43
+ ];
44
+ const surfaceHslRoles = [
45
+ "hsl-background",
46
+ "hsl-card",
47
+ "hsl-popover",
48
+ "hsl-secondary",
42
49
  "hsl-muted",
43
50
  "hsl-muted-foreground",
44
51
  "hsl-accent",
45
- "hsl-accent-foreground",
46
52
  "hsl-border",
47
53
  "hsl-input",
48
54
  "sidebar-background",
49
- "sidebar-foreground",
50
55
  "sidebar-accent",
51
- "sidebar-accent-foreground",
52
56
  "sidebar-border",
53
57
  ];
54
- const neutralHexRoles = [
58
+ const surfaceHexRoles = [
55
59
  "md3-surface",
56
60
  "md3-surface-dim",
57
61
  "md3-surface-bright",
@@ -61,8 +65,6 @@ describe("canonical surfaces, ink, and chrome are neutral", () => {
61
65
  "md3-surface-container-high",
62
66
  "md3-surface-container-highest",
63
67
  "md3-surface-variant",
64
- "md3-on-surface",
65
- "md3-on-surface-variant",
66
68
  "md3-outline",
67
69
  "md3-outline-variant",
68
70
  "depth-1",
@@ -70,26 +72,48 @@ describe("canonical surfaces, ink, and chrome are neutral", () => {
70
72
  "depth-3",
71
73
  "depth-4",
72
74
  ];
75
+ const inkHexRoles = ["md3-on-surface", "md3-on-surface-variant"];
76
+ // A tinted plane stays in the indigo band and below 0.1 chroma (0..1), so it
77
+ // reads as a cool neutral beside the accent rather than as a coloured fill.
78
+ const MAX_CHROMA = 0.1;
79
+ const inIndigoBand = (h: number) => h >= 220 && h <= 255;
80
+ const hexHueChroma = (hex: string) => {
81
+ const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
82
+ const max = Math.max(r, g, b);
83
+ const c = max - Math.min(r, g, b);
84
+ if (c === 0) return { h: 0, c };
85
+ const h = max === r ? ((g - b) / c) % 6 : max === g ? (b - r) / c + 2 : (r - g) / c + 4;
86
+ return { h: (h * 60 + 360) % 360, c };
87
+ };
73
88
 
74
89
  for (const [theme, spine] of [
75
90
  ["dark", DARK],
76
91
  ["light", LIGHT],
77
92
  ] as const) {
78
- it(`${theme}: shared surface and ink roles are achromatic`, () => {
79
- for (const token of neutralHslRoles) {
93
+ it(`${theme}: ink roles are achromatic`, () => {
94
+ for (const token of inkHslRoles) {
80
95
  expect(hslIn(spine, token).s, `--${token}`).toBe(0);
81
96
  }
82
- for (const token of neutralHexRoles) {
83
- const hex = spine.match(
84
- new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`),
85
- )?.[1];
97
+ for (const token of inkHexRoles) {
98
+ const hex = spine.match(new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`))?.[1];
99
+ expect(hex, `--${token} must be defined`).toBeDefined();
100
+ expect(hexHueChroma(hex!).c, `--${token} is achromatic`).toBe(0);
101
+ }
102
+ });
103
+
104
+ it(`${theme}: surface roles are neutral or a faint indigo`, () => {
105
+ for (const token of surfaceHslRoles) {
106
+ const { h, s: sat, l } = hslIn(spine, token);
107
+ const chroma = (sat / 100) * (1 - Math.abs((2 * l) / 100 - 1));
108
+ expect(chroma, `--${token} chroma`).toBeLessThanOrEqual(MAX_CHROMA);
109
+ if (sat > 0) expect(inIndigoBand(h), `--${token} hue ${h}`).toBe(true);
110
+ }
111
+ for (const token of surfaceHexRoles) {
112
+ const hex = spine.match(new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`))?.[1];
86
113
  expect(hex, `--${token} must be defined`).toBeDefined();
87
- expect(hex?.slice(1, 3), `--${token} red equals green`).toBe(
88
- hex?.slice(3, 5),
89
- );
90
- expect(hex?.slice(3, 5), `--${token} green equals blue`).toBe(
91
- hex?.slice(5, 7),
92
- );
114
+ const { h, c } = hexHueChroma(hex!);
115
+ expect(c, `--${token} chroma`).toBeLessThanOrEqual(MAX_CHROMA);
116
+ if (c > 0.02) expect(inIndigoBand(h), `--${token} hue ${h}`).toBe(true);
93
117
  }
94
118
  });
95
119
  }
@@ -432,7 +456,7 @@ describe("input tokens carry two DIFFERENT roles and must not be conflated", ()
432
456
  });
433
457
 
434
458
  describe("canonical light spine", () => {
435
- it("is white paper on a neutral canvas, never white-on-white", () => {
459
+ it("is white paper on a cool canvas, never white-on-white", () => {
436
460
  const canvas = hslIn(LIGHT, "hsl-background");
437
461
  const card = hslIn(LIGHT, "hsl-card");
438
462
  expect(card.l, "the card is paper").toBe(100);