Goldie Blocks
Coding Rules

Tailwind

Tailwind-specific conventions for styling and component composition

Class ordering

Follow the Prettier Tailwind plugin order. Within a class list, the order is:

  1. Layout (flex, grid, block, hidden)
  2. Position (relative, absolute, fixed, sticky, top-*, z-*)
  3. Size (w-*, h-*, min-*, max-*)
  4. Spacing (m-*, p-*, gap-*, space-*)
  5. Typography (text-*, font-*, leading-*, tracking-*, uppercase)
  6. Colors and background (bg-*, text-* color, border-* color)
  7. Border (border, border-*, rounded-*)
  8. Shadow and ring (shadow-*, ring-*)
  9. Opacity and visibility (opacity-*, invisible)
  10. Transitions and transforms (transition-*, duration-*, translate-*, scale-*)
  11. State variants (hover:, focus:, active:, disabled:) follow the same sub-order
  12. 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:

  • ALLOWED in 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 with bun 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:hidden to 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's stack-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.

  1. 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.
  2. 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.
  3. When an edge varies by state, put every state in one expression — contentClassName={open ? "pb-4" : "pb-2"} in one place, not a py-1 on the content plus a pb-4 on the row.
  4. 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 getBoundingClientRect on 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.

GroupTokens
Neutralsground #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 accentaccent-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).

TokenBound toJob
--color-goldie-apronaccent-50Goldie's panel and the G disc
--color-goldieaccent-700Goldie's name
--color-wash-rest / -hover / -selectedground (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 / -selectedline / ink-ghost / accent-400A card's spine by state
--color-blackinkTailwind black utilities print in ink

Editor rounding

TokenValueUse
--radius-subtle2pxBlocks, content cards, heading/text blocks
--radius-small4pxButtons, add-block controls, menu buttons
--radius-medium6pxModule/editor surfaces
--folio-round12pxA 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.

ClassFontSize / leadingUse
type-captionDM Sans 40011 / 16Kickers, chips, counts, the smallest chrome
type-labelDM Sans 40012 / 16Chrome labels, file notes, menu subtext
type-smallDM Sans 40014 / 22Menu items, captions, secondary text
type-bodyDM Sans 40016 / 24Default UI body text, composers, links
type-readingLora 40018 / 35Long-form legal text
type-headingDM Sans 70022 / 34Section headings
type-displayDM Sans 70032 / 50Page titles, hero headings

type-link folded into type-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.

On this page