@tangle-network/brand 1.3.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 +14 -0
- package/package.json +1 -1
- package/src/styles/named-themes.css +9 -9
- package/src/styles/tokens.css +93 -33
- package/src/styles/tokens.test.ts +154 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e67a809: Raise the dark surface ladder to AA and add the page-level primitives.
|
|
8
|
+
|
|
9
|
+
In the dark spine, `--md3-surface-container-high` and `--md3-surface-container-highest` sat close enough to the ink ramp that `--text-dim` fell under 4.5:1 on them. The ladder is re-spaced and the ink ramp moves with it, so every ink tier clears AA on all five planes.
|
|
10
|
+
|
|
11
|
+
Dark status chips keep their hue, drop 14% chroma, and lift the fill to 1.50:1 from the card. A new `--run-mix-*` ramp carries proportional bars, spaced in relative luminance so adjacent segments clear the 3:1 floor for non-text contrast.
|
|
12
|
+
|
|
13
|
+
One light token moves: `--surface-warning-text` goes `#b45309` to `#ab4f09`. It is a contrast fix, not a hue change. The old value measured 4.16:1 on the light page canvas, under the 4.5:1 body floor, whenever the colour was used as text away from its own pill background; the new value measures 4.51:1 there and 5.25:1 on the pill. Every other light value is unchanged.
|
|
14
|
+
|
|
15
|
+
`@tangle-network/ui` gains four primitives: `PageHeader`, `StatusPill`, `MetricStrip`, and `Toolbar`/`FilterField`. All additive; no existing export changes.
|
|
16
|
+
|
|
3
17
|
## 1.3.0
|
|
4
18
|
|
|
5
19
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
[data-theme="arena-light"],
|
|
53
53
|
[data-theme="tangle-light"] {
|
|
54
54
|
--surface-success-bg: #ecfdf5; --surface-success-border: #a7f3d0; --surface-success-text: #047857;
|
|
55
|
-
--surface-warning-bg: #fffbeb; --surface-warning-border: #fde68a; --surface-warning-text: #
|
|
55
|
+
--surface-warning-bg: #fffbeb; --surface-warning-border: #fde68a; --surface-warning-text: #ab4f09;
|
|
56
56
|
--surface-danger-bg: #fff1f2; --surface-danger-border: #fecdd3; --surface-danger-text: #b91c1c;
|
|
57
57
|
--surface-info-bg: #f0f9ff; --surface-info-border: #bae6fd; --surface-info-text: #0369a1;
|
|
58
58
|
/* Light-tuned interactive tokens — without these the named light scopes
|
|
@@ -184,23 +184,23 @@
|
|
|
184
184
|
[data-theme="tangle-dark"] {
|
|
185
185
|
--hsl-background: 240 24% 11%; --hsl-foreground: 225 22% 91%;
|
|
186
186
|
--hsl-card: 238 20% 22%; --hsl-card-foreground: 225 22% 91%;
|
|
187
|
-
--hsl-popover:
|
|
187
|
+
--hsl-popover: 239 22% 25%; --hsl-popover-foreground: 225 22% 91%;
|
|
188
188
|
--hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
|
|
189
|
-
--hsl-secondary:
|
|
190
|
-
--hsl-muted:
|
|
191
|
-
--hsl-accent:
|
|
189
|
+
--hsl-secondary: 239 22% 25%; --hsl-secondary-foreground: 225 22% 91%;
|
|
190
|
+
--hsl-muted: 239 22% 25%; --hsl-muted-foreground: 234 16% 74%;
|
|
191
|
+
--hsl-accent: 239 22% 25%; --hsl-accent-foreground: 225 22% 91%;
|
|
192
192
|
--hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
|
|
193
193
|
--hsl-border: 236 20% 35%; --hsl-input: 240 24% 15%;
|
|
194
194
|
--hsl-ring: 233 100% 87%;
|
|
195
|
-
--depth-1: #151523; --depth-2: #2d2e43; --depth-3: #
|
|
195
|
+
--depth-1: #151523; --depth-2: #2d2e43; --depth-3: #32324e; --depth-4: #393957;
|
|
196
196
|
--bg-root: #151523; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
|
|
197
197
|
--md3-surface-container-lowest: #0f0f1a; --md3-surface-container-low: #222235;
|
|
198
|
-
--md3-surface-container: #2d2e43; --md3-surface-container-high: #
|
|
199
|
-
--md3-surface-container-highest: #
|
|
198
|
+
--md3-surface-container: #2d2e43; --md3-surface-container-high: #32324e;
|
|
199
|
+
--md3-surface-container-highest: #393957; --md3-outline-variant: #474a6b;
|
|
200
200
|
--border-subtle: rgba(165, 180, 252, 0.10); --border-default: rgba(165, 180, 252, 0.16);
|
|
201
201
|
--border-hover: rgba(165, 180, 252, 0.28); --accent-text: #A5B4FC;
|
|
202
202
|
--brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
|
|
203
|
-
--text-primary: #e3e6ed; --text-secondary: #
|
|
203
|
+
--text-primary: #e3e6ed; --text-secondary: #c2c7d4; --text-muted: #b2b4c7; --text-dim: #9ea5b9;
|
|
204
204
|
}
|
|
205
205
|
/* Its light mode. */
|
|
206
206
|
[data-theme="tangle-light"] {
|
package/src/styles/tokens.css
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
.dark {
|
|
38
38
|
--md3-surface: #151523; /* L0 page canvas */
|
|
39
39
|
--md3-surface-dim: #0f0f1a; /* recessed page */
|
|
40
|
-
--md3-surface-bright: #
|
|
40
|
+
--md3-surface-bright: #393957;
|
|
41
41
|
/* Same value as --md3-surface-dim by design, not by drift: both name the floor
|
|
42
42
|
* of the ladder, and dark has no plane below it to give them. They stay two
|
|
43
43
|
* tokens because the roles diverge in light, where the well is paper (#ffffff)
|
|
@@ -46,11 +46,11 @@
|
|
|
46
46
|
--md3-surface-container-lowest: #0f0f1a; /* L-1 terminal/code well */
|
|
47
47
|
--md3-surface-container-low: #222235; /* L1 chrome, field wells */
|
|
48
48
|
--md3-surface-container: #2d2e43; /* L2 resting card */
|
|
49
|
-
--md3-surface-container-high: #
|
|
50
|
-
--md3-surface-container-highest: #
|
|
51
|
-
--md3-surface-variant: #
|
|
49
|
+
--md3-surface-container-high: #32324e; /* L3 nested / hover */
|
|
50
|
+
--md3-surface-container-highest: #393957; /* L4 overlay */
|
|
51
|
+
--md3-surface-variant: #32324e;
|
|
52
52
|
--md3-on-surface: #e3e6ed;
|
|
53
|
-
--md3-on-surface-variant: #
|
|
53
|
+
--md3-on-surface-variant: #b2b4c7;
|
|
54
54
|
|
|
55
55
|
--md3-primary: #A5B4FC;
|
|
56
56
|
--md3-primary-dim: #7C6CF6;
|
|
@@ -89,20 +89,23 @@
|
|
|
89
89
|
--hsl-foreground: 225 22% 91%; /* #e3e6ed */
|
|
90
90
|
--hsl-card: 238 20% 22%; /* L2 card #2d2e43 */
|
|
91
91
|
--hsl-card-foreground: 225 22% 91%;
|
|
92
|
-
--hsl-popover:
|
|
92
|
+
--hsl-popover: 239 22% 25%; /* L3 #32324e — the NESTED plane despite
|
|
93
|
+
the name; the overlay plane is --md3-surface-container-highest. A popover
|
|
94
|
+
carries body text, and the ladder above L3 is reserved for planes that lift
|
|
95
|
+
by shadow, so it sits on L3 and the ink ramp reaches it. */
|
|
93
96
|
--hsl-popover-foreground: 225 22% 91%;
|
|
94
97
|
/* The fill indigo, NOT the bright one: `.btn-primary` puts white on this, and
|
|
95
98
|
* #6366F1 carries white at only 4.47:1. This clears the floor at 7.4:1. The
|
|
96
99
|
* bright indigo stays available for accent TEXT via --accent-text. */
|
|
97
100
|
--hsl-primary: 246 54% 51%; /* #4c3fc6 */
|
|
98
101
|
--hsl-primary-foreground: 0 0% 100%;
|
|
99
|
-
--hsl-secondary:
|
|
102
|
+
--hsl-secondary: 239 22% 25%; /* L3 #32324e */
|
|
100
103
|
--hsl-secondary-foreground: 225 22% 91%;
|
|
101
|
-
--hsl-muted:
|
|
102
|
-
--hsl-muted-foreground:
|
|
104
|
+
--hsl-muted: 239 22% 25%; /* L3 hover well */
|
|
105
|
+
--hsl-muted-foreground: 234 16% 74%; /* #b2b4c7 — high enough that the
|
|
103
106
|
reduced-opacity caption derivatives (/70, /60) still clear the large-text
|
|
104
107
|
contrast floor against the L2 card. */
|
|
105
|
-
--hsl-accent:
|
|
108
|
+
--hsl-accent: 239 22% 25%; /* neutral hover (indigo lives in accent-surface-strong) */
|
|
106
109
|
--hsl-accent-foreground: 225 22% 91%;
|
|
107
110
|
--hsl-destructive: 348 74% 48%;
|
|
108
111
|
--hsl-destructive-foreground: 0 0% 100%;
|
|
@@ -154,7 +157,7 @@
|
|
|
154
157
|
--sidebar-foreground: 225 22% 91%;
|
|
155
158
|
--sidebar-primary: 246 54% 51%;
|
|
156
159
|
--sidebar-primary-foreground: 0 0% 100%;
|
|
157
|
-
--sidebar-accent:
|
|
160
|
+
--sidebar-accent: 239 22% 25%; /* L3 hover well */
|
|
158
161
|
--sidebar-accent-foreground: 225 22% 91%;
|
|
159
162
|
--sidebar-border: 236 20% 35%;
|
|
160
163
|
--sidebar-ring: 233 100% 87%;
|
|
@@ -162,8 +165,8 @@
|
|
|
162
165
|
/* Depth scale — tracks the MD3 ladder (canvas / card / nested / overlay) */
|
|
163
166
|
--depth-1: #151523;
|
|
164
167
|
--depth-2: #2d2e43;
|
|
165
|
-
--depth-3: #
|
|
166
|
-
--depth-4: #
|
|
168
|
+
--depth-3: #32324e;
|
|
169
|
+
--depth-4: #393957;
|
|
167
170
|
|
|
168
171
|
/* Status colors */
|
|
169
172
|
--status-running: #10B981;
|
|
@@ -175,18 +178,18 @@
|
|
|
175
178
|
--status-deleted: #6B7094;
|
|
176
179
|
|
|
177
180
|
/* Status surface tokens — bg / border / text for each semantic color */
|
|
178
|
-
--surface-success-bg: #
|
|
179
|
-
--surface-success-border: #
|
|
180
|
-
--surface-success-text: #
|
|
181
|
-
--surface-warning-bg: #
|
|
182
|
-
--surface-warning-border: #
|
|
183
|
-
--surface-warning-text: #
|
|
184
|
-
--surface-danger-bg: #
|
|
185
|
-
--surface-danger-border: #
|
|
186
|
-
--surface-danger-text: #
|
|
187
|
-
--surface-info-bg: #
|
|
188
|
-
--surface-info-border: #
|
|
189
|
-
--surface-info-text: #
|
|
181
|
+
--surface-success-bg: #2f5146;
|
|
182
|
+
--surface-success-border: #3c7760;
|
|
183
|
+
--surface-success-text: #5ed7a4;
|
|
184
|
+
--surface-warning-bg: #6a402c;
|
|
185
|
+
--surface-warning-border: #916436;
|
|
186
|
+
--surface-warning-text: #f4c14f;
|
|
187
|
+
--surface-danger-bg: #733b36;
|
|
188
|
+
--surface-danger-border: #9a5b56;
|
|
189
|
+
--surface-danger-text: #ffada9;
|
|
190
|
+
--surface-info-bg: #2d4e67;
|
|
191
|
+
--surface-info-border: #3d7292;
|
|
192
|
+
--surface-info-text: #67cdff;
|
|
190
193
|
--surface-teal-bg: #042f2e;
|
|
191
194
|
--surface-teal-border: #134e4a;
|
|
192
195
|
--surface-teal-text: #2dd4bf;
|
|
@@ -196,6 +199,31 @@
|
|
|
196
199
|
--surface-orange-bg: #431407;
|
|
197
200
|
--surface-orange-border: #7c2d12;
|
|
198
201
|
--surface-orange-text: #fb923c;
|
|
202
|
+
|
|
203
|
+
/* Run-mix ramp — the fills of a proportional status bar.
|
|
204
|
+
*
|
|
205
|
+
* Separate from the `--surface-*-text` tones because a 6px bar is read
|
|
206
|
+
* differently from a chip: it is told by hue alone, at a size too small to
|
|
207
|
+
* carry a glyph or a label, so its slices must ALSO separate from each other
|
|
208
|
+
* in greyscale. These five are spaced at least 1.18:1 apart in relative
|
|
209
|
+
* luminance — 1.30 in dark, 1.20 in light, where the ramp has less room
|
|
210
|
+
* before it runs into the paper — and each clears 3:1 against the track, so
|
|
211
|
+
* the mix stays countable with any form of colour blindness and in a
|
|
212
|
+
* monochrome screenshot. 1.18 is the floor the gate holds them to.
|
|
213
|
+
*
|
|
214
|
+
* Ranked by how much the reader needs to tell them apart: succeeded is
|
|
215
|
+
* brightest and cancelled quietest, so the two that matter — succeeded and
|
|
216
|
+
* failed — sit at opposite ends and can never be confused.
|
|
217
|
+
*
|
|
218
|
+
* Spacing them by OKLCH lightness does NOT work: that is perceptual, and the
|
|
219
|
+
* contrast ratio is computed from relative luminance, so an even perceptual
|
|
220
|
+
* ramp left every pair at 1.1:1. Solved in luminance space instead. */
|
|
221
|
+
--run-mix-succeeded: #76edb9;
|
|
222
|
+
--run-mix-waiting: #e9b642;
|
|
223
|
+
--run-mix-failed: #e1918e;
|
|
224
|
+
--run-mix-in-flight: #2d9bca;
|
|
225
|
+
--run-mix-cancelled: #767d90;
|
|
226
|
+
|
|
199
227
|
--surface-neutral-bg: var(--depth-3);
|
|
200
228
|
--surface-neutral-border: var(--border-subtle);
|
|
201
229
|
--surface-neutral-text: var(--text-muted);
|
|
@@ -234,15 +262,22 @@
|
|
|
234
262
|
--bg-selection: rgba(124, 108, 246, 0.28);
|
|
235
263
|
|
|
236
264
|
/* Text — neutral */
|
|
237
|
-
/* Four steps the eye can actually separate: 10.6 / 7.
|
|
265
|
+
/* Four steps the eye can actually separate: 10.6 / 7.8 / 6.5 / 5.4 against the
|
|
238
266
|
* L2 card. --text-dim is the faintest tier but it is still TEXT, so it clears
|
|
239
|
-
* the 4.5:1 body floor rather than only the 3:1 large-text one
|
|
240
|
-
*
|
|
241
|
-
*
|
|
267
|
+
* the 4.5:1 body floor rather than only the 3:1 large-text one.
|
|
268
|
+
*
|
|
269
|
+
* Solved against the WHOLE ladder, not against one plane. A faint tier tuned
|
|
270
|
+
* on the card alone still has to clear the floor on the nested and overlay
|
|
271
|
+
* planes above it, which carry the modals, the drawers and every dropdown
|
|
272
|
+
* menu — so both ends meet in the middle: the faint tiers sit high enough for
|
|
273
|
+
* the overlay, and the planes above the card sit low enough for them. The
|
|
274
|
+
* card, the chrome and the canvas are fixed points. tokens.test.ts asserts
|
|
275
|
+
* every tier on every plane, so a retune cannot open a gap on one plane while
|
|
276
|
+
* looking correct on another. */
|
|
242
277
|
--text-primary: #e3e6ed;
|
|
243
|
-
--text-secondary: #
|
|
244
|
-
--text-muted: #
|
|
245
|
-
--text-dim: #
|
|
278
|
+
--text-secondary: #c2c7d4;
|
|
279
|
+
--text-muted: #b2b4c7;
|
|
280
|
+
--text-dim: #9ea5b9;
|
|
246
281
|
|
|
247
282
|
/* Brand accents — Tangle */
|
|
248
283
|
--brand-primary: #7C6CF6;
|
|
@@ -485,7 +520,7 @@
|
|
|
485
520
|
--surface-success-text: #047857;
|
|
486
521
|
--surface-warning-bg: #fffbeb;
|
|
487
522
|
--surface-warning-border: #fde68a;
|
|
488
|
-
--surface-warning-text: #
|
|
523
|
+
--surface-warning-text: #ab4f09;
|
|
489
524
|
--surface-danger-bg: #fff1f2;
|
|
490
525
|
--surface-danger-border: #fecdd3;
|
|
491
526
|
--surface-danger-text: #b91c1c;
|
|
@@ -501,6 +536,31 @@
|
|
|
501
536
|
--surface-orange-bg: #fff7ed;
|
|
502
537
|
--surface-orange-border: #fed7aa;
|
|
503
538
|
--surface-orange-text: #c2410c;
|
|
539
|
+
|
|
540
|
+
/* Run-mix ramp — the fills of a proportional status bar.
|
|
541
|
+
*
|
|
542
|
+
* Separate from the `--surface-*-text` tones because a 6px bar is read
|
|
543
|
+
* differently from a chip: it is told by hue alone, at a size too small to
|
|
544
|
+
* carry a glyph or a label, so its slices must ALSO separate from each other
|
|
545
|
+
* in greyscale. These five are spaced at least 1.18:1 apart in relative
|
|
546
|
+
* luminance — 1.30 in dark, 1.20 in light, where the ramp has less room
|
|
547
|
+
* before it runs into the paper — and each clears 3:1 against the track, so
|
|
548
|
+
* the mix stays countable with any form of colour blindness and in a
|
|
549
|
+
* monochrome screenshot. 1.18 is the floor the gate holds them to.
|
|
550
|
+
*
|
|
551
|
+
* Ranked by how much the reader needs to tell them apart: succeeded is
|
|
552
|
+
* brightest and cancelled quietest, so the two that matter — succeeded and
|
|
553
|
+
* failed — sit at opposite ends and can never be confused.
|
|
554
|
+
*
|
|
555
|
+
* Spacing them by OKLCH lightness does NOT work: that is perceptual, and the
|
|
556
|
+
* contrast ratio is computed from relative luminance, so an even perceptual
|
|
557
|
+
* ramp left every pair at 1.1:1. Solved in luminance space instead. */
|
|
558
|
+
--run-mix-succeeded: #3c9b78;
|
|
559
|
+
--run-mix-waiting: #c16227;
|
|
560
|
+
--run-mix-failed: #cd342f;
|
|
561
|
+
--run-mix-in-flight: #00679e;
|
|
562
|
+
--run-mix-cancelled: #515367;
|
|
563
|
+
|
|
504
564
|
--surface-neutral-bg: var(--depth-3);
|
|
505
565
|
--surface-neutral-border: var(--border-subtle);
|
|
506
566
|
--surface-neutral-text: var(--text-muted);
|
|
@@ -185,34 +185,179 @@ describe("the faint ink tiers are still TEXT, on every plane they land on", () =
|
|
|
185
185
|
// surface, so any move in the ladder — lifting the card, softening the canvas,
|
|
186
186
|
// recasting the hue — silently drops them under the floor, and nothing about
|
|
187
187
|
// the result LOOKS wrong: it renders as slightly faint captions that happen to
|
|
188
|
-
// be unreadable.
|
|
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.
|
|
189
196
|
//
|
|
190
197
|
// Compared hex-to-hex through the MD3 ladder rather than the HSL bridge,
|
|
191
|
-
// because these tokens ARE hexes
|
|
192
|
-
//
|
|
198
|
+
// because these tokens ARE hexes, and the ladder names each plane at the same
|
|
199
|
+
// position in both themes.
|
|
193
200
|
const ratioOf = (a: number, b: number) =>
|
|
194
201
|
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
195
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
|
+
|
|
196
212
|
for (const [theme, spine] of [
|
|
197
213
|
["dark", DARK],
|
|
198
214
|
["light", LIGHT],
|
|
199
215
|
] as const) {
|
|
200
216
|
for (const tier of ["text-dim", "text-muted"] as const) {
|
|
201
|
-
it(`${theme}: --${tier} clears 4.5:1 on
|
|
217
|
+
it(`${theme}: --${tier} clears 4.5:1 on every plane in the ladder`, () => {
|
|
202
218
|
expect(spine, `--${tier} must be defined`).toContain(`--${tier}:`);
|
|
203
219
|
const ink = hexRelativeLuminanceIn(spine, tier);
|
|
204
|
-
for (const [plane, token] of
|
|
205
|
-
["card", "md3-surface-container"],
|
|
206
|
-
["canvas", "md3-surface"],
|
|
207
|
-
] as const) {
|
|
220
|
+
for (const [plane, token] of PLANES) {
|
|
208
221
|
expect(
|
|
209
222
|
ratioOf(ink, hexRelativeLuminanceIn(spine, token)),
|
|
210
|
-
`--${tier} on the ${plane}`,
|
|
223
|
+
`--${tier} on the ${plane} (--${token})`,
|
|
211
224
|
).toBeGreaterThanOrEqual(4.5);
|
|
212
225
|
}
|
|
213
226
|
});
|
|
214
227
|
}
|
|
215
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
|
+
});
|
|
216
361
|
});
|
|
217
362
|
|
|
218
363
|
describe("input tokens carry two DIFFERENT roles and must not be conflated", () => {
|