Sam Joyce

Nothing else moves

On invariants: the promise a tool makes about everything it was not asked to change, in layouts, in source files and in coordinates.

Contents (5)
  1. A font swap is an edit
  2. Comments are bytes too
  3. Rounding compounds
  4. Reveal without displacement
  5. Write the promise down

Most edits are small. A font finishes loading; a formatter removes a comment; an image is cropped, then cropped again. Each has a primary effect, the one you asked for, and a set of things it should leave exactly where they were. When a tool keeps that promise, nobody notices. When it breaks it, the failure shows up somewhere else: a paragraph jumps under your thumb, a compiler error points at the wrong column, a click lands two pixels off.

That second set is the subject here. Call it the tool’s invariant: a property of the input that survives the edit. Byte length unchanged. Every line box where it was. This pixel in the zoomed view is that pixel in the original. The button is still under the pointer. Invariants rarely make a feature list, because a kept invariant looks like nothing happening. They are part of the contract anyway, and whoever is downstream relies on them whether or not anybody wrote them down.

Each section below takes one small edit and gives you a figure where you can watch its promise break. The figures run in the page, and their numbers are measured or computed while you use them.

A font swap is an edit

A page that uses a web font often paints before the font arrives. With font-display: swap, the browser sets the text in a face it already has, then sets it all again once the real one has loaded. Nobody asked for that second layout. It happens because the two faces disagree about how wide each letter is and where a line of text sits inside its line box.

Literata is this site’s text face. Its fallbacks are tried in the same order everywhere: Georgia, then Times New Roman or Liberation Serif, then Noto Serif. Fig. 1 probes your device for that list.

Width decides where lines break. A word that fitted at the end of a line in the fallback may not fit in the web font, so it drops to the next line, and every line after it starts from somewhere new. The paragraph gains or loses a line and everything below it moves. Height is subtler. line-height fixes the size of each line box, but the face’s ascent and descent decide where the glyphs sit within it, so text can jump vertically even when every line breaks in the same place.

The invariant worth aiming for is that line boxes stay put. Two typefaces will never be identical, but you can make one occupy the same space as the other.

Fig. 1 · fallback metrics

17/28 px · 60 words

Literataa local serif

When a page first paints, its web font is often still on the way, so the browser sets this paragraph in a face it already has. Moments later the real face arrives and every line is set again. Where the two faces disagree about width or height, words cross line ends, the paragraph changes height, and everything after it moves.

@font-face {  font-family: "Literata Fallback Georgia";  src: local("Georgia");  /* ascent-override: 109.3141%; */  /* descent-override: 28.6056%; */  /* line-gap-override: 0%; */  /* size-adjust: 107.6714%; */}
Fallback face
Found on this device
–
Lines
–
Height
–
Last line
–
On swap
–
Describe this figure

Two copies of the same 60-word paragraph, one over the other, 17 px on a 28 px line. In ink: Literata. Behind it, in ochre: a local serif.

The measurements need JavaScript and run on your device.

The same paragraph in Literata and in a serif from your own device, one over the other. Raw is the local face as it is; Matched adds four descriptors. Swap does what the font swap does.

Press Swap in Raw and watch the paragraph rewrap. The readouts are taken from the page in your browser, so they depend on which fallback your device has. Then switch to Matched and swap again. The line counts should agree and the height change should fall to zero, or close to it. What’s left comes from individual letters, which still disagree: the last line ends a little earlier or later, and now and then a word that only just fitted crosses a line end.

The fix is four descriptors on the fallback’s @font-face rule. size-adjust scales the fallback, outlines and metrics together, until its average letter is as wide as the web font’s. ascent-override, descent-override and line-gap-override then replace its vertical metrics, so its text sits where the web font’s would. This site uses Georgia when it can:

css
@font-face {
  font-family: "Literata Fallback Georgia";
  src: local("Georgia");
  ascent-override: 109.3141%;
  descent-override: 28.6056%;
  line-gap-override: 0%;
  size-adjust: 107.6714%;
}

The values are read from the font files rather than tuned by eye; this site’s were computed with Capsize from its own subset of Literata. They also hide a small check. size-adjust scales the overrides too, so an override times its face’s size-adjust is the ascent the fallback ends up with. Do that for the Georgia, Times and Noto rules and all three come out at 117.70%. They were fitted to the same face, so they should.

Safari applies size-adjust (since version 17) but not yet the three metric overrides, so there the fallback still sits a little higher or lower than Literata until the font arrives.

None of this needs JavaScript, and the browser applies it before the first paint. The alternative, font-display: optional, avoids the second layout by keeping the fallback whenever the font is late, which on a slow first visit can mean the font never appears on that page. Matched metrics keep the swap and lose most of the jump.

Comments are bytes too

Positions are how developer tools talk to each other. A compiler reports file:line:column. A source map pairs positions in generated code with positions in the source. An editor jumps to an offset, and a language server sends ranges. Every one of those assumes the file it points into is the file the position was measured in.

The C standard takes the deleting route: in translation phase 3, each comment is replaced by one space character. A compiler can afford that because it tracks where everything came from. A tool that writes a new file for other tools to read has no such side channel.

So a tool that rewrites source text has an invariant whether it declares one or not. Take stripping comments. Delete the comment bytes and every byte after them moves back by the comment’s length; a block comment that spans lines takes its newlines with it, so later line numbers change as well. Write a space over each comment byte instead, keep every newline, and no position moves at all. The output is the same length, and a position measured in one file names the same token in the other.

Fig. 2 · the byte ruler

C · one cell per byte

nine lines of C with all three: //, "//" in a string, /* */ over two linestokeniser

Input 194 B

Output, delete 111 B

Input
Edit
Size
194 to 111 B
Lines
9 to 8
Offsets
111 moved
At 5:9
u, now (
Describe this figure

Input: nine lines of C with all three: //, "//" in a string, /* */ over two lines, in C. Delete: 194 to 111 bytes, 9 to 8 lines, 111 offsets moved. 5:9 was u, now (.

Comments are found by a tokeniser that skips string and character literals.

Input above, output below, on one ruler with a cell per byte. Washed cells are comment bytes. The outlined cell is a diagnostic’s position, measured in the input.

In the snippet, Delete takes 194 bytes down to 111 and nine lines down to eight. The unused variable u sits at 5:9 in the input. At 5:9 in the output there is a (, so a warning measured in one file and shown in the other points at the wrong thing. Blank keeps all 194 bytes and all nine lines, and 5:9 is still u.

Show the trick swaps the tokeniser for /\/\/.*$/gm, the obvious one-line regex. Try it on the "//" case.

Finding the comments is the hard part, and most of the difficulty is strings.

  • // inside a string literal is text, and so is /*. A tokeniser has to know where string and character literals start and end, escapes included, before it can say where a comment begins.
  • A block comment can span lines. Blank keeps its newlines; Delete removes them, and with them the line numbers of everything below.
  • With Windows line endings, a line comment ends before the \r, not at the \n. Overwrite the \r with a space and the line ending changes, even though the length doesn’t.
  • Zig has no block comments, but /// and //! are doc comments, and a line that starts with \\ is a multiline string in which // is just text.
  • Positions count bytes, not characters. An é is two bytes in UTF-8, so it blanks to two spaces. Writing one space would make the line a byte shorter and move everything after it.

Blanking has a visible cost. Lines that ended in a comment now end in spaces. Trimming them later doesn’t disturb any line:column position, since nothing follows them on their line, but it does move the byte offset of every later line. Whether that matters depends on what reads the file next.

decomment, one of my public repositories, is a dependency-free Zig CLI and library that removes source-code comments while preserving byte length and line positions. Fig. 2 is a sketch of the same promise, not a description of how decomment works.

Rounding compounds

Zooming into an image to read something small is an edit too, to the view rather than the file. Crop a box around the point of interest, scale it up to fill the view, and repeat until the detail is legible. Software agents that work from screenshots do this, because a model often sees a downscaled image and the thing it needs may be only a few pixels across. Whatever it finds then has to be reported in the original image’s coordinates, or the click goes somewhere else.

Here the invariant is that a point in the zoomed view maps back to one point in the original, and it is the right one.

Fig. 3’s presets are illustrative: a 1024-pixel image, a 256-pixel view and three steps of 3×.

Each level of zoom is a transform: move to the crop’s corner, then scale. Images crop on whole pixels, so a reasonable-looking implementation rounds the crop origin to integers at each level and carries on from there. Each rounding is under half a pixel of the view being cut. The next level, believing its crop was centred, cuts from that result and rounds again, and every further zoom magnifies whatever has already gone wrong.

Fig. 3 · zoom drift

1024 px image · 3× per step

Original, 1024 px

LevelScaleErrorOn view
00.25×0.00 px0.0 px
10.75×––
22.25×––
36.75×––
Crop arithmetic
Level
0 of 3
Scale
0.25×
Target
613.37, 287.91
Rounded path
613.37, 287.91
Error
0.00 px
Describe this figure

A 1024-pixel test pattern in a 256-pixel view. Each step crops around the target and scales the crop 3×.

Level 0 of 3, 0.25×. The target is at 613.37, 287.91; the view centre maps back to 613.37, 287.91, 0.00 px away.

A test pattern zoomed in three steps. The diamond is the target; the cross is where the rounded path believes it is. The minimap shows both in the original image.

Step down with Round per level. After three steps the rounded path reports (614.07, 286.07) for a target at (613.37, 287.91), which is 1.97 px out in the original. On the zoomed view the two marks are 13 px apart, and the gap grows roughly threefold with each step. On the minimap they still overlap, which is why this kind of drift survives casual testing.

The fix is to stop rounding in the middle. Keep each level as an exact affine map, a 3×3 matrix; multiply them; map the point back through the inverse of the product. Round once, at the edge of the system, when you hand a coordinate to something that will click it. Compose exactly reports the target at every level, with an error of 0.00 px. If the crops really must land on whole pixels, record the rounded origin you actually used in that level’s matrix. Rounding is fine as long as the arithmetic knows it happened.

pi-image-zoom, one of my public repositories, does coordinate-accurate, recursive image zoom for pi.dev agents. Fig. 3 sketches the general problem, not that tool’s code.

Reveal without displacement

Interfaces make the same kind of promise in layout. Open a disclosure and its contents need room. If they take it from the flow, everything below moves down by the panel’s height, and whatever the pointer was resting on slides out from under it. A reader reaching for the next control presses whatever arrived in its place.

There are three ways to open a panel, and each keeps a different promise.

  • Push inserts the panel into the flow. It is the simplest, and it is what <details> does by default. Everything after the panel moves.
  • Overlay draws the panel above the content, as a popover does. Nothing moves, but whatever it covers is out of reach until it closes.
  • Reserve holds the panel’s space from the start and fills it when asked. Nothing moves and nothing is covered. The cost is space that sits empty while the panel is closed.

Fig. 4 · reserved space

pointer at rest · push
How the panel opens
Delete
–
Under the pointer
Delete
Shift score
–
Describe this figure

A Details row that opens a panel, above a Delete button with a pointer resting on it.

Push, panel closed. The pointer rests on Delete.

A pointer resting on Delete while the panel above it opens. Every readout is measured from the page after the panel settles.

The shift score in Fig. 4 follows web.dev’s definition of Cumulative Layout Shift: for an element that moves, the share of the viewport covered by its old and new positions, multiplied by the distance it moved as a share of the viewport’s larger side. The figure treats its own stage as the viewport. On that scale a single button sliding out from under the pointer barely registers.

One detail of the metric matters here. CLS leaves out shifts that happen within 500 ms of user input, on the grounds that a shift you caused is one you expected. Opening a panel is exactly that case, so the page’s score won’t count it. The pointer still lands on something else. The metric can tell you the page held still while it loaded. Whether it holds still under a reader’s hand is up to the design.

The choice follows from what sits below the panel. Push is fine when nothing anyone is about to press comes after it. For a small panel, reserving its space costs little and settles the question, and an overlay suits content nobody needs while the panel is open. This site reserves space the same way at page scale: scrollbar-gutter: stable keeps room for a scrollbar on every page, so the text column doesn’t shift sideways between a page that scrolls and one that doesn’t.

Write the promise down

An invariant that nobody wrote down is one somebody will break, usually while fixing something else. The four figures suggest a habit.

  1. Name the invariant. “Byte length unchanged” is a sentence a reviewer can check; “handles comments” is not.
  2. Test it as a property, over many inputs rather than one: len(out) == len(in) and lines(out) == lines(in). The tests behind Fig. 2 check both for every input, read as C and as Zig.
  3. Make it visible. A readout that says 194 bytes in and 194 bytes out is a test that runs whenever someone looks.
  4. Measure it in production. For layout that means CLS from real visits, because a lab run won’t meet the slow font on the slow phone.

This site tries to keep to it. The footer says whether a page loads any JavaScript files, and how many figures need them, and the build fails if that sentence is false.

This page shows its own construction:

Published

More writing

Nº 002Previous

Your eval score is a distribution

A benchmark number is one draw from a spread. Four figures on reading it.

essay · 4 figures