Skip to content

Commit 8ebcf17

Browse files
authored
feat: add accessibility annotations to hotspots (#19)
* 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 * 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 --------- Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent 0df3e21 commit 8ebcf17

12 files changed

Lines changed: 574 additions & 8 deletions

mcp-server/src/state.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@ export class FlowState {
243243
validation: hotspot.validation || null,
244244
transitionType: hotspot.transitionType || null,
245245
transitionLabel: hotspot.transitionLabel || "",
246+
accessibility: hotspot.accessibility || null,
246247
};
247248

248249
if (!screen.hotspots) screen.hotspots = [];
@@ -304,7 +305,7 @@ export class FlowState {
304305
"requestSchema", "responseSchema", "customDescription", "documentId",
305306
"conditions", "onSuccessAction", "onSuccessTargetId", "onSuccessCustomDesc",
306307
"onErrorAction", "onErrorTargetId", "onErrorCustomDesc",
307-
"tbd", "tbdNote", "validation", "transitionType", "transitionLabel",
308+
"tbd", "tbdNote", "validation", "transitionType", "transitionLabel", "accessibility",
308309
];
309310
for (const key of allowed) {
310311
if (updates[key] !== undefined) {

mcp-server/src/tools/hotspot-tools.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,25 @@
1+
const accessibilitySchema = {
2+
type: "object",
3+
properties: {
4+
label: { type: "string", description: "Accessibility label (VoiceOver/TalkBack)" },
5+
role: {
6+
type: "string",
7+
enum: ["button", "link", "image", "heading", "text", "search-field",
8+
"toggle", "slider", "tab", "alert", "menu", "other"],
9+
},
10+
hint: { type: "string", description: "Usage hint (e.g. 'Double tap to sign in')" },
11+
traits: {
12+
type: "array",
13+
items: {
14+
type: "string",
15+
enum: ["selected", "disabled", "adjustable", "header", "summary",
16+
"plays-sound", "starts-media", "allows-direct-interaction"],
17+
},
18+
},
19+
},
20+
description: "Accessibility annotations for screen readers",
21+
};
22+
123
export const hotspotTools = [
224
{
325
name: "create_hotspot",
@@ -43,6 +65,7 @@ export const hotspotTools = [
4365
onErrorAction: { type: "string", enum: ["navigate", "back", "modal", "custom", ""] },
4466
onErrorTargetId: { type: "string" },
4567
onErrorCustomDesc: { type: "string" },
68+
accessibility: accessibilitySchema,
4669
},
4770
required: ["screenId", "label", "x", "y", "w", "h", "action"],
4871
},
@@ -66,6 +89,7 @@ export const hotspotTools = [
6689
apiEndpoint: { type: "string" },
6790
apiMethod: { type: "string" },
6891
customDescription: { type: "string" },
92+
accessibility: accessibilitySchema,
6993
},
7094
required: ["screenId", "hotspotId"],
7195
},

src/components/HotspotModal.jsx

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
22
import { COLORS, FONTS, styles } from "../styles/theme";
33
import { generateId } from "../utils/generateId";
44
import { DataFlowEditor } from "./DataFlowEditor";
5-
import { TRANSITION_TYPES } from "../constants";
5+
import { TRANSITION_TYPES, ACCESSIBILITY_ROLES, ACCESSIBILITY_TRAITS } from "../constants";
66

77
function FollowUpSection({ title, titleColor, action, setAction, targetId, setTargetId,
88
customDesc, setCustomDesc, otherScreens, dataFlow, onDataFlowChange }) {
@@ -143,6 +143,13 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents =
143143
const [validationPattern, setValidationPattern] = useState(hotspot?.validation?.pattern || "");
144144
const [validationErrorMessage, setValidationErrorMessage] = useState(hotspot?.validation?.errorMessage || "");
145145

146+
// Accessibility annotations
147+
const [a11yExpanded, setA11yExpanded] = useState(!!(hotspot?.accessibility));
148+
const [a11yLabel, setA11yLabel] = useState(hotspot?.accessibility?.label || "");
149+
const [a11yRole, setA11yRole] = useState(hotspot?.accessibility?.role || "");
150+
const [a11yHint, setA11yHint] = useState(hotspot?.accessibility?.hint || "");
151+
const [a11yTraits, setA11yTraits] = useState(hotspot?.accessibility?.traits || []);
152+
146153
useEffect(() => {
147154
const handleKeyDown = (e) => {
148155
if (e.key === "Escape") onClose();
@@ -200,6 +207,13 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents =
200207
setW(preset.w ?? w);
201208
setH(preset.h ?? h);
202209
if (preset.customDescription) setCustomDescription(preset.customDescription);
210+
if (preset.accessibility) {
211+
setA11yExpanded(true);
212+
setA11yLabel(preset.accessibility.label || "");
213+
setA11yRole(preset.accessibility.role || "");
214+
setA11yHint(preset.accessibility.hint || "");
215+
setA11yTraits(preset.accessibility.traits || []);
216+
}
203217
}}
204218
style={{ ...styles.select, flex: 1, marginTop: 0 }}
205219
>
@@ -222,6 +236,7 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents =
222236
w,
223237
h,
224238
customDescription,
239+
accessibility: a11yExpanded ? { label: a11yLabel, role: a11yRole, hint: a11yHint, traits: a11yTraits } : null,
225240
};
226241
const updated = [...presets, preset];
227242
setPresets(updated);
@@ -307,6 +322,12 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents =
307322
pattern: validationPattern,
308323
errorMessage: validationErrorMessage,
309324
} : null,
325+
accessibility: a11yExpanded ? {
326+
label: a11yLabel,
327+
role: a11yRole,
328+
hint: a11yHint,
329+
traits: a11yTraits,
330+
} : null,
310331
});
311332
}}>
312333
<div style={{ display: "flex", flexDirection: "column", gap: 14 }}>
@@ -738,6 +759,116 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents =
738759
</div>
739760
)}
740761

762+
{/* Accessibility annotations */}
763+
<div style={{
764+
border: `1px solid ${a11yExpanded ? "rgba(198,120,221,0.25)" : COLORS.border}`,
765+
borderRadius: 8,
766+
overflow: "hidden",
767+
}}>
768+
<button
769+
type="button"
770+
onClick={() => setA11yExpanded(!a11yExpanded)}
771+
style={{
772+
width: "100%",
773+
display: "flex",
774+
alignItems: "center",
775+
justifyContent: "space-between",
776+
padding: "10px 12px",
777+
background: a11yExpanded ? "rgba(198,120,221,0.06)" : "rgba(255,255,255,0.02)",
778+
border: "none",
779+
cursor: "pointer",
780+
fontFamily: FONTS.mono,
781+
}}
782+
>
783+
<span style={{
784+
fontSize: 10,
785+
fontWeight: 700,
786+
letterSpacing: "0.08em",
787+
color: a11yExpanded ? "#c678dd" : COLORS.textMuted,
788+
textTransform: "uppercase",
789+
}}>
790+
Accessibility
791+
</span>
792+
<span style={{ color: COLORS.textDim, fontSize: 12 }}>{a11yExpanded ? "\u25B2" : "\u25BC"}</span>
793+
</button>
794+
{a11yExpanded && (
795+
<div style={{ padding: "0 12px 12px", display: "flex", flexDirection: "column", gap: 10 }}>
796+
<label style={{ ...styles.monoLabel, marginTop: 8 }}>
797+
ACCESSIBILITY LABEL
798+
<input
799+
value={a11yLabel}
800+
onChange={(e) => setA11yLabel(e.target.value)}
801+
placeholder="e.g. Sign in button"
802+
style={styles.input}
803+
/>
804+
</label>
805+
<label style={styles.monoLabel}>
806+
ROLE
807+
<select value={a11yRole} onChange={(e) => setA11yRole(e.target.value)} style={styles.select}>
808+
<option value="">-- None --</option>
809+
{ACCESSIBILITY_ROLES.map((r) => (
810+
<option key={r} value={r}>{r}</option>
811+
))}
812+
</select>
813+
</label>
814+
<label style={styles.monoLabel}>
815+
HINT
816+
<input
817+
value={a11yHint}
818+
onChange={(e) => setA11yHint(e.target.value)}
819+
placeholder="e.g. Double tap to sign in"
820+
style={styles.input}
821+
/>
822+
</label>
823+
<div>
824+
<span style={{
825+
fontSize: 10,
826+
fontWeight: 700,
827+
letterSpacing: "0.08em",
828+
color: COLORS.textMuted,
829+
textTransform: "uppercase",
830+
fontFamily: FONTS.mono,
831+
display: "block",
832+
marginBottom: 6,
833+
}}>
834+
TRAITS
835+
</span>
836+
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
837+
{ACCESSIBILITY_TRAITS.map((trait) => {
838+
const isActive = a11yTraits.includes(trait);
839+
return (
840+
<button
841+
key={trait}
842+
type="button"
843+
onClick={() => {
844+
setA11yTraits((prev) =>
845+
prev.includes(trait)
846+
? prev.filter((t) => t !== trait)
847+
: [...prev, trait]
848+
);
849+
}}
850+
style={{
851+
padding: "3px 9px",
852+
borderRadius: 4,
853+
border: `1px solid ${isActive ? "rgba(198,120,221,0.35)" : COLORS.border}`,
854+
background: isActive ? "rgba(198,120,221,0.12)" : "rgba(255,255,255,0.05)",
855+
color: isActive ? "#c678dd" : COLORS.textDim,
856+
fontFamily: FONTS.mono,
857+
fontSize: 11,
858+
fontWeight: 600,
859+
cursor: "pointer",
860+
}}
861+
>
862+
{trait}
863+
</button>
864+
);
865+
})}
866+
</div>
867+
</div>
868+
</div>
869+
)}
870+
</div>
871+
741872
{/* Transition type — only visible when editing a connection-backed hotspot */}
742873
{connection && (action === "navigate" || action === "back" || action === "modal" || action === "api") && (
743874
<>

src/constants.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const GITHUB_URL = "https://github.com/codeflow-studio/drawd";
55
export const DOMAIN = "drawd.app";
66

77
// ── File Format ──────────────────────────────
8-
export const FILE_VERSION = 11;
8+
export const FILE_VERSION = 12;
99
export const FILE_EXTENSION = ".drawd";
1010
export const LEGACY_FILE_EXTENSION = ".flowforge";
1111
export const DEFAULT_EXPORT_FILENAME = "flow-export";
@@ -89,3 +89,14 @@ export const TRANSITION_TYPES = [
8989
export const TRANSITION_LABELS = Object.fromEntries(
9090
TRANSITION_TYPES.map(t => [t.value, t.label])
9191
);
92+
93+
// ── Accessibility ───────────────────────────
94+
export const ACCESSIBILITY_ROLES = [
95+
"button", "link", "image", "heading", "text", "search-field",
96+
"toggle", "slider", "tab", "alert", "menu", "other",
97+
];
98+
99+
export const ACCESSIBILITY_TRAITS = [
100+
"selected", "disabled", "adjustable", "header", "summary",
101+
"plays-sound", "starts-media", "allows-direct-interaction",
102+
];

src/pages/docs/userGuide.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,20 @@ When a screen has text-input hotspots, a yellow `Form` button appears in the scr
145145
> [!TIP]
146146
> Use the Form Summary to audit your form screens before generating instructions. Missing validations are easy to overlook in individual hotspot modals.
147147
148+
### Accessibility annotations
149+
150+
Every hotspot has an optional Accessibility section. Expand it to define how screen readers should announce the element:
151+
152+
- **Label** -- What VoiceOver or TalkBack reads aloud (e.g. "Sign in button")
153+
- **Role** -- The semantic role: button, link, image, heading, text, search-field, toggle, slider, tab, alert, menu, or other
154+
- **Hint** -- A usage hint describing the result of activating the element (e.g. "Double tap to sign in")
155+
- **Traits** -- Additional traits: selected, disabled, adjustable, header, summary, plays-sound, starts-media, allows-direct-interaction
156+
157+
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.
158+
159+
> [!TIP]
160+
> Accessibility annotations are saved with presets, so you can reuse common patterns across hotspots.
161+
148162
## Connecting Screens
149163

150164
Connections (navigation links) show how a user moves from one screen to another. They appear as curved arrows on the canvas between screen cards.

src/utils/buildPayload.test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ describe("buildPayload", () => {
66
const connections = [{ id: "c1" }];
77
const documents = [{ id: "d1" }, { id: "d2" }, { id: "d3" }];
88

9-
it("sets version to 11", () => {
9+
it("sets version to 12", () => {
1010
const payload = buildPayload([], [], { x: 0, y: 0 }, 1);
11-
expect(payload.version).toBe(11);
11+
expect(payload.version).toBe(12);
1212
});
1313

1414
it("sets metadata.screenCount to screens.length", () => {

src/utils/generateInstructionFiles.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { analyzeNavGraph } from "./analyzeNavGraph.js";
2-
import { PLATFORM_TERMINOLOGY, renderHotspotDetailBlock, renderBuildGuideActionTable, renderBuildGuideTransitionTable } from "./instructionRenderers.js";
2+
import { PLATFORM_TERMINOLOGY, renderHotspotDetailBlock, renderBuildGuideActionTable, renderBuildGuideTransitionTable, renderAccessibilityBlock, renderAccessibilityGuidance } from "./instructionRenderers.js";
33
import { screenReqId, connectionReqId } from "./generateReqIds.js";
44
import { TRANSITION_LABELS } from "../constants.js";
55

@@ -501,6 +501,10 @@ function generateScreenDetailMd(s, screens, connections, images, documents = [])
501501
md += `> **Note:** ${missing.length} field(s) marked with \u26A0\uFE0F have no validation rules configured.\n\n`;
502502
}
503503
}
504+
505+
// Accessibility subsection
506+
const a11yBlock = renderAccessibilityBlock(s.hotspots);
507+
if (a11yBlock) md += a11yBlock;
504508
} else {
505509
md += `*No interactive elements defined*\n\n`;
506510
}
@@ -771,6 +775,9 @@ function generateBuildGuideMd(screens, connections, options, screenGroups = [])
771775
const transitionTable = renderBuildGuideTransitionTable(platform);
772776
if (transitionTable) md += transitionTable;
773777

778+
const a11yGuide = renderAccessibilityGuidance(platform);
779+
if (a11yGuide) md += a11yGuide;
780+
774781
md += `### Steps\n\n`;
775782
md += `1. Implement each screen from screens.md as a separate ${pt.name} view/component\n`;
776783
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`;

src/utils/generateInstructionFiles.test.js

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,4 +218,53 @@ describe("generateInstructionFiles", () => {
218218
expect(buildGuide.content).toContain("fullScreenCover");
219219
expect(buildGuide.content).toContain("slideUp");
220220
});
221+
222+
it("includes Accessibility section in screens.md when hotspots have accessibility data", () => {
223+
const screenWithA11y = {
224+
...minimalScreen,
225+
hotspots: [{
226+
id: "h1",
227+
label: "Login",
228+
elementType: "button",
229+
interactionType: "tap",
230+
action: "navigate",
231+
x: 10, y: 10, w: 80, h: 15,
232+
accessibility: { label: "Sign in", role: "button", hint: "Double tap to sign in", traits: [] },
233+
}],
234+
};
235+
const result = generateInstructionFiles([screenWithA11y], [], defaultOptions);
236+
const screensFile = result.files.find((f) => f.name === "screens.md");
237+
expect(screensFile.content).toContain("#### Accessibility");
238+
expect(screensFile.content).toContain("Sign in");
239+
});
240+
241+
it("does not include Accessibility section when no hotspots have accessibility data", () => {
242+
const screenNoA11y = {
243+
...minimalScreen,
244+
hotspots: [{
245+
id: "h1",
246+
label: "Button",
247+
elementType: "button",
248+
interactionType: "tap",
249+
action: "navigate",
250+
x: 10, y: 10, w: 80, h: 15,
251+
}],
252+
};
253+
const result = generateInstructionFiles([screenNoA11y], [], defaultOptions);
254+
const screensFile = result.files.find((f) => f.name === "screens.md");
255+
expect(screensFile.content).not.toContain("#### Accessibility");
256+
});
257+
258+
it("includes Accessibility guidance in build-guide.md for platform-specific output", () => {
259+
const result = generateInstructionFiles([minimalScreen], [], { platform: "swiftui" });
260+
const buildGuide = result.files.find((f) => f.name === "build-guide.md");
261+
expect(buildGuide.content).toContain("### Accessibility");
262+
expect(buildGuide.content).toContain(".accessibilityLabel");
263+
});
264+
265+
it("does not include platform Accessibility guidance in build-guide.md for auto platform", () => {
266+
const result = generateInstructionFiles([minimalScreen], [], { platform: "auto" });
267+
const buildGuide = result.files.find((f) => f.name === "build-guide.md");
268+
expect(buildGuide.content).not.toContain(".accessibilityLabel");
269+
});
221270
});

src/utils/importFlow.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export function importFlow(fileText) {
6666
if (!Array.isArray(hs.dataFlow)) hs.dataFlow = [];
6767
if (!Array.isArray(hs.onSuccessDataFlow)) hs.onSuccessDataFlow = [];
6868
if (!Array.isArray(hs.onErrorDataFlow)) hs.onErrorDataFlow = [];
69+
if (hs.accessibility === undefined) hs.accessibility = null;
6970

7071
// v4 -> v5 migration: promote inline apiDocs to a document
7172
if (data.version < 5) {

0 commit comments

Comments
 (0)