@tangle-network/brand 1.27.1 → 1.29.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 +63 -0
- package/dist/index.d.ts +21 -25
- package/dist/index.js +23 -25
- package/dist/knot.d.ts +1 -3
- package/dist/knot.js +1 -2
- package/dist/social.d.ts +1 -3
- package/dist/social.js +1 -2
- package/package.json +4 -4
- package/src/palette.generated.ts +21 -21
- package/src/styles/css-test-utils.test.ts +10 -7
- package/src/styles/css-test-utils.ts +38 -13
- package/src/styles/focus.test.ts +4 -15
- package/src/styles/legacy-light.css +211 -350
- package/src/styles/named-themes.css +17 -25
- package/src/styles/named-themes.test.ts +35 -98
- package/src/styles/surface-ladder.test.ts +105 -38
- package/src/styles/theme-scopes.ts +4 -2
- package/src/styles/theme.css +13 -11
- package/src/styles/tokens.css +300 -476
- package/src/styles/tokens.test.ts +35 -12
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
--text-hero: var(--font-size-hero);
|
|
19
19
|
--text-page: var(--font-size-3xl);
|
|
20
20
|
--text-section: var(--font-size-xl);
|
|
21
|
-
--text-eyebrow: var(--font-size-
|
|
21
|
+
--text-eyebrow: var(--font-size-xs);
|
|
22
22
|
--text-control-sm: var(--control-text-sm);
|
|
23
23
|
--text-control-md: var(--control-text-md);
|
|
24
24
|
--text-control-lg: var(--control-text-lg);
|
|
@@ -28,6 +28,15 @@
|
|
|
28
28
|
--spacing-control-md: var(--control-height);
|
|
29
29
|
--spacing-control-lg: var(--control-height-lg);
|
|
30
30
|
--spacing-shell-header: var(--shell-header-height);
|
|
31
|
+
--color-brand: var(--brand-primary);
|
|
32
|
+
--color-brand-dim: var(--btn-primary-bg);
|
|
33
|
+
--color-brand-cool: var(--brand-cool);
|
|
34
|
+
--color-brand-glow: var(--brand-glow);
|
|
35
|
+
--color-brand-emerald: var(--brand-emerald);
|
|
36
|
+
--color-status-running: var(--status-running);
|
|
37
|
+
--color-status-creating: var(--status-creating);
|
|
38
|
+
--color-status-stopped: var(--status-stopped);
|
|
39
|
+
--color-status-error: var(--status-error);
|
|
31
40
|
--color-scrim: var(--scrim);
|
|
32
41
|
--color-scrim-strong: var(--scrim-strong);
|
|
33
42
|
--color-on-media: var(--on-media);
|
|
@@ -230,31 +239,14 @@
|
|
|
230
239
|
}
|
|
231
240
|
|
|
232
241
|
/* ───────────────────────── Tangle — canonical dark spine ───────────────────
|
|
233
|
-
* tangle-dark names the default :root/.dark spine explicitly
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* the dark-tuned root palette.
|
|
242
|
+
* tangle-dark names the default :root/.dark spine explicitly, for a dark island
|
|
243
|
+
* inside a light-named wrapper or a picker's explicit choice. It is the
|
|
244
|
+
* canonical spine by construction, never a copy of it.
|
|
237
245
|
*/
|
|
238
246
|
[data-theme="tangle-dark"] {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
--hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
|
|
243
|
-
--hsl-secondary: 249 24% 16%; --hsl-secondary-foreground: 0 0% 90.1961%;
|
|
244
|
-
--hsl-muted: 249 24% 16%; --hsl-muted-foreground: 240 5% 68%;
|
|
245
|
-
--hsl-accent: 249 24% 16%; --hsl-accent-foreground: 0 0% 90.1961%;
|
|
246
|
-
--hsl-destructive: 0 90% 77%; --hsl-destructive-foreground: 0 60% 10%;
|
|
247
|
-
--hsl-border: 243 20% 20%; --hsl-input: 244 29% 9%;
|
|
248
|
-
--hsl-ring: 233 100% 87%;
|
|
249
|
-
--depth-1: #0a0a14; --depth-2: #191826; --depth-3: #221f33; --depth-4: #2c2942;
|
|
250
|
-
--bg-root: #0a0a14; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
|
|
251
|
-
--md3-surface-container-lowest: #040408; --md3-surface-container-low: #12111f;
|
|
252
|
-
--md3-surface-container: #191826; --md3-surface-container-high: #221f33;
|
|
253
|
-
--md3-surface-container-highest: #2c2942; --md3-outline-variant: #2a293d;
|
|
254
|
-
--border-subtle: rgba(255, 255, 255, 0.10); --border-default: rgba(255, 255, 255, 0.16);
|
|
255
|
-
--border-hover: rgba(255, 255, 255, 0.28); --accent-text: #A5B4FC;
|
|
256
|
-
--brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
|
|
257
|
-
--text-primary: #e6e6e6; --text-secondary: #c7c7c7; --text-muted: #b5b5b5; --text-dim: #a5a5a5;
|
|
247
|
+
/* Nothing to declare: tokens.css seeds the complete dark spine on this
|
|
248
|
+
* selector, so a tangle-dark island carries every value itself and inherits
|
|
249
|
+
* nothing from a light wrapper. */
|
|
258
250
|
}
|
|
259
251
|
/* Its light mode. */
|
|
260
252
|
[data-theme="tangle-light"] {
|
|
@@ -378,7 +370,7 @@
|
|
|
378
370
|
--hsl-accent: 133 38.4% 88%; --hsl-accent-foreground: 143 25% 12%;
|
|
379
371
|
--hsl-border: 107 8.7% 79.8%; --hsl-input: 0 0% 100%;
|
|
380
372
|
--hsl-ring: 148 28% 43%;
|
|
381
|
-
--hsl-destructive: 0
|
|
373
|
+
--hsl-destructive: 0 73.7% 41.8%; /* the canonical light red, restated so the dark block below mirrors it */
|
|
382
374
|
--hsl-raised: 110 18.7% 93.7%; --hsl-raised-2: 0 0% 100%;
|
|
383
375
|
--sidebar-background: 108 21.7% 95.5%; --sidebar-foreground: 143 25% 12%;
|
|
384
376
|
--sidebar-primary: 153 38% 25%; --sidebar-primary-foreground: 0 0% 100%;
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
hexRelativeLuminanceIn,
|
|
12
12
|
hslIn,
|
|
13
13
|
hslToRgb,
|
|
14
|
+
resolveIn,
|
|
14
15
|
} from "./css-test-utils";
|
|
15
16
|
|
|
16
17
|
/**
|
|
@@ -100,71 +101,15 @@ describe('named theme: [data-theme="tangle-dark"]', () => {
|
|
|
100
101
|
);
|
|
101
102
|
if (!light) throw new Error('missing theme block: [data-theme="tangle-light"]');
|
|
102
103
|
|
|
103
|
-
it("
|
|
104
|
+
it("declares nothing: the attribute names the spine tokens.css seeds on it", () => {
|
|
104
105
|
// tangle-dark exists so the attribute can NAME the default spine (a dark
|
|
105
|
-
// island in a light-named wrapper, a picker's explicit selection).
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
"hsl-popover",
|
|
113
|
-
"hsl-popover-foreground",
|
|
114
|
-
"hsl-primary",
|
|
115
|
-
"hsl-primary-foreground",
|
|
116
|
-
"hsl-secondary",
|
|
117
|
-
"hsl-secondary-foreground",
|
|
118
|
-
"hsl-muted",
|
|
119
|
-
"hsl-muted-foreground",
|
|
120
|
-
"hsl-accent",
|
|
121
|
-
"hsl-accent-foreground",
|
|
122
|
-
"hsl-destructive",
|
|
123
|
-
"hsl-destructive-foreground",
|
|
124
|
-
"hsl-border",
|
|
125
|
-
"hsl-input",
|
|
126
|
-
"hsl-ring",
|
|
127
|
-
];
|
|
128
|
-
for (const token of hslTokens) {
|
|
129
|
-
expect(hslIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
|
|
130
|
-
hslIn(CANON, token),
|
|
131
|
-
);
|
|
132
|
-
}
|
|
133
|
-
const hexTokens = [
|
|
134
|
-
"depth-1",
|
|
135
|
-
"depth-2",
|
|
136
|
-
"depth-3",
|
|
137
|
-
"depth-4",
|
|
138
|
-
"md3-surface-container-lowest",
|
|
139
|
-
"md3-surface-container-low",
|
|
140
|
-
"md3-surface-container",
|
|
141
|
-
"md3-surface-container-high",
|
|
142
|
-
"md3-surface-container-highest",
|
|
143
|
-
"md3-outline-variant",
|
|
144
|
-
"accent-text",
|
|
145
|
-
"brand-primary",
|
|
146
|
-
"brand-glow",
|
|
147
|
-
"text-primary",
|
|
148
|
-
"text-secondary",
|
|
149
|
-
"text-muted",
|
|
150
|
-
"text-dim",
|
|
151
|
-
];
|
|
152
|
-
for (const token of hexTokens) {
|
|
153
|
-
expect(hexIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
|
|
154
|
-
hexIn(CANON, token),
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
// Aliased and rgba declarations don't fit hslIn/hexIn — compare them raw.
|
|
158
|
-
for (const token of [
|
|
159
|
-
"bg-root",
|
|
160
|
-
"bg-card",
|
|
161
|
-
"bg-input",
|
|
162
|
-
"border-subtle",
|
|
163
|
-
"border-default",
|
|
164
|
-
"border-hover",
|
|
165
|
-
]) {
|
|
166
|
-
expect(declIn(css, token), `--${token} must equal the :root dark spine`).toBe(
|
|
167
|
-
declIn(CANON, token),
|
|
106
|
+
// island in a light-named wrapper, a picker's explicit selection). It is
|
|
107
|
+
// the canonical spine by construction: tokens.css declares every dark token
|
|
108
|
+
// on this selector, so a copy here could only drift from it.
|
|
109
|
+
expect(css.replace(/^[^{]*\{/, "").trim()).toBe("");
|
|
110
|
+
for (const token of ["hsl-background", "hsl-destructive", "accent-text", "text-dim"]) {
|
|
111
|
+
expect(resolveIn(css, token), `--${token} resolves to the :root dark spine`).toBe(
|
|
112
|
+
resolveIn(CANON, token),
|
|
168
113
|
);
|
|
169
114
|
}
|
|
170
115
|
});
|
|
@@ -238,27 +183,14 @@ describe('named theme: [data-theme="tangle-dark"]', () => {
|
|
|
238
183
|
|
|
239
184
|
it("is self-sufficient inside a light-named wrapper — the island it exists for", () => {
|
|
240
185
|
// The feature's stated scenario: tangle-dark nested in a tangle-light
|
|
241
|
-
// wrapper.
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
const effective = (token: string) =>
|
|
249
|
-
const read = (source: string) => {
|
|
250
|
-
try {
|
|
251
|
-
return hslIn(source, token);
|
|
252
|
-
} catch {
|
|
253
|
-
return null;
|
|
254
|
-
}
|
|
255
|
-
};
|
|
256
|
-
const value = read(css) ?? read(wrapper);
|
|
257
|
-
if (!value) {
|
|
258
|
-
throw new Error(`--${token} resolves to nothing inside the island`);
|
|
259
|
-
}
|
|
260
|
-
return value;
|
|
261
|
-
};
|
|
186
|
+
// wrapper. A token the island omitted would inherit the WRAPPER's light
|
|
187
|
+
// value (its near-black secondary-foreground on the dark secondary measured
|
|
188
|
+
// ~1.5:1 once), which is why tokens.css seeds the whole spine on the
|
|
189
|
+
// island's own selector.
|
|
190
|
+
// The island declares every dark token on itself (the baseline tokens.css
|
|
191
|
+
// seeds on its selector), so nothing falls through to the wrapper: the
|
|
192
|
+
// chain is the island's own block over the canonical dark spine.
|
|
193
|
+
const effective = (token: string) => hslIn(css, token);
|
|
262
194
|
for (const [fgToken, bgToken] of [
|
|
263
195
|
["hsl-secondary-foreground", "hsl-secondary"],
|
|
264
196
|
["hsl-accent-foreground", "hsl-accent"],
|
|
@@ -304,14 +236,12 @@ describe("named themes: secondary text tiers are readable text", () => {
|
|
|
304
236
|
];
|
|
305
237
|
for (const scope of scopes) {
|
|
306
238
|
it(`[data-theme="${scope}"] holds AA for --text-muted and --text-dim`, () => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
);
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
return ref ? resolve(ref[1]) : hexIn(css!, token);
|
|
314
|
-
};
|
|
239
|
+
// The theme block over the spine it inherits; tangle-dark declares nothing
|
|
240
|
+
// and resolves to the canonical dark spine.
|
|
241
|
+
const blocks = blocksIn(themes, `[data-theme="${scope}"]`);
|
|
242
|
+
const css = blocks.find((b) => /--hsl-background:/.test(b)) ?? blocks[0];
|
|
243
|
+
const chain = scope.endsWith("-light") ? [css, CANON_LIGHT] : [css];
|
|
244
|
+
const resolve = (token: string) => hexIn(chain, token);
|
|
315
245
|
const planes = {
|
|
316
246
|
canvas: resolve("bg-root"),
|
|
317
247
|
card: resolve("bg-card"),
|
|
@@ -450,13 +380,16 @@ describe('named theme: [data-theme="hospitality"]', () => {
|
|
|
450
380
|
// sage planes (surface-ladder.test.ts holds their hue and contrast), and the
|
|
451
381
|
// dark destructive red is lightened to read on the lighter popover; it stays
|
|
452
382
|
// red. The other status hues, info and every category stay canonical.
|
|
453
|
-
for (const css of [
|
|
383
|
+
for (const [css, spine] of [
|
|
384
|
+
[light, CANON_LIGHT],
|
|
385
|
+
[dark, CANON],
|
|
386
|
+
] as const) {
|
|
454
387
|
expect(css).not.toMatch(
|
|
455
388
|
/--(hsl-)?(success|warning|info|error)\b|--destructive|--hsl-destructive-foreground|--surface-info-|--status-|--tone-|--run-mix-|--syntax-/,
|
|
456
389
|
);
|
|
457
|
-
expect(hslIn(css, "hsl-destructive").h, "destructive stays red").toBe(0);
|
|
390
|
+
expect(hslIn([css, spine], "hsl-destructive").h, "destructive stays red").toBe(0);
|
|
458
391
|
}
|
|
459
|
-
expect(
|
|
392
|
+
expect(hslIn(light, "hsl-destructive")).toEqual(hslIn(CANON_LIGHT, "hsl-destructive"));
|
|
460
393
|
});
|
|
461
394
|
|
|
462
395
|
it("keeps the canonical ladder roles: paper cards on a tinted canvas in light, rising planes in dark", () => {
|
|
@@ -680,8 +613,12 @@ describe("dark themes: destructive reads as text", () => {
|
|
|
680
613
|
'[data-theme="agents"]',
|
|
681
614
|
'.dark[data-theme="website"]',
|
|
682
615
|
].map((selector): [string, string] => {
|
|
683
|
-
|
|
684
|
-
|
|
616
|
+
// A selector can head two rules (tangle-light's shared status group and
|
|
617
|
+
// its theme block); the theme block is the one with a canvas. tangle-dark
|
|
618
|
+
// declares nothing and resolves to the spine it is seeded with.
|
|
619
|
+
const blocks = blocksIn(themes, selector);
|
|
620
|
+
const css = blocks.find((b) => b.includes("--hsl-background")) ?? (blocks.length === 1 ? blocks[0] : undefined);
|
|
621
|
+
if (css === undefined) throw new Error(`missing dark theme block: ${selector}`);
|
|
685
622
|
return [selector, css];
|
|
686
623
|
}),
|
|
687
624
|
];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { blockIn, blocksIn, contrastRatio, declIn, hexIn, hslIn, hslToRgb, oklchLightness } from "./css-test-utils";
|
|
4
|
+
import { blockIn, blocksIn, contrastRatio, declIn, hexIn, hslIn, hslToRgb, oklchLightness, resolveIn } from "./css-test-utils";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* The surface ladder and the interaction palette.
|
|
@@ -171,14 +171,13 @@ describe("shared surface context and interaction tokens (tokens.css)", () => {
|
|
|
171
171
|
}
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
-
it("
|
|
175
|
-
expect(declIn(body, "hsl-raised")).toBe("var(--hsl-
|
|
176
|
-
expect(declIn(body, "hsl-raised-2")).toBe("var(--hsl-
|
|
174
|
+
it("takes the raised planes from the spine's own L3 and L4 (values: the canonical suite below)", () => {
|
|
175
|
+
expect(declIn(body, "hsl-raised")).toBe("var(--hsl-secondary)");
|
|
176
|
+
expect(declIn(body, "hsl-raised-2")).toBe("var(--hsl-popover)");
|
|
177
177
|
expect(declIn(body, "well")).toBe("var(--well-page)");
|
|
178
178
|
expect(declIn(body, "shadow-raised")).toBe("var(--shadow-card)");
|
|
179
179
|
// A shadow token joins a comma list; "none" would void the list.
|
|
180
180
|
expect(declIn(body, "shadow-well")).toBe("0 0 0 0 transparent");
|
|
181
|
-
expect(declIn(body, "edge-highlight")).toBe("0 0 0 0 transparent");
|
|
182
181
|
});
|
|
183
182
|
|
|
184
183
|
it("derives the four interaction states from the focus ring at the specified alphas", () => {
|
|
@@ -223,19 +222,20 @@ describe("shared surface context and interaction tokens (tokens.css)", () => {
|
|
|
223
222
|
});
|
|
224
223
|
});
|
|
225
224
|
|
|
226
|
-
describe("Canonical surface ladder (
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
const
|
|
225
|
+
describe("Canonical surface ladder (the spine: :root, .dark, .light, dark, light, tangle-dark)", () => {
|
|
226
|
+
// Drew, 2026-10-26, on a Discovery run page built from the spine: "no color
|
|
227
|
+
// depth ladder". Raised levels painted the card and every well was one value,
|
|
228
|
+
// so a card in a card, and a field in it, read as the plane they sat on.
|
|
229
|
+
const css = rules(tokens);
|
|
230
|
+
const ladderRule = (selector: string) => {
|
|
231
231
|
const block = blocksIn(tokens, selector).find((b) => /--well-page:/.test(b));
|
|
232
|
-
if (!block) throw new Error(`no
|
|
232
|
+
if (!block) throw new Error(`no ladder rule under ${selector}`);
|
|
233
233
|
return block;
|
|
234
234
|
};
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
const DARK = blockIn(tokens, ".dark");
|
|
238
|
-
const LIGHT = blockIn(tokens, ".light");
|
|
235
|
+
const shared = ladderRule("[data-theme]");
|
|
236
|
+
const lightRule = ladderRule('[data-theme$="-light"]');
|
|
237
|
+
const DARK = [shared, blockIn(tokens, ".dark")];
|
|
238
|
+
const LIGHT = [lightRule, shared, blockIn(tokens, ".light")];
|
|
239
239
|
|
|
240
240
|
type Rgb = [number, number, number];
|
|
241
241
|
const toLab = (rgb: Rgb) => {
|
|
@@ -255,37 +255,104 @@ describe("Canonical surface ladder (every theme without its own ladder)", () =>
|
|
|
255
255
|
};
|
|
256
256
|
/** `color-mix(in oklab, a, b <t>)`. */
|
|
257
257
|
const mix = (a: Rgb, b: Rgb, t: number) => fromLab(toLab(a).map((v, i) => v * (1 - t) + toLab(b)[i] * t));
|
|
258
|
-
|
|
258
|
+
/** A token as the browser paints it, including the `page + 30% black` well. */
|
|
259
|
+
const rgb = (sources: string[], token: string): Rgb => {
|
|
260
|
+
const recess = /^color-mix\(in oklab, hsl\(var\(--([a-z0-9-]+)\)\) (\d+)%, black\)$/.exec(resolveIn(sources, token));
|
|
261
|
+
if (recess) return mix(rgb(sources, recess[1]!), [0, 0, 0], 1 - Number(recess[2]) / 100);
|
|
262
|
+
return hexIn(sources, token);
|
|
263
|
+
};
|
|
264
|
+
const Lof = (sources: string[], token: string) => oklchLightness(rgb(sources, token));
|
|
265
|
+
|
|
266
|
+
const ladder: Array<[role: string, token: string, light: number, dark: number]> = [
|
|
267
|
+
["page", "hsl-background", 94.9, 15.0],
|
|
268
|
+
["card", "hsl-card", 100, 21.7],
|
|
269
|
+
["raised", "hsl-raised", 95.2, 26.6],
|
|
270
|
+
["raised-2", "hsl-raised-2", 100, 31.6],
|
|
271
|
+
["popover", "hsl-popover", 100, 31.6],
|
|
272
|
+
["well on page", "well-page", 100, 10.2],
|
|
273
|
+
["well in card", "well-card", 94.9, 10.2],
|
|
274
|
+
["well in raised", "well-raised", 100, 21.7],
|
|
275
|
+
["well in raised-2", "well-raised-2", 94.9, 21.7],
|
|
276
|
+
["hairline", "hsl-border", 83.5, 34.1],
|
|
277
|
+
];
|
|
278
|
+
for (const [role, token, lightL, darkL] of ladder) {
|
|
279
|
+
it(`${role} (--${token}) lands at L ${lightL} light / ${darkL} dark`, () => {
|
|
280
|
+
expect(Lof(LIGHT, token)).toBeCloseTo(lightL, 0);
|
|
281
|
+
expect(Lof(DARK, token)).toBeCloseTo(darkL, 0);
|
|
282
|
+
});
|
|
283
|
+
}
|
|
259
284
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
285
|
+
const steps: Array<[child: string, parent: string]> = [
|
|
286
|
+
["hsl-card", "hsl-background"],
|
|
287
|
+
["hsl-raised", "hsl-card"],
|
|
288
|
+
["hsl-raised-2", "hsl-raised"],
|
|
289
|
+
["well-page", "hsl-background"],
|
|
290
|
+
["well-card", "hsl-card"],
|
|
291
|
+
["well-raised", "hsl-raised"],
|
|
292
|
+
["well-raised-2", "hsl-raised-2"],
|
|
293
|
+
];
|
|
294
|
+
it.each([
|
|
295
|
+
["light", LIGHT],
|
|
296
|
+
["dark", DARK],
|
|
297
|
+
])("steps every plane and well at least 4.4 L off its parent in %s", (_mode, sources) => {
|
|
298
|
+
for (const [child, parent] of steps) {
|
|
299
|
+
expect(Math.abs(Lof(sources, child) - Lof(sources, parent)), `${child} on ${parent}`).toBeGreaterThanOrEqual(4.4);
|
|
267
300
|
}
|
|
268
301
|
});
|
|
269
302
|
|
|
270
|
-
it("
|
|
271
|
-
const well
|
|
272
|
-
|
|
273
|
-
|
|
303
|
+
it("sinks every dark well below the plane it sits on, and lifts dark planes with a top highlight", () => {
|
|
304
|
+
for (const [well, parent] of steps.filter(([child]) => child.startsWith("well"))) {
|
|
305
|
+
expect(Lof(DARK, well), `${well} under ${parent}`).toBeLessThan(Lof(DARK, parent));
|
|
306
|
+
}
|
|
307
|
+
expect(declIn(shared, "edge-highlight")).toBe("inset 0 1px 0 rgba(255, 255, 255, 0.06)");
|
|
308
|
+
expect(declIn(lightRule, "edge-highlight")).toBe("0 0 0 0 transparent");
|
|
274
309
|
});
|
|
275
310
|
|
|
276
|
-
it("
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
311
|
+
it("draws every line with one hairline: 1.5:1 on the dark card and still seen on the raised plane", () => {
|
|
312
|
+
for (const sources of [LIGHT, DARK]) {
|
|
313
|
+
for (const alias of ["border-subtle", "border-default", "md3-outline-variant"]) {
|
|
314
|
+
expect(hexIn(sources, alias), `--${alias}`).toEqual(hexIn(sources, "hsl-border"));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
expect(contrastRatio(rgb(DARK, "hsl-border"), rgb(DARK, "hsl-card"))).toBeGreaterThanOrEqual(1.45);
|
|
318
|
+
expect(contrastRatio(rgb(DARK, "hsl-border"), rgb(DARK, "hsl-raised"))).toBeGreaterThanOrEqual(1.25);
|
|
319
|
+
expect(contrastRatio(rgb(LIGHT, "hsl-border"), rgb(LIGHT, "hsl-background"))).toBeGreaterThanOrEqual(1.4);
|
|
280
320
|
});
|
|
281
321
|
|
|
282
322
|
it.each([
|
|
283
|
-
["dark",
|
|
284
|
-
["light",
|
|
285
|
-
])("draws a field border that clears 3:1 on the card and
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
323
|
+
["dark", DARK],
|
|
324
|
+
["light", LIGHT],
|
|
325
|
+
])("draws a field border that clears 3:1 on the page, card and raised plane in %s (WCAG 1.4.11)", (_mode, sources) => {
|
|
326
|
+
expect(declIn(shared, "field-border")).toBe("color-mix(in oklab, hsl(var(--hsl-border)), hsl(var(--hsl-foreground)) 45%)");
|
|
327
|
+
const border = mix(rgb(sources, "hsl-border"), rgb(sources, "hsl-foreground"), 0.45);
|
|
328
|
+
for (const plane of ["hsl-background", "hsl-card", "hsl-raised"]) {
|
|
329
|
+
expect(contrastRatio(border, rgb(sources, plane)), `on --${plane}`).toBeGreaterThanOrEqual(3);
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("keeps every named theme with its own planes on the flat ladder until it is tuned", () => {
|
|
334
|
+
// The canonical ladder is the spine's indigo planes; a named theme that
|
|
335
|
+
// inherited it would nest an indigo card inside its own card.
|
|
336
|
+
const flat = css.match(/([^{}]*)\{\s*--hsl-raised:\s*var\(--hsl-card\);[^}]*\}/);
|
|
337
|
+
expect(flat, "the named-theme flat rule").not.toBeNull();
|
|
338
|
+
const flatBody = flat![0];
|
|
339
|
+
for (const [token, value] of [
|
|
340
|
+
["hsl-raised", "var(--hsl-card)"],
|
|
341
|
+
["hsl-raised-2", "var(--hsl-card)"],
|
|
342
|
+
["well-raised", "var(--well-card)"],
|
|
343
|
+
["well-raised-2", "var(--well-card)"],
|
|
344
|
+
["edge-highlight", "0 0 0 0 transparent"],
|
|
345
|
+
]) {
|
|
346
|
+
expect(declIn(flatBody, token!), `--${token}`).toBe(value);
|
|
347
|
+
}
|
|
348
|
+
const names = (selectors: string) => [...selectors.matchAll(/data-theme="([^"]+)"/g)].map((m) => m[1]);
|
|
349
|
+
const ownPlanes = new Set(
|
|
350
|
+
[...rules(themes).matchAll(/([^{}]*)\{([^{}]*)\}/g)]
|
|
351
|
+
.filter((m) => m[2]!.includes("--hsl-background:"))
|
|
352
|
+
.flatMap((m) => names(m[1]!)),
|
|
353
|
+
);
|
|
354
|
+
const tuned = new Set(["hospitality"]);
|
|
355
|
+
expect(new Set(names(flat![1]!))).toEqual(new Set([...ownPlanes].filter((name) => !tuned.has(name!))));
|
|
289
356
|
});
|
|
290
357
|
});
|
|
291
358
|
|
|
@@ -305,6 +372,6 @@ describe("Inset workspace backdrop (canonical themes)", () => {
|
|
|
305
372
|
expect(Math.abs(panel - backdrop)).toBeGreaterThanOrEqual(3);
|
|
306
373
|
});
|
|
307
374
|
it("keeps the dark floor tokens equal, as their comment says", () => {
|
|
308
|
-
expect(
|
|
375
|
+
expect(hexIn(DARK, "md3-surface-dim")).toEqual(hexIn(DARK, "md3-surface-container-lowest"));
|
|
309
376
|
});
|
|
310
377
|
});
|
|
@@ -18,8 +18,10 @@ const ladders = read("ladders.css");
|
|
|
18
18
|
|
|
19
19
|
/** The spine block of a selector: the rule that declares the surface ladder. */
|
|
20
20
|
function spine(source: string, selector: string): string {
|
|
21
|
-
const
|
|
22
|
-
|
|
21
|
+
const blocks = blocksIn(source, selector);
|
|
22
|
+
// A theme that declares nothing (tangle-dark) is the baseline its chain continues to.
|
|
23
|
+
const block = blocks.find((b) => b.includes("--hsl-background:")) ?? (blocks.length === 1 ? blocks[0] : undefined);
|
|
24
|
+
if (block === undefined) throw new Error(`missing spine block: ${selector}`);
|
|
23
25
|
return block;
|
|
24
26
|
}
|
|
25
27
|
|
package/src/styles/theme.css
CHANGED
|
@@ -36,16 +36,6 @@
|
|
|
36
36
|
--radius-xl: 12px;
|
|
37
37
|
--radius-full: 999px;
|
|
38
38
|
|
|
39
|
-
--color-brand: #6366F1;
|
|
40
|
-
--color-brand-dim: #4F46E5;
|
|
41
|
-
--color-brand-cool: #818CF8;
|
|
42
|
-
--color-brand-glow: #A5AAFC;
|
|
43
|
-
--color-brand-emerald: #10B981;
|
|
44
|
-
--color-status-running: #10B981;
|
|
45
|
-
--color-status-creating: #9B93F0;
|
|
46
|
-
--color-status-stopped: #FFB800;
|
|
47
|
-
--color-status-error: #FF4D6D;
|
|
48
|
-
|
|
49
39
|
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
50
40
|
--shadow-dropdown: 0 8px 30px rgba(0, 0, 0, 0.45);
|
|
51
41
|
--shadow-glow: none;
|
|
@@ -60,11 +50,23 @@
|
|
|
60
50
|
* Public HSL aliases remain HSL channel triples, never full CSS colors.
|
|
61
51
|
*/
|
|
62
52
|
@theme inline {
|
|
53
|
+
/* Brand and status colours follow the runtime tokens, so bg-brand and
|
|
54
|
+
* text-status-running change with the mode and the named theme. */
|
|
55
|
+
--color-brand: var(--brand-primary);
|
|
56
|
+
--color-brand-dim: var(--btn-primary-bg);
|
|
57
|
+
--color-brand-cool: var(--brand-cool);
|
|
58
|
+
--color-brand-glow: var(--brand-glow);
|
|
59
|
+
--color-brand-emerald: var(--brand-emerald);
|
|
60
|
+
--color-status-running: var(--status-running);
|
|
61
|
+
--color-status-creating: var(--status-creating);
|
|
62
|
+
--color-status-stopped: var(--status-stopped);
|
|
63
|
+
--color-status-error: var(--status-error);
|
|
64
|
+
|
|
63
65
|
--text-display: var(--font-size-display);
|
|
64
66
|
--text-hero: var(--font-size-hero);
|
|
65
67
|
--text-page: var(--font-size-3xl);
|
|
66
68
|
--text-section: var(--font-size-xl);
|
|
67
|
-
--text-eyebrow: var(--font-size-
|
|
69
|
+
--text-eyebrow: var(--font-size-xs);
|
|
68
70
|
|
|
69
71
|
/* Control and form type: text-control-sm/md/lg, text-label, text-help. */
|
|
70
72
|
--text-control-sm: var(--control-text-sm);
|