Skip to content
Closed
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
2 changes: 2 additions & 0 deletions .changeset/forty-hotels-write.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
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 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.
5 changes: 3 additions & 2 deletions docs/Quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h1>`:
The DOM renderer also accepts services and options:

```ts
renderDom(InviteMember, {
target: root,
showScreenName: true,
showScreenName: true, // renders <h1> with screen name
showSemanticIds: true, // adds data-intent-* attributes
})
```

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` for debugging and tooling

## 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
144 changes: 144 additions & 0 deletions packages/dom/src/dom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<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(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 = '<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(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 = '<div id="root"></div>'

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

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

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

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")
})
})
})
57 changes: 54 additions & 3 deletions packages/dom/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>()
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<TServices extends object = DefaultScreenServices>(
acts: ActNode<TServices>[]
): ActNode<TServices> | undefined {
Expand All @@ -30,6 +52,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 +62,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,7 +187,8 @@ export function renderDom<TServices extends object = DefaultScreenServices>(

function buildDom<TServices extends object = DefaultScreenServices>(
screenDef: ScreenDefinition<TServices>,
showScreenName?: boolean
showScreenName?: boolean,
showSemanticIds?: boolean
): HTMLElement {
const surface = screenDef.surfaces[0]
const main = document.createElement("main")
Expand All @@ -173,6 +197,13 @@ function buildDom<TServices extends object = DefaultScreenServices>(
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
Expand All @@ -183,19 +214,32 @@ function buildDom<TServices extends object = DefaultScreenServices>(
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
}
Expand Down Expand Up @@ -244,12 +288,19 @@ function buildDom<TServices extends object = DefaultScreenServices>(
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"
}
Expand Down