@tristan2828/ui-foundation 4.13.0 → 4.15.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/conventions/docs/add-an-entity.md +3 -2
- package/dist/components/app/app-shell-nav.d.ts +12 -0
- package/dist/components/app/app-shell-nav.js +5 -1
- package/dist/components/app/app-shell.d.ts +4 -1
- package/dist/components/app/app-shell.js +62 -9
- package/dist/testing/index.d.ts +14 -3
- package/dist/testing/index.js +43 -12
- package/package.json +1 -1
|
@@ -515,8 +515,9 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
515
515
|
`NAV_ENTRIES` so the shell smoke test covers the new screen, add
|
|
516
516
|
`/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
|
|
517
517
|
of a full record and of the sparse one to its `viewRoutes` (the table
|
|
518
|
-
page
|
|
519
|
-
|
|
518
|
+
page comes from `src/nav.ts`, which the suite is given as `nav`: each
|
|
519
|
+
nav page is a test of its own, so the suite's time per test stays flat
|
|
520
|
+
as the app grows; the form and the view aren't in the nav). With fields edited in place, copy `e2e/a11y.spec.ts`'s
|
|
520
521
|
"editing in place" block for them: a field open, saving and refused,
|
|
521
522
|
the rich-text editor, an open list, a list edited in place (an item
|
|
522
523
|
open, one being added, a refused change), and the leave prompt, in
|
|
@@ -11,6 +11,15 @@ export type NavItem = {
|
|
|
11
11
|
export type NavSection = {
|
|
12
12
|
label: string;
|
|
13
13
|
items: readonly NavItem[];
|
|
14
|
+
/**
|
|
15
|
+
* The label folds the section away: a button with a chevron. Whether
|
|
16
|
+
* it's open is remembered per browser, by the section's label. The
|
|
17
|
+
* section holding the page you're on is always open, and the icon rail
|
|
18
|
+
* shows every section's items whatever their state.
|
|
19
|
+
*/
|
|
20
|
+
collapsible?: boolean;
|
|
21
|
+
/** Whether a collapsible section starts open for someone who has never folded it. Defaults to true. */
|
|
22
|
+
defaultOpen?: boolean;
|
|
14
23
|
};
|
|
15
24
|
/** One entry of `AppShell`'s `nav`: a page, or a labelled section of pages. */
|
|
16
25
|
export type NavEntry = NavItem | NavSection;
|
|
@@ -19,6 +28,9 @@ export declare function isNavSection(entry: NavEntry): entry is NavSection;
|
|
|
19
28
|
export type NavGroup = {
|
|
20
29
|
label?: string;
|
|
21
30
|
items: readonly NavItem[];
|
|
31
|
+
/** From its section: the label folds it (NavSection's `collapsible`). */
|
|
32
|
+
collapsible?: boolean;
|
|
33
|
+
defaultOpen?: boolean;
|
|
22
34
|
};
|
|
23
35
|
/**
|
|
24
36
|
* The sidebar groups for a nav, in order. A flat nav (no sections) is the
|
|
@@ -16,7 +16,11 @@ export function navGroups(nav) {
|
|
|
16
16
|
if (run.length)
|
|
17
17
|
groups.push({ items: run });
|
|
18
18
|
run = [];
|
|
19
|
-
groups.push({
|
|
19
|
+
groups.push({
|
|
20
|
+
label: entry.label,
|
|
21
|
+
items: entry.items,
|
|
22
|
+
...(entry.collapsible && { collapsible: true, defaultOpen: entry.defaultOpen ?? true }),
|
|
23
|
+
});
|
|
20
24
|
}
|
|
21
25
|
else {
|
|
22
26
|
run.push(entry);
|
|
@@ -11,7 +11,10 @@ export type AppShellProps = {
|
|
|
11
11
|
* pages (`{ label, items }`, one per module, say). A nav with no sections
|
|
12
12
|
* is the single "Navigation" group. One entry is marked active: the most
|
|
13
13
|
* specific whose route matches, so '/tasks' and '/tasks/categories' can
|
|
14
|
-
* both be listed.
|
|
14
|
+
* both be listed. A section marked `collapsible` folds under its label,
|
|
15
|
+
* remembered per browser (`defaultOpen` for the first visit); the one
|
|
16
|
+
* holding the current page stays open, and the icon rail shows every
|
|
17
|
+
* section's items.
|
|
15
18
|
*/
|
|
16
19
|
nav: readonly NavEntry[];
|
|
17
20
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
|
|
2
|
+
import { ChevronRightIcon, LogOutIcon, MoonIcon, SunIcon } from 'lucide-react';
|
|
3
3
|
import { useTheme } from 'next-themes';
|
|
4
|
-
import { useEffect, useState } from 'react';
|
|
4
|
+
import { useEffect, useId, useState } from 'react';
|
|
5
5
|
import { Link, Navigate, Outlet, useLocation } from 'react-router';
|
|
6
6
|
import { useAuth } from '../../auth/use-auth.js';
|
|
7
7
|
import { activeNavTo, navGroups } from './app-shell-nav.js';
|
|
@@ -9,6 +9,7 @@ import { versionHref, versionLabel } from './app-shell-version.js';
|
|
|
9
9
|
import { ErrorState } from './error-state.js';
|
|
10
10
|
import { useErrorReporter } from '../../error-reporting/error-reporting-context.js';
|
|
11
11
|
import { ReportRoutePattern } from '../../error-reporting/error-reporting-provider.js';
|
|
12
|
+
import { cn } from 'cn';
|
|
12
13
|
import { Button } from '../ui/button.js';
|
|
13
14
|
import { Skeleton } from '../ui/skeleton.js';
|
|
14
15
|
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInset, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger, useSidebar, } from '../ui/sidebar.js';
|
|
@@ -47,6 +48,64 @@ function VersionEntry({ version }) {
|
|
|
47
48
|
const className = 'block truncate px-2 type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden';
|
|
48
49
|
return href ? (_jsx("a", { href: href, target: "_blank", rel: "noreferrer", className: `${className} hover:underline`, children: label })) : (_jsx("span", { className: className, children: label }));
|
|
49
50
|
}
|
|
51
|
+
// One group's entries. A plain Link, not NavLink: NavLink marks every
|
|
52
|
+
// matching entry current, and only the most specific one should be
|
|
53
|
+
// (activeNavTo).
|
|
54
|
+
function NavMenu({ items, activeTo }) {
|
|
55
|
+
return (_jsx(SidebarMenu, { children: items.map(({ to, label, icon: Icon }) => {
|
|
56
|
+
const isActive = to === activeTo;
|
|
57
|
+
return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
|
|
58
|
+
}) }));
|
|
59
|
+
}
|
|
60
|
+
// Whether a collapsible section is open, remembered per browser by its
|
|
61
|
+
// label. Storage can be missing or refuse (a private window): then it's
|
|
62
|
+
// remembered for the page's life only.
|
|
63
|
+
const SECTION_KEY = (label) => `ui-foundation:nav-section:${label}`;
|
|
64
|
+
function storedSectionOpen(label) {
|
|
65
|
+
try {
|
|
66
|
+
const stored = window.localStorage.getItem(SECTION_KEY(label));
|
|
67
|
+
return stored === null ? null : stored === 'open';
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function storeSectionOpen(label, open) {
|
|
74
|
+
try {
|
|
75
|
+
window.localStorage.setItem(SECTION_KEY(label), open ? 'open' : 'closed');
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Not remembered; the state still holds until the page reloads.
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// A section whose label folds it (#152). On the icon rail (the collapsed
|
|
82
|
+
// sidebar on a wide screen) its items always show and its label stays the
|
|
83
|
+
// rail's hidden one, so no page is out of reach and no hidden button
|
|
84
|
+
// takes a tab stop. The section holding the current page is always open:
|
|
85
|
+
// arriving in a closed one opens it and remembers that.
|
|
86
|
+
function CollapsibleNavGroup({ group, activeTo }) {
|
|
87
|
+
const { state, isMobile } = useSidebar();
|
|
88
|
+
const contentId = useId();
|
|
89
|
+
const [stored, setStored] = useState(() => storedSectionOpen(group.label));
|
|
90
|
+
const holdsCurrent = group.items.some((item) => item.to === activeTo);
|
|
91
|
+
// Arriving in a folded section unfolds it, and it stays unfolded after.
|
|
92
|
+
if (holdsCurrent && stored !== true)
|
|
93
|
+
setStored(true);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (holdsCurrent)
|
|
96
|
+
storeSectionOpen(group.label, true);
|
|
97
|
+
}, [holdsCurrent, group.label]);
|
|
98
|
+
if (state === 'collapsed' && !isMobile) {
|
|
99
|
+
return (_jsxs(SidebarGroup, { children: [_jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }));
|
|
100
|
+
}
|
|
101
|
+
const open = holdsCurrent || (stored ?? group.defaultOpen ?? true);
|
|
102
|
+
return (_jsxs(SidebarGroup, { "data-collapsible-section": open ? 'open' : 'closed', children: [_jsxs(SidebarGroupLabel, { render: _jsx("button", { type: "button", "aria-expanded": open, "aria-controls": contentId, "aria-disabled": holdsCurrent || undefined, onClick: () => {
|
|
103
|
+
if (holdsCurrent)
|
|
104
|
+
return;
|
|
105
|
+
setStored(!open);
|
|
106
|
+
storeSectionOpen(group.label, !open);
|
|
107
|
+
} }), className: "w-full justify-between text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-disabled:hover:bg-transparent aria-disabled:hover:text-sidebar-foreground/70", children: [group.label, _jsx(ChevronRightIcon, { "aria-hidden": "true", className: cn('transition-transform', open && 'rotate-90') })] }), _jsx(SidebarGroupContent, { id: contentId, hidden: !open, children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }));
|
|
108
|
+
}
|
|
50
109
|
function AppShellSkeleton() {
|
|
51
110
|
return (_jsxs("div", { className: "flex h-screen w-full gap-4 p-4", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-full w-56 shrink-0" }), _jsxs("div", { className: "flex-1 space-y-4 pt-2", children: [_jsx(Skeleton, { className: "h-8 w-64" }), _jsx(Skeleton, { className: "h-40 w-full" })] })] }));
|
|
52
111
|
}
|
|
@@ -79,13 +138,7 @@ export function AppShell({ title, nav, sidebarExtra, defaultSidebarOpen = true,
|
|
|
79
138
|
return _jsx(Navigate, { to: "/login", replace: true, state: isLoggingOut ? undefined : { from } });
|
|
80
139
|
}
|
|
81
140
|
const activeTo = activeNavTo(nav, location.pathname);
|
|
82
|
-
return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(
|
|
83
|
-
// A plain Link, not NavLink: NavLink marks every
|
|
84
|
-
// matching entry current, and only the most specific
|
|
85
|
-
// one should be (activeNavTo).
|
|
86
|
-
const isActive = to === activeTo;
|
|
87
|
-
return (_jsx(SidebarMenuItem, { children: _jsxs(SidebarMenuButton, { isActive: isActive, tooltip: label, render: _jsx(Link, { to: to, "aria-current": isActive ? 'page' : undefined }), children: [_jsx(Icon, {}), _jsx("span", { children: label })] }) }, to));
|
|
88
|
-
}) }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
|
|
141
|
+
return (_jsxs(TooltipProvider, { children: [reporter && _jsx(ReportRoutePattern, { reporter: reporter }), _jsxs(SidebarProvider, { defaultOpen: storedSidebarOpen(defaultSidebarOpen), children: [_jsx(CloseMobileSidebarOnNavigate, {}), _jsx("nav", { "aria-label": "Primary", children: _jsxs(Sidebar, { collapsible: "icon", children: [_jsx(SidebarHeader, { children: _jsx("span", { className: "px-2 text-sm font-semibold text-sidebar-foreground group-data-[collapsible=icon]:hidden", children: title }) }), _jsxs(SidebarContent, { children: [navGroups(nav).map((group, groupIndex) => group.collapsible && group.label !== undefined ? (_jsx(CollapsibleNavGroup, { group: { ...group, label: group.label }, activeTo: activeTo }, `${groupIndex}:${group.label}`)) : (_jsxs(SidebarGroup, { children: [group.label !== undefined && _jsx(SidebarGroupLabel, { children: group.label }), _jsx(SidebarGroupContent, { children: _jsx(NavMenu, { items: group.items, activeTo: activeTo }) })] }, `${groupIndex}:${group.label ?? ''}`))), sidebarExtra] }), _jsxs(SidebarFooter, { children: [version && _jsx(VersionEntry, { version: version }), _jsxs("div", { className: "flex items-center justify-between gap-2 px-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0", children: [_jsx("span", { className: "truncate type-caption text-sidebar-foreground/70 group-data-[collapsible=icon]:hidden", children: user.name }), _jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Log out", onClick: () => {
|
|
89
142
|
setIsLoggingOut(true);
|
|
90
143
|
// A failed request still ends the session on screen
|
|
91
144
|
// (AuthProvider), so this stays a deliberate logout: a
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Page } from '@playwright/test';
|
|
2
|
+
import { type NavEntry } from '../components/app/app-shell-nav.js';
|
|
2
3
|
import type { MswOverride } from '../mocks/override.js';
|
|
3
4
|
export type { MswHandle, MswOverride } from '../mocks/override.js';
|
|
4
5
|
/**
|
|
@@ -44,16 +45,26 @@ export type A11ySuiteOptions = {
|
|
|
44
45
|
viewRoutes?: readonly string[];
|
|
45
46
|
/** Screens outside the app shell, e.g. '/login' (and '/register' if the app has it). */
|
|
46
47
|
loggedOutRoutes: readonly string[];
|
|
48
|
+
/**
|
|
49
|
+
* The app's nav, the same `NavEntry[]` passed to `AppShell` (`src/nav.ts`).
|
|
50
|
+
* Given, each page in it is a test of its own, so the suite's time per
|
|
51
|
+
* test stays flat as the app grows, and one more test checks the
|
|
52
|
+
* rendered sidebar links exactly those pages. Left out, the pages are
|
|
53
|
+
* found in the sidebar at run time, in one test whose timeout grows with
|
|
54
|
+
* their number.
|
|
55
|
+
*/
|
|
56
|
+
nav?: readonly NavEntry[];
|
|
47
57
|
};
|
|
48
58
|
/**
|
|
49
59
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
50
|
-
* the Primary nav (
|
|
51
|
-
*
|
|
60
|
+
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
|
61
|
+
* when `nav` is left out), the given form routes, and the logged-out
|
|
62
|
+
* screens. axe's
|
|
52
63
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
53
64
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
54
65
|
* emulateMedia switches it.
|
|
55
66
|
*/
|
|
56
|
-
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
|
|
67
|
+
export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes, nav }: A11ySuiteOptions): void;
|
|
57
68
|
export type MockModeBannerSuiteOptions = {
|
|
58
69
|
/** Screens to check, logged-out ones included (e.g. '/login'). */
|
|
59
70
|
routes: readonly string[];
|
package/dist/testing/index.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
// are optional peer dependencies, needed only by apps that import this.
|
|
14
14
|
import AxeBuilder from '@axe-core/playwright';
|
|
15
15
|
import { expect, test } from '@playwright/test';
|
|
16
|
+
import { isNavSection } from '../components/app/app-shell-nav.js';
|
|
16
17
|
/**
|
|
17
18
|
* Forces a response for the page's *first* load: set before navigation, so
|
|
18
19
|
* it wins the race against the app's own first fetch. For states forced
|
|
@@ -92,36 +93,66 @@ async function navRoutes(page, isMobile) {
|
|
|
92
93
|
? (await page.getByRole('button', { name: 'Toggle Sidebar' }).first().click(), page.getByRole('dialog'))
|
|
93
94
|
: page.getByRole('navigation', { name: 'Primary' });
|
|
94
95
|
await expect(nav.getByRole('link').first()).toBeVisible();
|
|
96
|
+
// By element, not role: a link inside a folded sidebar section is hidden
|
|
97
|
+
// (AppShell's collapsible sections) and role queries skip it.
|
|
95
98
|
const hrefs = await nav
|
|
96
|
-
.
|
|
99
|
+
.locator('a[href]')
|
|
97
100
|
.evaluateAll((links) => links.map((link) => link.href));
|
|
98
101
|
// Only this app's pages: a sidebarExtra group may link to other sites.
|
|
99
102
|
const origin = new URL(page.url()).origin;
|
|
100
103
|
return [...new Set(hrefs.filter((href) => new URL(href).origin === origin).map((href) => new URL(href).pathname))];
|
|
101
104
|
}
|
|
105
|
+
// A page's share of the one discovering test's time: load, data, axe.
|
|
106
|
+
const NAV_PAGE_BUDGET_MS = 10_000;
|
|
107
|
+
function navEntryRoutes(nav) {
|
|
108
|
+
return [...new Set(nav.flatMap((entry) => (isNavSection(entry) ? entry.items : [entry]).map((item) => item.to)))];
|
|
109
|
+
}
|
|
110
|
+
async function expectNavPageAccessible(page, route, colorScheme) {
|
|
111
|
+
await page.goto(route);
|
|
112
|
+
await expect(page.locator('html')).toHaveClass(colorScheme);
|
|
113
|
+
// Wait for data, not the loading skeleton, so the real UI is checked.
|
|
114
|
+
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
115
|
+
await expectNoAxeViolations(page);
|
|
116
|
+
}
|
|
102
117
|
/**
|
|
103
118
|
* Accessibility of the real screens in light and dark mode: every page in
|
|
104
|
-
* the Primary nav (
|
|
105
|
-
*
|
|
119
|
+
* the Primary nav (one test each from `nav`, or discovered in the sidebar
|
|
120
|
+
* when `nav` is left out), the given form routes, and the logged-out
|
|
121
|
+
* screens. axe's
|
|
106
122
|
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
107
123
|
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
108
124
|
* emulateMedia switches it.
|
|
109
125
|
*/
|
|
110
|
-
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes }) {
|
|
126
|
+
export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes, nav }) {
|
|
127
|
+
const listed = nav && navEntryRoutes(nav);
|
|
128
|
+
if (listed) {
|
|
129
|
+
test('the sidebar links exactly the pages in nav', async ({ page, isMobile }) => {
|
|
130
|
+
const rendered = await navRoutes(page, isMobile);
|
|
131
|
+
expect([...rendered].sort(), 'The sidebar and the nav given to defineA11ySuite differ: pass the same NavEntry[] AppShell gets').toEqual([...listed].sort());
|
|
132
|
+
});
|
|
133
|
+
}
|
|
111
134
|
for (const colorScheme of ['light', 'dark']) {
|
|
112
135
|
test.describe(`accessibility (${colorScheme})`, () => {
|
|
113
136
|
test.beforeEach(async ({ page }) => {
|
|
114
137
|
await page.emulateMedia({ colorScheme });
|
|
115
138
|
});
|
|
116
|
-
|
|
117
|
-
for (const route of
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
|
|
122
|
-
await test.step(route, () => expectNoAxeViolations(page));
|
|
139
|
+
if (listed) {
|
|
140
|
+
for (const route of listed) {
|
|
141
|
+
test(`nav page ${route} has zero axe violations`, async ({ page }) => {
|
|
142
|
+
await expectNavPageAccessible(page, route, colorScheme);
|
|
143
|
+
});
|
|
123
144
|
}
|
|
124
|
-
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
test('every nav page has zero axe violations', async ({ page, isMobile }, testInfo) => {
|
|
148
|
+
const routes = await navRoutes(page, isMobile);
|
|
149
|
+
// One test for every page: its time grows with the app.
|
|
150
|
+
test.setTimeout(testInfo.timeout + routes.length * NAV_PAGE_BUDGET_MS);
|
|
151
|
+
for (const route of routes) {
|
|
152
|
+
await test.step(route, () => expectNavPageAccessible(page, route, colorScheme));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
125
156
|
for (const route of [...formRoutes, ...loggedOutRoutes]) {
|
|
126
157
|
test(`${route} has zero axe violations`, async ({ page }) => {
|
|
127
158
|
await page.goto(route);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.15.0",
|
|
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": {
|