@tangle-network/brand 1.1.0 → 1.3.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.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f3c5262: Add the missing `tangle-dark` named theme. The named set shipped `tangle-light` with no dark sibling, so `data-theme="tangle-dark"` matched no rule and silently fell through to whatever the element inherited — the light spine's values, inside a light-named wrapper. `tangle-dark` now selects the canonical dark spine by name: same indigo family as `tangle-light`, held to the dark contrast discipline, and self-sufficient inside a light-named wrapper (foregrounds and destructive included, so nothing leaks the light values).
8
+
9
+ ## 1.2.0
10
+
11
+ ### Minor Changes
12
+
13
+ - 2088fb9: Retune the token spine to a neutral-grey surface ladder with indigo as trim rather than as field, in both themes. Surfaces separate by their own fill so a card, a nested panel and an overlay each read as a distinct plane, and the canvas sits off pure black so long reading sessions land away from the glare end of the range.
14
+
15
+ Faint text in `Input`, `Textarea`, `StatCard` and `TerminalLine` now takes a dedicated `--text-dim` token instead of a faded stronger one. A translucent foreground renders as its colour composited over the plane behind it, so those hints, subtitles and timestamps measured differently on a card than on the canvas and fell under the 4.5:1 floor on both.
16
+
17
+ **Worth a look after upgrading:** in dark mode `--sidebar-background` is now one step BELOW the canvas rather than above it, so the nav reads as chrome the content sits in front of instead of as another raised card. Apps that composited their own surfaces on top of the sidebar assuming it was the lighter plane should give that area a visual pass. Light mode is unchanged — the sidebar is still paper on a grey canvas.
18
+
3
19
  ## 1.1.0
4
20
 
5
21
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.1.0",
3
+ "version": "1.3.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",
@@ -47,9 +47,9 @@
47
47
  }
48
48
  },
49
49
  "devDependencies": {
50
- "@storybook/react": "^8.6.18",
50
+ "@storybook/react": "^10.5.5",
51
51
  "@types/react": "^19.0.0",
52
- "react": "^19.0.0",
52
+ "react": "^19.2.8",
53
53
  "tsup": "^8.3.5",
54
54
  "typescript": "^5.6.0"
55
55
  },
@@ -18,30 +18,62 @@ function rulesOnly(css: string): string {
18
18
 
19
19
  /** Extract a rule's body by selector, tolerant of formatting and of nesting. */
20
20
  export function blockIn(source: string, selector: string): string {
21
+ const blocks = blocksIn(source, selector);
22
+ if (blocks.length === 0) throw new Error(`missing theme block: ${selector}`);
23
+ return blocks[0];
24
+ }
25
+
26
+ /**
27
+ * Every rule body whose selector list ENDS in `selector`, in source order — the
28
+ * match requires the selector immediately before the `{`, so a selector earlier
29
+ * in a comma-separated group (`selector, .other {`) is not returned.
30
+ *
31
+ * One selector can head MORE than one rule — `[data-theme="tangle-light"]` names
32
+ * both the shared light-status override group and the theme's own block — so a
33
+ * first-match read can silently slice the wrong region. Return them all and let
34
+ * the caller pick (e.g. the one that declares `--hsl-background`).
35
+ */
36
+ export function blocksIn(source: string, selector: string): string[] {
21
37
  // Comments first: a block's own doc-comment routinely spells its selector out,
22
38
  // and anchoring on that prose would slice the wrong region — silently, since
23
39
  // the result still looks like CSS.
24
40
  const css = rulesOnly(source);
25
41
  // Tolerate any whitespace between selector and brace — a formatter that closes
26
42
  // the gap must not turn a passing suite into a "missing block" error.
27
- const open = new RegExp(`${escapeRegex(selector)}\\s*\\{`).exec(css);
28
- if (!open) throw new Error(`missing theme block: ${selector}`);
29
- const start = open.index;
30
-
31
- // Walk braces to find THIS block's close. Matching the first `}` would end the
32
- // block at the first nested rule's close instead (an `@media`, a nested
33
- // selector) — and the slice would still look like CSS, so assertions would run
34
- // against a truncated region rather than failing. Depth-counting is exact, and
35
- // an unterminated block throws instead of returning something plausible.
36
- let depth = 0;
37
- for (let i = css.indexOf("{", start); i < css.length; i++) {
38
- if (css[i] === "{") depth += 1;
39
- else if (css[i] === "}") {
40
- depth -= 1;
41
- if (depth === 0) return css.slice(start, i);
43
+ const pattern = new RegExp(`${escapeRegex(selector)}\\s*\\{`, "g");
44
+
45
+ const blocks: string[] = [];
46
+ for (const open of css.matchAll(pattern)) {
47
+ const start = open.index;
48
+
49
+ // Walk braces to find THIS block's close. Matching the first `}` would end the
50
+ // block at the first nested rule's close instead (an `@media`, a nested
51
+ // selector) — and the slice would still look like CSS, so assertions would run
52
+ // against a truncated region rather than failing. Depth-counting is exact, and
53
+ // an unterminated block throws instead of returning something plausible.
54
+ let depth = 0;
55
+ let closed = false;
56
+ for (let i = css.indexOf("{", start); i < css.length; i++) {
57
+ if (css[i] === "{") depth += 1;
58
+ else if (css[i] === "}") {
59
+ depth -= 1;
60
+ if (depth === 0) {
61
+ blocks.push(css.slice(start, i));
62
+ closed = true;
63
+ break;
64
+ }
65
+ }
42
66
  }
67
+ if (!closed) throw new Error(`unterminated theme block: ${selector}`);
43
68
  }
44
- throw new Error(`unterminated theme block: ${selector}`);
69
+ return blocks;
70
+ }
71
+
72
+ /** Read a token's raw declaration value, whitespace-normalized. */
73
+ export function declIn(css: string, token: string): string {
74
+ const m = css.match(new RegExp(`--${token}:\\s*([^;]+)`));
75
+ if (!m) throw new Error(`missing token --${token}`);
76
+ return m[1].trim().replace(/\s+/g, " ");
45
77
  }
46
78
 
47
79
  /** Read an `H S% L%` channel triple. */
@@ -60,6 +92,32 @@ export function hslIn(
60
92
 
61
93
  type Rgb = [number, number, number];
62
94
 
95
+ /**
96
+ * Read a hex surface token as RGB 0-255.
97
+ *
98
+ * Accepts `#rgb`, `#rrggbb` and `#rrggbbaa` (alpha is dropped — compositing is a
99
+ * separate question, answered by `compositeOver`). A token that exists but is not
100
+ * hex reports exactly that, rather than claiming it is missing.
101
+ */
102
+ export function hexIn(css: string, token: string): Rgb {
103
+ const declaration = css.match(new RegExp(`--${token}:\\s*([^;]+)`));
104
+ if (!declaration) throw new Error(`missing surface --${token}`);
105
+
106
+ const value = declaration[1].trim();
107
+ const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/.exec(value);
108
+ if (!hex) {
109
+ throw new Error(`--${token} is not a hex color (found "${value}")`);
110
+ }
111
+
112
+ const digits = hex[1];
113
+ // #rgb is shorthand for #rrggbb; #rrggbbaa carries an alpha byte we drop.
114
+ const pairs =
115
+ digits.length === 3
116
+ ? [...digits].map((d) => d + d)
117
+ : [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 6)];
118
+ return pairs.map((p) => Number.parseInt(p, 16)) as Rgb;
119
+ }
120
+
63
121
  /** `H S% L%` → RGB 0-255. */
64
122
  export function hslToRgb({
65
123
  h,
@@ -120,33 +178,9 @@ export function contrastRatio(a: Rgb, b: Rgb): number {
120
178
  * Relative luminance, not HSL lightness: the ladder varies hue and saturation as
121
179
  * it rises, and HSL L is not perceptually uniform — two steps can share an L and
122
180
  * still read as different brightnesses, so an "is each plane brighter than the one
123
- * below" assertion made on HSL L would be measuring the wrong thing.
124
- *
125
- * Accepts `#rgb`, `#rrggbb` and `#rrggbbaa` (alpha is ignored — luminance is a
126
- * property of the color, not of how much of it you can see through). A token that
127
- * exists but is not hex reports exactly that, rather than claiming it is missing.
181
+ * below" assertion made on HSL L would be measuring the wrong thing. Hex parsing
182
+ * (and its error reporting) is `hexIn`'s.
128
183
  */
129
184
  export function hexRelativeLuminanceIn(css: string, token: string): number {
130
- const declaration = css.match(new RegExp(`--${token}:\\s*([^;]+)`));
131
- if (!declaration) throw new Error(`missing surface --${token}`);
132
-
133
- const value = declaration[1].trim();
134
- const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/.exec(value);
135
- if (!hex) {
136
- throw new Error(
137
- `--${token} is not a hex color (found "${value}") — luminance is undefined for it`,
138
- );
139
- }
140
-
141
- const digits = hex[1];
142
- // #rgb is shorthand for #rrggbb; #rrggbbaa carries an alpha byte we drop.
143
- const pairs =
144
- digits.length === 3
145
- ? [...digits].map((d) => d + d)
146
- : [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 6)];
147
-
148
- const [r, g, b] = pairs.map((p) => Number.parseInt(p, 16) / 255);
149
- const lin = (c: number) =>
150
- c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
151
- return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
185
+ return relativeLuminance(hexIn(css, token));
152
186
  }
@@ -2,7 +2,8 @@
2
2
  * components re-skin automatically because they reference the semantic tokens
3
3
  * (bg-card / border-border / surface-container, mapped in theme.css). Apply with
4
4
  * data-theme="<name>" on a wrapper (or <html>). The default :root / .dark spine
5
- * is the neutral "tangle" theme; "<name>-light" scopes are the light variants. */
5
+ * is the neutral "tangle" theme — also selectable by name as "tangle-dark";
6
+ * "<name>-light" scopes are the light variants. */
6
7
 
7
8
  /* Bridge — the actual re-skin mechanism. Tailwind utilities read the --color-*
8
9
  * layer (theme.css @theme: `bg-card` → var(--color-card) = hsl(var(--card))).
@@ -166,8 +167,42 @@
166
167
  --text-primary: #06231a; --text-secondary: #285048; --text-muted: #5f8472; --text-dim: #82a699;
167
168
  }
168
169
 
169
- /* ───────────────────────── Tangle (neutral) — light variant alias ─────────── */
170
- /* dark = default :root spine; this is its light mode for the showcase. */
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. */
184
+ [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: 240 22% 27%; --hsl-popover-foreground: 225 22% 91%;
188
+ --hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
189
+ --hsl-secondary: 240 22% 27%; --hsl-secondary-foreground: 225 22% 91%;
190
+ --hsl-muted: 240 22% 27%; --hsl-muted-foreground: 223 14% 69%;
191
+ --hsl-accent: 240 22% 27%; --hsl-accent-foreground: 225 22% 91%;
192
+ --hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
193
+ --hsl-border: 236 20% 35%; --hsl-input: 240 24% 15%;
194
+ --hsl-ring: 233 100% 87%;
195
+ --depth-1: #151523; --depth-2: #2d2e43; --depth-3: #363654; --depth-4: #434365;
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: #363654;
199
+ --md3-surface-container-highest: #434365; --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;
202
+ --brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
203
+ --text-primary: #e3e6ed; --text-secondary: #bec3d0; --text-muted: #a5abbb; --text-dim: #9299ab;
204
+ }
205
+ /* Its light mode. */
171
206
  [data-theme="tangle-light"] {
172
207
  --hsl-background: 240 33% 98%; --hsl-foreground: 245 32% 14%;
173
208
  --hsl-card: 0 0% 100%; --hsl-card-foreground: 245 32% 14%;
@@ -1,7 +1,16 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { describe, expect, it } from "vitest";
4
- import { blockIn, hslIn } from "./css-test-utils";
4
+ import {
5
+ blockIn,
6
+ blocksIn,
7
+ contrastRatio,
8
+ declIn,
9
+ hexIn,
10
+ hexRelativeLuminanceIn,
11
+ hslIn,
12
+ hslToRgb,
13
+ } from "./css-test-utils";
5
14
 
6
15
  /**
7
16
  * The named-theme contract.
@@ -16,6 +25,15 @@ const themes = readFileSync(
16
25
  path.resolve(import.meta.dirname, "named-themes.css"),
17
26
  "utf8",
18
27
  );
28
+ const tokens = readFileSync(
29
+ path.resolve(import.meta.dirname, "tokens.css"),
30
+ "utf8",
31
+ );
32
+
33
+ // The canonical dark spine, anchored on the LAST selector of its multi-selector
34
+ // header (`:root, [data-sandbox-ui], [data-theme="dark"], .dark {`) — the same
35
+ // trick tokens.test.ts uses.
36
+ const CANON = blockIn(tokens, ".dark");
19
37
 
20
38
  function block(selector: string): string {
21
39
  return blockIn(themes, selector);
@@ -69,3 +87,218 @@ describe('named theme: .dark[data-theme="intelligence"]', () => {
69
87
  expect(rules).not.toContain('[data-theme="intelligence-light"]');
70
88
  });
71
89
  });
90
+
91
+ describe('named theme: [data-theme="tangle-dark"]', () => {
92
+ const css = block('[data-theme="tangle-dark"]');
93
+ // `[data-theme="tangle-light"]` heads two rules — the shared light-status
94
+ // override group AND the theme's own block; the theme block is the one that
95
+ // declares the spine.
96
+ const light = blocksIn(themes, '[data-theme="tangle-light"]').find((b) =>
97
+ b.includes("--hsl-background"),
98
+ );
99
+ if (!light) throw new Error('missing theme block: [data-theme="tangle-light"]');
100
+
101
+ it("IS the canonical dark spine, named — no drift from tokens.css", () => {
102
+ // tangle-dark exists so the attribute can NAME the default spine (a dark
103
+ // island in a light-named wrapper, a picker's explicit selection). If its
104
+ // values diverge from :root, "tangle dark" becomes two palettes.
105
+ const hslTokens = [
106
+ "hsl-background",
107
+ "hsl-foreground",
108
+ "hsl-card",
109
+ "hsl-card-foreground",
110
+ "hsl-popover",
111
+ "hsl-popover-foreground",
112
+ "hsl-primary",
113
+ "hsl-primary-foreground",
114
+ "hsl-secondary",
115
+ "hsl-secondary-foreground",
116
+ "hsl-muted",
117
+ "hsl-muted-foreground",
118
+ "hsl-accent",
119
+ "hsl-accent-foreground",
120
+ "hsl-destructive",
121
+ "hsl-destructive-foreground",
122
+ "hsl-border",
123
+ "hsl-input",
124
+ "hsl-ring",
125
+ ];
126
+ for (const token of hslTokens) {
127
+ expect(hslIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
128
+ hslIn(CANON, token),
129
+ );
130
+ }
131
+ const hexTokens = [
132
+ "depth-1",
133
+ "depth-2",
134
+ "depth-3",
135
+ "depth-4",
136
+ "md3-surface-container-lowest",
137
+ "md3-surface-container-low",
138
+ "md3-surface-container",
139
+ "md3-surface-container-high",
140
+ "md3-surface-container-highest",
141
+ "md3-outline-variant",
142
+ "accent-text",
143
+ "brand-primary",
144
+ "brand-glow",
145
+ "text-primary",
146
+ "text-secondary",
147
+ "text-muted",
148
+ "text-dim",
149
+ ];
150
+ for (const token of hexTokens) {
151
+ expect(hexIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
152
+ hexIn(CANON, token),
153
+ );
154
+ }
155
+ // Aliased and rgba declarations don't fit hslIn/hexIn — compare them raw.
156
+ for (const token of [
157
+ "bg-root",
158
+ "bg-card",
159
+ "bg-input",
160
+ "border-subtle",
161
+ "border-default",
162
+ "border-hover",
163
+ ]) {
164
+ expect(declIn(css, token), `--${token} must equal the :root dark spine`).toBe(
165
+ declIn(CANON, token),
166
+ );
167
+ }
168
+ });
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"]) {
172
+ const dark = hslIn(css, token);
173
+ const lite = hslIn(light, token);
174
+ expect(
175
+ Math.abs(dark.h - lite.h),
176
+ `--${token} hue must sit within 10 of tangle-light's`,
177
+ ).toBeLessThanOrEqual(10);
178
+ }
179
+ });
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
+ it("elevates by lightening, never darkening", () => {
200
+ const planes = ["depth-1", "depth-2", "depth-3", "depth-4"].map((t) =>
201
+ hexRelativeLuminanceIn(css, t),
202
+ );
203
+ for (let i = 1; i < planes.length; i++) {
204
+ expect(planes[i], `depth step ${i} must be brighter than the one below`).toBeGreaterThan(
205
+ planes[i - 1],
206
+ );
207
+ }
208
+ const ladder = ["hsl-background", "hsl-card", "hsl-popover"].map(
209
+ (t) => hslIn(css, t).l,
210
+ );
211
+ for (let i = 1; i < ladder.length; i++) {
212
+ expect(ladder[i], `step ${i} lightness must rise`).toBeGreaterThan(
213
+ ladder[i - 1],
214
+ );
215
+ }
216
+ });
217
+
218
+ it("holds the dark contrast discipline — AA floors, AAA ink", () => {
219
+ const canvas = hslToRgb(hslIn(css, "hsl-background"));
220
+ const card = hslToRgb(hslIn(css, "hsl-card"));
221
+ const fg = hslToRgb(hslIn(css, "hsl-foreground"));
222
+ expect(contrastRatio(fg, canvas), "ink on canvas").toBeGreaterThanOrEqual(7);
223
+ expect(contrastRatio(fg, card), "ink on card").toBeGreaterThanOrEqual(7);
224
+
225
+ const muted = hslToRgb(hslIn(css, "hsl-muted-foreground"));
226
+ expect(contrastRatio(muted, card), "muted ink on card").toBeGreaterThanOrEqual(
227
+ 4.5,
228
+ );
229
+
230
+ const primary = hslToRgb(hslIn(css, "hsl-primary"));
231
+ const onPrimary = hslToRgb(hslIn(css, "hsl-primary-foreground"));
232
+ expect(
233
+ contrastRatio(onPrimary, primary),
234
+ "the fill indigo carries white",
235
+ ).toBeGreaterThanOrEqual(4.5);
236
+
237
+ // The ring is NON-TEXT: it must clear 3:1 against both planes it sits on.
238
+ const ring = hslToRgb(hslIn(css, "hsl-ring"));
239
+ expect(contrastRatio(ring, canvas), "ring on canvas").toBeGreaterThanOrEqual(3);
240
+ expect(contrastRatio(ring, card), "ring on card").toBeGreaterThanOrEqual(3);
241
+
242
+ expect(
243
+ contrastRatio(hexIn(css, "accent-text"), card),
244
+ "bright indigo accent text on card",
245
+ ).toBeGreaterThanOrEqual(4.5);
246
+
247
+ // The faintest tier is still TEXT: it clears the body floor on both planes.
248
+ const dim = hexIn(css, "text-dim");
249
+ expect(contrastRatio(dim, card), "text-dim on card").toBeGreaterThanOrEqual(4.5);
250
+ expect(contrastRatio(dim, canvas), "text-dim on canvas").toBeGreaterThanOrEqual(
251
+ 4.5,
252
+ );
253
+ });
254
+
255
+ it("is self-sufficient inside a light-named wrapper — the island it exists for", () => {
256
+ // The feature's stated scenario: tangle-dark nested in a tangle-light
257
+ // wrapper. The browser composes the two scopes, so a token the island omits
258
+ // inherits the WRAPPER's light value, not :root's — the light group's
259
+ // near-black secondary-foreground on the dark secondary measured ~1.5:1
260
+ // before the block redeclared its foregrounds. Model that cascade: every
261
+ // tangle-light rule (the shared light group AND the theme block), with the
262
+ // island's declarations overriding.
263
+ const wrapper = blocksIn(themes, '[data-theme="tangle-light"]').join("\n");
264
+ const effective = (token: string) => {
265
+ const read = (source: string) => {
266
+ try {
267
+ return hslIn(source, token);
268
+ } catch {
269
+ return null;
270
+ }
271
+ };
272
+ const value = read(css) ?? read(wrapper);
273
+ if (!value) {
274
+ throw new Error(`--${token} resolves to nothing inside the island`);
275
+ }
276
+ return value;
277
+ };
278
+ for (const [fgToken, bgToken] of [
279
+ ["hsl-secondary-foreground", "hsl-secondary"],
280
+ ["hsl-accent-foreground", "hsl-accent"],
281
+ ["hsl-destructive-foreground", "hsl-destructive"],
282
+ ] as const) {
283
+ expect(
284
+ contrastRatio(hslToRgb(effective(fgToken)), hslToRgb(effective(bgToken))),
285
+ `effective ${fgToken} on ${bgToken}, island inside tangle-light`,
286
+ ).toBeGreaterThanOrEqual(4.5);
287
+ }
288
+ });
289
+
290
+ it("keeps the dark-tuned :root status palette instead of redeclaring it", () => {
291
+ // Same contract as aubergine/arena: dark named themes inherit status from the
292
+ // dark spine, so there is exactly one dark status palette to tune. What must
293
+ // be proven is that the inherited pairs pass AA on their dark surfaces.
294
+ expect(css).not.toMatch(/--surface-(success|warning|danger|info)-/);
295
+ for (const name of ["success", "warning", "danger", "info"]) {
296
+ const text = hexIn(CANON, `surface-${name}-text`);
297
+ const bg = hexIn(CANON, `surface-${name}-bg`);
298
+ expect(
299
+ contrastRatio(text, bg),
300
+ `inherited ${name} status text on its dark surface`,
301
+ ).toBeGreaterThanOrEqual(4.5);
302
+ }
303
+ });
304
+ });
@@ -1,10 +1,10 @@
1
1
  /* Tangle design tokens — the single palette every Tangle surface renders from.
2
2
  *
3
- * The surface ladder carries an indigo cast and steps in even ~4-5% lightness
4
- * increments, so a card, a nested panel and an overlay each read as a distinct
5
- * plane by their own fill. A flat, desaturated ladder forces separation onto
6
- * borders instead, which is what makes an app read as grey and washed out.
7
- * Light mirrors it: white paper on a tinted canvas, never white-on-white.
3
+ * The surface ladder is NEUTRAL GREY and the indigo is trim — accent, never
4
+ * field. It steps in even lightness increments so a card, a nested panel and an
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
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,
10
10
  * add a named theme in named-themes.css (`data-theme="<name>"`) — do NOT re-declare
@@ -14,25 +14,47 @@
14
14
  /* Dark is the default theme (`:root`/`[data-sandbox-ui]`). It also responds to
15
15
  * the standard `[data-theme="dark"]` / `.dark` switch so any consumer can pin or
16
16
  * toggle a theme without inventing an ad-hoc `data-sandbox-theme` name. */
17
+ /*
18
+ * Surfaces are NEUTRAL GREY and the indigo is trim.
19
+ *
20
+ * Colour in the surface competes with colour in the accent: on an indigo-cast
21
+ * ladder the indigo nav pill, link and focus ring are one more indigo thing on
22
+ * an indigo page, and the eye stops finding them. Neutral planes give the brand
23
+ * a field to sit on, so a single accent does all the signalling.
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.
28
+ *
29
+ * Elevation is carried by surface lightness plus a hairline that can actually be
30
+ * 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,
32
+ * enough to state an edge without drawing a box around every panel.
33
+ */
17
34
  :root,
18
35
  [data-sandbox-ui],
19
36
  [data-theme="dark"],
20
37
  .dark {
21
- --md3-surface: #0a0a14; /* L0 page canvas */
22
- --md3-surface-dim: #060610; /* recessed page */
23
- --md3-surface-bright: #232238;
24
- --md3-surface-container-lowest: #050509; /* L-1 terminal/code well */
25
- --md3-surface-container-low: #12111f; /* L1 chrome, field wells */
26
- --md3-surface-container: #191826; /* L2 resting card */
27
- --md3-surface-container-high: #221f33; /* L3 nested / hover */
28
- --md3-surface-container-highest: #2c2942; /* L4 overlay */
29
- --md3-surface-variant: #2c2942;
30
- --md3-on-surface: #fdfdfd;
31
- --md3-on-surface-variant: #aaa8b8;
32
-
33
- --md3-primary: #818CF8;
34
- --md3-primary-dim: #6366F1;
35
- --md3-primary-container: #4F46E5;
38
+ --md3-surface: #151523; /* L0 page canvas */
39
+ --md3-surface-dim: #0f0f1a; /* recessed page */
40
+ --md3-surface-bright: #434365;
41
+ /* Same value as --md3-surface-dim by design, not by drift: both name the floor
42
+ * of the ladder, and dark has no plane below it to give them. They stay two
43
+ * 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
45
+ * 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: #363654; /* L3 nested / hover */
50
+ --md3-surface-container-highest: #434365; /* L4 overlay */
51
+ --md3-surface-variant: #363654;
52
+ --md3-on-surface: #e3e6ed;
53
+ --md3-on-surface-variant: #a5abbb;
54
+
55
+ --md3-primary: #A5B4FC;
56
+ --md3-primary-dim: #7C6CF6;
57
+ --md3-primary-container: #4C3FC5;
36
58
  --md3-on-primary: #ffffff;
37
59
  --md3-on-primary-container: #E0E7FF;
38
60
  --md3-primary-fixed: #A5AAFC;
@@ -45,8 +67,8 @@
45
67
  --md3-on-secondary-container: #E8E5FF;
46
68
 
47
69
  --md3-tertiary: #10B981;
48
- --md3-outline: #4a4965;
49
- --md3-outline-variant: #2a293d;
70
+ --md3-outline: #898da9;
71
+ --md3-outline-variant: #474a6b;
50
72
 
51
73
  --md3-error: #ff5277;
52
74
  --md3-error-dim: #eb2855;
@@ -55,32 +77,43 @@
55
77
  --md3-on-error-container: #ffbdf2;
56
78
 
57
79
  --md3-inverse-surface: #fdfdfd;
58
- --md3-inverse-primary: #4F46E5;
80
+ --md3-inverse-primary: #4C3FC5;
59
81
  --md3-inverse-on-surface: #000000;
60
- --md3-surface-tint: #818CF8;
61
-
62
- /* Semantic HSL bridge — the indigo-cast surface ladder */
63
- --hsl-background: 240 33% 6%; /* L0 canvas #0a0a14 */
64
- --hsl-foreground: 240 6% 93%; /* #ececee */
65
- --hsl-card: 244 23% 12%; /* L2 card #191826 */
66
- --hsl-card-foreground: 240 6% 93%;
67
- --hsl-popover: 247 23% 21%; /* L4 overlay #2c2942 */
68
- --hsl-popover-foreground: 240 6% 93%;
69
- --hsl-primary: 239 84% 62%;
82
+ --md3-surface-tint: #A5B4FC;
83
+
84
+ /* 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: 240 22% 27%; /* L4 overlay */
93
+ --hsl-popover-foreground: 225 22% 91%;
94
+ /* The fill indigo, NOT the bright one: `.btn-primary` puts white on this, and
95
+ * #6366F1 carries white at only 4.47:1. This clears the floor at 7.4:1. The
96
+ * bright indigo stays available for accent TEXT via --accent-text. */
97
+ --hsl-primary: 246 54% 51%; /* #4c3fc6 */
70
98
  --hsl-primary-foreground: 0 0% 100%;
71
- --hsl-secondary: 249 24% 16%; /* L3 #221f33 */
72
- --hsl-secondary-foreground: 240 6% 93%;
73
- --hsl-muted: 249 24% 16%; /* L3 hover well */
74
- --hsl-muted-foreground: 240 5% 68%; /* #a9a9b1 — high enough that the
99
+ --hsl-secondary: 240 22% 27%; /* L3 #363654 */
100
+ --hsl-secondary-foreground: 225 22% 91%;
101
+ --hsl-muted: 240 22% 27%; /* L3 hover well */
102
+ --hsl-muted-foreground: 223 14% 69%; /* #a5abbb — high enough that the
75
103
  reduced-opacity caption derivatives (/70, /60) still clear the large-text
76
104
  contrast floor against the L2 card. */
77
- --hsl-accent: 249 24% 16%; /* neutral hover (indigo lives in accent-surface-strong) */
78
- --hsl-accent-foreground: 240 6% 93%;
105
+ --hsl-accent: 240 22% 27%; /* neutral hover (indigo lives in accent-surface-strong) */
106
+ --hsl-accent-foreground: 225 22% 91%;
79
107
  --hsl-destructive: 348 74% 48%;
80
108
  --hsl-destructive-foreground: 0 0% 100%;
81
- --hsl-border: 243 19.6% 20%; /* #2a293d = --md3-outline-variant */
82
- --hsl-input: 244 29% 9%; /* recessed field well #12111f */
83
- --hsl-ring: 239 84% 67%;
109
+ /* Quiet on purpose: 1.6:1 against the resting card. Shadow does no work on a
110
+ * dark canvas, so the hairline is what states where a plane ends. */
111
+ --hsl-border: 236 20% 35%; /* #474a6b = --md3-outline-variant */
112
+ --hsl-input: 240 24% 15%; /* recessed field well */
113
+ /* Brighter than --primary on purpose: the ring is NON-TEXT and has to clear
114
+ * 3:1 against BOTH canvas and card. The fill indigo is tuned to carry white
115
+ * text instead, so the two cannot be the same value. */
116
+ --hsl-ring: 233 100% 87%;
84
117
  --hsl-success: 160 84% 39%;
85
118
  --hsl-warning: 40 94% 56%;
86
119
  --hsl-info: 200 88% 56%;
@@ -115,26 +148,26 @@
115
148
  --error: var(--hsl-destructive);
116
149
  --info: var(--hsl-info);
117
150
 
118
- /* Sidebar tokens — L1 chrome: one step ABOVE the canvas in lightness, so the
119
- * panel reads as its own plane rather than a hole in the page. */
120
- --sidebar-background: 244 29% 9%; /* L1 chrome #12111f */
121
- --sidebar-foreground: 240 6% 93%;
122
- --sidebar-primary: 239 84% 67%;
151
+ /* Sidebar tokens — L1 chrome: one step BELOW the canvas here, so the nav reads
152
+ * as chrome the content sits in front of rather than as another card. */
153
+ --sidebar-background: 242 26% 8%; /* L1 chrome #0f0f1a */
154
+ --sidebar-foreground: 225 22% 91%;
155
+ --sidebar-primary: 246 54% 51%;
123
156
  --sidebar-primary-foreground: 0 0% 100%;
124
- --sidebar-accent: 249 24% 16%; /* L3 hover well */
125
- --sidebar-accent-foreground: 240 6% 93%;
126
- --sidebar-border: 243 19.6% 20%; /* = --md3-outline-variant */
127
- --sidebar-ring: 239 84% 67%;
157
+ --sidebar-accent: 240 22% 27%; /* L3 hover well */
158
+ --sidebar-accent-foreground: 225 22% 91%;
159
+ --sidebar-border: 236 20% 35%;
160
+ --sidebar-ring: 233 100% 87%;
128
161
 
129
162
  /* Depth scale — tracks the MD3 ladder (canvas / card / nested / overlay) */
130
- --depth-1: #0a0a14;
131
- --depth-2: #191826;
132
- --depth-3: #221f33;
133
- --depth-4: #2c2942;
163
+ --depth-1: #151523;
164
+ --depth-2: #2d2e43;
165
+ --depth-3: #363654;
166
+ --depth-4: #434365;
134
167
 
135
168
  /* Status colors */
136
169
  --status-running: #10B981;
137
- --status-creating: #9B93F0;
170
+ --status-creating: #A5B4FC;
138
171
  --status-stopped: #FFB800;
139
172
  --status-warm: #FF8A4C;
140
173
  --status-cold: #4AABFF;
@@ -180,32 +213,39 @@
180
213
  --mesh-blue: transparent;
181
214
 
182
215
  /* Brand gradients — the indigo spark, kept ONLY for the logo mark + send button */
183
- --tangle-gradient: linear-gradient(135deg, #6366F1, #818CF8);
184
- --tangle-gradient-text: linear-gradient(135deg, #A5AAFC, #818CF8);
185
- --accent-gradient-strong: linear-gradient(135deg, #6366F1, #818CF8);
216
+ --tangle-gradient: linear-gradient(135deg, #4C3FC5, #A5B4FC);
217
+ --tangle-gradient-text: linear-gradient(135deg, #C7D2FE, #A5B4FC);
218
+ --accent-gradient-strong: linear-gradient(135deg, #4C3FC5, #7C6CF6);
186
219
  /* Accent wells — soft = hover neutral, strong = active/selected row (indigo) */
187
- --accent-surface-soft: rgba(255, 255, 255, 0.04);
188
- --accent-surface-strong: rgba(129, 140, 248, 0.14);
189
- --accent-text: #b9bffb;
220
+ --accent-surface-soft: rgba(165, 180, 252, 0.07);
221
+ --accent-surface-strong: rgba(76, 63, 197, 0.30);
222
+ /* The BRIGHT indigo, and only ever as text: --primary is the fill (it carries
223
+ * white), and at 6.7:1 on the card this is the one that stays legible as ink. */
224
+ --accent-text: #A5B4FC;
190
225
 
191
226
  /* Surfaces — solid fills off the ladder (no radial mesh) */
192
- --bg-root: #0a0a14;
227
+ --bg-root: #151523;
193
228
  --bg-dark: var(--depth-1);
194
229
  --bg-card: var(--depth-2);
195
230
  --bg-elevated: var(--depth-4);
196
231
  --bg-section: var(--depth-1);
197
232
  --bg-input: hsl(var(--hsl-input)); /* the field well — same color, hex-free */
198
- --bg-hover: rgba(255, 255, 255, 0.04);
199
- --bg-selection: rgba(129, 140, 248, 0.14);
233
+ --bg-hover: rgba(165, 180, 252, 0.07);
234
+ --bg-selection: rgba(124, 108, 246, 0.28);
200
235
 
201
236
  /* Text — neutral */
202
- --text-primary: #ececee;
203
- --text-secondary: #c8c8cd;
204
- --text-muted: #9a9aa2;
205
- --text-dim: #6b6b73;
237
+ /* Four steps the eye can actually separate: 10.6 / 7.5 / 5.8 / 4.7 against the
238
+ * L2 card. --text-dim is the faintest tier but it is still TEXT, so it clears
239
+ * the 4.5:1 body floor rather than only the 3:1 large-text one — asserted in
240
+ * tokens.test.ts against both the card and the canvas, since the tier is
241
+ * solved against one surface and rendered on both. */
242
+ --text-primary: #e3e6ed;
243
+ --text-secondary: #bec3d0;
244
+ --text-muted: #a5abbb;
245
+ --text-dim: #9299ab;
206
246
 
207
247
  /* Brand accents — Tangle */
208
- --brand-primary: #6366F1;
248
+ --brand-primary: #7C6CF6;
209
249
  --brand-strong: #2E2A5E;
210
250
  --brand-strong-text: #ffffff;
211
251
  --brand-strong-text-muted: rgba(255, 255, 255, 0.7);
@@ -217,17 +257,17 @@
217
257
  --brand-emerald: #10B981;
218
258
 
219
259
  /* Borders — neutral hairlines */
220
- --border-subtle: rgba(255, 255, 255, 0.05);
221
- --border-default: rgba(255, 255, 255, 0.07);
222
- --border-hover: rgba(255, 255, 255, 0.12);
223
- --border-accent: rgba(129, 140, 248, 0.20);
224
- --border-accent-hover: rgba(129, 140, 248, 0.35);
260
+ --border-subtle: rgba(165, 180, 252, 0.10);
261
+ --border-default: rgba(165, 180, 252, 0.16);
262
+ --border-hover: rgba(165, 180, 252, 0.28);
263
+ --border-accent: rgba(129, 140, 248, 0.35);
264
+ --border-accent-hover: rgba(129, 140, 248, 0.50);
225
265
 
226
266
  /* Buttons */
227
- --btn-primary-bg: #5B4ED4;
228
- --btn-primary-hover: #4F46E5;
267
+ --btn-primary-bg: #4C3FC5;
268
+ --btn-primary-hover: #5A50D6;
229
269
  --btn-primary-text: #ffffff;
230
- --btn-cta-bg: #4F46E5;
270
+ --btn-cta-bg: #4C3FC5;
231
271
  --btn-cta-text: #FFFFFF;
232
272
 
233
273
  /* Code / runtime syntax */
@@ -342,17 +382,17 @@
342
382
  * by shadow and ring, not by fill — which is how light-mode elevation actually
343
383
  * reads. Forcing a strictly-darkening ladder here is what produced the
344
384
  * white-on-white flatness this palette replaced. Pinned in tokens.test.ts. */
345
- --md3-surface: #eceef3; /* L0 page canvas — tinted */
346
- --md3-surface-dim: #dcdee6;
385
+ --md3-surface: #e9e9f1; /* L0 page canvas — neutral grey, BELOW the wells */
386
+ --md3-surface-dim: #d9d9e6;
347
387
  --md3-surface-bright: #ffffff;
348
388
  --md3-surface-container-lowest: #ffffff; /* insets read as paper */
349
- --md3-surface-container-low: #f6f7fa; /* L1 chrome, field wells — well */
389
+ --md3-surface-container-low: #f7f7fa; /* L1 chrome, field wells — well */
350
390
  --md3-surface-container: #ffffff; /* L2 resting card — paper */
351
- --md3-surface-container-high: #f1f2f7; /* L3 nested / hover — well */
391
+ --md3-surface-container-high: #f2f2f7; /* L3 nested / hover — well */
352
392
  --md3-surface-container-highest: #ffffff; /* L4 overlay — paper + shadow */
353
- --md3-surface-variant: #f1f2f7;
354
- --md3-on-surface: #191c24;
355
- --md3-on-surface-variant: #464858;
393
+ --md3-surface-variant: #f2f2f7;
394
+ --md3-on-surface: #2b2c3e;
395
+ --md3-on-surface-variant: #494b5d;
356
396
 
357
397
  --md3-primary: #3E349E;
358
398
  --md3-primary-dim: #352B88;
@@ -370,8 +410,8 @@
370
410
 
371
411
  --md3-tertiary: #065F46;
372
412
  --md3-tertiary-container: #D1FAE5;
373
- --md3-outline: #6B6E8A;
374
- --md3-outline-variant: #C7C6D6;
413
+ --md3-outline: #737696;
414
+ --md3-outline-variant: #cecfde;
375
415
 
376
416
  --md3-error: #ba1a1a;
377
417
  --md3-error-dim: #93000a;
@@ -384,26 +424,36 @@
384
424
  --md3-inverse-on-surface: #f0f1f4;
385
425
  --md3-surface-tint: #6366F1;
386
426
 
387
- /* Semantic HSL bridge — white paper on a tinted canvas */
388
- --hsl-background: 223 23% 94%; /* L0 canvas #eceef3 */
389
- --hsl-foreground: 0 0% 5%; /* #0d0d0d */
427
+ /* Semantic HSL bridge — white paper on a neutral-grey canvas */
428
+ --hsl-background: 240 24% 93%; /* L0 canvas #e9e9f1 */
429
+ /* Ink, not black. Pure #0d0d0d on white paper measures 19.5:1 — a hair off the
430
+ * 21:1 ceiling — and that maximum is what reads as glare rather than as
431
+ * legibility. 20.5% L lands 13.7:1 on the card and 11.4:1 on the canvas: AAA with
432
+ * headroom in both places, and a step the eye reads as text instead of as a
433
+ * hole. */
434
+ --hsl-foreground: 235 18% 20.5%; /* #2b2c3e */
390
435
  --hsl-card: 0 0% 100%; /* L2 card = white paper */
391
- --hsl-card-foreground: 0 0% 5%;
436
+ --hsl-card-foreground: 235 18% 20.5%;
392
437
  --hsl-popover: 0 0% 100%;
393
- --hsl-popover-foreground: 0 0% 5%;
394
- --hsl-primary: 245 62% 57%; /* #5B4ED4 light accent */
438
+ --hsl-popover-foreground: 235 18% 20.5%;
439
+ --hsl-primary: 246 54% 51%; /* #4c3fc6 — carries white at 7.4:1 */
395
440
  --hsl-primary-foreground: 0 0% 100%;
396
- --hsl-secondary: 230 27% 96%; /* L3 #f1f2f7 */
397
- --hsl-secondary-foreground: 0 0% 10%;
398
- --hsl-muted: 230 27% 96%; /* L3 hover well */
399
- --hsl-muted-foreground: 240 5% 38%; /* #5d5d66 text-muted */
400
- --hsl-accent: 230 27% 96%; /* neutral hover */
401
- --hsl-accent-foreground: 0 0% 10%;
441
+ --hsl-secondary: 240 26% 96%; /* L3 #f2f2f7 */
442
+ --hsl-secondary-foreground: 235 18% 20.5%;
443
+ --hsl-muted: 240 26% 96%; /* L3 hover well */
444
+ /* 34% L: the shared components fade captions with /70 and /60, and a
445
+ * translucent foreground renders as the token COMPOSITED over the plane behind
446
+ * it. Against white paper that compositing is brutal — a lighter value puts the
447
+ * /60 caption under the 3:1 large-text floor. This holds 3.0:1 there and 8.6:1
448
+ * at full strength, still a clear step below the 13.7:1 body ink. */
449
+ --hsl-muted-foreground: 235 12% 32.5%; /* #494b5d text-muted */
450
+ --hsl-accent: 240 26% 96%; /* neutral hover */
451
+ --hsl-accent-foreground: 235 18% 20.5%;
402
452
  --hsl-destructive: 0 72% 41%;
403
453
  --hsl-destructive-foreground: 0 0% 100%;
404
- --hsl-border: 244 16.3% 80.8%; /* #c7c6d6 = --md3-outline-variant */
405
- --hsl-input: 225 29% 97%; /* field well #f6f7fa */
406
- --hsl-ring: 245 62% 57%;
454
+ --hsl-border: 238 20% 84%; /* #cecfde = --md3-outline-variant */
455
+ --hsl-input: 240 30% 97%; /* field well */
456
+ --hsl-ring: 246 54% 51%;
407
457
  --hsl-success: 160 84% 30%;
408
458
  --hsl-warning: 41 96% 50%;
409
459
  --hsl-info: 212 80% 50%;
@@ -411,9 +461,9 @@
411
461
  /* depth-2 (card) and depth-4 (elevated) are BOTH paper, on purpose: in light,
412
462
  * a popover does not out-lighten a white card — it lifts by shadow. See the
413
463
  * ladder note above. */
414
- --depth-1: #eceef3; /* canvas */
464
+ --depth-1: #e9e9f1; /* canvas */
415
465
  --depth-2: #ffffff; /* card — paper */
416
- --depth-3: #f1f2f7; /* nested well */
466
+ --depth-3: #f2f2f7; /* nested well */
417
467
  --depth-4: #ffffff; /* elevated — paper, separated by shadow */
418
468
 
419
469
  --status-running: #059669;
@@ -457,20 +507,20 @@
457
507
 
458
508
  /* Sidebar — light, flat surface + hairline */
459
509
  --sidebar-background: 0 0% 100%; /* white paper */
460
- --sidebar-foreground: 0 0% 5%;
461
- --sidebar-primary: 245 62% 57%; /* #5B4ED4 */
510
+ --sidebar-foreground: 235 18% 20.5%;
511
+ --sidebar-primary: 246 54% 51%; /* #4c3fc6 */
462
512
  --sidebar-primary-foreground: 0 0% 100%;
463
- --sidebar-accent: 230 27% 96%; /* L3 hover well */
464
- --sidebar-accent-foreground: 0 0% 10%;
465
- --sidebar-border: 244 16.3% 80.8%; /* = --md3-outline-variant */
466
- --sidebar-ring: 245 62% 57%;
513
+ --sidebar-accent: 240 26% 96%; /* L3 hover well */
514
+ --sidebar-accent-foreground: 235 18% 20.5%;
515
+ --sidebar-border: 238 20% 84%; /* = --md3-outline-variant */
516
+ --sidebar-ring: 246 54% 51%;
467
517
 
468
518
  --mesh-teal: transparent;
469
519
  --mesh-violet: transparent;
470
520
  --mesh-blue: transparent;
471
521
 
472
522
  /* Surfaces — flat single-neutral light */
473
- --bg-root: #eceef3;
523
+ --bg-root: #e9e9f1;
474
524
  --bg-dark: var(--depth-1);
475
525
  --bg-card: var(--depth-2);
476
526
  /* Paper, exactly like --bg-card: in light you cannot raise a plane by making it
@@ -481,13 +531,22 @@
481
531
  --bg-elevated: var(--depth-4);
482
532
  --bg-section: var(--depth-1);
483
533
  --bg-input: hsl(var(--hsl-input)); /* the field well — same color, hex-free */
484
- --bg-hover: rgba(0, 0, 0, 0.04);
485
- --bg-selection: rgba(91, 78, 212, 0.10);
486
-
487
- --text-primary: #0d0d0d;
488
- --text-secondary: #2a2a30;
489
- --text-muted: #5d5d66;
490
- --text-dim: #8e8e98;
534
+ --bg-hover: rgba(76, 63, 198, 0.06);
535
+ --bg-selection: rgba(76, 63, 198, 0.16);
536
+
537
+ /* Mirrors the dark ramp's spacing: 13.7 / 10.5 / 8.6 / 5.6 against white paper.
538
+ * Every step carries the 240 cast so the ink belongs to the palette.
539
+ *
540
+ * --text-dim is pinned from the CANVAS (#e9e9f1), not the white card: it is
541
+ * the faintest tier, so the tinted L0 is the surface that binds it, and a
542
+ * value chosen against paper alone sits under the 4.5:1 body floor there.
543
+ * 4.6 on the canvas, 5.6 on the card — both floors are asserted in
544
+ * tokens.test.ts, because a ratio quoted in a comment drifts the moment a
545
+ * surface moves and nothing catches it. */
546
+ --text-primary: #2b2c3e;
547
+ --text-secondary: #3d3e52;
548
+ --text-muted: #494b5d;
549
+ --text-dim: #65677b;
491
550
 
492
551
  --brand-primary: #6366F1;
493
552
  --brand-strong: #2E2A5E;
@@ -500,16 +559,16 @@
500
559
  --brand-vibrant: #6366F1;
501
560
  --brand-emerald: #059669;
502
561
 
503
- --border-subtle: rgba(0, 0, 0, 0.06);
504
- --border-default: rgba(0, 0, 0, 0.08);
505
- --border-hover: rgba(0, 0, 0, 0.13);
562
+ --border-subtle: rgba(60, 52, 140, 0.10);
563
+ --border-default: rgba(60, 52, 140, 0.14);
564
+ --border-hover: rgba(60, 52, 140, 0.24);
506
565
  --border-accent: rgba(91, 78, 212, 0.18);
507
566
  --border-accent-hover: rgba(91, 78, 212, 0.35);
508
567
 
509
- --btn-primary-bg: #5B4ED4;
510
- --btn-primary-hover: #4F46E5;
568
+ --btn-primary-bg: #4C3FC5;
569
+ --btn-primary-hover: #5A50D6;
511
570
  --btn-primary-text: #ffffff;
512
- --btn-cta-bg: #5B4ED4;
571
+ --btn-cta-bg: #4C3FC5;
513
572
  --btn-cta-text: #ffffff;
514
573
 
515
574
  --code-keyword: #3E349E;
@@ -530,9 +589,9 @@
530
589
  --tangle-gradient: linear-gradient(135deg, #4F46E5, #5B4ED4);
531
590
  --tangle-gradient-text: linear-gradient(135deg, #4F46E5, #5B4ED4);
532
591
  --accent-gradient-strong: linear-gradient(135deg, #4F46E5, #5B4ED4);
533
- --accent-surface-soft: rgba(0, 0, 0, 0.04);
534
- --accent-surface-strong: rgba(91, 78, 212, 0.10);
535
- --accent-text: #4F46E5;
592
+ --accent-surface-soft: rgba(76, 63, 198, 0.05);
593
+ --accent-surface-strong: rgba(76, 63, 198, 0.13);
594
+ --accent-text: #5047eb;
536
595
 
537
596
  /* Typography — unified on Inter */
538
597
  --font-sans: "Inter", "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
@@ -32,17 +32,36 @@ const DARK = blockIn(tokens, ".dark");
32
32
  const LIGHT = blockIn(tokens, ".light");
33
33
 
34
34
  describe("canonical dark spine", () => {
35
- it("is indigo-cast, not grey — the canvas carries real saturation", () => {
35
+ it("keeps the canvas in the brand's hue family, at any strength", () => {
36
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.
37
40
  expect(bg.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
38
- 235,
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
+ });
54
+
55
+ it("guards reading comfort at the INK, not at the canvas", () => {
56
+ // A deep canvas is fine — glare comes from the ratio between ink and ground,
57
+ // not from the ground alone. So the floor here is loose, and the real guard
58
+ // is the 16:1 comfort ceiling on body text in contrast.test.ts. Pinning the
59
+ // canvas instead would forbid a deep spine that is perfectly comfortable
60
+ // once its ink is tuned.
61
+ const bg = hslIn(DARK, "hsl-background");
62
+ expect(bg.l, "an unlit canvas still needs to be a surface").toBeGreaterThan(
63
+ 3,
39
64
  );
40
- expect(bg.h).toBeLessThanOrEqual(250);
41
- // The flat spine this replaced sat at 7%. Anything near that reads grey.
42
- expect(
43
- bg.s,
44
- "a desaturated canvas is the bug, not the design",
45
- ).toBeGreaterThan(20);
46
65
  });
47
66
 
48
67
  it("lifts the card off the canvas by fill, not by border", () => {
@@ -101,6 +120,101 @@ describe("muted text stays legible at the opacities the UI actually uses", () =>
101
120
  }
102
121
  });
103
122
 
123
+ describe("the sidebar is its own plane", () => {
124
+ // Which SIDE of the canvas the nav sits on is a taste call — it is chrome
125
+ // below the content in dark and paper above it in light, and either reads
126
+ // fine. What is not a taste call is the two collapsing onto the same value:
127
+ // the nav would stop being a distinct region and the page would lose the edge
128
+ // that separates navigation from content, with no border to fall back on.
129
+ // Pinned as a minimum separation so a retune can move it without erasing it.
130
+ for (const [theme, spine] of [
131
+ ["dark", DARK],
132
+ ["light", LIGHT],
133
+ ] as const) {
134
+ it(`${theme}: sidebar and canvas stay separable`, () => {
135
+ const sidebar = hslIn(spine, "sidebar-background");
136
+ const canvas = hslIn(spine, "hsl-background");
137
+ expect(
138
+ Math.abs(sidebar.l - canvas.l),
139
+ "sidebar and canvas lightness must not converge",
140
+ ).toBeGreaterThanOrEqual(2);
141
+ });
142
+ }
143
+ });
144
+
145
+ describe("a hex quoted beside an HSL token is the value that token resolves to", () => {
146
+ // The spine is authored in HSL, but it is READ in hex — every comment here
147
+ // annotates a triple with the colour it produces, because `238 20% 22%` tells
148
+ // a reader nothing. That makes the annotations load-bearing documentation, and
149
+ // load-bearing documentation that nothing checks is documentation that lies:
150
+ // retuning a triple leaves the old hex sitting beside it, and the next person
151
+ // to trust it picks a neighbouring value against a surface that moved.
152
+ //
153
+ // Every declaration of the form `--token: H S% L%; /* … #rrggbb … */`, in every
154
+ // block, not just the two spines.
155
+ const ANNOTATED =
156
+ /^[^\S\n]*(--[a-z0-9-]+):\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%;[^\S\n]*\/\*([^*]*)\*\//gm;
157
+
158
+ const annotated = [...tokens.matchAll(ANNOTATED)].flatMap(
159
+ ([, token, h, s, l, comment]) =>
160
+ (comment.match(/#[0-9a-fA-F]{6}/g) ?? []).map((quoted) => ({
161
+ token,
162
+ quoted,
163
+ hsl: { h: Number(h), s: Number(s), l: Number(l) },
164
+ })),
165
+ );
166
+
167
+ it("finds the annotations to check", () => {
168
+ // Without this, rewriting a comment into a shape the pattern misses would
169
+ // empty the suite below and read as a pass.
170
+ expect(annotated.length).toBeGreaterThanOrEqual(15);
171
+ });
172
+
173
+ for (const { token, quoted, hsl } of annotated) {
174
+ it(`${token} resolves to ${quoted}`, () => {
175
+ const actual = `#${hslToRgb(hsl)
176
+ .map((c) => Math.round(c).toString(16).padStart(2, "0"))
177
+ .join("")}`;
178
+ expect(actual).toBe(quoted.toLowerCase());
179
+ });
180
+ }
181
+ });
182
+
183
+ describe("the faint ink tiers are still TEXT, on every plane they land on", () => {
184
+ // These two tiers are the ones that break. They are solved against a specific
185
+ // surface, so any move in the ladder — lifting the card, softening the canvas,
186
+ // recasting the hue — silently drops them under the floor, and nothing about
187
+ // the result LOOKS wrong: it renders as slightly faint captions that happen to
188
+ // be unreadable. Every plane is asserted, not just the one each was tuned on.
189
+ //
190
+ // Compared hex-to-hex through the MD3 ladder rather than the HSL bridge,
191
+ // because these tokens ARE hexes; `--md3-surface` and `--md3-surface-container`
192
+ // are the canvas and the card at the same positions.
193
+ const ratioOf = (a: number, b: number) =>
194
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
195
+
196
+ for (const [theme, spine] of [
197
+ ["dark", DARK],
198
+ ["light", LIGHT],
199
+ ] as const) {
200
+ for (const tier of ["text-dim", "text-muted"] as const) {
201
+ it(`${theme}: --${tier} clears 4.5:1 on both the card and the canvas`, () => {
202
+ expect(spine, `--${tier} must be defined`).toContain(`--${tier}:`);
203
+ const ink = hexRelativeLuminanceIn(spine, tier);
204
+ for (const [plane, token] of [
205
+ ["card", "md3-surface-container"],
206
+ ["canvas", "md3-surface"],
207
+ ] as const) {
208
+ expect(
209
+ ratioOf(ink, hexRelativeLuminanceIn(spine, token)),
210
+ `--${tier} on the ${plane}`,
211
+ ).toBeGreaterThanOrEqual(4.5);
212
+ }
213
+ });
214
+ }
215
+ }
216
+ });
217
+
104
218
  describe("input tokens carry two DIFFERENT roles and must not be conflated", () => {
105
219
  // `--input` is shadcn's input BORDER and the Switch off-track: a mid-tone that
106
220
  // must stay visible AGAINST a surface. `--hsl-input` / `--bg-input` are the
@@ -129,7 +243,7 @@ describe("input tokens carry two DIFFERENT roles and must not be conflated", ()
129
243
  });
130
244
 
131
245
  describe("canonical light spine", () => {
132
- it("is white paper on a TINTED canvas, never white-on-white", () => {
246
+ it("is white paper on a tinted canvas, never white-on-white", () => {
133
247
  const canvas = hslIn(LIGHT, "hsl-background");
134
248
  const card = hslIn(LIGHT, "hsl-card");
135
249
  expect(card.l, "the card is paper").toBe(100);
@@ -137,10 +251,17 @@ describe("canonical light spine", () => {
137
251
  canvas.l,
138
252
  "a pure-white canvas gives a white card nothing to lift off",
139
253
  ).toBeLessThan(97);
140
- expect(
141
- canvas.s,
142
- "the canvas carries a tint, not flat grey",
143
- ).toBeGreaterThan(10);
254
+ // Mirrors the dark spine exactly: HUE is the brand constraint and is pinned;
255
+ // saturation is a taste dial and is not. How far the surfaces lean is a live
256
+ // product decision, so asserting a band here only guarantees this test fails
257
+ // the next time that decision is revisited.
258
+ expect(canvas.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
259
+ 215,
260
+ );
261
+ expect(canvas.h).toBeLessThanOrEqual(260);
262
+ expect(canvas.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
263
+ 5,
264
+ );
144
265
  });
145
266
 
146
267
  it("ALTERNATES paper and well — light elevation is not a darkening ramp", () => {