@tristan2828/ui-foundation 4.63.0 → 4.63.2

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.
@@ -3,28 +3,28 @@
3
3
  <!-- index: written by `npm run docs:index` in the package from the headings below; never edit it by hand -->
4
4
  ## Index
5
5
 
6
- - [Palettes](#palettes) · lines 37–95
7
- - [Glow](#glow) · lines 97–115
8
- - [Semantic tones](#semantic-tones) · lines 117–145
9
- - [The three badge styles](#the-three-badge-styles) · lines 147–164
10
- - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 166–195
11
- - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 197–210
12
- - [Logos and brand marks](#logos-and-brand-marks) · lines 212–256
13
- - [Categorical colour](#categorical-colour) · lines 258–328
14
- - [Shapes and sizes](#shapes-and-sizes) · lines 330–351
15
- - [Typography](#typography) · lines 353–447
16
- - [Written text (notes)](#written-text-notes) · lines 387–447
17
- - [Density](#density) · lines 449–506
18
- - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 508–622
19
- - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 624–693
20
- - [Notices](#notices) · lines 695–723
21
- - [A value over time](#a-value-over-time) · lines 725–803
22
- - [Several values over time](#several-values-over-time) · lines 766–803
23
- - [A list as a board](#a-list-as-a-board) · lines 805–856
24
- - [A list as cards](#a-list-as-cards) · lines 858–904
25
- - [Key numbers on a record](#key-numbers-on-a-record) · lines 906–928
26
- - [Picking at random](#picking-at-random) · lines 930–961
27
- - [Filling in what's missing](#filling-in-whats-missing) · lines 963–1030
6
+ - [Palettes](#palettes) · lines 37–103
7
+ - [Glow](#glow) · lines 105–123
8
+ - [Semantic tones](#semantic-tones) · lines 125–153
9
+ - [The three badge styles](#the-three-badge-styles) · lines 155–172
10
+ - [Mapping an enum to tones](#mapping-an-enum-to-tones) · lines 174–203
11
+ - [Glyphs and icons: 3:1](#glyphs-and-icons-31) · lines 205–218
12
+ - [Logos and brand marks](#logos-and-brand-marks) · lines 220–264
13
+ - [Categorical colour](#categorical-colour) · lines 266–336
14
+ - [Shapes and sizes](#shapes-and-sizes) · lines 338–359
15
+ - [Typography](#typography) · lines 361–455
16
+ - [Written text (notes)](#written-text-notes) · lines 395–455
17
+ - [Density](#density) · lines 457–514
18
+ - [Widget cards (hub pages)](#widget-cards-hub-pages) · lines 516–630
19
+ - [Module cards (a hub's places)](#module-cards-a-hubs-places) · lines 632–701
20
+ - [Notices](#notices) · lines 703–731
21
+ - [A value over time](#a-value-over-time) · lines 733–811
22
+ - [Several values over time](#several-values-over-time) · lines 774–811
23
+ - [A list as a board](#a-list-as-a-board) · lines 813–864
24
+ - [A list as cards](#a-list-as-cards) · lines 866–912
25
+ - [Key numbers on a record](#key-numbers-on-a-record) · lines 914–936
26
+ - [Picking at random](#picking-at-random) · lines 938–969
27
+ - [Filling in what's missing](#filling-in-whats-missing) · lines 971–1038
28
28
 
29
29
  <!-- /index -->
30
30
 
@@ -94,6 +94,14 @@ The package's `e2e/fixtures-visual.spec.ts` runs every primitive,
94
94
  composite and tone story through axe in each palette, and measures every
95
95
  glyph and categorical slot against each palette's page and card.
96
96
 
97
+ **An app's own axe specs** check its palettes instead of light and dark:
98
+ `defineA11ySuite({ …, palettes: ['indigo-cyan'] })` (and
99
+ `defineDataEnvironmentBannerSuite`'s `palettes`) saves each as the
100
+ device's choice before the page loads and checks it dark. Pass the app's
101
+ default on every spec and `PALETTES` on the one that covers the shell.
102
+ Without `palettes`, a palette app's light run fails: there is no light
103
+ theme to find.
104
+
97
105
  ## Glow
98
106
 
99
107
  Under a palette only, from `--primary` so each palette glows in its own
@@ -188,5 +188,5 @@ export function EntityView({ title, documentTitle, titleEdit, subtitle, badges,
188
188
  const railSections = sections.filter((section) => section.placement === 'rail');
189
189
  const mainSections = sections.filter((section) => section.placement !== 'rail');
190
190
  const main = (_jsx("div", { className: cn(RAIL_LAYOUT.main, railSections.length === 0 && RAIL_LAYOUT.mainAlone), children: mainSections.map((section) => (_jsx(ViewSection, { section: section }, section.title))) }));
191
- return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), !dialog && _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2", children: [_jsx("h1", { id: dialog?.titleId, className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), (actions || printButton) && (_jsxs("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: [printButton, actions] }))] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
191
+ return (_jsx(EditInPlaceContext.Provider, { value: store, children: _jsxs("div", { className: layout === 'rail' ? RAIL_LAYOUT.page : 'flex max-w-4xl flex-col gap-6', "data-state": "success", "data-layout": layout, children: [editing && _jsx(LeaveGuard, { store: store }), !dialog && _jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-1 flex-col gap-2 sm:min-w-64", children: [_jsx("h1", { id: dialog?.titleId, className: "type-page-title break-words text-foreground", children: titleEdit ? (_jsx(EditableValue, { label: titleEdit.label, edit: titleEdit, layout: "inline", className: "w-full", children: title })) : (title) }), subtitle && (_jsx("div", { "data-slot": "entity-view-subtitle", className: "type-body break-words text-muted-foreground", children: subtitle })), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), (actions || printButton) && (_jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2 sm:justify-end", children: [printButton, actions] }))] }), _jsx("div", { role: "status", "data-slot": "entity-view-notice", className: "empty:hidden", children: notice }), strip && _jsx("div", { "data-slot": "entity-view-strip", children: strip }), layout === 'rail' ? (_jsxs("div", { className: RAIL_LAYOUT.columns, children: [mobileOrder === 'main-first' && main, railSections.length > 0 && (_jsx(Rail, { label: railLabel ?? `${title ?? 'Record'} details`, sections: railSections })), mobileOrder === 'rail-first' && main] })) : (sections.map((section) => _jsx(ViewSection, { section: section }, section.title))), footer && (_jsx("footer", { "data-slot": "entity-view-footer", className: "type-caption text-muted-foreground", children: footer }))] }) }));
192
192
  }
@@ -3,6 +3,6 @@ import * as React from "react";
3
3
  import { Input as InputPrimitive } from "@base-ui/react/input";
4
4
  import { cn } from "cn";
5
5
  function Input({ className, type, ...props }) {
6
- return (_jsx(InputPrimitive, { type: type, "data-slot": "input", className: cn("h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
6
+ return (_jsx(InputPrimitive, { type: type, "data-slot": "input", className: cn("h-9 w-full min-w-0 rounded-md border border-input bg-transparent palette:text-foreground px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
7
7
  }
8
8
  export { Input };
@@ -12,7 +12,7 @@ function SelectValue({ className, ...props }) {
12
12
  return (_jsx(SelectPrimitive.Value, { "data-slot": "select-value", className: cn("flex flex-1 text-left", className), ...props }));
13
13
  }
14
14
  function SelectTrigger({ className, size = "default", children, ...props }) {
15
- return (_jsxs(SelectPrimitive.Trigger, { "data-slot": "select-trigger", "data-size": size, className: cn("flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [children, _jsx(SelectPrimitive.Icon, { render: _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" }) })] }));
15
+ return (_jsxs(SelectPrimitive.Trigger, { "data-slot": "select-trigger", "data-size": size, className: cn("flex w-fit items-center justify-between gap-1.5 rounded-md border border-input bg-transparent palette:text-foreground py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-9 data-[size=sm]:h-8 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className), ...props, children: [children, _jsx(SelectPrimitive.Icon, { render: _jsx(ChevronDownIcon, { className: "pointer-events-none size-4 text-muted-foreground" }) })] }));
16
16
  }
17
17
  function SelectContent({ className, children, side = "bottom", sideOffset = 4, align = "center", alignOffset = 0, alignItemWithTrigger = true, ...props }) {
18
18
  return (_jsx(SelectPrimitive.Portal, { children: _jsx(SelectPrimitive.Positioner, { side: side, sideOffset: sideOffset, align: align, alignOffset: alignOffset, alignItemWithTrigger: alignItemWithTrigger, className: "isolate z-50", children: _jsxs(SelectPrimitive.Popup, { "data-slot": "select-content", "data-align-trigger": alignItemWithTrigger, className: cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className), ...props, children: [_jsx(SelectScrollUpButton, {}), _jsx(SelectPrimitive.List, { children: children }), _jsx(SelectScrollDownButton, {})] }) }) }));
@@ -2,6 +2,6 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
3
  import { cn } from "cn";
4
4
  function Textarea({ className, ...props }) {
5
- return (_jsx("textarea", { "data-slot": "textarea", className: cn("flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
5
+ return (_jsx("textarea", { "data-slot": "textarea", className: cn("flex field-sizing-content min-h-16 w-full rounded-md border border-input bg-transparent palette:text-foreground px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40", className), ...props }));
6
6
  }
7
7
  export { Textarea };
@@ -1,5 +1,6 @@
1
1
  import { expect, type Page } from '@playwright/test';
2
2
  import { type NavEntry } from '../components/app/app-shell-nav.js';
3
+ import { type PaletteName } from '../lib/palettes.js';
3
4
  import type { MswOverride } from '../mocks/override.js';
4
5
  export type { MswHandle, MswOverride } from '../mocks/override.js';
5
6
  export { expect };
@@ -81,6 +82,13 @@ export type A11ySuiteOptions = {
81
82
  * the check in one file that passes the whole nav.
82
83
  */
83
84
  checkSidebar?: boolean;
85
+ /**
86
+ * For an app with a palette (`<FoundationProviders palette>`), which is
87
+ * dark only: the palettes to check instead of light and dark, each saved
88
+ * as the device's choice before the page loads. Pass the app's default on
89
+ * every spec and all of `PALETTES` on one (the shell's).
90
+ */
91
+ palettes?: readonly PaletteName[];
84
92
  };
85
93
  /**
86
94
  * Every data view on the page has loaded: at least one shows its data (or,
@@ -103,9 +111,10 @@ export declare function expectDataViewsLoaded(page: Page, { success }?: {
103
111
  * one file keeps the whole-sidebar check. axe's
104
112
  * color-contrast rule against the rendered dark DOM is also what keeps the
105
113
  * dark-mode tokens readable. The theme follows the OS setting, so
106
- * emulateMedia switches it.
114
+ * emulateMedia switches it. An app with a palette passes `palettes`, and
115
+ * each is checked (dark) in place of light and dark.
107
116
  */
108
- export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav, checkSidebar, }: A11ySuiteOptions): void;
117
+ export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav, checkSidebar, palettes, }: A11ySuiteOptions): void;
109
118
  export type MockModeBannerSuiteOptions = {
110
119
  /** Screens to check, logged-out ones included (e.g. '/login'). */
111
120
  routes: readonly string[];
@@ -137,6 +146,8 @@ export type DataEnvironmentBannerSuiteOptions = {
137
146
  * (production data, so no banner).
138
147
  */
139
148
  dataLabel?: string | null;
149
+ /** For an app with a palette: the palettes to check the banner in, in place of light and dark (as for `defineA11ySuite`). */
150
+ palettes?: readonly PaletteName[];
140
151
  };
141
152
  /**
142
153
  * DataEnvironmentBanner: shown on every route, logged-out ones included,
@@ -151,4 +162,4 @@ export type DataEnvironmentBannerSuiteOptions = {
151
162
  * route, or is absent when it's null, which is how a production deploy
152
163
  * runs.
153
164
  */
154
- export declare function defineDataEnvironmentBannerSuite({ routes, mockMode, dataLabel, }: DataEnvironmentBannerSuiteOptions): void;
165
+ export declare function defineDataEnvironmentBannerSuite({ routes, mockMode, dataLabel, palettes, }: DataEnvironmentBannerSuiteOptions): void;
@@ -14,6 +14,7 @@
14
14
  import AxeBuilder from '@axe-core/playwright';
15
15
  import { expect, test as base } from '@playwright/test';
16
16
  import { navPathname, navTargets } from '../components/app/app-shell-nav.js';
17
+ import { PALETTE_STORAGE_KEY } from '../lib/palettes.js';
17
18
  export { expect };
18
19
  // A page load returns at the load event, but the app isn't up yet: React
19
20
  // renders only after MSW's worker.start(), which took up to 2.4 s past
@@ -174,6 +175,34 @@ async function navRoutes(page, isMobile) {
174
175
  // The pages a list of routes names, by path: a `to` that opens a list on a
175
176
  // saved view ('/widgets?status=active') is the /widgets page.
176
177
  const pagePaths = (routes) => [...new Set(routes.map(navPathname))].sort();
178
+ function a11yThemes(palettes) {
179
+ if (palettes?.length)
180
+ return palettes.map((palette) => ({ label: palette, colorScheme: 'dark', palette }));
181
+ return [
182
+ { label: 'light', colorScheme: 'light' },
183
+ { label: 'dark', colorScheme: 'dark' },
184
+ ];
185
+ }
186
+ /** Puts the page in a theme: the OS colour scheme, and the palette saved as this device's choice before any script runs. */
187
+ async function applyTheme(page, theme) {
188
+ await page.emulateMedia({ colorScheme: theme.colorScheme });
189
+ if (theme.palette) {
190
+ await page.addInitScript(([key, value]) => {
191
+ try {
192
+ window.localStorage.setItem(key, value);
193
+ }
194
+ catch {
195
+ // Storage blocked: the app falls back to its default palette.
196
+ }
197
+ }, [PALETTE_STORAGE_KEY, theme.palette]);
198
+ }
199
+ }
200
+ /** The theme is on the page: its class on <html>, and its palette. */
201
+ async function expectTheme(page, theme) {
202
+ await expect(page.locator('html')).toHaveClass(theme.colorScheme);
203
+ if (theme.palette)
204
+ await expect(page.locator('html')).toHaveAttribute('data-palette', theme.palette);
205
+ }
177
206
  // A page's share of the one discovering test's time: load, data, axe.
178
207
  const NAV_PAGE_BUDGET_MS = 10_000;
179
208
  /**
@@ -194,9 +223,9 @@ export async function expectDataViewsLoaded(page, { success = true } = {}) {
194
223
  await expect(page.getByRole('main').first()).toBeVisible();
195
224
  await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
196
225
  }
197
- async function expectNavPageAccessible(page, route, colorScheme) {
226
+ async function expectNavPageAccessible(page, route, theme) {
198
227
  await page.goto(route);
199
- await expect(page.locator('html')).toHaveClass(colorScheme);
228
+ await expectTheme(page, theme);
200
229
  // Wait for data, not the loading skeleton, so the real UI is checked. A
201
230
  // nav page may have no data view, or only an empty one, so none has to
202
231
  // show data.
@@ -212,9 +241,10 @@ async function expectNavPageAccessible(page, route, colorScheme) {
212
241
  * one file keeps the whole-sidebar check. axe's
213
242
  * color-contrast rule against the rendered dark DOM is also what keeps the
214
243
  * dark-mode tokens readable. The theme follows the OS setting, so
215
- * emulateMedia switches it.
244
+ * emulateMedia switches it. An app with a palette passes `palettes`, and
245
+ * each is checked (dark) in place of light and dark.
216
246
  */
217
- export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes = [], nav, checkSidebar = true, }) {
247
+ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes = [], nav, checkSidebar = true, palettes, }) {
218
248
  // Each `to` as given, query string and all, so a page that opens on a
219
249
  // saved view is checked on it.
220
250
  const listed = nav && navTargets(nav);
@@ -224,15 +254,15 @@ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes =
224
254
  expect(pagePaths(rendered), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual(pagePaths(listed));
225
255
  });
226
256
  }
227
- for (const colorScheme of ['light', 'dark']) {
228
- test.describe(`accessibility (${colorScheme})`, () => {
257
+ for (const theme of a11yThemes(palettes)) {
258
+ test.describe(`accessibility (${theme.label})`, () => {
229
259
  test.beforeEach(async ({ page }) => {
230
- await page.emulateMedia({ colorScheme });
260
+ await applyTheme(page, theme);
231
261
  });
232
262
  if (listed) {
233
263
  for (const route of listed) {
234
264
  test(`nav page ${route} has zero axe violations`, async ({ page }) => {
235
- await expectNavPageAccessible(page, route, colorScheme);
265
+ await expectNavPageAccessible(page, route, theme);
236
266
  });
237
267
  }
238
268
  }
@@ -242,7 +272,7 @@ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes =
242
272
  // One test for every page: its time grows with the app.
243
273
  test.setTimeout(testInfo.timeout + routes.length * NAV_PAGE_BUDGET_MS);
244
274
  for (const route of routes) {
245
- await test.step(route, () => expectNavPageAccessible(page, route, colorScheme));
275
+ await test.step(route, () => expectNavPageAccessible(page, route, theme));
246
276
  }
247
277
  });
248
278
  }
@@ -326,7 +356,7 @@ async function gotoWithDataLabel(page, route) {
326
356
  * route, or is absent when it's null, which is how a production deploy
327
357
  * runs.
328
358
  */
329
- export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', dataLabel = process.env.DATA_LABEL?.trim() || null, }) {
359
+ export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', dataLabel = process.env.DATA_LABEL?.trim() || null, palettes, }) {
330
360
  const banner = (page) => page.getByRole('status').filter({ hasText: "changes here don't reach production" });
331
361
  const expectAbsentEverywhere = async (page) => {
332
362
  for (const route of routes) {
@@ -365,13 +395,13 @@ export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.en
365
395
  const fill = (locator) => locator.evaluate((element) => getComputedStyle(element).backgroundColor);
366
396
  expect(await fill(banner(page))).not.toBe(await fill(page.getByRole('status').filter({ hasText: 'Mock data' })));
367
397
  });
368
- for (const colorScheme of ['light', 'dark']) {
369
- test(`the data-environment banner has zero axe violations (${colorScheme})`, async ({ page }) => {
370
- await page.emulateMedia({ colorScheme });
398
+ for (const theme of a11yThemes(palettes)) {
399
+ test(`the data-environment banner has zero axe violations (${theme.label})`, async ({ page }) => {
400
+ await applyTheme(page, theme);
371
401
  await forceLabel(page);
372
402
  for (const route of routes) {
373
403
  await gotoWithDataLabel(page, route);
374
- await expect(page.locator('html')).toHaveClass(colorScheme);
404
+ await expectTheme(page, theme);
375
405
  await expect(banner(page)).toBeVisible();
376
406
  await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
377
407
  await test.step(route, () => expectNoAxeViolations(page));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "4.63.0",
3
+ "version": "4.63.2",
4
4
  "description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
5
5
  "license": "MIT",
6
6
  "repository": {