App shell
The root every screen carries, and the thing that decides what a screen IS at a width. On a phone it is a column with the tab bar on the glass; past a 760px container it is a two-track grid with both chromes stacked in a 220px rail; past 900 the content column stops being a column and starts being a layout. It is the last file in the cascade and the only one that contains every other.
Anatomy
This anatomy is shown on the screens that hold it and not inside a stage, and that is a measurement rather than a shortcut. The shell declares min-height: 100dvh at every width and height: 100dvh past a 760 container, and below 760 the tab bar inside it is position: fixed to the viewport. A live shell dropped into a stage on this page would therefore be as tall as the window at every width, and at 360 its tab bar would leave the stage entirely and stick to the bottom of this documentation page. An anatomy that only exists inside its own scope is shown on the scene that holds it, so every part below is named against a class and linked to the coloured screen where it can be opened and measured.
.appthe shell itself. A flex column on paper, at least as tall as the viewport.min-heightis written twice,100vhthen100dvh: the second is correct on a phone whose browser chrome grows and shrinks, the first is the fallback for a browser that does not know it.appbaran organism. The App bar. It places itself into the shell's grid from its own file, which is the same convention that leaves.appbar .planinchip.css.screenthe content pane, and the only child the shell sizes directly.flex: 1, so the tab bar sits at the bottom of a short screen instead of floating halfway uppadding-bottomreadscalc(var(--row) + var(--space-48)), which is the fixed tab bar's own 56px floor plus 48px of air. Never an arbitrary 104: the value is derived from a component's height and moves when that height moves> :last-child { margin-bottom: 0 }the foot, and it is what makes the line above true. A block's bottom margin is a gap to a next sibling, and the block that ends the screen has none, so until 2026-08-12 the declared 48px of clearance was 48 on the screens whose last block carried no margin and 72 on the screens whose last block did.tabbaran organism. The Tab bar, fixed to the glass below 760 and static in row 2 of the rail above it.form-col,.rows-colthe shell's own two content columns. They carry no component and never will: they exist to be measured by the shell and to hold whatever the screen puts in them.col1,.col2the two tracks of the detail form. They carried no rule of any kind in the grey and held only because they were anonymous grid childrenmin-width: 0on all four. The default minimum of a flex or grid child isauto, and one long unbroken statement string (SPOTIFYAB STOCKHOLMis a real one) widens its own track and pushes the other one off the screen
| Open the shell here | Form | What to look at |
|---|---|---|
| Home | .app | the steady form, and the only screen that uses all three of the 900 layouts at once: the dashboard head, the groups column set and the four order declarations |
| Subscription detail | .app.detail | the two-track grid past 900, with .col1 and .col2 and a 48px gutter |
| Upgrade | .app.flow | the opt-out: one centred column, no tab bar, and the :has(.plans) widening from 620 to 760 |
| Add a subscription | .app.flow | .form-col holding its 560px measure past a 760 container |
| Settings | .app | .rows-col holding its 620px measure. One of the two screens where that measure came unbound past 900 and was found by widening the window |
Behaviour at width
The shell is the one place in this system where @media would be legal, and it uses @container anyway. Both points act here and they do different things.
At --bp-tablet, 47.5rem: the flex column becomes a two-track grid, a 220px rail and the content, the shell becomes exactly the viewport tall and the pane becomes its own scroller, and the blanket measure --container-page comes on. At --bp-desktop, 56.25rem: that measure comes off, the pane becomes an explicit flex column so its blocks can be reordered, and a detail screen becomes repeat(auto-fit, minmax(320px, 1fr)): as many columns as the pane holds, which is one or two.
The detail screen counts its columns instead of being told, and that is a repair the width sweep paid for. It was two hand-placed tracks, minmax(300px, 1fr) and minmax(320px, 1fr), and in the band from 900 to 927 the screen scrolled sideways inside its own pane: 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, and the second column's right edge landed at 928 on a 900 screen. The document never scrolled, which is how it survived stage 08. The root cause is the question, not the number: this query asks the screen while the layout lives in the pane, and past this same point the pane is 300px narrower than the screen because of the rail. A fluid rule asks nothing. Above 988 the geometry is identical to the hand-placed version, pixel for pixel; from 900 to 987 the screen now reads as one column instead of two cramped ones with the right one half off the pane.
auto-fit here and auto-fill on the grid, and the difference is the child count. A grid of tiles has an unknown number of children, so a collapsing empty track would make the column count depend on how many tiles a screen happens to hold. A detail screen has exactly two children on all six of its pages, always in DOM order, so the empty track auto-fit collapses at a wide pane is precisely the one that would otherwise squeeze both columns into a third of the width.
The shell also declares the container everything else reads, and it declares it on body, in base.css, not here: a container query matches an ancestor and never the element itself, so a container-type on .app could not let the shell query its own width. See Width.
Variants and sizes
One axis, three values, and it is chosen by what the screen is for rather than by what it holds. The steady form is a place you are; the flow is a chain you are walking; the detail is one thing seen from two sides. Every one of the 57 pages is exactly one of the three, counted by opening them: 20 steady, 26 flow, 8 detail in the grey, and 12, 10 and 6 in colour.
| Form Chosen by the JOB of the screen. A screen with a destination in the tab bar is steady; a screen that is one step of a chain is flow; a screen about one subscription is detail. Nothing about the content decides it |
Class | Pages | What the shell does |
|---|---|---|---|
| Steady | .app | 20 grey 16 coloured |
Column on a phone, two-track grid past 760, and past 900 the content column reorders its children: head 1, secondary 2, groups 3, trust 4. Home, Alerts, Settings, Save, and the state pages of each |
| Flow | .app.flow | 26 grey 10 coloured |
An opt-out and not a variant. One centred 620px column at every width, no tab bar, and therefore 64px at the foot instead of 104. It overrides the desktop grid rather than inheriting it, at 0-3-0 against the container rule's 0-2-0, so the query never touches it |
| Detail | .app.detail | 8 grey 6 coloured |
Steady up to 900, then .screen becomes a second grid: minmax(300px, 1fr) minmax(320px, 1fr) with a 48px gutter. The mobile order reads left to right, so "what is this" stays in front of "what now" |
| A fourth form for the landing | - | - | Forbidden with a reason rather than missing. index.html is the one page in the project with no shell at all, and it carries 39 classes in its own lp- namespace and runs on media queries throughout. It is a marketing page and not an app screen, and giving it a shell would put a tab bar on a page with no destinations. docs/inventory.md records it as a one-off, entire |
One screen inside the flow is not prose, and the widening is a named decision from stage 07 rather than drift. A row of three plan cards asks for a width of its own in grid.css and could never reach it inside 620, so the three were crushed to 185px each and both the button label and "Everything in Tendd Pro" wrapped. .app.flow > .screen:has(.plans) lifts that one screen to the row's width plus the screen's own two 16px margins and no more, so every other step of the chain keeps its reading measure. :has() rather than a class, because the screen that carries a plan row is identifiable by what is inside it.
It was 760px until 2026-08-18 and is 59rem, 944px, now. The 720 it was built on answered the eyebrow (181.23 needed, 201 given) and not the button (248.78 needed, 201 given), so the label this screen was widened FOR went on wrapping at every width from 768 to 1920 for as long as the cap existed. The grid recomputed the row from that label and this follows it: the row's 57rem plus the two 16px margins is 59rem. The arithmetic, the registers that were rejected and the band below 913 where the label still wraps are written there and not repeated here.
When to use it
There is no choosing here: every screen in the product is inside one, and the only question a builder answers is which of the three forms. That is the point of the component. The shell is where the product's two navigation models live, and DESIGN.md states the swap in one sentence: the tab bar is bottom chrome on mobile, and at a 760px container the whole shell becomes a dashboard, with the tab bar rotated into a 220px left rail on the panel tint, the app bar folded into the top of that rail, and the content taking the remaining width.
Container queries and not media queries, and the difference is not academic. A screen reflows on the width it is actually GIVEN rather than on the width of the browser, and the two part company the moment anything stands between body and .app: the reviewer's dock took 220px of it until 2026-08-12, and every coloured screen between an 840 and a 980 window rendered its mobile form inside a desktop one. A media query here was measured on 2026-08-13 and rejected: it would render the desktop rail inside the 434px specimen on the tab bar's own page, and it would move the point from a 760px container to a 760px window, two numbers that sit 15px apart wherever the scrollbar is classic. It is also why the container is declared on body in base.css rather than here: a container query matches an ancestor and never the element itself, so a container-type on the shell could not style the shell, and it would re-point every query inside it at a different box.
The 900 step is where the product stops being a phone screen stretched wide. Three components exist only past it and none of them is in this file: the Dashboard head puts the total beside the one thing that needs attention, the Groups column set runs the categories in two columns and then three, and the detail form splits into two tracks. What the shell contributes is the flex column that lets them be reordered and the removal of the blanket 780px measure that would otherwise hold them all to one width.
The rule, and the anti-rule
.app > .screen > .head { order: 1 }
.app > .screen > nav.actions { order: 2 }
.app > .screen > .groups { order: 3 }
.app > .screen > .trust { order: 4 }
measured at a 908px container: 1, 2, 3, 4
measured at a 892px container: 0, 0, 0, 0
Four declarations that only mean anything together. The DOM keeps the mobile-first order, where the add action sits after the list because the job of Home is to look and not to add; on a dashboard that same order puts the action under the shorter of two columns, where it reads as leftovers rather than as the way on. Three of the four were correctly refused by the component that owns the element they name: trust-block.css made the call first for order: 4 and named it, dashboard-head.css did the same for order: 1, groups-column-set.css for order: 3. Ordering siblings is the parent's job. Lose any one of them and the screen reads in source order with nothing erroring.
.app > .screen > .form-col { max-width: 560px } app-shell.css
.app > .screen > .rows-col { max-width: 620px } app-shell.css
.app > .screen > .card.prose { max-width: 560px } card.css
The three sat side by side in kit.css and they do not have one owner. .form-col and .rows-col carry no component, so they are the shell's. .card.prose is a Card, and the subject of a rule owns it, so card.css declares that one. Taking all three would give one measure two owners, and the pair would drift apart the day either moved. This is the same call chart-placeholder.css makes about its own 220px height inside the same container block.
@container (min-width: 760px) { .app > .screen > .form-col { max-width: 560px } } 0-3-0, holds
@container (min-width: 900px) { .app > .screen > * { max-width: none } } 0-2-0, loses
measured at a 908px container: .form-col 560px, .rows-col 620px, everything else none
Written as .app .form-col the measure ties at 0-2-0 with the blanket max-width: none in the 900 block, loses on source order, and unbinds with no symptom anybody would look for: the form ran at 588 instead of 560, the settings column at 733 instead of 620, and the Pro gate at 748 instead of 560. Two of those were on screens the stage had already accepted at 360 and at 760. A measure that holds at one width and silently stops holding at a wider one is why a desktop viewport is its own acceptance pass.
@container (width < 900px) { .app > .screen > :last-child { margin-bottom: 0 } }
@container (min-width: 900px) { .app > .screen:not(:has(> .head)) > :last-child { margin-bottom: 0 } }
home-empty, measured at 1280:
.empty order 0 renders at y=30
.head order 1 renders at y=245
One rule split across the 900 step, because past it the DOM's last child is not the screen's last block. The four order declarations above are what does it: on home-empty the empty state carries no order and renders first, the head carries order 1 and renders last, so zeroing :last-child there would close a real 24px gap between two blocks instead of a foot. :has(> .head) marks a reordered screen exactly, because .head is the only ordered element a screen can carry on its own. On the four Home states that are not empty the ordered-last block is .trust or .groups, and both already carry no bottom margin, so the guard costs nothing measurable at either width.
.app > .screen { padding-bottom: calc(var(--row) + var(--space-48)) }
add-subscription at 360, before: screen 1138px, 80px under .actions
add-subscription at 360, after: screen 1122px, 64px under it
guided-reveal at 1280, before: screen 1100px, 88px under .rstep
guided-reveal at 1280, after: screen 1076px, 64px under it
A block's bottom margin is a gap to a next sibling, and the block that ends the screen has none: it lands on the padding instead and the foot silently becomes two numbers added together. The 104 is the argument, not the 64. The foot of a steady screen reads "the tab bar, and 48px of air over it", and a child's margin turned that 48 into 72 on the screens whose last block happened to carry one, so one declared clearance was two distances across one product. The rule is written here and not in the child's file because a block cannot know that it is last, and a margin is position rather than paint.
.app { container-type: inline-size }
measured, 2026-08-11:
tab bar without it 220px, a rail beside the content
tab bar with it 965px, a block below it
and nothing errors, in either case
The trap of the whole round, and it is measured rather than reasoned. A container query matches an ancestor and never the element itself, so container-type here could not let the shell query its own width; it would re-point the tab bar's 760 and the dashboard head's 900 at a different box, silently. (The column set's 1340 was the third number in that list until stage 10 removed it.) With it on .app the tab bar keeps its column flow and still loses its rail. The container is declared once, on body, in base.css, with the reasoning and a 33-width verification at the foot of that file.
Limits
U2, context. The shell decides whether the tab bar may stand at all: .app.flow is the onboarding chain and carries none.
U11, context. No footer inside the app. Tendd is an app, and the one footer in the product is on the Welcome landing, which is a marketing page.
U15, composition. One content measure per screen, and every state of a screen ends in the same place. The measure is this component's padding-inline on .screen and never a max-width on a block inside it: a cap on the children ends a wide screen in the right place but cannot centre one, because every block would centre at its own width. One thing asks for a narrower box than the pane's 1280 and it is not a screen, it is a KIND of state: the interruption, one message and one way out, at --container-page wherever it stands. The screen built around a picture asked for it too, for one morning, and the founder replaced that answer with Home's width the same evening.
U17, composition. A screen-level order belongs to the layout that needs it. The four desktop orders are guarded on :has(> .groups), because they exist for a list running in balanced columns; unscoped, the single ordered block goes to the foot of any screen that has it and nothing else, which is what home-empty did unnoticed for six days. Full table with sources and how to check: Usage rules.
States
No states, and the reason is structural rather than an omission. The shell is a div on all 57 pages. It has no href, no button, no summary, no tabindex and no role of its own, and kit.css gave it no pointer response either. Every target inside it belongs to something else: the app bar's two links, the tab bar's four anchors, and every control on the screen between them, each with its own states on its own page. A hover written here would answer for fifty components that already answer for themselves.
The three things that look like states and are not are the two container steps and the flow's opt-out. None of them is caused by anything a person does; all three are in the matrix above with the query that fires them, and both queries are verified on both sides in the technical half below.
The technical half
| Reads | Which token | Growing from |
|---|---|---|
| the shell's own ground | --bg-surface | --paper, and --paper-dark in the dark theme. The only colour in the file: no ink, no line, no radius anywhere below it |
| the foot that clears the tab bar | --row inside a calc | primitive, straight. 56px, the same anchor floor tab-bar.css reads, so the two can never disagree |
| the screen's padding | --space-16, then --space-32 / --space-40 / --space-48 past 760 | primitive, straight |
| the flow column's padding | --space-24, --space-16, --space-64 | primitive, straight |
| the detail grid's gutter | --space-48 | primitive, straight |
| the secondary's margin past 900 | --space-24 | primitive, straight. A host margin, exactly as .gate .actions and .card.prose .actions are, and it sets no fill, ink, size or edge on the action row |
| the door row's margin when it has no host | --space-24 | primitive, straight, and added 2026-08-17. grid.css takes this side off .grid.roomy because a door row is normally inside an empty block or a dialog sheet that already spaces it. On path-choice it is a direct child of .screen, so the grid's zero met the quiet line's zero and the exit link rendered at 0px from the door above it at every width. The host of a hostless block is the shell, which is the same sentence the nav.actions row above states. 0-4-0, so it wins on arithmetic rather than on import order, and it reaches one placement of four |
| the foot it takes from the last block | --space-0 | primitive, straight. The only property this file sets on a hosted component, and it is a position. The paint stays with action-row.css, quiet-line.css, reveal-step.css, category-group.css and empty-block.css, and every one of those five classes keeps its own margin everywhere else it stands |
| the rail track, the reading measures, the two track minimums | 220 / 780 / 620 / 560 / 300 / 320 | WIDTHS, which are on neither scale. --tap and --row are the only size primitives the system has and none of these is either. The same hole dashboard-head.css records for its own two tracks |
| the viewport | 100vh, 100dvh | viewport units, on no scale. Written twice on purpose: dvh is right on a phone and vh is the fallback |
Both container queries, verified on both sides, driving the real container rather than the viewport. The container is body and on a page with a docked panel that is 268px narrower than the window, so a probe that sets a viewport width measures the wrong box. Driven to the exact container width and read back on a real shell: at a 752px container the shell computes display: flex, grid-template-columns: none and the screen's padding is 16px 16px 104px; at 768px it computes display: grid, grid-template-columns: 220px 548px, overflow: auto and the padding is 32px 40px 48px. At 892px the screen is still display: block, its children carry the blanket max-width: 780px, and all four order values read 0; at 908px the screen is display: flex, the children compute max-width: none except .form-col at 560 and .rows-col at 620, and the four orders read 1, 2, 3, 4. The exact edges hold too: 759 is still the narrow form and 760 is already the wide one.
The foot, measured on both sides of the change and at both ends of the range, on all 28 coloured screens of stage 08. Four screens move and the rest do not, and the reason the rest do not is worth as much as the reason the four do. At 360: add-subscription 1138 to 1122, guided-reveal 1351 to 1327, upgrade-current-plan 758 to 739. At 1280: add-subscription 1197 to 1181, guided-reveal 1100 to 1076. Everywhere else the screen is either stretched by flex: 1 because its content is shorter than the viewport, or it is the scroll pane past 760 and its content does not overflow, so the margin was invisible and removing it moves nothing on screen. upgrade-current-plan is the honest case: the margin was 24px and the page only lost 19, because at 19 the screen hit the stretch floor. The change is an improvement rather than a smaller number in five places and a correction on paper everywhere else, and both halves were measured rather than argued.
What this file does not write, and the list is the argument for where a rule lives. .app > .appbar and .app > .tabbar, at both widths, live in app-bar.css and tab-bar.css: a host positioning its child is written in the child's file today, and tab-bar.css says so out loud at the line precisely because this file could reasonably have claimed those four declarations. .app.detail .muted lives in muted-line.css on the same rule from the other side. And .app .head, .app .groups, .app .group and .app .chart all sat inside the same two container blocks in kit.css and not one of them is here: they are four other components. What the shell keeps is only what names its own boxes.
Lives in design/system/components/app-shell.css, and it is the last @import in design/system/index.css: the ladder puts a part above the thing that contains it, and this contains every part there is. Stands on 56 grey pages, which is every page in wireframes/ except the landing and the stage hub, and all 57 of them are coloured.
<div class="app">
<header class="appbar">...</header>
<main class="screen">
...
</main>
<nav class="tabbar" aria-label="Global navigation">...</nav>
</div>
<div class="app flow"> one centred column, no tab bar
<div class="app detail"> two tracks past a 900 container
The blanket that releases the pane is written at zero specificity since 2026-08-17, and that one change closed a six-year-old-looking bug six stages old. It read .app > .screen > * { max-width: none }, which is 0-2-0, and a component declaring its own measure as .app .readout is also 0-2-0 and loses on source order, because this file is an organism and is imported after every atom. Six measures were unbound that way, one at a time, each found only when somebody measured that exact element: the form column at 588 instead of 560, the Pro gate at 748, the plan row at 728, the readout at 980 instead of 525, the groups cap, and a standalone muted line at stage 10. The planned fix was a named list of what may widen; read off the corpus it is fourteen selectors long and one member has no name to be listed by, so both blankets and the detail re-release went inside :where() instead. Anything that declares a measure now wins; anything silent about width still gets the pane. Zero boxes moved at 360 across all 57 coloured pages, and the three that moved at 1440 are the dialog sheet, below.