@tangle-network/brand 1.3.0 → 1.5.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 +22 -0
- package/package.json +1 -1
- package/src/styles/named-themes.css +9 -9
- package/src/styles/theme-parity.test.ts +86 -0
- package/src/styles/theme.css +6 -0
- package/src/styles/tokens.css +105 -33
- package/src/styles/tokens.test.ts +154 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 532a6d8: Add `--shadow-overlay`, the elevation step above `--shadow-dropdown` for a surface that covers the page rather than sitting beside it — a drawer, a modal, a floating dock.
|
|
8
|
+
|
|
9
|
+
It is derived from `--hsl-foreground` rather than a fixed `rgba`, so it inverts with the theme: dark ink in light, a soft halo in dark. That is what makes it work on a dark canvas, where a black shadow renders as nothing. One declaration serves both themes, and the token is declared in the `@theme` block as well so Tailwind emits a `shadow-overlay` utility.
|
|
10
|
+
|
|
11
|
+
## 1.4.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- e67a809: Raise the dark surface ladder to AA and add the page-level primitives.
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
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.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
23
|
+
`@tangle-network/ui` gains four primitives: `PageHeader`, `StatusPill`, `MetricStrip`, and `Toolbar`/`FilterField`. All additive; no existing export changes.
|
|
24
|
+
|
|
3
25
|
## 1.3.0
|
|
4
26
|
|
|
5
27
|
### 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"] {
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { blockIn } from "./css-test-utils";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A token declared in both files has to carry the same value in both.
|
|
8
|
+
*
|
|
9
|
+
* `tokens.css` is what a consumer reads at runtime through `var(--x)`.
|
|
10
|
+
* `theme.css` is the Tailwind v4 `@theme` block, and it exists because Tailwind
|
|
11
|
+
* only emits a utility for a token it can see declared there — so the shared
|
|
12
|
+
* ones are necessarily written twice.
|
|
13
|
+
*
|
|
14
|
+
* That duplication is structural and cannot be factored out: `@theme` needs a
|
|
15
|
+
* literal, and a self-reference would be circular. What it can be is GATED. An
|
|
16
|
+
* edit to one file that misses the other would leave `shadow-overlay` (the
|
|
17
|
+
* utility, from theme.css) rendering a different shadow from
|
|
18
|
+
* `shadow-[var(--shadow-overlay)]` (the variable, from tokens.css), and the
|
|
19
|
+
* mismatch would show up as an inconsistency no one could reproduce from
|
|
20
|
+
* either file alone.
|
|
21
|
+
*/
|
|
22
|
+
const read = (name: string) =>
|
|
23
|
+
readFileSync(path.resolve(import.meta.dirname, name), "utf8");
|
|
24
|
+
|
|
25
|
+
/** `--token: value;` pairs in one CSS block, whitespace collapsed. */
|
|
26
|
+
function declarationsIn(css: string): Map<string, string> {
|
|
27
|
+
const out = new Map<string, string>();
|
|
28
|
+
for (const m of css.matchAll(/(--[a-z0-9-]+)\s*:\s*([^;]+);/g)) {
|
|
29
|
+
out.set(m[1] as string, (m[2] as string).split(/\s+/).join(" ").trim());
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The default spine, which is the one `@theme` mirrors. Anchor on the last
|
|
35
|
+
// selector of the multi-selector header, as the other suites here do.
|
|
36
|
+
const tokensCss = read("tokens.css");
|
|
37
|
+
const runtime = declarationsIn(blockIn(tokensCss, ".dark"));
|
|
38
|
+
const light = declarationsIn(blockIn(tokensCss, ".light"));
|
|
39
|
+
const theme = declarationsIn(blockIn(read("theme.css"), "@theme"));
|
|
40
|
+
|
|
41
|
+
const shared = [...theme.keys()].filter((t) => runtime.has(t)).sort();
|
|
42
|
+
|
|
43
|
+
describe("tokens.css and theme.css agree on every token they both declare", () => {
|
|
44
|
+
it("finds the shared tokens to check", () => {
|
|
45
|
+
// Guards the parse itself: a selector rename that silently emptied one
|
|
46
|
+
// block would otherwise make every assertion below vacuously pass.
|
|
47
|
+
expect(shared.length).toBeGreaterThan(5);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
for (const token of shared) {
|
|
51
|
+
it(`${token} carries one value`, () => {
|
|
52
|
+
expect(theme.get(token)).toBe(runtime.get(token));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A token written in terms of `--hsl-foreground` already inverts, because that
|
|
59
|
+
* ramp is what each spine redefines. Restating it on the light spine would pin
|
|
60
|
+
* it to one theme's ink and defeat the derivation — the failure would be silent,
|
|
61
|
+
* since both declarations are individually valid CSS.
|
|
62
|
+
*
|
|
63
|
+
* So the omission is the contract, and this asserts it rather than trusting the
|
|
64
|
+
* comment that states it.
|
|
65
|
+
*/
|
|
66
|
+
const selfInverting = [...runtime.entries()]
|
|
67
|
+
.filter(([, value]) => value.includes("var(--hsl-foreground)"))
|
|
68
|
+
.map(([token]) => token)
|
|
69
|
+
.sort();
|
|
70
|
+
|
|
71
|
+
describe("a self-inverting token is declared once, not per spine", () => {
|
|
72
|
+
it("finds the self-inverting tokens to check", () => {
|
|
73
|
+
expect(selfInverting.length).toBeGreaterThan(0);
|
|
74
|
+
// The assertions below are absence checks, so they hold trivially against
|
|
75
|
+
// an empty map. A selector rename that stopped matching the light spine
|
|
76
|
+
// would otherwise read as "nothing is restated" — the strongest possible
|
|
77
|
+
// pass from the weakest possible parse.
|
|
78
|
+
expect(light.size).toBeGreaterThan(5);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
for (const token of selfInverting) {
|
|
82
|
+
it(`${token} is not restated on the light spine`, () => {
|
|
83
|
+
expect(light.has(token)).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
});
|
package/src/styles/theme.css
CHANGED
|
@@ -104,4 +104,10 @@
|
|
|
104
104
|
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
105
105
|
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
|
|
106
106
|
--shadow-glow: none;
|
|
107
|
+
/* Theme-derived, so it inverts — see tokens.css. Declared here as well so
|
|
108
|
+
Tailwind emits the `shadow-overlay` utility, which is how a consumer
|
|
109
|
+
reaches it without writing `shadow-[var(--shadow-overlay)]`. */
|
|
110
|
+
--shadow-overlay:
|
|
111
|
+
0 2px 6px hsl(var(--hsl-foreground) / 0.1),
|
|
112
|
+
0 16px 40px hsl(var(--hsl-foreground) / 0.16);
|
|
107
113
|
}
|
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;
|
|
@@ -284,6 +319,18 @@
|
|
|
284
319
|
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
|
|
285
320
|
--shadow-glow: none;
|
|
286
321
|
--shadow-accent: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
322
|
+
/* The lift above --shadow-dropdown, for a surface that covers the page
|
|
323
|
+
rather than sitting beside it: a drawer, a modal, a floating dock.
|
|
324
|
+
Two layers — a tight contact shadow plus a wide ambient one.
|
|
325
|
+
Alone among the shadows this is derived from --hsl-foreground instead of
|
|
326
|
+
a fixed rgba, so it INVERTS with the theme. That is what makes it work in
|
|
327
|
+
dark: a black shadow on a dark canvas renders as nothing (see the ladder
|
|
328
|
+
note at the top of this file), while the foreground is near-white there,
|
|
329
|
+
so the same declaration reads as a soft halo. One definition therefore
|
|
330
|
+
serves both themes and the light block does not restate it. */
|
|
331
|
+
--shadow-overlay:
|
|
332
|
+
0 2px 6px hsl(var(--hsl-foreground) / 0.1),
|
|
333
|
+
0 16px 40px hsl(var(--hsl-foreground) / 0.16);
|
|
287
334
|
--shadow-status-running: none;
|
|
288
335
|
--shadow-status-creating: none;
|
|
289
336
|
--shadow-status-error: none;
|
|
@@ -485,7 +532,7 @@
|
|
|
485
532
|
--surface-success-text: #047857;
|
|
486
533
|
--surface-warning-bg: #fffbeb;
|
|
487
534
|
--surface-warning-border: #fde68a;
|
|
488
|
-
--surface-warning-text: #
|
|
535
|
+
--surface-warning-text: #ab4f09;
|
|
489
536
|
--surface-danger-bg: #fff1f2;
|
|
490
537
|
--surface-danger-border: #fecdd3;
|
|
491
538
|
--surface-danger-text: #b91c1c;
|
|
@@ -501,6 +548,31 @@
|
|
|
501
548
|
--surface-orange-bg: #fff7ed;
|
|
502
549
|
--surface-orange-border: #fed7aa;
|
|
503
550
|
--surface-orange-text: #c2410c;
|
|
551
|
+
|
|
552
|
+
/* Run-mix ramp — the fills of a proportional status bar.
|
|
553
|
+
*
|
|
554
|
+
* Separate from the `--surface-*-text` tones because a 6px bar is read
|
|
555
|
+
* differently from a chip: it is told by hue alone, at a size too small to
|
|
556
|
+
* carry a glyph or a label, so its slices must ALSO separate from each other
|
|
557
|
+
* in greyscale. These five are spaced at least 1.18:1 apart in relative
|
|
558
|
+
* luminance — 1.30 in dark, 1.20 in light, where the ramp has less room
|
|
559
|
+
* before it runs into the paper — and each clears 3:1 against the track, so
|
|
560
|
+
* the mix stays countable with any form of colour blindness and in a
|
|
561
|
+
* monochrome screenshot. 1.18 is the floor the gate holds them to.
|
|
562
|
+
*
|
|
563
|
+
* Ranked by how much the reader needs to tell them apart: succeeded is
|
|
564
|
+
* brightest and cancelled quietest, so the two that matter — succeeded and
|
|
565
|
+
* failed — sit at opposite ends and can never be confused.
|
|
566
|
+
*
|
|
567
|
+
* Spacing them by OKLCH lightness does NOT work: that is perceptual, and the
|
|
568
|
+
* contrast ratio is computed from relative luminance, so an even perceptual
|
|
569
|
+
* ramp left every pair at 1.1:1. Solved in luminance space instead. */
|
|
570
|
+
--run-mix-succeeded: #3c9b78;
|
|
571
|
+
--run-mix-waiting: #c16227;
|
|
572
|
+
--run-mix-failed: #cd342f;
|
|
573
|
+
--run-mix-in-flight: #00679e;
|
|
574
|
+
--run-mix-cancelled: #515367;
|
|
575
|
+
|
|
504
576
|
--surface-neutral-bg: var(--depth-3);
|
|
505
577
|
--surface-neutral-border: var(--border-subtle);
|
|
506
578
|
--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", () => {
|