@terpjs/contract 0.6.1 → 0.8.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.
@@ -0,0 +1,166 @@
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
+ expect(manifest.nonTextPairs).toEqual(pairsSource.nonTextPairs);
136
+ });
137
+
138
+ it("publishes both sections, so a missing one cannot read as no requirement", () => {
139
+ // `nonTextPairs` reached the manifest by being added to the builder's literal, which is a
140
+ // line that can be deleted without any other test noticing: a consumer would then see only
141
+ // the text pairings and read the absence of a boundary pairing as "nothing is required
142
+ // here" rather than "held in a section you were not given". Both sections are named
143
+ // explicitly rather than derived, because deriving them from the source file is what the
144
+ // assertion above already does — this one is about the shape the package publishes.
145
+ expect(Array.isArray(manifest.textPairs)).toBe(true);
146
+ expect(Array.isArray(manifest.nonTextPairs)).toBe(true);
147
+ expect(manifest.nonTextPairs.length).toBeGreaterThan(0);
148
+ });
149
+
150
+ it("references only tokens that exist, in both directions of every pairing", () => {
151
+ // Both sections. A typo in a token name is the failure this catches, and it is the only
152
+ // check that catches it for a pairing naming a token the sheet declares nowhere — the
153
+ // contrast gate would report it as an undefined declaration, which reads as a sheet
154
+ // problem rather than as a pairing problem.
155
+ for (const pair of [...manifest.textPairs, ...manifest.nonTextPairs]) {
156
+ expect(tokenByName.has(pair.fg), `${pair.id} fg ${pair.fg}`).toBe(true);
157
+ expect(tokenByName.has(pair.bg), `${pair.id} bg ${pair.bg}`).toBe(true);
158
+ }
159
+ });
160
+
161
+ it("says it is generated", () => {
162
+ // The file is committed, so the next person to open it needs to know editing it is futile.
163
+ expect(manifest.$comment).toContain("Generated");
164
+ expect(manifest.$comment).toContain("build-tokens.mjs");
165
+ });
166
+ });
@@ -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
+ }
@@ -0,0 +1,252 @@
1
+ {
2
+ "$comment": "Token pairings the framework renders, as data, in two sections held to two different bars. `textPairs` are foreground/background pairs painted as TEXT and held to WCAG 2.1 AA for normal text; `nonTextPairs` are the visual boundaries and state indicators SC 1.4.11 asks 3:1 of — a focus indicator, the border that says which control is active, the outline of a control against its surface. Two consumers read both: tokens.contrast.test.js measures each pairing at its own bar, and the generated token manifest publishes them so a theme editor or an agent can tell which tokens must stay legible against which. A pair of token names appears in one section only: the text bar is the stricter of the two, so restating a text pairing under a non-text name would add a case that cannot fail unless the stricter one already has, and would overstate how much the ratchets cover. Purely decorative marks stay absent from both sections — WCAG sets no ratio for a divider or for an aria-hidden ornament, 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": "muted-on-soft",
125
+ "label": "muted text on the accent wash",
126
+ "fg": "--color-fg-muted",
127
+ "bg": "--color-brand-primary-soft",
128
+ "layer": "semantic"
129
+ },
130
+ {
131
+ "id": "sidebar-text",
132
+ "label": "sidebar text",
133
+ "fg": "--color-sidebar-fg",
134
+ "bg": "--color-sidebar-bg",
135
+ "layer": "semantic"
136
+ },
137
+ {
138
+ "id": "sidebar-muted-text",
139
+ "label": "sidebar muted text",
140
+ "fg": "--color-sidebar-muted",
141
+ "bg": "--color-sidebar-bg",
142
+ "layer": "semantic"
143
+ },
144
+ {
145
+ "id": "muted-on-tone-neutral",
146
+ "label": "muted text on a neutral-toned row or card",
147
+ "fg": "--color-fg-muted",
148
+ "bg": "--color-neutral-100",
149
+ "layer": "semantic"
150
+ },
151
+ {
152
+ "id": "muted-on-tone-info",
153
+ "label": "muted text on an info-toned row or card",
154
+ "fg": "--color-fg-muted",
155
+ "bg": "--color-status-info-soft",
156
+ "layer": "semantic"
157
+ },
158
+ {
159
+ "id": "muted-on-tone-success",
160
+ "label": "muted text on a success-toned row or card",
161
+ "fg": "--color-fg-muted",
162
+ "bg": "--color-status-success-soft",
163
+ "layer": "semantic"
164
+ },
165
+ {
166
+ "id": "muted-on-tone-warning",
167
+ "label": "muted text on a warning-toned row or card",
168
+ "fg": "--color-fg-muted",
169
+ "bg": "--color-status-warning-soft",
170
+ "layer": "semantic"
171
+ },
172
+ {
173
+ "id": "muted-on-tone-danger",
174
+ "label": "muted text on a danger-toned row or card",
175
+ "fg": "--color-fg-muted",
176
+ "bg": "--color-status-danger-soft",
177
+ "layer": "semantic"
178
+ }
179
+ ],
180
+ "nonTextPairs": [
181
+ {
182
+ "id": "focus-ring-on-canvas",
183
+ "label": "the focus indicator on the canvas",
184
+ "fg": "--color-fg-accent",
185
+ "bg": "--color-bg-canvas",
186
+ "layer": "semantic"
187
+ },
188
+ {
189
+ "id": "active-toggle-border",
190
+ "label": "the border marking which layout toggle is active",
191
+ "fg": "--color-fg-accent",
192
+ "bg": "--color-neutral-100",
193
+ "layer": "semantic"
194
+ },
195
+ {
196
+ "id": "subtle-glyph-on-tone-neutral",
197
+ "label": "an icon button's glyph on a neutral wash",
198
+ "fg": "--color-fg-subtle",
199
+ "bg": "--color-neutral-100",
200
+ "layer": "semantic"
201
+ },
202
+ {
203
+ "id": "subtle-glyph-on-tone-info",
204
+ "label": "an icon button's glyph on an info row",
205
+ "fg": "--color-fg-subtle",
206
+ "bg": "--color-status-info-soft",
207
+ "layer": "semantic"
208
+ },
209
+ {
210
+ "id": "subtle-glyph-on-tone-success",
211
+ "label": "an icon button's glyph on a success row",
212
+ "fg": "--color-fg-subtle",
213
+ "bg": "--color-status-success-soft",
214
+ "layer": "semantic"
215
+ },
216
+ {
217
+ "id": "subtle-glyph-on-tone-warning",
218
+ "label": "an icon button's glyph on a warning row",
219
+ "fg": "--color-fg-subtle",
220
+ "bg": "--color-status-warning-soft",
221
+ "layer": "semantic"
222
+ },
223
+ {
224
+ "id": "subtle-glyph-on-tone-danger",
225
+ "label": "an icon button's glyph on a danger row",
226
+ "fg": "--color-fg-subtle",
227
+ "bg": "--color-status-danger-soft",
228
+ "layer": "semantic"
229
+ },
230
+ {
231
+ "id": "subtle-glyph-on-focus-wash",
232
+ "label": "an icon button's glyph on a focused clickable row",
233
+ "fg": "--color-fg-subtle",
234
+ "bg": "--color-brand-primary-soft",
235
+ "layer": "semantic"
236
+ },
237
+ {
238
+ "id": "control-boundary-on-surface",
239
+ "label": "a control's outline on a card",
240
+ "fg": "--color-neutral-300",
241
+ "bg": "--color-neutral-0",
242
+ "layer": "primitive"
243
+ },
244
+ {
245
+ "id": "control-boundary-on-canvas",
246
+ "label": "a control's outline on the canvas",
247
+ "fg": "--color-neutral-300",
248
+ "bg": "--color-neutral-50",
249
+ "layer": "primitive"
250
+ }
251
+ ]
252
+ }