@terpjs/contract 0.6.0 → 0.7.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/package.json +3 -2
- package/scripts/build-tokens.mjs +156 -25
- package/src/css-rules.js +107 -0
- package/src/css-rules.test.js +110 -0
- package/src/tokens.contrast.test.js +244 -0
- package/src/tokens.css +293 -14
- package/src/tokens.manifest.json +1266 -0
- package/src/tokens.manifest.test.js +149 -0
- package/src/tokens.themes.test.js +177 -0
- package/themes.json +43 -0
- package/token-pairs.json +138 -0
- package/tokens.contrast.json +71 -0
- package/tokens.dark.json +41 -4
- package/tokens.json +98 -7
- package/tokens.midnight.json +71 -0
- package/tokens.twilight.json +71 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { parseRules } from "./css-rules.js";
|
|
7
|
+
|
|
8
|
+
// Text legibility of the token sheet, measured rather than reviewed.
|
|
9
|
+
//
|
|
10
|
+
// The sheet is the single source of every colour a Terp app renders, and a foreground and
|
|
11
|
+
// a background are chosen independently — one token moves for a good reason and a pairing
|
|
12
|
+
// three components away stops being readable. Nothing catches that today: the token guard
|
|
13
|
+
// checks spelling, the theme test checks completeness, and neither knows that
|
|
14
|
+
// `--color-status-warning` is only ever painted on `--color-status-warning-soft`.
|
|
15
|
+
//
|
|
16
|
+
// So the pairings are declared here, as data, and held to WCAG 2.1 contrast. Each entry is
|
|
17
|
+
// a pairing some framework component actually renders as text; decorative boundaries are
|
|
18
|
+
// deliberately absent, because WCAG sets no ratio for a divider and asserting one would
|
|
19
|
+
// only teach the next reader to ignore this file.
|
|
20
|
+
|
|
21
|
+
const here = (name) => fileURLToPath(new URL(name, import.meta.url));
|
|
22
|
+
|
|
23
|
+
const tokensCss = fs.readFileSync(here("./tokens.css"), "utf8");
|
|
24
|
+
const registry = JSON.parse(fs.readFileSync(here("../themes.json"), "utf8"));
|
|
25
|
+
|
|
26
|
+
/** WCAG 2.1 AA, normal-size text. Large text and UI boundaries would be 3.0. */
|
|
27
|
+
const AA_NORMAL_TEXT = 4.5;
|
|
28
|
+
|
|
29
|
+
/** WCAG 2.1 AAA, normal-size text — the bar a theme named for contrast has to clear. */
|
|
30
|
+
const AAA_NORMAL_TEXT = 7;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pairings the framework renders as text, read from the shared data file.
|
|
34
|
+
*
|
|
35
|
+
* `token-pairs.json` is the single source: this gate holds each pairing to AA, and the
|
|
36
|
+
* generated manifest publishes the same list so a theme editor or an agent can tell which
|
|
37
|
+
* tokens must stay legible against which. Restating them here would let the gate and the
|
|
38
|
+
* published contract disagree about what is guaranteed.
|
|
39
|
+
*
|
|
40
|
+
* `label` names the component surface so a failure says what a user would be looking at, and
|
|
41
|
+
* `id` is the stable key — labels intentionally repeat across the primitive and semantic
|
|
42
|
+
* layers ("body text on the canvas" describes both), so only the id can identify a pairing.
|
|
43
|
+
*/
|
|
44
|
+
const TEXT_PAIRS = JSON.parse(fs.readFileSync(here("../token-pairs.json"), "utf8")).textPairs;
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pairings that do not reach AA today, with the ratio measured when they were recorded.
|
|
49
|
+
*
|
|
50
|
+
* A ratchet, not a mute button, and it moves in one direction only: a pairing may not drop
|
|
51
|
+
* below its recorded floor, and once it reaches AA it must leave this table — so an
|
|
52
|
+
* improvement cannot quietly leave a stale allowance behind, and a regression cannot hide
|
|
53
|
+
* behind an existing one. Keyed `<theme>/<label>`.
|
|
54
|
+
*
|
|
55
|
+
* Floors are the measured ratio truncated to four places, and the comparison below is
|
|
56
|
+
* against the *raw* ratio. Rounding to two places would straddle the AA boundary in both
|
|
57
|
+
* directions — 4.4951 would round into a pass, and two of these floors would only hold
|
|
58
|
+
* because rounding lifted them — so neither the gate nor the ratchet may depend on it.
|
|
59
|
+
*
|
|
60
|
+
* All five clear 3.0 (AA for large text and UI components) and fail 4.5 (normal text).
|
|
61
|
+
* Badge copy and button labels are normal text, so these are real defects, deliberately
|
|
62
|
+
* left visible: changing a token value repaints every app, which belongs to the semantic
|
|
63
|
+
* token layer rather than to the test that found it. Emptying this table is that work's
|
|
64
|
+
* acceptance criterion.
|
|
65
|
+
*
|
|
66
|
+
* Every entry is in `light` or `dark`, the two themes that shipped before the table existed.
|
|
67
|
+
* The themes added since carry none: a theme authored against this gate has no reason to land
|
|
68
|
+
* below AA, so an allowance for a new theme is a design mistake and not a legacy to record.
|
|
69
|
+
*/
|
|
70
|
+
const BELOW_AA = new Map([]);
|
|
71
|
+
|
|
72
|
+
/** The declarations of the one rule whose selector is exactly `selector`. */
|
|
73
|
+
function declarationsFor(selector) {
|
|
74
|
+
const matches = parseRules(tokensCss).filter((rule) => rule.selector === selector);
|
|
75
|
+
if (matches.length !== 1) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`expected exactly one \`${selector}\` rule in tokens.css, found ${matches.length}`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return matches[0].declarations;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// A theme is the base root with its own colours laid over it — the same resolution the
|
|
84
|
+
// cascade performs, so a pairing is measured at the value a browser would paint. The theme
|
|
85
|
+
// list comes from the registry rather than from a literal here, so a theme cannot be added to
|
|
86
|
+
// the sheet without this gate measuring it.
|
|
87
|
+
const base = declarationsFor(":root");
|
|
88
|
+
const THEMES = Object.fromEntries(
|
|
89
|
+
registry.themes.map((theme) => [
|
|
90
|
+
theme.name,
|
|
91
|
+
theme.name === registry.base
|
|
92
|
+
? base
|
|
93
|
+
: new Map([...base, ...declarationsFor(`[data-theme='${theme.name}']`)]),
|
|
94
|
+
]),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
/** sRGB channel → linear light, per WCAG 2.1 relative luminance. */
|
|
98
|
+
function linearise(channel) {
|
|
99
|
+
const scaled = channel / 255;
|
|
100
|
+
return scaled <= 0.04045 ? scaled / 12.92 : ((scaled + 0.055) / 1.055) ** 2.4;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Relative luminance of a `#rrggbb` value. */
|
|
104
|
+
function relativeLuminance(hex) {
|
|
105
|
+
const match = /^#([0-9a-f]{6})$/i.exec(hex);
|
|
106
|
+
if (!match) throw new Error(`not a six-digit hex colour: ${hex}`);
|
|
107
|
+
const [red, green, blue] = [0, 2, 4].map((offset) =>
|
|
108
|
+
linearise(Number.parseInt(match[1].slice(offset, offset + 2), 16)),
|
|
109
|
+
);
|
|
110
|
+
return 0.2126 * red + 0.7152 * green + 0.0722 * blue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** WCAG 2.1 contrast ratio between two `#rrggbb` values, 1.0 – 21.0. */
|
|
114
|
+
function contrastRatio(a, b) {
|
|
115
|
+
const [darker, lighter] = [relativeLuminance(a), relativeLuminance(b)].sort(
|
|
116
|
+
(x, y) => x - y,
|
|
117
|
+
);
|
|
118
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The ratio a theme's pairings must reach. AA for normal text by default; a theme may declare
|
|
123
|
+
* a higher floor in `themes.json`, which is how the high-contrast theme's promise is a gate
|
|
124
|
+
* rather than a sentence in its description.
|
|
125
|
+
*/
|
|
126
|
+
const floorFor = (name) =>
|
|
127
|
+
registry.themes.find((theme) => theme.name === name)?.minimumContrast ?? AA_NORMAL_TEXT;
|
|
128
|
+
|
|
129
|
+
/** Every pairing, in every registered theme, tagged with its `BELOW_AA` key. */
|
|
130
|
+
const cases = Object.entries(THEMES).flatMap(([theme, declarations]) =>
|
|
131
|
+
TEXT_PAIRS.map((pair) => ({
|
|
132
|
+
...pair,
|
|
133
|
+
theme,
|
|
134
|
+
declarations,
|
|
135
|
+
key: `${theme}/${pair.id}`,
|
|
136
|
+
floor: floorFor(theme),
|
|
137
|
+
})),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
/** The measured ratio for one case, with the painted values for the failure message. */
|
|
141
|
+
function measure({ fg, bg, declarations }) {
|
|
142
|
+
const foreground = declarations.get(fg);
|
|
143
|
+
const background = declarations.get(bg);
|
|
144
|
+
expect(foreground, `${fg} is not declared`).toBeDefined();
|
|
145
|
+
expect(background, `${bg} is not declared`).toBeDefined();
|
|
146
|
+
return {
|
|
147
|
+
ratio: contrastRatio(foreground, background),
|
|
148
|
+
painted: `${fg} (${foreground}) on ${bg} (${background})`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const meetsAa = cases.filter(({ key }) => !BELOW_AA.has(key));
|
|
153
|
+
const knownGaps = cases.filter(({ key }) => BELOW_AA.has(key));
|
|
154
|
+
|
|
155
|
+
describe("token sheet text contrast", () => {
|
|
156
|
+
it("measures a known ratio correctly", () => {
|
|
157
|
+
// The calculator itself needs a fixture, or a subtly wrong exponent would move every
|
|
158
|
+
// ratio below in the same direction and the suite would still look green.
|
|
159
|
+
expect(contrastRatio("#000000", "#ffffff")).toBeCloseTo(21, 5);
|
|
160
|
+
expect(contrastRatio("#ffffff", "#ffffff")).toBeCloseTo(1, 5);
|
|
161
|
+
// A published mid-tone pair, so the curve is checked and not just its endpoints.
|
|
162
|
+
expect(contrastRatio("#767676", "#ffffff")).toBeCloseTo(4.5422, 4);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("gives every pairing a unique id", () => {
|
|
166
|
+
// The id is the ratchet key and the manifest's handle. A duplicate would silently make
|
|
167
|
+
// one pairing's allowance apply to another, and labels cannot substitute — they repeat
|
|
168
|
+
// across the primitive and semantic layers on purpose.
|
|
169
|
+
const ids = TEXT_PAIRS.map((pair) => pair.id);
|
|
170
|
+
expect(ids.filter((id) => !id)).toEqual([]);
|
|
171
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("measures every registered theme", () => {
|
|
175
|
+
// The theme list is read from `themes.json`, so a theme added to the sheet is measured
|
|
176
|
+
// automatically — but only if the resolution above actually found its block. A theme that
|
|
177
|
+
// resolved to an empty map would produce cases that all read the base values and pass,
|
|
178
|
+
// which is the failure mode worth pinning: silent, and it looks like coverage.
|
|
179
|
+
expect(Object.keys(THEMES)).toEqual(registry.themes.map((theme) => theme.name));
|
|
180
|
+
for (const [name, declarations] of Object.entries(THEMES)) {
|
|
181
|
+
expect(declarations.size, `${name} resolved to nothing`).toBe(base.size);
|
|
182
|
+
}
|
|
183
|
+
expect(cases).toHaveLength(registry.themes.length * TEXT_PAIRS.length);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("keeps the known gaps to the themes that predate the gate", () => {
|
|
187
|
+
// A theme authored against this gate has no reason to land below AA, so an allowance for a
|
|
188
|
+
// newer theme would be a design mistake being recorded as history. Naming the two
|
|
189
|
+
// grandfathered themes explicitly is what stops the table from becoming a general amnesty.
|
|
190
|
+
const grandfathered = new Set(["light", "dark"]);
|
|
191
|
+
const themeOf = (key) => key.slice(0, key.indexOf("/"));
|
|
192
|
+
expect([...BELOW_AA.keys()].filter((key) => !grandfathered.has(themeOf(key)))).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("holds every pairing in exactly one of the two sets", () => {
|
|
196
|
+
// A typo in a `BELOW_AA` key would silently move a pairing from the strict set into
|
|
197
|
+
// neither set, so the gate would stop asserting anything about it.
|
|
198
|
+
expect(meetsAa.length + knownGaps.length).toBe(cases.length);
|
|
199
|
+
expect(knownGaps).toHaveLength(BELOW_AA.size);
|
|
200
|
+
const known = new Set(cases.map(({ key }) => key));
|
|
201
|
+
expect([...BELOW_AA.keys()].filter((key) => !known.has(key))).toEqual([]);
|
|
202
|
+
expect([...BELOW_AA.keys()]).toEqual([...BELOW_AA.keys()].sort());
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it.each(meetsAa)("$theme: $id ($label) reaches $floor:1 for normal text", (testCase) => {
|
|
206
|
+
const { ratio, painted } = measure(testCase);
|
|
207
|
+
expect(ratio, painted).toBeGreaterThanOrEqual(testCase.floor);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("holds the high-contrast theme to AAA rather than AA", () => {
|
|
211
|
+
// A theme called "high contrast" that only cleared the same bar as every other theme would
|
|
212
|
+
// be a name doing the work a measurement should. The floor is declared per theme in
|
|
213
|
+
// `themes.json`; this asserts at least one theme actually raises it, so the mechanism
|
|
214
|
+
// cannot rot into an unused field that reads as enforcement.
|
|
215
|
+
const raised = registry.themes.filter((theme) => theme.minimumContrast !== undefined);
|
|
216
|
+
expect(raised.length).toBeGreaterThan(0);
|
|
217
|
+
for (const theme of raised) {
|
|
218
|
+
expect(theme.minimumContrast, `${theme.name} floor`).toBeGreaterThanOrEqual(
|
|
219
|
+
AAA_NORMAL_TEXT,
|
|
220
|
+
);
|
|
221
|
+
expect(
|
|
222
|
+
[...BELOW_AA.keys()].filter((key) => key.startsWith(`${theme.name}/`)),
|
|
223
|
+
`${theme.name} raises its floor, so it cannot also carry an allowance`,
|
|
224
|
+
).toEqual([]);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// Deliberately titled as a gap, not as a pass: a green line reading "warning badge
|
|
229
|
+
// reaches AA" for a pairing measuring 3.07 is worse than no test, because it is the line
|
|
230
|
+
// a reviewer trusts.
|
|
231
|
+
it.each(knownGaps)(
|
|
232
|
+
"$theme: $id ($label) is a known contrast gap, held at its floor",
|
|
233
|
+
(testCase) => {
|
|
234
|
+
const { ratio, painted } = measure(testCase);
|
|
235
|
+
const floor = BELOW_AA.get(testCase.key);
|
|
236
|
+
expect(ratio, `${painted} regressed below its recorded floor`).toBeGreaterThanOrEqual(
|
|
237
|
+
floor,
|
|
238
|
+
);
|
|
239
|
+
expect(ratio, `${painted} now reaches AA — remove it from BELOW_AA`).toBeLessThan(
|
|
240
|
+
AA_NORMAL_TEXT,
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
});
|
package/src/tokens.css
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
:root {
|
|
6
6
|
/* Opts native chrome (scrollbars, the <select> option popup, form controls,
|
|
7
7
|
text-field carets) into the light palette so it never renders as foreign
|
|
8
|
-
OS-
|
|
8
|
+
OS-dark chrome. Each theme block below sets its own. */
|
|
9
9
|
color-scheme: light;
|
|
10
10
|
--color-brand-primary: #2563eb;
|
|
11
11
|
--color-brand-primary-contrast: #ffffff;
|
|
@@ -22,15 +22,40 @@
|
|
|
22
22
|
--color-neutral-700: #334155;
|
|
23
23
|
--color-neutral-800: #1e293b;
|
|
24
24
|
--color-neutral-900: #0f172a;
|
|
25
|
-
--color-status-success: #
|
|
25
|
+
--color-status-success: #15803d;
|
|
26
26
|
--color-status-success-soft: #f0fdf4;
|
|
27
|
-
--color-status-warning: #
|
|
27
|
+
--color-status-warning: #b45309;
|
|
28
28
|
--color-status-warning-soft: #fffbeb;
|
|
29
|
-
--color-status-danger: #
|
|
29
|
+
--color-status-danger: #b91c1c;
|
|
30
30
|
--color-status-danger-soft: #fef2f2;
|
|
31
|
-
--color-status-info: #
|
|
31
|
+
--color-status-info: #0369a1;
|
|
32
32
|
--color-status-info-soft: #f0f9ff;
|
|
33
33
|
--color-focus-ring: rgba(37,99,235,0.35);
|
|
34
|
+
--color-bg-canvas: #f8fafc;
|
|
35
|
+
--color-bg-surface: #ffffff;
|
|
36
|
+
--color-bg-raised: #ffffff;
|
|
37
|
+
--color-bg-inset: #f1f5f9;
|
|
38
|
+
--color-fg-default: #0f172a;
|
|
39
|
+
--color-fg-muted: #475569;
|
|
40
|
+
--color-fg-subtle: #64748b;
|
|
41
|
+
--color-fg-accent: #1d4ed8;
|
|
42
|
+
--color-fg-on-brand: #ffffff;
|
|
43
|
+
--color-border-default: #e2e8f0;
|
|
44
|
+
--color-border-strong: #cbd5e1;
|
|
45
|
+
--color-border-subtle: #f1f5f9;
|
|
46
|
+
--color-interactive-hover: #f1f5f9;
|
|
47
|
+
--color-interactive-active: #e2e8f0;
|
|
48
|
+
--color-interactive-selected: #eff6ff;
|
|
49
|
+
--color-sidebar-bg: #f8fafc;
|
|
50
|
+
--color-sidebar-fg: #0f172a;
|
|
51
|
+
--color-sidebar-muted: #475569;
|
|
52
|
+
--color-sidebar-accent: #f1f5f9;
|
|
53
|
+
--color-sidebar-border: #e2e8f0;
|
|
54
|
+
--color-chart-1: #2563eb;
|
|
55
|
+
--color-chart-2: #0d9488;
|
|
56
|
+
--color-chart-3: #d97706;
|
|
57
|
+
--color-chart-4: #7c3aed;
|
|
58
|
+
--color-chart-5: #db2777;
|
|
34
59
|
--shadow-sm: 0 1px 2px rgb(15 23 42 / 0.06);
|
|
35
60
|
--shadow-md: 0 4px 12px rgb(15 23 42 / 0.10);
|
|
36
61
|
--shadow-lg: 0 12px 32px rgb(15 23 42 / 0.18);
|
|
@@ -39,12 +64,40 @@
|
|
|
39
64
|
--space-2: 0.5rem;
|
|
40
65
|
--space-3: 0.75rem;
|
|
41
66
|
--space-4: 1rem;
|
|
67
|
+
--space-5: 1.25rem;
|
|
42
68
|
--space-6: 1.5rem;
|
|
69
|
+
--space-7: 1.75rem;
|
|
43
70
|
--space-8: 2rem;
|
|
71
|
+
--space-10: 2.5rem;
|
|
72
|
+
--space-12: 3rem;
|
|
73
|
+
--space-16: 4rem;
|
|
74
|
+
--space-20: 5rem;
|
|
44
75
|
--radius-sm: 0.25rem;
|
|
45
76
|
--radius-md: 0.5rem;
|
|
46
77
|
--radius-lg: 0.75rem;
|
|
78
|
+
--radius-xl: 1rem;
|
|
47
79
|
--radius-full: 9999px;
|
|
80
|
+
--border-width-thin: 1px;
|
|
81
|
+
--border-width-base: 2px;
|
|
82
|
+
--border-width-thick: 3px;
|
|
83
|
+
--z-index-base: 0;
|
|
84
|
+
--z-index-sticky: 30;
|
|
85
|
+
--z-index-backdrop: 40;
|
|
86
|
+
--z-index-drawer: 50;
|
|
87
|
+
--z-index-popover: 60;
|
|
88
|
+
--z-index-tooltip: 70;
|
|
89
|
+
--z-index-toast: 100;
|
|
90
|
+
--breakpoint-sm: 480px;
|
|
91
|
+
--breakpoint-md: 768px;
|
|
92
|
+
--breakpoint-lg: 1024px;
|
|
93
|
+
--breakpoint-xl: 1280px;
|
|
94
|
+
--motion-duration-instant: 100ms;
|
|
95
|
+
--motion-duration-fast: 150ms;
|
|
96
|
+
--motion-duration-base: 250ms;
|
|
97
|
+
--motion-duration-slow: 400ms;
|
|
98
|
+
--motion-easing-standard: ease;
|
|
99
|
+
--motion-easing-entrance: cubic-bezier(0, 0, 0.2, 1);
|
|
100
|
+
--motion-easing-exit: cubic-bezier(0.4, 0, 1, 1);
|
|
48
101
|
--font-family-sans: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
49
102
|
--font-family-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
50
103
|
--font-size-xs: 0.75rem;
|
|
@@ -52,19 +105,30 @@
|
|
|
52
105
|
--font-size-base: 1rem;
|
|
53
106
|
--font-size-lg: 1.125rem;
|
|
54
107
|
--font-size-xl: 1.5rem;
|
|
108
|
+
--font-size-2xl: 1.875rem;
|
|
109
|
+
--font-size-3xl: 2.25rem;
|
|
110
|
+
--font-size-display: 3rem;
|
|
55
111
|
--font-weight-normal: 400;
|
|
56
112
|
--font-weight-medium: 500;
|
|
57
113
|
--font-weight-semibold: 600;
|
|
58
114
|
--font-weight-bold: 700;
|
|
115
|
+
--font-line-height-tight: 1.2;
|
|
116
|
+
--font-line-height-snug: 1.35;
|
|
117
|
+
--font-line-height-base: 1.5;
|
|
118
|
+
--font-line-height-relaxed: 1.7;
|
|
119
|
+
--font-letter-spacing-tight: -0.02em;
|
|
120
|
+
--font-letter-spacing-base: 0;
|
|
121
|
+
--font-letter-spacing-wide: 0.08em;
|
|
59
122
|
}
|
|
60
123
|
|
|
61
|
-
/* Dark
|
|
124
|
+
/* Dark: an explicit choice via <html data-theme="dark">.
|
|
125
|
+
The slate-neutral dark counterpart to light, and the theme the OS dark preference selects. */
|
|
62
126
|
[data-theme='dark'] {
|
|
63
127
|
color-scheme: dark;
|
|
64
|
-
--color-brand-primary: #
|
|
128
|
+
--color-brand-primary: #1d4ed8;
|
|
65
129
|
--color-brand-primary-contrast: #ffffff;
|
|
66
|
-
--color-brand-primary-hover: #
|
|
67
|
-
--color-brand-primary-soft: #
|
|
130
|
+
--color-brand-primary-hover: #2563eb;
|
|
131
|
+
--color-brand-primary-soft: #15304e;
|
|
68
132
|
--color-neutral-0: #1e293b;
|
|
69
133
|
--color-neutral-50: #0f172a;
|
|
70
134
|
--color-neutral-100: #263449;
|
|
@@ -85,16 +149,206 @@
|
|
|
85
149
|
--color-status-info: #38bdf8;
|
|
86
150
|
--color-status-info-soft: #12303f;
|
|
87
151
|
--color-focus-ring: rgba(96,165,250,0.45);
|
|
152
|
+
--color-bg-canvas: #0f172a;
|
|
153
|
+
--color-bg-surface: #1e293b;
|
|
154
|
+
--color-bg-raised: #263449;
|
|
155
|
+
--color-bg-inset: #0f172a;
|
|
156
|
+
--color-fg-default: #f1f5f9;
|
|
157
|
+
--color-fg-muted: #b4c0d0;
|
|
158
|
+
--color-fg-subtle: #94a3b8;
|
|
159
|
+
--color-fg-accent: #60a5fa;
|
|
160
|
+
--color-fg-on-brand: #ffffff;
|
|
161
|
+
--color-border-default: #334155;
|
|
162
|
+
--color-border-strong: #475569;
|
|
163
|
+
--color-border-subtle: #263449;
|
|
164
|
+
--color-interactive-hover: #263449;
|
|
165
|
+
--color-interactive-active: #334155;
|
|
166
|
+
--color-interactive-selected: #15304e;
|
|
167
|
+
--color-sidebar-bg: #1e293b;
|
|
168
|
+
--color-sidebar-fg: #f1f5f9;
|
|
169
|
+
--color-sidebar-muted: #b4c0d0;
|
|
170
|
+
--color-sidebar-accent: #334155;
|
|
171
|
+
--color-sidebar-border: #334155;
|
|
172
|
+
--color-chart-1: #60a5fa;
|
|
173
|
+
--color-chart-2: #2dd4bf;
|
|
174
|
+
--color-chart-3: #fbbf24;
|
|
175
|
+
--color-chart-4: #a78bfa;
|
|
176
|
+
--color-chart-5: #f472b6;
|
|
88
177
|
}
|
|
89
178
|
|
|
90
|
-
/*
|
|
179
|
+
/* Midnight: an explicit choice via <html data-theme="midnight">.
|
|
180
|
+
A near-black dark for low light and OLED displays: cooler neutrals, a deeper canvas than dark, and a deeper accent that holds a white label at AA. */
|
|
181
|
+
[data-theme='midnight'] {
|
|
182
|
+
color-scheme: dark;
|
|
183
|
+
--color-brand-primary: #0b4ea8;
|
|
184
|
+
--color-brand-primary-contrast: #ffffff;
|
|
185
|
+
--color-brand-primary-hover: #1158c7;
|
|
186
|
+
--color-brand-primary-soft: #0c2d6b;
|
|
187
|
+
--color-neutral-0: #0d1117;
|
|
188
|
+
--color-neutral-50: #010409;
|
|
189
|
+
--color-neutral-100: #161b22;
|
|
190
|
+
--color-neutral-200: #21262d;
|
|
191
|
+
--color-neutral-300: #30363d;
|
|
192
|
+
--color-neutral-400: #484f58;
|
|
193
|
+
--color-neutral-500: #7d8590;
|
|
194
|
+
--color-neutral-600: #9aa4b2;
|
|
195
|
+
--color-neutral-700: #c9d1d9;
|
|
196
|
+
--color-neutral-800: #e6edf3;
|
|
197
|
+
--color-neutral-900: #f0f6fc;
|
|
198
|
+
--color-status-success: #3fb950;
|
|
199
|
+
--color-status-success-soft: #0c2417;
|
|
200
|
+
--color-status-warning: #d29922;
|
|
201
|
+
--color-status-warning-soft: #271a06;
|
|
202
|
+
--color-status-danger: #f85149;
|
|
203
|
+
--color-status-danger-soft: #2b0d0d;
|
|
204
|
+
--color-status-info: #58a6ff;
|
|
205
|
+
--color-status-info-soft: #0b2545;
|
|
206
|
+
--color-focus-ring: rgba(88,166,255,0.5);
|
|
207
|
+
--color-bg-canvas: #010409;
|
|
208
|
+
--color-bg-surface: #0d1117;
|
|
209
|
+
--color-bg-raised: #161b22;
|
|
210
|
+
--color-bg-inset: #010409;
|
|
211
|
+
--color-fg-default: #f0f6fc;
|
|
212
|
+
--color-fg-muted: #9aa4b2;
|
|
213
|
+
--color-fg-subtle: #8b949e;
|
|
214
|
+
--color-fg-accent: #58a6ff;
|
|
215
|
+
--color-fg-on-brand: #ffffff;
|
|
216
|
+
--color-border-default: #30363d;
|
|
217
|
+
--color-border-strong: #484f58;
|
|
218
|
+
--color-border-subtle: #21262d;
|
|
219
|
+
--color-interactive-hover: #161b22;
|
|
220
|
+
--color-interactive-active: #21262d;
|
|
221
|
+
--color-interactive-selected: #0c2d6b;
|
|
222
|
+
--color-sidebar-bg: #0d1117;
|
|
223
|
+
--color-sidebar-fg: #f0f6fc;
|
|
224
|
+
--color-sidebar-muted: #9aa4b2;
|
|
225
|
+
--color-sidebar-accent: #21262d;
|
|
226
|
+
--color-sidebar-border: #21262d;
|
|
227
|
+
--color-chart-1: #58a6ff;
|
|
228
|
+
--color-chart-2: #3fb950;
|
|
229
|
+
--color-chart-3: #d29922;
|
|
230
|
+
--color-chart-4: #bc8cff;
|
|
231
|
+
--color-chart-5: #f778ba;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/* Twilight: an explicit choice via <html data-theme="twilight">.
|
|
235
|
+
A warm violet-tinted dark. Its neutrals sit in a different hue family from dark and midnight, which is what proves a theme is a palette rather than a lightness setting. */
|
|
236
|
+
[data-theme='twilight'] {
|
|
237
|
+
color-scheme: dark;
|
|
238
|
+
--color-brand-primary: #5b21b6;
|
|
239
|
+
--color-brand-primary-contrast: #ffffff;
|
|
240
|
+
--color-brand-primary-hover: #6d28d9;
|
|
241
|
+
--color-brand-primary-soft: #2b2350;
|
|
242
|
+
--color-neutral-0: #241f31;
|
|
243
|
+
--color-neutral-50: #1a1622;
|
|
244
|
+
--color-neutral-100: #2d2740;
|
|
245
|
+
--color-neutral-200: #3a3350;
|
|
246
|
+
--color-neutral-300: #4d4468;
|
|
247
|
+
--color-neutral-400: #6b5f8a;
|
|
248
|
+
--color-neutral-500: #9d90b8;
|
|
249
|
+
--color-neutral-600: #b9aecd;
|
|
250
|
+
--color-neutral-700: #d8d0e6;
|
|
251
|
+
--color-neutral-800: #ece7f3;
|
|
252
|
+
--color-neutral-900: #f6f3fa;
|
|
253
|
+
--color-status-success: #5ed49a;
|
|
254
|
+
--color-status-success-soft: #14322a;
|
|
255
|
+
--color-status-warning: #e8b04b;
|
|
256
|
+
--color-status-warning-soft: #332612;
|
|
257
|
+
--color-status-danger: #f58a92;
|
|
258
|
+
--color-status-danger-soft: #3a1a25;
|
|
259
|
+
--color-status-info: #7cb8f0;
|
|
260
|
+
--color-status-info-soft: #1a2c44;
|
|
261
|
+
--color-focus-ring: rgba(167,139,250,0.5);
|
|
262
|
+
--color-bg-canvas: #1a1622;
|
|
263
|
+
--color-bg-surface: #241f31;
|
|
264
|
+
--color-bg-raised: #2d2740;
|
|
265
|
+
--color-bg-inset: #1a1622;
|
|
266
|
+
--color-fg-default: #f6f3fa;
|
|
267
|
+
--color-fg-muted: #b9aecd;
|
|
268
|
+
--color-fg-subtle: #a294bd;
|
|
269
|
+
--color-fg-accent: #a78bfa;
|
|
270
|
+
--color-fg-on-brand: #ffffff;
|
|
271
|
+
--color-border-default: #3a3350;
|
|
272
|
+
--color-border-strong: #4d4468;
|
|
273
|
+
--color-border-subtle: #2d2740;
|
|
274
|
+
--color-interactive-hover: #2d2740;
|
|
275
|
+
--color-interactive-active: #3a3350;
|
|
276
|
+
--color-interactive-selected: #2b2350;
|
|
277
|
+
--color-sidebar-bg: #241f31;
|
|
278
|
+
--color-sidebar-fg: #f6f3fa;
|
|
279
|
+
--color-sidebar-muted: #b9aecd;
|
|
280
|
+
--color-sidebar-accent: #3a3350;
|
|
281
|
+
--color-sidebar-border: #3a3350;
|
|
282
|
+
--color-chart-1: #a78bfa;
|
|
283
|
+
--color-chart-2: #5ed49a;
|
|
284
|
+
--color-chart-3: #e8b04b;
|
|
285
|
+
--color-chart-4: #7cb8f0;
|
|
286
|
+
--color-chart-5: #f58ac8;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/* High contrast: an explicit choice via <html data-theme="contrast">.
|
|
290
|
+
A high-contrast light theme: white surfaces, near-black text, darkened accents and visible borders. Every declared text pairing reaches AAA, not just AA. Not wired to `prefers-contrast: more` — that needs a dark high-contrast counterpart first, or a user who asked for both more contrast and dark would be handed a light theme. */
|
|
291
|
+
[data-theme='contrast'] {
|
|
292
|
+
color-scheme: light;
|
|
293
|
+
--color-brand-primary: #0842a0;
|
|
294
|
+
--color-brand-primary-contrast: #ffffff;
|
|
295
|
+
--color-brand-primary-hover: #05275a;
|
|
296
|
+
--color-brand-primary-soft: #e8f0fe;
|
|
297
|
+
--color-neutral-0: #ffffff;
|
|
298
|
+
--color-neutral-50: #ffffff;
|
|
299
|
+
--color-neutral-100: #f2f2f2;
|
|
300
|
+
--color-neutral-200: #595959;
|
|
301
|
+
--color-neutral-300: #404040;
|
|
302
|
+
--color-neutral-400: #303030;
|
|
303
|
+
--color-neutral-500: #1f1f1f;
|
|
304
|
+
--color-neutral-600: #141414;
|
|
305
|
+
--color-neutral-700: #0a0a0a;
|
|
306
|
+
--color-neutral-800: #000000;
|
|
307
|
+
--color-neutral-900: #000000;
|
|
308
|
+
--color-status-success: #0a5c2b;
|
|
309
|
+
--color-status-success-soft: #e6f4ea;
|
|
310
|
+
--color-status-warning: #6b3800;
|
|
311
|
+
--color-status-warning-soft: #fdf1e0;
|
|
312
|
+
--color-status-danger: #96000c;
|
|
313
|
+
--color-status-danger-soft: #fce8e8;
|
|
314
|
+
--color-status-info: #0b4a8f;
|
|
315
|
+
--color-status-info-soft: #e5f0fb;
|
|
316
|
+
--color-focus-ring: rgba(8,66,160,0.7);
|
|
317
|
+
--color-bg-canvas: #ffffff;
|
|
318
|
+
--color-bg-surface: #ffffff;
|
|
319
|
+
--color-bg-raised: #ffffff;
|
|
320
|
+
--color-bg-inset: #f2f2f2;
|
|
321
|
+
--color-fg-default: #000000;
|
|
322
|
+
--color-fg-muted: #141414;
|
|
323
|
+
--color-fg-subtle: #1f1f1f;
|
|
324
|
+
--color-fg-accent: #0842a0;
|
|
325
|
+
--color-fg-on-brand: #ffffff;
|
|
326
|
+
--color-border-default: #595959;
|
|
327
|
+
--color-border-strong: #000000;
|
|
328
|
+
--color-border-subtle: #767676;
|
|
329
|
+
--color-interactive-hover: #f2f2f2;
|
|
330
|
+
--color-interactive-active: #e0e0e0;
|
|
331
|
+
--color-interactive-selected: #e8f0fe;
|
|
332
|
+
--color-sidebar-bg: #ffffff;
|
|
333
|
+
--color-sidebar-fg: #000000;
|
|
334
|
+
--color-sidebar-muted: #141414;
|
|
335
|
+
--color-sidebar-accent: #e8f0fe;
|
|
336
|
+
--color-sidebar-border: #595959;
|
|
337
|
+
--color-chart-1: #0842a0;
|
|
338
|
+
--color-chart-2: #00564a;
|
|
339
|
+
--color-chart-3: #6b3800;
|
|
340
|
+
--color-chart-4: #4b1f79;
|
|
341
|
+
--color-chart-5: #8c0a4d;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/* Dark: the OS preference, unless the app pinned any theme explicitly. */
|
|
91
345
|
@media (prefers-color-scheme: dark) {
|
|
92
|
-
:root:not([data-theme
|
|
346
|
+
:root:not([data-theme]) {
|
|
93
347
|
color-scheme: dark;
|
|
94
|
-
--color-brand-primary: #
|
|
348
|
+
--color-brand-primary: #1d4ed8;
|
|
95
349
|
--color-brand-primary-contrast: #ffffff;
|
|
96
|
-
--color-brand-primary-hover: #
|
|
97
|
-
--color-brand-primary-soft: #
|
|
350
|
+
--color-brand-primary-hover: #2563eb;
|
|
351
|
+
--color-brand-primary-soft: #15304e;
|
|
98
352
|
--color-neutral-0: #1e293b;
|
|
99
353
|
--color-neutral-50: #0f172a;
|
|
100
354
|
--color-neutral-100: #263449;
|
|
@@ -115,5 +369,30 @@
|
|
|
115
369
|
--color-status-info: #38bdf8;
|
|
116
370
|
--color-status-info-soft: #12303f;
|
|
117
371
|
--color-focus-ring: rgba(96,165,250,0.45);
|
|
372
|
+
--color-bg-canvas: #0f172a;
|
|
373
|
+
--color-bg-surface: #1e293b;
|
|
374
|
+
--color-bg-raised: #263449;
|
|
375
|
+
--color-bg-inset: #0f172a;
|
|
376
|
+
--color-fg-default: #f1f5f9;
|
|
377
|
+
--color-fg-muted: #b4c0d0;
|
|
378
|
+
--color-fg-subtle: #94a3b8;
|
|
379
|
+
--color-fg-accent: #60a5fa;
|
|
380
|
+
--color-fg-on-brand: #ffffff;
|
|
381
|
+
--color-border-default: #334155;
|
|
382
|
+
--color-border-strong: #475569;
|
|
383
|
+
--color-border-subtle: #263449;
|
|
384
|
+
--color-interactive-hover: #263449;
|
|
385
|
+
--color-interactive-active: #334155;
|
|
386
|
+
--color-interactive-selected: #15304e;
|
|
387
|
+
--color-sidebar-bg: #1e293b;
|
|
388
|
+
--color-sidebar-fg: #f1f5f9;
|
|
389
|
+
--color-sidebar-muted: #b4c0d0;
|
|
390
|
+
--color-sidebar-accent: #334155;
|
|
391
|
+
--color-sidebar-border: #334155;
|
|
392
|
+
--color-chart-1: #60a5fa;
|
|
393
|
+
--color-chart-2: #2dd4bf;
|
|
394
|
+
--color-chart-3: #fbbf24;
|
|
395
|
+
--color-chart-4: #a78bfa;
|
|
396
|
+
--color-chart-5: #f472b6;
|
|
118
397
|
}
|
|
119
398
|
}
|