@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,314 @@
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("keeps the canvas in the brand's hue family, at any strength", () => {
36
+ const bg = hslIn(DARK, "hsl-background");
37
+ // HUE is the brand constraint and does not move: the greys are cool and sit
38
+ // in the indigo band, so they belong to the same family as the accent rather
39
+ // than reading as a foreign neutral pasted underneath it.
40
+ expect(bg.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
41
+ 215,
42
+ );
43
+ expect(bg.h).toBeLessThanOrEqual(260);
44
+ // SATURATION is a taste dial, not a contract. How far the surfaces lean —
45
+ // near-neutral graphite with the indigo purely in the accents, or a fully
46
+ // indigo-cast ground — is a live product decision, so the only thing pinned
47
+ // here is that the canvas is not dead grey. Asserting a band instead makes
48
+ // this test fail every time that decision is revisited, which tells you
49
+ // nothing about whether the palette is correct.
50
+ expect(bg.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
51
+ 5,
52
+ );
53
+ });
54
+
55
+ it("guards reading comfort at the INK, not at the canvas", () => {
56
+ // A deep canvas is fine — glare comes from the ratio between ink and ground,
57
+ // not from the ground alone. So the floor here is loose, and the real guard
58
+ // is the 16:1 comfort ceiling on body text in contrast.test.ts. Pinning the
59
+ // canvas instead would forbid a deep spine that is perfectly comfortable
60
+ // once its ink is tuned.
61
+ const bg = hslIn(DARK, "hsl-background");
62
+ expect(bg.l, "an unlit canvas still needs to be a surface").toBeGreaterThan(
63
+ 3,
64
+ );
65
+ });
66
+
67
+ it("lifts the card off the canvas by fill, not by border", () => {
68
+ const canvas = hslIn(DARK, "hsl-background");
69
+ const card = hslIn(DARK, "hsl-card");
70
+ expect(card.l, "the card must be lighter than the canvas").toBeGreaterThan(
71
+ canvas.l,
72
+ );
73
+ expect(
74
+ card.l - canvas.l,
75
+ "a <3% step is the flat ladder that forces separation onto borders",
76
+ ).toBeGreaterThanOrEqual(3);
77
+ });
78
+
79
+ it("defines the accent-TEXT token a named theme is forbidden from recoloring", () => {
80
+ // named-themes.test.ts asserts no named theme overrides --accent-text. That
81
+ // assertion is only meaningful if the base spine actually defines it.
82
+ expect(DARK).toContain("--accent-text:");
83
+ expect(LIGHT).toContain("--accent-text:");
84
+ });
85
+
86
+ it("steps canvas -> card -> nested -> overlay monotonically upward", () => {
87
+ const ladder = [
88
+ "md3-surface",
89
+ "md3-surface-container",
90
+ "md3-surface-container-high",
91
+ "md3-surface-container-highest",
92
+ ].map((t) => hexRelativeLuminanceIn(DARK, t));
93
+ for (let i = 1; i < ladder.length; i++) {
94
+ expect(
95
+ ladder[i],
96
+ `elevation step ${i} must be lighter than the plane below it`,
97
+ ).toBeGreaterThan(ladder[i - 1]);
98
+ }
99
+ });
100
+ });
101
+
102
+ describe("muted text stays legible at the opacities the UI actually uses", () => {
103
+ // The token is never rendered at full strength in the places that matter: the
104
+ // shared components fade captions with `/70` and `/60`, and a translucent
105
+ // foreground does not render the token's color — it renders the token composited
106
+ // over whatever is behind it. So the contrast that counts is the composited one,
107
+ // and asserting it here is the difference between a claim and a guarantee.
108
+ const CARD = hslToRgb(hslIn(DARK, "hsl-card"));
109
+ const MUTED = hslToRgb(hslIn(DARK, "hsl-muted-foreground"));
110
+
111
+ it("clears AA for normal text at full strength", () => {
112
+ expect(contrastRatio(MUTED, CARD)).toBeGreaterThanOrEqual(4.5);
113
+ });
114
+
115
+ for (const alpha of [0.7, 0.6]) {
116
+ it(`clears the 3:1 large-text floor at /${alpha * 100}`, () => {
117
+ const faded = compositeOver(MUTED, CARD, alpha);
118
+ expect(contrastRatio(faded, CARD)).toBeGreaterThanOrEqual(3);
119
+ });
120
+ }
121
+ });
122
+
123
+ describe("the sidebar is its own plane", () => {
124
+ // Which SIDE of the canvas the nav sits on is a taste call — it is chrome
125
+ // below the content in dark and paper above it in light, and either reads
126
+ // fine. What is not a taste call is the two collapsing onto the same value:
127
+ // the nav would stop being a distinct region and the page would lose the edge
128
+ // that separates navigation from content, with no border to fall back on.
129
+ // Pinned as a minimum separation so a retune can move it without erasing it.
130
+ for (const [theme, spine] of [
131
+ ["dark", DARK],
132
+ ["light", LIGHT],
133
+ ] as const) {
134
+ it(`${theme}: sidebar and canvas stay separable`, () => {
135
+ const sidebar = hslIn(spine, "sidebar-background");
136
+ const canvas = hslIn(spine, "hsl-background");
137
+ expect(
138
+ Math.abs(sidebar.l - canvas.l),
139
+ "sidebar and canvas lightness must not converge",
140
+ ).toBeGreaterThanOrEqual(2);
141
+ });
142
+ }
143
+ });
144
+
145
+ describe("a hex quoted beside an HSL token is the value that token resolves to", () => {
146
+ // The spine is authored in HSL, but it is READ in hex — every comment here
147
+ // annotates a triple with the colour it produces, because `238 20% 22%` tells
148
+ // a reader nothing. That makes the annotations load-bearing documentation, and
149
+ // load-bearing documentation that nothing checks is documentation that lies:
150
+ // retuning a triple leaves the old hex sitting beside it, and the next person
151
+ // to trust it picks a neighbouring value against a surface that moved.
152
+ //
153
+ // Every declaration of the form `--token: H S% L%; /* … #rrggbb … */`, in every
154
+ // block, not just the two spines.
155
+ const ANNOTATED =
156
+ /^[^\S\n]*(--[a-z0-9-]+):\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%;[^\S\n]*\/\*([^*]*)\*\//gm;
157
+
158
+ const annotated = [...tokens.matchAll(ANNOTATED)].flatMap(
159
+ ([, token, h, s, l, comment]) =>
160
+ (comment.match(/#[0-9a-fA-F]{6}/g) ?? []).map((quoted) => ({
161
+ token,
162
+ quoted,
163
+ hsl: { h: Number(h), s: Number(s), l: Number(l) },
164
+ })),
165
+ );
166
+
167
+ it("finds the annotations to check", () => {
168
+ // Without this, rewriting a comment into a shape the pattern misses would
169
+ // empty the suite below and read as a pass.
170
+ expect(annotated.length).toBeGreaterThanOrEqual(15);
171
+ });
172
+
173
+ for (const { token, quoted, hsl } of annotated) {
174
+ it(`${token} resolves to ${quoted}`, () => {
175
+ const actual = `#${hslToRgb(hsl)
176
+ .map((c) => Math.round(c).toString(16).padStart(2, "0"))
177
+ .join("")}`;
178
+ expect(actual).toBe(quoted.toLowerCase());
179
+ });
180
+ }
181
+ });
182
+
183
+ describe("the faint ink tiers are still TEXT, on every plane they land on", () => {
184
+ // These two tiers are the ones that break. They are solved against a specific
185
+ // surface, so any move in the ladder — lifting the card, softening the canvas,
186
+ // recasting the hue — silently drops them under the floor, and nothing about
187
+ // the result LOOKS wrong: it renders as slightly faint captions that happen to
188
+ // be unreadable. Every plane is asserted, not just the one each was tuned on.
189
+ //
190
+ // Compared hex-to-hex through the MD3 ladder rather than the HSL bridge,
191
+ // because these tokens ARE hexes; `--md3-surface` and `--md3-surface-container`
192
+ // are the canvas and the card at the same positions.
193
+ const ratioOf = (a: number, b: number) =>
194
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
195
+
196
+ for (const [theme, spine] of [
197
+ ["dark", DARK],
198
+ ["light", LIGHT],
199
+ ] as const) {
200
+ for (const tier of ["text-dim", "text-muted"] as const) {
201
+ it(`${theme}: --${tier} clears 4.5:1 on both the card and the canvas`, () => {
202
+ expect(spine, `--${tier} must be defined`).toContain(`--${tier}:`);
203
+ const ink = hexRelativeLuminanceIn(spine, tier);
204
+ for (const [plane, token] of [
205
+ ["card", "md3-surface-container"],
206
+ ["canvas", "md3-surface"],
207
+ ] as const) {
208
+ expect(
209
+ ratioOf(ink, hexRelativeLuminanceIn(spine, token)),
210
+ `--${tier} on the ${plane}`,
211
+ ).toBeGreaterThanOrEqual(4.5);
212
+ }
213
+ });
214
+ }
215
+ }
216
+ });
217
+
218
+ describe("input tokens carry two DIFFERENT roles and must not be conflated", () => {
219
+ // `--input` is shadcn's input BORDER and the Switch off-track: a mid-tone that
220
+ // must stay visible AGAINST a surface. `--hsl-input` / `--bg-input` are the
221
+ // recessed field FILL. Pointing `--input` at the fill is a tempting-looking
222
+ // "fix" that makes every input border and every off-state switch track vanish
223
+ // into the plane behind it — a legibility bug with no error and no visual clue
224
+ // beyond "the toggle disappeared". Pinned so it can't happen quietly.
225
+ it("--input is the border/track token, not the field fill", () => {
226
+ // The shadcn alias layer is declared ONCE and re-resolves per theme through
227
+ // the --hsl-* spine, so this is asserted on the file rather than per scope.
228
+ expect(tokens).toMatch(/--input:\s*var\(--hsl-muted-foreground\)/);
229
+ expect(
230
+ tokens,
231
+ "--input must not resolve to the field well — input borders and off-state switch tracks would disappear into the plane behind them",
232
+ ).not.toMatch(/--input:\s*var\(--hsl-input\)/);
233
+ });
234
+
235
+ for (const [name, spine] of [
236
+ ["dark", DARK],
237
+ ["light", LIGHT],
238
+ ] as const) {
239
+ it(`${name}: the field fill is single-sourced from --hsl-input`, () => {
240
+ expect(spine).toMatch(/--bg-input:\s*hsl\(var\(--hsl-input\)\)/);
241
+ });
242
+ }
243
+ });
244
+
245
+ describe("canonical light spine", () => {
246
+ it("is white paper on a tinted canvas, never white-on-white", () => {
247
+ const canvas = hslIn(LIGHT, "hsl-background");
248
+ const card = hslIn(LIGHT, "hsl-card");
249
+ expect(card.l, "the card is paper").toBe(100);
250
+ expect(
251
+ canvas.l,
252
+ "a pure-white canvas gives a white card nothing to lift off",
253
+ ).toBeLessThan(97);
254
+ // Mirrors the dark spine exactly: HUE is the brand constraint and is pinned;
255
+ // saturation is a taste dial and is not. How far the surfaces lean is a live
256
+ // product decision, so asserting a band here only guarantees this test fails
257
+ // the next time that decision is revisited.
258
+ expect(canvas.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
259
+ 215,
260
+ );
261
+ expect(canvas.h).toBeLessThanOrEqual(260);
262
+ expect(canvas.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
263
+ 5,
264
+ );
265
+ });
266
+
267
+ it("ALTERNATES paper and well — light elevation is not a darkening ramp", () => {
268
+ // Deliberately not monotonic. On a tinted canvas a raised plane is paper and
269
+ // a recessed one is a tinted well, so the ladder alternates. Pinning it stops
270
+ // a well-meaning "fix" from forcing a strictly-ordered ramp, which is exactly
271
+ // what produced the white-on-white flatness this palette replaced.
272
+ const paper = ["md3-surface-container", "md3-surface-container-highest"];
273
+ const wells = ["md3-surface-container-low", "md3-surface-container-high"];
274
+ const canvas = hexRelativeLuminanceIn(LIGHT, "md3-surface");
275
+
276
+ for (const t of paper) {
277
+ expect(hexRelativeLuminanceIn(LIGHT, t), `${t} is paper`).toBe(1);
278
+ }
279
+ for (const t of wells) {
280
+ const l = hexRelativeLuminanceIn(LIGHT, t);
281
+ expect(l, `${t} is a well — below paper`).toBeLessThan(1);
282
+ expect(l, `${t} still sits above the canvas`).toBeGreaterThan(canvas);
283
+ }
284
+ });
285
+
286
+ it("lets the overlay share the card's fill — it lifts by shadow, not lightness", () => {
287
+ // `--bg-card` (depth-2) and `--bg-elevated` (depth-4) are both paper by design;
288
+ // the semantic layer agrees (--hsl-card and --hsl-popover are both white). The
289
+ // separation is the shadow, so a shadow token must exist to carry it.
290
+ expect(hexRelativeLuminanceIn(LIGHT, "depth-2")).toBe(1);
291
+ expect(hexRelativeLuminanceIn(LIGHT, "depth-4")).toBe(1);
292
+ expect(LIGHT, "the overlay's separation depends on this").toMatch(
293
+ /--shadow-dropdown:/,
294
+ );
295
+ });
296
+
297
+ it("keeps the light canvas single-sourced — --depth-1 and --md3-surface agree", () => {
298
+ // Both are the L0 page canvas. Two hex literals for one plane is a divergence
299
+ // waiting to happen the next time one of them is retuned.
300
+ const canvasHex = (token: string) =>
301
+ LIGHT.match(new RegExp(`--${token}:\\s*(#[0-9a-fA-F]{6})`))?.[1];
302
+ expect(canvasHex("depth-1")).toBe(canvasHex("md3-surface"));
303
+ });
304
+
305
+ it("keeps the derived --bg-* surfaces on the ladder", () => {
306
+ // Hardcoded hex here is how `bg-card` (→ --hsl-card) and `var(--bg-card)`
307
+ // drifted into disagreeing about what a card is.
308
+ for (const token of ["bg-dark", "bg-card", "bg-elevated", "bg-section"]) {
309
+ expect(LIGHT, `--${token} must derive from the ladder`).toMatch(
310
+ new RegExp(`--${token}:\\s*var\\(--depth-`),
311
+ );
312
+ }
313
+ });
314
+ });