DEV Community

Lucas Martin
Lucas Martin

Posted on

What Users Actually Feel When Your AI-Generated Code Is 'Fine': A UX Autopsy of 3 Shipped Features

  • AI-generated code is often technically correct and experientially broken — it solves the ticket, and the ticket rarely describes the feeling.
  • Three real examples: a confirmation modal with no friction (14 accidental account deletions), a naked payment spinner (−4.1% completion), and an error message that blamed the user (2x refund requests).
  • Three prompt clauses — emotional stakes, latency honesty, blame avoidance — caught ~70% of these before review.
  • A 4-item UX regression checklist for AI-authored PRs, none of which a linter can catch.

Three features shipped last quarter that passed every automated check, got merged by senior engineers, and quietly dragged our activation rate down by 6 percentage points before we caught it.

All three were AI-generated. None had a bug in the traditional sense. Here's what actually went wrong.

The uncomfortable truth: most AI-generated code is technically correct and experientially broken. The model doesn't know what a user feels at 11pm on a phone with 40% battery trying to check out. It writes the code that solves the ticket. The ticket almost never describes the feeling.

Feature 1: the confirmation modal that never confirmed

Ticket: "Add a delete-account button in settings with a confirmation modal."

What shipped:

<Dialog>
  <DialogTitle>Delete account?</DialogTitle>
  <DialogDescription>
    This action cannot be undone.
  </DialogDescription>
  <DialogFooter>
    <Button variant="outline" onClick={close}>Cancel</Button>
    <Button variant="destructive" onClick={deleteAccount}>Delete</Button>
  </DialogFooter>
</Dialog>
Enter fullscreen mode Exit fullscreen mode

Technically perfect. Passed a11y checks. Matched the design system. Support tickets over the next three weeks: 14 users accidentally deleted their accounts.

What was missing: the type-your-username-to-confirm friction step. GitHub does it. Stripe does it. Every consequential delete flow on the internet does it. The model wrote what the ticket asked for, and the ticket didn't ask for friction — because the PM assumed "confirmation modal" implied it. It doesn't. To a model, a confirmation modal is exactly two buttons.

User interviews afterward:

"I thought Cancel was the red one because it was the destructive action."

"My thumb just... hit it. I don't even remember reading the modal."

The fix was 8 lines. The lesson was bigger: the model needs the emotional stakes in the prompt, not just the functional requirements.

Feature 2: the loading state that stole 800ms of trust

Ticket: "Add a loading spinner while the payment processes."

Shipped code, paraphrased:

{isProcessing ? <Spinner /> : <Button onClick={pay}>Pay $49</Button>}
Enter fullscreen mode Exit fullscreen mode

Technically correct. But what the user saw:

  1. Click "Pay $49"
  2. Button disappears, spinner appears
  3. ~1.2 seconds of a spinning circle with no context
  4. Success screen

We A/B tested it against this:

{isProcessing ? (
  <div className="flex items-center gap-2 text-sm text-muted-foreground">
    <Spinner />
    <span>{stage}</span>
  </div>
) : <Button onClick={pay}>Pay $49</Button>}

// stage cycles: "Contacting your bank..." → "Confirming payment..." → "Almost done..."
Enter fullscreen mode Exit fullscreen mode

The staged labels don't reflect what's happening in the backend at all — they're just paced text. Result: completion rate on the payment step went up 4.1%. Users who felt informed didn't bounce; users who watched a naked spinner did.

The model didn't know a spinner is a UX cliff. It knew a spinner was the canonical solution for a loading state. Those are different things.

Feature 3: the error message that blamed the user

Most AI-generated error handling has this shape:

try {
  await createOrder(input);
} catch (e) {
  toast.error("Invalid input. Please check your details and try again.");
}
Enter fullscreen mode Exit fullscreen mode

Every word in that message pushes the failure onto the user. "Invalid input." "Check your details." "Try again."

One user in a session recording:

"I checked. Twice. It's not my card. Forget this." (closes tab)

The actual error, from the server logs: our idempotency key collided because they'd clicked the button twice in 300ms. Nothing was invalid. The order had gone through on their first click.

Rewrite:

try {
  await createOrder(input);
} catch (e) {
  if (e.code === "idempotency_conflict") {
    toast.info("Looks like this went through already — refreshing your order list.");
    await refetchOrders();
    return;
  }
  toast.error(
    "Something on our end didn't work. Your card wasn't charged. Try again or ping support — we'll fix it."
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things changed: we handled the specific error case the model glossed over, and the generic fallback puts the blame on us, not the user, while telling them their card is safe. Refund requests on that flow dropped by half.

What the model can't feel — and how to compensate in the prompt

The pattern across all three: the model wrote sufficient code, not humane code. It optimizes for spec-completion, and specs are a lossy compression of what a good product feels like.

What we added to our prompts:

  • Emotional-stakes clause: "This flow is a delete action. Real users will lose real data if they mis-click. Design the friction accordingly."
  • Latency-honesty clause: "Any async call over 400ms must show a labelled progress indicator, not a naked spinner. Prefer 3 pacing labels over a single 'Loading...'."
  • Blame-avoidance clause: "Error messages must not use 'Invalid', 'Wrong', or 'You'. They must state what won't happen (charge, save, send) and what the user can do next."

Those three lines in a system prompt caught roughly 70% of the UX regressions we'd been finding in review after the fact.

A UX regression bar for AI-authored PRs

We now run every user-facing AI PR through a 4-item checklist before merge:

  1. Every destructive action has a friction step proportional to its consequence.
  2. Every async op over 400ms has a labelled progress state, not just a spinner.
  3. Every error message names what won't happen and offers a next step.
  4. Every empty state has a first-run tip, not just "No items yet."

None of these are things a linter can catch. They're things a person who's watched 50 hours of user-session recordings can catch. Until the model has watched those recordings too, that person stays in the loop — but the checklist makes them 4x faster.


If you're building a new SaaS and don't want to relearn these the hard way, most of the patterns above — typed-confirm modals, paced loading states, human error copy — are the kind of thing a mature template bakes in by default. The Applighter starters ship with them because they were extracted from exactly this kind of production autopsy.

Which of the four checklist items would your last AI-authored PR have failed? Mine was the naked spinner — twice.

Top comments (0)