From 34168abf0d254b43b4b390f54462dcb0ef69359e Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:52:45 +0700 Subject: [PATCH 1/2] feat: add accessibility annotations to hotspots Allow users to annotate hotspots with accessibility metadata (label, role, hint, traits) so generated instructions include VoiceOver/TalkBack implementation guidance. Bumps file format to v12. - Add collapsible Accessibility section to HotspotModal with label, role select, hint, and trait toggle pills - Add platform-specific accessibility API mappings for SwiftUI, React Native, Flutter, and Jetpack Compose - Render Accessibility table in screens.md and guidance in build-guide.md - Add accessibility field to MCP server create/update hotspot tools - Backfill accessibility: null for older .drawd files - Save/restore accessibility data with hotspot presets - Add 18 new tests covering rendering, migration, and integration --- mcp-server/src/state.js | 3 +- mcp-server/src/tools/hotspot-tools.js | 42 +++++ src/components/HotspotModal.jsx | 132 ++++++++++++++- src/constants.js | 13 +- src/pages/docs/userGuide.md | 14 ++ src/utils/buildPayload.test.js | 4 +- src/utils/generateInstructionFiles.js | 9 +- src/utils/generateInstructionFiles.test.js | 49 ++++++ src/utils/importFlow.js | 1 + src/utils/importFlow.test.js | 44 ++++- src/utils/instructionRenderers.js | 180 +++++++++++++++++++++ src/utils/instructionRenderers.test.js | 104 ++++++++++++ 12 files changed, 587 insertions(+), 8 deletions(-) diff --git a/mcp-server/src/state.js b/mcp-server/src/state.js index abadc65..9ac2326 100644 --- a/mcp-server/src/state.js +++ b/mcp-server/src/state.js @@ -243,6 +243,7 @@ export class FlowState { validation: hotspot.validation || null, transitionType: hotspot.transitionType || null, transitionLabel: hotspot.transitionLabel || "", + accessibility: hotspot.accessibility || null, }; if (!screen.hotspots) screen.hotspots = []; @@ -304,7 +305,7 @@ export class FlowState { "requestSchema", "responseSchema", "customDescription", "documentId", "conditions", "onSuccessAction", "onSuccessTargetId", "onSuccessCustomDesc", "onErrorAction", "onErrorTargetId", "onErrorCustomDesc", - "tbd", "tbdNote", "validation", "transitionType", "transitionLabel", + "tbd", "tbdNote", "validation", "transitionType", "transitionLabel", "accessibility", ]; for (const key of allowed) { if (updates[key] !== undefined) { diff --git a/mcp-server/src/tools/hotspot-tools.js b/mcp-server/src/tools/hotspot-tools.js index 709447a..b4a54e4 100644 --- a/mcp-server/src/tools/hotspot-tools.js +++ b/mcp-server/src/tools/hotspot-tools.js @@ -43,6 +43,27 @@ export const hotspotTools = [ onErrorAction: { type: "string", enum: ["navigate", "back", "modal", "custom", ""] }, onErrorTargetId: { type: "string" }, onErrorCustomDesc: { type: "string" }, + accessibility: { + type: "object", + properties: { + label: { type: "string", description: "Accessibility label (VoiceOver/TalkBack)" }, + role: { + type: "string", + enum: ["button", "link", "image", "heading", "text", "search-field", + "toggle", "slider", "tab", "alert", "menu", "other"], + }, + hint: { type: "string", description: "Usage hint (e.g. 'Double tap to sign in')" }, + traits: { + type: "array", + items: { + type: "string", + enum: ["selected", "disabled", "adjustable", "header", "summary", + "plays-sound", "starts-media", "allows-direct-interaction"], + }, + }, + }, + description: "Accessibility annotations for screen readers", + }, }, required: ["screenId", "label", "x", "y", "w", "h", "action"], }, @@ -66,6 +87,27 @@ export const hotspotTools = [ apiEndpoint: { type: "string" }, apiMethod: { type: "string" }, customDescription: { type: "string" }, + accessibility: { + type: "object", + properties: { + label: { type: "string", description: "Accessibility label (VoiceOver/TalkBack)" }, + role: { + type: "string", + enum: ["button", "link", "image", "heading", "text", "search-field", + "toggle", "slider", "tab", "alert", "menu", "other"], + }, + hint: { type: "string", description: "Usage hint (e.g. 'Double tap to sign in')" }, + traits: { + type: "array", + items: { + type: "string", + enum: ["selected", "disabled", "adjustable", "header", "summary", + "plays-sound", "starts-media", "allows-direct-interaction"], + }, + }, + }, + description: "Accessibility annotations for screen readers", + }, }, required: ["screenId", "hotspotId"], }, diff --git a/src/components/HotspotModal.jsx b/src/components/HotspotModal.jsx index 492f878..62586a1 100644 --- a/src/components/HotspotModal.jsx +++ b/src/components/HotspotModal.jsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { COLORS, FONTS, styles } from "../styles/theme"; import { generateId } from "../utils/generateId"; import { DataFlowEditor } from "./DataFlowEditor"; -import { TRANSITION_TYPES } from "../constants"; +import { TRANSITION_TYPES, ACCESSIBILITY_ROLES, ACCESSIBILITY_TRAITS } from "../constants"; function FollowUpSection({ title, titleColor, action, setAction, targetId, setTargetId, customDesc, setCustomDesc, otherScreens, dataFlow, onDataFlowChange }) { @@ -143,6 +143,13 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents = const [validationPattern, setValidationPattern] = useState(hotspot?.validation?.pattern || ""); const [validationErrorMessage, setValidationErrorMessage] = useState(hotspot?.validation?.errorMessage || ""); + // Accessibility annotations + const [a11yExpanded, setA11yExpanded] = useState(!!(hotspot?.accessibility)); + const [a11yLabel, setA11yLabel] = useState(hotspot?.accessibility?.label || ""); + const [a11yRole, setA11yRole] = useState(hotspot?.accessibility?.role || ""); + const [a11yHint, setA11yHint] = useState(hotspot?.accessibility?.hint || ""); + const [a11yTraits, setA11yTraits] = useState(hotspot?.accessibility?.traits || []); + useEffect(() => { const handleKeyDown = (e) => { if (e.key === "Escape") onClose(); @@ -200,6 +207,13 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents = setW(preset.w ?? w); setH(preset.h ?? h); if (preset.customDescription) setCustomDescription(preset.customDescription); + if (preset.accessibility) { + setA11yExpanded(true); + setA11yLabel(preset.accessibility.label || ""); + setA11yRole(preset.accessibility.role || ""); + setA11yHint(preset.accessibility.hint || ""); + setA11yTraits(preset.accessibility.traits || []); + } }} style={{ ...styles.select, flex: 1, marginTop: 0 }} > @@ -222,6 +236,7 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents = w, h, customDescription, + accessibility: a11yExpanded ? { label: a11yLabel, role: a11yRole, hint: a11yHint, traits: a11yTraits } : null, }; const updated = [...presets, preset]; setPresets(updated); @@ -307,6 +322,12 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents = pattern: validationPattern, errorMessage: validationErrorMessage, } : null, + accessibility: a11yExpanded ? { + label: a11yLabel, + role: a11yRole, + hint: a11yHint, + traits: a11yTraits, + } : null, }); }}>
@@ -738,6 +759,115 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents =
)} + {/* Accessibility annotations */} +
+ + {a11yExpanded && ( +
+ + + +
+ + TRAITS + +
+ {ACCESSIBILITY_TRAITS.map((trait) => { + const isActive = a11yTraits.includes(trait); + return ( + + ); + })} +
+
+
+ )} +
+ {/* Transition type — only visible when editing a connection-backed hotspot */} {connection && (action === "navigate" || action === "back" || action === "modal" || action === "api") && ( <> diff --git a/src/constants.js b/src/constants.js index 361d271..7ed454e 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,7 +5,7 @@ export const GITHUB_URL = "https://github.com/codeflow-studio/drawd"; export const DOMAIN = "drawd.app"; // ── File Format ────────────────────────────── -export const FILE_VERSION = 11; +export const FILE_VERSION = 12; export const FILE_EXTENSION = ".drawd"; export const LEGACY_FILE_EXTENSION = ".flowforge"; export const DEFAULT_EXPORT_FILENAME = "flow-export"; @@ -89,3 +89,14 @@ export const TRANSITION_TYPES = [ export const TRANSITION_LABELS = Object.fromEntries( TRANSITION_TYPES.map(t => [t.value, t.label]) ); + +// ── Accessibility ─────────────────────────── +export const ACCESSIBILITY_ROLES = [ + "button", "link", "image", "heading", "text", "search-field", + "toggle", "slider", "tab", "alert", "menu", "other", +]; + +export const ACCESSIBILITY_TRAITS = [ + "selected", "disabled", "adjustable", "header", "summary", + "plays-sound", "starts-media", "allows-direct-interaction", +]; diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md index d57b465..8b4d3d4 100644 --- a/src/pages/docs/userGuide.md +++ b/src/pages/docs/userGuide.md @@ -145,6 +145,20 @@ When a screen has text-input hotspots, a yellow `Form` button appears in the scr > [!TIP] > Use the Form Summary to audit your form screens before generating instructions. Missing validations are easy to overlook in individual hotspot modals. +### Accessibility annotations + +Every hotspot has an optional Accessibility section. Expand it to define how screen readers should announce the element: + +- **Label** -- What VoiceOver or TalkBack reads aloud (e.g. "Sign in button") +- **Role** -- The semantic role: button, link, image, heading, text, search-field, toggle, slider, tab, alert, menu, or other +- **Hint** -- A usage hint describing the result of activating the element (e.g. "Double tap to sign in") +- **Traits** -- Additional traits: selected, disabled, adjustable, header, summary, plays-sound, starts-media, allows-direct-interaction + +When you generate instructions, screens with accessibility annotations include an **Accessibility** table listing each annotated element. If a specific platform is selected (SwiftUI, React Native, Flutter, Jetpack Compose), the build guide includes platform-specific implementation patterns for accessibility modifiers. + +> [!TIP] +> Accessibility annotations are saved with presets, so you can reuse common patterns across hotspots. + ## Connecting Screens Connections (navigation links) show how a user moves from one screen to another. They appear as curved arrows on the canvas between screen cards. diff --git a/src/utils/buildPayload.test.js b/src/utils/buildPayload.test.js index 49530e7..0f16947 100644 --- a/src/utils/buildPayload.test.js +++ b/src/utils/buildPayload.test.js @@ -6,9 +6,9 @@ describe("buildPayload", () => { const connections = [{ id: "c1" }]; const documents = [{ id: "d1" }, { id: "d2" }, { id: "d3" }]; - it("sets version to 11", () => { + it("sets version to 12", () => { const payload = buildPayload([], [], { x: 0, y: 0 }, 1); - expect(payload.version).toBe(11); + expect(payload.version).toBe(12); }); it("sets metadata.screenCount to screens.length", () => { diff --git a/src/utils/generateInstructionFiles.js b/src/utils/generateInstructionFiles.js index 8ec3a25..e90c57b 100644 --- a/src/utils/generateInstructionFiles.js +++ b/src/utils/generateInstructionFiles.js @@ -1,5 +1,5 @@ import { analyzeNavGraph } from "./analyzeNavGraph.js"; -import { PLATFORM_TERMINOLOGY, renderHotspotDetailBlock, renderBuildGuideActionTable, renderBuildGuideTransitionTable } from "./instructionRenderers.js"; +import { PLATFORM_TERMINOLOGY, renderHotspotDetailBlock, renderBuildGuideActionTable, renderBuildGuideTransitionTable, renderAccessibilityBlock, renderAccessibilityGuidance } from "./instructionRenderers.js"; import { screenReqId, connectionReqId } from "./generateReqIds.js"; import { TRANSITION_LABELS } from "../constants.js"; @@ -501,6 +501,10 @@ function generateScreenDetailMd(s, screens, connections, images, documents = []) md += `> **Note:** ${missing.length} field(s) marked with \u26A0\uFE0F have no validation rules configured.\n\n`; } } + + // Accessibility subsection + const a11yBlock = renderAccessibilityBlock(s.hotspots, "auto"); + if (a11yBlock) md += a11yBlock; } else { md += `*No interactive elements defined*\n\n`; } @@ -771,6 +775,9 @@ function generateBuildGuideMd(screens, connections, options, screenGroups = []) const transitionTable = renderBuildGuideTransitionTable(platform); if (transitionTable) md += transitionTable; + const a11yGuide = renderAccessibilityGuidance(platform); + if (a11yGuide) md += a11yGuide; + md += `### Steps\n\n`; md += `1. Implement each screen from screens.md as a separate ${pt.name} view/component\n`; md += `2. For EACH screen, open its reference image from the \`images/\` folder and replicate the visual design exactly — colors, typography, spacing, layout, and component hierarchy\n`; diff --git a/src/utils/generateInstructionFiles.test.js b/src/utils/generateInstructionFiles.test.js index 03d4b58..c5ec8ff 100644 --- a/src/utils/generateInstructionFiles.test.js +++ b/src/utils/generateInstructionFiles.test.js @@ -218,4 +218,53 @@ describe("generateInstructionFiles", () => { expect(buildGuide.content).toContain("fullScreenCover"); expect(buildGuide.content).toContain("slideUp"); }); + + it("includes Accessibility section in screens.md when hotspots have accessibility data", () => { + const screenWithA11y = { + ...minimalScreen, + hotspots: [{ + id: "h1", + label: "Login", + elementType: "button", + interactionType: "tap", + action: "navigate", + x: 10, y: 10, w: 80, h: 15, + accessibility: { label: "Sign in", role: "button", hint: "Double tap to sign in", traits: [] }, + }], + }; + const result = generateInstructionFiles([screenWithA11y], [], defaultOptions); + const screensFile = result.files.find((f) => f.name === "screens.md"); + expect(screensFile.content).toContain("#### Accessibility"); + expect(screensFile.content).toContain("Sign in"); + }); + + it("does not include Accessibility section when no hotspots have accessibility data", () => { + const screenNoA11y = { + ...minimalScreen, + hotspots: [{ + id: "h1", + label: "Button", + elementType: "button", + interactionType: "tap", + action: "navigate", + x: 10, y: 10, w: 80, h: 15, + }], + }; + const result = generateInstructionFiles([screenNoA11y], [], defaultOptions); + const screensFile = result.files.find((f) => f.name === "screens.md"); + expect(screensFile.content).not.toContain("#### Accessibility"); + }); + + it("includes Accessibility guidance in build-guide.md for platform-specific output", () => { + const result = generateInstructionFiles([minimalScreen], [], { platform: "swiftui" }); + const buildGuide = result.files.find((f) => f.name === "build-guide.md"); + expect(buildGuide.content).toContain("### Accessibility"); + expect(buildGuide.content).toContain(".accessibilityLabel"); + }); + + it("does not include platform Accessibility guidance in build-guide.md for auto platform", () => { + const result = generateInstructionFiles([minimalScreen], [], { platform: "auto" }); + const buildGuide = result.files.find((f) => f.name === "build-guide.md"); + expect(buildGuide.content).not.toContain(".accessibilityLabel"); + }); }); diff --git a/src/utils/importFlow.js b/src/utils/importFlow.js index e6f7a4f..91b6318 100644 --- a/src/utils/importFlow.js +++ b/src/utils/importFlow.js @@ -66,6 +66,7 @@ export function importFlow(fileText) { if (!Array.isArray(hs.dataFlow)) hs.dataFlow = []; if (!Array.isArray(hs.onSuccessDataFlow)) hs.onSuccessDataFlow = []; if (!Array.isArray(hs.onErrorDataFlow)) hs.onErrorDataFlow = []; + if (hs.accessibility === undefined) hs.accessibility = null; // v4 -> v5 migration: promote inline apiDocs to a document if (data.version < 5) { diff --git a/src/utils/importFlow.test.js b/src/utils/importFlow.test.js index 71047ef..6ba4cf9 100644 --- a/src/utils/importFlow.test.js +++ b/src/utils/importFlow.test.js @@ -24,9 +24,9 @@ describe("importFlow", () => { ); }); - it("throws for future version > 11", () => { + it("throws for future version > 12", () => { expect(() => - importFlow(JSON.stringify({ version: 12, screens: [], connections: [] })) + importFlow(JSON.stringify({ version: 13, screens: [], connections: [] })) ).toThrow("Unsupported file version"); }); @@ -102,6 +102,46 @@ describe("importFlow", () => { expect(hs.onErrorAction).toBe(""); }); + it("backfills accessibility to null for older hotspots", () => { + const file = makeValidFile({ + screens: [ + { + id: "s1", + name: "Home", + hotspots: [{ id: "h1", label: "Tap", action: "navigate" }], + }, + ], + }); + const result = importFlow(file); + const hs = result.screens[0].hotspots[0]; + expect(hs.accessibility).toBeNull(); + }); + + it("preserves existing accessibility data on hotspots", () => { + const file = makeValidFile({ + screens: [ + { + id: "s1", + name: "Home", + hotspots: [{ + id: "h1", + label: "Login", + action: "navigate", + accessibility: { label: "Sign in", role: "button", hint: "Double tap to sign in", traits: ["selected"] }, + }], + }, + ], + }); + const result = importFlow(file); + const hs = result.screens[0].hotspots[0]; + expect(hs.accessibility).toEqual({ + label: "Sign in", + role: "button", + hint: "Double tap to sign in", + traits: ["selected"], + }); + }); + // --- v4 -> v5 migration --- it("promotes inline apiDocs to documents array and sets documentId on hotspot", () => { diff --git a/src/utils/instructionRenderers.js b/src/utils/instructionRenderers.js index bd0724d..0a7e4f2 100644 --- a/src/utils/instructionRenderers.js +++ b/src/utils/instructionRenderers.js @@ -95,6 +95,127 @@ export const PLATFORM_TERMINOLOGY = { }, }; +// Platform-specific accessibility API mappings. +// Each platform defines how to apply label, hint, role, and trait annotations. +export const ACCESSIBILITY_PLATFORM_MAP = { + swiftui: { + name: "SwiftUI", + label: `.accessibilityLabel("text")`, + hint: `.accessibilityHint("text")`, + roles: { + button: `.accessibilityAddTraits(.isButton)`, + link: `.accessibilityAddTraits(.isLink)`, + image: `.accessibilityAddTraits(.isImage)`, + heading: `.accessibilityAddTraits(.isHeader)`, + text: `.accessibilityAddTraits(.isStaticText)`, + "search-field": `.accessibilityAddTraits(.isSearchField)`, + toggle: `.accessibilityAddTraits(.isToggle)`, + slider: `Slider().accessibilityLabel("text")`, + tab: `.accessibilityAddTraits(.isTabBar)`, + alert: `alert modifier with accessibilityLabel`, + menu: `.accessibilityAddTraits(.isButton)` + ` with Menu`, + other: `.accessibilityAddTraits(...)`, + }, + traits: { + selected: `.accessibilityAddTraits(.isSelected)`, + disabled: `.disabled(true)`, + adjustable: `.accessibilityAdjustableAction { ... }`, + header: `.accessibilityAddTraits(.isHeader)`, + summary: `.accessibilityAddTraits(.isSummaryElement)`, + "plays-sound": `.accessibilityAddTraits(.playsSound)`, + "starts-media": `.accessibilityAddTraits(.startsMediaSession)`, + "allows-direct-interaction": `.accessibilityAddTraits(.allowsDirectInteraction)`, + }, + }, + "react-native": { + name: "React Native", + label: `accessibilityLabel="text"`, + hint: `accessibilityHint="text"`, + roles: { + button: `accessibilityRole="button"`, + link: `accessibilityRole="link"`, + image: `accessibilityRole="image"`, + heading: `accessibilityRole="header"`, + text: `accessibilityRole="text"`, + "search-field": `accessibilityRole="search"`, + toggle: `accessibilityRole="switch"`, + slider: `accessibilityRole="adjustable"`, + tab: `accessibilityRole="tab"`, + alert: `accessibilityRole="alert"`, + menu: `accessibilityRole="menu"`, + other: `accessibilityRole="none"`, + }, + traits: { + selected: `accessibilityState={{ selected: true }}`, + disabled: `accessibilityState={{ disabled: true }}`, + adjustable: `accessibilityRole="adjustable"`, + header: `accessibilityRole="header"`, + summary: `accessibilityRole="summary"`, + "plays-sound": `accessibilityHint (describe sound)`, + "starts-media": `accessibilityHint (describe media)`, + "allows-direct-interaction": `accessible={false} on inner touch`, + }, + }, + flutter: { + name: "Flutter", + label: `Semantics(label: "text")`, + hint: `Semantics(hint: "text")`, + roles: { + button: `Semantics(button: true)`, + link: `Semantics(link: true)`, + image: `Semantics(image: true)`, + heading: `Semantics(header: true)`, + text: `Semantics(label: "text")`, + "search-field": `Semantics(textField: true)`, + toggle: `Semantics(toggled: value)`, + slider: `Semantics(slider: true)`, + tab: `Semantics(label: "tab name", selected: isSelected)`, + alert: `Semantics(liveRegion: true)`, + menu: `Semantics(button: true, label: "menu")`, + other: `Semantics(label: "description")`, + }, + traits: { + selected: `Semantics(selected: true)`, + disabled: `ExcludeSemantics or Semantics(enabled: false)`, + adjustable: `Semantics(onIncrease: ..., onDecrease: ...)`, + header: `Semantics(header: true)`, + summary: `Semantics(label: "summary text")`, + "plays-sound": `Semantics(hint: "plays sound")`, + "starts-media": `Semantics(hint: "starts media")`, + "allows-direct-interaction": `Semantics(scopesRoute: false)`, + }, + }, + "jetpack-compose": { + name: "Jetpack Compose", + label: `Modifier.semantics { contentDescription = "text" }`, + hint: `Modifier.semantics { stateDescription = "text" }`, + roles: { + button: `Modifier.semantics { role = Role.Button }`, + link: `Modifier.semantics { role = Role.Button } (with URL action)`, + image: `Modifier.semantics { role = Role.Image }`, + heading: `Modifier.semantics { heading() }`, + text: `Text("...") (inherits semantics)`, + "search-field": `Modifier.semantics { role = Role.Button } with search label`, + toggle: `Modifier.semantics { role = Role.Switch }`, + slider: `Modifier.semantics { role = Role.Range }`, + tab: `Modifier.semantics { role = Role.Tab }`, + alert: `LiveData with Modifier.semantics { liveRegion = LiveRegionMode.Polite }`, + menu: `Modifier.semantics { role = Role.DropdownList }`, + other: `Modifier.semantics { contentDescription = "..." }`, + }, + traits: { + selected: `Modifier.semantics { selected = true }`, + disabled: `Modifier.semantics { disabled() }`, + adjustable: `Modifier.semantics { setProgress(action = { ... }) }`, + header: `Modifier.semantics { heading() }`, + summary: `Modifier.semantics { contentDescription = "summary" }`, + "plays-sound": `Modifier.semantics { stateDescription = "plays sound" }`, + "starts-media": `Modifier.semantics { stateDescription = "starts media" }`, + "allows-direct-interaction": `Modifier.semantics { clearAndSetSemantics { } }`, + }, + }, +}; + // Registry of hotspot action renderers. // Each entry owns its own markdown rendering logic for the detail block and build guide row. // To add a new action type: add one entry here — no other file needs to change. @@ -237,3 +358,62 @@ export function renderBuildGuideActionTable(platform) { return md; } + +// Render an #### Accessibility subsection for hotspots that have accessibility annotations. +// Returns null when no hotspot has accessibility data. +export function renderAccessibilityBlock(hotspots, _platform) { + const a11yHotspots = (hotspots || []).filter((h) => h.accessibility); + if (a11yHotspots.length === 0) return null; + + let md = `#### Accessibility\n\n`; + md += `| Element | A11y Label | Role | Hint | Traits |\n`; + md += `|---------|-----------|------|------|--------|\n`; + + for (const h of a11yHotspots) { + const a = h.accessibility; + const name = h.label || "Unnamed"; + const a11yLabel = a.label || "\u2014"; + const role = a.role || "\u2014"; + const hint = a.hint || "\u2014"; + const traits = a.traits?.length > 0 ? a.traits.join(", ") : "\u2014"; + md += `| ${name} | ${a11yLabel} | ${role} | ${hint} | ${traits} |\n`; + } + md += `\n`; + + return md; +} + +// Generate ### Accessibility guidance table for a specific platform's build guide. +// Returns null when platform is "auto" or unknown. +export function renderAccessibilityGuidance(platform) { + const pm = ACCESSIBILITY_PLATFORM_MAP[platform]; + if (!pm) return null; + + let md = `### Accessibility\n\n`; + md += `Apply accessibility annotations to every interactive element. Each hotspot's accessibility data from \`screens.md\` maps to ${pm.name} APIs:\n\n`; + md += `| Property | Implementation |\n`; + md += `|----------|---------------|\n`; + md += `| **Label** | \`${pm.label}\` |\n`; + md += `| **Hint** | \`${pm.hint}\` |\n`; + + // Show a few representative role mappings + const roleExamples = ["button", "link", "image", "heading"]; + for (const r of roleExamples) { + if (pm.roles[r]) { + md += `| **Role: ${r}** | \`${pm.roles[r]}\` |\n`; + } + } + + // Show a few representative trait mappings + const traitExamples = ["selected", "disabled", "header"]; + for (const t of traitExamples) { + if (pm.traits[t]) { + md += `| **Trait: ${t}** | \`${pm.traits[t]}\` |\n`; + } + } + + md += `\n`; + md += `> For the full role and trait mapping, refer to the [${pm.name} accessibility documentation](https://developer.apple.com/accessibility/).\n\n`; + + return md; +} diff --git a/src/utils/instructionRenderers.test.js b/src/utils/instructionRenderers.test.js index 04b63d6..53c2dfb 100644 --- a/src/utils/instructionRenderers.test.js +++ b/src/utils/instructionRenderers.test.js @@ -3,6 +3,8 @@ import { renderHotspotDetailBlock, renderBuildGuideActionTable, renderBuildGuideTransitionTable, + renderAccessibilityBlock, + renderAccessibilityGuidance, } from "./instructionRenderers.js"; const makeScreen = (id, name) => ({ id, name }); @@ -205,3 +207,105 @@ describe("renderBuildGuideTransitionTable", () => { expect(result).toContain("**custom**"); }); }); + +describe("renderAccessibilityBlock", () => { + it("returns null when no hotspots have accessibility data", () => { + const hotspots = [ + { label: "Button", action: "navigate", accessibility: null }, + { label: "Link", action: "navigate" }, + ]; + expect(renderAccessibilityBlock(hotspots, "auto")).toBeNull(); + }); + + it("returns null for an empty hotspots array", () => { + expect(renderAccessibilityBlock([], "auto")).toBeNull(); + }); + + it("returns a markdown table with correct heading and columns", () => { + const hotspots = [{ + label: "Login", + action: "navigate", + accessibility: { label: "Sign in", role: "button", hint: "Double tap to sign in", traits: [] }, + }]; + const result = renderAccessibilityBlock(hotspots, "auto"); + expect(result).toContain("#### Accessibility"); + expect(result).toContain("Element"); + expect(result).toContain("A11y Label"); + expect(result).toContain("Role"); + expect(result).toContain("Hint"); + expect(result).toContain("Traits"); + }); + + it("renders hotspot label, a11y label, role, hint, and traits in the table", () => { + const hotspots = [{ + label: "Submit", + action: "api", + accessibility: { label: "Submit form", role: "button", hint: "Sends data", traits: ["selected", "disabled"] }, + }]; + const result = renderAccessibilityBlock(hotspots, "auto"); + expect(result).toContain("Submit"); + expect(result).toContain("Submit form"); + expect(result).toContain("button"); + expect(result).toContain("Sends data"); + expect(result).toContain("selected"); + expect(result).toContain("disabled"); + }); + + it("uses em dash for missing optional fields", () => { + const hotspots = [{ + label: "Icon", + action: "navigate", + accessibility: { label: "", role: "", hint: "", traits: [] }, + }]; + const result = renderAccessibilityBlock(hotspots, "auto"); + expect(result).toContain("\u2014"); + }); + + it("only includes hotspots that have accessibility data (skips null)", () => { + const hotspots = [ + { label: "A", action: "navigate", accessibility: { label: "Accessible A", role: "button", hint: "", traits: [] } }, + { label: "B", action: "navigate", accessibility: null }, + { label: "C", action: "navigate" }, + ]; + const result = renderAccessibilityBlock(hotspots, "auto"); + expect(result).toContain("Accessible A"); + expect(result).not.toContain("| B |"); + expect(result).not.toContain("| C |"); + }); +}); + +describe("renderAccessibilityGuidance", () => { + it("returns null for platform 'auto'", () => { + expect(renderAccessibilityGuidance("auto")).toBeNull(); + }); + + it("returns null for unknown platform", () => { + expect(renderAccessibilityGuidance("cobol")).toBeNull(); + }); + + it("returns accessibility guidance for swiftui", () => { + const result = renderAccessibilityGuidance("swiftui"); + expect(result).toContain("### Accessibility"); + expect(result).toContain(".accessibilityLabel"); + expect(result).toContain(".accessibilityHint"); + }); + + it("returns accessibility guidance for react-native", () => { + const result = renderAccessibilityGuidance("react-native"); + expect(result).toContain("### Accessibility"); + expect(result).toContain("accessibilityLabel"); + expect(result).toContain("accessibilityRole"); + }); + + it("returns accessibility guidance for flutter", () => { + const result = renderAccessibilityGuidance("flutter"); + expect(result).toContain("### Accessibility"); + expect(result).toContain("Semantics"); + }); + + it("returns accessibility guidance for jetpack-compose", () => { + const result = renderAccessibilityGuidance("jetpack-compose"); + expect(result).toContain("### Accessibility"); + expect(result).toContain("semantics"); + }); +}); From 135674572edac6d95c44c23410e48dd5410887a4 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:58:14 +0700 Subject: [PATCH 2/2] refactor: simplify accessibility code after review - Extract shared accessibilitySchema constant in hotspot-tools.js (was duplicated verbatim in create and update tool definitions) - Fix stale closure bug in trait toggle onClick (use functional updater so rapid clicks don't lose state) - Add per-platform docsUrl to ACCESSIBILITY_PLATFORM_MAP (was hardcoded to Apple docs URL for all platforms) - Remove unused _platform parameter from renderAccessibilityBlock --- mcp-server/src/tools/hotspot-tools.js | 66 ++++++++++---------------- src/components/HotspotModal.jsx | 7 +-- src/utils/generateInstructionFiles.js | 2 +- src/utils/instructionRenderers.js | 8 +++- src/utils/instructionRenderers.test.js | 10 ++-- 5 files changed, 40 insertions(+), 53 deletions(-) diff --git a/mcp-server/src/tools/hotspot-tools.js b/mcp-server/src/tools/hotspot-tools.js index b4a54e4..d5dfa29 100644 --- a/mcp-server/src/tools/hotspot-tools.js +++ b/mcp-server/src/tools/hotspot-tools.js @@ -1,3 +1,25 @@ +const accessibilitySchema = { + type: "object", + properties: { + label: { type: "string", description: "Accessibility label (VoiceOver/TalkBack)" }, + role: { + type: "string", + enum: ["button", "link", "image", "heading", "text", "search-field", + "toggle", "slider", "tab", "alert", "menu", "other"], + }, + hint: { type: "string", description: "Usage hint (e.g. 'Double tap to sign in')" }, + traits: { + type: "array", + items: { + type: "string", + enum: ["selected", "disabled", "adjustable", "header", "summary", + "plays-sound", "starts-media", "allows-direct-interaction"], + }, + }, + }, + description: "Accessibility annotations for screen readers", +}; + export const hotspotTools = [ { name: "create_hotspot", @@ -43,27 +65,7 @@ export const hotspotTools = [ onErrorAction: { type: "string", enum: ["navigate", "back", "modal", "custom", ""] }, onErrorTargetId: { type: "string" }, onErrorCustomDesc: { type: "string" }, - accessibility: { - type: "object", - properties: { - label: { type: "string", description: "Accessibility label (VoiceOver/TalkBack)" }, - role: { - type: "string", - enum: ["button", "link", "image", "heading", "text", "search-field", - "toggle", "slider", "tab", "alert", "menu", "other"], - }, - hint: { type: "string", description: "Usage hint (e.g. 'Double tap to sign in')" }, - traits: { - type: "array", - items: { - type: "string", - enum: ["selected", "disabled", "adjustable", "header", "summary", - "plays-sound", "starts-media", "allows-direct-interaction"], - }, - }, - }, - description: "Accessibility annotations for screen readers", - }, + accessibility: accessibilitySchema, }, required: ["screenId", "label", "x", "y", "w", "h", "action"], }, @@ -87,27 +89,7 @@ export const hotspotTools = [ apiEndpoint: { type: "string" }, apiMethod: { type: "string" }, customDescription: { type: "string" }, - accessibility: { - type: "object", - properties: { - label: { type: "string", description: "Accessibility label (VoiceOver/TalkBack)" }, - role: { - type: "string", - enum: ["button", "link", "image", "heading", "text", "search-field", - "toggle", "slider", "tab", "alert", "menu", "other"], - }, - hint: { type: "string", description: "Usage hint (e.g. 'Double tap to sign in')" }, - traits: { - type: "array", - items: { - type: "string", - enum: ["selected", "disabled", "adjustable", "header", "summary", - "plays-sound", "starts-media", "allows-direct-interaction"], - }, - }, - }, - description: "Accessibility annotations for screen readers", - }, + accessibility: accessibilitySchema, }, required: ["screenId", "hotspotId"], }, diff --git a/src/components/HotspotModal.jsx b/src/components/HotspotModal.jsx index 62586a1..7760e52 100644 --- a/src/components/HotspotModal.jsx +++ b/src/components/HotspotModal.jsx @@ -841,9 +841,10 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents = key={trait} type="button" onClick={() => { - setA11yTraits(isActive - ? a11yTraits.filter((t) => t !== trait) - : [...a11yTraits, trait] + setA11yTraits((prev) => + prev.includes(trait) + ? prev.filter((t) => t !== trait) + : [...prev, trait] ); }} style={{ diff --git a/src/utils/generateInstructionFiles.js b/src/utils/generateInstructionFiles.js index e90c57b..81bf373 100644 --- a/src/utils/generateInstructionFiles.js +++ b/src/utils/generateInstructionFiles.js @@ -503,7 +503,7 @@ function generateScreenDetailMd(s, screens, connections, images, documents = []) } // Accessibility subsection - const a11yBlock = renderAccessibilityBlock(s.hotspots, "auto"); + const a11yBlock = renderAccessibilityBlock(s.hotspots); if (a11yBlock) md += a11yBlock; } else { md += `*No interactive elements defined*\n\n`; diff --git a/src/utils/instructionRenderers.js b/src/utils/instructionRenderers.js index 0a7e4f2..84bbe86 100644 --- a/src/utils/instructionRenderers.js +++ b/src/utils/instructionRenderers.js @@ -100,6 +100,7 @@ export const PLATFORM_TERMINOLOGY = { export const ACCESSIBILITY_PLATFORM_MAP = { swiftui: { name: "SwiftUI", + docsUrl: "https://developer.apple.com/accessibility/", label: `.accessibilityLabel("text")`, hint: `.accessibilityHint("text")`, roles: { @@ -129,6 +130,7 @@ export const ACCESSIBILITY_PLATFORM_MAP = { }, "react-native": { name: "React Native", + docsUrl: "https://reactnative.dev/docs/accessibility", label: `accessibilityLabel="text"`, hint: `accessibilityHint="text"`, roles: { @@ -158,6 +160,7 @@ export const ACCESSIBILITY_PLATFORM_MAP = { }, flutter: { name: "Flutter", + docsUrl: "https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility", label: `Semantics(label: "text")`, hint: `Semantics(hint: "text")`, roles: { @@ -187,6 +190,7 @@ export const ACCESSIBILITY_PLATFORM_MAP = { }, "jetpack-compose": { name: "Jetpack Compose", + docsUrl: "https://developer.android.com/jetpack/compose/accessibility", label: `Modifier.semantics { contentDescription = "text" }`, hint: `Modifier.semantics { stateDescription = "text" }`, roles: { @@ -361,7 +365,7 @@ export function renderBuildGuideActionTable(platform) { // Render an #### Accessibility subsection for hotspots that have accessibility annotations. // Returns null when no hotspot has accessibility data. -export function renderAccessibilityBlock(hotspots, _platform) { +export function renderAccessibilityBlock(hotspots) { const a11yHotspots = (hotspots || []).filter((h) => h.accessibility); if (a11yHotspots.length === 0) return null; @@ -413,7 +417,7 @@ export function renderAccessibilityGuidance(platform) { } md += `\n`; - md += `> For the full role and trait mapping, refer to the [${pm.name} accessibility documentation](https://developer.apple.com/accessibility/).\n\n`; + md += `> For the full role and trait mapping, refer to the [${pm.name} accessibility documentation](${pm.docsUrl}).\n\n`; return md; } diff --git a/src/utils/instructionRenderers.test.js b/src/utils/instructionRenderers.test.js index 53c2dfb..563e3af 100644 --- a/src/utils/instructionRenderers.test.js +++ b/src/utils/instructionRenderers.test.js @@ -214,7 +214,7 @@ describe("renderAccessibilityBlock", () => { { label: "Button", action: "navigate", accessibility: null }, { label: "Link", action: "navigate" }, ]; - expect(renderAccessibilityBlock(hotspots, "auto")).toBeNull(); + expect(renderAccessibilityBlock(hotspots)).toBeNull(); }); it("returns null for an empty hotspots array", () => { @@ -227,7 +227,7 @@ describe("renderAccessibilityBlock", () => { action: "navigate", accessibility: { label: "Sign in", role: "button", hint: "Double tap to sign in", traits: [] }, }]; - const result = renderAccessibilityBlock(hotspots, "auto"); + const result = renderAccessibilityBlock(hotspots); expect(result).toContain("#### Accessibility"); expect(result).toContain("Element"); expect(result).toContain("A11y Label"); @@ -242,7 +242,7 @@ describe("renderAccessibilityBlock", () => { action: "api", accessibility: { label: "Submit form", role: "button", hint: "Sends data", traits: ["selected", "disabled"] }, }]; - const result = renderAccessibilityBlock(hotspots, "auto"); + const result = renderAccessibilityBlock(hotspots); expect(result).toContain("Submit"); expect(result).toContain("Submit form"); expect(result).toContain("button"); @@ -257,7 +257,7 @@ describe("renderAccessibilityBlock", () => { action: "navigate", accessibility: { label: "", role: "", hint: "", traits: [] }, }]; - const result = renderAccessibilityBlock(hotspots, "auto"); + const result = renderAccessibilityBlock(hotspots); expect(result).toContain("\u2014"); }); @@ -267,7 +267,7 @@ describe("renderAccessibilityBlock", () => { { label: "B", action: "navigate", accessibility: null }, { label: "C", action: "navigate" }, ]; - const result = renderAccessibilityBlock(hotspots, "auto"); + const result = renderAccessibilityBlock(hotspots); expect(result).toContain("Accessible A"); expect(result).not.toContain("| B |"); expect(result).not.toContain("| C |");