From 4752d62a17403a69f5c7a435ddf64a377cc684ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:41:57 +0000 Subject: [PATCH] feat(dom): add opt-in semantic data attributes via showSemanticIds --- .changeset/forty-hotels-write.md | 2 + docs/Inspect-Screen.md | 2 +- docs/Quickstart.md | 5 +- packages/dom/README.md | 1 + packages/dom/src/dom-router.ts | 5 +- packages/dom/src/dom.test.ts | 144 +++++++++++++++++++++++++++++++ packages/dom/src/index.ts | 57 +++++++++++- 7 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 .changeset/forty-hotels-write.md 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" }