Mobile-first doctrine
Design order, thumb zone, no hover-only, sheets below md, safe area, the keyboard-up layout state, dvh, and what WCAG F102 actually forbids.
This is the canonical text of
CONVENTIONS.md§5b. That section carries the normative summary; this file carries the reasoning, the measurements and the failures each rule was written from.It was cited as binding from 2026-09-03 and did not exist until 2026-09-23. Its draft lived on
chore/mobile-first-convention, which was never merged and is gone from every ref. Two ledgers noticed and said so in writing; nothing failed, so nothing changed.scripts/knowledge-lint/now fails the build if either half goes missing again.
1. The design order
Build for the smallest screen first and scale up. Base (unprefixed) styles are the mobile case; larger screens are the override, never the reverse.
Verify at 375 first, then 320, then upward. Touch targets, spacing and type must be comfortable at the small end before any desktop polish — a desktop layout squeezed down keeps the desktop's assumptions and loses at every one.
This is not only an aesthetic order, it is a correctness order. §5's
emission-order rules mean a bare utility written as "the desktop default" beats
its own max-* override at every width. Designing small-first produces the
class strings that survive the cascade; designing large-first produces the ones
that quietly don't. See 04-pointer-and-touch.md.
2. The breakpoint ladder
All seven are "must pass" in TESSINA_UI_QA_FLOW.md §2.1:
| xs | mobile | sm | md | lg | xl | 2xl |
|---|---|---|---|---|---|---|
| 320 | 375 | 640 | 768 | 1024 | 1280 | 1536 |
Be honest about where these came from. They are Tailwind's defaults plus two device widths — not a design-system consensus.
Against the surveyed systems: 320 is the only near-universal value — it is also the width WCAG 1.4.10 Reflow is defined against. 375 is ours alone; no surveyed system has any breakpoint between 320 and 480. 768 is matched by exactly one of the six surveyed systems, 640 and 1024 have partial support, and past 1024 no two surveyed systems agree. One surveyed ladder is better-founded than ours — whole rem counts derived from column math rather than powers-of-two viewport guesses — and we keep ours because changing it now would re-flow every component, not because it is more principled.
What production sites actually ship
Measured from the live CSSOM of five high-traffic sites (rule counts, so they
weight by how load-bearing each breakpoint is), the three best-engineered sites
disagree irreconcilably about 768–1024 — worth knowing before anyone claims
our md is standard. One makes 768 the main event: at 640 it is one column with
4 visible navs; at 768 a second column appears, an aside becomes visible,
and navs jump 4 → 7, a shape that then holds unchanged through 1280. One treats
the whole band as desktop — its dominant breakpoint is 641 and it names 769
"desktop"; the most accessibility-rigorous site surveyed puts desktop at the
bottom of the band. One skips the band entirely — 640 straight to 1120.
They agree only that something changes between 640 and 769. Our md at 768 is
defensible and is a choice, not a convention.
The clearest argument for keying density to the pointer
One surveyed system keys control height to the viewport — max-md: and
max-sm: utilities grow some controls to 40px below 601/401px. It does not
work, and the failure is exactly the one our doctrine predicts: a 1024px touch
tablet gets the 32/36px control, while a 380px desktop window gets the 40px
one. Both answers are backwards, and no amount of tuning the breakpoint fixes
either.
Container widths are a separate axis
Components are also tested at 240 / 320 / 480 / fluid container widths, because the system is consumed in layouts we do not control.
"Components are tested by container width, not just page width." —
TESSINA_UI_QA_FLOW.md§2
A component inside a padded stage breaks at 375 exactly as 320 full-bleed does,
and a viewport media query never fires for it. Which axis to use is
07-decision-guide.md; the mechanics are 03-container-queries.md.
Two gaps in this ladder, named rather than hidden
- No height ladder. Only one surveyed system defines one (compact height < 480dp, expanded ≥ 900dp) and it warns that two-pane layouts are impractical at compact height. A landscape phone at 900×400 passes every width breakpoint we own and is still a bad two-pane target. We have no rule for it.
- Nothing above 1536. Only one surveyed system specifies behaviour past its top breakpoint (centred / left-aligned / add columns in twos). We say nothing about a 2560px monitor.
3. The thumb zone
A phone is held one-handed more often than not, and the bottom third of the screen is the only comfortably reachable region. Two consequences:
- Primary actions live at the bottom, full-width, clear of the home indicator. This is the strongest convention in the research: 13+ products, ~16–20pt side gutter, and no observed case of a button sitting under the home indicator.
- Destructive actions do not live where the thumb rests without a confirmation step.
The industry's own anti-pattern, recorded so we do not copy it: five surveyed products place a raised + at the single most reachable point of the screen — spending the best real estate on a create action while pushing actual destinations to the hardest-to-reach corners.
Button order flips with the axis, and a naive stack inverts it
Measured across the sweep:
- Stacked on phone → destructive on top, safe below (four products).
- Side-by-side, phone or desktop → safe left, destructive right (eight products).
A single DOM order cannot satisfy both. flex-col md:flex-row silently
reverses which button reads as primary — and it is also an emission-order
violation (§5). Write disjoint bands, and set the order explicitly per band
rather than letting the axis decide it.
4. Nothing is hover-only
An affordance reachable only by hover does not exist on a touch device.
This extends further than it first appears:
- Pointer-only hints are the same failure wearing a keyboard. One surveyed product prints
⌘+⏎inside its Back/Next labels in a touch-capable bar. - Icon-only navigation whose label lives in a tooltip fails this rule. ~10
products ship it, and not one of 84 surveyed products ships a pointer-aware
rail. If we ship
an icon-only rail it must show labels whenever
touch:matches. Recorded as rejected convention R3 in the research ledger,ui/knowledge/responsive/_research-ledger.md(in the source repo; it does not ship in the package).
Gate the capability, not the width: (hover: hover) and (pointer: fine) for
hover affordances, touch: for target sizing. §9's TES-42 row draws this line
explicitly — layout arrangement on the container axis, device capability on media
queries.
5. Below md, a dialog becomes a sheet
- A short confirm may stay centred. Two of ten surveyed phone confirms did — the iOS native alert idiom. "Centred dialog always becomes a sheet on phone" is false as stated.
- A long form presents as a tall sheet with the parent peeking, not an opaque takeover (six products).
- Dismissible sheets carry a grabber — ~36×5pt pill, centred.
A sheet switches on the VIEWPORT, never on a container. This is a recorded
decision, not a preference: a sheet is a viewport-level overlay, so
container-width switching was explicitly rejected for ArtifactPanel (§9,
TES-392). The component forks at (max-width: 767px).
Note the asymmetry with §7 below: layout arrangement keys to the container, but anything that escapes its box to cover the screen keys to the viewport.
6. The safe area
Edge-anchored surfaces (drawer, sheet, toast) use env(safe-area-inset-*)
directly. Positioned popups grow their collisionPadding through
lib/safe-area.ts (§7b.4) — a menu that stops 8px from an iPhone's bottom edge
is sitting inside the ~34px home-indicator strip, where the system swipe takes
the touches.
Three mechanics that are easy to get wrong:
env()is CSS-only. It is not exposed to script, andgetPropertyValuereturns the unresolved token stream.useSafeAreaInsetsreads it with a hidden fixed probe.- Playwright reports every inset as 0. The probe carries
data-slot="safe-area-probe"precisely so a test can override what it resolves to — that is the only way to exercise a notched device. - Rotation changes which edges are reserved, so the read subscribes to
orientationchangeas well asresize.
7. Keyboard-up is a third layout state
Not a variation on "mobile" — a distinct state, and the one existing house rules did not cover.
The finding that forced this section: twelve products dock the sticky action bar to the top edge of the keyboard, not the bottom of the viewport. It was the strongest single signal in the sweep.
The consequence is what matters: while the keyboard is up it covers the home
indicator, so safe-area-inset-bottom is the wrong inset. Both the touch:
floor and the safe-area rules implicitly assume keyboard-down.
House mechanics:
- Every
side="bottom"Drawer wrapsDrawer.VirtualKeyboardProviderand reads--drawer-keyboard-insettwice, CSS-only:padding-bottom: max(env(safe-area-inset-bottom), var(--drawer-keyboard-inset, 0px)). The0pxfallback is load-bearing. - Never set
interactive-widget=resizes-content— the provider measures the visual viewport itself. innerHeight - visualViewport.heighthas stopped being a keyboard measurement. iOS 26 Safari shrinks the layout viewport too. Measured on iPhone 15 Pro / iOS 26.6.2:innerHeight695→470,vv.height358 — the old clamp returned 0. The keyboard is now classified against the tallest viewport seen at the current width, on a coarse pointer only, re-anchoring wheninnerWidthchanges (§9, TES-348). In that branchinsetis0, and that is the honest answer: the shrunken layout viewport's bottom edge already sits on the keyboard.- Keep the focused field visible. A sticky bar with no matching scroll padding clips content — one surveyed product cuts "Date of birth" mid-glyph behind its own docked Continue bar.
8. dvh, not vh
Size anything screen-height with dvh, and clamp against 100dvh. vh is the
largest viewport height and ignores retracting browser chrome, so a 100vh
element is taller than the screen for most of a scroll on mobile.
Where a drawer combines the two:
max-height: min(calc(<size>dvh + inset), 100dvh).
Separately: iOS 26's
100svhcan exceed the space above a fixed bottom bar. Measure rather than assuming the units compose.
9. Never trap scroll
A locked overlay must release cleanly. Two hooks exist on purpose:
useTouchScrollLock— iOS only. Base UI'suseScrollLockselects withisIOS || !hasInsetScrollbars(...), so iPhone and iPad always take the overlay-scrollbar branch, whose entire body isoverflow: hidden. That stops a wheel and does nothing to a finger. The stronger branch is unreachable on iOS by construction, so it cannot be fixed by configuring or upgrading Base UI. The lock isposition: fixed+top: -scrollY, restored on release — the only thing WebKit respects.usePageScrollLock— every platform, for overlays with no other lock.
Running both off iOS would mean two locks fighting over the same styles, which is why this is two hooks and not one.
Knock-on effects worth knowing: the lock stamps data-tessina-scroll-locked on
<html>, and use-hide-on-scroll bails while it is present — the iOS lock parks
the body at scrollY 0 and jumps back on release, and both edges read as giant
scrolls.
Also: overflow: hidden clips visually but leaves the box programmatically
scrollable. Assert that a box cannot be scrolled; do not trust overflowX.
10. Motion is gated ON
Write motion-safe:, not motion-reduce: — see §4 and
04-pointer-and-touch.md. A bare transition-colors beside
motion-reduce:transition-none loses to emission order; measured in the docs
build, the bare utility was emitted ~200KB later and won, leaving 29 components
animating for everyone.
Carry the gate through duration too. transition-property's initial value is
all, not none, so motion-safe:transition-colors duration-200 hands reduced
motion every property at 200ms — worse than doing nothing.
11. Never make meaning UNREACHABLE
WCAG F102 — a failure of 1.4.10 Reflow (not 1.3.1; the mis-citation is common).
The test is reachability, not visibility. This is the part most people state too strongly, including an earlier draft of this file. F102's own text says content at a narrow width "should still be available after reflow … either by being repositioned in a single column view, or through some interaction offering the information in some other way, for example, in a disclosure area, a dialog, or via a link to another view."
The Reflow Understanding document goes further and names the hamburger as a conforming pattern: "the navigation changes first to hide options behind a 'More' dropdown menu. As zooming continues, most navigation options are eventually behind a 'hamburger' menu button. All the information and functionality is still available."
So: hiding behind a control is fine; disappearing is not. All five of F102's listed failure examples are unreachability — a sidebar block that vanishes with no equivalent, labels replaced by placeholder-only text, a search field with no icon or equivalent.
Small screens get the same capability set, reorganised. Do not reduce functionality or remove content for smaller viewports: a user reaches the same content whatever the window. A dense data table at 388px still offers sort, filter, pagination, selection, column preferences, sticky columns and per-row actions — nothing dropped, nothing behind a breakpoint. Row actions collapse into a kebab overflow, which is relocation, not removal.
Density may be a user preference — and must not eat the floor
A compact density mode and a 44px touch floor can coexist, but only under one condition: the compact scale is not fully applied to elements with limited target space — the dropdowns in select, multiselect, autosuggest, date picker — and is meant for full-page, data-intensive views.
A density reduction never touches a control that is already at its target-size minimum. That clause is what keeps a compact mode from quietly deleting the floor. The rest follows: comfortable is the enforced default, the preference is persisted, and compact mode does not replace comfortable mode.
Say what it costs: reduced spacing can hinder readability for users with vision impairment, and interaction with close targets for users with physical disability.
The reflow numbers, exactly
Reflow requires content at 320 CSS px wide (vertical scrolling content) or 256 CSS px tall (horizontal scrolling content) without two-dimensional scrolling. 320px is "equivalent to a starting viewport width of 1280 CSS pixels at 400% zoom" — 400% applies to the dimension, not the area.
Two practical notes:
- Test at 320×256 directly, not by zooming a 1280px window. WCAG's own worked example measures a 1280×1024 laptop at 400% as 318×236 once scrollbars and browser chrome are subtracted.
- The two-dimensional exemption is scoped, not blanket. Maps, diagrams, video, games and data tables are exempt as layouts, but "each cell within a table would still need to meet this success criterion."
Reflow does not require text enlargement. WCAG explicitly works an example where text is reduced at the reflowed breakpoint and still conforms. Our 12px type floor is therefore a house legibility choice, not a WCAG requirement — do not defend it as one.
Note that two major systems disagree with us here — one lists show/hide
among its responsive techniques, the other ships breakpoint-keyed display: none
helpers — and we reject both.
The rejected convention worth knowing about: the industry drops the labelled step list from multi-step forms at phone width, replacing it with an unlabelled progress hairline (two products) or nothing at all (eight products). "Which step am I on, of how many, and what remains" is meaningful content. Take the one form in the corpus that got it right instead — it prints "Step 2 of 2" as text alongside the graphic, which is the compact representation that survives a narrow width without losing the information.
The related trap: neither query fires for text zoom
A viewport query and a container query both measure width. Dynamic Type and 200% text zoom change neither. A layout that only reflows on a width query will overflow at a fixed width when the text grows — which is why §9's TES-72 row moved a trigger's trailing value to stacking by content, using flex basis, rather than by any breakpoint at all.
What spills a 320px edge without breaking any rule (2026-09-27, TES-588)
The harness drops its padding at 320px, so a component there sits flush against the viewport, and anything drawn past the component's own box becomes page scroll. Nine examples failed this way once every example was measured. The lessons:
- Visible content stays inside the box. A label centred on a slider's first or last tick hung 2px past the track. The end labels now grow inward from the tick; the tick itself never moves.
- Invisible room outside the box needs a declared edge. A 44px tap pad on a
40px button, and a scroll row's 12px bleed for rings and fade, both live in the
page gutter. Only the page knows whether a gutter exists, so it is opt-in:
IconButton
tapEdge, ChipGroupflush. Never remove the room for everyone. - A centring parent unbounds
max-w-full.items-centershrink-wraps a child to max-content, and itsmax-w-fullthen resolves against that. Tabs caps its own root for this reason, and a skeleton must copy its component's caps: TabsSkeleton spilled wherever the real strip fitted. classNameon a root that holds two rows can lay out neither. TimeRange's root gained an error row, and the stacking an example did withclassNamesilently stopped. Expose the inner row as adata-slothook.- Rejected: truncating ButtonGroup labels. The equal-width cells are narrower
than their widest label at every width (the group sums natural widths, then
flex-1splits equally), so truncation showed "Und…" at 1280px. The fix is the sizing model (TES-591); until then usestackOn="auto".
Where to go next
- The class-string mechanics that make any of this actually render:
04-pointer-and-touch.md - Choosing the adaptation axis:
07-decision-guide.md - The pattern catalogue with worked code:
05-adaptive-patterns.md - Evidence and product names behind every claim here: the research ledger