Subscription row
The object the whole product is made of. A line in a list, not a card in a stack: no fill, no radius, no border of its own, and the whole row is one link with one accessible name. Home is fourteen of them, and the census measured forty-seven with zero variation across twelve properties, which no other component in the sweep managed.
Anatomy
One anchor, four slots and three atoms inside it. Everything below is drawn by design/system/components/subscription-row.css and by the atoms' own files, 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.
.rowthe molecule itself, and it is thea: the anchor wraps every part below, so a subscription is one target and not five. Its floor is--row, 56px.logoan atom. The merchant's own mark at 36px, sized for this host by logo.css and never by this file.bodythe text pair, and the only part of the row this file gives a width:flex: 1 1 160pxwithmin-width: 0, so the name gets a floor and the chip yields.namethe merchant, in the person's own words.--type-sub, weight 600,--text-primary. It is what the eye scans for.whenwhen it happens next.--type-meta,--text-muted. The second tier, and there is no third.amtan atom. The price, at--type-bodyand tabular, sized for this host by amount.css.chipan atom. The status chip, pushed to the end by amargin-left: autothat lives in chip.css with the other four host placements of that atom. It was.taguntil step 6aria-labelon the anchor, and it is load-bearing: it reads the subscription as one sentence, so a screen reader says "Netflix, $17.99 a month, next in 2 days" rather than five fragments
The row draws no divider and no edge, and that is what makes a list read as one object rather than a stack of cards. The hairline between two rows belongs to the Category group organism, and the 14px corner around all of them belongs to its container. A row that drew its own line would draw a second one everywhere a list did its job.
Behaviour at width
A container query and not a media query since stage 10, at 28.75rem (460px). The row asks the width of its place, not of the window: what stops fitting is a merchant name beside an amount beside a control, and a desktop has narrow places too. Under the threshold the row takes the full width so the name stops wrapping into the amount.
The threshold is local and is not a token; it is listed in the container-threshold register on Width. Zero pixels moved on the conversion, because the nearest container is still body: what changed is that the rule is now true instead of accidentally true. The row is also fluid in its own right, flex: 1 1 160px with a min-width: 0 floor.
Variants and sizes
Two axes, and neither of them is size. Size is not an axis of this component at all, which is the unusual thing about it: the census measured 47 rows at two viewports across six coloured pages and found one value on every one of twelve properties. The row that carries fourteen subscriptions on Home is the same row, to the pixel, as the one standing beside a Cancel control on the save-focus state.
| State Chosen by what the product KNOWS, never by emphasis. There are two answers only: we have the subscription, or we are still fetching it |
Owned by | Where it stands |
|---|---|---|
Loaded a.row |
subscription-row.css | 50 rows on 5 grey pages. An anchor, always |
Loading div.row.is-skel |
skeleton-bar.css | 13 rows on 3 grey pages. Rendered as a div and not an anchor, so the one rule it needs is cursor: default, and it lives with the skeleton because it exists only for that state |
| Host Set by the CONTAINER, and it changes the row's box and nothing else. A host may place an atom or a molecule; the moment it repaints one, it has declared a variant nobody named |
What actually changes | Where it stands |
|---|---|---|
In a list .group .row |
nothing | The default, on 5 pages. The group around it owns the divider and the container owns the corner |
In a candidate .cand .row |
flex: 1 min-width: 0 |
2 rows on 1 page, home. It shares its line with a second control, so it stops being the whole width and is told it may shrink. See Save-focus candidate |
| Unrecognized, and cancelled | - | A backlog line, not a prohibition. Two node files name these as GC4 variants and neither has ever been drawn: docs/inventory.md lists them under the nine variants a node names and no page renders. The product answers both today with the chip's tone axis and, for an unmatched charge, with the logo's own empty form on the detail screen. If either becomes a row variant it is drawn from those, not invented |
The candidate host is a position and not a restyle, and it was checked rather than assumed. kit.css wrote three declarations there: flex: 1 and min-width: 0, which are layout and legitimate, and border-bottom: 0, which turns out to be a no-op. The row's base already writes border: 0, so there was no bottom border to remove; that line is a survival from wireframes/_wf.css, where the grey row did draw one. It painted nothing in colour and it is not carried. No colour, no size and no weight is touched by the host, which is the test this round exists to apply.
When to use it
This is the row a person meets on the screen the product exists for. J-MAIN reads "I want to see all my recurring charges in one calm view", and this is the view: fourteen of these under five category heads, with the monthly total above them. Everything else in the product is a way to get here or a way to act on one of these lines.
It is one of the three elements that carry the strategic dimension, and it is named as such in wireframes/docs/screens.md. benchmark.md calls trust and first-time clarity the one dimension that decides this product, an activation requirement rather than a nice-to-have, and on Home it is not a general feeling: it is the row (the transaction clarity layer), the attention row above it, and the trust line under it. The row's share of that is the mark, the real merchant name, the amount, and "in 2 days, Aug 3", which together answer "what is this and when does it happen" before a person has to ask.
The raw statement string is deliberately not here. SPOTIFYAB STOCKHOLM is what a bank actually sends, and it appears on node 2.7 as the decoder line, on the screen that exists to answer "what is this charge". Putting it in the row would put the cryptic thing back into the calm view and undo the one piece of translation the product performs. The row shows what a person recognises; the detail screen shows what the bank said.
The status chip appears only when the state is not the plain default. Fourteen "Active" tags in a column is noise on the one screen whose job is calm, so eleven of the fourteen rows on Home carry no chip at all, and the three that do are saying something: a price changed, a payment failed, a trial is running. Every line in the row comes from voice/docs/microcopy.md, and the amount is never a range and never an estimate: docs/bank-connection.md holds where each figure comes from.
The rule, and the anti-rule
The anchor wraps the mark, the name, the date, the amount and the chip, and the aria-label reads them as one sentence. A list of fourteen is fourteen targets, not fifty-six. Every part inside is a span, and the two atoms that could have been links (the mark and the amount) declare no states of their own for exactly this reason.
When a second action belongs on the line, it does not go inside the row. The Save-focus candidate is the component for that: it puts the cut beside the row as a separately named control with its own accessible name, so the row keeps its whole-row target. A button nested inside an anchor is invalid HTML, and the version that "works" (a link inside a link) hands a person two overlapping targets on the one decision that costs them money if they hit the wrong one.
A standing commitment, with its next date and its price. The chip says what state it is in. The row answers "what am I paying for", which is a question about the present.
Something that happened, on a date, is the Alert item. The two look alike from a distance and answer different questions: the row is a thing, the alert is an event. Read the difference in the text and not in the CSS. A row says "Amazon Prime, $14.99, in 19 days"; an alert says "A payment to Amazon Prime did not go through" and names who did it. Building an alert out of row markup is the mistake globals.md already caught once, on the trend row.
Limits
U13, context. PRESS, and no advance. The row goes down by one nudge on :active. It carries no arrow and gets none: adding one to fourteen rows would put fourteen new marks on the calmest screen in the product. What answers the pointer here is the fill, and the merchant mark lifting, which logo.css owns. Everything in this product that moves travels exactly one --nudge, which is 2px, and nothing anywhere travels two. Hover and release run at --dur-state (150ms) on --ease-arrive; a press runs at --dur-press (90ms), because it answers a finger that is already down. The full language and the live specimens are on Motion; the table with sources is Usage rules.
States
Three states, and they exist because the row is a link. Each is a token override rather than a style of its own: no hex and no number appears inside :hover, so the dark theme costs three lines in tokens.css rather than an edit per component.
The hover fades since 2026-08-16, and this file writes nothing for it. The product has one state change, colour over --dur-state (150ms) on --ease-state, declared once in base.css on the elements this system treats as interactive; the row is an a in 47 of its 60 placements, across four screens, so it is one of them; the other 13 are the div.row.is-skel loading form on three screens, which takes the fill token and sits still. This is the component the decision was weighed on, because it is the one a person meets fourteen times on a single screen: what fades is the fill and nothing else moves, so running the pointer down a list of subscriptions never makes a row grow, lift or slide under it. The ring is off the list and arrives instantly. See Motion, rule U12.
Three rather than four, and the missing one is not an omission. There is no disabled row: a subscription that cannot be opened is not rendered dim, it is not rendered. The loading form above is not a disabled row either, it is a div with no href at all. And there is no :active, because kit.css declared none, and writing one here would be a new decision about how the product answers a press, taken by the person moving a file.
Rest, live
Both are real anchors with a real href, which is the only way the states above can be reached at all. Faking a state with .is-hover is the same defect as inventing a role: it documents something the product cannot do.
The three, in both themes
The states are described, not photographed. Founder's decision, 2026-08-11: the snapshot strip went from every page in this kit, 120 images across 18 pages. They were framed as measurements rather than illustrations, and that framing was honest, but the bill came due all session: a value moves, and every picture of it is quietly stale until somebody re-shoots it. A stale picture is worse than no picture, because it looks checked. What is left is what a picture never carried anyway: the state, and the token it reads. The resting component is live and interactive above, so hover it, tab to it, and read the value off the real thing rather than off a photograph of it.
--text-primary on the name, --text-muted on the date--bg-hover, and nothing else: no border, no shift, no size change. A border appearing here would move the thirteen rows below it by a pixel--color-focus, 6.23:1 as a line, at 2px offset so the ring sits outside the row and moves no neighbour--color-focus, 6.82:1. This is the ring a keyboard user walks a list of fourteen with, so it is the one state on this component that has to survive both themes intactRetake, and this is the whole recipe, because there is no node Playwright in this repo and a
command that does not run is worse than no command:
serve the repo, then for theme in (none, data-theme="dark" on <html>):
goto /design/kit/subscription-row.html
move the pointer away -> screenshot #live-rest -> screens/subscription-row-rest-<theme>.png
hover #shot-row -> screenshot #live-rest -> screens/subscription-row-hover-<theme>.png
focus #shot-row by Tab, assert :focus-visible
-> screenshot #live-rest -> screens/subscription-row-focus-<theme>.png
Reached the way a person reaches them: a real pointer and a real Tab. NO FILE COMES OUT OF THIS TODAY, and the recipe is kept for the day one is
wanted rather than as a promise. The strip was removed by the founder on 2026-08-11 and the
states above are described instead, so there is nothing here for step 9 to diff. The earlier
wording said step 9 checks these files for a byte shift; it was written before the decision
and outlived it, which is the same freeze it was warning about, in prose.
The technical half
| Reads | Which token | Growing from |
|---|---|---|
| the merchant name | --text-primary | --ink |
| the next date | --text-muted | --muted, the single secondary tier |
| hover fill | --bg-hover | --panel, and --panel-dark in the dark theme, which is lighter and not darker |
| the focus ring | --color-focus | --petrol |
| its floor | --row | primitive, straight. 56px, twelve over the 44px of --tap, which this file never reads |
| name and date sizes | --type-sub, --type-meta | primitive, straight: geometry has nothing to override |
| gap and padding | --space-4, --space-8, --space-16 | primitive, straight |
| no fill, no border, no radius | none | by decision. The group owns the divider, the container owns the corner |
Three of its parts are atoms and this file paints none of them. Logo owns the 36px square, Amount owns the 14px figure, Chip owns the status label and the margin-left: auto that pushes it to the end, and Skeleton bar owns the loading form. Five declarations of kit.css were dropped because they repeat what those four files already say, and each one is named in the CSS where it would have gone.
DESIGN.md disagrees with this component in three places and the code wins in all three. The design doc says the merchant name is 14.5px, the amount 12.5px and the row padding 11px by 12px; the stylesheet has shipped 15px, 14px and 10px by 4px since stage 07, on 47 rendered rows, and all three rows are already in docs/tokens-audit.md. It matters more than a footnote: 14.5 folds to --type-body and 15 folds to --type-sub, so believing the document instead of the code would have shrunk the merchant name by two pixels on every row in the product.
Lives in design/system/components/subscription-row.css. Stands on 8 grey pages and 8 coloured ones: 64 places in the grey and 68 in colour, the loading form included; recounted 2026-08-20, when it said 60 on each side and the two sides had stopped agreeing. Coloured screens carrying it: Home, Home, could not refresh, Home, save focus, Home, loading, History and trends, History and trends, loading.
<li><a class="row" href="subscription-detail.html"
aria-label="Netflix, $17.99 a month, next in 2 days on 3 August, price changed">
<span class="logo"><img src="visuals/logos/netflix.svg" alt=""></span>
<span class="body"><span class="name">Netflix</span><span class="when">in 2 days · Aug 3</span></span>
<span class="amt">$17.99</span>
<span class="chip">Price changed</span>
</a></li>
The li is part of the copy for a reason: on every list page the row is a list item inside a ul that the Category group organism owns, and that organism is what draws the hairline between two of them. The row on its own, as it stands on this page, is deliberately shown without one.