Case Study · Product Designer & Builder

AI writes the happy path.
It skips the other 40%.

v0, Lovable, Bolt and Cursor build the screen where everything works — and quietly skip the ones where it doesn't. Edge-State Fixer takes an AI-generated React component, detects which of ten edge states it's missing, and writes designed, drop-in code for each — with the reasoning, a live preview, and a real before/after.

Try the live demo
Muzeeb Urrahaman Solo designer + builder Spec-driven · Jul 2026 Engine · Web · MCP
10
Edge states, detect → generate
~40%
Of real UX AI skips (my estimate)
3
Surfaces off one engine
292
Cross-browser tests, green
24/24
Engine + MCP unit tests
WCAG AA
axe-verified app
In one line

Paste an AI-generated component. A free static pre-pass works out which edge states it's missing; the model then writes designed, copy-paste code for each — with a one-line reason, a live preview, and a before/after toggle.

Code generators are excellent at the screen where everything works. They skip the states where it doesn't: the blank screen before data exists, the wait while it loads, the failure when it breaks, the silent submit, the raw validation popup. Those states are roughly 40% of a real product's experience — my estimate from tearing down AI-generated apps, not a measured figure — and they're skipped by design: nobody prompts for them, and a better model writes a better happy path, not a better failure state. That makes this a designer-shaped problem — I'd weight it ~90% design judgment, 10% model (how I think about it, not a metric).

What the teardowns changed: pasting components out of v0, Lovable, Bolt and Cursor, the striking thing wasn't that they skipped states — it's that they skipped the same states, the same way, every time. Because the gap is predictable, detection didn't need a model at all: I made it a free, deterministic pre-pass that decides which states are missing before any AI is called. That one finding — the gap is regular, not random — is why the detector is rules, not a prompt.

Problem

  • AI generators ship the happy path & leave the failure states blank.
  • Those states are ~40% of the real experience — skipped by design.
  • Re-prompting gives inconsistent, taste-free output every run.

Solution

  • A written state-design ruleset is the system prompt — the taste, encoded once.
  • Deterministic detection decides what applies; the model only fills in code.
  • Each fix drops in: matches your Tailwind, names variables you used.

Results

  • 10 states detected & generated end to end from a paste.
  • One engine ships to web, an MCP server & a CLI.
  • 292 cross-browser tests green; the app passes an a11y gate.
The bet
Why this exists

The model is a commodity. The taste is the moat.

The lazy version of this is a thin call to an LLM: "add edge states to this component." A better model erases that overnight, and it produces different, taste-free output every run. So the valuable asset here isn't generation — it's encoded taste: a written, opinionated definition of what a good empty, error or validation state actually is, applied identically every time.

That taste lives in one file — packages/engine/src/prompt/system.ts — and it is the system prompt. The few-shot examples come from hand-designed fixtures beside it. This document, not the model, is the defense against "just re-prompt the generator." "Did you handle the empty case?" is a forever question, not a generation trick — which is exactly why the product survives the models getting better.

Built spec-first. The whole thing was built with Spec-Driven Development (GitHub Spec Kit): a written constitution.md of guardrails, then spec → plan → tasks per feature. The top guardrail — "design quality is the product; if a generated state doesn't clearly out-design a lazy re-prompt, the feature isn't done" — is what kept scope honest.
The ten states
The taste, written down

Ten states — each with one opinionated rule.

These aren't generic categories; each carries a specific design stance from the ruleset. Five shipped in the MVP; five more (the states products hit after launch — auth, real networks, scale, limits) landed in V1.

Empty

Onboarding, not an error

Icon → headline → why it's empty → a primary "first action" CTA. A blank div wastes the most common first impression.

Loading

A skeleton, never a spinner

Shape mirrors the real content (rows for a table, cards for a grid) so nothing jumps when data lands. aria-busy + live region.

Error

Human copy plus a way out

Four beats: what happened + why + what to do + a retry. role="alert". Never "Something went wrong," never a raw error object.

Success toast

Never a silent submit

Past-tense confirm ("Saved", "Sent"), placement, ~4s duration, dismissible. Chooses optimistic vs confirm-on-success by risk.

Validation

Inline, per-field, human

Message under each field, aria-invalid + aria-describedby, validate on blur. Assumed rules surfaced, never invented.

No-permission

A boundary, not a bug

Names the wall, shows current-vs-required role, offers a forward path. Amber, not alarm-red. A 403 rendered blank reads as broken.

Offline

Preserve, communicate, recover

Keep the last-loaded UI (dim, don't blank), non-blocking banner, auto-refetch on reconnect. Never an infinite spinner.

No search results

Echo the query, offer an exit

"No matches for '<query>'" + clear-filters. Distinct from first-run empty — here data exists, the query excluded it.

End of list

A terminal marker, not a phantom spinner

Gate the loader on hasNextPage; replace the endless spinner with "You're all caught up." "Loading more" and "no more" must look different.

Over-limit

Name the limit, show the way forward

"You've used all 50 of your monthly generations" + a usage meter + when it resets / how to upgrade. Blameless copy. Design the feedback, never the limiter.

How it works
The pipeline

Deterministic detection, generative fixing.

The split is deliberate: a cheap, free static pass decides what applies — reproducibly, with no model call — and the model only does the subjective part, writing the designed code. That's what makes the tool cheap to run and predictable to reason about.

1

Paste a component

Any AI-generated React/JSX — a table, form, dashboard, feed, search view.

2

Detect signals — no model

An AST/regex pre-pass reads the code for real cues: fetch/useEffect/useQuery → data-dependent; onSubmit/mutations → mutating; a form; auth cues like 403/useSession/isAdmin/ShieldAlert; 429/quota; infinite-scroll. It even refuses to treat a bare role as an auth signal — it's usually a data column, not permission.

3

Classify each state

Every state is marked present, lazy, missing or not-applicable — so the tool fixes real gaps and pads nothing.

4

Generate to the ruleset

For each missing/lazy state the model returns zod-validated structured output (retry-once on a bad shape): designed code + a one-line rationale, matching your existing Tailwind and variable names.

5

Review & copy

Each fix arrives with a live in-document preview, a before/after toggle, and copy-to-clipboard — paste it straight back into your project.

The classification

Applicability is a first-class decision.

Most tools pad their output to look thorough. This one does the opposite — a read-only table gets no validation; a pure form gets no first-run empty. Four verdicts, and two of them emit nothing:

● Present

Already handled well.

emits nothing
● Lazy

Handled but under-designed — a bare spinner, a blank div, "Something went wrong."

emits a redesign
● Missing

Not handled at all.

emits designed code
● Not-applicable

Doesn't fit this component.

emits nothing
Edge-State Fixer landing page
The single paste-and-fix flow. The app dogfoods its own empty, loading, error, toast and validation states — including the waitlist form.
Before → After
A worked example

One real component, its real gaps, filled.

Take UserTable.tsx — a data table the generator shipped happy-path only. It fetches, shows a bare spinner while loading, and has no error path and no empty state: a failed request renders nothing, and zero users renders an empty <tbody>. Detection flags it as data-dependent, so empty · loading · error all apply. Here is the error state — the actual before and after code from the repo:

Before · what AI shipped
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
  fetch("/api/users")
    .then(r => r.json())
    .then(data => {
      setUsers(data);
      setLoading(false);
    });
  // no .catch → failure renders nothing
  // users.length === 0 → empty <tbody>
}, []);

if (loading) return (
  // a bare spinner — no layout, no context
  <div className="animate-spin ..." />
);
After · what the engine generates
<div role="alert" className="... py-16 text-center">
  <AlertTriangle className="h-5 w-5 text-red-600" />
  <h3>Couldn't load your team</h3>
  <p>We hit a problem reaching the server, so
     the list didn't load. This is usually
     temporary. Try again, and if it keeps
     happening, check your connection…</p>
  <button onClick={onRetry} disabled={isRetrying}>
    <RotateCw className={isRetrying
      ? "animate-spin" : ""} />
    {isRetrying ? "Retrying…" : "Try again"}
  </button>
</div>

Rationale (verbatim from the fixture): the error state is UX writing, not a stack trace — it names what happened, why in human terms, and what to do, with recovery one click away via a spinning "Try again." It never surfaces raw error.message, which would leak internals and mean nothing to the reader.

The same loop runs for every applicable state. These are real designed states from the tool:

The hard parts
The judgment calls

Anyone can ask a model for an error state.

The valuable decisions were about how a tool like this becomes something a developer trusts on their own code. Three examples, all real, from the ruleset and fixtures:

The engine is the asset; every surface is a thin wrapper. Web, MCP and CLI all call one decoupled detectAndFix() — a guardrail from day one — which is why the exact same taste ships everywhere and the app can dogfood its own states as living proof.
The engine contract

One call, typed and honest.

The public API is a single function returning a validated result — what the component does, and each state classified with its designed code and rationale.

import { detectAndFix } from "edge-state-fixer-engine";

const result = await detectAndFix(code);
// { dataDependent, mutating, hasForm, summary, states }

result.states.error  // → { status: "missing", code, rationale }
result.states.empty  // → { status: "not-applicable" }   ← emits nothing
// toast carries placement, durationMs, dismissible, pattern;
// validation carries the assumptions it surfaced (never invented rules)
The stack
The stack

One engine, three surfaces, one codebase.

A pnpm monorepo built engine-first. The engine holds all the value; the web app and MCP server are thin wrappers on the same detectAndFix().

PackageWhat it is
engineFramework-agnostic TypeScript, no web deps — the asset. detect() static pre-pass + detectAndFix() LLM generation, zod-validated structured output, retry-once.
providersAn LlmProvider interface with three modes: subscription CLI (drives the local claude CLI — no API key), Anthropic API, and a deterministic mock for demos & CI.
promptsystem.ts — the encoded state-design ruleset. examples.ts — few-shot from the hand-designed fixtures.
apps/webNext.js 15 App Router + Tailwind v4 · Sandpack live previews · Shiki code · sonner toasts. First load ~206 kB; key stays server-side.
packages/mcpMCP server — scan / fix / ruleset tools for Claude Code, Cursor & Windsurf. Published on npm as edge-state-fixer-mcp.
qualityTypeScript strict · Biome · Vitest · a Playwright suite of 292 tests across Chromium, Firefox, WebKit & mobile · a written TEST-PLAN mapping every requirement to a test.
In your IDE

It meets developers where they already are.

The MCP server exposes the engine to any MCP client. Generate a screen, then ask the IDE to scan and fix — scan is free and local (no model); fix generates designed code; ruleset hands the IDE's own model the design standard.

# one command to add it to Claude Code
claude mcp add edge-state-fixer -- npx -y edge-state-fixer-mcp

# then, after generating a screen:
"scan this component for missing edge states"  # free, local
"add the empty and error states"              # generates code
Results
Results

Working, end to end — and proven on real components.

Ten edge states are detected and generated from a single pasted component. Output is real and component-specific on the Claude subscription — no API key needed. The taste is validated against five hand-designed reference components — an analytics dashboard, a contact form, a data table, a notifications feed, and a search view — each producing states that visibly out-design a re-prompt. The full test matrix is green.

10

states, detect → generate

Five MVP + five V1 states, end to end from a paste, each emitted only where it applies.

292

tests, all green

Playwright across Chromium, Firefox, WebKit & mobile — plus 24/24 engine + MCP unit tests, and a WCAG AA axe gate.

3

surfaces, one engine

Web app, MCP server (on npm) & CLI — the same encoded taste everywhere, fully decoupled.

Reflection
Reflection

What I got wrong before I got it right.

The first version was the lazy one: a single prompt — "add the missing edge states to this component." It demoed fine and fell apart in use. Every run produced different, taste-free output, and a better model would erase the whole thing overnight. That failure is what forced the real insight — the value isn't generation, it's encoded taste — so I threw the thin wrapper out and moved the judgment into a written ruleset the model only fills in.

What I'd do differently: I built breadth — web, MCP, CLI, ten states — before proving anyone wants it. Next time I'd ship the free detector alone first and watch fix-acceptance before building the generator: demand data, then surface area.

Built for leverage: the taste lives in one engine, so it isn't a single app — it's an MCP server (on npm) and a CLI that other tools and coding agents call directly. The encoded judgment travels wherever a developer already works, instead of living behind one UI.

What's next
What I'd do next

Ordered by leverage, not flash.

The through-line. I didn't stop at designing good edge states — I built the system that detects the gap and produces them consistently, and made deliberate calls about how that system earns trust rather than merely impresses.
Interview brief
Anticipated questions

The things people probe for.

Isn't this just a wrapper around an LLM?
No — two things sit around the model that are the actual product. First, a written ruleset that defines what "good" is for each state, applied identically every run. Second, a free, deterministic detector that decides what applies before any model call. Swap the model out and the product still knows what good looks like and where it's needed. The generation is the commodity part.
Why not just re-prompt the code generator to "add edge states"?
Because you get inconsistent, taste-free output that changes every run, and no control over which states apply. This applies one explicit standard — skeleton-not-spinner, empty-as-onboarding, human error copy, inline validation — and only for the states that fit that specific component, classified as present / lazy / missing / not-applicable.
How does it decide what applies without wasting a model call?
A static AST/regex pre-pass reads the code for real cues: data (fetch / useEffect / useQuery), mutation (onSubmit / save / delete), a form, auth cues (403, useSession, isAdmin, ShieldAlert), 429/quota, infinite-scroll. It's reproducible and free. It also avoids obvious false positives — a bare role is treated as a data column, not an auth signal.
How does it avoid inventing business logic it shouldn't?
Hard boundary: it designs the feedback and UI, never the logic. It doesn't write the mutation, the auth check, or the rate limiter. Where it has to assume a rule — a required field, an admin requirement — it surfaces that as an explicit assumption the user can see and correct, rather than silently enforcing something the backend actually owns.
Do I need an API key to try it?
No. Provider selection is automatic: a deterministic mock (used by the e2e suite and offline demos), an Anthropic API key if present, or — the default — the local claude CLI on your logged-in Claude subscription. The live demo runs in mock mode; running locally gives real, component-specific output with no key.
How is it tested, and how do you know the output is actually good?
Two different things, tested separately. Mechanics: a Playwright suite of 292 tests across Chromium, Firefox, WebKit and mobile, engine + MCP unit tests at 24/24, TS strict, Biome, and a written TEST-PLAN mapping every requirement to a test, plus a WCAG AA axe gate. Taste: the five hand-designed fixtures are the ground truth — generated output has to out-design a lazy re-prompt of the original, which is a human design-QA judgment, not something Playwright can assert.
Are you a designer or a developer?
A product designer first — this is a designer-shaped problem, roughly 90% design judgment, 10% model. The building is what makes it unusual: I owned the problem framing, the design, the architecture and every line, and shipped it across web, an MCP server and a CLI myself instead of waiting for a handoff.