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.
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.
| Token | Floor | Ramp | Ceiling | Floor binds below |
|---|---|---|---|---|
--gutter | 20 px | 5.5vw | 80 px | 364 |
--section-gap | 64 px | 100vw / 12 | 160 px | 768 |
--section-gap-sm | 32 px | 100vw / 18 | 80 px | 576 |
--text-display-1 | 40 px | 1.4rem + 4.9vw | 64 px | 359 |
--text-display-2 | 32 px | 1.3rem + 3.1vw | 40 px | 361 |
--text-h1 | 28 px | 1.3rem + 2vw | 40 px | 360 |
--text-h2 | 22 px | 1.1rem + 1.2vw | 32 px | 367 |
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.
| Viewport | display-1 | display-2 | h1 | h2 | gutter |
|---|---|---|---|---|---|
| 320 | 40.00 | 32.00 | 28.00 | 22.00 | 20.00 |
| 375 | 40.78 | 32.42 | 28.30 | 22.10 | 20.63 |
| 390 | 41.51 | 32.89 | 28.60 | 22.28 | 21.45 |
| 768 | 60.03 | 40.00 | 36.16 | 26.82 | 42.24 |
| 1440 | 64.00 | 40.00 | 40.00 | 32.00 | 79.20 |
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.
| Viewport | What changes | Query | Measured on |
|---|---|---|---|
| 1079 | .cf-article puts its rail beside the body and makes it sticky | container 60rem | blog-artikel |
| 1007 | .cf-process and .cf-statement split into two columns; .col-* spans take effect | container 56rem | landing-page |
| 901 | the page-header object appears; the consent banner goes two-column | viewport 900 | ueber-uns, consent |
| 866 | .cf-value-row splits into icon and copy | container 48rem | ueber-uns |
| 820 | the values section becomes pinned and scroll-scrubbed | viewport 820 | ueber-uns |
| 792 | the blog axis appears; .subdivide unfolds | container 44rem | landing-page |
| 781 | the nav bar opens out of its toggle; the hero action returns to the corner; the footer CTA goes two-column; the accordion regains its indent | viewport 780 | landing-page |
| 612 | pagination shows the individual page numbers | container 34rem | news |
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.
.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.
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:
| Viewport | Banner | Of the screen | Clear band | After |
|---|---|---|---|---|
| 667 × 375 | 253 | 68 % | 38 | 191 / 100 |
| 812 × 375 | 253 | 68 % | 32 | 191 / 94 |
| 844 × 390 | 253 | 65 % | 47 | 191 / 109 |
| 900 × 400 | 253 | 63 % | 57 | 191 / 119 |
| 568 × 320 | 224 capped | 70 % | 12 | 164 / 72 |
.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.
<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
| Component | At 375 |
|---|---|
| Navigation | logo plate stays; links collapse into a Menü toggle that opens them as a full-width column under the bar |
| Hero | min(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 row | one column: figure above, copy below, the contour still around the pair |
| Blog grid | cards full width; the decorative time axis is dropped |
| Pagination | page numbers drop; previous, next and the current page remain |
| Article | rail above the body rather than beside it, and not sticky |
| Values | stacked and readable — never pinned, never scrubbed |
| Accordion | loses its indent; rows are 52–73 px tall |
| Forms | fields already full width; nothing changes |
| Consent | single 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 |
| Footer | CTA stacks over the button; meta rows wrap |
| Logo wall | marks 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 blocks | scroll inside their own box, never widen the page |
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.
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.
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.
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.
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.
--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.
.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.
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.
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.
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.
| Page | Controls | Under 24 px | 24–43 px | Spacing-exception failures |
|---|---|---|---|---|
| landing-page | 20 | 12 | 2 | 0 |
| expertise | 30 | 12 | 1 | 0 |
| ueber-uns | 18 | 12 | 1 | 0 |
| news | 40 | 12 | 16 | 0 |
| news-thema | 40 | 12 | 16 | 0 |
| blog-artikel | 33 | 13 | 11 | 0 |
| suche | 26 | 12 | 3 | 0 |
| suche-leer | 24 | 12 | 1 | 0 |
| karriere | 30 | 14 | 5 | 0 |
| karriere-leer | 28 | 13 | 1 | 0 |
| karriere-stelle | 26 | 14 | 6 | 0 |
| kontakt | 29 | 14 | 1 | 0 |
| kontakt-danke | 22 | 15 | 2 | 0 |
| bewerbung | 31 | 14 | 2 | 0 |
| bewerbung-danke | 20 | 13 | 2 | 0 |
| datenschutz | 54 | 28 | 14 | 0 |
| impressum | 24 | 17 | 2 | 0 |
| 404 | 25 | 12 | 2 | 0 |
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.
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):
| Control | Measured | Why |
|---|---|---|
.cf-btn--ghost | 334 × 34 | the ghost variant sets min-height: auto on purpose: it is a text-weight action, not a plate |
.cf-blog-card--compact | 332 × 41 | a one-line card in a dense list; the row height is the type's, not a control's |
.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.
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
| Where | Value | On a phone |
|---|---|---|
| Hero | min-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 screens | calc(100vh - var(--nav-height) - 3rem - var(--space-6)), now with a dvh line on top | only 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 track | 100vh per stage | never 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.
.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
.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.cssin the same commit. - Never
user-scalable=no, and never amaximum-scaleunder 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.
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.