@terpjs/contract 0.6.1 → 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,149 @@
|
|
|
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
|
+
// The published token manifest: the same tokens as machine-readable data.
|
|
9
|
+
//
|
|
10
|
+
// It exists because three consumers could not get the list any other way. A theme editor had
|
|
11
|
+
// to hard-code its own copy — `tokens.json` is Style-Dictionary-shaped and was never exported
|
|
12
|
+
// from the package, only the compiled CSS was. An agent editing a theme had to infer names
|
|
13
|
+
// from whatever it found in `node_modules`, with no way to tell which tokens are safe to theme
|
|
14
|
+
// or which must stay legible against which. A human had no list at all.
|
|
15
|
+
//
|
|
16
|
+
// A manifest that disagrees with the stylesheet beside it is worse than none, because it is
|
|
17
|
+
// the copy a tool trusts. So it is generated in the same run from the same sources, and these
|
|
18
|
+
// tests hold the two together.
|
|
19
|
+
|
|
20
|
+
const here = (name) => fileURLToPath(new URL(name, import.meta.url));
|
|
21
|
+
|
|
22
|
+
const manifest = JSON.parse(fs.readFileSync(here("./tokens.manifest.json"), "utf8"));
|
|
23
|
+
const tokensCss = fs.readFileSync(here("./tokens.css"), "utf8");
|
|
24
|
+
const pairsSource = JSON.parse(fs.readFileSync(here("../token-pairs.json"), "utf8"));
|
|
25
|
+
const registry = JSON.parse(fs.readFileSync(here("../themes.json"), "utf8"));
|
|
26
|
+
|
|
27
|
+
const rules = parseRules(tokensCss);
|
|
28
|
+
const declarationsFor = (selector) =>
|
|
29
|
+
rules.find((rule) => rule.selector === selector).declarations;
|
|
30
|
+
const base = declarationsFor(":root");
|
|
31
|
+
|
|
32
|
+
/** Each theme's own block: the base on `:root`, every other on its attribute selector. */
|
|
33
|
+
const blocks = new Map(
|
|
34
|
+
registry.themes.map((theme) => [
|
|
35
|
+
theme.name,
|
|
36
|
+
theme.name === registry.base ? base : declarationsFor(`[data-theme='${theme.name}']`),
|
|
37
|
+
]),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const tokenByName = new Map(manifest.tokens.map((token) => [token.name, token]));
|
|
41
|
+
|
|
42
|
+
describe("token manifest", () => {
|
|
43
|
+
it("names exactly the tokens the base root declares", () => {
|
|
44
|
+
// Either direction is a real failure: a token missing from the manifest is invisible to
|
|
45
|
+
// every tool that reads it, and a token in the manifest that the sheet does not declare
|
|
46
|
+
// is a control that would silently do nothing.
|
|
47
|
+
const manifestNames = manifest.tokens.map((token) => token.name).sort();
|
|
48
|
+
expect(manifestNames).toEqual([...base.keys()].sort());
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("publishes the theme list the sheet was generated from", () => {
|
|
52
|
+
// This is the list a consumer builds a theme picker from, and the set of keys a `values`
|
|
53
|
+
// map is allowed to use. A manifest naming a theme the sheet has no block for would hand
|
|
54
|
+
// a tool a theme it cannot apply.
|
|
55
|
+
expect(manifest.base).toBe(registry.base);
|
|
56
|
+
expect(manifest.systemDark).toBe(registry.systemDark);
|
|
57
|
+
expect(manifest.themes).toEqual(
|
|
58
|
+
registry.themes.map(({ name, label, appearance, description }) => ({
|
|
59
|
+
name,
|
|
60
|
+
label,
|
|
61
|
+
appearance,
|
|
62
|
+
description,
|
|
63
|
+
})),
|
|
64
|
+
);
|
|
65
|
+
for (const theme of manifest.themes) {
|
|
66
|
+
expect(blocks.has(theme.name), `${theme.name} has no block in the sheet`).toBe(true);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("records the value each token resolves to, in every theme", () => {
|
|
71
|
+
// `values` carries only the themes that declare the token; a theme absent from it inherits
|
|
72
|
+
// the base value. That is the cascade stated as data, so both halves are checked: a
|
|
73
|
+
// recorded value must match the theme's own block, and an omission must mean the block
|
|
74
|
+
// really does not declare it.
|
|
75
|
+
for (const token of manifest.tokens) {
|
|
76
|
+
for (const [name, block] of blocks) {
|
|
77
|
+
const recorded = token.values[name];
|
|
78
|
+
if (recorded === undefined) {
|
|
79
|
+
expect(
|
|
80
|
+
block.has(token.name),
|
|
81
|
+
`${token.name} is omitted for ${name} but ${name} declares it`,
|
|
82
|
+
).toBe(false);
|
|
83
|
+
} else {
|
|
84
|
+
expect(recorded, `${token.name} in ${name}`).toBe(block.get(token.name));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// The base value is never omitted: it is what every other theme falls back to.
|
|
88
|
+
expect(token.values[registry.base], `${token.name} base value`).toBe(
|
|
89
|
+
base.get(token.name),
|
|
90
|
+
);
|
|
91
|
+
expect(Object.keys(token.values)[0], `${token.name} value order`).toBe(registry.base);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("names no theme in a values map that the theme list omits", () => {
|
|
96
|
+
const known = new Set(manifest.themes.map((theme) => theme.name));
|
|
97
|
+
for (const token of manifest.tokens) {
|
|
98
|
+
expect(
|
|
99
|
+
Object.keys(token.values).filter((name) => !known.has(name)),
|
|
100
|
+
token.name,
|
|
101
|
+
).toEqual([]);
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("marks a token themeable exactly when some non-base theme overrides it", () => {
|
|
106
|
+
// This is the flag an editor uses to decide whether to offer a per-theme control, so
|
|
107
|
+
// getting it wrong means either a missing control or one that has no effect.
|
|
108
|
+
const overlays = registry.themes.filter((theme) => theme.name !== registry.base);
|
|
109
|
+
for (const token of manifest.tokens) {
|
|
110
|
+
const overridden = overlays.some((theme) => blocks.get(theme.name).has(token.name));
|
|
111
|
+
expect(token.themeable, `${token.name} themeable`).toBe(overridden);
|
|
112
|
+
// Geometry is declared once and inherited, so a non-themeable token carries exactly one
|
|
113
|
+
// value. A themeable one carries every theme's, because each theme is a full colour set.
|
|
114
|
+
expect(Object.keys(token.values), `${token.name} values`).toHaveLength(
|
|
115
|
+
overridden ? registry.themes.length : 1,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("gives every token a category from its source family", () => {
|
|
121
|
+
// `zIndex.base` flattens to `--z-index-base`; splitting the CSS name would call its
|
|
122
|
+
// family `z`, which is why the category comes from the source tree instead.
|
|
123
|
+
const categories = new Set(manifest.tokens.map((token) => token.category));
|
|
124
|
+
expect(categories.has("z")).toBe(false);
|
|
125
|
+
expect(categories.has("zIndex")).toBe(true);
|
|
126
|
+
for (const token of manifest.tokens) {
|
|
127
|
+
expect(token.category, token.name).toBeTruthy();
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("publishes the pairings the contrast gate enforces, unchanged", () => {
|
|
132
|
+
// The manifest is a claim about what is guaranteed; the gate is what guarantees it. If
|
|
133
|
+
// the two lists could differ, the published claim would be unverified.
|
|
134
|
+
expect(manifest.textPairs).toEqual(pairsSource.textPairs);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("references only tokens that exist, in both directions of every pairing", () => {
|
|
138
|
+
for (const pair of manifest.textPairs) {
|
|
139
|
+
expect(tokenByName.has(pair.fg), `${pair.id} fg ${pair.fg}`).toBe(true);
|
|
140
|
+
expect(tokenByName.has(pair.bg), `${pair.id} bg ${pair.bg}`).toBe(true);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("says it is generated", () => {
|
|
145
|
+
// The file is committed, so the next person to open it needs to know editing it is futile.
|
|
146
|
+
expect(manifest.$comment).toContain("Generated");
|
|
147
|
+
expect(manifest.$comment).toContain("build-tokens.mjs");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
// The token sheet's theme structure. `tokens.guard.test.ts` in react-core proves every
|
|
9
|
+
// `var(--x)` names a property the sheet declares *somewhere*; that is a spelling check and
|
|
10
|
+
// it is blind to which block declares what. This file proves the blocks agree.
|
|
11
|
+
//
|
|
12
|
+
// Five shapes have to hold, and each one fails silently in the browser rather than
|
|
13
|
+
// loudly at build time — which is why they are gates and not review items:
|
|
14
|
+
//
|
|
15
|
+
// 1. A colour a theme forgets falls through to the base value, so one element renders
|
|
16
|
+
// light-on-light under a dark theme. Every theme is a full set of colours, and the
|
|
17
|
+
// sheet has one block per theme plus the `prefers-color-scheme` copy of the OS dark
|
|
18
|
+
// theme, so a new colour has as many places to land as there are themes.
|
|
19
|
+
// 2. The OS-preference block is duplicated from the `systemDark` theme by the generator.
|
|
20
|
+
// Drift between them means the OS preference and the explicit toggle disagree — the same
|
|
21
|
+
// app renders two different darks depending on how the user got there.
|
|
22
|
+
// 3. Geometry (space, radius, font, shadow) is deliberately declared once, in `:root`,
|
|
23
|
+
// and inherited by every theme. That is the correct cascade and it must stay
|
|
24
|
+
// that way: a `--space-4` that appears in one theme only is a theme that
|
|
25
|
+
// silently re-spaces itself.
|
|
26
|
+
// 4. Every theme declares `color-scheme` matching its declared appearance, or native chrome
|
|
27
|
+
// the framework cannot restyle — the `<select>` popup, a native scrollbar, a caret —
|
|
28
|
+
// renders from the wrong palette.
|
|
29
|
+
// 5. The OS-preference block must not match a root that pinned a theme. This is the one
|
|
30
|
+
// that was a live defect: the selector was `:root:not([data-theme='light'])`, which was
|
|
31
|
+
// equivalent while light and dark were the only themes, and which outranks
|
|
32
|
+
// `[data-theme='contrast']` on specificity — so pinning any theme other than light got
|
|
33
|
+
// the dark colours laid over it whenever the OS preferred dark.
|
|
34
|
+
//
|
|
35
|
+
// Regenerate the sheet with `npm run -w @terpjs/contract tokens` after editing `themes.json`
|
|
36
|
+
// or any theme source; CI diffs the result, so these tests and the committed artifact move
|
|
37
|
+
// together.
|
|
38
|
+
|
|
39
|
+
const here = (name) => fileURLToPath(new URL(name, import.meta.url));
|
|
40
|
+
|
|
41
|
+
const tokensCss = fs.readFileSync(here("./tokens.css"), "utf8");
|
|
42
|
+
const registry = JSON.parse(fs.readFileSync(here("../themes.json"), "utf8"));
|
|
43
|
+
|
|
44
|
+
const BASE = registry.themes.find((theme) => theme.name === registry.base);
|
|
45
|
+
const OVERLAYS = registry.themes.filter((theme) => theme.name !== registry.base);
|
|
46
|
+
|
|
47
|
+
/** The base theme lives on `:root`; every other theme on its own attribute selector. */
|
|
48
|
+
const BASE_SELECTOR = ":root";
|
|
49
|
+
const selectorFor = (theme) => `[data-theme='${theme.name}']`;
|
|
50
|
+
/** The `@media (prefers-color-scheme: dark)` copy of the `systemDark` theme. */
|
|
51
|
+
const SYSTEM_DARK_SELECTOR = ":root:not([data-theme])";
|
|
52
|
+
|
|
53
|
+
const rules = parseRules(tokensCss);
|
|
54
|
+
|
|
55
|
+
/** The one rule whose selector is exactly `selector`. */
|
|
56
|
+
function ruleFor(selector) {
|
|
57
|
+
const matches = rules.filter((rule) => rule.selector === selector);
|
|
58
|
+
if (matches.length !== 1) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`expected exactly one \`${selector}\` rule in tokens.css, found ${matches.length}`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return matches[0];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const declarationsFor = (selector) => ruleFor(selector).declarations;
|
|
67
|
+
|
|
68
|
+
const base = declarationsFor(BASE_SELECTOR);
|
|
69
|
+
const isColour = (token) => token.startsWith("--color-");
|
|
70
|
+
|
|
71
|
+
/** Every theme block that is a set of colour overrides — i.e. all but the base. */
|
|
72
|
+
const overlayCases = OVERLAYS.map((theme) => ({
|
|
73
|
+
...theme,
|
|
74
|
+
selector: selectorFor(theme),
|
|
75
|
+
}));
|
|
76
|
+
|
|
77
|
+
describe("token sheet themes", () => {
|
|
78
|
+
it("parses the sheet it is asserting about", () => {
|
|
79
|
+
// A parser that silently found nothing would make every test below vacuously true, and a
|
|
80
|
+
// theme in the registry that the generator never emitted would make its own cases vanish
|
|
81
|
+
// rather than fail — so the selector list is asserted whole, in order.
|
|
82
|
+
expect(rules.map((rule) => rule.selector)).toEqual([
|
|
83
|
+
BASE_SELECTOR,
|
|
84
|
+
...OVERLAYS.map(selectorFor),
|
|
85
|
+
SYSTEM_DARK_SELECTOR,
|
|
86
|
+
]);
|
|
87
|
+
expect(base.size).toBeGreaterThan(0);
|
|
88
|
+
expect([...base.keys()].filter(isColour).length).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("ships more than a light and a dark theme", () => {
|
|
92
|
+
// The point of the semantic token layer is that a third theme is expressible without
|
|
93
|
+
// re-deriving every mapping by hand. One that ships only light and dark has not been
|
|
94
|
+
// proven, so this holds the floor the layer was built to clear.
|
|
95
|
+
expect(registry.themes.length).toBeGreaterThanOrEqual(3);
|
|
96
|
+
const appearances = new Set(registry.themes.map((theme) => theme.appearance));
|
|
97
|
+
// Both polarities represented: a set of dark variants would not exercise the layer any
|
|
98
|
+
// harder than dark alone did.
|
|
99
|
+
expect([...appearances].sort()).toEqual(["dark", "light"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("registers every theme source that exists on disk", () => {
|
|
103
|
+
// A `tokens.<name>.json` nobody registered compiles to nothing and is invisible: no
|
|
104
|
+
// block, no gate, no manifest entry. Registration is explicit on purpose, so the failure
|
|
105
|
+
// mode is a file that silently does nothing rather than a stray file becoming a theme.
|
|
106
|
+
const registered = new Set(registry.themes.map((theme) => theme.source));
|
|
107
|
+
const onDisk = fs
|
|
108
|
+
.readdirSync(here(".."))
|
|
109
|
+
.filter((name) => /^tokens(\.[a-z0-9-]+)?\.json$/.test(name));
|
|
110
|
+
expect(onDisk.filter((name) => !registered.has(name))).toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it.each(overlayCases)("declares every base colour in $selector", ({ selector }) => {
|
|
114
|
+
// A colour the theme omits inherits the base value: one light-on-light element.
|
|
115
|
+
const theme = declarationsFor(selector);
|
|
116
|
+
const missing = [...base.keys()].filter(
|
|
117
|
+
(token) => isColour(token) && !theme.has(token),
|
|
118
|
+
);
|
|
119
|
+
expect(missing).toEqual([]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it.each(overlayCases)("declares no token the base omits in $selector", ({ selector }) => {
|
|
123
|
+
// A theme-only token has no base value to fall back to, so the base render is the one
|
|
124
|
+
// that breaks — and `tokens.guard.test.ts` cannot see it, because the token *is*
|
|
125
|
+
// declared somewhere in the sheet.
|
|
126
|
+
const theme = declarationsFor(selector);
|
|
127
|
+
const orphans = [...theme.keys()].filter((token) => !base.has(token));
|
|
128
|
+
expect(orphans).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it.each(overlayCases)("leaves geometry to the base root in $selector", ({ selector }) => {
|
|
132
|
+
// Space, radius, font and shadow are theme-invariant by design: declared once and
|
|
133
|
+
// inherited. Re-declaring one in a single theme is how a theme quietly grows its
|
|
134
|
+
// own spacing scale.
|
|
135
|
+
const theme = declarationsFor(selector);
|
|
136
|
+
const geometry = [...theme.keys()].filter((token) => !isColour(token));
|
|
137
|
+
expect(geometry).toEqual([]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it.each([{ name: BASE.name, appearance: BASE.appearance, selector: BASE_SELECTOR }, ...overlayCases])(
|
|
141
|
+
"opts native chrome into the $appearance palette in $selector",
|
|
142
|
+
({ appearance, selector }) => {
|
|
143
|
+
// Without `color-scheme`, native chrome the framework cannot restyle stays in OS-light
|
|
144
|
+
// rendering under a dark theme — a white `<select>` popup over a black page.
|
|
145
|
+
expect(ruleFor(selector).properties.get("color-scheme")).toBe(appearance);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
it("copies the systemDark theme into the OS-preference block verbatim", () => {
|
|
150
|
+
// They are generated from one source and duplicated. Drift means the OS preference and
|
|
151
|
+
// the explicit toggle render different darks in the same app.
|
|
152
|
+
const systemDark = registry.themes.find((theme) => theme.name === registry.systemDark);
|
|
153
|
+
expect(systemDark, `systemDark "${registry.systemDark}" must be a registered theme`).toBeDefined();
|
|
154
|
+
const explicit = declarationsFor(selectorFor(systemDark));
|
|
155
|
+
const byPreference = declarationsFor(SYSTEM_DARK_SELECTOR);
|
|
156
|
+
expect([...byPreference.entries()]).toEqual([...explicit.entries()]);
|
|
157
|
+
expect(ruleFor(SYSTEM_DARK_SELECTOR).properties.get("color-scheme")).toBe(
|
|
158
|
+
systemDark.appearance,
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("lets the OS preference apply only to a root with no theme pinned", () => {
|
|
163
|
+
// The defect a third theme exposed. `:root:not([data-theme='light'])` matches
|
|
164
|
+
// `[data-theme='contrast']` and beats it on specificity, so an app that pinned a theme
|
|
165
|
+
// got the OS dark colours laid over it. Matching the *absence* of the attribute is the
|
|
166
|
+
// only form that stays correct as themes are added, so it is pinned here by shape rather
|
|
167
|
+
// than left to the generator's comment.
|
|
168
|
+
const guarded = rules.filter((rule) => rule.selector === SYSTEM_DARK_SELECTOR);
|
|
169
|
+
expect(guarded).toHaveLength(1);
|
|
170
|
+
for (const theme of registry.themes) {
|
|
171
|
+
expect(
|
|
172
|
+
SYSTEM_DARK_SELECTOR.includes(`'${theme.name}'`),
|
|
173
|
+
`the OS-preference selector must not name the ${theme.name} theme`,
|
|
174
|
+
).toBe(false);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
});
|
package/themes.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "The shipped themes, as data. `scripts/build-tokens.mjs` compiles one stylesheet block per entry and publishes the same list in the token manifest; `src/tokens.themes.test.js` holds every entry to completeness and `src/tokens.contrast.test.js` holds every entry to WCAG AA, or to `minimumContrast` where an entry sets a higher floor. Registration is explicit rather than a glob over `tokens.*.json`, because four things about a theme cannot be inferred from its file: which theme the OS dark preference selects, whether a theme reads as light or dark to native chrome, what contrast it promises, and what to call it. A theme is added by writing its overlay and adding it here — nothing else, and the gates refuse it if the overlay is incomplete or illegible.",
|
|
3
|
+
"base": "light",
|
|
4
|
+
"systemDark": "dark",
|
|
5
|
+
"themes": [
|
|
6
|
+
{
|
|
7
|
+
"name": "light",
|
|
8
|
+
"label": "Light",
|
|
9
|
+
"appearance": "light",
|
|
10
|
+
"source": "tokens.json",
|
|
11
|
+
"description": "The default. Carries every token, including the geometry the other themes inherit."
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"name": "dark",
|
|
15
|
+
"label": "Dark",
|
|
16
|
+
"appearance": "dark",
|
|
17
|
+
"source": "tokens.dark.json",
|
|
18
|
+
"description": "The slate-neutral dark counterpart to light, and the theme the OS dark preference selects."
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"name": "midnight",
|
|
22
|
+
"label": "Midnight",
|
|
23
|
+
"appearance": "dark",
|
|
24
|
+
"source": "tokens.midnight.json",
|
|
25
|
+
"description": "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."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "twilight",
|
|
29
|
+
"label": "Twilight",
|
|
30
|
+
"appearance": "dark",
|
|
31
|
+
"source": "tokens.twilight.json",
|
|
32
|
+
"description": "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."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "contrast",
|
|
36
|
+
"label": "High contrast",
|
|
37
|
+
"appearance": "light",
|
|
38
|
+
"source": "tokens.contrast.json",
|
|
39
|
+
"minimumContrast": 7,
|
|
40
|
+
"description": "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."
|
|
41
|
+
}
|
|
42
|
+
]
|
|
43
|
+
}
|
package/token-pairs.json
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Foreground/background pairings the framework renders as text, as data. Two consumers read this: tokens.contrast.test.js holds each pairing to WCAG 2.1 AA, and the generated token manifest publishes them so a theme editor or an agent can tell which tokens must stay legible against which. Decorative boundaries are deliberately absent — WCAG sets no ratio for a divider, and asserting one would teach the next reader to ignore this file. Names are CSS custom properties because that is the vocabulary a theme author writes and a manifest consumer reads.",
|
|
3
|
+
"textPairs": [
|
|
4
|
+
{
|
|
5
|
+
"id": "body-on-card",
|
|
6
|
+
"label": "body text on a card",
|
|
7
|
+
"fg": "--color-neutral-900",
|
|
8
|
+
"bg": "--color-neutral-0",
|
|
9
|
+
"layer": "primitive"
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"id": "body-on-canvas-primitive",
|
|
13
|
+
"label": "body text on the canvas",
|
|
14
|
+
"fg": "--color-neutral-900",
|
|
15
|
+
"bg": "--color-neutral-50",
|
|
16
|
+
"layer": "primitive"
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
"id": "muted-on-card",
|
|
20
|
+
"label": "muted text on a card",
|
|
21
|
+
"fg": "--color-neutral-600",
|
|
22
|
+
"bg": "--color-neutral-0",
|
|
23
|
+
"layer": "primitive"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"id": "muted-on-canvas-primitive",
|
|
27
|
+
"label": "muted text on the canvas",
|
|
28
|
+
"fg": "--color-neutral-600",
|
|
29
|
+
"bg": "--color-neutral-50",
|
|
30
|
+
"layer": "primitive"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": "primary-button-label",
|
|
34
|
+
"label": "primary button label",
|
|
35
|
+
"fg": "--color-brand-primary-contrast",
|
|
36
|
+
"bg": "--color-brand-primary",
|
|
37
|
+
"layer": "primitive"
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"id": "success-badge",
|
|
41
|
+
"label": "success badge",
|
|
42
|
+
"fg": "--color-status-success",
|
|
43
|
+
"bg": "--color-status-success-soft",
|
|
44
|
+
"layer": "primitive"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"id": "warning-badge",
|
|
48
|
+
"label": "warning badge",
|
|
49
|
+
"fg": "--color-status-warning",
|
|
50
|
+
"bg": "--color-status-warning-soft",
|
|
51
|
+
"layer": "primitive"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"id": "danger-badge",
|
|
55
|
+
"label": "danger badge",
|
|
56
|
+
"fg": "--color-status-danger",
|
|
57
|
+
"bg": "--color-status-danger-soft",
|
|
58
|
+
"layer": "primitive"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"id": "info-badge",
|
|
62
|
+
"label": "info badge",
|
|
63
|
+
"fg": "--color-status-info",
|
|
64
|
+
"bg": "--color-status-info-soft",
|
|
65
|
+
"layer": "primitive"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"id": "body-on-surface",
|
|
69
|
+
"label": "body text on a surface",
|
|
70
|
+
"fg": "--color-fg-default",
|
|
71
|
+
"bg": "--color-bg-surface",
|
|
72
|
+
"layer": "semantic"
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"id": "body-on-canvas",
|
|
76
|
+
"label": "body text on the canvas",
|
|
77
|
+
"fg": "--color-fg-default",
|
|
78
|
+
"bg": "--color-bg-canvas",
|
|
79
|
+
"layer": "semantic"
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
"id": "body-on-raised",
|
|
83
|
+
"label": "body text on a raised surface",
|
|
84
|
+
"fg": "--color-fg-default",
|
|
85
|
+
"bg": "--color-bg-raised",
|
|
86
|
+
"layer": "semantic"
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
"id": "muted-on-surface",
|
|
90
|
+
"label": "muted text on a surface",
|
|
91
|
+
"fg": "--color-fg-muted",
|
|
92
|
+
"bg": "--color-bg-surface",
|
|
93
|
+
"layer": "semantic"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"id": "muted-on-canvas",
|
|
97
|
+
"label": "muted text on the canvas",
|
|
98
|
+
"fg": "--color-fg-muted",
|
|
99
|
+
"bg": "--color-bg-canvas",
|
|
100
|
+
"layer": "semantic"
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"id": "subtle-on-surface",
|
|
104
|
+
"label": "subtle text on a surface",
|
|
105
|
+
"fg": "--color-fg-subtle",
|
|
106
|
+
"bg": "--color-bg-surface",
|
|
107
|
+
"layer": "semantic"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
"id": "accent-on-surface",
|
|
111
|
+
"label": "accent text on a surface",
|
|
112
|
+
"fg": "--color-fg-accent",
|
|
113
|
+
"bg": "--color-bg-surface",
|
|
114
|
+
"layer": "semantic"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"id": "accent-on-soft",
|
|
118
|
+
"label": "accent text on the accent wash",
|
|
119
|
+
"fg": "--color-fg-accent",
|
|
120
|
+
"bg": "--color-brand-primary-soft",
|
|
121
|
+
"layer": "semantic"
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
"id": "sidebar-text",
|
|
125
|
+
"label": "sidebar text",
|
|
126
|
+
"fg": "--color-sidebar-fg",
|
|
127
|
+
"bg": "--color-sidebar-bg",
|
|
128
|
+
"layer": "semantic"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "sidebar-muted-text",
|
|
132
|
+
"label": "sidebar muted text",
|
|
133
|
+
"fg": "--color-sidebar-muted",
|
|
134
|
+
"bg": "--color-sidebar-bg",
|
|
135
|
+
"layer": "semantic"
|
|
136
|
+
}
|
|
137
|
+
]
|
|
138
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"color": {
|
|
3
|
+
"brand": {
|
|
4
|
+
"primary": { "value": "#0842a0" },
|
|
5
|
+
"primaryContrast": { "value": "#ffffff" },
|
|
6
|
+
"primaryHover": { "value": "#05275a" },
|
|
7
|
+
"primarySoft": { "value": "#e8f0fe" }
|
|
8
|
+
},
|
|
9
|
+
"neutral": {
|
|
10
|
+
"0": { "value": "#ffffff" },
|
|
11
|
+
"50": { "value": "#ffffff" },
|
|
12
|
+
"100": { "value": "#f2f2f2" },
|
|
13
|
+
"200": { "value": "#595959" },
|
|
14
|
+
"300": { "value": "#404040" },
|
|
15
|
+
"400": { "value": "#303030" },
|
|
16
|
+
"500": { "value": "#1f1f1f" },
|
|
17
|
+
"600": { "value": "#141414" },
|
|
18
|
+
"700": { "value": "#0a0a0a" },
|
|
19
|
+
"800": { "value": "#000000" },
|
|
20
|
+
"900": { "value": "#000000" }
|
|
21
|
+
},
|
|
22
|
+
"status": {
|
|
23
|
+
"success": { "value": "#0a5c2b" },
|
|
24
|
+
"successSoft": { "value": "#e6f4ea" },
|
|
25
|
+
"warning": { "value": "#6b3800" },
|
|
26
|
+
"warningSoft": { "value": "#fdf1e0" },
|
|
27
|
+
"danger": { "value": "#96000c" },
|
|
28
|
+
"dangerSoft": { "value": "#fce8e8" },
|
|
29
|
+
"info": { "value": "#0b4a8f" },
|
|
30
|
+
"infoSoft": { "value": "#e5f0fb" }
|
|
31
|
+
},
|
|
32
|
+
"focusRing": { "value": "rgba(8,66,160,0.7)" },
|
|
33
|
+
"bg": {
|
|
34
|
+
"canvas": { "value": "#ffffff" },
|
|
35
|
+
"surface": { "value": "#ffffff" },
|
|
36
|
+
"raised": { "value": "#ffffff" },
|
|
37
|
+
"inset": { "value": "#f2f2f2" }
|
|
38
|
+
},
|
|
39
|
+
"fg": {
|
|
40
|
+
"default": { "value": "#000000" },
|
|
41
|
+
"muted": { "value": "#141414" },
|
|
42
|
+
"subtle": { "value": "#1f1f1f" },
|
|
43
|
+
"accent": { "value": "#0842a0" },
|
|
44
|
+
"onBrand": { "value": "#ffffff" }
|
|
45
|
+
},
|
|
46
|
+
"border": {
|
|
47
|
+
"default": { "value": "#595959" },
|
|
48
|
+
"strong": { "value": "#000000" },
|
|
49
|
+
"subtle": { "value": "#767676" }
|
|
50
|
+
},
|
|
51
|
+
"interactive": {
|
|
52
|
+
"hover": { "value": "#f2f2f2" },
|
|
53
|
+
"active": { "value": "#e0e0e0" },
|
|
54
|
+
"selected": { "value": "#e8f0fe" }
|
|
55
|
+
},
|
|
56
|
+
"sidebar": {
|
|
57
|
+
"bg": { "value": "#ffffff" },
|
|
58
|
+
"fg": { "value": "#000000" },
|
|
59
|
+
"muted": { "value": "#141414" },
|
|
60
|
+
"accent": { "value": "#e8f0fe" },
|
|
61
|
+
"border": { "value": "#595959" }
|
|
62
|
+
},
|
|
63
|
+
"chart": {
|
|
64
|
+
"1": { "value": "#0842a0" },
|
|
65
|
+
"2": { "value": "#00564a" },
|
|
66
|
+
"3": { "value": "#6b3800" },
|
|
67
|
+
"4": { "value": "#4b1f79" },
|
|
68
|
+
"5": { "value": "#8c0a4d" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/tokens.dark.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"color": {
|
|
3
3
|
"brand": {
|
|
4
|
-
"primary": { "value": "#
|
|
4
|
+
"primary": { "value": "#1d4ed8" },
|
|
5
5
|
"primaryContrast": { "value": "#ffffff" },
|
|
6
|
-
"primaryHover": { "value": "#
|
|
7
|
-
"primarySoft": { "value": "#
|
|
6
|
+
"primaryHover": { "value": "#2563eb" },
|
|
7
|
+
"primarySoft": { "value": "#15304e" }
|
|
8
8
|
},
|
|
9
9
|
"neutral": {
|
|
10
10
|
"0": { "value": "#1e293b" },
|
|
@@ -29,6 +29,43 @@
|
|
|
29
29
|
"info": { "value": "#38bdf8" },
|
|
30
30
|
"infoSoft": { "value": "#12303f" }
|
|
31
31
|
},
|
|
32
|
-
"focusRing": { "value": "rgba(96,165,250,0.45)" }
|
|
32
|
+
"focusRing": { "value": "rgba(96,165,250,0.45)" },
|
|
33
|
+
"bg": {
|
|
34
|
+
"canvas": { "value": "#0f172a" },
|
|
35
|
+
"surface": { "value": "#1e293b" },
|
|
36
|
+
"raised": { "value": "#263449" },
|
|
37
|
+
"inset": { "value": "#0f172a" }
|
|
38
|
+
},
|
|
39
|
+
"fg": {
|
|
40
|
+
"default": { "value": "#f1f5f9" },
|
|
41
|
+
"muted": { "value": "#b4c0d0" },
|
|
42
|
+
"subtle": { "value": "#94a3b8" },
|
|
43
|
+
"accent": { "value": "#60a5fa" },
|
|
44
|
+
"onBrand": { "value": "#ffffff" }
|
|
45
|
+
},
|
|
46
|
+
"border": {
|
|
47
|
+
"default": { "value": "#334155" },
|
|
48
|
+
"strong": { "value": "#475569" },
|
|
49
|
+
"subtle": { "value": "#263449" }
|
|
50
|
+
},
|
|
51
|
+
"interactive": {
|
|
52
|
+
"hover": { "value": "#263449" },
|
|
53
|
+
"active": { "value": "#334155" },
|
|
54
|
+
"selected": { "value": "#15304e" }
|
|
55
|
+
},
|
|
56
|
+
"sidebar": {
|
|
57
|
+
"bg": { "value": "#1e293b" },
|
|
58
|
+
"fg": { "value": "#f1f5f9" },
|
|
59
|
+
"muted": { "value": "#b4c0d0" },
|
|
60
|
+
"accent": { "value": "#334155" },
|
|
61
|
+
"border": { "value": "#334155" }
|
|
62
|
+
},
|
|
63
|
+
"chart": {
|
|
64
|
+
"1": { "value": "#60a5fa" },
|
|
65
|
+
"2": { "value": "#2dd4bf" },
|
|
66
|
+
"3": { "value": "#fbbf24" },
|
|
67
|
+
"4": { "value": "#a78bfa" },
|
|
68
|
+
"5": { "value": "#f472b6" }
|
|
69
|
+
}
|
|
33
70
|
}
|
|
34
71
|
}
|