Craft

The front page lays one continuous text along a square spiral. Nearly everything hard about it was a measurement problem, and most of what I learned came from getting it wrong first. Six of those, with the numbers.

A space is not a letter on iOS

Vertical arms set every glyph upright and step down by the font's vertical metric. WebKit routes the space glyph through its "treat as space" path instead, which uses the horizontal advance even in upright vertical text. Measured on device in SF Mono: 9.6px for a space against 19.1px for a letter. Ten characters of prose and the column has walked half a row off the grid.

No amount of letter-spacing fixes it, because letter-spacing adds the same amount to every glyph. The fix is a second correction that only spaces receive: word-spacing = uprightH − spaceH, taken from a probe made of spaces. A probe of 0000… cannot see the problem at all, which is why it survived so long.

Here is the same rig, measuring the browser you are reading this in:

00000000000000000000 nnnnnnnnnnnnnnnnnnnn00000000000000000000 ....................nnnnnnnnnnnnnnnnnnnn the quick brown fox
glyph classadvance downvs a letter
letters—px
digits—px
spaces—px
punctuation—px
bold letters—px
one character across—pxhorizontal
one line down—pxline-height

measuring…

19 characters, —px tall set upright.

Backwards text, forwards strings

Two of the spiral's four arms run backwards. The obvious implementation — reverse the characters — gives correct-looking output that is unusable: select it, copy it, and you get the sentence inside out. So the arms keep their strings in logical order and set direction: rtl; unicode-bidi: bidi-override, which lays the glyphs out backwards while the DOM stays forwards.

this sentence is laid out backwards

Select that line and copy it. It comes out forwards, because it never stopped being forwards. In a vertical writing mode the same declaration walks an arm from the bottom up, which is how the left edge of the spiral is drawn.

The correction loop I removed twice

Twice I built a loop that measured a real arm after rendering and corrected the letter-spacing by the error it found. Twice I deleted it. It latched onto transient states: it fired before the space probe had reported, decided every glyph was 1.5px short, and then kept that correction forever — including after the real measurement arrived and made it wrong.

What replaced it is duller and correct: probes that carry the exact styles of the thing they measure, one per glyph class, read once. There is no feedback loop anywhere in the layout now, and that is the point. A loop that can be wrong at any instant will eventually be wrong forever.

Arms, not characters

The first version rendered one element per character, which is the natural way to place things on a grid and the wrong way to make text. Browsers insert a newline between block boxes when you copy, so copying the page gave one word per line, and the DOM held several thousand nodes.

Now each straight run of the spiral is a single <span> of real inline text: 30 to 70 nodes for the whole page, and copy returns prose. Everything else — the corners, the reversed arms, the per-glyph spacing — exists to make that one decision possible.

Two animation frames after scrollTo

Measuring scroll performance, I read the DOM after a microtask and found that a scroll update cost 0ms. It cost nothing because it had not happened yet. The same mistake made WebKit look as though scrolling were broken when it was working perfectly.

Two requestAnimationFrame callbacks after a scrollTo, and the numbers become real. It is written down in the repo's own instructions now, because I will otherwise do it again.

Debugging on a phone you cannot attach to

The iOS bug only existed on a device with no inspector. What worked was a temporary ?debug overlay printing every measured metric, the per-glyph-class step, and the grid cell each arm's first and last character landed in — so that a screenshot carried the diagnosis and I could work from a photograph sent across the room.

Bucketing by glyph class is what exposed the space bug. Whole-arm averages hid it, because the average of letters and spaces looked plausible: an error of 0.5px per character is invisible until it has accumulated over a whole arm, at which point it is an entire row.