DEV Community

Renga
Renga

Posted on • Originally published at wisp-gules-mu.vercel.app

I set the font to the largest size and found the same bug eleven times

I was cleaning up the UI on a side-project iOS app and did one thing: set Dynamic Type to XXXL and screenshot every screen.

Reading the code had turned up nothing. The screenshots showed problems immediately.

Eleven of them, in the end. All the same cause.

Here's the conclusion first. "A parent that pins things side by side" × "text that grows" is not a bug, it's a pattern. And in Japanese it breaks reliably worse than in English.

English truncates. Japanese stacks vertically.

Same layout, different failure depending on language.

English wraps at word boundaries and, failing that, ends in . You can't read it, but you can tell something was cut.

Japanese doesn't do that. It can break between almost any two characters, so once a column is squeezed to one character wide, you get one character per line, stacked downward.

Actual output:

Rendered Intended
Paire / d / Macs Paired Macs (broken mid-word)
Claud / e / Code Claude Code
One character per line "実行中のセッション" (Running sessions)
Three step labels stacked vertically A three-step horizontal stepper
De / mo, turning the capsule into a circle A "Demo" badge

Paire / d / Macs is English breaking mid-word. Once the column has only a few characters left, even English gets there. Japanese gets there much earlier.

The cause had the same shape every time

Nearly all eleven were this:

HStack {
  Image(systemName: icon).frame(width: 44)   // fixed
  Text(label)
  Spacer()
  Text(value)                                 // pinned right
}
Enter fullscreen mode Exit fullscreen mode

The 44pt icon and the trailing value claim their width first, leaving the label column a few characters.

The fix: rows that carry a value drop the value to the line below — but affordances like chevrons and toggles stay on the right.

That row component was shared across the whole settings tree, so fixing one place fixed the entire settings screen. Which also means one decision inside a shared component was breaking eleven screens.

ViewThatFits is not a general answer

I used ViewThatFits to switch to a stacked layout. It broke the standard size.

ViewThatFits decides using each child's ideal width. A title that wraps reports its unwrapped full length as its ideal width. So the side-by-side candidate was judged "doesn't fit" and the badge dropped below the title at the standard size.

I could not have reasoned my way to this. I found it by screenshotting the standard size.

Where each one belongs:

  • ViewThatFits — when every child is short and has an intrinsic width (badges, two-word metrics)
  • An explicit isAccessibilitySize branch — when one of the children is a sentence that wraps

Only pin things to the right if you operate them in place

I reversed one of my own calls partway through.

I'd classified a .menu Picker with Toggle, as something that belongs pinned right. A screenshot of the audit-log screen disproved that — the filter label came out as 絞り… / 込み.

The rule I ended up with:

  • Pin right — controls whose state changes in place (Toggle)
  • Allowed to take the full-width row — things that merely open something (.menu Picker, chevron)

The modals had never been tested at all

I had a UITEST_FORCE_ACCESSIBILITY_XXXL env var for UI tests. It applies .dynamicTypeSize to the root view.

That does not propagate into a .sheet's environment.

So the paywall and the setup guide had never once been rendered at the largest text size. What was passing was "the tests are green," not "the modals were checked."

Now it's applied from the system side:

xcrun simctl ui <udid> content_size accessibility-extra-extra-extra-large
Enter fullscreen mode Exit fullscreen mode

I declared convergence twice

After fixing six, I wrote that the pattern had converged. It hadn't.

There were simply screens I hadn't looked at. When I did, four more appeared — numbers seven through ten. Then I went through the QR scanner, host-add and pairing flows in Japanese at XXXL, confirmed zero new findings, and only then called it converged at eleven.

The lesson is: don't declare convergence until you've actually looked at the screens. Judge by how many screens you looked at, not by how many you fixed.

And, incidentally, zero pluralization

While reading the audit log, 1 items need attention caught my eye. I swept the strings: of 38 English strings with a quantity placeholder, zero had plural variants.

  • 1 items need attention
  • 1 saved Macs
  • 1 days free (on the paywall)
  • 1 sessions, 1 active…in a share-sheet body, i.e. it leaves the device

Thirteen keys got variants. Japanese has no plural agreement, so nothing changed there. Strings like %d of %d Macs, where the number doesn't agree with the noun, were deliberately left alone.

The verification order mattered. Add one key, build, confirm 1 item needs attention on a real screen, then roll out the rest. Finally, read en.lproj/Localizable.stringsdict out of the build product and confirm NSStringPluralRuleType was generated correctly.

Things I deliberately didn't change

Recorded honestly, on the principle of not manufacturing work:

  • The list screens survive XXXL intact
  • Onboarding is fully reachable by scrolling at XXXL (clipping at the boundary is normal ScrollView behaviour)
  • The top padding in settings is the standard .large navigation title layout — the same as Apple's own apps

What I'd take away

  • "Fixed horizontal layout" × "growing text" is a pattern, not a bug. Find one, suspect every screen
  • Japanese breaks worse than English. English truncates; Japanese wraps anywhere, so it stacks vertically
  • One decision in a shared component broke eleven screens — and fixing one place fixed all of them
  • ViewThatFits judges on ideal width. A wrapping sentence among its children will break your standard size
  • Pin to the right only what the user operates in place
  • Env vars don't reach sheets. "Tests are green" is not "I looked at the modal"
  • Don't declare convergence until you've looked at the screens

Reading the code found zero of the eleven.


Separately from this, I build a desktop AI agent called Wisp.
It stands on your desktop, answers when you talk to it, and runs commands when you ask — always showing you what it's about to do first.

Top comments (0)