@tangle-network/brand 1.15.7 → 1.17.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.
@@ -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
@@ -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);