@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.
- package/CHANGELOG.md +30 -0
- package/package.json +3 -3
- package/src/styles/css-test-utils.ts +77 -43
- package/src/styles/named-themes.css +39 -4
- package/src/styles/named-themes.test.ts +234 -1
- package/src/styles/tokens.css +259 -140
- package/src/styles/tokens.test.ts +279 -13
|
@@ -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("
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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", () => {
|