@tristan2828/ui-foundation 3.0.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/LICENSE +21 -0
- package/README.md +51 -0
- package/bin/ui-foundation.mjs +249 -0
- package/conventions/AGENTS.md +149 -0
- package/conventions/agents/deny-impl-read.mjs +36 -0
- package/conventions/agents/skills/new-entity/SKILL.md +22 -0
- package/conventions/agents/spec-tester.md +35 -0
- package/conventions/docs/add-an-entity.md +204 -0
- package/conventions/docs/cell-patterns.md +204 -0
- package/conventions/docs/column-options.md +122 -0
- package/conventions/docs/design-language.md +116 -0
- package/conventions/docs/entity-plan-template.md +67 -0
- package/dist/api/contracts.d.ts +20 -0
- package/dist/api/contracts.js +4 -0
- package/dist/api/gateway/auth.d.ts +9 -0
- package/dist/api/gateway/auth.js +37 -0
- package/dist/api/gateway/errors.d.ts +5 -0
- package/dist/api/gateway/errors.js +36 -0
- package/dist/api/query-client.d.ts +2 -0
- package/dist/api/query-client.js +19 -0
- package/dist/api/schema.d.ts +246 -0
- package/dist/api/schema.js +5 -0
- package/dist/api/transport/index.d.ts +5 -0
- package/dist/api/transport/index.js +14 -0
- package/dist/auth/auth-context.d.ts +19 -0
- package/dist/auth/auth-context.js +12 -0
- package/dist/auth/auth-provider.d.ts +4 -0
- package/dist/auth/auth-provider.js +101 -0
- package/dist/auth/use-auth.d.ts +2 -0
- package/dist/auth/use-auth.js +9 -0
- package/dist/components/app/app-shell.d.ts +28 -0
- package/dist/components/app/app-shell.js +64 -0
- package/dist/components/app/data-table.d.ts +56 -0
- package/dist/components/app/data-table.js +167 -0
- package/dist/components/app/entity-form.d.ts +14 -0
- package/dist/components/app/entity-form.js +8 -0
- package/dist/components/app/error-state.d.ts +6 -0
- package/dist/components/app/error-state.js +20 -0
- package/dist/components/app/foundation-providers.d.ts +9 -0
- package/dist/components/app/foundation-providers.js +15 -0
- package/dist/components/app/mock-mode-banner.d.ts +3 -0
- package/dist/components/app/mock-mode-banner.js +16 -0
- package/dist/components/app/multi-choice.d.ts +14 -0
- package/dist/components/app/multi-choice.js +13 -0
- package/dist/components/app/password-input.d.ts +3 -0
- package/dist/components/app/password-input.js +30 -0
- package/dist/components/app/route-error-boundary.d.ts +1 -0
- package/dist/components/app/route-error-boundary.js +13 -0
- package/dist/components/theme-provider.d.ts +2 -0
- package/dist/components/theme-provider.js +8 -0
- package/dist/components/ui/badge.d.ts +7 -0
- package/dist/components/ui/badge.js +50 -0
- package/dist/components/ui/button.d.ts +8 -0
- package/dist/components/ui/button.js +34 -0
- package/dist/components/ui/calendar.d.ts +10 -0
- package/dist/components/ui/calendar.js +76 -0
- package/dist/components/ui/card.d.ts +11 -0
- package/dist/components/ui/card.js +25 -0
- package/dist/components/ui/combobox.d.ts +24 -0
- package/dist/components/ui/combobox.js +62 -0
- package/dist/components/ui/dialog.d.ts +17 -0
- package/dist/components/ui/dialog.js +37 -0
- package/dist/components/ui/empty.d.ts +11 -0
- package/dist/components/ui/empty.js +33 -0
- package/dist/components/ui/field.d.ts +24 -0
- package/dist/components/ui/field.js +67 -0
- package/dist/components/ui/input-group.d.ts +18 -0
- package/dist/components/ui/input-group.js +57 -0
- package/dist/components/ui/input.d.ts +3 -0
- package/dist/components/ui/input.js +8 -0
- package/dist/components/ui/label.d.ts +3 -0
- package/dist/components/ui/label.js +7 -0
- package/dist/components/ui/popover.d.ts +9 -0
- package/dist/components/ui/popover.js +23 -0
- package/dist/components/ui/select.d.ts +15 -0
- package/dist/components/ui/select.js +35 -0
- package/dist/components/ui/separator.d.ts +3 -0
- package/dist/components/ui/separator.js +8 -0
- package/dist/components/ui/sheet.d.ts +14 -0
- package/dist/components/ui/sheet.js +38 -0
- package/dist/components/ui/sidebar.d.ts +63 -0
- package/dist/components/ui/sidebar.js +255 -0
- package/dist/components/ui/skeleton.d.ts +2 -0
- package/dist/components/ui/skeleton.js +6 -0
- package/dist/components/ui/sonner.d.ts +3 -0
- package/dist/components/ui/sonner.js +24 -0
- package/dist/components/ui/spinner.d.ts +2 -0
- package/dist/components/ui/spinner.js +7 -0
- package/dist/components/ui/table.d.ts +13 -0
- package/dist/components/ui/table.js +45 -0
- package/dist/components/ui/textarea.d.ts +3 -0
- package/dist/components/ui/textarea.js +7 -0
- package/dist/components/ui/tooltip.d.ts +6 -0
- package/dist/components/ui/tooltip.js +16 -0
- package/dist/gateway.d.ts +2 -0
- package/dist/gateway.js +5 -0
- package/dist/hooks/table-url-changes.d.ts +14 -0
- package/dist/hooks/table-url-changes.js +32 -0
- package/dist/hooks/use-debounced-value.d.ts +1 -0
- package/dist/hooks/use-debounced-value.js +12 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +14 -0
- package/dist/hooks/use-table-url-state.d.ts +14 -0
- package/dist/hooks/use-table-url-state.js +43 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +28 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +1 -0
- package/dist/mocks/auth.d.ts +9 -0
- package/dist/mocks/auth.js +79 -0
- package/dist/mocks/e2e-hooks.d.ts +3 -0
- package/dist/mocks/e2e-hooks.js +28 -0
- package/dist/mocks/index.d.ts +3 -0
- package/dist/mocks/index.js +5 -0
- package/dist/mocks/override.d.ts +23 -0
- package/dist/mocks/override.js +1 -0
- package/dist/routes/login-schema.d.ts +7 -0
- package/dist/routes/login-schema.js +13 -0
- package/dist/routes/login.d.ts +8 -0
- package/dist/routes/login.js +58 -0
- package/dist/routes/register-schema.d.ts +9 -0
- package/dist/routes/register-schema.js +23 -0
- package/dist/routes/register.d.ts +1 -0
- package/dist/routes/register.js +58 -0
- package/dist/routes/return-path.d.ts +1 -0
- package/dist/routes/return-path.js +9 -0
- package/dist/testing/index.d.ts +40 -0
- package/dist/testing/index.js +103 -0
- package/eslint/index.d.ts +11 -0
- package/eslint/index.js +179 -0
- package/openapi/foundation.yaml +156 -0
- package/package.json +158 -0
- package/styles/index.css +88 -0
- package/styles/theme.css +209 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type Sort = readonly {
|
|
2
|
+
id: string;
|
|
3
|
+
desc: boolean;
|
|
4
|
+
}[];
|
|
5
|
+
export type UrlChanges = Record<string, string | string[] | null>;
|
|
6
|
+
export type TableView<F extends string, M extends string> = {
|
|
7
|
+
filters?: Partial<Record<F, string>>;
|
|
8
|
+
multiFilters?: Partial<Record<M, string[]>>;
|
|
9
|
+
sort?: Sort;
|
|
10
|
+
};
|
|
11
|
+
export declare function serializeSort(next: Sort): string | null;
|
|
12
|
+
export declare function applyUrlChanges(previous: URLSearchParams, changes: UrlChanges): URLSearchParams;
|
|
13
|
+
export declare function viewToUrlChanges<F extends string, M extends string>(filterNames: readonly F[], multiFilterNames: readonly M[], view: TableView<F, M>): UrlChanges;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The URL-building half of useTableUrlState (./use-table-url-state.ts),
|
|
2
|
+
// kept free of React and of component imports so tests/ can unit-test it.
|
|
3
|
+
export function serializeSort(next) {
|
|
4
|
+
return next[0] ? `${next[0].id}:${next[0].desc ? 'desc' : 'asc'}` : null;
|
|
5
|
+
}
|
|
6
|
+
// The next query string: each changed key is replaced outright. An array
|
|
7
|
+
// repeats the key, and null or '' removes it. Keys not in `changes` are
|
|
8
|
+
// kept, so unrelated params (and other tables' state) survive.
|
|
9
|
+
export function applyUrlChanges(previous, changes) {
|
|
10
|
+
const next = new URLSearchParams(previous);
|
|
11
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
12
|
+
next.delete(key);
|
|
13
|
+
if (Array.isArray(value))
|
|
14
|
+
for (const item of value)
|
|
15
|
+
next.append(key, item);
|
|
16
|
+
else if (value !== null && value !== '')
|
|
17
|
+
next.set(key, value);
|
|
18
|
+
}
|
|
19
|
+
return next;
|
|
20
|
+
}
|
|
21
|
+
// A view is "this combination and nothing else": every filter and the sort
|
|
22
|
+
// are set, and any the view doesn't name are cleared. Merging over the
|
|
23
|
+
// current state instead would let a leftover filter silently show something
|
|
24
|
+
// other than the view. Decided with the developer on 2026-09-28.
|
|
25
|
+
export function viewToUrlChanges(filterNames, multiFilterNames, view) {
|
|
26
|
+
return {
|
|
27
|
+
...Object.fromEntries(filterNames.map((name) => [name, view.filters?.[name] ?? ''])),
|
|
28
|
+
...Object.fromEntries(multiFilterNames.map((name) => [name, view.multiFilters?.[name] ?? []])),
|
|
29
|
+
sort: view.sort ? serializeSort(view.sort) : null,
|
|
30
|
+
page: null,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useDebouncedValue<T>(value: T, delayMs: number): T;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
// `value`, once it has stopped changing for `delayMs`. Used to keep a
|
|
3
|
+
// search box from sending one request per keystroke; the input itself stays
|
|
4
|
+
// bound to the live value so typing never lags.
|
|
5
|
+
export function useDebouncedValue(value, delayMs) {
|
|
6
|
+
const [debounced, setDebounced] = useState(value);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const timeout = setTimeout(() => setDebounced(value), delayMs);
|
|
9
|
+
return () => clearTimeout(timeout);
|
|
10
|
+
}, [value, delayMs]);
|
|
11
|
+
return debounced;
|
|
12
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function useIsMobile(): boolean;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
const MOBILE_BREAKPOINT = 768;
|
|
3
|
+
export function useIsMobile() {
|
|
4
|
+
const [isMobile, setIsMobile] = React.useState(() => window.innerWidth < MOBILE_BREAKPOINT);
|
|
5
|
+
React.useEffect(() => {
|
|
6
|
+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
7
|
+
const onChange = () => {
|
|
8
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
9
|
+
};
|
|
10
|
+
mql.addEventListener("change", onChange);
|
|
11
|
+
return () => mql.removeEventListener("change", onChange);
|
|
12
|
+
}, []);
|
|
13
|
+
return isMobile;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { SortingState } from '../components/app/data-table.js';
|
|
2
|
+
import { type TableView } from './table-url-changes.js';
|
|
3
|
+
export declare function useTableUrlState<F extends string, M extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[]): {
|
|
4
|
+
page: number;
|
|
5
|
+
sorting: SortingState;
|
|
6
|
+
filters: Record<F, string>;
|
|
7
|
+
multiFilters: Record<M, string[]>;
|
|
8
|
+
setPage: (next: number) => void;
|
|
9
|
+
setSorting: (next: SortingState) => void;
|
|
10
|
+
setFilter: (name: F, value: string) => void;
|
|
11
|
+
setFilters: (changes: Partial<Record<F, string>>) => void;
|
|
12
|
+
setMultiFilter: (name: M, values: string[]) => void;
|
|
13
|
+
applyView: (view: TableView<F, M>) => void;
|
|
14
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { useSearchParams } from 'react-router';
|
|
2
|
+
import { applyUrlChanges, serializeSort, viewToUrlChanges } from './table-url-changes.js';
|
|
3
|
+
// A table's page, sort and filters, kept in the URL query string
|
|
4
|
+
// (`?page=2&sort=name:asc&search=mouse&tags=fragile&tags=bulky`) instead of
|
|
5
|
+
// component state, so they survive going to the edit form and back, a
|
|
6
|
+
// refresh, or a shared link. Every update replaces the history entry rather
|
|
7
|
+
// than pushing one — typing in a search box shouldn't fill the back button.
|
|
8
|
+
//
|
|
9
|
+
// Single-value filters are strings ('' when unset). Multi-value filters
|
|
10
|
+
// (multi-choice fields) are string arrays, repeated in the URL the same way
|
|
11
|
+
// the API takes them. Changing the sort or any filter returns to page 1.
|
|
12
|
+
//
|
|
13
|
+
// Every setter is one update(), and must stay one: see setFilters.
|
|
14
|
+
export function useTableUrlState(filterNames, multiFilterNames = []) {
|
|
15
|
+
const [params, setParams] = useSearchParams();
|
|
16
|
+
const page = Math.max(1, Math.floor(Number(params.get('page'))) || 1);
|
|
17
|
+
const [sortField, sortDir] = (params.get('sort') ?? '').split(':');
|
|
18
|
+
const sorting = sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : [];
|
|
19
|
+
const filters = Object.fromEntries(filterNames.map((name) => [name, params.get(name) ?? '']));
|
|
20
|
+
const multiFilters = Object.fromEntries(multiFilterNames.map((name) => [name, params.getAll(name)]));
|
|
21
|
+
function update(changes) {
|
|
22
|
+
setParams((previous) => applyUrlChanges(previous, changes), { replace: true });
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
page,
|
|
26
|
+
sorting,
|
|
27
|
+
filters,
|
|
28
|
+
multiFilters,
|
|
29
|
+
setPage: (next) => update({ page: next > 1 ? String(next) : null }),
|
|
30
|
+
setSorting: (next) => update({ sort: serializeSort(next), page: null }),
|
|
31
|
+
setFilter: (name, value) => update({ [name]: value, page: null }),
|
|
32
|
+
// Several single-value filters in one call — for any user action that
|
|
33
|
+
// changes more than one (both ends of a date range, "clear all"). Two
|
|
34
|
+
// setFilter()s in the same tick lose all but the last: react-router
|
|
35
|
+
// hands each setSearchParams updater the params from the last render,
|
|
36
|
+
// not the ones the previous updater produced.
|
|
37
|
+
setFilters: (changes) => update({ ...changes, page: null }),
|
|
38
|
+
setMultiFilter: (name, values) => update({ [name]: values, page: null }),
|
|
39
|
+
// Replaces the whole filter + sort state at once — a saved view. See
|
|
40
|
+
// viewToUrlChanges() for why anything the view doesn't set is cleared.
|
|
41
|
+
applyView: (view) => update(viewToUrlChanges(filterNames, multiFilterNames, view)),
|
|
42
|
+
};
|
|
43
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
|
|
2
|
+
export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
|
|
3
|
+
export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
|
|
4
|
+
export { ErrorState } from './components/app/error-state.js';
|
|
5
|
+
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
6
|
+
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
7
|
+
export { MultiChoice } from './components/app/multi-choice.js';
|
|
8
|
+
export { PasswordInput } from './components/app/password-input.js';
|
|
9
|
+
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
10
|
+
export { ThemeProvider } from './components/theme-provider.js';
|
|
11
|
+
export { LoginRoute, type LoginRouteProps } from './routes/login.js';
|
|
12
|
+
export { RegisterRoute } from './routes/register.js';
|
|
13
|
+
export { returnPath } from './routes/return-path.js';
|
|
14
|
+
export { AuthProvider, useAuth } from './auth/use-auth.js';
|
|
15
|
+
export type { AuthContextValue, AuthStatus, AuthUser } from './auth/auth-context.js';
|
|
16
|
+
export type { AppError, Page, QuerySpec } from './api/contracts.js';
|
|
17
|
+
export { createQueryClient } from './api/query-client.js';
|
|
18
|
+
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
19
|
+
export type { TableView, UrlChanges } from './hooks/table-url-changes.js';
|
|
20
|
+
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
21
|
+
export { useIsMobile } from './hooks/use-mobile.js';
|
|
22
|
+
export { cn } from './lib/utils.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `@tristan2828/ui-foundation` — the foundation-owned layer every app shares.
|
|
2
|
+
// Primitives are imported one per path (`@tristan2828/ui-foundation/ui/button`);
|
|
3
|
+
// the gateway seam, MSW support and Playwright suites have their own entry
|
|
4
|
+
// points (`/gateway`, `/mocks`, `/testing`) so each stays behind its boundary.
|
|
5
|
+
// Composites
|
|
6
|
+
export { AppShell } from './components/app/app-shell.js';
|
|
7
|
+
export { DataTable } from './components/app/data-table.js';
|
|
8
|
+
export { EntityForm } from './components/app/entity-form.js';
|
|
9
|
+
export { ErrorState } from './components/app/error-state.js';
|
|
10
|
+
export { FoundationProviders } from './components/app/foundation-providers.js';
|
|
11
|
+
export { MockModeBanner } from './components/app/mock-mode-banner.js';
|
|
12
|
+
export { MultiChoice } from './components/app/multi-choice.js';
|
|
13
|
+
export { PasswordInput } from './components/app/password-input.js';
|
|
14
|
+
export { RouteErrorBoundary } from './components/app/route-error-boundary.js';
|
|
15
|
+
export { ThemeProvider } from './components/theme-provider.js';
|
|
16
|
+
// Screens outside the app shell
|
|
17
|
+
export { LoginRoute } from './routes/login.js';
|
|
18
|
+
export { RegisterRoute } from './routes/register.js';
|
|
19
|
+
export { returnPath } from './routes/return-path.js';
|
|
20
|
+
// Auth — useAuth() is the only way to read it; the provider's internals stay private.
|
|
21
|
+
export { AuthProvider, useAuth } from './auth/use-auth.js';
|
|
22
|
+
export { createQueryClient } from './api/query-client.js';
|
|
23
|
+
// Hooks
|
|
24
|
+
export { useTableUrlState } from './hooks/use-table-url-state.js';
|
|
25
|
+
export { useDebouncedValue } from './hooks/use-debounced-value.js';
|
|
26
|
+
export { useIsMobile } from './hooks/use-mobile.js';
|
|
27
|
+
// Utilities
|
|
28
|
+
export { cn } from './lib/utils.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cn } from "cn";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { cn } from "cn";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { components } from '../api/schema.js';
|
|
2
|
+
type User = components['schemas']['User'];
|
|
3
|
+
export declare const MOCK_USER: User;
|
|
4
|
+
export declare const MOCK_PASSWORD = "dev-password-123";
|
|
5
|
+
export declare function isMockAuthenticated(): boolean;
|
|
6
|
+
export declare function setMockAuthenticated(value: boolean): void;
|
|
7
|
+
export declare function resetMockAuth(): void;
|
|
8
|
+
export declare const authHandlers: import("msw").HttpHandler[];
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// MSW handlers for the foundation's /auth/* contract (openapi/foundation.yaml),
|
|
2
|
+
// against an in-memory session store. An app spreads `authHandlers` into its
|
|
3
|
+
// own handler list and calls `resetMockAuth()` from its own reset, so every
|
|
4
|
+
// app's mocks behave like the reference backend's auth without a copy of it.
|
|
5
|
+
import { http, HttpResponse } from 'msw';
|
|
6
|
+
// Same demo credentials as the reference backend's seeded dev user
|
|
7
|
+
// (backend/app/config.py's SEED_USER_EMAIL/SEED_USER_PASSWORD defaults), so
|
|
8
|
+
// logging in behaves the same whether MSW or the real API answers.
|
|
9
|
+
export const MOCK_USER = { id: 1, email: 'dev@example.com', name: 'Dev User' };
|
|
10
|
+
export const MOCK_PASSWORD = 'dev-password-123';
|
|
11
|
+
// Signed in by default: screen specs assume access, and only auth specs
|
|
12
|
+
// exercise the logged-out path (by overriding GET /auth/me to 401).
|
|
13
|
+
let authenticated = true;
|
|
14
|
+
// Mirrors the backend's email-uniqueness check and its auto-login on
|
|
15
|
+
// register: a new registration becomes the session's current user.
|
|
16
|
+
let registeredUsers = new Map([[MOCK_USER.email, MOCK_USER]]);
|
|
17
|
+
let currentUser = MOCK_USER;
|
|
18
|
+
let nextUserId = 2;
|
|
19
|
+
export function isMockAuthenticated() {
|
|
20
|
+
return authenticated;
|
|
21
|
+
}
|
|
22
|
+
export function setMockAuthenticated(value) {
|
|
23
|
+
authenticated = value;
|
|
24
|
+
}
|
|
25
|
+
export function resetMockAuth() {
|
|
26
|
+
authenticated = true;
|
|
27
|
+
registeredUsers = new Map([[MOCK_USER.email, MOCK_USER]]);
|
|
28
|
+
currentUser = MOCK_USER;
|
|
29
|
+
nextUserId = 2;
|
|
30
|
+
}
|
|
31
|
+
export const authHandlers = [
|
|
32
|
+
http.post('*/api/auth/register', async ({ request }) => {
|
|
33
|
+
const body = (await request.json());
|
|
34
|
+
// Mirrors the backend's register(): field validation, then the
|
|
35
|
+
// duplicate-email check, both landing on the same 422 {detail:[...]}.
|
|
36
|
+
const issues = [];
|
|
37
|
+
if (!body.name)
|
|
38
|
+
issues.push({ loc: ['body', 'name'], msg: 'field required', type: 'value_error.missing' });
|
|
39
|
+
if (!body.email)
|
|
40
|
+
issues.push({ loc: ['body', 'email'], msg: 'field required', type: 'value_error.missing' });
|
|
41
|
+
if (!body.password || body.password.length < 8) {
|
|
42
|
+
issues.push({
|
|
43
|
+
loc: ['body', 'password'],
|
|
44
|
+
msg: 'ensure this value has at least 8 characters',
|
|
45
|
+
type: 'value_error.any_str.min_length',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (body.email && registeredUsers.has(body.email)) {
|
|
49
|
+
issues.push({ loc: ['body', 'email'], msg: 'email already registered', type: 'value_error.email_exists' });
|
|
50
|
+
}
|
|
51
|
+
if (issues.length > 0) {
|
|
52
|
+
return HttpResponse.json({ detail: issues }, { status: 422 });
|
|
53
|
+
}
|
|
54
|
+
const user = { id: nextUserId++, email: body.email, name: body.name };
|
|
55
|
+
registeredUsers.set(user.email, user);
|
|
56
|
+
currentUser = user;
|
|
57
|
+
authenticated = true;
|
|
58
|
+
return HttpResponse.json(user);
|
|
59
|
+
}),
|
|
60
|
+
http.post('*/api/auth/login', async ({ request }) => {
|
|
61
|
+
const body = (await request.json());
|
|
62
|
+
if (body.email !== MOCK_USER.email || body.password !== MOCK_PASSWORD) {
|
|
63
|
+
return HttpResponse.json({ detail: 'Invalid email or password' }, { status: 401 });
|
|
64
|
+
}
|
|
65
|
+
currentUser = MOCK_USER;
|
|
66
|
+
authenticated = true;
|
|
67
|
+
return HttpResponse.json(MOCK_USER);
|
|
68
|
+
}),
|
|
69
|
+
http.post('*/api/auth/logout', () => {
|
|
70
|
+
authenticated = false;
|
|
71
|
+
return new HttpResponse(null, { status: 204 });
|
|
72
|
+
}),
|
|
73
|
+
http.get('*/api/auth/me', () => {
|
|
74
|
+
if (!authenticated) {
|
|
75
|
+
return HttpResponse.json({ detail: 'Not authenticated' }, { status: 401 });
|
|
76
|
+
}
|
|
77
|
+
return HttpResponse.json(currentUser);
|
|
78
|
+
}),
|
|
79
|
+
];
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Exposes the running MSW worker, plus the handler-building functions, on
|
|
2
|
+
// `window` so Playwright specs can force loading/empty/error/validation
|
|
3
|
+
// states via worker.use(...), and applies a pre-navigation override if a
|
|
4
|
+
// spec set one (see ./override.ts for the protocol).
|
|
5
|
+
//
|
|
6
|
+
// Only ever wired when MSW mocking itself is active (the app's main.tsx
|
|
7
|
+
// checks mock mode first) — there is no real backend or real data behind
|
|
8
|
+
// this in that mode, so there is nothing sensitive to expose.
|
|
9
|
+
import { delay, http, HttpResponse } from 'msw';
|
|
10
|
+
import './override.js';
|
|
11
|
+
export function exposeMswForE2E(worker) {
|
|
12
|
+
window.__msw = { worker, http, HttpResponse, delay };
|
|
13
|
+
const override = window.__E2E_MSW_OVERRIDE__;
|
|
14
|
+
if (override) {
|
|
15
|
+
worker.use(http[override.method](override.path, async () => {
|
|
16
|
+
if (override.delayMs)
|
|
17
|
+
await delay(override.delayMs);
|
|
18
|
+
if (override.bodyBase64 !== undefined) {
|
|
19
|
+
const bytes = Uint8Array.from(atob(override.bodyBase64), (character) => character.charCodeAt(0));
|
|
20
|
+
return new HttpResponse(bytes, {
|
|
21
|
+
status: override.status ?? 200,
|
|
22
|
+
headers: { 'Content-Type': override.contentType ?? 'application/octet-stream' },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return HttpResponse.json(override.body ?? {}, { status: override.status ?? 200 });
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@tristan2828/ui-foundation/mocks` — MSW support. A separate entry point
|
|
2
|
+
// so msw (an optional peer dependency) never reaches an app's main bundle
|
|
3
|
+
// unless it imports this.
|
|
4
|
+
export { authHandlers, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
|
|
5
|
+
export { exposeMswForE2E } from './e2e-hooks.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { SetupWorker } from 'msw/browser';
|
|
2
|
+
import type { delay, http, HttpResponse } from 'msw';
|
|
3
|
+
export type MswOverride = {
|
|
4
|
+
method: 'get' | 'post' | 'patch' | 'delete';
|
|
5
|
+
path: string;
|
|
6
|
+
status?: number;
|
|
7
|
+
body?: unknown;
|
|
8
|
+
delayMs?: number;
|
|
9
|
+
bodyBase64?: string;
|
|
10
|
+
contentType?: string;
|
|
11
|
+
};
|
|
12
|
+
export type MswHandle = {
|
|
13
|
+
worker: SetupWorker;
|
|
14
|
+
http: typeof http;
|
|
15
|
+
HttpResponse: typeof HttpResponse;
|
|
16
|
+
delay: typeof delay;
|
|
17
|
+
};
|
|
18
|
+
declare global {
|
|
19
|
+
interface Window {
|
|
20
|
+
__msw: MswHandle;
|
|
21
|
+
__E2E_MSW_OVERRIDE__?: MswOverride;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const loginFormSchema: z.ZodObject<{
|
|
3
|
+
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
4
|
+
password: z.ZodString;
|
|
5
|
+
}, z.core.$strip>;
|
|
6
|
+
export type LoginFormValues = z.infer<typeof loginFormSchema>;
|
|
7
|
+
export declare const LOGIN_FORM_DEFAULTS: LoginFormValues;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Client-side form validation for the login screen. This does not replace
|
|
2
|
+
// the wire-shaped LoginRequest generated into schema.d.ts (AGENTS.md
|
|
3
|
+
// "NEVER hand-write an API type") — the gateway builds that shape itself
|
|
4
|
+
// from these two plain strings.
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
export const loginFormSchema = z.object({
|
|
7
|
+
email: z.string().trim().min(1, 'Email is required').pipe(z.email('Enter a valid email address')),
|
|
8
|
+
password: z.string().min(1, 'Password is required'),
|
|
9
|
+
});
|
|
10
|
+
export const LOGIN_FORM_DEFAULTS = {
|
|
11
|
+
email: '',
|
|
12
|
+
password: '',
|
|
13
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type LoginRouteProps = {
|
|
2
|
+
/**
|
|
3
|
+
* Where "Create account" links. `null` hides the link, for an app with no
|
|
4
|
+
* self-service sign-up (accounts made by hand, no /register route).
|
|
5
|
+
*/
|
|
6
|
+
registerPath?: string | null;
|
|
7
|
+
};
|
|
8
|
+
export declare function LoginRoute({ registerPath }?: LoginRouteProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Screen: /login. The one route AppShell's auth redirect never catches (an
|
|
3
|
+
// app mounts it outside the AppShell route, see template/src/App.tsx). Not built
|
|
4
|
+
// on the EntityForm composite — that's specifically create/edit-an-entity
|
|
5
|
+
// chrome (title + Cancel + Submit), and a login screen has no "cancel"
|
|
6
|
+
// destination — so this uses the same mandated FieldGroup/Field primitives
|
|
7
|
+
// directly (AGENTS.md "Correct Patterns").
|
|
8
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
9
|
+
import { useState } from 'react';
|
|
10
|
+
import { useForm } from 'react-hook-form';
|
|
11
|
+
import { Link, useLocation, useNavigate } from 'react-router';
|
|
12
|
+
import { useAuth } from '../auth/use-auth.js';
|
|
13
|
+
import { ErrorState } from '../components/app/error-state.js';
|
|
14
|
+
import { PasswordInput } from '../components/app/password-input.js';
|
|
15
|
+
import { Button } from '../components/ui/button.js';
|
|
16
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card.js';
|
|
17
|
+
import { Field, FieldError, FieldGroup, FieldLabel } from '../components/ui/field.js';
|
|
18
|
+
import { Input } from '../components/ui/input.js';
|
|
19
|
+
import { Spinner } from '../components/ui/spinner.js';
|
|
20
|
+
import { LOGIN_FORM_DEFAULTS, loginFormSchema } from './login-schema.js';
|
|
21
|
+
import { returnPath } from './return-path.js';
|
|
22
|
+
export function LoginRoute({ registerPath = '/register' } = {}) {
|
|
23
|
+
const { login } = useAuth();
|
|
24
|
+
const navigate = useNavigate();
|
|
25
|
+
const location = useLocation();
|
|
26
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
27
|
+
const [submitError, setSubmitError] = useState(null);
|
|
28
|
+
const form = useForm({
|
|
29
|
+
resolver: zodResolver(loginFormSchema),
|
|
30
|
+
defaultValues: LOGIN_FORM_DEFAULTS,
|
|
31
|
+
});
|
|
32
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
33
|
+
setSubmitError(null);
|
|
34
|
+
setIsSubmitting(true);
|
|
35
|
+
try {
|
|
36
|
+
await login(values.email, values.password);
|
|
37
|
+
navigate(returnPath(location.state), { replace: true });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
const error = err;
|
|
41
|
+
if (error.kind === 'validation' && error.fieldErrors) {
|
|
42
|
+
for (const [field, messages] of Object.entries(error.fieldErrors)) {
|
|
43
|
+
form.setError(field, { message: messages[0] });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// 401 lands here as one form-level message — never bound to the
|
|
48
|
+
// email or password field individually, so a failed attempt can't
|
|
49
|
+
// be used to confirm which part was wrong.
|
|
50
|
+
setSubmitError(error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
setIsSubmitting(false);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return (_jsx("main", { className: "flex min-h-screen items-center justify-center bg-background p-6", children: _jsxs(Card, { className: "w-full max-w-sm", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-lg", children: _jsx("h1", { children: "Sign in" }) }) }), _jsx(CardContent, { children: _jsxs("form", { onSubmit: onSubmit, noValidate: true, className: "flex flex-col gap-6", children: [_jsxs(FieldGroup, { children: [_jsxs(Field, { "data-invalid": !!form.formState.errors.email, children: [_jsx(FieldLabel, { htmlFor: "login-email", children: "Email" }), _jsx(Input, { id: "login-email", type: "email", autoComplete: "username", "aria-invalid": !!form.formState.errors.email, ...form.register('email') }), _jsx(FieldError, { errors: [form.formState.errors.email] })] }), _jsxs(Field, { "data-invalid": !!form.formState.errors.password, children: [_jsx(FieldLabel, { htmlFor: "login-password", children: "Password" }), _jsx(PasswordInput, { id: "login-password", autoComplete: "current-password", "aria-invalid": !!form.formState.errors.password, ...form.register('password') }), _jsx(FieldError, { errors: [form.formState.errors.password] })] })] }), submitError && _jsx(ErrorState, { error: submitError }), _jsxs(Button, { type: "submit", disabled: isSubmitting, children: [isSubmitting && _jsx(Spinner, { className: "size-4" }), "Sign in"] }), registerPath !== null && (_jsx(Button, { variant: "link", size: "sm", nativeButton: false, render: _jsx(Link, { to: registerPath, state: location.state }), children: "Create account" }))] }) })] }) }));
|
|
58
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const registerFormSchema: z.ZodObject<{
|
|
3
|
+
name: z.ZodString;
|
|
4
|
+
email: z.ZodPipe<z.ZodString, z.ZodEmail>;
|
|
5
|
+
password: z.ZodString;
|
|
6
|
+
confirmPassword: z.ZodString;
|
|
7
|
+
}, z.core.$strip>;
|
|
8
|
+
export type RegisterFormValues = z.infer<typeof registerFormSchema>;
|
|
9
|
+
export declare const REGISTER_FORM_DEFAULTS: RegisterFormValues;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Client-side form validation for the register screen — same pattern as
|
|
2
|
+
// login-schema.ts. Mirrors RegisterRequest (email, name, password) plus one
|
|
3
|
+
// addition beyond the wire shape: a client-only confirmPassword field, since
|
|
4
|
+
// a mistyped password with no confirmation is a real, cheaply-prevented
|
|
5
|
+
// failure mode.
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
export const registerFormSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
name: z.string().trim().min(1, 'Name is required').max(200, 'Name must be 200 characters or fewer'),
|
|
10
|
+
email: z.string().trim().min(1, 'Email is required').pipe(z.email('Enter a valid email address')),
|
|
11
|
+
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
12
|
+
confirmPassword: z.string().min(1, 'Confirm your password'),
|
|
13
|
+
})
|
|
14
|
+
.refine((values) => values.password === values.confirmPassword, {
|
|
15
|
+
message: 'Passwords do not match',
|
|
16
|
+
path: ['confirmPassword'],
|
|
17
|
+
});
|
|
18
|
+
export const REGISTER_FORM_DEFAULTS = {
|
|
19
|
+
name: '',
|
|
20
|
+
email: '',
|
|
21
|
+
password: '',
|
|
22
|
+
confirmPassword: '',
|
|
23
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function RegisterRoute(): import("react").JSX.Element;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// Screen: /register (self-service sign-up; an app without it doesn't mount
|
|
3
|
+
// this route). Built the same way login.tsx was: FieldGroup/
|
|
4
|
+
// Field directly, not EntityForm — a registration form has no "cancel"
|
|
5
|
+
// destination either.
|
|
6
|
+
import { zodResolver } from '@hookform/resolvers/zod';
|
|
7
|
+
import { useState } from 'react';
|
|
8
|
+
import { useForm } from 'react-hook-form';
|
|
9
|
+
import { Link, useLocation, useNavigate } from 'react-router';
|
|
10
|
+
import { useAuth } from '../auth/use-auth.js';
|
|
11
|
+
import { ErrorState } from '../components/app/error-state.js';
|
|
12
|
+
import { PasswordInput } from '../components/app/password-input.js';
|
|
13
|
+
import { Button } from '../components/ui/button.js';
|
|
14
|
+
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card.js';
|
|
15
|
+
import { Field, FieldError, FieldGroup, FieldLabel } from '../components/ui/field.js';
|
|
16
|
+
import { Input } from '../components/ui/input.js';
|
|
17
|
+
import { Spinner } from '../components/ui/spinner.js';
|
|
18
|
+
import { REGISTER_FORM_DEFAULTS, registerFormSchema } from './register-schema.js';
|
|
19
|
+
import { returnPath } from './return-path.js';
|
|
20
|
+
export function RegisterRoute() {
|
|
21
|
+
const { register } = useAuth();
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
const location = useLocation();
|
|
24
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
25
|
+
const [submitError, setSubmitError] = useState(null);
|
|
26
|
+
const form = useForm({
|
|
27
|
+
resolver: zodResolver(registerFormSchema),
|
|
28
|
+
defaultValues: REGISTER_FORM_DEFAULTS,
|
|
29
|
+
});
|
|
30
|
+
const onSubmit = form.handleSubmit(async (values) => {
|
|
31
|
+
setSubmitError(null);
|
|
32
|
+
setIsSubmitting(true);
|
|
33
|
+
try {
|
|
34
|
+
await register(values.email, values.name, values.password);
|
|
35
|
+
navigate(returnPath(location.state), { replace: true });
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
const error = err;
|
|
39
|
+
if (error.kind === 'validation' && error.fieldErrors) {
|
|
40
|
+
for (const [field, messages] of Object.entries(error.fieldErrors)) {
|
|
41
|
+
if (field in REGISTER_FORM_DEFAULTS) {
|
|
42
|
+
form.setError(field, { message: messages[0] });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
setSubmitError(error);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
setSubmitError(error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
setIsSubmitting(false);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
return (_jsx("main", { className: "flex min-h-screen items-center justify-center bg-background p-6", children: _jsxs(Card, { className: "w-full max-w-sm", children: [_jsx(CardHeader, { children: _jsx(CardTitle, { className: "text-lg", children: _jsx("h1", { children: "Create an account" }) }) }), _jsx(CardContent, { children: _jsxs("form", { onSubmit: onSubmit, noValidate: true, className: "flex flex-col gap-6", children: [_jsxs(FieldGroup, { children: [_jsxs(Field, { "data-invalid": !!form.formState.errors.name, children: [_jsx(FieldLabel, { htmlFor: "register-name", children: "Name" }), _jsx(Input, { id: "register-name", autoComplete: "name", "aria-invalid": !!form.formState.errors.name, ...form.register('name') }), _jsx(FieldError, { errors: [form.formState.errors.name] })] }), _jsxs(Field, { "data-invalid": !!form.formState.errors.email, children: [_jsx(FieldLabel, { htmlFor: "register-email", children: "Email" }), _jsx(Input, { id: "register-email", type: "email", autoComplete: "username", "aria-invalid": !!form.formState.errors.email, ...form.register('email') }), _jsx(FieldError, { errors: [form.formState.errors.email] })] }), _jsxs(Field, { "data-invalid": !!form.formState.errors.password, children: [_jsx(FieldLabel, { htmlFor: "register-password", children: "Password" }), _jsx(PasswordInput, { id: "register-password", autoComplete: "new-password", "aria-invalid": !!form.formState.errors.password, ...form.register('password') }), _jsx(FieldError, { errors: [form.formState.errors.password] })] }), _jsxs(Field, { "data-invalid": !!form.formState.errors.confirmPassword, children: [_jsx(FieldLabel, { htmlFor: "register-confirm-password", children: "Confirm password" }), _jsx(PasswordInput, { id: "register-confirm-password", autoComplete: "new-password", "aria-invalid": !!form.formState.errors.confirmPassword, ...form.register('confirmPassword') }), _jsx(FieldError, { errors: [form.formState.errors.confirmPassword] })] })] }), submitError && _jsx(ErrorState, { error: submitError }), _jsxs(Button, { type: "submit", disabled: isSubmitting, children: [isSubmitting && _jsx(Spinner, { className: "size-4" }), "Create account"] }), _jsx(Button, { variant: "link", size: "sm", nativeButton: false, render: _jsx(Link, { to: "/login", state: location.state }), children: "Sign in instead" })] }) })] }) }));
|
|
58
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function returnPath(state: unknown): string;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Where to go after logging in or registering: the protected page AppShell
|
|
2
|
+
// redirected away from (it passes `{ from }` as router state), else home.
|
|
3
|
+
// Router state can't be set by a link, but only accept an in-app path
|
|
4
|
+
// anyway ("/x", never "//host" or "https://..."), so this can't become an
|
|
5
|
+
// open redirect if a future caller ever feeds it from a query param.
|
|
6
|
+
export function returnPath(state) {
|
|
7
|
+
const from = state?.from;
|
|
8
|
+
return typeof from === 'string' && from.startsWith('/') && !from.startsWith('//') ? from : '/';
|
|
9
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type Page } from '@playwright/test';
|
|
2
|
+
import type { MswOverride } from '../mocks/override.js';
|
|
3
|
+
export type { MswHandle, MswOverride } from '../mocks/override.js';
|
|
4
|
+
/**
|
|
5
|
+
* Forces a response for the page's *first* load: set before navigation, so
|
|
6
|
+
* it wins the race against the app's own first fetch. For states forced
|
|
7
|
+
* after load, use `waitForMswReady` and then `window.__msw.worker.use(...)`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function forceMswOverride(page: Page, override: MswOverride): Promise<void>;
|
|
10
|
+
/** `window.__msw` is set only once the app's mocking has started — page.goto's load event doesn't wait for it. */
|
|
11
|
+
export declare function waitForMswReady(page: Page): Promise<void>;
|
|
12
|
+
/** Starts the page logged out: GET /auth/me answers 401 on first load. */
|
|
13
|
+
export declare function forceLoggedOut(page: Page): Promise<void>;
|
|
14
|
+
export declare function expectNoAxeViolations(page: Page): Promise<void>;
|
|
15
|
+
export type A11ySuiteOptions = {
|
|
16
|
+
/**
|
|
17
|
+
* Form screens, which aren't in the sidebar: each entity's create route,
|
|
18
|
+
* plus an edit route when the form shows something only existing data has.
|
|
19
|
+
*/
|
|
20
|
+
formRoutes: readonly string[];
|
|
21
|
+
/** Screens outside the app shell, e.g. '/login' (and '/register' if the app has it). */
|
|
22
|
+
loggedOutRoutes: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Accessibility of the real screens in light and dark mode: every page in
|
|
26
|
+
* the Primary nav (discovered, so a new entity's table is covered with no
|
|
27
|
+
* edit), the given form routes, and the logged-out screens. axe's
|
|
28
|
+
* color-contrast rule against the rendered dark DOM is also what keeps the
|
|
29
|
+
* dark-mode tokens readable. The theme follows the OS setting, so
|
|
30
|
+
* emulateMedia switches it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function defineA11ySuite({ formRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
|
|
33
|
+
/**
|
|
34
|
+
* Mock mode is announced on every route, logged-out ones included — /login
|
|
35
|
+
* is where a mock session passing for a real one first cost someone time.
|
|
36
|
+
* The default Playwright run is MSW-backed, so the banner must be there.
|
|
37
|
+
*/
|
|
38
|
+
export declare function defineMockModeBannerSuite({ routes }: {
|
|
39
|
+
routes: readonly string[];
|
|
40
|
+
}): void;
|