Foundations · 11

Mobile

There is no mobile version of this site. There is one page that narrows, and this is the record of what it does on the way down — measured on the shipping pattern pages rather than described from the stylesheets.

One layout, narrowed

Every page is built from components that stack by default and split when there is room. The narrow state is not a fallback bolted on at the end: it is the state the CSS starts in, and the wide layout is what a query adds. A component that is never asked about its width is already correct on a phone.

That is why almost nothing here asks the viewport. A component asks its container, so the same card is right in a 1280 px content column, in a half-width split and in a 375 px phone without knowing which it is in. Only furniture genuinely pinned to the screen — the nav bar, the hero, the footer, the consent banner — is allowed to ask the viewport, and each of those exceptions is written down in the breakpoint register in tokens.css.

This page is the register's other half. The register lists thresholds as the numbers a query is written against — mostly container widths. A phone does not have a container width; it has a viewport. Everything below is the same set of thresholds seen from the device's side, with the viewport width at which each one actually trips on a shipping page, found by binary search rather than by arithmetic. → Layout & Grid

What changes continuously

Before any threshold fires, three things are already moving with the viewport. They are clamp()s, so each has a floor, a ramp and a ceiling, and the widths where the arms swap are as real as a breakpoint — they are just not queries, which is why the register deliberately leaves them out.

TokenFloorRampCeilingFloor binds below
--gutter20 px5.5vw80 px364
--section-gap64 px100vw / 12160 px768
--section-gap-sm32 px100vw / 1880 px576
--text-display-140 px1.4rem + 4.9vw64 px359
--text-display-232 px1.3rem + 3.1vw40 px361
--text-h128 px1.3rem + 2vw40 px360
--text-h222 px1.1rem + 1.2vw32 px367

All four type floors land within 8 px of each other, just under the narrowest phone in use. 359, 360, 361, 367 — the scale stops shrinking at about 360 and holds. That is worth knowing because it means a 320 px device and a 360 px device get identical type; only the gutter and the measure differ. Measured at 320 and at 360: display-1 is 40.00 and 40.04 px.

Viewportdisplay-1display-2h1h2gutter
32040.0032.0028.0022.0020.00
37540.7832.4228.3022.1020.63
39041.5132.8928.6022.2821.45
76860.0340.0036.1626.8242.24
144064.0040.0040.0032.0079.20
Every figure in these two tables is now re-derived in CI rather than trusted. The numbers here look like measurements and are actually derivations — "floor binds below" is a clamp solved for the viewport — so they can go stale the way this page's overflow count did four times: a ramp is retuned, the true answers move, and every screenshot still renders correctly. scripts/check-fluid-record.py parses the seven clamp() declarations and fails whenever any copy of the record — these two tables and the crossover sentence below, or the token, rhythm and stops-growing tables on Layout & Grid — disagrees with what the stylesheet computes. If it fires because the stylesheet moved, every failing figure it prints is the new correct value: update all the copies in the same commit. If only the record moved, restore it — the stylesheet is the source and this page is the record, in that order.

Body copy is 14 px at every width and does not participate. It is not a display size, it is the reading size, and a reading size that changes with the window is a reading size nobody has tuned. What changes on a phone is the measure, which the gutter and the single column take care of.

The order things happen in

Widest to narrowest, every layout change a page makes on the way to a phone. Each row was found by binary-searching the viewport width on the pattern page named — set the window one pixel either side of the number and the state flips.

ViewportWhat changesQueryMeasured on
1079.cf-article puts its rail beside the body and makes it stickycontainer 60remblog-artikel
1007.cf-process and .cf-statement split into two columns; .col-* spans take effectcontainer 56remlanding-page
901the page-header object appears; the consent banner goes two-columnviewport 900ueber-uns, consent
866.cf-value-row splits into icon and copycontainer 48remueber-uns
820the values section becomes pinned and scroll-scrubbedviewport 820ueber-uns
792the blog axis appears; .subdivide unfoldscontainer 44remlanding-page
781the nav bar opens out of its toggle; the hero action returns to the corner; the footer CTA goes two-column; the accordion regains its indentviewport 780landing-page
612pagination shows the individual page numberscontainer 34remnews

Drawn to scale across 320 → 1280. The last flip is at 612; the 375 row is a phone, marked for scale.

Nothing happens between 612 and 320. Every threshold in the system is above the widest phone, so a phone is not a special width the layout switches into — it is the far end of a ramp the layout has already been on since a laptop. The last 300 px of narrowing change only the gutter and the measure.

The two viewport tiers, 780 and 900, are still an open question. They govern different objects, neither was measured off the mockups, and the register flags them rather than reconciling them. Note what that costs on a real device: between 781 and 900 a page has an open nav bar and a hidden page-header object at the same time. It is not wrong, but it is not designed either.
A tablet is the widest measure this site draws, not the desktop. Every threshold in the table above is at 612 or over 780, so a 768 viewport sits in the gap: one column, full width, and — this is the part that surprises — still at the narrow type size, because the queries that step the type up are the same ones that split the column. The two move against each other, and 768 is where they are both at their worst. Measured on expertise, characters per line in .cf-value-row__body before this was capped: 36.5 at 375, 81.4 at 768, 68.1 at 1024, 93.7 at 1280 — the tablet reading longer than the laptop. The fix is not a threshold. It is --measure, which the system already had and which that component read nowhere; it now caps at 56ch from 768 up and leaves 375 untouched.
One height threshold. At a viewport height of 560 or less — a phone in landscape — the consent banner takes its short-screen form, so it cannot swallow the page it is asking about. It is the only height query in the system.
And for a year it said that and did the opposite. The block held one declaration, a 70vh cap with an inner scroll, and a cap only does something where the banner is already taller than it. The banner is 253 px at every width from 375 to 900 — it has no height query of its own — so across the whole band this threshold governs, the cap sat above it and never engaged. The nav is fixed at the top and the banner is fixed at the bottom, which makes the gap between them a constant that no amount of scrolling moves:
ViewportBannerOf the screenClear bandAfter
667 × 37525368 %38191 / 100
812 × 37525368 %32191 / 94
844 × 39025365 %47191 / 109
900 × 40025363 %57191 / 119
568 × 320224 capped70 %12164 / 72
The hero's one call to action is 48 px — .cf-btn's own min-height — and its headline is 190. On a 32 px band there is no scroll offset at which either fits, so on a landscape phone, on the one visit the notice is shown, the landing page's opening line could not be read whole and its button could not be pressed whole. The fix is in the two places the narrow form spends height a short screen has not got: the ghost rejoins the pair's row (it is a row of its own only because the screen is narrow, and a screen under 35rem tall is landscape), and the block padding drops a rung. What is left caps against var(--nav-height) plus one control plus a rung of air, so the backstop now says what it leaves rather than what it takes. → Consent

The phone column

Below 612 every component is in its narrow state, and the page is one column of full-width blocks separated by 64 px of section gap. Real pages, live, at 375:

Load the two pattern pages at 375 px

landing-page · 375

ueber-uns · 375

Scroll them. They are the same files the patterns section serves, in a 375 px frame — not screenshots, and not a mobile build. The consent banner is showing because each frame is a first visit, which is worth seeing here: it is the single-column, full-width-button state the table below describes, and on a phone it covers about a third of the screen until it is answered.

Behind a <details> on purpose. The pattern pages run the real consent script, and the real consent script moves focus to the banner when it appears. Two frames loaded on page load would take the keyboard away from someone reading this page, twice, before they had done anything. Closed, they cannot: document.activeElement is still body a second and a half in, and the focus move happens when you open the frames, as the answer to a control you pressed. No script on this page — the element does it.

Not for the reason it looks like, and this was measured rather than assumed. The first version of this note said a lazy iframe inside a closed <details> is never fetched. It is: Chromium requests both pattern pages on load regardless. What a closed <details> actually buys is that the frame is never rendered, and an unrendered frame cannot move the parent's focus. Same outcome for the reader, a different mechanism, and the difference matters if you ever reach for this trick to save a request.

What each component does

ComponentAt 375
Navigationlogo plate stays; links collapse into a Menü toggle that opens them as a full-width column under the bar
Heromin(92vh, 56rem) tall — 747 px on a 812 px screen; the button leaves the bottom-right corner and drops into the flow under the kicker
Statement, process card, value rowone column: figure above, copy below, the contour still around the pair
Blog gridcards full width; the decorative time axis is dropped
Paginationpage numbers drop; previous, next and the current page remain
Articlerail above the body rather than beside it, and not sticky
Valuesstacked and readable — never pinned, never scrubbed
Accordionloses its indent; rows are 52–73 px tall
Formsfields already full width; nothing changes
Consentsingle column, and the three controls go full width in a stack — unless the screen is also short, where the ghost rejoins the pair's row; see the height threshold above
FooterCTA stacks over the button; meta rows wrap
Logo wallmarks wrap and centre, a true 48 px apart on every row. Above a container of 72rem the seven fit on one line and are justified to the measure instead — see below
Tables and code blocksscroll inside their own box, never widen the page
Nothing that carries meaning is hidden on a phone. Two things are dropped and both are decoration: the blog grid's time axis, which is aria-hidden and duplicates the dates printed on the cards, and the page-header's isometric object, which is behind the title. No copy, no link and no control exists only on the wide layout.
Measured: no page overflows sideways at any width down to 320. All 18 pattern pages, at 320 / 375 / 390 / 768 / 1280 / 1920, have scrollWidth === clientWidth to the pixel — 108 measurements, none of them off by a subpixel. That is not luck — it is minmax(0, 1fr) on every grid track that holds text and overflow-x: auto on the three things that genuinely cannot shrink: tables, <pre>, and the documentation's own table wrapper.
This note said "seven pattern pages" until there were twelve. It was true when it was written and it went false without anybody touching it, which is the failure mode a measured claim has that a rule does not: the rule still holds, the count behind it quietly stopped covering the section. The five pages added since — suche, karriere, datenschutz, impressum, karriere-stelle — have now been through the same measurement, and the two widths above the phone were added at the same time because a claim about scrollWidth that stops at 768 is not a claim about the site.
It went stale again while this was being written. The count above read eleven when it was measured and twelve by the time it was committed — karriere-stelle landed in between, and it was re-measured rather than assumed to compose safely from the same components. That is the argument for the number being here at all: a count that can go false in one hour is a count worth re-running, and the honest form of this note is the figure plus what it was measured on, never the figure alone.
And a third time: twelve became fourteen. kontakt-danke and suche-leer landed after the note above was written, and the count was found stale by the run that collapsed .grid's stacked state to one track — which re-ran the whole matrix rather than patching the number, because a claim this page has watched go false three times is not one to update by arithmetic. All fourteen pages, all six widths, still zero. The same sweep is what caught the enlarged-font corner this claim does not cover: at a 20 px browser default on a 320 px viewport, five pages overflowed through .grid's gaps — fixed with that run, the record is on Layout & Grid — and two still do through the isometric plot and the Expertise stage, which are their own lanes' calls.
One of the two lanes has now answered its call. The isometric plot's --plot-u was clamp(1rem, 3.4vw, 2rem) — a floor in the reader's own unit, on a figure whose fit against the viewport was argued entirely in px and vw. At a 20 px browser default it grew past the column it was measured to fit; at 24 px, further still. Fixed by putting the floor and cap in px, clamp(16px, 3.4vw, 32px), the same convention every other layout token on this page already uses — the figure keeps its default rendering exactly (16 and 32 are what 1rem and 2rem were at the only root size this page had measured) and stops answering a setting it was never meant to. Re-measured on blog-artikel, both engines, 320/360/390/414 at root 16/20/24: zero overflow. The Expertise stage is still open — its cause is different, a grid track that does not cap the isometric drawing's intrinsic width, compounded by a card title that does not reflow at the largest sizes — and is written up where the fix that changed this page stopped short of it, rather than patched here on a guess.
The Expertise stage has answered its call too, and it needed two fixes. .ex-step is a single-column grid below the pinned layout's own query, with no grid-template-columns written on it — an implicit auto track, whose max is the column's max-content, not the available width. The figure inside carries an explicit width (a specified-size suggestion) that wins over its own max-width: 100% for that max-content contribution, so the track grew to fit the drawing instead of the other way round: measured at a 24 px root default, 320 px wide, the column read 388 px against 260 px available. Floored to minmax(0, 1fr) — the convention .cf-accordion__summary's own comment in components.css already states for the same reason, and scripts/check-grid-tracks.py holds every written track list to, which this one escaped only because it was never written — the column takes the available width and max-width: 100% finally has something to cap against.

That alone left 49 of the 98 px. The second cause was .text-foil's width: fit-content, on the card titles: the bare keyword does not resolve to min(max-content, available) on a plain in-flow block on either engine — Chromium 141 and WebKit both render it identical to width: max-content, ignoring the containing block entirely. Isolated: a fit-content heading in a 100 px box measured 277.84 px on both engines; the same heading as fit-content(100%) measured 100. base.css now uses the function form with an explicit stretch basis, which is what the class's own comment described all along and what the bare keyword never did. Re-measured on expertise, both engines, 320/360/390/414 at root 16/20/24: zero overflow. Both lanes the sweep opened are now closed.
A fourth time: fourteen became fifteen — and the count now has a gate. karriere-leer landed in the same hour as the footer unification, and this page said fourteen while the directory held fifteen. Found by the same re-run as ever: all fifteen pages, all six widths, every overflow reading zero — and the whole target-size table below re-counted in the same run rather than extended by one row, because the footer unification had just added the wordmark link to every page and a table half from one run and half from another is not a record. What is different this time is that the fifth staleness will not wait for a reader: scripts/check-mobile-coverage.py now fails CI whenever the pattern directory and this page's record disagree — a page missing from the table, or any typed count on this page that no longer equals what the directory holds. The check's own docstring repeats the house rule for answering it: re-run the matrix, never patch the number.
The fifth landing, and the first the gate caught before a reader did. news-thema — the topic-filtered state of the news archive — made the directory sixteen, and this time the counts above and the row below come from the same run as the page: all sixteen pages at all six widths read zero overflow, and the filtered page's visible control set at 375 × 812 measured element-for-element identical to news — the two pagination links the full archive has and the filter does not are hidden at that width on both, and everything else on the two pages is the same markup — so its row carries the same three numbers. The re-run reproduced the closest pair recorded below, 24.1 px on impressum at 375, to the decimal.
A row that wraps must not be justified, and the logo wall was. justify-content: space-between justifies every line, and the last line of a wrapped row is the one line that must never be justified: it holds the remainder. Seven marks wrap to 6 + 1 at a 768 px viewport, and the leftover mark sat flush left with 614.9 px of empty row beside it — 90 % of the wall. At 375 it was 3 + 3 + 1 and 265.1 px; at 320, 2 + 2 + 2 + 1 and 211.4 px. The same declaration also meant the gap token never governed: the marks actually sat 133–162 px apart at 320, 59–69 at 375 and 54 at 768, a different rhythm on every row of every width. None of it showed above 694, where the seven stand-ins fit on one line and there was no last line to strand — which is why a rule that was wrong at three of the four widths this page tests looked right every time anyone opened it. (694 is the viewport that figure had while the marks were text. With the wordmark files it is 1222 — binary-searched, one line at a viewport of 1222 and wrapping at 1221 — and the whole paragraph above is a record of a fixed bug rather than a description of the wall today.) Centred below 72rem and justified at or above it, the rhythm is 48 px everywhere and the desktop line is unchanged to the pixel.

This was the one threshold in the register measured against a string rather than a box, and it is not any more. It sat at 48rem behind a one-line width of 767.2 px — seven mono words at --text-lg — then at 44rem behind 617.0 px, when the fix that gave the stand-ins the real .t-label put them on --text-xs and took 150 px out of the wall without anything in that diff mentioning a breakpoint. Both numbers were measurements of a rendered word, and this page said so, and said the exposure would come back the day the wordmark files landed.

They have landed, and the trip point is a box now. The seven marks are SVGs, normalised to a shared optical height before they were committed — each scaled so the median column ink-extent, which is x-height, is 0.29 of its own box, so one height in the stylesheet draws seven marks at one apparent size (assets/img/partner/README.md). At the wall’s 2.5rem they measure 132.70, 86.14, 174.61, 60.72, 150.89, 99.41 and 94.27 px; six --space-12 gaps are 288; the wall holds one line at 1086.73 px, which is 67.92rem. 72rem is where the query sits and clears it by 65.27 px. Every browser gives that sum, and it does not move when a font lands or a type scale is edited — which leaves the SET of marks changing as the only event that moves it.

That event has already happened once. The query was 64rem for one release, behind a one-line width of 988.52 px, and two of the seven marks were wrong: Azure shipped as the glyph alone — a bare A, 15.19 px wide, beside six marks that each say their own name — and Spark shipped at the normalisation rule’s own answer, which is the one place that rule under-reads, because it assumes upright letter stems and Spark’s wordmark is a script. Fixing both cost 98.21 px and the wall stopped fitting above 64rem. 68rem would clear the new width by 1.27 px; 72rem was taken instead, because those box widths are fractional and sub-pixel rounding differs between engines and zoom levels.

The error is still asymmetric — too high only centres a row that could have been justified and costs a little air; too low strands a mark on a justified last line — so if a re-measurement is ambiguous, round up. What is gone is the reason the slack had to be generous: there is no longer a string in a font this repository does not ship anywhere in the arithmetic.

Touch, not pointer

A phone has no hover and a fingertip is about 9 mm across. Two consequences, both checkable.

Nothing is hover-only

Every hover in the system is an enhancement of something already visible: the button's specular travel, the nav link brightening, the card's rule inking, the arrow's 4 px nudge. Remove hover entirely and no state, no label and no destination becomes unreachable. The one place this was a real risk — the current-page marker in the navigation — is drawn with ink and a plate rather than a hover, which is also why it survives on touch. → Navigation

Target sizes, measured

Every interactive element on all 18 pattern pages, at a 375 × 812 touch viewport. This table was four rows for a long time — measured honestly, with the gap stated in prose rather than closed with an estimate — and the four rows it held were counted before the landing page was rebuilt around the acts, so they described a page that no longer exists. All fifteen have now been through the same element-by-element count in one run. Documentation chrome is excluded: .ds-back is position: fixed, drifts over the page as it scrolls, and the site build strips it from the shipped root pages anyway, so counting it would measure an element no reader of the shipping page can touch. The skip link is excluded on the same argument from the other side: it rests at translateY(-200%) and is on screen only once a keyboard has already found it, so no fingertip meets it as a target.

PageControlsUnder 24 px24–43 pxSpacing-exception failures
landing-page201220
expertise301210
ueber-uns181210
news4012160
news-thema4012160
blog-artikel3313110
suche261230
suche-leer241210
karriere301450
karriere-leer281310
karriere-stelle261460
kontakt291410
kontakt-danke221520
bewerbung311420
bewerbung-danke201320
datenschutz5428140
impressum241720
404251220

WCAG 2.2 AA (2.5.8, Target Size Minimum) passes on all 18. The requirement is 24 × 24 CSS px or enough space around a smaller target that a 24 px circle centred on it touches no other target's circle. Every element under 24 px is an inline text link — footer meta, legal lines, the address — or the footer's wordmark, 139 × 20 on every page since the footers were unified, and every one of them clears the circle test, or is exempted as a target inside a sentence, checked pairwise against every other control on the page, at 320 and 768 as well as 375, because a wrap point moves targets past each other and a single width cannot see that. A wrapped inline link is measured as its line boxes rather than its bounding box, which unions the lines and manufactures overlaps that exist in no reader's hand. 54 page-widths, zero failures. The closest pair anywhere is on impressum at 375 and 390, and it is the one pair in the whole sweep whose circle-test verdict depends on which rendering engine measures it — the note below is that pair's history, and it is worth reading before trusting the margin.

The impressum pair: a recorded failure that unrecorded itself, then came back as weather on one engine. An earlier run of this test found two links in a single sentence — Datenschutzerklärung and Kontaktformular — wrapped onto consecutive lines at 375, both starting at the left margin, their centres 23.87 px apart against a requirement of 24. It was recorded rather than fixed, because the arithmetic said it could not be fixed here: .t-body-sm is 12 px on a 17.4 px line box, two links on consecutive lines are 17.4 px apart vertically whatever else is done to them, and the criterion exempts a target "in a sentence or whose size is otherwise constrained by the line-height of non-target text" — which is exactly what these two are. Prose is not a toolbar.

Re-measured with the full fifteen-page run on Chromium, the same pair sat at 24.1 px and cleared outright. Nobody fixed it: the copy around it moved and the sentence wraps differently than when the note was written, which is exactly the behaviour the note predicted — a wrap point moves targets past each other. The margin was a tenth of a pixel and it was held by where a German sentence happens to break in a fallback face — which is also why it does not hold across engines.

On WebKit the same markup, same width, same fallback face measures 23.83 px — under the line again. Chromium and WebKit shape that fallback face's glyphs at very slightly different advance widths, so Datenschutzerklärung's line box is 119.73–119.78 px wide depending on engine and the second line's flush-left start moves with it. The vertical gap is engine-independent — 17.39 px, the line-height's own arithmetic, unmoved — so the whole swing rides on that one horizontal component: 16.68 px on Chromium, 16.29 px on WebKit, both at 375 and at 390, the only two of the four checked widths where both lines start flush left. Checked on both engines at 320, 360, 375, 390 and 414 to find the exact widths it recurs at, rather than trusting one sample.

The verdict does not move, because it never depended on the number. This pair passes on both engines by the inline-sentence exemption regardless of which side of 24 the circle test lands — 24.1 or 23.83 is weather, not a compliance question, exactly as the note before this one argued. What moved is only which engine you'd have to be measuring on to see the number the old note asserted as universal. If a future re-measurement puts both engines on the same side of 24 again, that is not a fix landing; it is a fallback face changing out from under both of them at once.

Every plate-shaped control clears 48 px, because .cf-btn carries min-height: 3rem and the accordion row carries 4 rem. Two things sit between 24 and 44 — legible, tappable, and short of the 44 px convention (WCAG 2.5.5 AAA, and Apple's HIG):

ControlMeasuredWhy
.cf-btn--ghost334 × 34the ghost variant sets min-height: auto on purpose: it is a text-weight action, not a plate
.cf-blog-card--compact332 × 41a one-line card in a dense list; the row height is the type's, not a control's
The nav toggle used to be the third row, and it was this page's open question. It is the primary control on a phone — the only way to reach any other page — and at 61 × 42 it missed the 44 px convention by two, because it is not a .cf-btn and never got the 3 rem floor: 11 px mono inside --space-3 padding lands at 42. This page flagged the one-line fix and stopped short of making it, calling the toggle's height the mono label's own metric and a designer's call. The call is now made: .cf-nav__toggle carries min-height: 2.75rem and measures 61 × 44. The deciding argument was not the convention but the bar's own arithmetic — --space-5 above and below inside a --nav-height of 5.25rem leaves exactly 44 px, so the plate grows into air the bar was already holding. Measured either side of the change: the bar is 84 px both, and nothing anchored to --nav-height moves. Above 780 px the toggle is not rendered and nothing changes at all. The two rows that remain are defensible where they are: a ghost action is deliberately not a plate, and a compact card is a row of a list rather than a button.

Text, zoom and the URL bar

Every page carries width=device-width, initial-scale=1 and nothing else. No maximum-scale, no user-scalable=no: pinch zoom works everywhere, which is not a nicety — it is how a large share of people read on a phone at all.

-webkit-text-size-adjust: 100% is set on html. That is not a zoom lock either; it switches off iOS Safari's own text inflation in landscape, which would otherwise resize some blocks and not others and quietly break every measured line length on this site. Reader-initiated zoom is untouched.

Both paragraphs above are now run rather than stated. scripts/check-viewport-zoom.py reads every page under design-system/ and every text-size-adjust declaration in the three shipping stylesheets, and fails on a missing viewport tag, a width that is not device-width, an initial-scale that is not 1, user-scalable=no, a maximum-scale under 5, or a text-size-adjust that is neither 100% nor auto. Sixty-one pages pass today, which is the point: this is the one rule in the system whose breach cannot be seen. A page that revokes pinch zoom renders identically at every width, in every screenshot, and in this page's own overflow numbers — a desktop browser ignores the directive outright. It is visible only to someone holding a phone who needs the text bigger and finds it will not move, and that person is not in the review. The checker reads prose the way this page writes it, so the forbidden values quoted in the paragraph above — inside <code> — are masked and do not fail the page that bans them.

Viewport units, and where they bite

WhereValueOn a phone
Heromin-height: min(92vh, 56rem)747 px on a 812 px screen. 92 rather than 100 on purpose: the page has to show that it continues
Consent, short screenscalc(100vh - var(--nav-height) - 3rem - var(--space-6)), now with a dvh line on toponly under a 560 px viewport height, i.e. landscape, and a backstop rather than the working state: 219 px against a 191 px banner at 375 tall. It was 70vh, which never engaged at all across that band
Values track100vh per stagenever runs below 820 px wide or 720 px tall, so the stage does not exist on a phone — the height half of that is new; see below

All three start from vh — deliberately, and it is worth knowing which behaviour that buys. vh is the large viewport: it resolves as though the URL bar were collapsed and does not change while the bar slides away, so nothing reflows mid-scroll. The cost is that the hero is a little taller than the visible screen on first paint, which is the direction of error this layout wants — the hero is meant to run past the fold. Everything clipped rather than scrolled carries dvh on top for the opposite reason: a clipped box has no reflow to avoid, so an oversized cap has nothing paying for it, and Safari's own toolbar leaves a crop-shaped gap — or, anchored to an edge instead of cropped, lets the box grow past where the cap meant to stop it — if nothing tracks the toolbar down. That is the two pinned stages (the values track here, the Expertise and landing-page builds) and, since the note below, the consent backstop too.

The width gate was never the only question, and the values track asked it alone. This mechanism has two siblings — .cf-pin, the Expertise and landing-page builds — and both pair their min-width: 64rem with min-height: 45rem, because a stage that must also clear the nav band cannot hold a card worth reading under 45rem of viewport. The values track's own gate was min-width: 51.25rem and nothing else. A width of 820 px says nothing about a phone turned sideways: iPhone 14 Pro Max landscape (932 × 430), iPhone 14 Pro landscape (852 × 393), Pixel 7 landscape (915 × 412) and Galaxy S23 Ultra landscape (915 × 412) all clear 820 px wide while standing at under half of 45rem tall — ordinary phones, held the ordinary way people hold a phone to watch something wide. Measured on all four, both Chromium and WebKit: view-timeline-name computed to --cf-values and .cf-values__stage computed position: sticky — the seven-viewport scroll hijack the "Values" row above swears never happens on a phone, running on one. Fixed with the same term the other two consumers already carried: and (min-height: 45rem), already in the register, so nothing new to add there — only a third name beside it. → tokens.css, components.css .cf-values__track
The consent backstop asked only half of the question the nav toggle's identical shape already answers. .cf-nav__list[data-open="true"] is the same mechanism as the short-screen consent block a few pages away in the same file — a max-height cap with overflow-y: auto behind it — and it has carried vh then dvh since the fix that gave the open menu its own scrollport. The consent block copied the cap's arithmetic but not the second line: it capped on 100vh alone.

.cf-consent is position: fixed; inset: auto 0 0 0 — bottom-anchored, not in the page's own scroll — so it is the clipped case this section's paragraph now names, not the hero's. vh is the chrome-collapsed viewport, larger than what is on screen while iOS Safari's toolbar is still showing, which it is on the one load this backstop is for: first paint, nothing scrolled yet. A cap sized off the larger figure lets the banner grow taller than the true visible screen, and because the box is anchored to the bottom rather than clipped by an ancestor, the overshoot pushes its top edge up — into the 72 px of nav-and-button clearance the whole backstop exists to hold open. That is the exact failure this block was written to fix, reopened by the one unit already being reasoned about two sections up.

Fixed the same way the nav list already had it: max-height: calc(100dvh - ...) declared after the vh line, so a browser with no dvh support keeps the line above unchanged. → components.css .cf-consent, the max-height: 35rem block

What does not happen on mobile

Do

  • Write the stacked state first and let a query add the wide one.
  • Ask the container. The viewport is for furniture pinned to the screen.
  • Give any element that cannot shrink — a table, a code block — its own scroll box.
  • Put minmax(0, 1fr) on grid tracks that hold text, or one long word takes the page sideways.
  • Check 320, 375, 390 and 768. The first is the floor, the last is where the section gap stops moving.

Don't

  • No separate mobile stylesheet, no mobile-only components, no device sniffing. One page, narrowed.
  • Don't hide content on small screens. Decoration may go; copy, links and controls may not.
  • Don't put a scroll hijack on a phone — the values track is gated at 820 px wide and 720 px tall for exactly this reason; a width gate alone still let one through sideways.
  • Don't add a threshold without writing it into the register in tokens.css in the same commit.
  • Never user-scalable=no, and never a maximum-scale under 5.

Checking it yourself

Everything on this page is reproducible in a browser with no tooling. Open a pattern page, narrow the window, and watch for four things in this order: does the document scroll sideways; does any block overflow its gutter; does every control stay tappable; does anything that carries meaning disappear.

// paste in the console at any width — the only test that matters first
document.documentElement.scrollWidth - document.documentElement.clientWidth
// 0 means the page fits. Anything else, find the culprit:
[...document.querySelectorAll('body *')]
  .filter(el => el.getBoundingClientRect().right > innerWidth + 1)
  .map(el => el.className || el.tagName)

The threshold numbers in the table above were found the same way, by binary search: set the viewport, read a computed style, halve the interval. That is why they are given as narrow at n, wide at n+1 rather than as the round number in the query — a container threshold and the viewport that produces it are two different numbers, and only one of them is what you can check with a window.

Writing this page found one bug and one open question.

The bug: the page-header's isometric object was supposed to disappear below 900 px and did not. .cf-iso { display: block } is declared later in components.css at the same specificity, and a media query adds none of its own, so the hide lost on source order — 172.5 px of drawing sat behind the title on every phone, for exactly the reason the rule was written to prevent. The hide now names its parent, which puts it at (0,2,0) and makes it independent of where .cf-iso sits in the file. Verified: hidden at 900, shown at 901.

The open question was the nav toggle's 42 px. It stood for the life of this page and is now answered above: 44, bought from air the bar already held.