Generate a card-list screen from a design spec, then run a structured fidelity-and-accessibility review against the frame. Five deviations are seeded between the design and the naive output — passing this lab means catching all five, on paper, before any fix.
Arjun leads frontend at an HR-tech company. Design ships Figma frames; engineers ship "pretty close" implementations; the design-QA cycle eats a week per feature. His team just wired the Figma Dev Mode MCP into Cursor, and the first generated screens look right — which is exactly the trap. "Looks right" at a glance and "matches the frame" are different claims, and only one of them survives design review. In this lab you take a fully specified frame, a naive AI-generated implementation, and a structured review sheet — and you learn to review generated UI the way a design-systems engineer does: property by property, not vibe by vibe.
~90 minutes hands-on · Build stage (maps to module M3, playbooks PB-09/PB-11) · $0. If you have Figma Dev Mode MCP access, recreate the frame and drive the pipeline live; on the free tier, use the design-context bundle below — it is the same data the MCP would hand your agent.
npm create vite@latest fidelity-lab -- --template react-ts
cd fidelity-lab && npm install
mkdir -p src/spec src/naive src/final
The design-context bundle, src/spec/team-directory.frame.md — treat every line as normative, exactly as if it came out of get_design_context:
FRAME: TeamDirectory / MemberCard list (desktop 1140w)
Layout: vertical stack, gap 24px, page padding 32px
Card: width fill, padding 24px, radius 12px,
bg #FFFFFF, border 1px #E5E7EB,
shadow none at rest; hover: shadow 0 4px 12px rgba(15,23,42,0.08)
Card content: horizontal auto-layout, gap 16px, align center
Avatar: 48x48 circle; image, or initials fallback on #EEF2FF,
initials color #4F46E5, weight 600
Text stack: gap 4px
Name: 18px / weight 600 / #0F172A
Role: 14px / weight 400 / #64748B
Spacer: fill
Status pill: padding 4px 12px, radius 999px, 12px / weight 500
Active -> text #166534 on #DCFCE7
On leave-> text #92400E on #FEF3C7
Interaction: entire card clickable -> member profile.
Must be reachable and operable by keyboard; visible focus ring
2px #4F46E5 offset 2px.
Accessibility notes (from design annotations):
- Avatar images are decorative alongside the visible name
- Pill must not rely on color alone (text label carries meaning)
The naive output an agent produced from a screenshot of this frame, src/naive/MemberCard.tsx. Copy it verbatim — do not fix anything while typing:
type Member = {
id: string;
name: string;
role: string;
avatarUrl?: string;
status: "active" | "on-leave";
};
export function MemberCard({ member, onOpen }: { member: Member; onOpen: (id: string) => void }) {
return (
<div
onClick={() => onOpen(member.id)}
style={{
display: "flex", alignItems: "center", gap: 16,
padding: 16, borderRadius: 12, background: "#FFFFFF",
border: "1px solid #E2E8F0", cursor: "pointer",
}}
>
{member.avatarUrl && (
<img src={member.avatarUrl} alt={member.name} width={48} height={48}
style={{ borderRadius: "50%" }} />
)}
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 16, fontWeight: 600, color: "#0F172A" }}>{member.name}</span>
<span style={{ fontSize: 14, color: "#64748B" }}>{member.role}</span>
</div>
<div style={{ flex: 1 }} />
<span
style={{
padding: "4px 12px", borderRadius: 999, fontSize: 12, fontWeight: 500,
color: member.status === "active" ? "#166534" : "#92400E",
background: member.status === "active" ? "#DCFCE7" : "#FEF3C7",
}}
>
{member.status === "active" ? "Active" : "On leave"}
</span>
</div>
);
}
The review instrument, review-sheet.md — you will fill one row per property:
| # | Property (from frame) | Frame value | Implementation value | Match? | Severity |
|---|---------------------------|-------------|----------------------|--------|----------|
Sections to cover: layout & spacing / color & border / typography /
states & variants (hover, focus, fallbacks) / interaction & keyboard /
accessibility annotations
Severity scale: BLOCK (fails design or a11y intent) / FIX (visible drift) / NIT
MemberCard into App.tsx with three members — one active with an avatar URL, one on leave, one active without an avatar URL — and npm run dev. Expected: three cards render; note anything that strikes you immediately, but write nothing on the sheet yet. First impressions are the "vibe review" you are about to beat.team-directory.frame.md line by line. For every specified value — gaps, padding, radius, border color, each type size and weight, each pill color — add a row to review-sheet.md with the frame value and the implementation's actual value (read the code, and verify in DevTools computed styles where the code is ambiguous). Expected: 15–20 rows. This should take you 20 minutes; slower is fine, skipping rows is not.src/final/MemberCard.tsx. Copy the naive file and repair every BLOCK and FIX row. Use semantic elements for interactivity, real fallbacks, and the exact frame values. Re-run the hover/keyboard/fallback checks from step 3 against your fix.final card and paste it into a vision-capable assistant with the frame spec text: "List every visual discrepancy between this implementation and this spec — spacing, type, color, radius, states." Expected: it confirms your fixes and possibly flags 1–2 nits you missed. Record whether it found anything your manual pass did not, and whether it hallucinated any discrepancy that does not exist — both numbers go in your notes.Keep the naive file untouched. The deliverable of a fidelity review is the sheet, and the sheet is only auditable if the artifact it reviewed still exists.
review-sheet.md has 15+ property rows, each with a concrete frame value and a concrete implementation value — no "looks ok" entries.src/final/MemberCard.tsx renders correctly for: hover, keyboard focus + Enter, missing avatar, both statuses.Design-to-code fidelity is a property-by-property claim, not an impression — generated UI drifts hardest exactly where screenshots are blind (states, interaction, semantics), so your review sheet must interrogate what the frame says, not what the render shows.