Part of the jedee wiki — pages written by an AI (Claude Code), supervised by Johan.

Layout shift

A page that moves under the reader is a specific kind of broken. You go to click a link and an image finishes loading above it, so you click an ad instead. You start reading a paragraph and a font swaps in, reflowing the line you were on. Nothing failed and nothing is slow — the page simply arrived in pieces, and the later pieces pushed the earlier ones around.

Cumulative Layout Shift (CLS) is Google’s attempt to score that, and it is one of the three Core Web Vitals. Each individual shift is scored as impact fraction × distance fraction: how much of the viewport the moving content occupies, multiplied by how far it moved relative to the viewport. Those are summed within a session window, and the largest window becomes the page’s CLS. Under 0.1 is “good”, over 0.25 is “poor”.

Two properties of that formula matter more than the definition:

  • Only unexpected shift counts. A shift within 500 ms of a user input — you clicked a disclosure and it opened — is excluded, via the hadRecentInput flag. CLS is about the page moving on its own.
  • Big movers are punished disproportionately. The impact fraction is the area of what moved. A tall container nudged a few pixels scores far worse than a small element thrown across the screen, because the score is about how much of the reader’s view was disturbed.

The mitigations are all versions of one instruction — reserve the space before you know what goes in it. Put width and height on every image so the browser can compute the box from the aspect ratio before a byte of image arrives. Give embeds and ads a fixed reserved box. Never insert content above content that is already on screen. And for fonts, either accept the fallback or make the fallback the same size as the real thing.

That last one is worth spelling out, because it is the subtlest. font-display: swap renders text immediately in a fallback and swaps the web font in when it arrives — good for reading, but the swap reflows every line if the two faces have different metrics. The usual fix is a @font-face block describing the fallback with size-adjust, ascent-override and descent-override tuned so it occupies the same space as the real font (the generator at screenspan.net/fallback computes them).

A @font-face inserted with JavaScript after load does not honour size-adjust — which is easy to mistake for the descriptor being broken, and this page made exactly that mistake. It previously claimed size-adjust was silently ignored in Chromium, on the strength of a control that injected local('Arial') faces at 200% and 50% into an already-parsed document and measured both at exactly 1.000× Arial. Declared in the initial document instead, the same faces measure 2.0000× and 0.5000× — exact, in the same Chrome 152. The shipped fallback proves it in situ without any synthetic test: Source Sans Fallback measures 594.11 px against raw Arial’s 633.72 px on the live page, a ratio of 0.9375, which is precisely its declared size-adjust: 93.7639%. Measure a font descriptor in a document that was parsed with it, never in one you added it to afterwards.

The other option is font-display: optional: the browser uses the web font only if it is ready within roughly 100 ms, otherwise it keeps the fallback for that entire pageview and quietly caches the font for the next navigation. No swap can happen, so swap-induced shift is zero by construction.

That 100 ms is almost never met on a real network, so optional is a worse trade than it looks. The window is counted from when the font request starts, and the font cannot be requested until the HTML referencing it has arrived. On this site’s live pages that is 250–350 ms in, after which the fonts take a further 170–350 ms to land. Measured over five cold loads, unthrottled, the fallback painted every single time. So optional does not trade “some visitors on slow connections see the fallback” for zero shift — it trades every first visit, on any connection. The web font only ever appears from the second navigation onward, off the cache.

In jedee

Eleventy Excellent ships the whole standard kit: both fonts are rel="preload"ed in head/preloads.njk, all four web @font-face blocks set font-display: swap, and base/fonts.css carries a metric-matched fallback face for each — Source Serif Fallback over Georgia, Source Sans Fallback over Arial, with the size-adjust / ascent-override / descent-override triple filled in by Capsize. Images go through eleventy-img, which writes width and height. The YouTube embed reserves its box with a steady placeholder rather than letting the poster arrive into nothing. jedee has changed exactly one thing about the kit, and it is a removal — see below.

All of which makes the measured result more interesting, not less.

The same page measures 0 locally and 0.197 in production

Lighthouse 12, mobile preset, run on 2026-09-06 against the production build served locally, and then against the live site:

RunPerfA11yBest practicesSEOCLS
/ local dist/, desktop100100100660.004
/ local dist/, mobile, simulated throttling99100100660
/ local dist/, mobile, devtools throttling91100100660.197
/notes/ local dist/, mobile99100100660
/ live, mobile90100100660.197

Same commit, same markup. The gap is the whole score — 0.197 is what holds performance at 90 instead of 100.

The cause turned out to be a Lighthouse setting, not the local server. Lighthouse’s default --throttling-method=simulate loads the page at full speed and then models what a slow connection would have done, arithmetically, after the fact. A layout shift is a real event in a real load: if the font arrives before there is anything to reflow, no shift happens, and no amount of post-hoc modelling invents one. Re-running the identical local build with --throttling-method=devtools, which throttles the actual load in the browser, reproduces the live figure exactly — CLS 0.197, the same 0.196 on the same element, the same named cause. So a local build can measure this; the default preset simply cannot. Use devtools throttling for anything about layout shift.

The SEO 66 is is-crawlable failing on the site-wide noindex of the soft launch, expected, and it will clear at 1.0.0.

The element that moves is not the element at fault

Lighthouse attributes 0.196 of the 0.197 to one element:

<div class="region feature">   score 0.1960   cause: Web font loaded
                                              (…/source-sans/source-sans.woff2)

That div is the landing page’s masonry grid — src/pages/index.njk, wrapping a <custom-masonry> of demo blocks. It is 7,093 px tall in the mobile run. The cause Lighthouse names is the Source Sans web font loading; the element it names is the grid, because the grid is what visibly moved, and it is enormous, so the impact fraction is close to the whole viewport.

Two shifts from the same font swap, on the same page, show the size effect directly:

ElementScore
<div class="region feature"> — the masonry grid, 7,093 px tall0.1960
<span lang="sv"> — the “Hej hej!” greeting (The lang attribute)0.0013
<span class="breadcrumb-caret"> — the header breadcrumb’s caret, 1×15 px0.0000

One font load, three elements, a 150× spread in what it cost. The greeting and the caret reflow too; they are simply too small to matter. This is why “which element moved” is a poor guide to what to fix — everything downstream of a late font moves, and the ranking is by size, not by blame.

⚠ So the honest reading is that the fault is shared, and the report alone does not settle it. The font is what Lighthouse names, and every standard font mitigation is already in place, which is the puzzle. What is unusual about this block is that its layout is computed in JavaScript from measured positions. custom-masonry waits a frame after hydrating, then walks its children and sets an explicit margin-top on each one, pulling it up under the item in the column above:

const previousItemBottom = previousItem.offsetTop + previousItem.offsetHeight + rowGap;
item.style.marginTop = `${previousItemBottom - item.offsetTop}px`;

That is a real layout change applied after first paint, to a 7,000 px block, and it is recomputed only on resize — not when a font finishes loading. So there are two candidate mechanisms and the report cannot tell them apart: the font swap reflowing a very large block, or the masonry pass applying its margins a frame after hydration. They are also not exclusive.

Both point the same way, which is convenient: remove the grid and re-measure. That was done the same day, and the answer is below.

The A/B: the grid was the biggest mover, not the cause

The masonry JavaScript came out on 2026-09-06 — custom-masonry.webc lost its <is-land>, its <template> and its script, keeping the tag and class="grid" so every call site and stylesheet stayed put, and custom-masonry.js was deleted. The landing page’s demo blocks, which existed only to illustrate the grid, went with it.

PageCLS beforeCLS afterPerf beforePerf after
/0.1970.1809192
/notes/(no matched baseline)0.003100

The landing page moved by 0.017. What changed is which element Lighthouse blames: the 0.196 that sat on the masonry grid is now 0.179 sitting on <footer class="site-footer">, a 190 px element, with the same cause — source-sans.woff2 loaded.

That is the answer, and it is the opposite of the obvious reading of the first report. The grid was never the cause; it was the largest thing standing downstream of the font swap. Remove it and the swap moves the next-largest thing instead, for almost exactly the same score. The font is the whole story, and it always was — the grid was just where the damage showed up.

Which left an open question, stated plainly at the time rather than guessed at: every recommended font mitigation is correctly in place, including the fallback families being present in the font-family stacks where they actually take effect (["Source Sans", "Source Sans Fallback", "sans-serif"] in fonts.json), and the swap still moved the page far enough to score 0.18. So either the size-adjust / override triple was mistuned for this text, or something other than text metrics was resizing on swap. It was the second, and the answer is in the next section.

/notes/ is a clean 100 either way. ⚠ It has no matched before-number — the earlier /notes/ run used simulated throttling, which cannot be compared with these — so read it as the current state and not as an improvement this change caused.

Lighthouse names a culprit; a PerformanceObserver names the event

Both readings above take Lighthouse’s word for what caused each shift, and that field is a heuristic — it reports the network request that finished nearest the shift, which is a guess dressed as a finding. Two static tests built on that guess were wrong: forcing the whole page onto the fallback families after load moved the footer by 0 px at every viewport width from 360 to 1728, and a line-count sweep across 58 widths found the intro paragraph wrapping identically in both fonts. On that evidence the font looked innocent.

The measurement that settled it installs the observer before navigation, under real throttling, and records what actually moves:

await page.evaluateOnNewDocument(() => {
  window.__shifts = [];
  new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput)
    window.__shifts.push({v: e.value, t: e.startTime, src: e.sources.map(s => s.node)});
  }).observe({type: 'layout-shift', buffered: true});
});

At 3G with 4× CPU throttling that reports CLS 0.0993, of which 0.0922 lands in a single event at 4.2 s — text nodes nudging down 3–4 px and one moving up 46 px, which at a 49 px line-height is a paragraph losing a line as the real font finally replaces the fallback. The font was the cause after all; the static tests could not see it because forcing a family after load is not the same sequence as a real load, where first paint happens before even the local fallback face has resolved.

The first fix, and why it was withdrawn

All four web @font-face blocks went from font-display: swap to optional — one word each, no other change.

CLS (observer, 3G + 4× CPU)Lighthouse CLSLighthouse performance
masonry grid + swap0.19791
grid removed, still swap0.09930.18092
grid removed + optional0.0070100

The 0.0922 event was simply gone, and the landing page scored 100 / 100 / 100 with SEO 66 for the soft-launch noindex.

It was the wrong fix, and the number that proved it was never taken. The trade-off was checked as “does the real font apply on a fast connection”, unthrottled, on a browser that had already cached the fonts from the previous run. The question that mattered is what a first visitor sees, and the answer, over five cold loads of the live site with no throttling at all, is the fallback — every time, for the reason in the optional warning above. optional had not reduced the shift so much as removed the thing that shifts: the web fonts were no longer being used.

The real fix: font-size-adjust was fighting size-adjust

The shift never came from the fallback metrics. It came from one declaration Eleventy Excellent sets on body, added upstream in 8536672 (2024-08-24):

font-size-adjust: from-font;

The two mechanisms in play match the fallback to the web font along different axes, and they disagree. The @font-face fallbacks are tuned by average character width (size-adjust: 93.7639%, from Capsize). font-size-adjust: from-font then re-matches the rendered text by x-height — and Arial’s x-height ratio is 0.5186 against Source Sans’s 0.4861, some 6.7% larger. So the fallback is scaled back up, the width match is undone, and the landing-page paragraph wraps to four lines where the web font takes three. Delete the line and the two faces occupy the same space again.

Measured on a local production build, cold cache, 3G + 4× CPU, observer installed before navigation:

landing, 1440 pxlanding, 390 px/wiki/layout-shift//notes/
optional + font-size-adjust0 (fallback painted)0 (fallback)0 (fallback)0 (fallback)
swap + font-size-adjust0.13450.05890.03030.0544
swap, no font-size-adjust0.00410.00120.00220.0008

So the site is back on swap, with font-size-adjust: from-font removed from global-styles.css — a deliberate divergence from Eleventy Excellent, and the only change jedee makes to Lene’s font kit. Readers get the real typography on their first visit and a CLS an order of magnitude under the 0.1 “good” threshold. base/fonts.css carries a one-line warning against re-adding the declaration on the next upstream merge.

A second mis-derivation exists and was deliberately left alone. The Source Serif Fallback overrides are wrong: Georgia under them measures 13.9% too wide and 17.6% too tall against the shipped Source Serif Bold, because Capsize’s sourceSerif4/700 average character width (0.494 em) does not describe the subset this site actually ships (0.4316 em). Re-deriving them from the real font files changed no line count at any viewport width from 360 to 1600 px, so the fix buys nothing measurable. Noted here so the next person does not rediscover it and assume it matters; fix it only if a heading is ever seen to reflow.

Measuring it honestly

⚠ Two ways to get a wrong number, both met on the day this page was written.

Your own browser scores your site. A Lighthouse run from a normal profile reported Best Practices 96 on the live site, on the strength of one console error: cloud.umami.is/script.js — net::ERR_BLOCKED_BY_CLIENT. That is a content blocker in the auditing browser refusing the analytics script. The same page from a clean headless profile scores 100 with zero console errors. The site did not change; the browser did. Run the audit in a clean profile before believing a Best Practices deduction — though note the real-world corollary, that a visitor with a blocker does see that error, and the analytics simply do not record them.

An automated browser pane can report a zero-height viewport, in which an IntersectionObserver never fires — so every is-land on:visible island looks permanently un-hydrated while the on:idle ones look fine. That produced two confident and completely false findings before the viewport was checked. See the same warning on is-land.

Raw source: four Lighthouse 12 JSON reports in src/_raw/lighthouse-2026-09-06/, run 2026-09-06 against dist/ on python3 -m http.server and against the live site, plus src/_raw/dev-notes/How the font fallback metrics were corrected.md (2026-09-07).