@tangle-network/brand 1.27.1 → 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.
@@ -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);
@@ -28,6 +28,15 @@
28
28
  --spacing-control-md: var(--control-height);
29
29
  --spacing-control-lg: var(--control-height-lg);
30
30
  --spacing-shell-header: var(--shell-header-height);
31
+ --color-brand: var(--brand-primary);
32
+ --color-brand-dim: var(--btn-primary-bg);
33
+ --color-brand-cool: var(--brand-cool);
34
+ --color-brand-glow: var(--brand-glow);
35
+ --color-brand-emerald: var(--brand-emerald);
36
+ --color-status-running: var(--status-running);
37
+ --color-status-creating: var(--status-creating);
38
+ --color-status-stopped: var(--status-stopped);
39
+ --color-status-error: var(--status-error);
31
40
  --color-scrim: var(--scrim);
32
41
  --color-scrim-strong: var(--scrim-strong);
33
42
  --color-on-media: var(--on-media);
@@ -230,31 +239,14 @@
230
239
  }
231
240
 
232
241
  /* ───────────────────────── Tangle — canonical dark spine ───────────────────
233
- * tangle-dark names the default :root/.dark spine explicitly. The named block
234
- * repeats its surfaces and ink so a dark island remains self-sufficient
235
- * inside a light-named wrapper. Indigo stays in the accent; status tokens inherit
236
- * the dark-tuned root palette.
242
+ * tangle-dark names the default :root/.dark spine explicitly, for a dark island
243
+ * inside a light-named wrapper or a picker's explicit choice. It is the
244
+ * canonical spine by construction, never a copy of it.
237
245
  */
238
246
  [data-theme="tangle-dark"] {
239
- --hsl-background: 240 33% 6%; --hsl-foreground: 0 0% 90.1961%;
240
- --hsl-card: 244 23% 12%; --hsl-card-foreground: 0 0% 90.1961%;
241
- --hsl-popover: 247 23% 21%; --hsl-popover-foreground: 0 0% 90.1961%;
242
- --hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
243
- --hsl-secondary: 249 24% 16%; --hsl-secondary-foreground: 0 0% 90.1961%;
244
- --hsl-muted: 249 24% 16%; --hsl-muted-foreground: 240 5% 68%;
245
- --hsl-accent: 249 24% 16%; --hsl-accent-foreground: 0 0% 90.1961%;
246
- --hsl-destructive: 0 90% 77%; --hsl-destructive-foreground: 0 60% 10%;
247
- --hsl-border: 243 20% 20%; --hsl-input: 244 29% 9%;
248
- --hsl-ring: 233 100% 87%;
249
- --depth-1: #0a0a14; --depth-2: #191826; --depth-3: #221f33; --depth-4: #2c2942;
250
- --bg-root: #0a0a14; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
251
- --md3-surface-container-lowest: #040408; --md3-surface-container-low: #12111f;
252
- --md3-surface-container: #191826; --md3-surface-container-high: #221f33;
253
- --md3-surface-container-highest: #2c2942; --md3-outline-variant: #2a293d;
254
- --border-subtle: rgba(255, 255, 255, 0.10); --border-default: rgba(255, 255, 255, 0.16);
255
- --border-hover: rgba(255, 255, 255, 0.28); --accent-text: #A5B4FC;
256
- --brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
257
- --text-primary: #e6e6e6; --text-secondary: #c7c7c7; --text-muted: #b5b5b5; --text-dim: #a5a5a5;
247
+ /* Nothing to declare: tokens.css seeds the complete dark spine on this
248
+ * selector, so a tangle-dark island carries every value itself and inherits
249
+ * nothing from a light wrapper. */
258
250
  }
259
251
  /* Its light mode. */
260
252
  [data-theme="tangle-light"] {
@@ -378,7 +370,7 @@
378
370
  --hsl-accent: 133 38.4% 88%; --hsl-accent-foreground: 143 25% 12%;
379
371
  --hsl-border: 107 8.7% 79.8%; --hsl-input: 0 0% 100%;
380
372
  --hsl-ring: 148 28% 43%;
381
- --hsl-destructive: 0 72% 41%;
373
+ --hsl-destructive: 0 73.7% 41.8%; /* the canonical light red, restated so the dark block below mirrors it */
382
374
  --hsl-raised: 110 18.7% 93.7%; --hsl-raised-2: 0 0% 100%;
383
375
  --sidebar-background: 108 21.7% 95.5%; --sidebar-foreground: 143 25% 12%;
384
376
  --sidebar-primary: 153 38% 25%; --sidebar-primary-foreground: 0 0% 100%;
@@ -11,6 +11,7 @@ import {
11
11
  hexRelativeLuminanceIn,
12
12
  hslIn,
13
13
  hslToRgb,
14
+ resolveIn,
14
15
  } from "./css-test-utils";
15
16
 
16
17
  /**
@@ -100,71 +101,15 @@ describe('named theme: [data-theme="tangle-dark"]', () => {
100
101
  );
101
102
  if (!light) throw new Error('missing theme block: [data-theme="tangle-light"]');
102
103
 
103
- it("IS the canonical dark spine, named — no drift from tokens.css", () => {
104
+ it("declares nothing: the attribute names the spine tokens.css seeds on it", () => {
104
105
  // tangle-dark exists so the attribute can NAME the default spine (a dark
105
- // island in a light-named wrapper, a picker's explicit selection). If its
106
- // values diverge from :root, "tangle dark" becomes two palettes.
107
- const hslTokens = [
108
- "hsl-background",
109
- "hsl-foreground",
110
- "hsl-card",
111
- "hsl-card-foreground",
112
- "hsl-popover",
113
- "hsl-popover-foreground",
114
- "hsl-primary",
115
- "hsl-primary-foreground",
116
- "hsl-secondary",
117
- "hsl-secondary-foreground",
118
- "hsl-muted",
119
- "hsl-muted-foreground",
120
- "hsl-accent",
121
- "hsl-accent-foreground",
122
- "hsl-destructive",
123
- "hsl-destructive-foreground",
124
- "hsl-border",
125
- "hsl-input",
126
- "hsl-ring",
127
- ];
128
- for (const token of hslTokens) {
129
- expect(hslIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
130
- hslIn(CANON, token),
131
- );
132
- }
133
- const hexTokens = [
134
- "depth-1",
135
- "depth-2",
136
- "depth-3",
137
- "depth-4",
138
- "md3-surface-container-lowest",
139
- "md3-surface-container-low",
140
- "md3-surface-container",
141
- "md3-surface-container-high",
142
- "md3-surface-container-highest",
143
- "md3-outline-variant",
144
- "accent-text",
145
- "brand-primary",
146
- "brand-glow",
147
- "text-primary",
148
- "text-secondary",
149
- "text-muted",
150
- "text-dim",
151
- ];
152
- for (const token of hexTokens) {
153
- expect(hexIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
154
- hexIn(CANON, token),
155
- );
156
- }
157
- // Aliased and rgba declarations don't fit hslIn/hexIn — compare them raw.
158
- for (const token of [
159
- "bg-root",
160
- "bg-card",
161
- "bg-input",
162
- "border-subtle",
163
- "border-default",
164
- "border-hover",
165
- ]) {
166
- expect(declIn(css, token), `--${token} must equal the :root dark spine`).toBe(
167
- declIn(CANON, token),
106
+ // island in a light-named wrapper, a picker's explicit selection). It is
107
+ // the canonical spine by construction: tokens.css declares every dark token
108
+ // on this selector, so a copy here could only drift from it.
109
+ expect(css.replace(/^[^{]*\{/, "").trim()).toBe("");
110
+ for (const token of ["hsl-background", "hsl-destructive", "accent-text", "text-dim"]) {
111
+ expect(resolveIn(css, token), `--${token} resolves to the :root dark spine`).toBe(
112
+ resolveIn(CANON, token),
168
113
  );
169
114
  }
170
115
  });
@@ -238,27 +183,14 @@ describe('named theme: [data-theme="tangle-dark"]', () => {
238
183
 
239
184
  it("is self-sufficient inside a light-named wrapper — the island it exists for", () => {
240
185
  // The feature's stated scenario: tangle-dark nested in a tangle-light
241
- // wrapper. The browser composes the two scopes, so a token the island omits
242
- // inherits the WRAPPER's light value, not :root's — the light group's
243
- // near-black secondary-foreground on the dark secondary measured ~1.5:1
244
- // before the block redeclared its foregrounds. Model that cascade: every
245
- // tangle-light rule (the shared light group AND the theme block), with the
246
- // island's declarations overriding.
247
- const wrapper = blocksIn(themes, '[data-theme="tangle-light"]').join("\n");
248
- const effective = (token: string) => {
249
- const read = (source: string) => {
250
- try {
251
- return hslIn(source, token);
252
- } catch {
253
- return null;
254
- }
255
- };
256
- const value = read(css) ?? read(wrapper);
257
- if (!value) {
258
- throw new Error(`--${token} resolves to nothing inside the island`);
259
- }
260
- return value;
261
- };
186
+ // wrapper. A token the island omitted would inherit the WRAPPER's light
187
+ // value (its near-black secondary-foreground on the dark secondary measured
188
+ // ~1.5:1 once), which is why tokens.css seeds the whole spine on the
189
+ // island's own selector.
190
+ // The island declares every dark token on itself (the baseline tokens.css
191
+ // seeds on its selector), so nothing falls through to the wrapper: the
192
+ // chain is the island's own block over the canonical dark spine.
193
+ const effective = (token: string) => hslIn(css, token);
262
194
  for (const [fgToken, bgToken] of [
263
195
  ["hsl-secondary-foreground", "hsl-secondary"],
264
196
  ["hsl-accent-foreground", "hsl-accent"],
@@ -304,14 +236,12 @@ describe("named themes: secondary text tiers are readable text", () => {
304
236
  ];
305
237
  for (const scope of scopes) {
306
238
  it(`[data-theme="${scope}"] holds AA for --text-muted and --text-dim`, () => {
307
- const css = blocksIn(themes, `[data-theme="${scope}"]`).find((b) =>
308
- /--text-dim:/.test(b),
309
- );
310
- expect(css, `${scope} declares its text ladder`).toBeDefined();
311
- const resolve = (token: string): ReturnType<typeof hexIn> => {
312
- const ref = css!.match(new RegExp(`--${token}:\\s*var\\(--([a-z0-9-]+)\\)`));
313
- return ref ? resolve(ref[1]) : hexIn(css!, token);
314
- };
239
+ // The theme block over the spine it inherits; tangle-dark declares nothing
240
+ // and resolves to the canonical dark spine.
241
+ const blocks = blocksIn(themes, `[data-theme="${scope}"]`);
242
+ const css = blocks.find((b) => /--hsl-background:/.test(b)) ?? blocks[0];
243
+ const chain = scope.endsWith("-light") ? [css, CANON_LIGHT] : [css];
244
+ const resolve = (token: string) => hexIn(chain, token);
315
245
  const planes = {
316
246
  canvas: resolve("bg-root"),
317
247
  card: resolve("bg-card"),
@@ -450,13 +380,16 @@ describe('named theme: [data-theme="hospitality"]', () => {
450
380
  // sage planes (surface-ladder.test.ts holds their hue and contrast), and the
451
381
  // dark destructive red is lightened to read on the lighter popover; it stays
452
382
  // red. The other status hues, info and every category stay canonical.
453
- for (const css of [light, dark]) {
383
+ for (const [css, spine] of [
384
+ [light, CANON_LIGHT],
385
+ [dark, CANON],
386
+ ] as const) {
454
387
  expect(css).not.toMatch(
455
388
  /--(hsl-)?(success|warning|info|error)\b|--destructive|--hsl-destructive-foreground|--surface-info-|--status-|--tone-|--run-mix-|--syntax-/,
456
389
  );
457
- expect(hslIn(css, "hsl-destructive").h, "destructive stays red").toBe(0);
390
+ expect(hslIn([css, spine], "hsl-destructive").h, "destructive stays red").toBe(0);
458
391
  }
459
- expect(declIn(light, "hsl-destructive")).toBe(declIn(CANON_LIGHT, "hsl-destructive"));
392
+ expect(hslIn(light, "hsl-destructive")).toEqual(hslIn(CANON_LIGHT, "hsl-destructive"));
460
393
  });
461
394
 
462
395
  it("keeps the canonical ladder roles: paper cards on a tinted canvas in light, rising planes in dark", () => {
@@ -680,8 +613,12 @@ describe("dark themes: destructive reads as text", () => {
680
613
  '[data-theme="agents"]',
681
614
  '.dark[data-theme="website"]',
682
615
  ].map((selector): [string, string] => {
683
- const css = blocksIn(themes, selector).find((b) => b.includes("--hsl-background"));
684
- if (!css) throw new Error(`missing dark theme block: ${selector}`);
616
+ // A selector can head two rules (tangle-light's shared status group and
617
+ // its theme block); the theme block is the one with a canvas. tangle-dark
618
+ // declares nothing and resolves to the spine it is seeded with.
619
+ const blocks = blocksIn(themes, selector);
620
+ const css = blocks.find((b) => b.includes("--hsl-background")) ?? (blocks.length === 1 ? blocks[0] : undefined);
621
+ if (css === undefined) throw new Error(`missing dark theme block: ${selector}`);
685
622
  return [selector, css];
686
623
  }),
687
624
  ];
@@ -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
 
@@ -305,6 +372,6 @@ describe("Inset workspace backdrop (canonical themes)", () => {
305
372
  expect(Math.abs(panel - backdrop)).toBeGreaterThanOrEqual(3);
306
373
  });
307
374
  it("keeps the dark floor tokens equal, as their comment says", () => {
308
- expect(declIn(DARK, "md3-surface-dim")).toBe(declIn(DARK, "md3-surface-container-lowest"));
375
+ expect(hexIn(DARK, "md3-surface-dim")).toEqual(hexIn(DARK, "md3-surface-container-lowest"));
309
376
  });
310
377
  });
@@ -18,8 +18,10 @@ const ladders = read("ladders.css");
18
18
 
19
19
  /** The spine block of a selector: the rule that declares the surface ladder. */
20
20
  function spine(source: string, selector: string): string {
21
- const block = blocksIn(source, selector).find((b) => b.includes("--hsl-background:"));
22
- if (!block) throw new Error(`missing spine block: ${selector}`);
21
+ const blocks = blocksIn(source, selector);
22
+ // A theme that declares nothing (tangle-dark) is the baseline its chain continues to.
23
+ const block = blocks.find((b) => b.includes("--hsl-background:")) ?? (blocks.length === 1 ? blocks[0] : undefined);
24
+ if (block === undefined) throw new Error(`missing spine block: ${selector}`);
23
25
  return block;
24
26
  }
25
27
 
@@ -36,16 +36,6 @@
36
36
  --radius-xl: 12px;
37
37
  --radius-full: 999px;
38
38
 
39
- --color-brand: #6366F1;
40
- --color-brand-dim: #4F46E5;
41
- --color-brand-cool: #818CF8;
42
- --color-brand-glow: #A5AAFC;
43
- --color-brand-emerald: #10B981;
44
- --color-status-running: #10B981;
45
- --color-status-creating: #9B93F0;
46
- --color-status-stopped: #FFB800;
47
- --color-status-error: #FF4D6D;
48
-
49
39
  --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
50
40
  --shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
51
41
  --shadow-glow: none;
@@ -60,11 +50,23 @@
60
50
  * Public HSL aliases remain HSL channel triples, never full CSS colors.
61
51
  */
62
52
  @theme inline {
53
+ /* Brand and status colours follow the runtime tokens, so bg-brand and
54
+ * text-status-running change with the mode and the named theme. */
55
+ --color-brand: var(--brand-primary);
56
+ --color-brand-dim: var(--btn-primary-bg);
57
+ --color-brand-cool: var(--brand-cool);
58
+ --color-brand-glow: var(--brand-glow);
59
+ --color-brand-emerald: var(--brand-emerald);
60
+ --color-status-running: var(--status-running);
61
+ --color-status-creating: var(--status-creating);
62
+ --color-status-stopped: var(--status-stopped);
63
+ --color-status-error: var(--status-error);
64
+
63
65
  --text-display: var(--font-size-display);
64
66
  --text-hero: var(--font-size-hero);
65
67
  --text-page: var(--font-size-3xl);
66
68
  --text-section: var(--font-size-xl);
67
- --text-eyebrow: var(--font-size-sm);
69
+ --text-eyebrow: var(--font-size-xs);
68
70
 
69
71
  /* Control and form type: text-control-sm/md/lg, text-label, text-help. */
70
72
  --text-control-sm: var(--control-text-sm);