@tangle-network/brand 1.0.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/package.json +5 -4
- package/src/styles/css-test-utils.test.ts +79 -0
- package/src/styles/css-test-utils.ts +186 -0
- package/src/styles/index.css +1 -1
- package/src/styles/{themes.css → named-themes.css} +93 -3
- package/src/styles/named-themes.test.ts +304 -0
- package/src/styles/tokens.css +231 -141
- package/src/styles/tokens.test.ts +314 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
blockIn,
|
|
6
|
+
blocksIn,
|
|
7
|
+
contrastRatio,
|
|
8
|
+
declIn,
|
|
9
|
+
hexIn,
|
|
10
|
+
hexRelativeLuminanceIn,
|
|
11
|
+
hslIn,
|
|
12
|
+
hslToRgb,
|
|
13
|
+
} from "./css-test-utils";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The named-theme contract.
|
|
17
|
+
*
|
|
18
|
+
* A named theme re-skins the SURFACE ladder and nothing else — the Tangle accent
|
|
19
|
+
* (primary / ring / accent-text) stays put, so a product carries its identity in
|
|
20
|
+
* its planes, not by inventing a second brand colour. These assertions live here
|
|
21
|
+
* rather than in the consuming app because the palette lives here: an app's job
|
|
22
|
+
* is only to opt in.
|
|
23
|
+
*/
|
|
24
|
+
const themes = readFileSync(
|
|
25
|
+
path.resolve(import.meta.dirname, "named-themes.css"),
|
|
26
|
+
"utf8",
|
|
27
|
+
);
|
|
28
|
+
const tokens = readFileSync(
|
|
29
|
+
path.resolve(import.meta.dirname, "tokens.css"),
|
|
30
|
+
"utf8",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// The canonical dark spine, anchored on the LAST selector of its multi-selector
|
|
34
|
+
// header (`:root, [data-sandbox-ui], [data-theme="dark"], .dark {`) — the same
|
|
35
|
+
// trick tokens.test.ts uses.
|
|
36
|
+
const CANON = blockIn(tokens, ".dark");
|
|
37
|
+
|
|
38
|
+
function block(selector: string): string {
|
|
39
|
+
return blockIn(themes, selector);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('named theme: .dark[data-theme="intelligence"]', () => {
|
|
43
|
+
const css = block('.dark[data-theme="intelligence"]');
|
|
44
|
+
|
|
45
|
+
it("retints the surface canvas to violet", () => {
|
|
46
|
+
for (const token of ["background", "card", "popover", "muted", "border"]) {
|
|
47
|
+
const { h } = hslIn(css, `hsl-${token}`);
|
|
48
|
+
expect(h, `--hsl-${token} hue`).toBeGreaterThanOrEqual(255);
|
|
49
|
+
expect(h, `--hsl-${token} hue`).toBeLessThanOrEqual(268);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("reads as graded depth, not mud: lightness rises, saturation tapers, hue locked", () => {
|
|
54
|
+
const ladder = ["hsl-background", "hsl-card", "hsl-secondary"].map((t) =>
|
|
55
|
+
hslIn(css, t),
|
|
56
|
+
);
|
|
57
|
+
for (let i = 1; i < ladder.length; i++) {
|
|
58
|
+
expect(ladder[i].l, `step ${i} lightness must rise`).toBeGreaterThan(
|
|
59
|
+
ladder[i - 1].l,
|
|
60
|
+
);
|
|
61
|
+
expect(
|
|
62
|
+
ladder[i].s,
|
|
63
|
+
`step ${i} saturation must not rise as it lightens`,
|
|
64
|
+
).toBeLessThanOrEqual(ladder[i - 1].s);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("never recolors the Tangle accent — surfaces carry the identity, not the brand", () => {
|
|
69
|
+
for (const token of ["--hsl-primary", "--hsl-ring", "--accent-text"]) {
|
|
70
|
+
expect(css, `${token} must not be overridden by a named theme`).not.toContain(
|
|
71
|
+
`${token}:`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("is dark-only — the SELECTOR enforces it, not the caller", () => {
|
|
77
|
+
// An unscoped `[data-theme="intelligence"]` would repaint every surface with
|
|
78
|
+
// the violet dark ramp if a consumer left the attribute on while switching to
|
|
79
|
+
// light. The `.dark` requirement makes that impossible.
|
|
80
|
+
//
|
|
81
|
+
// Checked against the RULES, not the raw file: prose in a comment may well
|
|
82
|
+
// spell the selector out, and a doc edit must not be able to fail this.
|
|
83
|
+
const rules = themes.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
84
|
+
expect(rules).toContain('.dark[data-theme="intelligence"]');
|
|
85
|
+
expect(rules).not.toMatch(/(?<!\.dark)\[data-theme="intelligence"\]/);
|
|
86
|
+
// No light variant: light falls through to brand's canonical light spine.
|
|
87
|
+
expect(rules).not.toContain('[data-theme="intelligence-light"]');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('named theme: [data-theme="tangle-dark"]', () => {
|
|
92
|
+
const css = block('[data-theme="tangle-dark"]');
|
|
93
|
+
// `[data-theme="tangle-light"]` heads two rules — the shared light-status
|
|
94
|
+
// override group AND the theme's own block; the theme block is the one that
|
|
95
|
+
// declares the spine.
|
|
96
|
+
const light = blocksIn(themes, '[data-theme="tangle-light"]').find((b) =>
|
|
97
|
+
b.includes("--hsl-background"),
|
|
98
|
+
);
|
|
99
|
+
if (!light) throw new Error('missing theme block: [data-theme="tangle-light"]');
|
|
100
|
+
|
|
101
|
+
it("IS the canonical dark spine, named — no drift from tokens.css", () => {
|
|
102
|
+
// tangle-dark exists so the attribute can NAME the default spine (a dark
|
|
103
|
+
// island in a light-named wrapper, a picker's explicit selection). If its
|
|
104
|
+
// values diverge from :root, "tangle dark" becomes two palettes.
|
|
105
|
+
const hslTokens = [
|
|
106
|
+
"hsl-background",
|
|
107
|
+
"hsl-foreground",
|
|
108
|
+
"hsl-card",
|
|
109
|
+
"hsl-card-foreground",
|
|
110
|
+
"hsl-popover",
|
|
111
|
+
"hsl-popover-foreground",
|
|
112
|
+
"hsl-primary",
|
|
113
|
+
"hsl-primary-foreground",
|
|
114
|
+
"hsl-secondary",
|
|
115
|
+
"hsl-secondary-foreground",
|
|
116
|
+
"hsl-muted",
|
|
117
|
+
"hsl-muted-foreground",
|
|
118
|
+
"hsl-accent",
|
|
119
|
+
"hsl-accent-foreground",
|
|
120
|
+
"hsl-destructive",
|
|
121
|
+
"hsl-destructive-foreground",
|
|
122
|
+
"hsl-border",
|
|
123
|
+
"hsl-input",
|
|
124
|
+
"hsl-ring",
|
|
125
|
+
];
|
|
126
|
+
for (const token of hslTokens) {
|
|
127
|
+
expect(hslIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
|
|
128
|
+
hslIn(CANON, token),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const hexTokens = [
|
|
132
|
+
"depth-1",
|
|
133
|
+
"depth-2",
|
|
134
|
+
"depth-3",
|
|
135
|
+
"depth-4",
|
|
136
|
+
"md3-surface-container-lowest",
|
|
137
|
+
"md3-surface-container-low",
|
|
138
|
+
"md3-surface-container",
|
|
139
|
+
"md3-surface-container-high",
|
|
140
|
+
"md3-surface-container-highest",
|
|
141
|
+
"md3-outline-variant",
|
|
142
|
+
"accent-text",
|
|
143
|
+
"brand-primary",
|
|
144
|
+
"brand-glow",
|
|
145
|
+
"text-primary",
|
|
146
|
+
"text-secondary",
|
|
147
|
+
"text-muted",
|
|
148
|
+
"text-dim",
|
|
149
|
+
];
|
|
150
|
+
for (const token of hexTokens) {
|
|
151
|
+
expect(hexIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
|
|
152
|
+
hexIn(CANON, token),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Aliased and rgba declarations don't fit hslIn/hexIn — compare them raw.
|
|
156
|
+
for (const token of [
|
|
157
|
+
"bg-root",
|
|
158
|
+
"bg-card",
|
|
159
|
+
"bg-input",
|
|
160
|
+
"border-subtle",
|
|
161
|
+
"border-default",
|
|
162
|
+
"border-hover",
|
|
163
|
+
]) {
|
|
164
|
+
expect(declIn(css, token), `--${token} must equal the :root dark spine`).toBe(
|
|
165
|
+
declIn(CANON, token),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("shares tangle-light's indigo hue family — same brand, dark register", () => {
|
|
171
|
+
for (const token of ["hsl-primary", "hsl-ring", "hsl-background"]) {
|
|
172
|
+
const dark = hslIn(css, token);
|
|
173
|
+
const lite = hslIn(light, token);
|
|
174
|
+
expect(
|
|
175
|
+
Math.abs(dark.h - lite.h),
|
|
176
|
+
`--${token} hue must sit within 10 of tangle-light's`,
|
|
177
|
+
).toBeLessThanOrEqual(10);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("tints its neutrals with the hue — nothing achromatic", () => {
|
|
182
|
+
for (const token of [
|
|
183
|
+
"hsl-background",
|
|
184
|
+
"hsl-card",
|
|
185
|
+
"hsl-popover",
|
|
186
|
+
"hsl-secondary",
|
|
187
|
+
"hsl-muted",
|
|
188
|
+
"hsl-accent",
|
|
189
|
+
"hsl-border",
|
|
190
|
+
"hsl-input",
|
|
191
|
+
]) {
|
|
192
|
+
const { h, s } = hslIn(css, token);
|
|
193
|
+
expect(h, `--${token} hue in the indigo band`).toBeGreaterThanOrEqual(220);
|
|
194
|
+
expect(h, `--${token} hue in the indigo band`).toBeLessThanOrEqual(250);
|
|
195
|
+
expect(s, `--${token} is hue-tinted, not dead grey`).toBeGreaterThan(5);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("elevates by lightening, never darkening", () => {
|
|
200
|
+
const planes = ["depth-1", "depth-2", "depth-3", "depth-4"].map((t) =>
|
|
201
|
+
hexRelativeLuminanceIn(css, t),
|
|
202
|
+
);
|
|
203
|
+
for (let i = 1; i < planes.length; i++) {
|
|
204
|
+
expect(planes[i], `depth step ${i} must be brighter than the one below`).toBeGreaterThan(
|
|
205
|
+
planes[i - 1],
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const ladder = ["hsl-background", "hsl-card", "hsl-popover"].map(
|
|
209
|
+
(t) => hslIn(css, t).l,
|
|
210
|
+
);
|
|
211
|
+
for (let i = 1; i < ladder.length; i++) {
|
|
212
|
+
expect(ladder[i], `step ${i} lightness must rise`).toBeGreaterThan(
|
|
213
|
+
ladder[i - 1],
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("holds the dark contrast discipline — AA floors, AAA ink", () => {
|
|
219
|
+
const canvas = hslToRgb(hslIn(css, "hsl-background"));
|
|
220
|
+
const card = hslToRgb(hslIn(css, "hsl-card"));
|
|
221
|
+
const fg = hslToRgb(hslIn(css, "hsl-foreground"));
|
|
222
|
+
expect(contrastRatio(fg, canvas), "ink on canvas").toBeGreaterThanOrEqual(7);
|
|
223
|
+
expect(contrastRatio(fg, card), "ink on card").toBeGreaterThanOrEqual(7);
|
|
224
|
+
|
|
225
|
+
const muted = hslToRgb(hslIn(css, "hsl-muted-foreground"));
|
|
226
|
+
expect(contrastRatio(muted, card), "muted ink on card").toBeGreaterThanOrEqual(
|
|
227
|
+
4.5,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const primary = hslToRgb(hslIn(css, "hsl-primary"));
|
|
231
|
+
const onPrimary = hslToRgb(hslIn(css, "hsl-primary-foreground"));
|
|
232
|
+
expect(
|
|
233
|
+
contrastRatio(onPrimary, primary),
|
|
234
|
+
"the fill indigo carries white",
|
|
235
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
236
|
+
|
|
237
|
+
// The ring is NON-TEXT: it must clear 3:1 against both planes it sits on.
|
|
238
|
+
const ring = hslToRgb(hslIn(css, "hsl-ring"));
|
|
239
|
+
expect(contrastRatio(ring, canvas), "ring on canvas").toBeGreaterThanOrEqual(3);
|
|
240
|
+
expect(contrastRatio(ring, card), "ring on card").toBeGreaterThanOrEqual(3);
|
|
241
|
+
|
|
242
|
+
expect(
|
|
243
|
+
contrastRatio(hexIn(css, "accent-text"), card),
|
|
244
|
+
"bright indigo accent text on card",
|
|
245
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
246
|
+
|
|
247
|
+
// The faintest tier is still TEXT: it clears the body floor on both planes.
|
|
248
|
+
const dim = hexIn(css, "text-dim");
|
|
249
|
+
expect(contrastRatio(dim, card), "text-dim on card").toBeGreaterThanOrEqual(4.5);
|
|
250
|
+
expect(contrastRatio(dim, canvas), "text-dim on canvas").toBeGreaterThanOrEqual(
|
|
251
|
+
4.5,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("is self-sufficient inside a light-named wrapper — the island it exists for", () => {
|
|
256
|
+
// The feature's stated scenario: tangle-dark nested in a tangle-light
|
|
257
|
+
// wrapper. The browser composes the two scopes, so a token the island omits
|
|
258
|
+
// inherits the WRAPPER's light value, not :root's — the light group's
|
|
259
|
+
// near-black secondary-foreground on the dark secondary measured ~1.5:1
|
|
260
|
+
// before the block redeclared its foregrounds. Model that cascade: every
|
|
261
|
+
// tangle-light rule (the shared light group AND the theme block), with the
|
|
262
|
+
// island's declarations overriding.
|
|
263
|
+
const wrapper = blocksIn(themes, '[data-theme="tangle-light"]').join("\n");
|
|
264
|
+
const effective = (token: string) => {
|
|
265
|
+
const read = (source: string) => {
|
|
266
|
+
try {
|
|
267
|
+
return hslIn(source, token);
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const value = read(css) ?? read(wrapper);
|
|
273
|
+
if (!value) {
|
|
274
|
+
throw new Error(`--${token} resolves to nothing inside the island`);
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
};
|
|
278
|
+
for (const [fgToken, bgToken] of [
|
|
279
|
+
["hsl-secondary-foreground", "hsl-secondary"],
|
|
280
|
+
["hsl-accent-foreground", "hsl-accent"],
|
|
281
|
+
["hsl-destructive-foreground", "hsl-destructive"],
|
|
282
|
+
] as const) {
|
|
283
|
+
expect(
|
|
284
|
+
contrastRatio(hslToRgb(effective(fgToken)), hslToRgb(effective(bgToken))),
|
|
285
|
+
`effective ${fgToken} on ${bgToken}, island inside tangle-light`,
|
|
286
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("keeps the dark-tuned :root status palette instead of redeclaring it", () => {
|
|
291
|
+
// Same contract as aubergine/arena: dark named themes inherit status from the
|
|
292
|
+
// dark spine, so there is exactly one dark status palette to tune. What must
|
|
293
|
+
// be proven is that the inherited pairs pass AA on their dark surfaces.
|
|
294
|
+
expect(css).not.toMatch(/--surface-(success|warning|danger|info)-/);
|
|
295
|
+
for (const name of ["success", "warning", "danger", "info"]) {
|
|
296
|
+
const text = hexIn(CANON, `surface-${name}-text`);
|
|
297
|
+
const bg = hexIn(CANON, `surface-${name}-bg`);
|
|
298
|
+
expect(
|
|
299
|
+
contrastRatio(text, bg),
|
|
300
|
+
`inherited ${name} status text on its dark surface`,
|
|
301
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|