@tristan2828/ui-foundation 3.0.0 → 3.1.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/README.md CHANGED
@@ -18,7 +18,7 @@ than installing this by hand:
18
18
  | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
19
19
  | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, table, textarea, tooltip |
20
20
  | `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
21
- | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `exposeMswForE2E` |
21
+ | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
22
22
  | `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
23
23
  | `@tristan2828/ui-foundation/eslint` | the lint rules, as a flat config: `export default [...uiFoundation()]` |
24
24
  | `@tristan2828/ui-foundation/styles.css` | tokens and base styles, after `@import "tailwindcss";` |
@@ -34,7 +34,8 @@ Run from an app's root:
34
34
  `sync --check` (in `verify`) fails when they differ from the installed
35
35
  version.
36
36
  - `ui-foundation check-contract [openapi.yaml]` fails unless the app's spec
37
- contains the foundation's part unchanged.
37
+ contains the foundation's part unchanged. A path marked `x-optional` may
38
+ be left out, and so may the components only such a path uses.
38
39
  - `ui-foundation check-deps` fails if `package.json` names a dependency
39
40
  missing from `deps-allowlist.json`.
40
41
 
@@ -160,6 +160,42 @@ function normalise(value) {
160
160
 
161
161
  const same = (a, b) => JSON.stringify(normalise(a)) === JSON.stringify(normalise(b))
162
162
 
163
+ // Every component a value reaches through $refs, following refs inside the
164
+ // components themselves, as 'section/name' keys.
165
+ function reachableComponents(roots, components) {
166
+ const found = new Set()
167
+ const walk = (value) => {
168
+ if (Array.isArray(value)) return value.forEach(walk)
169
+ if (!value || typeof value !== 'object') return
170
+ for (const [key, child] of Object.entries(value)) {
171
+ const match = key === '$ref' && typeof child === 'string' && child.match(/^#\/components\/([^/]+)\/([^/]+)/)
172
+ if (match) {
173
+ const id = `${match[1]}/${match[2]}`
174
+ if (!found.has(id)) {
175
+ found.add(id)
176
+ walk(components[match[1]]?.[match[2]])
177
+ }
178
+ } else {
179
+ walk(child)
180
+ }
181
+ }
182
+ }
183
+ roots.forEach(walk)
184
+ return found
185
+ }
186
+
187
+ // Components reachable from optional paths the app leaves out, and from no
188
+ // path it must (or does) have.
189
+ function optionalOnlyComponents(foundation, appHasPath) {
190
+ const required = []
191
+ const omitted = []
192
+ for (const [route, item] of Object.entries(foundation.paths)) {
193
+ ;(item['x-optional'] && !appHasPath(route) ? omitted : required).push(item)
194
+ }
195
+ const requiredIds = reachableComponents(required, foundation.components)
196
+ return new Set([...reachableComponents(omitted, foundation.components)].filter((id) => !requiredIds.has(id)))
197
+ }
198
+
163
199
  function checkContract(specPath) {
164
200
  const target = path.resolve(APP_ROOT, specPath)
165
201
  if (!existsSync(target)) fail(`check-contract: no ${specPath} here`)
@@ -175,11 +211,18 @@ function checkContract(specPath) {
175
211
  problems.push(`path ${route} differs`)
176
212
  }
177
213
  }
214
+ // A component only an omitted optional path uses may be left out with it
215
+ // (RegisterRequest, when there's no /auth/register). Computed from the
216
+ // refs, so a future optional path can't forget to mark its schemas.
217
+ const optionalOnly = optionalOnlyComponents(foundation, (route) => app.paths?.[route] !== undefined)
178
218
  for (const [section, entries] of Object.entries(foundation.components)) {
179
219
  for (const [name, entry] of Object.entries(entries)) {
180
220
  const appEntry = app.components?.[section]?.[name]
181
- if (appEntry === undefined) problems.push(`missing components.${section}.${name}`)
182
- else if (!same(appEntry, entry)) problems.push(`components.${section}.${name} differs`)
221
+ if (appEntry === undefined) {
222
+ if (!optionalOnly.has(`${section}/${name}`)) problems.push(`missing components.${section}.${name}`)
223
+ } else if (!same(appEntry, entry)) {
224
+ problems.push(`components.${section}.${name} differs`)
225
+ }
183
226
  }
184
227
  }
185
228
 
@@ -132,7 +132,7 @@ function SidebarGroupLabel({ className, render, ...props }) {
132
132
  return useRender({
133
133
  defaultTagName: "div",
134
134
  props: mergeProps({
135
- className: cn("flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", className),
135
+ className: cn("flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70 ring-sidebar-ring outline-hidden transition-[margin,opacity] duration-200 ease-linear group-data-[collapsible=icon]:pointer-events-none group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0 focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0", className),
136
136
  }, props),
137
137
  render,
138
138
  state: {
@@ -4,6 +4,13 @@ export declare const MOCK_USER: User;
4
4
  export declare const MOCK_PASSWORD = "dev-password-123";
5
5
  export declare function isMockAuthenticated(): boolean;
6
6
  export declare function setMockAuthenticated(value: boolean): void;
7
+ /**
8
+ * The user the mock session is signed in as, or null when signed out — the
9
+ * same answer GET /auth/me gives. For an app's own handlers that act as the
10
+ * signed-in user (per-user rows): after a mock registration this is the new
11
+ * user, not MOCK_USER. On null, answer 401 as /auth/me does.
12
+ */
13
+ export declare function getMockCurrentUser(): User | null;
7
14
  export declare function resetMockAuth(): void;
8
15
  export declare const authHandlers: import("msw").HttpHandler[];
9
16
  export {};
@@ -22,6 +22,15 @@ export function isMockAuthenticated() {
22
22
  export function setMockAuthenticated(value) {
23
23
  authenticated = value;
24
24
  }
25
+ /**
26
+ * The user the mock session is signed in as, or null when signed out — the
27
+ * same answer GET /auth/me gives. For an app's own handlers that act as the
28
+ * signed-in user (per-user rows): after a mock registration this is the new
29
+ * user, not MOCK_USER. On null, answer 401 as /auth/me does.
30
+ */
31
+ export function getMockCurrentUser() {
32
+ return authenticated ? currentUser : null;
33
+ }
25
34
  export function resetMockAuth() {
26
35
  authenticated = true;
27
36
  registeredUsers = new Map([[MOCK_USER.email, MOCK_USER]]);
@@ -1,3 +1,3 @@
1
- export { authHandlers, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
1
+ export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
2
2
  export { exposeMswForE2E } from './e2e-hooks.js';
3
3
  export type { MswHandle, MswOverride } from './override.js';
@@ -1,5 +1,5 @@
1
1
  // `@tristan2828/ui-foundation/mocks` — MSW support. A separate entry point
2
2
  // so msw (an optional peer dependency) never reaches an app's main bundle
3
3
  // unless it imports this.
4
- export { authHandlers, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
4
+ export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
5
5
  export { exposeMswForE2E } from './e2e-hooks.js';
@@ -30,11 +30,23 @@ export type A11ySuiteOptions = {
30
30
  * emulateMedia switches it.
31
31
  */
32
32
  export declare function defineA11ySuite({ formRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
33
+ export type MockModeBannerSuiteOptions = {
34
+ /** Screens to check, logged-out ones included (e.g. '/login'). */
35
+ routes: readonly string[];
36
+ /**
37
+ * Whether this run's app is MSW-backed. Defaults to
38
+ * `process.env.VITE_API !== 'real'`, the same switch the app is built with.
39
+ * When false (a run against the real backend) the banner must be absent.
40
+ */
41
+ mockMode?: boolean;
42
+ };
33
43
  /**
34
44
  * Mock mode is announced on every route, logged-out ones included — /login
35
45
  * is where a mock session passing for a real one first cost someone time.
36
46
  * The default Playwright run is MSW-backed, so the banner must be there.
47
+ *
48
+ * Against the real backend (`VITE_API=real`) it asserts the opposite: no
49
+ * banner on any route. That half catches a mock bundle shipped as if it
50
+ * were production, which is what the banner exists for.
37
51
  */
38
- export declare function defineMockModeBannerSuite({ routes }: {
39
- routes: readonly string[];
40
- }): void;
52
+ export declare function defineMockModeBannerSuite({ routes, mockMode, }: MockModeBannerSuiteOptions): void;
@@ -3,6 +3,12 @@
3
3
  // calls a suite with its own routes, instead of carrying a copy of the spec
4
4
  // to edit (each of these was forked by the first real app for exactly that).
5
5
  //
6
+ // Call a suite inside the spec file's own `test.describe(...)`. Playwright
7
+ // locates a test where test() is called, which for a suite is this package;
8
+ // a file argument (`playwright test e2e/a11y.spec.ts`) keeps a test only if
9
+ // it or an enclosing describe is located in that file. Without the app's
10
+ // own describe, selecting the spec by file runs nothing.
11
+ //
6
12
  // Runs in Node, inside Playwright: @playwright/test and @axe-core/playwright
7
13
  // are optional peer dependencies, needed only by apps that import this.
8
14
  import AxeBuilder from '@axe-core/playwright';
@@ -91,13 +97,33 @@ export function defineA11ySuite({ formRoutes, loggedOutRoutes }) {
91
97
  * Mock mode is announced on every route, logged-out ones included — /login
92
98
  * is where a mock session passing for a real one first cost someone time.
93
99
  * The default Playwright run is MSW-backed, so the banner must be there.
100
+ *
101
+ * Against the real backend (`VITE_API=real`) it asserts the opposite: no
102
+ * banner on any route. That half catches a mock bundle shipped as if it
103
+ * were production, which is what the banner exists for.
94
104
  */
95
- export function defineMockModeBannerSuite({ routes }) {
96
- test('mock mode is announced on every route, including logged-out ones', async ({ page }) => {
105
+ export function defineMockModeBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', }) {
106
+ if (mockMode) {
107
+ test('mock mode is announced on every route, including logged-out ones', async ({ page }) => {
108
+ const banner = page.getByRole('status').filter({ hasText: 'Mock data' });
109
+ for (const route of routes) {
110
+ await page.goto(route);
111
+ await test.step(route, () => expect(banner).toBeVisible());
112
+ }
113
+ });
114
+ return;
115
+ }
116
+ test('no mock-mode banner on any route against the real backend', async ({ page }) => {
97
117
  const banner = page.getByRole('status').filter({ hasText: 'Mock data' });
98
118
  for (const route of routes) {
99
119
  await page.goto(route);
100
- await test.step(route, () => expect(banner).toBeVisible());
120
+ await test.step(route, async () => {
121
+ // The banner renders beside the router, so once a screen's <main>
122
+ // is on the page the banner would be too. Without this wait a
123
+ // blank page would pass the count check trivially.
124
+ await expect(page.getByRole('main')).toBeVisible();
125
+ await expect(banner).toHaveCount(0);
126
+ });
101
127
  }
102
128
  });
103
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.0.0",
3
+ "version": "3.1.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": {
@@ -91,17 +91,39 @@
91
91
  "zod": "^4.6.5"
92
92
  },
93
93
  "peerDependenciesMeta": {
94
- "@axe-core/playwright": { "optional": true },
95
- "@eslint/js": { "optional": true },
96
- "@playwright/test": { "optional": true },
97
- "@tanstack/eslint-plugin-query": { "optional": true },
98
- "eslint": { "optional": true },
99
- "eslint-plugin-check-file": { "optional": true },
100
- "eslint-plugin-react-hooks": { "optional": true },
101
- "eslint-plugin-react-refresh": { "optional": true },
102
- "globals": { "optional": true },
103
- "msw": { "optional": true },
104
- "typescript-eslint": { "optional": true }
94
+ "@axe-core/playwright": {
95
+ "optional": true
96
+ },
97
+ "@eslint/js": {
98
+ "optional": true
99
+ },
100
+ "@playwright/test": {
101
+ "optional": true
102
+ },
103
+ "@tanstack/eslint-plugin-query": {
104
+ "optional": true
105
+ },
106
+ "eslint": {
107
+ "optional": true
108
+ },
109
+ "eslint-plugin-check-file": {
110
+ "optional": true
111
+ },
112
+ "eslint-plugin-react-hooks": {
113
+ "optional": true
114
+ },
115
+ "eslint-plugin-react-refresh": {
116
+ "optional": true
117
+ },
118
+ "globals": {
119
+ "optional": true
120
+ },
121
+ "msw": {
122
+ "optional": true
123
+ },
124
+ "typescript-eslint": {
125
+ "optional": true
126
+ }
105
127
  },
106
128
  "dependencies": {
107
129
  "@fontsource-variable/geist": "^5.3.0",