Stage 10 · Responsive
Width
This product has been responsive since stage 04: every screen is a live screen at the full viewport, mobile-first, and the pixel comparisons of stage 08 ran at two widths. What it did not have is a width that was named. Eighteen different width numbers ran in the code and not one of them was a token, so nobody could say which of them was a decision and which was a leftover.
This page is the register. It holds the two points and what each one is for, the ladder that decides whether a point is needed at all, the containers that keep a line readable, and the grid that counts its own columns. Every example on it is live: drag the handle in the corner of a dashed box and the thing described actually happens.
Three methods, and the point is the last one
The ladder is read top down. Take the fluid answer; if the content cannot carry it, put the content in a container; and only what is physically impossible either way earns a point. “It is easier to write” is not a reason. A product with a point for every difference is three products with a switch between them, and there is always a width where none of the three looks intended.
| Fluid | Container | Point | |
|---|---|---|---|
| The question | will the content just stretch? | is the line too long? | is the behaviour different? |
| The mechanism | minmax(auto-fill), clamp(), %, flex-wrap | max-width and margin-inline: auto | @media for the shell, @container for a component |
| How many in this product | no limit | two, plus one reading measure | two |
| What proves it | drag the width and nothing breaks | the measure, counted in ch | a row of the audit saying what fluid could not do |
1. Fluid: the grid counts its own columns
No query in this rule at all. The floor is --grid-col-min, and the number of columns is whatever fits. Drag the box: it answers at 640px and at 641px and at every width in between, including the ones nobody predicted.
drag the corner · container -
2. Container: the line stops before the eye loses it
Past roughly fifty characters the eye stops finding the start of the next line reliably, and on a wide monitor a mobile-first paragraph runs the whole width of the screen. The measure is counted in ch rather than in pixels, because the thing being limited is characters and not millimetres.
Netflix went up by $2.50, now $17.99 a month. We saw the new amount on your statement on 3 August, and nothing else about the subscription changed. You do not have to do anything today.
Netflix went up by $2.50, now $17.99 a month. We saw the new amount on your statement on 3 August, and nothing else about the subscription changed. You do not have to do anything today.
drag the corner · container - · the held line stops at 52 characters, the free one does not stop
3. Point: two behaviours, not one stretched
This is the case fluid cannot reach. A navigation bar fixed to the bottom of the glass and a rail standing in a grid track beside the content are two different things, and nothing interpolates between them. The box below carries the tablet point and asks its place rather than the window, which is why it also switches when you drag it narrow inside a wide screen.
drag the corner · container - · switches at 47.5rem, which is 760px at the default text size
The two points, and where each number comes from
Both numbers already ran in the code before this stage. Nothing here is new behaviour: what is new is that each one now has a name, an origin, and a row of the audit that says what breaks at that width. A point is put where the content breaks, never where somebody's device happens to be. 768 and 1024 do not appear in this product, and the reason is that devices change every year and content does not.
| Token | Value | What changes | Why fluid could not do it |
|---|---|---|---|
--bp-tablet |
47.5rem 760px |
the shell. The tab bar leaves the bottom of the glass and becomes a 220px rail on the left, the app bar moves into it, and the content pane becomes its own scroller | a fixed bottom bar and a rail in a grid track are two behaviours. There is no value between them to interpolate, and the rail needs 220px beside a column that still has to hold a subscription row, whose own floor is 300px |
--bp-desktop |
56.25rem 900px |
the content stops being a column. The blanket 780px cap comes off, the screen becomes an explicit flex column so its blocks can be reordered, the dashboard head takes two tracks and the subscription detail takes two | reordering blocks is not a stretch, and a measure that comes off is a switch rather than a slide |
The number is the page container's width, not the browser window's
Added 2026-08-13, after the founder reported that the width behaved oddly in the DevTools device toolbar. The toolbar turned out to be showing a number it had not applied, which is a browser state and not this product: at a stated 360 the page still rendered across the whole window with no emulation frame beside it. But the check that settled it found something the register genuinely owed its reader.
Every width query in this product is a @container query and the container is body, declared once in base.css. A container query reads the content box, and a classic scrollbar sits outside it. Measured on Home in Chromium, one pixel at a time:
| Window | Container | Shell |
|---|---|---|
| 760 | 745 | bottom bar |
| 770 | 755 | bottom bar |
| 774 | 759 | bottom bar |
| 775 | 775 | rail |
So the tablet point arrives at a 775px window where the scrollbar is classic (Windows, Linux, macOS set to always show it) and at exactly 760 where it is an overlay (macOS by default, iOS, Android). The container jumping 759 to 775 in a single pixel is the scrollbar leaving: past the point the pane becomes its own scroller and the document stops scrolling. Only the tablet point carries the offset, and for the same reason: at --bp-desktop the container equals the window and 900 is 900.
The register keeps the container number, and that is a decision rather than a default. 760 is the width at which the content has no room left, and when a scrollbar is taking 15px the content really does have 15px less. The alternative was measured before it was rejected: moving the shell to @media puts the number back in the window and costs the stand, where the 434px specimen on the tab bar's page renders the phone bottom bar today and would render the desktop rail. Two consequences are load-bearing and both are now written into design/system/CLAUDE.md: a point is a container width, and nothing between body and .app may take horizontal padding, which the reviewer's dock did with 220px until 2026-08-12.
Why rem and not px
A point in pixels asks how wide the window is. A point in rem asks how wide the window is relative to the text the person set. Somebody reading at a 24px root sits at a “desktop” width with a phone's worth of words in a line, and a pixel point cannot see that. At the browser default of 16px these are 760 and 900 exactly, and the root font size is set nowhere in this system, so naming them in rem costs zero pixels for everyone who has not asked for larger text.
Updated after publishing, 2026-08-13: the paragraph above was only half true on the day it was written. “The text the person set” was the whole ground of the decision, and this stage closed with the type scale still in px, which ignores that setting completely. So the point moved with the person and the type did not. Measured on design/home.html at a 24px root: the text stayed 16 and 12px while the 900px window fell below --bp-tablet (47.5rem is 1140px at that root), so the rail went away, the page measure came off and .screen went from 680 to 900. The reader this argument was written for was the one reader it made worse off. The founder asked why the type was not rem; the scale moved the same day, proved across 8 596 element records on all 32 coloured screens at four widths, and the paragraph above is now true rather than half true. No number in this register changed. See the type scale and docs/decisions.md.
And why this stage's own instrument could not have caught it. The width sweep drives the viewport, and page zoom scales px and rem alike, so every tool built here was blind to the browser's font-size setting by construction. A width sweep is not a text sweep. What it hid was real: at 390px and a 24px root design/upgrade.html scrolled sideways by 16px, because the plan card's non-wrapping button grows from 249px to 356px inside a 358px track. Fixed at the component, one scoped exception on button.html.
The register, and why the number is written twice
@media (min-width: var(--bp-tablet)) does not work, and the way it fails is the problem: a query is resolved before the variable cascade, so there is no error and no warning, the rule simply never matches. The number therefore has to be written twice, once in tokens.css where it is the source of truth and once as a literal inside each query.
That is not two sources of truth. It is a register and its application, and stage 10 turns the constraint into an instrument: every width query in design/system/ must resolve to a number from this register and to no other number, which is a grep rather than an opinion. Opened at step 2 and closed at step 4: the literals in the component files were still written in pixels while the register was being built, so for two steps the register and its application agreed in value and disagreed in unit. They were all repointed when those files were opened.
There is no third point, and no third number either
One number stood outside this register until 2026-08-13: the 1340 in groups-column-set.css, which took the category column set from two columns to three. It was carried as open rather than blessed, because a third point is the founder's call and never a side effect of a refactor.
It closed by disappearing rather than by being promoted, and measuring the count is what settled it. At a 900 container a category group rendered 269px, which is 31px under the 300px floor that same file declares its rows need, and it stayed under it for a 60px band; at 1335 a column was 486px, nearly two floors wide, one pixel before it snapped back to 310. A count switched by hand cannot help doing that: between two steps the columns grow without limit, and at the step they collapse. The set now takes columns: 300px and counts its own, so the floor is the rule instead of a sentence in a comment. The cap that stood beside it for one day is gone too: calc(300px * 3 + var(--space-48) * 2) was 996px, and it is what made the right edge of Home ragged at every width above 1296. Three columns stay the maximum through --container-wide instead, 1280 against the 1344 a fourth needs. One cap in one file, and it belongs to the shell, because it is the shell that decides where a screen ends.
Every width query in design/system/ resolves to a registered value, and to no other number. That is the instrument the register exists to be, and checking it is a grep.
Recounted 2026-08-16, and the sentence above used to say THREE. It was true when this page was published and stopped being true the day the public page was built. At stage 10 the census read 47.5rem x 8, 56.25rem x 5, 28.75rem x 2, on a corpus with no landing in it. Counted again today by script, with comments stripped, on the whole of design/system/: 47.5rem x 18 and 56.25rem x 10 of the page container, 28.75rem x 2, 80rem x 2 of the page container, 60rem x 6 / 59.99rem x 3 / 75rem x 1 / 85rem x 1 asked of the hero's NAMED container story, and one question that is not about width at all: max-height: 42.5rem x 2. 45 literals in nine groups. The five that were unregistered are registered below, with their origins, on the founder's word of 2026-08-16.
These two tokens are the only ones in the system with no var() reader, and that is the mechanism rather than an oversight. A query condition is resolved before the variable cascade, so @container (min-width: var(--bp-tablet)) cannot work anywhere, in any browser. The token is the register and the literal in the query is its application, so the reader here is a grep and not a var(). It is written down because "a token with no reader" is a defect everywhere else in this system, and the read-only critic raised exactly that on the first pass.
These tokens have no dark pair, and that is a decision
A :root and [data-theme="dark"] pair is a property of the semantic level, which is to say of colour. A width, a container and a gutter are geometry, and geometry does not change direction in the dark. Do not look for a pair here and do not declare an empty one. The one thing that will need a role is any surface that exists only on a wide screen and carries its own background, such as the rail, and that is checked in the dark theme at step 3, where nobody has yet seen it.
The containers
Two page containers and one reading measure. Two and not one, because a screen that is a form and a screen that is a pane of content stop at different places, and folding them would move ten flow screens to make one number tidier.
| Token | Value | What it holds |
|---|---|---|
--container-page | 48.75rem 780px | the widest a screen's content block gets between the two points |
--container-wide | 80rem 1280px | the widest it gets above the desktop point. Added 2026-08-13 |
--container-column | 38.75rem 620px | the single column: a flow screen centred in the pane, and the list column of the list-column pattern |
--container-text | 52ch | continuous text, anywhere it appears |
--container-wide closes a question this register carried in writing. The comment on --container-page said the measure “comes off entirely” above the desktop point and that whether that was right was a step 4 question. It was not right, and the founder's report is what settled it: a lot of white space on the right at large widths. The screen was not too narrow, two right edges disagreed.
| Window | Pane | Head + alert | Category column set | Unused |
|---|---|---|---|---|
| 1280 | 980 | 965 | 965 | 15 |
| 1440 | 1140 | 1125 | 996 | 144 |
| 1600 | 1300 | 1285 | 996 | 304 |
| 1920 | 1620 | 1605 | 996 | 624 |
One cap lived in app-shell.css and the other, 300 * 3 + 48 * 2 = 996, in groups-column-set.css, and neither knew about the other. Now every block of a wide screen stops at 1280, the column set fills it, and the groups cap is deleted rather than moved. 80rem is not a round guess: a fourth column of the 300px floor needs 1344px, so any cap below that keeps “three columns is the widest” true by arithmetic rather than by a remembered number. Nothing moves below a 1296px window, which is where the pane first passes 996. Above a 1360px pane the content stops growing and the air is symmetric: 50px each side at a 1600 window, 210 at 1920. The gap between the rail and the content grows with it, and that is the cost, named rather than discovered. Left-aligned, a 1920 window puts all 340px of the air on one side.
It is a padding on the pane and not a max-width on each block, and that is the founder's second question answered: maybe centre it on the page. A cap on the children ends a wide screen in the right place but it cannot CENTRE one: margin-inline: auto centres each block at ITS OWN width, and this product does not have one width per screen. Measured at 1920 before choosing: Home and its four states carry one width, 1280; History and Trends carries three, 1280 for the chart and the meta row, 525 for the readout and 459 for the muted lines; Alerts and Settings carry 620. Centring each child would have moved the readout 377px in and left the chart at zero, which is a ragged LEFT edge, the same defect this measure was opened to cure on the right.
So the shell writes padding-inline: max(var(--space-40), (100% - var(--container-wide)) / 2) on the pane. One content box is centred and every block inside it stays flush left. Below a 1360px pane the max() floor is the 40px the 760 block already set, so nothing moves; above it the two gaps grow together, 50 and 50 at a 1600 window, 210 and 210 at 1920. Verified at six widths on eleven pages: one distinct left edge per screen, two on a detail screen because a detail screen has two columns, and no horizontal scroll anywhere.
Two things fell out of it that a child cap could not have done. The detail grid IS .screen, so .app > .screen > * never matched it and it was on its way to backlog.md as the one screen that ends somewhere else; a padding reaches it, and unlike a max-width it does not move the pane's scrollbar into the middle of the window, because the pane still spans its track and only its content box is capped. And the ten flow screens are untouched, which is specificity rather than luck: .app.flow > .screen is 0-3-0 with its own padding shorthand against this rule's 0-2-0, and a container query adds nothing to either.
52 and not the 60 to 75 of a text page, and the number was read off this product rather than off a guideline. The corpus had four measures: 46ch on the readout, 48ch and 50ch on the two text blocks, 52ch on the empty block and the ruled muted line. 52 is the mode. Tendd has no long-form prose: its longest continuous text is a paragraph explaining a charge, and a 65ch line would make a two-sentence explanation look like an article. Step 4 folds the four with a measured report on each, and any that genuinely differ stay as a documented exception rather than being rounded into agreement.
The grid, and why the column count is not a token
The obvious way to write this is --col-count-tablet: 2 and --col-count-desktop: 3. It is wrong for one reason, and the reason is the whole ladder in miniature: a fluid answer never has to be switched. A grid built on a floor keeps working at 1100px, which nobody planned for, and at 1101px, and at whatever the next device does.
| Token | Value | What it is |
|---|---|---|
--grid-gap | 8px | the air between equal children. The door row takes 16 and overrides locally, on the axis the grid page already declares: gap follows the child's weight |
--grid-col-min | 10rem 160px | the default narrow floor, measured and not chosen: at a 360 viewport the content zone is 328px and the preset tiles stand two across with an 8px gap |
| the column count | - | not a token, and it will not become one. The grid counts it from the floor |
A child whose floor is not 160 overrides the token in its own file, which is where grid.css has said the floor belongs since stage 09: “a floor per child, with the floor in the child's own file”. That wiring happens at step 4, per child and with the arithmetic shown, because one floor demonstrably cannot reproduce two columns at 328px and three at 780px: two at 328 needs a floor at or under 160, and exactly three at 780 needs one over 189.
auto-fill and not auto-fit, and that is this product's own correction to the usual recipe. auto-fit collapses empty tracks, so the column count starts depending on how many children a screen happens to put in the grid, and the grid's own page forbids exactly that: “never by how many children there happen to be”. auto-fill keeps the count a property of the place.
The shell
The shell is the app bar, the carrier of the top-level navigation, and the page container. Its form on a wide screen is not a preference: it is derived from the navigation model of stage 03a by three questions, and it has three legal answers. The navigation model itself is not reopened here. How many destinations there are, in what order, and at which level was settled at 03a; this stage decides only what shape they take when there is room. Wanting different destinations on a desktop is a finding about the information architecture, not a layout decision.
The three questions, and the answers the model gives
| Question | Answer | Where it is written |
|---|---|---|
| How many top-level destinations | four: Home, Alerts, Save, You | ia/docs/sitemap.md, Global navigation. Each one carries a job cluster, and History and Trends is deliberately not among them because a mostly-locked tab would break the calm promise |
| Is there a second level that has to stay visible | no | the same file: Settings, Connections and Data and Privacy sit behind You as deep, Subscription Detail and the rest are contextual, and Upgrade appears at a gate rather than standing in the chrome |
| Does any screen take side space for new behaviour | no | the audit: split view was declined, and the ground is in the census section below |
The decision: B, the rail, confirmed rather than re-decided
Taken mechanically, four destinations with no permanent second level point at answer A, the destinations moving up into the header. The rail was kept, and the reason is that the form had already been chosen with the thing in front of the person choosing: the navigation model has said “a bottom tab bar, scaling to a left rail on desktop” since stage 03a, the shell has implemented it since stage 08 across 28 coloured pages, and on 2026-08-12 the founder looked at that rail on a coloured screen and set its width to 220px. Nothing has been learned since that argues the other way. Founder, 2026-08-13.
The counter-argument is recorded rather than dropped, because it is this project's own and it will come back. app-shell.css writes it at the line where the rail narrowed: a 240px column holding a 22px mark, a 46px word and four one-word destinations was mostly air, and the air read as importance the navigation does not have. A rail spends 15% of a 1440px window on four words. If that ever stops being worth it, the change is answer A and it is a change to three component files and a line of the sitemap, not a rewrite.
Both forms, live
The two specimens below are the real shell: the same markup a screen carries, drawn by the same rules out of design/system/. Nothing is redrawn here. The first box is 320px wide and the second is the full width of this column, and each one is its own container, which is why the same CSS gives two different answers on one page. They stack rather than standing side by side, and that is arithmetic rather than taste: this column is about 944px, so a 320px specimen beside its pair would leave the second one 604px, under the tablet point, and the page would have shown the same form twice while claiming to show two.
First: under the tablet point. The bar is on the glass at the foot of the specimen, the app bar is a row across the top. Second: over it. The same nav is a 220px rail in the first grid track, the app bar has folded into the head of that rail, and the content pane is its own scroller. Neither box declares a width for the navigation: at the narrow width left: 0; right: 0 hands it everything, and past the point the shell's grid track hands it 220.
On a phone this page shows the same form twice, and that is the demo telling the truth rather than breaking. A container cannot be wider than the page it stands in, so at 360 the second specimen is 289px and answers exactly as the first one does. The wide form needs a wide window to be shown in, which is the whole subject of the page.
One carrier at every width, and it is one element
The classic defect here is a header menu and a bottom bar both alive on a desktop, because each was built separately and neither was hidden. This product cannot have it, and not because somebody remembered to check: there is only one navigation element in the markup, and it changes form rather than yielding to a copy. Nothing is hidden, so nothing can be left behind in the accessibility tree for a screen reader to walk twice.
Measured on design/home.html at 28 widths from 320 to 1600 by computed style, not by reading the CSS: exactly one visible top-level carrier at every one of them. The switch is exact where the register says it is, 759 a bar and 760 a rail. On the activation chain the tab bar is absent from the markup entirely rather than hidden, which is the sitemap's own rule for that chain, so those screens carry zero carriers by construction and the content column stays centred at 620px at every width above it.
The rail is a surface that exists only on a wide screen, so it needs a role and it has one. It is --bg-recessed against the screen's --bg-surface, with --line-container down its right edge, and both are paired in the dark theme. Measured on design/home.html in both: the fill separates the rail from the pane at 1.08:1 light and 1.11:1 dark, the edge at 1.23:1 and 1.33:1, so the hairline is the load-bearing separator in both directions and the pair holds the same relationship rather than merely existing. The destinations read at 5.37:1 at rest and 6.23:1 current in the light theme, 5.77:1 and 6.82:1 in the dark. Checked rather than assumed: a wide layout is the one part of a mobile-first product that nobody has ever looked at in the dark.
One thing this stage found and did not fix
In the rail form the focus order and the visual order disagree, and the disagreement is worth stating precisely rather than waving at. Measured on design/home.html at 1440 across 23 focusable controls: the rail's head takes focus first, the eighteen controls of the content pane come next, and the four destinations, which sit visually in the same left column as that head, come last. The rail looks like one object and is tabbed as two, with the whole screen in between.
It is not a hidden-carrier defect and not a duplicate: everything reachable is visible and each control appears once. Content before navigation is also a defensible order, and it is the order a skip link exists to produce. What cannot fix it is CSS, and that is the part worth writing down: the shell reorders its blocks with order and grid-column, and neither moves the tab sequence. Making the two halves of the rail adjacent means changing the markup, which on a phone would put four destinations in front of the content for every person who tabs. So the order has to differ by width in the DOM, which is a decision about 53 pages rather than about 33, and it is written into docs/backlog.md addressed to stage 12, where the remaining screens get their markup for the first time.
How components behave
A media query asks about the screen. A container query asks about the place. A card does not know whether it stands in a one-column list or in a grid of three, and a desktop has narrow columns too: the detail screen's two are 546px each at a 1440 window. So a component asks its own container, and the one exception is the shell, where a media query is right because the shell is the viewport.
container-type: inline-size is declared by whoever places the component, a pattern or the page frame in base.css, never by the component itself: a place is not a property of a brick. And a container query matches an ancestor, never the element itself, so a component that declared its own container would not be able to read it.
23 of 60 adapt, and 37 do not
Counted from the CSS by script rather than remembered, and written into the width column of docs/inventory.md on every row. An empty cell there would mean "behaves unknown on a wide screen", which is why the column is filled on all sixty and the note on a component page is written only for the twenty-three. An empty "does not adapt" paragraph on thirty-seven pages is noise, not documentation.
| How it adapts | How many | Which |
|---|---|---|
| point only | 5 | app bar, category group, chart placeholder, dialog sheet, tab bar |
| point + fluid | 4 | app shell, dashboard head, grid, groups column set |
| container threshold + fluid | 2 | save-focus candidate, subscription row |
| fluid only | 4 | action row, alert item, meta row, merchant chip group |
| a reading measure, and no query at all | 7 | readout, text block, empty block, muted line, wash block, promise list, card |
| point + measure, on a pattern | 1 | list column |
| does not adapt | 37 | the rest |
Three rows moved at step 6, and which way is the point. Wash block and promise list were among the 39 that did not adapt, and the width sweep found their prose running without a measure. Card left the point group for the measure group without losing anything: its 560px measure stopped asking a question it never needed to ask. The groups column set moved from point to point + fluid when its column count stopped being declared.
The register of container thresholds
A container threshold is local and does not become a token: it belongs to one component in one kind of place, the same test a component token had to pass at stage 08. But every one is listed, because without a register a grep cannot tell a measured local threshold from a point invented for somebody's device.
| Threshold | Where | What it decides | Why it is not a point |
|---|---|---|---|
| 28.75rem 460px | save-focus-candidate.css, and the third rule of the same block in subscription-row.css for a cascade reason | the cut control drops to its own line and pushes to the end; the row takes the full width | it is a fact about one row: a merchant name, an amount and a second control stop sharing a line. It says nothing about the screen, and a candidate can stand in a 546px column on a desktop and need the same answer |
| 60rem 960px + 59.99 below | landing-orbit.css x6 and x2, landing-story.css x1, all asking the named container story | whether the hero's stage can hold the spread at all: below it the window, the list and the cards stack; above it they stand around the pinned window | it is a fact about one stage, and it asks the section by name rather than the page, so it is not a question about the product's layout at all |
| 75rem 1200px | landing-orbit.css x1, container story | the two corner figures clear the reason card's column and take their outer positions | the same: a fact about the width of one composition |
| 85rem 1360px | landing-orbit.css x1, container story | the widest band of the stage, where the spread stops growing | the same |
| 80rem 1280px | landing-story.css x2, the page container | the reason cards stand beside the list instead of under it | it is the one threshold here that asks the page, and it is not an invented number: 80rem is --container-wide, already declared in the width block. The arithmetic is in the file - the list is 620, its edge 310 from the centre, 24 of air puts a card's inner edge at 334, and a card stops being a card under 17rem, which puts the true point at 1260; 80rem is the next declared value above it |
| 42.5rem 680px height | landing-orbit.css x1 and landing-story.css x1, as @media (max-height: ...) | the pinned stage gives up and lays itself out as an ordinary column | it is the only height question in the product, and it cannot be a container query at all: reading a container's height needs container-type: size, which stops the box growing to its contents, which is the very thing this branch exists to allow. Measured between 640, which fails, and 780, which works |
Five rows arrived on 2026-08-16, on the founder's word, and they had been live since the landing was built. The alternative was redesigning the hero to ask the two points, and it was rejected on the ground that four of the five do not ask the page at all: they ask a named container, which is a question about one composition rather than about the product's layout. What they owed was their origins, and those are in the table. 28.75rem is still the only local threshold on an APP screen, and that is stated rather than left to be inferred: the app's half of the register is short because the app is, not because nobody looked. Zero pixels moved when those two blocks became container queries, and that is worth knowing rather than celebrating: the nearest container is still body, so the question resolves to the same number it did as a media query. What changed is what happens next, when a pattern puts that row in a column of its own. The rule is now true instead of accidentally true.
What moved, measured at both ends
At 360 the difference is zero on all 32 coloured pages, checked twice on two fresh origins by comparing the position and size of every element inside the shell. That half of the promise is not allowed to move: the mobile layout is the base of a mobile-first product and this stage had no business touching it. At 1280, 21 pages changed, and each one holds one of the causes below. A desktop difference with no cause is the same defect as any difference at 360.
| What | Was | Is | What moved, and which audit row |
|---|---|---|---|
14 query literals to rem | 9 at 760px, 5 at 900px | 47.5rem x 8, 56.25rem x 5 | nothing at the default text size, which is the point. The register is now true, and a person reading at a 24px root gets the narrow form while a line still holds a phone's worth of words. Eight and not nine: one of the 760 queries came off entirely at step 6, with the prose card's measure that never needed a query at all |
two @media at 460 | asking the window | @container asking the place | nothing today. See the register above |
| four reading measures folded into one | 46ch, 48ch, 50ch, 52ch | --container-text, 52ch | a text block's paragraph goes from 424px or 441px to 459px at 1280. 21 pages, and the whole of the change on 19 of them. Audit rows 9, 10 and 16, wider by air |
| the readout's measure, which was dead | computed none, rendered 980px, the full pane | 525px | the fourth time this product has found the same unbinding: .app .readout at 0-2-0 lost to the shell's max-width: none at 0-3-0 past the desktop point. A reading measure that silently stops applying at a wider container is exactly why acceptance happens at both ends. History and Trends and its empty state |
| the plan row's cap, also dead | computed none, rendered 728px | 720px | the defect stage 09 wrote down and carried with "it closes with the name". Founder's decision: it closes here. Four Upgrade pages |
| two grid rows stopped declaring a count | repeat(3, 1fr) | auto-fill with a floor per child | nothing at 360 and nothing at 1280: the fluid form reproduces both declared counts exactly, because both rows stand on flow screens whose pane is capped. At 320 the tile row goes from two cramped tiles to one, which is the floor doing its job: this component's own page puts a tile's survival at 159px, and two tiles at 320 were 146px each |
| the door row kept its count | 1fr 1fr | unchanged, deliberately | there are two doors because the product offers two ways in (D2), not because two fit. Fluid would have put four doors across a 1440 window with two tracks empty, on the screen whose whole job is a choice between two things. The count is content here, not width |
| the groups column set stopped switching a count | columns: 2 from 900, columns: 3 from 1340 | columns: 300px, capped at three | the third number is gone from the product. Before: a group was 269px at a 900 container, 31px under the floor the file declares, and stayed under it to about 960; at 1335 a column was 486px, one pixel before snapping back to 310. After: 1 column to 962, 2 from 963, 3 from 1311, never narrower than 300. The 996px cap this row first shipped with was deleted on 2026-08-13: the set fills --container-wide now, three columns of 395 at a 1600 window. Audit row 7, Home |
| three literals became container tokens | 620px twice, 780px once | --container-column, --container-page | nothing. Same values, one owner. A fourth, the landing's own 780, joined them at step 6 when the read-only critic found it repeating the token's value beside the token |
| four more prose measures, all found by the sweep | a plain muted line, a wash paragraph, a promise item and a prose card had no measure at all | --container-text on the first three; the card keeps its own named 560 | the muted line under the chart on History and Trends ran 1300px, 150 characters to a line, at a 1600 window, on all four states of that screen. Settings 620 to 459, Home empty 536 to 459, the wash paragraph 594 to 459, the three promises 620 to 459. Ink moves only where a line re-wraps: 38 muted boxes narrow and six paragraphs actually change shape |
| the prose card's measure stopped asking a point | max-width: 560px inside a 47.5rem query | the same rule, no query | at a 750 screen the card ran 684px and its paragraph read 78.9 characters, the worst measure in the product. A max-width never widens anything, so below 560 the query bought no behaviour and cost that band |
| the detail screen's two columns count themselves | minmax(300px, 1fr) minmax(320px, 1fr), placed by hand | repeat(auto-fit, minmax(320px, 1fr)) | the valley at 900 to 927 is closed. The point fires at 900, the rail takes 220 and the padding 80, so the pane is 600 while the two floors plus the gutter need 668: the pane scrolled sideways and the second column's right edge sat at 928 on a 900 screen. Above 988 the geometry is identical to the hand-placed version, pixel for pixel |
The width sweep, and the five things it caught
32 coloured pages by 58 widths is 1856 measurements. The widths run 320 to 1600 in steps of 40, with 10px steps around both points (730 to 790, 890 to 910), around the container threshold (450 to 470) and around the two widths where the groups column set changes its count. The reason is the pack's and it is exact: a defect lives between the points, and the worst width is the one where the content has already stopped fitting and the query has not yet fired. Three screenshots prove three widths.
| What is read at every stop | How | Why this one |
|---|---|---|
| horizontal scroll of the document | scrollWidth > clientWidth | the standard check, and it is not enough here: past the tablet point the shell gives .screen its own overflow: auto, so a layout that does not fit scrolls INSIDE the pane and the document stays clean. It came back clean at all 1600 stops while a real overflow was running |
| every element that leaves the viewport | the rectangle of every node inside the shell against clientWidth | this is the check that found the valley. Per element, so a clipped column cannot hide behind a scrolling ancestor |
| the carrier of top-level navigation | count of visible .tabbar elements and of the links inside them | the rule is exactly one at any width. Verified in the markup too: 22 app-frame screens carry one, the 10 flow screens carry none, and no screen carries two. CSS changes its form, never its number |
the reading measure, in ch | the box of every prose leaf over 90 characters, divided by the width of a "0" in that element's own computed font | ch and not px, because the whole reason the measure is a token in ch is that it follows the font. A cap in px reads as fine at 15px and as a wall of text at 20px |
What it caught, and who found it
| # | Finding | Who | Verdict |
|---|---|---|---|
| 1 | A prose paragraph at 78.9 characters a line, 684px, in the band just under the tablet point (history-trends-locked, worst at 750) | Claude, browser | fixed. The card's 560px measure sat inside a container query it never needed. The query came off, the measure stayed |
| 2 | A plain muted line has no measure at all: 1300px and 150 characters at a 1600 window on four states of History and Trends, 620 on Settings, 536 on Home empty | Claude, browser | fixed. The measure existed for the block form and the ruled modifier; the plain form had been missed since stage 08 |
| 3 | The wash paragraph and the promise item have no measure, 594px and 620px | Claude, browser | fixed. Both are running prose and both read --container-text now |
| 4 | The valley at 900 to 927: the Subscription Detail pane scrolls sideways and the second column's right edge sits at 928 on a 900 screen, on all six of its pages | Claude, browser | fixed. The two columns count themselves; above 988 nothing moved |
| 5 | A sixth element needed the > .screen > binding against the shell's blanket max-width: none | Claude, browser | bound, and filed in backlog.md as a question about the blanket itself: five was a pattern, six is a defect generator |
| 6 | The landing's 780px repeats --container-page as a literal, beside a comment saying it stays literal | Codex, source | fixed. The comment predated the token by one step |
| 7 | Four rows of inventory.md disagreed with the CSS, and the register said "one of the two values" while two live queries use the third | Codex, source | fixed in both the document and this page |
| 8 | The two point tokens have no var() reader, which the system's own rule calls a defect | Codex, source | dropped at verification, and written up instead: a query cannot read a variable, so these two are a register whose reader is a grep. A critic without that paragraph is right to raise it |
| 9 | The landing's plan row still declares repeat(3, 1fr) where the same file counts elsewhere | Codex, source | dropped at verification. The landing's pricing block holds exactly three plan options, so three is content and not a fit question. It is the door row's argument again, and the reason is now written at the rule |
| 10 | The register published its numbers as if they were window widths, and they are container widths. The tablet point fires at a 775px window wherever the scrollbar is classic. Three documents also stated that the shell runs on @media while the code has always run it on @container, and three stand pages still said a review page gives a screen 250px less than the browser, which stopped being true when the reviewer's dock was deleted on 2026-08-12 | Founder, browser (reported as "the responsive glitches"); measured by Claude | confirmed and written down, not migrated. The container number is the honest one: when a scrollbar takes 15px the content really does have 15px less. Moving the shell to @media was measured and rejected, it would render the desktop rail inside a 434px specimen on this very stand. The three false sentences are corrected, the two consequences are now rules, and the sweep grew a second pass that reports the window |
The state after the fixes, measured and not asserted
1856 stops: no horizontal scroll on the document, and no element leaving the viewport, on any page at any width. The pane no longer scrolls sideways anywhere either. The carrier is exactly one on every app-frame screen at every width, with the same four links. The worst reading measure left in the product is 60.7ch, the prose card, and everything else lands at 53ch or below.
At 360, all 32 pages are identical to the pre-stage baseline, compared element by element on the box AND on the ink the text actually paints: zero differences of either kind. At 1440, 32 pages differ in box and 19 in ink, and every difference belongs to a row of the table above. That asymmetry is the shape the stage promised.
The one measure left over 52ch stays, and is written down rather than resolved. The prose card's paragraph reads 60.7 characters at a 600 screen because the card holds 560px, and that is a named decision with its own note in card.css. 52ch and 560px disagree by eight characters. A named value is not re-derived in a verification step, so it goes to backlog.md for the founder.
Three traps, written down so the next stage does not pay for them again
A green measurement has to be interrogated when it is too good. Twice a comparison came back saying "nothing changed" when something had to have changed, and both times it was the HTTP cache holding the old CSS on the same origin. What works is a fresh port, so the browser has a new origin and no memory. The rule: when a result is surprisingly clean, re-run it somewhere the cache cannot reach before believing it.
And a CSS comment can eat a rule. Two of the four measure fixes were first written as a comment appended after an already-closed comment block, which left prose sitting in the stylesheet and a stray */ after it. The browser dropped what followed, the 360 comparison lit up on six pages, and that is how it was caught: the 360 check earned its keep by failing. A comment-balance check now runs over all 65 stylesheets and is clean.
And an instrument can hide the one thing it was built to see. The harness injects html{scrollbar-width:none} into every iframe so that the container really is the width the run claims, which the four checks above need. That injection is exactly what kept the 15px between a point and a window invisible for the whole stage: in here the tablet point fires at 760, in a browser window it fires at 775, and the sweep could not tell the difference. The injection stays; what it gained is a second pass at the two points with the scrollbar left alone, printed under THE POINT IN A REAL WINDOW, and a header saying which of the two numbers each pass reports. Third blind spot of this instrument, after the pane that scrolls inside a clean document and the cache that serves an old stylesheet.
New behaviour: none
The audit's new-behaviour column is empty on all 17 screens, so this step ran in its short form. A short form is not a skipped step. It owes three things: the list of what was considered, the ground each candidate failed on, and a measurement showing that no new behaviour arrived by accident while the layout was being moved around.
What was considered, and why none of it passed
| Candidate | What a wide screen would gain | On what ground it was rejected |
|---|---|---|
| Split-view the list and the detail in one pane | one fewer navigation per charge decoded | it meets the mechanical threshold twice and is refused on three recorded sources anyway; they are in the audit below. The job it would close is already closed by the detail screen standing in two columns past a 900 container since stage 08. Founder, 2026-08-13 |
| A table of the list sortable columns, one row per charge | scanning by amount or by date | the audience line is “not budget optimizers or spreadsheet fans”, and barrier P4 is “too many numbers and graphs”. A sortable table is the precise object this product exists not to be, and it would arrive at exactly the width where the person is most likely to be at work |
| Bulk select, bulk cancel | cancelling three forgotten things in one pass | width is not an argument for a new job. Which jobs exist is a decision of stage 03a and of scope, not of layout, and the cancel moment is a guided walk that is per subscription by design: a small win is one win, and five at once is a purge |
| A permanent second panel the total, the trust line or filters pinned beside the list | the number always in view | two grounds. D1 is portioned information, and a permanently visible summary un-portions it on the one device where the reveal has room to breathe. And the shell already spends the left side on the rail, so this would be a second permanent surface arguing with the one carrier |
| A hover-revealed row action cut or edit appearing on pointer-over | a cleaner row at rest | a wide viewport is not proof of a pointer, and the action would be unreachable by touch. Verified rather than asserted: the system declares hover 51 times and not one of those declarations touches display, visibility or opacity. Hover here is always a state of something already on the screen |
And it did not arrive by accident, which is measured rather than argued
All 32 coloured pages were rendered at a real 360 and a real 1440, the scrollbar suppressed so the container is the number it claims to be. On each page, every painted node inside the shell (a non-zero box, with display, visibility and opacity all live, plus any non-empty ::before or ::after content) and every focusable node were collected, and the two widths compared as sets.
| What was compared | 360 | 1440 | Result |
|---|---|---|---|
| Pages | 32 | 32 | 32 of 32 identical, in both directions |
| Elements inside the shell | 2149 | 2149 | not one node appears only at 1440, not one disappears |
| Focusable nodes | 303 | 303 | no affordance is added by width and none is taken away |
| Horizontal scroll | 0 | 0 | at both ends of the range |
What that proves is bounded, and the bound belongs in writing: the wide screen shows the same things arranged differently. It does not prove they are arranged well. That is the other half of the asymmetry, and it is the width sweep, not this table.
The one thing that is new at width is the rail's own surface, and it is a form of a carrier that exists on a phone (the same four items, the same order, one carrier) rather than a new behaviour. Because it is a surface no phone ever painted, it was checked in the dark theme instead of being assumed.
The idle control, run the other way
A new behaviour would have owed a new string. Split-view invents the state “nothing selected”, which has never existed on a phone: it would have gone through voice.md, into microcopy.md, and voice.html would have been rebuilt in the same step. None of that happened. microcopy.md carries no selection-empty line, needs none, and the whole of voice/ is untouched by this stage.
The census, and the audit
Both tables in full are in docs/responsive.md. What follows is the part a person needs on screen: what the product's width looked like before the stage, and what every screen is supposed to do with a wider window. Stage 12 reads the second table row by row, so that the agent building a screen reads the decision for that screen instead of taking it.
Before the stage: 18 width numbers, 0 tokens
| What | How many | Which |
|---|---|---|
| Query thresholds | 4 | 460 (x2), 760 (x9), 900 (x5), 1340 (x1) |
| Fixed layout widths | 11 | 220, 250, 300, 320, 420, 480, 560, 620, 720, 760, 780 |
Reading measures in ch | 4 | 46, 48, 50, 52 (x2) |
| Width tokens | 0 | no --bp-*, no --container-*, no --grid-* |
And the finding that decided the shape of step 4: the 32 coloured screens hold zero width rules of any kind. No @media, no @container, no inline width. The half of this stage that usually consists of dragging rules out of screen files is empty here, because stages 07 and 08 already held that line. What does have to move is smaller and more specific: two @media blocks at 460px that belong in a container query, and one at 760px serving a landing page that does not exist in colour yet.
The audit: one row per screen, 17 screens, 57 pages
Read off the grey corpus, because that is where the whole product lives; the coloured folder holds a sample. Same is the default: a screen earns another category only when there is ground for it, and “new behaviour” is only taken when a job or a barrier can be named.
| Screen | What the person does | Category | How |
|---|---|---|---|
| Welcome 1.1 | reads whether this is for her, before any ask for data | Wider, grid | fluid |
| Path Choice 1.2 | chooses between two doors | Same | container |
| Connect Bank 1.3 | decides to trust us and connects a bank | Same | container. This is the trust moment and width does not improve it |
| Add Subscription 1.4 | finds a preset or types one in | Wider, grid | fluid tiles, the form keeps its measure |
| Guided Reveal 1.5 | lives the aha one step at a time | Same | container. Principle 2, one thing at a time: width works against the gesture |
| Sign In 1.6 | comes back without a password | Same | container |
| Home 2.6 | looks at the whole list calmly | Wider, grid | point 900, then the column set counts its own columns from a 300px floor |
| Subscription Detail 2.7 | decodes a charge and decides its fate | Wider, grid | point 900, and past it the columns count themselves |
| Alerts 3.8 | reads what changed and goes to act | Wider, air | container |
| Cancel Guide 4.9 | walks the steps of cancelling | Wider, air | point 900, two columns already |
| Cancel Win 4.10 | lives a small win | Same | container. One number in the middle, and that is the screen |
| Share Snapshot 4.11 | shares a card that carries no account figures | Same | the card has its own ratio |
| History and Trends 5.12 | sees her own year | Wider, grid | fluid. A chart is the one thing that genuinely grows with width |
| Upgrade 5.13 | compares two plans | Wider, grid | fluid |
| Connections 6.14 | manages the sources | Same | container |
| Data and Privacy 6.15 | reads what we hold about her | Wider, air | container. This is continuous prose, and prose is what a measure is for |
| Settings 6.16 | looks for the right door | Same | container |
Same 8 · Wider 9 (grid 6, air 3) · New behaviour 0.
Why new behaviour is zero, and that is an answer
Mechanically the split-view threshold is met twice: flows.md gives Home to Subscription Detail in flow C and Alerts to Subscription Detail in flow D, and the first is also the steady state of the main flow. Three recorded decisions of this product argue the other way, and each carries a source. Barrier P4 in the as-is journey is “no app gave a calm, judgment-free space to just see subscriptions: too many numbers and graphs”, and a two-pane Home is what that looks like on a wide screen. Principles 1 and 2, calm over clever and one thing at a time, say the product shows one thing where a split view shows two. D1, the gradual reveal, is the promise a split view cancels on the desktop. And the job it would close, decoding a charge faster, is already closed by the width of the detail screen itself, which has stood in two columns since stage 08. Founder's decision, 2026-08-13: no split view.