@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.
@@ -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
+ });