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"