Architecture
The rules of the system, and they are short on purpose: a rule that needs a paragraph to state is usually two rules that disagree. Read this first. Everything else on this stand is material; this is what holds it together.
Two ladders, and they are not the same ladder
Both use the word "level" and they answer different questions. Confusing them is the most common way a design system goes wrong, so they are written apart.
The token ladder: where a value comes from
| Rung | Answers | Example | Overridden by a theme |
|---|---|---|---|
| primitive | what value | --petrol: #1c6a76, --radius-sm: 10px | never |
| semantic | why this colour here | --bg-action, --text-muted, --line-control | always, and the pair is written the same moment the role is |
There is no third rung, and a component level is not created wholesale. --button-bg and --card-radius would be a third layer of renaming: changing the colour of a button would mean opening three files instead of one. A component token is created case by case, and only where a state lands on no semantic role at all. The hover of a card and the hover of a list row are one --bg-hover; the hover of a destructive button has to darken from the danger colour rather than from the surface, and there it earns its name.
Geometry gets no semantic rung. A radius and a spacing have nothing to override: no theme and no rebrand moves them, they repeat. Components read them straight from primitive. This is where stage 08 makes stage 07's rule more precise rather than repeating it: "no value written directly in a component" becomes "colour goes through a role, geometry goes through a primitive".
The component ladder: what contains what
| Rung | Test | In this system |
|---|---|---|
| atoms | contains nothing else from the kit. One visual element, one content slot | 18 |
| molecules | two or more content slots in one unit, or hosts an atom | 23 |
| organisms | hosts a molecule, or is a screen shell, or is a container of repeated units. This is the ceiling: anything containing an organism is still an organism, and inside the group the ones that contain nothing else come first | 14 |
| patterns | a settled composition of the rungs above, standing on three screens or more. A new shelf in the same cupboard from stage 09, not a rebuild. It declares no colour and no type of its own: if one is needed, that is a component first | 3 |
Grouping by purpose is forbidden. By purpose a button and a sign-in dialog are both "forms", and they end up side by side while one lives inside the other. The cost is not cosmetic: alphabetical @import puts dialog.css above field.css, so a composite lands higher in the cascade than its own parts, and every contextual fix starts reaching for !important.
What a component reads
| It needs | It reads | Never |
|---|---|---|
| a colour | a semantic role: var(--text-muted) | a primitive, and never a literal |
| a radius, a spacing, a size | a primitive: var(--radius-sm) | a literal |
| a state | a state token: var(--bg-hover), var(--color-focus) | a hex or a number written into :hover |
A component reading a colour primitive directly is a hole the first theme finds. A hex inside :hover is the same hole one layer down: the dark theme would need forty edits instead of three lines, twenty components would each hover slightly differently, and stage 11 would begin by collecting states that should already exist.
Naming, and the three questions that decide a role
Role names are read out of the product, not taken from another system's vocabulary. --color-primary and --surface-2 are somebody else's names. Ours come from where each value actually stands and from DESIGN.md.
| Axis | The question | What it decides |
|---|---|---|
| Purpose | could these two places ever move apart | Two purposes are two tokens even at the same value today. The petrol fill of a button and the petrol ink of a trust link can part company, so they are two |
| Repetition | has it happened more than once | A colour standing in exactly one place has not proved it is a role. It stays primitive and goes on a list to be looked at again |
| Surface | ink, fill or line | One role on two surfaces is forbidden even at the same value, because the thresholds differ: ink 4.5:1 (3:1 from 24px or 19px bold), fill and line 3:1. A fill role on a 12px bold caption passes as a surface and fails as text, and nobody catches it, because nobody declared it text |
Two folders, two jobs
| Folder | What is in it | Who loads it |
|---|---|---|
design/system/ | tokens.css, base.css, index.css, components/. patterns/ joins at stage 09 with no move | the product, and the stand |
design/kit/ | the registry, the stand's own look, the hub, a page per component, docs/ | a person in a browser. It ships nothing |
A product class in the stand, or a stand style in a component file, is a defect either way. design/system/ can be lifted into another project whole; the stand stays here. @import runs by level, never alphabetically: all atoms, then molecules, then organisms, alphabetical inside each level, and inside organisms the ones that contain no other organism first. A composite must sit lower in the cascade than its own parts.
Contributing to the system
The rule is one sentence and everything below it is an address book: nothing new appears on a screen first. It is built in design/system/, it is shown here in design/kit/, and it reaches the screen from there. That order is not bureaucracy, it is the only arrangement in which a correction is made once and lands everywhere, and it is why the dark theme cost three lines rather than forty edits.
A new COMPONENT: five things, and four is not enough
| # | What | Why it is on the list |
|---|---|---|
| 1 | design/system/components/<name>.css | the component itself |
| 2 | design/kit/<name>.html | six sections, in order and with these ids: anatomy, variants, usage, rule, states, tech, every state drawn in both themes. A pattern page carries six too, with where in place of states |
| 3 | a row in design/kit/_nav.js, in its own level group | the registry draws both the hub card and the panel link. No row, no component |
| 4 | a row in docs/inventory.md, with its level | the level decides four orders downstream |
| 5 | an @import in index.css, into its level group and not at the end | appended at the end it looks harmless, and that is exactly how the ladder comes apart |
The last two are the ones that get skipped, and they matter most for a component added after the first build: at the consolidation of step 6, at a new screen, at the rollout of stage 12. Anything missing means the component is not finished, and an unfinished component does not go onto a screen.
Row 2 said "five blocks" until 2026-08-13, and the structural check said six. The check is the one that runs, so the check is right and the list was short by tech, the block naming the css file a page is drawn by. Two editions of one contract in one document, and the one a person reads before building was the wrong one.
The other four entrances
| What is new | Where it goes, and what it may not do |
|---|---|
| a USAGE RULE | a row in the table below with its "where it comes from" filled: counted on the grey pages, decided by a person, or caught by a critique log. A rule with an empty source is an invention with a table around it. Plus a Limits item on the page of every component it names, linking back, because the person about to break a rule is reading the component page and not this one |
| a COMPOSITION, three screens | a file in design/system/patterns/, a page here, a row in the registry in the patterns group, a line in docs/inventory.md, and an @import after every component. No colour, no type, no visual decision of its own; needing one is an order for a component, built first |
| a COMPOSITION, two screens | it stays markup and goes into the candidates table on Patterns. Two occurrences prove a composition is possible, three prove it is settled, and that is the whole reason the folder does not fill up with things nobody checked |
| a VALUE | tokens.css at its own level: raw is primitive, a colour with a job is semantic and reads its primitive through var(). No third level, and geometry gets none. A role or a state token is written twice, in :root and in [data-theme="dark"], as it is declared. The change is a named decision, "variable, value, why", and the origin travels with it in a comment. The unit is decided by who owns the number, 2026-08-13: rem for what a reader can resize, which is the type scale and the two width points, and they must share a unit or the point moves for a person whose text does not; px for geometry, which is spacing, radii, the rail and the column floor. A px font-size anywhere in design/system/ is a defect, and it is a grep. Reading measures are already in ch and line heights are already unitless, so neither needs a rule |
| anything, on a SCREEN only | Forbidden. A screen carries no style of its own: no page-level style block, no style attribute, no class the system does not define. If none of the entrances above fits, the system is missing something and the missing thing gets built; what was deliberately not built goes into docs/backlog.md rather than being worked around |
The last row is the one that costs the most to break, because a style written on one screen is invisible until the second screen needs the same thing, and by then there are two of them and no class name for either.
Where a correction goes
The section above is where NEW goes. This one is where a FIX goes, and they are different questions: the first asks which shelf a thing belongs on, the second asks which single place to touch so that every screen gets it.
| What is wrong | Where the fix goes |
|---|---|
| a colour is wrong everywhere | the semantic role in tokens.css |
| a value is wrong everywhere | the primitive in tokens.css |
| a component is wrong everywhere it stands | its file in components/ |
| the markup of a component is wrong | its page in design/kit/, then every screen that carries it |
| one screen looks wrong | almost never the screen. Work out which of the four above it really is |
A fix applied on one screen is a desync. It looks finished, and it is a second edition of the component that nobody declared.
Usage rules: what you may not do with what you may take
Every page on this stand answers "what may I take". This section answers the question nobody asks until after the screen is built. These are not anti-rules and they do not belong on a component page. An anti-rule is a SUBSTITUTION ("not a chip here, a label"), so it lives beside the component you should not have taken. These are two other kinds, and neither can be written as a substitution, because no other component is the answer: composition is how many and next to what, context is where it may appear at all.
Every row names its source, and a row with an empty source is an invention with a table around it. Four sources, one is enough: the counter, run over wireframes/*.html at stage 09 (55 pages, 17 screens, the whole product, where the colour is a sample of 8 screens until stage 12); wireframes/docs/conventions.md, which carried usage rules for four stages with no reader; the critique logs of 04, 06, 07 and 08, because a defect caught on two screens is a rule; and docs/census.md. Read forwards, the counter finds patterns: what repeats three times or more. Read backwards it finds these: what never happened once, although it could have.
| # | Rule | Class | Where it comes from | Components | How to check a new screen |
|---|---|---|---|---|---|
| U1 | One filled action per zone. A zone is a block a person reads as one thing: the screen's own content column, or a .panel, .card, .sheet, .rstep or <form> inside it. The second action of a zone takes .btn without .primary | composition | conventions.md ("what the one main action is") + the counter: 15 of 17 screens carry at most one + critique 07, row "One voice, one zone", carried unresolved + the last open row of census.md | button, action row | count .btn.primary per zone, not per page |
| U2 | No tab bar inside .app.flow, and none on the landing. The chain is one task and the way out of it is finishing it | context | the counter: 26 grey pages are a flow and not one has a tab bar; 28 carry a tab bar and not one is a flow + conventions.md section 8 | tab bar, app shell | is the shell .app.flow? then no tab bar |
| U3 | Exactly one .appbar per screen, and none on the landing | composition + context | the counter: 16 of 17 screens carry exactly one, the landing carries none | app bar | presence |
| U4 | One .textblock.status per screen. A screen that needs to announce two things is two states, not one screen | composition | the counter: 12 screens carry one, none carries two | text block | count |
| U5 | One introductory .textblock per screen, and it is the first block of its column | composition | the counter: 12 screens carry one, none carries two + the contract of the list column pattern | text block | count, and check it is first |
| U6 | A .textblock.status comes with a way out in the same container: an .actions row, or a .grid of doors | composition | conventions.md section 7 ("no dead ends") + the counter: 19 grey pages carry a status block, 17 have the exit beside it, and the two that do not are named below | text block, action row, grid | is there an exit in the same container? |
| U7 | A wait carries no control at all. An edge a person takes is a control; an edge the system takes is not | context | conventions.md section 7, added 2026-08-05 + the counter: connect-bank-loading and upgrade-processing carry zero buttons | button, action row | count controls; a wait has none |
| U8 | One .trust per screen, and only where a figure came from the bank. It is the read-only declaration, not decoration | composition + context | the counter: 5 screens, never twice (recounted 2026-08-19; History and trends took one, being the only screen with figures and no source line) + conventions.md section 5 | trust block | count, and ask where the figures came from |
| U9 | One .total per screen. The most important number is the biggest thing on the screen, and two of them means neither is | composition | the counter: 5 screens, never twice (recounted 2026-08-19 by counts.cjs; it was 4 until History and trends took the figure) + design principle 2 | big total | count |
| U10 | One .sheet at a time. A sheet over a sheet has no way back a person can see | composition | the counter: 3 screens carry one, none carries two | dialog sheet | count |
| U11 | No footer inside the app. Tendd is an app; the one footer in the product is on the Welcome landing | context | conventions.md section 8 | none: the rule is that none is built | presence |
| U12 | A state change is colour over --dur-state, and a component declares a transition only where its host is not a native interactive element. base.css puts the one transition on :where(a, button, input, select, textarea, summary, label) and moves four properties: background, border, colour, underline. Nothing on that list moves an object, and the focus ring is deliberately off it | context | stage 11, 2026-08-16, and the census that opened it: 68 state rules across 28 component files, 26 of them with no motion at all + founder's decision the same day + the ring exclusion's own ground, that a keyboard user moves faster than 150ms a stop | plan option, preset tile, alert item, and base.css | does the hover host answer to the element list? then nothing to write. Does it not? then one transition in the component file, reading the two tokens and never a literal |
| U13 | Four verbs, one distance, and nothing travels two. Fade is U12. Advance: a direction cue moves toward where it points. Lift: an identity mark rises and grows when its target is hovered, and so does the button itself. Press: anything pushable goes down while it is held. The distance is --nudge, 2px, everywhere | context | founder, 2026-08-16, on the colour-only pass: "большинство анимаций я не вижу". The exclusions carry their own ground: a disabled control, a loading form, the current tab, a figure and a card never move | button, logo, nav row, door, tab bar, and seven more, listed in docs/architecture.md | does the object have a direction, an identity or a press? then one verb, one nudge, from the tokens. None of the three? then it fades and nothing else |
| U14 | The brand moves once on arrival, and it answers a pointer anywhere: one crop number, two rules. The signature runs on the public page's bar only, once per load; the hover is the same gesture played, on every lockup, holding no pose at either end | context | founder, 2026-08-16, and the direction chosen at Concept, whose sentence is "the tighter the crop, the more abstract the mark". The hover half is the same founder the next morning: "при ховере я не вижу анимации" | brand mark, brand wordmark | is this the app? then no .signature on the lockup. Is it the public bar? then exactly one, and no second carrier anywhere |
| U15 | One content measure per screen, and every state of a screen ends in the same place. A block narrows itself only for READING - a paragraph, a prose card or a form column at 560, a list column at 620 - because that limit is a property of the TEXT. A block that is not text and wants to be narrower is telling you the SCREEN is too wide, and the answer goes on the pane rather than on the block. An interruption is the one state measured against something else: it is one message and one way out, at --container-page wherever it stands, and it is checked against the other interruptions instead | composition | founder, 2026-08-19, on History and trends at 1920: "что то растянуто на всю ширину что то нет". Measured on that one screen, the children ended at 1280, 780, 525, 459 and 384 + the counter, edges13.cjs: 12 families, 0 split, 1 declared exception, and 2 interruptions at 1 width. The rule was rewritten the same evening: the founder's answer to the five edges was not the narrow measure it first got but Home's wide one, and the value moved while the rule stood | app shell, chart placeholder, category bars, card | run edges13.cjs: one width under "the pane gives", and everything under "declared its own" is text or a control |
| U16 | [hidden] is a fact about an element, and a component may not out-declare it. The browser hides it at 0-1-0; any component that declares display beats it and the attribute silently does nothing. Two here do it on elements a screen legitimately hides: .metarow is display: flex and .muted is display: block. The answer is ONE rule at the end of index.css, which wins on source order. Never !important, never a selector trick, and never a runtime inline style where the markup has to be right with no script | context | built 2026-08-19, when History and trends gained three range views hidden in the AUTHORED markup, so that with no script the screen is the three-month view and not all three stacked + the census of the 70 component files: exactly two declare display on an element a screen hides + the precedent it corrects, behaviour.js on .tile at stage 08 | meta row, muted line, and index.css | is the element hidden in the markup? then check its component does not declare display. If it does, the fix is the last line of index.css and not a class |
| U17 | A screen-level order belongs to the layout that needs it, never to every screen. The four desktop orders - head 1, action row 2, groups 3, trust 4 - are scoped .app > .screen:has(> .groups), because they exist to lift the two secondaries above a list running in balanced columns and nowhere else. Unscoped, one order: 1 sends the ONLY ordered block to the foot of any screen that has it and nothing else, since every unordered sibling keeps the default 0 | composition | founder, 2026-08-19, when History and trends took Home's head and landed the whole head at the bottom of the screen + the bug that was already live: measured on home-empty at 1440, .empty at y=72 and .head at y=290. The empty state printed above the summary from the day the ordering was written, and no sweep caught it, because every instrument here checks widths and this is an order | app shell, dashboard head, groups column set, trust block | is the DOM order wrong at desktop? then order the WHOLE set and guard it on the block that makes the layout, never on the one block you are moving |
| U18 | 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. The five destinations are Home, Trends, Alerts, Save and You. A back control on a destination offers to leave a place the person did not arrive at from anywhere in particular, and the way out of a destination is the tab bar it already has | context | founder, 2026-08-19: "мне не нравится что у нас в трендс есть кнопка назад когда это стало отдельным пунктом" + the counter over both corpora: 16 destination pages, 12 pages below one, 0 wrong in either direction once History and trends was fixed. It had been the only destination with a back control since 2026-08-18, when Trends became a tab and the bar it was given as a DETAIL screen was never looked at again | app bar, tab bar | is the screen one of the five destinations? then the account link and no back. Is it below one? then the back control and no account link |
| U19 | The plan chip is a child of the account link, and it appears wherever that link does. A plan is a fact about the ACCOUNT, so it belongs to the control that opens the account: it lands on Settings, where a plan can be read and changed, and never on the upgrade screen, which is what keeps it a statement rather than a sell. Where there is no account link - the four Upgrade screens - it stays a sibling of the lockup. Inside the link it is pushed by the FORM of the bar and not by the page, 2026-08-20: glued to the name in the row, where the link is already the trailing slot, and pushed to the far edge in the rail, where the link is stretched to the rail's own width | composition + context | founder, 2026-08-19: "Free должно быть везде а не только в трендс", overruling the narrower answer shipped an hour earlier + the counter over both corpora: 15 pages carry the account link and all 15 carry the chip inside it; 4 carry it as a sibling; 0 carry it beside the greeting. The narrower answer and its argument are kept in docs/decisions.md, because a rejected reason that turns out to be overruled is worth more than a rule with no history | app bar, chip | is there an account link? then the chip is inside it. Is there none? then it is a sibling of the lockup |
The exceptions are named, not implied. A rule with an unwritten exception is a rule people learn to ignore. U6, counted rather than remembered: of the 19 grey pages carrying a status block, two do not carry the exit beside it. On cancel-guide-no-guide the exits are in the other column of a detail layout, which is a place a person can see; on upgrade-processing there is no exit anywhere, deliberately. connect-bank-loading, the other wait, carries no status block at all, so it is U7's case and not U6's. U1: add-subscription carries two filled actions and the rule permits it, because they sit in two zones, the manual form and the screen itself. That is the weakest permission in the table and it is written as such: stage 07 measured the two 55px apart and carried the row rather than closing it. U1 again: cancel-guide carries two filled actions in one column and the rule does not permit it. The screen is grey only, so nothing is broken in colour today; it is a line in docs/backlog.md, settled when the screen is coloured at stage 12, rather than by quietly widening the rule until it fits.
The idle control, the same one every declared list in this folder carries. A rule that forbids something the product never does anyway is a dead row. Thirteen of the nineteen are counted on screens that exist: U1 on 17 screens, U2 on 57 pages, U3 on 17, U4 and U5 on 12 each, U6 on 16 status blocks, U8 on 5, U9 on 5, U10 on 3, U15 on 54, U17 on the 5 screens that carry a head without a groups set or the other way round, U18 on 28 pages, 16 destinations and 12 below one, U19 on 19 pages. Six are carried on a decision, or on a census of FILES rather than of screens, and every one says so: U7 (two pages), U11 (one page), U16 (two component files of 70), and the three motion rules U12, U13 and U14. This table published eleven of them until 2026-08-19, because stage 11 wrote U12, U13 and U14 into docs/architecture.md and into Motion and did not rebuild this page; Motion had been pointing at "U12 in Architecture" for three days and Architecture stopped at U11. What was deliberately not made a rule: .summary, .hero and .empty never appear twice on a screen either, and each stands on one or two screens in the whole product. A counter that has seen one screen has not found a rule, it has described a screen. (.head and .chart were on that list until 2026-08-19, when History and trends took both and the sentence stopped being true of them.)
The one thing this stage promises
The product does not move by a pixel. There are exactly three legal sources of a visual change, all named in docs/tokens-audit.md, and the pixel comparison at step 8 checks every difference against them: consolidated drift at step 3, the founder's review of these foundations at step 4, and moving the product onto system classes at step 6. A difference with no line in one of those three lists is a defect, and it is fixed in tokens.css or in a component file. Never on the screen.
Closed at step 9, and the four things the stage learned
Two instruments is not redundancy, and the overlap proves it. Codex read the source in read-only, a browser pass read the render, merged afterwards with a "who found it" column. Codex returned 24 rows, the browser pass 4, and not one row was the same. A class of defect belongs to exactly one instrument, and running the other one twice finds none of it. The most serious finding of the stage, structural edits committed into the frozen wireframes/, was invisible to the check that runs every session, because that check looks for UNCOMMITTED changes and this one had been committed three steps earlier.
An instrument that returns the same answer at every input is not measuring the input. Three of this stage's probes were thrown away and rewritten: a wrap detector reading element height, under components that all stand on a min-height floor; a line counter using Range.getClientRects(), which counts inline boxes rather than lines; and a state scanner matching :hover inside comments that say no hover is declared. All three were confident, detailed and wrong.
A declared list needs an idle control, and the control has three answers. A row that covers nothing may be dead, may be waiting for a page that is still grey, or may have been CONSUMED by the renaming map and left standing. The One-offs table had one of each and both read identically until the question was asked.
Completeness is proved against what has NOT been built. The dry run over the densest still-grey screens: four of five needed nothing, the fifth needs 37 classes the system has never carried. A system checked only against the screens it was extracted from proves nothing. The whole account is on the proof.