@cocoar/vue-ui 3.1.0 → 3.2.0-beta.11

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 (180) hide show
  1. package/bin/cli.mjs +53 -0
  2. package/dist/components/checkbox/CoarCheckbox.vue.d.ts +1 -1
  3. package/dist/components/checkbox/CoarCheckbox.vue.d.ts.map +1 -1
  4. package/dist/components/code-block/CoarCodeBlock.vue.d.ts +1 -1
  5. package/dist/components/code-block/CoarCodeBlock.vue.d.ts.map +1 -1
  6. package/dist/components/data-list/CoarDataList.vue.d.ts +158 -0
  7. package/dist/components/data-list/CoarDataList.vue.d.ts.map +1 -0
  8. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts +30 -0
  9. package/dist/components/data-list/CoarDataListToolbar.vue.d.ts.map +1 -0
  10. package/dist/components/data-list/data-list-builder.d.ts +288 -0
  11. package/dist/components/data-list/data-list-builder.d.ts.map +1 -0
  12. package/dist/components/data-list/index.d.ts +12 -0
  13. package/dist/components/data-list/index.d.ts.map +1 -0
  14. package/dist/components/data-list/internal/compare.d.ts +14 -0
  15. package/dist/components/data-list/internal/compare.d.ts.map +1 -0
  16. package/dist/components/data-list/internal/reorder-core.d.ts +46 -0
  17. package/dist/components/data-list/internal/reorder-core.d.ts.map +1 -0
  18. package/dist/components/data-list/internal/search.d.ts +16 -0
  19. package/dist/components/data-list/internal/search.d.ts.map +1 -0
  20. package/dist/components/data-list/internal/useDataListLines.d.ts +92 -0
  21. package/dist/components/data-list/internal/useDataListLines.d.ts.map +1 -0
  22. package/dist/components/data-list/internal/useDataListReorder.d.ts +68 -0
  23. package/dist/components/data-list/internal/useDataListReorder.d.ts.map +1 -0
  24. package/dist/components/data-list/internal/useSearchHighlight.d.ts +19 -0
  25. package/dist/components/data-list/internal/useSearchHighlight.d.ts.map +1 -0
  26. package/dist/components/data-list/types.d.ts +185 -0
  27. package/dist/components/data-list/types.d.ts.map +1 -0
  28. package/dist/components/data-list/useDataListModel.d.ts +79 -0
  29. package/dist/components/data-list/useDataListModel.d.ts.map +1 -0
  30. package/dist/components/date-time/_shared/maskito-config.d.ts +7 -2
  31. package/dist/components/date-time/_shared/maskito-config.d.ts.map +1 -1
  32. package/dist/components/date-time/_shared/time-helpers.d.ts +11 -4
  33. package/dist/components/date-time/_shared/time-helpers.d.ts.map +1 -1
  34. package/dist/components/date-time/_shared/use-date-picker-base.d.ts +1 -1
  35. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts +1 -1
  36. package/dist/components/date-time/plain-date-picker/CoarPlainDatePicker.vue.d.ts.map +1 -1
  37. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts +1 -1
  38. package/dist/components/date-time/plain-date-time-picker/CoarPlainDateTimePicker.vue.d.ts.map +1 -1
  39. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts +1 -1
  40. package/dist/components/date-time/zoned-date-time-picker/CoarZonedDateTimePicker.vue.d.ts.map +1 -1
  41. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts +3 -0
  42. package/dist/components/dual-listbox/CoarDualListbox.vue.d.ts.map +1 -1
  43. package/dist/components/form-field/CoarFormField.vue.d.ts +1 -1
  44. package/dist/components/form-field/CoarFormField.vue.d.ts.map +1 -1
  45. package/dist/components/icon/CoarIcon.vue.d.ts +2 -2
  46. package/dist/components/icon/CoarIcon.vue.d.ts.map +1 -1
  47. package/dist/components/listbox/CoarListbox.vue.d.ts +7 -0
  48. package/dist/components/listbox/CoarListbox.vue.d.ts.map +1 -1
  49. package/dist/components/number-input/CoarNumberInput.vue.d.ts +1 -1
  50. package/dist/components/number-input/CoarNumberInput.vue.d.ts.map +1 -1
  51. package/dist/components/otp-input/CoarOtpInput.vue.d.ts +1 -1
  52. package/dist/components/otp-input/CoarOtpInput.vue.d.ts.map +1 -1
  53. package/dist/components/password-input/CoarPasswordInput.vue.d.ts +1 -1
  54. package/dist/components/password-input/CoarPasswordInput.vue.d.ts.map +1 -1
  55. package/dist/components/switch/CoarSwitch.vue.d.ts +1 -1
  56. package/dist/components/switch/CoarSwitch.vue.d.ts.map +1 -1
  57. package/dist/components/text-input/CoarTextInput.vue.d.ts +1 -1
  58. package/dist/components/text-input/CoarTextInput.vue.d.ts.map +1 -1
  59. package/dist/composables/dragRegistry.d.ts +12 -0
  60. package/dist/composables/dragRegistry.d.ts.map +1 -1
  61. package/dist/composables/index.d.ts +2 -2
  62. package/dist/composables/index.d.ts.map +1 -1
  63. package/dist/composables/useDragDrop.d.ts +62 -6
  64. package/dist/composables/useDragDrop.d.ts.map +1 -1
  65. package/dist/composables/useVirtualList.d.ts +21 -1
  66. package/dist/composables/useVirtualList.d.ts.map +1 -1
  67. package/dist/index.css +1 -1
  68. package/dist/index.d.ts +4 -2
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4508 -2039
  71. package/package.json +9 -3
  72. package/skills/cocoar-vue-ui/SKILL.md +244 -0
  73. package/skills/cocoar-vue-ui/references/components/avatar.md +178 -0
  74. package/skills/cocoar-vue-ui/references/components/badge.md +290 -0
  75. package/skills/cocoar-vue-ui/references/components/breadcrumb.md +238 -0
  76. package/skills/cocoar-vue-ui/references/components/button.md +252 -0
  77. package/skills/cocoar-vue-ui/references/components/calendar/agenda-view.md +372 -0
  78. package/skills/cocoar-vue-ui/references/components/calendar/coar-calendar.md +1647 -0
  79. package/skills/cocoar-vue-ui/references/components/calendar/day-view.md +361 -0
  80. package/skills/cocoar-vue-ui/references/components/calendar/index.md +312 -0
  81. package/skills/cocoar-vue-ui/references/components/calendar/month-view.md +486 -0
  82. package/skills/cocoar-vue-ui/references/components/calendar/performance.md +186 -0
  83. package/skills/cocoar-vue-ui/references/components/calendar/timeline-view.md +301 -0
  84. package/skills/cocoar-vue-ui/references/components/calendar/week-view.md +382 -0
  85. package/skills/cocoar-vue-ui/references/components/calendar/work-week-view.md +145 -0
  86. package/skills/cocoar-vue-ui/references/components/calendar/year-view.md +63 -0
  87. package/skills/cocoar-vue-ui/references/components/card.md +151 -0
  88. package/skills/cocoar-vue-ui/references/components/checkbox-group.md +103 -0
  89. package/skills/cocoar-vue-ui/references/components/checkbox.md +279 -0
  90. package/skills/cocoar-vue-ui/references/components/code-block.md +241 -0
  91. package/skills/cocoar-vue-ui/references/components/context-menu.md +355 -0
  92. package/skills/cocoar-vue-ui/references/components/data-grid/checkbox.md +213 -0
  93. package/skills/cocoar-vue-ui/references/components/data-grid/date-columns.md +272 -0
  94. package/skills/cocoar-vue-ui/references/components/data-grid/editing.md +236 -0
  95. package/skills/cocoar-vue-ui/references/components/data-grid/multi-select.md +193 -0
  96. package/skills/cocoar-vue-ui/references/components/data-grid/number.md +147 -0
  97. package/skills/cocoar-vue-ui/references/components/data-grid/select.md +202 -0
  98. package/skills/cocoar-vue-ui/references/components/data-grid/text.md +127 -0
  99. package/skills/cocoar-vue-ui/references/components/data-grid.md +1225 -0
  100. package/skills/cocoar-vue-ui/references/components/data-list.md +2121 -0
  101. package/skills/cocoar-vue-ui/references/components/date-or-time-picker.md +200 -0
  102. package/skills/cocoar-vue-ui/references/components/date-picker.md +203 -0
  103. package/skills/cocoar-vue-ui/references/components/date-time-picker.md +154 -0
  104. package/skills/cocoar-vue-ui/references/components/date-views.md +212 -0
  105. package/skills/cocoar-vue-ui/references/components/dialog.md +148 -0
  106. package/skills/cocoar-vue-ui/references/components/divider.md +157 -0
  107. package/skills/cocoar-vue-ui/references/components/document-viewer/annotations.md +400 -0
  108. package/skills/cocoar-vue-ui/references/components/document-viewer/coar-document-viewer.md +327 -0
  109. package/skills/cocoar-vue-ui/references/components/document-viewer/index.md +234 -0
  110. package/skills/cocoar-vue-ui/references/components/document-viewer/toolbar.md +221 -0
  111. package/skills/cocoar-vue-ui/references/components/drag-drop.md +267 -0
  112. package/skills/cocoar-vue-ui/references/components/dual-listbox.md +449 -0
  113. package/skills/cocoar-vue-ui/references/components/file-explorer/asset-store.md +327 -0
  114. package/skills/cocoar-vue-ui/references/components/file-explorer/in-memory-store.md +176 -0
  115. package/skills/cocoar-vue-ui/references/components/file-explorer/index.md +311 -0
  116. package/skills/cocoar-vue-ui/references/components/file-explorer/use-file-explorer.md +300 -0
  117. package/skills/cocoar-vue-ui/references/components/form-field.md +1025 -0
  118. package/skills/cocoar-vue-ui/references/components/fragment-parser.md +298 -0
  119. package/skills/cocoar-vue-ui/references/components/link.md +235 -0
  120. package/skills/cocoar-vue-ui/references/components/listbox.md +575 -0
  121. package/skills/cocoar-vue-ui/references/components/map/editor.md +448 -0
  122. package/skills/cocoar-vue-ui/references/components/map/index.md +351 -0
  123. package/skills/cocoar-vue-ui/references/components/markdown-diagrams.md +210 -0
  124. package/skills/cocoar-vue-ui/references/components/markdown-editor.md +1478 -0
  125. package/skills/cocoar-vue-ui/references/components/markdown-embeds.md +387 -0
  126. package/skills/cocoar-vue-ui/references/components/markdown-form.md +447 -0
  127. package/skills/cocoar-vue-ui/references/components/markdown.md +276 -0
  128. package/skills/cocoar-vue-ui/references/components/menu.md +380 -0
  129. package/skills/cocoar-vue-ui/references/components/mermaid.md +172 -0
  130. package/skills/cocoar-vue-ui/references/components/navbar.md +147 -0
  131. package/skills/cocoar-vue-ui/references/components/note.md +96 -0
  132. package/skills/cocoar-vue-ui/references/components/notice.md +131 -0
  133. package/skills/cocoar-vue-ui/references/components/number-input.md +215 -0
  134. package/skills/cocoar-vue-ui/references/components/otp-input.md +324 -0
  135. package/skills/cocoar-vue-ui/references/components/page-builder/authoring-contract.md +271 -0
  136. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-builder.md +476 -0
  137. package/skills/cocoar-vue-ui/references/components/page-builder/coar-page-renderer.md +677 -0
  138. package/skills/cocoar-vue-ui/references/components/page-builder/custom-elements.md +441 -0
  139. package/skills/cocoar-vue-ui/references/components/page-builder/idp-integration.md +122 -0
  140. package/skills/cocoar-vue-ui/references/components/page-builder/index.md +573 -0
  141. package/skills/cocoar-vue-ui/references/components/pagination.md +150 -0
  142. package/skills/cocoar-vue-ui/references/components/panel-layout.md +372 -0
  143. package/skills/cocoar-vue-ui/references/components/password-input.md +175 -0
  144. package/skills/cocoar-vue-ui/references/components/popconfirm.md +186 -0
  145. package/skills/cocoar-vue-ui/references/components/popover.md +173 -0
  146. package/skills/cocoar-vue-ui/references/components/progress-bar.md +178 -0
  147. package/skills/cocoar-vue-ui/references/components/radio-group.md +225 -0
  148. package/skills/cocoar-vue-ui/references/components/script-editor.md +1271 -0
  149. package/skills/cocoar-vue-ui/references/components/segmented-control.md +238 -0
  150. package/skills/cocoar-vue-ui/references/components/select.md +463 -0
  151. package/skills/cocoar-vue-ui/references/components/sidebar.md +421 -0
  152. package/skills/cocoar-vue-ui/references/components/spinner.md +132 -0
  153. package/skills/cocoar-vue-ui/references/components/switch.md +195 -0
  154. package/skills/cocoar-vue-ui/references/components/table.md +170 -0
  155. package/skills/cocoar-vue-ui/references/components/tabs.md +382 -0
  156. package/skills/cocoar-vue-ui/references/components/tag.md +178 -0
  157. package/skills/cocoar-vue-ui/references/components/text-input.md +256 -0
  158. package/skills/cocoar-vue-ui/references/components/toast.md +160 -0
  159. package/skills/cocoar-vue-ui/references/components/tooltip.md +121 -0
  160. package/skills/cocoar-vue-ui/references/components/transitions.md +193 -0
  161. package/skills/cocoar-vue-ui/references/components/tree.md +2388 -0
  162. package/skills/cocoar-vue-ui/references/components/virtual-list.md +212 -0
  163. package/skills/cocoar-vue-ui/references/components/wizard.md +251 -0
  164. package/skills/cocoar-vue-ui/references/components/zoned-date-time-picker.md +177 -0
  165. package/skills/cocoar-vue-ui/references/foundations/colors.md +708 -0
  166. package/skills/cocoar-vue-ui/references/foundations/design-principles.md +115 -0
  167. package/skills/cocoar-vue-ui/references/foundations/icons.md +381 -0
  168. package/skills/cocoar-vue-ui/references/foundations/localization/formatting.md +530 -0
  169. package/skills/cocoar-vue-ui/references/foundations/localization/setup.md +86 -0
  170. package/skills/cocoar-vue-ui/references/foundations/localization/timezones.md +182 -0
  171. package/skills/cocoar-vue-ui/references/foundations/localization/translations.md +305 -0
  172. package/skills/cocoar-vue-ui/references/foundations/motion.md +549 -0
  173. package/skills/cocoar-vue-ui/references/foundations/spacing.md +330 -0
  174. package/skills/cocoar-vue-ui/references/foundations/theming.md +140 -0
  175. package/skills/cocoar-vue-ui/references/foundations/typography.md +206 -0
  176. package/skills/cocoar-vue-ui/references/guide/error-handling.md +162 -0
  177. package/skills/cocoar-vue-ui/references/guide/getting-started.md +116 -0
  178. package/skills/cocoar-vue-ui/references/guide/migration-page-builder-3.md +207 -0
  179. package/skills/cocoar-vue-ui/references/guide/migration.md +140 -0
  180. package/skills/cocoar-vue-ui/references/guide/theming.md +98 -0
@@ -0,0 +1,677 @@
1
+ <!-- Generated from apps/docs/components/page-builder/coar-page-renderer.md by apps/docs/scripts/sync-skill.mjs. Do not edit; edit the docs page. -->
2
+
3
+ # `<CoarPageRenderer>` (Preview)
4
+
5
+ The runtime-renderer half of `@cocoar/vue-page-builder`. Takes a `PageNode` schema (produced by [`<CoarPageBuilder>`](./coar-page-builder.md) or written by hand) and renders it as live Cocoar components. This is the component you mount on the actual page that end-users see.
6
+
7
+ The renderer is also the **security boundary** — elements not in `config.allowedElements` are skipped at render time, even if they appear in hand-written or tampered JSON.
8
+
9
+ > **Tip: Stylesheet**
10
+ >
11
+ > Import `@cocoar/vue-page-builder/styles` once in your app — the renderer's layout styles (stack flexbox, section/card spacing) live there too, not just the builder chrome.
12
+
13
+ ## Props
14
+
15
+ | Prop | Type | Description |
16
+ |------|------|-------------|
17
+ | `schema` | `PageNode` | Required. The page schema to render. Legacy `column`/`row` containers and v1 flat documents are [migrated on the fly](#legacy-schemas-normalization). |
18
+ | `config` | [`PageConfig`](./index.md#pageconfig-the-consumer-contract) | Security/allowlist boundary. Elements not in `config.allowedElements` are skipped at render time (with one console warning per type) **and excluded from the value model**. Also supplies the `assetResolver` fallback and the [consumer element registrations](./custom-elements.md) (`config.elementTypes`). |
19
+ | `actions` | `Record<string, (values: ActionValues) => void \| Promise<unknown>>` | Map of action IDs to handler functions. Buttons and links call these. A returned Promise is awaited: buttons disable (the triggering one spins) until it settles, further clicks are ignored, and a rejection surfaces in the [form-level error banner](#async-actions-the-form-level-error-channel). |
20
+ | `onValidate` | `(values: ActionValues) => Record<string, string> \| Promise<Record<string, string>>` | Developer-only cross-field/server validation. Runs at **submit time** — when a `validates: true` button is clicked and after all declarative rules pass. May be sync or async; returns `{ fieldName: errorMessage }`. A non-empty result blocks the action. The reserved key `_form` addresses the form as a whole (banner instead of a field). Not exposed in builder UI. See [Validation](#validation). |
21
+ | `assetResolver` | `(id: string) => string` | Resolves an `assetId` to a URL at render time. Falls back to `config.assetResolver` when not set. Needed when the schema contains `image` nodes. |
22
+ | `initialValues` | `ActionValues` | Host-supplied field values for edit-form scenarios, merged **over** the schema's `defaultValue`s on init. Only keys that match a **named** input in the (allowed) tree are taken — stray host data never leaks into the action payload. Replacing the object with **different values** re-initializes the form, like a schema change; a value-identical replacement (e.g. an inline object literal re-created by a parent re-render — nested objects/arrays compare by content) is ignored, so in-progress user input survives. |
23
+ | `runtimeContext` | `Record<string, unknown>` | Host-owned runtime data. The document can only read paths explicitly declared by `config.contextFields`; undeclared paths resolve to the binding fallback. |
24
+ | `locale` | `string` | Active locale used to resolve page translation keys, legacy `LocalizedValue` props and localized templates. Regional locales fall back to their base locale and then `config.defaultLocale`. |
25
+ | `viewportWidth` | `number` | Optional deterministic container width. Runtime normally measures its container; previews and tests can provide an exact width. |
26
+ | `fallbackSchema` | `PageNode` | Host-owned safe document rendered when the customized document fails allow-list, binding or document-limit validation. `usingFallback` is exposed on the component ref. |
27
+
28
+ ## Usage
29
+
30
+ ```vue
31
+ <CoarPageRenderer
32
+ :schema="savedSchema"
33
+ :config="tenantConfig"
34
+ :actions="{
35
+ 'auth:login': (v) => auth.login(v),
36
+ 'auth:forgot-password': () => router.push('/forgot'),
37
+ }"
38
+ :on-validate="async (v) => serverValidate(v)"
39
+ :asset-resolver="(id) => `/tenant/${tenantId}/assets/${encodeURIComponent(id)}`"
40
+ />
41
+ ```
42
+
43
+ `ActionValues` is `Record<string, unknown>` — a flat map of all named fields at the time the action fires. Every input element with a `name` property contributes its value — **including untouched ones**: text/otp inputs contribute **strings** (`''` when untouched), `number-input` a **number** (`null`), `checkbox`/`switch` **booleans** (`false`), `multi-select` a **string array** (`[]`), `select`/`radio-group` a **string** (`null`), date inputs an **ISO string** (`null`). Fields in [conditionally hidden](#conditional-visibility-visiblewhen) or disallowed subtrees are excluded. Handlers receive a snapshot, not live state. Hand-written schemas carry no hard type guarantee, so narrow the values in your handler.
44
+
45
+ To prefill a form (edit scenarios), pass `initialValues` — the values seed **over** the schema defaults, filtered to the named fields that actually exist in the allowed tree. Prefer a stable reference (a `computed` or plain object created once): replacing it with different values re-initializes the form and discards user edits.
46
+
47
+ ```vue
48
+ <script setup>
49
+ // Stable per user — recomputed only when the source data changes.
50
+ const profileValues = computed(() => ({ email: user.email, newsletter: true }));
51
+ </script>
52
+
53
+ <template>
54
+ <CoarPageRenderer
55
+ :schema="profileSchema"
56
+ :actions="{ 'profile:save': (v) => api.save(v) }"
57
+ :initial-values="profileValues"
58
+ />
59
+ </template>
60
+ ```
61
+
62
+ ## Events & host form API
63
+
64
+ The renderer is not a black box between init and action click — it emits value changes and exposes a small form API on its component ref:
65
+
66
+ | Surface | Description |
67
+ |---------|-------------|
68
+ | `@update:values` (event) | Fires with a snapshot of the current value map on init, on every field edit and on `reset()` — unlocks autosave, drafts and dirty tracking. The snapshot is a copy, safe to keep; it contains the named fields of the allowed **and currently visible** tree. |
69
+ | `values` (exposed) | Snapshot of the current value map (same rules as the event). |
70
+ | `isDirty` (exposed) | `true` once any field differs from its initial state (schema defaults + `initialValues`). |
71
+ | `isFormValid` (exposed) | Quiet validation state — `true` while every declarative rule passes. Shows no errors. |
72
+ | `reset()` (exposed) | Back to the initial state: schema defaults + `initialValues`; touched flags, server errors and the form banner are cleared. |
73
+
74
+ ```vue
75
+ <script setup>
76
+ const form = ref();
77
+ onBeforeRouteLeave(() => !form.value?.isDirty || confirm('Discard changes?'));
78
+ </script>
79
+
80
+ <template>
81
+ <CoarPageRenderer ref="form" :schema="schema" @update:values="autosave" />
82
+ </template>
83
+ ```
84
+
85
+ ## JSON Schema
86
+
87
+ One node grammar for every element (wire-format **v4**): the v2 `props`-bag and v3 runtime-composition grammar remain compatible; v4 gives every element a stable page-wide `name` for Element Code and form identity.
88
+
89
+ ```ts
90
+ interface ElementNode {
91
+ id: string // stable UUID (crypto.randomUUID), assigned by the builder
92
+ type: string // element-registry key — a built-in type or a consumer key
93
+ props: Record<string, unknown> // element-specific props (JSON-safe bag)
94
+ style?: NodeStyle
95
+ responsive?: Partial<Record<'phone' | 'tablet' | 'desktop', Partial<NodeStyle>>>
96
+ bindings?: Record<string, RuntimeBinding | RuntimeTemplate>
97
+
98
+ // Value-model trio — meaningful when the element's definition declares `value`:
99
+ name?: string
100
+ defaultValue?: unknown
101
+ validation?: FieldValidation
102
+
103
+ visibleWhen?: VisibleWhen // conditional visibility — see below
104
+ children?: PageNode[] // containers only
105
+ }
106
+
107
+ interface NodeStyle {
108
+ // ── Container: how this node lays out its children ──
109
+ gap?: string // CSS gap between children — '8px', '1rem', …
110
+ padding?: string // CSS padding inside this node
111
+ justify?: 'start' | 'center' | 'end' // justify-content — main-axis
112
+ | 'space-between' | 'space-around' | 'space-evenly'
113
+ align?: 'start' | 'center' | 'end' | 'stretch' // align-items — cross-axis
114
+
115
+ // ── Self: how this node sits inside its parent ──
116
+ alignSelf?: 'start' | 'center' | 'end' | 'stretch' // align-self — overrides parent `align`
117
+ size?: 'fit' | 'fill' | 'fixed' // sizing along the parent's main axis
118
+ width?: string // used when size: 'fixed' — '380px', '100%', …
119
+ minHeight?: string // 'min-height' — e.g. '100dvh' to make the page fill the viewport
120
+ }
121
+ ```
122
+
123
+ The root is the one node shape outside the element grammar: `{ id, type: 'page', schemaVersion, enterSubmits?, stateCode?, rootCode?, translations?, style?, responsive?, children }` — a schema-shape marker, not a placeable element, with no props bag. `rootCode` is a constrained reactive presentation binding and can return only root `style`, `responsive`, and `enterSubmits` changes. **`4`** is current. Older documents remain readable and are normalized deterministically. `enterSubmits` opts the page into [Enter-to-submit](#enter-to-submit).
124
+
125
+ Node `id`s must be unique page-wide — the builder assigns them via `crypto.randomUUID()` and [repairs missing or duplicate ids](#legacy-schemas-normalization) at every entry point.
126
+
127
+ ### Layout behaviour
128
+
129
+ Containers are flexbox. The `page` root and `card` / `section` bodies are columns; a `stack` is either (`direction: 'column' | 'row'`, default `column`, plus optional `wrap` for rows).
130
+
131
+ - **page** — the schema root. A vertical stack; the only element allowed at the top of the tree.
132
+ - **stack** — generic flex container. Toggle `direction` between `column` and `row`. Row children are **natural-width by default** — opt a child into growing with `size: 'fill'`.
133
+ - **card** — `CoarCard` wrapper, optional `title`. Children stacked vertically.
134
+ - **section** — semantic `<section>` with optional `title` heading.
135
+
136
+ #### Sizing and alignment
137
+
138
+ `NodeStyle` separates *how a container arranges its children* from *how a node sizes and places itself*:
139
+
140
+ | Field | Applies to | Maps to | Use |
141
+ |-------|-----------|---------|-----|
142
+ | `justify` | containers | `justify-content` | distribute children on the main axis (e.g. push a button row right with `end`) |
143
+ | `align` | containers | `align-items` | align children on the cross axis |
144
+ | `alignSelf` | any node | `align-self` | override the parent's `align` for one node — e.g. center a single button in a left-aligned column |
145
+ | `size` | any node | flex / width | `fit` (natural) · `fill` (take available space) · `fixed` (+ `width`) |
146
+ | `minHeight` | any node | `min-height` | give a node a minimum height (see below) |
147
+
148
+ `size: 'fill'` is **direction-aware**: in a row it grows along the row; in a column it becomes full-width (so a "fill" Sign-in button spans the whole card).
149
+
150
+ #### Full-screen / centered pages
151
+
152
+ The renderer is a width-filling block and measures that container for responsive resolution. To center content on a full-screen page (the classic login card), size the `page` itself:
153
+
154
+ ```json
155
+ { "type": "page", "style": { "minHeight": "100dvh", "justify": "center", "align": "center" } }
156
+ ```
157
+
158
+ `minHeight: '100dvh'` makes the page fill the current dynamic viewport; `justify: 'center'` centers vertically (a column's main axis is vertical) and `align: 'center'` centers horizontally — no host CSS required beyond the host having its natural width. Legacy `vh` and the modern `svh`/`lvh` variants are supported as well.
159
+
160
+ ### Example — login page
161
+
162
+ ```json
163
+ {
164
+ "id": "root",
165
+ "type": "page",
166
+ "schemaVersion": 3,
167
+ "style": { "minHeight": "100dvh", "justify": "center", "align": "center", "padding": "48px" },
168
+ "children": [
169
+ {
170
+ "id": "n1",
171
+ "type": "card",
172
+ "props": {},
173
+ "style": { "size": "fixed", "width": "400px", "gap": "16px" },
174
+ "children": [
175
+ { "id": "n2", "type": "image", "props": { "assetId": "logo-primary", "alt": "Acme logo" } },
176
+ { "id": "n3", "type": "heading", "props": { "text": "Welcome back", "level": 1 } },
177
+ { "id": "n4", "type": "text-input", "name": "email",
178
+ "props": { "label": "Email", "inputType": "email" },
179
+ "validation": { "required": true } },
180
+ { "id": "n5", "type": "password-input", "name": "password",
181
+ "props": { "label": "Password" },
182
+ "validation": { "required": true, "minLength": 8 } },
183
+ { "id": "n6", "type": "checkbox", "name": "rememberMe", "defaultValue": false,
184
+ "props": { "label": "Remember me" } },
185
+ { "id": "n7", "type": "button", "style": { "size": "fill" },
186
+ "props": { "label": "Sign in", "action": "auth:login", "validates": true } },
187
+ { "id": "n8", "type": "link", "props": { "label": "Forgot password?", "action": "auth:forgot-password" } }
188
+ ]
189
+ }
190
+ ]
191
+ }
192
+ ```
193
+
194
+ Note the split: `name`, `defaultValue`, `validation` and `style` sit at **node level** (host vocabulary, uniform for every element), while `label`, `inputType`, `action`, `assetId`, … sit in **`props`** (each element's own vocabulary).
195
+
196
+ ### Try it live
197
+
198
+ The same card rendered live (logo omitted). Email is `required` + `inputType: 'email'`, password is `required` + `minLength: 8`, and the Sign-in button `validates`. Click it with empty fields — the click marks every field touched and reveals all errors at once; once the form is valid, the action receives the `ActionValues` and writes them below the card.
199
+
200
+ **Demo — `page-builder/demos/RendererLoginCard.vue`**
201
+
202
+ ```vue
203
+ <template>
204
+ <div class="renderer-demo">
205
+ <CoarPageRenderer :schema="schema" :actions="actions" />
206
+ <pre class="renderer-demo__result">{{ result }}</pre>
207
+ </div>
208
+ </template>
209
+
210
+ <script setup lang="ts">
211
+ import { ref } from 'vue';
212
+ import { CoarPageRenderer, type ActionValues, type PageNode } from '@cocoar/vue-page-builder';
213
+
214
+ const result = ref(
215
+ 'Click "Sign in" with empty fields — the click marks every field touched and reveals all errors at once.',
216
+ );
217
+
218
+ const schema: PageNode = {
219
+ id: 'root',
220
+ type: 'page',
221
+ style: { gap: '16px', padding: '24px', align: 'center' },
222
+ children: [
223
+ {
224
+ id: 'card',
225
+ type: 'card',
226
+ props: {},
227
+ style: { size: 'fixed', width: '360px', gap: '16px' },
228
+ children: [
229
+ { id: 'title', type: 'heading', props: { text: 'Welcome back', level: 3 } },
230
+ {
231
+ id: 'email',
232
+ type: 'text-input',
233
+ name: 'email',
234
+ props: {
235
+ label: 'Email',
236
+ inputType: 'email',
237
+ placeholder: 'you@example.com',
238
+ },
239
+ validation: { required: true },
240
+ },
241
+ {
242
+ id: 'password',
243
+ type: 'password-input',
244
+ name: 'password',
245
+ props: {
246
+ label: 'Password',
247
+ },
248
+ validation: { required: true, minLength: 8 },
249
+ },
250
+ {
251
+ id: 'remember',
252
+ type: 'checkbox',
253
+ name: 'rememberMe',
254
+ props: { label: 'Remember me' },
255
+ defaultValue: false,
256
+ },
257
+ {
258
+ id: 'submit',
259
+ type: 'button',
260
+ props: {
261
+ label: 'Sign in',
262
+ action: 'auth:login',
263
+ validates: true,
264
+ },
265
+ style: { size: 'fill' },
266
+ },
267
+ ],
268
+ },
269
+ ],
270
+ };
271
+
272
+ const actions = {
273
+ 'auth:login': (values: ActionValues) => {
274
+ result.value = JSON.stringify(values, null, 2);
275
+ },
276
+ };
277
+ </script>
278
+
279
+ <style scoped>
280
+ .renderer-demo__result {
281
+ margin: 16px 0 0;
282
+ padding: 12px 16px;
283
+ border-radius: 8px;
284
+ background: var(--coar-background-neutral-secondary, #f5f5f5);
285
+ color: var(--coar-text-neutral-secondary, #666);
286
+ font-size: 12px;
287
+ line-height: 1.5;
288
+ white-space: pre-wrap;
289
+ word-break: break-word;
290
+ }
291
+ </style>
292
+ ```
293
+
294
+ ## Generic runtime composition (v4)
295
+
296
+ These features are domain-neutral. Authentication pages are one consumer: names such as `approvedScopes` are ordinary schema configuration, not package concepts.
297
+
298
+ ### Responsive styles
299
+
300
+ The renderer applies a mobile-first cascade using its measured container width: Compact/base from 320 px, Phone from 390 px, Tablet from 768 px and Desktop from 1280 px. Base values live in `style`; breakpoint differences live in `responsive.phone`, `responsive.tablet` and `responsive.desktop`. The renderer and builder preview share the same resolver. Length values pass through a restrictive CSS-length parser, while colors, typography, radii and elevation use controlled design-token enums.
301
+
302
+ ### Safe bindings, localization and conditions
303
+
304
+ `bindings` maps an element prop to a controlled runtime source. A target may
305
+ be a top-level prop (`disabled`, `label`, …) or one action argument
306
+ (`actionValues.approvedScopes`). Supported direct sources are:
307
+
308
+ | Source | Value |
309
+ |--------|-------|
310
+ | `context` + `path` | Exact host path declared by `config.contextFields` |
311
+ | `state` + `path` | Customer-authored `definePageState(...)` value |
312
+ | `field` + `path` | Current named form value |
313
+ | `selection` + `path` | Current named Repeat selection (`string[]`) |
314
+ | `item` + `path` | Current Repeat item path declared by that Repeat's context contract |
315
+ | `index` | Current Repeat index |
316
+ | `expression` | Host-sandboxed JavaScript result supplied through `expressionValues` |
317
+
318
+ Context/item traversal is allow-listed; state/form/selection names come from
319
+ the page contract itself. A `RuntimeTemplate` can interpolate several
320
+ allow-listed values.
321
+
322
+ New page documents keep customer-owned messages once on the page root and reference them with a serializable translation binding:
323
+
324
+ ```json
325
+ {
326
+ "source": "translation",
327
+ "key": "page.submit.label",
328
+ "params": { "name": "Ada" },
329
+ "fallback": "Sign in"
330
+ }
331
+ ```
332
+
333
+ Element Code creates the same value through `i18n.text(key, params?, fallback?)`. Resolution is page catalogue → host `@cocoar/vue-localization` catalogue → fallback → key. `LocalizedValue` is retained as a compatibility format for existing schemas.
334
+
335
+ `visibleWhen` uses the same field/context/item sources with `equals`, `notEquals`, `in`, `notIn`, `exists`, `isEmpty` and `isNotEmpty`. Conditions can be combined with bounded `all`/`any` groups. Hidden subtrees do not render, validate or contribute values/action payloads.
336
+
337
+ ### Generic repeaters and selections
338
+
339
+ `repeat` renders its child template for an allow-listed context array. Item bindings and conditions can only read declared `itemFields`; `maxItems` is capped at 500. Its optional selection contract is also generic:
340
+
341
+ ```json
342
+ {
343
+ "type": "repeat",
344
+ "props": {
345
+ "source": "catalog.items",
346
+ "keyPath": "id",
347
+ "selection": {
348
+ "name": "chosenItemIds",
349
+ "valuePath": "id",
350
+ "requiredPath": "mandatory",
351
+ "defaultSelection": "all"
352
+ }
353
+ }
354
+ }
355
+ ```
356
+
357
+ The result is `ActionValues.chosenItemIds: string[]`. Required items are always
358
+ selected and cannot be unchecked. Host `initialValues` may seed the selection;
359
+ otherwise `defaultSelection` is `'none'` or `'all'`. Reconciliation retains
360
+ the current choice, removes stale and duplicate values, adds required values,
361
+ and emits the source-array order. The output name and item paths are freely
362
+ configured; the primitive has no knowledge of scopes, products, roles or any
363
+ other domain.
364
+
365
+ ### Feedback placement, actions and fallback
366
+
367
+ `feedback` is an authorable semantic zone. `kind: 'form-error'` places rejected async-action or `_form` validation errors at that exact tree position; other kinds provide error, success, info and loading status with appropriate live-region semantics. Every action-capable element uses the same optional [`ActionProps`](#action-arguments) payload contract.
368
+
369
+ Hosts can mark nodes as required, lock their visibility/style/placement, and cap node count/depth through `PageConfig`. If a saved customization violates those invariants, `fallbackSchema` provides a safe host-owned render path rather than a partially broken page.
370
+
371
+ ## Built-in Elements
372
+
373
+ Built-ins are pre-registered [element definitions](./custom-elements.md) — they ride exactly the same registry contract as consumer-registered elements. The prop names listed below live in each node's **`props` bag**; `name` / `defaultValue` / `validation` / `style` are node-level host fields on every element.
374
+
375
+ ### Containers
376
+
377
+ | Type | Description |
378
+ |------|-------------|
379
+ | `page` | Root container. Always column-direction. |
380
+ | `stack` | Generic flex container with toggleable `direction` (`column` \| `row`). Optional `wrap` for row-direction stacks. |
381
+ | `card` | `CoarCard` wrapper with optional `title` |
382
+ | `section` | Semantic section with optional `title` heading |
383
+ | `divider` | Visual separator (`CoarDivider`) |
384
+ | `spacer` | Empty space — `flex: 1` (fills available space) unless `size` is set |
385
+
386
+ ### Typography & Display
387
+
388
+ | Type | Props | Description |
389
+ |------|-------|-------------|
390
+ | `heading` | `text`, `level` (1–6) | H1–H6 heading |
391
+ | `paragraph` | `text` | Body text block |
392
+ | `note` | `text`, `variant` (`neutral` \| `info` \| `success` \| `warning` \| `error` \| `accent`) | `CoarNote` callout box |
393
+
394
+ ### Inputs
395
+
396
+ | Type | Key props (in `props`) | Value type | Cocoar component |
397
+ |------|-----------|------------|-----------------|
398
+ | `text-input` | `label`, `inputType`, `rows`, `placeholder`, `disabled` | `string` | `CoarTextInput` (textarea when `rows > 1`) |
399
+ | `password-input` | `label`, `placeholder`, `disabled` | `string` | `CoarPasswordInput` (masked) |
400
+ | `number-input` | `label`, `placeholder`, `min`, `max`, `step`, `decimals`, `disabled` | `number` | `CoarNumberInput` |
401
+ | `checkbox` | `label`, `disabled` | `boolean` | `CoarCheckbox` |
402
+ | `switch` | `label`, `disabled` | `boolean` | `CoarSwitch` |
403
+ | `radio-group` | `label`, `options`, `optionsSourceId`, `orientation`, `disabled` | `string` | `CoarRadioGroup` + `CoarRadioButton` |
404
+ | `select` | `label`, `options`, `optionsSourceId`, `placeholder`, `disabled` | `string` | `CoarSelect` |
405
+ | `multi-select` | `label`, `options`, `optionsSourceId`, `placeholder`, `disabled` | `string[]` | `CoarMultiSelect` |
406
+ | `otp-input` | `label`, `length`, `otpType`, `mask`, `disabled` | `string` | `CoarOtpInput` |
407
+ | `date-input` | `label`, `placeholder`, `disabled` — `defaultValue` is ISO `YYYY-MM-DD` | ISO `string` | `CoarPlainDatePicker` |
408
+ | `datetime-input` | `label`, `placeholder`, `disabled` — `defaultValue` is ISO `YYYY-MM-DDTHH:mm[:ss]` | ISO `string` | `CoarPlainDateTimePicker` |
409
+
410
+ All inputs support the node-level `name` (wires the value into `ActionValues`), `defaultValue`, and `validation`, plus `props.disabled`. Fields with `validation.required` get the `*` marker via `CoarFormField`. Required semantics adapt to the value shape: a required `multi-select` needs **at least one** selection, a required `otp-input` needs a **complete** code (all cells filled), a required `switch`/`checkbox` must be **on**.
411
+
412
+ > **Info: Date values are ISO strings**
413
+ >
414
+ > The wire format for `date-input`/`datetime-input` is always the ISO string — in the schema's `defaultValue` **and** in `ActionValues`. The renderer converts to/from `Temporal.PlainDate`/`PlainDateTime` at the picker boundary; an unparsable value renders as an empty picker instead of crashing. Zoned (time-zone-aware) date-times are deliberately not part of the element set yet.
415
+
416
+ #### `inputType`
417
+
418
+ `text-input` maps its `inputType` onto the right control and autocomplete hints:
419
+
420
+ | `inputType` | Renders | Autocomplete |
421
+ |-------------|---------|--------------|
422
+ | `'text'` (default) | `CoarTextInput` with `type="text"` | — |
423
+ | `'email'` | `CoarTextInput` with `type="email"` | `autocomplete="email"` |
424
+ | `'url'` | `CoarTextInput` with `type="url"` | `autocomplete="url"` |
425
+
426
+ Masked passwords are their own element: `password-input` (renders `CoarPasswordInput`). Legacy `text-input` nodes with `inputType: 'password'` migrate to it transparently on load.
427
+
428
+ `inputType: 'email'` also opts the field into the built-in **email format check** — see [Email format](#email-format).
429
+
430
+ #### Dynamic options (`optionsSource`)
431
+
432
+ The choice inputs (`select`, `multi-select`, `radio-group`) take their options from the static `options` array by default. For API-backed lists (countries, users, …), set the node's `optionsSourceId` and provide the resolver in the config — the async sibling of `assetResolver`:
433
+
434
+ ```ts
435
+ const config: PageConfig = {
436
+ optionsSource: async (sourceId) => {
437
+ if (sourceId === 'countries') return api.countries(); // Promise<OptionItem[]>
438
+ return [];
439
+ },
440
+ };
441
+ ```
442
+
443
+ A set `optionsSourceId` wins over the static `options`; without a configured `optionsSource` the static options are used (and the builder lint warns). While a load is in flight the list is empty; a failed load stays empty and warns once. The resolver is called once per element instance — memoize consumer-side when several elements share a source. Consumer elements get the same behavior via the exported `useResolvedOptions` composable.
444
+
445
+ #### Declarative rules
446
+
447
+ Rules live on the node-level `validation` property (host vocabulary, uniform for every valued element):
448
+
449
+ ```ts
450
+ interface FieldValidation {
451
+ required?: boolean // any valued element — emptiness comes from the element's definition
452
+ minLength?: number // string-rule elements (text-input, password-input, …)
453
+ maxLength?: number // string-rule elements
454
+ pattern?: string // string-rule elements; regex source applied as full-string match
455
+ matchField?: string // any valued element — value must equal this other named field's value
456
+ message?: string // custom error message — overrides defaults
457
+ }
458
+ ```
459
+
460
+ `required` and `matchField` are host-enforced on every valued element; the string rules (`minLength` / `maxLength` / `pattern`) are host-enforced on elements whose definition opts in via `value.textRules` (built-in: `text-input` and `password-input`). Other elements express extra rules through their definition's `validate` hook (run crash-guarded, after the host rules) — see [Custom elements](./custom-elements.md). How and when errors surface is described under [Validation](#validation).
461
+
462
+ ### Actions
463
+
464
+ | Type | Key props | Description |
465
+ |------|-----------|-------------|
466
+ | `button` | `label`, `action`, `validates`, `default`, `variant`, `size`, `icon` | `CoarButton` — calls the matching `actions` handler. Content-width by default; use `style.size: 'fill'` for a full-width button. `default: true` marks it as the [Enter-to-submit](#enter-to-submit) target. |
467
+ | `link` | `label`, `action` | Inline text link. Content-width by default. |
468
+
469
+ When `validates: true` on a button, clicking it validates all named fields before the action fires. The button **stays clickable while the form is invalid** — the click reveals the errors instead of firing the action. While a trigger is in flight (an async `onValidate` **or** an async action), the triggering button spins and every other action button and link disables; further clicks are ignored. See [Validation](#validation).
470
+
471
+ #### Action arguments
472
+
473
+ Buttons, links and consumer elements with `action: true` share one contract:
474
+
475
+ ```ts
476
+ interface ActionProps {
477
+ action?: string
478
+ actionValues?: Record<string, unknown>
479
+ actionValueField?: string
480
+ actionValue?: unknown
481
+ }
482
+ ```
483
+
484
+ All four fields are optional. `actionValues` is a JSON-safe key/value map. The
485
+ common Properties-panel editor accepts values such as `"de"`, `42`, `true`,
486
+ `null`, arrays and objects; every key has its own **fx** switch. A nested
487
+ binding such as `bindings["actionValues.language"]` replaces only that entry.
488
+ `actionValue` supplies the older single additional value under
489
+ `actionValueField` and remains supported.
490
+
491
+ The renderer builds a detached handler payload in this explicit order:
492
+
493
+ 1. current named form values,
494
+ 2. resolved `actionValues` (static defaults plus per-key bindings), overwriting colliding form keys,
495
+ 3. the dynamic `actionValue`, overwriting a static entry with the same `actionValueField`.
496
+
497
+ This order is identical for click, link activation, Enter-to-submit and consumer action elements. Invalid non-JSON values never reach a handler; builder and activation validation report them as errors.
498
+
499
+ ### Media
500
+
501
+ | Type | Props | Description |
502
+ |------|-------|-------------|
503
+ | `image` | `assetId`, `alt` | Resolved via `assetResolver` at render time. Raw URLs are not accepted. |
504
+
505
+ ### Consumer elements
506
+
507
+ The element set is **open**: register your own element types via `config.elementTypes` (or app-wide via `PAGE_ELEMENT_TYPES_KEY`) and they render, join the value model and validate exactly like built-ins. Element renderers wire themselves through the `usePageElement()` context (`getValue` / `setValue` / `getError` / `markTouched` / `triggerAction` / `isValidating` / `isSubmitting` / `pendingAction` / `formError` / `resolveAsset` / `config`). See the [Custom elements guide](./custom-elements.md).
508
+
509
+ ## Validation
510
+
511
+ Named fields validate against their declarative `validation` rules reactively, but errors only *show* once a field is **touched**:
512
+
513
+ - **text inputs** are touched on blur,
514
+ - **checkbox / select** are touched on change — choosing a value *is* the interaction, there is no meaningful blur moment,
515
+ - **clicking a `validates: true` button marks every named field touched at once.**
516
+
517
+ ### Click reveals errors
518
+
519
+ A validating button is **not** disabled while the form is invalid. Clicking it with an invalid form marks all fields touched, reveals every error — including checkbox and select errors that have no blur moment — focuses and scrolls the **first invalid control** into view (off-screen errors must not make the click look dead), and does **not** run the action. A disabled button can't explain itself; a click can.
520
+
521
+ Buttons disable only while a trigger is genuinely in flight — an async `onValidate` or an async action. The triggering button shows a spinner; every other action button and link disables; repeated clicks are ignored (double-submit guard for validating and non-validating buttons alike).
522
+
523
+ ### `pattern` semantics
524
+
525
+ `validation.pattern` is applied as a **full-string match** — the source is compiled as `^(?:pattern)$`, the same semantics as the HTML `pattern` attribute. An invalid pattern never crashes the page: it becomes an **inert rule** (the field passes) and the renderer logs one `console.warn` per distinct pattern.
526
+
527
+ ### Email format
528
+
529
+ A `text-input` with `inputType: 'email'` validates the entered value against the WHATWG email pattern (`input[type=email]` constraint semantics, full string) **by default** — no hand-written `pattern` needed. The check skips empty values (`required` decides those) and uses the localized `coar.pageBuilder.validation.email` message. Since submission is JS-driven (there is no `<form>`), the browser's own constraint never fires — this host-side rule replaces it. It rides the `textRules` opt-in, so a consumer element with `textRules` and an `inputType` prop participates the same way.
530
+
531
+ ### Submit-time `onValidate`
532
+
533
+ `onValidate` is the escape hatch for validation that can't be expressed declaratively — server checks, cross-field logic. Its contract:
534
+
535
+ 1. Clicking a `validates: true` button marks all fields touched. If any **declarative** rule fails, the errors show, the first invalid control is focused, and nothing else happens.
536
+ 2. Only when the declarative rules pass does `onValidate(values)` run. It may return the error map directly or a `Promise` of it; while a promise is pending, the triggering button spins and the other action buttons are disabled.
537
+ 3. A non-empty result blocks the action: `{ fieldName: errorMessage }` entries show on their fields, the reserved **`_form`** key (exported as `FORM_ERROR_KEY`) shows in the [form-level banner](#async-actions-the-form-level-error-channel). Errors keyed to fields that cannot display (hidden by `visibleWhen`, renamed, never on the page) are routed to the banner too — a blocked submit is never invisible.
538
+ 4. Editing a field clears that field's server error (and the form banner) immediately — a stale error never outlives the edit that addresses it.
539
+ 5. If `onValidate` throws (or rejects), the action does not run, a localized generic message shows in the form banner, and the error is logged to the console.
540
+ 6. An empty result lets the action fire — with **the exact snapshot `onValidate` approved** (edits made while an async `onValidate` was in flight never ship unvalidated; they need their own submit). A returned Promise is awaited.
541
+
542
+ `onValidate` does **not** run on keystrokes — it fires at submit time only.
543
+
544
+ ```vue
545
+ <CoarPageRenderer
546
+ :schema="schema"
547
+ :actions="{ 'auth:login': (v) => auth.login(v) }"
548
+ :on-validate="async (v) => {
549
+ const taken = await api.isEmailTaken(v.email as string);
550
+ return taken ? { email: 'This email is already registered' } : {};
551
+ }"
552
+ />
553
+ ```
554
+
555
+ ### Async actions & the form-level error channel
556
+
557
+ Real submits are API calls. An action handler may return a Promise — the renderer awaits it, keeps the whole form in its busy state (spinner on the triggering button, everything else disabled, reentry blocked) until it settles, and routes a **rejection** into the form-level error banner:
558
+
559
+ ```ts
560
+ const actions = {
561
+ 'auth:login': async (v) => {
562
+ const res = await api.login(v);
563
+ // Throw an Error whose message is user-facing — it becomes the banner text.
564
+ if (!res.ok) throw new Error('Invalid credentials');
565
+ },
566
+ };
567
+ ```
568
+
569
+ - An `Error`'s `message` is shown verbatim — that is the consumer's channel for user-facing failure text. Catch-and-rethrow if your transport errors ("Failed to fetch") shouldn't reach end users.
570
+ - Any other rejection shows the localized generic message (`coar.pageBuilder.formError.actionFailed`).
571
+ - The same banner shows the `_form` key of an `onValidate` result — for form-level *validation* outcomes like "Invalid credentials" from a validation endpoint.
572
+ - The banner clears on any field edit and at the start of every new trigger.
573
+
574
+ The default banner renders above the page (a `CoarNote` with `role="alert"`). Two ways to take over the presentation:
575
+
576
+ ```vue
577
+ <!-- Replace the banner via the slot… -->
578
+ <CoarPageRenderer :schema="schema" :actions="actions">
579
+ <template #form-error="{ error }">
580
+ <MyToast v-if="error" :text="error" />
581
+ </template>
582
+ </CoarPageRenderer>
583
+ ```
584
+
585
+ …or render it **inside** the page (e.g. right above the submit button) with a small custom element reading `usePageElement().formError` — see [Custom elements](./custom-elements.md).
586
+
587
+ ## Enter to submit
588
+
589
+ Enter-to-submit is double opt-in — the page and the element under the caret both agree:
590
+
591
+ 1. The **page root** sets `enterSubmits: true` (a checkbox in the builder's Page section; default off).
592
+ 2. The **element** declares Enter-eligibility in its definition (`value.submitOnEnter`). Built-ins: single-line `text-input` (`rows <= 1`), `password-input`, `number-input`. A multiline textarea, `select`s, `otp-input`, the date inputs (their picker panel uses Enter) and consumer elements that don't declare the flag never submit on Enter.
593
+
594
+ A plain Enter (no modifiers) inside an eligible input fires the page's **default button**: the first button with `default: true` (a checkbox in the button inspector; the lint warns when several buttons claim it), else the first `validates: true` button in tree order — with the full submit pipeline (validation, `onValidate`, busy state). Before triggering, the input is blurred so commit-on-blur controls (number input) flush the value the user sees. An Enter the element already consumed (`preventDefault`, e.g. inside a picker popover) and an IME composition-commit Enter never submit.
595
+
596
+ ## Conditional visibility (`visibleWhen`)
597
+
598
+ Any node can declare a visibility condition against the **live value model** (host vocabulary — works on every element, containers and consumer elements included):
599
+
600
+ ```jsonc
601
+ {
602
+ "id": "company", "type": "text-input", "name": "companyName",
603
+ "props": { "label": "Company name" },
604
+ "validation": { "required": true },
605
+ "visibleWhen": { "field": "isBusiness", "equals": true }
606
+ }
607
+ ```
608
+
609
+ | Field | Meaning |
610
+ |-------|---------|
611
+ | `field` | Name of the controlling field (a named input on the page). |
612
+ | `equals` | Visible while the field's value equals this (JSON-safe values compare by **content**, arrays and objects included). |
613
+ | `in` | Visible while the field's value is one of these (array). |
614
+
615
+ The condition gates the node **and its whole subtree** — in rendering *and* in the value model, in the same walk as `allowedElements`:
616
+
617
+ - A hidden `required` field never vetoes a validating button.
618
+ - Hidden values never ship: action payloads, `onValidate` input, `update:values` and the exposed `values` all carry only the allowed and **visible** tree.
619
+ - Values typed before hiding are kept internally and return when the node is re-shown (so a mis-click doesn't wipe input) — they just don't leave the renderer while hidden.
620
+ - A malformed condition fails **open** (the node stays visible), and a condition on the **page root is ignored** (a page can never blank itself). The builder lint flags malformed conditions, references to fields that aren't on the page, and **circular chains** (a field whose visibility depends on itself or on a mutual loop — hidden controllers can't be edited, so such chains can lock each other hidden).
621
+
622
+ Authoring: the builder's **Visibility** section offers the controlling-field select and a typed `equals` editor (checked/unchecked for boolean controllers, the option list for choice controllers, free text otherwise); the `in` form is JSON-authorable. Conditional nodes carry an eye marker on the canvas — the canvas always *shows* them (it is an authoring surface); the Preview tab applies the real gating.
623
+
624
+ ## Legacy schemas & normalization
625
+
626
+ The renderer migrates **on the fly**: legacy `column` / `row` containers become `stack` (with `direction: 'column'` / `'row'`), then v1 flat nodes (pre-GA grammar, element props directly on the node) get their `props` bag. Old saved schemas keep rendering without a round-trip through the builder. The migration happens at render time only and never mutates the schema object you passed in.
627
+
628
+ The builder goes further and normalizes at **every entry point** — the initial `v-model` value, external `v-model` replacement, and the JSON tab's Apply (which [gates on issue severity](./coar-page-builder.md#json-tab): errors block, warnings apply). The same helpers are exported for your own persistence layer:
629
+
630
+ ```ts
631
+ import {
632
+ normalizePageSchema,
633
+ migrateLegacyTypes,
634
+ migrateV1PropsBag,
635
+ KNOWN_ELEMENT_TYPES,
636
+ } from '@cocoar/vue-page-builder';
637
+
638
+ const { schema, issues, changed } = normalizePageSchema(stored);
639
+ ```
640
+
641
+ | Export | Description |
642
+ |--------|-------------|
643
+ | `normalizePageSchema(value)` | Returns `{ schema, issues, changed }`. Runs both migrations, then heals silently what has unambiguous intent: a non-`page` root (wrapped in a fresh page), missing `children` arrays, missing/duplicate/empty node ids (fresh UUIDs), missing `props` bags, numeric heading levels outside 1–6 (clamped), a missing/`1` `schemaVersion` (stamped `2`). Every `issue` carries a `severity`: **`error`** = data was dropped (non-object nodes — structurally broken input), **`warning`** = healed in place or lossless (unknown element types — kept in the tree, skipped at render time; non-array `children` or non-object `props` reset; non-numeric heading levels reset to 2; `children` on a non-container). |
644
+ | `migrateLegacyTypes(node)` | Just the `column`/`row` → `stack` mapping, recursive and identity-preserving when there is nothing to migrate. |
645
+ | `migrateV1PropsBag(node)` | The v1 → v2 wire-format migration: per node, a known element type without a `props` object gets its flat element props moved into a fresh bag. Idempotent and identity-preserving — safe to run unconditionally. |
646
+ | `KNOWN_ELEMENT_TYPES` | `ReadonlySet<string>` of the **built-in** element types. Consumer-registered keys are per-instance data (`config.elementTypes`) and deliberately not part of this module constant. |
647
+
648
+ ## Security boundary
649
+
650
+ The renderer enforces these rules **regardless of what the schema contains**:
651
+
652
+ 1. **Allowed elements** — `config.allowedElements` is the hard boundary (it takes built-in types and consumer-registered keys alike). Disallowed types are skipped at render time, with one console warning per type. The gate applies to the **value model** too: disallowed subtrees contribute no default values and cannot block validation — an invisible `required` field can never veto a validating button. Unregistered element types (typos, newer schema versions, consumer elements this instance hasn't registered) degrade the same lenient way: skipped with one warning per type, excluded from the value model, but **kept losslessly in the tree** — the builder [flags them as warnings on the canvas and in validation](./coar-page-builder.md) instead of destroying them.
653
+ 2. **Actions** — every registry element with `action: true` stores an action `id`, an inert string. Only handlers present in the `actions` prop fire — any other action ID is a silent no-op. The one piece of tenant-authored logic the renderer evaluates is `validation.pattern`: a regex compiled safely (invalid = inert rule) and anchored — never executed as code.
654
+ 3. **Reserved field names** — `__proto__`, `constructor` and `prototype` are excluded from the value model entirely (they would collide with `Object.prototype` machinery when used as map keys): such fields neither veto submission nor appear in payloads, and the builder lint flags them as errors.
655
+ 4. **Images** — `image` nodes store an `assetId` reference, never a raw URL. The renderer always goes through `assetResolver` — which makes the resolver **your** part of the boundary: it decides what an id can reach. Validate or encode the id before building a URL, e.g. allowlist `/^[A-Za-z0-9_-]+$/` or `encodeURIComponent(id)`, so a crafted id like `../other-tenant/logo` cannot traverse out of the tenant's asset prefix.
656
+
657
+ See the [Security Model](./index.md#security-model) section on the overview page for the full discussion.
658
+
659
+ ## i18n Keys
660
+
661
+ The renderer's validation messages can be translated via [`@cocoar/vue-localization`](../../foundations/localization/translations.md) (a peer dependency — English fallbacks are built in). A field-level `validation.message` overrides all of them for that field.
662
+
663
+ | Key | Default (English) | Used when |
664
+ |-----|-------------------|-----------|
665
+ | `coar.pageBuilder.validation.required` | `'This field is required'` | `required` fails — value empty, unset, or unchecked |
666
+ | `coar.pageBuilder.validation.minLength` | `'Minimum {n} characters'` | text shorter than `minLength` — `{n}` = the limit |
667
+ | `coar.pageBuilder.validation.maxLength` | `'Maximum {n} characters'` | text longer than `maxLength` — `{n}` = the limit |
668
+ | `coar.pageBuilder.validation.pattern` | `'Invalid format'` | `pattern` full-string match fails |
669
+ | `coar.pageBuilder.validation.matchField` | `'Does not match'` | value differs from the referenced field |
670
+ | `coar.pageBuilder.validation.email` | `'Enter a valid email address'` | the [built-in email check](#email-format) fails on an `inputType: 'email'` field |
671
+ | `coar.pageBuilder.formError.actionFailed` | `'Something went wrong. Please try again.'` | an action rejects without an `Error` message, or `onValidate` throws |
672
+
673
+ ## Pairing with the builder
674
+
675
+ The same `config` should be passed to both `<CoarPageBuilder>` and `<CoarPageRenderer>`. The builder uses it as UI affordance (palette filter, action dropdown, picker hook); the renderer uses it as the security boundary — and falls back to `config.assetResolver` when the `assetResolver` prop is absent, so handing the same object to both really is the whole wiring.
676
+
677
+ See the [integration walkthrough](./index.md#complete-idp-integration-walkthrough) for the full builder + renderer wiring example.