Tailwind
Tailwind-specific conventions for styling and component composition
Class ordering
Follow the Prettier Tailwind plugin order. Within a class list, the order is:
- Layout (
flex,grid,block,hidden) - Position (
relative,absolute,fixed,sticky,top-*,z-*) - Size (
w-*,h-*,min-*,max-*) - Spacing (
m-*,p-*,gap-*,space-*) - Typography (
text-*,font-*,leading-*,tracking-*,uppercase) - Colors and background (
bg-*,text-*color,border-*color) - Border (
border,border-*,rounded-*) - Shadow and ring (
shadow-*,ring-*) - Opacity and visibility (
opacity-*,invisible) - Transitions and transforms (
transition-*,duration-*,translate-*,scale-*) - State variants (
hover:,focus:,active:,disabled:) follow the same sub-order - Responsive variants (
sm:,lg:) come last within each group
Design tokens in use
This codebase uses the Goldie colour system (see Token
reference) and the Counsel typography. Prefer its
primitives, read as bg-[var(--color-ink)] and the like, over raw palette
utilities.
The document workspace uses restrained legal-document geometry. Avoid
rounded-xl, rounded-2xl, rounded-3xl, and large pill-like card radii
inside the module/block editor.
Raw palette values are checked
A raw Tailwind palette class (stone-*, amber-*, blue-*, emerald-*
and the rest of Tailwind's built-in ramps) or a hex literal such as
bg-[#…] in shipped src/ is outside the token list, so it will not follow
the next token change. bun run colour:tokens
(scripts/check-colour-tokens.mjs) blocks a new one (owner ruling
2026-10-07, GB-286) and runs in the verify:changed gates for any src/
change. Stories, tests and src/shared/ui/upstream/ are exempt.
Two lists hold the exceptions, each entry with its reason, in the manner of the glossary's grandfathered breaches:
ALLOWEDin the script: values that genuinely have no token (the tool lifecycle ramp, the badge's pinned ink).scripts/colour-tokens.baseline.json: files still carrying known raw values, by count. It may only shrink. Sweep a value only onto a token that computes to the same colour, or after looking at it in the running app; then lower the count withbun run colour:tokens --write-baseline.
In plain terms: a hand-written colour is like a clause pasted in rather than cross-referenced. The check refuses new ones and keeps a register of the old ones, with why each is still there.
Typography tokens
The Counsel design system defines seven type styles (the ladder under Type styles). Use the matching utility rather than building font/size/leading combinations by hand.
Two type faces for the interface, and a mono for source (owner rulings 2026-09-16). Ask of any element: is it the document, or is it the app? Those are the two answers:
- Lora (
font-counsel-reading) — the document: clauses, field names and answers. If it is on the paper, it is Lora. - DM Sans (
font-counsel-sans) — everything the app says or labels: chat, menus, buttons, titles, block and module names, insertion rows, file notes. Chrome labels tell themselves apart from the paper by face (sans against serif), and from speech by size and ink (12px, muted). - System monospace (
font-counsel-mono) — authored source only: the About facet's declarations, a source diff, a computed field's= count(…), the developer inspectors. Code shown as code, never a voice.And the clerk's notes— the transcript's file notes ("Proposed · 1 change") and a block's notice row sit inside the conversation, so they speak its face (owner ruling 2026-09-16); small and muted is what sets them apart.
In plain terms: the document reads like a document, everything else reads like the app, and the only typewriter text is actual code.
Three faces, with the mono as the "label on the file" voice — folded
the same day: once the paper set in Lora, serif-against-sans already said
"this is not the document", and the mono was a third signal doing a job the
serif had taken. Libre Baskerville for brand display, Geist as the shadcn
default, and a system-sans fallback — folded away first: a second serif
fought Lora, and two extra sans faces were habit, not meaning. The generic
font-sans, font-serif and font-mono utilities alias the three tokens,
so a fourth family cannot appear by accident.
The /story marketing route keeps its
typewriter kickers under its own plan (archived
2026-08-15-story-route-animated-ad, D3); it is an advertisement, not the
product surface this rule governs.
Shadow conventions
Use these named shadow values, not arbitrary shadow-[...] unless a specific design value is needed:
- Cards and panels:
shadow-[0_10px_28px_rgba(51,36,13,0.06)] - Elevated surfaces (composer, dropdowns):
shadow-[0_10px_30px_rgba(15,23,42,0.06)] - Module list items:
shadow-[0_8px_20px_rgba(15,23,42,0.04)]
Responsive layout
- Mobile-first: base styles are mobile,
lg:breakpoint switches to desktop two-column layout. - The chat layout uses
lg:grid lg:grid-cols-[minmax(0,1.08fr)_minmax(380px,0.92fr)]for the split view. - Use
hidden lg:flex/lg:hiddento show/hide panes at the desktop breakpoint.
Component extraction
Extract a Tailwind class group into a component (not a CSS class) when:
- The same visual treatment appears in three or more places with identical meaning.
- The group carries semantic meaning that would be lost if the classes were inlined (e.g. a status badge).
Do not use @apply to extract utilities into CSS. Keep styles in TSX via cn() or cva().
cn() and cva()
- Use
cn()(from#/lib/utils) for conditional class merging at the call site. - Use
cva()for variants defined on a component (e.g.buttonVariants). Export the variants function alongside the component so callers can reuse it for link-styled-as-button patterns. - When styling varies along two axes (kind × state), state it as a
cva()variant table, not a nested ternary chain. A conditional ladder hides the case grid; a table shows every cell, can be extended by adding a row (never by editing the ladder), and can be pinned cell-by-cell in a node test. The exemplar is the Stack'sstack-palette.ts(see The Stack); a second component hand-rolling the same class of decision should read or extend the existing table rather than start its own — see the construction principles. - Do not use template literals to build class strings —
cn()handles merging and deduplication correctly.
Spacing and hit targets
One edge, one owner. If two elements can both declare spacing that reaches
the same edge, they will sum — and no gate will tell you. tsc and Biome
cannot see it, and neither can a screenshot: the result looks like a design
choice.
In plain terms: decide once who is responsible for each gap, the way one clause and not two should govern a defined term.
- A component owns its inner padding; the caller owns the space around it. The component decides what its contents need; where it sits is not its business.
- Never put margin on a component's outermost element. It collides with whatever the parent also declares, and makes the component unusable anywhere that margin is wrong.
- When an edge varies by state, put every state in one expression —
contentClassName={open ? "pb-4" : "pb-2"}in one place, not apy-1on the content plus apb-4on the row. - Measure the edge; do not infer it. A padding chain is easy to reason
about wrongly — an element three levels up may already carry the row-edge
inset you are about to add again. Read
getBoundingClientRecton both sides of the gap and state the number.
Painted size and hit target are two decisions
The paint answers to the row's visual density; the target answers to whether a finger can land on it. They are allowed to differ, and on a small control they should — 24px is the practical floor for a target whatever the painted chip measures. Expand with a pseudo-element so no visible pixel moves:
// 20px chip, 36x28 target — grown into the dead margin on its right,
// not into the neighbouring control's target on its left.
"relative before:absolute before:-inset-y-1 before:-left-1 before:-right-3 before:content-['']"Grow a target into space nothing else claims. Check both neighbours first: a
symmetric -inset-x-* that overlaps an adjacent control's target silently
takes its clicks, because the later-painted element wins.
The expansion must sit on the element that receives the click. A
before:-inset-* on a wrapper paints the identical rectangle and catches
nothing — it measures correct, looks correct, and is dead. After adding one,
prove it with document.elementFromPoint just outside the painted edge, not
with the computed style.
Both failures cost time on 2026-08-23: a card's prose carried py-1 while its
row carried pb-4/pb-2, summing to 12px on an edge meant to be 8; and a
chevron's 36×28 target was written onto its wrapper span rather than its
button, so it measured 36×28 and clicked nothing.
Opacity syntax
Always use the slash syntax for color opacity: bg-stone-900/8, text-stone-950/60. Do not use bg-opacity-* or text-opacity-* — those are deprecated.
Named Tailwind groups
Use named groups when nesting matters and you need parent state to cascade:
<div className="group/card hover:shadow-md">
<span className="opacity-0 group-hover/card:opacity-100">…</span>
</div>Use descriptive names: group/button, group/message, group/card. Do not leave unnamed group when multiple groups could nest.
Token reference
Values only; the rules that govern them are above. src/app/app.css is the
definitive source — read it rather than this table, which can drift from it.
The Goldie colour system
Twenty primitives. Reach for these first; the Storybook page Design Tokens / Counsel Tokens shows every swatch.
| Group | Tokens |
|---|---|
| Neutrals | ground #f8f6f1, surface #ffffff, chrome #f1ede4, chrome-strong #e6e1d5, line #e8e2d2, line-soft #f0ebdd, line-dashed #e0d9c4, ink #2b2823, ink-muted #6f6a5e, ink-faint #9a958a, ink-ghost #c9c2ae |
| Amber, the one accent | accent-50 #fcf7e8, accent-100 #f3e4b8, accent-200 #e0d2a6, accent-600 #9a6b0f (fills, rings, spines), accent-700 #7a540c (words); numbered light to dark |
| Change (diff) | add-700 #3c7a3c, add-50 #eef5ec, del-700 #b23a3a, del-50 #fbe7e7 |
Every name above takes the --color- prefix.
Named roles
A role keeps its own name only when its job could need a different value from its primitive. Add one only for that reason; a second name for the same job is the drift this list replaced (2026-09-25: 233 tokens folded to 123).
| Token | Bound to | Job |
|---|---|---|
--color-goldie-apron | accent-50 | Goldie's panel and the G disc |
--color-goldie | accent-700 | Goldie's name |
--color-wash-rest / -hover / -selected | ground (all three) | A folio card's wash; hover and selection show on border and spine, never the fill (owner, 2026-09-28) |
--color-spine-rest / -hover / -selected | line / ink-ghost / accent-400 | A card's spine by state |
--color-black | ink | Tailwind black utilities print in ink |
Editor rounding
| Token | Value | Use |
|---|---|---|
--radius-subtle | 2px | Blocks, content cards, heading/text blocks |
--radius-small | 4px | Buttons, add-block controls, menu buttons |
--radius-medium | 6px | Module/editor surfaces |
--folio-round | 12px | A card's outer (right-hand) corners |
Type styles
Seven rungs, each a Tailwind utility (@utility in src/app/app.css). A rung
sets face, size, line and weight only. Ink is a separate decision made beside
it (text-[var(--color-ink-muted)]), and a heavier weight or a tighter line
composes over it (font-semibold, leading-5): a single-property utility on
the same element wins. Raw sizes (text-[13px], text-sm) do not appear in
app code; the nearest rung does.
| Class | Font | Size / leading | Use |
|---|---|---|---|
type-caption | DM Sans 400 | 11 / 16 | Kickers, chips, counts, the smallest chrome |
type-label | DM Sans 400 | 12 / 16 | Chrome labels, file notes, menu subtext |
type-small | DM Sans 400 | 14 / 22 | Menu items, captions, secondary text |
type-body | DM Sans 400 | 16 / 24 | Default UI body text, composers, links |
type-reading | Lora 400 | 18 / 35 | Long-form legal text |
type-heading | DM Sans 700 | 22 / 34 | Section headings |
type-display | DM Sans 700 | 32 / 50 | Page titles, hero headings |
folded into type-linktype-body plus a colour (2026-10-04): it was
type-body in the accent, and nothing used it.
The module/block editor surface sets its field names and answers in the reading face at 14px on an 18px line, and its chrome labels in the sans at 12px, muted.