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..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,6 +65,7 @@ export const hotspotTools = [
onErrorAction: { type: "string", enum: ["navigate", "back", "modal", "custom", ""] },
onErrorTargetId: { type: "string" },
onErrorCustomDesc: { type: "string" },
+ accessibility: accessibilitySchema,
},
required: ["screenId", "label", "x", "y", "w", "h", "action"],
},
@@ -66,6 +89,7 @@ export const hotspotTools = [
apiEndpoint: { type: "string" },
apiMethod: { type: "string" },
customDescription: { type: "string" },
+ accessibility: accessibilitySchema,
},
required: ["screenId", "hotspotId"],
},
diff --git a/src/components/HotspotModal.jsx b/src/components/HotspotModal.jsx
index 492f878..7760e52 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,116 @@ 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..81bf373 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);
+ 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..84bbe86 100644
--- a/src/utils/instructionRenderers.js
+++ b/src/utils/instructionRenderers.js
@@ -95,6 +95,131 @@ 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",
+ docsUrl: "https://developer.apple.com/accessibility/",
+ 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",
+ docsUrl: "https://reactnative.dev/docs/accessibility",
+ 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",
+ docsUrl: "https://docs.flutter.dev/ui/accessibility-and-internationalization/accessibility",
+ 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",
+ docsUrl: "https://developer.android.com/jetpack/compose/accessibility",
+ 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 +362,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) {
+ 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](${pm.docsUrl}).\n\n`;
+
+ return md;
+}
diff --git a/src/utils/instructionRenderers.test.js b/src/utils/instructionRenderers.test.js
index 04b63d6..563e3af 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)).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);
+ 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);
+ 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);
+ 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);
+ 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");
+ });
+});