@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
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
|
+
|
|
17
|
+
## 1.3.0
|
|
18
|
+
|
|
19
|
+
### Minor Changes
|
|
20
|
+
|
|
21
|
+
- f3c5262: Add the missing `tangle-dark` named theme. The named set shipped `tangle-light` with no dark sibling, so `data-theme="tangle-dark"` matched no rule and silently fell through to whatever the element inherited — the light spine's values, inside a light-named wrapper. `tangle-dark` now selects the canonical dark spine by name: same indigo family as `tangle-light`, held to the dark contrast discipline, and self-sufficient inside a light-named wrapper (foregrounds and destructive included, so nothing leaks the light values).
|
|
22
|
+
|
|
23
|
+
## 1.2.0
|
|
24
|
+
|
|
25
|
+
### Minor Changes
|
|
26
|
+
|
|
27
|
+
- 2088fb9: Retune the token spine to a neutral-grey surface ladder with indigo as trim rather than as field, in both themes. Surfaces separate by their own fill so a card, a nested panel and an overlay each read as a distinct plane, and the canvas sits off pure black so long reading sessions land away from the glare end of the range.
|
|
28
|
+
|
|
29
|
+
Faint text in `Input`, `Textarea`, `StatCard` and `TerminalLine` now takes a dedicated `--text-dim` token instead of a faded stronger one. A translucent foreground renders as its colour composited over the plane behind it, so those hints, subtitles and timestamps measured differently on a card than on the canvas and fell under the 4.5:1 floor on both.
|
|
30
|
+
|
|
31
|
+
**Worth a look after upgrading:** in dark mode `--sidebar-background` is now one step BELOW the canvas rather than above it, so the nav reads as chrome the content sits in front of instead of as another raised card. Apps that composited their own surfaces on top of the sidebar assuming it was the lighter plane should give that area a visual pass. Light mode is unchanged — the sidebar is still paper on a grey canvas.
|
|
32
|
+
|
|
3
33
|
## 1.1.0
|
|
4
34
|
|
|
5
35
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/brand",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Tangle brand system — design tokens, logo, Tailwind v4 theme. Single source of truth for every Tangle app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@storybook/react": "^
|
|
50
|
+
"@storybook/react": "^10.5.5",
|
|
51
51
|
"@types/react": "^19.0.0",
|
|
52
|
-
"react": "^19.
|
|
52
|
+
"react": "^19.2.8",
|
|
53
53
|
"tsup": "^8.3.5",
|
|
54
54
|
"typescript": "^5.6.0"
|
|
55
55
|
},
|
|
@@ -18,30 +18,62 @@ function rulesOnly(css: string): string {
|
|
|
18
18
|
|
|
19
19
|
/** Extract a rule's body by selector, tolerant of formatting and of nesting. */
|
|
20
20
|
export function blockIn(source: string, selector: string): string {
|
|
21
|
+
const blocks = blocksIn(source, selector);
|
|
22
|
+
if (blocks.length === 0) throw new Error(`missing theme block: ${selector}`);
|
|
23
|
+
return blocks[0];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Every rule body whose selector list ENDS in `selector`, in source order — the
|
|
28
|
+
* match requires the selector immediately before the `{`, so a selector earlier
|
|
29
|
+
* in a comma-separated group (`selector, .other {`) is not returned.
|
|
30
|
+
*
|
|
31
|
+
* One selector can head MORE than one rule — `[data-theme="tangle-light"]` names
|
|
32
|
+
* both the shared light-status override group and the theme's own block — so a
|
|
33
|
+
* first-match read can silently slice the wrong region. Return them all and let
|
|
34
|
+
* the caller pick (e.g. the one that declares `--hsl-background`).
|
|
35
|
+
*/
|
|
36
|
+
export function blocksIn(source: string, selector: string): string[] {
|
|
21
37
|
// Comments first: a block's own doc-comment routinely spells its selector out,
|
|
22
38
|
// and anchoring on that prose would slice the wrong region — silently, since
|
|
23
39
|
// the result still looks like CSS.
|
|
24
40
|
const css = rulesOnly(source);
|
|
25
41
|
// Tolerate any whitespace between selector and brace — a formatter that closes
|
|
26
42
|
// the gap must not turn a passing suite into a "missing block" error.
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (
|
|
43
|
+
const pattern = new RegExp(`${escapeRegex(selector)}\\s*\\{`, "g");
|
|
44
|
+
|
|
45
|
+
const blocks: string[] = [];
|
|
46
|
+
for (const open of css.matchAll(pattern)) {
|
|
47
|
+
const start = open.index;
|
|
48
|
+
|
|
49
|
+
// Walk braces to find THIS block's close. Matching the first `}` would end the
|
|
50
|
+
// block at the first nested rule's close instead (an `@media`, a nested
|
|
51
|
+
// selector) — and the slice would still look like CSS, so assertions would run
|
|
52
|
+
// against a truncated region rather than failing. Depth-counting is exact, and
|
|
53
|
+
// an unterminated block throws instead of returning something plausible.
|
|
54
|
+
let depth = 0;
|
|
55
|
+
let closed = false;
|
|
56
|
+
for (let i = css.indexOf("{", start); i < css.length; i++) {
|
|
57
|
+
if (css[i] === "{") depth += 1;
|
|
58
|
+
else if (css[i] === "}") {
|
|
59
|
+
depth -= 1;
|
|
60
|
+
if (depth === 0) {
|
|
61
|
+
blocks.push(css.slice(start, i));
|
|
62
|
+
closed = true;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
42
66
|
}
|
|
67
|
+
if (!closed) throw new Error(`unterminated theme block: ${selector}`);
|
|
43
68
|
}
|
|
44
|
-
|
|
69
|
+
return blocks;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read a token's raw declaration value, whitespace-normalized. */
|
|
73
|
+
export function declIn(css: string, token: string): string {
|
|
74
|
+
const m = css.match(new RegExp(`--${token}:\\s*([^;]+)`));
|
|
75
|
+
if (!m) throw new Error(`missing token --${token}`);
|
|
76
|
+
return m[1].trim().replace(/\s+/g, " ");
|
|
45
77
|
}
|
|
46
78
|
|
|
47
79
|
/** Read an `H S% L%` channel triple. */
|
|
@@ -60,6 +92,32 @@ export function hslIn(
|
|
|
60
92
|
|
|
61
93
|
type Rgb = [number, number, number];
|
|
62
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Read a hex surface token as RGB 0-255.
|
|
97
|
+
*
|
|
98
|
+
* Accepts `#rgb`, `#rrggbb` and `#rrggbbaa` (alpha is dropped — compositing is a
|
|
99
|
+
* separate question, answered by `compositeOver`). A token that exists but is not
|
|
100
|
+
* hex reports exactly that, rather than claiming it is missing.
|
|
101
|
+
*/
|
|
102
|
+
export function hexIn(css: string, token: string): Rgb {
|
|
103
|
+
const declaration = css.match(new RegExp(`--${token}:\\s*([^;]+)`));
|
|
104
|
+
if (!declaration) throw new Error(`missing surface --${token}`);
|
|
105
|
+
|
|
106
|
+
const value = declaration[1].trim();
|
|
107
|
+
const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/.exec(value);
|
|
108
|
+
if (!hex) {
|
|
109
|
+
throw new Error(`--${token} is not a hex color (found "${value}")`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const digits = hex[1];
|
|
113
|
+
// #rgb is shorthand for #rrggbb; #rrggbbaa carries an alpha byte we drop.
|
|
114
|
+
const pairs =
|
|
115
|
+
digits.length === 3
|
|
116
|
+
? [...digits].map((d) => d + d)
|
|
117
|
+
: [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 6)];
|
|
118
|
+
return pairs.map((p) => Number.parseInt(p, 16)) as Rgb;
|
|
119
|
+
}
|
|
120
|
+
|
|
63
121
|
/** `H S% L%` → RGB 0-255. */
|
|
64
122
|
export function hslToRgb({
|
|
65
123
|
h,
|
|
@@ -120,33 +178,9 @@ export function contrastRatio(a: Rgb, b: Rgb): number {
|
|
|
120
178
|
* Relative luminance, not HSL lightness: the ladder varies hue and saturation as
|
|
121
179
|
* it rises, and HSL L is not perceptually uniform — two steps can share an L and
|
|
122
180
|
* still read as different brightnesses, so an "is each plane brighter than the one
|
|
123
|
-
* below" assertion made on HSL L would be measuring the wrong thing.
|
|
124
|
-
*
|
|
125
|
-
* Accepts `#rgb`, `#rrggbb` and `#rrggbbaa` (alpha is ignored — luminance is a
|
|
126
|
-
* property of the color, not of how much of it you can see through). A token that
|
|
127
|
-
* exists but is not hex reports exactly that, rather than claiming it is missing.
|
|
181
|
+
* below" assertion made on HSL L would be measuring the wrong thing. Hex parsing
|
|
182
|
+
* (and its error reporting) is `hexIn`'s.
|
|
128
183
|
*/
|
|
129
184
|
export function hexRelativeLuminanceIn(css: string, token: string): number {
|
|
130
|
-
|
|
131
|
-
if (!declaration) throw new Error(`missing surface --${token}`);
|
|
132
|
-
|
|
133
|
-
const value = declaration[1].trim();
|
|
134
|
-
const hex = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})\b/.exec(value);
|
|
135
|
-
if (!hex) {
|
|
136
|
-
throw new Error(
|
|
137
|
-
`--${token} is not a hex color (found "${value}") — luminance is undefined for it`,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const digits = hex[1];
|
|
142
|
-
// #rgb is shorthand for #rrggbb; #rrggbbaa carries an alpha byte we drop.
|
|
143
|
-
const pairs =
|
|
144
|
-
digits.length === 3
|
|
145
|
-
? [...digits].map((d) => d + d)
|
|
146
|
-
: [digits.slice(0, 2), digits.slice(2, 4), digits.slice(4, 6)];
|
|
147
|
-
|
|
148
|
-
const [r, g, b] = pairs.map((p) => Number.parseInt(p, 16) / 255);
|
|
149
|
-
const lin = (c: number) =>
|
|
150
|
-
c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
|
151
|
-
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
185
|
+
return relativeLuminance(hexIn(css, token));
|
|
152
186
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* components re-skin automatically because they reference the semantic tokens
|
|
3
3
|
* (bg-card / border-border / surface-container, mapped in theme.css). Apply with
|
|
4
4
|
* data-theme="<name>" on a wrapper (or <html>). The default :root / .dark spine
|
|
5
|
-
* is the neutral "tangle" theme
|
|
5
|
+
* is the neutral "tangle" theme — also selectable by name as "tangle-dark";
|
|
6
|
+
* "<name>-light" scopes are the light variants. */
|
|
6
7
|
|
|
7
8
|
/* Bridge — the actual re-skin mechanism. Tailwind utilities read the --color-*
|
|
8
9
|
* layer (theme.css @theme: `bg-card` → var(--color-card) = hsl(var(--card))).
|
|
@@ -51,7 +52,7 @@
|
|
|
51
52
|
[data-theme="arena-light"],
|
|
52
53
|
[data-theme="tangle-light"] {
|
|
53
54
|
--surface-success-bg: #ecfdf5; --surface-success-border: #a7f3d0; --surface-success-text: #047857;
|
|
54
|
-
--surface-warning-bg: #fffbeb; --surface-warning-border: #fde68a; --surface-warning-text: #
|
|
55
|
+
--surface-warning-bg: #fffbeb; --surface-warning-border: #fde68a; --surface-warning-text: #ab4f09;
|
|
55
56
|
--surface-danger-bg: #fff1f2; --surface-danger-border: #fecdd3; --surface-danger-text: #b91c1c;
|
|
56
57
|
--surface-info-bg: #f0f9ff; --surface-info-border: #bae6fd; --surface-info-text: #0369a1;
|
|
57
58
|
/* Light-tuned interactive tokens — without these the named light scopes
|
|
@@ -166,8 +167,42 @@
|
|
|
166
167
|
--text-primary: #06231a; --text-secondary: #285048; --text-muted: #5f8472; --text-dim: #82a699;
|
|
167
168
|
}
|
|
168
169
|
|
|
169
|
-
/* ───────────────────────── Tangle (neutral) —
|
|
170
|
-
/* dark
|
|
170
|
+
/* ───────────────────────── Tangle (neutral) — the canonical pair ──────────── */
|
|
171
|
+
/* tangle-dark names the default :root/.dark spine explicitly — the values mirror
|
|
172
|
+
* tokens.css exactly (pinned in named-themes.test.ts). The named scope matters the
|
|
173
|
+
* moment the attribute would NOT resolve to :root: a dark island inside a
|
|
174
|
+
* light-named wrapper inherits the light --hsl-* without it, and a theme picker
|
|
175
|
+
* switching between named themes needs a `tangle-dark` that actually selects. Same
|
|
176
|
+
* indigo hue family as tangle-light (~239-246), held to the dark discipline —
|
|
177
|
+
* graphite-indigo neutrals, elevation by lightening, the fill indigo that carries
|
|
178
|
+
* white (7.4:1) with the bright indigo reserved for accent text. The foreground
|
|
179
|
+
* and destructive redeclares equal :root standalone, but the block must be
|
|
180
|
+
* self-sufficient: in the island nesting an omitted token inherits the WRAPPER's
|
|
181
|
+
* light value (near-black secondary text on the dark secondary measured ~1.5:1).
|
|
182
|
+
* Status tokens are deliberately not redeclared: the :root dark status palette is
|
|
183
|
+
* already dark-tuned, same as aubergine/arena. */
|
|
184
|
+
[data-theme="tangle-dark"] {
|
|
185
|
+
--hsl-background: 240 24% 11%; --hsl-foreground: 225 22% 91%;
|
|
186
|
+
--hsl-card: 238 20% 22%; --hsl-card-foreground: 225 22% 91%;
|
|
187
|
+
--hsl-popover: 239 22% 25%; --hsl-popover-foreground: 225 22% 91%;
|
|
188
|
+
--hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
|
|
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
|
+
--hsl-destructive: 348 74% 48%; --hsl-destructive-foreground: 0 0% 100%;
|
|
193
|
+
--hsl-border: 236 20% 35%; --hsl-input: 240 24% 15%;
|
|
194
|
+
--hsl-ring: 233 100% 87%;
|
|
195
|
+
--depth-1: #151523; --depth-2: #2d2e43; --depth-3: #32324e; --depth-4: #393957;
|
|
196
|
+
--bg-root: #151523; --bg-card: var(--depth-2); --bg-input: hsl(var(--hsl-input));
|
|
197
|
+
--md3-surface-container-lowest: #0f0f1a; --md3-surface-container-low: #222235;
|
|
198
|
+
--md3-surface-container: #2d2e43; --md3-surface-container-high: #32324e;
|
|
199
|
+
--md3-surface-container-highest: #393957; --md3-outline-variant: #474a6b;
|
|
200
|
+
--border-subtle: rgba(165, 180, 252, 0.10); --border-default: rgba(165, 180, 252, 0.16);
|
|
201
|
+
--border-hover: rgba(165, 180, 252, 0.28); --accent-text: #A5B4FC;
|
|
202
|
+
--brand-primary: #7C6CF6; --brand-glow: #A5AAFC;
|
|
203
|
+
--text-primary: #e3e6ed; --text-secondary: #c2c7d4; --text-muted: #b2b4c7; --text-dim: #9ea5b9;
|
|
204
|
+
}
|
|
205
|
+
/* Its light mode. */
|
|
171
206
|
[data-theme="tangle-light"] {
|
|
172
207
|
--hsl-background: 240 33% 98%; --hsl-foreground: 245 32% 14%;
|
|
173
208
|
--hsl-card: 0 0% 100%; --hsl-card-foreground: 245 32% 14%;
|
|
@@ -1,7 +1,16 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { describe, expect, it } from "vitest";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
blockIn,
|
|
6
|
+
blocksIn,
|
|
7
|
+
contrastRatio,
|
|
8
|
+
declIn,
|
|
9
|
+
hexIn,
|
|
10
|
+
hexRelativeLuminanceIn,
|
|
11
|
+
hslIn,
|
|
12
|
+
hslToRgb,
|
|
13
|
+
} from "./css-test-utils";
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
16
|
* The named-theme contract.
|
|
@@ -16,6 +25,15 @@ const themes = readFileSync(
|
|
|
16
25
|
path.resolve(import.meta.dirname, "named-themes.css"),
|
|
17
26
|
"utf8",
|
|
18
27
|
);
|
|
28
|
+
const tokens = readFileSync(
|
|
29
|
+
path.resolve(import.meta.dirname, "tokens.css"),
|
|
30
|
+
"utf8",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// The canonical dark spine, anchored on the LAST selector of its multi-selector
|
|
34
|
+
// header (`:root, [data-sandbox-ui], [data-theme="dark"], .dark {`) — the same
|
|
35
|
+
// trick tokens.test.ts uses.
|
|
36
|
+
const CANON = blockIn(tokens, ".dark");
|
|
19
37
|
|
|
20
38
|
function block(selector: string): string {
|
|
21
39
|
return blockIn(themes, selector);
|
|
@@ -69,3 +87,218 @@ describe('named theme: .dark[data-theme="intelligence"]', () => {
|
|
|
69
87
|
expect(rules).not.toContain('[data-theme="intelligence-light"]');
|
|
70
88
|
});
|
|
71
89
|
});
|
|
90
|
+
|
|
91
|
+
describe('named theme: [data-theme="tangle-dark"]', () => {
|
|
92
|
+
const css = block('[data-theme="tangle-dark"]');
|
|
93
|
+
// `[data-theme="tangle-light"]` heads two rules — the shared light-status
|
|
94
|
+
// override group AND the theme's own block; the theme block is the one that
|
|
95
|
+
// declares the spine.
|
|
96
|
+
const light = blocksIn(themes, '[data-theme="tangle-light"]').find((b) =>
|
|
97
|
+
b.includes("--hsl-background"),
|
|
98
|
+
);
|
|
99
|
+
if (!light) throw new Error('missing theme block: [data-theme="tangle-light"]');
|
|
100
|
+
|
|
101
|
+
it("IS the canonical dark spine, named — no drift from tokens.css", () => {
|
|
102
|
+
// tangle-dark exists so the attribute can NAME the default spine (a dark
|
|
103
|
+
// island in a light-named wrapper, a picker's explicit selection). If its
|
|
104
|
+
// values diverge from :root, "tangle dark" becomes two palettes.
|
|
105
|
+
const hslTokens = [
|
|
106
|
+
"hsl-background",
|
|
107
|
+
"hsl-foreground",
|
|
108
|
+
"hsl-card",
|
|
109
|
+
"hsl-card-foreground",
|
|
110
|
+
"hsl-popover",
|
|
111
|
+
"hsl-popover-foreground",
|
|
112
|
+
"hsl-primary",
|
|
113
|
+
"hsl-primary-foreground",
|
|
114
|
+
"hsl-secondary",
|
|
115
|
+
"hsl-secondary-foreground",
|
|
116
|
+
"hsl-muted",
|
|
117
|
+
"hsl-muted-foreground",
|
|
118
|
+
"hsl-accent",
|
|
119
|
+
"hsl-accent-foreground",
|
|
120
|
+
"hsl-destructive",
|
|
121
|
+
"hsl-destructive-foreground",
|
|
122
|
+
"hsl-border",
|
|
123
|
+
"hsl-input",
|
|
124
|
+
"hsl-ring",
|
|
125
|
+
];
|
|
126
|
+
for (const token of hslTokens) {
|
|
127
|
+
expect(hslIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
|
|
128
|
+
hslIn(CANON, token),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
const hexTokens = [
|
|
132
|
+
"depth-1",
|
|
133
|
+
"depth-2",
|
|
134
|
+
"depth-3",
|
|
135
|
+
"depth-4",
|
|
136
|
+
"md3-surface-container-lowest",
|
|
137
|
+
"md3-surface-container-low",
|
|
138
|
+
"md3-surface-container",
|
|
139
|
+
"md3-surface-container-high",
|
|
140
|
+
"md3-surface-container-highest",
|
|
141
|
+
"md3-outline-variant",
|
|
142
|
+
"accent-text",
|
|
143
|
+
"brand-primary",
|
|
144
|
+
"brand-glow",
|
|
145
|
+
"text-primary",
|
|
146
|
+
"text-secondary",
|
|
147
|
+
"text-muted",
|
|
148
|
+
"text-dim",
|
|
149
|
+
];
|
|
150
|
+
for (const token of hexTokens) {
|
|
151
|
+
expect(hexIn(css, token), `--${token} must equal the :root dark spine`).toEqual(
|
|
152
|
+
hexIn(CANON, token),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
// Aliased and rgba declarations don't fit hslIn/hexIn — compare them raw.
|
|
156
|
+
for (const token of [
|
|
157
|
+
"bg-root",
|
|
158
|
+
"bg-card",
|
|
159
|
+
"bg-input",
|
|
160
|
+
"border-subtle",
|
|
161
|
+
"border-default",
|
|
162
|
+
"border-hover",
|
|
163
|
+
]) {
|
|
164
|
+
expect(declIn(css, token), `--${token} must equal the :root dark spine`).toBe(
|
|
165
|
+
declIn(CANON, token),
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("shares tangle-light's indigo hue family — same brand, dark register", () => {
|
|
171
|
+
for (const token of ["hsl-primary", "hsl-ring", "hsl-background"]) {
|
|
172
|
+
const dark = hslIn(css, token);
|
|
173
|
+
const lite = hslIn(light, token);
|
|
174
|
+
expect(
|
|
175
|
+
Math.abs(dark.h - lite.h),
|
|
176
|
+
`--${token} hue must sit within 10 of tangle-light's`,
|
|
177
|
+
).toBeLessThanOrEqual(10);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("tints its neutrals with the hue — nothing achromatic", () => {
|
|
182
|
+
for (const token of [
|
|
183
|
+
"hsl-background",
|
|
184
|
+
"hsl-card",
|
|
185
|
+
"hsl-popover",
|
|
186
|
+
"hsl-secondary",
|
|
187
|
+
"hsl-muted",
|
|
188
|
+
"hsl-accent",
|
|
189
|
+
"hsl-border",
|
|
190
|
+
"hsl-input",
|
|
191
|
+
]) {
|
|
192
|
+
const { h, s } = hslIn(css, token);
|
|
193
|
+
expect(h, `--${token} hue in the indigo band`).toBeGreaterThanOrEqual(220);
|
|
194
|
+
expect(h, `--${token} hue in the indigo band`).toBeLessThanOrEqual(250);
|
|
195
|
+
expect(s, `--${token} is hue-tinted, not dead grey`).toBeGreaterThan(5);
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("elevates by lightening, never darkening", () => {
|
|
200
|
+
const planes = ["depth-1", "depth-2", "depth-3", "depth-4"].map((t) =>
|
|
201
|
+
hexRelativeLuminanceIn(css, t),
|
|
202
|
+
);
|
|
203
|
+
for (let i = 1; i < planes.length; i++) {
|
|
204
|
+
expect(planes[i], `depth step ${i} must be brighter than the one below`).toBeGreaterThan(
|
|
205
|
+
planes[i - 1],
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
const ladder = ["hsl-background", "hsl-card", "hsl-popover"].map(
|
|
209
|
+
(t) => hslIn(css, t).l,
|
|
210
|
+
);
|
|
211
|
+
for (let i = 1; i < ladder.length; i++) {
|
|
212
|
+
expect(ladder[i], `step ${i} lightness must rise`).toBeGreaterThan(
|
|
213
|
+
ladder[i - 1],
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("holds the dark contrast discipline — AA floors, AAA ink", () => {
|
|
219
|
+
const canvas = hslToRgb(hslIn(css, "hsl-background"));
|
|
220
|
+
const card = hslToRgb(hslIn(css, "hsl-card"));
|
|
221
|
+
const fg = hslToRgb(hslIn(css, "hsl-foreground"));
|
|
222
|
+
expect(contrastRatio(fg, canvas), "ink on canvas").toBeGreaterThanOrEqual(7);
|
|
223
|
+
expect(contrastRatio(fg, card), "ink on card").toBeGreaterThanOrEqual(7);
|
|
224
|
+
|
|
225
|
+
const muted = hslToRgb(hslIn(css, "hsl-muted-foreground"));
|
|
226
|
+
expect(contrastRatio(muted, card), "muted ink on card").toBeGreaterThanOrEqual(
|
|
227
|
+
4.5,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const primary = hslToRgb(hslIn(css, "hsl-primary"));
|
|
231
|
+
const onPrimary = hslToRgb(hslIn(css, "hsl-primary-foreground"));
|
|
232
|
+
expect(
|
|
233
|
+
contrastRatio(onPrimary, primary),
|
|
234
|
+
"the fill indigo carries white",
|
|
235
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
236
|
+
|
|
237
|
+
// The ring is NON-TEXT: it must clear 3:1 against both planes it sits on.
|
|
238
|
+
const ring = hslToRgb(hslIn(css, "hsl-ring"));
|
|
239
|
+
expect(contrastRatio(ring, canvas), "ring on canvas").toBeGreaterThanOrEqual(3);
|
|
240
|
+
expect(contrastRatio(ring, card), "ring on card").toBeGreaterThanOrEqual(3);
|
|
241
|
+
|
|
242
|
+
expect(
|
|
243
|
+
contrastRatio(hexIn(css, "accent-text"), card),
|
|
244
|
+
"bright indigo accent text on card",
|
|
245
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
246
|
+
|
|
247
|
+
// The faintest tier is still TEXT: it clears the body floor on both planes.
|
|
248
|
+
const dim = hexIn(css, "text-dim");
|
|
249
|
+
expect(contrastRatio(dim, card), "text-dim on card").toBeGreaterThanOrEqual(4.5);
|
|
250
|
+
expect(contrastRatio(dim, canvas), "text-dim on canvas").toBeGreaterThanOrEqual(
|
|
251
|
+
4.5,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("is self-sufficient inside a light-named wrapper — the island it exists for", () => {
|
|
256
|
+
// The feature's stated scenario: tangle-dark nested in a tangle-light
|
|
257
|
+
// wrapper. The browser composes the two scopes, so a token the island omits
|
|
258
|
+
// inherits the WRAPPER's light value, not :root's — the light group's
|
|
259
|
+
// near-black secondary-foreground on the dark secondary measured ~1.5:1
|
|
260
|
+
// before the block redeclared its foregrounds. Model that cascade: every
|
|
261
|
+
// tangle-light rule (the shared light group AND the theme block), with the
|
|
262
|
+
// island's declarations overriding.
|
|
263
|
+
const wrapper = blocksIn(themes, '[data-theme="tangle-light"]').join("\n");
|
|
264
|
+
const effective = (token: string) => {
|
|
265
|
+
const read = (source: string) => {
|
|
266
|
+
try {
|
|
267
|
+
return hslIn(source, token);
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const value = read(css) ?? read(wrapper);
|
|
273
|
+
if (!value) {
|
|
274
|
+
throw new Error(`--${token} resolves to nothing inside the island`);
|
|
275
|
+
}
|
|
276
|
+
return value;
|
|
277
|
+
};
|
|
278
|
+
for (const [fgToken, bgToken] of [
|
|
279
|
+
["hsl-secondary-foreground", "hsl-secondary"],
|
|
280
|
+
["hsl-accent-foreground", "hsl-accent"],
|
|
281
|
+
["hsl-destructive-foreground", "hsl-destructive"],
|
|
282
|
+
] as const) {
|
|
283
|
+
expect(
|
|
284
|
+
contrastRatio(hslToRgb(effective(fgToken)), hslToRgb(effective(bgToken))),
|
|
285
|
+
`effective ${fgToken} on ${bgToken}, island inside tangle-light`,
|
|
286
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("keeps the dark-tuned :root status palette instead of redeclaring it", () => {
|
|
291
|
+
// Same contract as aubergine/arena: dark named themes inherit status from the
|
|
292
|
+
// dark spine, so there is exactly one dark status palette to tune. What must
|
|
293
|
+
// be proven is that the inherited pairs pass AA on their dark surfaces.
|
|
294
|
+
expect(css).not.toMatch(/--surface-(success|warning|danger|info)-/);
|
|
295
|
+
for (const name of ["success", "warning", "danger", "info"]) {
|
|
296
|
+
const text = hexIn(CANON, `surface-${name}-text`);
|
|
297
|
+
const bg = hexIn(CANON, `surface-${name}-bg`);
|
|
298
|
+
expect(
|
|
299
|
+
contrastRatio(text, bg),
|
|
300
|
+
`inherited ${name} status text on its dark surface`,
|
|
301
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
});
|