@terpjs/contract 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/contract",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "type": "module",
5
5
  "description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
6
6
  "exports": {
@@ -31,6 +31,23 @@ const buildDir = mkdtempSync(join(tmpdir(), "terp-tokens-"));
31
31
 
32
32
  const read = (name) => JSON.parse(readFileSync(join(packageRoot, name), "utf8"));
33
33
 
34
+ /**
35
+ * WCAG 2.1 AA for normal-size text — the floor every theme is held to unless `themes.json`
36
+ * raises it. Declared here because the manifest publishes an effective floor per theme and
37
+ * the contrast gate reads it back; the constant has one home, on the writing side.
38
+ */
39
+ const AA_NORMAL_TEXT = 4.5;
40
+
41
+ /**
42
+ * WCAG 2.1 SC 1.4.11, non-text contrast: the bar for a control's visual boundary and for a
43
+ * state or focus indicator. Published once at the top level rather than per theme, and that is
44
+ * the claim: it is FLAT across every theme, including the one that raises its text floor to
45
+ * AAA, because WCAG defines no AAA tier for non-text contrast. A consumer holding the
46
+ * `nonTextPairs` section to a theme's raised text floor would be enforcing a standard nobody
47
+ * wrote; one holding it to nothing would read the section as decorative.
48
+ */
49
+ const UI_COMPONENT = 3;
50
+
34
51
  const registry = read("themes.json");
35
52
  const themes = registry.themes;
36
53
  const base = themes.find((theme) => theme.name === registry.base);
@@ -94,6 +111,34 @@ for (const theme of themes) {
94
111
  }
95
112
  rmSync(buildDir, { recursive: true, force: true });
96
113
 
114
+ /**
115
+ * The appearance switch: the one theme fact CSS can consume but cannot select on.
116
+ *
117
+ * `color-scheme` already records whether a theme reads light or dark, and it is the right
118
+ * answer for native chrome — but there is no selector for it, so nothing in a stylesheet can
119
+ * branch on it. Anything that must render one way under a light theme and another under a dark
120
+ * one therefore needs the same fact in custom-property form. Its first consumer is `AppShell`'s
121
+ * brand mark: the bundled icons all stroke in `currentColor`, but a company logo usually cannot,
122
+ * and a dark-ink one is invisible on three of the five shipped themes.
123
+ *
124
+ * Derived rather than declared per theme, and that is the whole reason it lives here. A
125
+ * stylesheet enumerating which themes are dark is a list that rots the first time one is added,
126
+ * silently — the wrong mark, on the new theme only. `appearance` is required by `themes.json`,
127
+ * validated above, and named in the registry's own comment as one of the four things a theme
128
+ * file cannot imply. So a sixth theme cannot forget to answer.
129
+ *
130
+ * Values are `block` / `none` rather than any particular layout keyword: a consumer centres
131
+ * from the BOX around the thing it is showing, so a theme never has to know what layout that
132
+ * consumer uses.
133
+ *
134
+ * Deliberately NOT in the token manifest, and the two gates that had to be widened for it say
135
+ * why in their own files: it is a mechanism, not a design knob, and a theme editor offering
136
+ * `block` / `none` as an editable pair offers a way to break the switch.
137
+ */
138
+ const appearanceSwitch = (appearance) =>
139
+ ` --appearance-show-light: ${appearance === "light" ? "block" : "none"};
140
+ --appearance-show-dark: ${appearance === "dark" ? "block" : "none"};`;
141
+
97
142
  const themeBlocks = overlays
98
143
  .map(
99
144
  (theme) => `
@@ -101,6 +146,7 @@ const themeBlocks = overlays
101
146
  ${theme.description} */
102
147
  [data-theme='${theme.name}'] {
103
148
  color-scheme: ${theme.appearance};
149
+ ${appearanceSwitch(theme.appearance)}
104
150
  ${compiled.get(theme.name)}
105
151
  }
106
152
  `,
@@ -118,6 +164,9 @@ const output = `/**
118
164
  text-field carets) into the ${base.appearance} palette so it never renders as foreign
119
165
  OS-${base.appearance === "light" ? "dark" : "light"} chrome. Each theme block below sets its own. */
120
166
  color-scheme: ${base.appearance};
167
+ /* The appearance as something a stylesheet can branch on — see appearanceSwitch above.
168
+ color-scheme records the same fact and no selector can read it. */
169
+ ${appearanceSwitch(base.appearance)}
121
170
  ${compiled.get(base.name)}
122
171
  }
123
172
  ${themeBlocks}
@@ -125,6 +174,7 @@ ${themeBlocks}
125
174
  @media (prefers-color-scheme: dark) {
126
175
  :root:not([data-theme]) {
127
176
  color-scheme: ${systemDark.appearance};
177
+ ${appearanceSwitch(systemDark.appearance).replace(/^ {2}/gm, " ")}
128
178
  ${compiled.get(systemDark.name).replace(/^ {2}/gm, " ")}
129
179
  }
130
180
  }
@@ -183,11 +233,18 @@ const manifest = {
183
233
  // alone rather than hard-coding the list it happens to know about.
184
234
  base: registry.base,
185
235
  systemDark: registry.systemDark,
186
- themes: themes.map(({ name, label, appearance, description }) => ({
236
+ themes: themes.map(({ name, label, appearance, description, minimumContrast }) => ({
187
237
  name,
188
238
  label,
189
239
  appearance,
190
240
  description,
241
+ // The ratio this theme's declared text pairings must reach, published as a NUMBER on every
242
+ // theme rather than only on the one that raises it. A consumer that saw the field only on
243
+ // `contrast` would have to know WCAG's AA constant to interpret its absence, and would then
244
+ // be free to interpret it differently from the gate — which is the one thing this file
245
+ // exists to prevent. `tokens.contrast.test.js` reads this value instead of re-deriving it,
246
+ // so the published floor IS the enforced floor.
247
+ minimumContrast: minimumContrast ?? AA_NORMAL_TEXT,
191
248
  })),
192
249
  tokens: [...baseTokens.entries()].map(([name, token]) => ({
193
250
  name,
@@ -204,6 +261,9 @@ const manifest = {
204
261
  ),
205
262
  themeable: overlays.some((theme) => sources.get(theme.name).has(name)),
206
263
  })),
264
+ // The floor for the `nonTextPairs` section below — flat across themes, unlike the per-theme
265
+ // text floor above. Named at the top level so the two sections cannot be read as sharing a bar.
266
+ nonTextMinimumContrast: UI_COMPONENT,
207
267
  textPairs: pairs.textPairs,
208
268
  // Both sections, because a consumer that can only see the text pairings would read the
209
269
  // absence of a boundary pairing as "no requirement" rather than "held elsewhere".
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Per-theme custom properties that are NOT design tokens, as an exact list.
3
+ *
4
+ * Two gates are stated over "non-colour" tokens and mean it: geometry is theme-invariant
5
+ * (`tokens.themes.test.js`), and the manifest names every token the base root declares
6
+ * (`tokens.manifest.test.js`). This pair is neither — it is the theme's own `appearance` in
7
+ * the one form a stylesheet can branch on, because `color-scheme` records the same fact and no
8
+ * selector can read it. The values are `block` / `none`, so it varies per theme by
9
+ * construction and belongs in no theme editor.
10
+ *
11
+ * Hand-written rather than imported from the generator that emits it, and that is the point:
12
+ * a third such property appearing in `tokens.css` fails both gates until someone adds it here
13
+ * with a reason. Importing the generator's own list would make every future addition
14
+ * self-approving.
15
+ */
16
+ export const APPEARANCE_MECHANISM_TOKENS = [
17
+ "--appearance-show-light",
18
+ "--appearance-show-dark",
19
+ ];
package/src/icons.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The bundled icon names, as data the contract publishes.
3
+ *
4
+ * Contract cannot import react-core — the dependency runs the other way — so the glyphs
5
+ * themselves stay in react-core and only their NAMES live here. That split is what lets
6
+ * `NavItem.icon` be a checked name on a manifest that knows nothing about React: an app naming
7
+ * an icon this set does not contain gets a typecheck error where it used to get a silent letter
8
+ * tile, and a future non-React adapter reads the same list.
9
+ *
10
+ * `as const` is load-bearing rather than stylistic. Without it the array widens to `string[]`,
11
+ * {@link IconName} becomes `string`, and every check built on it still compiles while asserting
12
+ * nothing at all — the whole change would ship and do nothing. That is what the
13
+ * `@ts-expect-error` guard beside the glyph table in react-core exists to catch: remove the
14
+ * `as const` here and the directive there becomes unused, which is itself an error.
15
+ *
16
+ * Kept in the glyph table's declaration order rather than sorted, so the two read as one list.
17
+ * react-core holds the table to this set exhaustively in both directions with `satisfies`, so a
18
+ * glyph added without a name here, or a name added without a glyph, is a compile error at the
19
+ * table rather than a runtime blank.
20
+ */
21
+ export const ICON_NAMES = [
22
+ "home",
23
+ "list",
24
+ "folder",
25
+ "users",
26
+ "shield",
27
+ "settings",
28
+ "sun",
29
+ "moon",
30
+ "monitor",
31
+ "moon-stars",
32
+ "sunset",
33
+ "contrast",
34
+ "document",
35
+ "chart",
36
+ "calendar",
37
+ "inbox",
38
+ "audit",
39
+ "hub",
40
+ "plus",
41
+ "edit",
42
+ "trash",
43
+ "search",
44
+ "check",
45
+ "x",
46
+ "chevron-down",
47
+ "chevron-right",
48
+ "chevron-left",
49
+ "arrow-left",
50
+ "external",
51
+ "logout",
52
+ "user",
53
+ "bell",
54
+ "key",
55
+ "globe",
56
+ "lock",
57
+ "tag",
58
+ "mail",
59
+ "refresh",
60
+ "filter",
61
+ "download",
62
+ "upload",
63
+ "star",
64
+ "heart",
65
+ "database",
66
+ "code",
67
+ "truck",
68
+ "cart",
69
+ "wallet",
70
+ "map-pin",
71
+ "clock",
72
+ "link",
73
+ "grid",
74
+ "book",
75
+ "briefcase",
76
+ "building",
77
+ "clipboard",
78
+ "layers",
79
+ "send",
80
+ "phone",
81
+ "image",
82
+ "video",
83
+ "music",
84
+ "wrench",
85
+ "zap",
86
+ "eye",
87
+ "eye-off",
88
+ ] as const;
89
+
90
+ /**
91
+ * A name {@link ICON_NAMES} contains — the type `NavItem.icon` and `Icon` accept.
92
+ *
93
+ * Deliberately NOT the type of `NavIcon.name`, which stays `string`. `NavIcon` falls back to the
94
+ * label's initial in a tile, so an unknown name there is a designed, visible behaviour with a
95
+ * specimen of its own; `Icon` renders nothing at all, which is silence, and silence is the thing
96
+ * this type exists to make impossible.
97
+ */
98
+ export type IconName = (typeof ICON_NAMES)[number];
package/src/index.ts CHANGED
@@ -5,5 +5,7 @@ export type { components, operations, paths } from "./schema";
5
5
 
6
6
  // Stack-agnostic UI contract: the module/route/nav manifest and the auth/session shape.
7
7
  export { defineModuleManifest } from "./manifest";
8
- export type { ModuleManifest, ModuleRoute, NavItem, RoleName } from "./manifest";
8
+ export { ICON_NAMES } from "./icons";
9
+ export type { IconName } from "./icons";
10
+ export type { ModuleManifest, ModuleRoute, NavGroup, NavItem, RoleName } from "./manifest";
9
11
  export type { AccessToken, Action, AuthSession, Credentials, CurrentUser } from "./auth";
package/src/manifest.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  /** A role name as understood by the app's backend (e.g. "viewer" | "editor" | "admin"). */
13
+ import type { IconName } from "./icons";
14
+
13
15
  export type RoleName = string;
14
16
 
15
17
  export interface ModuleRoute {
@@ -26,6 +28,25 @@ export interface ModuleRoute {
26
28
  view: string;
27
29
  /** Minimum role required to see the route; omitted = any authenticated user. */
28
30
  role?: RoleName;
31
+ /**
32
+ * Also require this named permission grant — the caller must hold it in
33
+ * `CurrentUser.permissions`.
34
+ *
35
+ * **ANDed with `role`**, and that is deliberately what the server does: a `Policy` carrying a
36
+ * `Permission` enforces the permission's role floor *and* the grant, so a client checking
37
+ * only one would disagree with the endpoint in one direction or the other. The same reasoning
38
+ * the `Authorized` component's `permission` prop already records.
39
+ *
40
+ * Deliberately not a combinator. The server's own declaration is one ref per read and one per
41
+ * write (`AuthzRef = Role | Permission | Roles`), so an any-of here could express a gate no
42
+ * `Policy` can declare — and a client gate that cannot correspond to a server gate can only
43
+ * drift from the endpoint it mirrors.
44
+ *
45
+ * A *display* and *routing* gate only; the server re-checks every request. Fails closed:
46
+ * unknown or misspelled names are simply absent from the grant list, and an app that mounts
47
+ * no grant capability has an empty list, which correctly hides everything that names one.
48
+ */
49
+ permission?: string;
29
50
  /**
30
51
  * Query-string keys this route reads, e.g. `["status", "page"]`.
31
52
  *
@@ -47,10 +68,101 @@ export interface NavItem {
47
68
  label: string;
48
69
  /** Destination path; should match a {@link ModuleRoute.path}. */
49
70
  to: string;
50
- /** Icon identifier the stack maps to its own icon set. */
51
- icon?: string;
71
+ /**
72
+ * Which bundled glyph the item shows, by name.
73
+ *
74
+ * A **checked** name ({@link IconName}), which is the one deliberately breaking part of the
75
+ * navigation model: a misspelled icon used to render the label's first letter in a tile, which
76
+ * looks like a considered fallback rather than like a typo, so it survived review and shipped.
77
+ * It is now a typecheck error at the manifest. Runtime behaviour is unchanged — the fallback
78
+ * still exists and still handles the honest case of an item with no icon at all.
79
+ */
80
+ icon?: IconName;
52
81
  /** Minimum role required to show the nav item. */
53
82
  role?: RoleName;
83
+ /**
84
+ * Also require this named permission grant — the caller must hold it in
85
+ * `CurrentUser.permissions`.
86
+ *
87
+ * **ANDed with `role`**, and that is deliberately what the server does: a `Policy` carrying a
88
+ * `Permission` enforces the permission's role floor *and* the grant, so a client checking
89
+ * only one would disagree with the endpoint in one direction or the other. The same reasoning
90
+ * the `Authorized` component's `permission` prop already records.
91
+ *
92
+ * Deliberately not a combinator. The server's own declaration is one ref per read and one per
93
+ * write (`AuthzRef = Role | Permission | Roles`), so an any-of here could express a gate no
94
+ * `Policy` can declare — and a client gate that cannot correspond to a server gate can only
95
+ * drift from the endpoint it mirrors.
96
+ *
97
+ * A *display* and *routing* gate only; the server re-checks every request. Fails closed:
98
+ * unknown or misspelled names are simply absent from the grant list, and an app that mounts
99
+ * no grant capability has an empty list, which correctly hides everything that names one.
100
+ */
101
+ permission?: string;
102
+ /**
103
+ * Match the URL exactly rather than as a segment-aligned prefix.
104
+ *
105
+ * The default is the prefix, and that is the useful behaviour: a detail page under a section
106
+ * keeps the section's tab lit, so `/records/123` leaves "Records" current. Set this where a
107
+ * destination should own only itself — typically a landing page that also has children in the
108
+ * nav, where the parent would otherwise stay lit on every child.
109
+ *
110
+ * It does not decide WHICH item is current when several match; that is a property of the set,
111
+ * and the adapter resolves it by longest match. This only says whether this item is a
112
+ * candidate at all.
113
+ */
114
+ exact?: boolean;
115
+ /**
116
+ * The {@link NavGroup} this item belongs to, by id.
117
+ *
118
+ * An item naming a group the app has not declared falls into the default headerless group
119
+ * rather than disappearing, and that is the deliberate direction to fail. A group is declared
120
+ * once by the **app**; the item is declared by a **module** that ships on its own schedule, so
121
+ * an id with no declaration yet is the normal first-run state of a module the app has not
122
+ * finished adopting. Silently dropping the link would hide a working screen and report nothing.
123
+ */
124
+ group?: string;
125
+ /**
126
+ * Sort key against the item's siblings inside its group.
127
+ *
128
+ * Absent is 0, so a positive number sorts below every unordered sibling and a negative one
129
+ * above — CSS `order` semantics, which is the vocabulary this framework already speaks.
130
+ * The sort is stable, so items that tie keep their declaration order and a manifest that
131
+ * declares no order anywhere renders exactly as it does today.
132
+ */
133
+ order?: number;
134
+ }
135
+
136
+ /**
137
+ * A named section of the primary navigation, declared once by the **app**.
138
+ *
139
+ * A group spans modules — a "Sales" group holds items contributed by several of them — so no
140
+ * module can own its label or its position, and it is the one part of the navigation model that
141
+ * cannot live on a module manifest. Items reference it by {@link NavItem.group}.
142
+ *
143
+ * Declaring groups is optional and additive: an app that declares none renders one flat,
144
+ * unlabelled list, which is what every app renders today.
145
+ */
146
+ export interface NavGroup {
147
+ /** Referenced by {@link NavItem.group}. */
148
+ id: string;
149
+ /**
150
+ * Rendered above the group's list.
151
+ *
152
+ * `null` renders **no label element at all** — a positioning-only group, which is how an app
153
+ * places its otherwise-ungrouped items somewhere other than the end without inventing a
154
+ * heading for them. Required rather than optional so that "no label" is a decision the
155
+ * declaration states, not an omission.
156
+ */
157
+ label: string | null;
158
+ /**
159
+ * Sort key against sibling groups.
160
+ *
161
+ * Absent is 0 and the sort is stable, so groups that tie keep declaration order. The default
162
+ * headerless group is **not** part of this sort: it is always emitted last. See
163
+ * `groupNav` in `@terpjs/react-core` for why.
164
+ */
165
+ order?: number;
54
166
  }
55
167
 
56
168
  export interface ModuleManifest {
@@ -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",
@@ -1353,6 +1431,13 @@
1353
1431
  "fg": "--color-fg-muted",
1354
1432
  "bg": "--color-status-danger-soft",
1355
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"
1356
1441
  }
1357
1442
  ],
1358
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 }) => {
package/token-pairs.json CHANGED
@@ -1,5 +1,5 @@
1
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. A pairing earns its place here even when no specimen paints it, and two of them are exactly that: axe measures the pixels a lane renders, so a surface that only appears on a state no specimen can reach - a failed sign-in, a failed create - has no lane coverage at all, and the declared pairing is the only gate it will ever have.",
2
+ "$comment": "The sidebar's edge (--color-sidebar-border against the canvas) is deliberately NOT declared: WCAG 1.4.11 covers UI components and graphical objects needed to understand content, and a decorative separator beside a sidebar that already differs in background is neither. Declaring it would fail at ~1.2:1 in four themes and the only ways out would be darkening a decorative line everywhere or booking an allowance the ratchet may not grow. 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. A pairing earns its place here even when no specimen paints it, and two of them are exactly that: axe measures the pixels a lane renders, so a surface that only appears on a state no specimen can reach - a failed sign-in, a failed create - has no lane coverage at all, and the declared pairing is the only gate it will ever have.",
3
3
  "textPairs": [
4
4
  {
5
5
  "id": "body-on-card",
@@ -189,6 +189,13 @@
189
189
  "fg": "--color-fg-muted",
190
190
  "bg": "--color-status-danger-soft",
191
191
  "layer": "semantic"
192
+ },
193
+ {
194
+ "id": "sidebar-nav-link-hover",
195
+ "label": "a hovered sidebar navigation link",
196
+ "fg": "--color-sidebar-fg",
197
+ "bg": "--color-sidebar-accent",
198
+ "layer": "semantic"
192
199
  }
193
200
  ],
194
201
  "nonTextPairs": [
package/tokens.json CHANGED
@@ -103,6 +103,15 @@
103
103
  "padY": { "value": "0.5rem" },
104
104
  "padX": { "value": "0.5rem" }
105
105
  }
106
+ },
107
+ "comfortable": {
108
+ "control": {
109
+ "minHeight": { "value": "2.25rem" }
110
+ },
111
+ "cell": {
112
+ "padY": { "value": "0.75rem" },
113
+ "padX": { "value": "0.75rem" }
114
+ }
106
115
  }
107
116
  },
108
117
  "radius": {
@@ -121,11 +130,21 @@
121
130
  "base": { "value": "0" },
122
131
  "sticky": { "value": "30" },
123
132
  "backdrop": { "value": "40" },
133
+ "skipLink": { "value": "45" },
124
134
  "drawer": { "value": "50" },
125
135
  "popover": { "value": "60" },
126
136
  "tooltip": { "value": "70" },
127
137
  "toast": { "value": "100" }
128
138
  },
139
+ "shell": {
140
+ "sidebarWidth": {
141
+ "expanded": { "value": "15rem" },
142
+ "collapsed": { "value": "4rem" }
143
+ },
144
+ "headerHeight": { "value": "3rem" },
145
+ "contentMaxWidth": { "value": "80rem" },
146
+ "brandSize": { "value": "1.75rem" }
147
+ },
129
148
  "breakpoint": {
130
149
  "sm": { "value": "480px" },
131
150
  "md": { "value": "768px" },