@tristan2828/ui-foundation 3.9.0 → 3.11.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
 
@@ -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,
@@ -56,6 +56,11 @@ is a filled-in example while the Widgets demo is still here).
56
56
  unresolved item under "Open questions", or needs a field type or screen
57
57
  shape the foundation doesn't support yet (the supported list is in the
58
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.
59
64
 
60
65
  1. **Add `<Entity>` to `openapi.yaml`** — schema, list, get, create, update,
61
66
  delete — exactly as the plan specifies: its fields, types, required
@@ -162,18 +167,85 @@ is a filled-in example while the Widgets demo is still here).
162
167
  `<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
163
168
  functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
164
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)
165
174
  - `src/routes/widgets/widgets-columns.tsx` →
166
- `src/routes/<entity>/<entity>-columns.tsx` (column defs; split any
167
- row-action dialogs into their own file, as
168
- `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)
169
178
  - `src/routes/widgets/widgets-table.tsx` →
170
179
  `src/routes/<entity>/<entity>-table.tsx` — thin consumer of the
171
180
  foundation's `<DataTable>`. Swap the type, columns, and toolbar
172
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)
173
190
  - `src/routes/widgets/widget-form.tsx` →
174
191
  `src/routes/<entity>/<entity>-form.tsx` — thin consumer of the
175
192
  foundation's `<EntityForm>` (its `danger` slot takes an edit-only
176
- 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`).
177
249
 
178
250
  **`<SelectValue>` shows the value, not the label.** Base UI renders the
179
251
  raw *value*, and `placeholder` only applies when the value is `null`.
@@ -239,14 +311,26 @@ is a filled-in example while the Widgets demo is still here).
239
311
  them choose. That is how every pattern in the catalogue was picked.
240
312
  - Any colour involved: [`design-language.md`](design-language.md). Most
241
313
  values should stay grey, and colour is never the only signal.
242
- 7. **Register routes** for `/<entity>`, `/<entity>/new`,
243
- `/<entity>/:id/edit` in `src/App.tsx`, and add a nav entry to
244
- `src/nav.ts`.
245
- 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
246
318
  (`loading`, `empty`, `error`, `validation`, `success`), forced through
247
319
  MSW overrides — `forceMswOverride`, `waitForMswReady` and the
248
320
  `window.__msw` handle from `@tristan2828/ui-foundation/testing`, and
249
- `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.
250
334
  States forced via a first-load init script (loading, load-time error)
251
335
  need `page.addInitScript` before navigation; states forced after the
252
336
  page is already up (a mutation's error response) can use a
@@ -258,20 +342,28 @@ is a filled-in example while the Widgets demo is still here).
258
342
  which cannot see requests the MSW service worker makes
259
343
  (`e2e/msw-contract.spec.ts` has an example).
260
344
  9. **Register the nav entry and route names** in `e2e/shell.spec.ts`'s
261
- `NAV_ENTRIES` so the shell smoke test covers the new screen, and add
262
- `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes` (the table page is
263
- 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).
264
350
  10. **`npm run verify`.** Fix until it passes. Then stop — do not add
265
351
  anything beyond what this list covers; note ideas in the app's
266
352
  backlog instead.
267
353
 
268
354
  ## What not to copy
269
355
 
356
+ - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
357
+ button group and `visibleColumns`) unless the plan's List screen lists
358
+ saved views. When it does, copy it: `useTableUrlState`'s third argument
359
+ takes the views, `activeView` says which one the filters are, and that
360
+ view's `columns` go to `DataTable`'s `visibleColumns` (the table's own
361
+ default list when no view is active, or `undefined` for every column).
270
362
  - `src/api/gateway/widgets.ts`, `src/mocks/data.ts`, `src/mocks/handlers.ts`
271
363
  are demo-domain content — write the entity's own versions rather than
272
364
  adapting these by find-and-replace.
273
- - The composites (`DataTable`, `EntityForm`, `ErrorState`, `AppShell`)
274
- are the foundation's and shared by every app. If a new entity needs a
365
+ - The composites (`DataTable`, `EntityForm`, `EntityView`, `Markdown`,
366
+ `ErrorState`, `AppShell`) are the foundation's and shared by every app. If a new entity needs a
275
367
  capability they don't have (a new field-type widget, say), don't copy
276
368
  one into `src/` to change it: raise it for the foundation (AGENTS.md
277
369
  "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,7 +23,9 @@ 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
@@ -56,7 +58,8 @@ One or two sentences: what one record is, and who uses the screens.
56
58
  a screen — a Base UI `Select` can't take `""` as an item value, so the
57
59
  pattern needs a sentinel and gets the display wrong without one.
58
60
  - **List**: `column` if it shows in the table, and `sortable` if you can
59
- 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`:
60
63
  flipped straight from its row, saved on its own (cell pattern 15).
61
64
  - **Filter**: `search` (the text search box), `yes` (a filter control), or
62
65
  blank.
@@ -72,6 +75,8 @@ per list; delete the section if there are none.
72
75
  - Most items: 50.
73
76
  - In the table: a done-count (`2/6 done`), or blank if the list isn't
74
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).
75
80
 
76
81
  | Field | Label | Type | Required | Options / rules |
77
82
  |---|---|---|---|---|
@@ -87,12 +92,49 @@ per list; delete the section if there are none.
87
92
 
88
93
  - Default sort:
89
94
  - Page size: 10 unless there's a reason.
95
+ - Saved views (optional; delete if none): one row per view, a button
96
+ above the filters that sets every filter and the sort in one press.
97
+ Columns: blank for the table's usual columns, or the exact columns the
98
+ view shows, for a view of one kind of record that shows a field only
99
+ that kind uses. The columns last while the view's filters do; any other
100
+ filter puts the usual columns back. If the usual columns should leave
101
+ out a field only a view shows, say so here.
102
+
103
+ | View | Filters | Sort | Columns |
104
+ |---|---|---|---|
105
+ | All tasks | none | dueDate asc | |
106
+ | Bugs | kind = bug | dueDate asc | title, severity, assignee, dueDate |
107
+
90
108
  - Anything else the table must show or do:
91
109
 
110
+ ## View screen
111
+
112
+ The read-only page for one record (`/<entity>/:id`), opened by clicking
113
+ its title in the table. Edit and Delete live in its header, so the table
114
+ has no row actions. Every field it shows renders as its table cell does.
115
+
116
+ - Title: the field that names a record, shown as the page heading and
117
+ linked from the table: `title`.
118
+ - Badges beside the title: a few status-like fields, as badges: `status`,
119
+ `priority`.
120
+ - Sections, in order. Each lists its fields in the order they show. A
121
+ section holding a single long text or a single sub-records list shows
122
+ it under the section's heading, with no label of its own.
123
+
124
+ | Section | Fields |
125
+ |---|---|
126
+ | Details | dueDate, project, tags |
127
+ | Checklist | checklist |
128
+ | Notes | notes |
129
+
130
+ - "Not set" labels: what an empty optional field reads as on the view
131
+ (`dueDate`: No due date; `checklist`: No items). A field not listed here
132
+ reads "Not set". A `yes/no` is never empty: it reads Yes or No.
133
+
92
134
  ## Screens and access
93
135
 
94
- - Screens: list, create, edit, delete (the only shape the playbook builds
95
- today).
136
+ - Screens: list, view, create, edit, delete (the shape the playbook
137
+ builds).
96
138
  - Ownership: **shared** (every signed-in user sees every record) or
97
139
  **per-user** (each user sees only their own).
98
140
 
@@ -52,5 +52,13 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
52
52
  * unaffected. See conventions/docs/entity-plan-template.md "Default sort".
53
53
  */
54
54
  enableSortingRemoval?: boolean;
55
+ /**
56
+ * The ids of the columns to show; the rest are hidden. They keep their
57
+ * order in `columns`. Leave undefined to show every column. A table
58
+ * with saved views passes the active view's `columns`
59
+ * (useTableUrlState's `activeView`), or its own default list when no
60
+ * view is active, which can leave out a column only one view shows.
61
+ */
62
+ visibleColumns?: readonly string[];
55
63
  };
56
- export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }: DataTableProps<TData>): import("react").JSX.Element;
64
+ export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }: DataTableProps<TData>): import("react").JSX.Element;
@@ -20,6 +20,20 @@ import { Button } from '../ui/button.js';
20
20
  import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
21
21
  import { Skeleton } from '../ui/skeleton.js';
22
22
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
23
+ // A column def's id as TanStack resolves it (constructColumn): `id`, then
24
+ // `accessorKey` with dots as underscores, then a string header. Group
25
+ // columns are looked through to their leaves, which visibility is keyed by.
26
+ function leafColumnIds(columns) {
27
+ return columns.flatMap((column) => {
28
+ if ('columns' in column && column.columns)
29
+ return leafColumnIds(column.columns);
30
+ const accessorKey = 'accessorKey' in column ? column.accessorKey : undefined;
31
+ const id = column.id ??
32
+ (accessorKey === undefined ? undefined : String(accessorKey).replaceAll('.', '_')) ??
33
+ (typeof column.header === 'string' ? column.header : undefined);
34
+ return id === undefined ? [] : [id];
35
+ });
36
+ }
23
37
  function TableSkeleton({ columnCount }) {
24
38
  return (_jsx("div", { className: "flex flex-col gap-2 p-2", "data-state": "loading", children: Array.from({ length: 5 }, (_, row) => (_jsx("div", { className: "flex gap-4", children: Array.from({ length: columnCount }, (_, col) => (_jsx(Skeleton, { className: "h-8 flex-1" }, col))) }, row))) }));
25
39
  }
@@ -109,8 +123,13 @@ function useBottomScrollbar(containerRef, isSuccessView) {
109
123
  }, [containerRef, isSuccessView, isOverflowing]);
110
124
  return { barRef, isOverflowing, scrollWidth };
111
125
  }
112
- export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }) {
126
+ export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }) {
113
127
  const pageCount = Math.max(1, Math.ceil(total / pageSize));
128
+ // TanStack treats a column missing from this map as visible, so every
129
+ // column gets an entry once a list is given.
130
+ const columnVisibility = visibleColumns
131
+ ? Object.fromEntries(leafColumnIds(columns).map((id) => [id, visibleColumns.includes(id)]))
132
+ : {};
114
133
  const table = useLegacyTable({
115
134
  data,
116
135
  columns,
@@ -118,6 +137,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
118
137
  state: {
119
138
  sorting,
120
139
  pagination: { pageIndex: page - 1, pageSize },
140
+ columnVisibility,
121
141
  },
122
142
  manualSorting: true,
123
143
  ...(enableSortingRemoval === undefined ? {} : { enableSortingRemoval }),
@@ -143,7 +163,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
143
163
  const isSuccessView = !error && !isLoading && !isPastLastPage && !isEmpty;
144
164
  const containerRef = useRef(null);
145
165
  const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
146
- return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: columns.length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
166
+ return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
147
167
  // With the sticky bar below as the visible scrollbar, hide the
148
168
  // container's own. It stays overflow-x-auto and fully
149
169
  // scrollable (wheel, trackpad, drag, and the sync effect); it
@@ -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
+ }
@@ -7,8 +7,15 @@ export type TableView<F extends string, M extends string> = {
7
7
  filters?: Partial<Record<F, string>>;
8
8
  multiFilters?: Partial<Record<M, string[]>>;
9
9
  sort?: Sort;
10
+ /**
11
+ * The ids of the columns this view shows, for DataTable's
12
+ * `visibleColumns`. Leave it out to show the table's default columns.
13
+ * Applies only while the view is active (see activeViewOf).
14
+ */
15
+ columns?: readonly string[];
10
16
  };
11
17
  export declare function serializeSort(next: Sort): string | null;
12
18
  export declare function applyUrlChanges(previous: URLSearchParams, changes: UrlChanges): URLSearchParams;
13
19
  export declare function viewToUrlChanges<F extends string, M extends string>(filterNames: readonly F[], multiFilterNames: readonly M[], view: TableView<F, M>): UrlChanges;
20
+ export declare function activeViewOf<F extends string, M extends string, V extends string>(views: Readonly<Record<V, TableView<F, M>>>, filters: Readonly<Record<F, string>>, multiFilters: Readonly<Record<M, readonly string[]>>): V | null;
14
21
  export {};
@@ -30,3 +30,17 @@ export function viewToUrlChanges(filterNames, multiFilterNames, view) {
30
30
  page: null,
31
31
  };
32
32
  }
33
+ // Which of `views` the current filters are: the first whose filters and
34
+ // multi filters equal the URL's exactly (multi filters in any order), or
35
+ // null. A view is active only while it is still "this combination and
36
+ // nothing else", so adding or changing any filter returns the table to its
37
+ // default columns: a view's columns are chosen for the records its filters
38
+ // select, and would hide a field those other records fill. Sort and page
39
+ // don't change which records are shown, so they don't count. Decided with
40
+ // the developer on 2026-10-05.
41
+ export function activeViewOf(views, filters, multiFilters) {
42
+ const sameItems = (a, b) => a.length === b.length && [...a].sort().every((item, index) => item === [...b].sort()[index]);
43
+ const matches = (view) => Object.keys(filters).every((name) => filters[name] === (view.filters?.[name] ?? '')) &&
44
+ Object.keys(multiFilters).every((name) => sameItems(multiFilters[name], view.multiFilters?.[name] ?? []));
45
+ return Object.keys(views).find((id) => matches(views[id])) ?? null;
46
+ }
@@ -1,10 +1,11 @@
1
1
  import type { SortingState } from '../components/app/data-table.js';
2
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[]): {
3
+ export declare function useTableUrlState<F extends string, M extends string = never, V extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[], views?: Readonly<Record<V, TableView<NoInfer<F>, NoInfer<M>>>>): {
4
4
  page: number;
5
5
  sorting: SortingState;
6
6
  filters: Record<F, string>;
7
7
  multiFilters: Record<M, string[]>;
8
+ activeView: V | null;
8
9
  setPage: (next: number) => void;
9
10
  setSorting: (next: SortingState) => void;
10
11
  setFilter: (name: F, value: string) => void;
@@ -1,5 +1,5 @@
1
1
  import { useSearchParams } from 'react-router';
2
- import { applyUrlChanges, serializeSort, viewToUrlChanges } from './table-url-changes.js';
2
+ import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from './table-url-changes.js';
3
3
  // A table's page, sort and filters, kept in the URL query string
4
4
  // (`?page=2&sort=name:asc&search=mouse&tags=fragile&tags=bulky`) instead of
5
5
  // component state, so they survive going to the edit form and back, a
@@ -10,14 +10,20 @@ import { applyUrlChanges, serializeSort, viewToUrlChanges } from './table-url-ch
10
10
  // (multi-choice fields) are string arrays, repeated in the URL the same way
11
11
  // the API takes them. Changing the sort or any filter returns to page 1.
12
12
  //
13
+ // `views` are the table's saved views, by id. `activeView` is the id of the
14
+ // one the current filters are, or null (activeViewOf() says when a view
15
+ // counts as active); pass that view's `columns` to DataTable. A view is
16
+ // pressed with applyView().
17
+ //
13
18
  // Every setter is one update(), and must stay one: see setFilters.
14
- export function useTableUrlState(filterNames, multiFilterNames = []) {
19
+ export function useTableUrlState(filterNames, multiFilterNames = [], views = {}) {
15
20
  const [params, setParams] = useSearchParams();
16
21
  const page = Math.max(1, Math.floor(Number(params.get('page'))) || 1);
17
22
  const [sortField, sortDir] = (params.get('sort') ?? '').split(':');
18
23
  const sorting = sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : [];
19
24
  const filters = Object.fromEntries(filterNames.map((name) => [name, params.get(name) ?? '']));
20
25
  const multiFilters = Object.fromEntries(multiFilterNames.map((name) => [name, params.getAll(name)]));
26
+ const activeView = activeViewOf(views, filters, multiFilters);
21
27
  function update(changes) {
22
28
  setParams((previous) => applyUrlChanges(previous, changes), { replace: true });
23
29
  }
@@ -26,6 +32,7 @@ export function useTableUrlState(filterNames, multiFilterNames = []) {
26
32
  sorting,
27
33
  filters,
28
34
  multiFilters,
35
+ activeView,
29
36
  setPage: (next) => update({ page: next > 1 ? String(next) : null }),
30
37
  setSorting: (next) => update({ sort: serializeSort(next), page: null }),
31
38
  setFilter: (name, value) => update({ [name]: value, page: null }),
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.9.0",
3
+ "version": "3.11.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
  },