@tangle-network/brand 1.28.0 → 1.29.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,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.29.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 4678ab2: The canonical spine (`:root`, `.dark`, `.light`, `data-theme` `dark`, `light`
8
+ and `tangle-dark`) has a real surface ladder. `--hsl-raised` is now the spine's
9
+ L3 plane and `--hsl-raised-2` its L4 plane, instead of both repeating the card,
10
+ and every adjacent plane and well is at least 4.4 OKLCH L apart (gated in
11
+ `surface-ladder.test.ts`):
12
+
13
+ - Dark: page `#0a0a14`, card `#191826`, raised `#252237`, raised-2 and popover
14
+ `#312e47`. A field in a raised card takes the card colour instead of the page's
15
+ near-black well, and dark cards and menus carry the 1px top highlight
16
+ (`--edge-highlight`).
17
+ - Light: page `#eceef3`, card white, raised `#edeff4`, raised-2 white. A field in
18
+ a raised card is white.
19
+
20
+ `--hsl-secondary` and `--hsl-popover` moved with the ladder (dark `#221f33` to
21
+ `#252237`, `#2c2942` to `#312e47`; light `#f2f3f8` to `#edeff4`). Named themes
22
+ other than Hospitality keep their flat ladder.
23
+
24
+ One hairline: `--border-subtle` and `--border-default` now alias `--hsl-border`
25
+ in the spine, so every divider, card edge and table rule renders one colour. The
26
+ dark hairline is `#37364a` (1.5:1 on the card, as documented; it measured 1.2:1).
27
+ `--glass-border-hover` now follows `--border-hover`.
28
+
29
+ The `--font-size-xs`, `-sm`, `-base` and `-lg` tokens now equal Tailwind's
30
+ `text-xs`, `text-sm`, `text-base` and `text-lg` (12, 14, 16 and 18px; they were
31
+ 11, 12, 15 and 16px), so `sm` means 14px in CSS and in a class. `text-eyebrow`,
32
+ the `Heading` eyebrow and subsection variants and `.tangle-api-meta` keep their
33
+ rendered sizes on the new steps. Code that relied on the old values reads
34
+ `--font-size-xs` for 12px captions and `--font-size-base` for 16px emphasis.
35
+
3
36
  ## 1.28.0
4
37
 
5
38
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -54,7 +54,7 @@ export declare const palettes: {
54
54
  readonly canvas: "#eceef3";
55
55
  readonly card: "#ffffff";
56
56
  readonly well: "#f5f6fa";
57
- readonly nested: "#f2f3f8";
57
+ readonly nested: "#edeff4";
58
58
  readonly overlay: "#ffffff";
59
59
  readonly deepest: "#ffffff";
60
60
  readonly hairline: "#c8c7d6";
@@ -87,10 +87,10 @@ export declare const palettes: {
87
87
  readonly canvas: "#0a0a14";
88
88
  readonly card: "#191826";
89
89
  readonly well: "#11101e";
90
- readonly nested: "#221f33";
91
- readonly overlay: "#2c2942";
90
+ readonly nested: "#252237";
91
+ readonly overlay: "#312e47";
92
92
  readonly deepest: "#040408";
93
- readonly hairline: "#2a293d";
93
+ readonly hairline: "#37364a";
94
94
  readonly ink: "#e6e6e6";
95
95
  readonly inkSecondary: "#c7c7c7";
96
96
  readonly inkMuted: "#b5b5b5";
package/dist/index.js CHANGED
@@ -95,7 +95,7 @@ const palettes = {
95
95
  canvas: "#eceef3",
96
96
  card: "#ffffff",
97
97
  well: "#f5f6fa",
98
- nested: "#f2f3f8",
98
+ nested: "#edeff4",
99
99
  overlay: "#ffffff",
100
100
  deepest: "#ffffff",
101
101
  hairline: "#c8c7d6",
@@ -128,10 +128,10 @@ const palettes = {
128
128
  canvas: "#0a0a14",
129
129
  card: "#191826",
130
130
  well: "#11101e",
131
- nested: "#221f33",
132
- overlay: "#2c2942",
131
+ nested: "#252237",
132
+ overlay: "#312e47",
133
133
  deepest: "#040408",
134
- hairline: "#2a293d",
134
+ hairline: "#37364a",
135
135
  ink: "#e6e6e6",
136
136
  inkSecondary: "#c7c7c7",
137
137
  inkMuted: "#b5b5b5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "1.28.0",
3
+ "version": "1.29.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",
@@ -43,7 +43,7 @@ export const palettes = {
43
43
  canvas: "#eceef3",
44
44
  card: "#ffffff",
45
45
  well: "#f5f6fa",
46
- nested: "#f2f3f8",
46
+ nested: "#edeff4",
47
47
  overlay: "#ffffff",
48
48
  deepest: "#ffffff",
49
49
  hairline: "#c8c7d6",
@@ -76,10 +76,10 @@ export const palettes = {
76
76
  canvas: "#0a0a14",
77
77
  card: "#191826",
78
78
  well: "#11101e",
79
- nested: "#221f33",
80
- overlay: "#2c2942",
79
+ nested: "#252237",
80
+ overlay: "#312e47",
81
81
  deepest: "#040408",
82
- hairline: "#2a293d",
82
+ hairline: "#37364a",
83
83
  ink: "#e6e6e6",
84
84
  inkSecondary: "#c7c7c7",
85
85
  inkMuted: "#b5b5b5",
@@ -1,6 +1,6 @@
1
1
  /* GENERATED by scripts/gen-compat.mjs. Do not edit.
2
2
  * Source: tokens.css + named-themes.css
3
- * SHA-256: 2ba9485893147231527707a89a66e3898a9f3bcdce5d5acc6afe07ffcc570d3e
3
+ * SHA-256: 0a80a683644d15e5e56e938a3094142b9f1a2dd6f3fe136154af4b6bc7c033e0
4
4
  * Light-default compatibility; all explicit modes remain canonical.
5
5
  */
6
6
  :where(:root),
@@ -27,8 +27,8 @@
27
27
  --hsl-background: 240 33% 6%;
28
28
  --hsl-input: 244 29% 9%;
29
29
  --hsl-card: 244 23% 12%;
30
- --hsl-secondary: 249 24% 16%;
31
- --hsl-popover: 247 23% 21%;
30
+ --hsl-secondary: 249 24% 17.5%;
31
+ --hsl-popover: 247 21% 23%;
32
32
  --hsl-muted: var(--hsl-secondary);
33
33
  --hsl-accent: var(--hsl-secondary);
34
34
  --md3-surface-dim: #040408;
@@ -69,11 +69,11 @@
69
69
  --md3-on-surface: var(--text-primary);
70
70
  --md3-on-surface-variant: var(--text-muted);
71
71
  --md3-outline: #8e8e8e;
72
- --hsl-border: 243 20% 20%;
72
+ --hsl-border: 244 16% 25%;
73
73
  --md3-outline-variant: hsl(var(--hsl-border));
74
74
  --sidebar-border: var(--hsl-border);
75
- --border-subtle: rgba(255, 255, 255, 0.10);
76
- --border-default: rgba(255, 255, 255, 0.16);
75
+ --border-subtle: hsl(var(--hsl-border));
76
+ --border-default: hsl(var(--hsl-border));
77
77
  --border-hover: rgba(255, 255, 255, 0.28);
78
78
  --hsl-primary: 246 54% 51%;
79
79
  --hsl-primary-foreground: 0 0% 100%;
@@ -250,7 +250,7 @@
250
250
  --glass-bg: var(--depth-2);
251
251
  --glass-bg-strong: var(--depth-3);
252
252
  --glass-border: var(--border-subtle);
253
- --glass-border-hover: var(--border-default);
253
+ --glass-border-hover: var(--border-hover);
254
254
  --glass-blur: 0px;
255
255
  --mesh-teal: transparent;
256
256
  --mesh-violet: transparent;
@@ -311,10 +311,10 @@
311
311
  --shell-inset-gutter: 0.5rem;
312
312
  --transcript-font-size: 1rem;
313
313
  --transcript-line-height: var(--line-height-base);
314
- --font-size-xs: 0.6875rem;
315
- --font-size-sm: 0.75rem;
316
- --font-size-base: 0.9375rem;
317
- --font-size-lg: 1rem;
314
+ --font-size-xs: 0.75rem;
315
+ --font-size-sm: 0.875rem;
316
+ --font-size-base: 1rem;
317
+ --font-size-lg: 1.125rem;
318
318
  --font-size-xl: 1.25rem;
319
319
  --font-size-2xl: 1.5rem;
320
320
  --font-size-3xl: 1.875rem;
@@ -408,7 +408,7 @@
408
408
  --hsl-background: 223 23% 94%;
409
409
  --hsl-input: 225 29% 97%;
410
410
  --hsl-card: 0 0% 100%;
411
- --hsl-secondary: 230 27% 96%;
411
+ --hsl-secondary: 228 25% 94.5%;
412
412
  --hsl-popover: var(--hsl-card);
413
413
  --md3-surface-dim: #dcdee6;
414
414
  --md3-surface-container-lowest: hsl(var(--hsl-card));
@@ -423,8 +423,6 @@
423
423
  --text-dim: #686868;
424
424
  --md3-outline: #787878;
425
425
  --hsl-border: 244 16% 81%;
426
- --border-subtle: rgba(0, 0, 0, 0.10);
427
- --border-default: rgba(0, 0, 0, 0.14);
428
426
  --border-hover: rgba(0, 0, 0, 0.24);
429
427
  --hsl-ring: var(--hsl-primary);
430
428
  --brand-primary: #6366F1;
@@ -558,17 +556,17 @@
558
556
  [data-sandbox-ui],
559
557
  [data-sandbox-theme],
560
558
  [data-theme] {
561
- --hsl-raised: var(--hsl-card);
562
- --hsl-raised-2: var(--hsl-card);
559
+ --hsl-raised: var(--hsl-secondary);
560
+ --hsl-raised-2: var(--hsl-popover);
563
561
  --well-page: color-mix(in oklab, hsl(var(--hsl-background)) 70%, black);
564
562
  --well-card: var(--well-page);
565
- --well-raised: var(--well-page);
566
- --well-raised-2: var(--well-page);
563
+ --well-raised: hsl(var(--hsl-card));
564
+ --well-raised-2: var(--well-raised);
567
565
  --well: var(--well-page);
568
566
  --field-border: color-mix(in oklab, hsl(var(--hsl-border)), hsl(var(--hsl-foreground)) 45%);
569
567
  --shadow-raised: var(--shadow-card);
570
568
  --shadow-well: 0 0 0 0 transparent;
571
- --edge-highlight: 0 0 0 0 transparent;
569
+ --edge-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
572
570
  --ix-hover: 0 0 0 1px hsl(var(--hsl-ring) / 0.45), 0 0 0 4px hsl(var(--hsl-ring) / 0.16);
573
571
  --ix-hover-border: hsl(var(--hsl-ring) / 0.7);
574
572
  --ix-press: 0 0 0 1px hsl(var(--hsl-ring) / 0.95), 0 0 0 4px hsl(var(--hsl-ring) / 0.3);
@@ -589,8 +587,15 @@
589
587
  --ix-hover-bg: hsl(var(--hsl-ring) / 0.07);
590
588
  --well-page: hsl(var(--hsl-card));
591
589
  --well-card: hsl(var(--hsl-background));
592
- --well-raised: hsl(var(--hsl-background));
593
- --well-raised-2: hsl(var(--hsl-background));
590
+ --well-raised-2: var(--well-card);
591
+ --edge-highlight: 0 0 0 0 transparent;
592
+ }
593
+ [data-theme="aubergine"], [data-theme="aubergine-light"], [data-theme="arena"], [data-theme="arena-light"], [data-theme="super"], [data-theme="super-light"], [data-theme="tangle-light"], [data-theme="agents"], [data-theme="agents-light"], [data-theme="website"], [data-theme="intelligence"] {
594
+ --hsl-raised: var(--hsl-card);
595
+ --hsl-raised-2: var(--hsl-card);
596
+ --well-raised: var(--well-card);
597
+ --well-raised-2: var(--well-card);
598
+ --edge-highlight: 0 0 0 0 transparent;
594
599
  }
595
600
  [data-slot="card"] :where(*) {
596
601
  --card: var(--hsl-raised);
@@ -746,7 +751,7 @@
746
751
  --text-hero: var(--font-size-hero);
747
752
  --text-page: var(--font-size-3xl);
748
753
  --text-section: var(--font-size-xl);
749
- --text-eyebrow: var(--font-size-sm);
754
+ --text-eyebrow: var(--font-size-xs);
750
755
  --text-control-sm: var(--control-text-sm);
751
756
  --text-control-md: var(--control-text-md);
752
757
  --text-control-lg: var(--control-text-lg);
@@ -18,7 +18,7 @@
18
18
  --text-hero: var(--font-size-hero);
19
19
  --text-page: var(--font-size-3xl);
20
20
  --text-section: var(--font-size-xl);
21
- --text-eyebrow: var(--font-size-sm);
21
+ --text-eyebrow: var(--font-size-xs);
22
22
  --text-control-sm: var(--control-text-sm);
23
23
  --text-control-md: var(--control-text-md);
24
24
  --text-control-lg: var(--control-text-lg);
@@ -1,7 +1,7 @@
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, blocksIn, contrastRatio, declIn, hexIn, hslIn, hslToRgb, oklchLightness } from "./css-test-utils";
4
+ import { blockIn, blocksIn, contrastRatio, declIn, hexIn, hslIn, hslToRgb, oklchLightness, resolveIn } from "./css-test-utils";
5
5
 
6
6
  /**
7
7
  * The surface ladder and the interaction palette.
@@ -171,14 +171,13 @@ describe("shared surface context and interaction tokens (tokens.css)", () => {
171
171
  }
172
172
  });
173
173
 
174
- it("defaults the raised planes and shadows to the plain spine (wells: the canonical suite below)", () => {
175
- expect(declIn(body, "hsl-raised")).toBe("var(--hsl-card)");
176
- expect(declIn(body, "hsl-raised-2")).toBe("var(--hsl-card)");
174
+ it("takes the raised planes from the spine's own L3 and L4 (values: the canonical suite below)", () => {
175
+ expect(declIn(body, "hsl-raised")).toBe("var(--hsl-secondary)");
176
+ expect(declIn(body, "hsl-raised-2")).toBe("var(--hsl-popover)");
177
177
  expect(declIn(body, "well")).toBe("var(--well-page)");
178
178
  expect(declIn(body, "shadow-raised")).toBe("var(--shadow-card)");
179
179
  // A shadow token joins a comma list; "none" would void the list.
180
180
  expect(declIn(body, "shadow-well")).toBe("0 0 0 0 transparent");
181
- expect(declIn(body, "edge-highlight")).toBe("0 0 0 0 transparent");
182
181
  });
183
182
 
184
183
  it("derives the four interaction states from the focus ring at the specified alphas", () => {
@@ -223,19 +222,20 @@ describe("shared surface context and interaction tokens (tokens.css)", () => {
223
222
  });
224
223
  });
225
224
 
226
- describe("Canonical surface ladder (every theme without its own ladder)", () => {
227
- // The spine's shared --bg-input sat +3.1 L above the dark page and -2.6 L under
228
- // the white card, so a field read as the plane it was on. The defaults derive
229
- // wells and the field border from each theme's own page, card, hairline and ink.
230
- const withWells = (selector: string) => {
225
+ describe("Canonical surface ladder (the spine: :root, .dark, .light, dark, light, tangle-dark)", () => {
226
+ // Drew, 2026-10-26, on a Discovery run page built from the spine: "no color
227
+ // depth ladder". Raised levels painted the card and every well was one value,
228
+ // so a card in a card, and a field in it, read as the plane they sat on.
229
+ const css = rules(tokens);
230
+ const ladderRule = (selector: string) => {
231
231
  const block = blocksIn(tokens, selector).find((b) => /--well-page:/.test(b));
232
- if (!block) throw new Error(`no well defaults under ${selector}`);
232
+ if (!block) throw new Error(`no ladder rule under ${selector}`);
233
233
  return block;
234
234
  };
235
- const defaults = withWells("[data-theme]");
236
- const lightDefaults = withWells('[data-theme$="-light"]');
237
- const DARK = blockIn(tokens, ".dark");
238
- const LIGHT = blockIn(tokens, ".light");
235
+ const shared = ladderRule("[data-theme]");
236
+ const lightRule = ladderRule('[data-theme$="-light"]');
237
+ const DARK = [shared, blockIn(tokens, ".dark")];
238
+ const LIGHT = [lightRule, shared, blockIn(tokens, ".light")];
239
239
 
240
240
  type Rgb = [number, number, number];
241
241
  const toLab = (rgb: Rgb) => {
@@ -255,37 +255,104 @@ describe("Canonical surface ladder (every theme without its own ladder)", () =>
255
255
  };
256
256
  /** `color-mix(in oklab, a, b <t>)`. */
257
257
  const mix = (a: Rgb, b: Rgb, t: number) => fromLab(toLab(a).map((v, i) => v * (1 - t) + toLab(b)[i] * t));
258
- const spine = (css: string, token: string) => hslToRgb(hslIn(css, token)) as Rgb;
258
+ /** A token as the browser paints it, including the `page + 30% black` well. */
259
+ const rgb = (sources: string[], token: string): Rgb => {
260
+ const recess = /^color-mix\(in oklab, hsl\(var\(--([a-z0-9-]+)\)\) (\d+)%, black\)$/.exec(resolveIn(sources, token));
261
+ if (recess) return mix(rgb(sources, recess[1]!), [0, 0, 0], 1 - Number(recess[2]) / 100);
262
+ return hexIn(sources, token);
263
+ };
264
+ const Lof = (sources: string[], token: string) => oklchLightness(rgb(sources, token));
265
+
266
+ const ladder: Array<[role: string, token: string, light: number, dark: number]> = [
267
+ ["page", "hsl-background", 94.9, 15.0],
268
+ ["card", "hsl-card", 100, 21.7],
269
+ ["raised", "hsl-raised", 95.2, 26.6],
270
+ ["raised-2", "hsl-raised-2", 100, 31.6],
271
+ ["popover", "hsl-popover", 100, 31.6],
272
+ ["well on page", "well-page", 100, 10.2],
273
+ ["well in card", "well-card", 94.9, 10.2],
274
+ ["well in raised", "well-raised", 100, 21.7],
275
+ ["well in raised-2", "well-raised-2", 94.9, 21.7],
276
+ ["hairline", "hsl-border", 83.5, 34.1],
277
+ ];
278
+ for (const [role, token, lightL, darkL] of ladder) {
279
+ it(`${role} (--${token}) lands at L ${lightL} light / ${darkL} dark`, () => {
280
+ expect(Lof(LIGHT, token)).toBeCloseTo(lightL, 0);
281
+ expect(Lof(DARK, token)).toBeCloseTo(darkL, 0);
282
+ });
283
+ }
259
284
 
260
- it("declares the derivations this suite measures", () => {
261
- expect(declIn(defaults, "well-page")).toBe("color-mix(in oklab, hsl(var(--hsl-background)) 70%, black)");
262
- expect(declIn(defaults, "field-border")).toBe("color-mix(in oklab, hsl(var(--hsl-border)), hsl(var(--hsl-foreground)) 45%)");
263
- expect(declIn(lightDefaults, "well-page")).toBe("hsl(var(--hsl-card))");
264
- for (const token of ["well-card", "well-raised", "well-raised-2"]) {
265
- expect(declIn(defaults, token)).toBe("var(--well-page)");
266
- expect(declIn(lightDefaults, token)).toBe("hsl(var(--hsl-background))");
285
+ const steps: Array<[child: string, parent: string]> = [
286
+ ["hsl-card", "hsl-background"],
287
+ ["hsl-raised", "hsl-card"],
288
+ ["hsl-raised-2", "hsl-raised"],
289
+ ["well-page", "hsl-background"],
290
+ ["well-card", "hsl-card"],
291
+ ["well-raised", "hsl-raised"],
292
+ ["well-raised-2", "hsl-raised-2"],
293
+ ];
294
+ it.each([
295
+ ["light", LIGHT],
296
+ ["dark", DARK],
297
+ ])("steps every plane and well at least 4.4 L off its parent in %s", (_mode, sources) => {
298
+ for (const [child, parent] of steps) {
299
+ expect(Math.abs(Lof(sources, child) - Lof(sources, parent)), `${child} on ${parent}`).toBeGreaterThanOrEqual(4.4);
267
300
  }
268
301
  });
269
302
 
270
- it("recesses the dark well at least 4 L below the page and the card", () => {
271
- const well = mix(spine(DARK, "hsl-background"), [0, 0, 0], 0.3);
272
- expect(oklchLightness(spine(DARK, "hsl-background")) - oklchLightness(well)).toBeGreaterThanOrEqual(4);
273
- expect(oklchLightness(spine(DARK, "hsl-card")) - oklchLightness(well)).toBeGreaterThanOrEqual(10);
303
+ it("sinks every dark well below the plane it sits on, and lifts dark planes with a top highlight", () => {
304
+ for (const [well, parent] of steps.filter(([child]) => child.startsWith("well"))) {
305
+ expect(Lof(DARK, well), `${well} under ${parent}`).toBeLessThan(Lof(DARK, parent));
306
+ }
307
+ expect(declIn(shared, "edge-highlight")).toBe("inset 0 1px 0 rgba(255, 255, 255, 0.06)");
308
+ expect(declIn(lightRule, "edge-highlight")).toBe("0 0 0 0 transparent");
274
309
  });
275
310
 
276
- it("steps the light well 4 L or more off the page and the card", () => {
277
- const page = oklchLightness(spine(LIGHT, "hsl-background"));
278
- const card = oklchLightness(spine(LIGHT, "hsl-card"));
279
- expect(card - page).toBeGreaterThanOrEqual(4);
311
+ it("draws every line with one hairline: 1.5:1 on the dark card and still seen on the raised plane", () => {
312
+ for (const sources of [LIGHT, DARK]) {
313
+ for (const alias of ["border-subtle", "border-default", "md3-outline-variant"]) {
314
+ expect(hexIn(sources, alias), `--${alias}`).toEqual(hexIn(sources, "hsl-border"));
315
+ }
316
+ }
317
+ expect(contrastRatio(rgb(DARK, "hsl-border"), rgb(DARK, "hsl-card"))).toBeGreaterThanOrEqual(1.45);
318
+ expect(contrastRatio(rgb(DARK, "hsl-border"), rgb(DARK, "hsl-raised"))).toBeGreaterThanOrEqual(1.25);
319
+ expect(contrastRatio(rgb(LIGHT, "hsl-border"), rgb(LIGHT, "hsl-background"))).toBeGreaterThanOrEqual(1.4);
280
320
  });
281
321
 
282
322
  it.each([
283
- ["dark", () => DARK],
284
- ["light", () => LIGHT],
285
- ])("draws a field border that clears 3:1 on the card and the page in %s (WCAG 1.4.11)", (_mode, css) => {
286
- const border = mix(spine(css(), "hsl-border"), spine(css(), "hsl-foreground"), 0.45);
287
- expect(contrastRatio(border, spine(css(), "hsl-card")), "on the card").toBeGreaterThanOrEqual(3);
288
- expect(contrastRatio(border, spine(css(), "hsl-background")), "on the page").toBeGreaterThanOrEqual(3);
323
+ ["dark", DARK],
324
+ ["light", LIGHT],
325
+ ])("draws a field border that clears 3:1 on the page, card and raised plane in %s (WCAG 1.4.11)", (_mode, sources) => {
326
+ expect(declIn(shared, "field-border")).toBe("color-mix(in oklab, hsl(var(--hsl-border)), hsl(var(--hsl-foreground)) 45%)");
327
+ const border = mix(rgb(sources, "hsl-border"), rgb(sources, "hsl-foreground"), 0.45);
328
+ for (const plane of ["hsl-background", "hsl-card", "hsl-raised"]) {
329
+ expect(contrastRatio(border, rgb(sources, plane)), `on --${plane}`).toBeGreaterThanOrEqual(3);
330
+ }
331
+ });
332
+
333
+ it("keeps every named theme with its own planes on the flat ladder until it is tuned", () => {
334
+ // The canonical ladder is the spine's indigo planes; a named theme that
335
+ // inherited it would nest an indigo card inside its own card.
336
+ const flat = css.match(/([^{}]*)\{\s*--hsl-raised:\s*var\(--hsl-card\);[^}]*\}/);
337
+ expect(flat, "the named-theme flat rule").not.toBeNull();
338
+ const flatBody = flat![0];
339
+ for (const [token, value] of [
340
+ ["hsl-raised", "var(--hsl-card)"],
341
+ ["hsl-raised-2", "var(--hsl-card)"],
342
+ ["well-raised", "var(--well-card)"],
343
+ ["well-raised-2", "var(--well-card)"],
344
+ ["edge-highlight", "0 0 0 0 transparent"],
345
+ ]) {
346
+ expect(declIn(flatBody, token!), `--${token}`).toBe(value);
347
+ }
348
+ const names = (selectors: string) => [...selectors.matchAll(/data-theme="([^"]+)"/g)].map((m) => m[1]);
349
+ const ownPlanes = new Set(
350
+ [...rules(themes).matchAll(/([^{}]*)\{([^{}]*)\}/g)]
351
+ .filter((m) => m[2]!.includes("--hsl-background:"))
352
+ .flatMap((m) => names(m[1]!)),
353
+ );
354
+ const tuned = new Set(["hospitality"]);
355
+ expect(new Set(names(flat![1]!))).toEqual(new Set([...ownPlanes].filter((name) => !tuned.has(name!))));
289
356
  });
290
357
  });
291
358
 
@@ -66,7 +66,7 @@
66
66
  --text-hero: var(--font-size-hero);
67
67
  --text-page: var(--font-size-3xl);
68
68
  --text-section: var(--font-size-xl);
69
- --text-eyebrow: var(--font-size-sm);
69
+ --text-eyebrow: var(--font-size-xs);
70
70
 
71
71
  /* Control and form type: text-control-sm/md/lg, text-label, text-help. */
72
72
  --text-control-sm: var(--control-text-sm);
@@ -1,10 +1,11 @@
1
1
  /* Tangle design tokens — the single palette every Tangle surface renders from.
2
2
  *
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.5: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.
3
+ * The surface ladder is near-neutral grey with a faint indigo cast; the indigo
4
+ * accent is trim, never field. Each plane steps at least 4.4 OKLCH L off the
5
+ * plane it sits on (surface-ladder.test.ts), so a card, a card in a card and a
6
+ * field each read by their own fill. Dark adds a 1.5:1 hairline and a top edge
7
+ * highlight, because a black shadow on a dark canvas renders as nothing; light
8
+ * is white paper on a grey canvas, separated by shadow.
8
9
  *
9
10
  * Each colour is declared ONCE, as a role (a plane, an ink tier, one of four
10
11
  * indigos, one ink per status hue), and every other name of that colour is an
@@ -15,26 +16,9 @@
15
16
  * add a named theme in named-themes.css (`data-theme="<name>"`) — do NOT re-declare
16
17
  * these tokens in the app, or its surfaces silently stop tracking the brand. */
17
18
 
18
- /* MD3 surface hierarchy */
19
19
  /* Dark is the default theme (`:root`/`[data-sandbox-ui]`). It also responds to
20
20
  * the standard `[data-theme="dark"]` / `.dark` switch so any consumer can pin or
21
21
  * toggle a theme without inventing an ad-hoc `data-sandbox-theme` name. */
22
- /*
23
- * Surfaces are NEUTRAL GREY and the indigo is trim.
24
- *
25
- * Colour in the surface competes with colour in the accent: on an indigo-cast
26
- * ladder the indigo nav pill, link and focus ring are one more indigo thing on
27
- * an indigo page, and the eye stops finding them. Neutral planes give the brand
28
- * a field to sit on, so a single accent does all the signalling.
29
- *
30
- * The canvas stays above pure black (#0a0a14, lifted toward indigo). Near-white ink on it is the
31
- * glare end of the range, so its luminance leaves room for distinct text tiers.
32
- *
33
- * Elevation is carried by surface lightness plus a hairline that can actually be
34
- * seen — a black shadow on a dark canvas renders as nothing, so shadow does no
35
- * work here. The hairline is about 1.5:1 against the resting card — quiet by design,
36
- * enough to state an edge without drawing a box around every panel.
37
- */
38
22
  /* Seed a complete baseline on every supported boundary BEFORE mode overrides.
39
23
  * Inherited var() aliases are already substituted at their parent; declaring
40
24
  * them here lets a nested light/dark/named scope resolve its own palette.
@@ -65,13 +49,14 @@
65
49
  /* ── Planes ─────────────────────────────────────────────────────────────
66
50
  * One ladder, authored once as HSL channels because the shadcn layer reads
67
51
  * hsl(var(--hsl-*)); every hex plane below is derived from it, so a step
68
- * moves in one place. L0 is lifted off black toward indigo; L1 chrome sits
69
- * below it and the cards step up from it in even increments. */
70
- --hsl-background: 240 33% 6%; /* L0 canvas #0a0a14 */
71
- --hsl-input: 244 29% 9%; /* L1 chrome and field wells #11101e */
72
- --hsl-card: 244 23% 12%; /* L2 resting card #191826 */
73
- --hsl-secondary: 249 24% 16%; /* L3 nested plane and hover well #221f33 */
74
- --hsl-popover: 247 23% 21%; /* L4 overlay #2c2942: a popover carries body text, so it sits on the top plane the ink ramp reaches */
52
+ * moves in one place. OKLCH L x100: page 15.0, card 21.7, raised 26.6,
53
+ * raised-2 and overlay 31.6, each at least 4.4 over the plane it sits on.
54
+ * The page is lifted off black so near-white ink keeps room for its tiers. */
55
+ --hsl-background: 240 33% 6%; /* L0 page #0a0a14 */
56
+ --hsl-input: 244 29% 9%; /* L1 chrome (the sidebar) and the legacy field fill #11101e */
57
+ --hsl-card: 244 23% 12%; /* L2 card #191826 */
58
+ --hsl-secondary: 249 24% 17.5%; /* L3 raised: a card in a card, and the hover well #252237 */
59
+ --hsl-popover: 247 21% 23%; /* L4 raised-2 and overlay #312e47: a popover carries body text, so it sits on the top plane the ink ramp reaches */
75
60
  --hsl-muted: var(--hsl-secondary);
76
61
  --hsl-accent: var(--hsl-secondary); /* neutral hover; indigo lives in --accent-surface-strong */
77
62
  /* The floor of the ladder: the recessed page and the terminal well share it
@@ -125,15 +110,16 @@
125
110
  --md3-on-surface-variant: var(--text-muted);
126
111
  --md3-outline: #8e8e8e;
127
112
 
128
- /* ── Hairlines ──────────────────────────────────────────────────────────
129
- * 1.5:1 against the resting card, quiet by design: shadow does no work on a
130
- * dark canvas, so the hairline states where a plane ends without drawing a
131
- * box around every panel. */
132
- --hsl-border: 243 20% 20%; /* #2a293d */
113
+ /* ── Hairline ───────────────────────────────────────────────────────────
114
+ * ONE role for every divider, card edge and table rule, so lines never
115
+ * disagree. 1.5:1 against the card and 1.3:1 on the raised plane: quiet by
116
+ * design, it states where a plane ends without boxing every panel.
117
+ * --border-subtle and --border-default are its older names. */
118
+ --hsl-border: 244 16% 25%; /* #37364a */
133
119
  --md3-outline-variant: hsl(var(--hsl-border));
134
120
  --sidebar-border: var(--hsl-border);
135
- --border-subtle: rgba(255, 255, 255, 0.10);
136
- --border-default: rgba(255, 255, 255, 0.16);
121
+ --border-subtle: hsl(var(--hsl-border));
122
+ --border-default: hsl(var(--hsl-border));
137
123
  --border-hover: rgba(255, 255, 255, 0.28);
138
124
 
139
125
  /* ── Accent: four indigos, each with one job ────────────────────────────
@@ -357,7 +343,7 @@
357
343
  --glass-bg: var(--depth-2);
358
344
  --glass-bg-strong: var(--depth-3);
359
345
  --glass-border: var(--border-subtle);
360
- --glass-border-hover: var(--border-default);
346
+ --glass-border-hover: var(--border-hover);
361
347
  --glass-blur: 0px;
362
348
 
363
349
  /* Mesh glows — off; depth comes from the ladder, not from glow */
@@ -461,12 +447,13 @@
461
447
  --transcript-font-size: 1rem; /* 16px: the chat reading size */
462
448
  --transcript-line-height: var(--line-height-base);
463
449
 
464
- /* Typography scale — override per-app for custom type hierarchy.
465
- Components reference these instead of hardcoded pixel values. */
466
- --font-size-xs: 0.6875rem; /* 11px — labels, timestamps */
467
- --font-size-sm: 0.75rem; /* 12px — captions, meta */
468
- --font-size-base: 0.9375rem; /* 15px — message body */
469
- --font-size-lg: 1rem; /* 16px — emphasized text */
450
+ /* Typography scale. Each --font-size-<step> is Tailwind's text-<step>, so a
451
+ step name means one size everywhere ("sm" is 14px); 12px is the floor.
452
+ Components reference these instead of pixel values; an app may override. */
453
+ --font-size-xs: 0.75rem; /* 12px — meta, timestamps, eyebrows */
454
+ --font-size-sm: 0.875rem; /* 14px — UI text */
455
+ --font-size-base: 1rem; /* 16px — reading text */
456
+ --font-size-lg: 1.125rem; /* 18px — emphasized text */
470
457
  --font-size-xl: 1.25rem; /* 20px — section title (h2) */
471
458
  /* Display tier — page titles, marketing section heads, and heroes.
472
459
  Hero sizes are fluid (clamp) so they scale with the viewport without
@@ -589,7 +576,7 @@
589
576
  --hsl-background: 223 23% 94%; /* L0 canvas #eceef3, BELOW the wells */
590
577
  --hsl-input: 225 29% 97%; /* L1 chrome and field wells #f5f6fa */
591
578
  --hsl-card: 0 0% 100%; /* L2 card: paper */
592
- --hsl-secondary: 230 27% 96%; /* L3 nested well #f2f3f8 */
579
+ --hsl-secondary: 228 25% 94.5%; /* L3 raised: a card in a card #edeff4, 4.8 L under its paper */
593
580
  --hsl-popover: var(--hsl-card); /* L4 overlay: paper, lifted by shadow, never out-lightening a card */
594
581
  --md3-surface-dim: #dcdee6; /* the recessed page */
595
582
  --md3-surface-container-lowest: hsl(var(--hsl-card)); /* insets read as paper */
@@ -609,10 +596,8 @@
609
596
  --text-dim: #686868;
610
597
  --md3-outline: #787878;
611
598
 
612
- /* ── Hairlines ──────────────────────────────────────────────────────────── */
599
+ /* ── Hairline: 1.7:1 on paper, 1.4:1 on the canvas ──────────────────────── */
613
600
  --hsl-border: 244 16% 81%; /* #c8c7d6 */
614
- --border-subtle: rgba(0, 0, 0, 0.10);
615
- --border-default: rgba(0, 0, 0, 0.14);
616
601
  --border-hover: rgba(0, 0, 0, 0.24);
617
602
 
618
603
  /* ── Accent ─────────────────────────────────────────────────────────────
@@ -807,15 +792,13 @@
807
792
  * level in, --hsl-raised-2 two levels in.
808
793
  * --well the field fill: --well-page on the page, --well-card in a card,
809
794
  * --well-raised in a card in a card, --well-raised-2 deeper still.
810
- * A theme that tunes its ladder (Hospitality) sets these per mode. Every other
811
- * theme gets wells derived from its own page and card, so a field always sits
812
- * at least 3 OKLCH L points off the plane it is on (agent-app-signoff
813
- * surface-ladder): dark wells recess below the page (page + 30% black: -4.6 L on
814
- * the page, -10.9 L in a card on the spine); light wells take the opposite plane
815
- * (the card on the page, the page in a card: 5 L either way). The old shared
816
- * --bg-input sat +3.1 above the dark page and -2.6 under the white card. Raised
817
- * levels still paint the card here: --hsl-raised is an HSL triplet and cannot
818
- * be derived per theme.
795
+ * The canonical ladder is the spine's own planes: raised is L3 (--hsl-secondary),
796
+ * raised-2 is L4 (--hsl-popover), and each well is a plane at least 4.4 OKLCH L
797
+ * off the level it fills. Dark wells always sit deeper (page + 30% black on the
798
+ * page and in a card, the card in a raised plane); light wells take the
799
+ * opposite plane (paper on a grey level, the canvas on paper). Dark raised
800
+ * planes also carry a 1px top highlight. Hospitality tunes its own ladder; the
801
+ * other named themes keep a flat one (below) until theirs is tuned.
819
802
  *
820
803
  * INTERACTION. Four states, all from the focus ring hue (--hsl-ring), so one
821
804
  * green or one indigo marks every interactive thing in a theme:
@@ -833,20 +816,20 @@
833
816
  [data-sandbox-ui],
834
817
  [data-sandbox-theme],
835
818
  [data-theme] {
836
- --hsl-raised: var(--hsl-card);
837
- --hsl-raised-2: var(--hsl-card);
819
+ --hsl-raised: var(--hsl-secondary);
820
+ --hsl-raised-2: var(--hsl-popover);
838
821
  --well-page: color-mix(in oklab, hsl(var(--hsl-background)) 70%, black);
839
822
  --well-card: var(--well-page);
840
- --well-raised: var(--well-page);
841
- --well-raised-2: var(--well-page);
823
+ --well-raised: hsl(var(--hsl-card));
824
+ --well-raised-2: var(--well-raised);
842
825
  --well: var(--well-page);
843
826
  /* The hairline is 1.5:1 by design; a field edge is non-text UI and needs 3:1.
844
- * 45% toward the ink measures 4.0:1 on the spine card in both modes. */
827
+ * 45% toward the ink clears 4:1 on the spine card in both modes. */
845
828
  --field-border: color-mix(in oklab, hsl(var(--hsl-border)), hsl(var(--hsl-foreground)) 45%);
846
829
  --shadow-raised: var(--shadow-card);
847
830
  /* A shadow token joins a comma list, so "none" would void the whole list. */
848
831
  --shadow-well: 0 0 0 0 transparent;
849
- --edge-highlight: 0 0 0 0 transparent;
832
+ --edge-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.06);
850
833
  --ix-hover: 0 0 0 1px hsl(var(--hsl-ring) / 0.45), 0 0 0 4px hsl(var(--hsl-ring) / 0.16);
851
834
  --ix-hover-border: hsl(var(--hsl-ring) / 0.7);
852
835
  --ix-press: 0 0 0 1px hsl(var(--hsl-ring) / 0.95), 0 0 0 4px hsl(var(--hsl-ring) / 0.3);
@@ -870,8 +853,20 @@
870
853
  --ix-hover-bg: hsl(var(--hsl-ring) / 0.07);
871
854
  --well-page: hsl(var(--hsl-card));
872
855
  --well-card: hsl(var(--hsl-background));
873
- --well-raised: hsl(var(--hsl-background));
874
- --well-raised-2: hsl(var(--hsl-background));
856
+ --well-raised-2: var(--well-card);
857
+ --edge-highlight: 0 0 0 0 transparent;
858
+ }
859
+
860
+ /* Named themes with their own planes keep a flat ladder until each is tuned as
861
+ * Hospitality was: a nested card paints the card, and its field the card's well. */
862
+ [data-theme="aubergine"], [data-theme="aubergine-light"], [data-theme="arena"], [data-theme="arena-light"],
863
+ [data-theme="super"], [data-theme="super-light"], [data-theme="tangle-light"], [data-theme="agents"],
864
+ [data-theme="agents-light"], [data-theme="website"], [data-theme="intelligence"] {
865
+ --hsl-raised: var(--hsl-card);
866
+ --hsl-raised-2: var(--hsl-card);
867
+ --well-raised: var(--well-card);
868
+ --well-raised-2: var(--well-card);
869
+ --edge-highlight: 0 0 0 0 transparent;
875
870
  }
876
871
 
877
872
  /* Surface context: keyed only on the Card primitive's data-slot, never on app