Grid
A repeated child, laid out in columns that change with the container. Two doors on the empty state, six preset tiles on Add a subscription, three plan cards on Upgrade. It is the only component in this system with two axes that cross, and the reason it is one component rather than three is that the thing varying is the layout and never the child.
Anatomy
A zone, a gap and a column count. There is no third thing. Everything below is drawn by design/system/components/grid.css and by the files of the children inside it, through the same entry point a product screen loads, so this page cannot drift from the screens: there is no second copy of the CSS here to drift from.
.grid, .grid.roomy, .plansthe organism itself, and it is adivon all 8 occurrences. Three declarations at most:display: grid, a gap, a column count. Three names, one rule shape, which is the merge. They were.tiles,.doorsand.plans, and step 6 folded the first two and left the third alonegapthe only spacing this component owns, and it is set by the size of the child. Written once on the base rule, because nothing about the container enters into itgrid-template-columnsthe axis that moves. One or two values at the narrow end, two or three past a 760px container.doora molecule. The Door, with its own container corner, its own control edge, its own 16px padding and its own hover and focus ring. This file gives it no rule at all.tilea molecule. The Preset tile, the second thing this grid holds, with its own pressed state.plan-opta molecule. The Plan option, the third. Also untouchedmarginon the base and on.plansonly, and it is the host's rather than the component's:.roomystands inside an empty block or a dialog sheet that already spaces itself, so it takes the margin back off. On one of its four placements there is no host, and the shell writes the margin back for that one alone: below
It reads no colour at all, and on this page that is the thing to check rather than to take on trust. There is not one --bg-*, --text-* or --line-* token in the file, no border, no radius, no font-size and no weight. The component with the most opportunity in this system to repaint its children paints nothing: every edge, corner, fill and word you can see above belongs to the Door.
Behaviour at width
Two of the three rows stopped declaring a column count at stage 10, and the third kept one. The line between them is where the count comes from.
The tile row is a fit question, so it counts itself: repeat(auto-fill, minmax(<floor>, 1fr)), with the floor in the child's own place - --grid-col-min, 10rem. It does not run away, because it stands on a flow screen whose pane is capped by --container-column: 3 columns in a 588px pane at every width past the point, 2 in a 328px pane at 360. The fluid form reproduces both declared counts exactly, which is why it cost nothing to take. The plan row was in this sentence until 2026-08-18 and is not any more: see Width below.
The door row is not a fit question and fluid would be wrong there. There are two doors because the product offers exactly two ways in, which is D2 and not a measurement, and there will never be a third. Measured rather than asserted: with a floor that keeps one door per row at 360 and two at a 760 container, auto-fill puts four doors across a 1440 window, two tracks empty, on the screen whose whole job is a choice between two things.
auto-fill and not auto-fit, which is this product's correction to the usual recipe: auto-fit collapses empty tracks, so the count would start depending on how many children a screen happens to hold, and this page forbids exactly that. The shell makes the opposite choice on the detail screen and it is the same rule read from the other end: there the children are exactly two on all six pages, so the collapsing track is the one that would otherwise squeeze both columns into a third of the pane. The question is never which keyword is better, it is whether the child count is known. See Width.
The landing's plan row keeps repeat(3, 1fr), and that is the door row's argument a second time. The read-only critic raised it as a hard-coded count and it was dropped at verification: the landing's pricing block holds exactly three plan options, so three is content rather than a fit question. The reason is now written at the rule, because a grep for repeat( will find that line again.
Variants and sizes
Two axes, and they cross. That is rare enough in this product to be worth handling carefully: laid out as a matrix of combinations they would be six cells, most of them empty, and a reader would be left to guess which combinations are legal. They are not combinations. They are two different questions with one answer each, and the matrix below is the two questions rather than their product.
Axis one: columns
| Columns Decided by the child's NARROW FLOOR: how small the child can get before it stops being readable, asked at both ends of the range. Never by importance, and never by how many children there happen to be |
Narrow / past 760 | Where it stands, and why that floor |
|---|---|---|
1 to 2 .grid.roomy |
1fr 1fr 1fr |
4 grey pages. A door is a heading and two full sentences and cannot halve a 360px screen without the sentence breaking into six lines, so it is one column there and two wide. It was .doors until step 6 |
2 to 3 .grid |
1fr 1fr repeat(3, 1fr) |
2 grey pages. A tile is a mark and a name and survives 151.5px, measured on add-subscription at 360, so it starts at two. Six presets in one column would be a scroll where the point is a glance |
1 to 3 .plans |
1fr repeat(3, 1fr) |
2 grey pages. A plan card is a price, a sentence, a button and a four-line list, so one at 360; three wide, because three plans that cannot be compared side by side are not a choice. The registry names two column values and the source has three, and this is the third: docs/inventory.md writes the axis as "1 to 2 / 2 to 3" while its own renaming map writes .plans as "1 to 3" |
Best value$69a yearThat is $5.75 a month, and it saves about $27 a year versus paying monthly.Start Tendd Pro Lifetimeone paymentTendd Pro stays open, for people who would rather never think about a renewal again.Not on sale yet. We are still working out the price. | ||
| 4 or more | - | Forbidden with a reason rather than missing. Nothing in this product asks a person to compare more than three things at once, and a fourth column at the widths this shell reaches would take every child under 180px. A set that wants four items is a list, and a list is the Divided list |
Axis two: gap
| Gap Decided by the child's WEIGHT: a bordered card takes air, a chip does not. Nothing about the container enters into it, which is why the gap is written once on the base rule and the 760 block does not touch it |
Value | Where it stands |
|---|---|---|
Roomy .grid.roomy |
--space-16 | Two whole cards of equal weight, and D2 says they are equals rather than a path and a fallback. The air between them is what stops the pair reading as one bordered block cut in half |
Tight .grid, .plans |
--space-8 | Six presets read as one catalogue rather than as six decisions, so they sit close |
| A gap that changes with the container | - | Gone, and the fold is what removed it. kit.css gave the door grid 12px narrow and 16px past 760. Both land on --space-16, so the override became byte-identical to the rule it was overriding and is deleted, exactly as .btn.compact was. What is left of that axis is the rule that survived it: a gap answers to the child and not to the width |
The fold moved this axis's numbers, and the rule and the values now disagree in one place. kit.css shipped 12px for doors, 10px for tiles and 10px for plans. Under the founder's 8px grid, 12 is a tie between 8 and 16 and goes to the larger; 10 goes down to 8. So a 2px difference between a door and a plan card became an 8px one: doors 16, tiles 8, plans 8.
Both a door and a plan card are full bordered cards, so by the stated rule they should take the same air, and they no longer do. It is carried as it folds rather than adjusted, because the mapping is mechanical and the adopted scale is one of this stage's three named sources of a visual change; deciding that a plan row should take a door's air would be a fourth, and nobody has taken it. Whether the plan grid goes to --space-16 is a founder's question, and it is one line either way.
Width is not an axis. Every one of these grids is 100% of the column it stands in, and what caps it is the screen's own reading measure, which belongs to the shell. The one exception is the plan row, which asks for a width of its own past a 760px container, and it asks because three cards side by side stop being comparable past that, not because the grid has a width. This paragraph said the cap was dead, and it stopped being true on 2026-08-13: the unbinding it describes - .app .plans at 0-2-0 losing to the shell's later blanket .app > .screen > * { max-width: none }, so the row rendered 728 instead of 720 at a 1440 viewport - was repaired at stage 10 step 1 by binding the cap through > .screen > at 0-3-0, and this page was not updated with the code. It is the same unbinding that took the form column from 560 to 588 and the Pro gate from 560 to 748, and all three are now tightened. What the cap IS, and why the number changed again on 2026-08-18, is in Width below.
Width: the plan row leaves the fluid family, and its cap is recomputed
This file was arguing both sides of the same question, forty lines apart. Stage 10 folded the plan row into the fluid family with “how many presets fit is a fact about the width available and the narrow floor of a tile; nobody chose three”. True of presets. The same file, about the same row on the other page: “the landing's pricing block holds exactly THREE plan options, so three is content and not a fit question. Fluid would put four or five tracks across a wide window and leave two of them empty on the one block whose whole job is a comparison between three named things.” One row, one product, two opposite arguments. The second is right, and it is the door row's argument word for word: D4 names two prices and the third card is the lifetime plan, so there will never be a fourth. Since 2026-08-18 the row declares repeat(3, minmax(0, 1fr)).
It was dormant only because the cap was too low to expose it. Capped at 720 the pane is 728 at most, and (728 + 8) / (210 + 8) = 3.37, so auto-fill happened to give three. Raise the cap to 896 and it becomes (896 + 8) / 218 = 4.14: four tracks, the last one empty, and three cards shrunk to 218 on the screen that asks for money. The declared count cannot make that mistake at any cap, and it changes nothing at the old one - measured at nine widths from 320 to 1920 before the cap moved, the row is three columns of exactly the same width either way.
And the cap is 57rem now, recomputed from the thing it was always aiming at. 720 came from stage 07, and the shell states its purpose in one sentence: the three cards “were crushed to 185px each and both the button label and 'Everything in Tendd Pro' wrapped”. The arithmetic behind it answered the eyebrow and not the button. Measured on the coloured Upgrade screen at 1440: the eyebrow needs 181.23 and had 201, so it fits; the widest button label, “Start Tendd Pro - $7.99 a month”, needs 248.78 and had 201. It wrapped at every width from 768 to 1920, for as long as the cap has existed, on the one control the whole screen is built around.
The new number is that label plus the card around it. 248.78 of button, 32 of the card's padding and 2 of its border is 282.78 a card; three of those and two 16px gaps is 880.34. The registers above it are 55.5rem (888, 2.55 of slack), 56rem (896, 5.22), 56.5rem (904, 7.89) and 57rem (912, 10.55): 57 is the first at or above half a --space-16 of slack, and slack is the point, because a fallback font or a rounding must not cost the one line the cap exists to buy. The screen around it follows at 59rem, which is 57 plus its own two 16px margins. In rem and not px, which the 720 was: a cap measured off a string has to grow with the string, or a reader at a 20px root gets the label wrapped again - the same argument the four --container-* tokens are written in rem for.
Below a 913px window the label still wraps, and that is stated rather than hidden. The cap is a ceiling, so between the row's 760 container point and the window where it starts binding, the three cards are narrower than 282.78 and the button takes two lines exactly as before. Measured after the change: the wrap stops at a 913px window, one line from there up, and the cap itself starts binding at 944. Closing the band below it needs either a shorter label, which belongs to voice/docs/microcopy.md and to the founder, or a stacked arrangement inside the band, which is a layout decision nobody has asked for.
What moved, measured rather than asserted. The whole coloured corpus fingerprinted at 360 and at 1440, 57 pages: one page differs at either width, and it is upgrade.html. The landing's own plan row is byte-identical, because landing-plan.css declares its three columns and its badge at its own specificity. Sideways scroll swept at every integer width from 320 to 1920 on the changed screen: none. The price rows are 0px apart at every three-column width. rollout12.cjs: 0 violations on all 57 screens, and no new local width threshold - this change adds no query at all, only two caps and a count.
2026-08-18: the plan row was filed under the wrong half of this file's own axis
The door row's block states the axis in one sentence, and it is this component's second axis, not a borrowed idea: “the axis is the child's WEIGHT: two whole bordered cards of equal standing take air, six chips do not.” The door row takes --space-16 on that ground. The plan row is three whole bordered cards of equal standing, each 293px wide and holding a price, a sentence, a full-width button and a four-line list - and it was taking the 8px this file gives six chips. Roomy and Tight are this file's own words; the row was filed under the wrong one. It takes 16, the door row's value, for the door row's reason.
Its bottom margin was a third value with no reason either. The base carries 24 under a grid; the door row takes it back to 0 because it has a host; the plan row had 16, which is neither, and under it stands “Maybe later” - a real exit at the same weight as the buttons inside the cards, sitting 16px from a row of bordered surfaces. It takes the base's 24. Both values were carried “byte for byte from before the fold”, which is how a number survives without ever being chosen.
No top margin, and that is a decision rather than an omission. The block above the plan row on the only screen that carries it is a muted line, and the muted line now pays the 24 below itself. One distance, one owner, and it cannot be paid twice in a flex column.
The cap moved with the gap and the card did not. Three cards of 282.78 with two 16px gaps is 880.34 rather than 864.34, so the register went 56rem to 57rem and the screen 58 to 59. Measured after: the card is 293.33 at both, to the hundredth. The whole change is air between cards.
When to use it
A grid is for a small set of things a person chooses between by looking at them side by side. That is a different job from a list: a list is walked one item at a time and its order carries meaning, and a grid is scanned as a set and its order carries almost none. If a person will read the items rather than compare them, the container is the Divided list or the Category group.
On the path, a person meets it at each of the three moments the product asks them to pick. Two doors is node 1.2 and it is D2 drawn: "Connect your bank" and "Add a subscription" as equals, neither recommended, neither carrying the accent, because two petrol objects on one screen is the One Voice Rule failing. Six preset tiles is node 1.4, and the catalogue is evidence rather than a boast: a blank form is the version people abandon, and tapping a preset turns the form below it into a confirmation instead of data entry. Three plan cards is node 5.13, the one screen that asks for money, where the grid's whole job is to let three prices be compared without scrolling between them.
Every string in the specimens above comes from voice/docs/microcopy.md: "Two ways to start", "Read-only, through Plaid, and about a minute", "Most tracked", "Best value", "Start Tendd Pro - $69 a year". The amount is on the plan button because nobody should learn what they are being charged after the tap.
The rule, and the anti-rule
The first tile is genuinely pressed, by aria-pressed="true" rather than by a class the product does not carry, and its petrol tint and petrol edge come from preset-tile.css. The grid contributed a gap. Measured on this page: a tile inside the grid is 14px with a 56px floor, a --radius-sm corner and the control edge, which is exactly what it is on its own page.
Two lines that look like a tidy adjustment and are an undeclared variant plus a broken norm: the tile takes --line-control because WCAG 1.4.11 asks 3:1 of the boundary that identifies a control, and a host softening it to the container hairline would drop it to 1.23:1 on the one screen that asks a person to press something. If a child genuinely looks wrong inside a grid, the answer is a declared modifier on the child, in the child's file, with the class in the markup.
Two things of equal weight, chosen between by comparison. Side by side past 760 so neither reads as the second option, stacked at 360 because a door cannot halve a phone, and in both forms the same two cards.
When the thing being laid out in columns is a long flow of content rather than a set of choices, the component is the Groups column set, and it is columns rather than grid on purpose: multi-column flows its content and balances the columns itself, which is right for five groups of unequal length, while a grid would give five equal cells and four of them would be part empty. The test is whether the reader compares the columns (a grid) or reads down them (a column set).
Limits
U6, composition. A grid of doors is one of the two legal ways out of a .textblock.status, and the only one on connections-add-source. A status block with neither is a dead end. Full table with sources and how to check: Usage rules.
Limits
A pair of doors keeps the 16px gutter; two columns of content take 48. Every .grid.roomy in the product held a pair of doors until 2026-08-19, and a door is a bordered box: 16px between two edges reads as a gap because there are two edges to see it against. History and trends puts two category groups in this box and the Pro gate puts a chart beside a card, and a group has no border at all, so at 16px two columns of text read as one column that has been chopped. 48 is not a new number: it is the column gap the groups column set already uses for exactly this. The guard names the doors and not the content, :not(:has(> .door)), which is the way round that ages: the four door screens are a closed set built at this gutter and correct at it, and anything else that lands in a roomy grid is content and gets the content gutter without being asked to declare it.
States
None, and the reason is one line: a grid is not interactive. It is a <div> on all 8 occurrences, it holds no anchor, no button and no summary of its own, it carries no tabindex and no role, and kit.css gave it no hover, no active and no focus rule. There is nothing here for a pointer to be over: a pointer that looks as though it is over the grid is over a child or over the gap between two.
Every target inside it has its states on its own page, and they are live in the specimens above rather than photographed here: the Door has hover and focus-visible, the Preset tile has hover, pressed and focus-visible, and the Button inside a plan card has its four. Hover a door, tab into a tile, and read the value off the real thing.
The technical half
| Reads | Which token | Growing from |
|---|---|---|
| the roomy gap | --space-16 | primitive, straight. 12px before the fold, a tie that went to the larger. Since 2026-08-19 the COLUMN gap becomes --space-48 past the tablet point on a roomy grid that holds no door: see below |
| the tight gap | --space-8 | primitive, straight. 10px before the fold |
| the space under a tile grid | --space-24 | primitive, straight. 22px before the fold |
| the space under a plan row | --space-16 | primitive, straight. 12px before the fold |
| the plan row's cap, and the landing's | none: 57rem, 780px, 420px | widths, and a width is not a margin, a padding or a gap, so the 8px grid does not govern it |
| no colour of any kind | none | by decision, and it is the whole of this component's argument. Not one role, not one border, not one type step |
The door row gives its bottom margin back to a host, and on one placement of four there is no host. 2026-08-17. The file's own reason for margin-bottom: 0 read "a door row DOES have a host: the empty block on 3 pages and the dialog sheet on 1, and both already space it". Counted over the four placements the product has, that is off by one: the empty block hosts two, connections-empty and home-empty; the sheet hosts one; and path-choice has none, because its door row is a direct child of .screen. So the grid's zero met the quiet line's zero and "Do this later" rendered at exactly 0px from the bottom edge of the door it is the alternative to, at every width, on the screen the whole product forks on. Reported by the founder on a screenshot. The same pairing elsewhere reads 24 after a tile row and 16 after an action row, which made this the only distance in the product nobody had chosen.
The margin comes back from the SHELL and not from here, at .app > .screen > .grid.roomy, 0-4-0 against this file's 0-3-0, beside the note app shell already carries for nav.actions. That is this file's own sentence read from the other end: the margin is the host's, and the host of a hostless block is the shell. The three placements that are inside a host keep their zero and not one of them moves; verified over all 57 coloured pages at 360 and 1440, the only door row that changed is path-choice.
It hosts three molecules and paints none of them. Door, Preset tile and Plan option get no rule here at all beyond the gap that separates them. The whole file is four base rules and two responsive blocks.
One container query, and it is measured on both sides, because a query that never fires looks calm and is the most expensive defect this stage can ship. The columns arrive at a 760px container. It is a CONTAINER query and not a media query, and the difference is not academic: these grids reflow on the width the screen is given, and that parts company with the browser window the moment anything stands between body and .app, as the reviewer's dock did with 220px until 2026-08-12. A media query here was measured and rejected on 2026-08-13; the ground is on Width. It needs an ancestor with container-type: inline-size, which base.css declares once on body; nothing in this file declares a second one, because a container between the body and these rules would re-point the query at a different box with no error and no symptom.
| Measured on this page | container 752px | container 768px | Reading |
|---|---|---|---|
.grid.roomy columns | 1 | 2 | the query fires |
.grid columns | 2 | 3 | the query fires |
.plans columns | 1 | 3 | the query fires, and it is the third column value the registry does not name |
.grid.roomy gap, holding doors | 16px | 16px | unchanged, and unchanged on purpose: this is the axis the fold removed |
.grid.roomy gap, holding anything else | 16px | 48px column | 2026-08-19: two columns of content need a wider gutter than two bordered boxes |
And one media query, on the landing's copy of the plan row. .lp-plan .plans goes to three columns at a 760px viewport, deliberately and not by oversight: index.html is the only page in the product with no .app shell, its own block runs on media queries throughout, and the landing copy of this rule matches that model rather than introducing a second mechanism on one page. Measured at 752px and 768px viewport: one column, then three.
Lives in design/system/components/grid.css. Stands on 8 grey pages: doors on 4 (path-choice, home-empty, connections-empty, connections-add-source), tiles on 2 (add-subscription and its loading state), plans on 2 (upgrade and the landing). Four are coloured: Home, empty, Add a subscription, Add a subscription, loading, Upgrade.
<div class="grid roomy">
<a class="door" href="connect-bank.html"><h3>Connect your bank</h3><p>Read-only, through Plaid.</p></a>
<a class="door" href="add-subscription.html"><h3>Add a subscription</h3><p>Start with one.</p></a>
</div>
<div class="grid">
<button type="button" class="tile" aria-pressed="true">...</button>
</div>
<div class="plans">
<div class="plan-opt">...</div>
</div>
The stand's measure, and it is the one number on this page that is a compromise. The specimens stand in .kit-stage app full wide, 620px. Measured at a 1440 viewport on the real screens, the product gives this component 908px for the doors on home-empty, 588px for the tiles on add-subscription and 728px for the plan row on upgrade; at 360 it gives all three 313px. 620 is honest for the tiles and close for the plan row, and it understates the doors by 288px at the widest end. It is still the right choice: .bleed is reserved for a component that genuinely spans the whole screen, which is a shell, an app bar or a tab bar, and 620 is a width the product really does hand a door grid, measured at 620px on connections-empty. Both ends of the axis are visible at both ends of the range, which is what this stage has to be able to show.