---
# GENERATED from apps/web/app/globals.css by apps/web/scripts/sync-design-exports.mjs.
# Edit the CSS or lib/design-mediums.ts, then run pnpm design:sync in apps/web.
name: "gatlas-design"
description: "The router for Gatlas design: anything Gatlas shows a reader. Use when building or changing a surface in apps/web (dashboard pages, artifacts, shared /s/ pages, marketing and auth pages, the help center, guides, blog and legal pages), a transactional email, the Open Graph or X card a gatlas.ai link unfurls into, or text with no CSS such as a Slack message or MCP tool output; when choosing a token, sizing type or a control, styling a table or chart, writing shipped copy, or reviewing a UI diff. Loads apps/web/content/design.md, apps/web/content/copy.md and the medium's file in apps/web/content/mediums/. For a deck of slides, a board deck included, use gatlas-deck; for a document on A4 (a white paper, spec sheet, one-pager or board report, on screen or as PDF) use gatlas-document; for a social post, carousel or social or ad banner use gatlas-social. Print signage has no medium yet: ask the owner first."
colors:
  accent: { light: "#f2f2f2", dark: "#1a1a1a" }
  accent-foreground: { light: "#171717", dark: "#ededed" }
  background: { light: "#ffffff", dark: "#000000" }
  background-200: { light: "#fafafa", dark: "#0a0a0a" }
  border: { light: "#00000014", dark: "#ffffff24" }
  border-illustration: { light: "#1717170f", dark: "#ededed0f" }
  border-strong: { light: "#00000070", dark: "#ffffff8a" }
  border-subtle: { light: "#0000001a", dark: "#ffffff21" }
  card: { light: "#ffffff", dark: "#0a0a0a" }
  card-foreground: { light: "#171717", dark: "#ededed" }
  chart-1: { light: "#171717", dark: "#ededed" }
  chart-2: { light: "#4d4d4d", dark: "#a0a0a0" }
  chart-3: { light: "#7d7d7d", dark: "#7d7d7d" }
  destructive: { light: "#d60020", dark: "#ff5e63" }
  destructive-foreground: { light: "#ffffff", dark: "#000000" }
  error-100: { light: "#ffeef0", dark: "#330a11" }
  foreground: { light: "#171717", dark: "#ededed" }
  illustration: { light: "#17171720", dark: "#ededed20" }
  info: { light: "#0064e2", dark: "#50a8ff" }
  info-100: { light: "#f0f7ff", dark: "#06193a" }
  ink: { light: "#171717", dark: "#ededed" }
  input: { light: "#00000014", dark: "#ffffff24" }
  muted: { light: "#f2f2f2", dark: "#1a1a1a" }
  muted-foreground: { light: "#4d4d4d", dark: "#a0a0a0" }
  paper: { light: "#ffffff", dark: "#000000" }
  petrol: { light: "#043f4a", dark: "#ededed" }
  petrol-100: { light: "#ebf6f8", dark: "#ffffff17" }
  popover: { light: "#ffffff", dark: "#0a0a0a" }
  popover-foreground: { light: "#171717", dark: "#ededed" }
  primary: { light: "#171717", dark: "#ededed" }
  primary-foreground: { light: "#ffffff", dark: "#000000" }
  ring: { light: "#0070f7", dark: "#50a8ff" }
  secondary: { light: "#f2f2f2", dark: "#1a1a1a" }
  secondary-foreground: { light: "#171717", dark: "#ededed" }
  sidebar: { light: "#fafafa", dark: "#0a0a0a" }
  sidebar-accent: { light: "#f2f2f2", dark: "#1a1a1a" }
  sidebar-accent-foreground: { light: "#171717", dark: "#ededed" }
  sidebar-border: { light: "#00000014", dark: "#ffffff24" }
  sidebar-foreground: { light: "#171717", dark: "#ededed" }
  sidebar-primary: { light: "#171717", dark: "#ededed" }
  sidebar-primary-foreground: { light: "#ffffff", dark: "#000000" }
  sidebar-ring: { light: "#0070f7", dark: "#50a8ff" }
  success: { light: "#107d32", dark: "#00ca52" }
  success-100: { light: "#ecfdec", dark: "#00250a" }
  warning: { light: "#a64f00", dark: "#ff9900" }
  warning-100: { light: "#fff6e1", dark: "#291800" }
series:
  chart-1: { light: "#171717", dark: "#ededed" }
  chart-2: { light: "#0070f7", dark: "#0071f6" }
  chart-3: { light: "#f22782", dark: "#f12b82" }
  chart-4: { light: "#9f00f4", dark: "#9440d5" }
  chart-5: { light: "#ffb200", dark: "#ffb200" }
  chart-6: { light: "#28a948", dark: "#00ab3e" }
  chart-7: { light: "#8f8f8f", dark: "#8f8f8f" }
  chart-8: { light: "#fc0035", dark: "#f13242" }
typography:
  display: { family: "Geist", size: "48px", weight: 450, weightStatic: 500, lineHeight: "56px", tracking: "-0.06em" }
  page-title: { family: "Geist", size: "40px", weight: 450, weightStatic: 500, lineHeight: "48px", tracking: "-0.06em" }
  title: { family: "Geist", size: "32px", weight: 450, weightStatic: 500, lineHeight: "40px", tracking: "-0.06em" }
  section: { family: "Geist", size: "24px", weight: 450, weightStatic: 500, lineHeight: "32px", tracking: "-0.04em" }
  subsection: { family: "Geist", size: "20px", weight: 450, weightStatic: 500, lineHeight: "26px", tracking: "0em" }
  lede: { family: "Geist", size: "18px", weight: 400, weightStatic: 400, lineHeight: "28px", tracking: "0em" }
  body: { family: "Geist", size: "16px", weight: 400, weightStatic: 400, lineHeight: "24px", tracking: "0em" }
  compact: { family: "Geist", size: "14px", weight: 400, weightStatic: 400, lineHeight: "20px", tracking: "0em" }
  label: { family: "Geist", size: "13px", weight: 400, weightStatic: 400, lineHeight: "16px", tracking: "0em" }
  metadata: { family: "Geist", size: "13px", weight: 400, weightStatic: 400, lineHeight: "16px", tracking: "0em" }
spacing:
  gap-copy: "16px"
  gap-group: "24px"
  gap-section: "32px"
  gap-tight: "4px"
  space-0-5: "2px"
  space-1: "4px"
  space-1-5: "6px"
  space-12: "48px"
  space-2: "8px"
  space-3: "12px"
  space-4: "16px"
  space-6: "24px"
  space-8: "32px"
rounded:
  radius-control: "6px"
  radius-overlay: "12px"
  radius-pill: "9999px"
---

# 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 copy doctrine in [`content/copy.md`](https://www.gatlas.ai/copy.md), one file per medium in `content/mediums/`, and one page per component in `content/design/<slug>.mdx`, rendered at the gated `/design/<slug>`. This file holds the rules CSS cannot hold, and the reasons behind the ones it can; the pages hold each component's examples, API, states and best practices.
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. Every value is code-owned. A number in this prose is a decision record, never a value to copy: a ratification (the display ceiling in §5, a row in §8), a measurement (a contrast ratio, a call-site count) or a score (a ratchet's baseline in §13). When the code and a number here disagree, the code is right and this document is stale.

**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-section`, never `text-2xl`. The rung is Tailwind's namespace; the role is ours, and a role can change size without a rename.

This document also takes the marks of [`copy.md`](https://www.gatlas.ai/copy.md) §7, the em dash ban this one inherits in §1 among them. If you find an em dash or a middle dot here, it is a bug.

## §1 Inherited doctrine

The base document is Vercel's brand guidelines (the `vercel-brand-guidelines` skill, served at `https://vercel.com/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. `https://www.gatlas.ai/gatlas-brand.css` now serves them, **generated from `globals.css` per request**, cached at the edge for an hour (a stale copy may be served for up to a day while it revalidates), 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 [`mediums/dashboard.md`](https://www.gatlas.ai/mediums/dashboard.md) §5, Boundaries, 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 [`mediums/dashboard.md`](https://www.gatlas.ai/mediums/dashboard.md) §5, Density.
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 behavior, 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 some 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 ([`mediums/dashboard.md`](https://www.gatlas.ai/mediums/dashboard.md) §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, the five states of [`mediums/dashboard.md`](https://www.gatlas.ai/mediums/dashboard.md) §6, and 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, color 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 on marketing and on the reading routes (the target state: the marketing, legal and blog routes that still set `display` move to `page-title` in the marketing phase, GAT-450); `title` for a major section turn, and for a dashboard route's title, one step down because the dashboard runs dense (§8 row 25); `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, bar the home hero's claim, its `h1`, which §8 row 26 ratifies, and the link-preview card's claim (row 13). 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 the marketing pill, on the terms §8 row 5 sets. 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.

**The values are Geist's.** Every hue is a step of Geist's scales, imported unchanged from `https://vercel.com/geist/colors` into `app/geist-colors.css` by `scripts/sync-geist-colors.mjs`, in Vercel's three layers: sRGB hex, `lab()` where supported, P3 `oklch()` on wide-gamut screens. Never edit that file; rerun the script. The tokens that are step references point at the step Geist gives their job: `--muted-foreground`, every status ink (`--info`, `--success`, `--warning`, `--destructive`) and tint (`--info-100`, `--success-100`, `--warning-100`, `--error-100`), `--ring` and `--sidebar-ring`, and the chart ladder after its first rung. 900 and 1000 are for text and icons, so every status ink is a 900 step; 700 and 800 for solid fills; 100 to 300 for backgrounds, so every status tint is a 100 step. The other neutral grounds, surfaces, actions and lines are not step references either: `--secondary`, `--accent`, `--muted`, `--border`, `--input`, the rest of the sidebar set, the tokens beside them in the theme blocks, and the `.auth-panel` grounds are literal `oklch()` values with zero chroma, so a change to Geist's grays does not reach them. Apart from petrol, every literal value in `globals.css` is achromatic wherever it sits, in a token, a gradient or a mask, and `lib/design-color.test.ts` holds that line by reading every literal in the file. Call sites read the meaning tokens, never a step, and the steps are not Tailwind classes. Petrol is the one hue outside Geist.

**Ink and paper come from one place per theme.** `--ink` holds the ink and `--paper` the paper. In light, `--ink` is `#171717`, the ink the logo in `brand/marks` is drawn in: the product owner ruled on October 2, 2026 that the logo's ink wins, so it is a literal and a Geist resync never moves it. `--paper` is white. The dark theme declares its own pair, `#ededed` on black, inside `@media not print`, so paper always takes the light pair. Every ink token reads `var(--ink)` in every theme: `--foreground`, `--primary`, `--card-foreground`, `--popover-foreground`, `--secondary-foreground`, `--accent-foreground`, `--sidebar-foreground`, `--sidebar-primary`, `--sidebar-accent-foreground`, and `--chart-1` both app-wide and as slot one of the artifact palette. Every paper token reads `var(--paper)`: `--background`, `--card` and `--popover` in light, and the foregrounds set on an ink or status fill, `--primary-foreground`, `--sidebar-primary-foreground` and `--destructive-foreground`. The generated marks and `brand/tokens.json` take their ink and paper from the same pair through `lib/design-model.ts`, and `lib/design-model.test.ts` fails when a token, a mark or the export drifts from it, or when a theme block types an ink or paper value anywhere but `--ink` and `--paper`. Call sites read the meaning tokens, never `--ink` or `--paper`.

**Where color is allowed.** Gatlas is ink on paper. This list is closed: color appears in the six places in the table and nowhere else, and each place has one job. If what you are about to color is not in the table, it is ink.

| Color | Tokens | Allowed for | Never |
|---|---|---|---|
| Ink and paper | `--foreground`, `--background`, `--background-200`, `--muted-foreground`, the alpha scale | everything not listed below: the logo, every button and control, links, headings, text and numbers, borders, surfaces, illustrations, and a chart with one series | |
| Accent, petrol | `--petrol`, `--petrol-100` | a small glyph or dot at the accent sites listed below, on a light ground | the logo, a button or any control, a link, text or a number, a heading, a border or rule, a surface or section ground, a gradient, a shadow, an illustration, a chart, a status, a dark ground |
| Status | `--info`, `--success`, `--warning`, `--destructive`, with `--destructive-foreground` and the tint rungs `--info-100`, `--success-100`, `--warning-100`, `--error-100` | state, always beside a word or a glyph | decoration, a favorable figure, a recommended row |
| Focus | `--ring` | keyboard focus | anything else |
| Series | `--chart-1` to `--chart-3` app-wide, a monochrome ramp of three inks; under `.artifact-canvas` and `.artifact-palette`, `--chart-1` to `--chart-8` are ink, then Geist steps (§8, row 4) | telling two or more series apart | a chart with one series (Charts, below) |
| Third-party marks | none: the vendor's own file under `public/connectors/`, drawn as published | a connector's logo in its vendor's colors, inside a tile: a link-preview card's tile, for a connector that card names (on a blog recap card, one its post names in `card.connectors`) (§8 row 13), and the `SourceIcon` tile the product already draws on a white ground | a Gatlas mark, text, a surface, a border, a recolored or tinted logo, a logo for a connector the surface does not name, on a link-preview card a logo too small to read at the size the card draws it (`LOGOS_ILLEGIBLE_ON_A_CARD`), a logo outside its tile |

**The accent sites.** Petrol appears at exactly these. `lib/design-color.test.ts` fails on any other file under `app/`, `components/`, `emails/` and `lib/` that names petrol, bar the three that name it as content and draw nothing (`NAMES_PETROL`: the `/brand` page, the `/design` swatch registry, and `lib/design-exports.ts`, which holds the list below). It reads petrol in every form a file would carry it: a class or `var()`, the custom property, the token read by name (`tokenHex("petrol")` or any quoted token name), and petrol's own hexes in any case and its `oklch()`, read from `brand/tokens.json`. `tokenHex` itself refuses petrol, so no renderer without CSS can draw it:

<!-- generated:accent-sites from ACCENT_SITES in apps/web/lib/design-exports.ts; edit it there, then run pnpm design:sync in apps/web -->
- The MCP note's dot, `McpNote`, in `components/dashboard/page-header.tsx`: `bg-petrol`.
- The check glyph beside each benefit on `/contact`, in `app/(marketing)/contact/page.tsx`: `text-petrol`.
- The included-feature check in the `/pricing` comparator, in `app/(marketing)/pricing/sections/comparator.tsx`: `text-petrol` on `bg-petrol-100`.
- The `/design/color` page's accent example, in `components/design-examples/color/accent.tsx`: `bg-petrol`, `text-petrol` and `bg-petrol-100`, the three sites above.
<!-- /generated:accent-sites -->

**An accent is a mark, never a color for a thing.** It takes one of these forms and no other: a glyph's color (`text-petrol`, `fill-petrol` or `stroke-petrol` on an icon or a check), a dot (`bg-petrol` on an element no larger than a glyph), or `bg-petrol-100` directly behind that glyph. It never carries meaning its neighboring text does not already state, so it is `aria-hidden` or sits beside its label. Petrol is not `--accent`: that token, despite its name, is the gray hover fill (§11, Action), and it never carries a hue.

**No hue on a dark ground.** In the dark theme `--petrol` resolves to the foreground and `--petrol-100` to an alpha of it, so every accent site turns ink by itself. Deep petrol does not read on black, and any petrol light enough to read there is turquoise, which is not the color chosen. Never author a dark-theme petrol, a `dark:` variant that adds a hue, or a lighter petrol.

**No accent on paper or in a feed.** Petrol never appears in a deck, a document (on paper or on the web) or a social image or link preview, and no accent site may be added in one: those mediums are monochrome, as the explainer deck already is (decision 9 of the mediums spec, 2026-10-01). `lib/design-color.test.ts` holds both halves: no site in the list above belongs to one of them, and no file under `docs/collateral/` but a PDF names petrol, read by the same detector as the code.

**Before you add color, answer in order.**

1. Is it a state: error, warning, success, info? A status token, beside a word or a glyph.
2. Is it keyboard focus? `--ring`.
3. Does it tell two or more chart series apart? The series slots.
4. Is it one of the accent sites above? Petrol, in one of the accent forms.
5. Is it another company's logo, for a connector the surface names, inside its tile? The vendor's colors, as the vendor publishes them.
6. Anything else is ink. There is no seventh answer.

A new accent site, a new chromatic token or any other new place for a hue is one pull request that changes this section, §8 row 22 and `ACCENT_SITES` in `lib/design-exports.ts` together (the list above is generated from it), and the product owner approves it. Third-party marks entered the table that way on 2026-10-05, adapted to what they are: a vendor's logo is that vendor's content, not a Gatlas hue, so it reads no token and adds no accent site, and its ratification is the card's own row, §8 row 13, rather than row 22. The product owner approved it with the D1 card design (GAT-515). `SourceIcon` had drawn the same marks in the product on a white tile since the rebrand; the row names that use rather than inventing it. Two surfaces sit outside this table because they cannot read tokens at all, each ratified by name: email templates (§8 row 23) and the two syntax-highlighting themes (§8 row 24). Nothing else carries chroma: never author a raw Tailwind hue class (`text-emerald-600`, `shadow-indigo-900/15`), a hex, an `oklch()` outside `globals.css`, or a tinted shadow or gradient; the test holds raw hue classes at zero. **The accent and the series palette are kept apart**: no chart slot reads `--petrol`, and the series teal is not the accent. The teal the product carried until 2026-09-24 was the palette's first slot promoted to a "brand" for one dot, which is how a product ends up with a color nobody chose. Do not color a figure because it is favorable, a row because it is recommended, or a bar because it is longest. Every use of color to carry meaning pairs with a non-color cue.

**Ground and surfaces.** `--background` is the page; `--background-200` is the second ground a step off it (table header grounds, card footers, inset wells, empty-state grounds). In light it is a recess, `#fafafa` below the white page. In dark it is a lift, `#0a0a0a` above the black page, because nothing renders below black: a value under the page rounds to the page's own `#000000` and draws no step, which is what the dark value did until GAT-514. `--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, and a field's edge on hover (§8, row 30); `--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-3`: three inks, ink end first, that cycle at three. Series past the third lap it and lean on stroke weight and dash. The artifact surface, and `/usage` through `.artifact-palette`, are the documented exception (§8, row 4). **One series draws in ink, everywhere**, the artifact surface included: color exists to tell series apart, and one series has nothing to tell apart. On the artifact surface slot one is ink, so this needs no special case, and the first series keeps its color when a second arrives.

**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`.

**Punctuation.** The marks are [`copy.md`](https://www.gatlas.ai/copy.md) §7's, in shipped text, in agent-facing text and in this file. What is this file's is the layout a metadata row takes without a middle dot, which is Gatlas's own ban: a middle dot between two facts declines to say how they relate, and it reads as a bullet once the row wraps. So separate a metadata row's spans with space (`gap`) and let the type roles tell them apart; where two facts must share one string, a comma. The middle dot is held by a ratchet (§13).

## §6 Product surfaces

Moved to [`content/mediums/dashboard.md`](https://www.gatlas.ai/mediums/dashboard.md) on 2026-10-01, with the share audience beside it. Its parts keep their names there, so a citation of "§6, Stale" still lands: density, boundaries, the artifact card's side inset, the shell contract, and sheets and dialogs are in its §5; the five states and tables in its §6; focus and keyboard traversal in its §11.

## §7 Marketing surfaces

Moved to [`content/mediums/marketing.md`](https://www.gatlas.ai/mediums/marketing.md) on 2026-10-01. What marketing changes and the three things it changes are in its §1; the hero, the type roles and their two exceptions (one ratified), and third-party marks in its §5; decoration and figures in its §6.

## §8 Ratified divergences

A divergence from the base document enters this table **in the same pull request that creates it**, or it is drift. Rows 23, 24, 25, 28 and 29 began as 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. The artifact masthead serif was row 1 until 2026-09-29, when it was retired and artifact titles took the dashboard's route title (row 25). The Medium column names the medium a row governs by its id in `lib/design-mediums.ts`, or `all` when the row holds in every medium.

| # | Medium | Divergence | Argument | Condition that keeps it honest | Pinned by | Date |
|---|---|---|---|---|---|---|
| 2 | all | 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/format.ts` | 2026-09-03 |
| 3 | dashboard | Pulsing in-progress dot | Directly against the base document's ban on pulsing status indicators. Motion carries in-progress state **so that** color 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 | dashboard, share, document, deck | Artifact charts use a categorical palette of Geist steps as the primary series signal | Not a divergence from the color rule, which permits color "when it is needed to distinguish series". It is a divergence from Vercel's own implementation of that rule, which cycles grays. The rest of the product stays monochrome; artifacts are the customer-facing surface where series identity is the question. The slots are ink, then Geist's steps as published, chosen by the product owner on 2026-09-24: `--ink`, then `--blue-700`, `--pink-700`, `--purple-700`, `--amber-700`, `--green-700`, `--gray-700` and `--red-700`, the 700 steps being Geist's step for solid fills. Ink first means a one-series chart draws in ink and its series keeps that color when a second arrives. | Scoped to `.artifact-canvas` so nothing else changes color, plus `.artifact-palette`, which carries the same declaration and nothing else, on the owner-only `/usage` page (GAT-224), whose charts distinguish tool categories. A use outside those two is a new row. Each slot is a reference, never a copied value, so dark mode and P3 come from the theme's ink and from Geist. **Measured limits, accepted with relief:** in light, amber-700 is 1.7:1 and green-700 2.9:1 on the chart surface, under the 3:1 floor for a graphic; green and red (slots six and eight) collapse for deuteranopes, so they are never adjacent and only meet in a chart of eight series. The relief is that color is never the only cue: every series carries stroke weight and dash for line and area, a hatch pattern past the eighth slot, a legend whose swatch is drawn from the same fill or stroke as its mark, and a labeled readout; on `/usage` a text label beside every bar. No slot is the accent. | `lib/artifacts/render-model/chart.test.ts` (`chartRung`, `chartLap`, `chartHatchId`, `chartHatchPairs`); `lib/usage-analytics.test.ts` (the category-to-slot map); `lib/design-color.test.ts` (the eight references, ink first, no slot reads `--petrol`) | 2026-09-04, widened to `/usage` 2026-09-23, Geist 700 steps 2026-09-24 |
| 5 | marketing | Pill radius on marketing in-page CTAs **and the masthead's two actions** | Marketing persuades a stranger; the product does not. The header exclusion was the original form of that line and it drew the boundary at the wrong place: the masthead is marketing chrome, read by the same stranger, and its log-in and sign-up are the site's two standing CTAs. Widened on the product owner's decision, 2026-09-22. | Opt-in per call site, never a `Button` default, never in the product, and never on a control that does something other than send a stranger further into the site. In-page CTAs keep the largest control rung; the masthead's pair takes the rung the bar sets (36px in the bar, 40px in the phone sheet), since the pill class only ever overrides radius and horizontal pad. | `BUTTON_PILL` in `components/ui/button-sizes.ts`; `lib/button-contract.test.ts` | 2026-09-03, widened 2026-09-22 |
| 6 | dashboard | 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 | all | `--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 | all | Dark `--card`, `--popover` and `--sidebar` sit on the second ground, `--background-200`, 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. The rung renders: `#0a0a0a`, the dark background-100 of Geist's HSL layer (`0 0% 4%`), one step above the `#000000` page. | Light and dark declare the same set of names, so neither theme can grow a token the other lacks. | `lib/design-tokens.test.ts` (the declared value); `lib/design-model.test.ts` (the rendered `#0a0a0a`) | 2026-09-04, rendered 2026-10-03 (GAT-514) |
| 9 | all | Status tints derived as an alpha of the edge color, 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 | all | `--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 | dashboard | `--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 | marketing, reading | 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`, the hero's claim block, and the guide series cover on `/docs/guides` only. Static, read as a luminance mask so it never lands on type. On the hero it runs edge to edge under an opaque claim panel that the live layer in row 15 shares. On the auth panel it now runs the full height of the column, because that panel carries no content at all: the evidence block and the per-page quotation came off on 2026-09-22. They were the reason for a fade that cleared the bottom 560px, and they were also the composition [`mediums/marketing.md`](https://www.gatlas.ai/mediums/marketing.md) §6 rules out: row counts on a dithered, moving ground, where "a decorated number reads as a rendered number". The words moved to the surfaces that persuade; the panel is a decorative ground beside a form column that carries every one of them. On the guide cover it is static only (no live layer), clears under the series label and title, and fills in under a schematic that carries no figures and sits on its own card. | The `.auth-panel` and `.ocean-ground` blocks in `globals.css`; `lib/design-tokens.test.ts` | 2026-09-04, widened 2026-09-08, cleared and widened again 2026-09-22 |
| 13 | link-preview | The OG and Twitter card renders outside the CSS token layer, on resolved hexes and raw font binaries, a changelog entry's card draws a panel of tiles that carries vendor logos in their own colors, the root card draws the explainer deck's plate 1.1, and a blog post's card draws another of the deck's plates or, for a release recap, the changelog's tiles | Satori reads neither CSS custom properties nor `next/font`, so the social card is the one surface that cannot be routed through `globals.css`. It reads each color by token name from `brand/tokens.json`, the export generated from `globals.css`, so 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. A changelog entry's card (the D1 design, GAT-515) shows what the entry is about: the wordmark top left, the section, date and headline bottom left, and on the right, from x 712, tiles in `background-200` that bleed off the card's edges, chosen by `cardVisual` in `lib/changelog.ts` from the entry's title or its `card` frontmatter. A tile carries only what [`mediums/social.md`](https://www.gatlas.ai/mediums/social.md) §5, Imagery, allows: the logos of the connectors the entry names, the Gatlas mark, MCP tool names in Geist Mono, or a schematic with no figures and no names (a chart, the company tree, a table, a message, a help page, a setup link, a read-only connection, an ownership handover). Connector logos keep their vendor's colors because a recolored logo is no longer the vendor's mark, and `SourceIcon` already draws them that way in the product; the product owner chose it over an all-ink variant on 2026-10-05. The root card is the same system without tiles: the wordmark top left, the claim bottom left, and on the right the explainer deck's plate 1.1, the layer on top (company tools, one Gatlas layer, the people and agents who read it), bleeding off the right edge, its lid in the illustration tone and its crease in ink. The deck's own code draws it, so the card and the deck that carries the same title show one drawing; the product owner chose it on 2026-10-05 over figures of vendor logos, which say what Gatlas connects to rather than what it is. A blog post's card (GAT-491) takes the same plates for the blog's long form: the changelog card's layout under the section Blog, and one plate the post names, so each post shows the drawing of its own argument; the product owner chose it on 2026-10-05 over the changelog's schematics, which made a post read as a changelog entry. A post that recaps releases is the exception (GAT-531): a recap is a summary of changelog entries, so its card may carry the changelog's own tiles instead of a plate, as the recap collage, which the product owner chose on 2026-10-05 over a plate and over a wall of the quarter's logos. | `app/opengraph-image.tsx`, a changelog entry's card, `app/(marketing)/changelog/[slug]/og.png/` (GAT-487, GAT-515), a blog post's card, `app/(marketing)/blog/[slug]/og.png/route.tsx` (GAT-491), which draws through the changelog card's `cardElement`, and the two routes that draw the mark for a browser, `app/apple-icon.tsx` and `app/favicon.ico/route.tsx`. No hex is typed in them: every color is `tokenHex()` of a named token, and the tile's corner and gaps are `lengthPx()` of `radius-overlay` and the spacing band. The cards' type is the export's too: `typeRole()` gives each headline the `display` role's static weight and tracking, and the fonts are the static `Geist-400.ttf`, `Geist-500.ttf` and `GeistMono-400.ttf` in `app/og-assets/`, the `weightStatic` of `label` and `display` and the mono cut for tool names, which `design:sync` copies from `brand/fonts/`, the static cuts of the pinned `geist` package; nothing imports the package at runtime. Vendor colors only inside a tile, only for a connector the card names (on a blog recap card, a connector the post names in its `card.connectors`, one to three logo tiles, which `postFrontmatter` in `lib/blog.ts` checks against the catalog and `LOGOS_ILLEGIBLE_ON_A_CARD`), only from the vendor's own file under `public/connectors/`, and only when the logo reads at the card's size: `LOGOS_ILLEGIBLE_ON_A_CARD` in `lib/connectors.ts` names the ones that do not, as measured, and the card draws their names. The root card's figure is `app/og-assets/root-figure.svg`, and a blog post's is `app/og-assets/plate-<n>.svg`, the plate its frontmatter's `card.figure` names from `PLATES` in `lib/card-figures.ts`, never 1.1; all are written by `docs/collateral/explainer-deck-2026-09/card-figures.py` with `build-deck.py`'s own functions and never drawn by hand, and their colors are token-name placeholders that `paintFigure` in `lib/card-figures.ts` resolves with `tokenHex()`. Plate 1.1 is the one figure the root card carries; neither the root card nor a blog post's plate card carries a vendor logo; a blog recap card carries the logos its `card.connectors` names. No texture and no positive tracking. The claim sets at the 64px [`mediums/social.md`](https://www.gatlas.ai/mediums/social.md) §5 allows, or at 80px when `claimSize` in `lib/root-card.ts` finds it sets in two lines or fewer at 80px in its column; nothing else on a card exceeds the display ceiling. | `lib/design-literals.test.ts` (no color outside the export, no positive `letterSpacing`); `lib/card-figures.test.ts` (every placeholder a token, no external reference, every figure painted, regenerating reproduces the files, plate 1.1 never a plate, the claim's size); `lib/blog-card.test.ts` (the plate enum rejects 1.1 and an unknown plate, a recap with a plate, an unknown connector or tool and an illegible logo rejected, each post's plate, a recap's collage, the default card, the metadata, the root card on a failure); `lib/card-headline.test.ts` (word positions within 1px of Chromium's, the line breaks unchanged); `lib/changelog.test.ts` (every entry resolves a visual, a pinned sample of the mapping, every New connector entry a logo file, an unknown connector or tool fails the build); `lib/connector-logos.test.ts` (every logo measured at the card's size); `app/og-assets/README.md` | 2026-09-05, read from the export 2026-10-01, type from the export 2026-10-02, the D1 tiles and vendor colors 2026-10-05, the root card's deck figure 2026-10-05 (owner approved), a blog post's deck plate 2026-10-05 (owner approved, GAT-491), the blog recap collage and its vendor colors 2026-10-05 (owner approved, GAT-531) |
| 14 | marketing | Motion on the example conversation's published artifact, and a scoped block for the conversation | [`mediums/marketing.md`](https://www.gatlas.ai/mediums/marketing.md) §6 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 | marketing | 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 [`mediums/marketing.md`](https://www.gatlas.ai/mediums/marketing.md) §6 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 color 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 |
| 17 | all | `gb-note` and `gb-status` carry a shape partner the base document does not specify | §12 says every color-carried meaning has a text or shape partner, and both primitives shipped carrying tone in color alone: a note's tone was one border color, 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 | dashboard, share, document, deck | A desk-and-sheet surface for document artifacts, as a 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 [`mediums/dashboard.md`](https://www.gatlas.ai/mediums/dashboard.md) §5, Boundaries: it is the boundary between one printed page and the next. | `.artifact-desk`, `.artifact-sheet` and `.artifact-frame` only, under the artifact surface, reading `--background-200`, `--background`, `--card`, `--border`, `--space-12`, `--space-6`, `--gray-alpha-100` and `--space-4` and nothing else. Every dimension is a custom property the renderer sets from `SHEET_PX` in `lib/artifacts/render-model/layout.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 lifts to `--gray-alpha-100` and the sheet takes the page ground, so the paper keeps an edge (row 8 makes `--card` the lifted second ground there); the deck stage takes the same pair. Chrome is the supported print-to-PDF path; Safari honors neither the page size nor its margins. Below 640px the sheets reflow to a stack and each card keeps its paper height; print keeps exact A4, on the product owner's decision of 2026-09-30. The `.artifact-frame` block puts every format's masthead on its surface's edges: it caps a dashboard's whole frame at the surface's width, a document's masthead at the sheet plus the desk's inset (`--frame-w`, set by the renderer from `SHEET_PX`, with the A4 portrait default declared in the block like the desk's), and a deck's masthead at the stage's width, `--deck-stage-w`, declared once for the stage and the masthead. | `lib/artifacts/surface-css.test.ts` (the inset, the phone reflow, the dark desk and the frame); `lib/artifacts/render-model/layout.test.ts` (`gridGeometry`, the `minRows` pin); `lib/artifacts/artifact-grid.render.test.ts` (the document lane, `frameStyle`) | 2026-09-15, widened 2026-09-30 |
| 19 | deck | 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/layout.ts`; `lib/design-tokens.test.ts` scans `components/artifacts/deck-stage.tsx` | 2026-09-15 |
| 21 | reading | A guide's figures break out of the reading measure its prose holds, as a scoped block in `globals.css` | §1 item 6 keeps the measure off lookup surfaces, and a guide interleaves both in one MDX column: the raw-to-view tables are a lookup surface narrowed to 68ch they would scroll. The alternative, a width class on every figure, makes the measure an author's job on every post. | `.guide-body` only, one child selector reading `--measure` and nothing else; prose children take the measure and a `figure` takes the column. | The `.guide-body` block in `globals.css` | 2026-09-22 |
| 22 | dashboard, marketing | Petrol as the one accent: a small glyph or dot at the sites §5 lists, on a light ground | The base document permits chroma for status, focus and series only. A product that is ink everywhere still needs a way to make a mark findable: the MCP note on the pages built over MCP, an included feature. Petrol was chosen on 2026-09-24 in place of the teal the product had carried, which was the artifact palette's first slot promoted for one dot. It is deep enough to read as ink at a glance and sits apart from every status hue, so it never reads as a state. It is an accent, not a brand color: the logo and every control stay ink, on the product owner's decision the same day. | The sites and forms in §5 and nowhere else. Never the logo, a button or control, text, a border, a surface, a chart or a status. Light ground only: the dark theme resolves it to ink, because deep petrol measures 1.8:1 on black and a lifted petrol turns turquoise; print takes the light value. A new site follows §5's recipe for one. | `lib/design-color.test.ts` (the sites, the forms, ink on dark, no chart slot, the logo and button stay ink); `--petrol`, `--petrol-100` in `globals.css` | 2026-09-23, petrol as the accent 2026-09-24 |
| 23 | email | Email templates author every size as a literal, and every color as a resolved hex | An email client resolves no CSS custom property, so a template that read the token layer would render unstyled in most inboxes. The same argument row 13 makes for the social card, which had been ratified while the email lane, a bigger surface, had not. | `emails/` only. Colors come from `EMAIL_COLORS` in `emails/_layout.tsx`, which reads each by token name from `brand/tokens.json`, so no template types a hex and the palette is the product's. Exempted by name in `design-callsites.test.ts` from the arbitrary size and radius rules only, so the exemption is enumerable rather than implicit; every other call-site rule, the copy lint and the petrol test read the emails. The literals are held to the export: no hex, no named color, every `tokenHex()` a light-mode meaning token, every size and line height a type role's, every weight regular or medium, every template wrapped in `_layout.tsx`. | `CANNOT_READ_TOKENS` in `lib/design-callsites.test.ts`; `lib/design-literals.test.ts` | 2026-09-24, colors from the export 2026-10-01 |
| 24 | dashboard, reading | The two syntax-highlighting themes carry a real palette | A syntax theme IS a palette: CodeMirror and the docs code block take color objects, and highlighting in one hue highlights nothing. This is the same permission §5 gives chart series, applied to a surface nobody had written down. | `components/sql-workspace/sql-editor.tsx` and `components/docs/code-block.tsx` only, with the `.docs-code` block in `globals.css`, which flips the docs block's highlighting to its dark theme under `.dark` by reading the variables the highlighter writes and nothing else. Neither paints product chrome; both sit inside a surface that reads tokens normally. | `CANNOT_READ_TOKENS` in `lib/design-callsites.test.ts` | 2026-09-24, the `.docs-code` block entered 2026-10-02 |
| 25 | dashboard, share | Dashboard route titles take the title role, 32px, one step below page-title | The dashboard runs dense (row 6), and at the page-title size a route's title outweighed the table and controls it heads. The product had drawn these titles at 20px since the Geist foundation, with no row to say so; §5 then moved them to page-title at 40px. The product owner chose the step between on 2026-09-30, after seeing all three on a real page. | Every route title under `app/(dashboard)`, plus the artifact and component mastheads, through `PageTitle` only. The shared artifact page and its failure pages draw `PageTitle` too, so a shared artifact reads at the size its author saw. Marketing and reading route titles take page-title (§5). | The `PageTitle` pin in `lib/design-tokens.test.ts` | 2026-09-30 |
| 26 | marketing | The home hero's claim, its `h1`, sets at 64px, above the display ceiling | The ceiling exists so nothing in the product outshouts its data, and the hero's claim carries none: the marketing section that is now [`mediums/marketing.md`](https://www.gatlas.ai/mediums/marketing.md) §5 named it as the one place a display scale above the ladder could enter this table, the day it was first used. The product owner decided on 2026-10-01 that it enters (decision 7 of the mediums spec). The size is 64px because Geist publishes a 64px heading rung. When this row was written the social card set its claim at 64px too; since 2026-10-05 a card claim that sets in two lines at 80px may take 80px ([`mediums/social.md`](https://www.gatlas.ai/mediums/social.md) §5, row 13), a rule for a feed image read at half size that leaves the hero at 64px. | The `h1` of `components/hero-section-7.tsx` alone, at the breakpoint where it takes `display` today; never the `MCP_CLAIM` line above it, which sets at `metadata`. Every other route title on marketing takes `page-title`, the target state the marketing phase (GAT-450) reaches. The tracking ramp's tightest step applies, as at every size at or above 30px. Ratified ahead of its use: the marketing phase (GAT-450) declares the token and applies it, and until then the hero renders at the ceiling. | Nothing yet: the marketing phase (GAT-450) pins it in `lib/design-marketing.test.ts` | 2026-10-01, the card's size separated from the hero's 2026-10-05 |
| 27 | deck | Deck titles, eyebrows, item heads, legend items, captions and figure labels are short noun phrases, not statements | The base document and [`content/copy.md`](https://www.gatlas.ai/copy.md) ask for a title that states its claim. The product owner ruled otherwise for decks on 2026-10-01, after reviewing the explainer and vision decks; [`mediums/deck.md`](https://www.gatlas.ai/mediums/deck.md) §7 holds the rule and its examples. | Deck sheets only, authored and artifact. A page, a document or a post states its claim. | Review: the collateral block of `docs/pr-review-bar.md` (2.9) | 2026-10-01 |
| 28 | dashboard, share | An artifact tab panel draws its own focus ring, as a scoped rule in `globals.css` | §12 asks for visible focus on every focusable element. Radix makes each tab panel a tab stop, and the generated `TabsContent` removes its outline, so the panel took focus and changed no pixels. The ring cannot go into the generated component without an edit `shadcn add` would overwrite (§3), so it hangs on the renderer's own hook. The product owner ratified it on 2026-10-02. | `[data-artifact-tabpanel]:focus-visible` only, set by `components/artifacts/tabs-block.tsx`, reading `--ring` and nothing else. The ring is drawn inside the box, because the panel sits in a scroll box that clips anything painted outside it. | `lib/design-tokens.test.ts` (the ring and its inset); `lib/artifacts/artifact-grid.render.test.ts` (every panel carries the hook) | 2026-10-02 |
| 29 | marketing | The `/brand` page draws the palette and the marks as content: hex literals, raw palette classes and literal white and black grounds | The page's subject is the brand's values. A swatch labeled with its own hex has to be that hex, and the mark is shown on a fixed white and a fixed black ground whatever the theme, because that is what a reader of a brand page checks. Reading tokens would make the page show whatever the theme resolves, which is the one thing it exists not to do. The product owner ratified it on 2026-10-02. | `app/(marketing)/brand/` only, excused by name from the hex, raw palette and literal monochrome rules and from no other rule; it names petrol as content and draws no accent site. | `CANNOT_READ_TOKENS` in `lib/design-callsites.test.ts`; `NAMES_PETROL` in `lib/design-color.test.ts` | 2026-10-02, exempt since 2026-09-24 |
| 30 | all | Hairlines below WCAG 1.4.11's 3:1: the decorative edges (`--border`, `--border-subtle`) and a field's edge at rest (`--input`, and the `ring-foreground/10` hairline the generated input, textarea and native select draw) | 1.4.11 asks 3:1 only of the boundary a user needs to find a component. A card's edge, a table rule or a separator identifies no control, so it stays a hairline. A field is such a component, and three things identify it, each clearing the bar: a visible label, a `--border-strong` edge on hover, and the focus ring. Its resting edge stays Geist's own hairline, as Geist's inputs keep theirs. Raising `--input` to 3:1 at rest takes alpha 0.42 in light and 0.35 in dark, and draws every form and filter row in outlines Geist does not have. Recommended over that stricter edge from renders of both (GAT-505). | Every field is named by text on screen: a `<Label htmlFor>`, a `<label>` around it, or `aria-labelledby` pointing at visible text such as its card's title. A field may name itself with `aria-label` instead where something else on screen identifies it: a control in a filter row (its search glyph or the value it shows), a cell of a repeated row editor (the heading over its rows), or a select whose shown value says what it picks, such as a version picker reading "Current (v3)". A placeholder is never that value. Every field takes the hover edge. A hairline that is the only sign of a control does not qualify. | `CONTRAST_EXEMPTIONS` and the hover pairs in `lib/design-color.test.ts`, which measures `--input` and the generated ring as `field-ring`; `lib/design-fields.test.ts` holds the labels and the hover rule in `globals.css` | 2026-10-03 |
| 31 | marketing, link-preview | Changelog entry titles are labels in the form `Area: what changed`, not statements | [`content/copy.md`](https://www.gatlas.ai/copy.md) asks a title to state its claim. The product owner ruled otherwise for changelog entries on 2026-10-05, with "New connectors: SharePoint and OneDrive" as the model; the Frontmatter section of `docs/changelog.md` holds the rule and its leads. | Changelog entry titles only, wherever an entry is served: `/changelog`, its entry pages, its Markdown, its feed and its link card. Every other marketing title states its claim. | `changelogFrontmatter` in `lib/changelog.ts` (the 72-character cap); the form by review, the changelog block of `docs/pr-review-bar.md` (2.11) | 2026-10-05 |
| 32 | link-preview | A changelog entry card's tiles take `--radius-overlay`, and its hairlines and schematics soften their corners | §5 keeps in-page surfaces square, and the radius ladder is dead. A card is not an in-page surface: its tiles are the card's imagery, drawn at the size of a feed image, where a square tile beside a rounded Gatlas mark reads as a crop rather than a tile. The decks and artifacts already soften drawn corners by the explainer deck's rule, so the card takes the same rule rather than a third. | The tiles of `app/(marketing)/changelog/[slug]/og.png/card.tsx` only, at `radius-overlay` read with `lengthPx()`, never a typed radius. Its drawn shapes follow `docs/collateral/explainer-deck-2026-09/build-deck.py`: a rectangle's corner is 12% of its shorter side under a cap, an elbow is a quarter arc that shrinks to fit a short run, and strokes take round caps and joins. No other surface in any medium follows from this row. | `lib/changelog.test.ts` (the card reads `radius-overlay` by name) | 2026-10-05 |

## §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 color 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**, one of four remaining. The error and info inks were darkened by hand on 2026-09-11 and 2026-09-16, because they had been derived from Geist's 700 steps, which are fill steps; since 2026-09-24 they point at Geist's 900 text steps, which clear the floor as published, and the two §8 rows that ratified the hand edits are gone. Of the two from before, the `--foreground` to `--muted-foreground` hierarchy step in both themes is still open, named here so a future audit finds it already known rather than newly discovered. Light `--border` against `--background` left this list on 2026-10-03, when §8 row 30 ratified it as a decorative hairline. 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` now sits on red-900 at 5.41:1. 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. Overlays are ring-only on the product owner's decision of 2026-09-29: the popover, the dropdown menu and the tooltip draw the generated `ring-1` hairline and no shadow or backdrop blur, through `OVERLAY_RING_ONLY` in `lib/overlay-surface.ts` and the wrappers in `components/overlays.tsx`, held by `lib/design-overlays.test.ts`. The stock shadows still in other primitives are blocked on the tokens. One exception is deferred, not fixed: the tooltip the generated sidebar draws when collapsed still has its shadow (GAT-300). Marketing files still carry stock shadow utilities that [`mediums/marketing.md`](https://www.gatlas.ai/mediums/marketing.md) §5 rules out; that rule is a target, and the marketing phase (GAT-450) purges them.
- **A spacing band contract**, needed before the section-gap retrofit.
- **The deck's rungs off the ladder.** The explainer generator sets its hero title at 88px (`.gd-hero`) and its stat value at 232px (`.gd-stat .v`) in `docs/collateral/explainer-deck-2026-09/build-deck.py`, and no row here ratifies either. The generator also sets five rungs off the ladder in its counters, chips and figure labels, at 8, 10, 11 and 12px below the label role and at 15px between two roles, and tracks its 18 and 20px heads at -0.02em where the roles set none. The deck phase (GAT-452) names the rungs it keeps as tokens and enters their row, or removes them; [`content/mediums/deck.md`](https://www.gatlas.ai/mediums/deck.md) adds no new one before then.

## §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 the chart blocks (`components/artifacts/blocks/`) needs a table of computed appearances, not reasoning about thresholds.** Three separate passes each missed a consumer in what was then one file, `artifact-grid.tsx`, 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 kerns a run of text when it draws it, but not when it lays it out.** It measures each grapheme apart, so a word's box is its advances and tracking, then draws the word in one opentype.js run that applies the font's GPOS kerning, so the word's ink ends short of its box by its kerning and the gap after it grows by that much. Symptom: at the display tracking, word gaps on a card vary by up to half a space ("organization  to" beside "pick which"), and it reproduces with a bare `ImageResponse`. The cards set their headlines with `CardHeadline` in `lib/card-headline.ts` (GAT-528), which typesets each line as a browser does: it breaks lines by Satori's own rule, then starts each word at the line's kerned advance, every glyph's advance, the tracking after it and every kerning pair, the space's included, and Satori draws each word on its own from there. Equal ink-to-ink gaps are the wrong model, since they crowd a word after a colon. `lib/card-headline.test.ts` holds the positions to headless Chromium's, recorded in `lib/fixtures/card-headline-browser.json` by `scripts/card-headline-fixture.mjs`.
- **Satori throws on Geist's variable font.** Its bundled parser fails in the `fvar` axis-name lookup, so the OG routes feed it the static `Geist-400.ttf` and `Geist-500.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`.

**Accent**: `--petrol`, `--petrol-100`, at the sites §5 lists and nowhere else. Not `--accent`, which is under Action.

**Sources**, the theme's one ink and one paper, which the ink and paper tokens above read and a call site never does: `--ink` and `--paper` (§5).

**Scales**, the palette the names above point at and never a name a call site reads: Geist's gray, gray-alpha, blue, red, amber, green, teal, purple and pink scales, steps 100 to 1000 (`--gray-100` to `--pink-1000`), generated into `app/geist-colors.css` (§5).

**Charts**: `--chart-1`, `--chart-2`, `--chart-3` app-wide; `--chart-4` to `--chart-8` exist only under `.artifact-canvas` and `.artifact-palette`, where all eight are the series palette (§8, row 4). The app-wide ramp has three inks, so it publishes three names; two more would repeat the first two. The published sheet below carries the app-wide three; the artifact palette lives in a scoped block, not in a theme block, so it is not published.

**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, one family to a line:
<!-- generated:primitives from REPORT_FAMILIES in apps/web/lib/design-preview.ts; edit it there, then run pnpm design:sync in apps/web -->
- Report shell and layout: `gb-report`, `gb-shell`, `gb-frame`, `gb-grid`, `gb-stack`, `gb-section`, `gb-section-head`, `gb-section-head-note`, `gb-divider`, `gb-keep-together`, `gb-page-break`, `gb-screen-only`, `gb-skip-link`, `gb-span-1` through `gb-span-12`.
- Masthead: `gb-masthead`, `gb-masthead-meta`, `gb-subject`, `gb-recipient`, `gb-author`, `gb-date`, `gb-period`, `gb-revision`, `gb-confidentiality`.
- Stat strip: `gb-stat-strip`, `gb-stat`, `gb-stat-label`, `gb-stat-value`, `gb-stat-unit`, `gb-stat-detail`.
- Delta: `gb-delta`.
- Qualifier: `gb-qualifier`.
- Report table: `gb-table-wrap`, `gb-numeric-col`, `gb-table-total`, `gb-overflow-cue`.
- Figure, caption and sources: `gb-figure`, `gb-caption`, `gb-sources`.
- Report chart: `gb-chart`, `gb-chart-crossing`, `gb-chart-direct-label`, `gb-chart-gridline`, `gb-chart-header`, `gb-chart-scale-line`, `gb-chart-threshold`, `gb-chart-ticks`, `gb-chart-value`, `gb-chart-viewport`, `gb-chart-zero-line`, `gb-data-point`, `gb-series-1` through `gb-series-3`, `gb-series-fill`, `gb-series-stroke`, `gb-legend`, `gb-legend-item`, `gb-swatch`.
- Note: `gb-note`.
- Status: `gb-status`, `gb-status-dot`.
- Section states: `gb-empty`, `gb-empty-next-step`, `gb-partial`, `gb-stale`, `gb-broken`, `gb-skeleton-line`.
- Footer: `gb-footer`.
<!-- /generated:primitives -->

**A chart's viewport clips top and bottom too.** `gb-chart-viewport` scrolls sideways, and a box that scrolls on one axis clips on the other, so it pads half a metadata line above and below the plot: a tick label centered on the plot's top or bottom edge renders whole (GAT-576). A label drawn further outside the plot, such as a value set above a point on the top gridline, gets its room inside the plot, from the scale's headroom.

**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`: neutral, which is the attribute left off, then `info`, `success`, `warning` and `error`; they 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. One custom property is the layer's own and not interface: `--gb-series`, the indirection from `gb-series-1` to `gb-series-3` to the stroke and fill. It is emitted; never read or set 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 the failure [`mediums/dashboard.md`](https://www.gatlas.ai/mediums/dashboard.md) §6 names for tables, 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` and `--font-heading` families, `--font-weight-title`, and the three tracking steps `--tracking-tighter`, `--tracking-snug`, `--tracking-tight`; and the two stacks those families resolve to, `--font-geist-sans` and `--font-geist-mono`, which `next/font` sets inside the product and the published sheet declares for everyone else.

**Components**: everything exported from `components/ui/*`, sized by the rung names the control ladder publishes, plus the shared surfaces in `components/dashboard/*`. Overlays come from `components/overlays.tsx`, never from `components/ui/dropdown-menu`, `tooltip` or `popover` directly. The one deferred exception is the tooltip the generated sidebar draws when collapsed, which it imports directly (GAT-300).

If none of these fits: extend the primitive. Never invent a token name, never read an internal one, never extrapolate a name from a neighboring 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, centered: 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.

**Component API**: each component's props, variants and states are on its page, `content/design/<slug>.mdx`; `lib/design-docs.test.ts` holds every page to the code.

**This list is published.** `https://www.gatlas.ai/gatlas-brand.css` serves exactly the names above as plain CSS; its tokens come from the theme blocks only, light and dark, with the dark block scoped to `@media not print` as in `globals.css` so a printed dark subtree takes the light palette (so of the chart names, the app-wide three): the tokens, the ten type roles both as custom properties and as real classes, `.font-title`, `.z-mono`, `.numeric`, the primitive layer, and the two 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`, and cached at the edge for an hour, with a stale copy served for up to a day while it revalidates, and in a browser for five minutes, so it cannot drift from its source by more than that; `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 artifact 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 artifact, 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 published interface, closed.** `https://www.gatlas.ai/gatlas-brand.css` emits these and nothing else, and `lib/design-stylesheet.test.ts` holds the two equal: every custom property this section names, bar the scoped chart slots and the `@theme` mirrors it says are not published (and `--gb-series`, which it emits as the layer's own, not as interface); the scale steps under Scales; each type role's size, line height and letter spacing as custom properties, named after the role as `--text-title--line-height` is; the classes `.text-display`, `.text-page-title`, `.text-title`, `.text-section`, `.text-subsection`, `.text-lede`, `.text-body`, `.text-compact`, `.text-label`, `.text-metadata`, `.font-title`, `.z-mono` and `.numeric`; the `gb-` classes under Primitives; and `.gatlas-brand`, `.light` and `.dark`, which scope the tokens. Every primitive rule opens on its own class, on a `:where()`, or on a published attribute, so the sheet styles only markup that opted in, and its element rules carry zero specificity through `:where()`. The one exception is a bare attribute rule: `[data-basis]` matches any host element that uses the same attribute, so a host page should not use it for its own purpose. Every other published attribute sits behind a `gb-` class. Page-owned CSS beside it takes its own namespace, `gb-custom-` for layout and `gb-viz-` for a chart's marks, and never selects a published class.

**It is published resolved, too.** `https://www.gatlas.ai/gatlas-tokens.json` serves the same values for a renderer that reads no CSS (an email client, Satori, a Python deck engine): every color as sRGB hex for light and dark (there is no print mode: paper takes light), alpha tokens also flattened over their ground, the ten type roles in px with the static weight a renderer without variable fonts uses, spacing, radius and the sheet geometry. Its `chart-1` to `chart-3` among the colors are the app-wide three, the grays every page draws with; the artifact palette (`chart-1` to `chart-8` as `.artifact-canvas` and `.artifact-palette` declare them, row 4 in §8) is its own `series` group, and a renderer drawing an artifact chart reads that one. This file's frontmatter has the same two groups, so its `colors.chart-2` and `series.chart-2` differ on purpose: the first is the app-wide gray, the second the artifact palette's blue. `lib/design-model.ts` builds it from `globals.css` and `geist-colors.css`, `design:sync` commits it as `brand/tokens.json`, and the URL serves that committed file byte for byte, cached at the edge like the sheet (an hour, and up to a day stale while it revalidates). Readers with no network read the same file, and the frontmatter of this file is the same model. This file's frontmatter carries every token; a medium file's carries only its name, description and canvases, and a token only where its medium's value differs from this file's, which none does yet. Each served id gets its own, so `/mediums/share.md` names share, not dashboard. Two lists in this file are generated the same way, each between `<!-- generated:` markers: the accent sites in §5, from `ACCENT_SITES` in `lib/design-exports.ts`, and the primitives above, from `REPORT_FAMILIES` in `lib/design-preview.ts`. After changing a value in `globals.css` or either list, run the `design:sync` script in `apps/web` and commit what it writes; `lib/design-exports.test.ts` fails until you do.

**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. These exist, each with its row: `.auth-panel` and `.ocean-ground` (row 12, and row 15 for the ocean's render loop), `.example-conversation` (row 14), `.artifact-canvas` and `.artifact-palette` (row 4; the canvas also carries the generated label-tier block), `.artifact-desk`, `.artifact-sheet` and `.artifact-frame` (row 18; the frame puts an artifact's masthead on the surface's edges), `.artifact-deck` (row 19), `.guide-body` (row 21), `.docs-code`, the dark-theme flip of the docs code block's syntax theme (row 24), and `[data-artifact-tabpanel]`, the focus ring a tab panel takes because it is a tab stop (row 28). `[data-control-density="comfortable"]` is a token override, not a page block (row 11). Each block is scoped and commented with the reason that justifies it. A new one needs the same, and its row in §8 in the same pull request.

## §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`, `/s`): 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: every field is named by text on screen, through a `<Label htmlFor>`, a `<label>` around it, or `aria-labelledby` on a visible title, and names itself with `aria-label` only where §8 row 30 allows it. Visible focus that actually changes pixels, on every focusable element.
- A link opens in the same tab, inside the site or out of it, and a reader who wants a new tab opens one. A new tab is kept for a reader in the middle of something on the page who must come back to it: a page holding a key the next step asks for, a form waiting for a value from another site, an unsaved draft, or a frame the link cannot leave. Each such site is named with its reason in `NEW_TAB_SITES` in `lib/design-callsites.test.ts`, and content links render through `ContentLink` in `components/mdx-components.tsx`.
- Color is never the only cue. Every color-carried meaning has a text or shape partner.
- Contrast meets WCAG AA: 4.5:1 for text and 3:1 for the focus ring and the strong edge a field takes on hover, in every mode and in the email palette, measured by `lib/design-color.test.ts`. Decorative hairlines and a field's edge at rest are exempt by §8 row 30; the remaining debts are named in §9 rather than hidden.
- `prefers-reduced-motion` is honored. **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** (the `design-*.test.ts` files under `apps/web/lib/`, which `ls` lists, with `button-contract`, `theme-fonts` and the artifact chart tests beside them) 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 now exists**, as `design-callsites.test.ts` rather than as a `check:design` script: a contract test runs in the web gate, which is a required check, so it blocks a merge without a second CI step to keep honest. Two rules governed building it, and still govern adding to 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.

`design-callsites.test.ts` (2026-09-24) carried nine rules, ten since GAT-493, over the `.tsx` of every medium that renders in this app and of the shared code those mediums mount, chosen through `surfacesOf` in `lib/design-mediums.ts`, with the generated primitives in `components/ui` left out. Four are held at **zero**, each purged before its assertion was written: a numeric `font-weight`, an arbitrary color value, positive tracking (three call sites, the cosmetics §9 reversed on the eyebrow role, surviving where the class had been copied; widened on 2026-09-29 to bracketed values after purging the two it found), and a width on `SheetContent` (nine call sites, GAT-262, 2026-09-28). A fifth zero joined on 2026-10-03 (GAT-493): a retired type rung, `3xl` to `5xl`, after their last two call sites took the title, page-title and display roles and the rungs stopped mirroring them. Five are ratchets whose score is the count on the day they switched on: **31** raw palette classes in 11 files, **10** literal monochrome classes in 7, **1** hex literal, **5** arbitrary radii in 2, and, a day later, **208** arbitrary text sizes in 46 (2026-09-25), the long tail below every floor that §9 names as the product's largest drift, held where it stands until row 11's reversion purges it. The middle dot (§5) was its tenth rule, a ratchet switched on at **138** in 54 files (2026-09-28), widened the next day to count the `&middot;` entity and the escapes, which JSX renders as the same dot and the rule had never seen, and purged to **125** in 50 files on the artifact surfaces (GAT-284). It moved to `copy-lint.test.ts` on 2026-10-02 (GAT-467), below. Every ratchet in these files holds its baseline through one helper, `lib/ratchet.ts`: a file may fall and must not rise, a file not on the baseline starts at zero, a file that falls fails until its baseline is lowered to the new count in the same change, so a win is locked in, and a file that reaches zero leaves the baseline so the score records it.

Its exemption list is five entries, and the test caps it at five. Each entry carries its reason in the code and names the token rules it is excused from; every other rule still reads it. `emails/` authors the literal sizes and radii row 23 ratifies; `app/opengraph-image.tsx` cannot resolve a custom property at all, and Satori picks a font by its numeric weight; the two syntax-highlighting themes take color objects with hexes, because monochrome highlighting highlights nothing; and `app/(marketing)/brand/` renders the palette as content. An entry is a path prefix, so it excuses nothing else that shares its name. Three of those are divergences this table had not recorded; they are rows 23, 24 and 29 now. Adding a sixth is a §8 decision, and the cap is what stops the list from quietly becoming the way past a failing rule.

`copy-lint.test.ts` (2026-10-01) holds the mechanical half of [`content/copy.md`](https://www.gatlas.ai/copy.md) over shipped string data (every `.ts` and `.tsx` under `app/`, `components/`, `emails/` and `lib/`, and the Slack agent's `apps/agent/agent/`), the content files, the agent's instructions and the collateral sources, reading strings through the TypeScript parser so a comment never counts. It switched on as three ratchets: **343** em and en dashes in 63 files, most of them in the MCP tool descriptions under `lib/mcp/`; **zero** middle dots in prose and collateral, once the explainer deck's generator had been purged (GAT-446); and **42** Title Case labels in 19 files. On 2026-10-02 (GAT-467) the middle dot in code moved here from `design-callsites.test.ts`, at **112** in 45 files read as strings, the emails and every `.ts` under `app/` counted for the first time; and three more of copy.md's rules joined: no exclamation mark and no emoji, both held at **zero**, and the words that do not ship that a lint can read without judgment, a ratchet at **19** in 11 files. Later that day (GAT-470) page titles joined, read from metadata `title`, `template` and `absolute` strings and from markdown frontmatter: one separator, the pipe, and the brand spelled Gatlas, held at **zero** after a purge in seven files. The same day `design-literals.test.ts` began reading Satori's `letterSpacing` in the image routes, which the class rule for positive tracking never saw, held at **zero** after the OG card's two tracked capitals were purged. Its ratchets are the same helper as above.

The file also dodges the trap in §10 that a lint for class names walks straight into: Tailwind scans `.ts` comments and string literals, so a rule that spelled out a palette class would emit it into the bundle. Every pattern is assembled from fragments and no complete utility name appears in the file.

Other rule files hold the rest. `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), held by the same ratchet (GAT-187). `design-marketing.test.ts` scans every file the marketing route group renders for a raw type rung or a hand-authored weight and fails on any. 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. `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).

**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. The tests keep it that way: they pin each deliberate value once, in `DECISIONS` in `lib/design-decisions.ts`, and hold everything else as an invariant (a role renders what its rung renders, every color token is in both themes, an alias stays an alias). Two kinds of test name a live value on purpose and say so when it moves: a mutation test that rewrites a declaration line fails with "the fixture moved" until its anchor follows the line, and the petrol detector's probes spell petrol's hex.

**The change recipes.** What each routine change takes. Every step in code is forced, since a named test fails until it is done, and `lib/design-exports.test.ts` fails until `design:sync` has run and its output is committed.

- **A ratified value**, a type size, a color, a control rung or a radius: the declaration in `globals.css`, the same value in `DECISIONS`, then `design:sync`. Title, page-title and display have no rung since GAT-493, so each size is declared once, in its role. Of the roles that still have a rung, subsection's is declared in `globals.css` and moves with it; the others sit on Tailwind's stock rungs, which the change leaves behind. Prose that quotes the old number, such as §8 row 25, is a decision record and is edited by hand.
- **A new type role**: its size and line height in `@theme inline`, its entry in `DECISIONS.typeRoles`, its heading flag in `ROLE_IS_HEADING` (`lib/design-stylesheet.ts`), its name in tailwind-merge's font-size group (`lib/utils.ts`, §10), then `design:sync`, and its name in §5 and §11.
- **A new meaning token**: both theme blocks, a group in `lib/design-preview.ts`, its name in §11, then `design:sync`.
- **A new accent site**: the recipe in §5, approved by the product owner.
- **A new primitive class**: `app/primitives.css`, its family in `REPORT_FAMILIES` (`lib/design-preview.ts`), the `/design` fixture, then `design:sync`, which rewrites the list in §11.
- **A new medium**: its `MEDIUMS` entry in `lib/design-mediums.ts` and the id list `lib/design-mediums.test.ts` pins, its doctrine file in the fixed sections if it owns one, and a loader in both skill trees if it needs its own. Serving, tracing and the exports follow from the registry.
- **A doctrine edit**: one prose edit. `lib/design-doctrine.test.ts` holds the section order and the one home of each repeated rule: a rule another doctrine file already words is cited by its section, never worded again. `lib/copy-lint.test.ts` holds the marks.

---

*Canonical source: `apps/web/content/design.md`. Served at `https://www.gatlas.ai/design.md`, with the copy doctrine at `https://www.gatlas.ai/copy.md` and each medium's doctrine at `https://www.gatlas.ai/mediums/<id>.md` for every id in `lib/design-mediums.ts` (`share` serves the dashboard file, `reading` the document file, `link-preview` the social file and `text` the copy doctrine, each under its own frontmatter). The served copies print every reference to a doctrine file as a link to its URL. Rendered counterpart: `/design`, operator-gated. The loader skill is `gatlas-design`. A newsletter or other marketing email, and print at a physical size other than A4 (signage, a booth banner), have no medium yet: ask the owner before building one.*
