@tristan2828/ui-foundation 3.18.1 → 3.19.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,14 +18,14 @@ than installing this by hand:
18
18
 
19
19
  | Import | What |
20
20
  |---|---|
21
- | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
21
+ | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `DataEnvironmentBanner`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
22
22
  | `@tristan2828/ui-foundation/ui/<name>` | shadcn primitives: badge, button, calendar, card, checkbox, combobox, dialog, empty, field, input, input-group, label, popover, select, separator, sheet, sidebar, skeleton, sonner, spinner, switch, table, textarea, tooltip |
23
23
  | `@tristan2828/ui-foundation/gateway` | `safeFetch`, `toAppError`, `networkError` — for an app's `src/api/gateway/` only |
24
- | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
25
- | `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
24
+ | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `environmentHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
25
+ | `@tristan2828/ui-foundation/testing` | Playwright: `defineA11ySuite`, `defineMockModeBannerSuite`, `defineDataEnvironmentBannerSuite`, `forceMswOverride`, `forceLoggedOut`, `waitForMswReady` |
26
26
  | `@tristan2828/ui-foundation/eslint` | the lint rules, as a flat config: `export default [...uiFoundation()]` |
27
27
  | `@tristan2828/ui-foundation/styles.css` | tokens and base styles, after `@import "tailwindcss";` |
28
- | `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth/*` and the error bodies |
28
+ | `@tristan2828/ui-foundation/openapi.yaml` | the part of an app's API contract the package calls: `/auth/*`, `/environment` (optional) and the error bodies |
29
29
 
30
30
  ## The `ui-foundation` command
31
31
 
@@ -37,8 +37,8 @@ is right. Do not disable, skip, or work around it.
37
37
  - NEVER hand-write an API type. All types come from `src/api/schema.d.ts`,
38
38
  generated from `openapi.yaml`. If a type is missing, run `npm run gen:api`.
39
39
  (Enforced: codegen diff in verify.)
40
- - NEVER change the foundation's part of `openapi.yaml` — the `/auth/*` paths
41
- and the error envelopes. The package's code calls exactly those shapes.
40
+ - NEVER change the foundation's part of `openapi.yaml` — the `/auth/*` and
41
+ `/environment` paths and the error envelopes. The package's code calls exactly those shapes.
42
42
  (Enforced: `ui-foundation check-contract` in verify.)
43
43
  - NEVER use a raw hex value or a Tailwind palette color (`bg-blue-500`).
44
44
  Semantic tokens only: `bg-primary`, `text-muted-foreground`. (Enforced:
@@ -68,7 +68,7 @@ is a filled-in example while the Widgets demo is still here).
68
68
  query parameter for each field the plan marks as a filter. Reuse the
69
69
  `Page` and error components already in the spec; do not redefine
70
70
  pagination or error shapes per entity, and leave the foundation's part
71
- of the spec (`/auth/*`, the error bodies) alone — `ui-foundation
71
+ of the spec (`/auth/*`, `/environment`, the error bodies) alone — `ui-foundation
72
72
  check-contract` fails verify if it changes.
73
73
  2. **`npm run gen:api`** to regenerate `src/api/schema.d.ts`. Never
74
74
  hand-edit it.
@@ -0,0 +1 @@
1
+ export declare function getDataLabel(): Promise<string | null>;
@@ -0,0 +1,10 @@
1
+ import { safeFetch, toAppError } from "./errors.js";
2
+ export async function getDataLabel() {
3
+ const res = await safeFetch("/environment");
4
+ if (res.status === 404)
5
+ return null;
6
+ if (res.status !== 200)
7
+ throw toAppError(res.status, res.body);
8
+ const label = res.body?.dataLabel;
9
+ return typeof label === "string" && label.trim() !== "" ? label.trim() : null;
10
+ }
@@ -71,10 +71,34 @@ export interface paths {
71
71
  patch?: never;
72
72
  trace?: never;
73
73
  };
74
+ "/environment": {
75
+ parameters: {
76
+ query?: never;
77
+ header?: never;
78
+ path?: never;
79
+ cookie?: never;
80
+ };
81
+ /**
82
+ * Which data the backend is serving, for DataEnvironmentBanner
83
+ * @description Needs no session: the banner shows on /login too. A null dataLabel means production data, and no banner.
84
+ */
85
+ get: operations["getDataEnvironment"];
86
+ put?: never;
87
+ post?: never;
88
+ delete?: never;
89
+ options?: never;
90
+ head?: never;
91
+ patch?: never;
92
+ trace?: never;
93
+ };
74
94
  }
75
95
  export type webhooks = Record<string, never>;
76
96
  export interface components {
77
97
  schemas: {
98
+ DataEnvironment: {
99
+ /** @description What the data is, as the banner names it ("dev" shows "Dev data"). null when the backend serves production data. */
100
+ dataLabel: string | null;
101
+ };
78
102
  User: {
79
103
  readonly id: number;
80
104
  /** Format: email */
@@ -243,4 +267,25 @@ export interface operations {
243
267
  500: components["responses"]["ServerError"];
244
268
  };
245
269
  };
270
+ getDataEnvironment: {
271
+ parameters: {
272
+ query?: never;
273
+ header?: never;
274
+ path?: never;
275
+ cookie?: never;
276
+ };
277
+ requestBody?: never;
278
+ responses: {
279
+ /** @description OK. */
280
+ 200: {
281
+ headers: {
282
+ [name: string]: unknown;
283
+ };
284
+ content: {
285
+ "application/json": components["schemas"]["DataEnvironment"];
286
+ };
287
+ };
288
+ 500: components["responses"]["ServerError"];
289
+ };
290
+ };
246
291
  }
@@ -2,6 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { useEffect } from 'react';
3
3
  import { useQuery, useQueryClient } from '@tanstack/react-query';
4
4
  import { getCurrentUser, login as loginRequest, logout as logoutRequest, register as registerRequest, } from '../api/gateway/auth.js';
5
+ import { DATA_LABEL_QUERY_KEY } from '../hooks/use-data-label.js';
5
6
  import { AuthContext } from './auth-context.js';
6
7
  // TanStack Query, not useEffect — AGENTS.md's "NEVER fetch in useEffect"
7
8
  // rule applies here too. A 401 here just means "not logged in", which the
@@ -11,14 +12,17 @@ const AUTH_QUERY_KEY = ['auth', 'me'];
11
12
  function isAuthError(error) {
12
13
  return error?.kind === 'auth';
13
14
  }
14
- // Every cached query except the session itself belongs to whoever was
15
- // logged in when it ran. Dropped on every session change so the next user
16
- // never sees the previous user's rows — widgets are per-user on the
17
- // backend. `removeQueries` with a predicate, not `clear()`: clear() would
18
- // also drop the auth query this provider's own useQuery observes, and that
19
- // observer wouldn't pick up the replacement setQueryData creates.
15
+ // Every cached query except these belongs to whoever was logged in when it
16
+ // ran: the session itself, and which data the backend serves
17
+ // (DataEnvironmentBanner's, the same for everyone).
18
+ const SESSION_INDEPENDENT_KEYS = new Set([AUTH_QUERY_KEY[0], DATA_LABEL_QUERY_KEY[0]]);
19
+ // Dropped on every session change so the next user never sees the
20
+ // previous user's rows — widgets are per-user on the backend.
21
+ // `removeQueries` with a predicate, not `clear()`: clear() would also drop
22
+ // the auth query this provider's own useQuery observes, and that observer
23
+ // wouldn't pick up the replacement setQueryData creates.
20
24
  function removeUserScopedQueries(queryClient) {
21
- queryClient.removeQueries({ predicate: (query) => query.queryKey[0] !== AUTH_QUERY_KEY[0] });
25
+ queryClient.removeQueries({ predicate: (query) => !SESSION_INDEPENDENT_KEYS.has(query.queryKey[0]) });
22
26
  }
23
27
  function startSession(queryClient, user) {
24
28
  removeUserScopedQueries(queryClient);
@@ -0,0 +1,5 @@
1
+ export declare function DataEnvironmentBanner(): import("react").JSX.Element;
2
+ /** The banner itself, for a label already known (Storybook, tests). Renders nothing for null. */
3
+ export declare function DataEnvironmentNotice({ label }: {
4
+ label: string | null;
5
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,28 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { DatabaseIcon } from 'lucide-react';
3
+ import { useDataLabel } from '../../hooks/use-data-label.js';
4
+ // The real backend, on data that isn't production's: a dev branch of the
5
+ // hosted database, a local one. Locally the app looks exactly like the
6
+ // deployed one (same data, same login), and an edit made there lands in
7
+ // data that gets reset. Only the backend knows which database it's on, so
8
+ // the label is its DATA_LABEL, served from GET /environment. Unset in
9
+ // production: no label, no banner.
10
+ //
11
+ // Rendered above the router, inside FoundationProviders (it queries
12
+ // through TanStack Query), so it shows on every route including /login.
13
+ // In mock mode the mock handler answers null: MockModeBanner already says
14
+ // what the data is there.
15
+ export function DataEnvironmentBanner() {
16
+ const label = useDataLabel();
17
+ return _jsx(DataEnvironmentNotice, { label: label });
18
+ }
19
+ /** The banner itself, for a label already known (Storybook, tests). Renders nothing for null. */
20
+ export function DataEnvironmentNotice({ label }) {
21
+ if (label === null)
22
+ return null;
23
+ return (_jsxs("div", { role: "status", "data-slot": "data-environment-banner", className: "flex h-9 items-center justify-center gap-1.5 border-b border-border bg-info px-4 text-sm text-info-foreground", children: [_jsx(DatabaseIcon, { className: "size-4 shrink-0", "aria-hidden": "true" }), _jsxs("span", { className: "truncate", children: [_jsx("strong", { className: "font-semibold", children: `${dataName(label)}:` }), " changes here don't reach production"] })] }));
24
+ }
25
+ // "dev" → "Dev data", "staging" → "Staging data".
26
+ function dataName(label) {
27
+ return `${label.charAt(0).toUpperCase()}${label.slice(1)} data`;
28
+ }
@@ -9,6 +9,8 @@ import { ThemeProvider } from '../theme-provider.js';
9
9
  // works: the theme outermost, the mock-mode banner above the router so it
10
10
  // shows on every route (/login included), and AuthProvider inside
11
11
  // QueryClientProvider because it queries /auth/me through TanStack Query.
12
+ // An app's DataEnvironmentBanner goes first among the children, before its
13
+ // router: it queries too, and shows on every route like the mock banner.
12
14
  export function FoundationProviders({ mockMode, queryClient, children, }) {
13
15
  const [client] = useState(() => queryClient ?? createQueryClient());
14
16
  return (_jsxs(ThemeProvider, { attribute: "class", defaultTheme: "system", enableSystem: true, children: [_jsx(MockModeBanner, { active: mockMode }), _jsx(QueryClientProvider, { client: client, children: _jsx(AuthProvider, { children: children }) })] }));
@@ -0,0 +1,3 @@
1
+ export declare const DATA_LABEL_QUERY_KEY: readonly ["environment"];
2
+ /** The backend's data label ("dev"), or null for production data, a failed request, or while it loads. */
3
+ export declare function useDataLabel(): string | null;
@@ -0,0 +1,18 @@
1
+ import { useQuery } from '@tanstack/react-query';
2
+ import { getDataLabel } from '../api/gateway/environment.js';
3
+ // Which data the backend serves (GET /environment), for
4
+ // DataEnvironmentBanner. It can't change while the page is open, so it's
5
+ // asked once per load: never stale, never refetched on focus. And it's the
6
+ // same whoever is signed in, so AuthProvider keeps it across session
7
+ // changes instead of dropping it with the user's rows.
8
+ export const DATA_LABEL_QUERY_KEY = ['environment'];
9
+ /** The backend's data label ("dev"), or null for production data, a failed request, or while it loads. */
10
+ export function useDataLabel() {
11
+ const { data } = useQuery({
12
+ queryKey: DATA_LABEL_QUERY_KEY,
13
+ queryFn: getDataLabel,
14
+ staleTime: Infinity,
15
+ gcTime: Infinity,
16
+ });
17
+ return data ?? null;
18
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,7 @@ export { DataTable, type DataTableProps, type SortingState } from './components/
3
3
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
4
4
  export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
5
5
  export { editInPlace, type EditControlProps, type EditInPlace, type EditInPlaceOptions, type EditKind, } from './components/app/edit-in-place.js';
6
+ export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
6
7
  export { EditableValue, type EditableValueProps } from './components/app/editable-value.js';
7
8
  export { ErrorState } from './components/app/error-state.js';
8
9
  export { FoundationProviders } from './components/app/foundation-providers.js';
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ export { DataTable } from './components/app/data-table.js';
8
8
  export { EntityForm } from './components/app/entity-form.js';
9
9
  export { EntityView, } from './components/app/entity-view.js';
10
10
  export { editInPlace, } from './components/app/edit-in-place.js';
11
+ export { DataEnvironmentBanner } from './components/app/data-environment-banner.js';
11
12
  export { EditableValue } from './components/app/editable-value.js';
12
13
  export { ErrorState } from './components/app/error-state.js';
13
14
  export { FoundationProviders } from './components/app/foundation-providers.js';
@@ -0,0 +1 @@
1
+ export declare const environmentHandlers: import("msw").HttpHandler[];
@@ -0,0 +1,8 @@
1
+ // MSW handler for GET /environment (openapi/foundation.yaml). Mock data is
2
+ // never production's, but MockModeBanner already says so: answering null
3
+ // keeps DataEnvironmentBanner out of mock mode. An app that renders the
4
+ // banner spreads `environmentHandlers` into its own handler list.
5
+ import { http, HttpResponse } from 'msw';
6
+ export const environmentHandlers = [
7
+ http.get('*/api/environment', () => HttpResponse.json({ dataLabel: null })),
8
+ ];
@@ -1,3 +1,4 @@
1
1
  export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
2
+ export { environmentHandlers } from './environment.js';
2
3
  export { exposeMswForE2E } from './e2e-hooks.js';
3
4
  export type { MswHandle, MswOverride } from './override.js';
@@ -2,4 +2,5 @@
2
2
  // so msw (an optional peer dependency) never reaches an app's main bundle
3
3
  // unless it imports this.
4
4
  export { authHandlers, getMockCurrentUser, isMockAuthenticated, MOCK_PASSWORD, MOCK_USER, resetMockAuth, setMockAuthenticated, } from './auth.js';
5
+ export { environmentHandlers } from './environment.js';
5
6
  export { exposeMswForE2E } from './e2e-hooks.js';
@@ -73,3 +73,29 @@ export type MockModeBannerSuiteOptions = {
73
73
  * were production, which is what the banner exists for.
74
74
  */
75
75
  export declare function defineMockModeBannerSuite({ routes, mockMode, }: MockModeBannerSuiteOptions): void;
76
+ export type DataEnvironmentBannerSuiteOptions = {
77
+ /** Screens to check, logged-out ones included (e.g. '/login'). */
78
+ routes: readonly string[];
79
+ /** Whether this run's app is MSW-backed. Defaults to `process.env.VITE_API !== 'real'`, as for the mock-mode banner. */
80
+ mockMode?: boolean;
81
+ /**
82
+ * Against the real backend only: the DATA_LABEL it was started with.
83
+ * Defaults to `process.env.DATA_LABEL`, null when that's unset or empty
84
+ * (production data, so no banner).
85
+ */
86
+ dataLabel?: string | null;
87
+ };
88
+ /**
89
+ * DataEnvironmentBanner: shown on every route, logged-out ones included,
90
+ * whenever the backend says its data isn't production's, and nowhere when
91
+ * it says it is.
92
+ *
93
+ * Mock mode: the mock handler answers null, so no banner (MockModeBanner
94
+ * covers mock data). A label forced through MSW shows on every route, in
95
+ * a tone apart from the mock banner, and passes axe in light and dark.
96
+ *
97
+ * Real backend (`VITE_API=real`): the banner names `dataLabel` on every
98
+ * route, or is absent when it's null, which is how a production deploy
99
+ * runs.
100
+ */
101
+ export declare function defineDataEnvironmentBannerSuite({ routes, mockMode, dataLabel, }: DataEnvironmentBannerSuiteOptions): void;
@@ -154,3 +154,83 @@ export function defineMockModeBannerSuite({ routes, mockMode = process.env.VITE_
154
154
  }
155
155
  });
156
156
  }
157
+ // The banner names the label as data: "dev" → "Dev data".
158
+ const dataName = (label) => `${label.charAt(0).toUpperCase()}${label.slice(1)} data`;
159
+ /**
160
+ * Opens a route and waits for the app's own GET /environment to answer,
161
+ * so "no banner" is checked after the backend has spoken, not before.
162
+ */
163
+ async function gotoWithDataLabel(page, route) {
164
+ const answered = page.waitForResponse((response) => new URL(response.url()).pathname === '/api/environment');
165
+ await page.goto(route);
166
+ await answered;
167
+ // The banner renders beside the router: once a screen's <main> is on
168
+ // the page, the banner would be too.
169
+ await expect(page.getByRole('main')).toBeVisible();
170
+ }
171
+ /**
172
+ * DataEnvironmentBanner: shown on every route, logged-out ones included,
173
+ * whenever the backend says its data isn't production's, and nowhere when
174
+ * it says it is.
175
+ *
176
+ * Mock mode: the mock handler answers null, so no banner (MockModeBanner
177
+ * covers mock data). A label forced through MSW shows on every route, in
178
+ * a tone apart from the mock banner, and passes axe in light and dark.
179
+ *
180
+ * Real backend (`VITE_API=real`): the banner names `dataLabel` on every
181
+ * route, or is absent when it's null, which is how a production deploy
182
+ * runs.
183
+ */
184
+ export function defineDataEnvironmentBannerSuite({ routes, mockMode = process.env.VITE_API !== 'real', dataLabel = process.env.DATA_LABEL?.trim() || null, }) {
185
+ const banner = (page) => page.getByRole('status').filter({ hasText: "changes here don't reach production" });
186
+ const expectAbsentEverywhere = async (page) => {
187
+ for (const route of routes) {
188
+ await gotoWithDataLabel(page, route);
189
+ await test.step(route, () => expect(banner(page)).toHaveCount(0));
190
+ }
191
+ };
192
+ const expectNamedEverywhere = async (page, label) => {
193
+ for (const route of routes) {
194
+ await gotoWithDataLabel(page, route);
195
+ await test.step(route, () => expect(banner(page)).toContainText(`${dataName(label)}:`));
196
+ }
197
+ };
198
+ if (!mockMode) {
199
+ if (dataLabel === null) {
200
+ test('no data-environment banner on production data', async ({ page }) => {
201
+ await expectAbsentEverywhere(page);
202
+ });
203
+ }
204
+ else {
205
+ test(`"${dataName(dataLabel)}" is announced on every route, including logged-out ones`, async ({ page }) => {
206
+ await expectNamedEverywhere(page, dataLabel);
207
+ });
208
+ }
209
+ return;
210
+ }
211
+ const forceLabel = (page) => forceMswOverride(page, { method: 'get', path: '*/api/environment', body: { dataLabel: 'dev' } });
212
+ test('no data-environment banner in mock mode, where the mock banner says it', async ({ page }) => {
213
+ await expectAbsentEverywhere(page);
214
+ });
215
+ test('a data label from the backend is announced on every route, including logged-out ones', async ({ page }) => {
216
+ await forceLabel(page);
217
+ await expectNamedEverywhere(page, 'dev');
218
+ // Both banners at once, and never the same one.
219
+ await expect(page.getByRole('status').filter({ hasText: 'Mock data' })).toBeVisible();
220
+ const fill = (locator) => locator.evaluate((element) => getComputedStyle(element).backgroundColor);
221
+ expect(await fill(banner(page))).not.toBe(await fill(page.getByRole('status').filter({ hasText: 'Mock data' })));
222
+ });
223
+ for (const colorScheme of ['light', 'dark']) {
224
+ test(`the data-environment banner has zero axe violations (${colorScheme})`, async ({ page }) => {
225
+ await page.emulateMedia({ colorScheme });
226
+ await forceLabel(page);
227
+ for (const route of routes) {
228
+ await gotoWithDataLabel(page, route);
229
+ await expect(page.locator('html')).toHaveClass(colorScheme);
230
+ await expect(banner(page)).toBeVisible();
231
+ await expect(page.locator('[data-state="loading"]')).toHaveCount(0);
232
+ await test.step(route, () => expectNoAxeViolations(page));
233
+ }
234
+ });
235
+ }
236
+ }
@@ -12,8 +12,9 @@ info:
12
12
  may not.
13
13
 
14
14
  A path marked `x-optional: true` may be left out of an app's spec (an
15
- app without self-service sign-up omits /auth/register), but if present
16
- it must match.
15
+ app without self-service sign-up omits /auth/register, an app that
16
+ doesn't render DataEnvironmentBanner omits /environment), but if
17
+ present it must match.
17
18
 
18
19
  servers:
19
20
  - url: /api
@@ -83,8 +84,36 @@ paths:
83
84
  "401": { $ref: "#/components/responses/UnauthorizedError" }
84
85
  "500": { $ref: "#/components/responses/ServerError" }
85
86
 
87
+ /environment:
88
+ x-optional: true
89
+ get:
90
+ operationId: getDataEnvironment
91
+ summary: Which data the backend is serving, for DataEnvironmentBanner
92
+ description: >
93
+ Needs no session: the banner shows on /login too. A null dataLabel
94
+ means production data, and no banner.
95
+ tags: [environment]
96
+ responses:
97
+ "200":
98
+ description: OK.
99
+ content:
100
+ application/json:
101
+ schema: { $ref: "#/components/schemas/DataEnvironment" }
102
+ "500": { $ref: "#/components/responses/ServerError" }
103
+
86
104
  components:
87
105
  schemas:
106
+ DataEnvironment:
107
+ type: object
108
+ required: [dataLabel]
109
+ properties:
110
+ dataLabel:
111
+ type: ["string", "null"]
112
+ minLength: 1
113
+ description: >
114
+ What the data is, as the banner names it ("dev" shows "Dev
115
+ data"). null when the backend serves production data.
116
+
88
117
  User:
89
118
  type: object
90
119
  required: [id, email, name]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.18.1",
3
+ "version": "3.19.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": {