@pienter/ui 0.2.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 (227) hide show
  1. package/CHANGELOG.md +64 -0
  2. package/CONVENTIONS.md +1499 -0
  3. package/LICENSE +21 -0
  4. package/README.md +71 -0
  5. package/components/action/button/Button.astro +95 -0
  6. package/components/action/button/Button.vue +92 -0
  7. package/components/action/button/IconButton.astro +86 -0
  8. package/components/action/button/IconButton.vue +85 -0
  9. package/components/action/button/button.css +194 -0
  10. package/components/action/toggle/Toggle.vue +38 -0
  11. package/components/action/toggle/toggle.css +77 -0
  12. package/components/action/toggle-group/ToggleGroup.vue +67 -0
  13. package/components/action/toggle-group/toggle-group.css +78 -0
  14. package/components/display/avatar/Avatar.astro +17 -0
  15. package/components/display/avatar/Avatar.vue +30 -0
  16. package/components/display/avatar/AvatarStack.astro +9 -0
  17. package/components/display/avatar/AvatarStack.vue +11 -0
  18. package/components/display/avatar/avatar.css +58 -0
  19. package/components/display/badge/Badge.astro +15 -0
  20. package/components/display/badge/Badge.vue +23 -0
  21. package/components/display/badge/badge.css +56 -0
  22. package/components/display/empty/Empty.astro +9 -0
  23. package/components/display/empty/Empty.vue +11 -0
  24. package/components/display/empty/empty.css +39 -0
  25. package/components/display/icon/Icon.astro +52 -0
  26. package/components/display/icon/Icon.vue +57 -0
  27. package/components/display/icon/icon.css +47 -0
  28. package/components/feedback/alert/Alert.astro +52 -0
  29. package/components/feedback/alert/Alert.vue +60 -0
  30. package/components/feedback/alert/alert.css +78 -0
  31. package/components/feedback/progress/Progress.astro +68 -0
  32. package/components/feedback/progress/Progress.vue +82 -0
  33. package/components/feedback/progress/progress.css +68 -0
  34. package/components/feedback/skeleton/Skeleton.astro +32 -0
  35. package/components/feedback/skeleton/Skeleton.vue +39 -0
  36. package/components/feedback/skeleton/skeleton.css +56 -0
  37. package/components/feedback/spinner/Spinner.astro +25 -0
  38. package/components/feedback/spinner/Spinner.vue +36 -0
  39. package/components/feedback/spinner/spinner.css +91 -0
  40. package/components/feedback/toast/Toast.astro +50 -0
  41. package/components/feedback/toast/Toast.vue +74 -0
  42. package/components/feedback/toast/toast.css +128 -0
  43. package/components/form/checkbox/Checkbox.astro +79 -0
  44. package/components/form/checkbox/Checkbox.vue +95 -0
  45. package/components/form/checkbox/checkbox.css +59 -0
  46. package/components/form/combobox/Combobox.vue +508 -0
  47. package/components/form/combobox/combobox.css +110 -0
  48. package/components/form/date-input/DateInput.astro +105 -0
  49. package/components/form/date-input/DateInput.vue +121 -0
  50. package/components/form/date-input/date-input.css +19 -0
  51. package/components/form/form/Form.astro +106 -0
  52. package/components/form/form/Form.vue +181 -0
  53. package/components/form/form/form.css +46 -0
  54. package/components/form/input-otp/InputOTP.astro +147 -0
  55. package/components/form/input-otp/InputOTP.vue +209 -0
  56. package/components/form/input-otp/input-otp.css +52 -0
  57. package/components/form/label/Label.astro +13 -0
  58. package/components/form/label/Label.vue +20 -0
  59. package/components/form/label/label.css +11 -0
  60. package/components/form/number-field/NumberField.astro +142 -0
  61. package/components/form/number-field/NumberField.vue +155 -0
  62. package/components/form/number-field/number-field.css +115 -0
  63. package/components/form/radio-group/RadioGroup.astro +105 -0
  64. package/components/form/radio-group/RadioGroup.vue +110 -0
  65. package/components/form/radio-group/radio-group.css +114 -0
  66. package/components/form/radio-group/types.ts +14 -0
  67. package/components/form/select/Segmented.vue +36 -0
  68. package/components/form/select/Select.astro +105 -0
  69. package/components/form/select/Select.vue +109 -0
  70. package/components/form/select/select.css +96 -0
  71. package/components/form/slider/Slider.astro +205 -0
  72. package/components/form/slider/Slider.vue +321 -0
  73. package/components/form/slider/slider.css +115 -0
  74. package/components/form/switch/Switch.astro +75 -0
  75. package/components/form/switch/Switch.vue +89 -0
  76. package/components/form/switch/switch.css +64 -0
  77. package/components/form/tags-input/TagsInput.astro +153 -0
  78. package/components/form/tags-input/TagsInput.vue +207 -0
  79. package/components/form/tags-input/tags-input.css +128 -0
  80. package/components/form/text-input/TextInput.astro +84 -0
  81. package/components/form/text-input/TextInput.vue +99 -0
  82. package/components/form/text-input/text-input.css +165 -0
  83. package/components/form/textarea/Textarea.astro +86 -0
  84. package/components/form/textarea/Textarea.vue +102 -0
  85. package/components/form/textarea/textarea.css +25 -0
  86. package/components/layout/accordion/Accordion.vue +59 -0
  87. package/components/layout/accordion/accordion.css +87 -0
  88. package/components/layout/card/Card.astro +13 -0
  89. package/components/layout/card/Card.vue +20 -0
  90. package/components/layout/card/card.css +55 -0
  91. package/components/layout/collapsible/Collapsible.vue +77 -0
  92. package/components/layout/collapsible/collapsible.css +76 -0
  93. package/components/layout/separator/Separator.astro +31 -0
  94. package/components/layout/separator/Separator.vue +33 -0
  95. package/components/layout/separator/separator.css +27 -0
  96. package/components/layout/table/DataTable.vue +127 -0
  97. package/components/layout/table/Table.astro +116 -0
  98. package/components/layout/table/Table.vue +146 -0
  99. package/components/layout/table/TableRow.vue +59 -0
  100. package/components/layout/table/table.css +201 -0
  101. package/components/layout/table/types.ts +35 -0
  102. package/components/layout/table/useTable.ts +7 -0
  103. package/components/navigation/breadcrumb/Breadcrumb.astro +36 -0
  104. package/components/navigation/breadcrumb/Breadcrumb.vue +36 -0
  105. package/components/navigation/breadcrumb/breadcrumb.css +37 -0
  106. package/components/navigation/navbar/Navbar.astro +62 -0
  107. package/components/navigation/navbar/Navbar.vue +50 -0
  108. package/components/navigation/navbar/navbar.css +77 -0
  109. package/components/navigation/pagination/Pagination.vue +107 -0
  110. package/components/navigation/pagination/pagination.css +53 -0
  111. package/components/navigation/sidebar/Sidebar.astro +132 -0
  112. package/components/navigation/sidebar/Sidebar.vue +174 -0
  113. package/components/navigation/sidebar/SidebarItemRender.astro +83 -0
  114. package/components/navigation/sidebar/SidebarItemRender.vue +98 -0
  115. package/components/navigation/sidebar/sidebar.css +303 -0
  116. package/components/navigation/sidebar/types.ts +72 -0
  117. package/components/navigation/tabs/Tabs.vue +84 -0
  118. package/components/navigation/tabs/tabs.css +39 -0
  119. package/components/overlay/alert-dialog/AlertDialog.astro +112 -0
  120. package/components/overlay/alert-dialog/AlertDialog.vue +117 -0
  121. package/components/overlay/alert-dialog/alert-dialog.css +57 -0
  122. package/components/overlay/command/Command.vue +356 -0
  123. package/components/overlay/command/command.css +179 -0
  124. package/components/overlay/dropdown-menu/DropdownMenu.vue +143 -0
  125. package/components/overlay/dropdown-menu/dropdown-menu.css +120 -0
  126. package/components/overlay/modal/Modal.astro +66 -0
  127. package/components/overlay/modal/Modal.vue +85 -0
  128. package/components/overlay/modal/modal.css +60 -0
  129. package/components/overlay/popover/Popover.vue +113 -0
  130. package/components/overlay/popover/popover.css +53 -0
  131. package/components/overlay/sheet/Sheet.vue +88 -0
  132. package/components/overlay/sheet/sheet.css +108 -0
  133. package/components/overlay/tooltip/Tooltip.vue +210 -0
  134. package/components/overlay/tooltip/tooltip.css +50 -0
  135. package/composables/useUrlSort.ts +48 -0
  136. package/icons/alert-triangle.ts +1 -0
  137. package/icons/arrow-down.ts +1 -0
  138. package/icons/arrow-up-down.ts +5 -0
  139. package/icons/arrow-up.ts +1 -0
  140. package/icons/bell.ts +1 -0
  141. package/icons/check.ts +1 -0
  142. package/icons/chevron-down.ts +1 -0
  143. package/icons/chevron-left.ts +1 -0
  144. package/icons/chevron-right.ts +1 -0
  145. package/icons/chevron-up-down.ts +5 -0
  146. package/icons/chevron-up.ts +1 -0
  147. package/icons/circle-alert.ts +1 -0
  148. package/icons/circle-check.ts +1 -0
  149. package/icons/clipboard.ts +1 -0
  150. package/icons/download.ts +1 -0
  151. package/icons/edit.ts +1 -0
  152. package/icons/external-link.ts +1 -0
  153. package/icons/eye.ts +1 -0
  154. package/icons/file.ts +1 -0
  155. package/icons/filter.ts +1 -0
  156. package/icons/folder.ts +1 -0
  157. package/icons/image.ts +1 -0
  158. package/icons/inbox.ts +1 -0
  159. package/icons/index.ts +91 -0
  160. package/icons/info.ts +1 -0
  161. package/icons/layers.ts +1 -0
  162. package/icons/link-2.ts +1 -0
  163. package/icons/list.ts +1 -0
  164. package/icons/loader.ts +3 -0
  165. package/icons/menu.ts +1 -0
  166. package/icons/more-horizontal.ts +1 -0
  167. package/icons/more-vertical.ts +1 -0
  168. package/icons/plus-circle.ts +1 -0
  169. package/icons/plus.ts +1 -0
  170. package/icons/save.ts +1 -0
  171. package/icons/search.ts +1 -0
  172. package/icons/send.ts +1 -0
  173. package/icons/settings.ts +1 -0
  174. package/icons/tool.ts +1 -0
  175. package/icons/trash-2.ts +1 -0
  176. package/icons/trash.ts +1 -0
  177. package/icons/upload-cloud.ts +1 -0
  178. package/icons/upload.ts +1 -0
  179. package/icons/x.ts +1 -0
  180. package/package.json +150 -0
  181. package/styles/0-settings/colors.css +241 -0
  182. package/styles/0-settings/index.css +5 -0
  183. package/styles/0-settings/layout.css +52 -0
  184. package/styles/0-settings/motion.css +11 -0
  185. package/styles/0-settings/spacing.css +15 -0
  186. package/styles/0-settings/typography.css +37 -0
  187. package/styles/0-utils/index.css +1 -0
  188. package/styles/1-reset/index.css +1 -0
  189. package/styles/1-reset/reset.css +26 -0
  190. package/styles/2-base/base.css +42 -0
  191. package/styles/2-base/forms.css +23 -0
  192. package/styles/2-base/index.css +2 -0
  193. package/styles/3-layout/container.css +57 -0
  194. package/styles/3-layout/index.css +2 -0
  195. package/styles/3-layout/section.css +17 -0
  196. package/styles/5-utilities/accessibility.css +13 -0
  197. package/styles/5-utilities/index.css +2 -0
  198. package/styles/5-utilities/text.css +5 -0
  199. package/styles/main.css +8 -0
  200. package/styles/styles.d.ts +6 -0
  201. package/utils/a11y/focus.ts +68 -0
  202. package/utils/a11y/id.ts +10 -0
  203. package/utils/a11y/index.ts +9 -0
  204. package/utils/a11y/keyboard.ts +32 -0
  205. package/utils/a11y/live-region.ts +36 -0
  206. package/utils/controllers/dialog.ts +205 -0
  207. package/utils/controllers/disclosure.ts +117 -0
  208. package/utils/controllers/form.ts +524 -0
  209. package/utils/controllers/index.ts +39 -0
  210. package/utils/controllers/menu.ts +255 -0
  211. package/utils/controllers/number-field.ts +103 -0
  212. package/utils/controllers/otp.ts +252 -0
  213. package/utils/controllers/popover.ts +434 -0
  214. package/utils/controllers/sidebar.ts +610 -0
  215. package/utils/controllers/slider.ts +336 -0
  216. package/utils/controllers/tags-input.ts +255 -0
  217. package/utils/controllers/toast.ts +426 -0
  218. package/utils/dom/index.ts +1 -0
  219. package/utils/dom/scroll-lock.ts +48 -0
  220. package/utils/index.ts +3 -0
  221. package/utils/sort/index.ts +3 -0
  222. package/utils/sort/serialize.ts +19 -0
  223. package/utils/sort/state.ts +11 -0
  224. package/utils/sort/types.ts +15 -0
  225. package/utils/validation/form.ts +93 -0
  226. package/utils/validation/index.ts +13 -0
  227. package/utils/validation/rules.ts +31 -0
package/CONVENTIONS.md ADDED
@@ -0,0 +1,1499 @@
1
+ # `@pienter/ui` Authoring Conventions
2
+
3
+ This document codifies how to author components and utilities in `packages/ui/`. Read it before starting any component task. If a needed convention is missing, escalate to the maintainer rather than improvise.
4
+
5
+ The architectural decisions behind these conventions live in the OpenSpec change `component-library-shadcn-baseline` (see `openspec/changes/component-library-shadcn-baseline/design.md`). This file is the working reference for contributors; the design doc is the rationale.
6
+
7
+ ---
8
+
9
+ ## BEM block & element naming
10
+
11
+ Component CSS uses **BEM-lite**:
12
+
13
+ - **Block**: `pui-` prefix + kebab-case component name. One block per component, declared in the component's CSS file.
14
+ - **Element**: `<block>__<element>` — child parts within the block (separator `__`).
15
+ - **Modifiers**: do **not** use `--` BEM modifiers. Variants, sizes, and runtime states are expressed via `data-*` attributes on the block (see next section). Reasons: keeps the class list short, lets us toggle states from JS without touching `class`, and matches shadcn's pattern.
16
+
17
+ **Worked example — Button**:
18
+
19
+ ```css
20
+ @layer components {
21
+ .pui-btn {
22
+ /* block */
23
+ }
24
+ .pui-btn__spinner {
25
+ /* element */
26
+ }
27
+ .pui-btn[data-variant='primary'] {
28
+ /* variant via data-attribute */
29
+ }
30
+ .pui-btn[data-state='loading'] {
31
+ /* runtime state via data-attribute */
32
+ }
33
+ }
34
+ ```
35
+
36
+ ---
37
+
38
+ ## Component module paths
39
+
40
+ Implementation modules live under a singular role category and retain a
41
+ colocated module folder:
42
+
43
+ ```text
44
+ components/<category>/<module>/
45
+ ```
46
+
47
+ The categories are `action`, `display`, `feedback`, `form`, `layout`,
48
+ `navigation`, and `overlay`. Cross-module implementation imports use these
49
+ categorized paths. Package consumers use the explicit flat exports instead,
50
+ for example `@pienter/ui/components/Button.vue` or
51
+ `@pienter/ui/components/Button.astro`. Category paths, colocated CSS, and
52
+ private helpers are not public package entrypoints.
53
+
54
+ ## Multi-component-per-directory exception
55
+
56
+ Components may co-locate in a single directory when they form a **tightly-coupled visual sibling family** sharing one CSS file. The directory name reflects the shared visual concept, not a single component name.
57
+
58
+ Current consumers:
59
+
60
+ - `components/display/avatar/` — `Avatar` + `AvatarStack` (the stack is a visual layout for grouping avatars; stack styling lives in `avatar.css` alongside the avatar visual)
61
+ - `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)
62
+ - `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)
63
+
64
+ Rules:
65
+
66
+ - The shared CSS file MUST be named after the shared visual concept (`avatar.css`, `select.css`)
67
+ - All co-located components MUST be in the same Astro tier
68
+ - Each component file has a separate flat public import (`@pienter/ui/components/Avatar.vue` and `@pienter/ui/components/AvatarStack.vue`)
69
+ - Each component still has its own entry in the directory's `AUDIT.md`
70
+ - New components SHOULD live in their own directory unless they meet the tightly-coupled-visual-sibling-family bar (i.e., this exception isn't a default — it's earned)
71
+
72
+ This exception is the directory-shape analogue to the form-primitive shared-scaffold exception (see "Form-primitive wrapper" archetype): both accept a single shared resource (CSS file or BEM block) across multiple component files because duplicating it would invite drift.
73
+
74
+ ---
75
+
76
+ ## CSS layer & token rules
77
+
78
+ All component CSS rules SHALL be wrapped in `@layer components { … }` so they
79
+ participate in the documented layer cascade (`reset, base, layout, components, utilities`).
80
+
81
+ All values referencing color, spacing, typography, radii, strokes, and shadows
82
+ SHALL use design tokens via `var(--…)` — no hardcoded hex, rgb, rem, or px
83
+ literals.
84
+
85
+ **Permitted literal escape hatches** (cases where no scale token applies):
86
+
87
+ - decorative widths on small visual elements (e.g., spinner ring border, accent
88
+ underlines) where the value is intrinsic to the element rather than scaled
89
+ - transition durations and timing functions
90
+
91
+ If a needed value isn't covered by a token _and_ isn't on this escape-hatch
92
+ list, add a token to `0-settings/` rather than hardcoding.
93
+
94
+ ---
95
+
96
+ ## `data-*` attribute conventions
97
+
98
+ Variants and runtime states live on `data-*` attributes on the block element, never on class names.
99
+
100
+ | Attribute | Purpose | Example values |
101
+ | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
102
+ | `data-variant` | Visual variant (mutually exclusive within a component) | `primary`, `secondary`, `ghost`, `danger`, `link` |
103
+ | `data-size` | Sizing scale (mutually exclusive) | `sm`, `md` (Button/IconButton); `sm`/`md`/`lg` (Icon); other components add their own |
104
+ | `data-animation` | Continuous decorative animation on the block | `spin` (Icon) |
105
+ | `data-tone` | Semantic tone for non-variant components (Alert, Badge, Toast) | `brand`, `success`, `warning`, `danger` |
106
+ | `data-block` | Block-level layout flag (boolean) | `'true'` or absent |
107
+ | `data-state` | Runtime state controlled by code or controller | `loading`, `open`, `closed`, `checked`, `indeterminate` |
108
+ | `data-status` | Derived semantic status for form primitives (validation outcome) | `error`, `success` |
109
+ | `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` |
110
+ | `data-orientation` | Layout direction for grouped controls (RadioGroup items, future SegmentedControl, etc.) — lives on the group container | `vertical` (default for RadioGroup), `horizontal` |
111
+
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.
113
+
114
+ **`data-state` vs `data-status`** — both attributes describe how a component
115
+ "is right now," but they have different sources:
116
+
117
+ - `data-state` is **runtime UI state** — owned by code or a controller, driven
118
+ by interaction (`loading` while a request is in flight, `open`/`closed` for a
119
+ disclosure, `checked`/`indeterminate` for a toggle). It usually changes in
120
+ response to user input or async work.
121
+ - `data-status` is **derived semantic status** — owned by validation /
122
+ form-orchestration logic, computed from props. `error` means "this field
123
+ failed validation" (typically derived from a non-empty `errors` array);
124
+ `success` means "this field passed and we want to surface that" (an explicit
125
+ prop, not auto-derived).
126
+
127
+ A single component MAY carry both: a TextInput can be `data-state="loading"`
128
+ (busy) AND `data-status="error"` (validation failed) at once.
129
+
130
+ ### Boolean attributes — only-when-truthy
131
+
132
+ For any boolean prop emitted as a `data-*` or `aria-*` attribute, render the
133
+ attribute _only when the prop is truthy_, never with a literal `'false'`:
134
+
135
+ ```vue
136
+ :data-block="block ? 'true' : undefined" :aria-busy="loading ? 'true' :
137
+ undefined"
138
+ ```
139
+
140
+ CSS targets the truthy presence (`[data-block='true']`, `[aria-busy='true']`).
141
+ Avoids `data-block="false"` polluting the DOM and matching unintended selectors.
142
+
143
+ **Conventions**:
144
+
145
+ - `data-state` is reserved for _runtime_ state (loading, open/closed, checked). Static visual choice belongs on `data-variant` or `data-size`.
146
+ - Values are kebab-case strings; never booleans or numbers.
147
+
148
+ **Worked example — Button in loading state**:
149
+
150
+ ```html
151
+ <button
152
+ class="pui-btn"
153
+ data-variant="primary"
154
+ data-size="md"
155
+ data-state="loading"
156
+ aria-busy="true"
157
+ disabled
158
+ >
159
+ <span class="pui-btn__spinner" aria-hidden="true"></span>
160
+ Saving…
161
+ </button>
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Component archetypes
167
+
168
+ Each component fits one of a small set of authoring archetypes. Archetypes are locked as they are encountered in Phase 1.
169
+
170
+ ### Native-element-wrapper
171
+
172
+ A component that wraps a single native HTML element (`<button>`, `<input>`, `<a>`, `<select>`, etc.) with class + `data-*` + ARIA wiring. The wrapper adds variant/size/state mapping but does not synthesize behavior — keyboard and form semantics come from the underlying native element.
173
+
174
+ **Authoring checklist for native-element wrappers**:
175
+
176
+ - [ ] Renders the underlying native element directly (`<button>`, `<input>`,
177
+ `<select>`, `<a>`, etc.); no `<div role="…">` simulations.
178
+ - [ ] Variant/size/visual props map to `data-*` attributes on the element.
179
+ - [ ] `disabled` flows to the native attribute when supported. For elements
180
+ that don't support native `disabled` (`<a>`), use
181
+ `aria-disabled="true"` AND `tabindex="-1"` AND `pointer-events: none`
182
+ in CSS so the element is fully inert.
183
+ - [ ] If polymorphic via a single prop (e.g., Button's `href` toggles
184
+ `<button>`/`<a>`), conditionally drop attributes that don't apply
185
+ (`type`, `disabled`) and use ARIA equivalents on the alternate branch.
186
+ - [ ] Loading state: emit `disabled` (or `aria-disabled` + `tabindex="-1"`
187
+ for `<a>`) AND `aria-busy="true"` AND `data-state="loading"` AND a
188
+ visually-rendered spinner element with `aria-hidden="true"`.
189
+ - [ ] Forwards arbitrary attributes via `...rest` (Astro) / inheritAttrs
190
+ (Vue) so consumers can attach `id`, `name`, event listeners, ARIA
191
+ labels.
192
+
193
+ **Worked example — Button**: see `packages/ui/components/action/button/`. Polymorphic `<button>`/`<a>` via `href`, variants/size/block/state on `data-*`, loading state via `aria-busy` + `data-state="loading"` + `<span class="pui-btn__spinner">` element.
194
+
195
+ ### Form-primitive wrapper
196
+
197
+ A component that wraps a single native form control (`<input>`, `<textarea>`,
198
+ `<select>`, grouped `<input type="radio">`, etc.) plus the surrounding label,
199
+ hint, and error-list — i.e. the visible label, the optional helper text under
200
+ the field, and the validation messages that appear after submit. Form
201
+ primitives extend the native-element-wrapper archetype with **shared
202
+ label / hint / error scaffolding** and the ARIA wiring that ties them to the
203
+ control.
204
+
205
+ **Shared scaffold — explicit BEM exception**: form primitives wrap their
206
+ control in a `pui-field` element that hosts the label, hint, and error list. The
207
+ control itself uses a per-element block name (`pui-input`, `pui-textarea`,
208
+ `pui-select`, `pui-checkbox`, …). This is an **explicit exception** to the
209
+ "block matches component name" rule: the scaffold styles (label typography,
210
+ hint colour, status-driven recolour) are reused by every form primitive, so
211
+ duplicating them under per-component blocks (`v-text-input__label`,
212
+ `pui-textarea__label`, `pui-select__label`, …) would produce 10+ identical
213
+ declarations and invite drift. The trade-off — that the wrapper class doesn't
214
+ match the component file name — is accepted in exchange for one source of
215
+ truth for field chrome.
216
+
217
+ `<fieldset>` is the wrapper for grouped controls (RadioGroup is the
218
+ v1 reference; future Checkbox-group variants would follow the same
219
+ shape) where a single legend describes multiple inputs; everything
220
+ else (TextInput, Textarea, Select, Switch, Checkbox-singular, NumberField,
221
+ DateInput) uses `<div class="pui-field">` with a single `<label>`. See the
222
+ RadioGroup audit (`components/form/radio-group/AUDIT.md`) for the worked example — `<fieldset
223
+ class="pui-field" role="radiogroup">` with `<legend class="pui-field__label">`
224
+ above the rendered options, hint and errors below.
225
+
226
+ **Authoring checklist for form-primitive wrappers**:
227
+
228
+ - [ ] Wrapper renders `<div class="pui-field">` (or `<fieldset class="pui-field">`
229
+ with `<legend class="pui-field__label">` for grouped controls like
230
+ RadioGroup).
231
+ - [ ] Label uses `<label class="pui-field__label" for={inputId}>` (or `<legend>`
232
+ for a fieldset wrapper) with `for` matching the control's `id`.
233
+ - [ ] If `id` is not consumer-provided, generate via
234
+ `generateId('<prefix>')` from `@pienter/ui/utils/a11y/id`, where
235
+ `<prefix>` is the bare native-element name (`'input'`, `'textarea'`,
236
+ `'select'`, `'checkbox'`); for grouped controls use the role
237
+ (`'radiogroup'`). Derive `${inputId}-hint` and `${inputId}-errors`
238
+ from the same base.
239
+ - [ ] Hint renders as `<p class="pui-field__hint" id={hintId}>` only when the
240
+ `hint` prop is set. Omit the element entirely when absent.
241
+ - [ ] Errors render as `<ul class="pui-field__hint" id={errorsId}
242
+ role="alert">` with one `<li>` per error message, only when errors are
243
+ present. Omit the element entirely when the errors array is empty.
244
+ - `role="alert"` (which carries implicit `aria-live="assertive"` and
245
+ `aria-atomic="true"`) is appropriate for submit-time validation messages
246
+ that arrive as a batch. For live/as-you-type validation — out of scope
247
+ for v1 — use `role="status"` or `aria-live="polite"` instead, to avoid
248
+ interrupting the user mid-keystroke. When live validation lands, this
249
+ rule is reopened.
250
+ - [ ] Control element receives `aria-invalid="true"` (string, per the
251
+ boolean-attr-only-when-truthy rule) when errors are present; omit the
252
+ attribute when not.
253
+ - [ ] Control element receives `aria-describedby` listing hint and/or errors
254
+ IDs (space-separated when both); omit the attribute entirely when
255
+ neither exists. Order: hint first, errors second. Hint-first matches a
256
+ "context, then problem" reading flow for screen-reader users; do not
257
+ reorder per-component.
258
+ - [ ] Visual status lives on `data-status` on the wrapper (`pui-field`), not on
259
+ `data-state`. CSS targets `.pui-field[data-status='error'] .pui-input`
260
+ etc. so the same selector pattern works across every form primitive.
261
+ `data-status` is derived: if a `status` prop is set, use it; otherwise
262
+ derive `error` from a non-empty errors array.
263
+ - [ ] Forwards remaining attributes onto the control element (Astro: `...rest`,
264
+ Vue: `inheritAttrs` defaults), so consumers can attach `name`,
265
+ `autocomplete`, `inputmode`, event listeners, ARIA labels, etc.
266
+
267
+ **Declared props are stripped from `$attrs`** — when a Vue component
268
+ declares a prop (e.g., `disabled`, `name`, `value`), Vue removes it from
269
+ `$attrs` before the `v-bind="$attrs"` spread. Re-bind explicitly on the
270
+ control element (`:disabled="disabled"`, `:name="name"`) to flow the
271
+ typed prop through. Order matters: bind `$attrs` first, then explicit
272
+ props — explicit bindings win, which is what we want.
273
+
274
+ - [ ] Compact form primitives (Switch, Checkbox, individual Radio items) use
275
+ `data-layout="inline"` on the `pui-field` wrapper to flip the layout to
276
+ control-left + label-right. Everything else (label association via
277
+ `for`/`id`, hint/error rendering, ARIA wiring, `aria-describedby`
278
+ joining, status-driven recolour) stays unchanged. The DOM source order
279
+ under `data-layout="inline"` is **control first, label second** so the
280
+ `<label for>` association is read in left-to-right order, and the
281
+ `pui-field__hint` elements remain after the label so a single CSS rule
282
+ (`grid-column: 1 / -1`) drops them onto a row below the control + label.
283
+
284
+ **Worked example — TextInput**: see `packages/ui/components/form/text-input/`. The
285
+ wrapper is `<div class="pui-field" data-status>`, the label is
286
+ `<label class="pui-field__label" for>`, the control is `<input class="pui-input">`,
287
+ hint and errors are `<p|ul class="pui-field__hint" id>`. `aria-invalid="true"`
288
+ emitted only on error; `aria-describedby` joins hint and error IDs with a
289
+ space when both exist.
290
+
291
+ **Worked example — Switch (inline layout)**: see
292
+ `packages/ui/components/form/switch/`. The wrapper is
293
+ `<div class="pui-field" data-layout="inline" data-status>`, the control is
294
+ `<input class="pui-switch" type="checkbox" role="switch">` rendered FIRST in
295
+ DOM order, the label is `<label class="pui-field__label" for>` rendered
296
+ second, and hint/errors render below the row spanning both columns. ARIA
297
+ wiring (`aria-checked`, `aria-invalid`, `aria-describedby`) follows the
298
+ same shape as TextInput.
299
+
300
+ **Worked example — RadioGroup (grouped controls)**: see
301
+ `packages/ui/components/form/radio-group/`. The wrapper is
302
+ `<fieldset class="pui-field" role="radiogroup" data-status>`, the label is
303
+ `<legend class="pui-field__label">`, the inner control region is a
304
+ `<div class="pui-radio-group" data-orientation>` containing one
305
+ `<label class="pui-radio-group__item">` per option (each label wraps its own
306
+ native `<input type="radio" class="pui-radio">`), and hint/errors render
307
+ inside the fieldset around the items. The outer field layout is stacked
308
+ (legend above, items below) — `data-layout="inline"` does NOT apply at
309
+ this level; per-item inline layout lives on `.pui-radio-group__item` via
310
+ `display: inline-flex`. Per-radio uses the **label-wrapping pattern**
311
+ (label wraps input) rather than `<label for>` association — the option's
312
+ short string label sits directly inside the wrapping label element. ARIA:
313
+ `aria-invalid` and `aria-describedby` land on the fieldset (group-level);
314
+ each input carries always-emit `aria-checked="true|false"`.
315
+
316
+ **pui-field scaffold CSS — co-located with TextInput for now**: the shared
317
+ `.pui-field`, `.pui-field__label`, `.pui-field__hint`, and
318
+ `.pui-field[data-layout='inline']` rules live in
319
+ `packages/ui/components/form/text-input/text-input.css` because TextInput was
320
+ the first form-primitive consumer. Per-control element rules
321
+ (`.pui-input`, `.pui-switch`, etc.) live in their own component CSS. **When
322
+ the fifth form-primitive ships** (Checkbox, RadioGroup, Select, Textarea,
323
+ NumberField, DateInput will push that count over), extract the
324
+ `pui-field*` rules to `packages/ui/styles/4-components/form-field.css` so
325
+ the scaffold isn't tied to one component's filename. The `data-layout`
326
+ documentation in this file points consumers at the scaffold regardless
327
+ of where the file lives.
328
+
329
+ ### Pure-markup with slots
330
+
331
+ Locked during Phase 1 alongside Card (2026-05-03). A component that renders
332
+ styled markup with **no behavior, no controller, no native form semantics**.
333
+ Variants drive visual style via `data-variant` / `data-tone`; content flows
334
+ in through slots (default and/or named). The component is essentially a
335
+ named CSS chunk wrapped in a framework primitive so consumers don't have to
336
+ remember the wrapper class.
337
+
338
+ **Authoring checklist for pure-markup components**:
339
+
340
+ - [ ] Renders a single block element (or a minimal nested structure when the
341
+ component has distinct semantic regions — e.g. an Empty illustration
342
+ slot above body content).
343
+ - [ ] Default slot for the primary content area. Named slots only when the
344
+ component has 2+ ordered regions whose layout the component owns —
345
+ see "Slot vs prop conventions" below.
346
+ - [ ] Variants and tones expressed via `data-variant` or `data-tone` per the
347
+ locked rules; no per-variant class names.
348
+ - [ ] No JS — no controllers, no event listeners, no reactive state. If a
349
+ pure-markup component grows behavior (loading, dismiss, expand), it
350
+ graduates to one of the other archetypes and the audit entry is updated.
351
+ - [ ] No ARIA roles **unless the component carries semantic meaning the
352
+ markup doesn't already convey**. Card has no role (it's a styled
353
+ `<div>`); Alert has `role="alert"` because its purpose is to surface
354
+ dynamic, attention-worthy messages. Don't add roles speculatively.
355
+ - [ ] If the CSS publishes element classes (`<block>__<element>`) for
356
+ consumer use **inside** the slot content (Card's `pui-card__title`,
357
+ `pui-card__eyebrow`, etc.), document those classes in the audit entry
358
+ as **public API**. Renaming or removing them is a breaking change.
359
+ - [ ] Vue uses the default `inheritAttrs: true` (the wrapper IS the only
360
+ element) so consumer-supplied `id`, `class`, `aria-*`, and event
361
+ listeners land on the wrapper. Astro spreads `...rest` on the wrapper
362
+ to the same effect.
363
+
364
+ **Worked example — Card**: see `packages/ui/components/layout/card/`. Renders a
365
+ single `<div class="pui-card" data-variant>` with a default slot. Variants
366
+ on `data-variant` (`bordered`, `brand`, `muted`); no ARIA role; no
367
+ controller. Public child classes — `pui-card`, `pui-card__eyebrow`,
368
+ `pui-card__title`, `pui-card__body`, `pui-card__meta`, `pui-card__footer` — are
369
+ applied by consumers to their own markup inside the slot.
370
+
371
+ ### Hybrid API (data + slots)
372
+
373
+ Locked during Phase 1 alongside Sidebar (2026-05-03). A component that
374
+ mixes a **data-driven, a11y-critical region** (e.g. Sidebar's navigation
375
+ menu) with **decorative or contextual regions** driven by named slots
376
+ (e.g. `#brand`, `#footer`). The a11y wiring — active-state derivation,
377
+ ARIA attributes, keyboard navigation, roving tabindex — lives once
378
+ inside the component and is not opt-out-able by consumers.
379
+
380
+ This is the canonical archetype for "data-driven menu surfaces" — any
381
+ future component that has a repetitive list of links/items where each
382
+ item must carry consistent ARIA + keyboard behavior follows this
383
+ pattern. Sidebar is the v1 reference; future Menubar / NavigationMenu
384
+ (deferred) would adopt the same shape.
385
+
386
+ **Authoring checklist for hybrid-API components**:
387
+
388
+ - [ ] One **driver-data prop** with a typed shape. When entries can vary
389
+ structurally (link vs group vs section heading), the type is a
390
+ **discriminated union** keyed on a `type` field. Place the union
391
+ in a colocated `types.ts` (`components/<category>/<module>/types.ts`) and
392
+ re-export it from the component file so consumers import one
393
+ symbol.
394
+ - [ ] **Named slots for non-driver regions** only — `#brand`, `#footer`,
395
+ `#actions`, etc. Each slot is a documented region with a fixed
396
+ DOM placement and appears in the component's audit entry. The
397
+ driver-data region's items can NOT be replaced via slot — doing
398
+ so would break the centralized a11y wiring.
399
+ - [ ] A11y wiring is owned by the component and consistent across every
400
+ data-driven entry: active-state derivation, `aria-current`,
401
+ `aria-expanded`, roving tabindex, keyboard arrow navigation,
402
+ `role` choice. Consumers cannot opt out of any of this — they
403
+ provide data, the component renders the right ARIA.
404
+ - [ ] Active-state is **derived**, not pre-marked on items. The
405
+ component takes an active-key prop (e.g. Sidebar's `:active-href`,
406
+ Tabs' `:model-value`) and applies the active styling/ARIA
407
+ internally. Consumers do not pass `active: true` on individual
408
+ items.
409
+ - [ ] Recursion (when items can nest, e.g. Sidebar groups) is handled
410
+ inside the component — the consumer always passes a single flat
411
+ `items` array, even when items are deep trees. In Vue this is
412
+ typically an internal recursive `<SidebarItemRender>` component
413
+ that is **not exported** (private to the parent file). In Astro
414
+ it is a recursive `.astro` component (see worked Sidebar example
415
+ below) — also private.
416
+ - [ ] State that the consumer might want to control (collapsed/expanded
417
+ shell, drawer open/closed, expanded group set) flows through
418
+ props + `v-model` / `update:*` events; runtime state is owned by
419
+ a controller (per the Stateful-component archetype).
420
+
421
+ **Worked example — Sidebar**: see `packages/ui/components/navigation/sidebar/`.
422
+ Hybrid: `:items` drives the menu (with the `link | group | section`
423
+ discriminated union from `sidebar/types.ts`); `#brand` and `#footer`
424
+ slots cover decorative regions. Active-state derived from
425
+ `:active-href` via the hierarchical-match algorithm (below). The
426
+ recursive item rendering lives in a private `SidebarItemRender.vue`
427
+ (Vue) and `SidebarItemRender.astro` (Astro). Runtime state — desktop
428
+ collapse, mobile drawer, per-group expand — owned by `mountSidebar`
429
+ in `controllers/sidebar.ts`.
430
+
431
+ ### Form orchestration
432
+
433
+ Locked during Phase 2 alongside Form (2026-05-03). Form is the **orchestrator**: it does not render a control of its own. Its job is to wire submit handling, run client-side validators, render error chrome (top-level summary + per-field), apply success state, and coordinate the submit busy lifecycle. The form-primitive wrappers (TextInput, Textarea, Switch, Checkbox, RadioGroup, Select, NumberField, DateInput) are the **fields**; Form orchestrates them.
434
+
435
+ **Layered split** — three layers, locked per design D13:
436
+
437
+ 1. **Shared primitives** (used by both Vue and Astro):
438
+ - `utils/validation/rules.ts` — pure validators (`isRequired`, `isEmail`, `minLength`, …).
439
+ - `utils/validation/form.ts:validateFormData(formData, validators)` — runs validators against `FormData`, returns `Record<string, string[]>`. Pure, framework-agnostic.
440
+ - `utils/validation/form.ts:normalizeFieldErrors(map)` — normalizes the `string | string[]` per-field shape from `FormSubmitResponse` to `string[]`.
441
+ - `utils/a11y/focus.ts:focusFirstInvalid(formEl)` — DOM-imperative focus helper.
442
+ - `utils/a11y/live-region.ts:announce(text, priority)` — DOM-imperative announcement.
443
+ 2. **Astro form controller** — `controllers/form.ts:mountForm(el, config) → teardown`. Full imperative orchestration: intercept submit, run validators, render per-field + summary errors, set `aria-busy`, await `onSubmit`, map response to UI, focus first invalid + announce. Astro's only orchestration path.
444
+ 3. **Vue Form orchestration** — lives **inside `Form.vue`** using Vue reactive idioms (refs, watch effects, computed). Calls the shared primitives directly. Does **NOT** wrap `mountForm` — wrapping an imperative DOM controller from Vue fights Vue's data flow (controller mutates the DOM, Vue patches it back, race conditions ensue).
445
+
446
+ **Why two implementations**: forcing a single shared controller means either (a) Vue wraps imperative DOM (loses reactive benefits), or (b) primitives become so abstract they're not really doing the work. Layered split keeps each implementation idiomatic while sharing the expensive parts (validators, focus management, announcement) and the response contract.
447
+
448
+ **Consumer responsibility — Vue vs Astro**:
449
+
450
+ | Concern | Vue (`Form.vue`) | Astro (`mountForm`) |
451
+ | --------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
452
+ | Per-field error display | Consumer wires `:errors` on each form-primitive child from `props.fieldErrors[name]` | Controller writes per-field errors into the field's `pui-field__hint` element automatically |
453
+ | `aria-busy` during submit | Consumer flips `:busy` prop on `<Form>` after async submit completes | Controller sets/removes `aria-busy` automatically |
454
+ | Status message | Consumer sets `:status-message` prop on `<Form>` | Controller renders `statusMessage` from response automatically |
455
+ | Server `errors` (top-level) | Consumer sets `:errors` prop on `<Form>` | Controller renders `response.errors` automatically |
456
+ | Throw vs `{ ok: false }` | Consumer's submit handler runs in parent; throwing is the consumer's exception-handling concern | Controller catches `onSubmit` throws and translates to `{ ok: false, errors: ['Submit failed'] }` |
457
+
458
+ **Error display rules**:
459
+
460
+ - 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. Vue: consumer wires `:errors` on each form-primitive child (typical Vue data flow). Astro: `mountForm` mutates the `pui-field` wrapper imperatively, marking the injected error list with `data-pui-form-errors="true"` so a future re-run can clear it without touching consumer-supplied static error markup.
461
+ - 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"`.
462
+ - 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.
463
+
464
+ **Submit lifecycle**:
465
+
466
+ 1. `submit` event → `event.preventDefault()`. Read `FormData`.
467
+ 2. Run client-side validators. If any fail: render field errors, set `aria-invalid` on each invalid control, focus first invalid, announce error count assertively. Do NOT call `onSubmit`. (Vue emits `validation-error` for telemetry.)
468
+ 3. If validation passes: clear errors, set `aria-busy="true"` on form, disable submit button, call `await onSubmit(data)` (Vue: emit `submit`; Astro: await `config.onSubmit`).
469
+ 4. After `onSubmit` returns: clear `aria-busy`, re-enable submit. Map response — `ok: false` → render server `fieldErrors` + top-level `errors`, focus first invalid, announce; `ok: true` → render `statusMessage`, announce politely.
470
+
471
+ **Disable propagation**: when Form's `busy` is `true`, child form-primitives do NOT auto-disable. The Form-level busy state controls the submit button only. Disabling other fields/buttons inside the form (e.g. a "Cancel" button) is the consumer's call — a Form-wide busy state shouldn't block dismiss affordances.
472
+
473
+ **Astro consumer pattern (locked)**: `Form.astro` renders chrome only. The consumer mounts `mountForm` from a `<script>` block colocated with the form markup. This is intentional: `validators` (functions) and `onSubmit` (async function) cannot be passed as Astro props. The consumer-writes-the-mount-script pattern is verbose but explicit, and matches how real product surfaces will wire up server-driven validation + redirect handling.
474
+
475
+ **Worked example — Form**: see `packages/ui/components/form/form/` and `packages/ui/utils/controllers/form.ts`. Vue uses reactive refs + watch effects; Astro uses `mountForm` from a consumer `<script>` block. Both consume the placeholder `FormSubmitResponse` shape and call the shared `focusFirstInvalid`, `announce`, and `validateFormData` primitives.
476
+
477
+ ### Active-href hierarchical-match algorithm
478
+
479
+ Locked during Phase 1 alongside Sidebar (2026-05-03). The canonical
480
+ algorithm for deriving "is this nav item active?" from a current URL
481
+ path (`activeHref`) and an item's target path (`itemHref`).
482
+
483
+ ```
484
+ isActive(activeHref, itemHref):
485
+ if activeHref is empty/null → false
486
+ if itemHref === '/' → return activeHref === '/' // root special-case
487
+ if activeHref === itemHref → true // exact match
488
+ if activeHref starts with (itemHref + '/') → true // hierarchical
489
+ otherwise → false
490
+ ```
491
+
492
+ **Worked examples**:
493
+
494
+ | activeHref | itemHref | match? | rationale |
495
+ | -------------------- | ---------------- | ------ | ------------------------------------------ |
496
+ | `/admin/members` | `/admin/members` | yes | exact |
497
+ | `/admin/members/123` | `/admin/members` | yes | hierarchical (`/admin/members/` prefix) |
498
+ | `/admin/members-old` | `/admin/members` | no | path-boundary check rejects substring-only |
499
+ | `/admin/members` | `/` | no | root special-case |
500
+ | `/` | `/` | yes | root exact |
501
+
502
+ **Group active-state**: a group item is active when _any_ of its
503
+ descendant items (recursively) is active. Sidebar uses this to expand
504
+ the matching group on mount and to highlight the group header.
505
+
506
+ **Edge cases — out of scope for v1**, documented for future:
507
+
508
+ - Trailing slashes are NOT normalized (`/admin/members/` vs
509
+ `/admin/members` are treated as different). Consumers that may emit
510
+ trailing slashes should normalize before passing to `:active-href`.
511
+ - Query strings and hash fragments are NOT stripped. If `activeHref =
512
+ '/admin/members?filter=active'`, no item href without that query
513
+ matches. Strip in the consumer for now; revisit if real surfaces
514
+ start to need server-driven querystring-aware activation.
515
+ - Case sensitivity follows JS string comparison (case-sensitive).
516
+
517
+ This algorithm lives in the Sidebar component (not in `utils/`) for
518
+ v1 because it has no other consumer; promote to a shared util when
519
+ Breadcrumb / Tabs / Pagination / DropdownMenu need the same matching
520
+ (Phase 3+).
521
+
522
+ ---
523
+
524
+ ## Slot vs prop conventions
525
+
526
+ Locked during Phase 1 alongside Card (2026-05-03). Rules apply across every
527
+ component, not only the pure-markup tier.
528
+
529
+ ### When to use a default slot
530
+
531
+ Use a single default slot for the primary content area when the slot's
532
+ content is "whatever the consumer wants" — rich markup, other components,
533
+ arbitrary structure. Examples: Card body, Modal body, Alert content,
534
+ AlertDialog body, Sheet body, Sidebar item content via `link.label` markup
535
+ (deferred to Sidebar audit).
536
+
537
+ ### When to use named slots
538
+
539
+ Use named slots when the component has **2+ distinct semantic regions** AND
540
+ the component itself owns the **layout and ordering** of those regions —
541
+ i.e. the consumer can't simply concatenate them inside a default slot
542
+ because the component places each in a fixed DOM position. Examples:
543
+
544
+ - Modal's `#footer` (placed below the body, inside the dialog content,
545
+ styled as the action bar)
546
+ - Nav's `#actions` (placed at the trailing edge of the navbar)
547
+ - Sidebar's `#brand` and `#footer` (header / footer regions of the layout
548
+ shell, deferred to Sidebar)
549
+ - AlertDialog's actions slot (deferred to Phase 4)
550
+
551
+ Each named slot is a **documented region with its own DOM placement** and
552
+ appears in the component's audit entry alongside its purpose.
553
+
554
+ ### When to use props for content
555
+
556
+ Use a prop when the content is a **single short string with no rich markup
557
+ in v1**. Examples: TextInput's `label`, `hint`, `placeholder`; Modal's
558
+ `title`, `subtitle`; AlertDialog's `title`, `description`, `confirmLabel`,
559
+ `cancelLabel`. Strings with no semantic structure → props. Anything that
560
+ might want a `<strong>` or a link inside it → slot.
561
+
562
+ ### Don't mix prop + slot for the same region
563
+
564
+ Avoid pairing a `:title` prop AND a `#title` slot for the same logical
565
+ region. Pick one and commit to it. Convention:
566
+
567
+ - If the region is **always** a single string in observed consumer usage →
568
+ prop (`<Modal title="Confirm">`).
569
+ - If a real consumer needs rich markup → switch the prop to a slot in a
570
+ deliberate change. Don't ship the dual-API "prop falls back to slot"
571
+ pattern; it doubles the surface and obscures intent.
572
+
573
+ ### Documented child classes — Card's pattern
574
+
575
+ When a pure-markup component's default slot accepts rich consumer markup
576
+ AND the component publishes element classes (`pui-card__title`,
577
+ `pui-card__eyebrow`, etc.) for consumers to apply within their own markup,
578
+ those classes are **public API**. Document them in the component's audit
579
+ entry next to the slot description. Consumers write
580
+ `<h3 class="pui-card__title">…</h3>` inside `<Card>`'s slot; the class names
581
+ are stable across versions and renaming them is a breaking change.
582
+
583
+ This pattern is the **flat-API equivalent** of shadcn-style compound
584
+ subcomponents. Where shadcn writes `<CardTitle>` (a separately-imported
585
+ React component), we write `<h3 class="pui-card__title">` inside a single
586
+ `<Card>` slot. The styling reach is the same; the import surface is one
587
+ component instead of seven.
588
+
589
+ ### Slots are templating, not subcomponents
590
+
591
+ Named slots accept arbitrary markup or other components. They never require
592
+ importing separately-named subcomponents (no `<CardHeader>` siblings, no
593
+ `<DialogTrigger>` imports). This restates the flat-API rule from D2 and
594
+ applies it to slot wiring: consumer code only ever imports the parent
595
+ component.
596
+
597
+ ### Vue `inheritAttrs` default by archetype
598
+
599
+ - **Pure-markup with slots** (Card, Empty, Skeleton, Alert, Badge): no
600
+ inner element to forward to. Use the default `inheritAttrs: true` so
601
+ consumer-supplied `id`, `class`, `aria-*`, and event listeners land on
602
+ the wrapper.
603
+ - **Native-element wrapper** (Button): the wrapper IS the native element.
604
+ Default `inheritAttrs: true` is correct — attrs land on the `<button>` /
605
+ `<a>` directly.
606
+ - **Form-primitive wrapper** (TextInput): the rendered tree has a wrapper
607
+ (`pui-field`) AND an inner control (`<input>`). Set `inheritAttrs: false`
608
+ and `v-bind="$attrs"` on the inner element so consumer attrs reach the
609
+ control, not the chrome wrapper.
610
+ - **Stateful component** (Modal): the rendered tree is a single mount
611
+ element (`<dialog class="pui-modal">`) that is both the wrapper and the
612
+ surface. Set `inheritAttrs: false` and `v-bind="$attrs"` on the
613
+ `<dialog>` so consumer-supplied `id`, `class`, `aria-*`, and event
614
+ listeners land on the dialog itself rather than on Vue's auto-merged
615
+ fallthrough target. The same pattern applies to AlertDialog and Sheet
616
+ when they land.
617
+
618
+ ---
619
+
620
+ ## Controller mount-on-element pattern
621
+
622
+ Locked during Phase 1 alongside Modal (2026-05-03). Stateful components (Modal,
623
+ AlertDialog, Sheet, Toast, Popover, Tooltip, DropdownMenu, Combobox, Command,
624
+ Accordion, Collapsible, plus per-input controllers — NumberField, TagsInput,
625
+ InputOTP, Slider, Sidebar) are wired through a controller in
626
+ `packages/ui/utils/controllers/`. The controller owns the runtime state
627
+ machine; the framework (Vue or an Astro `<script>` block) acts as the trigger
628
+ source.
629
+
630
+ ### Default signature — teardown-only
631
+
632
+ For controllers whose lifecycle is mount → teardown without runtime
633
+ imperative methods (e.g., `controllers/form.ts:mountForm`,
634
+ per-input mounts), the canonical signature is:
635
+
636
+ ```ts
637
+ export function mount<Name>(element: HTMLElement, config?: <Config>): () => void
638
+ ```
639
+
640
+ The returned function tears down all listeners, observers, and DOM mutations
641
+ introduced by the controller. Astro components invoke from a `<script>`
642
+ block; Vue components invoke from `onMounted` and call the teardown from
643
+ `onBeforeUnmount`.
644
+
645
+ ### Stateful-component signature — control object
646
+
647
+ For stateful components with imperative open/close (or show/hide / expand)
648
+ methods — Modal, AlertDialog, Sheet, Toast, Popover, Tooltip, DropdownMenu,
649
+ Combobox, Accordion, Collapsible — the controller returns a **control
650
+ object** instead of a bare teardown function:
651
+
652
+ ```ts
653
+ interface DialogControl {
654
+ open(): void;
655
+ close(): void;
656
+ isOpen(): boolean;
657
+ teardown(): void;
658
+ }
659
+
660
+ export function mountDialog(
661
+ element: HTMLElement,
662
+ config?: DialogConfig,
663
+ ): DialogControl;
664
+ ```
665
+
666
+ The control object collapses the dialog's state machine to a single source of
667
+ truth. Frameworks call `control.open()` / `control.close()` and observe the
668
+ controller-owned `data-state` attribute; they do not duplicate the state
669
+ machine in reactive props. This avoids the dual-state-machine problem where
670
+ Vue's `props.open` and a controller's internal state desync after a slow
671
+ async transition.
672
+
673
+ The framework's reactive prop is the **trigger source** — it tells the
674
+ controller "I want to be open now"; the controller is the **state machine** —
675
+ it owns the focus trap, scroll lock, portal placement, and the rendered
676
+ `data-state`. The `onClose` callback closes the loop on user-initiated
677
+ close (Escape, backdrop click): the controller invokes `onClose`, the
678
+ framework reflects it back through its prop / `update:modelValue`, and the
679
+ prop watcher then calls `control.close()` — single source of truth in the
680
+ framework's reactive layer, single source of truth in the controller's
681
+ imperative layer, no duplication.
682
+
683
+ `teardown()` is mandatory on every control object — it must release
684
+ listeners, focus traps, scroll locks, restore portaled DOM, and null all
685
+ internal references. Calling `teardown()` while `isOpen()` is `true` MUST
686
+ internally call `close()` first so resources don't leak.
687
+
688
+ ### Singleton-style exception
689
+
690
+ When a controller manages a single global resource (e.g., the Toast queue
691
+ with one global ARIA live region), the module MAY additionally export a
692
+ module-level API (`toast.show()`, `toast.dismiss()`). Use sparingly and
693
+ document the exception here when added.
694
+
695
+ ### When to share a controller vs write a new one
696
+
697
+ Controllers are SHARED when component variants differ only in
698
+ **configuration** — role, persistent flag, initial focus target, ARIA
699
+ attributes set via `setAttribute`. Modal and AlertDialog (Phase 4) share
700
+ `mountDialog` because the difference is `{ role: 'alertdialog', persistent:
701
+ true, initialFocus: 'cancel' }`.
702
+
703
+ Controllers are SEPARATE when components differ in **geometry, focus rules,
704
+ or trigger semantics**:
705
+
706
+ - Popover, Tooltip, DropdownMenu → their own `mountPopover` (anchor-positioned,
707
+ no scroll lock, no focus trap unless a modal popover, click-outside
708
+ dismisses by default)
709
+ - Combobox → its own `mountCombobox` (input + listbox composition, typeahead,
710
+ ARIA combobox pattern)
711
+ - Accordion + Collapsible → share a `mountDisclosure` (expand/collapse pair)
712
+ - Sidebar drawer → `mountSidebar` (the panel is `<nav>`, not `<dialog>`,
713
+ so it can't share `mountDialog`; see "Use native APIs where applicable"
714
+ below)
715
+
716
+ Sheet is NOT in this list — it shares `mountDialog` with Modal +
717
+ AlertDialog. All three render `<dialog>` and only differ in CSS
718
+ geometry (Sheet anchors to a side instead of centering). The controller
719
+ mechanics are identical.
720
+
721
+ Listed mapping (locked):
722
+
723
+ - `mountDialog` → Modal, AlertDialog, Sheet (all `<dialog>`-based; see "Use
724
+ native APIs where applicable" below — Sheet's slide-side geometry is a
725
+ CSS concern, the controller mechanics are identical)
726
+ - `mountPopover` → Popover, Tooltip, DropdownMenu, HoverCard (all
727
+ Popover-API-based; deferred to Phase 4)
728
+ - `mountCombobox` → Combobox, Command (search palette)
729
+ - `mountDisclosure` → Accordion, Collapsible
730
+ - `mountSidebar` → Sidebar (its own controller — desktop layout-shell
731
+ collapse, mobile drawer overlay, per-group expand, and roving-tabindex
732
+ keyboard nav are all Sidebar-specific geometry/focus-rule concerns; v1
733
+ owns its group expand/collapse inline and may compose into
734
+ `mountDisclosure` later)
735
+ - `mountToast` → Toast (with module-level singleton API per the
736
+ singleton-exception rule)
737
+ - `mountForm` → Form (Astro orchestration only; Vue uses reactive
738
+ idioms inside `Form.vue` and does not wrap the controller per D13).
739
+ Signature: `mountForm(formEl, { validators?, onSubmit?, onResult? })`
740
+ → teardown. Owns submit interception, client-side validation
741
+ kickoff, per-field + top-level error rendering, `aria-busy` lifecycle
742
+ management, `focusFirstInvalid` + `announce` calls, and the
743
+ `FormSubmitResponse` → UI mapping.
744
+ - per-input controllers (each its own file, non-stateful
745
+ teardown-only signature per the canonical NumberField pattern):
746
+ - `mountNumberField` → NumberField
747
+ (`controllers/number-field.ts`). Signature:
748
+ `mountNumberField(wrapperEl, { onChange? })` → teardown.
749
+ - `mountTagsInput` → TagsInput (`controllers/tags-input.ts`).
750
+ Signature: `mountTagsInput(wrapperEl, { initialTags?,
751
+ onChange?, maxTags?, separators? })` → teardown.
752
+ - `mountInputOTP` → InputOTP (`controllers/otp.ts`). Signature:
753
+ `mountInputOTP(wrapperEl, { pattern?, onChange? })` →
754
+ teardown.
755
+ - `mountSlider` → Slider (`controllers/slider.ts`). Signature:
756
+ `mountSlider(trackEl, { min?, max?, step?, initialValue?,
757
+ disabled?, onChange? })` → teardown. Note: mounts on the
758
+ inner `.pui-slider` track element, not the outer `.pui-field`
759
+ scaffold (matching NumberField's mount-on-inner pattern).
760
+
761
+ **Form primitives with NO controller** (markup-only with native
762
+ semantics): TextInput, Textarea (planned), Select (planned),
763
+ **Switch**, **Checkbox**, **RadioGroup**. RadioGroup is the
764
+ worked example of a grouped form primitive that does NOT need a
765
+ controller — native `<input type="radio">` elements with a shared
766
+ `name` provide arrow-key keyboard nav, single-selection, and
767
+ Tab-focuses-selected for free; Radix's roving-tabindex
768
+ implementation exists because Radix uses
769
+ `<button role="radio">` rather than the native input. See the
770
+ RadioGroup audit (`components/form/radio-group/AUDIT.md`) for the full rationale.
771
+
772
+ **Press-button family with NO controller** (markup-only,
773
+ `<button aria-pressed>`-driven): **Toggle** (single press-button),
774
+ **ToggleGroup** (multi-select press-button group), **Segmented**
775
+ (single-select press-button group). Each native `<button>` is
776
+ independently tabbable and independently activatable via
777
+ Enter / Space; pressed state is owned by the consumer (`v-model`)
778
+ and surfaced via always-emit `aria-pressed="true"|"false"`. None
779
+ of the three uses roving tabindex — the pattern is reserved for
780
+ single-focus-per-group controls (RadioGroup arrow-key navigation,
781
+ Tabs roving) where one item represents the entire group's focus
782
+ target. Multi-select press-button groups (ToggleGroup) and
783
+ Segmented's tab-like single-select are both "every button is
784
+ independently tabbable" by design, mirroring how shadcn /
785
+ ToggleGroup degrades to standard button focus when not wrapping
786
+ Radix's primitive.
787
+
788
+ **When two NON-`<dialog>` overlay controllers duplicate "modal mode"
789
+ mechanics — extract**: this rule applies only to overlays that don't use
790
+ the native `<dialog>` element. With `<dialog>`, the browser already
791
+ provides the modal mechanics (focus trap, Escape, top-layer rendering),
792
+ so `mountDialog` (Modal, AlertDialog, Sheet) is structurally a thin
793
+ wrapper and there is no shared "modal mode" body to extract.
794
+
795
+ Sidebar's drawer (`controllers/sidebar.ts`) implements its own
796
+ modal-overlay mechanics manually because the panel is a `<nav>`
797
+ landmark rather than a `<dialog>` (see "Use native APIs where
798
+ applicable" below): focus trap on the panel, ref-counted scroll lock
799
+ on body, capture trigger before mount + restore on dismiss,
800
+ ARIA-promotion (`role="dialog"` + `aria-modal="true"` + `aria-label`)
801
+ while open. Today this duplication exists in exactly one place
802
+ (Sidebar). When **a second non-`<dialog>` overlay** lands with the
803
+ same mechanics — Toast's auto-dismissing mode is a candidate, if it
804
+ ends up needing them — review whether to extract a shared
805
+ `utils/a11y/modal-mode.ts` primitive:
806
+ `enterModalMode(panel, { onEscape, restoreFocusTo, ariaLabel }) → exitModalMode()`.
807
+ One consumer is implementation; two is duplication; three is a pattern.
808
+
809
+ ### Use native APIs where applicable
810
+
811
+ Modern HTML provides native overlay primitives that reduce hand-rolled
812
+ focus traps, portals, and escape-key handling. Use them where they fit:
813
+
814
+ | Overlay archetype | Native API | Examples |
815
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
816
+ | Modal, alert dialog, sheet | `<dialog>` + `showModal()` / `close()` | Modal, AlertDialog, Sheet (slide-side variants of the dialog primitive) |
817
+ | Anchored popover, tooltip, dropdown menu, combobox listbox | Popover API (`popover` attribute + `showPopover()` / `hidePopover()`) | Popover, Tooltip, DropdownMenu, Combobox |
818
+ | 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. |
819
+ | 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>` (lazy-created by `controllers/toast.ts`); auto-dismiss + queue are owned by the singleton. The Popover API was considered and rejected: it requires a single element, but the toast region holds N siblings whose lifetimes are independent. |
820
+
821
+ **What native APIs give us (free)**: top-layer rendering (escapes any
822
+ ancestor stacking context / overflow), focus trap (`Tab` cycles within
823
+ the dialog while open), Escape dismiss (fires a `cancel` event whose
824
+ default closes the dialog), `::backdrop` overlay pseudo-element, `[open]`
825
+ state reflection, focus restoration to the trigger on close.
826
+
827
+ **What we still wire manually**: body scroll lock (browsers don't lock —
828
+ keep `utils/dom/scroll-lock.ts:lockScroll()`), backdrop click dismiss
829
+ for `<dialog>` (the browser doesn't auto-close — detect via
830
+ `event.target === el` since `<dialog>` IS the backdrop element),
831
+ persistent variants (suppress the `cancel` event with
832
+ `event.preventDefault()`), `role="alertdialog"` for AlertDialog (set
833
+ via `setAttribute`), our `data-state="open|closed"` reflection
834
+ convention (so CSS selectors continue working consistently).
835
+
836
+ The `mountDialog` / `mountPopover` / `mountSheet` controllers stay as
837
+ the public surface — they own the manual pieces above and standardize
838
+ the controller archetype across components — but their internals lean
839
+ on the native APIs rather than rebuilding them. `mountDialog` in
840
+ particular is a thin wrapper over `<dialog>.showModal()` /
841
+ `<dialog>.close()`.
842
+
843
+ ### Astro: hoisted vs inline script
844
+
845
+ Two patterns for post-render JS in Astro components:
846
+
847
+ **Hoisted module script** (`<script>` block at bottom of `.astro`, no
848
+ `is:inline`): Astro hoists, dedupes, and bundles. Runs once per page
849
+ load. Use this when many instances of the component can render per
850
+ page (NumberField is the v1 example — its `data-pui-number-field`
851
+ selector mounts the controller on every instance) — the dedupe
852
+ wins. The script uses `document.querySelectorAll([data-…])` to find
853
+ all instances and applies behavior to each.
854
+
855
+ **Inline script** (`<script>` block in or co-located with markup,
856
+ typically `is:inline`): runs verbatim in DOM order. Use this when
857
+ the component is essentially singleton per page (Modal, Sidebar) and
858
+ needs per-instance state via the `_<controllerName>` expando pattern.
859
+
860
+ Both patterns ARE compatible with the `_<controllerName>` expando rule
861
+ when stateful behavior is needed; the choice is about deduplication and
862
+ script-payload economy.
863
+
864
+ ### Stateful-component archetype
865
+
866
+ Parallel to native-element-wrapper and form-primitive wrapper, stateful
867
+ components share a common authoring shape.
868
+
869
+ **Authoring checklist for stateful components**:
870
+
871
+ - [ ] Controller in `packages/ui/utils/controllers/<name>.ts` returns a
872
+ control object `{ open / close / isOpen / teardown }` (or the
873
+ equivalent imperative pair for non-open/close components, e.g.
874
+ Disclosure's `expand / collapse / isExpanded / teardown`).
875
+ - [ ] Component template renders the wrapper element as the **mount point**
876
+ with `data-state="closed"` (or equivalent neutral state) as the
877
+ static initial value. The controller flips `data-state` at runtime;
878
+ CSS targets `data-state` selectors only — no class-toggling for state.
879
+ - [ ] Framework component captures a ref to the wrapper element and calls
880
+ `mount<Name>(el, config)` in `onMounted` (Vue) or in a `<script>`
881
+ block that runs after DOM is parsed (Astro). The control object is
882
+ stored on the framework instance (Vue: `let control` in setup; Astro:
883
+ `(el as any)._<name>` so consumer scripts can reach it).
884
+ - [ ] Framework component watches its reactive `open` (or equivalent) prop
885
+ and forwards changes to the controller via `control.open()` /
886
+ `control.close()`. The framework prop is the trigger; the controller
887
+ owns runtime side-effects.
888
+ - [ ] Framework component supplies an `onClose` (or equivalent
889
+ user-requested-state-change) callback to the controller. The callback
890
+ reflects the user intent back into framework state via emit /
891
+ `update:modelValue`; the framework's prop-watcher then calls
892
+ `control.close()`.
893
+ - [ ] `onBeforeUnmount` (Vue) / before navigation (Astro) calls
894
+ `control.teardown()` so listeners, traps, scroll locks, and portaled
895
+ DOM are released even if `close()` was never called.
896
+ - [ ] **Native APIs first**. `<dialog>`-based overlays (Modal, AlertDialog,
897
+ Sheet) get top-layer rendering, focus trap, and Escape dismissal
898
+ from the browser. Popover-API-based overlays (Popover, Tooltip,
899
+ DropdownMenu, Combobox listbox) get top-layer + light dismiss.
900
+ The controller layers on body scroll lock, backdrop-click
901
+ dismissal, persistent suppression, `data-state` reflection, and
902
+ any role override (`role="alertdialog"`). See "Use native APIs
903
+ where applicable" below for the full table.
904
+ - [ ] Non-`<dialog>` overlays (Sidebar drawer — the panel is a `<nav>`
905
+ landmark) use `utils/a11y/focus.ts:createFocusTrap` for the focus
906
+ trap. If a future overlay needs to portal out of its parent's
907
+ stacking context, add a portal helper at that point — none ships
908
+ currently because `<dialog>`-based overlays escape stacking via
909
+ the native top-layer and the Sidebar drawer stays inline. Vue's
910
+ `<Teleport>` is intentionally NOT used should portaling be added —
911
+ both frameworks share one mechanism for one mental model.
912
+ - [ ] Components requiring body-scroll lock use `utils/dom/scroll-lock.ts`.
913
+ The lock is reference-counted so nested overlays don't release each
914
+ other prematurely.
915
+ - [ ] Controllers managing manual focus traps (the non-`<dialog>` case)
916
+ use `utils/a11y/focus.ts:createFocusTrap`. They are also
917
+ responsible for capturing the trigger before focus moves and
918
+ restoring focus to it on close. `<dialog>`-based controllers
919
+ delegate both to the browser; a backup focus restore runs only
920
+ when the browser fails to restore (rare — e.g. the trigger was
921
+ removed from the DOM mid-open).
922
+
923
+ **Locked decisions (Modal, 2026-05-03)** — these apply to every stateful
924
+ overlay component that follows:
925
+
926
+ - **Native `<dialog>` element**: Modal renders a single `<dialog
927
+ class="pui-modal">` element that is both the surface and the backdrop
928
+ source (the backdrop is the `::backdrop` pseudo-element). The browser
929
+ provides top-layer rendering, focus trap, Escape dismissal (via the
930
+ `cancel` event), `[open]` reflection, and focus restoration to the
931
+ trigger on close. AlertDialog (Phase 4) and Sheet (Phase 4) reuse
932
+ the same controller — only their CSS geometry differs.
933
+ - **Portal target**: not applicable. `<dialog>.showModal()` renders
934
+ in the native top-layer, which already escapes any ancestor stacking
935
+ context or `overflow: hidden`. There is no portal helper involved.
936
+ - **Scroll-lock policy**: the body's `overflow` is set to `hidden` and
937
+ `padding-right` is increased by the scrollbar width to prevent layout
938
+ shift. The lock is reference-counted across all open overlays.
939
+ Browsers do NOT lock body scroll for `<dialog>` automatically, so the
940
+ controller still owns this.
941
+ - **`data-state` lifecycle**: `closed` (static initial) → `open` (controller
942
+ flips on `open()`) → `closed` (controller flips on `close()`). The
943
+ `[open]` attribute is set by the browser via `showModal()`/`close()`;
944
+ `data-state` is set by the controller in parallel so CSS selectors
945
+ consistent with the locked convention keep working. Animated
946
+ enter/exit transitions drive off the `data-state` selector pairs in
947
+ CSS; the `opening` / `closing` intermediate states are deferred to
948
+ per-component as needed (Modal v1 has none — see Modal audit
949
+ out-of-scope on `@starting-style`).
950
+ - **Initial focus**: `<dialog>.showModal()` auto-focuses the first
951
+ focusable child (or the element with `[autofocus]`) by default. On
952
+ top of that, the controller's `initialFocus` config can override:
953
+ `'cancel'` focuses the element marked `[data-dialog-cancel]`,
954
+ `HTMLElement` focuses an explicit element, `'first'` and `null`
955
+ defer to the browser. A `[data-dialog-initial-focus]` element
956
+ inside the dialog ALWAYS wins over the configured value (consumer
957
+ override). On `close()`, the browser restores focus to the trigger
958
+ (`document.activeElement` at the time `showModal()` was called); a
959
+ manual backup runs only when the browser fails to restore.
960
+ - **Outside-click semantics**: clicks land on the `<dialog>` element
961
+ itself when the user clicks the `::backdrop` (children clicks bubble
962
+ through with `target` = the inner content). The controller checks
963
+ `event.target === el` to detect a backdrop click. Persistent dialogs
964
+ (AlertDialog, future) skip this dismissal entirely.
965
+ - **Escape key**: when not persistent, Escape fires the native `cancel`
966
+ event whose default action is `el.close()`. The controller forwards
967
+ the close intent through `onClose` so the framework's reactive prop
968
+ stays authoritative. Persistent dialogs call `event.preventDefault()`
969
+ on the `cancel` event to suppress the default close.
970
+ - **Astro `_<controllerName>` expando**: the control object is exposed on
971
+ the `<dialog>` element via a property named after the controller (not
972
+ the component): `_dialog` for `mountDialog` (used by Modal,
973
+ AlertDialog, and Sheet), `_popover` for `mountPopover`, etc.
974
+ Consumers reach the API via
975
+ `document.getElementById('<id>')._dialog.open()`. The TypeScript cast
976
+ `(el as any)._dialog` is the v1 pattern; a typed helper
977
+ `getController<T>(el, key)` may land in `utils/dom/` later.
978
+
979
+ ### Worked example — Modal
980
+
981
+ The full-fat reference. See `packages/ui/components/overlay/modal/` and
982
+ `packages/ui/utils/controllers/dialog.ts` for the actual code.
983
+
984
+ ```ts
985
+ // packages/ui/utils/controllers/dialog.ts
986
+ export interface DialogControl {
987
+ open(): void;
988
+ close(): void;
989
+ isOpen(): boolean;
990
+ teardown(): void;
991
+ }
992
+
993
+ export interface DialogConfig {
994
+ role?: 'dialog' | 'alertdialog'; // default: 'dialog' (browser default for <dialog>)
995
+ persistent?: boolean; // default: false; suppresses cancel default + backdrop click
996
+ initialFocus?: 'first' | 'cancel' | HTMLElement | null; // default: 'first' (defers to native showModal())
997
+ onClose?: () => void; // user-requested close (Escape or backdrop click)
998
+ }
999
+
1000
+ export function mountDialog(
1001
+ el: HTMLDialogElement,
1002
+ config?: DialogConfig,
1003
+ ): DialogControl;
1004
+ ```
1005
+
1006
+ **Vue consumer** (full pattern):
1007
+
1008
+ ```vue
1009
+ <template>
1010
+ <dialog
1011
+ ref="rootRef"
1012
+ v-bind="$attrs"
1013
+ class="pui-modal"
1014
+ data-state="closed"
1015
+ data-pui-modal
1016
+ :aria-labelledby="titleId"
1017
+ >
1018
+ <h2 :id="titleId">{{ title }}</h2>
1019
+ <slot />
1020
+ </dialog>
1021
+ </template>
1022
+
1023
+ <script setup lang="ts">
1024
+ import { ref, watch, onMounted, onBeforeUnmount } from 'vue';
1025
+ import {
1026
+ mountDialog,
1027
+ type DialogControl,
1028
+ } from '@pienter/ui/utils/controllers/dialog.js';
1029
+
1030
+ defineOptions({ inheritAttrs: false });
1031
+
1032
+ const props = defineProps<{
1033
+ open: boolean;
1034
+ title: string;
1035
+ persistent?: boolean;
1036
+ }>();
1037
+ const emit = defineEmits<{ 'update:open': [boolean]; close: [] }>();
1038
+
1039
+ const rootRef = ref<HTMLDialogElement | null>(null);
1040
+ let control: DialogControl | null = null;
1041
+
1042
+ onMounted(() => {
1043
+ control = mountDialog(rootRef.value!, {
1044
+ persistent: props.persistent,
1045
+ onClose: () => {
1046
+ emit('update:open', false);
1047
+ emit('close');
1048
+ },
1049
+ });
1050
+ if (props.open) control.open();
1051
+ });
1052
+
1053
+ watch(
1054
+ () => props.open,
1055
+ (v) => (v ? control?.open() : control?.close()),
1056
+ );
1057
+
1058
+ onBeforeUnmount(() => {
1059
+ control?.teardown();
1060
+ control = null;
1061
+ });
1062
+ </script>
1063
+ ```
1064
+
1065
+ **Astro consumer** (open-by-default or trigger-driven via `_dialog`):
1066
+
1067
+ ```astro
1068
+ ---
1069
+ const { id, open, persistent, title } = Astro.props;
1070
+ ---
1071
+ <dialog
1072
+ id={id}
1073
+ class="pui-modal"
1074
+ data-state="closed"
1075
+ data-pui-modal
1076
+ data-persistent={persistent ? 'true' : undefined}
1077
+ data-open-initially={open ? 'true' : undefined}
1078
+ aria-labelledby={`${id}-title`}
1079
+ >
1080
+ <h2 id={`${id}-title`}>{title}</h2>
1081
+ <slot />
1082
+ </dialog>
1083
+
1084
+ <script>
1085
+ import { mountDialog } from '@pienter/ui/utils/controllers/dialog.js';
1086
+ document.querySelectorAll<HTMLDialogElement>('dialog[data-pui-modal]').forEach((el) => {
1087
+ if ((el as any)._dialog) return;
1088
+ const control = mountDialog(el, {
1089
+ persistent: el.dataset.persistent === 'true',
1090
+ });
1091
+ (el as any)._dialog = control;
1092
+ if (el.dataset.openInitially === 'true') control.open();
1093
+ });
1094
+ </script>
1095
+ ```
1096
+
1097
+ **Astro consumer page — opening from a trigger button**:
1098
+
1099
+ ```astro
1100
+ <button onclick="document.getElementById('demo-modal')._dialog.open()">
1101
+ Open
1102
+ </button>
1103
+ <Modal id="demo-modal" title="Confirm" persistent={false}>
1104
+ <p>Body text.</p>
1105
+ </Modal>
1106
+ ```
1107
+
1108
+ The `_dialog` consumer pattern is the v1 contract for Astro — each Modal
1109
+ exposes its control object as a property on the wrapper element so consumer
1110
+ scripts can reach it. A future iteration may add declarative
1111
+ `data-modal-trigger="<id>"` button wiring; deferred for v1.
1112
+
1113
+ ### Vue API decisions
1114
+
1115
+ - **`v-model:open`** is the public surface (Vue 3.4+). Implementations may
1116
+ use `defineModel<boolean>('open')` (Vue 3.6+) or the explicit
1117
+ `props: { open } + emit('update:open', boolean)` pair — both produce the
1118
+ same v-model semantics. The library currently uses the explicit
1119
+ `props + emit` pair for consistency with the existing Phase 1 components
1120
+ (Choice, TextInput, Switch, etc.); migrate when a future change refreshes
1121
+ all components together.
1122
+ - A separate `@close` event is emitted alongside `update:open(false)` so
1123
+ consumers can hook close-specific side effects without watching the
1124
+ v-model.
1125
+ - `inheritAttrs: false` on the wrapper, `v-bind="$attrs"` on the dialog
1126
+ content, mirroring TextInput.
1127
+
1128
+ ---
1129
+
1130
+ ## ARIA attribute conventions
1131
+
1132
+ Locked during Phase 1 alongside Modal + Card (2026-05-03). When to use
1133
+ static ARIA vs reactive bindings, and how each framework expresses the two
1134
+ shapes.
1135
+
1136
+ ### Static ARIA — write the attribute literally
1137
+
1138
+ When the value is **template-time constant** — known at render and never
1139
+ changes — write the attribute statically on the element. No reactive
1140
+ binding. Examples:
1141
+
1142
+ ```vue
1143
+ <!-- Modal dialog content -->
1144
+ <div role="dialog" aria-modal="true">…</div>
1145
+ ```
1146
+
1147
+ ```astro
1148
+ <ul role="list" aria-label="Breadcrumbs">…</ul>
1149
+ ```
1150
+
1151
+ This is the common case for `role`, `aria-modal`, `aria-label` (when the
1152
+ label is a literal string), and `aria-orientation`.
1153
+
1154
+ ### Reactive ARIA bindings (Vue) — `:aria-*`
1155
+
1156
+ When the value depends on **props or runtime state**, use Vue's reactive
1157
+ binding syntax:
1158
+
1159
+ ```vue
1160
+ <input
1161
+ :aria-invalid="hasErrors ? 'true' : undefined"
1162
+ :aria-describedby="describedBy"
1163
+ :aria-busy="loading ? 'true' : undefined"
1164
+ />
1165
+
1166
+ <button :aria-disabled="isDisabled ? 'true' : undefined">…</button>
1167
+
1168
+ <div :aria-labelledby="titleId">…</div>
1169
+ ```
1170
+
1171
+ The pattern matches the **boolean-attr-only-when-truthy** rule above: emit
1172
+ `'true'` (string) when the prop / state is truthy, otherwise `undefined`
1173
+ so Vue drops the attribute entirely. Never emit the literal string
1174
+ `'false'`.
1175
+
1176
+ ### Astro ARIA — template expressions
1177
+
1178
+ Astro doesn't have reactivity but its template-expression syntax handles
1179
+ the same conditional shape:
1180
+
1181
+ ```astro
1182
+ <input
1183
+ aria-invalid={hasErrors ? 'true' : undefined}
1184
+ aria-describedby={describedBy}
1185
+ />
1186
+
1187
+ <div aria-labelledby={`${id}-title`}>…</div>
1188
+ ```
1189
+
1190
+ The only difference from Vue is the syntax sigil (`{}` vs `:`). The
1191
+ `undefined`-drops-attribute behavior is identical. Consumer-supplied
1192
+ dynamic ARIA values land via the `...rest` spread on the appropriate
1193
+ element (the wrapper for pure-markup; the inner control for form
1194
+ primitives).
1195
+
1196
+ ### Boolean ARIA values — strings, not literals
1197
+
1198
+ Per the boolean-attr-only-when-truthy rule, emit `'true'` (the string)
1199
+ only when the underlying boolean prop is truthy; **omit the attribute
1200
+ entirely** otherwise. Never emit `'false'`. CSS that targets ARIA boolean
1201
+ state writes `[aria-busy='true']`, `[aria-invalid='true']`,
1202
+ `[aria-disabled='true']`, `[aria-expanded='true']`, etc.
1203
+
1204
+ **Disambiguation — native HTML booleans vs ARIA boolean strings**:
1205
+ native HTML boolean attributes (`disabled`, `readonly`, `required`,
1206
+ `checked`, `selected`) follow framework boolean-prop semantics — bind
1207
+ the boolean directly (`:disabled="x"` in Vue, `disabled={x}` in Astro);
1208
+ the framework renders the bare attribute when truthy and drops it when
1209
+ falsy. ARIA boolean attributes (`aria-busy`, `aria-invalid`,
1210
+ `aria-disabled`) take the _string_ `'true'` and the only-when-truthy
1211
+ idiom (`x ? 'true' : undefined`). Do not pass a raw boolean to an
1212
+ `aria-*` binding — Astro will stringify `false` to the literal
1213
+ `"false"`, which fails the rule. For `aria-expanded`, `aria-checked`,
1214
+ `aria-pressed`, and `aria-selected`, see Always-emit ARIA exceptions
1215
+ below — they require explicit `'true'`/`'false'`.
1216
+
1217
+ **Always-emit exceptions** — a small set of ARIA attributes carry
1218
+ meaning in BOTH their `'true'` and `'false'` states (the absent state
1219
+ means something different — typically "not applicable" rather than
1220
+ "explicitly false"). For these, emit the string `'true'` or `'false'`
1221
+ literally; do NOT omit when falsy:
1222
+
1223
+ - `aria-expanded` on a disclosure trigger / group toggle — absent means
1224
+ "not a disclosure"; `'false'` means "is a disclosure, currently
1225
+ collapsed". Used by Sidebar group toggles and any future
1226
+ Disclosure-pattern component.
1227
+ - `aria-checked` on `role="switch"` or `role="checkbox"` /
1228
+ `role="radio"` controls — absent means "not a check-style control";
1229
+ `'false'` means "is a check-style control, currently unchecked".
1230
+ Used by Switch, Checkbox, and RadioGroup items. v1 Checkbox emits
1231
+ the binary `'true'` / `'false'` only; the tri-state `'mixed'`
1232
+ branch is reserved for a future tri-state consumer (e.g., a
1233
+ "select all" data-table header) and re-enters the rule unchanged.
1234
+ - `aria-pressed` on a toggle button (`role="button"` with toggled
1235
+ state) — same shape; `'false'` means "is a toggle button, currently
1236
+ not pressed". Used by the future press-button Toggle (Phase 4).
1237
+ - `aria-selected` on listbox / option / tab items — absent means "not
1238
+ a selectable item"; `'false'` means "is selectable, currently not
1239
+ selected".
1240
+
1241
+ For these four, the binding shape is `:aria-<name>="x ? 'true' :
1242
+ 'false'"` (Vue) or `aria-<name>={x ? 'true' : 'false'}` (Astro). Native
1243
+ elements with intrinsic role semantics provide some of this for free
1244
+ (e.g. `<input type="checkbox">` carries implicit `checked` state) — but
1245
+ when the role is set explicitly (`role="switch"` on a checkbox-backed
1246
+ input), the matching ARIA attribute MUST also be set explicitly so AT
1247
+ reads the role-state pair consistently.
1248
+
1249
+ ### ARIA ownership — template vs controller
1250
+
1251
+ For stateful components (Modal, AlertDialog, Sheet, Popover, Disclosure),
1252
+ some ARIA attributes are owned by the **template** (template-time data —
1253
+ heading IDs, label strings, role choice when fixed) and some by the
1254
+ **controller** (runtime state — `aria-modal` set during open, `data-state`
1255
+ mirrored to ARIA semantics where applicable). Each controller's audit
1256
+ entry documents which ARIA attributes it owns; everything else is the
1257
+ framework template's responsibility. For Modal:
1258
+
1259
+ - Template: `aria-labelledby` (heading ID is template-time).
1260
+ - Browser: `role="dialog"` (the implicit role for `<dialog>`) and
1261
+ `aria-modal` (set implicitly when `showModal()` is called) — no
1262
+ template or controller wiring needed.
1263
+ - Controller: `role` override only — `mountDialog` calls
1264
+ `setAttribute('role', 'alertdialog')` for AlertDialog. Modal v1
1265
+ doesn't override and inherits the browser default.
1266
+
1267
+ ### Prefer `aria-labelledby` over `aria-label` when a visible label exists
1268
+
1269
+ When the labelling element is already in the DOM (the dialog title `<h2>`,
1270
+ the field's `<label>`), point at it with `aria-labelledby="<id>"` rather
1271
+ than duplicating the same string in `aria-label`. This avoids the two
1272
+ copies drifting apart and keeps a single source of truth for the visible
1273
+ label.
1274
+
1275
+ `aria-label` is the right call only when there's no visible label
1276
+ (icon-only buttons, search input without a sibling label, decorative
1277
+ landmarks).
1278
+
1279
+ ---
1280
+
1281
+ ## `:focus-visible` rule
1282
+
1283
+ Every interactive component renders a consistent keyboard-focus ring:
1284
+
1285
+ ```css
1286
+ .pui-<block > :focus-visible {
1287
+ outline: 3px solid var(--outline-clr-base);
1288
+ outline-offset: 2px;
1289
+ }
1290
+ ```
1291
+
1292
+ - Use `:focus-visible`, not `:focus` — mouse clicks on a button SHOULD NOT show the ring.
1293
+ - Token-only — never hardcode the color or width.
1294
+ - Apply on the focusable element. For native-element wrappers that's the wrapper itself; for composite components (Tabs, RadioGroup) it's the focusable child (e.g., `.pui-tabs__trigger:focus-visible`).
1295
+ - Do not remove the ring in a `:focus { outline: 0 }` reset — let it default and override only via `:focus-visible`.
1296
+
1297
+ ---
1298
+
1299
+ ## Astro tier mapping
1300
+
1301
+ Components ship Astro implementations only when listed in this tier. SPA-only interactive components are Vue-only; Astro consumers needing them render the Vue version as an Astro island via `client:visible`.
1302
+
1303
+ | Tier | Components | Astro? |
1304
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- |
1305
+ | Static markup | Card, Badge, Alert, Avatar, Avatar-stack, Breadcrumb, Empty, Icon, Skeleton, Spinner, Separator, Progress, Label | Yes — pure `.astro`, no JS |
1306
+ | Form primitives | Button, TextInput, Textarea, Form, Checkbox, RadioGroup, Switch, Select, NumberField, DateInput, TagsInput, InputOTP, Slider | Yes — JS via controllers where needed |
1307
+ | Marketing-needed interactive | Modal, AlertDialog, Toast | Yes — `controllers/dialog.ts`, `controllers/toast.ts` |
1308
+ | Layout | Navbar, Sidebar, Table | Yes — Table's sort ships as links, so still no JS |
1309
+ | SPA-only interactive | Tabs, Pagination, Segmented, Toggle (press), ToggleGroup, Popover, Tooltip, DropdownMenu, Combobox, Command, Sheet, Accordion, Collapsible, DataTable | **Vue-only** |
1310
+
1311
+ Table sort is deliberately not in the Vue-only row. Sorting a table is a
1312
+ navigation, not SPA state (ADR 0005), so the Astro tier renders each sortable
1313
+ header as an `<a>` pointing at the URL that column would produce next — same
1314
+ `aria-sort`, same chevron glyph, same asc → desc → unsorted cycle as the Vue
1315
+ tier's `<button>`, and no JavaScript. What stays Vue-only is `DataTable`, the
1316
+ data-driven wrapper: it renders rows from a reactive array and owns selection
1317
+ state, neither of which a static page has.
1318
+
1319
+ ---
1320
+
1321
+ ## shadcn / Radix commit pins
1322
+
1323
+ _Pinned 2026-05-03. Update both together as a separate, intentional change._
1324
+
1325
+ - **shadcn/ui**: [`7865621397708917369251d67029ad920e390a38`](https://github.com/shadcn-ui/ui/tree/7865621397708917369251d67029ad920e390a38) — 2026-04-30
1326
+ - **Radix Primitives**: [`22473d16404bfd446305db5b6c9308aece99fdec`](https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec) — 2026-02-13
1327
+
1328
+ Per-component briefs cite specific source paths under these SHAs. Subagents read the pinned references, not `main`.
1329
+
1330
+ ---
1331
+
1332
+ ## Per-component audits
1333
+
1334
+ Each component keeps its own audit next to its source, so a decision and the
1335
+ code it governs move in the same diff. Add an entry when you add a component;
1336
+ follow the shape of an existing one.
1337
+
1338
+ | Component | Audit |
1339
+ | ------------ | ------------------------------------------------------------------------------------------ |
1340
+ | Accordion | [`components/layout/accordion/AUDIT.md`](./components/layout/accordion/AUDIT.md) |
1341
+ | Alert | [`components/feedback/alert/AUDIT.md`](./components/feedback/alert/AUDIT.md) |
1342
+ | AlertDialog | [`components/overlay/alert-dialog/AUDIT.md`](./components/overlay/alert-dialog/AUDIT.md) |
1343
+ | Avatar | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1344
+ | AvatarStack | [`components/display/avatar/AUDIT.md`](./components/display/avatar/AUDIT.md) |
1345
+ | Badge | [`components/display/badge/AUDIT.md`](./components/display/badge/AUDIT.md) |
1346
+ | Breadcrumb | [`components/navigation/breadcrumb/AUDIT.md`](./components/navigation/breadcrumb/AUDIT.md) |
1347
+ | Button | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
1348
+ | Card | [`components/layout/card/AUDIT.md`](./components/layout/card/AUDIT.md) |
1349
+ | Checkbox | [`components/form/checkbox/AUDIT.md`](./components/form/checkbox/AUDIT.md) |
1350
+ | Collapsible | [`components/layout/collapsible/AUDIT.md`](./components/layout/collapsible/AUDIT.md) |
1351
+ | Combobox | [`components/form/combobox/AUDIT.md`](./components/form/combobox/AUDIT.md) |
1352
+ | Command | [`components/overlay/command/AUDIT.md`](./components/overlay/command/AUDIT.md) |
1353
+ | DataTable | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
1354
+ | DateInput | [`components/form/date-input/AUDIT.md`](./components/form/date-input/AUDIT.md) |
1355
+ | DropdownMenu | [`components/overlay/dropdown-menu/AUDIT.md`](./components/overlay/dropdown-menu/AUDIT.md) |
1356
+ | Empty | [`components/display/empty/AUDIT.md`](./components/display/empty/AUDIT.md) |
1357
+ | Form | [`components/form/form/AUDIT.md`](./components/form/form/AUDIT.md) |
1358
+ | Icon | [`components/display/icon/AUDIT.md`](./components/display/icon/AUDIT.md) |
1359
+ | IconButton | [`components/action/button/AUDIT.md`](./components/action/button/AUDIT.md) |
1360
+ | InputOTP | [`components/form/input-otp/AUDIT.md`](./components/form/input-otp/AUDIT.md) |
1361
+ | Label | [`components/form/label/AUDIT.md`](./components/form/label/AUDIT.md) |
1362
+ | Modal | [`components/overlay/modal/AUDIT.md`](./components/overlay/modal/AUDIT.md) |
1363
+ | Navbar | [`components/navigation/navbar/AUDIT.md`](./components/navigation/navbar/AUDIT.md) |
1364
+ | NumberField | [`components/form/number-field/AUDIT.md`](./components/form/number-field/AUDIT.md) |
1365
+ | Pagination | [`components/navigation/pagination/AUDIT.md`](./components/navigation/pagination/AUDIT.md) |
1366
+ | Popover | [`components/overlay/popover/AUDIT.md`](./components/overlay/popover/AUDIT.md) |
1367
+ | Progress | [`components/feedback/progress/AUDIT.md`](./components/feedback/progress/AUDIT.md) |
1368
+ | RadioGroup | [`components/form/radio-group/AUDIT.md`](./components/form/radio-group/AUDIT.md) |
1369
+ | Segmented | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1370
+ | Select | [`components/form/select/AUDIT.md`](./components/form/select/AUDIT.md) |
1371
+ | Separator | [`components/layout/separator/AUDIT.md`](./components/layout/separator/AUDIT.md) |
1372
+ | Sheet | [`components/overlay/sheet/AUDIT.md`](./components/overlay/sheet/AUDIT.md) |
1373
+ | Sidebar | [`components/navigation/sidebar/AUDIT.md`](./components/navigation/sidebar/AUDIT.md) |
1374
+ | Skeleton | [`components/feedback/skeleton/AUDIT.md`](./components/feedback/skeleton/AUDIT.md) |
1375
+ | Slider | [`components/form/slider/AUDIT.md`](./components/form/slider/AUDIT.md) |
1376
+ | Spinner | [`components/feedback/spinner/AUDIT.md`](./components/feedback/spinner/AUDIT.md) |
1377
+ | Switch | [`components/form/switch/AUDIT.md`](./components/form/switch/AUDIT.md) |
1378
+ | Table | [`components/layout/table/AUDIT.md`](./components/layout/table/AUDIT.md) |
1379
+ | Tabs | [`components/navigation/tabs/AUDIT.md`](./components/navigation/tabs/AUDIT.md) |
1380
+ | TagsInput | [`components/form/tags-input/AUDIT.md`](./components/form/tags-input/AUDIT.md) |
1381
+ | Textarea | [`components/form/textarea/AUDIT.md`](./components/form/textarea/AUDIT.md) |
1382
+ | TextInput | [`components/form/text-input/AUDIT.md`](./components/form/text-input/AUDIT.md) |
1383
+ | Toast | [`components/feedback/toast/AUDIT.md`](./components/feedback/toast/AUDIT.md) |
1384
+ | Toggle | [`components/action/toggle/AUDIT.md`](./components/action/toggle/AUDIT.md) |
1385
+ | ToggleGroup | [`components/action/toggle-group/AUDIT.md`](./components/action/toggle-group/AUDIT.md) |
1386
+ | Tooltip | [`components/overlay/tooltip/AUDIT.md`](./components/overlay/tooltip/AUDIT.md) |
1387
+
1388
+ ## Form submit response contract
1389
+
1390
+ _Placeholder shape — subject to change when ts-sdk publishes the canonical response shape. The symmetry requirement (same shape consumed by Vue `Form.vue` and Astro `controllers/form.ts:mountForm`) is locked; the shape itself is provisional and will be migrated together when ts-sdk lands._
1391
+
1392
+ Both the Vue `Form.vue` and the Astro `controllers/form.ts:mountForm` SHALL accept the same submit response shape, mapping server validation errors, success messages, and redirect signals through identical contracts. Drift between the two implementations is treated as a defect.
1393
+
1394
+ ```ts
1395
+ // packages/ui/utils/validation/form.ts
1396
+ export interface FormSubmitResponse {
1397
+ /** Whether the submit succeeded. */
1398
+ ok: boolean;
1399
+ /** Per-field errors keyed by field `name`. Single string or array of strings. */
1400
+ fieldErrors?: Record<string, string | string[]>;
1401
+ /** Top-level errors not tied to a specific field. */
1402
+ errors?: string[];
1403
+ /** Success/status message to announce via aria-live. */
1404
+ statusMessage?: string;
1405
+ /**
1406
+ * Optional post-submit redirect URL — orchestration does NOT
1407
+ * auto-navigate; the value is just stored on the response for the
1408
+ * consumer to act on.
1409
+ */
1410
+ redirect?: string;
1411
+ }
1412
+ ```
1413
+
1414
+ **Mapping rules — both frameworks**:
1415
+
1416
+ - `ok: true` clears all errors, renders `statusMessage` in `.pui-form__status`, and announces it politely.
1417
+ - `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.
1418
+ - `redirect` is a passive value — orchestration does NOT navigate; consumers act on it inside their `onResult` / `@submit` callback.
1419
+
1420
+ **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.
1421
+
1422
+ ```ts
1423
+ // Server APIs commonly send fieldErrors as `string` for single errors
1424
+ // and `string[]` for multiple. Use `normalizeFieldErrors` to coerce to
1425
+ // the canonical `Record<string, string[]>` shape:
1426
+ import { normalizeFieldErrors } from '@pienter/ui/utils/validation';
1427
+
1428
+ const response = await api.submit(data);
1429
+ const normalized = normalizeFieldErrors(response.fieldErrors);
1430
+ // Now safe to .map() and .length-check every entry.
1431
+ ```
1432
+
1433
+ ---
1434
+
1435
+ ## RTL approach
1436
+
1437
+ _Deferred unless a component explicitly needs it. Captured here when it does._
1438
+
1439
+ ---
1440
+
1441
+ ## ARIA live region
1442
+
1443
+ A single global ARIA live region is mounted lazily by the first `announce(text, priority)` call from `utils/a11y/live-region.ts`. Components SHALL NOT create per-component live regions; SHALL use `announce()` for dynamic state-change announcements.
1444
+
1445
+ ---
1446
+
1447
+ ## Component audit table
1448
+
1449
+ One row per module directory under `packages/ui/components/<category>/`. Where a directory ships multiple components (e.g. `avatar/` ships `Avatar` + `AvatarStack`; `select/` ships `Select` + `Segmented`) the row summarises the primary component and the co-located `AUDIT.md` carries the detail. "Last audited" is the date this table was last refreshed in full; a component's `AUDIT.md` may carry an older date if untouched since.
1450
+
1451
+ Pinned upstream SHAs used across the table:
1452
+
1453
+ - shadcn-ui/ui: `7865621397708917369251d67029ad920e390a38` (apps/v4 New York v4 registry, captured 2026-05-03)
1454
+ - radix-ui/primitives: `22473d16404bfd446305db5b6c9308aece99fdec` (captured 2026-05-03)
1455
+
1456
+ | Component | shadcn URL | shadcn SHA | Radix-Vue URL | Radix-Vue SHA | Last audited |
1457
+ | ------------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------ |
1458
+ | accordion | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/accordion.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/accordion/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1459
+ | alert | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/alert.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Radix has no Alert primitive; `role="alert"` is native) | n/a | 2026-05-27 |
1460
+ | alert-dialog | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/alert-dialog.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/alert-dialog/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1461
+ | avatar | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/avatar.tsx | 7865621397708917369251d67029ad920e390a38 | https://www.radix-ui.com/primitives/docs/components/avatar | n/a — referenced docs page, not source pin | 2026-05-27 |
1462
+ | badge | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/badge.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Badge is pure markup) | n/a | 2026-05-27 |
1463
+ | breadcrumb | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/breadcrumb.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (semantic `<nav>` + `<ol>`) | n/a | 2026-05-27 |
1464
+ | button | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/button.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (native `<button>`/`<a>`) | n/a | 2026-05-27 |
1465
+ | card | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/card.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Card is pure markup) | n/a | 2026-05-27 |
1466
+ | checkbox | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/checkbox.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/checkbox/src/checkbox.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1467
+ | collapsible | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/collapsible.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/collapsible/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1468
+ | combobox | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/combobox.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not ship a Combobox primitive | n/a | 2026-05-27 |
1469
+ | command | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/command.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not ship a Command primitive | n/a | 2026-05-27 |
1470
+ | date-input | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/date-picker.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not publish a DatePicker primitive | n/a | 2026-05-27 |
1471
+ | dropdown-menu | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/dropdown-menu.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/dropdown-menu/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1472
+ | empty | Bespoke — shadcn does not ship an Empty / EmptyState component; internal design, manual a11y review | Bespoke | n/a — no headless dep (Empty is pure markup) | n/a | 2026-05-27 |
1473
+ | form | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/form.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/form/src/form.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1474
+ | icon | Bespoke — shadcn does not ship a generic Icon component; internal design, manual a11y review | Bespoke | n/a — no headless dep | n/a | 2026-05-27 |
1475
+ | input-otp | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/input-otp.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — Radix does not publish an OTP primitive | n/a | 2026-05-27 |
1476
+ | label | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/label.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/label/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1477
+ | modal | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/dialog.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/dialog/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1478
+ | navbar | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/navigation-menu.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/navigation-menu | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1479
+ | number-field | Bespoke — shadcn/ui v4 has no dedicated NumberField component; internal design referencing WAI spinbutton pattern | Bespoke | n/a — Radix does not publish a Number primitive | n/a | 2026-05-27 |
1480
+ | pagination | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/pagination.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Pagination is semantic links) | n/a | 2026-05-27 |
1481
+ | popover | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/popover.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/popover/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1482
+ | progress | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/progress.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/progress/src/progress.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1483
+ | radio-group | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/radio-group.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/radio-group/src/radio-group.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1484
+ | select | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/select.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — v1 uses native `<select>`; Segmented co-located in this directory references the toggle-group Radix entry below | n/a | 2026-05-27 |
1485
+ | separator | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/separator.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/separator/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1486
+ | sheet | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/sheet.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/dialog/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1487
+ | sidebar | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/sidebar.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Radix has no Sidebar primitive; item-shape API is bespoke) | n/a | 2026-05-27 |
1488
+ | skeleton | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/skeleton.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (Skeleton is pure CSS) | n/a | 2026-05-27 |
1489
+ | slider | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/slider.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/slider/src/slider.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1490
+ | switch | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/switch.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/blob/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/switch/src/switch.tsx | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1491
+ | table | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/table.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (semantic `<table>`) | n/a | 2026-05-27 |
1492
+ | tabs | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/tabs.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/tabs/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1493
+ | tags-input | Bespoke — shadcn/ui v4 has no dedicated TagsInput; internal design referencing combobox + chip patterns | Bespoke | n/a — Radix does not publish a TagsInput primitive | n/a | 2026-05-27 |
1494
+ | text-input | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/input.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (native `<input>`) | n/a | 2026-05-27 |
1495
+ | textarea | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/textarea.tsx | 7865621397708917369251d67029ad920e390a38 | n/a — no headless dep (native `<textarea>`) | n/a | 2026-05-27 |
1496
+ | toast | https://github.com/shadcn-ui/ui/blob/7865621397708917369251d67029ad920e390a38/apps/v4/registry/new-york-v4/ui/sonner.tsx | 7865621397708917369251d67029ad920e390a38 | https://github.com/radix-ui/primitives/tree/22473d16404bfd446305db5b6c9308aece99fdec/packages/react/toast/src | 22473d16404bfd446305db5b6c9308aece99fdec | 2026-05-27 |
1497
+ | 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 |
1498
+ | 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 |
1499
+ | 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 |