@terpjs/contract 0.8.0 → 0.10.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/openapi.json +8 -0
- package/package.json +1 -1
- package/scripts/build-tokens.mjs +61 -1
- package/src/appearance-mechanism.js +19 -0
- package/src/icons.ts +98 -0
- package/src/index.ts +3 -1
- package/src/manifest.ts +128 -2
- package/src/routes-codegen.js +100 -32
- package/src/routes-codegen.test.js +92 -15
- package/src/schema.d.ts +5 -0
- package/src/tokens.contrast.test.js +16 -5
- package/src/tokens.css +23 -0
- package/src/tokens.manifest.json +104 -5
- package/src/tokens.manifest.test.js +49 -2
- package/src/tokens.themes.test.js +22 -1
- package/token-pairs.json +22 -1
- package/tokens.json +19 -0
|
@@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from "vitest";
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
canonicalPath,
|
|
11
|
-
|
|
11
|
+
extractRoutes,
|
|
12
12
|
generateRouteTable,
|
|
13
13
|
moduleFiles,
|
|
14
14
|
paramNamesOf,
|
|
@@ -66,17 +66,17 @@ describe("param extraction from a path", () => {
|
|
|
66
66
|
|
|
67
67
|
describe("extraction from a module manifest", () => {
|
|
68
68
|
it("reads every route path a manifest declares", () => {
|
|
69
|
-
const {
|
|
69
|
+
const { routes, problems } = extractRoutes(
|
|
70
70
|
manifest('{ path: "/records", view: "List" }, { path: "/records/:recordId", view: "Detail" }'),
|
|
71
71
|
"module.tsx",
|
|
72
72
|
);
|
|
73
73
|
expect(problems).toEqual([]);
|
|
74
|
-
expect(
|
|
74
|
+
expect(routes.map((route) => route.path)).toEqual(["/records", "/records/:recordId"]);
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
it("reads a manifest declared as a property value, not only `export const manifest`", () => {
|
|
78
78
|
// The packaged admin area's shape: defineModuleManifest(...) inside an object literal.
|
|
79
|
-
const {
|
|
79
|
+
const { routes, problems } = extractRoutes(
|
|
80
80
|
[
|
|
81
81
|
'import { defineModuleManifest } from "@terpjs/contract";',
|
|
82
82
|
"export const adminModule = {",
|
|
@@ -86,25 +86,25 @@ describe("extraction from a module manifest", () => {
|
|
|
86
86
|
"module.tsx",
|
|
87
87
|
);
|
|
88
88
|
expect(problems).toEqual([]);
|
|
89
|
-
expect(
|
|
89
|
+
expect(routes.map((route) => route.path)).toEqual(["/admin"]);
|
|
90
90
|
});
|
|
91
91
|
|
|
92
92
|
it("refuses a path that is not a plain string literal, naming file and line", () => {
|
|
93
|
-
const {
|
|
93
|
+
const { routes, problems } = extractRoutes(
|
|
94
94
|
manifest("{ path: `/records/${base}`, view: \"List\" }"),
|
|
95
95
|
"src/modules/records/module.tsx",
|
|
96
96
|
);
|
|
97
|
-
expect(
|
|
97
|
+
expect(routes.map((route) => route.path)).toEqual([]);
|
|
98
98
|
expect(problems).toHaveLength(1);
|
|
99
99
|
expect(problems[0]).toContain("src/modules/records/module.tsx:4");
|
|
100
100
|
expect(problems[0]).toContain("not a plain string literal");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
103
|
it("refuses a spread route and a non-literal routes array", () => {
|
|
104
|
-
const spread =
|
|
104
|
+
const spread = extractRoutes(manifest("...shared"), "module.tsx");
|
|
105
105
|
expect(spread.problems[0]).toContain("not an object literal");
|
|
106
106
|
|
|
107
|
-
const dynamic =
|
|
107
|
+
const dynamic = extractRoutes(
|
|
108
108
|
[
|
|
109
109
|
'import { defineModuleManifest } from "@terpjs/contract";',
|
|
110
110
|
"export const manifest = defineModuleManifest({ name: \"x\", routes: buildRoutes() });",
|
|
@@ -115,20 +115,20 @@ describe("extraction from a module manifest", () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it("refuses a module slot with no manifest call at all", () => {
|
|
118
|
-
const { problems } =
|
|
118
|
+
const { problems } = extractRoutes("export const views = {};", "module.tsx");
|
|
119
119
|
expect(problems[0]).toContain("no defineModuleManifest");
|
|
120
120
|
});
|
|
121
121
|
|
|
122
122
|
it("refuses a route with no path", () => {
|
|
123
|
-
const { problems } =
|
|
123
|
+
const { problems } = extractRoutes(manifest('{ view: "List" }'), "module.tsx");
|
|
124
124
|
expect(problems[0]).toContain("declares no `path`");
|
|
125
125
|
});
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
describe("rendering the declaration file", () => {
|
|
129
129
|
it("is deterministic: deduped, sorted, LF-only, one trailing newline", () => {
|
|
130
|
-
const first = renderRouteTable(["/b", "/a", "/b"]);
|
|
131
|
-
const second = renderRouteTable(["/b", "/b", "/a"]);
|
|
130
|
+
const first = renderRouteTable([{ path: "/b", search: [] }, { path: "/a", search: [] }, { path: "/b", search: [] }]);
|
|
131
|
+
const second = renderRouteTable([{ path: "/b", search: [] }, { path: "/b", search: [] }, { path: "/a", search: [] }]);
|
|
132
132
|
expect(first).toBe(second);
|
|
133
133
|
expect(first).not.toContain("\r");
|
|
134
134
|
expect(first.endsWith("}\n")).toBe(true);
|
|
@@ -136,14 +136,14 @@ describe("rendering the declaration file", () => {
|
|
|
136
136
|
});
|
|
137
137
|
|
|
138
138
|
it("types a parameterised route's params and leaves a paramless route empty", () => {
|
|
139
|
-
const rendered = renderRouteTable(["/records/:recordId", "/records", "/s/:a/i/:b"]);
|
|
139
|
+
const rendered = renderRouteTable([{ path: "/records/:recordId", search: [] }, { path: "/records", search: [] }, { path: "/s/:a/i/:b", search: [] }]);
|
|
140
140
|
expect(rendered).toContain('"/records": Record<never, never>;');
|
|
141
141
|
expect(rendered).toContain('"/records/:recordId": { recordId: string };');
|
|
142
142
|
expect(rendered).toContain('"/s/:a/i/:b": { a: string; b: string };');
|
|
143
143
|
});
|
|
144
144
|
|
|
145
145
|
it("augments the react-core table so the app's own types pick it up", () => {
|
|
146
|
-
const rendered = renderRouteTable(["/"]);
|
|
146
|
+
const rendered = renderRouteTable([{ path: "/", search: [] }]);
|
|
147
147
|
expect(rendered).toContain('declare module "@terpjs/react-core"');
|
|
148
148
|
expect(rendered).toContain("interface TerpRouteTable");
|
|
149
149
|
// A module augmentation only applies from a file that IS a module.
|
|
@@ -151,6 +151,83 @@ describe("rendering the declaration file", () => {
|
|
|
151
151
|
});
|
|
152
152
|
});
|
|
153
153
|
|
|
154
|
+
describe("declared query-string keys", () => {
|
|
155
|
+
it("reads a route's search keys, and defaults to none", () => {
|
|
156
|
+
const { routes, problems } = extractRoutes(
|
|
157
|
+
manifest(
|
|
158
|
+
'{ path: "/records", view: "List", search: ["status", "page"] }, ' +
|
|
159
|
+
'{ path: "/records/:recordId", view: "Detail" }',
|
|
160
|
+
),
|
|
161
|
+
"module.tsx",
|
|
162
|
+
);
|
|
163
|
+
expect(problems).toEqual([]);
|
|
164
|
+
expect(routes).toEqual([
|
|
165
|
+
{ path: "/records", search: ["status", "page"] },
|
|
166
|
+
{ path: "/records/:recordId", search: [] },
|
|
167
|
+
]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("refuses keys that cannot be read statically, naming file and line", () => {
|
|
171
|
+
// Held to the same standard as `path`: a key list assembled at runtime would emit a
|
|
172
|
+
// table missing a key, which turns a real navigation into a type error.
|
|
173
|
+
const spread = extractRoutes(
|
|
174
|
+
manifest('{ path: "/r", view: "L", search: [...FILTERS] }'),
|
|
175
|
+
"module.tsx",
|
|
176
|
+
);
|
|
177
|
+
expect(spread.problems.join("\n")).toMatch(/search` key is not a plain string literal/);
|
|
178
|
+
|
|
179
|
+
const dynamic = extractRoutes(
|
|
180
|
+
manifest('{ path: "/r", view: "L", search: FILTERS }'),
|
|
181
|
+
"module.tsx",
|
|
182
|
+
);
|
|
183
|
+
expect(dynamic.problems.join("\n")).toMatch(/search` is not an array literal/);
|
|
184
|
+
expect(dynamic.problems[0]).toMatch(/^module\.tsx:4:/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("refuses a key that is not a plain identifier, because it cannot be a typed property", () => {
|
|
188
|
+
const { problems } = extractRoutes(
|
|
189
|
+
manifest('{ path: "/r", view: "L", search: ["not-an-identifier"] }'),
|
|
190
|
+
"module.tsx",
|
|
191
|
+
);
|
|
192
|
+
expect(problems.join("\n")).toMatch(/is not a plain identifier/);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("emits a second table keyed only for routes that declare keys", () => {
|
|
196
|
+
const rendered = renderRouteTable([
|
|
197
|
+
{ path: "/records", search: ["status"] },
|
|
198
|
+
{ path: "/records/:recordId", search: [] },
|
|
199
|
+
]);
|
|
200
|
+
expect(rendered).toContain(' "/records": Record<never, never>;');
|
|
201
|
+
expect(rendered).toContain(" interface TerpRouteSearchTable {");
|
|
202
|
+
expect(rendered).toContain(' "/records": { status?: string };');
|
|
203
|
+
// The route that declares no keys is absent from the SEARCH table (it is of course
|
|
204
|
+
// present in the params table above), so passing `search` to it stays a typecheck
|
|
205
|
+
// error rather than accepting anything.
|
|
206
|
+
const searchTable = rendered.slice(rendered.indexOf("interface TerpRouteSearchTable"));
|
|
207
|
+
expect(searchTable).not.toContain("/records/:recordId");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("omits the search table entirely when no route declares a key", () => {
|
|
211
|
+
// An app that declares none gets exactly the file it had before route search existed
|
|
212
|
+
// — and no empty interface for its own linter to complain about.
|
|
213
|
+
const rendered = renderRouteTable([{ path: "/records", search: [] }]);
|
|
214
|
+
expect(rendered).not.toContain("TerpRouteSearchTable");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("unions the keys when two manifests mount one path", () => {
|
|
218
|
+
const rendered = renderRouteTable([
|
|
219
|
+
{ path: "/shared", search: ["b"] },
|
|
220
|
+
{ path: "/shared", search: ["a", "b"] },
|
|
221
|
+
]);
|
|
222
|
+
expect(rendered).toContain(' "/shared": { a?: string; b?: string };');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("keys the search table by the canonical spelling, like the params table", () => {
|
|
226
|
+
const rendered = renderRouteTable([{ path: "/admin/users/$userId", search: ["q"] }]);
|
|
227
|
+
expect(rendered).toContain(' "/admin/users/:userId": { q?: string };');
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
154
231
|
describe("generating across an app's module slots", () => {
|
|
155
232
|
it("merges every slot and dedupes a path two modules claim", () => {
|
|
156
233
|
const root = appWithModules({
|
package/src/schema.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ const here = (name) => fileURLToPath(new URL(name, import.meta.url));
|
|
|
22
22
|
|
|
23
23
|
const tokensCss = fs.readFileSync(here("./tokens.css"), "utf8");
|
|
24
24
|
const registry = JSON.parse(fs.readFileSync(here("../themes.json"), "utf8"));
|
|
25
|
+
const manifest = JSON.parse(fs.readFileSync(here("./tokens.manifest.json"), "utf8"));
|
|
25
26
|
|
|
26
27
|
/** WCAG 2.1 AA, normal-size text. Large text and UI boundaries would be 3.0. */
|
|
27
28
|
const AA_NORMAL_TEXT = 4.5;
|
|
@@ -35,8 +36,12 @@ const AAA_NORMAL_TEXT = 7;
|
|
|
35
36
|
* floor to AAA, because WCAG defines no AAA tier for non-text contrast — `minimumContrast` in
|
|
36
37
|
* themes.json is a promise about reading, and inventing a stricter non-text bar from it would
|
|
37
38
|
* be this file asserting a standard nobody wrote.
|
|
39
|
+
*
|
|
40
|
+
* Read from the manifest for the same reason `floorFor` is: both bars are published so a theme
|
|
41
|
+
* editor can hold an app's palette to them, and a bar that is published in one place and
|
|
42
|
+
* enforced from another is two numbers wearing one name.
|
|
38
43
|
*/
|
|
39
|
-
const UI_COMPONENT =
|
|
44
|
+
const UI_COMPONENT = manifest.nonTextMinimumContrast;
|
|
40
45
|
|
|
41
46
|
/**
|
|
42
47
|
* Pairings the framework renders as text, read from the shared data file.
|
|
@@ -186,12 +191,18 @@ function contrastRatio(a, b) {
|
|
|
186
191
|
}
|
|
187
192
|
|
|
188
193
|
/**
|
|
189
|
-
* The ratio a theme's pairings must reach
|
|
190
|
-
*
|
|
191
|
-
*
|
|
194
|
+
* The ratio a theme's pairings must reach — read from the MANIFEST, not from `themes.json`.
|
|
195
|
+
*
|
|
196
|
+
* AA for normal text by default; a theme may declare a higher floor in the registry, which is
|
|
197
|
+
* how the high-contrast theme's promise is a gate rather than a sentence in its description.
|
|
198
|
+
* The indirection is the point: the manifest publishes an effective floor per theme so the
|
|
199
|
+
* Studio's theme editor and an agent can hold an app's own palette to the same bar this gate
|
|
200
|
+
* holds the framework's. Reading it back here means the published floor and the enforced floor
|
|
201
|
+
* are one number. Re-deriving it from the registry on this side would let the manifest publish
|
|
202
|
+
* 4.5 for a theme this file measures at 7 and neither would notice.
|
|
192
203
|
*/
|
|
193
204
|
const floorFor = (name) =>
|
|
194
|
-
|
|
205
|
+
manifest.themes.find((theme) => theme.name === name)?.minimumContrast ?? AA_NORMAL_TEXT;
|
|
195
206
|
|
|
196
207
|
/**
|
|
197
208
|
* Every pairing in *list*, in every registered theme, tagged with its ratchet key and the
|
package/src/tokens.css
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
text-field carets) into the light palette so it never renders as foreign
|
|
8
8
|
OS-dark chrome. Each theme block below sets its own. */
|
|
9
9
|
color-scheme: light;
|
|
10
|
+
/* The appearance as something a stylesheet can branch on — see appearanceSwitch above.
|
|
11
|
+
color-scheme records the same fact and no selector can read it. */
|
|
12
|
+
--appearance-show-light: block;
|
|
13
|
+
--appearance-show-dark: none;
|
|
10
14
|
--color-brand-primary: #2563eb;
|
|
11
15
|
--color-brand-primary-contrast: #ffffff;
|
|
12
16
|
--color-brand-primary-hover: #1d4ed8;
|
|
@@ -77,6 +81,9 @@
|
|
|
77
81
|
--density-compact-control-min-height: 2rem;
|
|
78
82
|
--density-compact-cell-pad-y: 0.5rem;
|
|
79
83
|
--density-compact-cell-pad-x: 0.5rem;
|
|
84
|
+
--density-comfortable-control-min-height: 2.25rem;
|
|
85
|
+
--density-comfortable-cell-pad-y: 0.75rem;
|
|
86
|
+
--density-comfortable-cell-pad-x: 0.75rem;
|
|
80
87
|
--radius-sm: 0.25rem;
|
|
81
88
|
--radius-md: 0.5rem;
|
|
82
89
|
--radius-lg: 0.75rem;
|
|
@@ -88,10 +95,16 @@
|
|
|
88
95
|
--z-index-base: 0;
|
|
89
96
|
--z-index-sticky: 30;
|
|
90
97
|
--z-index-backdrop: 40;
|
|
98
|
+
--z-index-skip-link: 45;
|
|
91
99
|
--z-index-drawer: 50;
|
|
92
100
|
--z-index-popover: 60;
|
|
93
101
|
--z-index-tooltip: 70;
|
|
94
102
|
--z-index-toast: 100;
|
|
103
|
+
--shell-sidebar-width-expanded: 15rem;
|
|
104
|
+
--shell-sidebar-width-collapsed: 4rem;
|
|
105
|
+
--shell-header-height: 3rem;
|
|
106
|
+
--shell-content-max-width: 80rem;
|
|
107
|
+
--shell-brand-size: 1.75rem;
|
|
95
108
|
--breakpoint-sm: 480px;
|
|
96
109
|
--breakpoint-md: 768px;
|
|
97
110
|
--breakpoint-lg: 1024px;
|
|
@@ -130,6 +143,8 @@
|
|
|
130
143
|
The slate-neutral dark counterpart to light, and the theme the OS dark preference selects. */
|
|
131
144
|
[data-theme='dark'] {
|
|
132
145
|
color-scheme: dark;
|
|
146
|
+
--appearance-show-light: none;
|
|
147
|
+
--appearance-show-dark: block;
|
|
133
148
|
--color-brand-primary: #1d4ed8;
|
|
134
149
|
--color-brand-primary-contrast: #ffffff;
|
|
135
150
|
--color-brand-primary-hover: #2563eb;
|
|
@@ -184,6 +199,8 @@
|
|
|
184
199
|
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. */
|
|
185
200
|
[data-theme='midnight'] {
|
|
186
201
|
color-scheme: dark;
|
|
202
|
+
--appearance-show-light: none;
|
|
203
|
+
--appearance-show-dark: block;
|
|
187
204
|
--color-brand-primary: #0b4ea8;
|
|
188
205
|
--color-brand-primary-contrast: #ffffff;
|
|
189
206
|
--color-brand-primary-hover: #1158c7;
|
|
@@ -238,6 +255,8 @@
|
|
|
238
255
|
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. */
|
|
239
256
|
[data-theme='twilight'] {
|
|
240
257
|
color-scheme: dark;
|
|
258
|
+
--appearance-show-light: none;
|
|
259
|
+
--appearance-show-dark: block;
|
|
241
260
|
--color-brand-primary: #5b21b6;
|
|
242
261
|
--color-brand-primary-contrast: #ffffff;
|
|
243
262
|
--color-brand-primary-hover: #6d28d9;
|
|
@@ -292,6 +311,8 @@
|
|
|
292
311
|
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. */
|
|
293
312
|
[data-theme='contrast'] {
|
|
294
313
|
color-scheme: light;
|
|
314
|
+
--appearance-show-light: block;
|
|
315
|
+
--appearance-show-dark: none;
|
|
295
316
|
--color-brand-primary: #0842a0;
|
|
296
317
|
--color-brand-primary-contrast: #ffffff;
|
|
297
318
|
--color-brand-primary-hover: #05275a;
|
|
@@ -346,6 +367,8 @@
|
|
|
346
367
|
@media (prefers-color-scheme: dark) {
|
|
347
368
|
:root:not([data-theme]) {
|
|
348
369
|
color-scheme: dark;
|
|
370
|
+
--appearance-show-light: none;
|
|
371
|
+
--appearance-show-dark: block;
|
|
349
372
|
--color-brand-primary: #1d4ed8;
|
|
350
373
|
--color-brand-primary-contrast: #ffffff;
|
|
351
374
|
--color-brand-primary-hover: #2563eb;
|
package/src/tokens.manifest.json
CHANGED
|
@@ -7,31 +7,36 @@
|
|
|
7
7
|
"name": "light",
|
|
8
8
|
"label": "Light",
|
|
9
9
|
"appearance": "light",
|
|
10
|
-
"description": "The default. Carries every token, including the geometry the other themes inherit."
|
|
10
|
+
"description": "The default. Carries every token, including the geometry the other themes inherit.",
|
|
11
|
+
"minimumContrast": 4.5
|
|
11
12
|
},
|
|
12
13
|
{
|
|
13
14
|
"name": "dark",
|
|
14
15
|
"label": "Dark",
|
|
15
16
|
"appearance": "dark",
|
|
16
|
-
"description": "The slate-neutral dark counterpart to light, and the theme the OS dark preference selects."
|
|
17
|
+
"description": "The slate-neutral dark counterpart to light, and the theme the OS dark preference selects.",
|
|
18
|
+
"minimumContrast": 4.5
|
|
17
19
|
},
|
|
18
20
|
{
|
|
19
21
|
"name": "midnight",
|
|
20
22
|
"label": "Midnight",
|
|
21
23
|
"appearance": "dark",
|
|
22
|
-
"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."
|
|
24
|
+
"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.",
|
|
25
|
+
"minimumContrast": 4.5
|
|
23
26
|
},
|
|
24
27
|
{
|
|
25
28
|
"name": "twilight",
|
|
26
29
|
"label": "Twilight",
|
|
27
30
|
"appearance": "dark",
|
|
28
|
-
"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."
|
|
31
|
+
"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.",
|
|
32
|
+
"minimumContrast": 4.5
|
|
29
33
|
},
|
|
30
34
|
{
|
|
31
35
|
"name": "contrast",
|
|
32
36
|
"label": "High contrast",
|
|
33
37
|
"appearance": "light",
|
|
34
|
-
"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."
|
|
38
|
+
"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.",
|
|
39
|
+
"minimumContrast": 7
|
|
35
40
|
}
|
|
36
41
|
],
|
|
37
42
|
"tokens": [
|
|
@@ -787,6 +792,30 @@
|
|
|
787
792
|
},
|
|
788
793
|
"themeable": false
|
|
789
794
|
},
|
|
795
|
+
{
|
|
796
|
+
"name": "--density-comfortable-control-min-height",
|
|
797
|
+
"category": "density",
|
|
798
|
+
"values": {
|
|
799
|
+
"light": "2.25rem"
|
|
800
|
+
},
|
|
801
|
+
"themeable": false
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
"name": "--density-comfortable-cell-pad-y",
|
|
805
|
+
"category": "density",
|
|
806
|
+
"values": {
|
|
807
|
+
"light": "0.75rem"
|
|
808
|
+
},
|
|
809
|
+
"themeable": false
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
"name": "--density-comfortable-cell-pad-x",
|
|
813
|
+
"category": "density",
|
|
814
|
+
"values": {
|
|
815
|
+
"light": "0.75rem"
|
|
816
|
+
},
|
|
817
|
+
"themeable": false
|
|
818
|
+
},
|
|
790
819
|
{
|
|
791
820
|
"name": "--radius-sm",
|
|
792
821
|
"category": "radius",
|
|
@@ -875,6 +904,14 @@
|
|
|
875
904
|
},
|
|
876
905
|
"themeable": false
|
|
877
906
|
},
|
|
907
|
+
{
|
|
908
|
+
"name": "--z-index-skip-link",
|
|
909
|
+
"category": "zIndex",
|
|
910
|
+
"values": {
|
|
911
|
+
"light": "45"
|
|
912
|
+
},
|
|
913
|
+
"themeable": false
|
|
914
|
+
},
|
|
878
915
|
{
|
|
879
916
|
"name": "--z-index-drawer",
|
|
880
917
|
"category": "zIndex",
|
|
@@ -907,6 +944,46 @@
|
|
|
907
944
|
},
|
|
908
945
|
"themeable": false
|
|
909
946
|
},
|
|
947
|
+
{
|
|
948
|
+
"name": "--shell-sidebar-width-expanded",
|
|
949
|
+
"category": "shell",
|
|
950
|
+
"values": {
|
|
951
|
+
"light": "15rem"
|
|
952
|
+
},
|
|
953
|
+
"themeable": false
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
"name": "--shell-sidebar-width-collapsed",
|
|
957
|
+
"category": "shell",
|
|
958
|
+
"values": {
|
|
959
|
+
"light": "4rem"
|
|
960
|
+
},
|
|
961
|
+
"themeable": false
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
"name": "--shell-header-height",
|
|
965
|
+
"category": "shell",
|
|
966
|
+
"values": {
|
|
967
|
+
"light": "3rem"
|
|
968
|
+
},
|
|
969
|
+
"themeable": false
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
"name": "--shell-content-max-width",
|
|
973
|
+
"category": "shell",
|
|
974
|
+
"values": {
|
|
975
|
+
"light": "80rem"
|
|
976
|
+
},
|
|
977
|
+
"themeable": false
|
|
978
|
+
},
|
|
979
|
+
{
|
|
980
|
+
"name": "--shell-brand-size",
|
|
981
|
+
"category": "shell",
|
|
982
|
+
"values": {
|
|
983
|
+
"light": "1.75rem"
|
|
984
|
+
},
|
|
985
|
+
"themeable": false
|
|
986
|
+
},
|
|
910
987
|
{
|
|
911
988
|
"name": "--breakpoint-sm",
|
|
912
989
|
"category": "breakpoint",
|
|
@@ -1164,6 +1241,7 @@
|
|
|
1164
1241
|
"themeable": false
|
|
1165
1242
|
}
|
|
1166
1243
|
],
|
|
1244
|
+
"nonTextMinimumContrast": 3,
|
|
1167
1245
|
"textPairs": [
|
|
1168
1246
|
{
|
|
1169
1247
|
"id": "body-on-card",
|
|
@@ -1193,6 +1271,20 @@
|
|
|
1193
1271
|
"bg": "--color-neutral-50",
|
|
1194
1272
|
"layer": "primitive"
|
|
1195
1273
|
},
|
|
1274
|
+
{
|
|
1275
|
+
"id": "danger-on-card",
|
|
1276
|
+
"label": "danger text on a card",
|
|
1277
|
+
"fg": "--color-status-danger",
|
|
1278
|
+
"bg": "--color-neutral-0",
|
|
1279
|
+
"layer": "primitive"
|
|
1280
|
+
},
|
|
1281
|
+
{
|
|
1282
|
+
"id": "danger-on-canvas",
|
|
1283
|
+
"label": "danger text on the canvas",
|
|
1284
|
+
"fg": "--color-status-danger",
|
|
1285
|
+
"bg": "--color-neutral-50",
|
|
1286
|
+
"layer": "primitive"
|
|
1287
|
+
},
|
|
1196
1288
|
{
|
|
1197
1289
|
"id": "primary-button-label",
|
|
1198
1290
|
"label": "primary button label",
|
|
@@ -1339,6 +1431,13 @@
|
|
|
1339
1431
|
"fg": "--color-fg-muted",
|
|
1340
1432
|
"bg": "--color-status-danger-soft",
|
|
1341
1433
|
"layer": "semantic"
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
"id": "sidebar-nav-link-hover",
|
|
1437
|
+
"label": "a hovered sidebar navigation link",
|
|
1438
|
+
"fg": "--color-sidebar-fg",
|
|
1439
|
+
"bg": "--color-sidebar-accent",
|
|
1440
|
+
"layer": "semantic"
|
|
1342
1441
|
}
|
|
1343
1442
|
],
|
|
1344
1443
|
"nonTextPairs": [
|
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
|
|
6
|
+
import { APPEARANCE_MECHANISM_TOKENS } from "./appearance-mechanism.js";
|
|
6
7
|
import { parseRules } from "./css-rules.js";
|
|
7
8
|
|
|
8
9
|
// The published token manifest: the same tokens as machine-readable data.
|
|
@@ -44,8 +45,22 @@ describe("token manifest", () => {
|
|
|
44
45
|
// Either direction is a real failure: a token missing from the manifest is invisible to
|
|
45
46
|
// every tool that reads it, and a token in the manifest that the sheet does not declare
|
|
46
47
|
// is a control that would silently do nothing.
|
|
48
|
+
//
|
|
49
|
+
// The appearance switch is the exception and is subtracted by name. It is the theme's own
|
|
50
|
+
// `appearance` in the form a stylesheet can branch on, not a value anyone designs, and its
|
|
51
|
+
// values are `block` / `none` — a theme editor offering that pair offers a way to break the
|
|
52
|
+
// switch rather than a way to theme anything. The list is exact, so a third such property
|
|
53
|
+
// has to arrive here and say the same thing about itself.
|
|
47
54
|
const manifestNames = manifest.tokens.map((token) => token.name).sort();
|
|
48
|
-
|
|
55
|
+
const declaredNames = [...base.keys()].filter(
|
|
56
|
+
(name) => !APPEARANCE_MECHANISM_TOKENS.includes(name),
|
|
57
|
+
);
|
|
58
|
+
expect(manifestNames).toEqual(declaredNames.sort());
|
|
59
|
+
// And they really are declared — subtracting a name that is not there would hide a
|
|
60
|
+
// manifest gap rather than an exemption.
|
|
61
|
+
for (const name of APPEARANCE_MECHANISM_TOKENS) {
|
|
62
|
+
expect(base.has(name), `${name} is not declared on the base root`).toBe(true);
|
|
63
|
+
}
|
|
49
64
|
});
|
|
50
65
|
|
|
51
66
|
it("publishes the theme list the sheet was generated from", () => {
|
|
@@ -55,11 +70,12 @@ describe("token manifest", () => {
|
|
|
55
70
|
expect(manifest.base).toBe(registry.base);
|
|
56
71
|
expect(manifest.systemDark).toBe(registry.systemDark);
|
|
57
72
|
expect(manifest.themes).toEqual(
|
|
58
|
-
registry.themes.map(({ name, label, appearance, description }) => ({
|
|
73
|
+
registry.themes.map(({ name, label, appearance, description, minimumContrast }) => ({
|
|
59
74
|
name,
|
|
60
75
|
label,
|
|
61
76
|
appearance,
|
|
62
77
|
description,
|
|
78
|
+
minimumContrast: minimumContrast ?? 4.5,
|
|
63
79
|
})),
|
|
64
80
|
);
|
|
65
81
|
for (const theme of manifest.themes) {
|
|
@@ -67,6 +83,37 @@ describe("token manifest", () => {
|
|
|
67
83
|
}
|
|
68
84
|
});
|
|
69
85
|
|
|
86
|
+
it("publishes an effective contrast floor on every theme, never below AA", () => {
|
|
87
|
+
// The floor is published as a number on EVERY theme, including the four that take the
|
|
88
|
+
// default, because the consumer this file exists for is a theme editor holding an app's own
|
|
89
|
+
// palette to the same bar. A field present only on `contrast` would make the other four
|
|
90
|
+
// "unknown", and a consumer that guesses is a consumer that can disagree with the gate.
|
|
91
|
+
//
|
|
92
|
+
// `tokens.contrast.test.js` reads these numbers rather than the registry, so a wrong value
|
|
93
|
+
// here does not merely mislead a reader — it moves the bar the framework's own palettes are
|
|
94
|
+
// measured against, and the theme-list assertion above is what stops it from moving.
|
|
95
|
+
for (const theme of manifest.themes) {
|
|
96
|
+
expect(typeof theme.minimumContrast, `${theme.name} floor type`).toBe("number");
|
|
97
|
+
expect(theme.minimumContrast, `${theme.name} floor`).toBeGreaterThanOrEqual(4.5);
|
|
98
|
+
}
|
|
99
|
+
// And the mechanism must still be exercised by at least one theme, or "publishes a floor"
|
|
100
|
+
// decays into publishing the same constant five times.
|
|
101
|
+
expect(
|
|
102
|
+
manifest.themes.filter((theme) => theme.minimumContrast > 4.5).map((t) => t.name),
|
|
103
|
+
).not.toEqual([]);
|
|
104
|
+
// The non-text floor is one number for the whole file, not one per theme, because WCAG
|
|
105
|
+
// defines no AAA tier for non-text contrast. Published at the top level so the two pairing
|
|
106
|
+
// sections cannot be read as sharing a bar — and asserted as BELOW every text floor, which
|
|
107
|
+
// is the relationship a consumer would otherwise have to infer.
|
|
108
|
+
expect(manifest.nonTextMinimumContrast).toBe(3);
|
|
109
|
+
for (const theme of manifest.themes) {
|
|
110
|
+
expect(
|
|
111
|
+
manifest.nonTextMinimumContrast,
|
|
112
|
+
`${theme.name}: the non-text bar must not exceed the text bar`,
|
|
113
|
+
).toBeLessThan(theme.minimumContrast);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
70
117
|
it("records the value each token resolves to, in every theme", () => {
|
|
71
118
|
// `values` carries only the themes that declare the token; a theme absent from it inherits
|
|
72
119
|
// the base value. That is the cascade stated as data, so both halves are checked: a
|
|
@@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url";
|
|
|
3
3
|
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
|
|
6
|
+
import { APPEARANCE_MECHANISM_TOKENS } from "./appearance-mechanism.js";
|
|
6
7
|
import { parseRules } from "./css-rules.js";
|
|
7
8
|
|
|
8
9
|
// The token sheet's theme structure. `tokens.guard.test.ts` in react-core proves every
|
|
@@ -132,11 +133,31 @@ describe("token sheet themes", () => {
|
|
|
132
133
|
// Space, radius, font and shadow are theme-invariant by design: declared once and
|
|
133
134
|
// inherited. Re-declaring one in a single theme is how a theme quietly grows its
|
|
134
135
|
// own spacing scale.
|
|
136
|
+
//
|
|
137
|
+
// The appearance switch is the one non-colour that varies per theme, and it is subtracted
|
|
138
|
+
// by name rather than by a pattern — a prefix exemption would let a whole family through.
|
|
135
139
|
const theme = declarationsFor(selector);
|
|
136
|
-
const geometry = [...theme.keys()].filter(
|
|
140
|
+
const geometry = [...theme.keys()].filter(
|
|
141
|
+
(token) => !isColour(token) && !APPEARANCE_MECHANISM_TOKENS.includes(token),
|
|
142
|
+
);
|
|
137
143
|
expect(geometry).toEqual([]);
|
|
138
144
|
});
|
|
139
145
|
|
|
146
|
+
it.each(overlayCases)("declares the whole appearance switch in $selector", ({ selector }) => {
|
|
147
|
+
// The other direction, and the one that matters: half a switch is worse than none. A theme
|
|
148
|
+
// declaring `show-light` and forgetting `show-dark` inherits the base value for the second,
|
|
149
|
+
// so a dark theme would display BOTH marks — and the gate above would say nothing, because
|
|
150
|
+
// subtracting a token from a check is not the same as requiring it.
|
|
151
|
+
const theme = declarationsFor(selector);
|
|
152
|
+
const declared = APPEARANCE_MECHANISM_TOKENS.filter((token) => theme.has(token));
|
|
153
|
+
expect(declared).toEqual(APPEARANCE_MECHANISM_TOKENS);
|
|
154
|
+
// And exactly one of the two shows, or the switch is not a switch.
|
|
155
|
+
const shown = APPEARANCE_MECHANISM_TOKENS.filter(
|
|
156
|
+
(token) => theme.get(token) === "block",
|
|
157
|
+
);
|
|
158
|
+
expect(shown).toHaveLength(1);
|
|
159
|
+
});
|
|
160
|
+
|
|
140
161
|
it.each([{ name: BASE.name, appearance: BASE.appearance, selector: BASE_SELECTOR }, ...overlayCases])(
|
|
141
162
|
"opts native chrome into the $appearance palette in $selector",
|
|
142
163
|
({ appearance, selector }) => {
|