The one rule
Build a screen by choosing containers and components. If you find yourself writing a CSS rule, the honest question is whether what you need is a component this library is missing. Add it here, not on the page.
What a surface may own
Its pictures. The homepage keeps about thirty lines for a cropped mark and two small charts, and that is correct: a bar chart of payroll months is not a design system concern. It owns no colour, no spacing and no type.
What breaks loudly
A <button> with no class renders as the browser's own
grey control, because there is no bare button rule. That is deliberate: a missing class
should look broken rather than look fine.
The mechanism the whole palette hangs off. A ground is the only thing allowed to pair a foreground with a background, and it sets all of them at once: text, muted, borders, accent, status. Choosing a background therefore chooses everything that has to read on it.
This is not a convention to remember. A component never names two colours, so putting acid
text on cream is not something you must avoid writing; it is something there is no way to
write. Four grounds exist and there is no fifth: adding one means adding a row to
GROUNDS in tokens.ts, and the
contrast test then holds it to the same 4.5 as the other four.
.on-paper
Secondary text, which is what a paragraph inside a card is set in.
The muted tier: captions, hints, and the third rank of anything.
A card on this ground
Same markup, four different sets of colours, no variant per ground.
.on-green
Secondary text, which is what a paragraph inside a card is set in.
The muted tier on green is full cream, because nothing quieter clears AA. Hierarchy here comes from size and weight.
A card on this ground
Same markup, four different sets of colours, no variant per ground.
.on-ink
Secondary text, which is what a paragraph inside a card is set in.
The muted tier: captions, hints, and the third rank of anything.
A card on this ground
Same markup, four different sets of colours, no variant per ground.
.on-brine
Secondary text, which is what a paragraph inside a card is set in.
The muted tier: captions, hints, and the third rank of anything.
A card on this ground
Same markup, four different sets of colours, no variant per ground.
Computed from the token values as this page renders, so it cannot go stale.
test/design-system.test.mjs recomputes every one of these with
its own implementation of the formula and fails the build below 4.5. Anything on this table
reading anything other than AA or AAA is a bug.
| Pairing | Foreground | Background | Ratio | Grade |
|---|---|---|---|---|
| paper: primary text | #0F2418 | #FDFBF4 | 15.77 | AAA |
| paper: secondary text | #3B4A3E | #FDFBF4 | 9.07 | AAA |
| paper: muted text | #5F6B60 | #FDFBF4 | 5.40 | AA |
| paper: text on a card | #0F2418 | #FBF7EC | 15.26 | AAA |
| paper: text on a raised card | #0F2418 | #FFFFFF | 16.33 | AAA |
| paper: button label on accent | #FFFFFF | #1E7A46 | 5.35 | AA |
| paper: button label on accent hover | #FFFFFF | #196238 | 7.38 | AAA |
| paper: text on the accent tint | #1E7A46 | #EAF5E3 | 4.75 | AA |
| paper: urgent on its tint | #A8412C | #FBEDE8 | 5.32 | AA |
| paper: attention on its tint | #9A6412 | #FDF4E2 | 4.57 | AA |
| paper: good on its tint | #1E7A46 | #EAF5E3 | 4.75 | AA |
| green: primary text | #FBF7EC | #1E7A46 | 4.99 | AA |
| green: secondary text | #FBF7EC | #1E7A46 | 4.99 | AA |
| green: muted text | #FBF7EC | #1E7A46 | 4.99 | AA |
| green: text on a card | #FBF7EC | #17603A | 7.08 | AAA |
| green: text on a raised card | #FBF7EC | #155637 | 8.11 | AAA |
| green: button label on accent | #1E7A46 | #FBF7EC | 4.99 | AA |
| green: button label on accent hover | #1E7A46 | #FFFFFF | 5.35 | AA |
| green: text on the accent tint | #FBF7EC | #155637 | 8.11 | AAA |
| green: urgent on its tint | #FBF7EC | #155637 | 8.11 | AAA |
| green: attention on its tint | #FBF7EC | #155637 | 8.11 | AAA |
| green: good on its tint | #FBF7EC | #155637 | 8.11 | AAA |
| ink: primary text | #FBF7EC | #0F2418 | 15.26 | AAA |
| ink: secondary text | #DAD9CE | #0F2418 | 11.51 | AAA |
| ink: muted text | #B9BCB1 | #0F2418 | 8.47 | AAA |
| ink: text on a card | #FBF7EC | #16301F | 13.28 | AAA |
| ink: text on a raised card | #FBF7EC | #1D3A26 | 11.64 | AAA |
| ink: button label on accent | #0F2418 | #8BD44F | 9.04 | AAA |
| ink: button label on accent hover | #0F2418 | #7CC441 | 7.64 | AAA |
| ink: text on the accent tint | #8BD44F | #23402B | 6.32 | AA |
| ink: urgent on its tint | #EFA894 | #3A241E | 7.37 | AAA |
| ink: attention on its tint | #EDC26E | #39301B | 7.78 | AAA |
| ink: good on its tint | #8BD44F | #23402B | 6.32 | AA |
| brine: primary text | #0F2418 | #FFD84D | 11.81 | AAA |
| brine: secondary text | #313D1F | #FFD84D | 8.35 | AAA |
| brine: muted text | #525627 | #FFD84D | 5.58 | AA |
| brine: text on a card | #0F2418 | #FEDF73 | 12.46 | AAA |
| brine: text on a raised card | #0F2418 | #FEE58C | 13.07 | AAA |
| brine: button label on accent | #FFD84D | #0F2418 | 11.81 | AAA |
| brine: button label on accent hover | #FFD84D | #1B3A28 | 9.02 | AAA |
| brine: text on the accent tint | #0F2418 | #F2C93F | 10.27 | AAA |
| brine: urgent on its tint | #0F2418 | #F2C93F | 10.27 | AAA |
| brine: attention on its tint | #0F2418 | #F2C93F | 10.27 | AAA |
| brine: good on its tint | #0F2418 | #F2C93F | 10.27 | AAA |
Listed because knowing why acid is never a text colour on a light page is what stops somebody reintroducing it. Acid and brine are for grounds, marks and fills. They carry text on ink, and nowhere else.
| Pairing | Foreground | Background | Ratio | Grade |
|---|---|---|---|---|
| acid on cream | #8BD44F | #FBF7EC | 1.69 | fail |
| brine on cream | #FFD84D | #FBF7EC | 1.29 | fail |
| acid on green | #8BD44F | #1E7A46 | 2.96 | fail |
| green on brine | #1E7A46 | #FFD84D | 3.87 | AA large |
Type, spacing, radius and motion. One definition each, in
tokens.ts, referenced everywhere.
| --text-display | Business insurance for when things get sticky |
| --text-1 | Ridgeline Roofing Ltd |
| --text-2 | Statement of demands and needs |
| --text-3 | Asset register |
| --text-body-l | The lede, at 16.5px, for the sentence under a heading. |
| --text-body | Body, at 15px. Everything a person actually reads. |
| --text-small | Small, at 13.5px. Captions, hints, table cells. |
| --text-tiny | Tiny, at 12px. Footnotes and the disclaimer. |
| --text-micro | Micro, at 11px, uppercase. Section labels only. |
| --space-1 | |
| --space-2 | |
| --space-3 | |
| --space-4 | |
| --space-5 | |
| --space-6 | |
| --space-7 | |
| --space-8 |
Three steps and a pill, and they are load-bearing rather than decorative: 9px for a chip or an input, 14px for a card, 18px for a panel. Three steps is how nesting reads as nesting.
Three durations, and a hard rule about what they may touch: colour, opacity, and a one-pixel
press. Nothing in this system animates layout, position or size. Two effects that did were
built and removed from this product already, one because it produced a repaint artefact and
one because it failed to fire for four of seven sections and would have shipped a homepage
that rendered blank below the hero. Everything here also stops under
prefers-reduced-motion.
| --dur-1 | 160ms | State feedback: hover, focus, press. |
| --dur-2 | 260ms | Something arriving: a log row, a toast. |
| --dur-3 | 320ms | A value changing: a meter, a progress bar. |
default · hover · focus · active · disabled · loading
The component the whole workstream turns on. There is no button { }
rule anywhere in this system: a control looks like a button because it says
class="btn". The old stylesheet painted every button with the accent
fill, so a tab strip made of buttons had a hovered tab turn dark green and the tab component carried
three background: transparent declarations purely to undo it.
Variants here are custom properties rather than rules, so there is one hover rule for all five and
nothing depends on source order. Loading is aria-busy="true", which
is both the hook the styling reads and the thing a screen reader announces.
Variants
Small
Disabled
Loading
Full width
On every ground, unchanged
.on-paper
.on-green
.on-ink
.on-brine
default · hover · focus · disabled · readonly · invalid · compact
Styled by element rather than by class, and the distinction from the button is variance, not element
type: a <button> is a tab, a chip, a link and an action, while
an <input type="text"> is one thing. Invalid keys off
aria-invalid for the same reason loading keys off
aria-busy: a field cannot be visibly red and silently fine.
Default and hover
Invalid, disabled, readonly
Compact, for a dense row
Checkbox, radio, search bar
comfortable · dense · sticky · clickable · selected · empty · too much data
Too much data is a designed state here, not an overflow. The practice console lists a portfolio and the register lists every tool a trade owns; both are unbounded, and a table that shows fewer rows than it holds has to say so with the real total. Silent truncation reads as "this is everything", which is the one thing it is not. The empty case keeps the header so the columns still say what is missing.
Comfortable, with a clickable row and a selected row
| Client | Signal | Uninsured |
|---|---|---|
| Ridgeline Roofing Ltd | Payroll since March | £24,100 |
| Calder Electrical Ltd | Sum insured set in 2018 | £4,292 |
| Selected row | Subcontractor payments | £11,880 |
| Hollow Lane Joinery | Plant on hire | £7,400 |
Dense, with a sticky header inside a capped scroll
| Client | Signal | Uninsured |
|---|---|---|
| Client 1 Ltd | Tools bought since renewal | £1,200 |
| Client 2 Ltd | Tools bought since renewal | £1,517 |
| Client 3 Ltd | Tools bought since renewal | £1,834 |
| Client 4 Ltd | Tools bought since renewal | £2,151 |
| Client 5 Ltd | Tools bought since renewal | £2,468 |
| Client 6 Ltd | Tools bought since renewal | £2,785 |
| Client 7 Ltd | Tools bought since renewal | £3,102 |
| Client 8 Ltd | Tools bought since renewal | £3,419 |
| Client 9 Ltd | Tools bought since renewal | £3,736 |
| Client 10 Ltd | Tools bought since renewal | £4,053 |
| Client 11 Ltd | Tools bought since renewal | £4,370 |
| Client 12 Ltd | Tools bought since renewal | £4,687 |
| Client 13 Ltd | Tools bought since renewal | £5,004 |
| Client 14 Ltd | Tools bought since renewal | £5,321 |
Stacked below 560px, for a table that is the content rather than the detail
| Item | Bought | Cost | Replacement |
|---|---|---|---|
| Power tools from Screwfix Direct Cardiff Power tools · no serial number |
14 Feb 2026 | £1,240 | £1,271 |
| Makita 18v LXT 6-piece kit Power tools · serial MAK-8841207 |
3 Nov 2025 | £890 | £925 |
Too much data
| Client | Signal | Uninsured |
|---|---|---|
| Ridgeline Roofing Ltd | Payroll since March | £24,100 |
| Calder Electrical Ltd | Sum insured set in 2018 | £4,292 |
Empty
| Client | Signal | Uninsured |
|---|---|---|
|
No clients need attention this week
The header stays so the columns still say what would have been here. Nothing has changed in any connected ledger since Friday. | ||
default · quiet · inset · raised · accent · interactive · selected · toned
A card is a bordered block at 14px; a panel is the container a card sits inside, at 18px, with a
header row. Never a shadow. .card-interactive is only for a card
that is genuinely a link or a button: a hover state on something nobody can click is a promise the
page does not keep.
.card
.card-quiet, sitting on the ground
.card-inset, recessed
.card-raised, white on cream
.card-accent, the one that carries the answer
.card-interactive, which really is clickable
.card-selected
.card-urgent
.card-attention
The body, at 24px, holding whatever the panel is for.
neutral · good · attention · urgent · info · outline
Five tones and no more, because the point of a tone is that a reader learns what it means. The audit
page has domain vocabulary of its own (a statutory basis, a contractual one, a bare exposure, three
confidence levels) and maps those onto these five in
audit-page.ts. Those names used to be tones in the shared
stylesheet, which is how a design system ends up knowing what compulsory Employers' Liability is.
Status tones are tuned for the paper and ink grounds only.
default · hover · selected · focus · disabled
Live: click them. Selection is aria-selected rather than a class, so
a tab cannot look selected while announcing that it is not. The strip scrolls sideways rather than
wrapping, because a wrapped strip changes height when the selection moves and shifts everything
under it.
The first panel. Hover the strip: nothing turns green, because no rule reaches into a bare button.
The second panel.
The third panel.
closed · open · hover · focus
A <details>, so it works with no client script at all. That is
not a preference: the register page ships no JavaScript on purpose, because a sum insured should
never be computed in a browser, and it is the page with the most folded content in the product.
The caret is drawn in CSS so it inherits the text colour on every ground and rotates on open. The browser's own triangle is removed rather than restyled, because Safari and Chrome disagree about what it is.
Open by default, to show the state.
info · attention · urgent · good
A notice is part of the document: it stays, it can be long, and it is what the engine uses to say that a file did not state which way the money went. A toast is transient feedback about a click. This product is server-rendered and almost every message it has is a fact about the result, which is why notices are everywhere here and toasts are rare. A finding is the same tokens with no fill, for a list where tinting every row would turn the tone into wallpaper by about the third one.
Nothing in this file says which way the money went, so every row has been read as money out.
The website and the filed record disagree
Filed as a builder, the site reads as a roofer. Confirming this changes four cover lines.
Employers' liability is required by law from March and no premium appears in this ledger.
Read without assumptions. Every column mapped on its own header.
The disclaimer, which travels with every rendered audit and looks the same on every surface including inside a partner's frame.
info · good · attention · urgent
Neither may be the only place something is said. A lot of this product gets shown on a projector, and
a message that appears for four seconds in a corner is a message half the room missed. The modal is a
<dialog>, so focus trapping, Escape and the backdrop are the
browser's job. The toast fades and does not move: a toast that slides reflows the corner of the
screen while a presenter is pointing at it.
determinate · indeterminate · under · met · over
A progress bar shows how far along a task is: it only goes up, and full is good. A meter shows a value
against a target: it can sit anywhere, and full is not necessarily good. This product needs the second
far more, because its central argument is a shortfall. Both are divs with an inline width, because
<progress> and <meter>
cannot be styled consistently across browsers. The width is data, not design, and
aria-valuenow carries the same number to a reader.
Progress
Meter
Figure
pending · ok · failed
A sequence of attempted steps, each with an outcome and how long it took. Watch it fill in: the rows are already on the page at 30% opacity and the script clears them one at a time, so a dead script leaves a faint list rather than an empty box. This product had a hide-until-JavaScript reveal fail to fire for four of seven sections, which would have shipped a homepage that rendered blank below the hero, and that is why this one is visible-by-default.
user · assistant · tool call
An exchange with the tool calls shown. The tool block is the reason it is a component at all: most chat styling is a bubble, and the thing that matters here is that a reader can see the tool name and the payload, because the claim the agent page makes is that a real tool returned a real answer.
What insurance does Ridgeline Roofing actually need?
Reading the filed record and the site.
default · hover · selected · disabled · off
The chip is the second component the button trap bit, after the tab: it arrived painted solid green
and had to be repainted back. The brand chip's off state is the one
that matters, because three of the five ways into this product are not built and each has to say so
on its own face.
{
"product": "employers_liability",
"basis": "legal",
"triggered_by": ["payroll_seen_2026-03"]
}
open · confirmed
Domain-shaped rather than generic, and that is the right call: this is a design system for one product, and turning an extracted fact into a declared one is the move the whole engine is built around. The "changes" line is not decoration. A confirm card that does not say what it affects is asking somebody to answer a question with no stated consequence.
two column · stacked
The most-used component in the product, because the engine's discipline is that every fact carries its source and its confidence. Two variants, and the second exists because of a real failure: with a long label the two-column form wraps every value into a column two words wide. Below 560px the first becomes the second on its own.
wrap · section · stack · row · spread · grid · prose
Six containers, and between them they replaced almost every inline
style= on two surfaces. Spacing is never set on a child; it is set by
whichever container the child sits in. That is why a new screen needs no new CSS: you are choosing
containers, not writing margins. .prose exists because the same
max-width:78ch had been written eleven times.
.grid-3
auto-fit
no media query per surface
.prose caps a paragraph at 70 characters, which is the difference between body copy and a wall. A line that runs the full width of a 1080px page loses the reader at the end of every line, and every surface here had been solving that with an inline style of its own.
empty · loading · error · too much data
The deliverable, not an afterthought. All four are the same three things: a short statement of what is true, one line of why, and at most one action. Never an illustration, never a heading that says "No results", and never a dead end. An empty state with no action is a page telling somebody it cannot help them.
Empty
Connect an accounting feed and the tools already in the ledger arrive with dates and prices. Or add the first one by hand.
Every check the sources could answer came back clear. That is not the same as being well covered: a ledger can show what is held and never the absence of anything.
Loading
Error
"accounts.csv" is named as a CSV but is most likely a spreadsheet or a PDF that was renamed rather than exported.
Companies House returned nothing for "Ridgline Rooofing". Check the spelling, or search by company number.
Too much data
Item 1: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 2: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 3: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 4: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 5: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 6: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 7: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 8: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 9: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 10: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 11: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 12: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 13: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 14: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 15: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 16: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 17: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Item 18: a row in a list long enough that the box scrolls and the bottom edge fades, so the cut is visible rather than implied.
Each of these is a test in test/design-system.test.mjs, not a convention.
No hex anywhere in src/ except ui/tokens.ts, and partner/widget.ts, which renders in a partner's brand on a partner's page and cannot use ours.
Every custom property read anywhere in the product resolves to something a ground or a scale actually sets. This is the check that catches a rename that missed a surface, which otherwise ships as a silently unstyled page.
Every ground's text, muted tier, accent pair and status tints are measured against 4.5 on every run, with an implementation independent of the one this page prints from.
Nothing in the shared stylesheet may put a background or a colour on button as an element.
The register's edit grid, the source log rows and the claim transcript were in the shared stylesheet. Two became components; the rest went back to their surface.
Every component in the library must appear here. Adding one without a specimen fails the build, which is what stops this page becoming a document about the product it used to be.