iOS zoomed my app in and never zoomed back out. So I stopped grepping CSS and asked the browser

javascript dev.to

I was naming a new project in my app, on my iPhone. I tapped the text box, and the whole page zoomed in. The header was cut off on both sides. I finished typing, tapped away, and it stayed zoomed.

If you've built anything for phones, you might already know this one: iOS Safari zooms the page in when you focus a text input whose font is smaller than 16px, and it does not zoom back out. The fix is one line of CSS. Keeping it fixed is the hard part, because any later rule that shrinks an input brings the bug straight back, and nothing looks wrong on a desktop.

So I wanted a test that fails the moment any text input drops below 16px.

Attempt one: read the CSS

My first version read app.css with regular expressions and looked for font sizes on input selectors. It seemed simple. Over three rounds of review it turned out to have six holes, and two of them came from my own attempts to harden it:

  • Stripping out @media blocks swallowed the rule that came after them.
  • A 16px floor inside a media query was read as if it applied everywhere.
  • A CSS custom property looked like it won over the real declaration.
  • Selectors like .dense.pin and :is(.pin) slipped past the class match.

Every fix made the parser a bit smarter and a bit more wrong. The real problem was the approach. A regex can't do the cascade, specificity or inheritance, and all three decide what font size an input actually gets.

The browser already does all three. So I asked it.

Attempt two: ask the browser

The new check starts headless Chrome, loads my app, and asks for the computed font size of every input:

[...document.querySelectorAll('input, textarea, select')].map((el) => {
  const cs = getComputedStyle(el);
  return {
    id: el.id || null,
    type: el.getAttribute('type') || el.tagName.toLowerCase(),
    computedFontSizePx: parseFloat(cs.fontSize),
  };
});
Enter fullscreen mode Exit fullscreen mode

It skips checkboxes, radio buttons and other inputs you can't type into, because iOS never zooms for those. Anything left under 16px fails the test.

Three details mattered more than I expected.

It needs zero dependencies. My app has none, and the PC it runs on has 8GB of RAM, so I didn't want Playwright or Puppeteer just for this. Node 22 and later ship a WebSocket built in, and that's all you need to speak the Chrome DevTools Protocol. Chrome is launched with --headless=new --remote-debugging-port=0, the check reads the port Chrome picked from the DevToolsActivePort file, and it sends Runtime.evaluate over the socket.

It serves the files over HTTP, not file://. My index.html links /app.css with an absolute path. Under file:// that points at the root of the drive, so the stylesheet never loads and every input reads back at the browser's default size. That's a false failure today. Worse, it becomes a false pass the day someone removes the 16px rule. So the check runs a tiny throwaway server on a random port.

It measures at a phone size too. Headless Chrome starts at 800 by 600, so a rule inside @media (max-width: 430px) never applies there. A phone breakpoint is exactly where an input is most likely to get shrunk, and review caught that my first browser version never saw it. Now the check measures at 1280 by 900 and again at 390 by 844 in mobile mode.

A test that can't fail isn't a test

A check like this can quietly pass forever while checking nothing. So the suite proves it can fail. It copies the app into a temp folder, adds one line to the stylesheet copy, and expects exactly that input to be caught:

fs.appendFileSync(
  path.join(tmp, 'app.css'),
  '\n.newproj-panel input { font-size: 12px; }\n',
);
// ...
assert.equal(report.textInputsBelow16[0].id, 'newproj-name');
assert.equal(report.textInputsBelow16[0].computedFontSizePx, 12);
Enter fullscreen mode Exit fullscreen mode

A second test does the same thing inside a phone-sized @media block. Both work on a copy, because node --test runs files in parallel and another test reads the real stylesheet.

A skipped guard is a green run

The last decision was what to do when Chrome isn't installed.

Skipping looked polite. But node --test exits 0 when a test is skipped. On a machine without Chrome, the only check that can catch this bug would vanish, and the run would still report every test passing.

So it fails by default. If you really can't run it, you have to say so out loud with ALLOW_NO_CHROME=1, and the skip message says what's now unchecked.

That rule caught me this week, on my own PC. My Chrome is a per-user install, which the lookup doesn't search yet, so the suite went red until I pointed CHROME at it. Mildly annoying. It's exactly what I asked it to do.

What I took away

  • If a tool already answers the question, ask the tool. The browser knows what font size an input ends up with. My regex was guessing.
  • Prove every guard can fail, against a copy, before trusting it when it passes.
  • Treat a skip as a failure unless someone typed the opt-out.

This is part of Claude Remote, a small app I'm building that starts Claude Code sessions on my own Windows PC from my phone. It isn't public yet.

If you want to follow along: https://github.com/MrTig-afk

Source: dev.to

arrow_back Back to Tutorials