The request that looked like a one-line change
A feature request came in that, on paper, sounded almost too simple to plan: "Let therapists open a chart, click on an objective, and drill down into its targets and steps."
Technically, it was basically a one-liner. The data model already had everything — targets, steps, per-session scores, all wired up. The charting library was already in the codebase. The click target was one attribute away from being clickable. If I'd measured the task by "how many files change," I'd have said a day, maybe less.
But "technically simple" and "correct on the first try" are unrelated properties of a feature, and this one was entirely UX-shaped. How do you lay out an unknown number of targets side by side without it collapsing into a wall of tiny charts? Does the section stay pinned while you scroll past it, or does that just eat the screen? What does a chart need to say — not show, say — for a therapist scanning five kids' worth of sessions to actually notice the thing that matters?
None of that gets decided correctly on paper. It gets decided by looking at it, clicking it, and reacting honestly to what's in front of you — which is exactly the kind of feedback that's cheap to get on a static HTML file and expensive to get on a real component tree with real state, real i18n keys, and a real test suite attached to it.
So that's what I did differently this time: I opened a pull request with an HTML file, not a feature. Here's the whole process, and — more importantly — what it actually bought back in product terms.
Step 1: Write the contract before opening an editor
Before touching any code, I write down the shape of the problem in plain language — not "build the feature," but an actual contract: what triggers it, what data it needs, what "done" looks like, and, critically, what's still unknown.
For this feature, the contract read roughly like:
- Trigger: clicking an objective's title on the existing graphs screen.
- Output: a modal — one area chart per target (absolute values, side by side), and below that, one card per target holding a stacked bar chart per step.
- Interaction: clicking a target's title scrolls the modal down to that target's step card.
- Explicit non-goal: no schema change. The existing tables already modeled this correctly; the only gap was that the current query flattened everything down to the objective level instead of preserving target/step granularity.
Writing the contract down before opening an editor is what surfaces the gaps early instead of mid-implementation. Two real product questions fell out of this one immediately: how does the target row behave once there are more than two or three targets, and does that row stay visible while you scroll the step cards below it, or does the whole thing scroll together? Neither has a "correct" technical answer. They're product decisions, and the contract's job is to force them into the open where someone can actually decide them on purpose — instead of whoever writes the CSS three weeks later deciding by accident.
Step 2: Match the tool to the phase, not the phase to the tool
Working with an AI pair means the temptation is to reach for the same mode the whole way through — "have it write the feature." That's exactly backwards for anything UX-heavy. What actually worked was treating each phase as needing a different posture entirely:
- Exploration — facts only. Read-only passes whose sole job was mapping what already exists: where's the click target, what charting library is already in use, what does the query layer look like today, is there prior art for a similar interaction (a modal, a stacked chart, a similar drill-down) anywhere else in the codebase. No opinions at this stage — just facts with file references, because every opinion formed before the facts are in is a guess wearing a confident voice.
- Design — grounded in those facts. Only after the exploration pass came back did I ask for a concrete implementation approach: which existing functions to reuse, which files actually need to change, and what the new query looks like. Grounded in what's real, not in a fresh pattern invented from scratch because nobody checked first.
- Clarification — ask, don't guess. The two open questions from the contract (target row layout, sticky-or-not) got asked directly instead of being assumed. A wrong guess here doesn't cost ten seconds — it costs a whole review cycle to notice and another to fix. Asking costs ten seconds.
- Craft — mockup, and only the mockup. The last phase, and the one this whole post is really about, was building something you could actually click — grounded in the app's real design tokens (colors pulled straight out of the existing CSS custom properties, not invented), real content, and, as it turned out, a couple of rounds of "no, that's not it either."
The discipline that matters here isn't which tool you use in each phase — it's not letting design opinions leak into the exploration phase, and not letting implementation start before the design phase has actually settled anything.
Step 3: Build the mockup with real data, inside its real container
This is the single highest-leverage decision in the whole process, and it's the one I'd underrated before doing it properly this time: the mockup used the product's actual seed data, not placeholder content. Real objective titles pulled from the demo dataset. Real step-progression wording — actual ESDM prompt-fading levels ("with full physical support," "with partial physical support"), not generic "Step 1 / Step 2" labels. Real session dates. Real design tokens, lifted straight from the app's own CSS variables instead of somebody picking "a nice blue" that then has to be reconciled with the design system later.
And it wasn't a chart floating alone on a blank page. It got dropped into a full reconstruction of the dashboard it actually lives in — same header, same surrounding sections competing for the same vertical space, same card chrome. A chart that looks great in isolation and a chart that looks great sitting under four other sections fighting for attention are two completely different design problems, and you only catch the second one if you're honest enough to build the container, not just the widget.
I also wired in the real charting library — not hand-rolled SVG shapes standing in for it — specifically so "does this look right" was a meaningful question, evaluated against the same rendering engine that would actually ship. A mockup that fakes its own rendering engine just relocates the surprise to later.
Step 4: Iterate on the mockup — not on the pull request
This is the part that actually pays the bill. The moment the mockup was clickable, two things surfaced that no amount of upfront contract-writing would have caught, because neither one is visible until you're looking at the real thing move:
The sticky header, which was the "obviously correct" choice on paper, was actively bad in practice. Pinning the target-comparison row made complete sense in the abstract — keep the thing you're comparing against always in view while you scroll the detail below it. In practice, the moment real chart heights were in the picture (not the compressed placeholder heights from the first pass), that pinned section ate most of the modal's vertical space and left almost nothing for the content it was supposedly helping you compare against. Reverting it to a normal scroll took about two minutes once it was visible. It would have taken a design review, a follow-up ticket, and a second PR if it had shipped first.
The invented step names read as exactly what they were — invented. Swapping generic sub-skill labels for the real prompt-fading vocabulary from the seed data turned the "which step is actually driving the extra support load" callout from a UI feature into something that reads as clinically true. That's not a cosmetic difference — it's the difference between a stakeholder nodding along politely and a stakeholder saying "oh, that's the kid I was thinking of, yeah, that's exactly the pattern."
Neither of those is a defect you catch in code review. Code review checks whether the code does what the ticket says. It has no opinion on whether the ticket was right. You only find that out by looking at the thing move — and it is dramatically cheaper to find it out while "the thing" is a <div> in a scratch file than while it's a component tree with hooks, query keys, and translation strings already wired through it.
The part that actually matters: what this saves in a real product
Here's the part I want to make concrete, because "validate before you build" sounds like a truism until you look at where the cost actually sits.
Rework after real implementation is not proportional to the size of the change. A one-line CSS tweak in a static mockup is a one-line CSS tweak. The same one-line tweak, discovered after the feature has shipped through real components, means: touching a component that has state and props flowing through it, possibly touching a hook, possibly touching a query key, definitely re-running the type checker, possibly touching translation strings in two locale files, definitely going through review again, and possibly re-testing a flow that had already been marked done. The size of the fix didn't change. The blast radius of making the fix did.
Every round of that avoided rework is a round of engineering time that goes somewhere else. This is the part that's easy to lose sight of when you're heads-down on one feature: the hours not spent re-litigating a shipped component aren't just "saved," they're redirected — onto the next item in the backlog, onto the bug that's been sitting for two sprints, onto the feature after this one. Across a quarter, the difference between "we validate before we build" and "we build, then fix what we got wrong" isn't a rounding error. It's the difference between shipping the roadmap and shipping most of the roadmap plus a trail of follow-up tickets for things that were "mostly right the first time."
A pull request that is honestly just a mockup gets more honest feedback, faster. This sounds soft, but it's a real, measurable effect on review latency. When a reviewer knows a PR contains zero production code, they say "actually, I don't think this works" without the social friction of knowing that sentence costs someone a day of rewritten components. Bundling the UX conversation with the implementation makes disagreement expensive to raise, which quietly biases reviewers toward approving things they're not sure about. Splitting them apart makes disagreement cheap, which means you actually hear it — while it still costs ten minutes to act on.
It compounds. The second time you run this process on a similar feature, the contract-writing step is faster because you already know which questions tend to be the load-bearing ones. The mockup step is faster because half the design tokens and layout patterns are already proven out from the last one. None of this shows up as a single dramatic number — it shows up as the fourth feature in a related area, taking half the calendar time the first one did, for reasons nobody quite remembers to attribute back to "oh, right, we stopped rebuilding things."
The obvious order, and why it's the expensive one
The default order is: write the code, put it behind a flag or a screenshot, get feedback, refactor. It's not wrong, exactly — it works, plenty of teams ship this way. But the feedback arrives after the expensive part is already done. Every "actually, can the header not be sticky" becomes a diff against real components, real state, real tests, instead of a two-line edit in a file nobody's building on top of yet.
Flip the order — mockup first, iterate cheaply, then write the real thing — and the same disagreement, resolved in the same number of rounds, costs a fraction of what it used to. Same feature, same feedback, same number of "no, try agains. Just paid for at scratch-file prices instead of production-component prices.
That's the whole trick. Not fewer mistakes — the same number of mistakes, caught somewhere cheaper.
Top comments (0)