diff --git a/.changeset/goofy-oranges-wish.md b/.changeset/goofy-oranges-wish.md
new file mode 100644
index 0000000..7b7238e
--- /dev/null
+++ b/.changeset/goofy-oranges-wish.md
@@ -0,0 +1,5 @@
+---
+"@intent-framework/dom": patch
+---
+
+Add opt-in `showSemanticIds` rendering support for semantic `data-intent-*` attributes.
diff --git a/docs/Inspect-Screen.md b/docs/Inspect-Screen.md
index 49c17c4..097c52f 100644
--- a/docs/Inspect-Screen.md
+++ b/docs/Inspect-Screen.md
@@ -245,6 +245,6 @@ The current graph inspection has deliberate limitations:
- **No full reachability analysis.** Diagnostics check surface membership but do not trace whether all nodes are reachable through flows or surfaces.
- **No route-wide graph inspection.** `inspectScreen()` inspects one screen at a time. There is no cross-screen or route-level graph snapshot yet.
- **No DevTools package yet.** There is no dedicated browser extension or DevTools panel. The web-basic demo uses a `MutationObserver`-driven DOM side panel.
-- **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.
+- **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.
- **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.
- **No visual diff or time-travel debugging.** Graph snapshots are static. There is no history of state changes over time.
diff --git a/docs/Quickstart.md b/docs/Quickstart.md
index 701d29c..3120ca6 100644
--- a/docs/Quickstart.md
+++ b/docs/Quickstart.md
@@ -91,15 +91,18 @@ This produces:
- Typing a valid email enables the button reactively.
- Pressing Enter triggers the default action when unambiguous.
-The DOM renderer also accepts services and an option to show the screen name as an `
`:
+The DOM renderer also accepts services and options:
```ts
renderDom(InviteMember, {
target: root,
- showScreenName: true,
+ showScreenName: true, // show screen name as
+ showSemanticIds: true, // add data-intent-* attributes for debugging
})
```
+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.
+
## 4. Test semantically
The testing package lets you assert product behavior without touching the DOM:
diff --git a/packages/dom/README.md b/packages/dom/README.md
index 093dc7e..b879f02 100644
--- a/packages/dom/README.md
+++ b/packages/dom/README.md
@@ -20,6 +20,7 @@ npm install @intent-framework/core@0.1.0-alpha.1 @intent-framework/dom@0.1.0-alp
- Reactive action enablement and blocked reasons
- Enter key triggers the default action when unambiguous
- Opt-in screen-name heading via `showScreenName`
+- Opt-in semantic data attributes via `showSemanticIds`
## Minimal example
diff --git a/packages/dom/src/dom-router.ts b/packages/dom/src/dom-router.ts
index 3b75fcf..a52a3ac 100644
--- a/packages/dom/src/dom-router.ts
+++ b/packages/dom/src/dom-router.ts
@@ -18,6 +18,7 @@ export type RenderRouterOptions | ((pathname: string) => ScreenDefinition)
services?: Omit
showScreenName?: boolean
+ showSemanticIds?: boolean
}
export function renderRouter<
@@ -27,7 +28,7 @@ export function renderRouter<
router: Router,
options: RenderRouterOptions,
): RouterDomHandle {
- const { showScreenName } = options
+ const { showScreenName, showSemanticIds } = options
const win = options.window ?? window
let currentCleanup: (() => void) | undefined
@@ -64,6 +65,7 @@ export function renderRouter<
target: options.target,
services: mergedServices,
showScreenName,
+ showSemanticIds,
})
return
}
@@ -76,6 +78,7 @@ export function renderRouter<
target: options.target,
services: mergedServices,
showScreenName,
+ showSemanticIds,
})
} else {
options.target.textContent = "Not found"
diff --git a/packages/dom/src/dom.test.ts b/packages/dom/src/dom.test.ts
index bab41eb..4feb4f8 100644
--- a/packages/dom/src/dom.test.ts
+++ b/packages/dom/src/dom.test.ts
@@ -1384,6 +1384,116 @@ describe("DOM renderer", () => {
})
})
+ describe("showSemanticIds data attributes", () => {
+ it("does not add data-intent-* attributes by default", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("InviteMember", $ => {
+ const email = $.state.text("email")
+ const emailAsk = $.ask("Email", email).required()
+ const invite = $.act("Invite member")
+ .primary()
+ .when(emailAsk.valid)
+ .does(async () => {})
+ $.surface("main").contains(emailAsk, invite)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root })
+
+ expect(root.querySelector("[data-intent-screen]")).toBeNull()
+ expect(root.querySelector("[data-intent-ask]")).toBeNull()
+ expect(root.querySelector("[data-intent-action]")).toBeNull()
+ })
+
+ it("adds data-intent attributes with correct semantic IDs when enabled", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("InviteMember", $ => {
+ const email = $.state.text("email")
+ const emailAsk = $.ask("Email", email).required()
+ const invite = $.act("Invite member")
+ .primary()
+ .when(emailAsk.valid)
+ .does(async () => {})
+ $.surface("main").contains(emailAsk, invite)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root, showSemanticIds: true })
+
+ const main = root.querySelector("main")!
+ expect(main.getAttribute("data-intent-screen")).toBe("screen:invite-member")
+
+ const label = root.querySelector("label")!
+ expect(label.getAttribute("data-intent-ask")).toBe("ask:email")
+
+ const input = root.querySelector("input")!
+ expect(input.getAttribute("data-intent-ask")).toBe("ask:email")
+
+ const button = root.querySelector("button")!
+ expect(button.getAttribute("data-intent-action")).toBe("action:invite-member")
+ })
+
+ it("adds data-intent attributes for multiple asks and actions", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("MultiIntentScreen", $ => {
+ const email = $.state.text("email")
+ const password = $.state.text("password")
+ const emailAsk = $.ask("Email", email).required()
+ const passwordAsk = $.ask("Password", password).required()
+ const login = $.act("Log in").primary().when(emailAsk.valid).when(passwordAsk.valid)
+ const reset = $.act("Reset").when(true).does(async () => {})
+ $.surface("main").contains(emailAsk, passwordAsk, login, reset)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root, showSemanticIds: true })
+
+ const labels = root.querySelectorAll("label")
+ expect(labels[0]!.getAttribute("data-intent-ask")).toBe("ask:email")
+ expect(labels[1]!.getAttribute("data-intent-ask")).toBe("ask:password")
+
+ const inputs = root.querySelectorAll("input")
+ expect(inputs[0]!.getAttribute("data-intent-ask")).toBe("ask:email")
+ expect(inputs[1]!.getAttribute("data-intent-ask")).toBe("ask:password")
+
+ const buttons = root.querySelectorAll("button")
+ expect(buttons[0]!.getAttribute("data-intent-action")).toBe("action:log-in")
+ expect(buttons[1]!.getAttribute("data-intent-action")).toBe("action:reset")
+ })
+
+ it("default DOM output unchanged when showSemanticIds is omitted", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("DefaultCheck", $ => {
+ const text = $.state.text("name")
+ const ask = $.ask("Name", text).required()
+ $.act("Save").primary().when(ask.valid).does(async () => {})
+ $.surface("main").contains(ask)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root })
+
+ expect(root.querySelector("[data-intent-screen]")).toBeNull()
+ expect(root.querySelector("[data-intent-ask]")).toBeNull()
+ expect(root.querySelector("[data-intent-action]")).toBeNull()
+
+ const main = root.querySelector("main")
+ expect(main).not.toBeNull()
+ const form = root.querySelector("form")
+ expect(form).not.toBeNull()
+ const label = root.querySelector("label")
+ expect(label?.textContent).toBe("Name")
+ const input = root.querySelector("input")
+ expect(input).not.toBeNull()
+ const button = root.querySelector("button")
+ expect(button?.textContent).toBe("Save")
+ })
+ })
+
describe("showScreenName heading", () => {
it("does not render a screen name heading by default", () => {
document.body.innerHTML = ''
diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts
index 0a918bf..28e04df 100644
--- a/packages/dom/src/index.ts
+++ b/packages/dom/src/index.ts
@@ -1,5 +1,5 @@
-import type { ScreenDefinition, ActNode, DefaultScreenServices } from "@intent-framework/core"
-import { createScreenRuntime } from "@intent-framework/core"
+import type { ScreenDefinition, ActNode, DefaultScreenServices, InspectedScreen } from "@intent-framework/core"
+import { createScreenRuntime, inspectScreen } from "@intent-framework/core"
function getReasonId(actId: string): string {
return `${actId}-reason`
@@ -30,6 +30,7 @@ export type DomRendererOptions
target: HTMLElement
services?: TServices
showScreenName?: boolean
+ showSemanticIds?: boolean
}
export { renderRouter } from "./dom-router.js"
@@ -39,9 +40,9 @@ export function renderDom(
screenDef: ScreenDefinition,
options: DomRendererOptions
): () => void {
- const { target, services, showScreenName } = options
+ const { target, services, showScreenName, showSemanticIds } = options
target.innerHTML = ""
- const root = buildDom(screenDef, showScreenName)
+ const root = buildDom(screenDef, showScreenName, showSemanticIds)
target.appendChild(root)
const runtime = createScreenRuntime(screenDef, { services })
@@ -164,8 +165,19 @@ export function renderDom(
function buildDom(
screenDef: ScreenDefinition,
- showScreenName?: boolean
+ showScreenName?: boolean,
+ showSemanticIds?: boolean
): HTMLElement {
+ let inspected: InspectedScreen | undefined
+ let askSemanticIds: Map | undefined
+ let actSemanticIds: Map | undefined
+
+ if (showSemanticIds) {
+ inspected = inspectScreen(screenDef)
+ askSemanticIds = new Map(inspected.asks.map(a => [a.id, a.semanticId]))
+ actSemanticIds = new Map(inspected.acts.map(a => [a.id, a.semanticId]))
+ }
+
const surface = screenDef.surfaces[0]
const main = document.createElement("main")
@@ -173,6 +185,10 @@ function buildDom(
main.id = surface.id
}
+ if (showSemanticIds && inspected) {
+ main.setAttribute("data-intent-screen", inspected.semanticId)
+ }
+
if (showScreenName) {
const heading = document.createElement("h1")
heading.textContent = screenDef.name
@@ -190,11 +206,23 @@ function buildDom(
const label = document.createElement("label")
label.textContent = ask.label
label.htmlFor = ask.id
+ if (showSemanticIds && askSemanticIds) {
+ const sid = askSemanticIds.get(ask.id)
+ if (sid) {
+ label.setAttribute("data-intent-ask", sid)
+ }
+ }
container.appendChild(label)
const input = createInputForAsk(ask)
input.id = ask.id
input.name = ask.id
+ if (showSemanticIds && askSemanticIds) {
+ const sid = askSemanticIds.get(ask.id)
+ if (sid) {
+ input.setAttribute("data-intent-ask", sid)
+ }
+ }
if (ask.required) {
input.required = true
@@ -249,6 +277,12 @@ function buildDom(
button.id = act.id
button.type = "button"
button.textContent = act.label
+ if (showSemanticIds && actSemanticIds) {
+ const sid = actSemanticIds.get(act.id)
+ if (sid) {
+ button.setAttribute("data-intent-action", sid)
+ }
+ }
if (act.primary) {
button.className = "primary"