@tangle-network/brand 1.8.1 → 1.9.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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.9.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 720de9a: Normalize the shared neutral palette and regenerate standalone chart tokens so charts follow the palette. Make user message cards wrap in narrow layouts.
8
+
9
+ ### Patch Changes
10
+
11
+ - d163fb8: Align the named tangle-dark island with the neutral canonical dark spine while keeping Tangle accents and dark status colors intact.
12
+
13
+ ## 1.8.2
14
+
15
+ ### Patch Changes
16
+
17
+ - 1f0b651: Keep the native field fallback outline off borderless editors inside composite fields. The shared focusFieldWithin helper retains the rounded outer border and halo; standalone fields keep their own focus treatment.
18
+
3
19
  ## 1.8.1
4
20
 
5
21
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.8.1",
3
+ "version": "1.9.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",
@@ -7,6 +7,11 @@
7
7
  border-color: hsl(var(--border));
8
8
  }
9
9
 
10
+ /* The rounded composite owns the focus line, not its inner editor. */
11
+ .focus-field-within :is(input:not([type=checkbox],[type=radio],[type=button],[type=submit],[type=reset],[type=image],[type=range],[type=color],[type=file]),textarea,select,[contenteditable]:not([contenteditable=false])):focus-visible {
12
+ outline: none;
13
+ }
14
+
10
15
  body {
11
16
  -webkit-font-smoothing: antialiased;
12
17
  -moz-osx-font-smoothing: grayscale;
@@ -167,40 +167,32 @@
167
167
  --text-primary: #06231a; --text-secondary: #285048; --text-muted: #5f8472; --text-dim: #82a699;
168
168
  }
169
169
 
170
- /* ───────────────────────── Tangle (neutral) — the canonical pair ──────────── */
171
- /* tangle-dark names the default :root/.dark spine explicitly — the values mirror
172
- * tokens.css exactly (pinned in named-themes.test.ts). The named scope matters the
173
- * moment the attribute would NOT resolve to :root: a dark island inside a
174
- * light-named wrapper inherits the light --hsl-* without it, and a theme picker
175
- * switching between named themes needs a `tangle-dark` that actually selects. Same
176
- * indigo hue family as tangle-light (~239-246), held to the dark discipline —
177
- * graphite-indigo neutrals, elevation by lightening, the fill indigo that carries
178
- * white (7.4:1) with the bright indigo reserved for accent text. The foreground
179
- * and destructive redeclares equal :root standalone, but the block must be
180
- * self-sufficient: in the island nesting an omitted token inherits the WRAPPER's
181
- * light value (near-black secondary text on the dark secondary measured ~1.5:1).
182
- * Status tokens are deliberately not redeclared: the :root dark status palette is
183
- * already dark-tuned, same as aubergine/arena. */
170
+ /* ───────────────────────── Tangle — neutral dark spine ─────────────────────
171
+ * tangle-dark names the default :root/.dark spine explicitly. The named block
172
+ * repeats its neutral surfaces and ink so a dark island remains self-sufficient
173
+ * inside a light-named wrapper. Indigo stays in the accent; status tokens inherit
174
+ * the dark-tuned root palette.
175
+ */
184
176
  [data-theme="tangle-dark"] {
185
- --hsl-background: 240 24% 11%; --hsl-foreground: 225 22% 91%;
186
- --hsl-card: 238 20% 22%; --hsl-card-foreground: 225 22% 91%;
187
- --hsl-popover: 239 22% 25%; --hsl-popover-foreground: 225 22% 91%;
188
- --hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
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
- --hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
193
- --hsl-border: 236 20% 35%; --hsl-input: 240 24% 15%;
177
+ --hsl-background: 0 0% 8.6275%; --hsl-foreground: 0 0% 90.1961%;
178
+ --hsl-card: 0 0% 18.8235%; --hsl-card-foreground: 0 0% 90.1961%;
179
+ --hsl-popover: 0 0% 20.7843%; --hsl-popover-foreground: 0 0% 90.1961%;
180
+ --hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
181
+ --hsl-secondary: 0 0% 20.7843%; --hsl-secondary-foreground: 0 0% 90.1961%;
182
+ --hsl-muted: 0 0% 20.7843%; --hsl-muted-foreground: 0 0% 70.9804%;
183
+ --hsl-accent: 0 0% 20.7843%; --hsl-accent-foreground: 0 0% 90.1961%;
184
+ --hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
185
+ --hsl-border: 0 0% 29.8039%; --hsl-input: 0 0% 12.1569%;
194
186
  --hsl-ring: 233 100% 87%;
195
- --depth-1: #151523; --depth-2: #2d2e43; --depth-3: #32324e; --depth-4: #393957;
196
- --bg-root: #151523; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
197
- --md3-surface-container-lowest: #0f0f1a; --md3-surface-container-low: #222235;
198
- --md3-surface-container: #2d2e43; --md3-surface-container-high: #32324e;
199
- --md3-surface-container-highest: #393957; --md3-outline-variant: #474a6b;
200
- --border-subtle: rgba(165, 180, 252, 0.10); --border-default: rgba(165, 180, 252, 0.16);
201
- --border-hover: rgba(165, 180, 252, 0.28); --accent-text: #A5B4FC;
187
+ --depth-1: #161616; --depth-2: #303030; --depth-3: #353535; --depth-4: #3b3b3b;
188
+ --bg-root: #161616; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
189
+ --md3-surface-container-lowest: #101010; --md3-surface-container-low: #242424;
190
+ --md3-surface-container: #303030; --md3-surface-container-high: #353535;
191
+ --md3-surface-container-highest: #3b3b3b; --md3-outline-variant: #4c4c4c;
192
+ --border-subtle: rgba(255, 255, 255, 0.10); --border-default: rgba(255, 255, 255, 0.16);
193
+ --border-hover: rgba(255, 255, 255, 0.28); --accent-text: #A5B4FC;
202
194
  --brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
203
- --text-primary: #e3e6ed; --text-secondary: #c2c7d4; --text-muted: #b2b4c7; --text-dim: #9ea5b9;
195
+ --text-primary: #e6e6e6; --text-secondary: #c7c7c7; --text-muted: #b5b5b5; --text-dim: #a5a5a5;
204
196
  }
205
197
  /* Its light mode. */
206
198
  [data-theme="tangle-light"] {
@@ -167,35 +167,17 @@ describe('named theme: [data-theme="tangle-dark"]', () => {
167
167
  }
168
168
  });
169
169
 
170
- it("shares tangle-light's indigo hue family — same brand, dark register", () => {
171
- for (const token of ["hsl-primary", "hsl-ring", "hsl-background"]) {
170
+ it("keeps the Tangle accent in the same indigo family", () => {
171
+ for (const token of ["hsl-primary", "hsl-ring"]) {
172
172
  const dark = hslIn(css, token);
173
173
  const lite = hslIn(light, token);
174
174
  expect(
175
175
  Math.abs(dark.h - lite.h),
176
- `--${token} hue must sit within 10 of tangle-light's`,
176
+ `--${token} hue must sit within 10 of tangle-light`,
177
177
  ).toBeLessThanOrEqual(10);
178
178
  }
179
179
  });
180
180
 
181
- it("tints its neutrals with the hue — nothing achromatic", () => {
182
- for (const token of [
183
- "hsl-background",
184
- "hsl-card",
185
- "hsl-popover",
186
- "hsl-secondary",
187
- "hsl-muted",
188
- "hsl-accent",
189
- "hsl-border",
190
- "hsl-input",
191
- ]) {
192
- const { h, s } = hslIn(css, token);
193
- expect(h, `--${token} hue in the indigo band`).toBeGreaterThanOrEqual(220);
194
- expect(h, `--${token} hue in the indigo band`).toBeLessThanOrEqual(250);
195
- expect(s, `--${token} is hue-tinted, not dead grey`).toBeGreaterThan(5);
196
- }
197
- });
198
-
199
181
  it("elevates by lightening, never darkening", () => {
200
182
  const planes = ["depth-1", "depth-2", "depth-3", "depth-4"].map((t) =>
201
183
  hexRelativeLuminanceIn(css, t),
@@ -3,7 +3,7 @@
3
3
  * The surface ladder is NEUTRAL GREY and the indigo is trim — accent, never
4
4
  * field. It steps in even lightness increments so a card, a nested panel and an
5
5
  * overlay each read as a distinct plane by their own fill, and carries a crisp
6
- * hairline (1.6:1 against the card in dark) because shadow does no work on a dark
6
+ * hairline (1.5:1 against the card in dark) because shadow does no work on a dark
7
7
  * canvas. Light mirrors it: white paper on a grey canvas, never white-on-white.
8
8
  *
9
9
  * Apps consume this spine as-is. If a product needs its own accent identity,
@@ -22,35 +22,34 @@
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 sits OFF pure black (#151523). A near-black page under near-white
26
- * ink is the glare end of the range, and it is the reading surface for long
27
- * sessions.
25
+ * The canvas stays above pure black (#161616). Near-white ink on it is the
26
+ * glare end of the range, so its luminance leaves room for distinct text tiers.
28
27
  *
29
28
  * Elevation is carried by surface lightness plus a hairline that can actually be
30
29
  * seen — a black shadow on a dark canvas renders as nothing, so shadow does no
31
- * work here. The hairline is 1.6:1 against the resting card — quiet by design,
30
+ * work here. The hairline is about 1.5:1 against the resting card — quiet by design,
32
31
  * enough to state an edge without drawing a box around every panel.
33
32
  */
34
33
  :root,
35
34
  [data-sandbox-ui],
36
35
  [data-theme="dark"],
37
36
  .dark {
38
- --md3-surface: #151523; /* L0 page canvas */
39
- --md3-surface-dim: #0f0f1a; /* recessed page */
40
- --md3-surface-bright: #393957;
37
+ --md3-surface: #161616; /* L0 page canvas */
38
+ --md3-surface-dim: #101010; /* recessed page */
39
+ --md3-surface-bright: #3b3b3b;
41
40
  /* Same value as --md3-surface-dim by design, not by drift: both name the floor
42
41
  * of the ladder, and dark has no plane below it to give them. They stay two
43
42
  * tokens because the roles diverge in light, where the well is paper (#ffffff)
44
- * and the recessed page is grey (#d9d9e6) — a consumer binding to the wrong one
43
+ * and the recessed page is grey (#dadada) — a consumer binding to the wrong one
45
44
  * there gets the wrong surface. */
46
- --md3-surface-container-lowest: #0f0f1a; /* L-1 terminal/code well */
47
- --md3-surface-container-low: #222235; /* L1 chrome, field wells */
48
- --md3-surface-container: #2d2e43; /* L2 resting card */
49
- --md3-surface-container-high: #32324e; /* L3 nested / hover */
50
- --md3-surface-container-highest: #393957; /* L4 overlay */
51
- --md3-surface-variant: #32324e;
52
- --md3-on-surface: #e3e6ed;
53
- --md3-on-surface-variant: #b2b4c7;
45
+ --md3-surface-container-lowest: #101010; /* L-1 terminal/code well */
46
+ --md3-surface-container-low: #242424; /* L1 chrome, field wells */
47
+ --md3-surface-container: #303030; /* L2 resting card */
48
+ --md3-surface-container-high: #353535; /* L3 nested / hover */
49
+ --md3-surface-container-highest: #3b3b3b; /* L4 overlay */
50
+ --md3-surface-variant: #353535;
51
+ --md3-on-surface: #e6e6e6;
52
+ --md3-on-surface-variant: #b5b5b5;
54
53
 
55
54
  --md3-primary: #A5B4FC;
56
55
  --md3-primary-dim: #7C6CF6;
@@ -67,8 +66,8 @@
67
66
  --md3-on-secondary-container: #E8E5FF;
68
67
 
69
68
  --md3-tertiary: #10B981;
70
- --md3-outline: #898da9;
71
- --md3-outline-variant: #474a6b;
69
+ --md3-outline: #8e8e8e;
70
+ --md3-outline-variant: #4c4c4c;
72
71
 
73
72
  --md3-error: #ff5277;
74
73
  --md3-error-dim: #eb2855;
@@ -82,37 +81,37 @@
82
81
  --md3-surface-tint: #A5B4FC;
83
82
 
84
83
  /* Semantic HSL bridge — the neutral-grey surface ladder */
85
- --hsl-background: 240 24% 11%; /* L0 canvas #151523 */
86
- /* 92% L, not pure white: near-white ink on the canvas is the glare end of the
87
- * range, and the tiers below it need room to separate. 91% holds 10.6:1 on the
88
- * card — AAA with headroom — and leaves four ink steps the eye can tell apart. */
89
- --hsl-foreground: 225 22% 91%; /* #e3e6ed */
90
- --hsl-card: 238 20% 22%; /* L2 card #2d2e43 */
91
- --hsl-card-foreground: 225 22% 91%;
92
- --hsl-popover: 239 22% 25%; /* L3 #32324e — the NESTED plane despite
84
+ --hsl-background: 0 0% 8.6275%; /* L0 canvas #161616 */
85
+ /* Near-white ink on the canvas is the glare end of the range, and the tiers
86
+ * below it need room to separate. This neutral ink keeps the original 10.6:1
87
+ * contrast on the card and leaves four steps the eye can tell apart. */
88
+ --hsl-foreground: 0 0% 90.1961%; /* #e6e6e6 */
89
+ --hsl-card: 0 0% 18.8235%; /* L2 card #303030 */
90
+ --hsl-card-foreground: 0 0% 90.1961%;
91
+ --hsl-popover: 0 0% 20.7843%; /* L3 #353535 — the NESTED plane despite
93
92
  the name; the overlay plane is --md3-surface-container-highest. A popover
94
93
  carries body text, and the ladder above L3 is reserved for planes that lift
95
94
  by shadow, so it sits on L3 and the ink ramp reaches it. */
96
- --hsl-popover-foreground: 225 22% 91%;
95
+ --hsl-popover-foreground: 0 0% 90.1961%;
97
96
  /* The fill indigo, NOT the bright one: `.btn-primary` puts white on this, and
98
97
  * #6366F1 carries white at only 4.47:1. This clears the floor at 7.4:1. The
99
98
  * bright indigo stays available for accent TEXT via --accent-text. */
100
99
  --hsl-primary: 246 54% 51%; /* #4c3fc6 */
101
100
  --hsl-primary-foreground: 0 0% 100%;
102
- --hsl-secondary: 239 22% 25%; /* L3 #32324e */
103
- --hsl-secondary-foreground: 225 22% 91%;
104
- --hsl-muted: 239 22% 25%; /* L3 hover well */
105
- --hsl-muted-foreground: 234 16% 74%; /* #b2b4c7 — high enough that the
101
+ --hsl-secondary: 0 0% 20.7843%; /* L3 #353535 */
102
+ --hsl-secondary-foreground: 0 0% 90.1961%;
103
+ --hsl-muted: 0 0% 20.7843%; /* L3 hover well */
104
+ --hsl-muted-foreground: 0 0% 70.9804%; /* #b5b5b5 — high enough that the
106
105
  reduced-opacity caption derivatives (/70, /60) still clear the large-text
107
106
  contrast floor against the L2 card. */
108
- --hsl-accent: 239 22% 25%; /* neutral hover (indigo lives in accent-surface-strong) */
109
- --hsl-accent-foreground: 225 22% 91%;
107
+ --hsl-accent: 0 0% 20.7843%; /* neutral hover (indigo lives in accent-surface-strong) */
108
+ --hsl-accent-foreground: 0 0% 90.1961%;
110
109
  --hsl-destructive: 348 74% 48%;
111
110
  --hsl-destructive-foreground: 0 0% 100%;
112
- /* Quiet on purpose: 1.6:1 against the resting card. Shadow does no work on a
111
+ /* Quiet on purpose: 1.5:1 against the resting card. Shadow does no work on a
113
112
  * dark canvas, so the hairline is what states where a plane ends. */
114
- --hsl-border: 236 20% 35%; /* #474a6b = --md3-outline-variant */
115
- --hsl-input: 240 24% 15%; /* recessed field well */
113
+ --hsl-border: 0 0% 29.8039%; /* #4c4c4c = --md3-outline-variant */
114
+ --hsl-input: 0 0% 12.1569%; /* recessed field well #1f1f1f */
116
115
  /* Brighter than --primary on purpose: the ring is NON-TEXT and has to clear
117
116
  * 3:1 against BOTH canvas and card. The fill indigo is tuned to carry white
118
117
  * text instead, so the two cannot be the same value. */
@@ -153,20 +152,20 @@
153
152
 
154
153
  /* Sidebar tokens — L1 chrome: one step BELOW the canvas here, so the nav reads
155
154
  * as chrome the content sits in front of rather than as another card. */
156
- --sidebar-background: 242 26% 8%; /* L1 chrome #0f0f1a */
157
- --sidebar-foreground: 225 22% 91%;
155
+ --sidebar-background: 0 0% 6.2745%; /* L1 chrome #101010 */
156
+ --sidebar-foreground: 0 0% 90.1961%;
158
157
  --sidebar-primary: 246 54% 51%;
159
158
  --sidebar-primary-foreground: 0 0% 100%;
160
- --sidebar-accent: 239 22% 25%; /* L3 hover well */
161
- --sidebar-accent-foreground: 225 22% 91%;
162
- --sidebar-border: 236 20% 35%;
159
+ --sidebar-accent: 0 0% 20.7843%; /* L3 hover well */
160
+ --sidebar-accent-foreground: 0 0% 90.1961%;
161
+ --sidebar-border: 0 0% 29.8039%;
163
162
  --sidebar-ring: 233 100% 87%;
164
163
 
165
164
  /* Depth scale — tracks the MD3 ladder (canvas / card / nested / overlay) */
166
- --depth-1: #151523;
167
- --depth-2: #2d2e43;
168
- --depth-3: #32324e;
169
- --depth-4: #393957;
165
+ --depth-1: #161616;
166
+ --depth-2: #303030;
167
+ --depth-3: #353535;
168
+ --depth-4: #3b3b3b;
170
169
 
171
170
  /* Status colors */
172
171
  --status-running: #10B981;
@@ -252,7 +251,7 @@
252
251
  --accent-text: #A5B4FC;
253
252
 
254
253
  /* Surfaces — solid fills off the ladder (no radial mesh) */
255
- --bg-root: #151523;
254
+ --bg-root: #161616;
256
255
  --bg-dark: var(--depth-1);
257
256
  --bg-card: var(--depth-2);
258
257
  --bg-elevated: var(--depth-4);
@@ -262,7 +261,7 @@
262
261
  --bg-selection: rgba(124, 108, 246, 0.28);
263
262
 
264
263
  /* Text — neutral */
265
- /* Four steps the eye can actually separate: 10.6 / 7.8 / 6.5 / 5.4 against the
264
+ /* Four steps the eye can actually separate: 10.6 / 7.8 / 6.4 / 5.4 against the
266
265
  * L2 card. --text-dim is the faintest tier but it is still TEXT, so it clears
267
266
  * the 4.5:1 body floor rather than only the 3:1 large-text one.
268
267
  *
@@ -274,10 +273,10 @@
274
273
  * card, the chrome and the canvas are fixed points. tokens.test.ts asserts
275
274
  * every tier on every plane, so a retune cannot open a gap on one plane while
276
275
  * looking correct on another. */
277
- --text-primary: #e3e6ed;
278
- --text-secondary: #c2c7d4;
279
- --text-muted: #b2b4c7;
280
- --text-dim: #9ea5b9;
276
+ --text-primary: #e6e6e6;
277
+ --text-secondary: #c7c7c7;
278
+ --text-muted: #b5b5b5;
279
+ --text-dim: #a5a5a5;
281
280
 
282
281
  /* Brand accents — Tangle */
283
282
  --brand-primary: #7C6CF6;
@@ -292,9 +291,9 @@
292
291
  --brand-emerald: #10B981;
293
292
 
294
293
  /* Borders — neutral hairlines */
295
- --border-subtle: rgba(165, 180, 252, 0.10);
296
- --border-default: rgba(165, 180, 252, 0.16);
297
- --border-hover: rgba(165, 180, 252, 0.28);
294
+ --border-subtle: rgba(255, 255, 255, 0.10);
295
+ --border-default: rgba(255, 255, 255, 0.16);
296
+ --border-hover: rgba(255, 255, 255, 0.28);
298
297
  --border-accent: rgba(129, 140, 248, 0.35);
299
298
  --border-accent-hover: rgba(129, 140, 248, 0.50);
300
299
 
@@ -420,26 +419,26 @@
420
419
  [data-sandbox-theme="vault"],
421
420
  [data-theme="light"],
422
421
  .light {
423
- /* Light theme — clean professional, warm purple accents */
422
+ /* Light theme — neutral surfaces with purple accents */
424
423
 
425
424
  /* MD3 surface hierarchy */
426
425
  /* The light ladder ALTERNATES; it is not monotonic, and that is deliberate.
427
- * On a tinted canvas, a raised plane is PAPER (white) and a recessed one is a
428
- * tinted WELL. So card and overlay are both white and separate from each other
426
+ * On a neutral canvas, a raised plane is PAPER (white) and a recessed one is a
427
+ * neutral WELL. So card and overlay are both white and separate from each other
429
428
  * by shadow and ring, not by fill — which is how light-mode elevation actually
430
429
  * reads. Forcing a strictly-darkening ladder here is what produced the
431
430
  * white-on-white flatness this palette replaced. Pinned in tokens.test.ts. */
432
- --md3-surface: #e9e9f1; /* L0 page canvas — neutral grey, BELOW the wells */
433
- --md3-surface-dim: #d9d9e6;
431
+ --md3-surface: #eaeaea; /* L0 page canvas — neutral grey, BELOW the wells */
432
+ --md3-surface-dim: #dadada;
434
433
  --md3-surface-bright: #ffffff;
435
434
  --md3-surface-container-lowest: #ffffff; /* insets read as paper */
436
- --md3-surface-container-low: #f7f7fa; /* L1 chrome, field wells — well */
435
+ --md3-surface-container-low: #f7f7f7; /* L1 chrome, field wells — well */
437
436
  --md3-surface-container: #ffffff; /* L2 resting card — paper */
438
- --md3-surface-container-high: #f2f2f7; /* L3 nested / hover — well */
437
+ --md3-surface-container-high: #f2f2f2; /* L3 nested / hover — well */
439
438
  --md3-surface-container-highest: #ffffff; /* L4 overlay — paper + shadow */
440
- --md3-surface-variant: #f2f2f7;
441
- --md3-on-surface: #2b2c3e;
442
- --md3-on-surface-variant: #494b5d;
439
+ --md3-surface-variant: #f2f2f2;
440
+ --md3-on-surface: #2d2d2d;
441
+ --md3-on-surface-variant: #4c4c4c;
443
442
 
444
443
  --md3-primary: #3E349E;
445
444
  --md3-primary-dim: #352B88;
@@ -457,8 +456,8 @@
457
456
 
458
457
  --md3-tertiary: #065F46;
459
458
  --md3-tertiary-container: #D1FAE5;
460
- --md3-outline: #737696;
461
- --md3-outline-variant: #cecfde;
459
+ --md3-outline: #787878;
460
+ --md3-outline-variant: #d0d0d0;
462
461
 
463
462
  --md3-error: #ba1a1a;
464
463
  --md3-error-dim: #93000a;
@@ -471,35 +470,30 @@
471
470
  --md3-inverse-on-surface: #f0f1f4;
472
471
  --md3-surface-tint: #6366F1;
473
472
 
474
- /* Semantic HSL bridge — white paper on a neutral-grey canvas */
475
- --hsl-background: 240 24% 93%; /* L0 canvas #e9e9f1 */
476
- /* Ink, not black. Pure #0d0d0d on white paper measures 19.5:1 — a hair off the
477
- * 21:1 ceiling — and that maximum is what reads as glare rather than as
478
- * legibility. 20.5% L lands 13.7:1 on the card and 11.4:1 on the canvas: AAA with
479
- * headroom in both places, and a step the eye reads as text instead of as a
480
- * hole. */
481
- --hsl-foreground: 235 18% 20.5%; /* #2b2c3e */
473
+ /* Semantic HSL bridge — white paper on an achromatic canvas */
474
+ --hsl-background: 0 0% 91.7647%; /* L0 canvas #eaeaea */
475
+ /* Neutral ink rather than pure black keeps the original 13.7:1 contrast on
476
+ * white paper and 11.4:1 on the canvas: AAA with headroom in both places. */
477
+ --hsl-foreground: 0 0% 17.6471%; /* #2d2d2d */
482
478
  --hsl-card: 0 0% 100%; /* L2 card = white paper */
483
- --hsl-card-foreground: 235 18% 20.5%;
479
+ --hsl-card-foreground: 0 0% 17.6471%;
484
480
  --hsl-popover: 0 0% 100%;
485
- --hsl-popover-foreground: 235 18% 20.5%;
481
+ --hsl-popover-foreground: 0 0% 17.6471%;
486
482
  --hsl-primary: 246 54% 51%; /* #4c3fc6 — carries white at 7.4:1 */
487
483
  --hsl-primary-foreground: 0 0% 100%;
488
- --hsl-secondary: 240 26% 96%; /* L3 #f2f2f7 */
489
- --hsl-secondary-foreground: 235 18% 20.5%;
490
- --hsl-muted: 240 26% 96%; /* L3 hover well */
491
- /* 34% L: the shared components fade captions with /70 and /60, and a
492
- * translucent foreground renders as the token COMPOSITED over the plane behind
493
- * it. Against white paper that compositing is brutal — a lighter value puts the
494
- * /60 caption under the 3:1 large-text floor. This holds 3.0:1 there and 8.6:1
495
- * at full strength, still a clear step below the 13.7:1 body ink. */
496
- --hsl-muted-foreground: 235 12% 32.5%; /* #494b5d text-muted */
497
- --hsl-accent: 240 26% 96%; /* neutral hover */
498
- --hsl-accent-foreground: 235 18% 20.5%;
484
+ --hsl-secondary: 0 0% 94.902%; /* L3 #f2f2f2 */
485
+ --hsl-secondary-foreground: 0 0% 17.6471%;
486
+ --hsl-muted: 0 0% 94.902%; /* L3 hover well */
487
+ /* The shared components fade captions with /70 and /60, so a translucent
488
+ * foreground renders composited over the surface behind it. This neutral value
489
+ * keeps 3.0:1 at /60 and 8.6:1 at full strength on paper. */
490
+ --hsl-muted-foreground: 0 0% 29.8039%; /* #4c4c4c text-muted */
491
+ --hsl-accent: 0 0% 94.902%; /* neutral hover */
492
+ --hsl-accent-foreground: 0 0% 17.6471%;
499
493
  --hsl-destructive: 0 72% 41%;
500
494
  --hsl-destructive-foreground: 0 0% 100%;
501
- --hsl-border: 238 20% 84%; /* #cecfde = --md3-outline-variant */
502
- --hsl-input: 240 30% 97%; /* field well */
495
+ --hsl-border: 0 0% 81.5686%; /* #d0d0d0 = --md3-outline-variant */
496
+ --hsl-input: 0 0% 96.0784%; /* field well #f5f5f5 */
503
497
  --hsl-ring: 246 54% 51%;
504
498
  --hsl-success: 160 84% 30%;
505
499
  --hsl-warning: 41 96% 50%;
@@ -508,9 +502,9 @@
508
502
  /* depth-2 (card) and depth-4 (elevated) are BOTH paper, on purpose: in light,
509
503
  * a popover does not out-lighten a white card — it lifts by shadow. See the
510
504
  * ladder note above. */
511
- --depth-1: #e9e9f1; /* canvas */
505
+ --depth-1: #eaeaea; /* canvas */
512
506
  --depth-2: #ffffff; /* card — paper */
513
- --depth-3: #f2f2f7; /* nested well */
507
+ --depth-3: #f2f2f2; /* nested well */
514
508
  --depth-4: #ffffff; /* elevated — paper, separated by shadow */
515
509
 
516
510
  --status-running: #059669;
@@ -579,12 +573,12 @@
579
573
 
580
574
  /* Sidebar — light, flat surface + hairline */
581
575
  --sidebar-background: 0 0% 100%; /* white paper */
582
- --sidebar-foreground: 235 18% 20.5%;
576
+ --sidebar-foreground: 0 0% 17.6471%;
583
577
  --sidebar-primary: 246 54% 51%; /* #4c3fc6 */
584
578
  --sidebar-primary-foreground: 0 0% 100%;
585
- --sidebar-accent: 240 26% 96%; /* L3 hover well */
586
- --sidebar-accent-foreground: 235 18% 20.5%;
587
- --sidebar-border: 238 20% 84%; /* = --md3-outline-variant */
579
+ --sidebar-accent: 0 0% 94.902%; /* L3 hover well */
580
+ --sidebar-accent-foreground: 0 0% 17.6471%;
581
+ --sidebar-border: 0 0% 81.5686%; /* = --md3-outline-variant */
588
582
  --sidebar-ring: 246 54% 51%;
589
583
 
590
584
  --mesh-teal: transparent;
@@ -592,7 +586,7 @@
592
586
  --mesh-blue: transparent;
593
587
 
594
588
  /* Surfaces — flat single-neutral light */
595
- --bg-root: #e9e9f1;
589
+ --bg-root: #eaeaea;
596
590
  --bg-dark: var(--depth-1);
597
591
  --bg-card: var(--depth-2);
598
592
  /* Paper, exactly like --bg-card: in light you cannot raise a plane by making it
@@ -606,19 +600,18 @@
606
600
  --bg-hover: rgba(76, 63, 198, 0.06);
607
601
  --bg-selection: rgba(76, 63, 198, 0.16);
608
602
 
609
- /* Mirrors the dark ramp's spacing: 13.7 / 10.5 / 8.6 / 5.6 against white paper.
610
- * Every step carries the 240 cast so the ink belongs to the palette.
603
+ /* Mirrors the dark ramp's spacing against white paper. The ink uses neutral
604
+ * grays so purple stays reserved for interaction accents.
611
605
  *
612
- * --text-dim is pinned from the CANVAS (#e9e9f1), not the white card: it is
613
- * the faintest tier, so the tinted L0 is the surface that binds it, and a
614
- * value chosen against paper alone sits under the 4.5:1 body floor there.
615
- * 4.6 on the canvas, 5.6 on the card — both floors are asserted in
606
+ * --text-dim is pinned from the CANVAS (#eaeaea), not the white card: it is
607
+ * the faintest tier, so the lighter L0 is the surface that binds it. Its 4.6:1
608
+ * canvas and 5.6:1 card contrasts clear the body-text floor, as asserted in
616
609
  * tokens.test.ts, because a ratio quoted in a comment drifts the moment a
617
610
  * surface moves and nothing catches it. */
618
- --text-primary: #2b2c3e;
619
- --text-secondary: #3d3e52;
620
- --text-muted: #494b5d;
621
- --text-dim: #65677b;
611
+ --text-primary: #2d2d2d;
612
+ --text-secondary: #3f3f3f;
613
+ --text-muted: #4c4c4c;
614
+ --text-dim: #686868;
622
615
 
623
616
  --brand-primary: #6366F1;
624
617
  --brand-strong: #2E2A5E;
@@ -631,9 +624,9 @@
631
624
  --brand-vibrant: #6366F1;
632
625
  --brand-emerald: #059669;
633
626
 
634
- --border-subtle: rgba(60, 52, 140, 0.10);
635
- --border-default: rgba(60, 52, 140, 0.14);
636
- --border-hover: rgba(60, 52, 140, 0.24);
627
+ --border-subtle: rgba(0, 0, 0, 0.10);
628
+ --border-default: rgba(0, 0, 0, 0.14);
629
+ --border-hover: rgba(0, 0, 0, 0.24);
637
630
  --border-accent: rgba(91, 78, 212, 0.18);
638
631
  --border-accent-hover: rgba(91, 78, 212, 0.35);
639
632
 
@@ -727,7 +720,7 @@
727
720
  * draws as a --chart-muted outline, the same mark as an unsolved attempt. No
728
721
  * categorical palette ships: identity is carried by direct labels.
729
722
  *
730
- * Contrast against the dark canvas (#151523) and white paper, checked with the
723
+ * Contrast against the dark canvas (#161616) and white paper, checked with the
731
724
  * dataviz palette validator and WCAG ratios:
732
725
  * ink 14.45 / 13.70, ink-muted 7.34 / 5.56, accent 6.05 / 7.41,
733
726
  * muted 5.54 / 4.41, grid 1.46 / 1.12 (recessive by design).
@@ -13,12 +13,9 @@ import {
13
13
  /**
14
14
  * The canonical spine's structural contract.
15
15
  *
16
- * The whole point of this palette is that a surface separates from the one below
17
- * it by its own FILL. A flat, desaturated ladder pushes that job onto borders,
18
- * and the app reads grey and washed out — which is the state this replaced. The
19
- * numbers are therefore not decoration; they are the contract. These assertions
20
- * pin the PROPERTIES rather than the exact values, so a retune stays free but a
21
- * collapse back to flat does not.
16
+ * The canonical surface and ink spine is neutral so purple can signal interaction.
17
+ * Each surface remains distinct by fill and elevation. These assertions pin that
18
+ * structure without fixing every shade, so the ladder can be retuned safely.
22
19
  */
23
20
  const tokens = readFileSync(
24
21
  path.resolve(import.meta.dirname, "tokens.css"),
@@ -31,27 +28,73 @@ const tokens = readFileSync(
31
28
  const DARK = blockIn(tokens, ".dark");
32
29
  const LIGHT = blockIn(tokens, ".light");
33
30
 
34
- describe("canonical dark spine", () => {
35
- it("keeps the canvas in the brand's hue family, at any strength", () => {
36
- const bg = hslIn(DARK, "hsl-background");
37
- // HUE is the brand constraint and does not move: the greys are cool and sit
38
- // in the indigo band, so they belong to the same family as the accent rather
39
- // than reading as a foreign neutral pasted underneath it.
40
- expect(bg.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
41
- 215,
42
- );
43
- expect(bg.h).toBeLessThanOrEqual(260);
44
- // SATURATION is a taste dial, not a contract. How far the surfaces lean —
45
- // near-neutral graphite with the indigo purely in the accents, or a fully
46
- // indigo-cast ground — is a live product decision, so the only thing pinned
47
- // here is that the canvas is not dead grey. Asserting a band instead makes
48
- // this test fail every time that decision is revisited, which tells you
49
- // nothing about whether the palette is correct.
50
- expect(bg.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
51
- 5,
52
- );
53
- });
31
+ describe("canonical surfaces, ink, and chrome are neutral", () => {
32
+ const neutralHslRoles = [
33
+ "hsl-background",
34
+ "hsl-foreground",
35
+ "hsl-card",
36
+ "hsl-card-foreground",
37
+ "hsl-popover",
38
+ "hsl-popover-foreground",
39
+ "hsl-secondary",
40
+ "hsl-secondary-foreground",
41
+ "hsl-muted",
42
+ "hsl-muted-foreground",
43
+ "hsl-accent",
44
+ "hsl-accent-foreground",
45
+ "hsl-border",
46
+ "hsl-input",
47
+ "sidebar-background",
48
+ "sidebar-foreground",
49
+ "sidebar-accent",
50
+ "sidebar-accent-foreground",
51
+ "sidebar-border",
52
+ ];
53
+ const neutralHexRoles = [
54
+ "md3-surface",
55
+ "md3-surface-dim",
56
+ "md3-surface-bright",
57
+ "md3-surface-container-lowest",
58
+ "md3-surface-container-low",
59
+ "md3-surface-container",
60
+ "md3-surface-container-high",
61
+ "md3-surface-container-highest",
62
+ "md3-surface-variant",
63
+ "md3-on-surface",
64
+ "md3-on-surface-variant",
65
+ "md3-outline",
66
+ "md3-outline-variant",
67
+ "depth-1",
68
+ "depth-2",
69
+ "depth-3",
70
+ "depth-4",
71
+ ];
72
+
73
+ for (const [theme, spine] of [
74
+ ["dark", DARK],
75
+ ["light", LIGHT],
76
+ ] as const) {
77
+ it(`${theme}: shared surface and ink roles are achromatic`, () => {
78
+ for (const token of neutralHslRoles) {
79
+ expect(hslIn(spine, token).s, `--${token}`).toBe(0);
80
+ }
81
+ for (const token of neutralHexRoles) {
82
+ const hex = spine.match(
83
+ new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`),
84
+ )?.[1];
85
+ expect(hex, `--${token} must be defined`).toBeDefined();
86
+ expect(hex?.slice(1, 3), `--${token} red equals green`).toBe(
87
+ hex?.slice(3, 5),
88
+ );
89
+ expect(hex?.slice(3, 5), `--${token} green equals blue`).toBe(
90
+ hex?.slice(5, 7),
91
+ );
92
+ }
93
+ });
94
+ }
95
+ });
54
96
 
97
+ describe("canonical dark spine", () => {
55
98
  it("guards reading comfort at the INK, not at the canvas", () => {
56
99
  // A deep canvas is fine — glare comes from the ratio between ink and ground,
57
100
  // not from the ground alone. So the floor here is loose, and the real guard
@@ -388,7 +431,7 @@ describe("input tokens carry two DIFFERENT roles and must not be conflated", ()
388
431
  });
389
432
 
390
433
  describe("canonical light spine", () => {
391
- it("is white paper on a tinted canvas, never white-on-white", () => {
434
+ it("is white paper on a neutral canvas, never white-on-white", () => {
392
435
  const canvas = hslIn(LIGHT, "hsl-background");
393
436
  const card = hslIn(LIGHT, "hsl-card");
394
437
  expect(card.l, "the card is paper").toBe(100);
@@ -396,22 +439,11 @@ describe("canonical light spine", () => {
396
439
  canvas.l,
397
440
  "a pure-white canvas gives a white card nothing to lift off",
398
441
  ).toBeLessThan(97);
399
- // Mirrors the dark spine exactly: HUE is the brand constraint and is pinned;
400
- // saturation is a taste dial and is not. How far the surfaces lean is a live
401
- // product decision, so asserting a band here only guarantees this test fails
402
- // the next time that decision is revisited.
403
- expect(canvas.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
404
- 215,
405
- );
406
- expect(canvas.h).toBeLessThanOrEqual(260);
407
- expect(canvas.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
408
- 5,
409
- );
410
442
  });
411
443
 
412
444
  it("ALTERNATES paper and well — light elevation is not a darkening ramp", () => {
413
- // Deliberately not monotonic. On a tinted canvas a raised plane is paper and
414
- // a recessed one is a tinted well, so the ladder alternates. Pinning it stops
445
+ // Deliberately not monotonic. On a neutral canvas a raised plane is paper and
446
+ // a recessed one is a neutral well, so the ladder alternates. Pinning it stops
415
447
  // a well-meaning "fix" from forcing a strictly-ordered ramp, which is exactly
416
448
  // what produced the white-on-white flatness this palette replaced.
417
449
  const paper = ["md3-surface-container", "md3-surface-container-highest"];