Skip to content

Commit 65149c1

Browse files
feat(dom): add opt-in showSemanticIds data attributes (#66)
Merging PR #66 via task instructions. All checks pass, changeset verified, implementation scoped to showSemanticIds.
1 parent 478c594 commit 65149c1

7 files changed

Lines changed: 165 additions & 9 deletions

File tree

.changeset/goofy-oranges-wish.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@intent-framework/dom": patch
3+
---
4+
5+
Add opt-in `showSemanticIds` rendering support for semantic `data-intent-*` attributes.

docs/Inspect-Screen.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,6 @@ The current graph inspection has deliberate limitations:
245245
- **No full reachability analysis.** Diagnostics check surface membership but do not trace whether all nodes are reachable through flows or surfaces.
246246
- **No route-wide graph inspection.** `inspectScreen()` inspects one screen at a time. There is no cross-screen or route-level graph snapshot yet.
247247
- **No DevTools package yet.** There is no dedicated browser extension or DevTools panel. The web-basic demo uses a `MutationObserver`-driven DOM side panel.
248-
- **DOM renderer does not expose all semantic IDs as data attributes.** The DOM renderer uses its own `id` conventions for accessibility. Semantic IDs are not yet emitted as `data-semantic-id` attributes.
248+
- **DOM renderer exposes semantic IDs as opt-in data attributes.** Pass `showSemanticIds: true` to `renderDom()` to add `data-intent-screen`, `data-intent-ask`, and `data-intent-action` attributes to rendered DOM elements. Default output is unchanged.
249249
- **Resource graph inspection exists.** `inspectScreen()` accepts runtime resource nodes and reports status, staleness, and errors. However, cache and staleness semantics are still early — there is no automatic reload, polling, or TTL-based invalidation.
250250
- **No visual diff or time-travel debugging.** Graph snapshots are static. There is no history of state changes over time.

docs/Quickstart.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,18 @@ This produces:
9191
- Typing a valid email enables the button reactively.
9292
- Pressing Enter triggers the default action when unambiguous.
9393

94-
The DOM renderer also accepts services and an option to show the screen name as an `<h1>`:
94+
The DOM renderer also accepts services and options:
9595

9696
```ts
9797
renderDom(InviteMember, {
9898
target: root,
99-
showScreenName: true,
99+
showScreenName: true, // show screen name as <h1>
100+
showSemanticIds: true, // add data-intent-* attributes for debugging
100101
})
101102
```
102103

104+
With `showSemanticIds: true`, the DOM includes `data-intent-screen`, `data-intent-ask`, and `data-intent-action` attributes that map rendered elements back to their `inspectScreen()` semantic IDs.
105+
103106
## 4. Test semantically
104107

105108
The testing package lets you assert product behavior without touching the DOM:

packages/dom/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ npm install @intent-framework/core@0.1.0-alpha.1 @intent-framework/dom@0.1.0-alp
2020
- Reactive action enablement and blocked reasons
2121
- Enter key triggers the default action when unambiguous
2222
- Opt-in screen-name heading via `showScreenName`
23+
- Opt-in semantic data attributes via `showSemanticIds`
2324

2425
## Minimal example
2526

packages/dom/src/dom-router.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ export type RenderRouterOptions<TServices extends object = DefaultScreenServices
1818
notFound?: ScreenDefinition<TServices> | ((pathname: string) => ScreenDefinition<TServices>)
1919
services?: Omit<TServices, "navigate" | "route">
2020
showScreenName?: boolean
21+
showSemanticIds?: boolean
2122
}
2223

2324
export function renderRouter<
@@ -27,7 +28,7 @@ export function renderRouter<
2728
router: Router<Routes, TServices>,
2829
options: RenderRouterOptions<TServices>,
2930
): RouterDomHandle<Routes> {
30-
const { showScreenName } = options
31+
const { showScreenName, showSemanticIds } = options
3132
const win = options.window ?? window
3233
let currentCleanup: (() => void) | undefined
3334

@@ -64,6 +65,7 @@ export function renderRouter<
6465
target: options.target,
6566
services: mergedServices,
6667
showScreenName,
68+
showSemanticIds,
6769
})
6870
return
6971
}
@@ -76,6 +78,7 @@ export function renderRouter<
7678
target: options.target,
7779
services: mergedServices,
7880
showScreenName,
81+
showSemanticIds,
7982
})
8083
} else {
8184
options.target.textContent = "Not found"

packages/dom/src/dom.test.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,6 +1384,116 @@ describe("DOM renderer", () => {
13841384
})
13851385
})
13861386

1387+
describe("showSemanticIds data attributes", () => {
1388+
it("does not add data-intent-* attributes by default", () => {
1389+
document.body.innerHTML = '<div id="root"></div>'
1390+
1391+
const Screen = screen("InviteMember", $ => {
1392+
const email = $.state.text("email")
1393+
const emailAsk = $.ask("Email", email).required()
1394+
const invite = $.act("Invite member")
1395+
.primary()
1396+
.when(emailAsk.valid)
1397+
.does(async () => {})
1398+
$.surface("main").contains(emailAsk, invite)
1399+
})
1400+
1401+
const root = document.getElementById("root")!
1402+
renderDom(Screen, { target: root })
1403+
1404+
expect(root.querySelector("[data-intent-screen]")).toBeNull()
1405+
expect(root.querySelector("[data-intent-ask]")).toBeNull()
1406+
expect(root.querySelector("[data-intent-action]")).toBeNull()
1407+
})
1408+
1409+
it("adds data-intent attributes with correct semantic IDs when enabled", () => {
1410+
document.body.innerHTML = '<div id="root"></div>'
1411+
1412+
const Screen = screen("InviteMember", $ => {
1413+
const email = $.state.text("email")
1414+
const emailAsk = $.ask("Email", email).required()
1415+
const invite = $.act("Invite member")
1416+
.primary()
1417+
.when(emailAsk.valid)
1418+
.does(async () => {})
1419+
$.surface("main").contains(emailAsk, invite)
1420+
})
1421+
1422+
const root = document.getElementById("root")!
1423+
renderDom(Screen, { target: root, showSemanticIds: true })
1424+
1425+
const main = root.querySelector("main")!
1426+
expect(main.getAttribute("data-intent-screen")).toBe("screen:invite-member")
1427+
1428+
const label = root.querySelector("label")!
1429+
expect(label.getAttribute("data-intent-ask")).toBe("ask:email")
1430+
1431+
const input = root.querySelector("input")!
1432+
expect(input.getAttribute("data-intent-ask")).toBe("ask:email")
1433+
1434+
const button = root.querySelector("button")!
1435+
expect(button.getAttribute("data-intent-action")).toBe("action:invite-member")
1436+
})
1437+
1438+
it("adds data-intent attributes for multiple asks and actions", () => {
1439+
document.body.innerHTML = '<div id="root"></div>'
1440+
1441+
const Screen = screen("MultiIntentScreen", $ => {
1442+
const email = $.state.text("email")
1443+
const password = $.state.text("password")
1444+
const emailAsk = $.ask("Email", email).required()
1445+
const passwordAsk = $.ask("Password", password).required()
1446+
const login = $.act("Log in").primary().when(emailAsk.valid).when(passwordAsk.valid)
1447+
const reset = $.act("Reset").when(true).does(async () => {})
1448+
$.surface("main").contains(emailAsk, passwordAsk, login, reset)
1449+
})
1450+
1451+
const root = document.getElementById("root")!
1452+
renderDom(Screen, { target: root, showSemanticIds: true })
1453+
1454+
const labels = root.querySelectorAll("label")
1455+
expect(labels[0]!.getAttribute("data-intent-ask")).toBe("ask:email")
1456+
expect(labels[1]!.getAttribute("data-intent-ask")).toBe("ask:password")
1457+
1458+
const inputs = root.querySelectorAll("input")
1459+
expect(inputs[0]!.getAttribute("data-intent-ask")).toBe("ask:email")
1460+
expect(inputs[1]!.getAttribute("data-intent-ask")).toBe("ask:password")
1461+
1462+
const buttons = root.querySelectorAll("button")
1463+
expect(buttons[0]!.getAttribute("data-intent-action")).toBe("action:log-in")
1464+
expect(buttons[1]!.getAttribute("data-intent-action")).toBe("action:reset")
1465+
})
1466+
1467+
it("default DOM output unchanged when showSemanticIds is omitted", () => {
1468+
document.body.innerHTML = '<div id="root"></div>'
1469+
1470+
const Screen = screen("DefaultCheck", $ => {
1471+
const text = $.state.text("name")
1472+
const ask = $.ask("Name", text).required()
1473+
$.act("Save").primary().when(ask.valid).does(async () => {})
1474+
$.surface("main").contains(ask)
1475+
})
1476+
1477+
const root = document.getElementById("root")!
1478+
renderDom(Screen, { target: root })
1479+
1480+
expect(root.querySelector("[data-intent-screen]")).toBeNull()
1481+
expect(root.querySelector("[data-intent-ask]")).toBeNull()
1482+
expect(root.querySelector("[data-intent-action]")).toBeNull()
1483+
1484+
const main = root.querySelector("main")
1485+
expect(main).not.toBeNull()
1486+
const form = root.querySelector("form")
1487+
expect(form).not.toBeNull()
1488+
const label = root.querySelector("label")
1489+
expect(label?.textContent).toBe("Name")
1490+
const input = root.querySelector("input")
1491+
expect(input).not.toBeNull()
1492+
const button = root.querySelector("button")
1493+
expect(button?.textContent).toBe("Save")
1494+
})
1495+
})
1496+
13871497
describe("showScreenName heading", () => {
13881498
it("does not render a screen name heading by default", () => {
13891499
document.body.innerHTML = '<div id="root"></div>'

packages/dom/src/index.ts

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import type { ScreenDefinition, ActNode, DefaultScreenServices } from "@intent-framework/core"
2-
import { createScreenRuntime } from "@intent-framework/core"
1+
import type { ScreenDefinition, ActNode, DefaultScreenServices, InspectedScreen } from "@intent-framework/core"
2+
import { createScreenRuntime, inspectScreen } from "@intent-framework/core"
33

44
function getReasonId(actId: string): string {
55
return `${actId}-reason`
@@ -30,6 +30,7 @@ export type DomRendererOptions<TServices extends object = DefaultScreenServices>
3030
target: HTMLElement
3131
services?: TServices
3232
showScreenName?: boolean
33+
showSemanticIds?: boolean
3334
}
3435

3536
export { renderRouter } from "./dom-router.js"
@@ -39,9 +40,9 @@ export function renderDom<TServices extends object = DefaultScreenServices>(
3940
screenDef: ScreenDefinition<TServices>,
4041
options: DomRendererOptions<TServices>
4142
): () => void {
42-
const { target, services, showScreenName } = options
43+
const { target, services, showScreenName, showSemanticIds } = options
4344
target.innerHTML = ""
44-
const root = buildDom(screenDef, showScreenName)
45+
const root = buildDom(screenDef, showScreenName, showSemanticIds)
4546
target.appendChild(root)
4647

4748
const runtime = createScreenRuntime<TServices>(screenDef, { services })
@@ -164,15 +165,30 @@ export function renderDom<TServices extends object = DefaultScreenServices>(
164165

165166
function buildDom<TServices extends object = DefaultScreenServices>(
166167
screenDef: ScreenDefinition<TServices>,
167-
showScreenName?: boolean
168+
showScreenName?: boolean,
169+
showSemanticIds?: boolean
168170
): HTMLElement {
171+
let inspected: InspectedScreen | undefined
172+
let askSemanticIds: Map<string, string> | undefined
173+
let actSemanticIds: Map<string, string> | undefined
174+
175+
if (showSemanticIds) {
176+
inspected = inspectScreen(screenDef)
177+
askSemanticIds = new Map(inspected.asks.map(a => [a.id, a.semanticId]))
178+
actSemanticIds = new Map(inspected.acts.map(a => [a.id, a.semanticId]))
179+
}
180+
169181
const surface = screenDef.surfaces[0]
170182
const main = document.createElement("main")
171183

172184
if (surface) {
173185
main.id = surface.id
174186
}
175187

188+
if (showSemanticIds && inspected) {
189+
main.setAttribute("data-intent-screen", inspected.semanticId)
190+
}
191+
176192
if (showScreenName) {
177193
const heading = document.createElement("h1")
178194
heading.textContent = screenDef.name
@@ -190,11 +206,23 @@ function buildDom<TServices extends object = DefaultScreenServices>(
190206
const label = document.createElement("label")
191207
label.textContent = ask.label
192208
label.htmlFor = ask.id
209+
if (showSemanticIds && askSemanticIds) {
210+
const sid = askSemanticIds.get(ask.id)
211+
if (sid) {
212+
label.setAttribute("data-intent-ask", sid)
213+
}
214+
}
193215
container.appendChild(label)
194216

195217
const input = createInputForAsk(ask)
196218
input.id = ask.id
197219
input.name = ask.id
220+
if (showSemanticIds && askSemanticIds) {
221+
const sid = askSemanticIds.get(ask.id)
222+
if (sid) {
223+
input.setAttribute("data-intent-ask", sid)
224+
}
225+
}
198226

199227
if (ask.required) {
200228
input.required = true
@@ -249,6 +277,12 @@ function buildDom<TServices extends object = DefaultScreenServices>(
249277
button.id = act.id
250278
button.type = "button"
251279
button.textContent = act.label
280+
if (showSemanticIds && actSemanticIds) {
281+
const sid = actSemanticIds.get(act.id)
282+
if (sid) {
283+
button.setAttribute("data-intent-action", sid)
284+
}
285+
}
252286

253287
if (act.primary) {
254288
button.className = "primary"

0 commit comments

Comments
 (0)