@tangle-network/brand 1.1.0 → 1.4.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.
@@ -32,17 +32,36 @@ const DARK = blockIn(tokens, ".dark");
32
32
  const LIGHT = blockIn(tokens, ".light");
33
33
 
34
34
  describe("canonical dark spine", () => {
35
- it("is indigo-cast, not grey — the canvas carries real saturation", () => {
35
+ it("keeps the canvas in the brand's hue family, at any strength", () => {
36
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.
37
40
  expect(bg.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
38
- 235,
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,
39
64
  );
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
65
  });
47
66
 
48
67
  it("lifts the card off the canvas by fill, not by border", () => {
@@ -101,6 +120,246 @@ describe("muted text stays legible at the opacities the UI actually uses", () =>
101
120
  }
102
121
  });
103
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.
189
+ //
190
+ // EVERY plane, not just the two a tier is easiest to tune on. Asserting the
191
+ // canvas and the card alone would leave the nested and overlay planes
192
+ // unguarded while this test's name still claimed the whole ladder — and those
193
+ // are the planes that carry a modal, a drawer, a w-80 dashboard panel and four
194
+ // dropdown menus, so a tier that fails only there fails where the reading
195
+ // actually happens.
196
+ //
197
+ // Compared hex-to-hex through the MD3 ladder rather than the HSL bridge,
198
+ // because these tokens ARE hexes, and the ladder names each plane at the same
199
+ // position in both themes.
200
+ const ratioOf = (a: number, b: number) =>
201
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
202
+
203
+ /** Every plane the spine paints resting content on, floor to overlay. */
204
+ const PLANES = [
205
+ ["canvas", "md3-surface"],
206
+ ["chrome / field well", "md3-surface-container-low"],
207
+ ["card", "md3-surface-container"],
208
+ ["nested / hover", "md3-surface-container-high"],
209
+ ["overlay", "md3-surface-container-highest"],
210
+ ] as const;
211
+
212
+ for (const [theme, spine] of [
213
+ ["dark", DARK],
214
+ ["light", LIGHT],
215
+ ] as const) {
216
+ for (const tier of ["text-dim", "text-muted"] as const) {
217
+ it(`${theme}: --${tier} clears 4.5:1 on every plane in the ladder`, () => {
218
+ expect(spine, `--${tier} must be defined`).toContain(`--${tier}:`);
219
+ const ink = hexRelativeLuminanceIn(spine, tier);
220
+ for (const [plane, token] of PLANES) {
221
+ expect(
222
+ ratioOf(ink, hexRelativeLuminanceIn(spine, token)),
223
+ `--${tier} on the ${plane} (--${token})`,
224
+ ).toBeGreaterThanOrEqual(4.5);
225
+ }
226
+ });
227
+ }
228
+ }
229
+
230
+ it("keeps a real STEP between the ink tiers, not merely an order", () => {
231
+ // A tier that has to clear AA on a lighter plane can always be brightened
232
+ // until it does — but once it sits within a hair of the tier above it, the
233
+ // rung has been deleted rather than repaired, and both an ordering check
234
+ // and a pure contrast check call that a pass.
235
+ //
236
+ // 1.2:1 is the floor because that is roughly where two greys on the same
237
+ // surface stop being separable side by side. The ramp is solved to it, so a
238
+ // retune that compresses one end reds here instead of quietly flattening.
239
+ const MIN_TIER_STEP = 1.2;
240
+ const ratioOf = (a: number, b: number) =>
241
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
242
+ for (const [theme, spine] of [
243
+ ["dark", DARK],
244
+ ["light", LIGHT],
245
+ ] as const) {
246
+ const tiers = ["text-dim", "text-muted", "text-secondary", "text-primary"];
247
+ const lums = tiers.map((t) => hexRelativeLuminanceIn(spine, t));
248
+ for (let i = 1; i < tiers.length; i++) {
249
+ expect(
250
+ ratioOf(lums[i], lums[i - 1]),
251
+ `${theme}: --${tiers[i - 1]} and --${tiers[i]} are too close to read as different tiers`,
252
+ ).toBeGreaterThanOrEqual(MIN_TIER_STEP);
253
+ }
254
+ // Monotonic in the direction the theme recedes: dark quiets by darkening,
255
+ // light by lightening.
256
+ const rising = lums.every((l, i) => i === 0 || l > lums[i - 1]);
257
+ const falling = lums.every((l, i) => i === 0 || l < lums[i - 1]);
258
+ expect(
259
+ theme === "dark" ? rising : falling,
260
+ `${theme}: the ink ramp must move in one direction from dim to primary`,
261
+ ).toBe(true);
262
+ }
263
+ });
264
+
265
+ it("keeps a status chip a TINT in light and a RAISED FILL in dark", () => {
266
+ // The two themes want opposite things from a chip's fill, and treating them
267
+ // the same is how this got broken once already: a dark-mode rule — "the fill
268
+ // must separate from the card so the chip reads as a plane" — was applied to
269
+ // light, where the card is white, so 1.5:1 from it IS a dark grey. Every
270
+ // contrast floor still passed and every chip turned to mud.
271
+ //
272
+ // Light: the fill stays a WASH near paper and the border does the
273
+ // separating. Dark: the fill is a raised tint that has to lift off the card,
274
+ // because a dark chip with a near-card fill is just coloured text.
275
+ const ratioOf = (a: number, b: number) =>
276
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
277
+ const TONES = ["success", "warning", "danger", "info"] as const;
278
+
279
+ for (const tone of TONES) {
280
+ const lightFill = ratioOf(
281
+ hexRelativeLuminanceIn(LIGHT, `surface-${tone}-bg`),
282
+ hexRelativeLuminanceIn(LIGHT, "md3-surface-container"),
283
+ );
284
+ expect(
285
+ lightFill,
286
+ `light --surface-${tone}-bg must stay a tint near paper — the border separates the chip, not the fill`,
287
+ ).toBeLessThanOrEqual(1.25);
288
+
289
+ const darkFill = ratioOf(
290
+ hexRelativeLuminanceIn(DARK, `surface-${tone}-bg`),
291
+ hexRelativeLuminanceIn(DARK, "md3-surface-container"),
292
+ );
293
+ expect(
294
+ darkFill,
295
+ `dark --surface-${tone}-bg must lift off the card, or the chip is only coloured text`,
296
+ ).toBeGreaterThanOrEqual(1.3);
297
+ }
298
+ });
299
+
300
+ it("keeps the run-mix ramp countable without colour", () => {
301
+ // The bar is 6px tall and told by hue alone, so its slices must separate in
302
+ // GREYSCALE too. Spacing them evenly by OKLCH lightness does not do it —
303
+ // that is perceptual, while the ratio is computed from relative luminance —
304
+ // so the ramp is solved in luminance space and pinned here.
305
+ const RAMP = [
306
+ "run-mix-succeeded",
307
+ "run-mix-waiting",
308
+ "run-mix-failed",
309
+ "run-mix-in-flight",
310
+ "run-mix-cancelled",
311
+ ] as const;
312
+ const ratioOf = (a: number, b: number) =>
313
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
314
+ // The bar's track is `bg-background-tertiary` -> `--muted`, which is an HSL
315
+ // bridge token. `--md3-surface-container-high` is the same plane expressed
316
+ // as a hex, which is what this helper can read.
317
+ for (const [theme, spine, track] of [
318
+ ["dark", DARK, "md3-surface-container-high"],
319
+ ["light", LIGHT, "md3-surface-container-high"],
320
+ ] as const) {
321
+ const lums = RAMP.map((t) => hexRelativeLuminanceIn(spine, t));
322
+ const trackLum = hexRelativeLuminanceIn(spine, track);
323
+ for (const [i, l] of lums.entries()) {
324
+ expect(
325
+ ratioOf(l, trackLum),
326
+ `${theme}: --${RAMP[i]} against the bar's track`,
327
+ ).toBeGreaterThanOrEqual(2.9);
328
+ }
329
+ for (let i = 1; i < lums.length; i++) {
330
+ expect(
331
+ ratioOf(lums[i], lums[i - 1]),
332
+ `${theme}: --${RAMP[i - 1]} and --${RAMP[i]} are indistinguishable in greyscale`,
333
+ ).toBeGreaterThanOrEqual(1.18);
334
+ }
335
+ }
336
+ });
337
+
338
+ it("keeps every status TEXT legible on the bare page canvas", () => {
339
+ // Each status text token is solved against its own paired background, and
340
+ // that pairing is what `StatusPill` guarantees. But the tokens also get used
341
+ // as bare text — a "required" hint, a warning headline — where the plane
342
+ // behind them is the page itself, and nothing about that use announces it
343
+ // has left the pairing. So every tone is held to the body floor on the
344
+ // canvas as well, and the set stands or falls together rather than one tone
345
+ // being quietly scoped tighter than its siblings.
346
+ const ratioOf = (a: number, b: number) =>
347
+ (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
348
+ for (const [theme, spine] of [
349
+ ["dark", DARK],
350
+ ["light", LIGHT],
351
+ ] as const) {
352
+ const canvas = hexRelativeLuminanceIn(spine, "md3-surface");
353
+ for (const tone of ["success", "warning", "danger", "info"] as const) {
354
+ expect(
355
+ ratioOf(hexRelativeLuminanceIn(spine, `surface-${tone}-text`), canvas),
356
+ `${theme}: --surface-${tone}-text on the page canvas`,
357
+ ).toBeGreaterThanOrEqual(4.5);
358
+ }
359
+ }
360
+ });
361
+ });
362
+
104
363
  describe("input tokens carry two DIFFERENT roles and must not be conflated", () => {
105
364
  // `--input` is shadcn's input BORDER and the Switch off-track: a mid-tone that
106
365
  // must stay visible AGAINST a surface. `--hsl-input` / `--bg-input` are the
@@ -129,7 +388,7 @@ describe("input tokens carry two DIFFERENT roles and must not be conflated", ()
129
388
  });
130
389
 
131
390
  describe("canonical light spine", () => {
132
- it("is white paper on a TINTED canvas, never white-on-white", () => {
391
+ it("is white paper on a tinted canvas, never white-on-white", () => {
133
392
  const canvas = hslIn(LIGHT, "hsl-background");
134
393
  const card = hslIn(LIGHT, "hsl-card");
135
394
  expect(card.l, "the card is paper").toBe(100);
@@ -137,10 +396,17 @@ describe("canonical light spine", () => {
137
396
  canvas.l,
138
397
  "a pure-white canvas gives a white card nothing to lift off",
139
398
  ).toBeLessThan(97);
140
- expect(
141
- canvas.s,
142
- "the canvas carries a tint, not flat grey",
143
- ).toBeGreaterThan(10);
399
+ // Mirrors the dark spine exactly: HUE is the brand constraint and is pinned;
400
+ // saturation is a taste dial and is not. How far the surfaces lean is a live
401
+ // product decision, so asserting a band here only guarantees this test fails
402
+ // the next time that decision is revisited.
403
+ expect(canvas.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
404
+ 215,
405
+ );
406
+ expect(canvas.h).toBeLessThanOrEqual(260);
407
+ expect(canvas.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
408
+ 5,
409
+ );
144
410
  });
145
411
 
146
412
  it("ALTERNATES paper and well — light elevation is not a darkening ramp", () => {