BXUP Track progress & test yourself — free
Frontend & Mobile · Hands-on lab · ~5 min read
FRONTEND & MOBILE · HANDS-ON LAB

Lab 01 · On-System Component Scaffolding

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.

Time & tier

~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.

Setup

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>
  );
}

Guided steps

  1. Baseline the naive output. Run 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.
  2. Classify what the lint caught — and what it missed. Spend five minutes reading the naive page against the design system by hand. The lint catches style-level drift; write down at least two problems in the naive page that no regex flagged. (Hint: think about which primitive should own that checkbox, and what happens visually between clicking Save and the alert.)
  3. Author the instruction file. Create the file your tool reads — .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.
  4. Generate the settings page. Prompt: "Build 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.
  5. Run the gate.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.
  6. Prove the states exist in Storybook. Write 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.
  7. Adversarial re-run. In a fresh chat with no extra context beyond the repo, ask for "a quick promo banner component for the settings page." Run the lint on the result. This tests whether your instructions hold when you don't restate them in the prompt — the real-world condition.
  8. Write the one-paragraph team rule. In 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.
Warning

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.

Verify

  • check-onsystem.mjs src/naive fails with your recorded baseline count; check-onsystem.mjs src/pages passes with 0 findings.
  • The generated page imports only from src/ds — grep -n "from" src/pages/SettingsPage.tsx shows no other UI sources.
  • Storybook renders Default, Dirty, Saving, ServerError as four visibly distinct states.
  • The adversarial banner (step 7) also passed the lint on first generation, or you logged which instruction gap it exposed and closed it.
  • notes.md contains the baseline inventory, at least two lint-invisible findings, and the iteration log.

Self-grading rubric (pass = 7/10)

  • Conditioning quality (0–3): instruction file names all primitives with prop signatures and includes at least one exemplar component = 3; prose rules only = 1.
  • Verification discipline (0–3): every iteration went through regenerate-and-lint, never hand-patching output = 3; any hand-patch = max 1.
  • Lint-blindness awareness (0–2): both machine-invisible defect classes found in step 2 = 2; one = 1.
  • State completeness (0–2): all four stories present and distinct = 2.
▸Answer key — the naive baseline's seeded defectsoptional deep dive — click to expand

The naive SettingsPage.naive.tsx carries nine deliberate off-system defects. The lint catches seven; two are machine-invisible by design.

Caught by the lint:

  • Inline style attributesevery element styles via style={{…}}; nothing is themeable. Rule: inline style attribute (6+ hits).
  • Raw hex colors#111827, #374151, #D1D5DB, #3B82F6; the primary blue is not even the brand primary (--color-primary is indigo #4f46e5). Rule: raw hex color.
  • Hardcoded px values28px, 18px, 8px, 10px 16px, 6px; the type scale and spacing scale are bypassed entirely. Rule: hardcoded px value.
  • Hardcoded font-family"Inter, sans-serif" restated inline instead of --font-body. Rule: hardcoded font-family.
  • Raw <button>bypasses Button, so it has no loading state, no disabled-while-busy, no variants. Rule: raw button element.
  • Raw <input type="text">bypasses TextField, so it has no htmlFor wiring, no error slot, no aria-invalid. Rule: raw input element.
  • Raw <input type="checkbox">same rule; should be the Switch primitive.

Machine-invisible (step 2 targets — full credit requires both):

  • Missing states. There is no dirty, saving, or server-error state; "Save" fires alert(). A regex cannot know your definition of done — only the Storybook story requirement catches this.
  • Wrong primitive semantics. Even if the checkbox were styled perfectly, the design system says notification toggles are Switch, not checkbox — a component-choice decision, invisible to style linting, that your instruction file must state explicitly.

Scoring: your instruction file gets full marks if the step-7 adversarial generation passed the lint on the first try; that is the observable proof the conditioning — not the prompt — is doing the work.

Key Takeaway:

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.

This is the free reading view. Open BXUP to track your progress, run the hands-on labs, and prove it with original, exam-pattern assessments.
© Anvi Innovate · BXUP — Be Exponentially Upskilled · BXUP is an independent learning platform, not affiliated with or endorsed by AWS, Microsoft, Google, NVIDIA, IBM, Salesforce, Databricks, Oracle or Anthropic. All trademarks belong to their respective owners. · bxup.in