DEV Community

John
John

Posted on • Originally published at hexisteme.github.io

The AI Can't See What It Drew

Originally published on hexisteme notes.

A while back I wrote about why your vibe-coded app looks worse than you expect. That post diagnosed the cause. This one is the fix that actually worked, on a real job: redesigning the mascot in my trip expense-splitting app.

The mascot is the face of the app. It shows up in more than twenty places — onboarding, settings, the stats screen, the map, the diary, the settlement report, and five little mini-games. And it was nothing. One circle did double duty as head and body. No legs. No hands. No eyebrows. One X for an eye. Visually its identity was zero: a tinted circle. I knew it was bad. What I could not do was say what to change.

Words don't converge on a picture

I kept talking myself in circles about it, and so did the AI I was pairing with. Rounder? Add a hat? Bigger eyes? Every sentence sounded reasonable and none of them moved the decision. At some point I noticed what was actually going on: this was not a shortage of information. Nobody needed to go fetch a fact. It was a shortage of fidelity. A visual decision cannot converge in prose, because prose is not the medium the decision lives in.

That is the tell. When a discussion loops and more words don't help, you don't need more analysis — you need a picture. So I stopped arguing and built prototypes.

Three variants, not more tints

The rule I gave myself: make variants that are structurally different, not palette swaps. Different silhouette, different anatomy, a different device carrying the identity. Repainting the same shape in different colors teaches you nothing. Three genuinely different creatures force a real choice.

I built three and rendered every one as an action sheet so I could look at them side by side:

  • A, a jelly bean. The safe evolution of what I already had. It slots into the UI cleanly, but its whole identity hangs on a single coin floating over its head. Shrink it and it's just a round blob again.
  • B, a wallet. Object personification: a wallet body with a flap, a snap button, stitching, banknotes peeking out the top, and stubby limbs, with the face on the wallet's front. Emotion gets a second channel — the banknotes pop up higher when the mood is up.
  • C, a cat. Maximum facial expressiveness, which is great for the games. But "treasurer" is carried only by a scarf, and the link to the travel and money context is thin.

Then I looked. Not at the code — at the pictures.

The bug a code review cannot catch

The first render of the wallet had a problem you would never find by reading the diff: the arms read as legs. The little guy looked like it had four legs and no arms.

The cause was one line. The arm's rotation anchor was set to anchor: .bottom — the fingertip end — so the arm rotated around the hand instead of the shoulder. The shoulder was swinging around a pinned fingertip. In code that is a completely ordinary, plausible-looking value. .bottom is a normal anchor. Nothing about the source says "this will look like a leg." You cannot see it by reading. You can only see it by rendering.

This is the whole point, and it is why the essay is called what it's called. The AI that wrote that line could never have seen the result. It emitted syntactically fine SwiftUI, the type checker was happy, a human scanning the diff would nod it through — and the thing on screen was wrong. An AI writing UI code is blind to its own output. So are you, in a review. The only cure is to render and look.

Grep before you touch a contract

While I was in there, I nearly caused a regression. The mascot's initializer takes an expression parameter, and I assumed expression meant facial expression. Since I was rebuilding the face anyway, I went to delete it.

Before I did, I grepped the call sites. expression was not the face at all. It was a screen-context badge channel — a small marker for language, map, stats, diary, spend, or Pro — and eight call sites were passing it. Deleting it would have silently broken a feature in eight places that had nothing to do with the face I was changing.

The lesson is boring and it keeps paying rent: read every call site before you change a parameter's contract. A name is a guess about meaning; the call sites are the meaning. I kept the badge, and decoupled it from the body's wobble so both read more clearly.

Why the wallet won

I picked B, the wallet. The reason is one sentence: the silhouette alone says "money app," and for a mascot on an expense app, saying what the app is is the whole job. A tinted circle said nothing. A wallet says it before you've read a single word.

Emotion ended up with three independent channels: the face (a MascotMood type with seven states — eyebrows, eyes with a highlight for a bit of life, mouth, a cheek touch), the banknotes popping with the mood, and squash-and-stretch on the whole body. The old mascot had one channel, a mouth shape.

The part I'm quietly happy about: it dropped in with the interface unchanged. The initializer is still AnimatedMascot(tint:direction:action:size:expression:speed:externalPhase:). All twenty-plus call sites stayed exactly as they were, and the build went straight to BUILD SUCCEEDED. A redesign that reads as a rewrite from the outside but touches one file on the inside.

Here is the before and after:

Old Wallet
Silhouette one circle (head = body) wallet + flap + banknotes + limbs
Legs and hands none present (walk actually walks)
Expression mouth only, no eyebrows seven-state mood with eyebrows
Eyes white plus pupil plus a highlight
Identity none wallet = money
Emotion channels one three

Pre-register what would prove you wrong

Liking a render is not proof. Before I committed, I wrote down what I would observe if the choice were wrong — falsifiers — and then went and checked them in the real app: built and run on the simulator (iPhone 17 Pro Max, iOS 26.3) for the big screens, plus a headless size ladder rendering the production mascot at 28, 36, 44, 64, and 96pt across idle, wave, stunned, and laugh.

  • "The wallet is unreadable at small sizes and looks like a rectangle." Did not fire. The wallet is clearly readable at 36 and 44pt — flap, banknotes, stitching, and limbs all identifiable. List rows are 44pt, so they're safe. Only at 28pt does it degrade to a coral blob, and 28pt appeared in exactly one spot, a single game accent. I bumped that one to 36pt.
  • "Emotions are illegible in the mini-games." Partly true. Emotions are clear at 64pt and up, and the shared defeat mascot is 72pt, so the big moment is fine. But small 28–36pt game accents do blur taunt versus laugh versus stunned — the face is small relative to the body. That was a pre-registered risk, and I accepted it with a planned size bump.
  • "The travel context is lost." Still open. A wallet says money, not travel. For now the onboarding and header copy carry the travel context in text, and I'm leaving this one for real user feedback rather than guessing.

Two of three either didn't fire or were survivable, and the one that's open is open honestly, with a stated plan. That's a decision I can defend later, because I wrote down in advance what would sink it.

Even the verification surface needs verifying

One more thing, because it's a good reminder. While checking all this in the simulator, every emoji rendered as a tofu box — the little "?" rectangle. My first instinct was that I'd shipped a bug.

I hadn't. A grep showed zero custom fonts anywhere; every emoji goes through a plain Text on the system font. That means on a real device it falls back to Apple Color Emoji and renders fine. The tofu was an iOS simulator runtime artifact — the emoji font simply wasn't loaded in that build of the simulator — not a product bug. The mascot itself, being pure SwiftUI shapes, was fine at every size.

The lesson: the surface you verify on can lie to you too. One render told me the truth about the arms and a different render told me a falsehood about the emoji. You have to know which surface you're looking at.

The loop that works for visual decisions

Stripped down, here's the loop that came out of this:

  1. Diagnose fidelity versus information. If the discussion loops and more words don't help, you have a fidelity problem. Render — don't debate.
  2. Build three structural variants, not more tints. Different silhouette and anatomy, not different colors.
  3. Render and look before you review the code. The AI can't see what it drew and neither can you in a diff. Screenshots are ground truth; source is not.
  4. Grep the call sites before touching a contract. A parameter's name is a guess; its uses are the meaning.
  5. Pre-register what would prove the choice wrong, and check it at real sizes. Liking a picture isn't proof.

The prequel explained why vibe-coded UI comes out worse than you'd think. This is the answer: stop reviewing code you can't see, and start rendering pictures you can.

More notes at hexisteme.github.io/notes.

Top comments (0)