Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/goofy-oranges-wish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@intent-framework/dom": patch
---

Add opt-in `showSemanticIds` rendering support for semantic `data-intent-*` attributes.
2 changes: 1 addition & 1 deletion docs/Inspect-Screen.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 5 additions & 2 deletions docs/Quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>`:
The DOM renderer also accepts services and options:

```ts
renderDom(InviteMember, {
target: root,
showScreenName: true,
showScreenName: true, // show screen name as <h1>
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:
Expand Down
1 change: 1 addition & 0 deletions packages/dom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion packages/dom/src/dom-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type RenderRouterOptions<TServices extends object = DefaultScreenServices
notFound?: ScreenDefinition<TServices> | ((pathname: string) => ScreenDefinition<TServices>)
services?: Omit<TServices, "navigate" | "route">
showScreenName?: boolean
showSemanticIds?: boolean
}

export function renderRouter<
Expand All @@ -27,7 +28,7 @@ export function renderRouter<
router: Router<Routes, TServices>,
options: RenderRouterOptions<TServices>,
): RouterDomHandle<Routes> {
const { showScreenName } = options
const { showScreenName, showSemanticIds } = options
const win = options.window ?? window
let currentCleanup: (() => void) | undefined

Expand Down Expand Up @@ -64,6 +65,7 @@ export function renderRouter<
target: options.target,
services: mergedServices,
showScreenName,
showSemanticIds,
})
return
}
Expand All @@ -76,6 +78,7 @@ export function renderRouter<
target: options.target,
services: mergedServices,
showScreenName,
showSemanticIds,
})
} else {
options.target.textContent = "Not found"
Expand Down
110 changes: 110 additions & 0 deletions packages/dom/src/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1384,6 +1384,116 @@ describe("DOM renderer", () => {
})
})

describe("showSemanticIds data attributes", () => {
it("does not add data-intent-* attributes by default", () => {
document.body.innerHTML = '<div id="root"></div>'

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 = '<div id="root"></div>'

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 = '<div id="root"></div>'

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 = '<div id="root"></div>'

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 = '<div id="root"></div>'
Expand Down
44 changes: 39 additions & 5 deletions packages/dom/src/index.ts
Original file line number Diff line number Diff line change
@@ -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`
Expand Down Expand Up @@ -30,6 +30,7 @@ export type DomRendererOptions<TServices extends object = DefaultScreenServices>
target: HTMLElement
services?: TServices
showScreenName?: boolean
showSemanticIds?: boolean
}

export { renderRouter } from "./dom-router.js"
Expand All @@ -39,9 +40,9 @@ export function renderDom<TServices extends object = DefaultScreenServices>(
screenDef: ScreenDefinition<TServices>,
options: DomRendererOptions<TServices>
): () => 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<TServices>(screenDef, { services })
Expand Down Expand Up @@ -164,15 +165,30 @@ export function renderDom<TServices extends object = DefaultScreenServices>(

function buildDom<TServices extends object = DefaultScreenServices>(
screenDef: ScreenDefinition<TServices>,
showScreenName?: boolean
showScreenName?: boolean,
showSemanticIds?: boolean
): HTMLElement {
let inspected: InspectedScreen | undefined
let askSemanticIds: Map<string, string> | undefined
let actSemanticIds: Map<string, string> | 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")

if (surface) {
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
Expand All @@ -190,11 +206,23 @@ function buildDom<TServices extends object = DefaultScreenServices>(
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
Expand Down Expand Up @@ -249,6 +277,12 @@ function buildDom<TServices extends object = DefaultScreenServices>(
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"
Expand Down