@tristan2828/ui-foundation 3.9.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
 
@@ -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,9 +342,11 @@ 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.
@@ -270,8 +356,8 @@ is a filled-in example while the Widgets demo is still here).
270
356
  - `src/api/gateway/widgets.ts`, `src/mocks/data.ts`, `src/mocks/handlers.ts`
271
357
  are demo-domain content — write the entity's own versions rather than
272
358
  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
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
275
361
  capability they don't have (a new field-type widget, say), don't copy
276
362
  one into `src/` to change it: raise it for the foundation (AGENTS.md
277
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,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
  |---|---|---|---|---|
@@ -89,10 +94,34 @@ per list; delete the section if there are none.
89
94
  - Page size: 10 unless there's a reason.
90
95
  - Anything else the table must show or do:
91
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
+
92
121
  ## Screens and access
93
122
 
94
- - Screens: list, create, edit, delete (the only shape the playbook builds
95
- today).
123
+ - Screens: list, view, create, edit, delete (the shape the playbook
124
+ builds).
96
125
  - Ownership: **shared** (every signed-in user sees every record) or
97
126
  **per-user** (each user sees only their own).
98
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
+ }
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.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
  },