Fine in Chrome, broken in Safari: two bugs a kids' app only caught by checking twice
A Zhuyin-teaching app for my kid rendered perfectly in Chrome — twice. One bug only surfaced in Safari, the other only in an actual print preview. Both times the fix was checking a different lens, not staring harder at the same one.
Background
bopomofokids.com is a free, ad-free web app that teaches Traditional-Chinese Zhuyin (Bopomofo, ㄅㄆㄇ) to a 4-to-6-year-old — my wife built the first version to practice with our own kid, and I picked it up and grew it into a full React SPA with seven game modes. No backend, no accounts, no API keys; the reading audio is real human recordings from Taiwan’s Ministry of Education, not synthesized speech.
Being static and client-only means almost all of the risk in this codebase is rendering risk, not server risk. And the audience skews heavily toward phones and iPads — a lot of them iOS. Twice now, a change looked completely correct in the browser I happened to have open, and was actually broken for a meaningful slice of that audience. Both times, the bug only existed from a different vantage point than the one I’d checked.
Bug 1: vertical-rl fell over in WebKit
Zhuyin annotation belongs to one character at a time: the phonetic letters
for a syllable stack top-to-bottom in a narrow column to that character’s
right, with the tone mark floating in its own smaller column beside them. The
first implementation of this leaned on the browser’s own CJK vertical text
shaping to do the stacking — writing-mode: vertical-rl on the letter
column, so the letters would lay out top-to-bottom for free instead of being
positioned by hand.
It looked exactly right in Chromium. It only fell apart once the e2e suite
ran the same assertions against a second engine: in WebKit, vertical-rl
scattered the letters out of their stacked order — one that should render
upright came out sideways, letters detached from the ones next to them —
while the identical markup stayed correct in Chromium the whole time.
The fix removed the special layout entirely: the letter column is now a plain
flex flex-col stack of individual <span> elements, one span per letter,
positioned by ordinary block layout instead of vertical text shaping. It
renders identically across engines for the boring reason that it isn’t doing
anything engine-specific — just stacking boxes.
Since then, the project’s Playwright config runs this component’s spec on
both chromium and webkit projects on purpose, specifically because
this is the bug that only reproduced in one of them. A chromium-only run
would have shipped it again.
Bug 2: the print layout that took three tries
The app has a printable flashcard sheet — all 37 symbol cards, laid out for an actual kindergarten parent to print and cut up. Getting 37 cards to paginate cleanly across physical A4 pages turned out to need three separate attempts, and the first two both looked fine on screen.
Attempt 1 — CSS grid, break-inside: avoid on each card. Cards still
split in half across the printed page boundary. break-inside /
page-break-inside is unreliably honored inside display: grid (and
display: flex) containers in Chrome and Safari’s print pagination — a
long-standing engine limitation, not a bug in the CSS written here (see
Chromium issues 616228
and 719908,
and Firefox bug 1375736).
Attempt 2 — CSS multi-column (columns-3). This computed a correct
3-column layout on screen. Once the container’s total height spanned many
printed pages, though, Chrome filled column 1 across several pages before
ever moving to column 2 or 3 — one full-width card per printed page, roughly
eight pages for 37 cards instead of the expected three or four. That’s a
distinct multicol-and-print-fragmentation interaction, not a repeat of
attempt 1’s issue.
What actually worked — a flat list of row <div>s. The 37 cards are
hand-chunked into rows of 3, each row an ordinary block-level <div> that is
internally flex but is not itself part of any grid, flex-wrap, or
multi-column container at the outer level:
{chunk(ALL_SYMBOLS, PRINT_COLUMNS).map((row, i) => (
<div key={i} className="flex gap-4 break-inside-avoid">
{row.map((item) => (
<PrintCard key={item.id} item={item} />
))}
</div>
))}
print sheet — deliberately flat rows, not grid/columns
Each row is a plain block box — the one case break-inside: avoid reliably
supports — and each row is far shorter than a full page, so pagination just
moves a whole row down when it doesn’t fit. No column-fill interaction, no
per-card fragmentation.
The thread connecting both
Neither bug was found by looking harder at the same screen. Bug 1 was found by pointing the same assertions at a second rendering engine instead of trusting the first one that passed. Bug 2 was found by switching from “does this look like three columns on my monitor” to “what does an actual printed page look like” — and even the automated version of that check only works if the tool is told to emulate the real output medium, not left at its convenient default.
Takeaways
Chromium rendering a component correctly says nothing about WebKit, and a browser window rendering a layout correctly says nothing about a printed page. Both are real, common output targets for this app’s audience — and both needed to be checked as their own case, not inferred from the case that was already open.
break-inside is unreliable-to-ignored inside grid, flex, and multi-column
containers in current Chrome/Safari print pagination — a known engine gap,
not something a cleverer selector fixes. A flat list of ordinary block
boxes sidesteps the whole class of problem instead of fighting it.
Playwright’s page.pdf() at its default (wide) viewport doesn’t reproduce
print pagination at all — it’ll pass on a layout that fails in a real
print preview. A tool being available isn’t the same as it being
configured to actually exercise the scenario you care about.