|
| 1 | +# DOM Multi-Surface Rendering |
| 2 | + |
| 3 | +**Status:** Design Proposal |
| 4 | +**Date:** 2026-06-26 |
| 5 | +**Author:** Big Pickle |
| 6 | +**Lane:** `renderer/dom` |
| 7 | +**Depends on:** Nothing |
| 8 | +**Affected package:** `@intent-framework/dom` |
| 9 | +**Changeset:** None (proposal-only) |
| 10 | + |
| 11 | +--- |
| 12 | + |
| 13 | +## Problem |
| 14 | + |
| 15 | +`renderDom` renders only the first surface (`screenDef.surfaces[0]`) and ignores all others. The graph supports multiple surfaces (`ScreenDefinition.surfaces: SurfaceNode[]`), and examples already define them — `choice-form/RegistrationForm.ts` defines both a `"main"` and a `"sidebar"` surface — but the DOM renderer silently drops all but the first. |
| 16 | + |
| 17 | +### Current behavior (broken) |
| 18 | + |
| 19 | +In `packages/dom/src/index.ts:185`: |
| 20 | + |
| 21 | +```ts |
| 22 | +const surface = screenDef.surfaces[0] |
| 23 | +``` |
| 24 | + |
| 25 | +All asks and actions are then rendered into a single `<form>` inside a single `<main>`, regardless of which surface they belong to. The second surface's contents are never materialized. |
| 26 | + |
| 27 | +--- |
| 28 | + |
| 29 | +## Audit: what surfaces currently mean |
| 30 | + |
| 31 | +| Concept | Current state | |
| 32 | +|---------|---------------| |
| 33 | +| `SurfaceNode` in core | `{ id, name, items: Array<AskNode \| ActNode> }` | |
| 34 | +| Multiple surfaces per screen | Supported at the type level. Graph diagnostics correctly check "is this node in ANY surface?" across all surfaces. | |
| 35 | +| Surface item types | Asks and Acts. Resources are not items — they appear "through" the asks/acts that reference them, or independently via the runtime. | |
| 36 | +| Ordering | Surfaces are rendered in registration order. Items within a surface are rendered in `.contains(...)` argument order. | |
| 37 | +| Deduplication | The same AskNode / ActNode can appear in multiple surfaces. The graph already deduplicates surface-membership checks (a node in any surface is considered surfaced). | |
| 38 | + |
| 39 | +--- |
| 40 | + |
| 41 | +## Design decisions |
| 42 | + |
| 43 | +### Q1: What should `renderDom` output when a screen has multiple surfaces? |
| 44 | + |
| 45 | +**Answer:** One `<section>` (or `<div>` with `role="region"` and an `aria-label`) per surface, inside the screen's `<main>`. |
| 46 | + |
| 47 | +Each surface section contains only the asks and actions listed in that surface's `items`, in the order they appear in `.contains(...)`. |
| 48 | + |
| 49 | +### Q2: Should each surface render as a separate semantic section? |
| 50 | + |
| 51 | +**Answer:** Yes. Use `<section>` elements with: |
| 52 | +- `id` attribute set to the surface's internal id (e.g. `surface_main`, `surface_sidebar`) |
| 53 | +- `aria-label` set to the surface name (e.g. `"main"`, `"sidebar"`) |
| 54 | +- A heading (`<h2>`) visible only when `showScreenName` is enabled, using the surface name |
| 55 | + |
| 56 | +### Q3: What happens when the same ask/action appears in multiple surfaces? |
| 57 | + |
| 58 | +**Answer:** The same **node** appears in multiple surface sections. This is the correct semantic: one logical state, multiple visual locations. |
| 59 | + |
| 60 | +### Q4: How do we avoid duplicate DOM ids? |
| 61 | + |
| 62 | +**Answer:** All ask inputs, buttons, hint paragraphs, reason paragraphs, and feedback outputs must use unique DOM ids. |
| 63 | + |
| 64 | +If the same `ActNode` or `AskNode` appears in `k` surfaces, its DOM elements must be duplicated `k` times with **different ids**. The simplest scheme: append `--<surfaceName>` to the base id. |
| 65 | + |
| 66 | +Examples: |
| 67 | + |
| 68 | +| Element | Single surface | Multi-surface | |
| 69 | +|---------|----------------|---------------| |
| 70 | +| Ask input id | `ask_email` | `ask_email--main`, `ask_email--sidebar` | |
| 71 | +| Act button id | `act_log_in` | `act_log_in--main`, `act_log_in--sidebar` | |
| 72 | +| Blocked reason id | `act_log_in-reason` | `act_log_in-reason--main`, `act_log_in-reason--sidebar` | |
| 73 | +| Enter hint id | `ask_email-enter-hint` | `ask_email-enter-hint--main`, `ask_email-enter-hint--sidebar` | |
| 74 | +| Feedback output | `feedback-output` | **One per surface**: `feedback-output--main`, `feedback-output--sidebar` | |
| 75 | + |
| 76 | +The `for` attribute on `<label htmlFor>` must match the per-surface input id. |
| 77 | + |
| 78 | +### Q5: Should duplicate controls share the same underlying state? |
| 79 | + |
| 80 | +**Answer:** Yes. Since the same `AskNode` (and therefore the same state object) is referenced in multiple surfaces, typing in surface A updates the same state as typing in surface B. This is the expected behavior — the state is shared. |
| 81 | + |
| 82 | +The event listeners attach to each DOM copy but all write to the same `ask.state.set()`. |
| 83 | + |
| 84 | +### Q6: Should duplicate action buttons execute the same action? |
| 85 | + |
| 86 | +**Answer:** Yes. Clicking "Log in" in surface A and "Log in" in surface B both call `runtime.executeAct(act)` on the same `ActNode`. Execution is idempotent at the semantic level. |
| 87 | + |
| 88 | +### Q7: How should `aria-describedby`, labels, hints, blocked reasons, and Enter default action work across duplicated controls? |
| 89 | + |
| 90 | +**Answer:** Each DOM copy gets its own set of adjacent elements, all suffixed with the surface name. |
| 91 | + |
| 92 | +The reactive subscription logic in `renderDom` must: |
| 93 | +- Query buttons and inputs **per surface** (by surface-suffixed id) |
| 94 | +- Subscribe to `act.enabled` and update **all** per-surface copies of that act's button, reason element, and aria attributes |
| 95 | +- Subscribe to Enter-key default action for **each** per-surface copy of an ask input |
| 96 | + |
| 97 | +For the **Enter default action**: the default action is a screen-level concept. `findDefaultAction(screenDef.acts)` remains unchanged — it operates over all acts regardless of surface. Each ask input in each surface independently listens for Enter and triggers the same default action. |
| 98 | + |
| 99 | +For **`aria-describedby` on ask inputs**: each per-surface copy of an input independently manages its own `aria-describedby` attribute, referencing the surface-suffixed hint id. |
| 100 | + |
| 101 | +For **blocked reasons on act buttons**: each per-surface copy of a button independently manages its own `aria-describedby` pointing to its own surface-suffixed reason `<p>` element. |
| 102 | + |
| 103 | +### Q8: Should `data-intent-*` semantic ids remain the same across duplicated surface occurrences? |
| 104 | + |
| 105 | +**Answer:** Yes. `data-intent-ask` and `data-intent-action` are semantic identifiers, not DOM-positional ones. The same ask/action node gets the same semantic id in every surface where it appears. |
| 106 | + |
| 107 | +```html |
| 108 | +<!-- Surface "main" --> |
| 109 | +<label data-intent-ask="ask:email">Email</label> |
| 110 | +<input data-intent-ask="ask:email" id="ask_email--main" /> |
| 111 | + |
| 112 | +<!-- Surface "sidebar" --> |
| 113 | +<label data-intent-ask="ask:email">Email</label> |
| 114 | +<input data-intent-ask="ask:email" id="ask_email--sidebar" /> |
| 115 | +``` |
| 116 | + |
| 117 | +### Q9: How should resources appear when included in a surface? |
| 118 | + |
| 119 | +**Answer:** Resources are not items in `SurfaceNode.items`. They are loaded by the runtime and their status is available via `ResourceRef` conditions. Resources are visible to the user only indirectly — through the asks/acts that depend on them (via `.when(resource.ready, ...)` or via a loading indicator). |
| 120 | + |
| 121 | +The DOM renderer currently has no explicit resource rendering. A future enhancement could render a resource's status (loading spinner, error message, stale indicator) as an implicit DOM element within each surface where any item depends on that resource. This is **out of scope** for this proposal; see "Future work" below. |
| 122 | + |
| 123 | +For now, resources with autoLoad still load correctly through the runtime, and actions that depend on resource conditions still enable/disable reactively. The only change is that each per-surface copy of a button subscribes to the same `act.enabled` condition independently. |
| 124 | + |
| 125 | +### Q10: What is the smallest implementation plan that preserves current single-surface behavior? |
| 126 | + |
| 127 | +The single-surface case (`screenDef.surfaces.length === 1`) must produce **identical** DOM output to the current implementation, including all ids (no `--surfaceName` suffix when there is only one surface). This maintains backward compatibility for all existing tests and examples. |
| 128 | + |
| 129 | +--- |
| 130 | + |
| 131 | +## Implementation plan |
| 132 | + |
| 133 | +### Phase 1: Restructure `buildDom` to iterate over surfaces |
| 134 | + |
| 135 | +**Estimated size:** ~150 lines changed in `packages/dom/src/index.ts` |
| 136 | + |
| 137 | +Replace the single-surface `buildDom` with a `buildSurface` helper called once per surface. |
| 138 | + |
| 139 | +**`buildDom` (top-level):** |
| 140 | +```ts |
| 141 | +function buildDom(screenDef, showScreenName, showSemanticIds): HTMLElement { |
| 142 | + const main = document.createElement("main") |
| 143 | + // screen-level attributes... |
| 144 | + for (const surface of screenDef.surfaces) { |
| 145 | + const el = buildSurface(screenDef, surface, showScreenName, showSemanticIds, isSingleSurface) |
| 146 | + main.appendChild(el) |
| 147 | + } |
| 148 | + return main |
| 149 | +} |
| 150 | +``` |
| 151 | + |
| 152 | +**`buildSurface` (per surface):** |
| 153 | +```ts |
| 154 | +function buildSurface(screenDef, surface, showScreenName, showSemanticIds, single): HTMLElement { |
| 155 | + const section = document.createElement("section") |
| 156 | + section.id = single ? surface.id : `${surface.id}--section` |
| 157 | + // surface heading (if showScreenName)... |
| 158 | + const form = document.createElement("form") |
| 159 | + // render only this surface's items... |
| 160 | + // per-surface feedback output... |
| 161 | + return section |
| 162 | +} |
| 163 | +``` |
| 164 | + |
| 165 | +### Phase 2: Suffix DOM ids when multi-surface |
| 166 | + |
| 167 | +When `screenDef.surfaces.length > 1`, all DOM element ids for asks and actions within a surface get a `--<surfaceName>` suffix. |
| 168 | + |
| 169 | +The `--surfaceName` suffix uses the surface `name` (sanitized), e.g. `ask_email--sidebar`. |
| 170 | + |
| 171 | +This means: |
| 172 | +- `label.htmlFor` → `ask_email--sidebar` |
| 173 | +- `control.id` → `ask_email--sidebar` |
| 174 | +- `control.name` → `ask_email--sidebar` |
| 175 | +- `button.id` → `act_log_in--sidebar` |
| 176 | +- hint ids → `ask_email-enter-hint--sidebar` |
| 177 | +- reason ids → `act_log_in-reason--sidebar` |
| 178 | +- output ids → `feedback-output--sidebar` |
| 179 | + |
| 180 | +**Helper:** |
| 181 | +```ts |
| 182 | +function surfaceSuffix(surfaceName: string, isMulti: boolean): string { |
| 183 | + return isMulti ? `--${surfaceName}` : "" |
| 184 | +} |
| 185 | +``` |
| 186 | + |
| 187 | +### Phase 3: Subscribe per-surface copies |
| 188 | + |
| 189 | +The current subscription loop: |
| 190 | + |
| 191 | +```ts |
| 192 | +for (const act of screenDef.acts) { |
| 193 | + const button = form.querySelector(`#${act.id}`) as HTMLButtonElement | null |
| 194 | + // subscribe... |
| 195 | +} |
| 196 | +``` |
| 197 | + |
| 198 | +Must become per-surface: |
| 199 | + |
| 200 | +```ts |
| 201 | +for (const surface of screenDef.surfaces) { |
| 202 | + for (const item of surface.items) { |
| 203 | + if (item type is ActNode) { |
| 204 | + const button = form.querySelector(`#${item.id}${suffix}`) as HTMLButtonElement | null |
| 205 | + // subscribe... |
| 206 | + } |
| 207 | + } |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +Or more practically, iterate over `screenDef.acts` and `screenDef.asks` but query with the surface-specific selector. The safest approach: loop over surfaces, and for each surface, loop over its items, find the existing button in the DOM, and attach the subscription. |
| 212 | + |
| 213 | +The same applies to: |
| 214 | +- Button click handlers → `runtime.executeAct(act)` per surface copy |
| 215 | +- Status change subscriptions → `updateFeedback(act, outputEl)` per surface output element |
| 216 | +- Enter key default → per surface ask copy |
| 217 | +- Enter hint reactive toggle → per surface hint and input |
| 218 | + |
| 219 | +### Phase 4: Single feedback output → per-surface feedback outputs |
| 220 | + |
| 221 | +Currently there is one `<output id="feedback-output">` in the form. With multi-surface, each surface gets its own output: |
| 222 | + |
| 223 | +```ts |
| 224 | +const output = document.createElement("output") |
| 225 | +output.id = `feedback-output${suffix}` |
| 226 | +``` |
| 227 | + |
| 228 | +The `updateFeedback` calls from status change subscriptions must target the correct per-surface output element. |
| 229 | + |
| 230 | +### Phase 5: Tests |
| 231 | + |
| 232 | +See "Test plan" below. |
| 233 | + |
| 234 | +--- |
| 235 | + |
| 236 | +## Files likely to change |
| 237 | + |
| 238 | +| File | Nature of change | |
| 239 | +|------|-----------------| |
| 240 | +| `packages/dom/src/index.ts` | **Primary.** Restructure `buildDom`, add `buildSurface`, surface-suffixed ids, per-surface subscriptions, per-surface feedback, per-surface Enter key handling. | |
| 241 | +| `packages/dom/src/dom.test.ts` | Add multi-surface test suite (see Test Plan). Existing single-surface tests must continue to pass unchanged. | |
| 242 | +| `packages/dom/src/dom-router.ts` | Unchanged — delegates to `renderDom`. | |
| 243 | + |
| 244 | +--- |
| 245 | + |
| 246 | +## Test plan |
| 247 | + |
| 248 | +### Backward-compatibility tests (must pass before and after) |
| 249 | + |
| 250 | +All existing tests in `dom.test.ts` and `dom-router.test.ts` **must pass without modification**. These all use single-surface screens. |
| 251 | + |
| 252 | +### Multi-surface tests (new) |
| 253 | + |
| 254 | +1. **Two surfaces render as two `<section>` elements** |
| 255 | + - Screen with `"main"` and `"sidebar"` surfaces |
| 256 | + - Expect two `<section>` children inside `<main>` |
| 257 | + - Each section has the correct `aria-label` and `id` |
| 258 | + |
| 259 | +2. **Each surface contains only its own items** |
| 260 | + - `"main"` has asks A, B and act X |
| 261 | + - `"sidebar"` has ask C and act Y |
| 262 | + - Assert DOM: main section has A, B, X; sidebar section has C, Y; no cross-contamination |
| 263 | + |
| 264 | +3. **Duplicate node in two surfaces has surface-suffixed ids** |
| 265 | + - Same ask in `"main"` and `"sidebar"` |
| 266 | + - Expect `ask_email--main` and `ask_email--sidebar` |
| 267 | + |
| 268 | +4. **Duplicate controls share state** |
| 269 | + - Type in `ask_email--main`, assert `ask_email--sidebar` value also changes |
| 270 | + - Vice versa |
| 271 | + |
| 272 | +5. **Duplicate action buttons execute same action** |
| 273 | + - Click button in surface A, assert action ran |
| 274 | + - Click same button in surface B, assert action ran |
| 275 | + |
| 276 | +6. **Blocked reason rendered per-surface for duplicate act** |
| 277 | + - Act with blocked reason in two surfaces |
| 278 | + - Each surface gets its own reason element with surface-suffixed id |
| 279 | + |
| 280 | +7. **`aria-describedby` on button is surface-specific** |
| 281 | + - Button in `"main"` has `aria-describedby="act_log_in-reason--main"` |
| 282 | + - Button in `"sidebar"` has `aria-describedby="act_log_in-reason--sidebar"` |
| 283 | + |
| 284 | +8. **Feedback output per surface** |
| 285 | + - Execute action in surface A — only surface A's output shows feedback |
| 286 | + - Execute same action in surface B — only surface B's output shows feedback |
| 287 | + |
| 288 | +9. **Enter key default action works in both surfaces** |
| 289 | + - Press Enter in ask input in `"main"` — triggers default action |
| 290 | + - Press Enter in ask input in `"sidebar"` — triggers same default action |
| 291 | + |
| 292 | +10. **`data-intent-*` semantic ids are same across surfaces** |
| 293 | + - `showSemanticIds: true` — same `data-intent-ask="ask:email"` on both copies |
| 294 | + |
| 295 | +11. **Cleanup removes all subscriptions across all surfaces** |
| 296 | + - After `cleanup()`, state changes should not update any per-surface copy |
| 297 | + |
| 298 | +--- |
| 299 | + |
| 300 | +## Compatibility notes |
| 301 | + |
| 302 | +| Example | Surfaces defined | Current behavior | After multi-surface | |
| 303 | +|---------|-----------------|------------------|---------------------| |
| 304 | +| `canonical-invite/InviteMember.ts` | 1 (`main`) | Rendered correctly | No change | |
| 305 | +| `resource-lifecycle/ResourceDemo.ts` | 1 (`main`) | Rendered correctly | No change | |
| 306 | +| `secret-vault` (all 3 screens) | 1 (`main`) each | Rendered correctly | No change | |
| 307 | +| `choice-form/RegistrationForm.ts` | 2 (`main`, `sidebar`) | Silently drops "sidebar" | Both surfaces render | |
| 308 | +| `web-basic` screens | 1 (`main`) each | Rendered correctly | No change | |
| 309 | + |
| 310 | +The `choice-form` example is the only one that defines multiple surfaces. After implementation, `renderDom(RegistrationForm, ...)` will render both `"main"` and `"sidebar"` sections. The example's `renderDom` call site (in `main.ts`) needs no changes — it will automatically pick up the extra surface. |
| 311 | + |
| 312 | +--- |
| 313 | + |
| 314 | +## Future work (not in scope) |
| 315 | + |
| 316 | +- **Resource rendering in surfaces:** Show loading spinners, error states, or stale indicators for resources within surfaces that reference them. Requires adding resource dependencies to surface items or a resource-to-surface mapping. |
| 317 | +- **Surface-level layout hints:** Allow surfaces to declare layout roles (nav, sidebar, main, footer) that map to semantic HTML elements. |
| 318 | +- **Dynamic surface visibility:** Hide/show surfaces based on conditions. |
| 319 | +- **Surface composition:** Surface-within-surface or named slot patterns. |
| 320 | + |
| 321 | +--- |
| 322 | + |
| 323 | +## Validation |
| 324 | + |
| 325 | +Before merging the implementation PR: |
| 326 | + |
| 327 | +```sh |
| 328 | +pnpm test # all existing + new tests pass |
| 329 | +pnpm typecheck # no type errors |
| 330 | +pnpm build # clean build |
| 331 | +pnpm lint # no lint errors |
| 332 | +pnpm pack:check # packages are packable |
| 333 | +pnpm changeset status # no unexpected changesets |
| 334 | +``` |
0 commit comments