@tristan2828/ui-foundation 3.8.0 → 3.10.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
@@ -4,8 +4,8 @@
4
4
  [![release](https://github.com/Tristan2828/ui-foundation/actions/workflows/release.yml/badge.svg)](https://github.com/Tristan2828/ui-foundation/actions/workflows/release.yml)
5
5
 
6
6
  The shared layer of a family of small, contract-first, database-backed React
7
- apps: shadcn primitives, the `DataTable`/`EntityForm` composites, an app
8
- shell with session-cookie auth, the gateway's error seam, design tokens,
7
+ apps: shadcn primitives, the `DataTable`/`EntityView`/`EntityForm`
8
+ composites, an app shell with session-cookie auth, the gateway's error seam, design tokens,
9
9
  lint rules, Playwright suites, and the conventions an AI agent follows to
10
10
  build on it. Apps upgrade by bumping the version.
11
11
 
@@ -18,7 +18,7 @@ than installing this by hand:
18
18
 
19
19
  | Import | What |
20
20
  |---|---|
21
- | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityForm`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
21
+ | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView`, `EntityForm`, `Markdown`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `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
24
  | `@tristan2828/ui-foundation/mocks` | MSW: `authHandlers`, `resetMockAuth`, `getMockCurrentUser`, `MOCK_USER`, `exposeMswForE2E` |
@@ -3,8 +3,8 @@
3
3
  ## Stack
4
4
  Vite + React 19 + TypeScript, Tailwind v4, shadcn/ui (Base UI primitives),
5
5
  React Router v7, TanStack Query. The shared layer — primitives, the
6
- `DataTable`/`EntityForm` composites, the app shell, auth, the gateway error
7
- seam, design tokens, lint rules — is the npm package
6
+ `DataTable`/`EntityView`/`EntityForm` composites, the app shell, auth,
7
+ the gateway error seam, design tokens, lint rules — is the npm package
8
8
  `@tristan2828/ui-foundation`, updated by bumping its version. Everything in
9
9
  `src/` is this app's own.
10
10
 
@@ -75,7 +75,8 @@ is right. Do not disable, skip, or work around it.
75
75
 
76
76
  ## Required States
77
77
  Every data view handles: loading, empty, error, and success. Use
78
- `<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does.
78
+ `<Skeleton>`, `<Empty>`, and the error boundary — `DataTable` already does,
79
+ and `EntityView` (one record) adds not found: a plain 404, never a retry.
79
80
  Every screen has one Playwright test per state, forced via MSW overrides
80
81
  (`@tristan2828/ui-foundation/testing`).
81
82
 
@@ -132,7 +133,9 @@ net.
132
133
  Follow `docs/foundation/add-an-entity.md` (in Claude Code, `/new-entity
133
134
  <Name>` is a shortcut to it). It builds from the entity's plan,
134
135
  `docs/entities/<entity>.md`; if there's no plan, work one out with the
135
- developer first. Never guess the fields.
136
+ developer first. Never guess the fields. The files it copies are the
137
+ Widgets demo's until the app removes it; after that, the `### Reference
138
+ files` section below names the app's own.
136
139
 
137
140
  ## Upgrading the Foundation
138
141
  ```bash
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: new-entity
3
- description: This skill should be used when the user asks to "add an entity", "add a new entity", "scaffold a CRUD screen", "add a resource screen", or names a new domain object (e.g. "add Invoice") that needs a spec, gateway, mocks, table, and form built following this repo's widgets reference pattern.
3
+ description: This skill should be used when the user asks to "add an entity", "add a new entity", "scaffold a CRUD screen", "add a resource screen", or names a new domain object (e.g. "add Invoice") that needs a spec, gateway, mocks, table, view and form built following this repo's widgets reference pattern.
4
4
  allowed-tools: Read, Write, Edit, Bash, Glob, Grep, Agent
5
5
  ---
6
6
 
@@ -1,7 +1,7 @@
1
1
  # Add an Entity
2
2
 
3
3
  The most-repeated task in this system: add a full CRUD entity — spec, mocks,
4
- gateway, tests, table, form, routes — following the patterns in
4
+ gateway, tests, table, view, form, routes — following the patterns in
5
5
  `src/routes/widgets/`. This file is the only copy of the steps, written for
6
6
  **any AI coding tool or a person**: follow it directly ("add a Game entity
7
7
  following docs/foundation/add-an-entity.md"). In Claude Code,
@@ -12,6 +12,30 @@ Do not skip steps or reorder them. Replace `<Entity>` with the PascalCase
12
12
  entity name (e.g. `Invoice`) and `<entity>` with its kebab-case form
13
13
  (`invoice`) throughout.
14
14
 
15
+ ## The reference files
16
+
17
+ The steps name the template's Widgets demo as the code to copy:
18
+ `src/routes/widgets/`, `src/api/gateway/widgets.ts` and
19
+ `widget-categories.ts`, their mocks and tests. An app deletes the demo
20
+ once its own entity works, and from then on those names point at nothing.
21
+ So the app's `AGENTS.md`, below the foundation block, has a
22
+ **`### Reference files`** section: for each pattern (a table, a form, a
23
+ reference field, a multi choice and the rest), the app's own file to copy.
24
+
25
+ - **That section exists:** it wins. Wherever a step below names a demo
26
+ file, copy the file the section names for that pattern instead. A
27
+ pattern it doesn't list, and the demo is gone: stop and ask which file
28
+ to copy. Never rebuild a pattern from this file's prose alone.
29
+ - **It doesn't, and the demo is still here:** copy the demo, as written.
30
+ - **When the app removes the demo:** write the section first, naming the
31
+ file that now holds each pattern the demo showed. The template's own
32
+ `AGENTS.md` has the section, filled in for the demo, as the shape.
33
+
34
+ The demo's referenced entity is `WidgetCategory` (`/widget-categories`,
35
+ the `widget_categories` table), named for its widget so it never collides
36
+ with an app's own entity. An app whose entity is a Category builds it
37
+ alongside the demo with no rename.
38
+
15
39
  ## Before anything: the entity plan
16
40
 
17
41
  **Never guess what an entity is.** Every field, type, option list and rule
@@ -32,6 +56,11 @@ is a filled-in example while the Widgets demo is still here).
32
56
  unresolved item under "Open questions", or needs a field type or screen
33
57
  shape the foundation doesn't support yet (the supported list is in the
34
58
  template). Write the case in `docs/BLOCKERS.md`.
59
+ - **The plan's "Screens" line names a view** (`list, view, create, edit,
60
+ delete`), and its "View screen" section says what the view shows. A plan
61
+ written before the view existed has neither: ask the developer whether
62
+ to add them before step 1. The reference files build the table *with* a
63
+ view, where Edit and Delete live, so the table has no row actions.
35
64
 
36
65
  1. **Add `<Entity>` to `openapi.yaml`** — schema, list, get, create, update,
37
66
  delete — exactly as the plan specifies: its fields, types, required
@@ -64,14 +93,14 @@ is a filled-in example while the Widgets demo is still here).
64
93
  a raw sentinel instead of a "not set" label, passes `verify` unnoticed.
65
94
  One row costs nothing and step 8 asserts it. Extend `tests/mocks/conformance.test.ts` so the
66
95
  new handlers are validated against `openapi.yaml`, the same way
67
- `widgets`/`categories` already are.
96
+ `widgets`/`widget-categories` already are.
68
97
  5. **Add a gateway module in `src/api/gateway/<entity>.ts`** until step 3's
69
98
  tests pass, built on `safeFetch`/`toAppError` from
70
99
  `@tristan2828/ui-foundation/gateway` (see `src/api/gateway/widgets.ts`).
71
100
  Wire → `Page<T>` / `AppError` translation only; no hand-written types
72
101
  (everything comes from `schema.d.ts`).
73
- 6. **Copy the widgets reference files, one for one, not just the two
74
- screens** — shaped by the plan: table columns and sortable columns from
102
+ 6. **Copy the reference files, one for one, not just the two screens**
103
+ (the Widgets demo's, or the ones the app's `### Reference files` names) — shaped by the plan: table columns and sortable columns from
75
104
  its "List" column, toolbar filters from its "Filter" column, form fields
76
105
  and labels from its field table, and field types by pattern (a
77
106
  `reference` field copies Category's searchable combobox, a
@@ -85,12 +114,14 @@ is a filled-in example while the Widgets demo is still here).
85
114
  `<MultiChoice>` control from the foundation on the
86
115
  form and as a toolbar filter via `useTableUrlState`'s multi filters,
87
116
  badges in the table, and a `filters` array the gateway sends as a
88
- repeated parameter — and a `multi reference` copies Extra Categories:
117
+ repeated parameter; each option shows its label, never its wire value
118
+ (`quick_win` reads "Quick win"): one `Record<value, label>`
119
+ (`WIDGET_TAG_LABELS`) feeds `MultiChoice`'s `getLabel` and the badges — and a `multi reference` copies Extra Categories:
89
120
  the `<MultiReference>` control on the form and as a toolbar filter, an
90
121
  array of ids on the wire, a repeated any-of list filter, and names from
91
122
  a **lookup by id** on the referenced entity
92
- (`GET /categories?ids=1&ids=3`, `getCategoriesByIds`,
93
- `useCategoriesByIdsQuery`). Add that `ids` parameter to the referenced
123
+ (`GET /widget-categories?ids=1&ids=3`, `getWidgetCategoriesByIds`,
124
+ `useWidgetCategoriesByIdsQuery`). Add that `ids` parameter to the referenced
94
125
  entity's list endpoint if it lacks one. Never name a picked id from
95
126
  search results alone: a saved pick the current search doesn't return
96
127
  would show without a name. The table looks up every id on its page in
@@ -128,7 +159,7 @@ is a filled-in example while the Widgets demo is still here).
128
159
  once and is restored if the save fails, with a toast naming the row),
129
160
  then refetches the lists. Its specs cover a flip that saves, the
130
161
  keyboard, and a failed save putting the switch back.
131
- - `src/routes/widgets/use-widgets.ts`, `use-categories.ts` →
162
+ - `src/routes/widgets/use-widgets.ts`, `use-widget-categories.ts` →
132
163
  `src/routes/<entity>/use-<entity>.ts` (TanStack Query hooks over the
133
164
  new gateway module)
134
165
  - `src/routes/widgets/widget-schema.ts` →
@@ -136,18 +167,85 @@ is a filled-in example while the Widgets demo is still here).
136
167
  `<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
137
168
  functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
138
169
  the shape)
170
+ - `src/routes/widgets/widget-format.ts` →
171
+ `src/routes/<entity>/<entity>-format.ts` (the lookups and formatters
172
+ both the columns and the view render values with: badge variant maps,
173
+ date and number formatters, the done-count)
139
174
  - `src/routes/widgets/widgets-columns.tsx` →
140
- `src/routes/<entity>/<entity>-columns.tsx` (column defs; split any
141
- row-action dialogs into their own file, as
142
- `delete-widget-action.tsx` does — a fast-refresh hazard otherwise)
175
+ `src/routes/<entity>/<entity>-columns.tsx` (column defs: the plan's
176
+ title field is a link to the record's view, cell pattern 16, and
177
+ there is no row-actions column)
143
178
  - `src/routes/widgets/widgets-table.tsx` →
144
179
  `src/routes/<entity>/<entity>-table.tsx` — thin consumer of the
145
180
  foundation's `<DataTable>`. Swap the type, columns, and toolbar
146
181
  filters.
182
+ - `src/routes/widgets/widget-view.tsx` →
183
+ `src/routes/<entity>/<entity>-view.tsx` — thin consumer of the
184
+ foundation's `<EntityView>`. Swap the title, badges and sections for
185
+ the plan's "View screen" (below).
186
+ - `src/routes/widgets/delete-widget-action.tsx` →
187
+ `src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
188
+ view's header: the confirm dialog, then `onDeleted` leaves for the
189
+ list; its own file, a fast-refresh hazard otherwise)
147
190
  - `src/routes/widgets/widget-form.tsx` →
148
191
  `src/routes/<entity>/<entity>-form.tsx` — thin consumer of the
149
192
  foundation's `<EntityForm>` (its `danger` slot takes an edit-only
150
- delete). Swap the zod schema and fields.
193
+ delete). Swap the zod schema and fields. Every way out lands on a
194
+ view (below).
195
+
196
+ **The view** is the read-only page for one record, at `/<entity>/:id`.
197
+ `<EntityView>` owns its loading skeleton, its not-found state (another
198
+ user's record is the same plain 404, with a way back to the list and
199
+ no retry) and its error state with retry. The screen supplies what the
200
+ plan's "View screen" section lists:
201
+
202
+ - **Title and badges.** The plan's title field is the page's `<h1>`.
203
+ Its badge fields sit beside it, each the same `Badge` and variant map
204
+ as its table cell. Edit (a `Button` rendering a `Link` to
205
+ `/<entity>/:id/edit`) and the delete action go in `actions`.
206
+ - **Sections**, one per row of the plan's section table, in order.
207
+ Fields are label/value rows (`fields`), labelled as on the form. A
208
+ section that holds one long text or one sub-record list is a block
209
+ (`content`) with no label of its own, since a label would only repeat
210
+ the heading.
211
+ - **Every value renders the way its table cell does**: badges, names
212
+ of linked records, a done-count, from the shared lookups in
213
+ `<entity>-format.ts`. Never re-derive one for the view.
214
+ - **An empty value is passed through as it is** (`null`, `''`, or
215
+ `list.length > 0 && …`), and `EntityView` shows the field's
216
+ `emptyLabel`, the plan's "not set" label, never a blank. A yes/no is
217
+ never empty: it reads Yes or No (cell pattern 12).
218
+ - **A reference links to the referenced record's view** when that
219
+ entity has one: the name, from the same lookup by id the table uses,
220
+ inside `<Link to={`/<other>/${id}`}>`. Without a view, the plain name
221
+ (Widget's categories have none).
222
+ - **Sub-records are read-only**: the done-count, then each item in
223
+ order with its state as a glyph and words, never a checkbox
224
+ (`ChecklistItems` in `widget-view.tsx`). A list of links: each an
225
+ `<a target="_blank" rel="noreferrer">` with an `ExternalLinkIcon`
226
+ and a visually hidden "(opens in a new tab)".
227
+ - **Long text the plan marks Markdown** renders through the
228
+ foundation's `<Markdown>`: headings, lists, tables and links, links
229
+ opening in a new tab; raw HTML shows as the characters typed, never
230
+ as markup. The app needs no Markdown dependency of its own. Plain
231
+ long text renders as text (`whitespace-pre-line` keeps its line
232
+ breaks).
233
+ - **Quick actions and editing in place aren't in the playbook yet**
234
+ (flipping a yes/no, changing a status or ticking an item from the
235
+ view). Don't build them on your own: a row's value can hold a control
236
+ and `actions` takes any buttons, so they'll slot in once a release
237
+ adds them.
238
+
239
+ **The table opens the view, and the form returns to it.** The title
240
+ column's link is the way in (a `yes/no` marked `toggle` stays in its
241
+ row). Saving an edit, or cancelling one, goes back to the record's view;
242
+ creating opens the new record's view (the id the create returns);
243
+ cancelling a create goes back to the list. Each navigates with
244
+ `{ replace: true }`, so Back from the view doesn't reopen the form. The
245
+ create and update mutations put the saved record into its detail cache
246
+ (`setQueryData`), so the view shows the save at once, and the delete
247
+ mutation drops it (`removeQueries`), so the view it leaves never
248
+ refetches a record that's gone (`use-widgets.ts`).
151
249
 
152
250
  **`<SelectValue>` shows the value, not the label.** Base UI renders the
153
251
  raw *value*, and `placeholder` only applies when the value is `null`.
@@ -213,14 +311,26 @@ is a filled-in example while the Widgets demo is still here).
213
311
  them choose. That is how every pattern in the catalogue was picked.
214
312
  - Any colour involved: [`design-language.md`](design-language.md). Most
215
313
  values should stay grey, and colour is never the only signal.
216
- 7. **Register routes** for `/<entity>`, `/<entity>/new`,
217
- `/<entity>/:id/edit` in `src/App.tsx`, and add a nav entry to
218
- `src/nav.ts`.
219
- 8. **Add Playwright specs for both screens**, one test per state
314
+ 7. **Register routes** for `/<entity>`, `/<entity>/new`, `/<entity>/:id`
315
+ (the view) and `/<entity>/:id/edit` in `src/App.tsx`, and add a nav
316
+ entry to `src/nav.ts`.
317
+ 8. **Add Playwright specs for all three screens**, one test per state
220
318
  (`loading`, `empty`, `error`, `validation`, `success`), forced through
221
319
  MSW overrides — `forceMswOverride`, `waitForMswReady` and the
222
320
  `window.__msw` handle from `@tristan2828/ui-foundation/testing`, and
223
- `e2e/widgets-table.spec.ts`/`e2e/widget-form.spec.ts` for the pattern.
321
+ `e2e/widgets-table.spec.ts`/`e2e/widget-form.spec.ts`/
322
+ `e2e/widget-view.spec.ts` for the pattern. The view's states are
323
+ loading, not found (a 404: "Not found", the way back, no retry), error
324
+ (a failed load, then Try again recovering) and success (every section,
325
+ each value as its cell shows it), plus the sparse record showing its
326
+ "not set" labels and no blank value. Its flows: the table's title link
327
+ opens it, saving and cancelling the form return to it, creating opens
328
+ the new record's view, and Delete confirms then lands on the list. If
329
+ the plan has Markdown long text, a test forces a record whose text has
330
+ a heading, a list, a table, a link and raw HTML, and asserts the HTML
331
+ shows as text. Add the view's spec to `playwright.config.ts`'s
332
+ `mobile-chrome` `testMatch`, with a test that at phone width each
333
+ label sits above its value and nothing scrolls sideways.
224
334
  States forced via a first-load init script (loading, load-time error)
225
335
  need `page.addInitScript` before navigation; states forced after the
226
336
  page is already up (a mutation's error response) can use a
@@ -232,9 +342,11 @@ is a filled-in example while the Widgets demo is still here).
232
342
  which cannot see requests the MSW service worker makes
233
343
  (`e2e/msw-contract.spec.ts` has an example).
234
344
  9. **Register the nav entry and route names** in `e2e/shell.spec.ts`'s
235
- `NAV_ENTRIES` so the shell smoke test covers the new screen, and add
236
- `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes` (the table page is
237
- picked up from the sidebar automatically; the form isn't in it).
345
+ `NAV_ENTRIES` so the shell smoke test covers the new screen, add
346
+ `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
347
+ of a full record and of the sparse one to its `viewRoutes` (the table
348
+ page is picked up from the sidebar automatically; the form and the view
349
+ aren't in it).
238
350
  10. **`npm run verify`.** Fix until it passes. Then stop — do not add
239
351
  anything beyond what this list covers; note ideas in the app's
240
352
  backlog instead.
@@ -244,8 +356,8 @@ is a filled-in example while the Widgets demo is still here).
244
356
  - `src/api/gateway/widgets.ts`, `src/mocks/data.ts`, `src/mocks/handlers.ts`
245
357
  are demo-domain content — write the entity's own versions rather than
246
358
  adapting these by find-and-replace.
247
- - The composites (`DataTable`, `EntityForm`, `ErrorState`, `AppShell`)
248
- are the foundation's and shared by every app. If a new entity needs a
359
+ - The composites (`DataTable`, `EntityForm`, `EntityView`, `Markdown`,
360
+ `ErrorState`, `AppShell`) are the foundation's and shared by every app. If a new entity needs a
249
361
  capability they don't have (a new field-type widget, say), don't copy
250
362
  one into `src/` to change it: raise it for the foundation (AGENTS.md
251
363
  "Changing the Foundation") and build the screen once the release ships.
@@ -7,8 +7,9 @@ per-entity, and only the shape is reusable. Copy the markup into your
7
7
 
8
8
  Patterns 1–10 were each chosen for a real column, from a page of
9
9
  alternatives ([`column-options.md`](column-options.md)), not designed in the abstract.
10
- Pattern 12 (boolean) is the template's own reference: Widget's In Stock
11
- column renders it and its specs pin it. The other patterns from 11 on are
10
+ Patterns 12 (boolean) and 16 (title linking to its view) are the
11
+ template's own references: Widget's In Stock and Name columns render them
12
+ and their specs pin them. The other patterns from 11 on are
12
13
  marked **Unproven**: written ahead of a real
13
14
  column, so no app has tested them on real data yet. Prefer a proven
14
15
  pattern when one fits. When you use an unproven one, say so in the
@@ -350,3 +351,40 @@ now", "happened"). Widget's In Stock column is the reference
350
351
  stable").
351
352
  - **Cost:** one click changes data, with no confirmation. Only for values
352
353
  that are cheap to flip back; never for anything destructive.
354
+
355
+ ## 16. Title linking to the record's view
356
+
357
+ The column that names a record (its name or title), when the entity has a
358
+ view (`/<entity>/:id`). Widget's Name column is the reference
359
+ (`src/routes/widgets/widgets-columns.tsx`).
360
+
361
+ ```tsx
362
+ const TITLE_LINK_CLASS =
363
+ 'rounded-sm font-medium text-foreground underline-offset-4 outline-none hover:underline ' +
364
+ 'focus-visible:underline focus-visible:ring-3 focus-visible:ring-ring/50'
365
+
366
+ cell: ({ row }) => (
367
+ <Link to={`/widgets/${row.original.id}`} className={TITLE_LINK_CLASS}>
368
+ {row.original.name}
369
+ </Link>
370
+ )
371
+ ```
372
+
373
+ - **A plain `Link`, not a `Button`.** It goes somewhere and does nothing
374
+ else; a button-styled title down every row is a wall of buttons
375
+ (pattern 7's cost).
376
+ - **The record's own name is the link text**, so each row's link is
377
+ already distinct for a screen reader, with no `aria-label`.
378
+ - `font-medium` marks it as the row's name; the underline appears on hover
379
+ and keyboard focus. Foreground, not `text-primary`: it's the row's
380
+ heading, and a column of coloured text pulls the eye from every other
381
+ column.
382
+ - **The view replaces the row actions.** Edit and Delete live in the
383
+ view's header, so a table whose title links to a view has no actions
384
+ column. A `yes/no` marked `toggle` (pattern 15) stays in its row.
385
+ - **A reference to another record** (a cell showing a linked record's
386
+ name) uses the same link when *that* entity has a view, pointing at
387
+ `/<other>/${id}`, on the table and on the view alike.
388
+ - **Cost:** none to speak of. Specs that match the title as a cell still
389
+ work (`getByRole('cell', { name })` reads the link's text); ones that
390
+ opened the form from a row button go through the view's Edit instead.
@@ -23,12 +23,15 @@ One or two sentences: what one record is, and who uses the screens.
23
23
  - **Field**: camelCase, as it appears in the API (`releaseDate`).
24
24
  - **Type** — what the foundation supports today:
25
25
  - *Demonstrated by the Widget reference* (copied directly): `text` (one
26
- line), `long text`, `decimal` (fixed places — say how many),
26
+ line), `long text` (say `Markdown` when it's written in Markdown, like
27
+ notes or a write-up: the view renders its headings, lists, tables and
28
+ links, and it's never a table column), `decimal` (fixed places — say how many),
27
29
  `date-time`, `email`, `yes/no` (a boolean that is always yes or no:
28
30
  say its default and the filter's three labels, e.g. Any stock /
29
31
  In stock / Out of stock), `single choice` (list the options, in display
30
32
  order; see the note below if it is *optional*), `multi choice` (list
31
- the options; any number can be picked —
33
+ the options, with a label for any whose wire value isn't fit to show,
34
+ e.g. `quick_win` (Quick win); any number can be picked —
32
35
  chips on the form, badges in the table, an any-of filter), `reference`
33
36
  (to another entity — name it; a searchable combobox), `multi reference`
34
37
  (to any number of records of another entity — name it; chips with a
@@ -55,7 +58,8 @@ One or two sentences: what one record is, and who uses the screens.
55
58
  a screen — a Base UI `Select` can't take `""` as an item value, so the
56
59
  pattern needs a sentinel and gets the display wrong without one.
57
60
  - **List**: `column` if it shows in the table, and `sortable` if you can
58
- sort by it; blank if it's form-only. A `yes/no` can also say `toggle`:
61
+ sort by it; blank if it's only on the form and the view. The title
62
+ field's column links to the record's view. A `yes/no` can also say `toggle`:
59
63
  flipped straight from its row, saved on its own (cell pattern 15).
60
64
  - **Filter**: `search` (the text search box), `yes` (a filter control), or
61
65
  blank.
@@ -71,6 +75,8 @@ per list; delete the section if there are none.
71
75
  - Most items: 50.
72
76
  - In the table: a done-count (`2/6 done`), or blank if the list isn't
73
77
  shown.
78
+ - On the view: always shown, read-only: the done-count, then each item in
79
+ order (a `url` item field opens in a new tab).
74
80
 
75
81
  | Field | Label | Type | Required | Options / rules |
76
82
  |---|---|---|---|---|
@@ -88,10 +94,34 @@ per list; delete the section if there are none.
88
94
  - Page size: 10 unless there's a reason.
89
95
  - Anything else the table must show or do:
90
96
 
97
+ ## View screen
98
+
99
+ The read-only page for one record (`/<entity>/:id`), opened by clicking
100
+ its title in the table. Edit and Delete live in its header, so the table
101
+ has no row actions. Every field it shows renders as its table cell does.
102
+
103
+ - Title: the field that names a record, shown as the page heading and
104
+ linked from the table: `title`.
105
+ - Badges beside the title: a few status-like fields, as badges: `status`,
106
+ `priority`.
107
+ - Sections, in order. Each lists its fields in the order they show. A
108
+ section holding a single long text or a single sub-records list shows
109
+ it under the section's heading, with no label of its own.
110
+
111
+ | Section | Fields |
112
+ |---|---|
113
+ | Details | dueDate, project, tags |
114
+ | Checklist | checklist |
115
+ | Notes | notes |
116
+
117
+ - "Not set" labels: what an empty optional field reads as on the view
118
+ (`dueDate`: No due date; `checklist`: No items). A field not listed here
119
+ reads "Not set". A `yes/no` is never empty: it reads Yes or No.
120
+
91
121
  ## Screens and access
92
122
 
93
- - Screens: list, create, edit, delete (the only shape the playbook builds
94
- today).
123
+ - Screens: list, view, create, edit, delete (the shape the playbook
124
+ builds).
95
125
  - Ownership: **shared** (every signed-in user sees every record) or
96
126
  **per-user** (each user sees only their own).
97
127
 
@@ -0,0 +1,48 @@
1
+ import { type ReactNode } from 'react';
2
+ import type { AppError } from '../../api/contracts.js';
3
+ export type EntityViewField = {
4
+ label: string;
5
+ /**
6
+ * The value as the screen renders it. `null`, `undefined`, `false`, `''`
7
+ * and an empty array all count as empty and show `emptyLabel` instead,
8
+ * so `{tags.map(...)}` or `{email}` can be passed as they are.
9
+ */
10
+ value: ReactNode;
11
+ /** What an empty value reads as: the plan's "not set" label. Defaults to "Not set". */
12
+ emptyLabel?: string;
13
+ };
14
+ /**
15
+ * A titled card on the view: either label/value rows (`fields`), or one
16
+ * block of content under the heading with no label of its own (`content`),
17
+ * for long text (`<Markdown>`) or a sub-record list, where a label would
18
+ * only repeat the heading. A block that's empty shows `emptyLabel`, the
19
+ * same rule as a field.
20
+ */
21
+ export type EntityViewSection = {
22
+ /** The section's heading (an <h2>). Unique on the page. */
23
+ title: string;
24
+ fields: EntityViewField[];
25
+ } | {
26
+ title: string;
27
+ content: ReactNode;
28
+ emptyLabel?: string;
29
+ };
30
+ export type EntityViewProps = {
31
+ /** The record's name or title, the page's <h1>. */
32
+ title?: string;
33
+ /** A few status-like values beside the title, as `<Badge>`s. */
34
+ badges?: ReactNode;
35
+ /** The header's actions: an Edit button, a Delete with its confirm dialog. */
36
+ actions?: ReactNode;
37
+ sections: EntityViewSection[];
38
+ isLoading: boolean;
39
+ /** A `notfound` error (including another user's record) shows "Not found" and a way back, never a retry. */
40
+ error?: AppError | null;
41
+ onRetry?: () => void;
42
+ /** The entity's list: a link above the title, and the not-found state's way out. */
43
+ back: {
44
+ to: string;
45
+ label: string;
46
+ };
47
+ };
48
+ export declare function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }: EntityViewProps): import("react").JSX.Element;
@@ -0,0 +1,63 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // Generic read-only view of one record, the read-side partner of
3
+ // EntityForm: the record's title with a few status badges beside it, an
4
+ // actions area (Edit, Delete), and sections of label/value rows laid out to
5
+ // read rather than as a form. It owns the loading, not-found and error
6
+ // states, the way DataTable does for a list.
7
+ //
8
+ // Deliberately thin, like EntityForm: each value is the screen's own
9
+ // markup, built with the same cell patterns as the table's columns
10
+ // (badges, names of linked records, a done-count), and <Markdown> for
11
+ // long text. This component never formats a field. What it does guarantee
12
+ // is that an empty value reads as the plan's "not set" label, never a
13
+ // blank.
14
+ //
15
+ // A value can be any markup, a control included, and `actions` takes any
16
+ // buttons: that's the room left for quick actions on this page (a toggle,
17
+ // a status change, ticking a checklist item) without the form.
18
+ import { useId } from 'react';
19
+ import { Link } from 'react-router';
20
+ import { ArrowLeftIcon, SearchXIcon } from 'lucide-react';
21
+ import { ErrorState } from './error-state.js';
22
+ import { Button } from '../ui/button.js';
23
+ import { Card, CardContent, CardHeader } from '../ui/card.js';
24
+ import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle, } from '../ui/empty.js';
25
+ import { Skeleton } from '../ui/skeleton.js';
26
+ const DEFAULT_EMPTY_LABEL = 'Not set';
27
+ function isEmptyValue(value) {
28
+ return (value === null ||
29
+ value === undefined ||
30
+ value === false ||
31
+ value === '' ||
32
+ (Array.isArray(value) && value.length === 0));
33
+ }
34
+ function BackLink({ back }) {
35
+ return (_jsxs(Link, { to: back.to, className: "inline-flex w-fit items-center gap-1 rounded-sm type-label text-muted-foreground outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50", children: [_jsx(ArrowLeftIcon, { "aria-hidden": "true", className: "size-4" }), back.label] }));
36
+ }
37
+ function orEmptyLabel(value, emptyLabel) {
38
+ return isEmptyValue(value) ? (_jsx("span", { className: "text-muted-foreground", children: emptyLabel ?? DEFAULT_EMPTY_LABEL })) : (value);
39
+ }
40
+ // Label beside value from `sm` up; below it (a phone) the label sits
41
+ // above its value, so nothing is squeezed into a narrow second column.
42
+ function FieldRow({ field }) {
43
+ return (_jsxs("div", { className: "flex flex-col gap-1 py-3 first:pt-0 last:pb-0 sm:grid sm:grid-cols-[minmax(8rem,12rem)_1fr] sm:gap-6", children: [_jsx("dt", { className: "type-label text-muted-foreground", children: field.label }), _jsx("dd", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(field.value, field.emptyLabel) })] }));
44
+ }
45
+ function ViewSection({ section }) {
46
+ const headingId = useId();
47
+ return (_jsx("section", { "aria-labelledby": headingId, children: _jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx("h2", { id: headingId, className: "type-section-title text-foreground", children: section.title }) }), _jsx(CardContent, { children: 'fields' in section ? (_jsx("dl", { className: "flex flex-col divide-y divide-border", children: section.fields.map((field) => (_jsx(FieldRow, { field: field }, field.label))) })) : (_jsx("div", { className: "min-w-0 type-body break-words text-foreground", children: orEmptyLabel(section.content, section.emptyLabel) })) })] }) }));
48
+ }
49
+ function EntityViewSkeleton() {
50
+ return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "loading", children: [_jsx(Skeleton, { className: "h-5 w-24" }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(Skeleton, { className: "h-7 w-64 max-w-full" }), _jsx(Skeleton, { className: "h-5 w-40" })] }), Array.from({ length: 2 }, (_, section) => (_jsxs(Card, { children: [_jsx(CardHeader, { children: _jsx(Skeleton, { className: "h-5 w-32" }) }), _jsx(CardContent, { className: "flex flex-col gap-4", children: Array.from({ length: 4 }, (_, row) => (_jsxs("div", { className: "flex flex-col gap-1 sm:flex-row sm:gap-6", children: [_jsx(Skeleton, { className: "h-5 w-32" }), _jsx(Skeleton, { className: "h-5 flex-1" })] }, row))) })] }, section)))] }));
51
+ }
52
+ export function EntityView({ title, badges, actions, sections, isLoading, error, onRetry, back, }) {
53
+ if (error?.kind === 'notfound') {
54
+ // A record that doesn't exist and one that belongs to another user are
55
+ // the same plain 404, so this never says which. Retrying can't help.
56
+ return (_jsxs(Empty, { "data-state": "notfound", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyMedia, { variant: "icon", children: _jsx(SearchXIcon, {}) }), _jsx(EmptyTitle, { children: "Not found" }), _jsx(EmptyDescription, { children: error.message })] }), _jsx(EmptyContent, { children: _jsxs(Button, { size: "sm", variant: "outline", nativeButton: false, render: _jsx(Link, { to: back.to }), children: ["Back to ", back.label] }) })] }));
57
+ }
58
+ if (error)
59
+ return _jsx(ErrorState, { error: error, onRetry: onRetry });
60
+ if (isLoading)
61
+ return _jsx(EntityViewSkeleton, {});
62
+ return (_jsxs("div", { className: "flex max-w-4xl flex-col gap-6", "data-state": "success", children: [_jsx(BackLink, { back: back }), _jsxs("header", { className: "flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between", children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-2", children: [_jsx("h1", { className: "type-page-title break-words text-foreground", children: title }), badges && _jsx("div", { className: "flex flex-wrap items-center gap-1.5", children: badges })] }), actions && _jsx("div", { className: "flex shrink-0 flex-wrap items-center gap-2", children: actions })] }), sections.map((section) => (_jsx(ViewSection, { section: section }, section.title)))] }));
63
+ }
@@ -0,0 +1,7 @@
1
+ import type { ComponentProps } from 'react';
2
+ export type MarkdownProps = {
3
+ /** The Markdown source. Raw HTML in it is shown as text, never rendered. */
4
+ children: string;
5
+ className?: string;
6
+ } & Omit<ComponentProps<'div'>, 'children'>;
7
+ export declare function Markdown({ children, className, ...rest }: MarkdownProps): import("react").JSX.Element;
@@ -0,0 +1,60 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import ReactMarkdown, {} from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import { cn } from 'cn';
5
+ import { ExternalLinkIcon, SquareCheckIcon, SquareIcon } from 'lucide-react';
6
+ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../ui/table.js';
7
+ const REMARK_PLUGINS = [remarkGfm];
8
+ // Every element react-markdown hands over carries `node` (the syntax tree
9
+ // node). It isn't a DOM attribute, so it's dropped before spreading.
10
+ function omitNode(props) {
11
+ const rest = { ...props };
12
+ delete rest.node;
13
+ return rest;
14
+ }
15
+ const COMPONENTS = {
16
+ h1: (props) => _jsx("h3", { ...omitNode(props), className: "type-section-title mt-2 text-foreground" }),
17
+ h2: (props) => _jsx("h4", { ...omitNode(props), className: "type-section-title mt-2 text-foreground" }),
18
+ h3: (props) => _jsx("h5", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
19
+ h4: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
20
+ h5: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
21
+ h6: (props) => _jsx("h6", { ...omitNode(props), className: "type-label mt-1 text-foreground" }),
22
+ a: (props) => {
23
+ const { href, children, ...rest } = omitNode(props);
24
+ if (!href)
25
+ return _jsx("span", { children: children });
26
+ return (_jsxs("a", { ...rest, href: href, target: "_blank", rel: "noreferrer", className: "text-primary underline underline-offset-4 hover:text-primary/80", children: [children, _jsx(ExternalLinkIcon, { "aria-hidden": "true", className: "ml-0.5 inline size-3 align-baseline" }), _jsx("span", { className: "sr-only", children: " (opens in a new tab)" })] }));
27
+ },
28
+ ul: (props) => {
29
+ const { className, ...rest } = omitNode(props);
30
+ const isTaskList = className?.includes('contains-task-list');
31
+ return _jsx("ul", { ...rest, className: cn('flex flex-col gap-1', isTaskList ? 'list-none' : 'list-disc pl-6') });
32
+ },
33
+ ol: (props) => _jsx("ol", { ...omitNode(props), className: "flex list-decimal flex-col gap-1 pl-6" }),
34
+ li: (props) => {
35
+ const { className, ...rest } = omitNode(props);
36
+ return _jsx("li", { ...rest, className: cn(className?.includes('task-list-item') && 'flex items-start gap-2') });
37
+ },
38
+ // A GitHub task-list box (`- [x] done`). Read-only, so a glyph with its
39
+ // state in words, never a checkbox someone will try to tick.
40
+ input: ({ checked }) => checked ? (_jsx(SquareCheckIcon, { role: "img", "aria-label": "Done", className: "mt-0.5 size-4 shrink-0 text-foreground" })) : (_jsx(SquareIcon, { role: "img", "aria-label": "Not done", className: "mt-0.5 size-4 shrink-0 text-muted-foreground" })),
41
+ blockquote: (props) => (_jsx("blockquote", { ...omitNode(props), className: "flex flex-col gap-3 border-l-2 border-border pl-4 text-muted-foreground" })),
42
+ hr: () => _jsx("hr", { className: "border-border" }),
43
+ pre: (props) => (_jsx("pre", { ...omitNode(props), className: "overflow-x-auto rounded-lg bg-muted p-3 font-mono text-foreground [&_code]:bg-transparent [&_code]:p-0" })),
44
+ code: (props) => _jsx("code", { ...omitNode(props), className: "rounded bg-muted px-1 py-0.5 font-mono text-foreground" }),
45
+ img: (props) => {
46
+ const { alt, ...rest } = omitNode(props);
47
+ return _jsx("img", { ...rest, alt: alt ?? '', loading: "lazy", className: "max-w-full rounded-md" });
48
+ },
49
+ // GFM tables use the package's own table, zebra stripes and density
50
+ // tokens included, so a table in the notes reads like every other table.
51
+ table: (props) => _jsx(Table, { ...omitNode(props) }),
52
+ thead: (props) => _jsx(TableHeader, { ...omitNode(props) }),
53
+ tbody: (props) => _jsx(TableBody, { ...omitNode(props) }),
54
+ tr: (props) => _jsx(TableRow, { ...omitNode(props) }),
55
+ th: (props) => _jsx(TableHead, { ...omitNode(props) }),
56
+ td: (props) => _jsx(TableCell, { ...omitNode(props) }),
57
+ };
58
+ export function Markdown({ children, className, ...rest }) {
59
+ return (_jsx("div", { "data-slot": "markdown", ...rest, className: cn('flex min-w-0 flex-col gap-3 type-body text-foreground break-words', className), children: _jsx(ReactMarkdown, { remarkPlugins: REMARK_PLUGINS, components: COMPONENTS, children: children }) }));
60
+ }
@@ -1,9 +1,14 @@
1
1
  import * as React from 'react';
2
- export declare function MultiChoice<T extends string>({ options, value, onValueChange, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
2
+ export declare function MultiChoice<T extends string>({ options, value, onValueChange, getLabel, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }: {
3
3
  /** Every allowed option, in display order. */
4
4
  options: readonly T[];
5
5
  value: T[];
6
6
  onValueChange: (value: T[]) => void;
7
+ /**
8
+ * The text shown for an option: on its chip, in the dropdown, and what
9
+ * typing matches against. Defaults to the value itself.
10
+ */
11
+ getLabel?: (value: T) => string;
7
12
  /** Id for the text input, so a <FieldLabel htmlFor> labels it. */
8
13
  id?: string;
9
14
  placeholder?: string;
@@ -5,9 +5,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
5
5
  // filter — so entities reuse it rather than re-assembling the combobox
6
6
  // primitive each time. Built on shadcn's Combobox `multiple` mode, following
7
7
  // its own ComboboxMultiple example.
8
+ //
9
+ // Values are the wire's enum strings; `getLabel` names them when those
10
+ // aren't fit to show (`quick_win` → "Quick win"). Chips, options and the
11
+ // typed-text match all use the label, and the value is what's emitted.
8
12
  import * as React from 'react';
9
13
  import { Combobox, ComboboxChip, ComboboxChips, ComboboxChipsInput, ComboboxContent, ComboboxEmpty, ComboboxItem, ComboboxList, ComboboxValue, useComboboxAnchor, } from '../ui/combobox.js';
10
- export function MultiChoice({ options, value, onValueChange, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
14
+ export function MultiChoice({ options, value, onValueChange, getLabel = String, id, placeholder, 'aria-label': ariaLabel, 'aria-invalid': ariaInvalid, className, }) {
11
15
  const anchor = useComboboxAnchor();
12
- return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next) => onValueChange(next), children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: option }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: "No options found." }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: option }, option)) })] })] }));
16
+ return (_jsxs(Combobox, { multiple: true, autoHighlight: true, items: options, value: value, onValueChange: (next) => onValueChange(next), itemToStringLabel: getLabel, children: [_jsx(ComboboxChips, { ref: anchor, className: className, children: _jsx(ComboboxValue, { children: (selected) => (_jsxs(React.Fragment, { children: [selected.map((option) => (_jsx(ComboboxChip, { children: getLabel(option) }, option))), _jsx(ComboboxChipsInput, { id: id, "aria-label": ariaLabel, "aria-invalid": ariaInvalid, placeholder: selected.length === 0 ? placeholder : undefined })] })) }) }), _jsxs(ComboboxContent, { anchor: anchor, children: [_jsx(ComboboxEmpty, { children: "No options found." }), _jsx(ComboboxList, { children: (option) => (_jsx(ComboboxItem, { value: option, children: getLabel(option) }, option)) })] })] }));
13
17
  }
package/dist/index.d.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  export { AppShell, type AppShellProps, type NavItem } from './components/app/app-shell.js';
2
2
  export { DataTable, type DataTableProps, type SortingState } from './components/app/data-table.js';
3
3
  export { EntityForm, type EntityFormProps } from './components/app/entity-form.js';
4
+ export { EntityView, type EntityViewField, type EntityViewProps, type EntityViewSection, } from './components/app/entity-view.js';
4
5
  export { ErrorState } from './components/app/error-state.js';
5
6
  export { FoundationProviders } from './components/app/foundation-providers.js';
7
+ export { Markdown, type MarkdownProps } from './components/app/markdown.js';
6
8
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
7
9
  export { MultiChoice } from './components/app/multi-choice.js';
8
10
  export { MultiReference } from './components/app/multi-reference.js';
package/dist/index.js CHANGED
@@ -6,8 +6,10 @@
6
6
  export { AppShell } from './components/app/app-shell.js';
7
7
  export { DataTable } from './components/app/data-table.js';
8
8
  export { EntityForm } from './components/app/entity-form.js';
9
+ export { EntityView, } from './components/app/entity-view.js';
9
10
  export { ErrorState } from './components/app/error-state.js';
10
11
  export { FoundationProviders } from './components/app/foundation-providers.js';
12
+ export { Markdown } from './components/app/markdown.js';
11
13
  export { MockModeBanner } from './components/app/mock-mode-banner.js';
12
14
  export { MultiChoice } from './components/app/multi-choice.js';
13
15
  export { MultiReference } from './components/app/multi-reference.js';
@@ -18,6 +18,12 @@ export type A11ySuiteOptions = {
18
18
  * plus an edit route when the form shows something only existing data has.
19
19
  */
20
20
  formRoutes: readonly string[];
21
+ /**
22
+ * Record views (`/<entity>/:id`, built on EntityView), which aren't in
23
+ * the sidebar either: each entity's view of a full record, plus its
24
+ * sparse one, so both filled values and "not set" labels are checked.
25
+ */
26
+ viewRoutes?: readonly string[];
21
27
  /** Screens outside the app shell, e.g. '/login' (and '/register' if the app has it). */
22
28
  loggedOutRoutes: readonly string[];
23
29
  };
@@ -29,7 +35,7 @@ export type A11ySuiteOptions = {
29
35
  * dark-mode tokens readable. The theme follows the OS setting, so
30
36
  * emulateMedia switches it.
31
37
  */
32
- export declare function defineA11ySuite({ formRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
38
+ export declare function defineA11ySuite({ formRoutes, viewRoutes, loggedOutRoutes }: A11ySuiteOptions): void;
33
39
  export type MockModeBannerSuiteOptions = {
34
40
  /** Screens to check, logged-out ones included (e.g. '/login'). */
35
41
  routes: readonly string[];
@@ -68,7 +68,7 @@ async function navRoutes(page, isMobile) {
68
68
  * dark-mode tokens readable. The theme follows the OS setting, so
69
69
  * emulateMedia switches it.
70
70
  */
71
- export function defineA11ySuite({ formRoutes, loggedOutRoutes }) {
71
+ export function defineA11ySuite({ formRoutes, viewRoutes = [], loggedOutRoutes }) {
72
72
  for (const colorScheme of ['light', 'dark']) {
73
73
  test.describe(`accessibility (${colorScheme})`, () => {
74
74
  test.beforeEach(async ({ page }) => {
@@ -90,6 +90,14 @@ export function defineA11ySuite({ formRoutes, loggedOutRoutes }) {
90
90
  await expectNoAxeViolations(page);
91
91
  });
92
92
  }
93
+ for (const route of viewRoutes) {
94
+ test(`${route} has zero axe violations`, async ({ page }) => {
95
+ await page.goto(route);
96
+ // EntityView's loaded state, not its skeleton or an error.
97
+ await expect(page.locator('[data-state="success"]')).toBeVisible();
98
+ await expectNoAxeViolations(page);
99
+ });
100
+ }
93
101
  });
94
102
  }
95
103
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tristan2828/ui-foundation",
3
- "version": "3.8.0",
3
+ "version": "3.10.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": {
@@ -134,6 +134,8 @@
134
134
  "js-yaml": "^5.4.2",
135
135
  "lucide-react": "^1.47.0",
136
136
  "react-day-picker": "^10.0.1",
137
+ "react-markdown": "^10.1.0",
138
+ "remark-gfm": "^4.0.1",
137
139
  "shadcn": "^4.21.0",
138
140
  "tw-animate-css": "^1.4.0"
139
141
  },