App bar
The top chrome of every screen in the app, and one of the two components in this product that change form rather than size at a breakpoint. Below a 760px container it is a row across the top; past it, the same markup folds into the head of the left rail and runs as a column. It stands on 57 grey pages, which is every page of the app, and it ties with the App shell for the widest reach of any organism in the system.
Anatomy
One row, three slots, and six things that may stand in them. Everything below is drawn by design/system/components/app-bar.css and by the chip's own file, 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.
The stage is bleed, and this component earns it. The app bar has no width of its own in either form: on a phone it takes the whole screen, and past 760 the shell's grid column gives it 220px. It declares neither number. --kit-measure would be a third width the product never uses, so the bound is off and the bar takes the stage, which is the honest way to show a component whose width is always somebody else's decision.
.appbarthe organism itself, aheader: a three-slot flex row on a paper ground with a hairline under it. It has nohref, no hover and no ring, and its states are the three links inside it.backthe way out, on 31 pages, and it was 36 until 2026-08-19, when the five History and trends pages gave theirs up on the founder's decision: a tab destination is not somewhere you arrived at from anywhere in particular, and its way out is the tab bar. Ink at weight 600, a 44px floor and no left padding, so the mark sits on the screen's own margin rather than 8px inside it. It draws a left arrow with a shaft, 18px at stroke 2.5, a mask overcurrentColor, with--space-2between the mark and the word. All four of those changed on 2026-08-17: see the technical half.closenew on 2026-08-12, on the 2 Upgrade screens. A sibling of.backand not a variant of it: same box, same floor, same ink, same states, different mark. It draws the cross, the same one the search field's clear control uses.lockupnew on 2026-08-12, on all 54, and the only thing here that is always present. A wrapper and not a component: it holds the Brand mark and the Wordmark, two atoms, and this file gives it a gap and a place and never a colour or a size. It is wrapped rather than left as two children because past 760 the bar becomes a column, and two loose children would stack vertically at exactly the width where the brand has the most room.acct"Hi, Emma", on 15 pages, and it was 10 until 2026-08-19: History and trends took the same destination bar Home, Alerts, Save and You already had. A greeting and not a label, muted, because the name is not the subject of any screen it appears on. It also holds the plan chip on all fifteen, which is the one thing this slot ever contains besides the greeting: it was the five trend pages until 2026-08-19, when the founder sent it everywhere. In the ROW the link is the trailing slot and the chip is already at the right edge of the screen; in the RAIL the link is stretched to the rail's own width, so the greeting keeps the left edge and the plan stands at the right one.step"Step 2 of 3", on 11 pages. A marker and never a progress bar: a filled bar on step 1 of 3 tells an avoider the road is long.planan atom. The Chip, on 19 pages, and it is the bar's most repeated slot after the lockup. 4 Upgrade screens carry it as a sibling of the lockup, pushed to the end by amargin-left: autothat lives in chip.css with the other host placements of that atom. The other 15 carry it inside the account link, on the founder's decision of 2026-08-19: the plan is a fact about the account, so it belongs to the control that opens the account, and it appears wherever that control does.chip.csstakes theautoback for that case and this file gives the link a gapmargin-righton the lockup, and it is load-bearing besidespace-between. The detail screens fill only two of the three slots, and without it the brand lands in the right rail where every other screen puts the chip: measured at stage 07, 262px of travel on the walk this person does most. It carried over from the wordmark unchanged when the brand arrived, and so did its reason
Behaviour at width
From --bp-tablet, 47.5rem, through a @container query. The bar reads the box the shell is given rather than the browser window, so a screen shown in a narrow frame keeps the narrow form. The threshold is the register's own: see Width.
What changes. The bar stops being a sticky row across the top and becomes the head of the rail: position goes from sticky to static, it moves into grid track 1 row 1, the flex direction turns from row to column with the items aligned to the start, the hairline moves from the bottom edge to the right one so it continues the rail's own edge, and the lockup takes order: -1 so the brand stands at the top of the column rather than wherever the row happened to leave it. The greeting drops from --type-body to --type-meta, loses its side padding, and stretches to the rail's width so the plan chip inside it can take back the margin-left: auto every other chip in this bar has. That last one is 2026-08-20, the founder looking at the rail: in a column of shrink-to-fit items the link was 99px wide inside a 220px track and the chip sat in the middle of an empty column, 105px short of the edge. Measured after, on all 15 pages of both corpora and in both themes: the chip ends at the bar's inner right edge, to within a pixel, at 800 and at 1440.
Why the lockup is a wrapper and not two loose children is this behaviour and nothing else: past the point the bar is a column, and two unwrapped atoms would stack vertically at exactly the width where the brand has the most room to be read across.
The 220px is not here. The bar declares no width in either form; the shell's grid track gives it one above the point and the viewport gives it one below. The track was set to 220 by the founder on 2026-08-12, and the measured floor is 180, where "Your subscriptions" wraps to two lines. On .app.flow the bar stays a row at every width, because the activation chain has no rail for it to be the head of.
Variants and sizes
One axis, and it is not a size. The bar has one type step for each of its parts at every width; what changes past 760 is the direction it runs in. The third row below is an opt-out rather than a third value, and it is named as one.
| Form Chosen by the CONTAINER and never by the screen. No page opts in and no page opts out, except the onboarding chain, which opts out for a stated reason and is the third row |
What changes | Where it stands |
|---|---|---|
Row .app > .appbar |
position: sticky --space-16 gap --line-divider under |
Under a 760px container, on all 57 pages. Sticky, so the way out never scrolls away from a person who is lost |
Column rail @container 760 |
position: static column, flex-start --space-4 gap --line-container right |
At or past 760, on the 28 pages that are not the onboarding chain. It stops being sticky because there is nothing left to stick to: it is beside the content now, in row 1 of the shell's grid |
Row, pinned .app.flow > .appbar |
flex-direction: row border-right: 0 |
26 grey pages, the onboarding chain. An opt-out, and at 360 it does nothing at all: every one of its four declarations is already true at the mobile width. It exists to win at desktop, and it wins on specificity rather than on a query: 0-3-0 against the container rule's 0-2-0. The chain is one centred column with no tab bar, so it has no rail to fold into |
| A second size | - | Forbidden with a reason rather than missing. The bar carries one type step per part at every width, and the only size that moves across the breakpoint moves DOWN: the account link drops from --type-body to --type-meta in the rail, because there it is a line of the rail rather than the trailing slot of a bar. A taller or shorter bar would be the screen deciding, and this axis belongs to the container |
The live specimen above shows the form your own window gives it. The query reads the body container, so at this moment you are looking at the row or at the rail, never both. Narrow the window past 760 and the bar you are reading turns over. The measured numbers are in the technical half.
Composition is not an axis, and this is where that gets tested. The five slots combine seven ways across the 57 grey pages, and every one of them is the same class: nothing in .appbar tells them apart, and nothing needs to. The consolidation named one axis for this component and the table below is content, listed so that "six variants" in the stage 07 coverage table is not read as six forms.
| What stands in it | Grey pages | Which screens |
|---|---|---|
.back + .wordmark | 21 | Every detail, alert and connection screen: a way back and a brand mark |
.wordmark + .acct | 10 | The calm view and settings. Nothing to go back to, so the trailing slot greets instead |
.back + .wordmark + .step | 9 | The onboarding chain, where the marker says how far along you are |
.back or .close + .wordmark + .plan | 8 | History and trends, and the upgrade pair: the plan is the thing being decided about. The two Upgrade screens are the whole of .close |
.wordmark alone | 3 | Three states with nowhere to go: the transit screen among them |
.wordmark + .step | 2 | The two chain steps a person may not step back out of |
.wordmark + .plan | 1 | One screen, the current plan |
The table says .wordmark and the live specimen above says .lockup, and both are right. Every figure in it is a count of the 54 grey pages, and the grey is frozen: it carries the word with no mark beside it, because there was no mark when it was written. The coloured product carries the lockup in that slot on all 32 of its screens. The class in the column is the one the counted corpus writes, and it is left as measured rather than updated to the corpus it does not describe. The two corpora agree again at stage 12, where the rollout builds the remaining screens in colour.
Seven, and docs/inventory.md says ten. Measured here by reading the children of header.appbar on all 57 grey pages and grouping by class string. The stage 07 coverage table counted "10 compositions rendered" and the difference is almost certainly the label inside .back, which changes wording seven ways and class zero ways. The number is left as measured and the divergence is reported rather than reconciled on this page: the inventory is the parent's file.
When to use it
A person meets this on every screen after the landing, which is the whole of the product. Its job is the smallest one any component has and the easiest to get wrong: say where you are, and keep the way out visible. It is not a toolbar, it holds no actions, and on the calm view it holds nothing but the brand and a greeting. That emptiness is the design: J-MAIN asks for a view that lowers anxiety, and a top bar full of controls is the shape of an app that wants something from you.
Its links are two tiers, used as two tiers. .back and .close read --text-body and .acct reads --text-muted, and the difference is not drift: the way out of a screen is a thing a person actively looks for, and a greeting is not. The Two Tiers Rule at the smallest scale the product has.
Every string in it comes from voice/docs/microcopy.md, and the back control says where you land rather than saying "Back": "Your subscriptions" on the six detail states, "Netflix" on the cancel guide, "You" on the two screens behind settings. The exception is the onboarding chain, where "Back" is correct because the previous step is a step and not a place. Voice closed that one on 2026-08-11, after the error state carried one destination under two wordings a screen apart. The mark is no longer part of the string: it is drawn by the class, so a label can be rewritten without anybody remembering to carry a chevron with it.
Back and close are different jobs and not two moods of one. .back steps to a place, and the label names the place. .close leaves a thing unfinished and returns to wherever the person came from, so the label is the verb: "Close". That is why they are siblings and why they are two classes rather than one with a modifier. It is also the only reason either mark can be drawn at all, which is the part that took until 2026-08-12 to fix.
The rule, and the anti-rule
A chip saying which plan you are on, or a step marker, or a greeting. All three are things a person reads. None of them is pressable and none of them is meant to be: the bar tells you where you are, and the screen under it is where anything happens.
The moment the trailing slot should do something, it is not a chip and it is not the bar's job. It is a Button in an Action row, standing in the screen where the person is looking. A commit action hidden in the top right of a bar is the pattern this product was designed against: it puts the loudest decision in the quietest corner, and the audience for this product is the one that does not go looking.
"Step 2 of 3" as a plain muted count, at --type-meta, carrying no fill and no track. It answers the question and then stops.
A progress bar on step 1 of 3 tells an avoider the road is long, and this is the audience that closes the app rather than finishing. The comment survives verbatim from kit.css because the reason is the whole product. There is no progress component in this system, and its absence is a decision rather than a gap.
Limits
U3, composition and context. Exactly one app bar per screen, and none on the landing, which carries its own marketing header. 16 of the 17 screens carry exactly one; the seventeenth is the landing. U18, context. A tab destination carries the account link and never a back control; a screen below one carries the back control and never the account link. This component holds both slots and neither knows which screen it is on, so the rule is checked in the markup rather than declared here. Counted over both corpora on 2026-08-19: 16 destination pages and 12 below one, 0 wrong in either direction, with one named exception - settings-no-account carries neither, because there is no account to link to.
U19, composition and context. The plan chip is a child of the account link and appears wherever that link does: 15 pages, all 15 with it inside. Where there is no account link, the four Upgrade screens, it stays a sibling of the lockup. A plan is a fact about the account, so it belongs to the control that opens the account, and what keeps it a statement rather than a sell is where it lands: Settings, never the upgrade screen.
Full table with sources and how to check: Usage rules.
States
Three states, and they exist because three of the seven things in this bar are links. The bar itself is a header: no href, no hover, no ring, and nothing to give it. Each state 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.
Two of the three hovers change ink and nothing else, and the third moves instead. .back rests in ink since 2026-08-17, so there is no darker ink for it to go to: its answer is the advance verb, the arrow travelling one --nudge toward where it points. The label stays put. No fill arrives, no edge arrives, and nothing moves. A border appearing under the pointer inside a 44px bar would push the entire screen down by a pixel, on every screen in the product, from a mouse passing over a greeting. The two marks come along for free: they are painted in currentColor, so the chevron and the cross darken with their own labels and the hover needs no extra line and no dark theme pair.
There is no active and no disabled. kit.css declared neither, and writing one here would be a new decision about how the product answers a press, taken by the person moving a file rather than by the person who owns the product.
Rest, live
The three links are live and real in Anatomy, at the top of this page: hover them, tab to them, and read the values off the real thing. Faking a state with .is-hover is the same defect as inventing a role, because 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. 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.
--text-body on the back control, --text-muted on the greeting and the marker, on --bg-surface--text-primary, on both links, and nothing else changes. The two arrive at the same ink from two different tiers, which is what makes them read as one response--color-focus, 6.23:1 as a line, at 2px offset so the ring sits outside the link and moves no neighbour--color-focus, 6.82:1. The back control is the first stop in the tab order of 38 screens, so this is the first ring a keyboard user ever sees in this productThe ring is declared in the component's file as well as in base.css, and it moves no pixel: same token, same 2px, same 2px offset. It is re-declared at the component's own specificity so that nothing added to this file later can out-rank the base ring and leave a keyboard user with no mark at all.
The technical half
| Reads | Which token | Growing from |
|---|---|---|
| the bar's ground | --bg-surface | --paper, and --paper-dark in the dark theme |
| its bottom edge | --line-divider | --hairline-soft. A separator inside one surface, decorative, 1.23:1 by decision |
| the rail's right edge, past 760 | --line-container | --hairline. A container edge and not a divider: two roles, two jobs, one hue |
| the back and close controls | --text-body | --slate |
| the greeting and the step marker | --text-muted | --muted, the single secondary tier |
| all three hovers | --text-primary | --ink |
| the two marks | none, and that is the point | currentColor through a mask. The arrow (18px, stroke 2.5, since 2026-08-17 - it was a mirrored chevron, which is this product's go deeper mark and the wrong one for leave this screen) and the cross (16px, stroke 2, unchanged) take the ink of the control they sit in, at rest and on hover, in both themes, so neither has a colour rule and neither can drift from its own label |
| the focus ring | --color-focus | --petrol |
| all three links' floor | --tap | primitive, straight. 44px, the WCAG floor this project holds everywhere |
| the links and the marker | --type-body, --type-meta | primitive, straight: geometry has nothing to override. --type-sub left this file on 2026-08-12 with the wordmark, and the bar now sets no type for the brand at all |
| the brand | none, and that is the change | the mark and the word are drawn by brand-mark.css and brand-wordmark.css. This file names neither a colour nor a size for either of them |
| padding and gap | --space-0, --space-4, --space-8, --space-16 | primitive, straight |
| the stacking order | none | --z-appbar, a primitive since 2026-08-20, on the founder's decision. It sits one under the tab bar's --z-tabbar, and where the two chromes meet the bottom bar wins. Proposed at stage 07, deferred to stage 08, refused by stage 09 on the ground that three literals are not a scale - right about the third one, the money field's 1, and wrong about these two, which are one relationship rather than two values. Zero pixels: 5 is 5 |
| the plan chip's placement | none, and not here | All three of its answers are in chip.css, with the other five host placements of that atom: auto in the bar, 0 inside the account link, and auto again inside it past the point. This file supplies only the box they resolve against, which is the link stretching in the rail. Checked before it was written and left where it was |
Two folds moved pixels, both from the founder's review of the foundations, and they move in opposite directions. The gap between the three slots was 12px and folds UP to --space-16, so the slots stand 4px further apart. The vertical padding was 10px and folds DOWN to --space-8, so the bar is 4px shorter. Nobody chose either: the mapping in the audit runs to the nearest step with ties to the larger, and it is applied mechanically to every value in the system. Both are the second of the three legal sources of a visual change.
Two characters became two drawn marks on 2026-08-12, and one class doing two jobs is what had blocked both. a.back carried ‹ on 16 of the 18 coloured back controls and × Close on 2, and CSS cannot select by the words inside an element, so while they shared a name neither mark could be drawn: a chevron on the word Close is worse than a character on the right word. Splitting .close off unblocks both at once, and it unblocks the most common mark in the product. Neither path is new: the chevron is the one nav-row.css draws, mirrored, and the cross is the one text-input.css draws on the search field's clear control. Same 24 unit cell, same 2 unit stroke, same 16px box, same mask over currentColor. Three marks, one family, one place to change all of them.
The gap was zero for five days and came back with the arrow, and both halves were measured rather than chosen. The chevron painted 7 of 24 units, so inside a 16px box it was 4.67px of ink with 5.67px of clearance each side; the character it replaced had a 5.39px advance plus a 3.94px word space, about 5.1px of air. The mask's own clearance was the gap, to within half a pixel. The arrow paints 14 of 24 and its stroke reaches 20.25, so inside an 18px box the ink stops 2.81px from the edge and the label landed against it. --space-2 puts the pair back at 4.8px - the old air to within a third of a pixel, and the smallest step on the scale spent exactly where the drawing needs it. The cross keeps its zero: it did not change. What does move, and it is named under source 4 in docs/tokens-audit.md: the label's left edge steps right by 6.67px on .back and 3.73px on .close, because a 16px box is wider than a 9.33px or 12.27px character prefix. It lands on the screens the day the markup does.
The markup caught up at step 6 and the two edits were exactly these. The two Close controls, on design/upgrade.html and design/upgrade-payment-failed.html, became <a class="close" href="history-trends-locked.html">Close</a>. The sixteen back controls kept class="back" and dropped the leading ‹ . Removing a literal from a coloured screen without touching its frozen grey original was already the renaming map's own precedent, on the two [chart] placeholders, so it needed no new permission. Counted after the sweep: 16 back controls and 2 close controls across the coloured screens, and no ‹ or × left in any of them. design/kit/shell.html took the same edit on its one .back.
The brand arrived on 2026-08-12, and it took three paint properties off this file. Until that day app-bar.css set the wordmark's weight, size and tracking, directly under a paragraph saying an organism hosts and positions and repaints nothing. Nobody had noticed, because there was nothing to compare it against: the word was the whole identity and the bar was its only host. The mark arriving gave the brand a file of its own, and the type went with it. What stays here is the only part that was ever the bar's, which is where the lockup sits and what happens to it when the row becomes a rail. The change is subtractive: this component reads one type token fewer than it did yesterday.
What the brand moved on the screens, measured rather than asserted. The lockup inserts three elements per screen, so the step 8 fingerprint could not be paired by document order and was re-aligned by an LCS on tag and text: design/kit/screens/brandproof.cjs. Result across the 28 coloured screens of stage 08, the whole coloured corpus at the time, at both viewports: 3774 elements matched, 168 added, 0 dropped, 69 moved. All 69 are the brand's own slot and the two plan chips whose margin-left: auto absorbs its new width. Not one other element on any of those 28 screens changed a single declared property. The ledger is in design/kit/screens/brand-ledger.txt.
The container is body, and it is declared in base.css, not here. A container query matches an ANCESTOR container and never the element itself, so a second container-type in this file would not let the bar query its own width; it would re-point this query and every other query inside it at a different box, silently. 760 is written as a number because a custom property is not valid inside a @container condition.
Lives in design/system/components/app-bar.css. Stands on 56 grey pages, every page in wireframes/ except the landing and the stage hub; all 57 of them are coloured. Coloured screens carrying it: Home, Subscription detail, Add a subscription, History and trends, Upgrade, Settings, Alerts.
<header class="appbar">
<a class="back" href="home.html">Your subscriptions</a>
<span class="lockup"><span class="brand" aria-hidden="true"></span><span class="wordmark">Ten<span class="dd">dd</span></span></span>
<span class="chip">Pro</span>
</header>
<header class="appbar">
<a class="close" href="history-trends-locked.html">Close</a>
<span class="lockup"><span class="brand" aria-hidden="true"></span><span class="wordmark">Ten<span class="dd">dd</span></span></span>
<span class="chip">Free</span>
</header>
<header class="appbar">
<span class="lockup"><span class="brand" aria-hidden="true"></span><span class="wordmark">Ten<span class="dd">dd</span></span></span>
<a class="acct" href="settings.html" aria-label="Hi Emma, you are on the free plan, open your account">Hi, Emma<span class="chip">Free</span></a>
</header>
The header element is part of the copy for a reason: the bar is the banner of the screen, it is the first thing in the tab order, and rendering it as a div would take that from a screen reader with nothing visible changing.