@tristan2828/ui-foundation 3.12.0 → 3.14.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.
Files changed (34) hide show
  1. package/README.md +1 -1
  2. package/conventions/docs/add-an-entity.md +57 -12
  3. package/conventions/docs/cell-patterns.md +369 -17
  4. package/conventions/docs/design-language.md +23 -4
  5. package/conventions/docs/entity-plan-template.md +21 -9
  6. package/dist/components/app/edit-in-place-store.d.ts +76 -0
  7. package/dist/components/app/edit-in-place-store.js +159 -0
  8. package/dist/components/app/edit-in-place.d.ts +88 -0
  9. package/dist/components/app/edit-in-place.js +10 -0
  10. package/dist/components/app/editable-value.d.ts +23 -0
  11. package/dist/components/app/editable-value.js +176 -0
  12. package/dist/components/app/entity-view.d.ts +10 -1
  13. package/dist/components/app/entity-view.js +20 -7
  14. package/dist/components/app/milkdown-editor.d.ts +2 -0
  15. package/dist/components/app/milkdown-editor.js +221 -0
  16. package/dist/components/app/multi-reference.d.ts +5 -1
  17. package/dist/components/app/multi-reference.js +2 -2
  18. package/dist/components/app/rich-text-editor.d.ts +20 -0
  19. package/dist/components/app/rich-text-editor.js +25 -0
  20. package/dist/components/app/stage-circle.d.ts +20 -0
  21. package/dist/components/app/stage-circle.js +30 -0
  22. package/dist/components/ui/badge.d.ts +1 -1
  23. package/dist/components/ui/field.js +2 -2
  24. package/dist/components/ui/toggle.d.ts +8 -0
  25. package/dist/components/ui/toggle.js +33 -0
  26. package/dist/hooks/use-record-update.d.ts +14 -1
  27. package/dist/hooks/use-record-update.js +10 -4
  28. package/dist/index.d.ts +4 -0
  29. package/dist/index.js +4 -0
  30. package/dist/lib/markdown-merge.d.ts +19 -0
  31. package/dist/lib/markdown-merge.js +196 -0
  32. package/dist/testing/index.d.ts +18 -1
  33. package/dist/testing/index.js +21 -2
  34. package/package.json +5 -2
package/README.md CHANGED
@@ -18,7 +18,7 @@ than installing this by hand:
18
18
 
19
19
  | Import | What |
20
20
  |---|---|
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` |
21
+ | `@tristan2828/ui-foundation` | `AppShell`, `DataTable`, `EntityView` (with `EditableValue`, `editInPlace`), `EntityForm`, `Markdown`, `RichTextEditor`, `ErrorState`, `MultiChoice`, `MultiReference`, `ListEditor`, `PasswordInput`, `RouteErrorBoundary`, `FoundationProviders`, `LoginRoute`, `RegisterRoute`, `useAuth`, `useTableUrlState`, `useRecordUpdate`, `useDebouncedValue`, `createQueryClient`, `cn`; types `Page`, `AppError`, `QuerySpec` |
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` |
@@ -168,7 +168,10 @@ is a filled-in example while the Widgets demo is still here).
168
168
  `src/routes/<entity>/<entity>-schema.ts` (zod schema mirroring
169
169
  `<Entity>Create`/`<Entity>Update`, plus the form ↔ wire conversion
170
170
  functions — see `widgetToFormValues`/`formValuesToWidgetCreate` for
171
- the shape)
171
+ the shape. A Markdown long text is checked with `refine`, never
172
+ trimmed, like Widget's `description`: a trim rewrites text nobody
173
+ touched, a leading indent that makes a code block or the text's
174
+ own ending)
172
175
  - `src/routes/widgets/widget-format.ts` →
173
176
  `src/routes/<entity>/<entity>-format.ts` (the lookups and formatters
174
177
  both the columns and the view render values with: badge variant maps,
@@ -189,6 +192,15 @@ is a filled-in example while the Widgets demo is still here).
189
192
  `src/routes/<entity>/<entity>-quick-actions.tsx`, **only when the
190
193
  plan's "View screen" lists `Quick actions`**: one component per
191
194
  control the plan names (below).
195
+ - `src/routes/widgets/widget-fields.tsx` →
196
+ `src/routes/<entity>/<entity>-fields.tsx`: **one control per field
197
+ the plan edits in place** (`Edit in place`), shared by the form
198
+ (through a `Controller`) and the view (through `editInPlace`), and
199
+ the Markdown long text's `RichTextEditor` (on the form whether or
200
+ not it edits in place). Every other field's control stays inline in
201
+ the form, as `widget-form.tsx`'s Category, Tags and Available From
202
+ do. No `Edit in place` line and no Markdown long text: no fields
203
+ file.
192
204
  - `src/routes/widgets/delete-widget-action.tsx` →
193
205
  `src/routes/<entity>/delete-<entity>-action.tsx` (Delete in the
194
206
  view's header: the confirm dialog, then `onDeleted` leaves for the
@@ -200,7 +212,8 @@ is a filled-in example while the Widgets demo is still here).
200
212
  view (below).
201
213
 
202
214
  **The view** is the read-only page for one record, at `/<entity>/:id`
203
- (apart from the plan's quick actions, if it lists any).
215
+ (apart from the plan's quick actions and fields edited in place, if it
216
+ lists any).
204
217
  `<EntityView>` owns its loading skeleton, its not-found state (another
205
218
  user's record is the same plain 404, with a way back to the list and
206
219
  no retry) and its error state with retry. The screen supplies what the
@@ -261,8 +274,7 @@ is a filled-in example while the Widgets demo is still here).
261
274
  breaks).
262
275
  - **Quick actions: only the fields the plan's `Quick actions` line
263
276
  names**, each a control that saves on its own (cell pattern 17,
264
- `widget-quick-actions.tsx`). A `single choice` in the header is a
265
- `Select` showing its badge (`WidgetStatusSelect`); a `yes/no` is a
277
+ `widget-quick-actions.tsx`). A `yes/no` is a
266
278
  `Switch` with its words (`WidgetInStockSwitch`); a sub-record list's
267
279
  yes/no item field is a checkbox per item in a named group
268
280
  (`ChecklistItems`), whose change is a function of the record
@@ -273,9 +285,29 @@ is a filled-in example while the Widgets demo is still here).
273
285
  `content`): `EntityView` needs nothing new. **With no `Quick actions`
274
286
  line, every value is read-only** and there is no quick-actions file:
275
287
  the view is exactly the read-only one above.
276
- - **Editing in place isn't in the playbook yet** (a text or date field
277
- turning into its form control on the view). Don't build it: every
278
- other change goes through Edit.
288
+ - **Edit in place: only the fields the plan's `Edit in place` line
289
+ names** (cell pattern 18). Each becomes `editInPlace({ kind, value,
290
+ control, schema, save })` (`widgetEdits` in `widget-view.tsx`):
291
+ `value` is what the form's control starts from, `control` is the
292
+ shared control from `<entity>-fields.tsx` given the props it's
293
+ handed (`readOnly={props.disabled}`, `aria-label={props.label}`,
294
+ `aria-describedby={props.describedBy}`), `schema` is the form's own
295
+ rule (`<entity>FormSchema.shape.<field>`), and `save` is
296
+ `useEdit<Entity>Field(id).mutateAsync({ <field>: value })` (copy
297
+ `useEditWidgetField`: the single-field save with `optimistic: false,
298
+ toastOnError: false`). `kind` follows the control: `text` (one line;
299
+ a number or integer too), `long-text` (Markdown, `RichTextEditor`
300
+ with `autoFocus` and `onProblem={props.onProblem}`), `choice` (a
301
+ select or a single reference: its list opens with the field,
302
+ `defaultOpen`, non-modal, `onChange={(v) => props.commit(v)}`,
303
+ `onOpenChange={(open) => !open && props.cancel()}`; a yes/no switch
304
+ commits on change), `multi` (a multi reference; `isEqual` comparing
305
+ ids as a set). A field gets `edit`, a `content` section gets `edit`,
306
+ the title gets `titleEdit={{ label, ...edit }}`, and a header badge
307
+ wraps itself in `<EditableValue label edit layout="inline">`. The
308
+ shown value stays exactly as before. **With no `Edit in place` line,
309
+ nothing is editable in place** and none of this is copied. Never a
310
+ computed field or a sub-record list.
279
311
 
280
312
  **The table opens the view, and the form returns to it.** The title
281
313
  column's link is the way in (a `yes/no` marked `toggle` stays in its
@@ -373,7 +405,15 @@ is a filled-in example while the Widgets demo is still here).
373
405
  save answers if the plan has one, a refused save (an MSW override
374
406
  answering 422 with the reason on the field) goes back with the toast,
375
407
  the "Saving" spinner shows while nothing is disabled, and, for a
376
- list, two quick changes both land. If
408
+ list, two quick changes both land. Fields edited in place get their
409
+ own spec (copy `e2e/widget-edit-in-place.spec.ts`): only the plan's
410
+ fields offer it; each kind saves (Enter, a pick, Ctrl/Cmd+Enter,
411
+ leaving) with the PATCH it should send and "Saving…" while it waits;
412
+ unchanged sends nothing; Esc restores; the schema's refusal is never
413
+ sent; a 422 (an MSW override, the reason on the field) and a 500 stay
414
+ open with the draft and the reason; opening a second field saves the
415
+ first, or doesn't open if that fails; and leaving the page with
416
+ unsaved text asks first. Add it to `mobile-chrome`'s `testMatch`. If
377
417
  the plan has Markdown long text, a test forces a record whose text has
378
418
  a heading, a list, a table, a link and raw HTML, and asserts the HTML
379
419
  shows as text. Add the view's spec to `playwright.config.ts`'s
@@ -394,7 +434,10 @@ is a filled-in example while the Widgets demo is still here).
394
434
  `/<entity>/new` to `e2e/a11y.spec.ts`'s `formRoutes`, and add the view
395
435
  of a full record and of the sparse one to its `viewRoutes` (the table
396
436
  page is picked up from the sidebar automatically; the form and the view
397
- aren't in it).
437
+ aren't in it). With fields edited in place, copy `e2e/a11y.spec.ts`'s
438
+ "editing in place" block for them: a field open, saving and refused,
439
+ the rich-text editor, an open list, and the leave prompt, in both
440
+ themes.
398
441
  10. **`npm run verify`.** Fix until it passes. Then stop — do not add
399
442
  anything beyond what this list covers; note ideas in the app's
400
443
  backlog instead.
@@ -404,9 +447,11 @@ is a filled-in example while the Widgets demo is still here).
404
447
  - `widget-quick-actions.tsx`, its `quick action: …` specs and
405
448
  `useSaveWidgetField` unless the plan has a `Quick actions` line (or,
406
449
  for the hook, a `yes/no` marked `toggle`). Copy only the controls for
407
- the fields the line names: a plan naming only its status gets the
408
- status picker, and the In Stock switch and the checklist's checkboxes
409
- are left out.
450
+ the fields the line names: a plan naming only its yes/no gets the
451
+ switch, and the checklist's checkboxes are left out.
452
+ - `widgetEdits`, `useEditWidgetField`, `widget-edit-in-place.spec.ts` and
453
+ the in-place a11y block unless the plan has an `Edit in place` line,
454
+ and then only for the fields it names.
410
455
 
411
456
  - The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
412
457
  button group and `visibleColumns`) unless the plan's List screen lists
@@ -8,9 +8,14 @@ per-entity, and only the shape is reusable. Copy the markup into your
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
10
  Patterns 12 (boolean), 15 (yes/no flipped in the row), 16 (title linking
11
- to its view) and 17 (quick action on the view) are the template's own
12
- references: Widget's In Stock and Name columns and its view render them,
13
- and their specs pin them. The other patterns from 11 on are
11
+ to its view), 17 (quick action on the view) and 18 (editable value) are
12
+ the template's own references: Widget's In Stock and Name columns and its
13
+ view render them, and their specs pin them. Pattern 19 (stage circle)
14
+ and the multi-value variant of 4, the dependency list in 10 and the
15
+ pressed icon in 15 were each built for a real app's screen first; the
16
+ package's Storybook renders them (`app/StageCircle`,
17
+ `patterns/CellPatterns`), and its checks pin their accessibility and
18
+ contrast in both themes. The other patterns from 11 on are
14
19
  marked **Unproven**: written ahead of a real
15
20
  column, so no app has tested them on real data yet. Prefer a proven
16
21
  pattern when one fits. When you use an unproven one, say so in the
@@ -109,6 +114,63 @@ narrow.
109
114
  cell, absent in an isolated Storybook story.
110
115
  - Encode the value in the glyph's **shape** (solid / half / slashed /
111
116
  dashed), not its colour.
117
+ - **Give the trigger a focus ring**
118
+ (`rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50`):
119
+ it's focusable, and a span has none of its own.
120
+
121
+ ### Several values: the icons side by side, every name in one tooltip
122
+
123
+ A multi value (several categories on one record) where each value has
124
+ its own icon and colour slot, and the column is narrow. The package's
125
+ Storybook renders it (`patterns/CellPatterns`, "Icons with one tooltip").
126
+
127
+ ```tsx
128
+ // values: the row's categories, `undefined` for one still loading.
129
+ const names = values.map((value) => value?.name ?? '…').join(', ')
130
+ if (values.length === 0) return emptyCell
131
+
132
+ <Tooltip>
133
+ <TooltipTrigger
134
+ render={
135
+ <span role="img" aria-label={names} tabIndex={0}
136
+ className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
137
+ }
138
+ >
139
+ {values.map((value, index) => {
140
+ if (!value) return <span key={index} className="text-muted-foreground">…</span>
141
+ const Icon = CATEGORY_ICON[value.icon]
142
+ return <Icon key={value.id} className={`size-4 shrink-0 ${CATEGORY_COLOR[value.color]}`} />
143
+ })}
144
+ </TooltipTrigger>
145
+ <TooltipContent>{names}</TooltipContent>
146
+ </Tooltip>
147
+ ```
148
+
149
+ - **One tooltip on the whole group**, listing every name in the record's
150
+ order ("Home, Finance"), not one per icon: one tab stop per cell, and
151
+ the names read as one value.
152
+ - **The trigger is the `role="img"` span**, named by that same list. The
153
+ icons inside are presentational (children of an `img`), so they need no
154
+ `aria-hidden` of their own. Read-only: never a button.
155
+ - **Each icon tinted with its value's colour slot**
156
+ ([`design-language.md`](design-language.md) "Categorical colour"), the
157
+ icon's shape telling values apart in greyscale.
158
+ - **A value still loading shows "…"**, in the cell and in the names,
159
+ never a bare id.
160
+ - **Where there's room** (the record's view), each icon sits beside its
161
+ name instead (pattern 2 per value, in a `flex flex-wrap gap-x-3 gap-y-1`
162
+ list), with no tooltip.
163
+
164
+ **Testing note.** With several tooltips on a page, one that's closing
165
+ stays in the DOM for a moment beside the one opening. A spec that reads
166
+ the open tooltip must select `[data-slot=tooltip-content][data-open]`;
167
+ the bare `[data-slot=tooltip-content]` can match the closing one, and
168
+ reads the wrong text:
169
+
170
+ ```ts
171
+ await page.getByRole('img', { name: 'Home, Finance', exact: true }).focus()
172
+ await expect(page.locator('[data-slot=tooltip-content][data-open]')).toHaveText('Home, Finance')
173
+ ```
112
174
 
113
175
  ## 5. Enum → tone-mapped badge
114
176
 
@@ -116,6 +178,11 @@ A many-valued enum that collapses to good / neutral / bad. **The grouping
116
178
  is the decision, not the style** — see
117
179
  [`design-language.md`](design-language.md#mapping-an-enum-to-tones).
118
180
 
181
+ An **ordered** status (a lifecycle every record moves through toward
182
+ *done*) reads better as a stage circle beside the word (pattern 19);
183
+ the tone badge stays right for a status with no order, or one whose end
184
+ isn't *done*.
185
+
119
186
  Keep a separate short-label map when the API strings are full sentences
120
187
  (`"Yes – Full Crossplay"` → `Full`): read once in a dropdown they're right,
121
188
  read down 25 rows under a column header they're noise.
@@ -232,6 +299,40 @@ A one-to-many value (`★ 4.7 (3)` opening a per-person breakdown).
232
299
  can't be sorted server-side without a backend change
233
300
  (`enableSorting: false`).
234
301
 
302
+ ### A second example: what a row depends on
303
+
304
+ The records a row waits on (parts it needs, a date that has to pass,
305
+ other records that have to finish), summarised in the cell, listed in
306
+ the popover. The package's Storybook renders it
307
+ (`patterns/CellPatterns`, "Dependency list").
308
+
309
+ - **The cell: a computed summary.** "Ready" when nothing holds the row,
310
+ else the count of what does ("1 hold", "2 holds"). Compute it where the
311
+ data is: the backend, when the column sorts or filters on it.
312
+ - **The popover: every related record, grouped by kind**, each kind
313
+ under a small heading (`type-caption text-muted-foreground`) in its own
314
+ labelled group, in a fixed order. Each record keeps its kind's glyph
315
+ (`aria-hidden`, beside the name), so the kinds stay apart in
316
+ greyscale.
317
+ - **Held reads dark, with its reason; clear reads muted.** A held record
318
+ is `text-foreground` with why after its name ("Bulb: unavailable",
319
+ "Delivery day: hasn't happened", or the blocking record's status);
320
+ a clear one is `text-muted-foreground` with a visually hidden ": clear",
321
+ so a screen reader hears the state the colour shows. No tone: a hold
322
+ is the normal state of waiting, not an error, and the count already
323
+ says how many.
324
+ - **Name the popup**: `<PopoverContent aria-label={`What ${name} depends on`}>`.
325
+ It's a `dialog`, and axe fails one with no name.
326
+ - **A row that links nothing has nothing to open**: render the summary as
327
+ plain text (`<span>Ready</span>`), not a button. A button that opens an
328
+ empty popover is a dead end.
329
+ - **The trigger reads as the value**, with a dotted underline
330
+ (`underline decoration-dotted underline-offset-4`) and a focus ring,
331
+ so it looks openable without becoming a column of buttons.
332
+ - The trigger's name starts with the summary it shows
333
+ (`aria-label={`${summary}: what ${name} depends on`}`), so it's still
334
+ found by its visible text, and says which row.
335
+
235
336
  ## 11. Number or currency — *Unproven*
236
337
 
237
338
  A count, a quantity, a price.
@@ -358,6 +459,91 @@ now", "happened"). Widget's In Stock column is the reference
358
459
  - **Cost:** one click changes data, with no confirmation. Only for values
359
460
  that are cheap to flip back; never for anything destructive.
360
461
 
462
+ ### A variant: a pressed icon inside another value's cell
463
+
464
+ A yes/no that belongs to another value rather than having a column of
465
+ its own: a "focus" flag that only exists while the status is "doing", a
466
+ "pinned" mark on an open item. It's a small icon button inside the owning
467
+ value's cell, beside its word. The package's Storybook renders it
468
+ (`patterns/CellPatterns`, "Pressed icon in a cell").
469
+
470
+ **Pick it over the Switch** when the yes/no only means something while
471
+ another value allows it, or when a Switch and its words would crowd that
472
+ value's cell. A yes/no that stands on its own keeps its own column and
473
+ the Switch.
474
+
475
+ ```tsx
476
+ // Off: faint. On: the destructive text tone, filled. The primitive's
477
+ // pressed background is dropped: the icon carries the value.
478
+ const FLAG_TOGGLE_CLASS =
479
+ 'text-muted-foreground/80 hover:text-muted-foreground aria-pressed:bg-transparent ' +
480
+ 'aria-pressed:text-destructive-text'
481
+
482
+ // <entity>-focus-toggle.tsx, rendered inside the status cell
483
+ export function FocusToggle({ item }: { item: Item }) {
484
+ const save = useSaveItemField(item.id) // useRecordUpdate, like Widget's useSaveWidgetField
485
+ return (
486
+ <>
487
+ <Toggle size="icon-xs" pressed={item.focus} aria-label={`Focus: ${item.name}`}
488
+ className={FLAG_TOGGLE_CLASS} onPressedChange={(focus) => save.mutate({ focus })}>
489
+ <FlagIcon className="group-aria-pressed/toggle:fill-current" />
490
+ </Toggle>
491
+ <span className="inline-flex size-3.5 shrink-0">
492
+ {save.isPending && <Spinner aria-label="Saving" className="size-3.5 text-muted-foreground" />}
493
+ </span>
494
+ </>
495
+ )
496
+ }
497
+
498
+ // The status cell: the word stays, the toggle only while the status allows it.
499
+ <span className="inline-flex items-center gap-1.5">
500
+ <StageCircle {...STATUS_STAGE[item.status]} />
501
+ {STATUS_LABEL[item.status]}
502
+ {item.status === 'doing' && <FocusToggle item={item} />}
503
+ </span>
504
+ ```
505
+
506
+ - **`Toggle` from the package** (`/ui/toggle`, shadcn's, on Base UI), at
507
+ `size="icon-xs"`: a 24px target, the smallest WCAG 2.2 allows, which
508
+ keeps the row's height. It's `aria-pressed` by itself.
509
+ - **Named for the row, not the state**: `Focus: <record name>`. The
510
+ pressed state is announced on its own; a name that changes with it
511
+ ("Unfocus") reads twice.
512
+ - **The icon carries the value.** Filled when on
513
+ (`group-aria-pressed/toggle:fill-current`), outline when off, so it
514
+ reads in greyscale. Pick a glyph with an area to fill (`FlagIcon`,
515
+ `StarIcon`, `PinIcon`), not a line.
516
+ - **Faint, but never below 3:1.** Off is `text-muted-foreground/80`, the
517
+ faintest step that still clears WCAG's 3:1 non-text minimum in light
518
+ mode (3.2:1; dark is 5.2:1). `/70` measures 2.7:1, and a control a
519
+ reader can't see fails them. Hover lifts it to full
520
+ `muted-foreground`. axe doesn't measure icons, so the package's
521
+ Storybook glyph check does.
522
+ - **Tone: `destructive-text`, read as "flagged", not "error".** A flag
523
+ claims attention ahead of the rest of the row, and the destructive
524
+ tone is the one with that pull. The other tones are taken where it
525
+ sits: `info` is the in-progress status it belongs to (an info flag
526
+ beside an info stage disappears), `success` reads as done, and
527
+ `warning` says something may be wrong. It stays honest because it's
528
+ rare (a flag nearly every row carries is the "about a third" limit
529
+ broken) and because no word beside it says error. If a flag is common,
530
+ or does mean "something's wrong", it's an enum with a verdict
531
+ (pattern 5) instead.
532
+ - **It saves on its own, through `useRecordUpdate`**, like the Switch: the
533
+ new value shows at once, and a refusal (a cap the server enforces)
534
+ puts it back with the toast giving the server's reason. The hook does
535
+ both; add nothing.
536
+ - **The "Saving" slot is always there**, after the toggle, so nothing
537
+ moves while it saves, and nothing is disabled.
538
+ - **Only rendered while the owning value allows it.** When the status
539
+ moves on, the toggle goes; what the server does with the flag then (it
540
+ clears it, or keeps it for next time) is the plan's rule, and shows
541
+ through the record it returns.
542
+ - **On the record's view** it's the same component beside the owning
543
+ value in the header, as a quick action (pattern 17): the plan names it
544
+ under `Quick actions`. There its name is the field's label alone
545
+ (`Focus`), like the view's other controls.
546
+
361
547
  ## 16. Title linking to the record's view
362
548
 
363
549
  The column that names a record (its name or title), when the entity has a
@@ -397,23 +583,25 @@ cell: ({ row }) => (
397
583
 
398
584
  ## 17. Quick action on the view
399
585
 
400
- A value on the record's view that changes right where it's shown, saved on
401
- its own the moment it changes, without the form: a status picked from the
402
- header, a yes/no flipped, a sub-record item ticked. Widget's view is the
403
- reference (`src/routes/widgets/widget-quick-actions.tsx`): Status and In
404
- Stock in the header, Checklist items ticked in place.
586
+ A value on the record's view that changes right where it's shown, with one
587
+ click, saved on its own the moment it changes, without the form: a yes/no
588
+ flipped, a sub-record item ticked. Widget's view is the reference
589
+ (`src/routes/widgets/widget-quick-actions.tsx`): In Stock in the header,
590
+ Checklist items ticked in place.
405
591
 
406
592
  **Read-only is the default.** A value on the view is a control only when
407
- the plan's "View screen" names it under `Quick actions`; everything else
408
- renders as its table cell does, and changes through Edit. A quick action
409
- is for a value people change often and can change back as easily: never
410
- anything destructive, never text (that's editing in place, not built
411
- yet).
593
+ the plan's "View screen" names it under `Quick actions` (or `Edit in
594
+ place`, pattern 18); everything else renders as its table cell does, and
595
+ changes through Edit. A quick action is for a value people change often
596
+ and can change back as easily: never anything destructive. Anything you
597
+ type or pick from a list (text, a status) edits in place instead
598
+ (pattern 18); a status was a quick action in 3.12, and moved there in
599
+ 3.13.
412
600
 
413
601
  | The plan's field | The control | Accessible name |
414
602
  |---|---|---|
415
- | `single choice`, in the header | A `Select` (`size="sm"`) whose value and options render as the field's tone-mapped `Badge` (pattern 5), in place of the badge | The field's label: `Status` |
416
603
  | `yes/no`, in the header or a field row | A `Switch` (`size="sm"`) with the value in words beside it, `aria-hidden` (pattern 15) | The field's label: `In stock` |
604
+ | A `yes/no` that belongs to another value (pattern 15's variant), beside that value in the header | The same pressed icon `Toggle` as in the table's cell | The field's label: `Focus` |
417
605
  | A sub-record list's `yes/no` item field | Each item a `Checkbox` inside a `<label>` with the item's text, so the text ticks it too; the list in one `role="group"` named for the list, described by the done-count | `Done: <item text>` (a visually hidden `Done: ` inside the label) |
418
606
 
419
607
  ```tsx
@@ -452,12 +640,176 @@ const save = useSaveWidgetField(widget.id) // one per control
452
640
  - **Saving shows, and blocks nothing.** Each control keeps its own
453
641
  "Saving" spinner in a slot that's always there (nothing moves), and
454
642
  nothing is disabled: not the other controls, not this one, not Edit.
455
- - **Keep the value's look.** The status keeps its tone-mapped badge
456
- inside the picker; the yes/no keeps its words; a ticked item keeps the
457
- muted text a done item had. The control only adds the affordance.
643
+ - **Keep the value's look.** The yes/no keeps its words; a ticked item
644
+ keeps the muted text a done item had. The control only adds the
645
+ affordance.
458
646
  - **One component per control, in its own file** (`<entity>-quick-actions.tsx`),
459
647
  so each hook lives in a component and the view's sections stay plain
460
648
  data.
461
649
  - **Cost:** one click changes data, with no confirmation, and the server
462
650
  may still say no. Keep the list short: a view where every value is a
463
651
  control reads as a form.
652
+
653
+ ## 18. Editable value
654
+
655
+ A value on the record's view that turns into **the form's own control**
656
+ where it's shown, and saves when you leave it: a title, a status, a
657
+ price, notes, a list of linked records. Widget's view is the reference
658
+ (`src/routes/widgets/widget-view.tsx`): Name (the title), Status (its
659
+ header badge), Price, Description (rich text) and Extra Categories, with
660
+ the controls in `widget-fields.tsx`, the same ones the form uses.
661
+
662
+ The rule behind every part of it: **nothing typed is ever lost.**
663
+
664
+ **Which values.** Only those the plan's "View screen" names under `Edit
665
+ in place`; read-only is the default, and a value not named renders
666
+ exactly as it did. Single values only: text, long text, a single choice
667
+ (optional ones with their "not set" option), an integer or a rating, a
668
+ yes/no, a single or multi reference. Never a computed field, and never a
669
+ sub-record list (adding, removing and reordering items stays on the
670
+ form). The view's Edit button and the form stay.
671
+
672
+ **At rest: says it's editable without shouting.** The value reads as it
673
+ always did. Hover tints it (`bg-muted`) and shows a pencil; the pencil is
674
+ a real button ("Edit <label>"), so Tab reaches it and Enter opens the
675
+ field, and its focus ring tints the value too. On a touch screen the
676
+ pencil always shows. A click anywhere on the value opens it, except on a
677
+ link inside it (a reference to a record with a view, a link in the
678
+ notes), which still navigates.
679
+
680
+ **Open: the form's control, nothing else.** The same labels, choices and
681
+ pickers as `EntityForm`; the field's label is its accessible name. No
682
+ Save button, no Undo:
683
+
684
+ | Kind (`editInPlace({ kind })`) | Saves on | Esc |
685
+ |---|---|---|
686
+ | `text`: one line | Enter, or leaving it | puts the saved value back |
687
+ | `long-text`: notes, rich text for Markdown | Ctrl/Cmd+Enter, or leaving it (Enter is a new line) | the same |
688
+ | `choice`: a select, a switch, a single reference | the pick (its list opens with the field, non-modal) | closes the list with nothing picked: gives up |
689
+ | `multi`: a multi reference | leaving it, or Ctrl/Cmd+Enter (Enter picks) | closes the list first; Esc again gives up |
690
+
691
+ Leaving means clicking or tabbing anywhere else. Unchanged closes with no
692
+ request; a value the form's schema refuses never leaves the browser and
693
+ shows the schema's message. Opening another value saves the open one
694
+ first; if that save fails, the open one stays and the other doesn't
695
+ open.
696
+
697
+ **Saving: still open, read-only, and says so.** Under the control,
698
+ "Saving…" with a spinner (`role="status"`) until the server agrees, then
699
+ it closes on the saved value. **Not optimistic**: the value only shows as
700
+ saved once it is, and the view shows the record the server returned (a
701
+ side effect included). The rest of the page stays usable.
702
+
703
+ **Refused: still open, with exactly what was typed and why.** The reason
704
+ under the control in the destructive text tone (`FieldError`), word for
705
+ word: a 422's field error as `EntityForm` shows it, or the error's
706
+ message (a 500, no answer). The control is `aria-invalid` and described
707
+ by the reason. Nothing reverts on its own, and there's no toast. Leaving
708
+ again retries; Esc gives up.
709
+
710
+ **Leaving the page** (a link, Back) while a value holds unsaved text asks
711
+ "Discard your changes?" first (Keep editing / Discard); closing the tab
712
+ asks through the browser. A link press doesn't save the field on its
713
+ way out: the prompt decides.
714
+
715
+ ```tsx
716
+ const editField = useEditWidgetField(widget.id) // optimistic: false, toastOnError: false
717
+ const price = editInPlace({
718
+ kind: 'text',
719
+ value: widget.price, // as the form's control holds it
720
+ schema: widgetFormSchema.shape.price, // the form's own rule
721
+ save: (price) => editField.mutateAsync({ price }),
722
+ control: (props) => (
723
+ <WidgetPriceInput id={props.id} value={props.value} onChange={props.onChange}
724
+ invalid={props.invalid} readOnly={props.disabled}
725
+ aria-label={props.label} aria-describedby={props.describedBy} />
726
+ ),
727
+ })
728
+ // A field: { label: 'Price', value: <…as the table shows it…>, edit: price }
729
+ // The title: <EntityView titleEdit={{ label: 'Name', ...name }} />
730
+ // A badge: <EditableValue label="Status" edit={status} layout="inline"><Badge>…</Badge></EditableValue>
731
+ ```
732
+
733
+ - **One control per field, shared with the form** (`<entity>-fields.tsx`):
734
+ each takes `value`/`onChange` and `readOnly`, and the form wraps it in a
735
+ `Controller`. Never a second, in-place-only control.
736
+ - **Long text written as Markdown edits as rich text** (`RichTextEditor`):
737
+ formatted where it's read, saved as Markdown with every block the person
738
+ didn't touch kept exactly as written. On the form too, so it's one
739
+ control.
740
+ - **The save is the single-field save** (`useRecordUpdate` with
741
+ `optimistic: false, toastOnError: false`, the entity's
742
+ `useEdit<Entity>Field`), `mutateAsync` of one field's PATCH.
743
+ - **Keep the value's look at rest**: the badge stays a badge, the price
744
+ stays formatted. Only the open state looks like a form.
745
+ - **Cost:** a value that looks like text but edits on click can surprise.
746
+ Keep it to the fields the plan names, and leave the form for everything
747
+ else.
748
+
749
+ ## 19. Stage circle for an ordered status
750
+
751
+ A status that's an ordered lifecycle (each record moves through the same
752
+ stages, in the same order, toward one that means *done*), with one or
753
+ more exit states off the side. A small circle beside the word fills as
754
+ the record moves on. The package ships it: `StageCircle`, rendered with
755
+ every stage and tone in its Storybook (`app/StageCircle`).
756
+
757
+ | Stage (`stage`) | Drawn as | Say it for |
758
+ |---|---|---|
759
+ | `0` | a dashed outline | the first stage: not started |
760
+ | `1`, `2`, `3` | the outline with a quarter, a half, three quarters filled | a stage under way, in order |
761
+ | `'complete'` | a solid circle with a tick | the final stage: done |
762
+ | `'exit'` | the outline, struck through | off the lifecycle: dropped, cancelled |
763
+
764
+ ```tsx
765
+ import { StageCircle, type StageCircleStage, type StageCircleTone } from '@tristan2828/ui-foundation'
766
+
767
+ // <entity>-format.ts: typed by the enum, so a new value can't go unmapped.
768
+ const STATUS_STAGE: Record<ItemStatus, { stage: StageCircleStage; tone?: StageCircleTone }> = {
769
+ idea: { stage: 0 },
770
+ considering: { stage: 1 },
771
+ doing: { stage: 2, tone: 'info' },
772
+ done: { stage: 'complete', tone: 'success' },
773
+ dropped: { stage: 'exit' },
774
+ }
775
+
776
+ cell: ({ getValue }) => {
777
+ const status = getValue() as ItemStatus
778
+ return (
779
+ <span className="inline-flex items-center gap-1.5">
780
+ <StageCircle {...STATUS_STAGE[status]} />
781
+ {STATUS_LABEL[status]}
782
+ </span>
783
+ )
784
+ }
785
+ ```
786
+
787
+ - **The word stays**, in plain `text-foreground`. The circle is
788
+ `aria-hidden`; the word is what's read, and what a reader who doesn't
789
+ know the shapes yet goes by.
790
+ - **The shape carries the stage, in greyscale.** Dashed, filling, solid,
791
+ struck through: the stage reads from the shape alone, and the tone only
792
+ reinforces it.
793
+ - **Tone it like pattern 5:** the stage in progress is `info` ("notice
794
+ this"), complete is `success`, and the rest stay `muted` (the default).
795
+ The tones are the `*-text` shades, so the circle clears 3:1 on the page
796
+ in both themes, measured by the package's glyph check. An exit is
797
+ muted: dropped is an end, not a failure. Give it `destructive` only if
798
+ the exit really is bad news (failed, rejected).
799
+ - **Map the stages in order**, the first `0`, the final `'complete'`, the
800
+ ones between `1`–`3` in order. Two middle stages fill a quarter and a
801
+ half; three, up to three quarters. More than three stages between
802
+ first and done is more than a fill can tell apart: use the tone badge.
803
+ - **Stage circle or tone badge (pattern 5)?** The circle is for an
804
+ *ordered* status: every record goes the same way, and "how far along
805
+ toward done" is the question. A status with no order (`active` /
806
+ `paused` / `blocked`), or one whose end isn't *done*, keeps pattern 5.
807
+ Widget's Status is the second kind: `draft` → `active` → `archived` is
808
+ an order in time, but `archived` is retirement, not completion, and its
809
+ good state is the middle one. A tick on `archived` would say "done"
810
+ where nothing was finished, so it stays a tone badge.
811
+ - **On the record's view** it's the same markup in the header, beside the
812
+ title; editing the status in place (pattern 18) wraps it in
813
+ `<EditableValue>` like a badge.
814
+ - **Cost:** a reader learns the shapes once. Until then the word does the
815
+ work, which is why it stays.
@@ -82,6 +82,24 @@ Two rules that keep this honest:
82
82
  greyscale screenshot and a printed page all lose it. The label text
83
83
  usually carries the meaning already; where a cell is a glyph rather than
84
84
  a word, vary the *shape* (filled / half / slashed) too, not just the hue.
85
+ The package's `StageCircle` does this for an ordered status: dashed,
86
+ filling, solid with a tick, struck through
87
+ ([`cell-patterns.md`](cell-patterns.md) pattern 19).
88
+
89
+ ## Glyphs and icons: 3:1
90
+
91
+ An icon that carries meaning on its own (a stage circle, a category's
92
+ icon, a pressed flag) must clear WCAG's **3:1 non-text minimum** against
93
+ the background behind it, in both themes. axe measures text only, so the
94
+ package's Storybook has a glyph check of its own: every `<svg>` in its
95
+ design-language stories, its ink (with any opacity modifier painted over
96
+ its background) against what's behind it. Every `*-text` tone, every
97
+ category slot and `muted-foreground` clear it as they are. An opacity
98
+ modifier is where it goes wrong: `text-muted-foreground/80` is the
99
+ faintest step that still clears it in light mode (3.2:1), and `/70`
100
+ doesn't (2.7:1). A deliberately faint icon that carries nothing on its
101
+ own (an "empty" star beside a rating's filled ones, pattern 9) may go
102
+ lower; say so in a comment.
85
103
 
86
104
  ## Categorical colour
87
105
 
@@ -119,10 +137,11 @@ Four things about them:
119
137
  `text-foreground`, so readability never depends on the hue. A slot used
120
138
  as a background needs its own contrast check, which nothing here does
121
139
  for you.
122
- - **Nothing re-measures them automatically.** All eight clear the 3:1
123
- non-text minimum in both themes (measured, recorded in `theme.css`), but
124
- axe checks text contrast, not icon contrast — so a change to these
125
- values is not caught by any gate.
140
+ - **The glyph check re-measures them.** All eight clear the 3:1
141
+ non-text minimum in both themes (recorded in `theme.css`). axe checks
142
+ text contrast, not icon contrast, so the package's Storybook glyph check
143
+ draws an icon in each slot (`patterns/CellPatterns`, "Icons with one
144
+ tooltip") and fails a change that takes one below 3:1.
126
145
 
127
146
  If you find yourself wanting a ninth, that is usually a sign the column
128
147
  should be showing a shape or a label rather than more colours.