Skip to content

Commit 665f46d

Browse files
authored
docs: DOM multi-surface rendering design proposal
1 parent a404622 commit 665f46d

1 file changed

Lines changed: 334 additions & 0 deletions

File tree

Lines changed: 334 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,334 @@
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

Comments
 (0)