@tangle-network/brand 1.1.0 → 1.3.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 +16 -0
- package/package.json +3 -3
- package/src/styles/css-test-utils.ts +77 -43
- package/src/styles/named-themes.css +38 -3
- package/src/styles/named-themes.test.ts +234 -1
- package/src/styles/tokens.css +186 -127
- package/src/styles/tokens.test.ts +134 -13
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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).
|
|
8
|
+
|
|
9
|
+
## 1.2.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
**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.
|
|
18
|
+
|
|
3
19
|
## 1.1.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/brand",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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))).
|
|
@@ -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: 240 22% 27%; --hsl-popover-foreground: 225 22% 91%;
|
|
188
|
+
--hsl-primary: 246 54% 51%; --hsl-primary-foreground: 0 0% 100%;
|
|
189
|
+
--hsl-secondary: 240 22% 27%; --hsl-secondary-foreground: 225 22% 91%;
|
|
190
|
+
--hsl-muted: 240 22% 27%; --hsl-muted-foreground: 223 14% 69%;
|
|
191
|
+
--hsl-accent: 240 22% 27%; --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: #363654; --depth-4: #434365;
|
|
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: #363654;
|
|
199
|
+
--md3-surface-container-highest: #434365; --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: #bec3d0; --text-muted: #a5abbb; --text-dim: #9299ab;
|
|
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
|
+
});
|
package/src/styles/tokens.css
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/* Tangle design tokens — the single palette every Tangle surface renders from.
|
|
2
2
|
*
|
|
3
|
-
* The surface ladder
|
|
4
|
-
* increments
|
|
5
|
-
* plane by their own fill
|
|
6
|
-
*
|
|
7
|
-
* Light mirrors it: white paper on a
|
|
3
|
+
* The surface ladder is NEUTRAL GREY and the indigo is trim — accent, never
|
|
4
|
+
* field. It steps in even lightness increments so a card, a nested panel and an
|
|
5
|
+
* overlay each read as a distinct plane by their own fill, and carries a crisp
|
|
6
|
+
* hairline (1.6:1 against the card in dark) because shadow does no work on a dark
|
|
7
|
+
* canvas. Light mirrors it: white paper on a grey canvas, never white-on-white.
|
|
8
8
|
*
|
|
9
9
|
* Apps consume this spine as-is. If a product needs its own accent identity,
|
|
10
10
|
* add a named theme in named-themes.css (`data-theme="<name>"`) — do NOT re-declare
|
|
@@ -14,25 +14,47 @@
|
|
|
14
14
|
/* Dark is the default theme (`:root`/`[data-sandbox-ui]`). It also responds to
|
|
15
15
|
* the standard `[data-theme="dark"]` / `.dark` switch so any consumer can pin or
|
|
16
16
|
* toggle a theme without inventing an ad-hoc `data-sandbox-theme` name. */
|
|
17
|
+
/*
|
|
18
|
+
* Surfaces are NEUTRAL GREY and the indigo is trim.
|
|
19
|
+
*
|
|
20
|
+
* Colour in the surface competes with colour in the accent: on an indigo-cast
|
|
21
|
+
* ladder the indigo nav pill, link and focus ring are one more indigo thing on
|
|
22
|
+
* an indigo page, and the eye stops finding them. Neutral planes give the brand
|
|
23
|
+
* a field to sit on, so a single accent does all the signalling.
|
|
24
|
+
*
|
|
25
|
+
* The canvas sits OFF pure black (#151523). A near-black page under near-white
|
|
26
|
+
* ink is the glare end of the range, and it is the reading surface for long
|
|
27
|
+
* sessions.
|
|
28
|
+
*
|
|
29
|
+
* Elevation is carried by surface lightness plus a hairline that can actually be
|
|
30
|
+
* seen — a black shadow on a dark canvas renders as nothing, so shadow does no
|
|
31
|
+
* work here. The hairline is 1.6:1 against the resting card — quiet by design,
|
|
32
|
+
* enough to state an edge without drawing a box around every panel.
|
|
33
|
+
*/
|
|
17
34
|
:root,
|
|
18
35
|
[data-sandbox-ui],
|
|
19
36
|
[data-theme="dark"],
|
|
20
37
|
.dark {
|
|
21
|
-
--md3-surface: #
|
|
22
|
-
--md3-surface-dim: #
|
|
23
|
-
--md3-surface-bright: #
|
|
24
|
-
--md3-surface-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
--md3-surface-
|
|
30
|
-
--md3-
|
|
31
|
-
--md3-
|
|
32
|
-
|
|
33
|
-
--md3-
|
|
34
|
-
--md3-
|
|
35
|
-
--md3-
|
|
38
|
+
--md3-surface: #151523; /* L0 page canvas */
|
|
39
|
+
--md3-surface-dim: #0f0f1a; /* recessed page */
|
|
40
|
+
--md3-surface-bright: #434365;
|
|
41
|
+
/* Same value as --md3-surface-dim by design, not by drift: both name the floor
|
|
42
|
+
* of the ladder, and dark has no plane below it to give them. They stay two
|
|
43
|
+
* tokens because the roles diverge in light, where the well is paper (#ffffff)
|
|
44
|
+
* and the recessed page is grey (#d9d9e6) — a consumer binding to the wrong one
|
|
45
|
+
* there gets the wrong surface. */
|
|
46
|
+
--md3-surface-container-lowest: #0f0f1a; /* L-1 terminal/code well */
|
|
47
|
+
--md3-surface-container-low: #222235; /* L1 chrome, field wells */
|
|
48
|
+
--md3-surface-container: #2d2e43; /* L2 resting card */
|
|
49
|
+
--md3-surface-container-high: #363654; /* L3 nested / hover */
|
|
50
|
+
--md3-surface-container-highest: #434365; /* L4 overlay */
|
|
51
|
+
--md3-surface-variant: #363654;
|
|
52
|
+
--md3-on-surface: #e3e6ed;
|
|
53
|
+
--md3-on-surface-variant: #a5abbb;
|
|
54
|
+
|
|
55
|
+
--md3-primary: #A5B4FC;
|
|
56
|
+
--md3-primary-dim: #7C6CF6;
|
|
57
|
+
--md3-primary-container: #4C3FC5;
|
|
36
58
|
--md3-on-primary: #ffffff;
|
|
37
59
|
--md3-on-primary-container: #E0E7FF;
|
|
38
60
|
--md3-primary-fixed: #A5AAFC;
|
|
@@ -45,8 +67,8 @@
|
|
|
45
67
|
--md3-on-secondary-container: #E8E5FF;
|
|
46
68
|
|
|
47
69
|
--md3-tertiary: #10B981;
|
|
48
|
-
--md3-outline: #
|
|
49
|
-
--md3-outline-variant: #
|
|
70
|
+
--md3-outline: #898da9;
|
|
71
|
+
--md3-outline-variant: #474a6b;
|
|
50
72
|
|
|
51
73
|
--md3-error: #ff5277;
|
|
52
74
|
--md3-error-dim: #eb2855;
|
|
@@ -55,32 +77,43 @@
|
|
|
55
77
|
--md3-on-error-container: #ffbdf2;
|
|
56
78
|
|
|
57
79
|
--md3-inverse-surface: #fdfdfd;
|
|
58
|
-
--md3-inverse-primary: #
|
|
80
|
+
--md3-inverse-primary: #4C3FC5;
|
|
59
81
|
--md3-inverse-on-surface: #000000;
|
|
60
|
-
--md3-surface-tint: #
|
|
61
|
-
|
|
62
|
-
/* Semantic HSL bridge — the
|
|
63
|
-
--hsl-background: 240
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
--hsl-
|
|
68
|
-
--hsl-
|
|
69
|
-
--hsl-
|
|
82
|
+
--md3-surface-tint: #A5B4FC;
|
|
83
|
+
|
|
84
|
+
/* Semantic HSL bridge — the neutral-grey surface ladder */
|
|
85
|
+
--hsl-background: 240 24% 11%; /* L0 canvas #151523 */
|
|
86
|
+
/* 92% L, not pure white: near-white ink on the canvas is the glare end of the
|
|
87
|
+
* range, and the tiers below it need room to separate. 91% holds 10.6:1 on the
|
|
88
|
+
* card — AAA with headroom — and leaves four ink steps the eye can tell apart. */
|
|
89
|
+
--hsl-foreground: 225 22% 91%; /* #e3e6ed */
|
|
90
|
+
--hsl-card: 238 20% 22%; /* L2 card #2d2e43 */
|
|
91
|
+
--hsl-card-foreground: 225 22% 91%;
|
|
92
|
+
--hsl-popover: 240 22% 27%; /* L4 overlay */
|
|
93
|
+
--hsl-popover-foreground: 225 22% 91%;
|
|
94
|
+
/* The fill indigo, NOT the bright one: `.btn-primary` puts white on this, and
|
|
95
|
+
* #6366F1 carries white at only 4.47:1. This clears the floor at 7.4:1. The
|
|
96
|
+
* bright indigo stays available for accent TEXT via --accent-text. */
|
|
97
|
+
--hsl-primary: 246 54% 51%; /* #4c3fc6 */
|
|
70
98
|
--hsl-primary-foreground: 0 0% 100%;
|
|
71
|
-
--hsl-secondary:
|
|
72
|
-
--hsl-secondary-foreground:
|
|
73
|
-
--hsl-muted:
|
|
74
|
-
--hsl-muted-foreground:
|
|
99
|
+
--hsl-secondary: 240 22% 27%; /* L3 #363654 */
|
|
100
|
+
--hsl-secondary-foreground: 225 22% 91%;
|
|
101
|
+
--hsl-muted: 240 22% 27%; /* L3 hover well */
|
|
102
|
+
--hsl-muted-foreground: 223 14% 69%; /* #a5abbb — high enough that the
|
|
75
103
|
reduced-opacity caption derivatives (/70, /60) still clear the large-text
|
|
76
104
|
contrast floor against the L2 card. */
|
|
77
|
-
--hsl-accent:
|
|
78
|
-
--hsl-accent-foreground:
|
|
105
|
+
--hsl-accent: 240 22% 27%; /* neutral hover (indigo lives in accent-surface-strong) */
|
|
106
|
+
--hsl-accent-foreground: 225 22% 91%;
|
|
79
107
|
--hsl-destructive: 348 74% 48%;
|
|
80
108
|
--hsl-destructive-foreground: 0 0% 100%;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
--hsl-
|
|
109
|
+
/* Quiet on purpose: 1.6:1 against the resting card. Shadow does no work on a
|
|
110
|
+
* dark canvas, so the hairline is what states where a plane ends. */
|
|
111
|
+
--hsl-border: 236 20% 35%; /* #474a6b = --md3-outline-variant */
|
|
112
|
+
--hsl-input: 240 24% 15%; /* recessed field well */
|
|
113
|
+
/* Brighter than --primary on purpose: the ring is NON-TEXT and has to clear
|
|
114
|
+
* 3:1 against BOTH canvas and card. The fill indigo is tuned to carry white
|
|
115
|
+
* text instead, so the two cannot be the same value. */
|
|
116
|
+
--hsl-ring: 233 100% 87%;
|
|
84
117
|
--hsl-success: 160 84% 39%;
|
|
85
118
|
--hsl-warning: 40 94% 56%;
|
|
86
119
|
--hsl-info: 200 88% 56%;
|
|
@@ -115,26 +148,26 @@
|
|
|
115
148
|
--error: var(--hsl-destructive);
|
|
116
149
|
--info: var(--hsl-info);
|
|
117
150
|
|
|
118
|
-
/* Sidebar tokens — L1 chrome: one step
|
|
119
|
-
*
|
|
120
|
-
--sidebar-background:
|
|
121
|
-
--sidebar-foreground:
|
|
122
|
-
--sidebar-primary:
|
|
151
|
+
/* Sidebar tokens — L1 chrome: one step BELOW the canvas here, so the nav reads
|
|
152
|
+
* as chrome the content sits in front of rather than as another card. */
|
|
153
|
+
--sidebar-background: 242 26% 8%; /* L1 chrome #0f0f1a */
|
|
154
|
+
--sidebar-foreground: 225 22% 91%;
|
|
155
|
+
--sidebar-primary: 246 54% 51%;
|
|
123
156
|
--sidebar-primary-foreground: 0 0% 100%;
|
|
124
|
-
--sidebar-accent:
|
|
125
|
-
--sidebar-accent-foreground:
|
|
126
|
-
--sidebar-border:
|
|
127
|
-
--sidebar-ring:
|
|
157
|
+
--sidebar-accent: 240 22% 27%; /* L3 hover well */
|
|
158
|
+
--sidebar-accent-foreground: 225 22% 91%;
|
|
159
|
+
--sidebar-border: 236 20% 35%;
|
|
160
|
+
--sidebar-ring: 233 100% 87%;
|
|
128
161
|
|
|
129
162
|
/* Depth scale — tracks the MD3 ladder (canvas / card / nested / overlay) */
|
|
130
|
-
--depth-1: #
|
|
131
|
-
--depth-2: #
|
|
132
|
-
--depth-3: #
|
|
133
|
-
--depth-4: #
|
|
163
|
+
--depth-1: #151523;
|
|
164
|
+
--depth-2: #2d2e43;
|
|
165
|
+
--depth-3: #363654;
|
|
166
|
+
--depth-4: #434365;
|
|
134
167
|
|
|
135
168
|
/* Status colors */
|
|
136
169
|
--status-running: #10B981;
|
|
137
|
-
--status-creating: #
|
|
170
|
+
--status-creating: #A5B4FC;
|
|
138
171
|
--status-stopped: #FFB800;
|
|
139
172
|
--status-warm: #FF8A4C;
|
|
140
173
|
--status-cold: #4AABFF;
|
|
@@ -180,32 +213,39 @@
|
|
|
180
213
|
--mesh-blue: transparent;
|
|
181
214
|
|
|
182
215
|
/* Brand gradients — the indigo spark, kept ONLY for the logo mark + send button */
|
|
183
|
-
--tangle-gradient: linear-gradient(135deg, #
|
|
184
|
-
--tangle-gradient-text: linear-gradient(135deg, #
|
|
185
|
-
--accent-gradient-strong: linear-gradient(135deg, #
|
|
216
|
+
--tangle-gradient: linear-gradient(135deg, #4C3FC5, #A5B4FC);
|
|
217
|
+
--tangle-gradient-text: linear-gradient(135deg, #C7D2FE, #A5B4FC);
|
|
218
|
+
--accent-gradient-strong: linear-gradient(135deg, #4C3FC5, #7C6CF6);
|
|
186
219
|
/* Accent wells — soft = hover neutral, strong = active/selected row (indigo) */
|
|
187
|
-
--accent-surface-soft: rgba(
|
|
188
|
-
--accent-surface-strong: rgba(
|
|
189
|
-
|
|
220
|
+
--accent-surface-soft: rgba(165, 180, 252, 0.07);
|
|
221
|
+
--accent-surface-strong: rgba(76, 63, 197, 0.30);
|
|
222
|
+
/* The BRIGHT indigo, and only ever as text: --primary is the fill (it carries
|
|
223
|
+
* white), and at 6.7:1 on the card this is the one that stays legible as ink. */
|
|
224
|
+
--accent-text: #A5B4FC;
|
|
190
225
|
|
|
191
226
|
/* Surfaces — solid fills off the ladder (no radial mesh) */
|
|
192
|
-
--bg-root: #
|
|
227
|
+
--bg-root: #151523;
|
|
193
228
|
--bg-dark: var(--depth-1);
|
|
194
229
|
--bg-card: var(--depth-2);
|
|
195
230
|
--bg-elevated: var(--depth-4);
|
|
196
231
|
--bg-section: var(--depth-1);
|
|
197
232
|
--bg-input: hsl(var(--hsl-input)); /* the field well — same color, hex-free */
|
|
198
|
-
--bg-hover: rgba(
|
|
199
|
-
--bg-selection: rgba(
|
|
233
|
+
--bg-hover: rgba(165, 180, 252, 0.07);
|
|
234
|
+
--bg-selection: rgba(124, 108, 246, 0.28);
|
|
200
235
|
|
|
201
236
|
/* Text — neutral */
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
237
|
+
/* Four steps the eye can actually separate: 10.6 / 7.5 / 5.8 / 4.7 against the
|
|
238
|
+
* 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 — asserted in
|
|
240
|
+
* tokens.test.ts against both the card and the canvas, since the tier is
|
|
241
|
+
* solved against one surface and rendered on both. */
|
|
242
|
+
--text-primary: #e3e6ed;
|
|
243
|
+
--text-secondary: #bec3d0;
|
|
244
|
+
--text-muted: #a5abbb;
|
|
245
|
+
--text-dim: #9299ab;
|
|
206
246
|
|
|
207
247
|
/* Brand accents — Tangle */
|
|
208
|
-
--brand-primary: #
|
|
248
|
+
--brand-primary: #7C6CF6;
|
|
209
249
|
--brand-strong: #2E2A5E;
|
|
210
250
|
--brand-strong-text: #ffffff;
|
|
211
251
|
--brand-strong-text-muted: rgba(255, 255, 255, 0.7);
|
|
@@ -217,17 +257,17 @@
|
|
|
217
257
|
--brand-emerald: #10B981;
|
|
218
258
|
|
|
219
259
|
/* Borders — neutral hairlines */
|
|
220
|
-
--border-subtle: rgba(
|
|
221
|
-
--border-default: rgba(
|
|
222
|
-
--border-hover: rgba(
|
|
223
|
-
--border-accent: rgba(129, 140, 248, 0.
|
|
224
|
-
--border-accent-hover: rgba(129, 140, 248, 0.
|
|
260
|
+
--border-subtle: rgba(165, 180, 252, 0.10);
|
|
261
|
+
--border-default: rgba(165, 180, 252, 0.16);
|
|
262
|
+
--border-hover: rgba(165, 180, 252, 0.28);
|
|
263
|
+
--border-accent: rgba(129, 140, 248, 0.35);
|
|
264
|
+
--border-accent-hover: rgba(129, 140, 248, 0.50);
|
|
225
265
|
|
|
226
266
|
/* Buttons */
|
|
227
|
-
--btn-primary-bg: #
|
|
228
|
-
--btn-primary-hover: #
|
|
267
|
+
--btn-primary-bg: #4C3FC5;
|
|
268
|
+
--btn-primary-hover: #5A50D6;
|
|
229
269
|
--btn-primary-text: #ffffff;
|
|
230
|
-
--btn-cta-bg: #
|
|
270
|
+
--btn-cta-bg: #4C3FC5;
|
|
231
271
|
--btn-cta-text: #FFFFFF;
|
|
232
272
|
|
|
233
273
|
/* Code / runtime syntax */
|
|
@@ -342,17 +382,17 @@
|
|
|
342
382
|
* by shadow and ring, not by fill — which is how light-mode elevation actually
|
|
343
383
|
* reads. Forcing a strictly-darkening ladder here is what produced the
|
|
344
384
|
* white-on-white flatness this palette replaced. Pinned in tokens.test.ts. */
|
|
345
|
-
--md3-surface: #
|
|
346
|
-
--md3-surface-dim: #
|
|
385
|
+
--md3-surface: #e9e9f1; /* L0 page canvas — neutral grey, BELOW the wells */
|
|
386
|
+
--md3-surface-dim: #d9d9e6;
|
|
347
387
|
--md3-surface-bright: #ffffff;
|
|
348
388
|
--md3-surface-container-lowest: #ffffff; /* insets read as paper */
|
|
349
|
-
--md3-surface-container-low: #
|
|
389
|
+
--md3-surface-container-low: #f7f7fa; /* L1 chrome, field wells — well */
|
|
350
390
|
--md3-surface-container: #ffffff; /* L2 resting card — paper */
|
|
351
|
-
--md3-surface-container-high: #
|
|
391
|
+
--md3-surface-container-high: #f2f2f7; /* L3 nested / hover — well */
|
|
352
392
|
--md3-surface-container-highest: #ffffff; /* L4 overlay — paper + shadow */
|
|
353
|
-
--md3-surface-variant: #
|
|
354
|
-
--md3-on-surface: #
|
|
355
|
-
--md3-on-surface-variant: #
|
|
393
|
+
--md3-surface-variant: #f2f2f7;
|
|
394
|
+
--md3-on-surface: #2b2c3e;
|
|
395
|
+
--md3-on-surface-variant: #494b5d;
|
|
356
396
|
|
|
357
397
|
--md3-primary: #3E349E;
|
|
358
398
|
--md3-primary-dim: #352B88;
|
|
@@ -370,8 +410,8 @@
|
|
|
370
410
|
|
|
371
411
|
--md3-tertiary: #065F46;
|
|
372
412
|
--md3-tertiary-container: #D1FAE5;
|
|
373
|
-
--md3-outline: #
|
|
374
|
-
--md3-outline-variant: #
|
|
413
|
+
--md3-outline: #737696;
|
|
414
|
+
--md3-outline-variant: #cecfde;
|
|
375
415
|
|
|
376
416
|
--md3-error: #ba1a1a;
|
|
377
417
|
--md3-error-dim: #93000a;
|
|
@@ -384,26 +424,36 @@
|
|
|
384
424
|
--md3-inverse-on-surface: #f0f1f4;
|
|
385
425
|
--md3-surface-tint: #6366F1;
|
|
386
426
|
|
|
387
|
-
/* Semantic HSL bridge — white paper on a
|
|
388
|
-
--hsl-background:
|
|
389
|
-
|
|
427
|
+
/* Semantic HSL bridge — white paper on a neutral-grey canvas */
|
|
428
|
+
--hsl-background: 240 24% 93%; /* L0 canvas #e9e9f1 */
|
|
429
|
+
/* Ink, not black. Pure #0d0d0d on white paper measures 19.5:1 — a hair off the
|
|
430
|
+
* 21:1 ceiling — and that maximum is what reads as glare rather than as
|
|
431
|
+
* legibility. 20.5% L lands 13.7:1 on the card and 11.4:1 on the canvas: AAA with
|
|
432
|
+
* headroom in both places, and a step the eye reads as text instead of as a
|
|
433
|
+
* hole. */
|
|
434
|
+
--hsl-foreground: 235 18% 20.5%; /* #2b2c3e */
|
|
390
435
|
--hsl-card: 0 0% 100%; /* L2 card = white paper */
|
|
391
|
-
--hsl-card-foreground:
|
|
436
|
+
--hsl-card-foreground: 235 18% 20.5%;
|
|
392
437
|
--hsl-popover: 0 0% 100%;
|
|
393
|
-
--hsl-popover-foreground:
|
|
394
|
-
--hsl-primary:
|
|
438
|
+
--hsl-popover-foreground: 235 18% 20.5%;
|
|
439
|
+
--hsl-primary: 246 54% 51%; /* #4c3fc6 — carries white at 7.4:1 */
|
|
395
440
|
--hsl-primary-foreground: 0 0% 100%;
|
|
396
|
-
--hsl-secondary:
|
|
397
|
-
--hsl-secondary-foreground:
|
|
398
|
-
--hsl-muted:
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
441
|
+
--hsl-secondary: 240 26% 96%; /* L3 #f2f2f7 */
|
|
442
|
+
--hsl-secondary-foreground: 235 18% 20.5%;
|
|
443
|
+
--hsl-muted: 240 26% 96%; /* L3 hover well */
|
|
444
|
+
/* 34% L: the shared components fade captions with /70 and /60, and a
|
|
445
|
+
* translucent foreground renders as the token COMPOSITED over the plane behind
|
|
446
|
+
* it. Against white paper that compositing is brutal — a lighter value puts the
|
|
447
|
+
* /60 caption under the 3:1 large-text floor. This holds 3.0:1 there and 8.6:1
|
|
448
|
+
* at full strength, still a clear step below the 13.7:1 body ink. */
|
|
449
|
+
--hsl-muted-foreground: 235 12% 32.5%; /* #494b5d text-muted */
|
|
450
|
+
--hsl-accent: 240 26% 96%; /* neutral hover */
|
|
451
|
+
--hsl-accent-foreground: 235 18% 20.5%;
|
|
402
452
|
--hsl-destructive: 0 72% 41%;
|
|
403
453
|
--hsl-destructive-foreground: 0 0% 100%;
|
|
404
|
-
--hsl-border:
|
|
405
|
-
--hsl-input:
|
|
406
|
-
--hsl-ring:
|
|
454
|
+
--hsl-border: 238 20% 84%; /* #cecfde = --md3-outline-variant */
|
|
455
|
+
--hsl-input: 240 30% 97%; /* field well */
|
|
456
|
+
--hsl-ring: 246 54% 51%;
|
|
407
457
|
--hsl-success: 160 84% 30%;
|
|
408
458
|
--hsl-warning: 41 96% 50%;
|
|
409
459
|
--hsl-info: 212 80% 50%;
|
|
@@ -411,9 +461,9 @@
|
|
|
411
461
|
/* depth-2 (card) and depth-4 (elevated) are BOTH paper, on purpose: in light,
|
|
412
462
|
* a popover does not out-lighten a white card — it lifts by shadow. See the
|
|
413
463
|
* ladder note above. */
|
|
414
|
-
--depth-1: #
|
|
464
|
+
--depth-1: #e9e9f1; /* canvas */
|
|
415
465
|
--depth-2: #ffffff; /* card — paper */
|
|
416
|
-
--depth-3: #
|
|
466
|
+
--depth-3: #f2f2f7; /* nested well */
|
|
417
467
|
--depth-4: #ffffff; /* elevated — paper, separated by shadow */
|
|
418
468
|
|
|
419
469
|
--status-running: #059669;
|
|
@@ -457,20 +507,20 @@
|
|
|
457
507
|
|
|
458
508
|
/* Sidebar — light, flat surface + hairline */
|
|
459
509
|
--sidebar-background: 0 0% 100%; /* white paper */
|
|
460
|
-
--sidebar-foreground:
|
|
461
|
-
--sidebar-primary:
|
|
510
|
+
--sidebar-foreground: 235 18% 20.5%;
|
|
511
|
+
--sidebar-primary: 246 54% 51%; /* #4c3fc6 */
|
|
462
512
|
--sidebar-primary-foreground: 0 0% 100%;
|
|
463
|
-
--sidebar-accent:
|
|
464
|
-
--sidebar-accent-foreground:
|
|
465
|
-
--sidebar-border:
|
|
466
|
-
--sidebar-ring:
|
|
513
|
+
--sidebar-accent: 240 26% 96%; /* L3 hover well */
|
|
514
|
+
--sidebar-accent-foreground: 235 18% 20.5%;
|
|
515
|
+
--sidebar-border: 238 20% 84%; /* = --md3-outline-variant */
|
|
516
|
+
--sidebar-ring: 246 54% 51%;
|
|
467
517
|
|
|
468
518
|
--mesh-teal: transparent;
|
|
469
519
|
--mesh-violet: transparent;
|
|
470
520
|
--mesh-blue: transparent;
|
|
471
521
|
|
|
472
522
|
/* Surfaces — flat single-neutral light */
|
|
473
|
-
--bg-root: #
|
|
523
|
+
--bg-root: #e9e9f1;
|
|
474
524
|
--bg-dark: var(--depth-1);
|
|
475
525
|
--bg-card: var(--depth-2);
|
|
476
526
|
/* Paper, exactly like --bg-card: in light you cannot raise a plane by making it
|
|
@@ -481,13 +531,22 @@
|
|
|
481
531
|
--bg-elevated: var(--depth-4);
|
|
482
532
|
--bg-section: var(--depth-1);
|
|
483
533
|
--bg-input: hsl(var(--hsl-input)); /* the field well — same color, hex-free */
|
|
484
|
-
--bg-hover: rgba(
|
|
485
|
-
--bg-selection: rgba(
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
534
|
+
--bg-hover: rgba(76, 63, 198, 0.06);
|
|
535
|
+
--bg-selection: rgba(76, 63, 198, 0.16);
|
|
536
|
+
|
|
537
|
+
/* Mirrors the dark ramp's spacing: 13.7 / 10.5 / 8.6 / 5.6 against white paper.
|
|
538
|
+
* Every step carries the 240 cast so the ink belongs to the palette.
|
|
539
|
+
*
|
|
540
|
+
* --text-dim is pinned from the CANVAS (#e9e9f1), not the white card: it is
|
|
541
|
+
* the faintest tier, so the tinted L0 is the surface that binds it, and a
|
|
542
|
+
* value chosen against paper alone sits under the 4.5:1 body floor there.
|
|
543
|
+
* 4.6 on the canvas, 5.6 on the card — both floors are asserted in
|
|
544
|
+
* tokens.test.ts, because a ratio quoted in a comment drifts the moment a
|
|
545
|
+
* surface moves and nothing catches it. */
|
|
546
|
+
--text-primary: #2b2c3e;
|
|
547
|
+
--text-secondary: #3d3e52;
|
|
548
|
+
--text-muted: #494b5d;
|
|
549
|
+
--text-dim: #65677b;
|
|
491
550
|
|
|
492
551
|
--brand-primary: #6366F1;
|
|
493
552
|
--brand-strong: #2E2A5E;
|
|
@@ -500,16 +559,16 @@
|
|
|
500
559
|
--brand-vibrant: #6366F1;
|
|
501
560
|
--brand-emerald: #059669;
|
|
502
561
|
|
|
503
|
-
--border-subtle: rgba(
|
|
504
|
-
--border-default: rgba(
|
|
505
|
-
--border-hover: rgba(
|
|
562
|
+
--border-subtle: rgba(60, 52, 140, 0.10);
|
|
563
|
+
--border-default: rgba(60, 52, 140, 0.14);
|
|
564
|
+
--border-hover: rgba(60, 52, 140, 0.24);
|
|
506
565
|
--border-accent: rgba(91, 78, 212, 0.18);
|
|
507
566
|
--border-accent-hover: rgba(91, 78, 212, 0.35);
|
|
508
567
|
|
|
509
|
-
--btn-primary-bg: #
|
|
510
|
-
--btn-primary-hover: #
|
|
568
|
+
--btn-primary-bg: #4C3FC5;
|
|
569
|
+
--btn-primary-hover: #5A50D6;
|
|
511
570
|
--btn-primary-text: #ffffff;
|
|
512
|
-
--btn-cta-bg: #
|
|
571
|
+
--btn-cta-bg: #4C3FC5;
|
|
513
572
|
--btn-cta-text: #ffffff;
|
|
514
573
|
|
|
515
574
|
--code-keyword: #3E349E;
|
|
@@ -530,9 +589,9 @@
|
|
|
530
589
|
--tangle-gradient: linear-gradient(135deg, #4F46E5, #5B4ED4);
|
|
531
590
|
--tangle-gradient-text: linear-gradient(135deg, #4F46E5, #5B4ED4);
|
|
532
591
|
--accent-gradient-strong: linear-gradient(135deg, #4F46E5, #5B4ED4);
|
|
533
|
-
--accent-surface-soft: rgba(
|
|
534
|
-
--accent-surface-strong: rgba(
|
|
535
|
-
--accent-text: #
|
|
592
|
+
--accent-surface-soft: rgba(76, 63, 198, 0.05);
|
|
593
|
+
--accent-surface-strong: rgba(76, 63, 198, 0.13);
|
|
594
|
+
--accent-text: #5047eb;
|
|
536
595
|
|
|
537
596
|
/* Typography — unified on Inter */
|
|
538
597
|
--font-sans: "Inter", "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
@@ -32,17 +32,36 @@ const DARK = blockIn(tokens, ".dark");
|
|
|
32
32
|
const LIGHT = blockIn(tokens, ".light");
|
|
33
33
|
|
|
34
34
|
describe("canonical dark spine", () => {
|
|
35
|
-
it("
|
|
35
|
+
it("keeps the canvas in the brand's hue family, at any strength", () => {
|
|
36
36
|
const bg = hslIn(DARK, "hsl-background");
|
|
37
|
+
// HUE is the brand constraint and does not move: the greys are cool and sit
|
|
38
|
+
// in the indigo band, so they belong to the same family as the accent rather
|
|
39
|
+
// than reading as a foreign neutral pasted underneath it.
|
|
37
40
|
expect(bg.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
|
|
38
|
-
|
|
41
|
+
215,
|
|
42
|
+
);
|
|
43
|
+
expect(bg.h).toBeLessThanOrEqual(260);
|
|
44
|
+
// SATURATION is a taste dial, not a contract. How far the surfaces lean —
|
|
45
|
+
// near-neutral graphite with the indigo purely in the accents, or a fully
|
|
46
|
+
// indigo-cast ground — is a live product decision, so the only thing pinned
|
|
47
|
+
// here is that the canvas is not dead grey. Asserting a band instead makes
|
|
48
|
+
// this test fail every time that decision is revisited, which tells you
|
|
49
|
+
// nothing about whether the palette is correct.
|
|
50
|
+
expect(bg.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
|
|
51
|
+
5,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("guards reading comfort at the INK, not at the canvas", () => {
|
|
56
|
+
// A deep canvas is fine — glare comes from the ratio between ink and ground,
|
|
57
|
+
// not from the ground alone. So the floor here is loose, and the real guard
|
|
58
|
+
// is the 16:1 comfort ceiling on body text in contrast.test.ts. Pinning the
|
|
59
|
+
// canvas instead would forbid a deep spine that is perfectly comfortable
|
|
60
|
+
// once its ink is tuned.
|
|
61
|
+
const bg = hslIn(DARK, "hsl-background");
|
|
62
|
+
expect(bg.l, "an unlit canvas still needs to be a surface").toBeGreaterThan(
|
|
63
|
+
3,
|
|
39
64
|
);
|
|
40
|
-
expect(bg.h).toBeLessThanOrEqual(250);
|
|
41
|
-
// The flat spine this replaced sat at 7%. Anything near that reads grey.
|
|
42
|
-
expect(
|
|
43
|
-
bg.s,
|
|
44
|
-
"a desaturated canvas is the bug, not the design",
|
|
45
|
-
).toBeGreaterThan(20);
|
|
46
65
|
});
|
|
47
66
|
|
|
48
67
|
it("lifts the card off the canvas by fill, not by border", () => {
|
|
@@ -101,6 +120,101 @@ describe("muted text stays legible at the opacities the UI actually uses", () =>
|
|
|
101
120
|
}
|
|
102
121
|
});
|
|
103
122
|
|
|
123
|
+
describe("the sidebar is its own plane", () => {
|
|
124
|
+
// Which SIDE of the canvas the nav sits on is a taste call — it is chrome
|
|
125
|
+
// below the content in dark and paper above it in light, and either reads
|
|
126
|
+
// fine. What is not a taste call is the two collapsing onto the same value:
|
|
127
|
+
// the nav would stop being a distinct region and the page would lose the edge
|
|
128
|
+
// that separates navigation from content, with no border to fall back on.
|
|
129
|
+
// Pinned as a minimum separation so a retune can move it without erasing it.
|
|
130
|
+
for (const [theme, spine] of [
|
|
131
|
+
["dark", DARK],
|
|
132
|
+
["light", LIGHT],
|
|
133
|
+
] as const) {
|
|
134
|
+
it(`${theme}: sidebar and canvas stay separable`, () => {
|
|
135
|
+
const sidebar = hslIn(spine, "sidebar-background");
|
|
136
|
+
const canvas = hslIn(spine, "hsl-background");
|
|
137
|
+
expect(
|
|
138
|
+
Math.abs(sidebar.l - canvas.l),
|
|
139
|
+
"sidebar and canvas lightness must not converge",
|
|
140
|
+
).toBeGreaterThanOrEqual(2);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("a hex quoted beside an HSL token is the value that token resolves to", () => {
|
|
146
|
+
// The spine is authored in HSL, but it is READ in hex — every comment here
|
|
147
|
+
// annotates a triple with the colour it produces, because `238 20% 22%` tells
|
|
148
|
+
// a reader nothing. That makes the annotations load-bearing documentation, and
|
|
149
|
+
// load-bearing documentation that nothing checks is documentation that lies:
|
|
150
|
+
// retuning a triple leaves the old hex sitting beside it, and the next person
|
|
151
|
+
// to trust it picks a neighbouring value against a surface that moved.
|
|
152
|
+
//
|
|
153
|
+
// Every declaration of the form `--token: H S% L%; /* … #rrggbb … */`, in every
|
|
154
|
+
// block, not just the two spines.
|
|
155
|
+
const ANNOTATED =
|
|
156
|
+
/^[^\S\n]*(--[a-z0-9-]+):\s*([\d.]+)\s+([\d.]+)%\s+([\d.]+)%;[^\S\n]*\/\*([^*]*)\*\//gm;
|
|
157
|
+
|
|
158
|
+
const annotated = [...tokens.matchAll(ANNOTATED)].flatMap(
|
|
159
|
+
([, token, h, s, l, comment]) =>
|
|
160
|
+
(comment.match(/#[0-9a-fA-F]{6}/g) ?? []).map((quoted) => ({
|
|
161
|
+
token,
|
|
162
|
+
quoted,
|
|
163
|
+
hsl: { h: Number(h), s: Number(s), l: Number(l) },
|
|
164
|
+
})),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
it("finds the annotations to check", () => {
|
|
168
|
+
// Without this, rewriting a comment into a shape the pattern misses would
|
|
169
|
+
// empty the suite below and read as a pass.
|
|
170
|
+
expect(annotated.length).toBeGreaterThanOrEqual(15);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
for (const { token, quoted, hsl } of annotated) {
|
|
174
|
+
it(`${token} resolves to ${quoted}`, () => {
|
|
175
|
+
const actual = `#${hslToRgb(hsl)
|
|
176
|
+
.map((c) => Math.round(c).toString(16).padStart(2, "0"))
|
|
177
|
+
.join("")}`;
|
|
178
|
+
expect(actual).toBe(quoted.toLowerCase());
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("the faint ink tiers are still TEXT, on every plane they land on", () => {
|
|
184
|
+
// These two tiers are the ones that break. They are solved against a specific
|
|
185
|
+
// surface, so any move in the ladder — lifting the card, softening the canvas,
|
|
186
|
+
// recasting the hue — silently drops them under the floor, and nothing about
|
|
187
|
+
// the result LOOKS wrong: it renders as slightly faint captions that happen to
|
|
188
|
+
// be unreadable. Every plane is asserted, not just the one each was tuned on.
|
|
189
|
+
//
|
|
190
|
+
// Compared hex-to-hex through the MD3 ladder rather than the HSL bridge,
|
|
191
|
+
// because these tokens ARE hexes; `--md3-surface` and `--md3-surface-container`
|
|
192
|
+
// are the canvas and the card at the same positions.
|
|
193
|
+
const ratioOf = (a: number, b: number) =>
|
|
194
|
+
(Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
195
|
+
|
|
196
|
+
for (const [theme, spine] of [
|
|
197
|
+
["dark", DARK],
|
|
198
|
+
["light", LIGHT],
|
|
199
|
+
] as const) {
|
|
200
|
+
for (const tier of ["text-dim", "text-muted"] as const) {
|
|
201
|
+
it(`${theme}: --${tier} clears 4.5:1 on both the card and the canvas`, () => {
|
|
202
|
+
expect(spine, `--${tier} must be defined`).toContain(`--${tier}:`);
|
|
203
|
+
const ink = hexRelativeLuminanceIn(spine, tier);
|
|
204
|
+
for (const [plane, token] of [
|
|
205
|
+
["card", "md3-surface-container"],
|
|
206
|
+
["canvas", "md3-surface"],
|
|
207
|
+
] as const) {
|
|
208
|
+
expect(
|
|
209
|
+
ratioOf(ink, hexRelativeLuminanceIn(spine, token)),
|
|
210
|
+
`--${tier} on the ${plane}`,
|
|
211
|
+
).toBeGreaterThanOrEqual(4.5);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
104
218
|
describe("input tokens carry two DIFFERENT roles and must not be conflated", () => {
|
|
105
219
|
// `--input` is shadcn's input BORDER and the Switch off-track: a mid-tone that
|
|
106
220
|
// must stay visible AGAINST a surface. `--hsl-input` / `--bg-input` are the
|
|
@@ -129,7 +243,7 @@ describe("input tokens carry two DIFFERENT roles and must not be conflated", ()
|
|
|
129
243
|
});
|
|
130
244
|
|
|
131
245
|
describe("canonical light spine", () => {
|
|
132
|
-
it("is white paper on a
|
|
246
|
+
it("is white paper on a tinted canvas, never white-on-white", () => {
|
|
133
247
|
const canvas = hslIn(LIGHT, "hsl-background");
|
|
134
248
|
const card = hslIn(LIGHT, "hsl-card");
|
|
135
249
|
expect(card.l, "the card is paper").toBe(100);
|
|
@@ -137,10 +251,17 @@ describe("canonical light spine", () => {
|
|
|
137
251
|
canvas.l,
|
|
138
252
|
"a pure-white canvas gives a white card nothing to lift off",
|
|
139
253
|
).toBeLessThan(97);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
254
|
+
// Mirrors the dark spine exactly: HUE is the brand constraint and is pinned;
|
|
255
|
+
// saturation is a taste dial and is not. How far the surfaces lean is a live
|
|
256
|
+
// product decision, so asserting a band here only guarantees this test fails
|
|
257
|
+
// the next time that decision is revisited.
|
|
258
|
+
expect(canvas.h, "canvas hue sits in the indigo band").toBeGreaterThanOrEqual(
|
|
259
|
+
215,
|
|
260
|
+
);
|
|
261
|
+
expect(canvas.h).toBeLessThanOrEqual(260);
|
|
262
|
+
expect(canvas.s, "a colourless canvas has left the brand behind").toBeGreaterThan(
|
|
263
|
+
5,
|
|
264
|
+
);
|
|
144
265
|
});
|
|
145
266
|
|
|
146
267
|
it("ALTERNATES paper and well — light elevation is not a darkening ramp", () => {
|