@tangle-network/brand 1.0.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 +14 -0
- package/package.json +3 -2
- package/src/styles/css-test-utils.test.ts +79 -0
- package/src/styles/css-test-utils.ts +152 -0
- package/src/styles/index.css +1 -1
- package/src/styles/{themes.css → named-themes.css} +55 -0
- package/src/styles/named-themes.test.ts +71 -0
- package/src/styles/tokens.css +96 -65
- package/src/styles/tokens.test.ts +193 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 1.0.0
|
|
4
18
|
|
|
5
19
|
### Major Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/brand",
|
|
3
|
-
"version": "1.
|
|
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
|
+
}
|
package/src/styles/index.css
CHANGED
|
@@ -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
|
+
});
|
package/src/styles/tokens.css
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
-
/* Tangle
|
|
2
|
-
|
|
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: #
|
|
13
|
-
--md3-surface-dim: #
|
|
14
|
-
--md3-surface-bright: #
|
|
15
|
-
--md3-surface-container-lowest: #
|
|
16
|
-
--md3-surface-container-low: #
|
|
17
|
-
--md3-surface-container: #
|
|
18
|
-
--md3-surface-container-high: #
|
|
19
|
-
--md3-surface-container-highest: #
|
|
20
|
-
--md3-surface-variant: #
|
|
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: #
|
|
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 —
|
|
54
|
-
--hsl-background: 240
|
|
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:
|
|
65
|
+
--hsl-card: 244 23% 12%; /* L2 card #191826 */
|
|
57
66
|
--hsl-card-foreground: 240 6% 93%;
|
|
58
|
-
--hsl-popover:
|
|
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:
|
|
71
|
+
--hsl-secondary: 249 24% 16%; /* L3 #221f33 */
|
|
63
72
|
--hsl-secondary-foreground: 240 6% 93%;
|
|
64
|
-
--hsl-muted:
|
|
65
|
-
--hsl-muted-foreground: 240
|
|
66
|
-
|
|
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:
|
|
71
|
-
--hsl-input:
|
|
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 —
|
|
103
|
-
|
|
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:
|
|
124
|
+
--sidebar-accent: 249 24% 16%; /* L3 hover well */
|
|
108
125
|
--sidebar-accent-foreground: 240 6% 93%;
|
|
109
|
-
--sidebar-border:
|
|
126
|
+
--sidebar-border: 243 19.6% 20%; /* = --md3-outline-variant */
|
|
110
127
|
--sidebar-ring: 239 84% 67%;
|
|
111
128
|
|
|
112
|
-
/* Depth scale —
|
|
113
|
-
--depth-1: #
|
|
114
|
-
--depth-2: #
|
|
115
|
-
--depth-3: #
|
|
116
|
-
--depth-4: #
|
|
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 —
|
|
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 —
|
|
175
|
-
--bg-root: #
|
|
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(--
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
--md3-surface
|
|
329
|
-
--md3-surface-
|
|
330
|
-
--md3-surface-
|
|
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 —
|
|
365
|
-
--hsl-background:
|
|
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:
|
|
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:
|
|
396
|
+
--hsl-secondary: 230 27% 96%; /* L3 #f1f2f7 */
|
|
374
397
|
--hsl-secondary-foreground: 0 0% 10%;
|
|
375
|
-
--hsl-muted:
|
|
398
|
+
--hsl-muted: 230 27% 96%; /* L3 hover well */
|
|
376
399
|
--hsl-muted-foreground: 240 5% 38%; /* #5d5d66 text-muted */
|
|
377
|
-
--hsl-accent:
|
|
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:
|
|
382
|
-
--hsl-input:
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
--depth-
|
|
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:
|
|
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:
|
|
463
|
+
--sidebar-accent: 230 27% 96%; /* L3 hover well */
|
|
438
464
|
--sidebar-accent-foreground: 0 0% 10%;
|
|
439
|
-
--sidebar-border:
|
|
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: #
|
|
448
|
-
--bg-dark:
|
|
449
|
-
--bg-card:
|
|
450
|
-
--bg-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
+
});
|