@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.
@@ -8,7 +8,7 @@ import { afterEach, describe, expect, it } from "vitest";
8
8
 
9
9
  import {
10
10
  canonicalPath,
11
- extractRoutePaths,
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 { paths, problems } = extractRoutePaths(
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(paths).toEqual(["/records", "/records/:recordId"]);
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 { paths, problems } = extractRoutePaths(
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(paths).toEqual(["/admin"]);
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 { paths, problems } = extractRoutePaths(
93
+ const { routes, problems } = extractRoutes(
94
94
  manifest("{ path: `/records/${base}`, view: \"List\" }"),
95
95
  "src/modules/records/module.tsx",
96
96
  );
97
- expect(paths).toEqual([]);
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 = extractRoutePaths(manifest("...shared"), "module.tsx");
104
+ const spread = extractRoutes(manifest("...shared"), "module.tsx");
105
105
  expect(spread.problems[0]).toContain("not an object literal");
106
106
 
107
- const dynamic = extractRoutePaths(
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 } = extractRoutePaths("export const views = {};", "module.tsx");
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 } = extractRoutePaths(manifest('{ view: "List" }'), "module.tsx");
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
@@ -382,6 +382,11 @@ export interface components {
382
382
  * Format: uuid
383
383
  */
384
384
  id: string;
385
+ /**
386
+ * Permissions
387
+ * @default []
388
+ */
389
+ permissions: string[];
385
390
  /** Role Name */
386
391
  role_name: string;
387
392
  /** Role Rank */
@@ -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 = 3;
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. AA for normal text by default; a theme may declare
190
- * a higher floor in `themes.json`, which is how the high-contrast theme's promise is a gate
191
- * rather than a sentence in its description.
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
- registry.themes.find((theme) => theme.name === name)?.minimumContrast ?? AA_NORMAL_TEXT;
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;
@@ -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
- expect(manifestNames).toEqual([...base.keys()].sort());
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((token) => !isColour(token));
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 }) => {