@tangle-network/brand 0.9.0 → 1.1.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,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 15402db: Ship the indigo surface ladder as the canonical Tangle palette.
8
+
9
+ The `:root` spine was a flat, desaturated neutral. Every product app overrode it with its own hand-written palette, so brand's own colors were rendered almost nowhere — and the apps that did inherit them read as grey and washed out, because a flat ladder forces surface separation onto borders instead of fills.
10
+
11
+ Both themes now ship the ladder the products actually converged on: an indigo-cast dark scale stepped in even ~4-5% lightness increments, and a light scale of white paper on a tinted canvas rather than white-on-white. Surfaces, depth scale, sidebar and `--bg-root` all move together so the ladder stays coherent.
12
+
13
+ Visual change for every consumer. Apps that were overriding the spine should delete those overrides; apps that were inheriting it get the branded look with no code change.
14
+
15
+ Also adds a dark-only `intelligence` named theme — a violet surface ramp — and exports the named-theme stylesheet as `./styles/named-themes.css`, so named themes can be imported at all (they shipped, but no export reached them). The name is deliberately not `themes.css`: one letter from the existing `./styles/theme.css` (the Tailwind `@theme` map) is a typo that resolves successfully to the wrong stylesheet. No published version ever exported `./styles/themes.css` — 1.0.0's export map is `./styles/{index,tokens,globals,theme}.css` — so this adds an export rather than renaming a public one, and nothing downstream can break. A named theme re-skins surfaces only; the Tangle accent stays put.
16
+
17
+ ## 1.0.0
18
+
19
+ ### Major Changes
20
+
21
+ - 0ef3a1a: Graduate `@tangle-network/brand` to stable 1.0.0. The design-token + prose layer is mature and consumed across every app; declaring 1.0 lets its future minors stay within `@tangle-network/ui`'s `^1.x` peer range. Combined with the new `onlyUpdatePeerDependentsWhenOutOfRange` changeset option, this ends the churn where every brand minor force-majored `ui` (5→6→7… all the way to 10). After this one-time coordinated bump, brand token/prose changes no longer re-version `ui` at all — `ui` versions only when its own component code changes.
22
+
3
23
  ## 0.9.0
4
24
 
5
25
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/brand",
3
- "version": "0.9.0",
3
+ "version": "1.1.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",
@@ -35,7 +35,8 @@
35
35
  "./styles/index.css": "./src/styles/index.css",
36
36
  "./styles/tokens.css": "./src/styles/tokens.css",
37
37
  "./styles/globals.css": "./src/styles/globals.css",
38
- "./styles/theme.css": "./src/styles/theme.css"
38
+ "./styles/theme.css": "./src/styles/theme.css",
39
+ "./styles/named-themes.css": "./src/styles/named-themes.css"
39
40
  },
40
41
  "peerDependencies": {
41
42
  "react": ">=18"
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { blockIn, hexRelativeLuminanceIn, hslIn } from "./css-test-utils";
3
+
4
+ /**
5
+ * The helpers every other suite's correctness rests on.
6
+ *
7
+ * Their failure mode is the dangerous one: they return something that still looks
8
+ * like CSS, so a wrong answer here makes assertions pass or fail against the wrong
9
+ * text instead of erroring. Each case below is a way that can happen.
10
+ */
11
+ describe("blockIn", () => {
12
+ it("anchors on the rule, not on prose that names the selector", () => {
13
+ const css = `
14
+ /* the .light spine is defined below */
15
+ .dark { --x: 1; }
16
+ .light { --x: 2; }`;
17
+ expect(blockIn(css, ".light")).toContain("--x: 2");
18
+ });
19
+
20
+ it("closes on THIS block's brace, not the first nested one", () => {
21
+ const css = `
22
+ .dark {
23
+ --outer: 1;
24
+ @media (min-width: 40rem) { --inner: 2; }
25
+ --after-nested: 3;
26
+ }
27
+ .light { --x: 9; }`;
28
+ const block = blockIn(css, ".dark");
29
+ expect(block, "a nested rule must not end the block early").toContain(
30
+ "--after-nested",
31
+ );
32
+ expect(block, "and must not swallow the following rule").not.toContain(
33
+ "--x: 9",
34
+ );
35
+ });
36
+
37
+ it("tolerates a missing space before the brace", () => {
38
+ expect(blockIn(".dark{ --x: 1; }", ".dark")).toContain("--x: 1");
39
+ });
40
+
41
+ it("throws on a missing or unterminated block rather than returning junk", () => {
42
+ expect(() => blockIn(".dark { --x: 1; }", ".nope")).toThrow(/missing/);
43
+ expect(() => blockIn(".dark { --x: 1;", ".dark")).toThrow(/unterminated/);
44
+ });
45
+ });
46
+
47
+ describe("hexRelativeLuminanceIn", () => {
48
+ it("reads #rgb, #rrggbb and #rrggbbaa alike", () => {
49
+ // #fff and #ffffff are the same color; the alpha byte does not change its
50
+ // luminance.
51
+ expect(hexRelativeLuminanceIn("--a: #fff;", "a")).toBeCloseTo(1, 10);
52
+ expect(hexRelativeLuminanceIn("--a: #ffffff;", "a")).toBeCloseTo(1, 10);
53
+ expect(hexRelativeLuminanceIn("--a: #ffffff80;", "a")).toBeCloseTo(1, 10);
54
+ expect(hexRelativeLuminanceIn("--a: #000;", "a")).toBeCloseTo(0, 10);
55
+ });
56
+
57
+ it("orders planes by perceived brightness, not by HSL lightness", () => {
58
+ const canvas = hexRelativeLuminanceIn("--a: #0a0a14;", "a");
59
+ const card = hexRelativeLuminanceIn("--a: #191826;", "a");
60
+ expect(card).toBeGreaterThan(canvas);
61
+ });
62
+
63
+ it("says a token is non-hex rather than claiming it is missing", () => {
64
+ expect(() => hexRelativeLuminanceIn("--a: var(--b);", "a")).toThrow(
65
+ /not a hex color/,
66
+ );
67
+ expect(() => hexRelativeLuminanceIn("--a: #fff;", "b")).toThrow(/missing/);
68
+ });
69
+ });
70
+
71
+ describe("hslIn", () => {
72
+ it("reads an `H S% L%` triple", () => {
73
+ expect(hslIn("--a: 240 33% 6%;", "a")).toEqual({ h: 240, s: 33, l: 6 });
74
+ });
75
+
76
+ it("throws when the slot is absent", () => {
77
+ expect(() => hslIn("--a: 240 33% 6%;", "b")).toThrow(/missing HSL slot/);
78
+ });
79
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Helpers for asserting on the stylesheets as source text.
3
+ *
4
+ * Deliberately NOT a `.test.ts` file: importing one test file from another makes
5
+ * Vitest register the imported file's top-level suites a second time, in the
6
+ * importing file's context.
7
+ */
8
+
9
+ /** Escape every regex metacharacter, so a selector is matched literally. */
10
+ function escapeRegex(literal: string): string {
11
+ return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
12
+ }
13
+
14
+ /** Strip CSS comments, so a selector named in prose is never mistaken for a rule. */
15
+ function rulesOnly(css: string): string {
16
+ return css.replace(/\/\*[\s\S]*?\*\//g, "");
17
+ }
18
+
19
+ /** Extract a rule's body by selector, tolerant of formatting and of nesting. */
20
+ export function blockIn(source: string, selector: string): string {
21
+ // Comments first: a block's own doc-comment routinely spells its selector out,
22
+ // and anchoring on that prose would slice the wrong region — silently, since
23
+ // the result still looks like CSS.
24
+ const css = rulesOnly(source);
25
+ // Tolerate any whitespace between selector and brace — a formatter that closes
26
+ // 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);
42
+ }
43
+ }
44
+ throw new Error(`unterminated theme block: ${selector}`);
45
+ }
46
+
47
+ /** Read an `H S% L%` channel triple. */
48
+ export function hslIn(
49
+ css: string,
50
+ token: string,
51
+ ): { h: number; s: number; l: number } {
52
+ const m = css.match(
53
+ new RegExp(
54
+ `--${token}:\\s*(\\d+(?:\\.\\d+)?)\\s+(\\d+(?:\\.\\d+)?)%\\s+(\\d+(?:\\.\\d+)?)%`,
55
+ ),
56
+ );
57
+ if (!m) throw new Error(`missing HSL slot --${token}`);
58
+ return { h: Number(m[1]), s: Number(m[2]), l: Number(m[3]) };
59
+ }
60
+
61
+ type Rgb = [number, number, number];
62
+
63
+ /** `H S% L%` → RGB 0-255. */
64
+ export function hslToRgb({
65
+ h,
66
+ s,
67
+ l,
68
+ }: {
69
+ h: number;
70
+ s: number;
71
+ l: number;
72
+ }): Rgb {
73
+ const sat = s / 100;
74
+ const lit = l / 100;
75
+ const c = (1 - Math.abs(2 * lit - 1)) * sat;
76
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
77
+ const m = lit - c / 2;
78
+ const [r, g, b] =
79
+ h < 60
80
+ ? [c, x, 0]
81
+ : h < 120
82
+ ? [x, c, 0]
83
+ : h < 180
84
+ ? [0, c, x]
85
+ : h < 240
86
+ ? [0, x, c]
87
+ : h < 300
88
+ ? [x, 0, c]
89
+ : [c, 0, x];
90
+ return [(r + m) * 255, (g + m) * 255, (b + m) * 255];
91
+ }
92
+
93
+ function relativeLuminance([r, g, b]: Rgb): number {
94
+ const lin = (c: number) => {
95
+ const v = c / 255;
96
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
97
+ };
98
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
99
+ }
100
+
101
+ /**
102
+ * What a translucent foreground actually becomes once the surface behind it shows
103
+ * through. A `/70` utility does not render the token's color — it renders this.
104
+ */
105
+ export function compositeOver(fg: Rgb, bg: Rgb, alpha: number): Rgb {
106
+ return fg.map((c, i) => alpha * c + (1 - alpha) * bg[i]) as Rgb;
107
+ }
108
+
109
+ /** WCAG contrast ratio between two opaque colors. */
110
+ export function contrastRatio(a: Rgb, b: Rgb): number {
111
+ const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort(
112
+ (x, y) => y - x,
113
+ );
114
+ return (hi + 0.05) / (lo + 0.05);
115
+ }
116
+
117
+ /**
118
+ * Read a hex surface token as WCAG relative luminance (0-1).
119
+ *
120
+ * Relative luminance, not HSL lightness: the ladder varies hue and saturation as
121
+ * it rises, and HSL L is not perceptually uniform — two steps can share an L and
122
+ * 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.
128
+ */
129
+ 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);
152
+ }
@@ -7,5 +7,5 @@
7
7
 
8
8
  @import "./tokens.css";
9
9
  @import "./theme.css";
10
- @import "./themes.css";
10
+ @import "./named-themes.css";
11
11
  @import "./globals.css";
@@ -187,3 +187,58 @@
187
187
  --brand-primary: #6366f1; --brand-glow: #818cf8;
188
188
  --text-primary: #15131f; --text-secondary: #3f3a52; --text-muted: #6a6680; --text-dim: #9490a6;
189
189
  }
190
+
191
+ /* ───────────────────── Intelligence — violet surface ramp ────────────────────
192
+ * A violet retint of the surface ladder (hue ~260-265, saturation tapering as
193
+ * lightness rises). The brand indigo primary / ring / accent-text are
194
+ * deliberately NOT overridden: the accent stays Tangle, only the surfaces carry
195
+ * the product's identity.
196
+ *
197
+ * Apply `data-theme="intelligence"` on <html> alongside the `.dark` class.
198
+ * The selector REQUIRES `.dark` rather than trusting the consumer to swap the
199
+ * attribute on every mode change: a violet dark ramp left standing over a light
200
+ * page would repaint every surface. Dark-only is the contract, so the selector —
201
+ * not the caller — enforces it. There is deliberately no `-light` variant;
202
+ * light falls through to brand's canonical light spine. */
203
+ .dark[data-theme="intelligence"] {
204
+ --md3-surface: #0d0a10;
205
+ --md3-surface-dim: #0a080d;
206
+ --md3-surface-bright: #2e2936;
207
+ --md3-surface-container-lowest: #070509;
208
+ --md3-surface-container-low: #100d15;
209
+ --md3-surface-container: #14111a;
210
+ --md3-surface-container-high: #1b1821;
211
+ --md3-surface-container-highest: #24202b;
212
+ --md3-surface-variant: #24202b;
213
+ --md3-on-surface-variant: #aaa6bb;
214
+
215
+ --hsl-background: 262 24% 5%;
216
+ --hsl-card: 262 19% 11%;
217
+ --hsl-popover: 260 16% 13%;
218
+ --hsl-secondary: 262 15% 18%;
219
+ --hsl-muted: 262 17% 15%;
220
+ --hsl-muted-foreground: 255 12% 62%;
221
+ --hsl-accent: 262 15% 20%;
222
+ --hsl-border: 262 12% 28%;
223
+ --hsl-input: 260 14% 16%;
224
+
225
+ /* seated a touch deeper than the canvas so the panel reads as a base plane */
226
+ --sidebar-background: 264 26% 4%;
227
+ --sidebar-accent: 262 16% 16%;
228
+ --sidebar-border: 262 12% 13%;
229
+
230
+ --depth-1: #0e0b13;
231
+ --depth-2: #16131c;
232
+ --depth-3: #201c28;
233
+ --depth-4: #2a2533;
234
+ --bg-elevated: hsl(262 17% 15%);
235
+ --bg-section: hsl(262 22% 7%);
236
+ --bg-root:
237
+ radial-gradient(ellipse 70% 50% at 20% 0%, rgba(129, 99, 241, 0.08), transparent),
238
+ radial-gradient(ellipse 50% 40% at 80% 10%, rgba(129, 140, 248, 0.06), transparent),
239
+ linear-gradient(180deg, var(--depth-1), #0d0a12 100%);
240
+
241
+ --border-subtle: rgba(120, 108, 158, 0.1);
242
+ --border-default: rgba(120, 108, 158, 0.18);
243
+ --border-hover: rgba(120, 108, 158, 0.28);
244
+ }
@@ -0,0 +1,71 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { blockIn, hslIn } from "./css-test-utils";
5
+
6
+ /**
7
+ * The named-theme contract.
8
+ *
9
+ * A named theme re-skins the SURFACE ladder and nothing else — the Tangle accent
10
+ * (primary / ring / accent-text) stays put, so a product carries its identity in
11
+ * its planes, not by inventing a second brand colour. These assertions live here
12
+ * rather than in the consuming app because the palette lives here: an app's job
13
+ * is only to opt in.
14
+ */
15
+ const themes = readFileSync(
16
+ path.resolve(import.meta.dirname, "named-themes.css"),
17
+ "utf8",
18
+ );
19
+
20
+ function block(selector: string): string {
21
+ return blockIn(themes, selector);
22
+ }
23
+
24
+ describe('named theme: .dark[data-theme="intelligence"]', () => {
25
+ const css = block('.dark[data-theme="intelligence"]');
26
+
27
+ it("retints the surface canvas to violet", () => {
28
+ for (const token of ["background", "card", "popover", "muted", "border"]) {
29
+ const { h } = hslIn(css, `hsl-${token}`);
30
+ expect(h, `--hsl-${token} hue`).toBeGreaterThanOrEqual(255);
31
+ expect(h, `--hsl-${token} hue`).toBeLessThanOrEqual(268);
32
+ }
33
+ });
34
+
35
+ it("reads as graded depth, not mud: lightness rises, saturation tapers, hue locked", () => {
36
+ const ladder = ["hsl-background", "hsl-card", "hsl-secondary"].map((t) =>
37
+ hslIn(css, t),
38
+ );
39
+ for (let i = 1; i < ladder.length; i++) {
40
+ expect(ladder[i].l, `step ${i} lightness must rise`).toBeGreaterThan(
41
+ ladder[i - 1].l,
42
+ );
43
+ expect(
44
+ ladder[i].s,
45
+ `step ${i} saturation must not rise as it lightens`,
46
+ ).toBeLessThanOrEqual(ladder[i - 1].s);
47
+ }
48
+ });
49
+
50
+ it("never recolors the Tangle accent — surfaces carry the identity, not the brand", () => {
51
+ for (const token of ["--hsl-primary", "--hsl-ring", "--accent-text"]) {
52
+ expect(css, `${token} must not be overridden by a named theme`).not.toContain(
53
+ `${token}:`,
54
+ );
55
+ }
56
+ });
57
+
58
+ it("is dark-only — the SELECTOR enforces it, not the caller", () => {
59
+ // An unscoped `[data-theme="intelligence"]` would repaint every surface with
60
+ // the violet dark ramp if a consumer left the attribute on while switching to
61
+ // light. The `.dark` requirement makes that impossible.
62
+ //
63
+ // Checked against the RULES, not the raw file: prose in a comment may well
64
+ // spell the selector out, and a doc edit must not be able to fail this.
65
+ const rules = themes.replace(/\/\*[\s\S]*?\*\//g, "");
66
+ expect(rules).toContain('.dark[data-theme="intelligence"]');
67
+ expect(rules).not.toMatch(/(?<!\.dark)\[data-theme="intelligence"\]/);
68
+ // No light variant: light falls through to brand's canonical light spine.
69
+ expect(rules).not.toContain('[data-theme="intelligence-light"]');
70
+ });
71
+ });
@@ -1,5 +1,14 @@
1
- /* Tangle Sandbox UI design tokens */
2
- /* Brand: warm purple palette, #5B4ED4 primary */
1
+ /* Tangle design tokens — the single palette every Tangle surface renders from.
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.
8
+ *
9
+ * Apps consume this spine as-is. If a product needs its own accent identity,
10
+ * add a named theme in named-themes.css (`data-theme="<name>"`) — do NOT re-declare
11
+ * these tokens in the app, or its surfaces silently stop tracking the brand. */
3
12
 
4
13
  /* MD3 surface hierarchy */
5
14
  /* Dark is the default theme (`:root`/`[data-sandbox-ui]`). It also responds to
@@ -9,15 +18,15 @@
9
18
  [data-sandbox-ui],
10
19
  [data-theme="dark"],
11
20
  .dark {
12
- --md3-surface: #16161f;
13
- --md3-surface-dim: #111119;
14
- --md3-surface-bright: #2a2940;
15
- --md3-surface-container-lowest: #101017;
16
- --md3-surface-container-low: #16161f;
17
- --md3-surface-container: #1c1b27;
18
- --md3-surface-container-high: #232231;
19
- --md3-surface-container-highest: #2c2b3d;
20
- --md3-surface-variant: #2c2b3d;
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;
21
30
  --md3-on-surface: #fdfdfd;
22
31
  --md3-on-surface-variant: #aaa8b8;
23
32
 
@@ -37,7 +46,7 @@
37
46
 
38
47
  --md3-tertiary: #10B981;
39
48
  --md3-outline: #4a4965;
40
- --md3-outline-variant: #34324a;
49
+ --md3-outline-variant: #2a293d;
41
50
 
42
51
  --md3-error: #ff5277;
43
52
  --md3-error-dim: #eb2855;
@@ -50,25 +59,27 @@
50
59
  --md3-inverse-on-surface: #000000;
51
60
  --md3-surface-tint: #818CF8;
52
61
 
53
- /* Semantic HSL bridge — flat neutral spine (Tangle Quiet) */
54
- --hsl-background: 240 7% 9%; /* #15151a canvas — lifted off pure black */
62
+ /* Semantic HSL bridge — the indigo-cast surface ladder */
63
+ --hsl-background: 240 33% 6%; /* L0 canvas #0a0a14 */
55
64
  --hsl-foreground: 240 6% 93%; /* #ececee */
56
- --hsl-card: 240 5% 13%; /* #1f1f23 surface, clearly above canvas */
65
+ --hsl-card: 244 23% 12%; /* L2 card #191826 */
57
66
  --hsl-card-foreground: 240 6% 93%;
58
- --hsl-popover: 240 5% 17%; /* #28282d elevated */
67
+ --hsl-popover: 247 23% 21%; /* L4 overlay #2c2942 */
59
68
  --hsl-popover-foreground: 240 6% 93%;
60
69
  --hsl-primary: 239 84% 62%;
61
70
  --hsl-primary-foreground: 0 0% 100%;
62
- --hsl-secondary: 240 5% 15%; /* #242428 surface-2 */
71
+ --hsl-secondary: 249 24% 16%; /* L3 #221f33 */
63
72
  --hsl-secondary-foreground: 240 6% 93%;
64
- --hsl-muted: 240 5% 15%; /* #242428 — neutral hover well */
65
- --hsl-muted-foreground: 240 4% 64%; /* #9c9ca6 text-muted */
66
- --hsl-accent: 240 5% 15%; /* neutral hover (indigo lives in accent-surface-strong) */
73
+ --hsl-muted: 249 24% 16%; /* L3 hover well */
74
+ --hsl-muted-foreground: 240 5% 68%; /* #a9a9b1 — high enough that the
75
+ reduced-opacity caption derivatives (/70, /60) still clear the large-text
76
+ contrast floor against the L2 card. */
77
+ --hsl-accent: 249 24% 16%; /* neutral hover (indigo lives in accent-surface-strong) */
67
78
  --hsl-accent-foreground: 240 6% 93%;
68
79
  --hsl-destructive: 348 74% 48%;
69
80
  --hsl-destructive-foreground: 0 0% 100%;
70
- --hsl-border: 240 5% 16%; /* soft edge — fill carries separation, not the line */
71
- --hsl-input: 240 5% 15%;
81
+ --hsl-border: 243 19.6% 20%; /* #2a293d = --md3-outline-variant */
82
+ --hsl-input: 244 29% 9%; /* recessed field well #12111f */
72
83
  --hsl-ring: 239 84% 67%;
73
84
  --hsl-success: 160 84% 39%;
74
85
  --hsl-warning: 40 94% 56%;
@@ -92,6 +103,11 @@
92
103
  --destructive: var(--hsl-destructive);
93
104
  --destructive-foreground: var(--hsl-destructive-foreground);
94
105
  --border: var(--hsl-border);
106
+ /* shadcn's `--input` is the input BORDER and the Switch's off-track — a
107
+ * mid-tone that has to stay visible AGAINST a surface. It is deliberately NOT
108
+ * the field fill: pointing it at the recessed well would tint a border and a
109
+ * track the same color as the plane behind them, and both would vanish. The
110
+ * field fill is --hsl-input / --bg-input below. */
95
111
  --input: var(--hsl-muted-foreground);
96
112
  --ring: var(--hsl-ring);
97
113
  --success: var(--hsl-success);
@@ -99,21 +115,22 @@
99
115
  --error: var(--hsl-destructive);
100
116
  --info: var(--hsl-info);
101
117
 
102
- /* Sidebar tokens — flat surface + hairline */
103
- --sidebar-background: 240 6% 11%; /* #1a1a1f surface */
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 */
104
121
  --sidebar-foreground: 240 6% 93%;
105
122
  --sidebar-primary: 239 84% 67%;
106
123
  --sidebar-primary-foreground: 0 0% 100%;
107
- --sidebar-accent: 240 5% 15%; /* neutral hover */
124
+ --sidebar-accent: 249 24% 16%; /* L3 hover well */
108
125
  --sidebar-accent-foreground: 240 6% 93%;
109
- --sidebar-border: 240 5% 16%; /* soft edge */
126
+ --sidebar-border: 243 19.6% 20%; /* = --md3-outline-variant */
110
127
  --sidebar-ring: 239 84% 67%;
111
128
 
112
- /* Depth scale — flat neutral spine (Tangle Quiet) */
113
- --depth-1: #15151a;
114
- --depth-2: #1f1f23;
115
- --depth-3: #26262b;
116
- --depth-4: #2e2e34;
129
+ /* 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;
117
134
 
118
135
  /* Status colors */
119
136
  --status-running: #10B981;
@@ -157,7 +174,7 @@
157
174
  --glass-border-hover: var(--border-default);
158
175
  --glass-blur: 0px;
159
176
 
160
- /* Mesh glows — quieted to off; chrome is flat in Tangle Quiet */
177
+ /* Mesh glows — off; depth comes from the ladder, not from glow */
161
178
  --mesh-teal: transparent;
162
179
  --mesh-violet: transparent;
163
180
  --mesh-blue: transparent;
@@ -171,13 +188,13 @@
171
188
  --accent-surface-strong: rgba(129, 140, 248, 0.14);
172
189
  --accent-text: #b9bffb;
173
190
 
174
- /* Surfaces — flat single-neutral (no radial mesh) */
175
- --bg-root: #15151a;
191
+ /* Surfaces — solid fills off the ladder (no radial mesh) */
192
+ --bg-root: #0a0a14;
176
193
  --bg-dark: var(--depth-1);
177
194
  --bg-card: var(--depth-2);
178
195
  --bg-elevated: var(--depth-4);
179
196
  --bg-section: var(--depth-1);
180
- --bg-input: var(--depth-3);
197
+ --bg-input: hsl(var(--hsl-input)); /* the field well — same color, hex-free */
181
198
  --bg-hover: rgba(255, 255, 255, 0.04);
182
199
  --bg-selection: rgba(129, 140, 248, 0.14);
183
200
 
@@ -319,15 +336,21 @@
319
336
  /* Light theme — clean professional, warm purple accents */
320
337
 
321
338
  /* MD3 surface hierarchy */
322
- --md3-surface: #f8f9fb;
323
- --md3-surface-dim: #d9dae0;
324
- --md3-surface-bright: #f8f9fb;
325
- --md3-surface-container-lowest: #ffffff;
326
- --md3-surface-container-low: #f3f4f7;
327
- --md3-surface-container: #edeef2;
328
- --md3-surface-container-high: #e7e8ed;
329
- --md3-surface-container-highest: #e1e2e8;
330
- --md3-surface-variant: #e1e2e8;
339
+ /* The light ladder ALTERNATES; it is not monotonic, and that is deliberate.
340
+ * On a tinted canvas, a raised plane is PAPER (white) and a recessed one is a
341
+ * tinted WELL. So card and overlay are both white and separate from each other
342
+ * by shadow and ring, not by fill — which is how light-mode elevation actually
343
+ * reads. Forcing a strictly-darkening ladder here is what produced the
344
+ * 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;
347
+ --md3-surface-bright: #ffffff;
348
+ --md3-surface-container-lowest: #ffffff; /* insets read as paper */
349
+ --md3-surface-container-low: #f6f7fa; /* L1 chrome, field wells — well */
350
+ --md3-surface-container: #ffffff; /* L2 resting card — paper */
351
+ --md3-surface-container-high: #f1f2f7; /* L3 nested / hover — well */
352
+ --md3-surface-container-highest: #ffffff; /* L4 overlay — paper + shadow */
353
+ --md3-surface-variant: #f1f2f7;
331
354
  --md3-on-surface: #191c24;
332
355
  --md3-on-surface-variant: #464858;
333
356
 
@@ -361,34 +384,37 @@
361
384
  --md3-inverse-on-surface: #f0f1f4;
362
385
  --md3-surface-tint: #6366F1;
363
386
 
364
- /* Semantic HSL bridge — flat neutral light (Tangle Quiet) */
365
- --hsl-background: 0 0% 100%; /* #ffffff */
387
+ /* Semantic HSL bridge — white paper on a tinted canvas */
388
+ --hsl-background: 223 23% 94%; /* L0 canvas #eceef3 */
366
389
  --hsl-foreground: 0 0% 5%; /* #0d0d0d */
367
- --hsl-card: 240 7% 97%; /* #f7f7f8 surface */
390
+ --hsl-card: 0 0% 100%; /* L2 card = white paper */
368
391
  --hsl-card-foreground: 0 0% 5%;
369
392
  --hsl-popover: 0 0% 100%;
370
393
  --hsl-popover-foreground: 0 0% 5%;
371
394
  --hsl-primary: 245 62% 57%; /* #5B4ED4 light accent */
372
395
  --hsl-primary-foreground: 0 0% 100%;
373
- --hsl-secondary: 240 6% 93%; /* #ececee surface-2 */
396
+ --hsl-secondary: 230 27% 96%; /* L3 #f1f2f7 */
374
397
  --hsl-secondary-foreground: 0 0% 10%;
375
- --hsl-muted: 240 6% 93%; /* #ececee neutral hover */
398
+ --hsl-muted: 230 27% 96%; /* L3 hover well */
376
399
  --hsl-muted-foreground: 240 5% 38%; /* #5d5d66 text-muted */
377
- --hsl-accent: 240 6% 93%; /* neutral hover */
400
+ --hsl-accent: 230 27% 96%; /* neutral hover */
378
401
  --hsl-accent-foreground: 0 0% 10%;
379
402
  --hsl-destructive: 0 72% 41%;
380
403
  --hsl-destructive-foreground: 0 0% 100%;
381
- --hsl-border: 240 6% 89%; /* hairline ≈ rgba(0,0,0,0.08) */
382
- --hsl-input: 240 6% 89%;
404
+ --hsl-border: 244 16.3% 80.8%; /* #c7c6d6 = --md3-outline-variant */
405
+ --hsl-input: 225 29% 97%; /* field well #f6f7fa */
383
406
  --hsl-ring: 245 62% 57%;
384
407
  --hsl-success: 160 84% 30%;
385
408
  --hsl-warning: 41 96% 50%;
386
409
  --hsl-info: 212 80% 50%;
387
410
 
388
- --depth-1: #ffffff;
389
- --depth-2: #f7f7f8;
390
- --depth-3: #ececee;
391
- --depth-4: #e3e3e6;
411
+ /* depth-2 (card) and depth-4 (elevated) are BOTH paper, on purpose: in light,
412
+ * a popover does not out-lighten a white card — it lifts by shadow. See the
413
+ * ladder note above. */
414
+ --depth-1: #eceef3; /* canvas */
415
+ --depth-2: #ffffff; /* card — paper */
416
+ --depth-3: #f1f2f7; /* nested well */
417
+ --depth-4: #ffffff; /* elevated — paper, separated by shadow */
392
418
 
393
419
  --status-running: #059669;
394
420
  --status-creating: #3E349E;
@@ -430,13 +456,13 @@
430
456
  --surface-neutral-text: var(--text-muted);
431
457
 
432
458
  /* Sidebar — light, flat surface + hairline */
433
- --sidebar-background: 240 7% 97%; /* #f7f7f8 surface */
459
+ --sidebar-background: 0 0% 100%; /* white paper */
434
460
  --sidebar-foreground: 0 0% 5%;
435
461
  --sidebar-primary: 245 62% 57%; /* #5B4ED4 */
436
462
  --sidebar-primary-foreground: 0 0% 100%;
437
- --sidebar-accent: 240 6% 93%; /* neutral hover */
463
+ --sidebar-accent: 230 27% 96%; /* L3 hover well */
438
464
  --sidebar-accent-foreground: 0 0% 10%;
439
- --sidebar-border: 240 6% 89%; /* hairline */
465
+ --sidebar-border: 244 16.3% 80.8%; /* = --md3-outline-variant */
440
466
  --sidebar-ring: 245 62% 57%;
441
467
 
442
468
  --mesh-teal: transparent;
@@ -444,12 +470,17 @@
444
470
  --mesh-blue: transparent;
445
471
 
446
472
  /* Surfaces — flat single-neutral light */
447
- --bg-root: #ffffff;
448
- --bg-dark: #f7f7f8;
449
- --bg-card: #f7f7f8;
450
- --bg-elevated: #ffffff;
451
- --bg-section: #f7f7f8;
452
- --bg-input: #ececee;
473
+ --bg-root: #eceef3;
474
+ --bg-dark: var(--depth-1);
475
+ --bg-card: var(--depth-2);
476
+ /* Paper, exactly like --bg-card: in light you cannot raise a plane by making it
477
+ * lighter than white, so an overlay lifts by SHADOW. A consumer that fills with
478
+ * --bg-elevated and applies no shadow gets no separation from a card — that is
479
+ * the contract, not an oversight (--hsl-popover is white for the same reason).
480
+ * Pair it with --shadow-dropdown. */
481
+ --bg-elevated: var(--depth-4);
482
+ --bg-section: var(--depth-1);
483
+ --bg-input: hsl(var(--hsl-input)); /* the field well — same color, hex-free */
453
484
  --bg-hover: rgba(0, 0, 0, 0.04);
454
485
  --bg-selection: rgba(91, 78, 212, 0.10);
455
486
 
@@ -0,0 +1,193 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ blockIn,
6
+ compositeOver,
7
+ contrastRatio,
8
+ hexRelativeLuminanceIn,
9
+ hslIn,
10
+ hslToRgb,
11
+ } from "./css-test-utils";
12
+
13
+ /**
14
+ * The canonical spine's structural contract.
15
+ *
16
+ * The whole point of this palette is that a surface separates from the one below
17
+ * it by its own FILL. A flat, desaturated ladder pushes that job onto borders,
18
+ * and the app reads grey and washed out — which is the state this replaced. The
19
+ * numbers are therefore not decoration; they are the contract. These assertions
20
+ * pin the PROPERTIES rather than the exact values, so a retune stays free but a
21
+ * collapse back to flat does not.
22
+ */
23
+ const tokens = readFileSync(
24
+ path.resolve(import.meta.dirname, "tokens.css"),
25
+ "utf8",
26
+ );
27
+
28
+ // Each spine is declared on a multi-selector header
29
+ // (`:root, [data-sandbox-ui], [data-theme="dark"], .dark {`), so anchor on the
30
+ // LAST selector — the one the opening brace actually follows.
31
+ const DARK = blockIn(tokens, ".dark");
32
+ const LIGHT = blockIn(tokens, ".light");
33
+
34
+ describe("canonical dark spine", () => {
35
+ it("is indigo-cast, not grey — the canvas carries real saturation", () => {
36
+ const bg = hslIn(DARK, "hsl-background");
37
+ expect(bg.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
38
+ 235,
39
+ );
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
+ });
47
+
48
+ it("lifts the card off the canvas by fill, not by border", () => {
49
+ const canvas = hslIn(DARK, "hsl-background");
50
+ const card = hslIn(DARK, "hsl-card");
51
+ expect(card.l, "the card must be lighter than the canvas").toBeGreaterThan(
52
+ canvas.l,
53
+ );
54
+ expect(
55
+ card.l - canvas.l,
56
+ "a <3% step is the flat ladder that forces separation onto borders",
57
+ ).toBeGreaterThanOrEqual(3);
58
+ });
59
+
60
+ it("defines the accent-TEXT token a named theme is forbidden from recoloring", () => {
61
+ // named-themes.test.ts asserts no named theme overrides --accent-text. That
62
+ // assertion is only meaningful if the base spine actually defines it.
63
+ expect(DARK).toContain("--accent-text:");
64
+ expect(LIGHT).toContain("--accent-text:");
65
+ });
66
+
67
+ it("steps canvas -> card -> nested -> overlay monotonically upward", () => {
68
+ const ladder = [
69
+ "md3-surface",
70
+ "md3-surface-container",
71
+ "md3-surface-container-high",
72
+ "md3-surface-container-highest",
73
+ ].map((t) => hexRelativeLuminanceIn(DARK, t));
74
+ for (let i = 1; i < ladder.length; i++) {
75
+ expect(
76
+ ladder[i],
77
+ `elevation step ${i} must be lighter than the plane below it`,
78
+ ).toBeGreaterThan(ladder[i - 1]);
79
+ }
80
+ });
81
+ });
82
+
83
+ describe("muted text stays legible at the opacities the UI actually uses", () => {
84
+ // The token is never rendered at full strength in the places that matter: the
85
+ // shared components fade captions with `/70` and `/60`, and a translucent
86
+ // foreground does not render the token's color — it renders the token composited
87
+ // over whatever is behind it. So the contrast that counts is the composited one,
88
+ // and asserting it here is the difference between a claim and a guarantee.
89
+ const CARD = hslToRgb(hslIn(DARK, "hsl-card"));
90
+ const MUTED = hslToRgb(hslIn(DARK, "hsl-muted-foreground"));
91
+
92
+ it("clears AA for normal text at full strength", () => {
93
+ expect(contrastRatio(MUTED, CARD)).toBeGreaterThanOrEqual(4.5);
94
+ });
95
+
96
+ for (const alpha of [0.7, 0.6]) {
97
+ it(`clears the 3:1 large-text floor at /${alpha * 100}`, () => {
98
+ const faded = compositeOver(MUTED, CARD, alpha);
99
+ expect(contrastRatio(faded, CARD)).toBeGreaterThanOrEqual(3);
100
+ });
101
+ }
102
+ });
103
+
104
+ describe("input tokens carry two DIFFERENT roles and must not be conflated", () => {
105
+ // `--input` is shadcn's input BORDER and the Switch off-track: a mid-tone that
106
+ // must stay visible AGAINST a surface. `--hsl-input` / `--bg-input` are the
107
+ // recessed field FILL. Pointing `--input` at the fill is a tempting-looking
108
+ // "fix" that makes every input border and every off-state switch track vanish
109
+ // into the plane behind it — a legibility bug with no error and no visual clue
110
+ // beyond "the toggle disappeared". Pinned so it can't happen quietly.
111
+ it("--input is the border/track token, not the field fill", () => {
112
+ // The shadcn alias layer is declared ONCE and re-resolves per theme through
113
+ // the --hsl-* spine, so this is asserted on the file rather than per scope.
114
+ expect(tokens).toMatch(/--input:\s*var\(--hsl-muted-foreground\)/);
115
+ expect(
116
+ tokens,
117
+ "--input must not resolve to the field well — input borders and off-state switch tracks would disappear into the plane behind them",
118
+ ).not.toMatch(/--input:\s*var\(--hsl-input\)/);
119
+ });
120
+
121
+ for (const [name, spine] of [
122
+ ["dark", DARK],
123
+ ["light", LIGHT],
124
+ ] as const) {
125
+ it(`${name}: the field fill is single-sourced from --hsl-input`, () => {
126
+ expect(spine).toMatch(/--bg-input:\s*hsl\(var\(--hsl-input\)\)/);
127
+ });
128
+ }
129
+ });
130
+
131
+ describe("canonical light spine", () => {
132
+ it("is white paper on a TINTED canvas, never white-on-white", () => {
133
+ const canvas = hslIn(LIGHT, "hsl-background");
134
+ const card = hslIn(LIGHT, "hsl-card");
135
+ expect(card.l, "the card is paper").toBe(100);
136
+ expect(
137
+ canvas.l,
138
+ "a pure-white canvas gives a white card nothing to lift off",
139
+ ).toBeLessThan(97);
140
+ expect(
141
+ canvas.s,
142
+ "the canvas carries a tint, not flat grey",
143
+ ).toBeGreaterThan(10);
144
+ });
145
+
146
+ it("ALTERNATES paper and well — light elevation is not a darkening ramp", () => {
147
+ // Deliberately not monotonic. On a tinted canvas a raised plane is paper and
148
+ // a recessed one is a tinted well, so the ladder alternates. Pinning it stops
149
+ // a well-meaning "fix" from forcing a strictly-ordered ramp, which is exactly
150
+ // what produced the white-on-white flatness this palette replaced.
151
+ const paper = ["md3-surface-container", "md3-surface-container-highest"];
152
+ const wells = ["md3-surface-container-low", "md3-surface-container-high"];
153
+ const canvas = hexRelativeLuminanceIn(LIGHT, "md3-surface");
154
+
155
+ for (const t of paper) {
156
+ expect(hexRelativeLuminanceIn(LIGHT, t), `${t} is paper`).toBe(1);
157
+ }
158
+ for (const t of wells) {
159
+ const l = hexRelativeLuminanceIn(LIGHT, t);
160
+ expect(l, `${t} is a well — below paper`).toBeLessThan(1);
161
+ expect(l, `${t} still sits above the canvas`).toBeGreaterThan(canvas);
162
+ }
163
+ });
164
+
165
+ it("lets the overlay share the card's fill — it lifts by shadow, not lightness", () => {
166
+ // `--bg-card` (depth-2) and `--bg-elevated` (depth-4) are both paper by design;
167
+ // the semantic layer agrees (--hsl-card and --hsl-popover are both white). The
168
+ // separation is the shadow, so a shadow token must exist to carry it.
169
+ expect(hexRelativeLuminanceIn(LIGHT, "depth-2")).toBe(1);
170
+ expect(hexRelativeLuminanceIn(LIGHT, "depth-4")).toBe(1);
171
+ expect(LIGHT, "the overlay's separation depends on this").toMatch(
172
+ /--shadow-dropdown:/,
173
+ );
174
+ });
175
+
176
+ it("keeps the light canvas single-sourced — --depth-1 and --md3-surface agree", () => {
177
+ // Both are the L0 page canvas. Two hex literals for one plane is a divergence
178
+ // waiting to happen the next time one of them is retuned.
179
+ const canvasHex = (token: string) =>
180
+ LIGHT.match(new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`))?.[1];
181
+ expect(canvasHex("depth-1")).toBe(canvasHex("md3-surface"));
182
+ });
183
+
184
+ it("keeps the derived --bg-* surfaces on the ladder", () => {
185
+ // Hardcoded hex here is how `bg-card` (→ --hsl-card) and `var(--bg-card)`
186
+ // drifted into disagreeing about what a card is.
187
+ for (const token of ["bg-dark", "bg-card", "bg-elevated", "bg-section"]) {
188
+ expect(LIGHT, `--${token} must derive from the ladder`).toMatch(
189
+ new RegExp(`--${token}:\\s*var\\(--depth-`),
190
+ );
191
+ }
192
+ });
193
+ });