# tailthemes — full agent reference

Premium Tailwind themes — AI-produced, human-grade — readable, downloadable, and extendable by LLMs

Each theme is a self-describing directory with a manifest, agent instructions, pages, section components, and one theme.css.
Components are independent React sections composed into pure pages, with shared primitives isolated under components/ui.
All visual decisions use semantic CSS variables; components consume only semantic Tailwind utilities.
Tailwind v4's @theme inline bridge maps those variables to utilities such as bg-background and text-foreground.
The manifest publishes matching light and dark token tables so agents can inspect and rebrand themes mechanically.

## Atrium

- Slug: atrium
- Category: dashboard
- Price: $39
- Description: A Tailwind CRM dashboard template that works: drag a deal between stages and every total on the page recomputes, run the three-step wizard and the record is on the board when you get there, edit or delete an account in the row it sits in. Nine pages ship: an overview that leads with the quarter's one number, a drag-and-drop pipeline board, a dense accounts table with working create, edit and delete plus the record it opens into, a forecast split by rep against the quarter's target, a stage funnel, a month calendar of close dates, a grouped activity log the console writes to as you work, two three-step create wizards, and a living /components styleguide where every specimen is the working component. Sixteen independent sections and nine primitives build them. One file declares the spine — twelve deals, twelve accounts, ten contacts — and every figure in the theme is derived from it, so no two panels can disagree. Four real recharts, coloured entirely through CSS variables, each with a table twin so no value is trapped behind a hover. Density is the craft: a 13px console tier inside a 1.125 type ladder, Inter Tight and Red Hat Mono shipped as self-hosted woff2, tabular figures in every numeric column, and status that always pairs a colour with an icon and a word. Panels share hairlines rather than floating apart, so the console reads as one sheet. A quiet near-white admin ground and indigo-violet in light, a lifted graphite in dark, a light/dark toggle in the top bar, and every pair computed to WCAG AA. Built for Tailwind v4, Next.js 15 and React 19; every colour lives in one theme.css.
- Pages: /, /pipeline, /accounts, /forecast, /calendar, /activity, /new-deal, /new-account, /components
- Tags: tailwind crm dashboard template, nextjs admin dashboard template, crm, kanban, charts, tailwind-v4
- Site: https://tailthemes.com/themes/atrium
- Manifest: https://tailthemes.com/api/themes/atrium
- Registry: https://tailthemes.com/r/atrium
- Preview: https://tailthemes.com/preview/atrium

### Atrium AGENTS.md

# Atrium — rules for agents

Atrium is a CRM console theme (Tailwind v4 / Next.js 15 / React 19). The demo
content belongs to a fictional CRM, **Marlow**, as used by a fictional company,
**Torrey Cold Chain** — cold-chain monitoring hardware and software sold to food
and pharma distributors, five reps, twelve open deals.

**It works.** Since 2.2.0 the board drags, the wizard saves, accounts and
contacts have real CRUD, the toolbar filters, the calendar pages and the top bar
flips the mode — all against one fixture file, so every total on screen moves
together. Read invariants 8 and 13 before touching anything: they are the two
that reversed, and they are why the rest of the file reads the way it does.

**2.2.1 is a render pass, not a feature.** Four things the owner could see and
the source could not: the rail stopped collapsing to unlabelled icons, the rail
tone got the step back that 2.2.0's lighter ground had eaten, the wizard stopped
printing its page title twice, and the empty stage in `stage-breakdown` stopped
rendering as nothing. Every one of them is in the table below.

**2.2.2 fixes two things that were wrong on all nine routes at once**, which is
what makes them worth a version: the frame measured itself against the wrong
box, and the heading band had no alignment a reader could see. Both were
single-file defects with nine-page symptoms — the shell and `page-heading` are
rendered by every route — and both are invariants now (20 and 21).

Read this before changing anything. The four workflows in `.claude/skills/`
(`rebrand`, `add-section`, `add-page`, `check-quality`) are the sanctioned way to
extend Atrium; they are written against the real file names and token names
below.

---

## What 2.2.0, 2.2.1 and 2.2.2 reversed

Every row here is a decision an earlier version argued for in this file, and a
later one overturned on the owner's critique. They are listed together because a
future session will otherwise read the old reasoning somewhere and reinstate it.
The first two rows are 2.2.2, the next five are 2.2.1, the rest are 2.2.0.

| Was | Is | Why it flipped |
|---|---|---|
| the shell at `min-h-screen`, the rail at `h-screen` (`app-shell`) | `.console-frame` / `.console-column` / `.console-drawer`, all `calc(100dvh - var(--tt-chrome-h, 0px))` | *"the tailthemes bar makes the sidebar height wack"*: 100dvh is the window, and the console does not always own the window's top edge. Under the preview's 32px banner the frame overflowed by exactly that, permanently, and the rail's foot sat 32px below the viewport on every route. Nothing declares the variable in a buyer's app, so the 0px fallback is the shipped behaviour |
| the heading band as two stacks with `items-end justify-between` (`page-heading`) | one two-column grid, `items-baseline`, two named rows and a closing hairline | *"vertical content alignment is wack for all pages"*: bottom-flush is not an alignment a reader can see. The h1 landed mid-way up the right-hand block and the hero figure's baseline answered to nothing. Now the title and the figure share one baseline, the lede and the facts share the next |
| the rail collapsed to a 56px icon rail from lg to 2xl (`app-shell` header) | labelled at every width it appears — 208px from lg, 240px at 2xl | *"the sidebar reads unfinished"*: the measured cause (a 240px rail clipping the board's fifth column at 1280) expired inside 2.2.0, when the columns became `xl:flex-1 xl:min-w-0` and started distributing width instead of clipping. What the collapse still cost was eight unlabelled grey icons on the console's most-photographed width |
| `--secondary` at L 0.922, "one step down rather than four" (theme.css) | L 0.900 | *"improve sidebar tone colour"*: 0.045 L off a 0.967 ground is not a plane, it is an unpainted one — and the active nav row's tint separated from it at 1.09:1 |
| the wizard's `PanelHeader`, titled "New deal" / "New account" | no panel header; the headline field's band is the header, and it carries the meta and the Draft badge | the panel printed the page's own h1 again seventy pixels under it. Invariant 18 already refuses this for numbers; it is the same defect in words |
| the wizard's headline field at `w-full` — 1,192px of `type-d4` | a measure: `max-w-md` for a name, `max-w-48` for a value, with the caption under it | an oversized *input* reads as a mistake unless it is sized for what goes in it. The oversized moment is the type step, never the field's span |
| `stage-breakdown` plotting all five stages (invariant 5, 2.2.0) | …and giving the empty one a track, a `minPointSize` and a `$0` | right call, unfinished: recharts drops any bar whose width computes to zero and takes the row's background and label with it, so Legal review rendered as a stage name beside nothing |
| "Interactivity without JavaScript wherever it is possible" (8) | client JS for the behaviours that ARE the product; the DOM for content state | a board that cannot move a card is a photograph of a board |
| "Reordering belongs to the app a buyer wires up" (`pipeline-board`) | native HTML5 drag and drop plus a keyboard path | the first thing anyone tries on a kanban |
| the twelve deals declared in eight components, reconciled by hand (13) | one fixture file, every total a selector | hand reconciliation holds only while nothing moves |
| the deep-paper ground at L 0.904 (theme.css) | a near-white admin ground at L 0.967; separation moved inside the card | *"dashboard contrast bg is too strong"* |
| the near-black dark ground at L 0.150 | L 0.178, with the whole neutral stack lifted to match | the dark half of the same complaint |
| the KPI strip as `gap-px` on `bg-border` (17) | one `bg-card` frame with `divide-x` inside | *"stats separation"*: the gap trick only worked on a loud ground |
| the drafting-paper masthead, "the console's front door" (14) | deleted, asset and all | *"remove the content top annoying background"* |
| "a console shows records, not photographs" (14) | portraits for the five Marlow seats; initials for everyone else | a CRM is a list of named humans; initials everywhere is a placeholder |
| bare native `<select>`s | `ui/control`'s Select, drawing its own lucide caret | *"select carets"*: Chrome draws none inside a bordered box |
| a segmented control whose selected state was 1.06:1 and a font weight | a bordered inset track, the selected segment raised onto `--card`, per-option counts | *"filter toggle"* |
| a toolbar whose scope and owner changed nothing | both are console state; the surfaces render what they leave | a control that states "3" and shows twelve is a lie the affordance made legible |
| account names linking to the page they are on, a "sanctioned exception" (16) | buttons that open the record below | the exception was a description of a bug |
| stage odds published as four values (13) | five, with Legal review at 70% | a stage a card can be dragged into needs odds |
| one `style` attribute, for an asset URL (2) | `style` carries a length computed from data, never a colour | Tailwind cannot generate a class from a runtime number |
| "only four files carry `'use client'`" (5) | every file does; the rules that mattered moved to 8 | the provider has to be above the sections that read it |

---

## File map

```
themes/atrium/
  theme.json                     manifest — must stay truthful (see "Manifest" below)
  README.md                      human quick start
  AGENTS.md                      this file
  .claude/skills/                rebrand · add-section · add-page · check-quality
  src/
    theme.css                    EVERY design decision — tokens, both ladders, the class layer
    fonts.ts                     next/font/local wiring; exports fontClassName (load-bearing)
    fonts/                       InterTight-Variable.woff2 · RedHatMono-Variable.woff2 · both OFL files
    assets/
      avatar-*.jpg               the five Marlow seats, generated portraits (invariant 14)
      README.md                  the shoot sheet, the prompts, how to regenerate one
    app/                         EVERY page is 'use client' and opens with <ConsoleProvider>
      home.tsx                   / — heading → KPIs → charts → closing list + activity
      pipeline.tsx               /pipeline — heading → filters → the board → stages + activity
      accounts.tsx               /accounts — heading → filters → table → record → coverage
      forecast.tsx               /forecast — heading → by-rep table → bookings + funnel → closing
      calendar.tsx               /calendar — heading → the month grid → closing list
      activity.tsx               /activity — heading → filters → the grouped log
      new-deal.tsx               /new-deal — heading → the deal wizard
      new-account.tsx            /new-account — heading → the account wizard
      components.tsx             /components — the living styleguide (not linked from the console)
    components/                  ONE FILE PER SECTION, no cross-section imports
      app-shell.tsx              labelled rail + top bar + status bar; takes the page as children
      page-heading.tsx           breadcrumb, h1, lede, and the route's figures (prop: view)
      filter-bar.tsx             segmented scope + selects + actions (prop: variant)
      kpi-row.tsx                3 stat tiles, one framed sheet, recharts sparklines
      bookings-chart.tsx         columns + dashed target line
      stage-breakdown.tsx        horizontal bars by stage
      funnel-chart.tsx           the stage funnel — exports StageFunnel, not FunnelChart
      deal-table.tsx             what closes this month
      forecast-table.tsx         the quarter by rep, with a tfoot totals row
      activity-timeline.tsx      calls, moves, flags (props: variant, bleed)
      pipeline-board.tsx         five-stage board with drag and drop
      accounts-table.tsx         the dense table, plus edit and delete
      record-panel.tsx           the open account, plus contact CRUD
      record-form.tsx            the three-step create wizard (props: variant, bleed)
      calendar-view.tsx          the month grid (prop: bleed)
      coverage-map.tsx           the authored region cartogram
      ui/                        shared primitives — the ONLY cross-section imports
        button.tsx               Button, ButtonLink, buttonClasses
        badge.tsx                Badge, StatusBadge, Eyebrow, statusInk
        panel.tsx                Panel, PanelHeader, PanelFooter
        avatar.tsx               Avatar, OwnerCell — portraits for seats, initials for contacts
        icons.tsx                MarlowMark — the brand glyph, and the only hand-drawn SVG
        chart.tsx                chart chrome: series vars, axis styling, tooltip, table twin
        control.tsx              Field, TextField, TextArea, Select, SegmentedControl
        console-state.tsx        THE SPINE — fixtures, reducer, selectors, formatters
        mode-toggle.tsx          the top bar's light/dark switch
```

**Every section carries `'use client'`, and so does every page.** The three
primitives that hold state or call hooks — `console-state`, `mode-toggle`,
`chart` — carry it too. The six pure primitives (`button`, `badge`, `panel`,
`avatar`, `icons`, `control`) deliberately do **not**: they take props and
return markup, so they work in either graph and a buyer can use them from a
server component. That is 2.2.0's one structural change — see invariant 8.

---

## Invariants

**1. All design lives in `src/theme.css`.**
No other file may introduce a colour, a radius, a font family or a font size that
is not a Tailwind step. If a change needs a new visual value it becomes a token
in `theme.css` first — in both the light block (`:root, .tt-atrium`) and the dark
block (`.dark, .tt-atrium.dark`) — and the matching entry is added to `tokens`
and `tokensDark` in `theme.json`. The consistency gate fails if the two drift.

**2. Semantic utilities first; `var()` for the tokens the bridge does not map.
Never an alpha modifier on a colour.**
The marketplace's `@theme inline` bridge maps the core fourteen colours, the
radius and the two font families to utilities: use `bg-card`, `text-foreground`,
`text-muted-foreground`, `bg-accent`, `text-accent-foreground`, `border-border`,
`outline-ring`, `rounded-themed`, `font-mono`.

Atrium adds tokens the bridge does not know about. Those are used as arbitrary
values whose content is a variable — still the token system, because the value
lives in `theme.css` and nowhere else:

| Token | Written as | Used for |
|---|---|---|
| `--primary-hover` / `--primary-active` | `hover:bg-[var(--primary-hover)]` | the filled button's state ramp |
| `--surface-tint` | `bg-[var(--surface-tint)]` | active nav row, selected table row, tint badge |
| `--control-border` | `border-[var(--control-border)]` | every input, select and hover-bordered card |
| `--positive` / `--warning` / `--critical` + foregrounds | `bg-[var(--positive)]` | `StatusBadge` only |
| `--chart-1` | `var(--chart-1)` | recharts props |
| `--text-*` | `text-[length:var(--text-caption)]` | the one chart-tick exception |

**Alpha-modified colour states are banned** (`hover:bg-primary/90`,
`border-muted-foreground/70`, `active:bg-primary/80`). Alpha moves L, C and H at
once and the result is not a token — it is a composite that no contrast table
covers. 1.1.0 shipped four of them and this file used to *instruct* them; 2.0.0
replaced every one with a named step. Alpha stays legal only for genuine
translucency: the masthead's `bg-foreground/12` mask layer, which is opacity on
an ink, not a colour state.

**Bare `shadow-*` is banned too.** The theme has exactly one shadow, the chart
tooltip, and it is the `.shadow-panel` class in `theme.css`, built from
`--shadow-color`.

Forbidden anywhere in `src/`: hex values, `bg-[#…]`, `text-white`, `text-black`,
every palette class (`bg-blue-600`, `text-slate-500`, …), bare `text-*` size
utilities, and inline `style` attributes. The publish gate lints for this.

**`style` carries geometry that is a datum, and nothing else.** 2.1.0 allowed
exactly one `style` attribute (the masthead mask's asset URL). That asset is
gone; the rule that replaced it is narrower in kind and wider in count: a
`style` attribute may carry a **length computed from data** — the KPI meter's
`width: 69%`, which is `weighted ÷ target` and therefore cannot be a Tailwind
class, since Tailwind cannot generate a class from a runtime number. Everything
else stays out. **A colour in a `style` attribute is a bug**, here and anywhere
else, and so is a length that is a design decision rather than a measurement:
padding, gaps, radii and type sizes are tokens.

**3. Contrast rules that come out of Atrium's specific palette.**
- Every pair is computed. The verified table is in
  `.claude/skills/check-quality/SKILL.md`; re-run it after **any** token change.
- `text-primary` is the link and inline-emphasis colour on every light surface
  (5.28:1 at worst) and every dark surface (5.15:1 at worst). 1.1.0 routed links
  through `text-accent-foreground`; that token is now the ink that sits **on the
  amber spot** and nothing else.
- Never place text on `bg-primary` other than `text-primary-foreground`, and
  never at reduced opacity.
- `border-border` is a decorative hairline (1.61:1 light, 1.31:1 dark). It always
  carries a background change with it; it never separates alone. 2.2.0 held it
  where it was while the ground lightened by 0.06 L: with a quiet ground the
  hairline is the dominant separator, and the one-sheet signature depends on it.
- **Form controls do not use `border-border`.** WCAG 1.4.11 wants 3:1 on a
  control boundary. Every input, select and control-shaped button carries
  `border-[var(--control-border)]` — 4.29:1 on `bg-card` and 3.90:1 on
  `bg-background` in light, 4.50:1 and 4.78:1 in dark. The dark value was 3.27:1
  until 2.2.0, which is legal and invisible; the owner's "select carets" note was
  half affordance and half edge.
- The state ramp moves **away from the label**, not toward the ground: light
  darkens (white label, 7.76 → 10.07 → 12.86), dark lightens (deep-violet label,
  6.87 → 8.66 → 10.80). This is the theme's one recorded colour-construction
  deviation and the reasoning is in `theme.css`.

**4. Status colour is reserved, and never the only signal.**
`--positive`, `--warning` and `--critical` mean deal health and nothing else.
They are never reused as a chart series, and every use goes through
`StatusBadge`, which pairs the colour with a lucide icon and the word ("On
track", "Watch", "At risk"). A row read in greyscale still states its health.

**5. Charts are recharts, coloured with variables, animation off.**
- Four chart sections: `kpi-row`, `bookings-chart`, `stage-breakdown` and
  `funnel-chart`, sharing `ui/chart`. (Until 2.2.0 this invariant also said
  those were the only client components in the theme. Every file is a client
  component now — see invariant 8 — so the rule it protected moved there.)
- Colour is `CHART_1` (`var(--chart-1)`) and `CHART_TARGET` (`var(--accent)`)
  from `ui/chart`, plus `CHART_QUIET` for a de-emphasised sparkline stroke and
  `CHART_SURFACE` for the ring a mark wears against its ground. **Never a hex in
  a recharts prop.** Axis text, grid and cursor use `AXIS_TICK`, `GRID_STROKE`,
  `CURSOR_FILL` from the same file.
- **A zero is drawn, not skipped.** Recharts filters out any bar whose width or
  height computes to zero and takes that row's background and label with it, so
  an empty category renders as a name beside nothing — which reads as a failed
  chart. `CHART_TRACK` / `CHART_TRACK_FILL` are the bed every bar lies in;
  `minPointSize={2}` keeps the zero row's rectangle alive so its background and
  label render; and a `Cell` paints that row in the track colour so the two
  surviving pixels are the bed rather than a mark claiming a value. The label
  then states `$0`. The track is chrome — it never carries a value, so it is not
  a second series.
- **A mark needs room at the plot edge.** The sparkline's current-week dot is
  r=4 under a 2px ring, so its right margin is 10 against 5 everywhere else: at
  a symmetric margin the dot's outer edge landed exactly on the plot edge and
  the trend read as running out of room.
- **There is no `--chart-2`.** 2.0.0 collapsed it into `--accent`, so the amber
  that draws the target rule is the same token as the amber that labels a target
  in the UI. Amber means target, in a chart and out of one.
- `isAnimationActive={false}` on every series, tooltip and cursor. A console
  that animates while you read a number is broken, and switching animation off
  at the prop is also what satisfies `prefers-reduced-motion` — there is nothing
  left to suppress.
- Text never wears the series colour: labels, legends and tick text use ink
  tokens. The `Legend` `formatter` returns a `type-caption text-muted-foreground`
  span for exactly this reason.
- Two series maximum per chart, distinguished by **shape before hue** (solid
  columns against a dashed rule). A third series means a second chart.
- **A funnel is one series.** `funnel-chart` gives every band `--chart-1` and
  names the stage inside it on `--primary-foreground`. A colour per band is five
  series pretending to be one, and it would make hue encode a stage the board
  already encodes by position. The band label is the only place in Atrium where
  type sits on a series colour, and it is the pair the contrast table verifies.
- **No sparkline starts at zero.** Every trend line carries
  `<YAxis hide domain={[min - pad, max + pad]} />` with ~8% headroom, or a 30%
  rise and a 14% fall render as the same flat picture. The line is `CHART_QUIET`
  and the current period is a `ReferenceDot` at r=4 with a 2px `CHART_SURFACE`
  ring.
- Direct labels round to one decimal below $1M (`$112.5k`, never `$113k`): the
  spine is the product, and a label that disagrees with the board column beside
  it is the defect the theme exists to avoid.
- Every chart ships a `ChartTable` twin. A value may be enhanced by a tooltip;
  it may never be reachable only through one. The stage chart's twin ships
  `defaultOpen`, so the feature appears in the product photography.
- The chart palette is validated for colour-vision separation, not eyeballed —
  see the `dataviz` validator command in `check-quality`.

**6. Sections are independent.**
A file in `src/components/` may import from `./ui/*`, from `lucide-react`, from
`react` and (chart sections only) from `recharts`. Nothing else. **No section
imports another section** — that is what makes per-component registry install
work, and 2.2.0 did not loosen it.

The shared state did not become a back door. `ui/console-state.tsx` is a
primitive like `ui/button.tsx`: sections read it, no section reaches through it
into another section, and `useConsole()` returns a working seed snapshot when no
provider is above it. Drop `pipeline-board.tsx` and `ui/` into a project with no
provider and you get a rendered board with the twelve real deals; add the
provider and it moves.

**7. Pages are pure composition.**
Exactly one `<h1>` per page, owned by `page-heading`. `src/app/*.tsx` import
sections, wrap them in `ConsoleProvider` and `AppShell`, and pass
content-selecting props (`variant`, `view`, `headingLevel`, `current`, `bleed`).
No data fetching, no Next-specific APIs, no `next/link`, no `next/image` — pages
must render standalone *and* inside the marketplace preview route. Links are
plain `<a href>`.

A page never computes a figure. `PageHeading` takes `view="pipeline"` and reads
the route's statement from its own table; that keeps every page-level number in
one place and keeps the page file a list of components. A page that starts doing
arithmetic is a section that has not been written yet.

**8. JavaScript where the interaction IS the product; the DOM everywhere else.**

This invariant reversed in 2.2.0, on the owner's instruction, and the reversal is
narrow rather than total. 2.1.0 said "interactivity without JavaScript wherever
it is possible" and shipped a board that could not move a card, a form whose
submit changed nothing, and a toolbar whose controls did nothing. Those are not
zero-JS wins; they are a photograph of a CRM. The rule now has two halves and
you need both:

**Client JavaScript is required for the behaviours a buyer will try in the first
ten seconds** — dragging a card between stages, completing the wizard and seeing
the record appear, editing and deleting an account, filtering, paging the
calendar, flipping the mode. Each of these IS the product expectation for an
admin theme; shipping the layout without them was the defect.

**The DOM still holds every piece of state it can hold.** Unchanged and not to
be "upgraded": the mobile menu and the chart table twins are `<details>`; the
segmented controls are radio inputs styled through `peer-checked` (they take a
`value`/`onChange` pair *only* where the toolbar has to drive a surface); the
next-steps checklist is native checkboxes. Do not reach for state where the DOM
already holds it, and do not reach for a library where the platform has an API —
the board's drag is native HTML5 drag and drop, and it added no dependency.

**Every interaction ships a keyboard path.** The board's grip is a real button
answering the arrow keys, and focus follows the card into its new column. A
drag-only board is an inaccessible board.

**Every section file is `'use client'`, and so is every `src/app/*.tsx`.** The
pages open with `<ConsoleProvider>` and close with it — that is what lets a
page's header state a figure the panels below it also state, from one array.
The six stateless primitives stay directive-free on purpose: they are props in,
markup out, so they still work from a server component in a buyer's app.

**9. One radius language.**
`rounded-themed` everywhere. `rounded-full` is reserved for pills, status dots
and avatars. Nothing else. Recharts bar corners use the numeric `radius` prop
(`[3,3,0,0]`), which is geometry, not a design token.

**10. Focus is always visible, and never transitioned.**
Every interactive element carries
`focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`
(inset variants use `focus-visible:-outline-offset-2`). `transition-property` is
always scoped — `transition-colors`, `transition-transform` — never `all`, so
the outline can never fade in.

**11. Icons come from `lucide-react`; the mark does not.**
- **Size via `className`** only — `size-3.5` inside badges, `size-4` inline,
  `size-5` for controls, `size-6` for the mark. Never `size`/`width`/`height`
  props.
- **`strokeWidth={1.5}` everywhere**, including `MarlowMark`.
- **Colour is `currentColor`** through a token class or inheritance. Never set
  `stroke` or `fill`.
- **`aria-hidden="true"`** unless the icon is the only label; the icons inside
  `StatusBadge` sit beside the word, so they are decorative.
- **`MarlowMark` stays hand-drawn.** Identity is bespoke, UI iconography is not.

**12. Density is the design, and it has rules.**
- The console tier is 13px, set **once** as `type-dense` on the shell root and
  inherited. Never repeat it on a child; opt down with `type-caption`, up with
  `type-small` / `type-body` / `type-d1`.
- **Bare `text-*` utilities are banned.** Sizes route through the class layer at
  the foot of `theme.css`, which is the token surface: `type-d1`…`type-d4`
  (display), `type-lead` / `type-body` / `type-small` / `type-dense` /
  `type-caption` (UI), and `type-label` for uppercase heads. The ratio is
  **1.125** and it is recorded in `theme.json`.
- Tracking is a property of the step, never a class an author picks. Every
  display step is −0.01em (the whole ladder sits in the 24–40px band); every UI
  step is 0; `type-label` is +0.07em. Do not write `tracking-*` in a component.
- Weight for display steps comes from `--display-weight` inside the type class,
  which is how dark gets its one-step drop (600 → 500) for free. Do not add
  `font-semibold` to a `type-d*` element.
- Numbers that sit in a column get `tabular-nums` **and** `font-mono`. Large
  standalone figures — the hero figure, a KPI value — get neither: equal-width
  digits make a display number look loose, and that is the dataviz rule Atrium
  follows over the CONTRACT's blanket `tnum`.
- Table cells are `px-3 py-2`. That measures **36px** in `deal-table` and
  **53px** in `accounts-table`, whose account cell carries a second line. Panels
  are `p-4`. Page gutter is `px-4 sm:px-5 lg:px-8`. Do not invent a third
  padding step.

**13. One fixture file, and NEVER a typed total.**

This is the invariant 2.2.0 changed most, and it made the previous version of it
obsolete rather than wrong. 2.1.0 declared the same twelve deals in eight
components and reconciled them by hand in a table; the rule was "if you change a
deal, change it in nine files". That worked exactly as long as nothing moved.

**`src/components/ui/console-state.tsx` declares the fixtures once**: twelve
deals, twelve accounts, ten contacts, eleven log entries, the five reps, the
quarter and its target. Every figure the theme prints is a selector over that
state, computed at render. `$1,284,500` is not written anywhere in `src/` — it
is `sum(deals.value)`. `$618,350` is `sum(value × STAGE_ODDS[stage])`. The rep
table is the same deals grouped by owner. The forecast hero is
`target − weighted`.

Three rules follow, and they are the whole contract:

1. **Never type a total.** If a surface needs a number the console states, add a
   selector in `console-state.tsx`. A literal figure in a component is a future
   contradiction, and after 2.2.0 it is also a lie the moment a card is dragged.
2. **Time is derived, never typed.** Fixtures carry ISO dates only —
   `closesOn`, `stageSince`, `lastTouchOn`, `at` — read against `TODAY`
   (`2026-08-13`, the instant the status bar states). "9 days in stage" is
   computed. Add a date, never a duration. A moved deal's `stageSince` becomes
   `TODAY`, so its card is honest the moment you drop it.
3. **Identity is derived from the collection, never from a counter.** `nextId`
   reads the highest suffix that already exists. A module-level counter looks
   fine until the tab rehydrates from `sessionStorage`, and then two records
   share a key — which 2.2.0 shipped for one pass and the browser caught.

**The one record outside every total** is still the wizard's draft. It states
what saving would do (`12 → 13`, `$1,284,500 → $1,338,500`) against the console's
*current* totals, live as you type — and then saving does it. A draft that
silently contradicted the dashboard behind it would be the generated tell; one
that states its own arithmetic and then performs it is the product.

**Stage odds are 10 / 30 / 55 / 70 / 80.** Legal review's 70% is new in 2.2.0:
2.1.0 published four odds and left the stage out because its column happened to
be empty, which stopped being an option the moment a card could be dragged into
it.

**State lives in the tab.** `sessionStorage` holds anything you change, so a
deal created in the wizard is on the board after you navigate, and a new tab is
the seed again. The status bar carries the reset, and it appears only once
something has actually changed.

**A status badge states a claim; the card must show its evidence.** Every
`watch` and `at-risk` deal carries a `cause` string rendered in the status ink
beneath the close date. `on-track` deals have none. A badge whose reason is
nowhere on the card is a badge a buyer does not believe.

**14. Imagery: portraits for the seats, and nothing else.**

This invariant reversed twice and the current shape is deliberate.

**The masthead texture is deleted.** 1.1.0 added a drafting-paper mask behind
the overview's header band; 2.0.0 argued it was "the console's front door".
The owner's 2.2.0 critique named it — *"remove the content top annoying
background"* — and the argument does not survive contact with the surface: it
put a reading cost on the one band that carries the quarter's headline number,
and it was the only thing in the theme that needed a binary asset and an inline
`style`. The `backdrop` prop, the layer, the mask geometry and
`src/assets/masthead-grid.png` are all gone. **Decorative photography, textures
and backdrops remain banned**: every pixel of a console is a control or a
number, and a texture under a figure is a reading cost with no information in it.

**People are content, so people get photographs.** A CRM is a list of named
humans, and initials in every avatar slot is a placeholder wearing a design
decision. `src/assets/avatar-*.jpg` holds five generated portraits — one per
Marlow seat — shot as one set: a single high-key backdrop in the theme's own
271° family, one light direction, one crop system, 192×192 for a 96px ceiling,
about 8 KB each.

**Who gets a photograph is a rule, not a preference.** Marlow has a profile for
its own five seats. It does not have one for someone else's employee, so the ten
contacts at customer accounts keep initials — which is also the honest empty
state, since a CRM holds a contact's name long before it holds their face. The
rule is encoded in `ui/avatar.tsx`: a name in the `PORTRAITS` map renders an
`<img>`, everything else renders initials. Adding a rep means adding a portrait
to that map, in the same set, or the directory stops looking commissioned.

**Imagery.** The files in `src/assets/` are AI-generated (Gemini) and are
licensed to you with the theme. They are regenerable — the shoot sheet and the
exact per-portrait command are in `src/assets/README.md`; the marketplace
generates them with `bun run generate-image`. They are portraits of fictional
colleagues and must never be described as photographs of a real team.

**15. Manifest stays truthful, and so is the styleguide's version line.**
Adding, renaming or deleting a section or page means updating `theme.json`
(`pages`, `components`, and `tokens`/`tokensDark` if tokens moved) in the same
change. Bump `version` for anything a buyer would notice — and bump the
`<Eyebrow>` at the top of `src/app/components.tsx` with it. That string ships in
the buyer's zip; 1.1.0 shipped it reading `Atrium 1.0.0`.

**17. The signature: the console is one sheet.**
Panels share hairlines instead of floating apart. The board's five columns are
separated by `divide-x divide-border` rather than by gaps, the record panel's
three columns share a `bg-border` seam, the wizard splits what you are writing
from what it would change across one hairline, and the styleguide's swatch and
type tables are the same device. It recurs on every route, it survives a rebrand
and dark mode, and it is namable from a capture: *the panels share edges*.

**2.2.0 fixed the place the device was faked.** The KPI band claimed to be one
instrument strip and was built as `grid gap-px` on a `bg-border` ground — three
`bg-card` tiles with a 1px gap painted to look like a rule. At the 2.1.0 ground
that read as a divider; at the 2.2.0 ground, where card and ground are 1.10:1
apart, it read as three tiles floating on a stripe, which is the owner's "stats
separation" note. It is now what it always claimed: one `bg-card` frame with
`divide-x` / `divide-y` inside it. **When you write a list of equal things,
use one frame and internal dividers, not a gap on a coloured ground** — the
`gap-px` trick only ever worked because the ground was loud.

Write new lists of equal things as hairline grids. **Never a card with a coloured
left border**; it is banned project-wide. Free-floating `gap-3` cards are the
pattern 2.0.0 removed — do not reintroduce them for a list of peers.

**18. One oversized moment per view, and it is a number.**
`composition-pass` allows exactly one per page, never zero. Exactly one per
route, declared here before any markup changes:

| Route | The moment |
|---|---|
| `/` | the header's `type-d4` hero figure, open pipeline |
| `/pipeline` | the board breaking the container |
| `/accounts` | the full-bleed accounts table |
| `/forecast` | the `type-d4` hero figure, to target |
| `/calendar` | the `type-d4` hero figure, what closes this month |
| `/activity` | the full-bleed log |
| `/new-deal` | the value field, set at `type-d4` |
| `/new-account` | the account-name field, set at `type-d4` |
| `/components` | the `type-d4` specimen row |

The two create routes are the one place the moment is an **input** rather than a
reading. It is still the figure the screen is about, and it is still exactly one:
the field the record is named by, at the top of the display ladder. Every other
control on those pages is `h-9`.

**The wizard's headline field sits above the steps and never leaves the screen.**
That is not a layout preference: a three-step wizard whose oversized moment lives
on step two has zero on steps one and three, and composition-pass fails both
ways. A persistent header gives every step exactly one, and it is the same one.

`/calendar`'s hero figure is the month's closing value; the marked today cell
carries `type-d1`, one step below it, so the grid never competes with the header.

A container break is not a moment. `/` bleeds its masthead and `/forecast` bleeds
its rep table, and both still lead with a hero figure — the break is where the
densest thing on the page reaches the gutters, which is a separate mandate.

A number that is the hero figure appears **once** on its page. The overview's
KPI strip deliberately does not repeat `$1,284,500`: a dashboard that prints its
headline twice in one viewport reads as a mistake, not as a hierarchy. **The
same rule binds words**: 2.2.1 deleted the wizard's panel header because it said
"New account" seventy pixels under the page's h1 saying "New account". A panel
directly under a page heading either says something the heading does not, or it
does not get a header.

**On the two create routes the moment is an input, so it is sized like one.**
`type-d4` is the moment; the field's span is not. `max-w-md` holds an account
name, `max-w-48` holds seven digits, and the caption under the field is what
states that the size was a decision. A `w-full` display-sized input across a
1,200px card reads as a broken stylesheet — which is what the owner saw. Note
that `controlBase` already carries `w-full`: constrain with `max-w-*`, never
with a second `w-*`, because two width utilities in one class list are resolved
by the order Tailwind emitted them and not by the order you wrote them.

**19. The rail is a plane, it is labelled, and it is the same on all nine
routes.**
- `bg-secondary` at L 0.900 against a 0.967 ground — a step you can see, which
  is what the 2.2.1 tone change bought. Every pair on it is computed:
  `text-secondary-foreground` 9.65:1, `text-muted-foreground` 5.06:1,
  `--control-border` 2.58:1 on card in light (softened by owner call 2026-08-14 —
  the boundary is one of three identifiers: fill, border, focus ring), 4.50:1
  dark, `--surface-tint` separating at 1.17:1 plus 32° of
  hue, which is what makes the active nav row visible.
- **208px from `lg`, 240px at `2xl`, labelled at both.** There is no icon-only
  state and there is no per-route variant: a create route gets the same rail as
  the board. Below `lg` the rail is hidden and the *same* `SidebarBody` opens
  from the top bar's `<details>` menu, so there is exactly one nav rendering in
  the theme.
- Group heads, per-row counts, the workspace switcher's name and the account
  row's name and role are all part of the rail, not decoration on it. A rail
  that says "Pipeline 12" is a different product from a rail that says nothing.
- The account row is the rail's **foot**: `mt-auto`, a hairline running the
  width of the padding box, aligned with the nav rows above it. It is not a
  card — a `rounded-themed` box with one border is a card that forgot three
  sides, and that is what 2.2.0 shipped.
- The cost is paid on `/pipeline`, knowingly: at 1280 the board's five columns
  distribute ~201px instead of ~236px, so card titles and the close/age line
  wrap onto two lines. Nothing clips, the columns are `xl:flex-1 xl:min-w-0`,
  and one route's density is the right price for eight labelled rows on nine.
- A grid beside the rail cannot assume half the old width. `/`'s bottom row is
  `xl:grid-cols-[4fr_3fr]` because `deal-table` has a 560px floor (`min-w-140`)
  and an even split against a 208px rail scrolled its health column out of the
  frame. **When a rail width changes, re-measure every fixed-floor table.**

**20. The frame is the viewport MINUS whatever sits above the console.**
Atrium is the only theme in the catalog sized to the window rather than to its
content: the rail is a sticky full-height column so a 60-row table never scrolls
the navigation away. That measurement assumed the console owns the top edge of
the window, and in the marketplace preview it does not — the theme renders under
a 32px banner, so a shell measured at a bare `100dvh` overflowed the window by
exactly that and the rail's foot landed 32px below the bottom edge, at rest, on
all nine routes.
- **Three lengths, one calc, and it lives in `theme.css`**: `.console-frame`
  (the shell's floor), `.console-column` (the rail's sticky column) and
  `.console-drawer` (the below-`lg` menu's ceiling), each
  `calc(100dvh - var(--tt-chrome-h, 0px))`. Never write a viewport length in a
  component again — `min-h-screen` and `h-screen` are both wrong here.
- **The fallback is the shipped case.** `--tt-chrome-h` is published by whatever
  renders above the console (`components/preview/preview-chrome.tsx` declares it
  at `2rem`, only when the bar renders). Nothing declares it in a buyer's app or
  under `?chrome=0`, so `0px` resolves and the frame is the whole viewport.
- It is a *read*, not a dependency: the theme names one variable and works
  without it. Do not add a second, and do not let a theme write one.

**21. The heading band is one instrument: two columns, two rows, one baseline
each.**
`page-heading` is rendered by all nine routes, so an alignment defect in it is a
nine-page defect — which is what 2.2.2 fixed. The shape is fixed:
- **Row 1 is the statement**: the h1 on the left, the figure the view leads with
  on the right, its `type-label` on the same line, right-aligned to the gutter.
  `items-baseline` is the whole point — the h1 and the 34px figure sit on ONE
  baseline. A route with no hero (`/pipeline`, `/accounts`, `/activity`, whose
  moments are the board, the table and the log) promotes its facts into this
  row, so every route's top line is title-versus-figures.
- **Row 2 is the context**: the lede on the left, the supporting facts on the
  right, on their own shared baseline.
- Both columns end in the same grid row, so the two sides finish flush. That is
  a consequence of the grid, not a second rule to maintain.
- **The band closes on a hairline**, on the page measure. Panels sharing edges is
  the signature (17) and the header was the one region floating free of it. It
  does not bleed to the gutters: a full-width rule 16px above a full-bleed
  panel's own top border reads as a doubled line, and near the top bar it reads
  as a second top bar.
- Two columns from `lg` only, where the rail is present and the measure holds
  both sides of a line. Below that the same four elements stack in reading
  order.
- **Never bottom-align two stacks and call it alignment.** Flush bottoms on
  blocks of different internal composition is a coincidence, not a relationship,
  and it is what the owner saw.

**16. Every internal link resolves, and it resolves somewhere else.**
An href in Atrium points at one of the eight pages, at an anchor that exists on
the page it names, or at a `mailto:` / `tel:`. Never invent a route to make copy
read better. Check after any edit:

```bash
grep -rhoE 'href[=:] ?[{"]*"[^"]*"' themes/atrium/src | grep -oE '"[^"]*"' | sort -u
```

**A link that resolves to the page you are already on is the same defect as a
dead one**, and 2.0.0 shipped six: both "New deal" buttons pointed at
`/pipeline`, "New account" pointed at `/accounts`, the empty board column's
"Move a deal here" pointed at `/pipeline`, and all three saved views were scroll
anchors on the overview. 2.1.0 rewired every one. The test is a click, not a
status code — if the URL does not change, the link is wrong.

**One exception now, and only one**: the nav row of the page you are on, which
carries `aria-current="page"` and is *meant* to be inert.

2.1.0 listed a second — the twelve account names in `accounts-table`, linking to
the route they were already on. 2.2.0 retired it rather than documenting it: the
names are `<button>`s that open the record below, which is what the link always
pretended to do. **When a control acts on the surface you are looking at, it is a
button.** That rule now covers the board's grip, the row's edit and delete, the
contact actions, the calendar's month arrows and the mode toggle.

**When it is an action, do not make it a link.** Moving a deal into legal review
and adding a filter both happen on the surface you are looking at, so both are
`<Button>`. Reach for `ButtonLink` only when a page is on the other end.

**Slugs are flat, and that is a platform constraint, not taste.**
`lib/pipeline/package.ts` writes every route's re-export at a fixed `../../`
depth, and `lib/pipeline/screenshots.ts` names captures
`<slug>-<mode>-<width>.png` with no directory support. A nested path like
`/deals/new` therefore ships a zip that does not build and a capture filename
that cannot be written. Hence `/new-deal` and `/new-account`. One path segment,
always.

**The create routes have no nav row.** `navParent` in `app-shell.tsx` lights the
record surface they belong to (new-deal → Pipeline, new-account → Accounts),
`aria-current` stays off because they are not that page, and the top bar's global
action removes itself so the form's own button is the only create on screen.

Anchors are allowed when the target exists. `#closing` is on `/forecast` (where
the nav's saved view points) and on the overview; `#forecast` and `#activity`
stay on the overview as deep-link targets. Put `id="…" className="scroll-mt-20"`
on the panel, matching `bookings-chart.tsx`.

Contact addresses use the reserved `.example` domain so nothing in the demo can
reach a real inbox — including the ones you type into the contact form, which is
validated for shape and never for deliverability. The console search still posts
to `/accounts` as a real form. **The wizards no longer post**: they save into the
console state and then offer a link to the surface the record landed on, because
a form that navigates away from what it just changed cannot show you that it
worked.

---

## Motion personality — feedback only

Motion in a console is confirmation, never arrival.

- **Allowed**: micro-feedback at 120–150ms `ease-out` on hover and press, on
  `colors` only; instant focus rings; the native `<details>` chevron rotation.
- **Not allowed**: entrances, staggers, scroll reveals, pulsing badges,
  skeletons on data that is already there, and any ambient loop. Atrium spends
  its one ambient slot on nothing — that is the deliberate deviation from the
  house allowance.
- **Pressed is a background step**, never a scale:
  `active:bg-[var(--primary-active)]`. The decision has not changed since 1.1.0;
  the mechanism has — it is a named step now, not an alpha wash. A dense toolbar
  that bounces reads as noise.
- **Hover changes at most two properties.** Cards change border and background;
  rows change background only.
- **Dragging is feedback, not choreography.** The card being dragged drops to
  `opacity-50`; the column under the pointer takes `--surface-tint`. No lift, no
  rotation, no placeholder that animates into place, and nothing tweens between
  columns — the card is simply in the new column on the next render. A board
  that performs the move costs you the half second you were going to spend
  reading the total it changed.
- **Nothing animates on a value change.** The one exception is the KPI meter's
  width, which is `transition-[width]` at 150ms: it is a bar whose length IS the
  datum, and a bar that jumps reads as a re-render rather than as a change.
- Every transition carries `motion-reduce:transition-none`, and the charts have
  animation switched off at the prop, so `prefers-reduced-motion` has nothing
  left to turn off.

---

## Type and spacing scale (follow it, do not invent new steps)

Ratio **1.125** (dashboard/ops). Two ladders, both tokenised in `theme.css` and
recorded in `theme.json`; components use the class layer, never a bare `text-*`.

| Use | Class | Size |
|---|---|---|
| Hero figure (one per view) | `type-d4` | 27 → 34px fluid |
| Styleguide specimen | `type-d3` | 27 → 30px fluid |
| Styleguide title | `type-d2` | 27px |
| KPI value | `type-d1` | 24px |
| Page h1, group titles | `type-body font-semibold` | 16px |
| Panel title, section head, column total | `type-small font-semibold` | 14px |
| Console body | inherited `type-dense` from the shell | 13px |
| Meta, captions, axis ticks, badges | `type-caption` | 12px |
| Column heads, group labels, eyebrows | `type-label font-semibold` | 12px, +0.07em, uppercase |
| Numbers in columns | `font-mono tabular-nums` | — |
| Standalone figures (hero, KPI) | proportional, no `tabular-nums` | — |
| Panel padding | `p-4` | |
| Table cell | `px-3 py-2` | |
| Page gutter | `px-4 sm:px-5 lg:px-8`, vertical `py-4 lg:py-6` | |
| Panel gap on a page | `gap-4`; hairline grids use `gap-px` on `bg-border` | |

The unusual step is `--text-dense`, 13px, named as a fifth UI step between
caption and small. It is the density decision that makes this theme; it is not
rounded to 12 or 14 to satisfy a table.

Panels sit on `bg-background`; the rail is `bg-secondary` (invariant 19); table
heads and panel footers are `bg-muted`; the active nav row and the selected
table row are `bg-[var(--surface-tint)]`.

---

## Typeface roles

| Role | Family | Why |
|---|---|---|
| `--font-text` (aliased by `--font-sans`) | **Inter Tight** | the tighter fit and larger x-height are what hold the 13px console tier, and it is not the Inter three siblings already claim |
| `--font-mono` | **Red Hat Mono** | a console register a CRM actually has, without reading as a code editor |

No display role: the CONTRACT closes serif off for dashboards, and a console
whose numbers are the product has nothing for a third voice to say.

Both are self-hosted latin-subset woff2 under `src/fonts/` with their OFL files,
loaded by `src/fonts.ts` through `next/font/local`. `next/font/google` is
forbidden. **The mono is exercised in the product, not only in the styleguide**:
deal refs (`D-0912`), the activity feed's timestamps, the status bar's clock, the
this-week counts, and every numeric table column.

---

## Content register — technical-plain

One clause, concrete nouns, the mechanism named rather than sold: *"Twelve open
deals across five stages, owned by five reps."* No adjectives of enthusiasm, no
second person, no exclamation. Numbers are stated, not celebrated. Empty states
say what the surface is for and offer one action, with no terminal period.

Buttons are verb-first and two to four words, and they carry the **page's own
verb, pointing at the page that carries it out**: `/pipeline` offers "New deal"
→ `/new-deal`, `/accounts` offers "New account" → `/new-account`. A destructive
action names what it destroys ("Delete account"), and the way out comes first
and is neutral ("Keep it"), because a confirm whose safe answer is the quiet one
is a confirm designed to be got wrong. The top bar's
global action is deliberately secondary so exactly one filled button appears per
screen, and it stands down entirely on the two create routes. A log has nothing
to create, so `/activity` ships no primary action rather than a button with
nowhere to go. The `Email` / `Call` pair on the record panel is the one
sanctioned one-word exception — "Discard draft", not "Cancel".

---

## What never to touch

- **`app/`, `lib/`, `package.json`, `tsconfig.json` at the repo root** — that is
  the marketplace, not the theme. Atrium owns `themes/atrium/` and nothing else.
- **The `@theme inline` bridge** in the marketplace's `app/globals.css`. Atrium
  defines variables; the bridge maps them to utilities exactly once. Do not
  duplicate it in `theme.css` (the commented copy at the top of that file is
  documentation for standalone use only).
- **`.tt-atrium` / `.dark` selectors** in `theme.css`. Both scopes are required:
  `:root` for standalone, `.tt-atrium` for the embedded marketplace preview.
- **`screenshots/`** — generated by the publish pipeline.
- Token *names*. Rebranding changes values, never the semantic names, or every
  component and both skills break.
- **`ui/console-state.tsx`'s exported shape.** Every section reads it. Add a
  selector, never a second source of a number, and never make `useConsole()`
  throw outside a provider — the styleguide and a buyer's first paste of one
  file both depend on the seed fallback.
- **`.scroll-track` / `.scroll-track-page`.** They carry `contain: paint` for a
  measured browser reason (theme.css says which). Removing one reintroduces
  hundreds of pixels of phantom page width that no element measurement shows.
- **`.console-frame` / `.console-column` / `.console-drawer`.** The frame's three
  viewport lengths, invariant 20. Replacing one with `h-screen` or
  `min-h-screen` re-breaks the console under the preview banner, and the symptom
  (a rail cut off 32px below the fold) is invisible in the source.

## Foundry

- Slug: foundry
- Category: portfolio
- Price: free
- Description: A free Tailwind agency portfolio template with editorial confidence. Fraunces set at 81px on warm paper — a real display serif with an optical-size axis, not the buyer's OS font — with Archivo for text, IBM Plex Mono for every slug and label, and a 1.500 type scale whose tracking is baked into each step. A confident coral accent, and a services ledger that sets your work like a table of contents: numbered rows, real prices, real durations, one row tinted and bled to the band edge. Team pages use initials cast at plate scale instead of stock photography; the one image in the theme is a grayscale paper texture used as a luminance mask, so the legal masthead reads as a printed sheet while every pixel of colour still comes from the tokens. Every page opens with a different section — the price ledger on services, the case ledger on work, six monogram plates on studio, three addressed desks on contact — instead of the same masthead five times. Seven pages (home, services, studio, a work page whose four case studies open out in place — brief, response, outcome figures and deliverables per client, a contact page carrying a validated enquiry form, three desks and an area map of the arch drawn from the tokens rather than pulled from a tile server, a journal built around one full-length article, and a legal imprint) plus a /components styleguide that sets out every token, section and primitive in one place. Twenty-one independent sections and nine shared primitives, light-first with a complete warm dark mode that the navbar toggle switches and remembers, and that drops the display weight one step. Every colour pairing is computed to WCAG AA in both modes, including the coral contrast trap: bright coral for display type and fills, a derived darker coral for links, labels and focus rings, plus a lightness-stepped state ramp instead of alpha hovers. Built for Tailwind v4, Next.js 15 and React 19; the three typefaces ship self-hosted with their licences, and every design decision routes through a single theme.css, so rebranding is a one-file edit. Free, MIT-spirited and fully open — source, skills, fonts and all.
- Pages: /, /work, /services, /team, /contact, /journal, /legal, /components
- Tags: free tailwind agency portfolio template, free nextjs portfolio template, agency, editorial, free, tailwind-v4
- Site: https://tailthemes.com/themes/foundry
- Manifest: https://tailthemes.com/api/themes/foundry
- Registry: https://tailthemes.com/r/foundry
- Preview: https://tailthemes.com/preview/foundry

### Foundry AGENTS.md

# Foundry — rules for agents

Foundry is a **free**, fully open agency theme (Tailwind v4 / Next.js 15 /
React 19). The demo content belongs to a fictional studio that shares the theme's
name — **Foundry**, a twelve-person brand and digital studio in a railway arch in
Bermondsey, London.

Read this before changing anything. The four workflows in `.claude/skills/`
(`rebrand`, `add-section`, `add-page`, `check-quality`) are the sanctioned way to
extend Foundry; they are written against the real file names and token names
below.

---

**Typefaces**: Fraunces, Archivo and IBM Plex Mono ship under the SIL Open Font License 1.1; the license files in `src/fonts/` travel with every redistribution.

## File map

```
themes/foundry/
  theme.json                     manifest — must stay truthful (see "Manifest" below)
  README.md                      human quick start
  AGENTS.md                      this file
  .claude/skills/                rebrand · add-section · add-page · check-quality
  src/
    theme.css                    EVERY design decision — tokens, light + dark,
                                 plus the class layer (font-display, type-*,
                                 ramp-*, shadow-sheet) that carries the scale
    fonts.ts                     next/font/local → the three CSS font variables
    fonts/                       the shipped faces + their verbatim OFL licences
      Fraunces-Variable.woff2      display · wght 100–900, opsz 9–144, SOFT, WONK
      Archivo-Variable.woff2       text · wght 100–900, wdth 62–125
      IBMPlexMono-Regular.woff2    mono 400
      IBMPlexMono-Medium.woff2     mono 500
    assets/
      laid-paper.jpg             the ONE image — a grayscale luminance mask, never painted (invariant 9)
      README.md                  its prompt, its budget and how to judge a replacement
    app/                         EVERY page opens with a DIFFERENT section — see
                                 "Opening devices" below; it is a rule, not an accident
      home.tsx                   / — hero → clients → work → ledger → manifesto → quotes → cta
      work.tsx                   /work — case ledger (carries the h1) → clients → cta
      services.tsx               /services — services ledger (carries the h1) → capabilities → process → faq → cta
      team.tsx                   /team — plate grid (carries the h1) → manifesto → open roles → cta
      contact.tsx                /contact — desks + the arch (carries the h1) → working FAQ → form → cta
      journal.tsx                /journal — the article (carries the h1) → shorter notes → cta
      legal.tsx                  /legal — page header → the imprint → cta (the ONE page-header route)
      components.tsx             /components — the living styleguide (invariant 15)
    components/                  ONE FILE PER SECTION, no cross-section imports
      site-header.tsx            sticky nav; mobile menu is a <details> disclosure
      hero.tsx                   display headline + four-cell facts rail
      client-band.tsx            client wordmark band
      selected-work.tsx          #work — four case-study teaser rows, each linking to /work#<slug>
      work-index.tsx             #cases — the same four cases in full, one anchored ledger entry each,
                                 closed by the totals row (prop: heading)
      services-ledger.tsx        #ledger — THE signature section (prop: heading)
      manifesto.tsx              #studio — sticky display heading + numbers strip
      testimonial.tsx            lead quote + two supporting quotes
      capabilities.tsx           #capabilities — six capability cards
      process.tsx                #process — four numbered engagement phases
      faq.tsx                    #faq — <details> list (prop: variant "engagement" | "working")
      team-grid.tsx              #people — six monogram plates (prop: heading)
      open-roles.tsx             #roles — two open roles
      contact-desk.tsx           #desks — three enquiry desks; #arch is the address entry and the drawn
                                 map (prop: heading)
      enquiry-form.tsx           #enquiry — CLIENT ISLAND: the validated project enquiry form
      journal-entry.tsx          #note — the article layout; carries the h1 on /journal
      journal-notes.tsx          #notes — four shorter notes, finished in place
      legal-notice.tsx           #terms — the imprint: six clauses + the company number
                                 (prop: showHeading)
      page-header.tsx            the /legal masthead, and the sheet's one shipping surface
                                 (prop: facts) — see "Opening devices"
      cta.tsx                    #contact — closing accent panel
      site-footer.tsx            brand block + footer nav + legal bar
      ui/                        shared primitives — the ONLY cross-section imports
        button.tsx               Button, ButtonLink, buttonClasses
        arrow-link.tsx           ArrowLink — the standing text link
        badge.tsx                Badge, Eyebrow
        container.tsx            Container (prose | default | wide)
        field.tsx                Field, Input, Textarea, Select — the form family
        icons.tsx                FoundryMark — the one hand-drawn SVG (UI icons come from lucide-react)
        mode-toggle.tsx          ModeToggle — CLIENT ISLAND: the navbar light/dark control
        monogram.tsx             Monogram (sm | md | block), initialsOf
        section-heading.tsx      SectionHeading (eyebrow + display h2 + lead + action)
```

---

## Opening devices — one per page, and no two the same

Until 2.2.1 five of the seven routes opened with the same block: `page-header`'s
eyebrow, display h1, lede and three-cell facts strip, ~520px of it, so a buyer
flipping work → services → team → contact → legal saw one screen five times and
`services-ledger` — the theme's *declared signature* — opened nothing, including
the page named after it. The survey that found it is
`ops/design/page-variety-2026-08-13.md`; the fix is
`ops/design/foundry-page-variety-2026-08-13.md`.

**The rule now:** a route opens with the strongest instrument it owns, and that
instrument carries the route's `<h1>`. A masthead is the right opener only for a
page that has no better device.

| Route | Opens with | The h1 lives in |
|---|---|---|
| `/` | `hero` — d4 headline, coral half-line, colophon strip | `hero` |
| `/work` | `work-index` — entry 01's oversized coral numeral | `work-index heading="page"` |
| `/services` | `services-ledger` — rule 01, `from £8k`, `4–6 weeks` | `services-ledger heading="page"` |
| `/team` | `team-grid` — the plate sheet, masthead as its first cell | `team-grid heading="page"` |
| `/contact` | `contact-desk` — three addressed desks, then the arch and the map | `contact-desk heading="page"` |
| `/journal` | `journal-entry` — the article masthead | `journal-entry` |
| `/legal` | `page-header` — an imprint has no better device | `page-header` |

Consequences that are load-bearing, not incidental:

- **The `heading` prop is the mechanism, and it is a content selector, not a
  style switch.** `"section"` renders the shared `SectionHeading` (h2);
  `"page"` renders eyebrow + h1 + lede at the quiet padding step. Never two h1s
  on a route, and never a `page-header` above a component in `"page"` mode.
- **No page carries two display headings in its first screen.** That was the
  other half of the same defect — `page-header`'s h1 followed 80px later by a
  section h2 saying something similar. `legal-notice` takes `showHeading={false}`
  on `/legal` for exactly this reason, and `enquiry-form`'s head is a d1 legend
  inside the form's own column rather than a d3 masthead.
- **A stat rail that the next section restates is filler, and it is deleted, not
  moved** — with one exception, which is the shape the exception has to take:
  `/work`'s `31 / 7 wks / 94%` became `work-index`'s **totals row**, at the foot
  of the ledger it summarises. A figure earns a place by being a conclusion, not
  a preview.
- **`/contact`'s order is forced by the background alternation, not chosen.**
  `contact-desk` (muted), `faq` (background) and `enquiry-form` (muted) before
  `cta` admit exactly one sequence, and it is desks → objections → form. Do not
  "fix" it by putting the form second; that is two `bg-muted` bands running
  together for 2,800px, which is the rule the alternation table exists to stop.

---

## Invariants

**1. All design lives in `src/theme.css`.**
No other file may introduce a colour, a radius, a font family, a type size or a
tracking value. If a change needs a new visual value, it becomes a token in
`theme.css` first — in **both** the light block (`:root, .tt-foundry`) and the
dark block (`.dark, .tt-foundry.dark`) — and the matching entry is added to
`tokens` and `tokensDark` in `theme.json`.

*Both blocks, even for values that do not change between modes.* The marketplace
loads every theme's stylesheet on one origin, and a sibling theme's bare
`.dark { --text-d4: … }` block matches Foundry's wrapper too. Only the
two-class `.tt-foundry.dark` selector outranks it. This is measured, not
theoretical: during the 2.0.0 pass Foundry's H1 briefly rendered at another
theme's step because the scale tokens were declared in the light block only.

**1a. `theme.css` also carries a small CLASS LAYER, and that is deliberate.**
`font-display` · `type-d1…d4` · `type-lead/body/small/caption/label` ·
`type-slug` · `hang-quote` · `ramp-primary` · `ramp-quiet` · `ramp-card` ·
`shadow-sheet`. The layer lives in `@layer components`, so a Tailwind utility
on the same element always wins — put resting values in the class and states in
the utility, never the reverse (`ramp-card` is why: `bg-card ramp-quiet` had it
backwards and the hover tint never painted).

The marketplace's `@theme inline` bridge belongs to the marketplace and maps
colour, radius and two font families — nothing else. A theme therefore has no
way to emit `text-d4` or `hover:bg-primary-hover` as Tailwind utilities, and the
alternatives (`text-5xl`, `hover:bg-primary/90`) are the two things
`themes/CONTRACT.md` bans outright. So the scale and the state ramp ship as
classes in the same file as the tokens they read.

Rules for that layer, all load-bearing:

- Every rule is emitted twice — bare, and again under `.tt-foundry` — because
  other themes define the same class names on the same origin. The scoped copy
  (0,2,0) wins in the preview; the bare copy is what a standalone buyer gets.
- The classes set **scale, tracking, weight or a state colour, and nothing
  else**. The moment one grows a margin or a border it has become component
  styling and belongs in a component.
- No component sets `font-size`, `letter-spacing` or a display `font-weight`.
  Those are properties of the *step*, and the step owns them.

**2. Semantic utilities only. Zero raw colour.**
Allowed: `bg-background` `text-foreground` `bg-card` `text-card-foreground`
`bg-primary` `text-primary-foreground` `bg-secondary` `text-secondary-foreground`
`bg-muted` `text-muted-foreground` `bg-accent` `text-accent-foreground`
`border-border` `outline-ring` `rounded-themed` `font-sans` `font-mono`, plus
the class layer from invariant 1a (`font-display`, `type-*`, `ramp-*`,
`shadow-sheet`).
One token appears in a second property, and it is deliberate:
**`border-accent-foreground`** is the invalid field's edge in `ui/field.tsx` —
the darker coral, 6.07:1 on `bg-card`, well past the 3:1 a meaningful non-text
graphic needs, and never the only signal (an icon and a sentence carry the
error). `text-border` and the other stroke uses in the map are the same idea:
the token doing its own job in `stroke`/`fill` rather than a new value.
**Alpha-modified colour states are banned** — `hover:bg-primary/90` and
`hover:border-muted-foreground/40` shipped until 1.4.0 and are gone: alpha moves
L, C and H at once, and on Foundry's coral it also moved the deep-brown button
label's contrast. Colour states come from `--primary-hover` / `--primary-active`
/ `--surface-tint` through `ramp-primary` and `ramp-quiet`.
Alpha survives in exactly two places, both genuine translucency rather than a
colour state: the header's `bg-background/85` scrim under `backdrop-blur`, and
`page-header`'s `bg-foreground/10` texture layer (invariant 9).
**Bare `shadow-*` is banned** — the one shadow in the theme is `shadow-sheet`,
which carries `--shadow-color` (the ink's hue, never neutral black).
Forbidden anywhere in `src/components` and `src/app`: hex values, `bg-[…]`,
`text-white`, `text-black`, and every palette class (`bg-orange-500`,
`text-stone-400`, …). The publish gate lints for this.

**3. THE CORAL RULE — the contrast rule that comes out of Foundry's palette.**
Foundry's coral is bright on purpose, and bright coral on warm paper is a
contrast trap. The measured numbers:

- `text-primary` (`#e26d5a`) is **3.09:1** on `bg-background` and **3.20:1** on
  `bg-card`. That clears WCAG AA for **large text only** (≥24px, or ≥18.66px
  bold) — so `text-primary` is for **display type, decorative marks and fills**,
  never for body copy, labels, links or anything under `text-2xl`.
- On `bg-muted` (2.84:1), `bg-secondary` (2.70:1) and `bg-accent` (2.74:1) the
  bright coral fails **even the large-text minimum**. Use
  **`text-accent-foreground`** (`#a8412c`, ≥5.13:1 on every light surface) there
  — for coral text of any size, on any tinted surface.
- Never put anything but `text-primary-foreground` on `bg-primary`, and never at
  reduced opacity. `--primary-foreground` is a deep warm brown, not white: white
  on Foundry's coral is only 3.20:1.
- `--ring` is deliberately **not** `--primary` (which would be 2.70:1 on
  `bg-secondary`). It is the darker coral, so a focus ring is visible on every
  surface in the theme. Do not "simplify" it back to `--primary`.
- **In dark mode the restriction lifts**: the lifted coral is ≥6.88:1 on every
  dark surface, so `text-primary` is safe at any size there. The components are
  written to the stricter light-mode rule so one class works in both.

Quick audit — every hit must be display-size text on `bg-background`/`bg-card`,
a fill, or a decorative mark:

```bash
grep -rn 'text-primary' themes/foundry/src | grep -v 'text-primary-foreground'
```

**4. `border-border` is a hairline, not a text colour.** It measures 1.33:1
(light) / 1.46:1 (dark) against the page. Foundry rules a lot of lines — every
structure a reader must perceive also changes background (`bg-card` on a
`bg-border` grid), never border alone.

**5. Sections are independent.**
A file in `src/components/` may import from `./ui/*`, from `lucide-react` and
from `../assets/*`, and nothing else. No section imports another section. This
is what makes per-component registry install work.

**6. Pages are pure composition.**
`src/app/*.tsx` import sections, stack them, and pass content-selecting props
(`heading` on `services-ledger`, `work-index`, `team-grid` and `contact-desk`;
`showHeading` on `legal-notice`; `variant` on `faq`; `eyebrow`/`title`/`lead`/
`facts` on `page-header`). No data fetching, no Next-specific APIs, no
`next/link`, no `next/image` — pages must render standalone *and* inside the
marketplace preview route. Links are plain `<a href>`.

The one exception is `app/components.tsx`, which defines four page-local chrome
helpers (`Slug`, `Frame`, `Plate`, `Caption`). They are styleguide furniture, not
theme parts: keeping them in the page is what stops them from landing in
`src/components/` and inflating the component inventory. Do not export them, do
not reuse them from another page.

**7. Server components, and exactly two islands.**
Everything in Foundry renders on the server except two files, and both are
named here because the list is the rule:

| Island | Why it cannot be a server component |
|---|---|
| `ui/mode-toggle.tsx` | It writes a class onto the document (or the `.tt-foundry` wrapper) and reads a stored choice. Both are browser facts. |
| `enquiry-form.tsx` | Validation state, focus movement and the success panel are all state. The alternative — `:user-invalid` alone — cannot move focus to the first bad field or say what to fix. |

The mobile menu and the FAQ are still native `<details>` elements and stay that
way: a disclosure needs no JavaScript, and buying one with an island would be
the drift this table exists to stop. A third island needs an argument in a
`/design-pass` file, not a `'use client'` line.

`ui/field.tsx` is deliberately **not** an island — the field family is plain
markup, so a server-rendered form (a search box, a newsletter row) can use it
without dragging state into the page.

**8. One radius language, and it is nearly square.**
`--radius` is `0.25rem`. `rounded-themed` everywhere; `rounded-none` on the
full-bleed monogram plate; `rounded-full` is reserved for **status dots only**.
Foundry has no pills — `Badge` is a square-cornered chip on purpose. Do not
introduce `rounded-lg`, `rounded-xl` or a second radius.

**8a. Icons come from `lucide-react`; the mark and the monograms do not.**
Import named icons in the file that uses them
(`import { ArrowRight } from "lucide-react"`). House rules, no exceptions:

- **`strokeWidth={1.5}` everywhere**, including `FoundryMark`. Foundry's line is
  a hairline — lucide's default 2 is heavier than any rule on the page. One
  value, no second weight.
- **Size via `className` only** — `size-4` inline (link and button arrows),
  `size-5` for controls (menu toggle, FAQ +/−), `size-6` for the mark. Never
  `size`/`width`/`height` props; lucide defaults to 24px, so an icon with no
  size class is a bug.
- **Colour is `currentColor`** through a token class or inheritance. Never set
  `stroke`/`fill`. **THE CORAL RULE applies to icons**: `text-primary` is only
  for the `size-6` header mark on `bg-background` (3.09:1 — a decorative
  non-text graphic, the 3:1 floor); small icons use `text-foreground`,
  `text-muted-foreground`, `text-accent-foreground` or the inherited
  `text-primary-foreground` inside a coral button. The footer mark is
  `text-accent-foreground` because it sits on `bg-muted`, where the bright coral
  falls to 2.84:1.
- **`aria-hidden="true"` always, with one named exception.** Every icon in
  Foundry is decorative; no icon is the sole carrier of meaning, and the FAQ's
  `+`/`−` and the menu's hamburger/`X` are shape changes so no state is
  signalled by colour alone. The exception is `ui/mode-toggle.tsx`, where the
  Sun/Moon **is** the control's label: it is carried by `aria-label` plus
  `aria-pressed`, and the two states are different shapes, so the rule the
  `aria-hidden` habit protects — never colour alone, never icon alone — still
  holds. The icon inside stays `aria-hidden`; the button carries the name.
- **The set as shipped**: `ArrowRight` (links), `Menu`/`X` (the menu),
  `Plus`/`Minus` (the FAQ), `Sun`/`Moon` (the toggle), `AlertCircle` (a field
  error), `ChevronDown` (the select), `Check` (the sent panel). Nine icons for
  twenty-one sections is the restraint working; adding a tenth is a decision.
- **Restraint is the design.** Foundry is editorial print: arrows on links, a
  disclosure toggle, a menu toggle, and that is the whole set. Deliverable
  lists are mono type, not tick lists; process phases are numbered, not
  iconed. When in doubt, no icon.
- **`FoundryMark` in `ui/icons.tsx` stays hand-drawn**, and `ui/monogram.tsx`
  stays type. Identity is bespoke, UI iconography is not — do not add other
  SVGs to `icons.tsx`, and do not re-export lucide through it.
- Any package an icon set drags in is declared in `theme.json` `dependencies`.

**9. ONE image: `src/assets/laid-paper.jpg`, and it is a mask, not a picture.**
No photography, no second texture, and no decorative *structure* layer — in CSS
or in a raster. People are `Monogram` initials plates, case covers are oversized
index numerals, the mark is inline SVG, panels are built from tokens. If a
section needs a "portrait", it uses `ui/monogram.tsx`.

**9a. Drawing is allowed when it carries information. 2.2.0 drew the map.**
The boundary this invariant has always policed is what a device *claims*, not
what medium it is made of — decoration that imitates composition is banned, and
that is the whole of it. `contact-desk`'s area sketch of the streets around
Arch 41 is the other thing: content, in a `<figure>`, with a caption, an
`aria-label` that says what it shows, and the same facts in prose beside it. It
is authored SVG, every stroke `currentColor` over a token class, so it redraws
itself in dark mode and a rebrand carries it. No tiles, no map service, no
raster, ever — an external tile breaks the offline zip and bakes somebody
else's colours into Foundry's page.

Weight carries its hierarchy exactly as it does on the page: the viaduct in
`--secondary-foreground`, named streets in `--muted-foreground`, an unnamed
side street as a `--border` hairline, and coral on the arch and the walk to the
station only.

**Wire Diagram was considered and rejected here**, which is worth writing down
because `themes/CONTRACT.md` makes it the standard for diagrams: it is the
standard for *architecture, flow and topology*, where the JSON is the content
and a renderer can place the nodes. A street map is cartography — the positions
**are** the information, so every node would be hand-placed anyway and the JSON
would buy nothing a buyer's agent could meaningfully edit. Authored SVG is the
honest answer for this one class, and only this one.

A second drawing needs the same test: does it carry information a reader would
otherwise have to be told in words? If not, it is decoration and invariant 9
still says no.

**What ships.** `page-header.tsx` — since 2.2.1 the opener of `/legal` and of
the styleguide, and nothing else — carries one `aria-hidden` layer:
`bg-foreground/10` masked by `laid-paper.jpg` with `mask-mode: luminance`,
`mask-size: cover`, `inset-0`. The file is grayscale and is never painted; it
supplies shape only, and `--foreground` supplies every pixel of colour. Measured
at 1280 on the shipped captures, that puts a 20-level (of 255) relief across the
band in light and 19 in dark, with the token itself as the mean. Prompt, budget
and how to judge a replacement: `src/assets/README.md`.

**One surface is fewer than three, and that was priced before it shipped.** The
page-variety pass took the masthead off four routes, so the sheet went with it.
Moving the layer onto whichever section now opens each page was considered and
rejected: it would put a material claim behind four different devices, and the
hero still cannot have it (the arithmetic below is unchanged). `/legal` keeps
the masthead partly because an imprint has no better opening device and partly
because the theme's one material claim needs a shipping page to make it on.

**2.2.1 added a second mask layer, and it is this invariant enforced rather than
bent.** Full-bleed on the section meant the sheet stopped dead on the section's
bottom edge — lawful as a boundary, but the band underneath is `bg-muted`, a
different warm tint, so a reader saw two edges in one place: the texture running
out and the ground changing. That doubled line is the false edge finding 5 bans.
The layer now composites the luminance mask with a `to bottom` alpha ramp
(`mask-composite: intersect`), zero from 64% down, so the relief is gone before
the boundary arrives. `intersect` means the ramp can only *remove* relief, so
every contrast pair measured against the full-strength composite still stands as
the worst case. The gradient's stops are `currentColor`, not black — an alpha
mask reads only the alpha channel, and invariant 2 has no exemption for a mask.

**The hero does not get it, and the reason is arithmetic, not taste.** THE CORAL
RULE (invariant 3) leaves `text-primary` at **3.09:1** on `bg-background` —
three percent of headroom over the 3:1 large-text floor. The hero's "carry
weight." span is `text-primary` sitting directly on the section ground, so any
layer that darkens that ground breaks it: the same 10% sheet takes the coral to
**2.96:1** at the mask's mean and **2.78:1** over its brightest patch. Every
opacity quiet enough to keep 3:1 is too quiet to see. `page-header` carries no
coral — eyebrow is `text-accent-foreground` (5.29:1 at worst under the sheet),
h1 is `text-foreground` (15.55:1), lead is `text-muted-foreground` (6.19:1) —
which is why it is the surface and the hero is not. Do not "finish the job" by
adding the layer to `hero.tsx`.

---

This is a **standing decision**, revised three times under `/design-pass` on
2026-08-13. Read the trail before reopening it; most of the arguments you would
make have already been made and measured.

1. `ops/design/foundry-imagery-2026-08-13.md` — killed **coloured raster
   imagery** on four findings.
2. `ops/design/foundry-backgrounds-2026-08-13.md` — tested **print structure
   drawn in CSS from `var()`** (ruled sheet, registration marks, type-area
   rules, ghosted numeral). Eight devices, all rejected. It retired findings 1,
   3 and 4 for anything drawn in CSS, and added finding 5.
3. `ops/design/foundry-hero-texture-2026-08-13.md` — this one. A **grayscale
   raster used as a luminance mask** over a token layer, on the owner's
   directive that generated background imagery be visible in heroes across the
   catalog. Shipped.

The mechanism is what changed, so be precise about what it retires:

- **Finding 1 (a raster breaks rebrand) is dead for this class.** Nothing is
  baked, because nothing in the file is coloured. `--foreground` paints the
  layer; a rebrand carries the sheet.
- **Finding 2 (the tokens already are the paper) is dead for this class, and it
  is the interesting one.** It was measured against a *coloured* grain that
  resolved to `#f6f1e8` — byte-identical to the token it was imitating — so the
  image restated a claim the tokens already made. A mask does not restate the
  token, it **modulates** it: the token stays the mean and the texture is the
  ±10 levels of relief around it. Tokens claim paper; the mask shows it.
- **Finding 3 (dark mode needs a second asset) is dead.** One file, one opacity,
  two modes: light paints ink down into the tooth, dark paints light across it.
  No `dark:` layer, no image URL in `theme.css`, no untruthful token table.
- **Finding 4 (there is no usable opacity) is dead — measured.** At 10% the
  band's quietest gutter sits 2.4 levels off the flat token and its deepest 11;
  `--background` remains the surface the band reads as, and the
  `bg-background` → `bg-muted` alternation is intact because `page-header` is a
  `bg-background` section and stays one.
- **Finding 5 (no lawful offset, no false edges) survives untouched — and it is
  now the boundary.** It killed lines and marks, and it still does. A full-bleed
  texture has no edge to place and no offset to invent, which is exactly why
  this class clears it and the rules class did not.

**The new boundary — texture is in, structure is out.** The line is not
"raster vs CSS", it is *does the device draw a thing a reader could mistake for
composition*:

- Banned, still and in any medium: ruled sheets, laid lines rendered as rules,
  registration or crop marks, type-area rules, ghosted numerals, grids, borders,
  vignettes, anything with a visible edge. One generation in the shipping pass
  came back with three bright chain lines across the frame and was thrown away
  for exactly this — a structure device wearing a texture's clothes is still a
  structure device.
- Full-bleed only. `inset-0` + `mask-size: cover` on a section that already owns
  its band. A texture that stops somewhere is a false edge.
- Decorative only. `aria-hidden`, no information, no state, no motion.
- One asset. A second texture is a second material claim, and Foundry makes one.
- Grayscale only. A coloured file tints the layer and finding 1 comes back.

Foundry's "print" reference stays **structural** — ledgers, ruled rows, indices,
mono slugs, tabular figures. The sheet the type is printed on is now material as
well; the apparatus around the type is not, and never will be.

**10. Focus is always visible, at one of three documented offsets.**
Every interactive element carries `focus-visible:outline-2` +
`focus-visible:outline-ring`. The offset is not a free choice — it is the
element's type:

| Offset | Used by |
|---|---|
| `focus-visible:outline-offset-2` | controls: buttons, `ButtonLink`, header nav chips, `<summary>` toggles |
| `focus-visible:-outline-offset-2` | full-bleed row links (`selected-work` rows, FAQ summaries) so the ring is not clipped by the row |
| `focus-visible:outline-offset-4` | inline text links in prose (`ArrowLink`, footer links, the jobs mailto, both wordmarks) so the ring clears the glyphs |

Never remove it, and do not invent a fourth offset. Outlines follow
`border-radius`, so with `--radius: 0.25rem` every ring is the same near-square
shape as the chips — give focusable elements `rounded-themed` rather than
overriding the ring geometry.

**11. Real content only.**
Foundry ships the studio's own copy — real prices, real durations, six named
people, four case studies with outcomes. Replacing it is fine; replacing it with
lorem ipsum, `Service One`, or grey placeholder boxes is not.

**12. `data-brand-name` belongs to the two wordmarks and nowhere else.**
The header wordmark (`site-header.tsx`) and the footer wordmark
(`site-footer.tsx`) each wrap the studio name in `<span data-brand-name>`. The
marketplace's rebrand-before-buy injects the buyer's name there. Every other
mention of "Foundry" in the copy is ordinary demo text — do not add the attribute
to it, and do not remove it from the two wordmarks.

**13. Manifest stays truthful.**
Adding, renaming or deleting a section or page means updating `theme.json`
(`pages`, `components`, and `tokens`/`tokensDark` if tokens moved) in the same
change. Bump `version` for anything a buyer would notice. A documentation-only
change (this file, `README.md`) does not earn a version of its own — it rides the
next real bump and the pass file says so. **1.3.0 carried the imagery verdict's
invariant 9 rewrite**, which is the tension `ops/design/foundry-imagery-2026-08-13.md`
left open. **1.4.0 ships the sheet** (`page-header`'s luminance layer,
`src/assets/laid-paper.jpg`) **and settles the carried debt**: the backgrounds
verdict's unbilled doc bump rides here, so nothing is outstanding.
**2.0.0 is the catalog's typeface retrofit pilot** and is major for three
reasons a buyer sees on the first screen: three real shipped faces where there
were none, a 1.500 type scale replacing 216 bare Tailwind steps, and the hero
taken from the retired A2 archetype to A6. It also carries the colour floors and
state ramp, the signature and deviation declarations, the ledger row's container
break, the team plates at slug scale, and the proof redistribution that removed
the testimonial section from `/work` and `/services`. Trail:
`ops/design/critique-foundry-2026-08-13.md` (the critique) and
`ops/design/foundry-retrofit-2026-08-13.md` (the execution, with every
recomputed contrast pair).
**2.1.0 ships `/contact`** — one page, one section (`contact-desk`) and the
seven links the audit above retargeted. No token moves, so the whole verified
contrast table stands unchanged. Trail:
`ops/design/foundry-contact-2026-08-13.md`.
**2.2.0 is the one-shot pass**: the theme is bought as a studio's whole site, so
it now carries the three surfaces every studio site needs and the two controls a
visitor expects to work — the enquiry form (with the field family under it), the
drawn arch map, `/journal`, `/legal`, and the navbar mode toggle. Two invariants
were amended deliberately rather than quietly: 7 (two named islands, not zero)
and 9 (drawing that carries information). No token moves again, so the verified
contrast table stands for the third version running. Trail:
`ops/design/foundry-1shot-2026-08-13.md`.
**2.2.1 is the page-variety and polish pass.** Five routes opened with the same
masthead; four now open with the section they are about, the fifth (`/legal`)
keeps it because an imprint has no better device, and the four duplicate stat
rails are gone — one of them promoted to `work-index`'s totals row. It carries
four owner-commissioned polish items with it: `/contact`'s doubled display
heading (the form's head is a d1 legend now), the sheet's hard bottom edge (an
alpha ramp, invariant 9), the journal's empty right third (three margin notes,
and two blocks that break the measure), and 56px off the hero's top padding.
Minor rather than major, and the test is the stylesheet: no token moved, no
scale step moved, no route was added or removed. Five first screens moved.
Trail: `ops/design/page-variety-2026-08-13.md` (the survey across all seven
themes) and `ops/design/foundry-page-variety-2026-08-13.md` (this pass).
Foundry is free:
`priceCents` is `0` and `license` is `"free"` — those two must always agree, and
changing either is a pricing decision, not a code change.

**14. Every internal link resolves — routes *and* anchors.**
Foundry has eight routes (`/`, `/work`, `/services`, `/team`, `/contact`,
`/journal`, `/legal`, `/components`) and ten linked anchors (`#main`,
`#contact`, `#work`, `#cases`, `#ledger`, `#roles`, `#arch`, plus the four case
slugs `#halcyon-rail` `#marlow-and-kite` `#verity-health` `#northbank-press` on
`/work`). Five more ids exist as landmarks the sections open on rather than link
targets: `#desks`, `#enquiry`, `#note`, `#notes`, `#terms`. A link to anything
else is a bug — v1.1 shipped five dead destinations because four of them were
`href` *fields inside a data array*, which the obvious grep misses. Audit both
forms:

```bash
grep -rnE 'href[=:]' themes/foundry/src
```

There are deliberately **no per-case routes**: a case entry is complete in place
on `/work`, and the home teaser rows link to its anchor. Do not "add the missing
case pages" — the four teasers and the four ledger entries are the same four
cases, and duplicating them into stub routes is how a catalogue starts lying.

**`/journal` is the same doctrine, and 2.2.0 applied it deliberately.** One
note is opened out in full (`journal-entry`, which carries the page's h1 and is
the article layout a buyer copies), and the four shorter notes below it are
finished at their own length in a hairline grid. Nothing on that page links to a
post that does not exist. When the fiction grows past eight notes, per-post
routes become the right answer and the index becomes a real index — that is the
`plan` row in `lib/stats.ts`, not a gap to fill today.

**The article doubles as case depth, and that is why a case-study surface is
still rejected.** It is the Marlow & Kite wordmark written long, and it links to
`/work#marlow-and-kite` instead of restating it. A second case system would put
the same four projects in three places.

**`/contact` is the opposite call, and 2.1.0 made it.** Until then the header's
`Contact` nav item, both `Start a project` buttons, the hero's primary button
and two footer links — one of them labelled `Arch 41, Bermondsey` — all pointed
at `/#contact`, the closing panel on the *home* page. Nothing 404'd, so the
link audit stayed green while seven of the theme's highest-intent controls sent
a reader off the page they were on to a panel holding one mailto and no address.
A case entry is complete inside a ledger; an enquiry is not complete inside a
CTA. Those seven now point at `/contact` (the footer's address link at
`/contact#arch`). `#contact` survives as the landmark `cta` carries on all six
pages, and the styleguide's primitives frame still demonstrates it — do not
delete the id.

**There IS a contact form now, and 2.2.0 retired the argument against it.**
Until then this file said a form here would be a control that does nothing,
resting on three legs: no field primitives, no client JavaScript, no backend.
Two of those were *our* constraints and both were paid off in the same pass
(`ui/field.tsx`, invariant 7's island table). The third is answered the way this
theme answers everything — by publishing it: the `<form>` carries a real
`action="/api/enquiries"` and `method="post"`, the demo stops before the POST,
and the mono line under the button says so in as many words. A studio that
publishes its prices can publish its endpoint.

What that costs a buyer is one line: replace `setSent(true)` in
`enquiry-form.tsx` with `event.currentTarget.submit()` or a `fetch` to their own
route, and delete the demo note. Everything above it — validation on submit and
on leaving a field, focus moved to the first bad field, the designed error and
success states — is already what they want.

The three desks stayed. A form is one way in, and a studio that answers its own
phone should not hide the number behind a text box.

`process` left `/contact` in the same change: the page alternates
background → muted → background, a sixth section cannot alternate between two
`bg-background` bookends, and the phases already run on `/services`.

**15. `/components` is part of the anatomy, and `data-component` is its contract.**
`src/app/components.tsx` is Foundry's living styleguide: the tokens panel, every
section rendered once with its shipped content, and the seven primitives in one
frame. It ships in the download, so the buyer inherits it.

- Every frame carries `data-component="<the name in theme.json>"`. The
  screenshots pipeline takes one element shot per frame at 1280 in both modes;
  the visible mono label and the attribute come from the same string in `Frame`,
  so they cannot drift. Never nest one frame inside another — the capture
  selector is flat and would shoot the same pixels twice.
- Adding, renaming or removing a section means adding, renaming or removing its
  frame in the same change. Check 9 in `.claude/skills/check-quality/` is the
  coverage gate; it blocks publishing, it is not advice.
- The `ui/*` primitives share one frame, `data-component="ui"`.
- `site-header` and `site-footer` are captured in place at the top and bottom of
  the page instead of inside specimen boxes. A wrapper is the sticky containing
  block, so the header does not stick on this page — accepted, not a bug.
- **The bed is `bg-secondary`, and this is the one page exempt from the
  background alternation table below.** The frames hold sections that are
  themselves `bg-background` or `bg-muted`; the sand is the only surface no
  section band uses, so it is the only bed that keeps every frame's edge in both
  modes.
- **Four `<h1>` elements live here** — the page's own opener, plus the `hero`,
  `page-header` and `journal-entry` specimens, which render theirs because that
  is what they do on a real page. Do not demote them to `h2`: the specimen would
  stop being the component. This is the only Foundry page where the one-h1 rule
  is relaxed.
- The page has no mode toggle of its own — the preview stage flips light and
  dark around it — but the `ui` frame's `ModeToggle` specimen is the real
  control and does flip this page when pressed. A specimen that did nothing
  would not be the component.
- The site nav does not link to it: the fiction does not know it is a theme. The
  manifest, the README and this file are where it is documented.

---

## Typefaces — three shipped roles

Foundry is sold on its type, so Foundry ships its type. Until 2.0.0 it rendered
in the buyer's OS UI font and said so in its own footer; the colophon now reads
`Set in Fraunces, Archivo and IBM Plex Mono` and is true.

| Role | Family | Why this one |
|---|---|---|
| `--font-display` | **Fraunces** (OFL 1.1, variable) | A display serif with a real `opsz` axis is the only kind of type that looks *set* at 81px rather than scaled up; `WONK` gives a letterpress irregularity at display sizes only. |
| `--font-text` | **Archivo** (OFL 1.1, variable) | A grotesque descended from mid-century American signage — industrial ancestry for the arch, neutral enough not to fight the serif, solid at 14–18px on warm paper. |
| `--font-mono` | **IBM Plex Mono** (OFL 1.1, 400/500) | Mono is Foundry's second voice, not a code font: it carries every eyebrow, every tracked label, every deliverables list and every spec cell. Plex's humanist forms are the printer's-slug register. |

- Self-hosted in `src/fonts/`, loaded by `src/fonts.ts` with `next/font/local`.
  **`next/font/google` is forbidden** — build-time fetching breaks the offline
  zip. Each family ships its verbatim `OFL-*.txt` beside the woff2.
- `src/fonts.ts` exports **`fontClassName`**, and that name is load-bearing: the
  generated standalone layout puts it on `<html>`, and
  `lib/preview-registry.tsx`'s `PREVIEW_FONTS` appends it to the `.tt-foundry`
  wrapper. Removing either makes `bun run check` and `bun run screenshots` fail,
  which is the point.
- Each token keeps the metric-adjusted system stack as its tail
  (`adjustFontFallback`), so nothing reflows while the woff2 arrives.
- **Never body copy in the display face.** Fraunces is h1/h2/h3, display
  figures, the monogram plates and the two wordmarks. Nothing else.

---

## Type scale — ratio 1.500, and tracking belongs to the step

Editorial/brand is 1.500 in `themes/CONTRACT.md`, and Foundry is the catalog's
primary editorial case. Display ladder anchored at 1.5rem; UI ladder fixed,
because readability is not a personality.

| Class | Size | Leading | Tracking | Used by |
|---|---|---|---|---|
| `font-display type-d4` | `clamp(2.75rem, 1.75rem + 5vw, 5.0625rem)` | 1.05 | −0.03em | hero h1 · the case-ledger cover numerals · the arch's `41` · the journal headline · the company number |
| `font-display type-d3` | `clamp(2.25rem, 1.5rem + 3.75vw, 3.375rem)` | 1.1 | −0.02em | page h1 · every section h2 · the pull quote |
| `font-display type-d2` | 2.25rem | 1.2 | −0.01em | ledger/work/role h3 · outcome and price figures |
| `font-display type-d1` | 1.5rem | 1.25 | −0.01em | card h3 · person name · fact figures · both wordmarks · client roster |
| `type-lead` | 1.125rem | 1.6 | 0 | hero deck · section ledes · FAQ questions · ledger indices |
| `type-body` | 1rem | 1.55 | 0 | section body copy |
| `type-small` | 0.875rem | 1.45 | 0 | card body · captions · footer copy |
| `type-caption` | 0.75rem | 1.4 | 0 | mono chrome that is not a tracked label |
| `font-mono type-label` | 0.75rem | 1.4 | **+0.08em**, uppercase, weight 500 | every eyebrow, slug, deliverable, spec label |

**Tracking is a property of the step, and this overturns a documented
decision.** Up to 1.4.0 this file said *"`tracking-tighter` on every display
size… Nothing in between"*, and that single −0.05em served a 96px headline and a
20px wordmark alike — a constant, not optical correction. `composition-pass`
requires the opposite, the contract wins, and the correction is now impossible
to get wrong because no component may set `letter-spacing` at all. **There are
no `tracking-*` utilities anywhere in `src/`, and no bare `text-*` utilities
either.** Both are gone; putting one back is a bug.

Dark mode drops the display weight one step (`--display-weight` 600 → 500) and
the label weight with it (500 → 400): light-on-dark always reads heavier.

| Use | Classes |
|---|---|
| Major section padding | `py-20 sm:py-28 lg:py-32` |
| Standard section padding | `py-16 sm:py-20 lg:py-24` (`manifesto`, `capabilities`) |
| Quiet section padding | `py-12 sm:py-14 lg:py-16` (`process`, `open-roles`, `enquiry-form`, `journal-notes`, `legal-notice`) |
| Minor section padding | `py-14 sm:py-16` (`client-band`) or `py-16 sm:py-20` (`site-footer`) |
| **Page-opening top step** | `pt-12 sm:pt-16 lg:pt-20` — the section that opens a route, and only its top edge; the bottom keeps its own register |
| Card padding | `p-6` / `p-7` / `p-8 lg:p-9` |
| Gutter | always `<Container>` — never a bespoke `max-w-*` + `px-*` pair |

**The opening top step is asymmetric on purpose.** A sticky bar already supplies
64–80px of air above the first word, so a section that opens a route and matches
its own bottom padding on top puts ~208px of nothing before the eyebrow — which
is what the hero shipped until 2.2.1 and what the owner called dead air. Two
rungs down on the top edge only; `hero`, `work-index`, `services-ledger`,
`contact-desk` and `team-grid` all take it in their page-opening form. A section
that does *not* open a route keeps its symmetric register.

Every page carries **at least two density registers at ≥1.75:1** and at least
two container widths. Do not flatten `process` or `open-roles` back to `py-32`:
that is what took `/services` and `/team` to a 1.14 spread and made both pages
read as one long grid.

---

## Signature, deviation, register, motion — the four declarations

**Signature — the ruled ledger row.** *A hairline-ruled list where each row is a
mono index, a display-size name and a right-aligned figure, with one row tinted
and bled to the band edge.* It recurs on `/` (services + selected work),
`/services` and `/work` — three routes, structural, describable from memory.

The instances must keep differing in what the **right column** carries — price
(`services-ledger`) · outcome (`selected-work`) · index and year in the left rail
(`work-index`). One device carrying three sections becomes monotony the moment
they converge — which is why `contact-desk` is a hairline grid and not a fourth
ledger. Three desks are a set of equal things; the ruled `<ol>` stays reserved
for ordered, contents-page lists.

**It is still three.** Everything 2.2.0 added that is a set of equal things went
into the hairline grid instead: four journal notes, six legal clauses, the
enquiry form's fields. The one new ruled list is the form's three-row
what-happens-next rail, which carries no index, no display-size name and no
right-hand figure — it is the FAQ's ruled disclosure shape, not the ledger's.

**Sanctioned deviation (exactly one).** *Coral display type at 3.09:1 — below the
4.5 body floor — kept as a large-text-only colour.* It is bought with a strict
size rule and a second derived coral (`--accent-foreground`, ≥5.13:1) that
carries every link, label and ring. That is THE CORAL RULE (invariant 3), and it
is the only house rule Foundry breaks on purpose. Do not add a second.

**Content register — Editorial.** 2–4 display words in the H1; the deck carries
the idea; long sentences and semicolons allowed in body copy. Section ledes are
**one sentence, ≤20 words** — the second sentence is always explanation the
section itself should carry. Eyebrows ≤3 words. No em dash as a colon substitute
in a label, a cell or a heading; `·` is the separator Foundry uses instead. The
em dash stays legal in display and body contexts, one per paragraph.

Three uses of the banned word *actually* survive on purpose, each contradicting
a stated industry norm and each carrying real contrastive stress:
"documentation your engineers will **actually** open on a Tuesday" · "who
**actually** does the work" · "the people who will **actually** be in your
meetings". The two filler uses were cut in 2.0.0. Do not remove the other three
mechanically.

**Motion personality — near-static.** Print does not move, it is *placed*.
One class only: micro feedback, `--duration-feedback` 150ms with
`--ease-feedback`, on hover and press of the two ramps, plus the 4px arrow nudge
on `ArrowLink` and `selected-work`. **No entrances, no ambient moment, no scroll
effects, ever** — Foundry is the catalog's near-static model and both
`motion-pass` and `CONTRACT.md` cite it as such. The FAQ's animation-free
`+`/`−` swap is the canon. Reduced motion is authored in, not retrofitted:
`motion-safe:` on both transforms, and a `prefers-reduced-motion` block that
zeroes the ramps.

**2.2.0 added two working controls and no motion.** The mode toggle swaps a
glyph with no transition (the FAQ's canon, applied to a second control), the
form's error and success states appear rather than animate, and both sit on
ramps that already existed. A theme whose personality is *placed, not moved*
does not get to make an exception for its newest thing.

---

**Background rhythm.** Sections alternate `bg-background` → `bg-muted` →
`bg-background` down every page, and every section ends with
`border-b border-border`. The fixed assignment is:

| `bg-background` | `bg-muted` |
|---|---|
| `hero`, `selected-work`, `work-index`, `manifesto`, `capabilities`, `faq`, `page-header`, `journal-entry`, `cta` | `client-band`, `services-ledger`, `process`, `testimonial`, `team-grid`, `open-roles`, `contact-desk`, `enquiry-form`, `journal-notes`, `legal-notice`, `site-footer` |

When inserting a section, check its neighbours in the page file first. The
styleguide page is the documented exception: its specimen groups share one
`bg-secondary` bed, ruled apart by hairlines (invariant 15).

**Hairline grids** (`grid gap-px … border border-border bg-border` with `bg-card`
children) are Foundry's signature for sets of equal things. **Ruled `<ol>` rows**
(`border-t border-border` on the list, `border-b border-border` on each item) are
the signature for ordered, table-of-contents-style lists — the services ledger
and selected work. Prefer both to free-floating cards with shadows.

---

## What never to touch

- **`app/`, `lib/`, `package.json`, `tsconfig.json` at the repo root** — that is
  the marketplace, not the theme. Foundry owns `themes/foundry/` and nothing
  else. (`lib/preview-registry.tsx` and `lib/stats.ts` carry Foundry's two
  integration rows; those are the marketplace's files, edited only when a page is
  added or removed.)
- **The `@theme inline` bridge** in the marketplace's `app/globals.css`. Foundry
  defines variables; the bridge maps them to utilities exactly once. Do not
  duplicate it in `theme.css` (the commented copy at the top of that file is
  documentation for standalone use only).
- **`.tt-foundry` / `.dark` selectors** in `theme.css`. Both scopes are required:
  `:root` for standalone, `.tt-foundry` for the embedded marketplace preview.
- **`screenshots/`** — generated by the publish pipeline.
- **`src/fonts/`** — the shipped woff2 files and their OFL licences. Replacing a
  family is a design decision that goes through `/design-pass`, not an edit; the
  licence file always travels with the font.
- Token *names*. Rebranding changes values, never the semantic names, or every
  component and both skills break.

---

## Imagery

`src/assets/laid-paper.jpg` is **AI-generated (Gemini)** and is licensed to you
with the theme. It is regenerable — the prompt lives in `src/assets/README.md`
and the marketplace produces it with `bun run generate-image`. It is not a
photograph of a real place, product, studio or customer, and it is the only
image Foundry ships (invariant 9).

## Grove

- Slug: grove
- Category: saas landing
- Price: $39
- Description: A calm Next.js SaaS landing page template for products that would rather explain than shout. Every row-like thing in it — the ledger table, the close checklist, the invoice list, the month-end register — sits on one 3rem pitch, so unrelated panels on unrelated pages line up like columns of the same book. Warm-paper palette at a declared 86° undertone, a deep green ink and a real ochre second hue carrying the status pills and the form error states. Typography is shipped, not wished for: Fraunces for display, Instrument Sans for text and IBM Plex Mono for the ledger register, self-hosted and OFL-licensed, on a 1.250 modular scale that lives in tokens rather than in 253 hardcoded utilities. Ships ten pages — home, pricing, blog, a chaptered product walkthrough, a long-form guide, a contact page with a hand-drawn area map that needs no tile provider, a combined privacy-cookies-terms page, sign-up and sign-in screens, and a living /components styleguide that renders every token and section on one page — with twenty-one independent sections. The interactive parts work: a light/dark toggle in the navbar that remembers, a monthly/yearly billing switch on the pricing cards, and three forms that validate in the browser with designed error and success states. A pricing table people read backed by a full comparison matrix, and a deliberate warm dark mode (not an inverted grey). Every internal link lands on a real page: no dead CTAs to wire up before you can demo it, and the blog's article and topic hrefs are the only fixtures, left pointing at your CMS on purpose. Built for Tailwind v4, Next.js 15 and React 19; every colour, size, duration and easing routes through semantic tokens in a single theme.css, so rebranding is a one-file edit. The three journal covers and the three author portraits are AI-generated and ship with the theme — no stock licence to buy, and the prompts are included so you can regenerate them.
- Pages: /, /pricing, /blog, /demo, /month-end-guide, /signup, /login, /contact, /privacy, /components
- Tags: next.js saas landing page template, saas website template tailwind, tailwind saas landing page, marketing, tailwind-v4
- Site: https://tailthemes.com/themes/grove
- Manifest: https://tailthemes.com/api/themes/grove
- Registry: https://tailthemes.com/r/grove
- Preview: https://tailthemes.com/preview/grove

### Grove AGENTS.md

# Grove — rules for agents

Grove is a calm SaaS landing theme (Tailwind v4 / Next.js 15 / React 19). The
demo content belongs to a fictional product, **Alder** — bookkeeping, runway and
month-end for design studios of two to twenty people.

**The signature.** *The rules moved off the paper and into the ledger.* Every
row-like structure in Grove — the hero ledger's table rows, the close checklist,
the invoice list, the feed list, the summary rail, the guide's month register,
the subprocessor register on `/privacy` — is locked to one **3rem pitch** with a
hairline under it, so unrelated panels on unrelated pages line up like columns of
the same book. `ui/ground.ts` owns the number; `ruledRow` is how you use it. If a
change breaks the pitch, the change is wrong.

**The two reversals, recorded rather than erased.** 1.x shipped a generated
paper texture; measured, its surviving component was a low-frequency cloud that
dragged a quarter of the hero a token-step darker and locally inverted the
hairlines, so 2.0.0 removed it. **Do not put a raster back behind text.** 2.0.0
then drew a `repeating-linear-gradient` ruling at the pitch across every
`bg-background` section, doubled in the hero — *2.2.0 removed that too*, by
owner decision. The paper stays warm, the pitch stays locked, the visible ruling
is gone. What survived both reversals is the part that was ever structural: the
row. Two consequences carry forward —

- **No text in Grove sits on a hairline any more.** The `--border` column of the
  contrast table stops being the theme's floor and becomes informational.
- **The hero lost the only structure it had**, so it gained one radial paper
  wash in its place (`paperWash` in `hero.tsx`). Nine other sections lost their
  ground layer and needed nothing: they already carry cards, hairline grids,
  tables, or the alternating `bg-muted` band.

**The one sanctioned deviation.** `--card: #ffffff` — the single achromatic
token in a system with a declared 86° warm undertone. Grove's cards are a *lit
surface*, not printed stock: they are the only elements in the theme that carry
a shadow, and white is what "lit" means on paper. Zero deviations is a fail and
two is a fail; this is the one.

**Register**: declarative-quiet. **Motion personality**: calm — micro-feedback
and disclosures only, no entrances, no ambient moment. **Type ratio**: 1.250,
anchored at 1.5rem. **Hero archetype**: A8, asymmetric offset.

Read this before changing anything. The four workflows in `.claude/skills/`
(`rebrand`, `add-section`, `add-page`, `check-quality`) are the sanctioned way to
extend Grove; they are written against the real file names and token names below.

---

## File map

```
themes/grove/
  theme.json                     manifest — must stay truthful (see "Manifest" below)
  README.md                      human quick start
  AGENTS.md                      this file
  .claude/skills/                rebrand · add-section · add-page · check-quality
  src/
    theme.css                    EVERY design decision — tokens, light + dark
    assets/                      generated imagery + README.md (the prompts)
      journal-month-end-close.jpg   featured journal cover, 1680×560
      journal-no-dashboard.jpg      journal cover, 1200×675
      journal-chasing-invoices.jpg  journal cover, 1200×675
      portrait-elin-marsh.jpg       author portrait, 320×320
      portrait-devan-okonjo.jpg     author portrait, 320×320
      portrait-priya-nadkarni.jpg   author portrait, 320×320
    app/
      home.tsx                   / — hero → proof → features → testimonials → pricing → faq → cta
      pricing.tsx                /pricing — page header → plans → comparison → billing faq → cta
      blog.tsx                   /blog — page header → article index → newsletter
      demo.tsx                   /demo — page header → walkthrough → testimonials → cta
      month-end-guide.tsx        /month-end-guide — page header → guide → testimonials
                                 → logo cloud → cta. The long-form page
      contact.tsx                /contact — contact map (opening) → contact desk
                                 → logo cloud → cta. The only page whose <h1>
                                 lives outside hero/page-header/auth-form
      privacy.tsx                /privacy — page header → legal doc → cta. Privacy,
                                 cookies and terms on one route, on purpose
      signup.tsx                 /signup — auth form (signup) → logo cloud → cta
      login.tsx                  /login — auth form (signin) only; the shortest page
      components.tsx             /components — the living styleguide: tokens, every
                                 section once, the primitives. Not in the nav.
    components/                  ONE FILE PER SECTION, no cross-section imports
      site-header.tsx            sticky nav; mobile menu is a <details> disclosure
      hero.tsx                   headline + ledger product panel; its four-cell
                                 summary rail is what exits the right frame
      logo-cloud.tsx             customer wordmark band
      feature-showcase.tsx       #how — three numbered steps + close checklist
      feature-split.tsx          #features — two mirrored copy/panel rows
      feature-grid.tsx           six secondary capabilities
      testimonials.tsx           lead quote + two supporting + stat strip
      pricing-table.tsx          three plan cards, monthly/yearly (prop: showHeading)
      pricing-comparison.tsx     full comparison matrix (semantic <table>)
      faq.tsx                    <details> accordion (prop: variant "general" | "billing")
      cta.tsx                    closing accent panel
      newsletter.tsx             subscribe form (island)
      page-header.tsx            editorial opener (props: lead? meta? variant
                                 "full" | "compact") — eyebrow, h1, an optional
                                 lead and an optional one-line mono meta strip
      blog-index.tsx             topic pills + featured card + two-up grid
      auth-form.tsx              sign-up / sign-in (prop: variant); owns its page's <h1>
      walkthrough.tsx            four chapters of one month, each with a figure
                                 (prop: showHeading, false on /demo)
      guide.tsx                  the long-form template: contents rail + prose,
                                 then the month as a full-bleed register
      contact-desk.tsx           contact routes, who answers, message form (island)
      contact-map.tsx            address, getting here, and the drawn area plan
                                 (prop: opening — carries /contact's h1)
      legal-doc.tsx              privacy + cookies + terms, with the subprocessor
                                 register as its oversized moment
      site-footer.tsx            footer nav + legal bar
      ui/                        shared primitives — the ONLY cross-section imports
        button.tsx               Button, ButtonLink, buttonClasses
        badge.tsx                Badge, Eyebrow
        container.tsx            Container (prose | default | wide)
        icons.tsx                GroveMark — the brand glyph; one of the two
                                 hand-drawn SVGs (the other is the contact map)
        article-card.tsx         ArticleCard + the Post type
        type.ts                  the type ladder, weight steps, numeral rules
                                 and motion vocabulary, as class strings
        ground.ts                the 3rem row pitch — the signature. Named for the
                                 ruled ground it used to also draw; the filename
                                 stays so a 2.1.0 buyer's import survives
        field.tsx                field classes and the ochre error state — pure,
                                 so the styleguide can render the specimens
        form.tsx                 the submit-time validation hook (island)
        mode-toggle.tsx          the header's light/dark switch (island)
```

---

## Invariants

**1. All design lives in `src/theme.css`.**
No other file may introduce a colour, a radius, a type size, a duration or a
font family. If a change needs a new visual value, it becomes a token in
`theme.css` first — in both the light block (`:root, .tt-grove`) and the dark
block (`.dark, .tt-grove.dark`) — and the matching entry is added to `tokens`
and `tokensDark` in `theme.json`. Fifty-two tokens, both modes.

The two `ui/*` constant modules are the *only* sanctioned indirection, and they
introduce no values of their own: `type.ts` and `ground.ts` compose token
references into class strings so that a size or a pitch is written once. A
number that appears in a component and not in `theme.css` is a bug.

**2. Semantic utilities, or a token reference. Never alpha as a colour state.**
Allowed: `bg-background` `text-foreground` `bg-card` `text-card-foreground`
`bg-primary` `text-primary-foreground` `bg-secondary` `text-secondary-foreground`
`bg-muted` `text-muted-foreground` `bg-accent` `text-accent-foreground`
`border-border` `outline-ring` `rounded-themed` `font-sans` `font-mono`.

The marketplace's Tailwind bridge maps the colour, radius and font-family tokens
to utilities but not the state ramp, the type ladder or the motion vocabulary.
Those are referenced directly — `bg-[var(--primary-hover)]`,
`border-[var(--input-border)]`, `text-[length:var(--text-d3)]`,
`duration-[var(--duration-feedback)]`. That form is a *token reference*, not an
arbitrary value: it carries no literal, it survives `rebrand`, and it picks up
each mode with no `dark:` utility. Prefer `ui/type.ts` and `ui/ground.ts`, which
compose the common ones once.

**Alpha is not a colour state.** `hover:bg-primary/90`, `border-primary/50` and
`bg-primary/30` are banned: alpha moves L, C and H at once, so a "10% darker"
hover is really a different hue at a different chroma, and it composites
differently on every ground it lands on. Use the ramp tokens
(`--primary-hover`, `--primary-active`, `--surface-tint`, `--chart-projected`,
`--input-border`). Alpha stays legal for **genuine translucency** only — the
sticky header's `bg-background/85` behind `backdrop-blur`, and the dark-mode
scrim over journal covers.

**Bare `shadow-*` colours are banned** in the same spirit: shadows take
`shadow-[var(--shadow-color)]`, which is Grove's ink at 7% (light) and a warm
near-black at 55% (dark). A neutral-black shadow on warm paper is the cheapest
generated tell there is.

Forbidden anywhere in `src/components` and `src/app`: hex values, `rgb()`,
`text-white`, `text-black`, and every palette class (`bg-emerald-600`,
`text-stone-500`, …). The publish gate lints for this.

**3. Contrast rules that come out of Grove's specific palette.**
Every pair below is computed, not eyeballed; the full table is in
`.claude/skills/check-quality` §4, `ops/design/grove-retrofit-2026-08-13.md` and
`ops/design/grove-2.2.0-2026-08-13.md`.

- **Text no longer lands on a hairline.** 2.0.0 pinned the whole theme's floor
  at `--muted-foreground` on solid `--border` (4.66:1) because the ruled ground
  put a rule under every line of type. With the ruling gone that pair is moot:
  hairlines separate rows, and text sits *beside* them, never on them. The
  theme's real floor is now `--muted-foreground` on `--secondary`, 5.26:1 light.
  `--primary` stays at L 0.469 anyway — moving it re-opens every other pair for
  no gain.
- **Error states are ochre, not red.** Grove has no destructive token and is not
  getting one: a third hue for a state that lasts four seconds would break a
  declared two-hue system. The status axis already reads green = settled,
  ochre = waiting on someone, secondary = not started, and a field that needs
  the reader is waiting on someone. `ui/form.tsx` owns it —
  `--accent-foreground` on the border and the message (8.25:1 on `--card`),
  always with `aria-invalid`, a `CircleAlert` and a sentence.
- **`text-primary` is legible on every surface in the theme**, in both modes —
  worst pair 5.32:1 (light, on `bg-accent`). That is deliberate: 2.0.0 pushed
  `--primary` to L 0.50 precisely so links, ticks, figures and marks could share
  one green without a second ink token. It replaced the 1.x rule that sent green
  text to `text-accent-foreground` on tinted surfaces.
- **`text-accent-foreground` is ochre, not green.** `--accent` is a real second
  hue (ΔH 74° from `--primary`), so accent text reads as the other half of a
  colour pair: eyebrows, mono stamps, topic labels, and the "waiting on someone"
  half of the status axis. Do not use it where you mean an action.
- **Status colour is never the only signal.** Green means settled, ochre means
  waiting, secondary means not started — and every pill states its status in
  words as well.
- Never place text on `bg-primary` other than `text-primary-foreground`, and
  never at reduced opacity — `text-primary-foreground/80` fails AA.
- `border-border` is a hairline and the ruling, not a text colour.
- **Form inputs do not use `border-border`.** WCAG 1.4.11 wants 3:1 on the
  boundary of a UI component, and the hairline is 1.24:1 light / 1.42:1 dark.
  Every input in Grove carries **`border-[var(--input-border)]`** — 3.86:1 on
  `bg-card` and 3.49:1 on `bg-background` in light, 3.91:1 / 4.30:1 in dark.
  This token replaced the computed `border-muted-foreground/70` blend of 1.x and
  is deliberately a hair stronger, because the alpha version fell to 2.94:1 on
  `bg-background` panels. Keep inputs on `bg-card` or `bg-background`.

**4. Sections are independent.**
A file in `src/components/` may import from `./ui/*` and from `lucide-react`,
and nothing else. No section imports another section. This is what makes
per-component registry install work.

**5. Pages are pure composition.**
Exactly one `<h1>` per page, and it belongs to the page's opening section:
`page-header` on `/pricing`, `/blog`, `/demo`, `/month-end-guide` and
`/privacy`; `hero` on `/`; `auth-form` on `/signup` and `/login`; `contact-map`
in `opening` mode on `/contact`. Never stack two of them. The `guide` section
carries `<h2>`s for its chapters, which is body structure inside one article
rather than five marketing sections.

**5a. One opening device per page, and no page repeats another's.**
2.2.0 opened `/pricing`, `/demo`, `/month-end-guide`, `/contact` and `/privacy`
with a byte-identical eyebrow → Fraunces H1 → lede → hairline → three-mono-facts
stack at the same y-offsets, so five of nine routes showed one screen. The rail
was the tell: on every one of the five its facts were restated verbatim by the
section directly beneath it, which is what pushed the section boundary to ~590px
of a 900px viewport. 2.2.1 removed the rail from `page-header` entirely and made
each page hand over to its own device inside the first screen —

| page | what a buyer sees first |
|---|---|
| `/` | the ledger panel, running off the right frame |
| `/pricing` | the billing switch and three plan cards |
| `/blog` | the topic pills and the full-bleed journal cover |
| `/demo` | walkthrough chapter 01 — `00:00 · 1 March` and the feeds panel |
| `/month-end-guide` | the numbered contents rail beside chapter 01 |
| `/contact` | the drawn area plan, full-bleed |
| `/privacy` | the legal contents rail and "The short version" |
| `/signup`, `/login` | the editorial column beside the sticky form card |

Rules that keep it that way. **A masthead is one option, never the default**: if
a page has a section that answers the question the buyer arrived with, that
section opens the page and the masthead shrinks to `variant="compact"` above it
or disappears. **Nothing is stated twice within a screen of itself** — a fact in
a masthead that the next section also carries is deleted from the masthead, not
reworded; the survivors ride the one-line `meta` strip. And **a section that
opens a page hides its own opener** rather than stacking a second eyebrow, H2
and lede under the page's own: that is what `showHeading` is for on
`pricing-table` and `walkthrough`, and what `opening` is for on `contact-map`.
Adding a page means naming its device here before writing it.
`/components` is the one page that owns its `<h1>` directly (it opens with its
own intro, not with a section), which is why the three page-opening sections take
`headingLevel`: `hero`, `page-header` and `auth-form` each accept
`headingLevel?: 1 | 2`, default `1`. The styleguide passes `2` so its specimens
do not stack four `<h1>`s on one page. The classes are identical at either level
— only the tag changes. **Pages never pass it**; it exists for the styleguide.
`src/app/*.tsx` import sections, stack them, and pass content-selecting props
(`variant`, `showHeading`, `eyebrow`/`title`/`lead` on `page-header`). No data
fetching, no Next-specific APIs, no `next/link`, no `next/image` — pages must
render standalone *and* inside the marketplace preview route. Links are plain
`<a href>`.

**6. Server components, with a closed list of five islands.**
Through 2.1.0 nothing in Grove ran client JavaScript. 2.2.0 sanctions
interaction, deliberately and by name rather than by precedent. **Five files
carry `'use client'`, and a sixth is a change to this list, not a judgement
call.** Each one is here because it cannot be a `<details>` or a native control:

| Island | Why nothing native does this |
|---|---|
| `ui/mode-toggle.tsx` | Writes a class onto an ancestor and persists the choice |
| `ui/form.tsx` | The validation hook the three forms share. Its pure half lives in `ui/field.tsx` **without** the directive, because `/components` renders the rest and error specimens from a server component and a client module's plain function cannot be called there. One boundary, two files, no ambiguity — shipping them as one file 500'd the styleguide, and the capture gate is what caught it |
| `pricing-table.tsx` | One state switches nine strings across three cards; three separate disclosures would let a reader compare a monthly price against a yearly one |
| `newsletter.tsx` | Designed error and success states |
| `contact-desk.tsx` | Same |

Rules that keep the list from growing on its own:

- **The mobile menu and the FAQ stay `<details>`.** Zero JavaScript, native
  keyboard behaviour, no hydration cost. Do not "upgrade" them to React state.
- **Every island degrades.** Forms keep a real `action` and their `required` /
  `type` / `minLength` attributes; `noValidate` is set in an effect, so with
  scripting off the browser still gates the submit and the buyer's endpoint
  still receives the POST. The mode toggle simply does nothing.
- **`auth-form` is the quietest island**: it validates and draws error states,
  then lets a *valid* submit through to the native POST. An auth screen that
  swallowed its own submission to show a success card would be lying — the
  buyer's route handler decides whether the sign-in worked.
- **No island fetches anything, and none of them own layout.** State selects
  strings and classes. A section that needs data is not a Grove section.

**7. One radius language, with one step down.**
`rounded-themed` (`--radius`, 0.75rem) everywhere. `rounded-full` is reserved for
pills, status dots and the author initials disc. Chart bars take
`rounded-t-[var(--radius-sm)]` (0.25rem) — nested corners step down, and a 12px
radius on a 23px bar renders a lozenge rather than a bar. Nothing else.

**8. Focus is always visible.**
Every interactive element carries
`focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`
(inset variants use `focus-visible:-outline-offset-1/-2`; text links inside prose
use `outline-offset-4`). Never remove it. The one place the ring is *moved*
rather than dropped is `ui/article-card.tsx`, where the whole card is the click
target: the link sets `focus-visible:outline-none` and the card draws the ring
via `has-[a:focus-visible]:…`.

**8a. Icons come from `lucide-react`; the mark does not.**
Import named icons from `lucide-react` in the section that uses them
(`import { Check } from "lucide-react"`). House rules, no exceptions:

- **Size via `className`** only — `size-4` inline (list ticks, link and button
  arrows, table marks), `size-5` for controls (nav toggle, FAQ chevron),
  `size-7` for display marks. Never `size`/`width`/`height` props; lucide
  defaults to 24px, so an icon with no size class is a bug.
- **`strokeWidth={1.5}` everywhere**, including `GroveMark`. Grove's line
  weight is lighter than lucide's default 2 — one value, no exceptions.
- **Colour is `currentColor`** through a token class (`text-primary`,
  `text-muted-foreground`, or inherited). Never set `stroke`/`fill`.
- **`aria-hidden="true"`** unless the icon is the only label — Grove's icons are
  all decorative, and the ones carrying meaning (the pricing comparison ticks)
  pair with an `sr-only` sentence.
- **Two hand-drawn SVGs, and the list is closed.** `GroveMark` in
  `ui/icons.tsx` (identity is bespoke) and the area plan in `contact-map.tsx`
  (a drawing of a place is *content* — no more an icon than a journal cover is).
  UI iconography is lucide and always will be. Do not add hand-rolled SVGs to
  `ui/icons.tsx`, and do not re-export lucide icons through it. A third drawing
  is an argument about which of these two it replaces.
- **A drawn illustration obeys the same token rule as everything else**: every
  shape paints `currentColor` and inherits it from a token text class on its
  group, so there is not one colour value in `contact-map.tsx`. Label sizes in a
  drawing are *drawing* sizes — SVG resolves a CSS length into user units, so the
  map's street names are `--text-d1` against a 30-unit carriageway. `--text-caption`
  would be correct as UI and illegible as cartography.
- Any new package an icon set drags in must be declared in `theme.json`
  `dependencies`.

**9. Real content only.**
Grove ships Alder's copy — real plan names, real numbers, real article excerpts.
Replacing it is fine; replacing it with lorem ipsum, `Feature One`, or grey
placeholder boxes is not.

**9a. Imagery is deliberate: three covers and three faces.**
Grove is a token-built theme: the hero ledger, the runway bars, the review queue
and the close checklist are all drawn from tokens on purpose — they are the
product demonstration, and a photograph in any of those places would be a lie
about what the theme ships. The photography in Grove is the three journal
covers — because a blog index of three same-length titles in the same weight
needs something type cannot give it, and because the buyer's CMS has an image
field on day one — and the three author portraits.

**The portraits replaced a placeholder, which is the only reason they exist.**
Through 2.1.0 a byline carried a disc with the author's initials on it: a
placeholder wearing a design. Grove ships faces for the three people who write
its journal, generated in one session against one lighting direction so they
read as a commissioned set rather than three stock photographs. They are
fictional people. The initials disc survives in `ArticleCard` as the honest
empty state for a CMS author record with no image, and the styleguide captions
it.

They carry `alt=""` and `aria-hidden`, deliberately: the person's name is set
immediately beside the portrait in every layout that uses one, and a described
image would make a screen reader announce the same human twice.

**`testimonials` stays typographic and that is a decision, not an omission.**
Its quotes are pull quotes at display size with the attribution set as an
editorial caption; a row of faces there would turn Grove's quietest section into
a review widget. Faces appear where a face is the information — a byline, and
the two people who answer the contact page.

Six files, and no seventh. Grove's grounds are drawn from tokens; nothing
raster sits behind text.

Rules if you add more:
- Assets live in `src/assets/` and are **statically imported** by the component
  that uses them (`import cover from "../assets/x.jpg"`). Never `next/image`,
  never an external URL — the standalone zip has to run offline.
- Render with a plain `<img>` carrying `src`/`width`/`height` from the import,
  `object-cover`, and a frame built from tokens (`rounded-themed`,
  `border-border`). The `cover` prop on `ArticleCard` requires `alt` in its
  type; keep that pattern — descriptive `alt`, or `alt=""` + `aria-hidden` for a
  purely decorative backdrop.
- **No text set over a photograph** anywhere in Grove, and no raster behind text
  at all. If a *photograph* ever needs text on it, the contrast is computed
  against the actual pixels, and the fix is a scrim, not a lighter text colour.
- **Covers carry a dark-mode scrim.** A photograph of warm paper is by a wide
  margin the brightest object on a `#120f09` ground and reads as a lit window
  cut in a wall. `ArticleCard` draws one `bg-background/25` layer over the image
  in dark mode only — token-coloured, genuine translucency, one asset still
  serving both modes. Palette anchoring fixes the hues; it does not fix the
  lightness, and this is the honest patch for the difference.
- Prompts are palette-anchored and recorded in `src/assets/README.md`, one per
  file. No text, no logos, no recognizable people in a generated image.

**Imagery disclosure.** The files in `src/assets/` are **AI-generated (Gemini)**
and are licensed to you with the theme — they are ours to pass on, with no stock
licence to buy and no attribution to carry. They are also regenerable: every
prompt is recorded in `src/assets/README.md`, and the marketplace produces them
with `bun run generate-image`. They are photographs of nothing real — not a real
place, product, studio or customer.

**9b. Background effects are token gradients, and there are exactly two.**
Grove has a *background effect layer*: CSS gradients that add depth to a surface
without an asset and without a colour. It is the one sanctioned exception to the
"no `bg-[…]`" half of invariant 2, and it is narrow.

The two shipped effects:

| Where | Effect | Layers |
|---|---|---|
| `hero.tsx` — section | Paper wash | `radial-gradient(120% 100% at 78% 50%, var(--muted) 0%, var(--background) 58%)` — lights the right half, where the ledger panel sits, so the white card is lit from behind rather than pasted onto flat paper |
| `cta.tsx` — accent panel | Corner wash | `radial-gradient(90% 110% at 10% 0%, var(--card) 0%, var(--accent) 58%)` — lifts the panel where the mark and headline sit |

Both are declared as a documented `const` at the top of the section that uses
them, and both go on the section element itself with the flat `bg-*` utility
left in place as the fallback. **The budget is still two.** 2.1.0 spent it on
the ruling (used site-wide in two weights) and the corner wash; 2.2.0 spends it
on the hero wash and the corner wash. The hero got the replacement because it
was the only band the ruling was actually carrying — strip it and 60vh of flat
`--background` sits behind the headline. Nine other sections lost the ruling and
needed nothing.

Rules for any effect added later — all of them, or it is not an effect, it is a
colour leak:

- **`var()` only.** Every colour in the gradient is a bare `var(--token)`. No
  hex, no `rgb()`/`hsl()`, no palette class, no `color-mix()`. That is what
  makes the effect survive `rebrand` by construction and pick up each mode's
  values with no `dark:` utility — Grove has none, deliberately.
  (`color-mix()` is excluded for a second reason: Tailwind emits a
  non-`color-mix` fallback at *full* strength, so a 6% wash silently becomes a
  100% one on an old engine — and that fallback is what would fail contrast.)
- **Interpolate between verified surfaces.** A gradient's every intermediate
  step lies between its endpoints in luminance, so if both endpoints are
  surfaces already in the check-quality contrast table, the effect cannot
  introduce a new floor. `--card` → `--accent` is safe. `--primary` → anything
  is not: text on `--primary` is only ever `--primary-foreground`.
- **Hairlines are the exception to that rule** and get computed instead: a
  `--border` rule under text is a real composite, and the worst case is text on
  solid `--border` — 4.79:1 light, 5.21:1 dark for `--muted-foreground`, which
  is why `--border` is the only hairline colour allowed here.
- **Decorative only.** No `aria` anything, no extra DOM node, no
  `pointer-events`. The effect is a `background-image` on a surface that already
  exists; the flat `bg-*` utility stays on the element as the fallback.
- **Motion-free.** Grove's motion personality is micro-feedback and disclosures
  only. An animated gradient is not a Grove effect.
- **No text over a gradient's steep end**, same spirit as the no-text-over-image
  rule: keep the type on the calm part of the wash.
- **Two is the budget.** Grove's whitespace is deliberate; the third effect
  makes the first two noise. Adding one means arguing which one it replaces.
- **The pitch is not negotiable.** `3rem` is `ruledRow`'s `min-h-12` *and* the
  `h-12` on the hero ledger's rows, the guide's register and the subprocessor
  register. Change one and the signature stops being structural, which is the
  only reason it counts as a signature at all.

**9c. Typefaces are shipped, not wished for.**
Three roles, all self-hosted in `src/fonts/` with their OFL alongside, wired by
`src/fonts.ts` and registered in `PREVIEW_FONTS` so the zip, the preview and the
screenshots render the same letterforms.

| Token | Family | Why this one |
|---|---|---|
| `--font-display` | **Fraunces** (variable, `opsz`/`SOFT`/`WONK`) | An old-style with real optical sizing: it gets warmer as it gets larger, which is what paper does and what a UI grotesque cannot. `WONK` and `SOFT` are pinned at 0 — restrained, letterpress, never craft-brand. |
| `--font-text` (`--font-sans`) | **Instrument Sans** (variable) | A slightly narrow neo-grotesque with humanist warmth that holds at 14px in a table cell, which is where most of Grove's type lives. Deliberately not Inter. |
| `--font-mono` | **IBM Plex Mono** (400/500) | Grove has a genuine machine register — timecodes, dates, refs, amounts. Plex reads as *ledger*; JetBrains reads as *terminal*. Proper tabular figures, slashed zero. |

Rules:

- **`next/font/google` is forbidden.** It fetches at build time and breaks the
  offline-zip guarantee that already governs imagery.
- **OFL or Apache-2.0 only**, licence file ships beside the woff2. A commercial
  foundry face is an owner escalation, never an agent decision.
- **Every face keeps a metric-adjusted fallback** so the swap does not reflow.
- **Body copy never sets in the display face.** `text.d1`–`d4` carry
  `--font-display`; everything else inherits `--font-sans`.
- Adding or changing a face means updating `theme.json` `tokens`/`tokensDark`,
  the `PREVIEW_FONTS` entry, and re-shooting the captures — `bun run check`
  fails a `--font-*` token whose first family does not ship, and
  `bun run screenshots` asserts `document.fonts.check()` before every capture.

**9d. The type ladder is tokens, and `ui/type.ts` is how you use it.**
Ratio **1.250**, anchored at 1.5rem (calm SaaS, per `themes/CONTRACT.md`; no
neighbouring theme in the category shares it). `--text-d3` and `--text-d4` are
fluid `clamp()`s across 375→1280, so **a heading never carries a responsive size
ramp** — that ramp is where 1.x ran two different ratios inside one headline.

- **A bare `text-lg` is a bug.** Import from `ui/type.ts`: `text.d1`…`text.d4`,
  `text.lead`, `text.body`, `text.small`, `text.caption`, `text.eyebrow`.
- **Weight steps down in dark.** `weight.display` is `font-semibold` in light
  and `font-medium` in dark, because light-on-dark reads bolder at display size.
  `weight.displayQuiet` is the 500→400 pair for pull quotes.
- **Tracking is size-dependent** and comes with the step: −0.025em at d4 down to
  0 at body, +0.09em on uppercase eyebrows.
- **Numerals.** `numeric.mono` (money, refs, dates, timecodes) and
  `numeric.sans` (counts and headline figures) both carry `tabular-nums`. Every
  price, KPI, stat, table cell and axis label gets one of them. This theme sells
  bookkeeping; a price column that does not line up is the one typographic error
  its buyer's own customers would notice.

**10. Manifest stays truthful.**
Adding, renaming or deleting a section or page means updating `theme.json`
(`pages`, `components`, and `tokens`/`tokensDark` if tokens moved) in the same
change. Bump `version` for anything a buyer would notice.

**11. Every internal link resolves.**
An href in Grove points at one of the nine *product* pages, at an anchor that
exists on the home page (`#how`, `#features`, `#invoicing`, `#pricing`), at an
anchor on the page it sits on (the guide's contents rail, the legal page's), at
a `mailto:`, or at the one `tel:` on the contact page.
`/components` is deliberately not among them: nothing links to it, because Alder
does not know it is a theme (see invariant 12). Never invent a
route to make copy read better — retarget the link, change the copy, or build
the page the label already promises. `/month-end-guide` is the third case: the
footer named it before it existed, and 2.1.0 shipped it rather than quietly
retargeting the label. `/contact` is the fourth: 2.1.0's footer offered "Talk to
us" as a `mailto:`, which is not a contact page, and 2.2.0 built the page the
label was standing in for. Check after any edit:

```bash
grep -rhoE 'href[=:] ?[{"]*"[^"]*"' themes/grove/src | grep -oE '"[^"]*"' | sort -u
```

The two documented exceptions are fixture data, not chrome: article permalinks
(`/blog/<slug>`) in `blog-index.tsx` and the topic pills (`/blog/topic/<x>`).
They belong to the buyer's CMS. They also stay unwired for a structural reason:
the marketplace's page plumbing is **flat-slug only** — `lib/pipeline/package.ts`
writes a fixed `../../` depth for every route, and `lib/pipeline/screenshots.ts`
names captures `<slug>-<mode>-<width>.png` with no directory — so a nested
`/blog/<x>` route ships a zip that does not build and a capture with nowhere to
land. Wire them up when that is fixed, not before; `/month-end-guide` is
deliberately flat for the same reason.
Form `action` targets are POST endpoints, not navigation — `auth-form` posts to
its own path, `newsletter` posts to `/subscribe`.

**12. The styleguide is part of the theme, and it stays complete.**
`src/app/components.tsx` renders `/components`: the token panel, every section
once, and the five primitives in one frame. It is the buyer's living
documentation of their own system and it ships in the zip. Rules:

- **`data-component` is a contract, not decoration.** Every `name` in
  `theme.json` `components` must appear as a `data-component` on this page —
  the `ui/*` primitives collectively as the single `data-component="ui"` frame,
  every other component as its own frame. The screenshots pipeline captures
  per-component element shots from those attributes, and `check-quality` §9
  fails the build if one is missing. **Adding a section means adding its frame
  in the same change.**
- **Each section renders exactly once**, so `#how`, `#features`, `#pricing` and
  the form input ids stay unique on the page. Sections needing props get one
  representative instance, and the prop is printed in the frame's mono label.
- **Values are never printed, only token names.** No hex, no `0.75rem`. The
  swatches read the variables through the semantic utilities (`bg-primary`,
  `bg-muted-foreground`, `bg-ring`…), so a rebrand recolours the page instead of
  leaving a stale number in the markup.
- **Nothing links to it** and it is absent from `site-header`/`site-footer` nav.
- **Styleguide-only helpers live in the page file** (`Frame`, `Label`, `Group`,
  `Cell`). They caption specimens; they never graduate to `src/components/`.
- The page ground is `bg-muted` throughout, which is the one deliberate
  exception to the alternating-band rhythm below: the frames need one constant
  ground to read against.

---

## Type, spacing and composition (follow it, do not invent new steps)

Sizes come from `ui/type.ts`; spacing comes from the table below. Nothing here
is a suggestion — a size that is not on the ladder cannot be rescaled by a
buyer, and that is the thing this theme is sold as.

| Use | Classes |
|---|---|
| Page h1 | `` `${text.d4} ${weight.display} text-balance` `` |
| Section h2 | `` `${text.d3} ${weight.display} text-balance` `` |
| Lead quote, stat figure | `` `${text.d2} ${weight.display}` `` (`weight.displayQuiet` for quotes) |
| Card / panel title | `` `${text.d1} ${weight.display}` `` |
| Card h3 | `` `${text.lead} font-semibold` `` |
| Lead paragraph | `` `${text.lead} text-muted-foreground` `` |
| Body | `` `${text.small}` `` or `` `${text.body} text-muted-foreground` `` |
| Eyebrow / label | `<Eyebrow>` or `` `${text.eyebrow} font-semibold` `` |
| Money, refs, dates | `` `${numeric.mono}` `` |
| Counts and figures | `` `${numeric.sans}` `` |
| Loud section padding | `py-20 sm:py-28 lg:py-32` |
| Quiet section padding | `py-16 sm:py-20 lg:py-18` |
| Opener / closer padding | `py-16 sm:py-20 lg:py-24` and `py-20 sm:py-24 lg:py-28` |
| Card padding | `p-6` / `p-7` / `p-8 lg:p-10` |
| Gutter | always `<Container>` — never a bespoke `max-w-*` + `px-*` pair |
| Row pitch | `ruledRow` (`min-h-12`) on anything that reads as a row |

**Density registers are counted, not felt.** Loud is `lg:py-32`, quiet is
`lg:py-18` — a 1.78:1 spread — and they **alternate** down every page. Two loud
sections adjacent is a dead zone; two quiet is a wall. `logo-cloud` at `lg:py-8`
is punctuation between registers, not a register.

**Every page carries, and a new page must too:**

- **≥2 container widths.** `default` is the rhythm; `wide` belongs to
  `testimonials`, `pricing-comparison`, `blog-index`, the `guide`'s register
  band and `cta`.
- **≥1 container break** — a section that leaves the gutters entirely.
- **Exactly one oversized moment**, never zero and never two: home is the hero
  ledger panel running off the right frame, `/pricing` is the full-bleed
  comparison matrix, `/blog` is the full-bleed featured cover, `/demo` is the
  `3h 40m` band, `/month-end-guide` is the full-bleed register — six dated rows
  at the pitch, at twice the width of the prose column above them — `/contact` is
  the full-bleed area plan, and `/privacy` is the full-bleed subprocessor
  register. `/signup` and `/login` deliberately have none: their single job is
  one form, and a second loud object on a conversion screen is a competing
  primary action. That exception is recorded, not accidental.

**Motion is a vocabulary, not a reflex.** `motion.feedback` (140ms, ease-out) for
hover and press; `motion.state` (240ms, house curve) for disclosures;
`motion.nudge` for the ≤2px arrow lean. Everything that moves is authored
`motion-safe:` from the start. No entrances — calm things do not arrive, they are
already there. The focus ring is never transitioned.

Section backgrounds alternate `bg-background` → `bg-muted` → `bg-background` down
each page, and every section ends with `border-b border-border`. Keep that rhythm
when inserting a section: check its neighbours in the page file first. A section
is a flat token surface and nothing else — since 2.2.0 there is no ground layer,
no `aria-hidden` first child and no `relative` wrapper to remember. The two
`bg-[…gradient…]` effects (invariant 9b) live on the section element itself.

Hairline grids (`grid gap-px … border border-border bg-border` with `bg-card`
children) are Grove's idiom for lists of equal things. Prefer them to
free-floating cards with shadows.

---

## What never to touch

- **`app/`, `lib/`, `package.json`, `tsconfig.json` at the repo root** — that is
  the marketplace, not the theme. Grove owns `themes/grove/` and nothing else.
- **The `@theme inline` bridge** in the marketplace's `app/globals.css`. Grove
  defines variables; the bridge maps them to utilities exactly once. Do not
  duplicate it in `theme.css` (the commented copy at the top of that file is
  documentation for standalone use only).
- **`.tt-grove` / `.dark` selectors** in `theme.css`. Both scopes are required:
  `:root` for standalone, `.tt-grove` for the embedded marketplace preview.
- **`screenshots/`** — generated by the publish pipeline.
- Token *names*. Rebranding changes values, never the semantic names, or every
  component and both skills break.
- **`src/fonts/` and `src/fonts.ts`.** Swapping a face is a version bump with
  new captures, not an edit. Deleting `fontClassName` breaks the zip's layout
  and the preview registry at once.
- **The 3rem pitch** (invariant 9b). It is the signature.
- **The five-island list** (invariant 6). Adding a sixth is an argument, in
  writing, about why nothing native does the job.
- **`ArticleCard`'s initials fallback.** It is the designed empty state for an
  author with no photograph, not dead code left behind by the portraits.
- **The hero panel's `lg:w-[130%]` summary rail.** It is not a stray number:
  the panel is fitted (`left:58%` to `right:0`) because the ledger's Amount
  column is right-aligned and any wider panel throws the amounts off the frame,
  so the *rail* is what runs off instead. Four cells on a 130% measure put each
  at 32.5% of the panel, so three cells and the third one's closing hairline
  fill the visible band and the fourth begins at 97.5% and exits. The proportion
  is width-independent, so the sliver is always 2.5% of the panel — 13px at
  1280, 20px at 1920 — and the fourth cell's own `p-5` is 20px, so the frame
  always cuts padding and never a glyph. The panel's `overflow-hidden` is what
  clips it. Change the percentage, the column count or the padding and the exit
  goes back to being a stop.
- **The drawn area plan in `contact-map.tsx`.** It is the reason the contact
  page needs no tile provider, no API key and no cookie banner. Replacing it
  with an embedded map trades all three away for a worse-looking block.
- **The pricing comparison matrix.** Semantic `<table>`, grouped row headers,
  `sr-only` sentences on every tick and dash, horizontal scroll on small
  screens. Almost nothing in the catalog is this good.
- **The `/signup` editorial column beside the sticky form card**, including the
  `WHAT HAPPENS NEXT` 01/02/03 list and the three trust bullets. It is the
  best-converting screen in the theme.
- **The `<details>`-based FAQ and mobile menu.** Zero client JavaScript, native
  keyboard behaviour, no hydration cost. Do not "upgrade" them to React state.
- **`ArticleCard`'s no-cover state** and the styleguide caption documenting it.
- **Alder's voice.** Declarative-quiet, specific, never salesy. The fixture
  spine — `3h 40m`, `8.4 months`, `19 days`, `£142,860`, `1,400+ studios`,
  `£18/£49/£129` monthly and `£180/£490/£1,290` yearly (ten months for twelve,
  exactly, everywhere), Fieldwork Studio at twelve people, the six named studios
  and the five named humans, plus 2.2.0's contact and legal facts —
  `Unit 4, Portwall Yard, Redcliffe, Bristol BS1 6NB`, `0117 496 0142`,
  one working day, `eleven people`, company `14882031`, VAT `GB 412 7788 03`,
  EU hosting in Falkenstein, ninety days to deletion, five named subprocessors —
  agrees everywhere it appears. Changing one number means changing all of them. The `guide`'s register is the worked example: its
  six durations sum to `3h 40m` and its running total hits the walkthrough's own
  `01:10`, `02:20` and `03:40` on the days the walkthrough names.

## Kiln

- Slug: kiln
- Category: e-commerce
- Price: $39
- Description: A Tailwind ecommerce Next.js template for makers selling small-batch physical goods, built so the photography is the layout and the theme stays out of its way. The shelf is the move: a full-bleed hairline plate of white bays, one square photograph each, running edge to edge on the shop front and again on the product page. Fired clay against a slate-sage second hue drawn out of the ash glazes in the photographs, a 6px radius, and a display serif set on a real optical-size axis so the headline is set rather than scaled. The cart works: add to cart from the product page in the glaze you picked, watch the count in the header move, open the drawer and see what is in it, change quantities or remove a line on the cart page and the subtotal, the delivery charge and the total follow - all of it persisted, and serialized into the checkout POST. Ships a shop front, a full product page with a three-shot gallery of the same pot, a working cart with an order summary and a gift note, a making page carrying the four-firings-a-year calendar, a delivery page with a real rates table, a returns FAQ and the shop's small print, and a living styleguide - fifteen sections, eight primitives, and a light/dark toggle in the navbar because the photography holds in both. Every link in the header, the footer and the cart resolves to a page in the zip. Three self-hosted OFL typefaces (Newsreader, Karla, Spline Sans Mono) and thirteen AI-generated ceramics photographs are included under the same licence, prompts and all, graded to one exposure so the catalogue reads as one commissioned shoot. Every colour routes through twenty semantic tokens in one theme.css, in a deliberate warm dark mode as well as light, so rebranding is a one-file edit. Built for Tailwind v4, Next.js 15 and React 19; five client islands, everything else a server component.
- Pages: /, /product, /making, /delivery, /cart, /components
- Tags: tailwind ecommerce nextjs template, ecommerce template for makers, shop, cart, tailwind-v4
- Site: https://tailthemes.com/themes/kiln
- Manifest: https://tailthemes.com/api/themes/kiln
- Registry: https://tailthemes.com/r/kiln
- Preview: https://tailthemes.com/preview/kiln

### Kiln AGENTS.md

# Kiln — rules for agents

Kiln is an e-commerce theme for makers (Tailwind v4 / Next.js 15 / React 19).
The demo content belongs to a fictional studio, **Marl** — two people throwing
stoneware in Whitstable, Kent, firing four times a year and selling what comes
out of the kiln.

The theme's premise, and the reason every rule below exists: **the photography
is the layout**. Kiln's job is to frame a maker's own pictures and then get out
of the way. Anything that adds decoration next to a product photograph is
working against the product.

Read this before changing anything. The four workflows in `.claude/skills/`
(`rebrand`, `add-section`, `add-page`, `check-quality`) are the sanctioned way
to extend Kiln; they are written against the real file names and token names
below.

---

## The five declarations

Everything else in this file follows from these. They were owed as a group and
they were decided as a group in 2.0.0, because a serif display wants an
editorial cadence and an editorial cadence wants a display-size headline.

| | Declaration | Why |
|---|---|---|
| **Signature** | **The shelf** — a full-bleed hairline plate of white bays, one square photograph per bay, running past the container to both page edges | Structural, not decorative. It is the thing a viewer describes from memory ("six square pots on cream, edge to edge"), it is the same construction as the `promises` plate and the `firing-calendar` plate, and the plate appears on **every one of the five** routes. 1.x declared a "full-bleed studio band" that lived on one route and was never declared before the theme was built. |
| **Hero archetype** | **A8** — asymmetric offset, artifact bleeding off-frame | The copy is indented from the page spine on the plaster wall; the studio photograph takes the right two fifths and is cropped by the page edge. 1.x declared A4 (type over a photograph), which invariant 6 makes structurally impossible in this theme, and shipped the retired A2. |
| **Content register** | **Editorial** — 2–4 display words, the deck carries the idea; sentences may run long | A maker's storefront is a studio, not a utility. The 1.x H1 ("Pots made one at a time") was the catalog's fourth declarative-quiet headline in a row; `content-pass` quotes it as the example. |
| **Type ratio** | **1.333**, anchored at 1.5rem → 1.5 · 2 · 2.665 · 3.553rem | The consumer/commerce step from the CONTRACT table. Kiln is the catalog's only e-commerce theme, so the slot has no neighbour to collide with. |
| **Undertone** | **66°**, window 54°–78° | Every neutral in both modes sits inside it. `--card: #ffffff` is the one sanctioned achromatic (see invariant 3). |

**Second hue** — `--accent` is a slate-sage at H 118, **ΔH 81.6° from the clay**,
taken from the ash and sand glazes in the shipped photographs. Kiln was
monochromatic-warm through 1.x, which is why the restock band and the muted band
were the same rectangle. The sage is a *surface* (the restock band, the selected
glaze chip); the clay that sits on it is `--accent-foreground`.

**Typefaces** — three shipped roles, self-hosted under OFL in `src/fonts/`,
loaded by `src/fonts.ts`, applied by the generated layout in the zip and by
`PREVIEW_FONTS` in the marketplace preview:

| Role | Family | One clause |
|---|---|---|
| `--font-display` | **Newsreader** | A real `opsz` axis (6–72), so the H1 is *set* at 57px rather than a 16px drawing scaled up. H1 and H2 only, never body copy. |
| `--font-text` | **Karla** | A humanist grotesque with visible hand in the lowercase — the warmest OFL candidate that still disappears at 14px, which is where most of Kiln's words live. |
| `--font-mono` | **Spline Sans Mono** | The machine register was real before the face was: every price, SKU, firing date, quantity and rating is already `font-mono tabular-nums`. The warm end of the OFL monos, so a price reads as a shelf label rather than a line of code. |

---

## File map

```
themes/kiln/
  theme.json                     manifest — must stay truthful (see invariant 10)
  README.md                      human quick start
  AGENTS.md                      this file
  .claude/skills/                rebrand · add-section · add-page · check-quality
  src/
    theme.css                    EVERY design decision — 20 colour/radius tokens,
                                 the type ladder, the motion vocabulary, light +
                                 dark, plus the `kiln-*` class layer
    fonts.ts                     next/font/local → CSS variables; exports
                                 `fontClassName`, the one load-bearing export
    fonts/                       self-hosted woff2 + verbatim OFL per family
      Newsreader-Variable.woff2     display, 132 KB
      Karla-Variable.woff2          text/UI, 32 KB
      SplineSansMono-Variable.woff2 mono, 36 KB
      OFL-Newsreader.txt · OFL-Karla.txt · OFL-SplineSansMono.txt
    assets/                      generated imagery + README.md (the prompts,
                                 the backdrop grade spec, the crop rule)
      studio-bench.jpg              hero artifact, 1920×1080
      ridge-tumbler.jpg             featured product, 1000×1000
      ash-bowl.jpg                  catalogue, 1000×1000
      everyday-mugs.jpg             catalogue, 1000×1000
      fluted-jug.jpg                catalogue, 1000×1000
      speckled-plate.jpg            catalogue, 1000×1000
      stem-vase.jpg                 catalogue (sold out), 1000×1000
      *-thumb.jpg                   tight 400×400 crops for the cart lines
      tumbler-glaze-detail.jpg      product gallery, 1000×1000
      tumbler-in-use.jpg            product gallery, 1000×1000
      throwing-wheel.jpg            the making, 1600×1067
      hero-plaster.jpg              hero ground, 1200×675 — NOT a photograph:
                                    a colourless grayscale texture that is
                                    blended into the background token
    app/
      home.tsx                   / — hero → catalogue → making → promises → reviews → restock
      product.tsx                /product — product detail → reviews → promises → related
      making.tsx                 /making — page header → making → firings → shelf → restock
      delivery.tsx               /delivery — page header → rates → promises →
                                 returns → small print
      cart.tsx                   /cart — page header → cart → promises
      components.tsx             /components — the living styleguide (not in Marl's nav)
    components/                  ONE FILE PER SECTION, no cross-section imports
      site-header.tsx            sticky bar; server component composing the mode
                                 toggle and the cart drawer; mobile menu is <details>
      hero.tsx                   A8: statement, two actions, three facts, artifact
      product-grid.tsx           the shelf (props: heading, lede, only,
                                 headingLevel, id, showFiringNote, surface)
      product-detail.tsx         gallery, glaze radios, quantity, a working add
                                 to cart, specs · 'use client'
      cart-contents.tsx          the live cart: stepper, remove, gift note, the
                                 computed summary, the empty state
                                 (prop: variant) · 'use client'
      collection-split.tsx       process photograph beside four making stages
                                 (prop: moreHref → the long version)
      firing-calendar.tsx        the kiln year: next date at d4, four firings in the plate
      promises.tsx               dispatch, packing, returns — the quiet register
                                 (prop: moreHref → the full terms)
      delivery-rates.tsx         the £60 threshold at d4 beside the rates table, 5:7
      returns-faq.tsx            four disclosures; carries the #returns anchor (prop: id)
      reviews.tsx                three named buyer quotes
      newsletter.tsx             the restock list, and the theme's error style
      small-print.tsx            who you buy from, when an order is an order,
                                 what happens to your email — /delivery's last word
      page-header.tsx            editorial opener for non-shop pages; the
                                 optional `spec` hangs one or two shelf labels
                                 in the band's right third (prop: spec)
      site-footer.tsx            four columns, address, firing-date bar
      ui/                        shared primitives — the ONLY cross-section imports
        button.tsx               Button, ButtonLink, buttonClasses
        badge.tsx                Badge (accent | neutral | surface), Eyebrow
        container.tsx            Container (prose | default | wide)
        product-card.tsx         ProductCard + the Product type — one bay
        icons.tsx                KilnMark — the brand glyph, the only hand-drawn SVG
        cart-store.ts            THE CART: priced fixtures, lines, totals,
                                 localStorage, useCart · 'use client'
        cart-drawer.tsx          the header's live cart disclosure · 'use client'
        mode-toggle.tsx          the navbar light/dark switch · 'use client'
```

**The cart's one source of truth.** `ui/cart-store.ts` owns the price, the name
and the remaining stock of the three pieces a cart can hold, and
`product-detail`, `cart-contents` and `cart-drawer` all read them from there —
no component types a price. `product-grid` keeps its own display catalogue,
because it also carries the sold-out vase and the full-size photographs, and the
two must agree (the spine table in `check-quality` § 9 is where that agreement
is written down).

---

## Invariants

**1. All design lives in `src/theme.css`.**
No other file may introduce a colour, a radius, a type size or a font family. If
a change needs a new visual value, it becomes a token in `theme.css` first — in
both the light block (`:root, .tt-kiln`) and the dark block
(`.dark, .tt-kiln.dark`) — and the matching entry is added to `tokens` and
`tokensDark` in `theme.json`. `bun run check` fails on any drift between them.

**2. Semantic utilities and the `kiln-*` class layer. Zero raw colour, zero
alpha colour states, zero bare `text-*`.**

Allowed: `bg-background` `text-foreground` `bg-card` `text-card-foreground`
`bg-primary` `text-primary-foreground` `bg-secondary` `text-secondary-foreground`
`bg-muted` `text-muted-foreground` `bg-accent` `text-accent-foreground`
`border-border` `divide-border` `outline-ring` `rounded-themed` `font-sans`
`font-mono`, plus the class layer at the foot of `theme.css`:

| Class | What it is |
|---|---|
| `kiln-display` | the display family **and** `--display-weight` together, so no component picks either |
| `kiln-d1` … `kiln-d4` | the display ladder — size, leading and tracking as one step |
| `kiln-lead` `kiln-body` `kiln-small` `kiln-caption` | the UI ladder, tracking 0 at every step |
| `kiln-label` | 12px, uppercase, +0.1em — the one size where positive tracking is correct |
| `kiln-ramp-primary` | the filled control: `--primary` → `-hover` → `-active` |
| `kiln-ramp-quiet` | every non-filled control: hover → `--surface-tint`, press → `--secondary` |
| `kiln-edge` | `--input-border`, the perceivable boundary for form controls |
| `kiln-shadow-chip` · `kiln-shadow-sheet` | elevation from `--shadow-color` |
| `kiln-fill-*` | flat swatches of the ramp tokens, for the styleguide only |

**Forbidden anywhere in `src/components` and `src/app`:**
- hex values, `bg-[…]`, `text-white`, `text-black`, and every palette class
  (`bg-orange-600`, `text-stone-500`, …);
- **bare `text-*` sizes** (`text-sm`, `text-4xl`, …) — sizes route through the
  ladder exactly like colours do. 1.x used ten distinct stock steps whose
  neighbouring ratios wobbled 1.2 / 1.25 / 1.333, which is the definition of no
  scale having been chosen;
- **alpha-modified colour states** — `hover:bg-primary/90`,
  `border-muted-foreground/70`, `shadow-foreground/10`. Alpha moves lightness,
  chroma *and* hue at once, and on a warm cream ground `bg-primary/90`
  desaturates the hover toward the paper and shifts its hue. Use the ramp.
  (1.x's invariant 2 explicitly sanctioned this pattern. It was wrong, and the
  contract's own banned example shipped in `ui/button.tsx` because of it.)
- **bare `shadow-*`** — every shadow is `kiln-shadow-chip` or
  `kiln-shadow-sheet`, both tinted with the ink's hue. A neutral black shadow on
  warm cream is the cheapest generated tell there is;
- **the `dark:` variant**, anywhere, for anything. Kiln's dark mode is a
  **class** — `.dark` / `.tt-kiln.dark` — and no theme in this catalogue
  declares `@custom-variant dark`, so Tailwind compiles `dark:hidden` to a
  `prefers-color-scheme` query: the visitor's **operating system**, which the
  navbar toggle does not touch and cannot touch. A light-OS visitor who switches
  the page to dark would get dark tokens with light-mode markup, and no
  screenshot can ever show it because a capture only ever runs under one OS
  setting. Markup that differs by mode uses `[.dark_&]:` — a plain `.dark &`
  descendant selector. Two places do: the plaster blend in `hero.tsx` and the
  Sun/Moon swap in `ui/mode-toggle.tsx`. Verified 2.2.0 across all eight
  combinations of OS scheme × page mode × before/after click.

Alpha stays legal for **genuine translucency only**: `bg-background/85` on the
sticky header, over content that is really behind it. That is the one hit.

The publish gate lints for the colour rules; the rest are `check-quality` greps.

**3. Contrast rules that come out of Kiln's specific palette.**
- `text-primary` is legible on `bg-background` and `bg-card` only. On
  `bg-muted` it computes to 4.47:1 in light mode, on `bg-secondary` 4.28:1, on
  `bg-accent` 4.44:1 — all under AA. Use **`text-accent-foreground`** for clay
  text on any tinted surface (6.03:1 or better everywhere). `text-primary`
  stays correct for icons and small non-text marks on any surface, which is why
  the lucide marks in `promises` and `hero` use it.
- **The focus outline is always offset outward** (`focus-visible:outline-offset-2`,
  or `-outline-offset-2` for inset controls sitting on `card`/`background`), so
  it is computed against the page surface. Ring-on-primary is 1.00:1, which is
  why **no interactive element sits on a `bg-primary` panel** and Kiln has no
  full-bleed primary CTA band. The one exception is the product bay, whose ring
  is inset (`-outline-offset-2`) so it frames the bay rather than bleeding into
  the hairline gap between bays.
- `border-border` is a hairline (1.25:1 light, 1.42:1 dark), never a text colour
  and never a form control's boundary. **Every input, select and radio chip
  carries `kiln-edge`** — `--input-border`, which computes 4.02 on card · 3.58 on
  background · 3.37 on muted · 3.22 on secondary · 3.34 on accent in light, and
  3.88 / 4.17 / 3.72 / 3.54 / 3.43 in dark. That is WCAG 1.4.11 on **every** Kiln
  surface. The 1.x alpha version cleared it on card and background and quietly
  missed on muted (2.86:1), which is where the cart's selects actually live.
- `--card: #ffffff` is the one sanctioned achromatic, and the idea it is
  sanctioned for is stated twice: a product sitting on a lit surface, and the
  white bay every photograph is mounted in. Do not "fix" it to a tinted white.
- Structure that must be perceivable pairs the hairline with a background
  change (`bg-card` on `bg-muted`), never the hairline alone.

**4. Sections are independent.**
A file in `src/components/` may import from `./ui/*`, from `../assets/*`, from
`lucide-react` and — in the two client sections — from `react`, and nothing
else. No section imports another section. Two sections importing the same asset
is expected and fine; three sections importing `ui/cart-store` is the same rule
working as intended, because a primitive is what a shared thing becomes. That is
what makes per-component registry install work.

**5. Pages are pure composition.**
Exactly one `<h1>` per page, owned by the page's opening section: `hero` on `/`,
`product-detail` on `/product`, `page-header` on `/making`, `/delivery` and
`/cart`, and the page itself on `/components`. Sections that can own an `<h1>` take a `headingLevel` prop so the
styleguide can demote them; never stack two. `src/app/*.tsx` import sections,
stack them, and pass content-selecting props. No data fetching, no Next-specific
APIs, no `next/link`, no `next/image` — pages must render standalone *and*
inside the marketplace preview route. Links are plain `<a href>`. The one import
a page makes beyond sections is `../../theme.json`, in `components.tsx`, so the
styleguide's version eyebrow cannot drift from the artifact.

**6. Photography is the product, and no text ever sits on a photograph.**
This is the one theme in the catalog where photography is load-bearing rather
than decorative: a buyer of a ceramic tumbler is buying what they can see.
Rules:
- Assets live in `src/assets/` and are **statically imported** by the component
  that uses them (`import cover from "../assets/x.jpg"`). Never `next/image`,
  never an external URL — the standalone zip has to run offline.
- Render with a plain `<img>` carrying `src`/`width`/`height` from the import,
  `object-cover`, and a frame built from tokens. Products are **always square**
  (`aspect-square`); one aspect across the catalogue is most of what makes the
  grid look designed.
- **One exposure across the catalogue.** The six catalogue backdrops are graded
  to OKLCH **L 0.930 · C 0.025 · H 80**, which is the numeric spec in
  `src/assets/README.md`. A regenerated shot is graded to that spec, not
  accepted as generated. Before shipping any change to the set, open all six
  side by side at 1280 and check that the shadows all fall the same way — that
  check is why `fluted-jug.jpg` is now mirrored.
- **Small frames get their own crop.** A catalogue shot is composed with
  generous negative space, which is right at 400px and illegible at 96px. The
  cart lines use the `-thumb.jpg` files: tight 400×400 crops derived from the
  same graded originals by the documented crop rule. They are *derived*, never
  separately generated — a thumbnail of a different pot is the same lie as a
  gallery of different pots.
- **No text is set over photographic pixels.** Labels that appear on a
  photograph — the stock chip on a product bay — use `Badge tone="surface"`,
  which brings its own opaque `bg-card`. The contrast pair therefore stays a
  token pair, and it stays correct after a buyer replaces every photograph with
  their own. A scrim tuned to *our* picture is a trap for *theirs*.
  The hero's plaster ground is the one raster behind text, and it is exempt for a
  specific reason: it is a colourless luminance layer blended into the background
  token, not a picture, and it is not a slot a buyer swaps. See invariant 13.
- `alt` is required by the `Product` type and by review. Descriptive for product
  imagery; `alt=""` + `aria-hidden` only for a purely decorative backdrop — the
  plaster ground is the only one.
- Prompts are palette-anchored and recorded in `src/assets/README.md`, one per
  file, alongside the grade spec and the crop rule.

**Imagery disclosure.** The files in `src/assets/` are **AI-generated (Gemini)**
and are licensed to you with the theme — ours to pass on, with no stock licence
to buy and no attribution to carry. They are regenerable: every prompt is
recorded in `src/assets/README.md`, and the marketplace produces them with
`bun run generate-image`. They are photographs of nothing real — not a real
studio, product, potter or customer. `hero-plaster.jpg` is generated the same way
and then flattened to a colourless grayscale layer; its prompt and flattening
recipe are in `src/assets/README.md`.

**7. Server components by default, client islands where the interaction is the
product, and honest states either way.**

Through 2.1.0 this invariant read *nothing in Kiln needs client JavaScript*, and
that was true of a storefront you could only look at. It is false of a
storefront you can buy from: "Add to cart" that adds nothing is the one missing
interaction a buyer notices in the first ten seconds, and no amount of
server-rendered craft above it survives that. 2.2.0 amends the rule rather than
abandoning it.

**Five files, and only these five, may say `'use client'`:**

| File | Why it must |
|---|---|
| `ui/cart-store.ts` | the store itself — state, arithmetic, `localStorage` |
| `ui/cart-drawer.tsx` | a live count and real contents in the header |
| `ui/mode-toggle.tsx` | flipping the `dark` class is a DOM write |
| `product-detail.tsx` | add to cart, with the glaze that is actually selected |
| `cart-contents.tsx` | the stepper, remove, the totals, the gift note |

Everything else stays a server component, **including `site-header.tsx`**, which
composes two islands and holds no state of its own. Adding a sixth needs a
reason in the pass file. The mobile menu, the spec disclosures, the returns
questions, the glaze radios and the newsletter's `:user-invalid` validation are
all still browser-native, because for those the browser is already correct.

**The rule the amendment does not relax — REAL state, never a depicted one.**
Every control writes the store that every other surface reads:

- a quantity control changes the number in the cart, not a local `useState` that
  lies to the header;
- the header's count is a sum, not a fixture;
- there is no "Added!" flash, no toast that fires without a line appearing, and
  no CSS-only confirmation. The product page's status line is the cart being
  read back out;
- `£146` and `4 items` are no longer typed anywhere in Kiln. They are
  `2×£28 + £52 + £38` and `2+1+1`;
- the delivery row is arithmetic against the £60 threshold, so a cart that drops
  under it charges £4.20 and says so, instead of printing "Free" forever;
- the "one of these is the last" note is derived from `stock === 1` and
  disappears with the line it was about.

**The cart opens seeded, and that is not a simulation.** A demo cart with
nothing in it demonstrates nothing, so the store's server snapshot *and* its
first-visit snapshot are the shipped fixture. Nothing is persisted until the
visitor changes something, and every seeded line is a real entry they can
decrement, remove, and watch the total follow. Take all three out and
`cart-contents` renders its designed empty state — which is the first version of
Kiln where that state is reachable outside the styleguide.

The sold-out stem vase still carries **no `href`** and renders as a plain name
and price, because a clickable sold-out card that lands on an in-stock product
is a simulated state.

**Named limitations** (also in `theme.json.known_limitations` — they are
declared, not hidden):
- Both header disclosures swap their trigger icon to an X when open, so a panel
  that is showing always shows how to close it. Escape and outside-click still
  do not dismiss them, and since 2.2.0 that is a **choice**: the drawer is an
  island and could, but the mobile menu beside it would have to become one too
  for the two to behave alike, and a second island to close a menu is not worth
  it. Two disclosures that behave identically beat two that differ.
- The mode toggle's precedence is **rendered page → stored choice → light**. A
  page that arrives dark stays dark, because something upstream said so (the
  marketplace preview renders `?mode=dark` that way) and a `kiln-mode` left in
  storage by another tab must not overrule a link that said what it wanted.
  Storage decides only what the page left open — standalone, that is every case.
  The corollary is honest and worth knowing: an *explicitly light* page is
  indistinguishable in the DOM from a default one, so a stored dark preference
  does apply there. Reading `?mode=` to tell them apart would put the
  marketplace's URL contract inside a component that ships in a buyer's zip.
- The stored choice is applied in an effect, so a visitor who chose dark and
  reloads sees the rendered mode for one frame. Closing that needs a blocking
  script in the document head, which is the host app's file.
- **One product page, and it stays one.** Every in-stock bay links to it. This is
  a decided stop, not a gap: a Kiln product page shows three photographs of the
  *same object*, graded to the one backdrop spec, plus its own glaze fixtures,
  spec disclosures and per-piece review count. A second product page is a second
  shoot and six new spine rows, and it proves nothing about the system the first
  one does not already prove. Re-argue it with a shoot, or leave it declared.
  The cart holds three of the six pieces for the same photographic reason: a
  cart line renders at 80–96px and needs the tight `-thumb` crop, and the shoot
  ships three of those.
- **`/checkout` is a POST target, not a page.** The cart's summary form posts the
  real cart there — one hidden `line` field per line, `id:glaze:quantity` — and
  stops. It posts **choices, never prices**: a client-supplied total is a pattern
  a buyer would inherit and get burned by. Designing the page after the button
  would mean rendering a card field that takes no card and an order number for an
  order nobody placed — the simulated state this invariant exists to forbid — and
  in a real build that screen belongs to the buyer's payment provider anyway.

**8. Focus is always visible.**
Every interactive element carries
`focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`
(inputs and selects use `focus-visible:-outline-offset-2`; text links use
`outline-offset-4`). Never remove it, and never let it fade in — focus
indication is instant and is deliberately outside every transition. The one
place the ring is *moved* rather than dropped is `ui/product-card.tsx`, where
the whole bay is the click target: the link sets `focus-visible:outline-none`
and the bay draws the ring via `has-[a:focus-visible]:…`, inset so it frames the
bay rather than bleeding into the plate's hairline gap.

**9. Icons come from `lucide-react`; the mark does not.**
Import named icons in the section that uses them (`import { Truck } from
"lucide-react"`). House rules, no exceptions:
- **Size via `className`** only — `size-3.5`/`size-4` inline, `size-5` for
  controls, `size-6`/`size-7` for display marks. Never `size`/`width`/`height`
  props; lucide defaults to 24px, so an icon with no size class is a bug.
- **`strokeWidth={1.5}` everywhere**, including `KilnMark`. One value.
- **Colour is `currentColor`** through a token class (`text-primary`,
  `text-muted-foreground`, or inherited). Never set `stroke`/`fill`.
- **`aria-hidden="true"`** unless the icon is the only label. Where an icon
  carries meaning it is paired with text (the stock chips say "Sold out", they
  do not merely turn a colour).
- **A button carrying an icon trims 2px of padding on the icon side.** That is
  in `buttonClasses`, not in the call sites — a glyph has its own side bearings
  and does not need the same air as a letter.
- **`KilnMark` in `ui/icons.tsx` stays hand-drawn.** Identity is bespoke, UI
  iconography is not. Do not add other hand-rolled SVGs there.
- Any package an icon set drags in must be declared in `theme.json`
  `dependencies`.

**10. Manifest stays truthful.**
Adding, renaming or deleting a section or page means updating `theme.json`
(`pages`, `components`, and `tokens`/`tokensDark` if tokens moved) in the same
change, **and adding the section to `src/app/components.tsx` in a
`data-component` frame** — the screenshots pipeline captures from those frames
and the publish gate fails when the manifest and the captured set drift. Bump
`version` for anything a buyer would notice. The styleguide reads `version` from
the manifest, so there is no second copy of it to forget.

**11. Every internal link resolves, and a nav label names a page.**
An href in Kiln points at one of the six pages, at an anchor that is rendered on
the page it names, or at a `mailto:`. Never invent a route to make copy read
better — retarget the link or change the copy. Check after any edit:

```bash
grep -rhoE 'href[=:] ?\{?"[^"]*"' themes/kiln/src | grep -oE '"[^"]*"' | sort -u
```

Expected set: `#main`, `/`, `/#shop`, `/#restock`, `/cart`, `/delivery`,
`/delivery#returns`, `/making`, `/product`, `mailto:hello@marl.studio`,
`mailto:trade@marl.studio`.

**Nav items are routes, not scroll positions.** Through 2.0.0 "The making" and
"Delivery" were `/#making` and `/#delivery`: a top-level nav label whose
destination was a scroll, and in Delivery's case a scroll to a section whose only
heading is `sr-only`. Both are now pages, and the footer's Delivery/Returns pair
— which used to be two labels at one identical destination — resolves to
`/delivery` and `/delivery#returns`. `collection-split` still carries
`id="making"` and `promises` still carries `id="delivery"`: they are section
anchors a buyer composing a one-page variant will want, and nothing in Kiln
links to them any more.

**A block never links to the page it is standing on.** That is what
`collection-split`'s and `promises`' `moreHref` props are for: the pages that
carry the teaser pass the href, the destination page omits it.

Form `action` targets are POST endpoints, not navigation: `product-detail` posts
to `/cart`, `cart-contents` posts to `/cart` and `/checkout`, `newsletter` posts
to `/restock`. Checkout deliberately stops at that button — what happens after
it belongs to the buyer's payment provider. The cart drawer therefore ships
**one** action, "View cart", pointing at the cart. It used to ship two buttons
at one destination, the primary one named for a checkout it did not reach.

**12. Real content only, in the Editorial register.**
Kiln ships Marl's copy — real prices, real dimensions, real firing dates,
reviews from named people who each name the piece they own. Replacing it is
fine; replacing it with lorem ipsum, "Product One", or grey placeholder boxes is
not. Scarcity in the copy is factual ("6 left in this glaze", "next firing 14
March") and never a pressure tactic: no countdowns, no invented urgency.

The register is **Editorial**: the H1 is 2–4 display words and the deck carries
the idea. Section headings stay ≤6 words, ledes stay one sentence ≤20 words,
CTAs stay 2–4 words verb-first. Every fact that appears twice is in the fixture
spine table in `.claude/skills/check-quality` — put new repeated facts there or
keep them single-sourced, because a spine-less fixture drifts the first time an
agent touches one screen.

**13. The hero's plaster ground is a luminance layer. The asset carries no
colour.**
The statement block stands on a wall. The wall is a generated plaster texture,
and the rule that lets a raster sit behind a shop's headline is that it never
paints a colour of its own — it *modulates the background token*.

| Piece | What it is |
|---|---|
| `src/assets/hero-plaster.jpg` | 1200×675, 100 KB grayscale. **R = G = B in every pixel**; verified max channel deviation **0**. |
| The layer | the `<img alt="" aria-hidden>` in `hero.tsx`, `object-cover object-left`, inside the `relative isolate` band |
| Light | `mix-blend-multiply` at `opacity-[0.24]` — multiply scales all three channels by one neutral factor, so the cream darkens in the trowel strokes and the hue cannot shift |
| Dark | `[.dark_&]:mix-blend-soft-light` at `[.dark_&]:opacity-[0.42]` — multiply is invisible on `#171310`, so dark lifts and sinks around the ground instead; each channel moves as a function of its own value, which is why the warm dark stays warm rather than going grey |

The rules:

- **Blend, never paint.** The colour on screen is `--background` put through a
  neutral function. A rebrand recolours the wall by definition, and neither mode
  needs its own asset — only its own blend mode and opacity, both of which are
  classes.
- **One asset, both modes.** Per-mode tuning is a `[.dark_&]:` class. A second
  file is a second thing to keep in sync.
- **0.42 in dark, not 0.65.** At 0.65 the trowel arcs became the loudest element
  in the band and read closer to JPEG blocking than to lime plaster. Only the
  number changed; the mechanism was always right.
- **`object-left` is a decision.** At 375 the 1200px texture crops to a third of
  its width, and the left third is where the arcs run longest and quietest.
  `cover`'s centred default put the busiest part of the wall under the headline.
- **The blend is fenced.** `isolate` on the band means the blend can reach that
  band's own `bg-background` and nothing else. The studio photograph is
  positioned against the *section*, outside that stacking context: **no blend
  mode ever touches a photograph**, which is the same rule as "the accent never
  appears on a photograph".
- **The composite is measured, not assumed.** check-quality §4 carries the table:
  every text run in the hero against the darkest and brightest 8px window of the
  real composited ground, at 375/768/1280/1920, both modes. Retune the opacity
  and re-run it.
- **Not a buyer slot.** `product-grid`, `product-detail` and `collection-split`
  are where a buyer's own photographs go. The plaster is theme furniture; if it is
  replaced it is replaced by another *grayscale* texture, or the rule is broken.
- **One textured surface is the budget.** Kiln sells frame discipline; a second
  texture competes with the products.

---

## Motion personality — unhurried

*Feedback is quick and quiet, the cart drawer is the only thing that moves at
any size, and nothing ever animates over a photograph.*

| Class | Where | Value |
|---|---|---|
| Micro feedback | buttons, links, nav, disclosure summaries, the quantity stepper, the mode toggle | 150ms `ease-out`, colour only — carried by `kiln-ramp-*`, not by per-component classes |
| Press | every filled and quiet control | a colour step to `--primary-active` / `--secondary`; on a product bay, the picture scales to `0.99` |
| State change | the cart drawer | 300ms `cubic-bezier(.22,1,.36,1)`, `translate-x` 16px + opacity, entered via `starting:` |
| Mode flip | the navbar toggle | **none** — Sun and Moon are swapped by a `[.dark_&]` `display` rule, the same mechanism the drawer's open/close icons use. A mode flip repaints the page; animating the glyph on top of that is theater, and doing it in CSS is also what keeps the correct icon in the server HTML |
| Entrance | none | a shop is already there when you arrive |
| Ambient | none | the photographs are the identity |

**The one allowed deviation**: product photographs scale to `1.02` on bay hover
and `0.99` on press. On a storefront the picture is the affordance; a border
colour change alone reads as a dead link. Nothing else in Kiln scales.

The deviation is **two-part, and both parts are deliberate**: the scale, and the
**duration**. It runs at 300ms — the state-change duration, not the 120–150ms
micro-feedback class it technically belongs to. "Unhurried" is the reason: a
photograph that snaps looks like a rollover, and a photograph that eases looks
like a shelf. Read as a bug by a future agent otherwise.

Every transition is authored `motion-safe:` from the start (the ramp classes use
a `prefers-reduced-motion` media query, which is the same thing in CSS), so
reduced motion removes all of it and nothing shifts. `transition-property`
is always scoped (`transition-colors`, `transition-transform`,
`transition-[opacity,transform]`) — never `transition-all`.

---

## Type, density and rhythm (follow it, do not invent new steps)

| Use | Classes |
|---|---|
| Page h1 (home) | `kiln-display kiln-d4 text-balance` |
| Page h1 (interior) / section h2 | `kiln-display kiln-d3 text-balance` |
| Sub-section / large number | `kiln-d2` |
| Small heading / empty-state h2 | `kiln-display kiln-d1` |
| Card h3 | `kiln-body font-medium` |
| Lead paragraph | `kiln-lead text-muted-foreground` |
| Body | `kiln-body` or `kiln-small text-muted-foreground` |
| Eyebrow / label | `<Eyebrow>` or `kiln-label font-semibold` |
| Prices, SKUs, dates, quantities | `font-mono tabular-nums` — a functional role, never decoration |
| Card padding | `p-4 sm:p-5 lg:p-6` (bays) · `p-6` · `p-6 lg:p-8` |
| Gutter | always `<Container>` — never a bespoke `max-w-*` + `px-*` pair |

**Density registers** — three, and the ratio between the loudest and quietest is
**2:1** at `lg`. 1.x ran everything at 96px and 80px, a 1.2:1 spread, which is
uniform rather than airy: air needs a tight register to be air against.

| Register | `lg` padding | Sections |
|---|---|---|
| Loud | `lg:py-24` (96px) | `product-grid`, `reviews`, `cart-contents`, `delivery-rates` |
| Standard | `lg:py-20` (80px) | `collection-split`, `newsletter`, `returns-faq`, `site-footer` |
| Quiet | `lg:py-12` (48px) | `promises`, `product-detail`, `firing-calendar`, `small-print` |

Registers alternate down every page — never two loud adjacent, never two quiet.

**Container widths** — every route uses at least two, plus the full-bleed plate.
`/` runs `default` + the plate; `/product` runs `wide` (gallery and buy column)
+ `prose` (the spec disclosures) + the plate; `/cart` runs `default`
(`page-header`) + `wide` (`cart-contents`) + the plate; `/making` and
`/delivery` run `default` + the plate, like `/`. `grep -rn 'width="'`
returning nothing is how 1.x shipped one width on all three routes with the
primitive sitting unused.

**A reading measure is `max-w-*` inside the default gutter, not a centred
`prose` container** — unless the block is the *last* thing in its section, which
is the one case `/product`'s spec disclosures satisfy. `Container width="prose"`
is `mx-auto`, so an opener wrapped in it floats ~190px right of the sections
above and below and reads as a mistake rather than as a register.
`page-header` carries the rule in its own doc comment; `firing-calendar` and
`returns-faq` were both built the wrong way first and the 1280 capture is what
caught it. Constrain the text, not the container.

**One oversized moment per route, never zero and never two:**
`/` the studio photograph bleeding off the page · `/product` the lead shot
breaking the container to the left page edge · `/cart` the total at `--text-d4`
· `/making` the next firing date at `--text-d4` · `/delivery` the free-delivery
threshold at `--text-d4`. The mono figure at the top of the ladder is Kiln's
idiom for "the number this page is about"; it appears once per route and never
twice. `/cart` has **none** in the empty state, which is correct rather than a
gap: an empty cart has no number to be about, and the empty state's own
`kiln-d1` heading is the whole point of it being designed.

**Section backgrounds** alternate down each page — `background` → `muted` →
`background` → `muted` → `accent`, with `site-footer` on `secondary` so it
always closes on a surface of its own. Every section but the footer ends with
`border-b border-border`. Keep the rhythm when inserting a section: check its
neighbours in the page file first, and use `product-grid`'s `surface` prop
rather than wrapping it.

**Hairline plates** (`grid gap-px … border-y border-border bg-border` with
`bg-card` children, outside the `<Container>`) are the signature. Prefer them to
free-floating cards with shadows. One rule when you build one: **the plate must
never show an empty bay** — pick a column ladder that divides the item count at
every breakpoint, because a hole in the last row reads as a missing shelf plank.

---

## What never to touch

- **`app/`, `lib/`, `package.json`, `tsconfig.json` at the repo root** — that is
  the marketplace, not the theme. Kiln owns `themes/kiln/` and nothing else. The
  one line it needs elsewhere is its `PREVIEW_FONTS` entry in
  `lib/preview-registry.tsx`, which is that file's documented integration point.
- **The `@theme inline` bridge** in the marketplace's `app/globals.css`. Kiln
  defines variables; the bridge maps them to utilities exactly once. Do not
  duplicate it in `theme.css` (the commented copy at the top of that file is
  documentation for standalone use only). The bridge maps colour, radius and two
  font families — everything else Kiln needs is the `kiln-*` class layer, which
  is why that layer exists.
- **The `kiln-` prefix on the class layer.** The marketplace preview loads all
  seven themes' stylesheets onto one origin. An unprefixed `.type-d1` belongs to
  whichever file was imported last.
- **The repeated type ladder in the dark block.** Same reason, inverted: a
  neighbour theme's bare `.dark { --text-d4 }` has the same specificity as
  `.tt-kiln`, so the ladder is redeclared under `.tt-kiln.dark` to outrank it.
- **`.tt-kiln` / `.dark` selectors** in `theme.css`. Both scopes are required:
  `:root` for standalone, `.tt-kiln` for the embedded marketplace preview.
- **`screenshots/`** — generated by the publish pipeline.
- **`data-brand-name`** on the two wordmarks in `site-header.tsx` and
  `site-footer.tsx` (three spans in total). The marketplace's rebrand-before-buy
  injects the buyer's name there and only there.
- Token *names*. Rebranding changes values, never the semantic names, or every
  component and both skills break.

## Marquee — Dark SaaS Landing Page

- Slug: marquee
- Category: saas landing
- Price: $39
- Description: A dark-first Tailwind React SaaS landing page template built around one signature: the ticker rails — full-bleed strips of timestamped, figure-bearing events drifting between sections, a different evidence feed on each route. Five fiction routes ship a whole marketing site for Recur, the demo product: home opening on the reconciliation surface, a product tour that ends in the Δ $0.00 band, pricing with a working monthly/annual switch, customers carrying a full case study and the go-live wall, and a signup form with real validation. Interactions are real, not screenshots: the navbar mode toggle, the billing arithmetic done in front of you, focus moved to the first invalid field. Ships Bricolage Grotesque, Schibsted Grotesk and Spline Sans Mono self-hosted (OFL licenses included), a 1.333 modular type scale with every size on a token, a raspberry-on-wine palette whose gold second hue is reserved for flagged rows, and a /components styleguide listing all 48 tokens per mode. The hero ground carries a generated poster grain shipped as a grayscale luminance mask, so every tinted pixel still comes from a token and a rebrand carries the texture unchanged. The light variant is a designed blush-paper table, not a token flip; every foreground/background pair in both modes is verified against WCAG AA by computation. Every rendered href resolves to a page or anchor the theme ships.
- Pages: /, /product, /pricing, /customers, /signup, /components
- Tags: tailwind react saas landing page template, saas landing page, dark saas landing, pricing page tailwind, nextjs saas marketing site, subscription analytics demo
- Site: https://tailthemes.com/themes/marquee
- Manifest: https://tailthemes.com/api/themes/marquee
- Registry: https://tailthemes.com/r/marquee
- Preview: https://tailthemes.com/preview/marquee

### Marquee — Dark SaaS Landing Page AGENTS.md

# AGENTS.md — Marquee

Rules for any agent (or human) editing this theme. They are not style
suggestions; the publish gate and the extension skills in `.claude/skills/`
assume them.

## The four declarations

| | |
|---|---|
| **Signature** | **The ticker rail** — a full-bleed, hairline-bounded strip of mono, timestamped, figure-bearing events drifting edge to edge between sections: the marquee the theme is named for. Three tellings on three routes, each a different feed with a different job: `/` (the wire — revenue events), `/product` (the match log), `/customers` (the go-lives). `/pricing` and `/signup` carry none: a price and a form have no event stream, and a rail added for texture is decoration. "A scrolling strip" is not the signature — every 2026 portfolio has one; *the product's evidence rendered as a feed, agreeing with the spine table*, is. A new route earns a rail only if it has a genuine event stream to publish. |
| **Sanctioned deviation** | **The rail's text moves.** motion-pass bans animating text while it is read; the rails animate caption-size mono horizontally. Bought with: the whole ambient budget is spent here (one rail per route, nothing else moves continuously), caption-size mono only — never body copy, never headings — a 90s loop (`--duration-ticker`: a drift, not a scroll), pause on hover, and a fully readable static row under `prefers-reduced-motion` (the animation is `motion-safe:` at the call site). Exactly one deviation — do not add a second. Dark-first is NOT a deviation here: the catalog's canvas-band ledger makes [0.00–0.20] a sanctioned band and directed this theme into it. |
| **Register** | **Declarative-loud** (content-pass). H1s are 1–3 word fragments at display size ("Revenue, reconciled." · "Wire. Match. Close." · "Priced by MRR."); the deck carries the idea; body sentences short and period-heavy. Second person for the reader; the fiction's voice is confident, never hype — the banned-vocabulary list binds in full. |
| **Motion personality** | **One moving part.** The marquee drift is the only continuous motion in the theme. Micro feedback 150ms `ease-out` on hover/press; disclosures and the mode glyph swap are instant or ≤240ms; **no entrances, no scroll effects, ever**. Reduced motion is authored in, not retrofitted. |

## File map

```
themes/marquee/
  theme.json                     manifest — inventory + full token tables. Keep truthful.
  README.md                      human quick start
  AGENTS.md                      this file
  .claude/skills/                rebrand · add-section · add-page · check-quality
  src/
    theme.css                    EVERY design decision. Colour, type, radius, weight, the drift.
    assets/                      poster-mottle.jpg (grayscale luminance mask, AI-generated,
                                 regenerable — see assets/README.md) + that README
    fonts.ts                     next/font/local → --font-marquee-{display,text,mono}
    fonts/                       BricolageGrotesque-Variable.woff2, SchibstedGrotesk-Variable.woff2,
                                 SplineSansMono-Variable.woff2 + one verbatim OFL per family
    app/
      home.tsx                   header · hero · rail(wire) · sources · metrics · reconcile · proof · cta · footer
      product.tsx                header · product-tour(+page lead) · rail(match) · cta · footer
      pricing.tsx                header · pricing-plans(+page lead) · faq · footer (no cta — the plans carry the buttons)
      customers.tsx              header · case-study(+page lead) · rail(wins) · customer-wall · footer
      signup.tsx                 header · signup-panel (carries its own h1) · footer
      components.tsx             the living styleguide — tokens, every section, every primitive
    components/
      site-header.tsx            sticky nav + mode toggle + JS-free details menu · sticky={false} on /components
      hero.tsx                   A3 centered manuscript + the reconciliation surface (home's oversized moment)
                                 + the poster grain (masked --texture-ink layer, fades before the surface)
      ticker-rail.tsx            THE SIGNATURE · feed="wire" | "match" | "wins"
      sources-band.tsx           eight billing sources, hairline grid
      metrics-grid.tsx           six metric cards, sample figures first
      reconcile.tsx              wire/match/close steps + the drawn MatchFlow figure + the match-run table, 7/5
      proof.tsx                  named outcome quotes beside their figures
      product-tour.tsx           three surfaces, alternating 7/5 · ends in the Δ $0.00 band · lead?: ReactNode
      pricing-plans.tsx          ISLAND: the billing-period switch · lead?: ReactNode
      faq.tsx                    six objections as native details, 5/7
      case-study.tsx             Standfirst in full, prose measure + figures rail · lead?: ReactNode
      customer-wall.tsx          the go-live wall + the action cell
      signup-panel.tsx           ISLAND: validated signup form + what-happens-next rail
      cta.tsx                    the closing band (muted)
      site-footer.tsx            brand block + three columns + legal row
      ui/
        button.tsx               primary | secondary | ghost · sm | md | lg · state-ramp hovers
        badge.tsx                Badge (neutral | flag) + Eyebrow
        container.tsx            default (6xl) | wide (7xl) | prose (3xl)
        delta.tsx                DeltaChip — the reconciliation verdict, settled | flag
        field.tsx                Field, Input, Select — plain markup, no state
        logo.tsx                 LogoMark — the one hand-drawn SVG
        mode-toggle.tsx          ISLAND: light/dark, aria-pressed, localStorage, nearest .tt-marquee
        section-heading.tsx      eyebrow + display h2 + one-sentence lede
        type.ts                  THE TYPE VOCABULARY. The only place a size is named.
```

**Inventory, reconciled**: 15 sections + 9 primitives = 24 `components[]`
rows, 5 routable pages + `/components`, 48 tokens per mode. `/components`
states the same numbers; `bun run check` closes the manifest against the
captured frames in both directions.

## Openings — five routes, five instruments

| route | opening device | the h1 lives in |
|---|---|---|
| `/` | the centered manuscript + reconciliation surface | `hero` |
| `/product` | the tour's page lead, then the wire panel | the lead (`id="tour-title"`) in `product.tsx` |
| `/pricing` | the plans and the billing switch | the lead (`id="plans-title"`) in `pricing.tsx` |
| `/customers` | the case study at prose measure | the lead (`id="case-title"`) in `customers.tsx` |
| `/signup` | the form itself | `signup-panel` (`id="signup-title"`) |

Page-lead rules (signal's pattern, kept): the lead lives in the page file,
renders the route's `<h1>` with the id the section is labelled by, takes the
tight register (`py-10 sm:py-12`), and keeps the default container. A section
rendered without its lead falls back to an `h2` — which is exactly what the
styleguide specimens do. **Exactly one h1 per fiction route** (CONTRACT §SEO);
`/components` renders three (its own + the hero and signup specimens') and
documents it — the specimen would stop being the component demoted.

## Invariants

**1. All design lives in `src/theme.css`.** No component introduces a colour,
family, size or radius. Both mode blocks stay complete — 48 tokens each, and
`theme.json` `tokens`/`tokensDark` mirror them exactly. Dark is the DEFAULT
(`:root, .tt-marquee`); `.light` is the designed variant; `.dark` is restated
so the standard class convention works. The manifest is mode-based
regardless: `tokens` = light, `tokensDark` = dark. Do not swap.

**2. Semantic utilities only. Zero raw colour, zero bare sizes.** Colours ride
`bg-background`-family utilities or `bg-(--token)` arbitrary-property
utilities (`bg-(--surface-tint)`, `hover:bg-(--primary-hover)`); sizes come
from `ui/type.ts` (`TEXT.*`, `DISPLAY`, `INNER_RADIUS`) and nowhere else.
Alpha colour states are banned — the ramp tokens exist for hover/press. The
one translucent token is `--texture-ink` (foreground with a per-mode baked
alpha), painted only through the hero's luminance mask — the CONTRACT's
genuine-translucency case, not a state. The hero layer's `style` attribute
carries the asset URL and mask geometry only, never a colour. Bare
`shadow-*` is banned; the two shadows in the theme carry
`var(--shadow-color)` inline (`shadow-[0_32px_64px_-32px_var(--shadow-color)]`
on the hero surface, `0_24px_48px_-24px` on the featured plan and mobile
menu). No `@layer` class layer ships — the type vocabulary is TypeScript, so
there is nothing to collide on a shared origin.

**3. Gold is the delta, and nothing else.** `--accent` (gold dark / ochre
light) appears only where the match flags something a human should look at:
the flagged ticker item, the flagged match rows, the `flag` tones of Badge
and DeltaChip, the invalid field edge, and the form's error text. It is never
decoration, never a second brand colour, and never the only signal — flagged
things always say so in words. Raspberry (`--primary`) acts; gold flags.
Adding gold anywhere else is a design change, not an edit.

**4. `border-border` is a hairline** (1.39:1 dark / 1.37:1 light against the
ground). Structure a reader must perceive always pairs it with a background
change — the hairline grids put `bg-muted` cells on a `bg-border` ground.

**5. Sections are independent.** A file in `src/components/` imports from
`./ui/*`, `lucide-react` and (hero only) `../assets/*`, nothing else. No section imports another
section. Pages are pure composition — no data fetching, no next/link, no
next/image; links are plain `<a href>`.

**6. Server components, and exactly three islands.**

| Island | Why it cannot be a server component |
|---|---|
| `ui/mode-toggle.tsx` | reads/writes a browser fact (mode class + localStorage) |
| `pricing-plans.tsx` | the billing switch does the monthly/annual arithmetic live |
| `signup-panel.tsx` | validation, focus management and the success state are state |

The ticker rails are NOT islands — the drift is a theme.css keyframe on
server-rendered content. The mobile menu and the FAQ are native `<details>`.
A fourth island needs an argument in a `/design-pass` file.

**7. One radius language.** `--radius` 0.875rem outer, `--radius-inner`
0.625rem nested (corners step down, never up). `rounded-full` is reserved for
the live dot in the rails. No pills otherwise.

**8. Icons: lucide, `strokeWidth={1.75}`, size via className, colour via
`currentColor`, `aria-hidden` always** (no icon is the sole carrier of
meaning; the mode toggle's button carries its own aria-label). The shipped
set: ArrowRight, Check, ChevronDown, Menu, X, Plus, Minus, Sun, Moon,
AlertCircle — ten icons; adding an eleventh is a decision. Hand-rolled
**icons** are a bug; the closed list of bespoke SVG is: `LogoMark` (the brand
mark) and `MatchFlow` in `reconcile.tsx` — a **content figure** (CONTRACT
amendment B): geometry computed from the match-run data, `currentColor` only,
`aria-hidden` with the steps list and match table as its text alternative,
static (the rail drift is the whole ambient budget). A third bespoke SVG
needs an argument in a `/design-pass` file.

**9. Deliberately photography-free.** Marquee's identity is typographic: the
poster ground, the display face, the rails. The product surfaces (bars,
feeds, tables) are drawn from tokens, and a photographic "hero image" would
be a different theme. The ONE image asset is `src/assets/poster-mottle.jpg` —
a grayscale **luminance mask**, not a picture: the hero paints
`bg-(--texture-ink)` and the file only decides where the ink lands, so every
tinted pixel comes from a token and a rebrand carries the texture unchanged.
It is AI-generated (Gemini) and regenerable — prompt, shaping steps and the
measured ceilings live in `src/assets/README.md`; the composite AA and
visibility arithmetic in `ops/design/marquee-imagery-2026-08-14.md`. The
binding numbers if the asset or `--texture-ink` changes: the raspberry
eyebrow ≥ 4.5:1 over the mask's brightest pixel in both modes, and no masked
pixel brighter (L*) than the `--border` hairline. The falloff gradient ends
the grain before the section rule — a texture edge where no structural
boundary exists is banned. The CTA band and the page leads measured out —
argued zero in the pass file; do not texture them without new arithmetic.
The decorative bar chart is `aria-hidden` with the stated figure beside it.

**10. Numerals.** `tabular-nums` wherever digits stack — rails, feeds,
tables, plan features, the by-the-numbers rail. The hero MRR figure and plan
prices are display figures and stay proportional on purpose.

**11. The spine is law.** Fixture facts that appear in more than one place
are listed in `.claude/skills/check-quality/SKILL.md` § spine with every
location. Change a fact in one place and you change it everywhere, or the
match — the product's whole argument — is visibly broken on its own site.

**12. `data-brand-name` belongs to the two wordmarks** (header + footer) and
nowhere else. Other mentions of "Recur" are ordinary demo copy.

**13. Every rendered href resolves.** Routes: `/`, `/product`, `/pricing`,
`/customers`, `/signup`, `/components`. Anchors: `#main` (all pages),
`#plans`, `#faq` (/pricing), `#standfirst`, `#wall` (/customers), `#wire`,
`#match`, `#close` (/product). Audit `grep -rnE 'href[=:]' themes/marquee/src`
after any link change.

**14. Manifest stays truthful.** Adding, renaming or deleting a section or
page means updating `theme.json` (`pages`, `components`, tokens if they
moved) and the `/components` frame in the same change. Bump `version` for
anything a buyer would notice. `priceCents` 3900 / `license` "standard" is a
pricing decision — never change it in a code edit.

**15. `/components` is part of the anatomy.** Every section renders once in a
`data-component`-framed specimen; primitives share the `ui` frame; the token
poster is `data-component="tokens"`. The bed is `bg-muted`; frames are
`bg-background`. The header is captured `sticky={false}`; the footer renders
in place as the shell. The fiction's nav does not link here.

## Typefaces — three shipped roles

| Role | Family | Why this one |
|---|---|---|
| `--font-display` | **Bricolage Grotesque** (OFL 1.1, variable, opsz 12–96 + wght 200–800) | a loud grotesque with a real optical axis — the poster voice of the declarative-loud register; its opsz keeps 57px display sharp and 24px card titles calm |
| `--font-text` | **Schibsted Grotesk** (OFL 1.1, variable, wght 400–900) | a sturdy newspaper grotesque; carries 14–18px body on the wine ground without fighting the display face |
| `--font-mono` | **Spline Sans Mono** (OFL 1.1, variable, wght 300–700) | the machine register is genuine: the rails, the match log, every reconciled figure |

Self-hosted in `src/fonts/` with verbatim OFL texts; loaded by `src/fonts.ts`
(`next/font/local`); `fontClassName` is the load-bearing export the packaged
layout and `lib/preview-registry.tsx` `PREVIEW_FONTS` both read.
`next/font/google` is forbidden. Never body copy in the display face.

## Type scale — ratio 1.333, dark-first weights

Display ladder anchored 1.5rem: d1 1.5 · d2 2.0 · d3 2.667 · d4 3.556 (d3/d4
fluid). UI ladder near-fixed. Tracking belongs to the step (d4 −0.03 → d1
−0.01, labels +0.08em); no component sets `font-size`, `letter-spacing` or a
display weight. **The light variant carries the heavier display weight**
(`--weight-display` 700 light / 600 dark) — Marquee is dark-first, so the
optical dark-mode weight drop runs the other way.

| Use | Classes |
|---|---|
| Hero register | `pt-14 pb-16 sm:pt-16 sm:pb-20 lg:pt-24 lg:pb-24` (surface at `lg:mt-24`) |
| Standard section | `py-16 sm:py-20 lg:py-24` |
| Closing band | `py-20 sm:py-24 lg:py-28` |
| Page lead (tight) | `py-10 sm:py-12` |
| Rail strip | `py-3` on the label cell — the rail is a strip, not a band |
| Footer | `py-14 sm:py-16` |

Loosest ÷ tightest at lg = py-28 ÷ py-12 = 2.33 ≥ 1.75 (composition gate).

**Background rhythm** — sections alternate and every section ends
`border-b border-border`:

| `bg-background` | `bg-muted` | other |
|---|---|---|
| hero, metrics-grid, proof, product-tour, pricing-plans, case-study, signup-panel, site-footer (border-t) | sources-band, reconcile, faq, customer-wall, cta | ticker-rail (`bg-secondary` strip) · the Δ band (`bg-(--surface-tint)`) |

## What never to touch

- **`app/`, `lib/`, `package.json` at the repo root** — the marketplace, not
  the theme. (`lib/preview-registry.tsx` and `lib/stats.ts` carry Marquee's
  integration rows; edit those only when a page is added or removed.)
- **The `@theme inline` bridge** in the marketplace's `app/globals.css`.
- **`.tt-marquee` / mode-class selectors** in `theme.css` — all three scope
  blocks are load-bearing on the shared preview origin.
- **`src/fonts/`** — the woff2 files and their OFL texts travel together;
  swapping a family is a `/design-pass`, not an edit.
- **Token names.** Rebranding changes values, never names.
- **`screenshots/`** — generated by the pipeline.

## Meridian — Docs & API Reference

- Slug: meridian
- Category: docs
- Price: $39
- Description: A Tailwind documentation template built as a reading surface, with the four interactions a docs product is expected to have. Prose stops at a ruled line and only machine content crosses it: the reading edge runs down every article column, and code blocks, parameter tables and the architecture figure are the things allowed past. Eight routes ship and every link on them resolves. The SDK page is one tabbed comparison of the same consumer in TypeScript, Python and Go, and a hash selects the language. The release log filters by tag. The reference carries a request builder that assembles a real curl command from the publish parameters and never pretends to send it. The header carries a light/dark switch, because dark here is a designed second table, not an inversion. Search is keyboard-first and opens on the platform's own modifier, every code block copies, and the delivery diagram is canonical Wire Diagram JSON your agents can edit. Source Sans 3 and Source Code Pro ship as self-hosted webfonts, the type scale is a real 1.250 ladder, and the masthead carries a contour-survey texture drawn entirely from tokens. Light and dark are both complete and both computed against WCAG AA.
- Pages: /, /guides, /api, /sdks, /changelog, /support, /legal, /components
- Tags: tailwind documentation template, api reference template nextjs, docs, search, tailwind-v4
- Site: https://tailthemes.com/themes/meridian
- Manifest: https://tailthemes.com/api/themes/meridian
- Registry: https://tailthemes.com/r/meridian
- Preview: https://tailthemes.com/preview/meridian

### Meridian — Docs & API Reference AGENTS.md

# AGENTS.md — Meridian

Rules for any agent (or human) editing this theme. They are not style
suggestions; the publish gate and the skills in `.claude/skills/` assume them.

## What this theme is

A documentation surface for **Halyard**, a fictional product: durable queues
over plain HTTP. Publish with an idempotency key, lease with a visibility
timeout, extend or ack or nack, dead-letter after `max_deliveries`, replay. The
API is internally consistent across every page — if you add an endpoint, it has
to fit that model.

The design goal is legibility over hours, not impact over seconds. Every
decision below follows from that.

## The signature — **the reading edge**

> **Prose stops at a ruled line, and machine content is the only thing allowed
> past it.**

A hairline at 36rem runs the full height of every article column. Paragraphs,
ledes, callouts and the meta rule stop at it. Code blocks, parameter tables,
endpoint bars and the Wire figure cross it, because they are scanned rather than
read and a wrapped shell command is worse than a wide one. On `/` the same idea
is the hero's code panel overhanging the container's right edge.

It recurs on all eight routes — `/` (the hero's overhanging code panel),
`/guides`, `/api`, `/sdks`, `/changelog`, `/support`, `/legal` and
`/components` — it is structural rather than chromatic, and it is one border
plus one `max-width` — `.reading-edge` and `.measure`, defined adjacent in
`theme.css` so that changing one without the other is obvious.

What crosses the line is decided per surface, and the decision is always the
same question: is this read, or is it scanned?

| Route | Stops at the line | Crosses it |
|---|---|---|
| `/guides` | paragraphs, callouts, the meta rule | code blocks, the Wire figure |
| `/api` | summaries, callouts, the builder's one line of prose | request lines, parameter tables, responses, the request builder |
| `/sdks` | the language notes | install lines, the three loops, the capability table, the spec |
| `/changelog` | the tagged change rows | the filter bar, the endpoints a release added, the migration sample |
| `/support` | the instructions | the required-fields table, the worked report |
| `/legal` | the clauses | the retention table, the availability table |

The three controls 2.2.0 added are placed by the same question, and it is the
test that let them in. The **filter bar** and the **request builder** are
scanned, so they cross. The **language tab strip** is a segmented control that
hugs its three options, so it sits inside the line and the blocks it switches
are the things that cross. The **mode toggle** is header chrome and never meets
the line at all. An interaction that could not be placed by this question is an
interaction that does not belong on this theme.

Before 2.0.0 this was the theme's worst layout defect: the article column had
two right edges (prose at 576px, code at 693px) and read unfinished, worst in
the 1024–1279 band. Same pixels, opposite reading — the edge declares the
disagreement instead of hiding it.

### The one sanctioned deviation

**Prose is capped at 36rem while its container runs to 43rem — the measure wins
over the grid.** Every other theme in the catalog fits content to its column.
This one refuses, because 64 characters is the reading decision and the grid is
not allowed to overrule it. What it buys: an hour-long read that never tires the
eye, and the ruled edge that makes the refusal legible. Exactly one deviation;
do not add a second.

## The reading surface — read this before touching type or layout

1. **Prose is capped by `.measure` (36rem) and set at 18px from 40rem up.** That
   is 32em of text, about 64 characters — the middle of the 45–75 band, not its
   ceiling. **Do not widen the prose cap** to fill the column, and do not remove
   `.reading-edge` from the article: together they are the signature.
2. **`/guides` reads, `/api` scans.** The guide runs `.type-read` (18/32 from
   40rem, 16/28 below); the reference runs `.type-scan` (16/28 everywhere). Two
   densities, one scale. Do not homogenise them.

   Every new reading surface picks one of the two and says why in its file
   header. The shipped calls: **`/changelog` reads** (a release entry is a
   narrative sentence), **`/support` reads** (a stuck reader is following
   instructions), **`/legal` reads** (a reader following obligations is in the
   same posture as a stuck one), **`/sdks` scans** (a reader arrives knowing
   their language and leaves with one block). A page that picks neither is a
   page that was not designed.
3. **The rails are the frame, not content.** They are sticky, they never reflow,
   and below their breakpoint they disappear rather than shrink. The outline
   lives in exactly one rail at every width: nested under the current sidebar
   item below `xl`, in the right rail from `xl` (`xl:hidden` on the nested list
   enforces it). If you add a rail, honour that.
4. **Nothing in the theme sets text below 12px**, and nothing uses a bare
   `text-*` utility — sizes route through the `.type-*` classes exactly like
   colours route through tokens.

## Typefaces — two roles, both shipped

| Role | Family | Why |
|---|---|---|
| `--font-text` (aliased `--font-sans`) | **Source Sans 3** | A humanist sans drawn for long-form screen reading; large x-height and open apertures hold at the 18px/32 the guides column asks for. |
| `--font-mono` | **Source Code Pro** | Drawn from Source Sans's skeleton by the same foundry, so the two share proportions and vertical metrics — which matters here more than in any other theme, because Meridian mixes sans and mono on nearly every line. |

No display role: a documentation surface has one voice for reading and one for
machines, and a third would be decoration.

Both are OFL 1.1, self-hosted latin-subset variable woff2 in `src/fonts/` with
their licences, loaded by `src/fonts.ts` through `next/font/local` with
`adjustFontFallback`. `next/font/google` is forbidden — build-time fetching
breaks the offline-zip guarantee. The one load-bearing export is
`fontClassName`; the marketplace's `PREVIEW_FONTS` entry puts it on the
`.tt-meridian` wrapper so captures and zips render identical letterforms.

The 64-character measure is a fact about Source Sans 3's advance width. Change
the face and you have changed the measure — recompute it.

## Type scale — ratio 1.250

Display ladder anchored at 1.5rem: **d1 24 · d2 30 · d3 37.5 · d4 46.875px**,
d3/d4 fluid and at their ceiling by 1024px. UI ladder fixed at
caption 12 · small 14 · body 16 · lead 18, plus `--text-label` (12px mono,
uppercase, +0.08em) and the two reading densities.

| Class | Where |
|---|---|
| `.type-d3` | every `h1` |
| `.type-d1` | every `h2`, on both routes — `/api` used to set its own at 20px |
| `.type-d4` | **reserved for exactly one element**: the quickstart's numeral ladder on `/`, the page's oversized moment |
| `.type-read` / `.type-scan` | the two prose densities |
| `.type-label` | every mono eyebrow, group heading and chrome label |

Tracking is a property of the step, not a class an author picks. Weight travels
with the display steps, including the dark-mode drop (600 → 500), so no
component picks a weight and none forgets the drop.

## Content register — technical-plain

Declared before the copy, per `content-pass`: **technical-plain** — sentences
name mechanisms rather than benefits, one clause, concrete nouns. "A lease is a
claim." "A consumer that crashes without acking is not an error condition."

One note on the H1 budget. `content-pass` ties H1 length to the ratio (≤1.250 →
9–12 words), but that rule assumes a small H1; this ladder puts `h1` at 37.5px,
squarely in the mid band, so the ≤8-word rule governs and the H1s run 3–6 words.
The mechanism-naming happens in the lede and the section headings, where a
documentation reader actually looks for it.

Banned vocabulary, the em-dash budget and the no-exclamation rule bind
unchanged. Rendered strings carry zero em dashes and zero banned words; code
comments are exempt and do use them.

## Two hues, spent on two jobs each

- **azure** (`--primary`, `--ring`) carries structure: links, keywords, focus,
  the one primary action per screen.
- **brass** (`--accent`) marks exactly two things: literal strings inside code,
  and the reader's current position — the header's active-section rule, the
  index's "you are here" mark, the current version's check, the outline rail's
  current heading.

1.1.0 let brass drift onto POST badges, `required` chips, warning callouts, step
numerals, diagram branch labels, link hover and the copy-success check: six jobs
for a colour declared to have two. 2.0.0 spent it back. Their replacements are
all achromatic, and every one of them survives greyscale:

| Was brass | Is now |
|---|---|
| POST method badge | fill step + ink weight (`Badge tone="strong"`); DELETE inverts (`caution`) |
| `required` chip | the shared chip fill with `font-medium` |
| warning callout | a two-pixel `--foreground` rule; note and tip step down to one |
| quickstart numerals | ink at `.type-d4` — they are ordinals, not positions |
| diagram branch labels | mono label at `--secondary-foreground` |
| link hover | the underline thickens 1px → 2px; the hue does not move |
| copy-success check | the glyph swap plus the `aria-live` region |

2.2.0 added three selectable things and spent brass on none of them, which is
the same argument one version later. **A selection is not a position.** The
reader's position on `/sdks` is still the outline rail's brass mark; the chosen
*language* is a preference they expressed, and so are an active filter chip and
a dark-mode button. All three take achromatic devices that already exist:

| Selection | Marked by |
|---|---|
| the selected language tab | `--secondary` fill + `font-medium` — the sidebar's own `aria-current` treatment — plus `aria-selected` |
| an active filter chip | `Badge tone="caution"`, the inverted chip `DELETE` and `breaking` already use, plus `aria-pressed` |
| dark mode on | the glyph swap (moon → sun) plus `aria-pressed` |

A third hue is not a palette problem, it is a design problem. Fix the element.

## Motion personality — **still**

A reading surface does not move, and 2.2.0 added four interactions without
adding a frame of animation. A tab switch, a filter, a rebuilt command and a
mode flip are all **instant swaps**: the reader asked for a different view of
the page, and the fastest honest answer is the new view. Animating a tab panel
would animate text being read, which is on this skill's never list.

One motion class exists: micro-feedback at
150ms on colour, border and background, delivered through the state-ramp classes
(`.ramp-primary`, `.ramp-quiet`, `.ramp-card`) rather than by alpha. The class
layer lives in `@layer components`, so a Tailwind utility on the same element
always wins — put resting values in the class and states in the utility, never
the reverse (`.ramp-card` is why: `bg-card ramp-quiet` had it backwards and the
hover tint never painted). No entrances, no ambient
moment, no dialog animation — the search overlay appears instantly, which is
also the fastest it can be. Transitions written in markup are `motion-safe:` and
scoped (`transition-colors`, `transition-transform`), never `transition-all`;
the class-layer ramps carry their own `prefers-reduced-motion` rule. Focus
indication is never transitioned.

The one documented omission: interactive elements have no *press* state beyond
the ramps' `:active` step. `motion-pass` asks for one; for a theme whose
interactive surface is entirely links, a colour step is the honest amount.

An animation added here without a `motion-safe:` prefix fails the gate. A second
ambient animation is out of character; do not add one.

## Invariants

1. **All colour lives in `src/theme.css`.** Components use semantic utilities
   only — `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`,
   `border-border`, `outline-ring`, `rounded-themed`, `font-sans`, `font-mono`.
   **Alpha-modified colour states are banned** (`hover:bg-primary/90`,
   `border-accent/40`): alpha moves L, C and H at once, so hover states go
   through the state ramp (`--primary-hover`, `--primary-active`,
   `--surface-tint`) and fills go through a real token. Alpha survives only for
   genuine translucency, and only inside `theme.css` — `--scrim` and
   `--mask-ink`. (1.1.0's invariant 1 blessed opacity modifiers; that directly
   contradicted `themes/CONTRACT.md`, and the contract wins.) **Bare `shadow-*`
   is banned** — the three floating panels use `.shadow-panel`, which carries
   the ink's hue. Raw hex, `rgb()`, `text-white` and palette classes are not
   allowed anywhere in markup.
2. **No arbitrary values.** The one exception in the codebase is
   `[&::-webkit-details-marker]:hidden`, which hides the native disclosure
   triangle and carries no value of its own.
3. **Sections are independent.** A file in `components/` never imports another
   file in `components/`. Shared pieces come from `components/ui/` only. That is
   why the three-pane frame is `ui/docs-layout.tsx` and not a section.
4. **Pages are composition.** No data fetching, no Next-specific APIs.
5. **Eight client components, each earned — and one rule that decides it.**
   See § The four interactions below for the rule and the ledger. The list:
   `ui/code-block.tsx` (the copy button), `ui/search-dialog.tsx` (⌘K),
   `ui/outline-rail.tsx` (the scroll-spy current marker), `ui/kbd.tsx` (the
   platform modifier), `ui/lang-tabs.tsx` (the language selection),
   `ui/log-filter.tsx` (the tag filter), `ui/request-builder.tsx` (the assembled
   curl) and `ui/mode-toggle.tsx` (light/dark). Both menus and the version
   switcher are `<details>` precisely so they stay server components, and every
   *section* is still a server component — `sdk-shell`, `changelog-log` and
   `api-reference` build their markup on the server and hand it to a client
   primitive, so the boundary is always the smallest thing that holds state. A
   ninth `'use client'` needs a reason in the pass file and has to pass the rule.
6. **One search dialog per page.** It binds ⌘K on `document`; a second instance
   would answer the same key. It is mounted in `site-header.tsx` and nowhere
   else — which is why `/components` renders the header as the page's real
   shell inside its frame instead of a second copy.
7. **Anything scrollable needs `min-w-0`, and so does its wrapper.** Grid and
   flex children default to `min-width: auto`, so a `<pre>` or a table will
   otherwise widen the whole page instead of scrolling inside its own box.
   `code-block.tsx`, `params-table.tsx`, `wire-figure.tsx` and the article
   column carry it; so must any wrapper you put around them.

   The wrapper half of that sentence cost `/` a 375px page scroll through two
   versions. `quickstart-panel`'s code column was a bare `<div class="lg:col-span-7">`
   around a `CodeBlock` that carried `min-w-0` itself — but it is the **grid
   child** that sizes the track, so the track resolved to the script's
   max-content (473px inside a 343px container) and the whole document scrolled
   sideways at 375. The block's own `min-w-0` cannot reach up. 2.1.0 put it on
   both children. Check the *chain*, not the leaf.
8. **Real content only.** Halyard's endpoints, parameters, response bodies,
   error codes and limits agree with each other across all four pages (see
   § The spine). Change one and change the rest. No lorem ipsum, ever.
9. **Contrast is computed, not eyeballed.** Both token tables pass WCAG AA on
   every pair the components use (table below). Changing a colour means
   re-running the check in `.claude/skills/check-quality/`.
10. **Focus is always visible.** Interactive elements carry
    `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`.
    Removing it fails the gate.
11. **Icons come from `lucide-react`; the meridian sphere does not.** Import
    named icons in the file that uses them. Never pass `strokeWidth` — the
    theme's line weight is lucide's default of 2, and one weight is the rule.
    Size via `className` only (`size-3.5` inside mono text, `size-4` inline with
    body and in controls, `size-5` for card glyphs). Colour is `currentColor`
    through a token class. `aria-hidden="true"` unless the icon is the only
    label. `ui/logo.tsx` stays hand-drawn; do not add other hand-rolled SVGs.
12. **One texture, on the masthead, as a luminance mask — and the reading zones
    stay flat.** See below.
13. **Every class-layer rule is emitted twice** — bare and under `.tt-meridian`
    — and the full type ladder is repeated verbatim inside the dark block. The
    marketplace preview loads every theme's stylesheet on one origin, so a
    sibling theme's bare `.type-d4` or `.dark { --text-d4: … }` matches this
    theme's elements too. The two-class selector is what outranks it. Adding a
    class-layer rule without its `.tt-meridian` twin is a latent collision.

## The four interactions — invariant 5 in full

Meridian is a reading surface, so interactivity is rationed. 2.2.0 took the
client boundary count from four to eight, and four separate excuses is not a
rule. The rule:

> **An interaction ships only if the static page is *lying* without it.**

That is the placeholder convention (§ Links and destinations) applied to
behaviour instead of to `href`s. A page that prints three languages and lets you
pick none is not lying; it is just long. A page whose sidebar says "Python" and
lands you on a screen showing TypeScript first **is** misleading, because the
label promised a selection the page could not make.

The ledger, so the next agent argues with the reasoning rather than the count:

| Interaction | Where | What was lying without it |
|---|---|---|
| Language tabs | `/sdks` | Twelve links across four surfaces named a language; each was a scroll position pretending to be a selection |
| Tag filter | `/changelog` | Five tags printed on eighteen rows that a reader could read and not use |
| Request builder | `/api#publish` | Nothing was lying; this one is a judgment call, argued below |
| Mode toggle | the header | `theme.css` calls dark **co-primary**, and a co-primary mode with no control is a claim rather than a mode |
| Copy buttons | everywhere | Already shipped since 1.0.0 through `CodeBlock`; `/sdks`, `/changelog`, `/support` and `/legal` inherited it, verified rather than assumed |
| A live "try it" that sends | **refused** | There is no API behind `api.halyard.dev`. A button answering a fabricated `202` is the worst lie in the theme, because it looks like evidence |

**The request builder, and why it is the exception.** It builds a `curl` string
from the publish endpoint's own parameters and hands it to the same copy control
every code block has. It never fetches, never mocks, never shows a response. It
is called **"Build the request"** and one line of prose says the command runs in
your terminal — renaming the control is the third answer in § Links rule 3 ("a
label the destination cannot honour → fix the label"), applied to an action.
Every default is a spine value, so the command it assembles is the command
already printed in the masthead and in `/guides#publishing`.

It is on **one** endpoint of nine, deliberately. Nine builders turn a reference
into a console, and `composition-pass`'s logic holds for interaction as well as
for type: one loud moment reads as loud only against quiet ones. Publish is the
call the quickstart teaches, the call the masthead prints, and the only one
whose parameters a reader genuinely varies. Do not add a second builder without
retiring this argument first.

**Every one of the four degrades.** Tabs render all three panels into the markup
and open the first without JavaScript; the filter renders every release; the
builder's static output is the default command; the toggle's glyph is swapped by
CSS (`.mode-light` / `.mode-dark` in `theme.css`), so a script-free dark capture
shows the sun and not the moon that means "turn dark on". Tailwind's own `dark:`
variant is **unusable in this catalogue** — nothing declares
`@custom-variant dark`, so `dark:` compiles to `prefers-color-scheme` and would
ignore the class the toggle actually sets. That is why the swap is two class-layer
rules and not two utilities.

## The luminance layer — invariant 12 in full

Meridian ships one image, `src/assets/contour-plot.jpg`: a grayscale plotter-
drawn contour survey. It is **never painted**. `docs-hero.tsx` uses it as a
`mask-mode: luminance` mask over a `.contour` layer whose only property is
`background-color: var(--mask-ink)`, full-bleed (`inset-0`, `mask-size: cover`),
`aria-hidden`. The file carries shape; `--mask-ink` — which is `--foreground` at
a per-mode alpha — carries every pixel of colour.

That is the whole mechanism, and it is what makes an image legal in a theme
whose first invariant is that all colour lives in `theme.css`:

- **No colour is baked in**, so a rebrand carries the plot. `.claude/skills/rebrand/`
  stays a one-file edit.
- **One asset serves both modes.** Light reads as pencil on paper, dark as a
  plotter trace on ink — same file, no `dark:` variant, no second asset. The
  *strength* differs by mode because it is a token, not a utility: light runs at
  18.8% and dark at 16%, since the contours were near-threshold on paper.
- **`theme.json` stays truthful** — the token tables still describe every colour
  in the theme, because the layer introduces none.

**Where it may go, and where it may not.**

- **The masthead only.** `docs-hero` is the one textured surface. `/guides` and
  `/api` are **reading zones and stay flat** — the three-pane article column is
  untouchable, because the argument of this theme is legibility over hours and
  nothing goes behind prose that a reader spends an hour on.
- **Full-bleed or not at all.** A texture that stops somewhere draws an edge,
  and Meridian's borders are decorative by decision (1.39:1) precisely so that
  no edge carries meaning.
- **No structure.** Contours and grain are texture. Ruled grids, graticules with
  straight lines, radiating arcs with a focal point, anything with a visible
  seam — those are composition, and they compete with a page whose composition
  is three panes. Two of the four candidates in
  `ops/design/meridian-hero-texture-2026-08-13.md` were rejected on exactly
  this.
- **No third hue.** The layer is `--foreground`. Painting it `--primary` or
  `--accent` would spend azure or brass on decoration, and both are committed.

**Contrast is computed against the composite**, at the asset's own measured
luminance distribution (mean 0.159 · p99 0.459 · max 0.635, from
`src/assets/README.md`):

```
surface = background·(1 − a·m) + foreground·(a·m)      a = --mask-ink alpha
```

| Pair on `docs-hero` | Light (a = 18.8%): flat → crest (p99) → brightest | Dark (a = 16%): flat → crest → brightest |
|---|---|---|
| `foreground` (h1) | 17.19 → 14.35 → 13.40 | 16.27 → 14.05 → 12.97 |
| `muted-foreground` (lede, mono eyebrow, ⌘K line) | 5.83 → 4.86 → **4.54** | 7.37 → 6.37 → 5.88 |

`muted-foreground` over the brightest single pixel is the section's floor at
**4.54:1**, and 18.8% is the cap: at 20% it falls to 4.46:1 and the gate is red.
(1.1.0 ran the light layer at 16% for a 4.73:1 floor; the contours were
near-threshold on paper, so 2.0.0 spent the headroom.) Everything with its own
background — the code block, the buttons, the `Kbd` chips — sits on top of the
layer and is unaffected.

## Composition — `/` is a marketing surface, `/guides` and `/api` are not

`composition-pass` exempts article bodies, so the reading surfaces run one
column at one width **on purpose** and their sameness is the product. Do not
"fix" them. The landing page gets the mandates in full:

The six reading surfaces are `/guides`, `/api`, `/sdks`, `/changelog`,
`/support` and `/legal`. All six run the same three-pane frame at the same
width, and that sameness is deliberate: a documentation site whose pages each
have their own layout is a site the reader has to re-learn on every route.
Variety lives in what the column *contains* — an article, an endpoint block,
three tabbed loops, a filtered log, a worked example, a retention table — not in
the frame around it.

| Mandate | How `/` meets it |
|---|---|
| Hero archetype | **A9** (index/directory-as-hero) — the masthead *is* the index |
| Asymmetric split | hero 7/5 at `xl`; quickstart 5/7 |
| Density registers | masthead `lg:pt-28` vs quickstart `lg:py-12` = **2.33:1** |
| Container widths | `Container` wide (7xl) and narrow (5xl, the quickstart) |
| Container break | the hero code panel overhangs the container's right edge |
| Oversized moment | **exactly one** — the quickstart's four numerals at `.type-d4` |

**The landing gutter is asymmetric, and only on `/`.** `Container inset="landing"`
opens the left margin to 96px from `lg` and leaves the right at 32px. The
reading surfaces spend their left edge on a sidebar, so the 32px gutter is
never what a reader sees there; `/` has no sidebar and was opening its H1 32px
from the glass. The right side does not move, because that is the side the code
panel leaves by — widening it would only pull the overhang back inside. The
shell (header, footer, the three-pane rails) keeps 32px on all eight routes and
does not follow the landing page.

**The overhang ends at the frame, not at a border.** From `lg` to the 7xl cap
the panel's right edge lands exactly on the viewport, so `CodeBlock bleed`
drops its right border and right radius over that band — a rounded, bordered
corner drawn on the glass reads as a bad crop, not as a bleed. Below `lg` the
panel is a card inside the gutter and keeps four sides; at `2xl` it floats in
the canvas and gets them back. **Known band:** between 1281 and 1535 the
container is centred but the panel still runs open-edged, so its fill stops
0–128px short of the glass with no right border. The alternative is a gutter
wide enough to hold a bordered panel at 1280, which cancels the bleed at the
width the theme is reviewed at; the bleed was judged worth more.

The oversized moment is `.type-d4` and `.type-d4` is the oversized moment: the
ladder step exists for that one element. Putting a second thing at d4 cancels
both.

## Links and destinations

1. **Every `href` resolves.** No `#`, no dead anchors, no invented external URL.
2. **The placeholder convention is visible, not silent.** A destination this
   theme does not implement is **not** a link to the nearest real page — that is
   a link that lies, and 1.1.0 had thirteen of them including the support
   strip's primary CTA. It renders as a non-link carrying `aria-disabled` and
   the word "soon": `NavItem.soon` in the sidebar, an hrefless row in the footer
   columns, `DisabledAction` for buttons, a plain span in the version switcher.
   The header nav simply lists what ships.
3. **"Soon" is a last resort, not a shrug.** 2.0.0 marked every placeholder;
   2.1.0 asked of each one whether a documentation product would really have
   that page, and built the three that would. Ask the same question before you
   reach for the chip. The test that decides it:

   - **A page a docs product publishes → build it.** `/changelog` (the header
     had promised it since 1.1.0), `/sdks` (one comparison page, not three
     stubs), `/support` (a theme cannot ship a ticket console; the instructions
     for using one are exactly what a docs site publishes), `/legal` (2.2.0 — an
     API company that bills for delivery publishes its terms).
   - **A page that would have to be faked → keep the chip.** The footer's
     **Status** row needs live data, and a static "all systems operational" is a
     fake dashboard. Its **Security** row is a compliance surface, and inventing
     certifications for a fictional company is a kind of fiction this catalogue
     does not ship. `/sdks#openapi` keeps one `DisabledAction` for the same
     reason: a theme cannot serve a file download.

     **`/legal` is on the other side of that line, and the two sit in the same
     footer column, so name the difference.** `/legal` carries **policy** — what
     a company states about itself: the agreement, the use limits, the retention
     table, the availability target. **Security** would carry **attestation** —
     what a third party issues. So `/legal` names no certification, no auditor,
     no sub-processor and no standard number, and the chip beside Security stays
     exactly where it is.
   - **A label the destination cannot honour → fix the label.** The header's
     button read "Get API keys" and pointed at `/guides`; issuing a key is a
     dashboard action. It now reads "API keys" and points at `/guides#before`,
     which is the section that documents scopes and the sandbox key. Renaming
     the link is a legitimate third answer, and often the right one.
   - **A row whose meaning the theme cannot serve → leave it a span.** The
     version switcher's v2.3 and v1.9 mean "these docs at that version", and the
     theme ships one copy of the docs. Pointing them at `/changelog` would be a
     new lie in the old one's clothes; the panel gained an "All releases" row
     underneath instead.
4. **A non-link must not look like one.** The current sidebar item is a
   `<span aria-current="page">`; the current version in the switcher is a span
   with a check mark; the index's current row is a span with the brass mark.
5. **Anchor ids are a contract.** `/guides` owns `#before`, `#publishing`,
   `#leases`, `#pipeline`, `#failure`, `#production`. `/api` owns `#auth`,
   `#create-queue`, `#delete-queue`, `#publish`, `#lease`, `#extend`, `#ack`,
   `#nack`, `#dead-letters`, `#replay`, `#errors`, `#limits`. `/sdks` owns
   `#behaviour`, `#clients`, `#typescript`, `#python`, `#go`, `#openapi`.
   `/changelog` owns one id per release: `#v2-4-1`, `#v2-4-0`, `#v2-3-2`,
   `#v2-3-0`, `#v2-2-0`, `#v2-0-0`, `#v1-9-0`. `/support` owns `#answers`,
   `#report`, `#incidents`, `#raises`. `/legal` owns `#terms`,
   `#acceptable-use`, `#retention`, `#sla`. (All six shells also carry `-title`
   ids on their headings.)

   **Three of `/sdks`'s ids are on tab panels, not sections.** When the three
   language sections became one tabbed block, `#typescript`, `#python` and `#go`
   moved onto the panels rather than disappearing — they are named in this
   page's sidebar, the guides sidebar, the footer and the search index, twelve
   links in all. A hash matching a panel selects that tab on load and on every
   `hashchange`, so those links resolve *and* now do what their label says. If
   you rename a panel id you are renaming an anchor; the same rules apply.

   **`/components` renders all six shells on one page**, so the whole set has
   to be disjoint — that is why the guides ids are spelled differently from the
   reference ids (`#publish`/`#lease` would otherwise collide), why the
   changelog uses version numbers, and why `/support` does not own `#limits`
   even though a section of it is about limits. The duplicate-id check in
   `.claude/skills/check-quality/` §6 is what enforces this; run it after adding
   any section. Renaming one id means renaming it in the sidebar, the outline,
   the footer, the masthead index and `INDEX`.
6. **The skip link moves focus, not just scroll.** Each page's `<main>` carries
   `id="main" tabIndex={-1}` with `focus:outline-none`.
7. **The wordmark link has no `aria-label`.** Its accessible name comes from the
   `data-brand-name` text inside it, so a rebrand renames the link too.

## The spine — facts that must agree

Every one of these appears in two or more places. Change one, change all.

| Fact | Value | Where |
|---|---|---|
| Base URL | `https://api.halyard.dev/v1` — stated **once** | `/api` header, every `$API` export |
| Endpoint paths | never carry `/v1` | every request line, every endpoint bar |
| Endpoint count | **nine** | `/api` lede, `theme.json`, `/components` frame note |
| Message id | `msg_01HQ8Z3F` | hero, guides ×2, api ×2 |
| Lease id | `lse_01HQ8Z` | quickstart, guides ×3, api ×3 |
| Order | `ord_10427`, `total_cents: 4990` | hero, quickstart, guides, api ×2 |
| `published_at` | `2026-08-13T09:15:01Z` | api ×2 |
| Lease expiry | `09:15:31Z` (= published + the 30s timeout) | guides, api |
| Extended expiry | `09:16:01Z` (= expiry + `extend_by: 30s`) | api |
| `visibility_timeout` | `30s` | quickstart, guides, api params, api response |
| `max_deliveries` | `5` | guides, api params, dead-letter `delivery_count: 5` |
| Lease ceiling | `max: 25`, `wait: 20s` | quickstart, guides, api params, index |
| Idempotency window | 24 hours | guides callout, api params |
| `created_at` | `2026-08-13T09:14:22Z` | guides, api |
| Scopes | `queues:admin` · `queues:write` · `queues:read` | guides prose, api scope table, every request line |
| Ack ceiling | 5,000/s, so `X-RateLimit-Remaining: 4991` is in range | api limits, api ack sample |
| `X-RateLimit-Reset` | `1786612560` (= `2026-08-13T09:16:00Z`) | api ack, api delete |
| Error codes | 400/401/404/409/429/503 | api table, auth sample |
| Body cap · durability · 202 | 256 KB · three AZs · 202 | guides, api, diagram note |
| Backoff | doubles 1s → 5m | guides, diagram note, sdks |
| Release line | `v2.4.1`, 13 August 2026, current | footer build string, api meta, changelog, sdks meta ×3 |
| Documented versions | v2.4 current · v2.3 maintained · v1.9 archived | version switcher, changelog sidebar, changelog entries |
| Breaking changes | **exactly one** since v1.9 (v2.0.0, leases replaced receipts) | changelog meta line, the one `caution` tag in the log |
| Release that added each endpoint | extend + nack = 2.4.0 · dead letters + replay = 2.3.0 · `ordering_key` = 2.2.0 | changelog request-line bars, `/api` |
| Ack ceiling history | 1,000/s → 5,000/s in 2.4.0 | changelog, api limits |
| `max_deliveries` history | 3 → 5 in 2.3.0 | changelog, api params |
| Package names | `@halyard/node` · `halyard` · `halyard-go` | sdks ×2 each, guides consumer.ts |
| Client behaviour | batch 25 · long poll 20s · extend at half-timeout · retry 429/503 | sdks capability table, guides, api |
| Support first reply | one business day | support strip, support meta, support prose |
| Report fixtures | `orders` (live, eu-west) · `msg_01HQ8Z3F` · `lse_01HQ8Z` · `09:15:31Z` · 409 against 204 | support worked report, guides, api |
| Availability target | **99.9% monthly** on publish and lease, per region | legal `#sla` prose, legal availability table, support `#incidents` |
| SLA remedy | 10% credit, one per region per month, 30-day claim window | legal availability table |
| Dead-letter retention | **14 days** | guides production prose, legal retention table, legal terms (the drain window) |
| Request-log retention | **30 days** | support required fields, legal retention table |
| Terms notice period | 30 days, against the API's own 90-day removal overlap | legal `#terms` ×2, changelog v2.0.0 |
| Builder defaults | `orders` · `ord_10427` · `total_cents: 4990` · `8f14e45f` | api request builder, hero code panel, guides publishing |

## Search

`ui/search-dialog.tsx` holds a hand-maintained `INDEX` array of 40 entries:
title, section, href, kind. It covers every page and heading this theme ships
across all eight routes — a new section that is not in `INDEX` is a page the
theme's own search cannot find — and it is the
buyer's extension point — replace `INDEX` with whatever your content pipeline
emits (an MDX frontmatter walk, a build-time JSON, an API call) and nothing else
in the component changes. **Every href in `INDEX` must resolve**, exactly like
the rest of the theme's links.

Keyboard contract, which the gate checks: ⌘K or Ctrl+K toggles, ↑ and ↓ move the
active option, ↵ opens it, Esc closes and returns focus to the trigger, Tab is
trapped inside the panel, and the empty state is a designed sentence with one
action. The list is a real `role="listbox"` with `aria-activedescendant` and
`role="option"` rows, and the active row is scrolled into view — arrow keys used
to move a highlight a screen reader could not hear and a scroll box would not
follow.

## The diagram — canonical Wire is the source, this renderer is not

`themes/CONTRACT.md` makes Wire Diagram (wirediagram.app) the standard for
diagram content, so the diagram **is** Wire:
`src/assets/delivery-pipeline.wire.json` is canonical Wire v1. Edit the graph,
never the markup.

**2.1.0's file was Wire-*shaped*, which is not the same thing.** It used
`label`, `note`, a `branch` field on the child and a bespoke `loop`, and reading
the published package (`@aigentive/wire-core@1.0.4`) none of those four are
Wire's vocabulary. That made the promise below false: a renderer swap would have
been a content migration. 2.2.0 fixed the file, not the promise.

| 2.1.0 | Canonical Wire v1 |
|---|---|
| `label` | `title` |
| `note` | `description` |
| `branch: "yes"` on the child | `from: "acked.yes"` |
| — | `branches: ["yes","no"]` declared on the condition node |
| `loop: "lease"` on the child | an entry in `edges` — the schema's own answer for a connection the parent/child tree cannot express |
| — | `version: 1`, `layout: "TB"`, `tone` on the two ends |
| `kind: "action"` on the terminal nodes | `kind: "end"` |
| `$schema` at an https URL that 404s | `wire://schemas/wire-diagram`, the resource id the package actually publishes |

The renderer follows the **schema**, not the file: it folds `after` into `from`,
parses `"{node}.{branch}"` refs, knows all twelve node kinds, draws `edges` as
labelled return lines, renders `attachedTo` notes as annotations with no edge,
and guards against a cycle a buyer's edit could introduce. A node it does not
recognise falls back to `action` rather than throwing.

**Styling is mapped to this theme's law, not to Wire's defaults.** Wire's `tone`
is a severity signal that most renderers spend as hue. This one cannot — two
hues, two jobs each, and neither job is "warning" — so tone maps onto the same
achromatic ramp `callout.tsx` and `badge.tsx` already use: a two-pixel
`--foreground` rule for `warning` and `error`, the azure hairline for `info`
(azure carries structure and a decision point is structure), a plain hairline for
the rest, and **the tone's own word printed in the node's kind line**
(`end · warning`). It survives greyscale, which a hue does not. Brass appears
nowhere in the figure: a static diagram contains no literal string and no reader
position.

The upstream React renderer (`@aigentive/wire-react`) is real and installs
cleanly, and is deliberately not used here: it requires its own stylesheet with a
hardcoded palette, which would put a second colour system inside a theme whose
contract is "every colour is a token", and its canvas is a client component that
a static figure on a reading surface does not need. `ui/wire-figure.tsx` renders
the same JSON with tokens, and now that the JSON is canonical, swapping in the
upstream renderer is a component change with no content migration. `theme.json`
`dependencies` stays truthful: `lucide-react` only.

**One figure, and the second one was considered and refused.** The candidate was
a queue-topology diagram — one producer, one queue, several consumers competing
on leases, ordering keys running in parallel. Parallelism by ordering key is
already one sentence on `/guides#production` and one parameter row on `/api`, and
a second figure of the same system on the same site teaches the reader to skip
both. A diagram earns its place by carrying what prose cannot.

## Verified contrast (WCAG 2.1, computed)

| Pair | Light | Dark |
|---|---|---|
| foreground on background | 17.19:1 | 16.27:1 |
| foreground on card | 18.47:1 | 15.28:1 |
| foreground on muted | 16.14:1 | 14.72:1 |
| foreground on secondary | 15.24:1 | 13.30:1 |
| foreground on surface-tint | 16.67:1 | 13.79:1 |
| muted-foreground on background | 5.83:1 | 7.37:1 |
| muted-foreground on card | 6.26:1 | 6.92:1 |
| muted-foreground on muted | 5.47:1 | 6.67:1 |
| muted-foreground on secondary | 5.17:1 | 6.03:1 |
| muted-foreground on surface-tint | 5.65:1 | 6.25:1 |
| secondary-foreground on secondary | 11.60:1 | 9.96:1 |
| secondary-foreground on muted | 12.28:1 | 11.02:1 |
| primary on background | 7.06:1 | 7.86:1 |
| primary on card | 7.58:1 | 7.39:1 |
| primary on secondary | 6.26:1 | 6.43:1 |
| primary-foreground on primary | 7.58:1 | 8.02:1 |
| primary-foreground on primary-hover | 9.90:1 | 6.38:1 |
| primary-foreground on primary-active | 12.70:1 | 5.05:1 |
| accent on card | 5.92:1 | 9.78:1 |
| accent on background | 5.51:1 | 10.41:1 |
| **accent on secondary** (tightest in the theme) | **4.88:1** | 8.51:1 |
| background on foreground (the caution badge) | 17.19:1 | 16.27:1 |
| ring on background (non-text, needs 3:1) | 7.06:1 | 7.86:1 |

Code runs at 12–14px on `--card`, so its four roles are checked against the card
rather than the page: comments 6.26 / 6.92, strings (brass) 5.92 / 9.78, keywords
(azure) 7.58 / 7.39, numerals 12.28 / 11.02.

`--accent` on `--secondary` (4.88:1 light) is the tightest text pair. Any change
to `--accent`, `--secondary` or `--muted-foreground` must be re-verified.

**Borders are deliberately decorative** (1.39:1 light, 1.58:1 dark). Every
element whose boundary carries meaning also carries a fill step and a text or
icon label, so nothing depends on a hairline — including the warning callout,
whose extra weight is paired with a glyph and the word "Careful". Do not "fix"
the border by darkening it; fix the element that leans on it.

**Undertone**: hue 258°, every neutral within ±12° and over the chroma floor for
its lightness. `--card: #ffffff` is the one sanctioned achromatic.

## What never to touch

- **The signature and its deviation** — the reading edge, `.measure`, and the
  36rem cap. They are one decision in three files.
- **The meridian sphere in `src/components/ui/logo.tsx`** — identity is bespoke.
- **`min-w-0` on `code-block.tsx`, `params-table.tsx`, `wire-figure.tsx` and the
  article column** — removing it breaks 375px in a way that is invisible on a
  desktop screen.
- **The parameter table's two-column shape** — name/type/default/required
  stacked left, description right. It is why the table survives 375px with no
  horizontal scrollbar.
- **The `.tt-meridian` twin on every class-layer rule** (invariant 13), including
  `.mode-light` / `.mode-dark`.
- **`ui/mode-toggle.tsx`'s root resolution and its read-the-DOM start.** It
  toggles `closest(".tt-meridian") ?? document.documentElement`, reads its
  initial state from the class already on that element, and applies stored
  preference **only** when standalone. Seeding it from storage instead would let
  the theme overrule a marketplace preview that had already chosen a mode.
- **The three `/sdks` panel ids.** They are anchors twelve links depend on
  (§ Links rule 5), not internal names.
- **`@theme inline` mappings** — they live in the host app's `globals.css`,
  never in a theme. Do not add a `@theme` block here.
- The anchor ids listed above, and the single mount point of the search dialog.
- **Anything outside `themes/meridian/`.**

## Extending

| Task | Skill |
|---|---|
| Apply a buyer's brand | `.claude/skills/rebrand/` |
| Add a section | `.claude/skills/add-section/` |
| Add a page | `.claude/skills/add-page/` |
| Verify before shipping | `.claude/skills/check-quality/` |

## Imagery

Meridian ships **one** image: `src/assets/contour-plot.jpg`, **AI-generated
(Gemini)**, licensed to you with the theme and regenerable — the prompt is in
`src/assets/README.md` and the marketplace produces it with
`bun run generate-image`. It is not a photograph of a real place, product or
person, and it is never painted: it is a luminance mask, so the colour is
`--mask-ink` and nothing else (invariant 12).

Everything else stays as it was, by decision. Documentation is read, not
browsed; no photography, no illustration, no second texture, and the one figure
that earns its place is structural, so it is drawn from data. Meridian's
identity is typographic; keep it that way.

## Relay — AI Agent Product Landing

- Slug: relay
- Category: saas landing
- Price: $39
- Description: An AI SaaS landing page template for agent products that ships the agent UI with it. One support ticket is told four ways: a live card in the hero, a full five-turn transcript with real Stripe tool calls and a policy citation, a timed run log of every call, and a Wire Diagram decision flow that forks where the agent stops. Two Wire Diagram graphs ship as canonical JSON in Wire's own schema, so a buyer's agent edits the diagrams the way it edits everything else. Plus a handoff panel showing what a human receives, a contact page with named desks and the registered entity, a working light and dark toggle in the navbar, and a price calculator that does the real arithmetic on closed tickets. A cool blue-grey system at OKLCH hue 256 with one amber accent that marks every place a person acts and nowhere else — no purple, no gradients. Every interior page opens on its own instrument rather than a shared masthead: the transcript card on product, the write-path diagram on security, the calculator on pricing, the desk grid on contact. Self-hosted Public Sans and JetBrains Mono, a 1.200 modular type scale on two token ladders, home, product, security, pricing, contact and a living /components styleguide, Tailwind v4 semantic tokens, and complete light and dark modes verified against WCAG AA by computation.
- Pages: /, /product, /security, /pricing, /contact, /components
- Tags: ai saas landing page template, ai agent website template, ai agent landing page, tailwind-v4
- Site: https://tailthemes.com/themes/relay
- Manifest: https://tailthemes.com/api/themes/relay
- Registry: https://tailthemes.com/r/relay
- Preview: https://tailthemes.com/preview/relay

### Relay — AI Agent Product Landing AGENTS.md

# AGENTS.md — Relay

Rules for any agent (or human) editing this theme. They are not style
suggestions; the publish gate and the extension skills in `.claude/skills/`
assume them.

## What Relay is

A landing page for an **AI agent product** that ships the agent UI with it. The
fictional product is an AI support agent for a subscription company's **billing
queue**: it reads Stripe, a read replica of the accounts table and the help
center, closes the routine tickets, and hands the rest to a named person with
the research attached.

The positioning line every piece of copy descends from:

> For support leads at subscription SaaS who cannot let an AI near billing,
> Relay closes the routine billing tickets from live Stripe and account data and
> hands the rest to a human with the research already attached.

## The signature — **one ticket, told four ways**

Ticket `ZD-4821` arrives as a live card in the hero, replays as a full
transcript, replays again as a timed rail, and replays a third time as a forked
decision graph. Home carries a fourth trace in `outcome-band`: the run's own
numbers, in the ops-sheet voice. The same eight facts, four registers, three
routes.

It is structural, it survives a rebrand, it survives a palette change, and it is
the actual reason to buy Relay over any other agent-landing template. Two
conditions keep it honest, and both are load-bearing:

1. **The four tellings must agree.** They are reconciled by the spine table in
   `.claude/skills/check-quality/`, not by hope. Changing a number in one place
   means changing it in every place that table lists.
2. **The graph must be able to represent the run.** `ZD-4821` takes *both*
   branches — the first policy check clears and Relay answers, the customer comes
   back, the second check blocks. That is why `gather` in
   `agent-loop.wire.json` lists **two** parents, `["ticket", "reopened"]` —
   Wire's own multi-parent edge, drawn as a return edge. A tree with no way home
   would make the diagram contradict the log it cites.

Amber-means-human is **not** the signature. It is a colour rule (below), it is
deleted by the first rebrand, and a signature has to be a structural or material
device.

### The sanctioned deviation — one, and this is it

**`agent-flow`'s centred section heading.** Every other heading in Relay is
left-set. That one is centred because the figure below it is symmetric about the
fork, and a left-set heading would fight its axis. Adding a second centred
heading anywhere is a regression, not a precedent.

## The opening device — every route opens on a different instrument

**No marketing page in Relay wears a shared lid.** Until 2.2.1 four of the five
opened with a pixel-identical `ui/page-hero`: eyebrow at y=128, H1 at y=170,
lede at y=217, a hairline at x=852 and a 2×2 of mono label/value pairs at x=886
and x=1047. Only the eight strings inside changed, and on three of the four
pages every one of those strings was restated verbatim in the section directly
below — the ledger's own column counts, the desk grid's badges, the run
summary in the transcript's footer. That is not a masthead, it is a caption
written twice.

| Route | Opens on | Carries the `<h1>` |
|---|---|---|
| `/` | `agent-hero` — the offset copy block and the overhanging ticket card | `agent-hero` |
| `/product` | `conversation-thread`'s card, whole and unclipped, the title set into its own header rail | the card's head row |
| `/security` | `scope-ledger`'s write-path Wire figure under a one-line masthead | `SectionHeading level={1}` |
| `/pricing` | `price-estimator` — a working slider with the answer already beside it | `SectionHeading level={1}`, inside the control column |
| `/contact` | `support-desk`'s four addressed desks on the wide rail | `SectionHeading level={1}` |
| `/components` | `ui/page-hero` — a styleguide is an inventory, and its lid is a specimen | `ui/page-hero` |

Three rules hold this in place:

1. **The opening section carries the page's `<h1>`**, at `type-d3`, through
   `SectionHeading level={1}` — one heading block, one ladder step, no second
   masthead grammar to keep in sync. `conversation-thread` is the one that sets
   its own, because its `<h1>` lives inside the card rather than above it.
2. **A fact the opening section already states does not get a second telling in
   a lid.** Every number the four mastheads carried is still on its page: the
   run summary is the transcript's footer strip, the scope counts are the
   ledger's lede, `31 min` is contact's d4 figure and `24/7` is the fourth
   desk's badge. Deleting the restatement is the whole change.
3. **`ui/page-hero` survives, and it is not deprecated.** It is the right
   opener for a page with no better device — a legal notice, a changelog — and
   `add-page` still reaches for it *second*. Adding it back on top of a section
   that already opens well is the regression this section exists to stop.

Interior pages now open on the sheet (`bg-background`) rather than on the
masthead's `bg-card` band, which is what home has always done. The first
card-ground band arrives with the first section that owns one.

## Copy register — **technical-plain**

> 8–12 word headlines that name the mechanism. One clause. Concrete nouns.
> Numbers over adjectives, and every number checkable against the spine.

The H1 is *"Relay closes routine billing tickets and escalates the rest to a
person"* — eleven words, the mechanism stated, no cadence borrowed from the
quiet-declarative run the catalog was told to stop writing. Section headings stay
≤6 words, ledes stay one sentence ≤20 words, eyebrows ≤3, CTAs verb-first with
their number kept (*"Start free for 14 days"*).

Two phrases are fixed strings and must be repeated verbatim wherever they appear
— one promise, one unit:

- **"You approve its first 50 replies"** — the trust guarantee. `agent-hero`,
  `pricing-table`, `cta`, `faq`. Never "drafts for a week"; that was a second
  unit for the same policy and a buyer could not tell whether it was one rule or
  two.
- **"No card to start"** — the trial term. `agent-hero`, `pricing-table`, `cta`,
  the pricing masthead. Never "No card for the trial".

## File map

```
themes/relay/
  theme.json                     manifest — inventory + full token tables. Keep truthful.
  README.md                      human quick start
  AGENTS.md                      this file
  .claude/skills/                rebrand · add-section · add-page · check-quality
  src/
    theme.css                    EVERY design decision: colour, type, radius, fonts, the class layer.
    fonts.ts                     next/font/local → the two shipped faces; exports `fontClassName`
    fonts/                       self-hosted woff2 + verbatim OFL licences
    assets/
      README.md                  what each file is, and the prompt that regenerates the mask
      agent-loop.wire.json       the decision graph, canonical Wire JSON
      scope-model.wire.json      /security's write-path graph, canonical Wire JSON
      hero-routing.png           the hero's routing field — a colourless alpha mask, not a picture
    app/
      home.tsx                   header · agent-hero · outcome-band · how-it-works · boundary-grid · integrations · cta · footer
      product.tsx                header · conversation-thread (lead) · tool-timeline · agent-flow · handoff-panel · guardrails · cta · footer
      security.tsx               header · scope-ledger (lead) · guardrails · audit-record · cta · footer
      pricing.tsx                header · price-estimator (lead) · pricing-table · faq · cta · footer
      contact.tsx                header · support-desk (lead) · cta · footer
      components.tsx             the living styleguide — every part in a labelled frame
    components/
      site-header.tsx            sticky nav + JS-free <details> mobile menu
      agent-hero.tsx             A8 offset: copy in columns 2–7, the live ticket card overhanging right, over the routing field
      outcome-band.tsx           65% at d4 (home's oversized moment) + the quote, and this run's own numbers
      how-it-works.tsx           three steps: reads, drafts, hands off
      boundary-grid.tsx          what it closes / what it passes, weighted 7/5 like the claim
      integrations.tsx           six systems with their access level stated, on the wide rail
      conversation-thread.tsx    the full five-turn transcript with citation and real proration
      tool-timeline.tsx          the same run as a timed log — the theme's container break
      agent-flow.tsx             the decision flow — section shell over ui/wire-figure
      handoff-panel.tsx          what the human receives (8/4), plus the queue empty state
      guardrails.tsx             four named rules, stated as configuration (renders on /product AND /security)
      scope-ledger.tsx           the write-path figure, then the eight scopes on the wide rail: scope, system, direction, credential, objects
      audit-record.tsx           retention at d4 beside one real audit row — the blocked call, field by field (4/8)
      pricing-table.tsx          three plans priced per closed ticket + the $0 band
      price-estimator.tsx        CLIENT ISLAND. the slider, the real arithmetic, the cheaper plan named (7/5)
      support-desk.tsx           /contact: four addressed desks on the wide rail, then 31 min beside the registered entity (5/7)
      faq.tsx                    six <details> objections
      cta.tsx                    closing panel
      site-footer.tsx            four columns + non-interactive status line
      ui/
        button.tsx               primary | secondary | ghost · sm | md | lg · disabled
        badge.tsx                mono pill · neutral | accent | outline
        container.tsx            the content column, and `WideRail`, the one sanctioned break
        logo.tsx                 the relay mark + wordmark, and `LogoMark` alone
        section-heading.tsx      eyebrow + heading + lede; `level={1}` for the section that opens a page
        page-hero.tsx            the standalone masthead — the /components lid, and the fallback for a page with no better opening device
        chat-turn.tsx            one transcript row · customer | agent | human
        tool-call.tsx            one tool invocation block
        wire-figure.tsx          the Wire renderer — every .wire.json figure goes through it
        mode-toggle.tsx          CLIENT ISLAND. light/dark, onto .tt-relay or the document
```

## Typefaces — two roles, both shipped

| Token | Family | Why |
|---|---|---|
| `--font-text` (`--font-sans` is its alias) | **Public Sans** | the USWDS engineering face, drawn for federal technical material — it reads as a record someone is accountable for, which is the register of a console trusted near money |
| `--font-mono` | **JetBrains Mono** | drawn for long reading of code at small sizes, and small is the only size Relay ever sets it at |

**No display face.** Relay's display sizes are the text face at 600 (500 in
dark). A third role would fight the flat ops-sheet register the theme is built
on, and the mono already carries more of this theme's identity than the sans
does — every eyebrow, every badge, the whole run log, all tool-call chrome.

Both are OFL 1.1, latin-subset woff2, self-hosted in `src/fonts/` with their
licences, loaded by `src/fonts.ts` through `next/font/local`. **`next/font/google`
is forbidden** — build-time fetching breaks the offline-zip guarantee. The system
stack stays as each token's tail with `adjustFontFallback`, so the swap does not
reflow a page full of tabular run logs. `src/fonts.ts` exports **`fontClassName`**;
the zip's generated layout puts it on `<html>` and the marketplace preview
appends it to the `.tt-relay` wrapper.

## Type scale — r = 1.200, two ladders, one class layer

The display ladder is anchored at 1.5rem and stepped by **1.200** (the
dev-tools/docs ratio — Relay's personality is an operations console, not the
`saas landing` string in its manifest category). The UI ladder is near-fixed:
readability is not a personality.

| Class | Token | Size | Used for |
|---|---|---|---|
| `type-d4` | `--text-d4` | 34.6 → 41.5px fluid | **exactly one element per page** |
| `type-d3` | `--text-d3` | 28.8 → 34.6px fluid | page H1s |
| `type-d2` | `--text-d2` | 24 → 28.8px fluid | section headings |
| `type-d1` | `--text-d1` | 24px | prices, the closing panel heading |
| `type-lead` | `--text-lead` | 18px / 1.6 | ledes, pull quotes |
| `type-body` | `--text-body` | 16px / 1.55 | body |
| `type-small` | `--text-small` | 14px / 1.45 | the UI default |
| `type-caption` | `--text-caption` | 12px / 1.4 | mono metadata |
| `type-label` | — | 12px, uppercase, +0.1em | eyebrows and column heads |

Rules:

1. **Bare `text-*` utilities are banned.** Sizes route through this ladder
   exactly like colour routes through the palette. So are bare `tracking-*` and
   `leading-*` on type: the step carries all three.
2. **Weight lives on the display step**, via `--display-weight` (600 light, 500
   dark). That is the optical checklist's dark-mode weight compensation applied
   once for the whole theme instead of every heading remembering it. Do not add
   `font-semibold` to a `type-d*` element.
3. **Responsiveness lives in the clamp, not in a variant.** `type-d3` is one
   class at every width. A ladder you restate at each breakpoint is not a ladder.
4. **The class layer is emitted twice**, bare and under `.tt-relay`. Every
   theme's stylesheet is loaded at once on the marketplace preview origin, and a
   bare `.type-d4` from a later-loaded theme would otherwise win. Same reason the
   dark block restates the whole token table.

## The colour idea — read this before touching `src/theme.css`

Relay is **a cool sheet with one warm mark**, and the temperature carries
meaning:

| Temperature | Tokens | Means |
|---|---|---|
| cool | `background` `card` `secondary` `muted` `foreground` | the machine |
| warm | `primary` `accent` `accent-foreground` | **a human acts here** |

Amber appears in exactly eleven places, and every one of them is a person acting:

1. **the primary control the reader drives** — the primary button, and the
   `price-estimator` slider's thumb and filled track (`accent-primary`). The
   reader is the person; a control they hold is them acting.
2. the handoff panel's header band
3. the escalation node on the run timeline
4. **the `human` node of any Wire figure** — `agent-flow`'s blocked branch, and
   `scope-ledger`'s "The refund goes to a person"
5. the `blocks` branch badge that leads to it
6. the `Escalated` badges
7. the `Sent by a person` badge
8. the human reviewer's avatar
9. the "Relay hands these over" mark in the boundary grid

Two of those are new in 2.2.0 and neither is an exception bolted on: the rule
**generalised** instead. Amber follows `kind: "human"` in the *data*, so a new
figure inherits it without a decision; and the primary control is the primary
control whether it is a button or a slider. If you find yourself arguing for a
tenth *kind* of amber, that is the signal you are decorating.

It appears **nowhere else**. Consequences you must preserve:

1. **Never spend amber on a feature card, an icon row, a check mark, a section
   eyebrow, a status dot, a tool-call arrow or a marketing badge.** Every one of
   those was amber in 1.2.0 — about forty marks, of which eight meant anything —
   and the semantic the product's whole argument rests on was unreadable. They
   are all `muted-foreground` now. That is not timidity; it is what makes the
   nine legible.
2. **The machine's own mark is not amber.** `ui/logo.tsx` renders in
   `foreground`, the agent avatar inverts `foreground`, the streaming dots are
   `muted-foreground`.
3. **`--accent` is read against `--card`, never against the sheet.** In light
   they sit 1.20:1 apart and `--accent` against `--background` is 1.02:1. Every
   amber surface in this theme is on or beside a card. Putting one on a bare
   `bg-background` field makes it disappear.
4. **`--accent-foreground` has one job: ink on the amber wash.** It used to
   double as the primary button's hover fill, which over-constrained it into
   under-stepping the hover in both modes. The hover is `--primary-hover` now.

## The undertone — declared, and measured

Undertone hue is **256°**. Every cool neutral sits within a 3° spread of it and
clears the contract's chroma floor with headroom:

| Token (light) | hex | L | C | floor |
|---|---|---|---|---|
| `--card` | `#f5f9ff` | 0.981 | 0.0091 | 0.008 |
| `--background` | `#dfe7f2` | 0.925 | 0.0172 | 0.015 |
| `--muted` | `#d6dfeb` | 0.900 | 0.0191 | 0.015 |
| `--secondary` | `#cdd8e7` | 0.878 | 0.0239 | 0.015 |
| `--border` | `#becada` | 0.835 | 0.0260 | — |
| `--foreground` | `#121820` | 0.207 | 0.0184 | — |
| `--muted-foreground` | `#505c6b` | 0.470 | 0.0286 | 0.020 |
| dark `--foreground` | `#e2eaf5` | 0.934 | 0.0172 | 0.015 |

Two things follow, and neither is negotiable:

- **`--card` is not `#ffffff`.** It is a second surface register, not a lit
  panel, so it carries the undertone. The contract sanctions pure white only
  where "a lit surface" is the stated idea, and Relay does not state it.
- **The sheet sits at L 0.925, not near-white.** `--card` and `--background`
  cannot *both* be near-white and both carry the undertone: 8-bit sRGB cannot
  express C 0.009 above about L 0.981, so a near-white sheet forces the card to
  0.99 and the two collapse to a 1.08:1 band. Dropping the sheet is what buys the
  1.18:1 separation below. It also lands Relay's canvas in the deep-paper
  lightness band, which no other theme occupies.

## Surface bands — measured, and louder than the decoration

| Step | Light | Dark |
|---|---|---|
| `background` → `card` | **1.180** | **1.169** |
| `card` → `muted` | 1.273 | 1.142 |
| `card` → `secondary` | 1.364 | 1.133 |
| `background` → `secondary` | 1.156 | 1.325 |
| `card` → `accent` | 1.202 | 1.197 |
| hero routing field vs the sheet | 1.147 | 1.159 |

The last row is the point. In 1.2.0 the background texture was a **stronger**
surface event (1.22:1 / 1.33:1) than every structural band change in the theme
(1.08:1), which is why the pages read as one continuous field. The field is now
quieter than the smallest band step, in both modes, at one opacity.

`--border` remains **decorative-contrast by design** (1.25:1 light, 1.43:1 dark)
and is not a 3:1 target. That rule is now true: no bordered surface in this theme
relies on its hairline, because every one of them also changes background by at
least 1.14:1.

## Density registers — three, alternating

`composition-pass` named Relay as its reference failure: twelve sections at
`py-24` and two at `py-20`, a 1.20:1 spread. Three registers now, and they
alternate on every page:

| Register | Classes | lg value | Sections |
|---|---|---|---|
| quiet | `py-12 sm:py-14` | 56px | `outcome-band` · `cta` · `ui/page-hero` · `price-estimator` · footer |
| standard | `py-16 sm:py-20` | 80px | `how-it-works` · `integrations` · `tool-timeline` · `handoff-panel` · `faq` · `scope-ledger` · `audit-record` |
| loud | `py-24 sm:py-32` | 128px | `agent-hero` · `boundary-grid` · `conversation-thread` · `agent-flow` · `guardrails` · `pricing-table` · `support-desk` |

128 ÷ 56 = **2.29:1** on every page. Home runs loud · quiet · standard · loud ·
standard · quiet; product runs loud · standard · loud · standard · loud ·
quiet; security runs standard · loud · standard · quiet; pricing runs
**quiet** · loud · standard · quiet; contact runs loud · quiet. No two adjacent
sections share a register.

`price-estimator` is quiet on purpose, and now it opens `/pricing`: a calculator
is a control, not a proclamation, and the quiet register is what lets the loud
`pricing-table` under it read as the page's weight. It was the only slot that
worked when it sat between the table and the FAQ, and it is still the only one
that keeps the alternation now that it leads.

Security's arc is symmetric around the rule — standard · loud · standard — and
that is the argument's shape rather than a coincidence: reach, rule, record,
with the rule loudest because it is the answer the reviewer came for. Losing the
masthead sharpened it; the symmetry used to be padded on both ends by a lid and
a closing panel.

`ui/page-hero` stays in the quiet register and now renders on `/components`
only.

**One oversized moment per page, never two:**

| Page | The moment |
|---|---|
| home | `outcome-band`'s **65%** at `type-d4` — a full ladder step above the H1's `type-d3` |
| product | `agent-flow`'s fork: the figure is set narrow to `max-w-md` before the fork and opens to the full `max-w-4xl` at it, so the shape widens exactly where the decision happens |
| security | `audit-record`'s **13 months** at `type-d4`, set beside the record it governs |
| pricing | the full-bleed **$0** band under the plans |
| contact | `support-desk`'s **31 min** at `type-d4`, set beside the registered entity |

`price-estimator`'s total is `type-d1`, the price step — **not** d4. Two oversized
moments on one page cancel, and `/pricing`'s belongs to the `$0` band.

**One container break per page:** `integrations` on home, `tool-timeline` on
product, `scope-ledger` on security and `support-desk` on contact all use
`WideRail`; pricing's `$0` band goes full-bleed. Nothing else leaves the
`max-w-6xl` column except the hero's overhanging ticket card.

**Asymmetry — the rule is per page: no page repeats a split shape inside
itself.** (One shape used twice on one surface is the letter of the rule and none
of its intent; the same proportion recurring on two different routes is a house
style, which is the opposite problem.)

| Page | Shapes |
|---|---|
| home | the hero's 6-column offset from column 2 · `outcome-band` 7/4 · `boundary-grid` 7/5 |
| product | `handoff-panel` 8/4 |
| security | `audit-record` 4/8 |
| pricing | `price-estimator` 7/5 · the `$0` band 3/9 |
| contact | `support-desk` 5/7 |

Dropping the masthead took a 7/4 off four pages, which is the point: the same
split on four routes was a house style, and the shapes that remain are each
page's own. Every page still clears composition-pass — one asymmetric split, two
container widths (`Container` and `WideRail`, or the full-bleed band), one
container break.

`scope-ledger`'s row is a 4/2/3/3 ledger rhythm inside one row, not a split
shape.

## Invariants

0. **Never use Tailwind's `dark:` variant.** No theme here declares
   `@custom-variant dark`, so `dark:` compiles to `prefers-color-scheme` — the
   OS setting, not the class `ui/mode-toggle` writes. Relay's dark mode is the
   whole token table restated under the plain selector `.dark, .tt-relay.dark`,
   so every colour flows from a custom property that flips with the class and
   the theme is immune by construction. A mode-conditional *value* is a token; a
   mode-conditional *style* is a class-layer rule in `theme.css`, emitted bare
   and `.tt-relay`-scoped. Neither is ever a `dark:` utility.

1. **All colour lives in `src/theme.css`.** Components use semantic utilities
   only — `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`,
   `border-border`, `outline-ring`, `rounded-themed`, `font-sans`, `font-mono` —
   plus the `type-*` class layer for size. Opacity modifiers on tokens
   (`bg-background/85`) are fine for genuine translucency; **alpha-modified
   colour states are not** — hover and press use `--primary-hover` and
   `--primary-active`. Raw hex, `rgb()`, `text-white` and `bg-amber-500` are
   banned, including inside the faux agent UI.

2. **Arbitrary values: seven kinds, and only these.** Every one is non-colour or
   is a token reference, and exists because Tailwind ships no utility for it:

   | Class | File | Why |
   |---|---|---|
   | `[&::-webkit-details-marker]:hidden` | `site-header.tsx`, `faq.tsx`, `ui/wire-figure.tsx` | hides the native disclosure triangle |
   | `transition-[background-color,border-color,color]` and its `motion-safe:` twin adding `transform` | `ui/button.tsx` | enumerated transition-property; `transition-all` is banned and there is no combined utility |
   | `motion-safe:active:scale-[0.98]` | `ui/button.tsx` | the press scale motion-pass specifies; Tailwind's scale steps skip 0.98 |
   | `[animation-delay:160ms]` / `[animation-delay:320ms]` | `ui/chat-turn.tsx` | the streaming dots' stagger; Tailwind's `delay-*` targets transitions, not animations, and a theme may not define keyframes |
   | `hover:bg-[var(--primary-hover)]` / `active:bg-[var(--primary-active)]` | `ui/button.tsx` | the state-ramp tokens; the marketplace's `@theme inline` bridge is the marketplace's file and maps neither |
   | `bg-[var(--primary-hover)]` etc. | `app/components.tsx` | the same three tokens, as styleguide swatches |

   The arbitrary **variant** `[.dark_&]` is gone: dark-mode display weight now
   rides on `--display-weight` in the token layer, and the routing field uses one
   opacity in both modes.

3. **Sections are independent.** A file in `components/` never imports another
   file in `components/`. Shared pieces come from `components/ui/` only.

4. **Pages are composition, and there are exactly two client islands.** No data
   fetching, no Next-specific APIs. Every *page* is pure composition and every
   *section* is a server component, with two named exceptions — and the test
   they had to pass is "`<details>` cannot do this", not "this would be nicer
   with state":

   | Island | Why HTML cannot | 
   |---|---|
   | `ui/mode-toggle.tsx` | a mode switch writes a class onto an **ancestor**; CSS has no selector that does that |
   | `price-estimator.tsx` | it does arithmetic, and there is none in HTML |

   Everything else stays JS-free and that is still a selling point: the mobile
   menu, the FAQ, the diagrams' "Read as text" and the whole transcript are
   `<details>` and static markup. **A third island needs the same argument in
   writing.** Rejected once already, and the reasoning stands: a "show the tool
   call" disclosure on the transcript would hide the product's proof on the
   theme's strongest screen, and `tool-timeline`'s own lede is "nothing hidden
   behind a spinner".

5. **Anything scrollable needs `min-w-0`.** `ui/tool-call.tsx` and the result
   column in `tool-timeline.tsx` carry `min-w-0` + `overflow-x-auto`; so must any
   new wrapper you put around them. **And the shipped fixtures never reach it** —
   in a static gallery screenshot "scrollable" does not exist, and a result cut
   mid-token at the card edge reads as broken. Keep tool-call results under about
   44 characters.

6. **The transcript is a console, not a bubble chat.** `ui/chat-turn.tsx` is a
   row: fixed avatar gutter, mono metadata line, body at `max-w-prose`. Left and
   right rounded bubbles read as a consumer messenger and would erase the theme's
   identity. Do not "improve" it into bubbles.

7. **Real content only, and internally consistent.** One ticket runs through the
   whole theme and **the reconciliation table in `.claude/skills/check-quality/`
   is the contract**, not this paragraph. The arithmetic is real: Coilbook's
   period is 1 Mar – 1 Apr (31 days) and it moved from Solo $39 to Studio $149 on
   12 March, leaving 20 days, so Stripe bills `$149 × 20/31 = $96.13` and credits
   `$39 × 20/31 = $25.16`, invoicing **$70.97**. Both sides prorate, on the same
   denominator. If you change one of those five numbers, change all five and walk
   the spine table. No lorem ipsum, ever.

8. **Ticket ids never start with `#` followed by hex digits.** `#4821` is
   indistinguishable from a colour to the token-leak lint, which is why the
   theme writes `ZD-4821`. Keep the prefix.

9. **Contrast is computed, not eyeballed.** Both token tables pass WCAG AA on
   every pair the components use (table below). Changing a colour means
   re-running the check in `.claude/skills/check-quality/`.

10. **Focus is always visible, and never transitioned.** Interactive elements
    carry `focus-visible:outline-2 focus-visible:outline-offset-2
    focus-visible:outline-ring`, plus a radius class so the outline follows the
    theme radius instead of boxing the text. `outline-color` appears in no
    `transition-property` list in this theme. Removing either fails the gate.

11. **Colour is never the only signal.** Every escalated/resolved/access state
    renders an icon *and* a word.

12. **Icons come from `lucide-react`; the relay mark does not.** House rules:
    never pass `strokeWidth` (Relay's line weight is lucide's default of 2);
    size via `className` only (`size-3.5` in mono runs, `size-4` inline with
    body text and buttons, `size-5` for section marks, `size-6` for the
    integrations row); colour is `currentColor` through a token class; and
    `aria-hidden="true"` unless the icon is the only label. **An icon repeated
    down a column is texture, not signal** — the run log's glyphs are one per
    kind of call (`CreditCard` for a Stripe read, `Search`, `ShieldCheck`,
    `Send`, `Inbox`, `ArrowRightLeft`), not six identical wrenches.
    `ui/logo.tsx` stays hand-drawn; do not add other hand-rolled SVGs.

## Optical checklist — where each item landed

| Item | Where |
|---|---|
| Size-dependent tracking | on the ladder step: −0.025em at d4, −0.02em at d3, −0.01em at d1/d2, **0 on every UI step** |
| Uppercase label tracking | `type-label`, +0.1em at 12px |
| Dark-mode weight | `--display-weight` 600 → 500 |
| Overshoot / circles | `rounded-full` on the streaming dots and the footer status dot; `rounded-md` on a 6px box draws a squircle |
| Optical centering | `pb-px` on every button, `pt-1 pb-1.5` on every badge |
| Cap-height alignment | `mt-0.5` on icons in wrapped list rows |
| Numerals | `tabular-nums` wherever digits stack |
| Microtypography | non-breaking space before every duration unit; the handoff panel's comma lives inside its text node, not as a flex child |
| Measure | `max-w-prose` on transcript bodies, FAQ answers, ledes, card bodies |
| Hanging punctuation | **not applied** — Relay ships no display-size quotation marks |
| Asymmetric button padding | **not applied** — no Relay button renders an icon |

## Motion personality — **attentive**

> Micro-feedback at 140–150ms `ease-out` on hover and press; `<details>`
> disclosures swap without animation; **no entrance animations** — an agent
> console does not arrive, it is already running. The one ambient identity
> moment is the three-dot streaming indicator on the agent's last turn.

- **The streaming indicator lives in exactly one component** —
  `ui/chat-turn.tsx`, rendered only where a caller passes `streaming`. Today
  that is `agent-hero` and the `ui` frame on `/components`.
- **It is labelled, visibly.** Three evenly spaced dots in a row is also the
  universal carousel pager, and on a still — which is how most buyers meet this
  theme — nothing distinguishes "the agent is drafting" from "slide one of
  three". `Drafting a reply` sits beside them in the mono metadata register the
  rest of the turn already uses. The label used to be `sr-only`, which told
  everyone except the people looking at it, and it left the theme's one ambient
  moment reading as chrome from a component the theme does not ship. Invariant
  11 (colour is never the only signal) generalises here: **motion is never the
  only signal either.**
- **`prefers-reduced-motion` is authored in, not retrofitted.** The dots are
  `motion-safe:animate-pulse`; the press is `motion-safe:active:scale-[0.98]`
  and `transform` only joins the transition list under `motion-safe:`. The state
  is also announced as `sr-only` text.
- **`transition-property` is enumerated, never `all`.**
- **The FAQ, the mobile menu and the mode toggle swap glyphs, they do not rotate
  them.** The toggle's sun/moon is a conditional render with no transition on it.
- **The slider has no transition at all.** `price-estimator`'s range input moves
  with the pointer; a control that eases toward the cursor reads as broken, and
  drag feedback is not an animation.
- **No new ambient moment.** The streaming dots in `ui/chat-turn` keep the
  theme's one slot, which is why neither island animates anything.
- **Press is a colour state, not only a transform.** Every button variant has an
  `active:` fill from the state ramp.

## The diagrams — Wire JSON is the source, `ui/wire-figure` is the renderer

Relay ships **two** Wire Diagram graphs and one renderer. The JSON is canonical;
edit it, never the markup.

| File | Figure | Where |
|---|---|---|
| `src/assets/agent-loop.wire.json` | how a run decides | `agent-flow` on `/product` |
| `src/assets/scope-model.wire.json` | what a write has to pass | inside `scope-ledger` on `/security` |

Both are **valid Wire**, not Wire-shaped. Read the contract from the source
before touching either — `https://wirediagram.app/.well-known/wire-docs.json`
points at `/llm/schema/wire-diagram.json`, `/llm/agent-guide.md` and the recipes.
2.1.0 shipped five deviations and all five are fixed; do not reintroduce them:

| Do not write | Write |
|---|---|
| `$schema: .../schema/wire-diagram.json` (**404**) | `$schema: https://wirediagram.app/llm/schema/wire-diagram.json` |
| `label` | `title` — required, with `id` and `kind` |
| `note` | `description` |
| `tone: "human"` | `kind: "human"` — `tone`'s enum is `default \| success \| warning \| error \| info \| ai` and **`human` is not in it**; `human` is a first-class kind |
| `branch` / `loop` invented on a node | `branches: [...]` on the condition, targets referencing `"<conditionId>.<branch>"`, and extra parents listed in `from` |

**Relay adds exactly two keys, and both live in `data`** — the bag Wire's own
styling recipe reserves for serializable implementation fields:

- **`data.share`** — the published share, printed on the branch badge.
- **`data.weight`** — fork column weighting, `2` or `1`. Set it **only where the
  theme has a real share to state**. `agent-loop`'s fork is weighted 2:1 because
  the product claims 65/35 and two equal columns would argue the endings are
  equally likely. `scope-model`'s forks are *partitions of a fixed set* (5 and 3,
  then 2 and 1), the badge carries the exact count, and equal columns are the
  honest drawing — so they carry no weight at all.

**The back edge is a Wire edge now.** `gather.from` is
`["ticket", "reopened"]`. The renderer builds its layout tree from `from[0]` and
draws every additional parent as a return edge under the node that owns it, so
"returns to Read the account" is *generated* from a native multi-parent
reference. Without it the graph is a tree, and a tree cannot represent a ticket
that comes back — which is exactly what `ZD-4821` does.

**No `node.style`, anywhere.** Wire's style recipe offers two paths and Relay
takes the second in full: `style.fill` with explicit colours, or "React CSS
variables … and classNames for host app styling boundaries". A hex in the JSON
would import a second colour system into a theme whose contract is "every colour
is a token". Colour comes from `kind`, and `kind` comes from the graph.

**Six of Wire's twelve kinds are used**, each earning its place: `trigger`
(`trigger`), `retrieval` (`lookup`), `action` (`step`), `condition` (`check`),
`guardrail` (`limit`), `human` (`handoff`). `ai`, `tool`, `memory`, `note`, `end`
and `group` are deliberately unused — a kind with one member is vocabulary for
its own sake. **`group` was considered and rejected** for the scope figure: it
would draw the inventory the ledger table already is, and answer "what is
connected", which `integrations` already owns.

The upstream React renderer (`@aigentive/wire-react`) is still not used: it
requires its own stylesheet with a hardcoded slate/blue palette, and theme
stylesheets load globally on the marketplace's shared preview origin (Meridian's
investigation is the citation — `ops/design/meridian-2026-08-13.md` §Diagram path
taken). Swapping it in later is a renderer change with **no content migration**;
that is the entire point of keeping the JSON canonical. `theme.json`
`dependencies` stays truthful: `lucide-react` only.

Three rules the renderer relies on:

1. **`kind: "human"` is the amber switch.** It is the only warm mark in either
   figure and it means "a person acts here". Do not spend it on a node a person
   does not own.
2. **The fork bracket is drawn on a grid whose column count lands the rail ends
   on the branch centres** — six columns for a weighted 2:1 fork (33.3% and
   83.3%), four for an equal one (25% and 75%). No arbitrary values, and no
   arithmetic to re-derive if a weighting changes. Each branch then terminates in
   the same `Connector` every other edge uses, so the arrowhead is present at the
   one moment the figure exists for.
3. **A figure is centred only under a centred heading.** `agent-flow` is the
   theme's one centred heading, so its figure is centred; every other figure
   passes `align="start"`. A centred figure under a left-set heading leaves their
   left edges ~218px apart at 1280 and reads as an accident — that is the
   original reason `agent-flow`'s heading was centred at all.

Each figure also generates its own "Read as text" list from the same array, so
the text alternative cannot drift from the drawing.

## Links and destinations

Relay ships six routes — `/`, `/product`, `/security`, `/pricing`, `/contact`,
`/components`.

1. **Every `href` resolves.** No `#`, no `href="#"` placeholders, no 404, no
   invented external URL. The `mailto:` and `tel:` addresses on `/contact` are
   the only non-route destinations in the theme, and they are fictional-product
   addresses on the product's own domain, which is what an address is for.
2. **The placeholder convention.** A destination the theme does not implement
   (About, Careers, Terms, Sign in) points at the nearest *real* shipped page —
   `/product` for product and company links, `/pricing` for commercial ones,
   `/security` for the security and data-handling ones (Security, Privacy, DPA,
   Sub-processors). **Contact is no longer one of them**: the footer's Contact
   link pointed at `/pricing` and now points at the page that answers it, and
   `Company details` was added beside the Legal column's four. Repointing is the
   *point* of adding a page. The nav itself names only real destinations:
   Product, Run log, Security, Pricing.
   **`Guardrails` used to be the third nav item and pointed at
   `/product#guardrails`.** Three of the four items were then the same page
   under three names, which is a menu that behaves as one link — the 2.0.0
   critique's D8. The rules have their own page now and the item points at it.
   Do not re-add a nav item whose destination another nav item already owns.
3. **A non-link must not look like one.** The footer's status line is a `<p>`
   with a decorative dot: no hover, no focus stop, no pointer. The handoff
   panel's `Approve and send` / `Edit draft` are `<button>`s with no `href`.
4. **Anchor ids are a contract.** `#timeline` is targeted by the header nav, the
   footer, the handoff panel and `audit-record`. `#guardrails` is targeted by the
   footer, the handoff panel and the transcript's citation chip. `#integrations`
   is targeted by the footer, `#scopes` and `#audit` by the footer (Scopes, DPA,
   Sub-processors), `#desks` by the footer (Company details). `#main` by the skip
   link on all six pages. `#how`, `#boundary`, `#thread`, `#flow`, `#handoff`,
   `#plans`, `#estimate` and `#faq` exist so sections are deep-linkable and
   `add-page` has stable handles. Do not rename any of them.
4b. **`/contact` deliberately has no nav slot.** The header already carries four
   destinations, two buttons and the mode toggle; a fifth label crowds `md`, and
   a support lead looks for Contact in the footer's Company column, which is
   where it is. That is a decision, not an omission — and it does not weaken rule
   2 above, because the destination is real.
5. **The skip link moves focus, not just scroll.** Each page's `<main>` carries
   `id="main" tabIndex={-1}` with `focus:outline-none`.
5b. **`guardrails` renders on two routes**, `/product` and `/security`, and that
   is the only section that does. On product it is the rule that stopped the run
   you just read; on security it is the middle of reach → rule → record. Links
   written for the run keep pointing at `/product#guardrails`; the nav and the
   footer's Security link point at the page.
6. **The wordmark link has no `aria-label`.** Its accessible name comes from the
   `data-brand-name` text inside it, so rebranding renames the link too.
7. **`/components` is not in Relay's nav.** The fiction does not know it is a
   theme.

## Verified contrast (WCAG 2.1, computed)

Worst cases per mode; everything else in the theme is more contrasty.

| Pair | Light | Dark | Floor |
|---|---|---|---|
| `foreground` on `background` | 14.31 | 15.61 | 4.5 |
| `foreground` on `card` | 16.88 | 13.35 | 4.5 |
| `muted-foreground` on `background` | 5.46 | 7.13 | 4.5 |
| `muted-foreground` on `card` | 6.44 | 6.10 | 4.5 |
| `muted-foreground` on `muted` (tool-call body) | 5.06 | 6.97 | 4.5 |
| **`muted-foreground` on `secondary`** | **4.72** | 5.38 | 4.5 |
| `muted-foreground` on `accent` (handoff meta) | 5.36 | 5.10 | 4.5 |
| `secondary-foreground` on `muted` (tool-call values) | 9.63 | 11.72 | 4.5 |
| `primary` on `background` | 5.50 | 9.03 | 4.5 |
| `primary` on `card` | 6.48 | 7.72 | 4.5 |
| `primary` on `secondary` | 4.75 | 6.81 | 4.5 |
| `primary-foreground` on `primary` (button rest) | 6.85 | 8.90 | 4.5 |
| `primary-foreground` on `primary-hover` | 8.86 | 10.92 | 4.5 |
| `primary-foreground` on `primary-active` | 11.29 | 13.41 | 4.5 |
| **`accent-foreground` on `accent`** | **4.71** | 7.44 | 4.5 |
| `accent-foreground` on `card` | 5.66 | 8.90 | 4.5 |
| `background` on `foreground` (agent avatar, featured plan header) | 14.31 | 15.61 | 4.5 |
| `ring` on `background` (non-text, needs 3) | 5.50 | 9.03 | 3.0 |
| `ring` on `secondary` (focus ring on the diagram ground) | 4.75 | 6.81 | 3.0 |

Alpha-composited pairs, computed against the real composite:

| Pair | Light | Dark |
|---|---|---|
| `muted-foreground` on `bg-background/85` over `card` (sticky header) | 5.61 | 7.01 |
| `accent-foreground` on `bg-accent/60` over `card` | 5.06 | 8.06 |

**The hero's routing field**, at full mask alpha — the darkest patch in light,
the brightest in dark. Everywhere else the mask is thinner; the card, the buttons
and the tool-call block are opaque fills sitting *over* the field, untouched.

| Pair over the field | Plain ground | Full mask alpha | Floor |
|---|---|---|---|
| light `foreground` on `background` (h1) | 14.31 | **12.48** | 4.5 |
| light `muted-foreground` (eyebrow, lede, notes) | 5.46 | **4.76** | 4.5 |
| light `ring` (button focus outline) | 5.50 | **4.79** | 3.0 |
| dark `foreground` on `background` (h1) | 15.61 | **13.46** | 4.5 |
| dark `muted-foreground` | 7.13 | **6.15** | 4.5 |
| dark `ring` | 9.03 | **7.79** | 3.0 |

**The ceiling.** Light `muted-foreground` fails AA at `bg-foreground/10` (4.47).
The shipped `/7` keeps a deliberate margin in both modes and, unlike 1.2.0's
`/10` + `/12` pair, weighs the same in each.

**One pair is excluded on purpose.** `accent-foreground` on `secondary` computes
4.01:1 and is never rendered: amber ink in this theme sits on `accent` or on
`card`, never on a bare cool fill. If you introduce that pair, darken
`--accent-foreground` first.

## Imagery — one file, and it is a mask

**Relay ships no photography, deliberately.** The identity is typographic and
token-driven, and the transcript is the thing being sold: it recolours with a
rebrand, a screen reader can read it, and it is the component the buyer is paying
for. A hero photograph would push it below the fold. Do not "discover" the gap
and fill it.

**The transcript avatars are not placeholders.** `ui/chat-turn.tsx` draws a 32px
monogram beside a name that is always rendered in text, and that is what Zendesk,
Front and Intercom actually draw for a customer with no avatar — the console
being *real* is the thing this theme sells. Generating portraits of Priya and
Dana would not fill a gap; it would make a fabricated support record look like a
genuine one. Asked and answered in the 2.2.0 pass file; do not re-open it without
a new argument.

What the hero *does* carry is a **luminance layer**:
`src/assets/hero-routing.png`, a routing-and-queue plan behind `agent-hero`. It
is organic, irregular detail CSS cannot draw, and it says what the product does —
lanes of waiting items, right-angle junctions, holding queues, hand-off points.
(The 1.2.0 asset was a printed-circuit field: the single most-used background on
AI product pages, and it said nothing about this product. The rule below did not
change; the drawing did.)

**The luminance-layer rule — the only way raster is allowed into this theme:**

1. **The asset carries no colour.** Every pixel is black; the **alpha channel**
   is the drawing. A raster with colour baked in may not ship here.
2. **All colour comes from a token.** The layer is a flat `bg-foreground/7`
   rectangle masked by the PNG. `--foreground` is cool in *both* tables, so the
   field can never go warm. **Never mask `--primary` or `--accent`.**
3. **One asset, one opacity, both modes.** There is nothing to keep in sync.
4. **Opacity is a contrast budget, not a taste setting** — see the table above.
5. **Sizing is `100% auto`, not `cover`.** The trace weight then stays
   proportional to the viewport width instead of magnifying into slabs at 375,
   and the mask's own downward dissolve always lands inside the section. On
   narrow viewports the field reads as a masthead band above the headline; that
   is the design, not a truncation.
6. **The mask URL is the theme's one inline style.** The hashed asset path only
   exists after the bundler runs, so it cannot be a class.

**Disclosure — say it, do not hide it.** `hero-routing.png` is **AI-generated**
(Gemini, via `bun run generate-image`) and is licensed to you with the theme. It
is **grayscale-only** by construction and **regenerable**: the prompt and the
flattening steps are in `src/assets/README.md`. Never describe it as a photograph
of a real board, place or product — it is a drawing of nothing.

## What never to touch

- **The relay mark in `src/components/ui/logo.tsx`** — identity is bespoke.
- **The amber restraint rule** — see "The colour idea". It extends to the hero's
  routing field: that layer is masked over `--foreground` and must stay there.
- **The routing field's opacity** (`bg-foreground/7`) — a computed AA ceiling
  with a weight-parity constraint, not a preference.
- **`@theme inline` mappings** — they live in the host app's `globals.css`, never
  in a theme. Do not add a `@theme` block here; the `type-*` class layer at the
  foot of `theme.css` is how this theme emits its scale instead.
- **`min-w-0` / `overflow-x-auto` in `ui/tool-call.tsx` and `tool-timeline.tsx`**
  — removing them breaks the 375px layout in a way that is invisible on a
  desktop screen.
- **The `data-component` attributes on `src/app/components.tsx`** — the
  screenshots pipeline captures one element shot per frame; the attribute is the
  contract.
- **The `ZD-` ticket prefix** — see invariant 8.
- **`kind: "human"` as the amber switch** — it is a Wire kind, not a Relay
  invention, and both figures and the whole colour law hang off it.
- **The two-island ceiling** — see invariant 4. A third needs a written argument.
- **The spine table in `.claude/skills/check-quality/`** — extend it, never
  shorten it.
- **Anything outside `themes/relay/`.**

## Known limitations

- `/components` carries three `<h1>`s: its own, plus the ones inside the
  `agent-hero` and `ui/page-hero` frames. That is the cost of a styleguide that
  renders real sections rather than reduced copies; the page's own `<h1>` is
  first in DOM order. The four product pages carry exactly one each.
- `outcome-band` and `ui/page-hero` use `lg:border-l border-border` as a neutral
  column rule. It is a 1px hairline, not a colour-stripe card — the project's
  no-colored-left-border rule is intact.
- `agent-flow`'s figure is tall (a topology drawn as a chain plus a fork). The
  narrow-then-wide treatment makes the fork the centre of mass; compacting the
  pre-fork chain into a horizontal row is the next move if it ever needs one.
  `scope-ledger`'s figure inherits the same height, and it sits above an
  eight-row ledger, so `/security` is now the theme's longest page.
- **`ui/mode-toggle` cannot prevent a flash on a standalone deployment.** The
  theme ships as source, not as a document, so the pre-paint script that reads
  `localStorage` belongs in the buyer's own layout. The README says so; the
  toggle itself only syncs after mount.
- **In the marketplace preview the toggle writes onto a React-owned element.**
  `PreviewLinkScope` renders the `.tt-relay` wrapper, so a re-render of that
  component would drop a class the toggle added. Its only re-render happens once
  on mount, before a visitor can click, and the toggle's `MutationObserver` keeps
  the glyph honest if it ever does. The zip is unaffected — there the toggle owns
  `documentElement`.
- **The stored mode is honoured standalone and ignored in the preview.** That is
  deliberate (the preview's mode belongs to the preview and to the screenshot
  pipeline), but it does mean toggling inside a preview does not persist across a
  navigation there.

## Extending

Use the skills; they encode the above:

| Task | Skill |
|---|---|
| Apply a buyer's brand | `.claude/skills/rebrand/` |
| Add a section | `.claude/skills/add-section/` |
| Add a page | `.claude/skills/add-page/` |
| Verify before shipping | `.claude/skills/check-quality/` |

## Signal — Dark Dev-Tool Landing

- Slug: signal
- Category: saas landing
- Price: $39
- Description: A dark-first developer tool landing page template for CLIs, built around one signature: the annotated regression trace — a terminal transcript that names the commit that caused the number. Six routes ship a whole company site: home, pricing with a working cost estimator, quickstart, CLI reference, changelog with a live tag filter, and contact carrying the engineering addresses and the legal minimum. Every one of them opens with a different device — the estimator, the tag filter, an `sgl --help` terminal, the four contact routes — rather than the same heading-and-stats masthead five times. Interactions are real, not screenshots: a light/dark toggle in the navbar, copy buttons on every command, per-tag changelog filtering, and an estimator that does the per-gigabyte arithmetic in front of you. Ships IBM Plex Sans and IBM Plex Mono self-hosted (OFL), a 1.200 modular type scale with every size on a token, a steel-cyan palette with a true amber second hue that lets the changelog say breaking, a /components styleguide listing all 45 tokens per mode, and a light variant that is a designed variant rather than a token flip — every foreground/background pair in both modes verified against WCAG AA by computation. Every rendered href resolves to a page or an anchor the theme actually ships.
- Pages: /, /pricing, /changelog, /docs, /cli, /contact, /components
- Tags: developer tool landing page template, dark saas template, cli landing page template, tailwind-v4, dev-tools, dark-mode, changelog, ibm-plex
- Site: https://tailthemes.com/themes/signal
- Manifest: https://tailthemes.com/api/themes/signal
- Registry: https://tailthemes.com/r/signal
- Preview: https://tailthemes.com/preview/signal

### Signal — Dark Dev-Tool Landing AGENTS.md

# AGENTS.md — Signal

Rules for any agent (or human) editing this theme. They are not style suggestions;
the publish gate and the extension skills in `.claude/skills/` assume them.

## The four declarations

Every extension of this theme holds to these. They are the theme, compressed.

| | |
|---|---|
| **Signature** | **The annotated regression trace** — a terminal transcript that names the commit that caused the number. Marker, labelled rows, next command. It runs on `/` (the hero), `/docs` (the payoff band), `/changelog` (the migration strip on breaking releases) and `/cli` (the payoff band prints what `sgl trace --deploy 8f21c4e` answers, which is the line the other two transcripts end on). **Four tellings, and 2.2.1 kept it at four across six routes**: `/pricing` and `/contact` carry none, because the signature answers *which commit caused this number* and neither a price nor a postcode has a commit. This is also why the cost estimator is built as a control and not as a terminal — see § "The sanctioned islands". A new route earns a telling only if it *closes* something the existing ones open; a transcript added for texture is decoration. "A terminal in the hero" is not the signature — Vercel, Bun, Deno, Railway, Warp and Turso all have one. The *argument rendered as output* is. **`/cli`'s `sgl --help` opener is the primitive, not a fifth telling**: a command index has no `▲` marker, no amber, no `→` next command and no fixture from the regression narrative. Count tellings by that shape, never by counting terminals. |
| **Sanctioned deviation** | **Dark is the default, not the override.** `:root` carries the dark table; `.light` is the variant. Every other theme in the catalog is the other way round. What it buys: a buyer who drops the zip in with no theme class gets the look they bought the screenshot for, and the identity is not one class away from being lost. Exactly one deviation — do not add a second. |
| **Register** | **Technical-plain** (`content-pass`). H1 at 9–12 words naming the mechanism; one clause; concrete nouns; no first-person plural. "Every deploy lands on the same timeline as the latency it moved", not "Find the deploy that moved your p95" — the second is quiet-declarative and is on record as a cadence collision with three other themes. |
| **Motion personality** | **Mechanical** (`motion-pass`). `steps()` is in character; feedback is crisp at **120ms `ease-out`**; the ambient slot is spent on the terminal cursor and nothing else. |

## File map

```
themes/signal/
  theme.json                     manifest — inventory + full token tables. Keep truthful.
  README.md                      human quick start
  AGENTS.md                      this file
  .claude/skills/                rebrand · add-section · add-page · check-quality
  src/
    theme.css                    EVERY design decision. Colour, type, radius, weight.
    fonts.ts                     next/font/local → --font-plex-sans / --font-plex-mono
    fonts/                       IBMPlexSans-Variable.woff2, IBMPlexMono-{Regular,Medium,SemiBold}.woff2
                                 + OFL-IBMPlexSans.txt, OFL-IBMPlexMono.txt (verbatim)
    assets/
      hero-contours.png          the hero's mask — greyscale, no colour (see invariant 10)
      README.md                  its prompt, its shaping, how to regenerate it
    app/
      home.tsx                   header · terminal-hero · incident-band · integrations · feature-grid · changelog · cta · footer
      pricing.tsx                header · cost-estimator(+page lead) · pricing-plans · cta · footer
      changelog.tsx              header · changelog(full, +page lead) · cta · footer
      docs.tsx                   header · page-hero(+prereqs counterweight) · docs-shell · footer
      cli.tsx                    header · `sgl --help` terminal + page lead · docs-shell(cli) · footer
      contact.tsx                header · contact-card(+page lead) · cta · footer
      components.tsx             the living styleguide — tokens, every section, every primitive
    components/
      site-header.tsx            sticky nav + theme toggle + JS-free <details> mobile menu · `sticky={false}` on /components
      terminal-hero.tsx          headline, install row (+copy), `sgl watch` terminal (the oversized moment)
      incident-band.tsx          the regression drawn: p95 series, envelope, deploy marker (2.3.0; content figure, CONTRACT amendment B)
      integrations.tsx           full-bleed hairline grid of eight data sources (the container break)
      feature-grid.tsx           4 code-bearing cards + a named outcome + 3 command cards
      changelog-section.tsx      release entries; `variant="preview" | "full"` · ISLAND: the tag filter
      pricing-plans.tsx          3 plans on one price model + what counts as a gigabyte + never-metered ledger
      cost-estimator.tsx         ISLAND: volume + sample rate → the invoice arithmetic (the /pricing oversized moment)
      contact-card.tsx           4 contact routes + full-bleed operations band + the legal minimum (#legal)
      cta.tsx                    closing panel (+copy on the init row)
      site-footer.tsx            3 link columns + legal row + status line
      docs-shell.tsx             sidebar + article + on-this-page rail + payoff band;
                                 `variant="quickstart" | "cli"` — one shell, two documents
      ui/
        button.tsx               primary | secondary | ghost · sm | md | lg · icon padding trim
        badge.tsx                mono pill · neutral (tinted) | accent (filled amber)
        theme-toggle.tsx         ISLAND: sun/moon, aria-pressed, localStorage, nearest .tt-signal
        copy-button.tsx          ISLAND: clipboard write, instant glyph swap, silent failure
        container.tsx            the content column · size="default" (6xl) | "wide" (7xl)
        logo.tsx                 pulse glyph + wordmark — the hand-drawn brand mark (content figures are separate: invariant 11)
        code-block.tsx           filename bar + built-in highlighter · surface="card" | "sunken" · copy
        inline-code.tsx          inline CLI strings, flags, env vars, filenames
        terminal-window.tsx      terminal chrome + the Dim / Ok / Hot transcript roles
        section-heading.tsx      eyebrow + h2 + lede
        page-hero.tsx            the /docs masthead — counterweight required (§ Openings)
        type.ts                  THE TYPE VOCABULARY. The only place a size is named.
```

**Inventory, reconciled**: 12 sections + 12 primitives = 24 `components[]` rows,
6 routable pages + `/components`, 45 tokens per mode. `/components` states the
same three numbers; `bun run check` closes the manifest against the captured
frames in both directions. (2.3.0 added `incident-band` — the owner critique
"too much text presentation, not enough drawn content" answered per
`ops/design/svg-ideation-signal-2026-08-13.md`.)

## Openings — one masthead, six instruments

Through 2.2.0 five of six routes opened on `ui/page-hero`: the same 7/5 split,
the same panel at x 760–1184, the same panel top at y 113, five times, and two
of the five counterweights were stat cards. The catalog survey
(`ops/design/page-variety-2026-08-13.md`) scored that 3/6 = 0.50. 2.2.1 is the
fix, and the rule it leaves behind:

**A page opens with the instrument it is about. The masthead is one option, not
the default.**

| route | opening device |
|---|---|
| `/` | the annotated `sgl watch` terminal, overhanging right |
| `/pricing` | the estimator: slider, sample presets, invoice ledger, `$44.98` at `d4` |
| `/changelog` | the tag-filter strip and the first release entry |
| `/docs` | `ui/page-hero` + the prerequisites checklist — **the theme's one masthead** |
| `/cli` | an `sgl --help` terminal on the left, the page lead on the right |
| `/contact` | the four addressed route cards |

Four of those pages hand a **page lead** into the section that opens them
(`cost-estimator`, `changelog-section`, `contact-card` take `lead?: ReactNode`;
`/cli` composes its own band). Rules for a lead:

1. **The lead lives in the page file**, next to the composition it belongs to —
   the same place `FreeTier`, `ReleaseCadence` and `OfficeHours` lived as
   counterweights. It is not a primitive: a shared `ui/page-lead` would put a
   masthead component back on five of six manifest rows, which is the metric
   the survey measures.
2. **The lead renders the page's `<h1>` and carries the id the section is
   labelled by** — `estimate-title`, `changelog-title`, `routes-title`. Miss it
   and the section loses its accessible name.
3. **A section carrying a lead takes the tight register** — `py-10 sm:py-12`,
   the exact padding `ui/page-hero density="tight"` holds. That is what keeps
   each page at ≥1.75:1 against its loosest band with the masthead gone; at
   `py-14` `/cli` measured 1.71 and failed the gate.
4. **The lead keeps the marketing measure** (`Container` default, `max-w-6xl`)
   even when the instrument below it is `wide`. The h1 hangs on the same left
   edge as the wordmark; the step out to `max-w-7xl` underneath *is* the page's
   container break.
5. **Facts move, they do not multiply.** Everything the four deleted
   counterweights carried is stated exactly once now: the free tier by the Free
   plan and the estimator's closing line, the cadence figures by
   `changelog-section`'s closing band, the global flags by the `--help` output,
   the office hours by one mono line under the contact grid.

## Typefaces — shipped, not wished for

Two roles, no display face: **the mono is the display voice here.** Both are SIL
OFL 1.1, self-hosted, latin subset, no italic (the theme sets none).

- **`--font-text` · IBM Plex Sans** (variable, 100–700) — its flared stem terminals and
  angled cuts read as *engineered* rather than neutral, and they survive the 14px
  the docs shell lives at. `--font-sans` is its alias, because the `@theme inline`
  bridge in the host app maps `--font-sans`.
- **`--font-mono` · IBM Plex Mono** (400 / 500 / 600) — one superfamily with the text
  face. Signal's whole thesis is that the terminal and the page are the same
  object; a sans and a mono drawn on one skeleton make that argument
  typographically instead of asserting it.

Not Inter: it is claimed by three other themes, and at display size it is the most
anonymous grotesque in wide use.

`src/fonts.ts` exports **`fontClassName`** — the one load-bearing export, read by
the generated `app/layout.tsx` in the zip and by `PREVIEW_FONTS` in
`lib/preview-registry.tsx`. Adding or removing a face means updating both, and the
`bun run check` fonts gate will say so.

Metric fallback: the sans uses `adjustFontFallback: "Arial"`. The mono uses
`adjustFontFallback: false` **on purpose** — the only adjustable fallbacks Next
offers are Arial and Times, both proportional, and substituting either behind a
terminal transcript would re-flow every column during the swap. Plex Mono's
600/1000em advance is within 2% of SF Mono, Menlo and Consolas, so the native mono
stack in the token tail *is* the metric match.

## Type scale — ratio 1.200, and bare `text-*` is banned

`src/theme.css` holds the values; `src/components/ui/type.ts` holds the pairings.
**A size never travels without its leading**, and on display steps never without
its optical tracking, which is why every component imports `TEXT` instead of
writing a utility.

| | rung | size | leading | tracking |
|---|---|---|---|---|
| display | `TEXT.d4` | 2.160 → 2.592rem (fluid 375→1280) | 1.06 | −0.02em |
| | `TEXT.d3` | 1.800 → 2.160rem (fluid) | 1.15 | −0.01em |
| | `TEXT.d2` | 1.800rem | 1.20 | −0.01em |
| | `TEXT.d1` | 1.500rem | 1.25 | −0.01em |
| UI | `TEXT.lead` | 1.125rem | 1.6 | — |
| | `TEXT.body` | 1rem | 1.55 | — |
| | `TEXT.small` | 0.875rem | 1.45 | — |
| | `TEXT.caption` | 0.75rem | 1.4 | — |
| | `TEXT.label` | 0.75rem uppercase | 1.4 | +0.08em |

Ratio **1.200** is the contract's dev-tools/docs row, and it is right for this
theme independent of the table: a technical-plain register wants a compressed
display range, because the argument is carried by the transcript and the code, not
by a big headline. The H1 tops out near 41px, which is what makes the 9–12 word
headline typographically possible.

`DISPLAY_WEIGHT` (`--weight-display`) is **600 in dark, 700 in light**. Light-on-dark
reads bolder, and Signal is dark-*first*, so the light variant is the one that
steps up.

**There is no size between the rungs.** Adding one is a scale change, not a tweak:
it changes `theme.json`'s recorded ratio and the ladder in `type.ts` together.

## Dark-first — read this before touching `src/theme.css`

| CSS scope | Values |
|---|---|
| `:root, .tt-signal` | **dark** (the default) |
| `.light, .tt-signal.light` | light overrides + every mode-invariant token restated |
| `.dark, .tt-signal.dark` | dark restated in full |

All three blocks carry **all 45 tokens**. That is scoping, not redundancy: on the
shared marketplace preview origin every theme's stylesheet loads at once and the
wrapper element carries both `.tt-signal` and the mode class, so a bare
`.light { --text-d4: … }` from another theme matches the same element at the same
specificity as `.tt-signal` and source order would decide. `.tt-signal.light`
outranks it — but only if the value is declared there. Change a value in one block
and you change it in all three.

`theme.json` is mode-based, not default-based: **`tokens` is the light table,
`tokensDark` is the dark table**, regardless of which one CSS treats as the
default. Never swap them to "match" the CSS. Every variable `theme.css` declares
must appear in both manifest tables — `bun run check` enforces it.

## Colour construction

Authored in OKLCH, shipped as hex.

- **Undertone hue: 200° (steel).** Every neutral sits within ±12° of it and clears
  the contract's chroma floor for its lightness band (L ≥ 0.95 → C ≥ 0.008 ·
  L 0.85–0.95 → C ≥ 0.015 · L 0.25–0.60 → C ≥ 0.020).
- **Primary hue: 209°** — the terminal phosphor, and **31.2° clear of grove's
  177.9° teal**, which the 1.5.0 palette collided with on all three escape clauses.
- **Accent hue: 72° (amber), ΔH 137° from primary.** A true second hue with a job,
  not a tint of the first: `breaking` in the changelog, the regression rows in a
  transcript, a signed delta in a code sample, the release badge. **Primary means
  healthy. Amber means look here. Nothing else gets amber.**
- **State ramp, never alpha.** `--primary-hover` (ΔL 0.06 away from the ground) and
  `--primary-active` (ΔL 0.12) are tokens. Alpha moves L, C and H at once.
- **`--shadow-color` is a token and never `--background`.** A shadow that changes
  hue when the buyer rebrands is the one thing a shadow must never do. Dark is
  L 0.10 (the ground is L\* 4.45; the contract's L ~0.20 would be *lighter* than
  the page) and light is L 0.30 — a documented per-mode deviation, and neither is
  pure black.
- **`--surface-tint`** is the brand-tinted surface: icon chips, the install row,
  the /docs counterweight card, the neutral badge, press states on quiet controls.

### Light mode is a designed variant, not a token flip

The paper idea, stated honestly: `--background` is the **tray** (L\* 94.79) and
`--card` is the **sheet** laid on it (L\* 98.89) — a **4.10 L\*** elevation step.
1.5.0 shipped a 2.07 L\* step under a `#ffffff` card, which the contract sanctions
only where "a lit surface" is the stated idea, and Signal never stated one.

| | texture spread (measured, p2→p98) | `--background` → `--card` | ratio |
|---|---|---|---|
| dark | 8.03 L\* | 5.35 L\* | 1.50 : 1 |
| light | 6.55 L\* | 4.10 L\* | 1.60 : 1 |

1.5.0's light ratio was **3.8 : 1** — a decorative layer nearly four times louder
than the structure behind it. Re-measure both numbers from a render if you touch
either the mask alpha or the two surface tokens.

### Colour area, measured

Bucketed every 2nd pixel of the 1280 hero band (y 64–880) to its nearest token:

| | background | card | primary + accent | surface-tint |
|---|---|---|---|---|
| dark | 66.4% | 25.2% | **5.29%** | 3.5% |
| light | 59.5% | 25.1% | **5.20%** | 1.7% |

1.5.0 measured 1.38% dark and **0.78% light** — a greyscale page with one green
button and one green `$`. The area now comes from real surfaces: the terminal's
brand title bar and status line, the filled amber badge, the amber transcript
rows, the primary CTA. Re-run the bucketing after any change to those.

## Invariants

1. **All colour lives in `src/theme.css`.** Components use semantic utilities only
   — `bg-background`, `bg-card`, `text-foreground`, `text-muted-foreground`,
   `border-border`, `outline-ring`, `rounded-themed`, `font-sans`, `font-mono` —
   plus the CSS-variable shorthand for tokens the host bridge does not map:
   `bg-(--surface-tint)`, `hover:bg-(--primary-hover)`, `shadow-(color:--shadow-color)`,
   `rounded-(--radius-inner)`.

   **Opacity modifiers on colour tokens are BANNED.** `bg-primary/10`,
   `hover:border-primary/40`, `shadow-background/60` — all gone, all replaced by
   ramp and surface tokens. (This invariant said the opposite until 2.0.0. It was
   true under the old rules and is now false; alpha moves lightness, chroma and
   hue at once, which is why the contract bans it.) Alpha survives in exactly two
   places, both **genuine translucency**: the hero's `bg-primary/15` mask layer and
   the CTA's `from-primary/16` wash, plus the header's `bg-background/85
   backdrop-blur`. Raw hex, `rgb()`, `text-white`, `bg-emerald-500` and friends are
   not permitted anywhere — including inside the terminal window.

2. **No arbitrary values.** Three exceptions exist, and all three carry structure
   rather than design values:
   - `[&::-webkit-details-marker]:hidden`, which hides the native disclosure triangle;
   - `bg-radial-[50%_50%_at_50%_50%]` on the CTA wash, which states a gradient's geometry;
   - `animate-[signal-cursor_1.06s_steps(2,jump-none)_infinite]` on the terminal
     cursor, which states an animation's timing.

   None names a colour, a size or a radius. **`text-(length:--text-…)`,
   `bg-(--surface-tint)` and friends are not arbitrary values** — they are token
   references, which is the whole point.

   **One `style` attribute exists**, on the hero's texture layer, carrying exactly
   two things: the bundler's URL for `assets/hero-contours.png` and the mask's
   geometry. A colour in a `style` attribute is a bug.

3. **Sections are independent.** A file in `components/` never imports another file
   in `components/`. Shared pieces come from `components/ui/` only. (This is why
   `Code` was promoted out of `docs-shell.tsx` into `ui/inline-code.tsx` and why the
   `Dim` / `Ok` / `Hot` transcript roles live in `ui/terminal-window.tsx`: three
   sections need them and none may import another.)

4. **Pages are composition.** No data fetching, no Next-specific APIs, no page-level
   state. Every *page* file is a server component that imports sections and stacks
   them. Client state lives in the four named islands below and nowhere else — see
   § "The sanctioned islands".

5. **Anything scrollable needs `min-w-0`.** Grid and flex children default to
   `min-width: auto`, so a `<pre>` will otherwise widen the whole page instead of
   scrolling inside its own box.

6. **Real content only.** The product is a fictional deploy-aware observability tool
   called Signal, CLI binary `sgl`. Commands, config and release notes are written
   to be plausible — keep them that way. No lorem ipsum, ever.

7. **Contrast is computed, not eyeballed.** Both token tables pass WCAG AA on every
   pair the components use (see below). Changing a colour means re-running the check
   in `.claude/skills/check-quality/`.

8. **Focus is always visible.** Interactive elements carry
   `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring`
   plus a radius token so the outline follows the theme radius instead of boxing the
   text. `outline-offset-2` is load-bearing: `--ring` *is* `--primary`, so a ring
   drawn on a primary fill would be invisible; the offset puts it on the page.

9. **Every animation ships a reduced-motion variant.** The theme's motion
   inventory, complete (2.3.0): the cursor
   (`motion-safe:animate-[signal-cursor …]`, the **ambient** slot) and the docs
   chevron rotation (`motion-safe:transition-transform`). Keyframes live in
   `src/theme.css` under the `/* ---- motion ---- */` fence (CONTRACT
   amendment A), slug-namespaced. **The figure-reveal slot (motion-pass
   amendment C) is deliberately unspent**: the incident band's drawn figure is
   static because it sits below the 1280 fold, amendment C only sanctions a
   reveal above it, and the static capture pipeline shoots mid-animation — the
   `steps(24)` draw stays specified in the ideation doc for the day the
   section moves up. Verified: under `prefers-reduced-motion: reduce` the page
   has **zero** elements with a running animation. Colour cross-fades
   (`transition-colors duration-120 ease-out`) are exempt — a colour
   cross-fade is not motion. A new animation without a `motion-safe:` prefix
   or reduced-motion coverage in the fence fails the gate.

   **The footer status line does not animate.** Its dot used to pulse.
   `motion-pass` allows one ambient moment per surface and Signal spends it on
   the cursor; a second one makes both noise, and "badges/pills pulsing for
   attention" is separately banned — status is stated, not performed. 2.3.0
   replaced the (already static) dot with the 90-day uptime strip: stated *and*
   evidenced, still motion-free.

10. **The effect layer is two devices, and that is the ceiling.** The masked contour
   field behind the hero (`terminal-hero.tsx`) and the `--primary` falloff behind the
   CTA (`cta.tsx`). Both are `aria-hidden`, `pointer-events-none`, motion-free and
   colour-free in markup — the tint comes from token utilities, so a rebrand re-tints
   them for free. The rules:

   - **The luminance-layer rule — colour comes from tokens, shape comes from the
     asset.** `assets/hero-contours.png` is greyscale and carries the texture in its
     **alpha channel**; the visible layer is a flat `bg-primary/15` rectangle that the
     mask cuts holes in. Nothing about the brand is baked into the file, which is why
     one asset serves light *and* dark. A texture with colour in it is the thing this
     rule exists to forbid — measured and rejected in
     `ops/design/signal-imagery-2026-08-13.md`.
   - **The texture REPLACED the hero wash; it is not a third device.** The wash's
     optical story ("light from above the frame") is baked into the same alpha channel
     as a top-centred glow. Pattern layers beside it were measured and rejected in
     `ops/design/signal-backgrounds-2026-08-13.md`. Read both files before proposing a
     third device, and re-argue with pixels or not at all.
   - **A device must reach zero before EVERY section edge, not just the one you were
     thinking about.** For the hero the falloff lives in the mask: alpha reaches
     exactly zero at ~57% of the asset's height, above the bottom crop at every
     viewport. For the CTA the wash is an ellipse sized `50% 50%` at `50% 50%` of its
     band, so it reaches zero at all four edges. The 1.5.0 version was bottom-anchored
     with the focal point at its *top* centre: it hit the footer rule at zero and drew
     a hard horizontal seam the full width at y ≈ 484 in both modes, 224px higher. One
     fixed edge, one new edge.
   - **Amplitude is measured, not chosen by eye.** At 1280 the field swings **8.03 L\***
     dark / **6.55 L\*** light peak-to-trough, and its brightest dark pixel (L\* 14.70)
     stays **below `--border`** (21.17) while its darkest light pixel (L\* 86.49) stays
     **above** `--border` (83.89) — a decorative line may never out-contrast a
     structural hairline. Both modes run the same `bg-primary/15`; at /18 the light
     field put `--muted-foreground` at 4.53:1 over its darkest pixel, so the alpha is a
     measurement, not a preference.
   - **The mask stays `cover`.** Capping `mask-size` would make the pen weight
     viewport-invariant (it currently thickens from ~1.3px at 1280 to ~2px at 1920),
     and it was rejected on measurement: the asset's alpha is **255 at both its left
     and right edges**, so any fixed mask width narrower than the viewport draws a hard
     vertical seam at the crop — which breaks the rule directly above. `cover` also
     couples the falloff to the box height, which is what keeps the bottom edge clean at
     every width. Fixing the pen weight needs a **new asset**, generated wider with its
     side alpha taken to zero, and that needs the same standard of proof as the current
     one.

11. **Icons come from `lucide-react`; the pulse glyph does not.** House rules, no
   exceptions:

   - **Never pass `strokeWidth`.** Signal's line weight *is* lucide's default of 2.
     One weight, enforced by omission.
   - **Size via `className` only** — `size-3.5` for marks set in mono text, `size-4`
     inline with body text and buttons, `size-5` for controls and chips. Never
     `size`/`width`/`height` props.
   - **Colour is `currentColor`** through a token class, and **no glyph is
     filled**. The header star used to be `fill-current text-primary` in a
     tinted chip, which gave the loudest treatment in the header to the one
     element that does nothing and made it compete with `Start free`. It is a
     hairline chip with an unfilled `--muted-foreground` star since 2.2.1.
   - **`aria-hidden="true"`** unless the icon is the only label. Where one carries
     meaning (the changelog tag marks) the word sits next to it.
   - **Glyphs must differ in silhouette.** `Route` and `Logs` were replaced with
     `Network` and `ScrollText` because at `size-5` they rendered as the same "dots
     joined by lines" shape as `Waypoints`.
   - **`ui/logo.tsx` stays hand-drawn.** The pulse glyph is the identity. Do
     not add hand-rolled **icons** — a wrench lucide ships is never redrawn.
   - **Content figures are not iconography** (CONTRACT amendment B, 2.3.0): a
     drawing whose geometry is computed from the section's own data and which
     carries information the copy would otherwise state is authored as inline
     SVG. The closed list today: the incident band's p95 figure
     (`incident-band.tsx`), the changelog's fractured rule + break mark
     (`changelog-section.tsx`), the footer's 90-day uptime strip
     (`site-footer.tsx`). Each is `currentColor`-only through token classes,
     `aria-hidden`, with its accessible content beside it built from the same
     data. Extending the list means extending the `<svg` allowlist in
     `check-quality` §2 in the same change.
   - **Terminal chrome is not iconography.** The rings in `ui/terminal-window.tsx`, the
     `$` prompts, the status dot in the footer and the `▲`/`→` characters inside the
     transcript are rendered text and shapes. They are the product, not the interface.

12. **Composition is counted, not felt** (`composition-pass`). Every marketing route
   holds all six gates, and the numbers are here so a future section does not quietly
   break one:

   | gate | home | /pricing | /changelog | /docs | /cli | /contact |
   |---|---|---|---|---|---|---|
   | asymmetric splits | hero 5/7, changelog 1/3 | estimator 5/7, boundary 7/5 | entries 1/3, cadence 5/7 | masthead 7/5, shell 3/6/3 | opening 7/5 **reversed**, shell 3/6/3 | legal 5/7 |
   | container widths (≥2) | 6xl + 7xl (integrations) | 6xl lead + plans · 7xl estimator | 6xl lead + cta · 7xl log + cadence | 6xl masthead + 7xl shell | 6xl opening + 7xl shell | 6xl routes + legal · 7xl operations |
   | container break (≥1) | integrations, full-bleed | the estimator band | the log's wider measure, the full-bleed cadence band | the shell's wider measure | the shell's wider measure | the operations band |
   | density ≥1.75:1 | `py-32` ÷ `py-14` = **2.29** | `py-28` ÷ `py-12` = **2.33** | `py-28` ÷ `py-12` = **2.33** | `py-24` ÷ `py-12` = **2.00** | `py-24` ÷ `py-12` = **2.00** | `py-28` ÷ `py-12` = **2.33** |
   | oversized moment (exactly 1) | the terminal overhanging right | the estimator total at `d4` | `216` in the closing cadence band | the full-bleed payoff band | the full-bleed payoff band | the full-bleed operations band |
   | optical checklist | size tracking · optical centering (`pb-px`) · cap-height icon alignment (`mt-px`) · circle overshoot (`size-7.5`) · dark-mode weight drop · hanging quote (`-indent-1.5`) · asymmetric button padding · `tabular-nums` on every digit run | | | | | |

   **One `d4` per page, and three pages have none.** `/pricing` spends its `d4`
   on the estimator's total and `/changelog` on the cadence band's `216`; every
   page lead's h1 is `d3`, the plan prices are `d2`, and the cadence band's two
   supporting figures are `d1`. `/docs`, `/cli` and `/contact` carry no `d4` at
   all — their moment is a full-bleed field, which is its own qualifying
   category. A second display-size figure does not add a moment, it cancels the
   one there is.

   The retired archetype **A2** (left column, unresolved right) is why `PageHero`
   takes a required `counterweight`. Do not add a masthead without one — and on a
   page that owns a better instrument, do not add a masthead at all
   (§ "Openings — one masthead, six instruments").

## The sanctioned islands

Through 2.1.0 this theme had no `'use client'` anywhere, and that entry in
§ "What never to touch" ended: *if it is ever added it is a single client
component, argued in a pass file, not a licence.*
`ops/design/signal-pricing-2026-08-13.md` is that argument. It opens the door
**four components wide, by name.** The list is closed; a fifth island needs its
own pass file and its own argument.

| Island | Why it cannot be markup | What ships without JS |
|---|---|---|
| `ui/theme-toggle` | The light table is a *designed* variant — a 4.10 L\* elevation ladder, an ochre accent, a display weight that steps up. 2.1.0 gave the visitor no way to see any of it, which made half the theme invisible inventory. | Whichever mode the host set. Only the switch is lost. |
| `ui/copy-button` | `brew install signalhq/tap/sgl` is the hero's primary instruction. A dev-tool page that makes you drag-select 28 monospace characters is the tell. | The command is still selectable text. |
| `cost-estimator` | `/pricing` exists to answer "what will this cost **me**". A static table cannot; a slider over real arithmetic can. | Its default row — 1,071 GB at 10% → $44.98 — as a static ledger. |
| `changelog-section` (the tag filter) | Eight releases, four tag classes. "Show me only the breaking ones" is the question a changelog exists to answer. | All eight entries, exactly the 2.1.0 page. |

Four rules hold the door where it is:

1. **Every island degrades to the 2.1.0 render.** Nothing is only reachable
   through JavaScript. Write the server output first, then make it interactive.
2. **`<details>` stays where it already works.** The mobile nav and the mobile
   docs menu are *not* converted — a disclosure that works without JS gains
   nothing by becoming state, and converting it would cost the zip a client
   boundary for free.
3. **The island is the smallest thing that needs state.** `ui/copy-button` is a
   client component so `ui/code-block` does not have to be. `changelog-section`
   is the exception and is stated as one: the filter has to own the list it
   filters, so the section carries the directive and its fixture array ships to
   the browser.
4. **No island animates.** Every control here is
   `transition-colors duration-120 ease-out`, which motion-pass exempts from the
   motion budget, and the copy glyph **swaps instantly** — this theme is
   mechanical, and a terminal confirms by redrawing rather than by fading. The
   ambient slot is still spent on the hero cursor and nothing else.

**The flash.** A theme cannot inject a blocking script into a layout it does not
own, so `ui/theme-toggle` applies the stored preference on mount and a returning
visitor who chose light may see one dark frame. That is a host-app fix and the
README says where it goes. Do not "solve" it inside the theme with
`dangerouslySetInnerHTML`.

**Which element carries the mode class.** The token tables are scoped to
`.tt-signal` as well as `:root`, so the toggle writes to the nearest `.tt-signal`
ancestor when embedded (the marketplace preview) and to `document.documentElement`
when standalone (the buyer's app). Both classes are set explicitly, never
removed-and-implied: `.tt-signal.dark` exists precisely so a dark island inside a
light page resolves.

**The DOM outranks storage.** At mount the toggle reads the root's own class
first. A mode class already there is a decision the *host* made for this render —
the preview's `?mode=` override is exactly that — and a preference stored on some
earlier visit must not overrule it. Only when the host has said nothing does
`localStorage` apply; failing both, dark. Getting this backwards silently breaks
every capture taken through the preview.

**`dark:` is banned in this theme, and the reason is mechanical.** No theme in
this catalog declares `@custom-variant dark`, so Tailwind's `dark:` variant
compiles to `@media (prefers-color-scheme: dark)` — the **operating system's**
setting, not the class this toggle writes. A `dark:hidden` glyph beside a
class-driven page disagrees the moment a dark-OS visitor picks light, and a static
capture cannot show it. Signal is immune by construction rather than by care:
every mode difference lives in the three token blocks, and `src/` contains **zero**
`dark:` and `light:` utilities. `check-quality` §2 greps for them. If a
mode-conditional *rule* is ever genuinely needed it is a class-layer rule in
`src/theme.css`, emitted bare and `.tt-signal`-scoped like the token blocks, with
every variable still mirrored in `tokens`/`tokensDark` — never a `dark:` utility.

## The type and colour rules a new section must not break

- Sizes come from `TEXT` in `ui/type.ts`. A bare `text-sm` is a bug.
- Numbers get `tabular-nums`, always — dates, versions, metrics, table columns.
- Uppercase labels use `TEXT.label`, which carries the +0.08em tracking floor.
- Nested corners step down: `rounded-themed` (8px) outside, `rounded-(--radius-inner)`
  (5px) inside. Never the other way round.
- Non-interactive elements get no hover state. The feature cards lost theirs in 2.0.0
  because an `<article>` that lights up under the cursor promises a click it does not
  have.
- Interactive elements get a **press** state, not just a hover: `active:bg-(--primary-active)`
  on primary, `active:bg-(--surface-tint)` on quiet controls.

## The styleguide page (`/components`)

`src/app/components.tsx` renders the whole system once: the token panel (all 45
variables per mode, read through utilities so a rebrand recolours it for free), all
**11** sections in labelled frames, and the **12** `ui/` primitives in one combined
frame. Those two numbers are printed on the page and are the same two
`bun run check` reconciles against `theme.json`.

Four rules hold it together:

1. **`data-component="<manifest name>"` on every specimen frame.** The screenshots
   pipeline captures per-component element shots from exactly those nodes.
2. **Nothing on the page is hardcoded.** Swatches are token utilities, specimens are
   the shipped components with their real content.
3. **The page adds no mode toggle of its own.** It renders dark and the preview stage
   flips the mode class. The switch inside the `site-header` specimen is the header's,
   not the page's — a component frame shows the component, controls included.
4. **The page header is `sticky={false}`.** Element captures are *viewport crops*, so a
   `position: sticky` header paints over whichever frame is scrolled under it — four
   1.5.0 component shots shipped to the marketplace with the navbar smeared across
   them. `isolate` fixes stacking order, not the crop. The header's own frame is where
   stickiness is demonstrated.

## Links and destinations

1. **Every `href` resolves.** No `#`, no `href="#"`-style placeholders, no 404, no
   invented external URL. Verified mechanically: extract every rendered `href`, resolve
   page paths against the seven routes and fragments against that page's `id`s
   (`.claude/skills/check-quality/` §5).

   **`mailto:` is the one sanctioned scheme**, and only on `/contact`. It does not
   reopen the invented-URL rule: that rule exists because an invented `https://`
   destination 404s in the buyer's shipped site, and a `mailto:` cannot 404 — it
   opens a compose window, and the label *is* the destination, which is the 2.1.0
   test verbatim. An invented `https://` is still a bug.
2. **The placeholder convention is retired — a link goes where its label says.**
   2.0.0 pointed **36 of its 54 authored hrefs at `/docs`**, across twelve distinct
   destinations in the whole theme: thirteen sidebar topics that reloaded the page
   you were on, two hero buttons with one destination between them, an `About`
   and a `Security` and a `Privacy` that all opened a quickstart. That passes a
   resolver and fails a buyer, who finds out only after clicking. The three moves that
   replaced it, in order of preference:
   - **Build the destination** if the theme is already promising it in more than one
     place (`/cli` closed `CLI reference`, the sidebar's four `sgl …` rows, and
     `Sign in` at once).
   - **Re-aim the label at a real anchor** (`Self-hosting` → `/cli#collector`,
     `Install the CLI` → `/docs#install`).
   - **Cut it.** `Roadmap`, `Status`, `Support`, `About`, `Engineering blog`,
     `Security`, `Privacy`, `Query cookbook`, `Incident templates`, `SDKs` and the
     changelog's `archive` link are gone. A footer is a site map; a site map naming
     pages the site does not have is the oldest tell in a template.
3. **A non-link must not look like one.** The header's `6.1k` star count is a `<span>`
   chip because Signal ships no repository URL, and the docs sidebar's current item is
   a `<span aria-current="page">`, not a self-link.
4. **The docs sidebar navigates pages, and there are two of them.** `Quickstart`
   (`/docs`) and `CLI reference` (`/cli`) under one heading, `Pricing`,
   `Integrations` and `Changelog` under `Elsewhere`. Whichever row matches the rendering variant's route
   becomes the current item, and that page's own anchors nest under it. Below `xl` the
   nested list is the page's only in-page navigator; from `xl` the "On this page" rail
   takes over. **The outline lives in exactly one rail at every width.**
5. **Sign-in is `sgl login`.** A CLI-first product with no console does not ship a
   `/login`; the header's `Sign in` opens `/cli#login`, which is the command that
   actually signs you in. Inventing an auth page would be the placeholder convention
   wearing a different hat.
6. **Anchor ids are a contract.** `#features` is targeted by the header nav;
   `#integrations` by the footer, the docs sidebar and the CLI reference's Next grid;
   `#main` by the skip link on all seven pages. `#changelog` and `#payoff` are
   deep-link handles, and `#payoff` exists on both docs routes. `/cli`'s seven command
   ids (`#login`, `#watch`, `#query`, `#trace`, `#diff`, `#cost`, `#collector`) are
   targeted by the header, the footer and the quickstart. `/pricing#estimate` is
   targeted by the Usage plan's own button and by the feature grid's cost card;
   `/contact#legal` by the footer's legal row. Do not rename any of them.
7. **Routes are flat, and that is a packaging constraint, not a preference.**
   `lib/pipeline/package.ts` writes one `app/<slug>/page.tsx` per manifest page at a
   fixed `../../` depth, so a nested path like `/docs/cli` emits a broken re-export in
   the buyer's zip. This is why the reference lives at `/cli`.
10. **The header nav is five rows and stays five.** 2.2.0 swapped `Integrations` for
   `Pricing`: a price is the second question every visitor has, and `Integrations`
   was the only nav row aimed at an anchor rather than a page. It keeps all three of
   its other entry points. A sixth row is not the fix for the next page — re-aim one.
8. **The skip link moves focus, not just scroll.** Each page's `<main>` carries
   `id="main" tabIndex={-1}` with `focus:outline-none`.
9. **The wordmark link has no `aria-label`.** Its accessible name comes from the
   `data-brand-name` text inside it, so rebranding renames the link too.

## Verified contrast (WCAG 2.1, computed from the shipped hex)

Every pair the components actually use, both modes. Nothing here is asserted; the
script that produced this table reads `src/theme.css`.

| Pair | Dark | Light |
|---|---|---|
| foreground on background | 16.18:1 | 15.64:1 |
| foreground on card | 14.57:1 | 17.35:1 |
| foreground on secondary | 12.75:1 | 13.84:1 |
| foreground on surface-tint | 13.32:1 | 15.33:1 |
| muted-foreground on background | 6.57:1 | 5.94:1 |
| muted-foreground on card | 5.91:1 | 6.59:1 |
| muted-foreground on secondary | 5.17:1 | 5.26:1 |
| muted-foreground on muted | 5.67:1 | 5.62:1 |
| muted-foreground on surface-tint | 5.41:1 | 5.82:1 |
| secondary-foreground on secondary | 9.42:1 | 9.89:1 |
| secondary-foreground on card | 10.76:1 | 12.40:1 |
| secondary-foreground on background | 11.95:1 | 11.18:1 |
| secondary-foreground on surface-tint | 9.84:1 | 10.96:1 |
| primary on background | 7.06:1 | 6.03:1 |
| primary on card | 6.35:1 | 6.68:1 |
| primary on secondary | 5.56:1 | 5.33:1 |
| primary on muted | 6.09:1 | 5.70:1 |
| primary on surface-tint | 5.81:1 | 5.91:1 |
| primary-hover on background (link hover) | 8.77:1 | 7.80:1 |
| primary-active on background (link press) | 10.85:1 | 9.95:1 |
| primary-hover on card | 7.89:1 | 8.65:1 |
| **primary-foreground on primary** (buttons, terminal title bar, status line) | 7.49:1 | 6.71:1 |
| primary-foreground on primary-hover | 9.31:1 | 8.68:1 |
| primary-foreground on primary-active | 11.52:1 | 11.07:1 |
| accent on background | 9.70:1 | 6.01:1 |
| accent on card (`Hot`, strings, breaking tags) | 8.74:1 | 6.67:1 |
| accent on secondary | 7.65:1 | 5.32:1 |
| accent on surface-tint | 7.99:1 | 5.89:1 |
| accent-foreground on accent (the filled badge) | 9.41:1 | 6.65:1 |
| ring on background (non-text, needs 3:1) | 7.06:1 | 6.03:1 |
| ring on card (3:1) | 6.35:1 | 6.68:1 |
| ring on surface-tint (3:1) | 5.81:1 | 5.91:1 |

The hero's contour layer changes the ground under the headline block, so those pairs
are computed **against the rendered pixels**, not against `--background`: dark over
the texture's brightest pixel, light over its darkest.

| Pair over the textured hero ground | Dark | Light |
|---|---|---|
| foreground | 13.11:1 | 12.55:1 |
| muted-foreground | 5.32:1 | 4.77:1 |
| primary | 5.72:1 | 4.84:1 |
| accent | 7.87:1 | 4.82:1 |

Every pair clears the 4.5 floor. **`--muted-foreground` over the light texture (4.77:1)
is the tightest pair in the theme** — raising `bg-primary/15` or re-shaping the mask
means re-measuring these four rows, in both modes, from a render.

## What never to touch

- **The `sgl watch` transcript content** in `terminal-hero.tsx` — the annotated
  regression narrative, the sparkline escalation `▁▂▄▆██`, the 24-second gap, `@nadia`,
  the `→` next command. This is the signature. Do not rewrite it, do not shorten it, do
  not make it more marketing.
- **The pulse glyph in `src/components/ui/logo.tsx`** — identity is bespoke.
- **The no-photography decision.** Signal ships exactly one asset and it is a colourless
  mask. Photography, screenshots and any *coloured* image remain off-identity; the depth
  here comes from token-driven layers that re-tint per mode and survive a rebrand.
  That failure is measured, not assumed — `ops/design/signal-imagery-2026-08-13.md`,
  `signal-backgrounds-2026-08-13.md`, `signal-hero-texture-2026-08-13.md`.

  **Imagery.** The files in `src/assets/` are AI-generated (Gemini) and are licensed to
  you with the theme. They are regenerable — the prompt and the shaping recipe are in
  `src/assets/README.md`.
- **The `.dark` block in `src/theme.css`** — it is not dead code (see § Dark-first).
- **`@theme inline` mappings** — they live in the host app's `globals.css`, never in a
  theme. Do not add a `@theme` block here; that is why sizes are applied as
  `text-(length:--text-d3)` rather than as a registered utility.
- **`min-w-0` on `code-block.tsx` / `terminal-window.tsx`** — removing it breaks the
  375px layout in a way that is invisible on a desktop screen.
- **The island list.** Four components carry `'use client'` and the list is closed
  (§ "The sanctioned islands"). 2.2.0 opened it deliberately, with an argument per
  island and a stated no-JS fallback for each; that is the standard a fifth one has
  to meet. What is *not* negotiable is the shape: pages stay pure composition, the
  `<details>` menus stay `<details>`, and no island introduces a runtime dependency
  beyond `lucide-react` and React itself.
- **Anything outside `themes/signal/`.**

## Extending

Use the skills; they encode the above:

| Task | Skill |
|---|---|
| Apply a buyer's brand | `.claude/skills/rebrand/` |
| Add a section | `.claude/skills/add-section/` |
| Add a page | `.claude/skills/add-page/` |
| Verify before shipping | `.claude/skills/check-quality/` |
