@pienter/ui 0.5.0 → 0.8.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 (66) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/CONVENTIONS.md +91 -28
  3. package/README.md +66 -0
  4. package/components/display/record-details/RecordDetails.vue +61 -0
  5. package/components/display/record-details/record-details.css +37 -0
  6. package/components/display/record-details/types.ts +8 -0
  7. package/components/feedback/toast/Toast.vue +3 -4
  8. package/components/feedback/toast/ToastHost.vue +165 -0
  9. package/components/feedback/toast/toast.ts +116 -0
  10. package/components/feedback/toast/types.ts +48 -0
  11. package/components/form/block-editor/BlockEditor.vue +455 -0
  12. package/components/form/block-editor/block-editor.css +149 -0
  13. package/components/form/block-editor/types.ts +15 -0
  14. package/components/form/checkbox/Checkbox.vue +10 -2
  15. package/components/form/combobox/Combobox.vue +32 -39
  16. package/components/form/combobox/combobox.css +1 -1
  17. package/components/form/date-input/DateInput.vue +10 -2
  18. package/components/form/date-input/date-input.css +2 -17
  19. package/components/form/form/Form.vue +10 -9
  20. package/components/form/form/form.css +2 -22
  21. package/components/form/input-otp/InputOTP.vue +1 -1
  22. package/components/form/input-otp/input-otp.css +1 -1
  23. package/components/form/label/label.css +2 -11
  24. package/components/form/number-field/NumberField.vue +10 -3
  25. package/components/form/number-field/number-field.css +3 -3
  26. package/components/form/radio-group/RadioGroup.vue +1 -1
  27. package/components/form/record-form/RecordFields.vue +128 -0
  28. package/components/form/record-form/RecordForm.vue +116 -0
  29. package/components/form/record-form/fields.ts +20 -0
  30. package/components/form/record-form/record-form.css +15 -0
  31. package/components/form/record-form/types.ts +28 -0
  32. package/components/form/select/Select.vue +13 -4
  33. package/components/form/select/select.css +6 -9
  34. package/components/form/slider/Slider.vue +1 -1
  35. package/components/form/switch/Switch.vue +1 -1
  36. package/components/form/tags-input/TagsInput.vue +17 -2
  37. package/components/form/tags-input/tags-input.css +16 -12
  38. package/components/form/text-input/TextInput.vue +10 -2
  39. package/components/form/text-input/text-input.css +10 -129
  40. package/components/form/textarea/Textarea.vue +10 -2
  41. package/components/form/textarea/textarea.css +3 -19
  42. package/components/layout/app-layout/AppLayout.vue +116 -0
  43. package/components/layout/app-layout/app-layout.css +115 -0
  44. package/components/layout/index/Index.vue +373 -0
  45. package/components/layout/index/index.css +157 -0
  46. package/components/layout/index/useIndex.ts +407 -0
  47. package/components/layout/table/DataTable.vue +14 -1
  48. package/components/layout/table/Table.vue +12 -0
  49. package/components/layout/table/table.css +2 -1
  50. package/components/navigation/breadcrumb/Breadcrumb.vue +24 -5
  51. package/components/navigation/breadcrumb/breadcrumb.css +20 -0
  52. package/components/navigation/sidebar/Sidebar.vue +11 -8
  53. package/components/navigation/sidebar/sidebar.css +23 -16
  54. package/components/navigation/tabs/Tabs.vue +6 -0
  55. package/components/navigation/tabs/tabs.css +7 -7
  56. package/composables/useMenu.ts +20 -27
  57. package/package.json +17 -2
  58. package/styles/0-settings/colors.css +10 -0
  59. package/styles/4-components/form-field.css +112 -0
  60. package/styles/4-components/index.css +1 -0
  61. package/styles/main.css +1 -0
  62. package/utils/a11y/focus.ts +9 -3
  63. package/utils/a11y/index.ts +5 -1
  64. package/utils/a11y/live-region.ts +2 -1
  65. package/utils/cms/index.ts +283 -0
  66. package/utils/cms/schema.json +126 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,155 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0 - 2026-09-15
6
+
7
+ ### Breaking
8
+
9
+ - Index drops `headingLevel`. The heading level now follows `placement`: `h1`
10
+ in the default module placement, `h2` in related placement. Replace
11
+ `heading-level="2"` on an embedded list with `placement="related"`.
12
+
13
+ ### Added
14
+
15
+ - Index `placement` (`module` | `related`, default `module`), exposed as
16
+ `data-placement` on `.pui-index`. Module is the page itself and renders flat:
17
+ the heading and actions above a divider, an unboxed toolbar, the table with
18
+ DataTable's own border and radius, and the pagination footer as a band
19
+ below. Related is a list inside another record's detail page and renders as
20
+ one bordered region holding the header, toolbar, table and pagination
21
+ footer, with a second-level heading. Existing consumers are unchanged; the
22
+ toolbar and results no longer share a bordered body in module placement, so
23
+ consumer styles targeting that box no longer apply.
24
+ - Index `sync-query` accepts a string as well as a boolean. The string is a
25
+ key prefix: `sync-query="contacts"` reads and writes `contacts.page`,
26
+ `contacts.page_size`, `contacts.search`, `contacts.sort` and
27
+ `contacts.filter[...]`, so a related index can share its parent page's URL.
28
+ Unrelated parameters, including the bare `page`, survive untouched.
29
+
30
+ ## 0.7.1 - 2026-09-15
31
+
32
+ ### Changed
33
+
34
+ - RecordFields reuses field error arrays while draft values change, avoiding
35
+ repeated issue scans and unnecessary error-prop updates during typing.
36
+
37
+ - Sidebar and Tabs skip navigation DOM scans for unrelated key presses; Sidebar
38
+ also handles Space activation before collecting visible entries.
39
+
40
+ - Menus initialize roving focus with one enabled-item search and avoid repeating
41
+ full tabindex updates when keyboard navigation triggers a focus event.
42
+
43
+ - NumberField stepper clicks emit one model update while preserving native input
44
+ and change events, so consumer validation and autosave handlers run once.
45
+
46
+ ## 0.7.0 - 2026-09-15
47
+
48
+ ### Changed
49
+
50
+ - Index preserves structured backend errors and offers reset for rejected queries.
51
+ Refresh callbacks wait for navigation and loading to settle. Sortable columns
52
+ are checked against the endpoint's allowed fields before loading.
53
+
54
+ - `Breadcrumb` is now a bar with an optional `actions` slot on the
55
+ inline-end side. The `pui-breadcrumb` block class moved from the `<ol>`
56
+ to the `<nav>`; the list is `pui-breadcrumb__trail`.
57
+ - Index renders its pagination footer as a page-level band after the
58
+ results card instead of inside it, mirroring the header. Pages can pin
59
+ it to the viewport bottom; consumer styles targeting the footer's card
60
+ corner radii no longer apply.
61
+ - Text inputs, textareas, selects, input groups and the tags input now rest
62
+ on the `--border-clr-input` border Button already uses, so control edges
63
+ clear 3:1 in both themes, and take the standard `:focus-visible` outline
64
+ ring instead of `outline: none` plus a translucent halo. The brand border
65
+ tint on focus stays; consumer overrides of the old `box-shadow` ring no
66
+ longer apply. Tabs and the tags-input remove button use the same tokenised
67
+ ring.
68
+ - Breadcrumb links get a `:focus-visible` ring and the bar's gap reads the
69
+ `--space-s` token it was meant to. Record-details labels are
70
+ `--text-clr-muted` and its row separators `--border-clr-subtle`, matching
71
+ other in-panel dividers; Tabs' rule uses the same subtle border and its
72
+ active marker is `--stroke-lg`.
73
+ - Sidebar honours `prefers-reduced-motion` for the drawer slide, backdrop
74
+ fade, caret rotation and width collapse, not only the rail flyout.
75
+ - The tags-input remove button is a 24px target; pills keep their height by
76
+ dropping block padding.
77
+ - Form renders its top-level error summary with the danger `Alert` instead of
78
+ a bespoke box, so it matches every other error surface; consumer styles
79
+ targeting `.pui-form__error-summary` no longer apply. The shared live region
80
+ is created when a Form mounts, so the first "N errors found" announcement is
81
+ no longer dropped.
82
+ - Per-field error lists on every form primitive are `role="status"` rather
83
+ than `role="alert"`: a failed submit is announced once, assertively, by the
84
+ summary and the live region, and each field's errors stay reachable through
85
+ `aria-describedby`. Required fields show a visual `*` after the label.
86
+ - Index announces result changes: the pagination summary and the selection
87
+ container are persistent `role="status"` regions, so page, sort, filter and
88
+ selection-count changes are read out. Its title now names the table through
89
+ `aria-labelledby`.
90
+ - The tags input announces `Removed <tag>` on either removal path, and
91
+ DataTable row checkboxes are named after the first column's cell value
92
+ (`Select <value>`) instead of a uniform "Select row".
93
+ - The block editor's drag handle leaves the tab order (`tabindex="-1"`) since
94
+ Enter and Space do nothing on it; the move buttons are the keyboard path and
95
+ the hint text now says so.
96
+ - The shared field scaffold (`.pui-field`, its label, hint and required
97
+ marker, and the `.pui-input` / `.pui-textarea` surface) moved from
98
+ TextInput's stylesheet to `styles/4-components/form-field.css`, loaded by
99
+ `@pienter/ui/styles`. Textarea, Select, DateInput, Label and the other
100
+ primitives no longer depend on TextInput being on the page for their
101
+ border, focus ring and marker to render. Form's error summary list drops
102
+ the user-agent margin and indent.
103
+
104
+ - Collapsed sidebar rail entries drop their own inline padding so a
105
+ nine-character caption such as "Companies" no longer breaks mid-word.
106
+
107
+ ### Added
108
+
109
+ - `ToastHost` and the `toast` controller provide package-owned FIFO queuing,
110
+ auto-dismiss timing, persistent notifications, hover/focus pause, actions,
111
+ Escape dismissal and live-region announcements.
112
+ - `AppLayout` provides a reusable Vue application shell with header slots,
113
+ Sidebar navigation, a responsive drawer, skip link and focusable main
114
+ landmark without depending on Vue Router.
115
+ - An explicit `@pienter/ui/utils/a11y` export supports directory imports in
116
+ consumer TypeScript project builds.
117
+ - Index's sort select shares state with table headers and supports allowed fields
118
+ without visible columns. Sort labels can be customized independently of columns.
119
+ - Individual filter updates and refresh/loading access in Index action and
120
+ selection slots reduce consumer wiring.
121
+ - `normalizeListQuery` validates structured query state directly, without a URL
122
+ encoding round trip.
123
+ - Index `headingLevel` (`1 | 2 | 3`, default `1`) so a nested list can keep its
124
+ `title`, `description` and actions without a second `h1`; at level 2 or 3
125
+ the title sizes like a section heading. `tableLabel` names the table when
126
+ there is no title.
127
+ - Table and DataTable `label` (a visually hidden `<caption>`) and `labelledby`
128
+ (`aria-labelledby` to a visible heading) give the `<table>` an accessible
129
+ name.
130
+ - Select `required`, mirroring TextInput.
131
+ - TagsInput `required`: the marker plus `aria-required` on the entry input,
132
+ since the native attribute would validate the typed text rather than the
133
+ tag list.
134
+ - `ensureLiveRegion()` from `@pienter/ui/utils/a11y` creates the shared live
135
+ region ahead of the first `announce()`.
136
+
137
+ ## 0.6.0 - 2026-09-11
138
+
139
+ ### Changed
140
+
141
+ - CMS surfaces use softer decorative borders and a lower-contrast canvas; the block editor no longer sits inside a second card. New `--bg-clr-canvas` and `--border-clr-subtle` semantic tokens leave control, text and focus contrast intact.
142
+ - Form validation focuses a visible, enabled invalid control, including when other errors are in hidden tab panels.
143
+ - Index separates page headings and actions from a bordered search, filter and results surface.
144
+ - Compact CMS layouts use tighter table rows and inline pagination controls. Form fields no longer double label spacing or stretch controls beside hints.
145
+
146
+ ### Added
147
+
148
+ - `RecordFields` and RecordForm’s `fields` slot for sidebar and tabbed layouts within one form; Tabs items can supply panel IDs and relationships.
149
+ - `BlockEditor` for typed content blocks, with model updates, app-provided fields/factories, pointer drag-and-drop, keyboard movement, add/remove and controlled collapse.
150
+ - `Breadcrumb` link slot for router links and `RecordForm` before-field slot for headings and other content between fields.
151
+ - `Index`, `RecordForm`, and `RecordDetails` components for CMS pages composed with ordinary Vue routes, props, events, and slots. Index accepts an async loader and owns query state, debounced search, loading, retry, stale-request protection and optional Vue Router query synchronization.
152
+ - Backend query and response types, JSON Schema, URL query codecs, and immutable record path helpers under `@pienter/ui/utils/cms`.
153
+
5
154
  ## 0.5.0 - 2026-08-30
6
155
 
7
156
  ### Breaking
package/CONVENTIONS.md CHANGED
@@ -59,6 +59,7 @@ Current consumers:
59
59
  - `components/display/avatar/` — `Avatar` + `AvatarStack` (the stack is a visual layout for grouping avatars; stack styling lives in `avatar.css` alongside the avatar visual)
60
60
  - `components/form/select/` — `Select` + `Segmented` (the segmented control is a tab-like single-select visual; shares form-primitive scaffolding with Select; will be retrofit in Phase 4)
61
61
  - `components/action/button/` — `Button` + `IconButton` (the icon-only control is a square button. `button.css` shares the base/variant/size/state/focus rules across both via a `.pui-btn, .pui-icon-btn` selector group; a `.pui-btn`-only block holds the label, link variant, block layout, and loading fade that must not reach the icon button; a `.pui-icon-btn` rule adds the square box)
62
+ - `components/feedback/toast/` — `Toast` + `ToastHost` (Toast renders one notification; ToastHost orchestrates the visible family and owns the shared `pui-toast-region` block in `toast.css`)
62
63
 
63
64
  Rules:
64
65
 
@@ -106,6 +107,7 @@ Variants and runtime states live on `data-*` attributes on the block element, ne
106
107
  | `data-status` | Derived semantic status for form primitives (validation outcome) | `error`, `success` |
107
108
  | `data-layout` | Form-primitive scaffold layout selector — lives on `.pui-field`; flips between stacked label-above (default) and compact inline control-left + label-right | `stacked` (default; attribute may be omitted), `inline` |
108
109
  | `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
110
+ | `data-placement` | Where a CMS surface is mounted, which decides its chrome and heading level — lives on `.pui-index` | `module` (default), `related` |
109
111
 
110
112
  The example values listed for each attribute are non-exhaustive — components add states as needed (e.g. `expanded`, `selected`, `active` for upcoming Disclosure, Tabs, Sidebar). New values follow the same kebab-case rule and live on the same attribute family.
111
113
 
@@ -236,14 +238,16 @@ above the rendered options, hint and errors below.
236
238
  - [ ] Hint renders as `<p class="pui-field__hint" id={hintId}>` only when the
237
239
  `hint` prop is set. Omit the element entirely when absent.
238
240
  - [ ] Errors render as `<ul class="pui-field__hint" id={errorsId}
239
- role="alert">` with one `<li>` per error message, only when errors are
241
+ role="status">` with one `<li>` per error message, only when errors are
240
242
  present. Omit the element entirely when the errors array is empty.
241
- - `role="alert"` (which carries implicit `aria-live="assertive"` and
242
- `aria-atomic="true"`) is appropriate for submit-time validation messages
243
- that arrive as a batch. For live/as-you-type validation out of scope
244
- for v1 use `role="status"` or `aria-live="polite"` instead, to avoid
245
- interrupting the user mid-keystroke. When live validation lands, this
246
- rule is reopened.
243
+ - `role="status"` (polite, atomic) keeps a submit batch from being read
244
+ three times over: Form's summary `Alert` and `announce(…, 'assertive')`
245
+ own the interrupting channel, and the list stays reachable through
246
+ `aria-describedby` on the control. Do not put `role="alert"` on a
247
+ per-field list.
248
+ - [ ] When the primitive takes `required`, the label text is followed by
249
+ `<span class="pui-field__required" aria-hidden="true">*</span>`; the
250
+ native attribute carries the semantics, the marker is visual only.
247
251
  - [ ] Control element receives `aria-invalid="true"` (string, per the
248
252
  boolean-attr-only-when-truthy rule) when errors are present; omit the
249
253
  attribute when not.
@@ -310,18 +314,16 @@ short string label sits directly inside the wrapping label element. ARIA:
310
314
  `aria-invalid` and `aria-describedby` land on the fieldset (group-level);
311
315
  each input carries always-emit `aria-checked="true|false"`.
312
316
 
313
- **pui-field scaffold CSS co-located with TextInput for now**: the shared
314
- `.pui-field`, `.pui-field__label`, `.pui-field__hint`, and
315
- `.pui-field[data-layout='inline']` rules live in
316
- `packages/ui/components/form/text-input/text-input.css` because TextInput was
317
- the first form-primitive consumer. Per-control element rules
318
- (`.pui-input`, `.pui-switch`, etc.) live in their own component CSS. **When
319
- the fifth form-primitive ships** (Checkbox, RadioGroup, Select, Textarea,
320
- NumberField, DateInput will push that count over), extract the
321
- `pui-field*` rules to `packages/ui/styles/4-components/form-field.css` so
322
- the scaffold isn't tied to one component's filename. The `data-layout`
323
- documentation in this file points consumers at the scaffold regardless
324
- of where the file lives.
317
+ **pui-field scaffold CSS lives in the styles entry**: the shared
318
+ `.pui-field`, `.pui-field__label`, `.pui-field__required`, `.pui-field__hint`,
319
+ `.pui-field[data-layout='inline']` rules and the `.pui-input` /
320
+ `.pui-textarea` control surface live in
321
+ `packages/ui/styles/4-components/form-field.css`, imported by
322
+ `styles/main.css` so every consumer of `@pienter/ui/styles` gets them
323
+ regardless of which primitive is on the page. Per-control element rules
324
+ (`.pui-select__control`, `.pui-switch`, `.pui-inputgroup`, etc.) stay in
325
+ their own component CSS and recolour from the parent
326
+ `.pui-field[data-status]`.
325
327
 
326
328
  ### Pure-markup with slots
327
329
 
@@ -453,8 +455,8 @@ Locked during Phase 2 alongside Form (2026-05-03). Form is the **orchestrator**:
453
455
 
454
456
  **Error display rules**:
455
457
 
456
- - Per-field errors land in the form-primitive's `pui-field__hint role="alert"` element, set `aria-invalid="true"` on the control, set `data-status="error"` on the wrapper. The consumer wires `:errors` on each form-primitive child (typical Vue data flow).
457
- - Top-level errors (`response.errors[]` or consumer-supplied `:errors` prop) land in `.pui-form__error-summary` at the top of the form, with `role="alert"` and `aria-live="assertive"`.
458
+ - Per-field errors land in the form-primitive's `pui-field__hint role="status"` element, set `aria-invalid="true"` on the control, set `data-status="error"` on the wrapper. The consumer wires `:errors` on each form-primitive child (typical Vue data flow).
459
+ - Top-level errors (`response.errors[]` or consumer-supplied `:errors` prop) render in an `Alert tone="danger"` at the top of the form; Alert carries `role="alert"`.
458
460
  - Success: `statusMessage` renders inside `.pui-form__status` with `aria-live="polite"`, and is also announced via the global ARIA live region by the shared `announce()` primitive.
459
461
 
460
462
  **Submit lifecycle**:
@@ -848,12 +850,12 @@ One consumer is implementation; two is duplication; three is a pattern.
848
850
  Modern HTML provides native overlay primitives that reduce hand-rolled
849
851
  focus traps, portals, and escape-key handling. Use them where they fit:
850
852
 
851
- | Overlay archetype | Native API | Examples |
852
- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
853
- | Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()` | Modal, AlertDialog, Sheet (slide-side variants of the dialog primitive) |
854
- | Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`) | Popover, Tooltip, DropdownMenu, Combobox |
855
- | Navigation drawer, sidebar | **Manual** | Sidebar drawer is `<nav>`, not `<dialog>` — keep manual `inert` / focus-trap mechanics. The semantic landmark is more important than the modal mechanics. |
856
- | Toast | **Manual** — `<ol class="pui-toast-region">` with `aria-live="polite"` and `aria-atomic="false"` | Toasts are non-modal status notifications. The visual layer is a fixed-position `<ol>`; auto-dismiss + queue are owned by whoever renders the region. The Popover API was considered and rejected: it requires a single element, but the toast region holds N siblings whose lifetimes are independent. |
853
+ | Overlay archetype | Native API | Examples |
854
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
855
+ | Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()` | Modal, AlertDialog, Sheet (slide-side variants of the dialog primitive) |
856
+ | Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`) | Popover, Tooltip, DropdownMenu, Combobox |
857
+ | Navigation drawer, sidebar | **Manual** | Sidebar drawer is `<nav>`, not `<dialog>` — keep manual `inert` / focus-trap mechanics. The semantic landmark is more important than the modal mechanics. |
858
+ | Toast | **Manual** — `<ToastHost>` teleports a `role="region"` container to `document.body` | Toasts are non-modal status notifications. `ToastHost` renders the visible queue and owns timing and interaction; the package toast controller owns queue state and announces through the single global live region. The Popover API was considered and rejected because one region holds independently timed siblings. |
857
859
 
858
860
  **What native APIs give us (free)**: top-layer rendering (escapes any
859
861
  ancestor stacking context / overflow), focus trap (`Tab` cycles within
@@ -1252,6 +1254,7 @@ follow the shape of an existing one.
1252
1254
  | Accordion | [`components/layout/accordion/AUDIT.md`](./components/layout/accordion/AUDIT.md) |
1253
1255
  | Alert | [`components/feedback/alert/AUDIT.md`](./components/feedback/alert/AUDIT.md) |
1254
1256
  | AlertDialog | [`components/overlay/alert-dialog/AUDIT.md`](./components/overlay/alert-dialog/AUDIT.md) |
1257
+ | AppLayout | [`components/layout/app-layout/AUDIT.md`](./components/layout/app-layout/AUDIT.md) |
1255
1258
  | Avatar | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1256
1259
  | AvatarStack | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1257
1260
  | Badge | [`components/display/badge/AUDIT.md`](./components/display/badge/AUDIT.md) |
@@ -1293,6 +1296,7 @@ follow the shape of an existing one.
1293
1296
  | Textarea | [`components/form/textarea/AUDIT.md`](./components/form/textarea/AUDIT.md) |
1294
1297
  | TextInput | [`components/form/text-input/AUDIT.md`](./components/form/text-input/AUDIT.md) |
1295
1298
  | Toast | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
1299
+ | ToastHost | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
1296
1300
  | Toggle | [`components/action/toggle/AUDIT.md`](./components/action/toggle/AUDIT.md) |
1297
1301
  | ToggleGroup | [`components/action/toggle-group/AUDIT.md`](./components/action/toggle-group/AUDIT.md) |
1298
1302
  | Tooltip | [`components/overlay/tooltip/AUDIT.md`](./components/overlay/tooltip/AUDIT.md) |
@@ -1326,7 +1330,7 @@ export interface FormSubmitResponse {
1326
1330
  **Mapping rules**:
1327
1331
 
1328
1332
  - `ok: true` clears all errors, renders `statusMessage` in `.pui-form__status`, and announces it politely.
1329
- - `ok: false` renders `errors` in `.pui-form__error-summary`, applies `fieldErrors` to the matching form-primitive wrappers (per-field error list inside `pui-field__hint role="alert"` + `aria-invalid="true"` on the control), focuses the first invalid field via `focusFirstInvalid`, and announces the error count assertively.
1333
+ - `ok: false` renders `errors` in the danger `Alert`, applies `fieldErrors` to the matching form-primitive wrappers (per-field error list inside `pui-field__hint role="status"` + `aria-invalid="true"` on the control), focuses the first invalid field via `focusFirstInvalid`, and announces the error count assertively.
1330
1334
  - `redirect` is a passive value — orchestration does NOT navigate; consumers act on it inside their `@submit` handler.
1331
1335
 
1332
1336
  **Field error normalization**: `fieldErrors[name]` MAY be a single string or an array of strings. Orchestration normalizes to `string[]` via the shared `normalizeFieldErrors` helper before applying to the DOM.
@@ -1409,3 +1413,62 @@ Pinned upstream SHAs used across the table:
1409
1413
  | toggle | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/toggle.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/toggle/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1410
1414
  | toggle-group | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/toggle-group.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/toggle-group/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1411
1415
  | tooltip | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/tooltip.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/tooltip/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1416
+
1417
+ ## CMS page composition
1418
+
1419
+ `Index`, `RecordForm`, and `RecordDetails` compose existing primitives. Consumers
1420
+ register routes and supply endpoint-specific data functions, rendering and actions.
1421
+
1422
+ Index accepts `load(query, { signal })`, `columns` and `queryOptions`. It owns
1423
+ query state, rows, metadata, loading, failures and retry. Search is debounced;
1424
+ search/filter/sort/page-size changes reset page to 1. New requests and unmount
1425
+ abort earlier work; stale responses are ignored even when loaders ignore the signal.
1426
+ `sync-query` opts into Vue Router query decoding and history synchronization.
1427
+ Without it, state stays local. Index imports the optional `vue-router` peer, but
1428
+ requires an installed router instance only for URL synchronization. A string
1429
+ value is a key prefix: `sync-query="contacts"` reads and writes `contacts.page`,
1430
+ `contacts.search`, `contacts.filter[...]` and so on, so a related index shares
1431
+ its parent page's URL without touching the page's own parameters.
1432
+
1433
+ `placement` selects the chrome for the two CMS placements (`CONTEXT.md`, ADR
1434
+ 0008), exposed as `data-placement` on `.pui-index`. `module` (default) is the
1435
+ page itself: an `h1` header above a divider, an unboxed toolbar, the table with
1436
+ DataTable's own border, and the pagination footer as a band below. `related` is
1437
+ a list inside another record's detail page: one bordered region holding the
1438
+ `h2` header, toolbar, table and pagination footer, divided by the subtle border
1439
+ token. The heading level follows the placement; there is no heading-level prop.
1440
+ An index definition — one collection's columns, query options, copy and cell
1441
+ slots — is a consumer component composing `Index` and taking `placement` and a
1442
+ parent id as props. The library ships no definition type or helper.
1443
+
1444
+ Index applies the existing default sort cycle; Table/DataTable still emit a column
1445
+ key as intent, per ADR 0005. A toolbar select shares that sort state and includes
1446
+ all allowed fields, including those without visible columns. Sortable columns
1447
+ must belong to the allowed sort vocabulary; labels may be overridden with
1448
+ `sortLabels`. Filter slots can update one key without replacing siblings. Action
1449
+ and selection slots receive loading state and an awaitable refresh callback.
1450
+ Structured backend query errors offer reset; other load failures offer retry.
1451
+ Use DataTable when the consumer needs controlled data
1452
+ and request state. Index's compact row spacing uses
1453
+ `--pui-table-cell-padding-block`, which Table otherwise defaults to `--space-xs`.
1454
+
1455
+ RecordForm emits detached JSON snapshots, preserving every supplied property.
1456
+ Consumers construct an explicit writable object from backend records. Typed path
1457
+ segments identify nested fields and array indices; fields display issues for
1458
+ their path and descendants. The form summary retains unmatched issues.
1459
+ `--pui-record-form-columns` customizes the field grid, defaulting to one column.
1460
+
1461
+ RecordForm's `fields` slot replaces its default RecordFields rendering for
1462
+ custom sidebar/tab layouts. RecordFields takes the same draft and renders a
1463
+ subset, with `--pui-record-fields-columns` as its grid seam. It never renders a
1464
+ form element. Callers expose the relevant tab when backend issues arrive.
1465
+
1466
+ BlockEditor owns an ordered array of stable `id`/`type` blocks and emits updates
1467
+ for edits, addition, removal and reordering. Creation factories and slot content
1468
+ stay caller-owned. Optional `v-model:collapsed` lets pages reveal invalid blocks.
1469
+ Its colocated audit records pointer, keyboard, focus and cancellation behavior.
1470
+
1471
+ The backend contract lives in `docs/cms/backend-contract.md` with a distributable
1472
+ JSON Schema at `utils/cms/schema.json`. It defines request/response data only;
1473
+ endpoint URLs, authentication, permissions and domain operations belong to the
1474
+ application.
package/README.md CHANGED
@@ -32,6 +32,72 @@ import { icons } from '@pienter/ui/icons';
32
32
  import { generateId } from '@pienter/ui/utils/a11y/id.js';
33
33
  ```
34
34
 
35
+ ### Application layout and notifications
36
+
37
+ `AppLayout` supplies the application landmarks and responsive Sidebar shell.
38
+ Provide product-specific brand and tools through slots, navigation as
39
+ `SidebarItem[]`, and routed content through the default slot. It does not import
40
+ Vue Router; adapt Sidebar clicks through its `navigate` event and pass the
41
+ current route as `active-href`.
42
+
43
+ ```vue
44
+ <script setup lang="ts">
45
+ import { ref } from 'vue';
46
+ import AppLayout from '@pienter/ui/components/AppLayout.vue';
47
+ import ToastHost from '@pienter/ui/components/ToastHost.vue';
48
+ import { toast } from '@pienter/ui/toast';
49
+
50
+ const drawerOpen = ref(false);
51
+ </script>
52
+
53
+ <template>
54
+ <AppLayout
55
+ v-model:drawer-open="drawerOpen"
56
+ :top-items="navigation"
57
+ :active-href="route.path"
58
+ >
59
+ <template #brand>My project</template>
60
+ <RouterView />
61
+ </AppLayout>
62
+ <ToastHost />
63
+ </template>
64
+ ```
65
+
66
+ Call `toast.show({ tone: 'success', title: 'Page saved' })` from any Vue
67
+ module. The default controller shows three notifications at once for five
68
+ seconds. Use `duration: 0` for a persistent notification or
69
+ `createToastController()` when an application needs isolated state.
70
+
71
+ ### CMS pages
72
+
73
+ Compose ordinary Vue routes with `Index`, `RecordForm`, and `RecordDetails`:
74
+
75
+ ```ts
76
+ import Index from '@pienter/ui/components/Index.vue';
77
+ import RecordForm from '@pienter/ui/components/RecordForm.vue';
78
+ import RecordFields from '@pienter/ui/components/RecordFields.vue';
79
+ import BlockEditor from '@pienter/ui/components/BlockEditor.vue';
80
+ import RecordDetails from '@pienter/ui/components/RecordDetails.vue';
81
+ import type { RecordFormField } from '@pienter/ui/components/RecordForm.types';
82
+ import { decodeListQuery, encodeListQuery } from '@pienter/ui/utils/cms';
83
+ ```
84
+
85
+ Index receives `load(query, { signal })`, columns and query options. It handles
86
+ loading, search, pagination, errors and retry; `sync-query` enables Vue Router URL
87
+ synchronization. Index requires the `vue-router` package; an installed router
88
+ instance is needed only when synchronization is enabled. RecordForm receives `v-model`, fields and validation issues,
89
+ emitting a JSON payload on `submit`. RecordDetails receives a record and fields.
90
+ Use slots for custom cells, controls, values and actions. Your pages own route
91
+ declarations, endpoint functions and saving. Use RecordForm’s `fields` slot and
92
+ RecordFields to put groups in sidebars and tab panels while retaining one form.
93
+ BlockEditor accepts `v-model`, block creation factories and a `block` slot; it
94
+ provides drag-and-drop, keyboard movement, add/remove and collapse controls.
95
+
96
+ The [CMS guide](../../docs/cms/README.md) contains working examples. The
97
+ [backend contract](../../docs/cms/backend-contract.md) defines query parameters,
98
+ response envelopes and validation paths; its JSON Schema ships at
99
+ `@pienter/ui/utils/cms/schema.json`.
100
+
35
101
  ### Themes
36
102
 
37
103
  The default theme follows the operating-system color preference, using
@@ -0,0 +1,61 @@
1
+ <script lang="ts">
2
+ export type { RecordDetailsField } from './types.js';
3
+ </script>
4
+
5
+ <script setup lang="ts" generic="T extends object">
6
+ import { readPath } from '../../../utils/cms/index.js';
7
+ import type { RecordDetailsField } from './types.js';
8
+
9
+ const props = withDefaults(
10
+ defineProps<{
11
+ record: T;
12
+ fields: readonly RecordDetailsField[];
13
+ emptyValue?: string;
14
+ }>(),
15
+ { emptyValue: '—' },
16
+ );
17
+
18
+ function valueFor(field: RecordDetailsField): unknown {
19
+ return readPath(props.record, field.path ?? [field.name]);
20
+ }
21
+
22
+ function displayValue(value: unknown): string {
23
+ if (value === undefined || value === null || value === '')
24
+ return props.emptyValue;
25
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
26
+ if (typeof value === 'object') return JSON.stringify(value);
27
+ return String(value);
28
+ }
29
+ </script>
30
+
31
+ <template>
32
+ <div class="pui-record-details">
33
+ <dl class="pui-record-details__list">
34
+ <div
35
+ v-for="field in fields"
36
+ :key="field.name"
37
+ class="pui-record-details__field"
38
+ >
39
+ <dt class="pui-record-details__label">{{ field.label }}</dt>
40
+ <dd class="pui-record-details__value">
41
+ <slot
42
+ :name="`field:${field.name}`"
43
+ :field="field"
44
+ :value="valueFor(field)"
45
+ :record="record"
46
+ >
47
+ {{ displayValue(valueFor(field)) }}
48
+ </slot>
49
+ </dd>
50
+ </div>
51
+ </dl>
52
+ <slot />
53
+ <div v-if="$slots.actions" class="pui-record-details__actions">
54
+ <slot name="actions" :record="record" />
55
+ </div>
56
+ </div>
57
+ </template>
58
+
59
+ <style>
60
+ @import './record-details.css';
61
+ </style>
@@ -0,0 +1,37 @@
1
+ @layer components {
2
+ .pui-record-details {
3
+ display: grid;
4
+ gap: var(--space-s);
5
+
6
+ & .pui-record-details__list {
7
+ display: grid;
8
+ margin: 0;
9
+ }
10
+
11
+ & .pui-record-details__field {
12
+ display: grid;
13
+ grid-template-columns: minmax(0, 1fr) minmax(0, 3fr);
14
+ gap: var(--space-s);
15
+ padding-block: var(--space-xs);
16
+ border-block-end: var(--stroke-sm) solid var(--border-clr-subtle);
17
+ }
18
+
19
+ & .pui-record-details__label {
20
+ color: var(--text-clr-muted);
21
+ font-size: var(--step--1);
22
+ }
23
+
24
+ & .pui-record-details__value {
25
+ margin: 0;
26
+ min-inline-size: 0;
27
+ overflow-wrap: anywhere;
28
+ white-space: pre-wrap;
29
+ }
30
+
31
+ & .pui-record-details__actions {
32
+ display: flex;
33
+ flex-wrap: wrap;
34
+ gap: var(--space-xs);
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ import type { ValuePath } from '../../../utils/cms/index.js';
2
+
3
+ export interface RecordDetailsField {
4
+ name: string;
5
+ label: string;
6
+ /** Defaults to the literal property name, without splitting dots. */
7
+ path?: ValuePath;
8
+ }
@@ -31,10 +31,9 @@
31
31
  import { computed } from 'vue';
32
32
  import Icon from '../../display/icon/Icon.vue';
33
33
  import type { IconName } from '../../../icons/index.js';
34
+ import type { ToastTone } from './types.js';
34
35
 
35
- type Tone = 'brand' | 'success' | 'warning' | 'danger';
36
-
37
- const toneIconMap: Record<Tone, IconName> = {
36
+ const toneIconMap: Record<ToastTone, IconName> = {
38
37
  brand: 'info',
39
38
  success: 'circle-check',
40
39
  warning: 'alert-triangle',
@@ -43,7 +42,7 @@ const toneIconMap: Record<Tone, IconName> = {
43
42
 
44
43
  const props = withDefaults(
45
44
  defineProps<{
46
- tone?: Tone;
45
+ tone?: ToastTone;
47
46
  title: string;
48
47
  message?: string;
49
48
  dismissible?: boolean;