---
name: gatlas-design
description: "Build or change a Gatlas product or marketing surface: dashboard pages, marketing pages, emails, embeds, artifact renderers. Covers the token API, the component API, the type and control ladders, and the composition rules the CSS cannot encode. This is the running web app at gatlas.ai. It is NOT the skill for decks, documents or social cards, which is gatlas-design-system."
---

# Gatlas design

## §0 What this document is

Gatlas's design system has three layers. This document is the second.

1. **Mechanism**: `apps/web/app/globals.css` and `apps/web/components/ui/*`. Tokens, primitives, ladders. Makes the right thing the default.
2. **Judgment**: this file. The rules CSS cannot hold, and the reasons behind the ones it can.
3. **Enforcement**: the contract tests under `apps/web/lib/*.test.ts`, plus the four gates. See §13.

Four rules this document obeys about itself, so that it stays true rather than becoming a period piece:

**Values live in code, not here.** Name a role (`title`, `label`, `control-md`) and point at the file that owns its number. This document states exactly three literal values, in §5, each because the number *is* the judgment rather than a tuning: the display ceiling, the reading measure, and the tracking ramp's step boundaries. There is no fourth. Digits elsewhere in this file are section indices, dates, or parts of a filename.

**The names are test-pinned.** `apps/web/lib/design-tokens.test.ts` asserts, in both directions, that every custom property named here is declared in `globals.css` and that every token the `/design` preview draws is named here. Rename a token and this document fails CI. Its honest limit: it catches a rename, never a value that moved while the prose quoted the old one. That limit is why the first rule exists.

**`/design` is the rendered counterpart.** Before you invent anything, open it. It draws every token in both themes and every primitive at every rung. It is operator-gated (`canPreviewDesignSystem` in `apps/web/lib/design-preview.ts`): local development, or a super-admin.

**Use role names, not rung names.** `globals.css` emits ten role-named type utilities. Say `text-title`, never `text-3xl`. The rung is Tailwind's namespace; the role is ours, and a role can change size without a rename.

This document also takes its own prose rules, including the em dash ban in §1. If you find one here, it is a bug.

## §1 Inherited doctrine

The base document is Vercel's brand guidelines (the `vercel-brand-guidelines` skill, served at `vercel.com/geist/design.md`). It is authoritative for everything this document does not speak to. **This document is a diff.** It never restates a rule in order to agree with it, and its shortness is the point: if a passage here could be deleted without changing what ships, delete it.

Adopted whole, by section name: *Frame the reader's job* · *Choose the composition* · *Typography and rhythm* · *Color, surfaces, and boundaries* · *Data and evidence* · *Motion and delight* · *Media and icons* · *Inspect and revise privately* · *Reject generated-design reflexes*.

Nine things do not transfer, because the base document is *report* doctrine and Gatlas is an application:

1. **The authorship shell.** Vercel's wordmark header and triangle footer are their marks. Gatlas has its own shell (sidebar and header in the product, `Header`/`Footer` on marketing) and never renders another company's mark as authorship.
2. **The `vbg-` namespace.** Vercel prefixes because their stylesheet is loaded into documents they do not own. Ours publishes the canonical names, so markup written against it pastes into `apps/web` unchanged. The *stylesheet* does transfer, and this item used to say it did not: the argument was that our document is consumed inside the codebase, and that stopped being true the day an agent in another repository needed the values. `gatlas.ai/gatlas-brand.css` now serves them, **generated from `globals.css` on every request** and never hand-authored. The rule this item was really protecting is unchanged and still absolute: never introduce a parallel token layer. A second *authored* layer is one; a derived artifact that cannot disagree with its source is not. §11 says what it emits.
3. **A visible `<caption>` on every table.** The transferable requirement is an accessible *name*. `sr-only` satisfies it; a visible caption above a dashboard ledger is chrome.
4. **"No visible theme switcher" as doctrine.** Gatlas has no switcher today, but not for this reason. See §9.
5. **Direct labels over legends, absolutely.** True at report width. An artifact chart occupying one grid cell has no lane for a direct label, so it keys its series in a legend whose swatch is drawn from the same fill or stroke as the mark.
6. **The reading measure applied to everything.** It governs prose. Tables, forms, the SQL editor and ledger columns are lookup surfaces and take the width their content needs.
7. **A literal twelve-column grid.** Inside a fixed-sidebar shell the outer grid is the shell. Alignment is still required; the column count is not.
8. **"One continuous canvas" as an absolute.** A UI with selection, hover and per-row failure needs boundaries that a report does not. The bar for earning one is in §6, and it is high.
9. **"A title does not name the genre."** Right for a report. Wrong for a route title, where the genre *is* the nav label the user clicked.

## §2 Priority order

When requirements compete, protect them in this order.

1. **Correctness and safety of the data shown.** Never let a failed lookup, an empty read or a truncated page render as a plausible value. A missing figure renders as the empty-value placeholder (§8), never as zero; a partial result says it is partial; a count on a card and the row count at its destination come from one predicate or the page says why they differ. This clause is first because it is this repository's recurring bug class rather than a general principle, and because a finance product that renders a confident wrong number has failed at design, not only at engineering.
2. **The token and component API.** Never hand-patch what the system can own. §3 says what to do instead.
3. **The user's task, legible at the density this product runs at.** Density is a real constraint here, not a failure of restraint. See §6.
4. **Gatlas identity: type, restraint, the monochrome field.**
5. **A composition specific to this surface** rather than a template or a model default.
6. **Polish**: responsive behaviour, interaction detail, motion.

## §3 Integration

Styling decisions move **up** into tokens and primitives, never **out** into call sites. A value that appears twice at a call site is a token that has not been declared yet.

Triage every styling need before writing it:

- **A token or primitive already covers it.** Use it. If a call site is currently hand-patching it, migrate the call site in the same change.
- **One nearly covers it.** Extend the primitive (a new rung, a new variant), then migrate. Deepening an existing module beats adding a shallow one.
- **Nothing covers it.** Build the primitive first, then purge the hand patches it replaces.

**A purge that runs before its component exists is a find-and-replace the next author undoes.** Order matters more than speed here.

Do not add a styling dependency. Charts are hand-drawn SVG (`components/artifacts/artifact-grid.tsx` explains why); icons are `lucide-react`; there is no second icon set and no chart library. Motion has two permitted sources: `tw-animate-css` for CSS, and the `motion` library, which four marketing components already import; a third is a decision, not a default. The one rendering library is `vgpu`, scoped to the ocean ground (§8, row 15).

`components/ui/*` is generated shadcn. Extend by `className` or by wrapping. When a change has to go inside a generated file, say so in the commit: `shadcn add` would silently overwrite it.

## §4 The three passes

**Frame.** What is the user's task on this surface, or the reader's question? What is the strongest supported answer, what evidence earns it, and what would change it? For a product surface, also: what states can this data be in, and what does each look like (§6)?

**Compose.** Geometry before components. Map the material to a visual variable before choosing a primitive. Run the squint test (at a glance, is the dominant object obvious and the reading path stable?) and the text-mask test (with the words blurred, does the hierarchy still communicate identity, emphasis, grouping and progression?). If every block has equal weight, redesign before coding.

**Inspect.** In order:

1. Does the surface obey §2, clause 1? Find the failure paths: empty, loading, error, partial, stale. Open each.
2. Does anything hand-patch a value the token layer owns? Route it through §3.
3. Type roles consistent, peers equal, baselines aligned, every gap owned by one parent.
4. Keyboard: tab order, visible focus, no trap, every action reachable. Screen reader: landmarks, one `h1`, accessible names.
5. Both themes, and reflow to a narrow screen without horizontal overflow.
6. Restraint: can any surface, border, badge, icon, colour or paragraph be removed without losing meaning? Remove it.
7. The four gates: lint, type-check, test, build. All four, every time; the exact commands are in the repository's `AGENTS.md`.

There is no browser test suite. Step 5 and the visual half of step 4 are **manual**, and a change that skips them has not been verified.

## §5 The visual system

Every rule here points at a name. The values live in `globals.css`.

**Type roles.** Ten, emitted as `text-display`, `text-page-title`, `text-title`, `text-section`, `text-subsection`, `text-lede`, `text-body`, `text-compact`, `text-label`, `text-metadata`. Use `display` only for a single page-defining statement where scale is earned; `page-title` for a route's title; `title` for a major section turn; `section` and `subsection` for nested structure; `lede` for one orientation passage; `body` for reading; `compact` for dense prose; `label` for a compact name, a table cell, a list row; `metadata` for a caption, an eyebrow, subordinate evidence context. `label` and `metadata` share a value today and are free to stop: they are two roles, not two spellings, and the density knob is deliberately two edits.

**The display ceiling is 48px.** Nothing in the product is larger. The rungs above it are clamped rather than deleted so a responsive class is inert instead of wrong.

**The tracking ramp is a step function of size**, and it keys off the rung name, not the resolved value. Its boundaries: at or above 30px, the tightest step; at 24px, one step looser; at 16 to 20px, looser again, and only in a heading role; at or below 14px, none. Never apply a `tracking-*` utility at a size the ramp already governs, and never apply positive tracking anywhere.

**Prose sits at 60 to 68 characters per line.** Rewrite before shrinking, and never narrow a lookup surface to reach it (§1, item 6).

**One control ladder.** `--control-h-sm` through `--control-h-xl`, plus `--control-h-touch`, the enlarged target the narrow-viewport bump applies to the rungs that can take it (§10), with `--control-text`, `--control-text-micro`, `--control-leading`, `--control-pad-sm` through `--control-pad-xl` and `--control-gap`. Every control primitive consumes it; none re-derives its own number. The heights alias the raw metrics `--ds-size-small`, `--ds-size-medium`, `--ds-size-large` and `--ds-size-touch`. `--control-text-micro` is barred from form controls.

**Radius.** Controls take `--radius-control`; overlay surfaces (menus, popovers, modals) take `--radius-overlay`; `--radius-pill` is opt-in on marketing CTAs only. All three deliberately bypass the `--radius` ladder, which is dead (§10). In-page surfaces are square: tables, stat tiles, disclosures, wells and empty states draw their edge with no radius, and the `Table` primitive draws its own frame so no call site authors one (`lib/design-tables.test.ts`). The Entities page briefly took the overlay radius on its table and tiles (#334) and was the one rounded table in the product; that ended in GAT-179. Rounding in-page surfaces is the radius phase (§9), one reviewed change, never a per-page choice. Never author a numeric radius.

**Colour is monochrome by default and quarantined otherwise.** Chroma is permitted in exactly three places: the four status tokens (`--info`, `--success`, `--warning`, `--destructive`, with `--destructive-foreground` and the tint rungs `--info-100`, `--success-100`, `--warning-100`, `--error-100`), the focus ring `--ring`, and chart series. Nothing else. Do not colour a figure because it is favourable, a row because it is recommended, or a bar because it is longest. Every use of colour to carry meaning pairs with a non-colour cue.

**Ground and surfaces.** `--background` is the page; `--background-200` is the recessed ground a step off it (table header grounds, card footers, inset wells, empty-state grounds); `--card` and `--popover` are the raised surfaces, with `--card-foreground` and `--popover-foreground`. `--muted` is the hover and selection fill only, with `--muted-foreground` for receding text. The sidebar carries its own set: `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, `--sidebar-ring`. Actions read `--primary`, `--primary-foreground`, `--secondary`, `--secondary-foreground`, `--accent`, `--accent-foreground`, over `--foreground`.

**Boundaries.** `--border` is the default edge, `--border-subtle` an interior rule, `--border-strong` a hover step; `--input` is a field edge. The raw alpha scale they come from is `--gray-alpha-100`, `--gray-alpha-200`, `--gray-alpha-300` and `--gray-alpha-500`, and it is also where hover and press fills come from. Illustrations paint with `--illustration` and `--border-illustration`, both an alpha of the raw `--foreground` so they flip with the theme on their own. They read `--foreground` and never its `@theme` mirror `--color-foreground`, which is emitted once on `:root` and would hand a nested dark wrapper the light theme's ink.

**Charts.** The app-wide ladder is `--chart-1` through `--chart-5`: a monochrome ramp, ink end first, that cycles. Series past the cycle lap it and lean on stroke weight and dash. The artifact surface is the documented exception (§8, row 4).

**Mono.** Geist Mono only for code, commands, paths, raw tokens, timestamps and short operational identifiers: a schema name, a key prefix, a connection label. Set only the identifier, never its sentence or its whole table. `.z-mono` carries slashed zero with tabular figures, because disambiguating zero from the letter O is the entire reason an identifier is in mono. Financial figures are Geist Sans with tabular figures, not mono: use `.numeric`, or nothing, since `globals.css` sets tabular figures once on `table`.

**Weight.** Regular for body, medium for controls and labels, `--font-weight-title` for headings. Emphasis is scarce. Never author a numeric `font-weight`.

## §6 Product surfaces

The dashboard runs dense, and that is a decision rather than an accident: it packs six controls into a filter row where the base document sets one control in a paragraph of prose. Density is a deliberate variable with two knobs, and **cell padding is the larger one**. `TableCell` in `components/ui/table.tsx` spends most of a row's height on padding; changing the type role alone will not fix a row that reads as cramped, and will not fix one that reads as loose either.

**Every data surface owes five states**, and a surface that has only drawn the happy one is unfinished:

- **Empty**, distinguishing "nothing here yet" (which needs the action that creates the first one) from "nothing matched these filters" (which needs the filters, not the illustration).
- **Loading**, as a skeleton that mirrors the real rhythm line for line. A skeleton with a different number of rows than the content that replaces it is a layout shift you shipped on purpose.
- **Error**, saying what failed and what to do. Never a blank region.
- **Partial**, when a read was truncated or one component of many failed. Per-component failure isolation is the artifact grid's rule and the right default everywhere: one drifted column must not take a page down.
- **Stale**, when the data is real but old. Say how old. This is clause 1 of §2 in its most common form.

**Tables are evidence and controls at once.** Semantic `<table>`. `scope="col"` on every header (the primitive sets it; do not remove it). A header's alignment matches its column, so a numeric column is right-aligned in the head as well as the body. Cells align on the row's first text baseline; never centre or bottom-align a body row. One cell type is exempt: a cell whose only content is an inline-flex icon generates no line box, so its baseline is synthesised from its bottom content edge, and because a row takes the lowest of its cells' baselines that one cell drags every sibling text cell down. Those cells, and only those, take `align-middle`; a flex wrapper with `items-center` does not rescue them, and reverting the default would bottom-align every text row instead. The rule is written on `TableCell` in `components/ui/table.tsx`. An accessible name, not a visible caption. Do not spend a column repeating one category down a run of rows.

**Focus and keyboard traversal are requirements, not polish.** Every interactive element is reachable by keyboard, and focus visibly changes pixels. Whole-row navigation uses a stretched anchor over the row's primary link, never a click handler on the row: a click handler has no `href`, no tab stop, no modifier-click and does not appear in a screen reader's link list. Two things make the stretched version safe. The row carries `[transform:translate(0)]` as well as `relative`, because WebKit computes `position: relative` on a `<tr>` as `static` and the pseudo-element then escapes to the table's own container (§10). And every cell's *content* is raised with `relative z-1`, never the cell itself, so tooltips fire and text stays selectable while each cell's padding gutter, plus the whole primary-link cell, stays under the anchor. Raise the cell instead and there is nothing left to click. **Radix and cmdk menus drive their keyboard highlight with `data-highlighted` and `data-[selected=true]`, not real DOM focus**, so a `focus:` selector on a menu item is dead CSS. Match the mechanism the primitive actually uses; `components/ui/select.tsx` and `components/ui/dropdown-menu.tsx` set both.

**Earn every boundary.** Spacing, alignment and a change in density come first. A boundary is earned when it communicates selection, interaction, a real grouping, or a per-item failure envelope. It is not earned to repair weak hierarchy, and a card inside a card is always wrong. Where a boundary is earned, draw it once: a shadow *and* a ring on the same element is the edge drawn twice. A variant with no resting surface does not grow one when it is disabled either: `ghost` and `link` override the base's filled disabled treatment and keep the muted text as the only cue, so a disabled toggle does not flash a box it never had.

**The shell contract is implicit and unpinned.** The dashboard header's height is duplicated as a viewport calculation in `app/(dashboard)/sql/workspace-client.tsx`. Changing one without the other produces no type error and no failing test, and the SQL workspace silently scrolls or clips. If you touch the header height, grep for the calculation.

## §7 Marketing surfaces

The job is persuading a stranger rather than briefing a stakeholder. That changes exactly three things; everything else in §1 and §5 carries over unchanged.

1. **Pills on in-page CTAs.** `shape="pill"` with the largest control rung, opt-in per call site. Never the `Button` default, never in the header, never in the product.
2. **At most one composed illustration per page**, built from the app's own evidence rather than stock or generated art, on the page that is about the thing it shows. The home hero carries none: it is typographic, and its ground is the one moment of craft (§8, rows 12 and 15).
3. **Sentence case still applies**, including in headings and CTA labels.

**Decoration is permitted in inverse proportion to the density of numbers on the surface.** The hero's claim block and the auth panel carry no figures and can hold one bounded, named moment of craft. Feature and process sections get type and space. Anything carrying a figure, a table, a chart or a customer's name gets nothing: a decorated number reads as a rendered number, and this product's whole claim is that the numbers are the customer's actual books.

The hero is the claim at the display ceiling on a short measure, a rule that spans exactly the headline's measure, a lede and the two actions, on the ocean ground with the claim on a solid panel. The example conversation is the figure on `/product/mcp-server`, the page about the thing it demonstrates; the ground never runs under a figure.

**The type roles hold on marketing, with two ratified exceptions.** Chrome and prose take the roles with no exception: the header, the footer, the about page, legal, the blog, the docs, every product page's running text, every button and form. They are read, and the roles exist so that reading is consistent across the site; there is no argument for a raw rung in a footer. Exactly two places may diverge, each entering §8 the day it is first used: a display scale above the ladder's ceiling for the hero's claim alone, and the labels inside a drawn figure, which follow the figure's own scale. A third, the template-derived sections that shipped with raw rungs, was row 20 until 2026-09-15, when the width sweep converted the last of them and the row left the table. Hand-authored weights have no exception anywhere: `--font-weight-title`, medium for a control or a label, and the body weight cover every case. `lib/design-marketing.test.ts` holds every marketing file at zero raw rungs and zero hand weights.

**Third-party marks are a truth question, not an aesthetic one.** Never render another company's mark under a claim of customer relationship. Connector marks are legitimate because Gatlas genuinely connects to those systems; the caption is what would make them a lie. There is no `components/ui/svgs` directory any more, which means a hand-port fails loudly rather than quietly; `shadcn add` would recreate it silently, so check after running it.

## §8 Ratified divergences

A divergence from the base document enters this table **in the same pull request that creates it**, or it is drift. Three of the rows below were unratified deviations found in review rather than decisions taken; that is the failure mode this rule exists to stop. A row leaves the table when its condition stops holding, not when someone stops liking it.

| # | Divergence | Argument | Condition that keeps it honest | Pinned by | Date |
|---|---|---|---|---|---|
| 1 | Newsreader serif on artifact mastheads | An artifact is what a portfolio company's board actually receives. It is the one surface that must not read as admin chrome. | One class, mastheads only, nowhere else in the product. | `.artifact-display` in `globals.css`; `lib/theme-fonts.test.ts` | 2026-09-03 |
| 2 | Em dash as the empty-value placeholder | The base document's ban is a prose rule. A dash meaning "no value" in a ledger is a data convention, and it is how §2 clause 1 renders a missing figure without inventing a zero. | Table cells and detail rows only. Still banned in shipped prose, including this file. | `formatMeasure` in `lib/artifacts/render-model.ts` | 2026-09-03 |
| 3 | Pulsing in-progress dot | Directly against the base document's ban on pulsing status indicators. Motion carries in-progress state **so that** colour stays reserved for success, warning and error. | Every pulse `aria-hidden` beside a text label; the reduced-motion block ships with it; no pulse in a static legend. A legend explains a marker and is never itself in progress, so its key is the static form of the dot. | The reduced-motion block in `globals.css`; `lib/design-tokens.test.ts` fails any pulsing dot that is not `aria-hidden` | 2026-09-03 |
| 4 | Artifact charts use a categorical hue palette as the primary series signal | Not a divergence from the colour rule, which permits colour "when it is needed to distinguish series". It is a divergence from Vercel's own implementation of that rule, which cycles greys. The rest of the product stays monochrome; artifacts are the customer-facing surface where series identity is the question, and the palette is CVD-validated rather than eyeballed. | Scoped to `.artifact-canvas` so nothing else changes colour. Every series carries a redundant non-colour cue: stroke weight and dash for line and area, a hatch pattern for bar and pie. A legend swatch is drawn from the same fill or stroke as its mark. | `lib/artifacts/render-model.test.ts` (`chartRung`, `chartLap`, `chartHatchId`, `chartHatchPairs`) | 2026-09-04 |
| 5 | Pill radius on marketing in-page CTAs | Marketing persuades a stranger; the product does not. | Rides on the largest control rung, opt-in per call site, never a `Button` default, never in the header, never in the product. | `BUTTON_PILL` in `components/ui/button-sizes.ts`; `lib/button-contract.test.ts` | 2026-09-03 |
| 6 | A denser default control rung than Geist's single desktop size | Stated as a choice, not a correction toward the authority. Gatlas's dashboard packs a filter row where the base document sets one control in prose. | One ladder, one number per rung; the narrow-viewport bump restores a target on every rung that can grow in both axes, and the four dense rungs opt out by name (§10). | `--control-h-md` in `globals.css`; `lib/button-contract.test.ts`; the bump's exclusion list in `lib/design-tokens.test.ts` | 2026-09-03 |
| 7 | `--radius-control` and `--radius-overlay` bypass the `--radius` ladder | The ladder is dead (§10) and cannot be repaired as a side effect of an unrelated change. These give controls and overlays a real radius meanwhile. | Interim by construction. Both retire when the ladder is activated, and that activation is its own reviewed change. | The `@theme inline` block in `globals.css` | 2026-09-03 |
| 8 | Dark `--card`, `--popover` and `--sidebar` sit on the recessed ground rather than the page ground | Vercel's dark cards do sit a rung above ground, and against a pure black page a card that shares the page's value is not a card. | Light and dark declare the same set of names, so neither theme can grow a token the other lacks. | `lib/design-tokens.test.ts` | 2026-09-04 |
| 9 | Status tints derived as an alpha of the edge colour, not from the tint rungs | Interim. The tint rungs are declared and correct; migrating the call sites is a separate change with its own review. | The tint rungs stay declared and drawn on `/design` so the debt is visible. Any *new* tinted surface takes the rung. | The `-100` rungs in `globals.css`, drawn by `lib/design-preview.ts` | 2026-09-04 |
| 10 | `--text-label` as a rung Tailwind does not have | Geist publishes a label role at a size Tailwind's scale skips, and the alternative (retuning the smallest stock rung) would enlarge the densest surface in the product. | New label work takes the role. Migrating the existing dense chrome is its own ticket, and the count is the score (§13). | `lib/design-tokens.test.ts` | 2026-09-03 |
| 11 | `--control-text` at the label size on the product surface, comfortable opted in | Geist pairs its control with body copy of the same size. Gatlas's dashboard body is smaller, so Geist's control renders larger than the text beside it and the widest type in a filter row is a button. This treats the symptom; the cause is the unmigrated body ladder. | **Reverts** when the body migration lands, together with the `[data-control-density="comfortable"]` block. Filed as GAT-77. Without that ticket this line is drift. | `lib/design-tokens.test.ts` (exactly two declarations, ever) | 2026-09-04 |
| 12 | A dithered texture on the auth panel and the hero ground | The base document hard-rejects textures. This one exists to solve a contrast problem, not to decorate: the auth panel's ground was very nearly invisible against the page in light and exactly invisible in dark, so the texture *is* the value step. The texture itself is zero new bytes, no client boundary and no motion; the live layer over it is row 15. | `/login`, `/signup`, `/forgot-password`, `/reset-password` and the hero's claim block only. Static, read as a luminance mask so it never lands on type, and cleared toward content by a fade on the auth panel; on the hero it runs edge to edge under an opaque claim panel that the live layer in row 15 shares. | The `.auth-panel` and `.ocean-ground` blocks in `globals.css`; `lib/design-tokens.test.ts` | 2026-09-04, widened 2026-09-08 |
| 13 | The OG and Twitter card renders outside the token layer, on hand-authored hexes and raw font binaries | Satori reads neither CSS custom properties nor `next/font`, so the social card is the one surface that cannot be routed through `globals.css`. A hex literal is how it renders in the product's own ink rather than in a second, drifting palette, and the card is what a stranger sees before the site does. | `app/opengraph-image.tsx` only. Every hex names the token it mirrors in a comment beside it, and the fonts are the static `Geist-400.ttf` and `Geist-600.ttf` pair in `app/og-assets/`, extracted from the `geist` devDependency, which nothing imports at runtime. | `lib/theme-fonts.test.ts`, which pins each hex to the declaration of the token it mirrors; `app/og-assets/README.md` | 2026-09-05 |
| 14 | Motion on the example conversation's published artifact, and a scoped block for the conversation | §7 gives a chart nothing. The conversation's final frame is the product's own artifact renderer, and its bars draw in once the way a chart conventionally arrives, as a re-enactment of publishing rather than an ornament on a figure. The block exists because the draw rule targets the renderer's own SVG, which no call-site utility can reach. | `/product/mcp-server` only, as that page's one figure, never on the home page and never on the ocean's ground; moved there from the home hero on 2026-09-15 when the hero became typographic. The draw runs once per pass, the loop restarts only while the box is on screen, and the reduced-motion path renders the finished chart with no motion at all. A visitor without script gets the finished transcript through a noscript style, so no reader depends on the motion. | The `.example-conversation` block in `globals.css`; `lib/landing-evidence.test.ts` | 2026-09-08, moved 2026-09-15 |
| 15 | A WebGPU render loop over the dithered ground | The base document permits one bounded moment of craft on a surface that carries no figures, and §7 names the hero and the auth panel as those surfaces. This is that moment. It reverses a 2026-09-04 rejection on new evidence: the static treatment it needed now exists and is paid for, the library is Vercel's own, and the port is monochrome, so no colour rule moves. | Mounts only behind the nine gates listed in `components/landing/ocean-ground.tsx`, in that order; the mount gates fall back to the static ground and the two runtime gates stop the loop. Paints only `--foreground` on the surface's own ground. Never under a figure. Loads by dynamic import after idle so no route's first paint pays for it. | `lib/design-tokens.test.ts` scans the component for every gate by name and `next.config.ts` for the loader rule | 2026-09-08 |
| 16 | Light `--destructive` is darker than Geist red-700, and dark `--error-100` a step darker than its ramp | Not a taste divergence, a contrast correction. Geist's `oklch(62.56% 0.2524 23.03)` measures **4.041:1** against the light ground, below the 4.5:1 floor §12 calls flat and non-negotiable, and it failed in both directions at once because `--destructive-foreground` is white. Darkened to 58% on 2026-09-11 (4.830:1 on the ground); on 2026-09-16 the same ink was measured against its own tint rung, the combination every error box and the Coverage grid's error cell draws, and `text-error` on `bg-error-100` was **4.30:1** light and **4.43:1** dark. The divergence is the floor winning over the palette, which is the order §2 already sets. | Same hue, same family. Light ink at **56%**: 4.56:1 on `--error-100`, 5.12:1 on the ground, 5.12:1 for white on it. Dark keeps its ink (5.28:1 on the ground, where most error text is) and steps the tint rung from 22.1% to **20%**: 4.63:1. If Geist restates the pair above the floor, this row leaves. | `lib/design-tokens.test.ts` pins both values and the reason | 2026-09-16 |
| 17 | `gb-note` and `gb-status` carry a shape partner the base document does not specify | §12 says every colour-carried meaning has a text or shape partner, and both primitives shipped carrying tone in colour alone: a note's tone was one border colour, a status's was its ink and edge, and `gb-status-dot` was the same mark at the same radius in every rung. With the hue removed, three notes meaning three different things were three copies of one note. That is what a reader who cannot separate the hues gets every time, not an edge case. | A glyph on the note and a shape on the status dot, both driven by the tone attribute the layer already publishes, so no call site changes and no new name enters §11. The neutral default takes neither, because the absence of a mark is what neutral means. `/design` draws every tone beside itself with the hue removed, so a regression is visible rather than argued. | `app/primitives.css`; the hue-removed pair in `app/(dashboard)/design/primitives.tsx` | 2026-09-11 |
| 18 | A desk-and-sheet surface for document artifacts, as a fifth scoped block in `globals.css` | A document is what a board member prints. The base document's one continuous canvas is right for a screen and wrong for paper, where the page is the unit the reader holds, and drawing the sheets on screen as they will print is the whole promise of the format. The sheet's edge is earned under §6: it is the boundary between one printed page and the next. | `.artifact-desk` and `.artifact-sheet` only, under the artifact surface, reading `--background-200`, `--background`, `--card` and `--border` and nothing else. Every dimension is a custom property the renderer sets from `SHEET_PX` in `lib/artifacts/render-model.ts`, with the A4 portrait defaults declared in the block, so the stylesheet restates no number the row model owns and `describe_artifact`'s row height and the drawn rows agree by construction. No shadow and no radius: paper has neither. In dark the desk takes the page ground so the sheet keeps its value step (row 8). Chrome is the supported print-to-PDF path; Safari honours neither the page size nor its margins. | `lib/artifacts/render-model.test.ts` (`gridGeometry`, the `minRows` pin); `lib/artifacts/artifact-grid.render.test.ts` (the document lane) | 2026-09-15 |
| 19 | Deck slides are drawn on the fixed canvas the deck skill uses and scaled to fit the stage, so every type role on a slide renders below its floor whenever the stage is narrower than the canvas | §12 says never to shrink type below its floor to make density fit, and a scaled slide does exactly that. The canvas is kept at the skill's size so a slide reads on screen at the proportions its print has and one grid holds on screen, on paper and in the skill. Accepted by the product owner on the Phase 2 spec rather than found in review. The alternative, a smaller canvas on the same twelve-by-nine grid, is a two-number change in one row of `SHEET_PX` if this is ever reversed. | Slides only, inside `.artifact-deck`; the scale is one CSS expression on `.artifact-deck__canvas` and nothing else in the product scales type. Paper is unaffected: print resets the scale and each slide is its own page at canvas size. Hover readouts and keyboard stops keep working at any scale because they are markup, not pixels. | The `deck` row of `SHEET_PX` in `lib/artifacts/render-model.ts`; `lib/design-tokens.test.ts` scans `components/artifacts/deck-stage.tsx` | 2026-09-15 |
| 20 | `--info` is darker and less saturated than Geist blue-700 in light, lighter and less saturated in dark | Measured against its own tint rung, Geist's `oklch(57.61% 0.2508 258.23)` read 4.27:1 on light `--info-100` and its dark sibling 3.86:1: below the 4.5:1 floor, the same ink-on-tint failure row 16 closed for error. The floor wins over the palette (§2). | Light `oklch(55% 0.24 258.23)`: 4.63:1 on the tint, in gamut. Dark `oklch(62% 0.2 258.23)`: 4.65:1 on the tint, 5.64:1 on the ground, the first in-gamut step that clears the floor (lightness alone pushed the blue out of sRGB). `--ring` keeps Geist's blue: non-text, 3:1, passing. If Geist restates blue-700 above the floor, this row leaves. | `lib/design-tokens.test.ts` pins both values and the reason | 2026-09-16 |

## §9 Rejected divergences and open decisions

**Rejected.** Do not re-litigate these without new evidence.

- **`.z-eyebrow`'s mono, uppercase and positive tracking.** Reversed mid-project: it had been checked against the token contract and never against the doctrine, where a tracked all-caps eyebrow is the first item on the reject list and mono-on-a-label breaks the mono rule. The class survives as a size and colour role; its cosmetics do not.
- **Positive letter-spacing, anywhere.** Geist's foundation has none.
- **Differentiating chart series by an opacity lap.** Opacity was never the mechanism. Pattern is.
- **Stock photography, autoplaying media, gradient text on a figure, a CDN illustration in an empty state, a decorative blur field, a hex-literal grid background.** All shipped once; all removed.
- **A shader that replaces the static ground.** Rejected on 2026-09-04 and still rejected: every gate needs a fallback, so the static treatment must exist first. What shipped on 2026-09-08 (§8, row 15) is a live layer over that treatment, not instead of it; the earlier rejection was of the order, and the order still holds. It was also of the cost on the route where the user's intent is to get in, which the dynamic import and the idle gate now answer.

**Open.** Each with the shape of its answer.

- **The theme.** `ThemeProvider` in `components/providers.tsx` ships light-only with system following disabled and no switcher, so the dark half of the token system is authored, tested and unreachable in the product. The specs record a decision to follow the OS; the code does not implement it, because a second decision requires dark to get a real verification pass first. Do not cite this document as the reason for either state: **the absence of a switcher agrees with the base document by coincidence, not by derivation**, and whether a SaaS product offers a theme control is a product decision. Filed as BIF-295.
- **The size threshold below which a legend beats a direct label.** Currently judged per surface.
- **The dense-chrome migration.** Most explicitly-sized text in the dashboard sits below the label role, plus a long tail of arbitrary sizes below every floor, some of them SVG attributes no class sweep reaches. This is the largest single item of drift in the product and the thing divergence row 11 reverts on.
- **The radius ladder.** `--radius` is dead (§10). Activating it is the first step of the radius phase and would change the shape of surfaces nobody has seen rounded, so it needs a human's eye rather than a token edit.
- **Measured contrast debts**, two of four remaining (the `--info` ink on its tint, 4.27:1 light and 3.86:1 dark, was paid on 2026-09-16 the same way as error: row 20). Then the two from before: light `--border` against `--background`, and the `--foreground` to `--muted-foreground` hierarchy step in both themes. Named here so a future audit finds them already known rather than newly discovered. The third is **paid**: `--destructive-foreground` against light `--destructive` measured 4.041:1, failing in both directions at once because the foreground is white, and light `--destructive` is now darkened to clear the floor at 4.830:1 (row 16). It is worth recording what closed it, because the debt had been listed here since the doctrine was written and nothing moved: **it took drawing the token at the size it fails at, next to the thing it fails against.** A named debt in a list is not a forcing function; a rendered one is.
- **Elevation.** No shadow tokens are declared, and the stock shadows still in the tree are blocked on them.
- **A spacing band contract**, needed before the section-gap retrofit.

## §10 Traps

Each of these was a real bug in this repository. Each line names the symptom you would otherwise chase.

- **`--radius` is unitless, not a length.** Every rung in the radius ladder adds a pixel length to it, which is invalid arithmetic, so the whole declaration is dropped and the radius computes to nothing. Symptom: a component that names a live radius rung renders square, with no error anywhere.
- **The heading weight token is `--font-weight-title`.** Spelling it with "heading" in the middle makes it silently unreachable, because `--font-heading` already occupies the font-family namespace and the family wins the utility. Symptom: a heading renders at body weight and the token looks correct in the stylesheet.
- **A new control or type utility must be registered in tailwind-merge's `classGroups` in `lib/utils.ts`.** Otherwise it never dedupes against the class a call site passes, both survive into the DOM, and which wins is a source-order accident. Symptom: an override that works in one component and not in another.
- **Tailwind extracts class candidates from every scanned file, comments and string literals included.** A utility named only in a TypeScript comment or a test string is emitted into the bundle. CSS comments are not scanned; `.ts` and `.md` ones are. Symptom: a class you have not used yet already has a rule.
- **A `var()` written inside a `globals.css` comment still counts as a reference.** Symptom: a theme key you deliberately did not declare appears in `:root`.
- **The touch bump is its own max-width query, not Tailwind's `md` breakpoint**, and the two do not coincide. Symptom: a touch tablet in the band between them gets desktop control sizes and iOS zooms the viewport on focus. It is deliberately not gated on `(pointer: coarse)`, which is the first narrowing anyone proposes on reading it: iPadOS with a pointing device attached reports `pointer: fine` and still zooms a focused field whose type sits under the iOS threshold, so that gate would restore the bug the block exists to fix. The four dense rungs are excluded from the bump by name, and a square rung that is bumped taller takes an equal `min-width` or it renders as a stripe.
- **`focus-visible:ring-2` writes the same ring custom property a static `ring-1` writes**, so focus *replaces* the hairline instead of layering over it. `components/ui/button.tsx` shows the fix (two independent box-shadows); `components/ui/input.tsx` and `textarea.tsx` were fixed the same way.
- **Index arithmetic in `artifact-grid.tsx` needs a table of computed appearances, not reasoning about thresholds.** Three separate passes each missed a consumer in that one file, and each miss shipped two series drawn identically.
- **A fixture must render the case that breaks, not the case that works.** `/design` drew a chart with few enough series that the lapping bug was invisible, which is why the fixture passed while the bug was live.
- **A light surface nested inside `.dark` inherits dark, and nothing says so.** Custom properties inherit, and the only thing that stops a `.dark` ancestor's values reaching a descendant is a redeclaration in between. So a surface that means to be light must *say* `light`; omitting the class is not neutral, it is "whatever `<html>` currently is". Symptom: correct-looking output whenever the app sits in its default theme, and silently wrong the moment anything sets dark, including a stored `next-themes` value left behind by one session. `/design`'s side-by-side halves were exactly this: the dark half named its theme and the light half did not, so with the app in dark both columns rendered dark while every caption still claimed one was light. `:root, .light` is one selector list rather than a second copy of the palette, because a copy is the parallel token layer §1 item 2 forbids.
- **Turbopack emits CSS to `.next/static/chunks/*.css`.** Symptom: a diff of the built stylesheet finds nothing because it looked in the wrong directory.
- **Next merges a file-based OG card only into the segment that declares one.** A page that drops its `openGraph.images` while keeping the `openGraph` object rebuilds the card from that object alone and inherits nothing from the root. Symptom: a route ships `twitter:card=summary_large_image` with no image behind it, while every other route's preview is fine. Drop the `openGraph` and `twitter` keys entirely so the root card inherits, or give the segment its own `opengraph-image`.
- **WebKit computes `position: relative` on a `<tr>` as `static`.** A stretched anchor then escapes to the nearest positioned ancestor, which is the table's own container, so every row's link covers the whole table and the last one wins the paint order. Symptom: in Safari only, and silently, every row click opens the last row's link. The row needs `[transform:translate(0)]`, which establishes a containing block whatever `position` computes to.
- **Satori throws on Geist's variable font.** Its bundled parser fails in the `fvar` axis-name lookup, so the OG route feeds it the static `Geist-400.ttf` and `Geist-600.ttf` pair instead. Symptom: the card route throws at render with a font-parsing error that names no font.
- **`new URL("..", import.meta.url).pathname` percent-encodes.** A checkout under a path containing a space, and this repository has siblings under `~/Library/Mobile Documents/`, yields `Mobile%20Documents` and every `readFileSync` beneath it fails. Symptom: a contract test that is green in CI and throws `ENOENT` on one machine. Use `fileURLToPath`.
- **vgpu's Next example type-checks only with a `@webgpu/types` reference the example omits.** Symptom: `GPUTextureFormat` is not a name, in a file that imports nothing wrong. `wgsl-env.d.ts` carries the reference.
- **Installing `vgpu` also installs a native Node WebGPU adapter: prebuilt binaries shipped as ordinary package files, plus a postinstall script.** Symptom: `node_modules` grows by a large margin for a package the browser never loads, and an install runs a script nobody asked for. The never-built list in the root `package.json` stops the script; the files stay, and nothing imports them.

## §11 The bounded API

These are the names a call site may read. Treat the list as closed.

**Ground and surface**: `--background`, `--background-200`, `--foreground`, `--card`, `--card-foreground`, `--popover`, `--popover-foreground`.

**Action**: `--primary`, `--primary-foreground`, `--secondary`, `--secondary-foreground`, `--accent`, `--accent-foreground`.

**Quiet and lines**: `--muted`, `--muted-foreground`, `--border`, `--border-subtle`, `--border-strong`, `--input`, `--ring`, `--gray-alpha-100`, `--gray-alpha-200`, `--gray-alpha-300`, `--gray-alpha-500`.

**State**: `--info`, `--info-100`, `--success`, `--success-100`, `--warning`, `--warning-100`, `--destructive`, `--destructive-foreground`, `--error-100`.

**Charts**: `--chart-1`, `--chart-2`, `--chart-3`, `--chart-4`, `--chart-5` app-wide; `--chart-6`, `--chart-7`, `--chart-8` exist only under `.artifact-canvas`.

**Sidebar**: `--sidebar`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-primary-foreground`, `--sidebar-accent`, `--sidebar-accent-foreground`, `--sidebar-border`, `--sidebar-ring`.

**Illustration**: `--illustration`, `--border-illustration`.

**Metrics**: `--ds-size-small`, `--ds-size-medium`, `--ds-size-large`, `--ds-size-touch`, `--page-width`, `--page-margin`, `--page-column`, `--masthead-rail-start`, `--masthead-rail-end`, `--radius-control`, `--radius-overlay`, `--radius-pill`. `--radius` itself is declared but dead (§10); do not read it.

**Spacing band**: `--space-0-5`, `--space-1`, `--space-1-5`, `--space-2`, `--space-3`, `--space-4`, `--space-6`, `--space-8`, `--space-12`, and the four relationship aliases `--gap-tight`, `--gap-copy`, `--gap-group`, `--gap-section`. Read the alias where a relationship is meant and the rung where a pad is meant. The control padding rungs are not aliases for layout: a table is not a control.

**Layer metrics**: `--rule-width`, `--measure`, `--font-weight-regular`, `--font-weight-medium`, `--focus-width`, `--focus-gap`, `--col-min`, `--status-edge-mix`, `--dash-threshold`, `--dash-forecast`, `--list-indent`, `--marker-size`. `--col-min` is read guarded, as `minmax(min(var(--col-min), 100%), 1fr)`: unguarded, the track exceeds its container and a figure inside a clipped cell is hidden rather than scrolled, which breaks §12 in this token's only published use.

**Primitives**: the `gb-` classes `app/primitives.css` declares, which are the vocabulary an agent writes without ever reading the stylesheet. Shell and layout: `gb-report`, `gb-shell`, `gb-skip-link`, `gb-masthead`, `gb-masthead-meta`, `gb-subject`, `gb-author`, `gb-recipient`, `gb-period`, `gb-date`, `gb-revision`, `gb-confidentiality`, `gb-section`, `gb-section-head`, `gb-section-head-note`, `gb-divider`, `gb-grid`, `gb-span-1` through `gb-span-12`, `gb-stack`, `gb-frame`, `gb-footer`, `gb-keep-together`, `gb-page-break`, `gb-screen-only`. Evidence: `gb-stat-strip`, `gb-stat`, `gb-stat-label`, `gb-stat-value`, `gb-stat-unit`, `gb-stat-detail`, `gb-qualifier`, `gb-delta`, `gb-table-wrap`, `gb-table-total`, `gb-numeric-col`, `gb-overflow-cue`, `gb-sources`, `gb-figure`, `gb-caption`. Chart: `gb-chart`, `gb-chart-header`, `gb-chart-viewport`, `gb-chart-ticks`, `gb-chart-scale-line`, `gb-chart-zero-line`, `gb-chart-gridline`, `gb-chart-threshold`, `gb-chart-crossing`, `gb-chart-direct-label`, `gb-chart-value`, `gb-series-stroke`, `gb-series-fill`, `gb-data-point`, `gb-series-1` through `gb-series-3`, `gb-legend`, `gb-legend-item`, `gb-swatch`. State: `gb-empty`, `gb-empty-next-step`, `gb-partial`, `gb-stale`, `gb-broken`, `gb-note`, `gb-skeleton-line`, `gb-status`, `gb-status-dot`. Attributes: `data-basis` on a figure, `data-polarity` on a variance and **not optional**, `data-tone` (the same five on `gb-note` and on `gb-status`, which were not the same set until one of them silently rendered a running sync as one that never ran), `data-gap`, `data-density`, and `data-reason`, which **carries no style on purpose**: the two empties differ by the sentence and the next step an author writes, not by anything the layer paints, and the attribute is there so the choice is stated in the markup and can be asserted in a test. Nothing is missing; do not add a rule for it.

**Two things the primitives are not.** They are not for `components/ui/*`: a Tailwind utility sits in a later cascade layer and wins at any specificity, so `gb-numeric-col` on a `TableHead` that carries `text-left` renders left in the head and right in the body, which is §6's named failure arriving by a different mechanism. And they own no prose flow: `gb-report` zeroes element margins, so a run of bare semantic HTML has no separation and the author must reach for `gb-stack`. Both are recorded rather than fixed, in the spec's amendment 4, and `/design` now draws both rather than describing them: the `TableHead` case sits beside a correct table and an author-error one, so the three renderings can be told apart.

**Focus is declared twice, and `gb-skip-link` declares it a third time.** `:where(.gb-report, .gb-frame) :focus-visible` covers a document and a component embedded in a surface it does not own. It cannot cover a skip link, which is by definition a document's first focusable child and therefore sits where an author has no scope around them yet — so that primitive carries its own ring, offset, underline and foreground. Drawing the fixture is what found this: the layer shipped with the one primitive whose job is to be the first thing a keyboard reaches revealing itself with no visible indicator at all, which §12 admits no exception to and which nothing but a Tab press could have surfaced.

**Control scale**: `--control-h-sm`, `--control-h-md`, `--control-h-lg`, `--control-h-xl`, `--control-h-touch`, `--control-text`, `--control-text-micro`, `--control-leading`, `--control-pad-sm`, `--control-pad-md`, `--control-pad-lg`, `--control-pad-xl`, `--control-gap`.

**Type**: the ten role utilities in §5, the `--font-sans`, `--font-mono`, `--font-serif` and `--font-heading` families, `--font-weight-title`, and the three tracking steps `--tracking-tighter`, `--tracking-snug`, `--tracking-tight`.

**Components**: everything exported from `components/ui/*`, sized by the rung names the control ladder publishes, plus the shared surfaces in `components/dashboard/*`.

If none of these fits: extend the primitive. Never invent a token name, never read an internal one, never extrapolate a name from a neighbouring primitive, and never reach for an arbitrary Tailwind value for a property the token layer owns.

**One layout class on marketing.** `.page-column` is `--page-column` plus its margins, centred: the masthead's bar and bands, the hero, every home section, `Container` and the footer read it, so the wordmark's left edge, a section's edge and the footer's edge are one line. A marketing section that states its own `max-w-*` is drift.

**This list is published.** `gatlas.ai/gatlas-brand.css` serves exactly the names above as plain CSS: the tokens, the ten type roles both as custom properties and as real classes, `.font-title`, `.z-mono`, `.numeric`, the primitive layer, and the three font stacks `next/font` injects at runtime, which no stylesheet can see. It is generated from `globals.css` and `app/primitives.css` per request by `lib/design-stylesheet.ts`, so it cannot drift; `lib/design-stylesheet.test.ts` asserts every direction that matters, including the one whose absence shipped a broken file once: **the published sheet must declare every name it reads**. Inside `apps/web` the `@import` makes Tailwind emit the type keys onto `:root`, so a primitive reading one resolves and every check against `globals.css` passes while the published artefact carries sixteen names it never declares. For a consumer without Tailwind each of those is invalid at computed-value time, the declaration drops, and the text renders at the inherited size with the title weight still applied. Test the artefact, not the source it came from. Two things it deliberately withholds: the `@theme` mirrors like `--color-foreground`, because a consumer without Tailwind wants the raw token and reading the mirror is the trap in §5, and the dead radius ladder. Use it wherever the product's own components cannot reach: an artifact, a deck, another repository. It is not a second API, it is this one, addressable by URL.

**The one escape hatch**, so there is a legitimate place to go: a page may own local geometry in a scoped block in `globals.css`, under a name that belongs to that page, reading only the tokens above. `.auth-panel`, `.artifact-canvas`, `.artifact-desk`, `.example-conversation` and `.ocean-ground` are the five that exist. Each is scoped, each is commented with the reason that justifies it, and each is in §8. A sixth needs the same.

## §12 Accessibility and responsive floor

Flat and non-negotiable.

- An `id="main"` landmark on every route, including the externally shared surfaces (`/setup`, `/invite`, `/mcp/setup`, `/oauth`, `/embed`): those are the pages a customer sees before they have an account. A skip link wherever navigation precedes the main content.
- One descriptive `h1` per page; headings in order.
- Native controls with visible labels. Visible focus that actually changes pixels, on every focusable element.
- Colour is never the only cue. Every colour-carried meaning has a text or shape partner.
- Contrast meets WCAG AA. The current debts are named in §9 rather than hidden.
- `prefers-reduced-motion` is honoured. **The global reduce block in `globals.css` stops CSS animations and transitions; it does nothing to a JavaScript render loop.** Anything driven by script gates itself. `ExampleConversation` is script-driven and gates on the media query itself. `OceanGround` gates on the media query itself too.
- Overflow scrolls; it never hides. Grid and flex children get `min-width: 0`. Reflow before shrinking, and never shrink type or a control below its floor to make density fit.

## §13 Enforcement

Layer 3 has two halves, and they catch different failures.

**Contract tests** (`design-tokens`, `design-preview`, `design-stylesheet`, `design-primitives`, `button-contract`, `theme-fonts`, and the artifact chart tests) catch **system drift**: a token declared in one theme and not the other, a token drawn but never named here, a role and its rung diverging, a control rung re-derived instead of read. Given that this product's characteristic failure is silent rather than loud, and that an unresolved `var()` and a dropped declaration both fail with no error at all, **these are the more valuable half**. They run in the web test gate, which runs in CI.

**Call-site lints** catch **drift at the edges**: hex literals outside an allowlist, arbitrary Tailwind values for token-owned properties, raw palette classes, bare `<button>` and `<input>` outside `components/ui`, and eventually `rounded-none`. **This half does not exist yet.** There is no `check:design` script and nothing in `.github/workflows/web.yml` runs one. Two rules govern building it:

- **Each lint rule ships with its own purge, never before it**, so CI never fails on violations nobody has had a chance to fix.
- **Every new rule is an error, not a warning.** The repository already carries warnings nobody reads.

Three of these rules exist. `design-controls.test.ts` holds a per-file baseline of bare `<button>`, `<input>`, `<select>` and `<textarea>` outside `components/ui` (38 in 19 files when it switched on, 2026-09-16, after a purge of 21 buttons; 5 in 5 files the next day after the form-control purge, which added `NativeSelect` for the forms that want the platform picker); a file may only go down and a new file may not appear (GAT-187). `design-marketing.test.ts` scans every file the marketing routes render for a raw type rung or a hand-authored weight and fails on any. `design-tables.test.ts` scans the dashboard for a table frame or header ground authored at a call site (a wrapper with a `border` or `rounded-*` around a framed `Table`, a `bg-*` on `TableHeader`) and for `--radius-overlay` on an in-page surface, and fails on any (GAT-179). It shipped as a ratchet with a per-file baseline (217 rungs, 72 weights) and became a zero the same day, when the width sweep converted the last template section; the count it records is the score.

**Record the count when each rule switches on.** The rule's job is to hold that number at zero, not to be tuned down when it complains.

The eval loop is four cheap things rather than a harness:

1. **`/design` is the visual fixture.** Every token in both themes, every primitive at every rung, on one page. Extend it whenever you add either, and make it render the case that breaks (§10). The primitive half of that is no longer a promise: `design-primitives.test.ts` fails if the layer declares a class the fixture does not draw, or if the fixture applies one the layer does not declare. It searches for the literal name, so a class assembled at runtime reads as undrawn — which is the right answer, because composing a primitive's name is how an author writes one that does not exist. The *second* half of the sentence is still yours to keep: nothing can assert that what you drew is the case that breaks, and the last fixture to pass while its bug was live passed by drawing too few series.
2. **Re-count the hand patches at every phase boundary and record it.** The counters are the honest score, and **a class that stops falling means its cause was not actually removed**.
3. **One question per pull request: did this change need a hand patch?** If yes, that is a system gap. Route it through §3.
4. **Re-run the alignment audit against this document**, not against the base one, at each phase boundary. The diff is the score.

**Definition of done for the mechanism layer:** changing the product's radius, type scale, control density or elevation is a diff confined to `globals.css` and `components/ui/*`, with no call site edited by hand.

---

*Canonical source: `apps/web/content/design.md`. Served at `https://gatlas.ai/design.md`. Rendered counterpart: `/design`, operator-gated. The loader skill is `gatlas-design`; the skill for decks, documents and social cards is `gatlas-design-system`, which governs a different set of surfaces entirely.*
