Author instruction files that condition your AI tool to generate UI on your design system — then prove it worked with an automated lint that must report zero off-system styles, and a Storybook that shows every required state actually present.
Maya is a senior frontend engineer at a payments startup. Her team adopted Copilot and Cursor six months ago, and the review queue tells the story: hex colors instead of tokens, raw <button> elements instead of the Button primitive, inline styles nobody can theme. Every generated PR needs a manual de-drifting pass. In this lab you play Maya's role: you get a small design system — tokens, primitives, Storybook — and a baseline page that was generated without any conditioning. Your job is to write instruction files good enough that the next generation lands on-system on the first try, and to verify that claim with a machine, not your eyeballs.
~90 minutes hands-on · Build stage (maps to module M2, playbook PB-02) · $0 — any AI coding tool you already have (Copilot, Cursor, Claude Code, Windsurf) on a free or existing plan.
Scaffold the repo and Storybook:
npm create vite@latest onsystem-lab -- --template react-ts
cd onsystem-lab && npm install
npx storybook@latest init
mkdir -p src/ds src/naive src/pages scripts
Create the design-system core. First src/ds/tokens.css — every visual decision in this repo must trace back to one of these variables:
:root {
--color-bg: #ffffff;
--color-surface: #f8fafc;
--color-text: #0f172a;
--color-text-muted: #64748b;
--color-primary: #4f46e5;
--color-primary-hover: #4338ca;
--color-danger: #dc2626;
--color-border: #e2e8f0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--radius-md: 0.5rem;
--font-body: "Inter", system-ui, sans-serif;
--text-sm: 0.875rem;
--text-base: 1rem;
--text-lg: 1.125rem;
--text-xl: 1.5rem;
}
Two of the eight primitives (author TextField, Switch, Card, Stack, Heading, Text yourself following the same pattern — 10 minutes, or generate them and hand-review). src/ds/Button.tsx:
import { type ButtonHTMLAttributes } from "react";
import "./tokens.css";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "secondary" | "danger";
loading?: boolean;
};
export function Button({ variant = "primary", loading, children, disabled, ...rest }: Props) {
return (
<button
className={`ds-btn ds-btn--${variant}`}
disabled={disabled || loading}
aria-busy={loading || undefined}
{...rest}
>
{loading ? "Saving…" : children}
</button>
);
}
And src/ds/TextField.tsx:
import { type InputHTMLAttributes, useId } from "react";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label: string;
error?: string;
};
export function TextField({ label, error, ...rest }: Props) {
const id = useId();
const errId = `${id}-err`;
return (
<div className="ds-field">
<label htmlFor={id}>{label}</label>
<input id={id} aria-invalid={!!error} aria-describedby={error ? errId : undefined} {...rest} />
{error && <p id={errId} className="ds-field__error">{error}</p>}
</div>
);
}
Now the verification gate, scripts/check-onsystem.mjs — this is the machine that replaces your eyeballs:
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
const RULES = [
{ name: "raw hex color", re: /#[0-9a-fA-F]{3,8}\b/ },
{ name: "inline style attribute", re: /style=\{\{/ },
{ name: "raw <button> element", re: /<button[\s>]/ },
{ name: "raw <input> element", re: /<input[\s>]/ },
{ name: "hardcoded px value", re: /[:\s]["']?\d+px/ },
{ name: "hardcoded font-family", re: /fontFamily|font-family/ },
];
const dir = process.argv[2] ?? "src/pages";
let findings = 0;
for (const file of readdirSync(dir).filter((f) => /\.(tsx|css)$/.test(f))) {
const lines = readFileSync(join(dir, file), "utf8").split("\n");
lines.forEach((line, i) => {
for (const rule of RULES) {
if (rule.re.test(line)) {
findings++;
console.log(`${file}:${i + 1} [${rule.name}] ${line.trim()}`);
}
}
});
}
console.log(findings === 0 ? "PASS: 0 off-system styles" : `FAIL: ${findings} off-system findings`);
process.exit(findings === 0 ? 0 : 1);
Finally, the baseline: src/naive/SettingsPage.naive.tsx, generated by a tool with no instruction file. Save it exactly as-is — it is your measurement of what unconditioned generation does:
export default function SettingsPage() {
return (
<div style={{ maxWidth: "640px", margin: "0 auto", fontFamily: "Inter, sans-serif" }}>
<h1 style={{ color: "#111827", fontSize: "28px" }}>Settings</h1>
<section>
<h2 style={{ fontSize: "18px", color: "#374151" }}>Profile</h2>
<label>
Display name
<input type="text" style={{ border: "1px solid #D1D5DB", padding: "8px" }} />
</label>
</section>
<section>
<h2 style={{ fontSize: "18px", color: "#374151" }}>Notifications</h2>
<label>
<input type="checkbox" defaultChecked /> Email me about account activity
</label>
</section>
<button
style={{ background: "#3B82F6", color: "white", padding: "10px 16px", border: "none", borderRadius: "6px" }}
onClick={() => alert("Saved")}
>
Save changes
</button>
</div>
);
}
node scripts/check-onsystem.mjs src/naive. Expected output: a FAIL with a double-digit finding count. Copy the findings list into notes.md — this is the defect inventory your instruction file must prevent. Do not fix the naive file; it stays as evidence..cursor/rules/design-system.mdc, .github/copilot-instructions.md, or CLAUDE.md. It must state, minimum: components come only from src/ds (list all eight with their prop signatures); all color, spacing, radius and type come from tokens.css variables; no inline styles, no raw button/input elements, no px literals; every page with a mutation needs default, dirty, saving/loading, and server-error states; labels are wired via the primitives, never bare. Paste Button.tsx and TextField.tsx into the file as few-shot exemplars — exemplar code conditions generators harder than prose rules do.src/pages/SettingsPage.tsx: a settings page with a Profile section (display name, email), a Notifications section (two toggles), and a Save action that shows saving and server-error states. Follow the project rules." Expected: output that imports from ../ds and contains zero literal style values. Save whatever you get — no hand-editing yet.node scripts/check-onsystem.mjs src/pages. If it fails, do not fix the page by hand — fix the instruction file (that is the artifact this lab produces), regenerate, and re-run. Repeat until PASS. Log each iteration in notes.md: which rule fired, which instruction line you added.src/pages/SettingsPage.stories.tsx with four stories: Default, Dirty, Saving, ServerError (drive them via props or MSW-style mocked handlers). Run npm run storybook. Expected: all four render distinctly — Saving shows the busy button, ServerError shows an error surfaced through TextField's error prop or an alert region, not a console.log.notes.md: what generated UI must pass before review in your repo (the lint, the state stories, anything else you found). This paragraph is what you'd actually ship to your team.If you make the lint pass by editing the generated page instead of the instruction file, you have done the manual de-drifting pass this lab exists to eliminate — the next generation will drift exactly the same way.
check-onsystem.mjs src/naive fails with your recorded baseline count; check-onsystem.mjs src/pages passes with 0 findings.src/ds — grep -n "from" src/pages/SettingsPage.tsx shows no other UI sources.notes.md contains the baseline inventory, at least two lint-invisible findings, and the iteration log.Instruction files plus exemplar components are how you turn "please follow our design system" from a per-prompt plea into a repo property — but only an automated on-system gate makes the claim verifiable, and only Storybook state stories catch what no regex can see.