diff --git a/.changeset/forty-hotels-write.md b/.changeset/forty-hotels-write.md
new file mode 100644
index 0000000..a845151
--- /dev/null
+++ b/.changeset/forty-hotels-write.md
@@ -0,0 +1,2 @@
+---
+---
diff --git a/docs/Inspect-Screen.md b/docs/Inspect-Screen.md
index 49c17c4..ae4388c 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 can expose semantic IDs as data attributes.** The DOM renderer uses its own `id` conventions for accessibility. Pass `showSemanticIds: true` to `renderDom()` to emit `data-intent-screen`, `data-intent-surface`, `data-intent-ask`, and `data-intent-action` attributes on rendered elements.
- **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..df58f35 100644
--- a/docs/Quickstart.md
+++ b/docs/Quickstart.md
@@ -91,12 +91,13 @@ 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, // renders with screen name
+ showSemanticIds: true, // adds data-intent-* attributes
})
```
diff --git a/packages/dom/README.md b/packages/dom/README.md
index 093dc7e..0d50a56 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` for debugging and tooling
## 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..1fc97c9 100644
--- a/packages/dom/src/dom.test.ts
+++ b/packages/dom/src/dom.test.ts
@@ -1488,4 +1488,148 @@ describe("DOM renderer", () => {
expect(headingsAfter[0]!.textContent).toBe("MyScreen")
})
})
+
+ describe("showSemanticIds data attributes", () => {
+ it("does not add semantic data 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(true).does(async () => {})
+ $.surface("main").contains(emailAsk, invite)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root })
+
+ const main = root.querySelector("main")!
+ expect(main.hasAttribute("data-intent-screen")).toBe(false)
+
+ const label = root.querySelector("label")!
+ expect(label.hasAttribute("data-intent-ask")).toBe(false)
+
+ const input = root.querySelector("input")!
+ expect(input.hasAttribute("data-intent-ask")).toBe(false)
+
+ const button = root.querySelector("button")!
+ expect(button.hasAttribute("data-intent-action")).toBe(false)
+ })
+
+ it("adds semantic data attributes when showSemanticIds is 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(true).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")
+ expect(main.getAttribute("data-intent-surface")).toBe("surface:main")
+
+ const labels = root.querySelectorAll("label")
+ expect(labels[0]!.getAttribute("data-intent-ask")).toBe("ask:email")
+
+ const inputs = root.querySelectorAll("input")
+ expect(inputs[0]!.getAttribute("data-intent-ask")).toBe("ask:email")
+
+ const buttons = root.querySelectorAll("button")
+ expect(buttons[0]!.getAttribute("data-intent-action")).toBe("action:invite-member")
+ })
+
+ it("assigns the same semantic ID to both label and input for the same ask", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("Test", $ => {
+ const name = $.state.text("name")
+ const ask = $.ask("Name", name).required()
+ $.act("Save").primary().when(true).does(async () => {})
+ $.surface("main").contains(ask)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root, showSemanticIds: true })
+
+ const labelId = root.querySelector("label")!.getAttribute("data-intent-ask")
+ const inputId = root.querySelector("input")!.getAttribute("data-intent-ask")
+ expect(labelId).toBe("ask:name")
+ expect(inputId).toBe("ask:name")
+ })
+
+ it("generates unique IDs for duplicate ask labels", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("Test", $ => {
+ const a = $.state.text("a")
+ const b = $.state.text("b")
+ const askA = $.ask("Email", a).required()
+ const askB = $.ask("Email", b).required()
+ $.act("Save").primary().when(true).does(async () => {})
+ $.surface("main").contains(askA, askB)
+ })
+
+ 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:email-2")
+ })
+
+ it("generates unique IDs for duplicate action labels", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("Test", $ => {
+ const a = $.act("Save").primary().when(true).does(async () => {})
+ const b = $.act("Save").when(true).does(async () => {})
+ $.surface("main").contains(a, b)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root, showSemanticIds: true })
+
+ const buttons = root.querySelectorAll("button")
+ expect(buttons[0]!.getAttribute("data-intent-action")).toBe("action:save")
+ expect(buttons[1]!.getAttribute("data-intent-action")).toBe("action:save-2")
+ })
+
+ it("does not break existing accessibility or behavior", () => {
+ document.body.innerHTML = ''
+
+ const Screen = screen("Login", $ => {
+ 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)
+ $.surface("main").contains(emailAsk, passwordAsk, login)
+ })
+
+ const root = document.getElementById("root")!
+ renderDom(Screen, { target: root, showSemanticIds: true })
+
+ const labels = root.querySelectorAll("label")
+ expect(labels).toHaveLength(2)
+ expect(labels[0]!.textContent).toBe("Email")
+ expect(labels[1]!.textContent).toBe("Password")
+
+ const button = root.querySelector("button") as HTMLButtonElement
+ expect(button.textContent).toBe("Log in")
+ expect(button.getAttribute("type")).toBe("button")
+ expect(button.disabled).toBe(true)
+
+ const output = root.querySelector("output")!
+ expect(output.getAttribute("aria-live")).toBe("polite")
+
+ // Semantic IDs should still be present alongside normal attributes
+ expect(button.hasAttribute("data-intent-action")).toBe(true)
+ expect(button.getAttribute("data-intent-action")).toBe("action:log-in")
+ })
+ })
})
diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts
index 0a918bf..a1e434c 100644
--- a/packages/dom/src/index.ts
+++ b/packages/dom/src/index.ts
@@ -13,6 +13,28 @@ function sanitizeLabel(label: string): string {
return label.replace(/\.+$/, "")
}
+function semanticSlugify(text: string): string {
+ return text
+ .replace(/([a-z])([A-Z])/g, "$1-$2")
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9-]/g, "")
+}
+
+function createSemanticIdFactory(prefix: string): (source: string) => string {
+ const used = new Map()
+ let unnamed = 0
+ return (source: string): string => {
+ const slug = semanticSlugify(source)
+ const base = slug.length > 0 ? slug : String(++unnamed)
+ const count = used.get(base) ?? 0
+ used.set(base, count + 1)
+ return count === 0
+ ? `${prefix}:${base}`
+ : `${prefix}:${base}-${count + 1}`
+ }
+}
+
function findDefaultAction(
acts: ActNode[]
): ActNode | undefined {
@@ -30,6 +52,7 @@ export type DomRendererOptions
target: HTMLElement
services?: TServices
showScreenName?: boolean
+ showSemanticIds?: boolean
}
export { renderRouter } from "./dom-router.js"
@@ -39,9 +62,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,7 +187,8 @@ export function renderDom(
function buildDom(
screenDef: ScreenDefinition,
- showScreenName?: boolean
+ showScreenName?: boolean,
+ showSemanticIds?: boolean
): HTMLElement {
const surface = screenDef.surfaces[0]
const main = document.createElement("main")
@@ -173,6 +197,13 @@ function buildDom(
main.id = surface.id
}
+ if (showSemanticIds) {
+ main.setAttribute("data-intent-screen", `screen:${semanticSlugify(screenDef.name)}`)
+ if (surface) {
+ main.setAttribute("data-intent-surface", `surface:${semanticSlugify(surface.name)}`)
+ }
+ }
+
if (showScreenName) {
const heading = document.createElement("h1")
heading.textContent = screenDef.name
@@ -183,19 +214,32 @@ function buildDom(
form.setAttribute("method", "POST")
form.setAttribute("novalidate", "")
+ const askSemanticIds = showSemanticIds ? createSemanticIdFactory("ask") : null
+
for (const ask of screenDef.asks) {
const container = document.createElement("div")
container.className = "ask-group"
+ const askSemanticId = askSemanticIds ? askSemanticIds(ask.label) : null
+
const label = document.createElement("label")
label.textContent = ask.label
label.htmlFor = ask.id
+
+ if (askSemanticId) {
+ label.setAttribute("data-intent-ask", askSemanticId)
+ }
+
container.appendChild(label)
const input = createInputForAsk(ask)
input.id = ask.id
input.name = ask.id
+ if (askSemanticId) {
+ input.setAttribute("data-intent-ask", askSemanticId)
+ }
+
if (ask.required) {
input.required = true
}
@@ -244,12 +288,19 @@ function buildDom(
form.appendChild(container)
}
+ const actSemanticIds = showSemanticIds ? createSemanticIdFactory("action") : null
+
for (const act of screenDef.acts) {
const button = document.createElement("button")
button.id = act.id
button.type = "button"
button.textContent = act.label
+ if (actSemanticIds) {
+ const semanticId = actSemanticIds(act.label)
+ button.setAttribute("data-intent-action", semanticId)
+ }
+
if (act.primary) {
button.className = "primary"
}