# AI Atlas web — frontend guide (`apps/web`)
Next 16.3 (App Router, React 19, TypeScript strict, Tailwind v4, Geist). Server components by default; client components
only for interactivity (`'use client'`). The FastAPI service runs on `http://127.0.0.1:8331` in dev (`:8321` in prod); the
browser talks to the same origin — `/api/v1/*` and `/health` are rewritten to the API in `next.config.ts`. Root `.env` is
loaded by `next.config.ts` (`process.loadEnvFile`), so `API_URL` / `NEXT_PUBLIC_SITE_URL` live at the repo root.
```bash
pnpm install # repo root
cd apps/web && pnpm dev # http://localhost:8330 (prod: pnpm start → :8320)
pnpm typecheck && pnpm build # must both pass before you report
node qa/screens.mjs # Playwright sweep → qa/screens/*.png (see "Verification")
```
## 1. Principles (non-negotiable, from CLAUDE.md)
- **Every number comes from the API.** No hardcoded counts, dates or sample rows. Missing → `` / `—` / "Unavailable".
- **Provenance is a feature.** Show source, tier, observed time (and confidence) wherever a value is displayed in detail; link to `/methodology`.
- **Estimates are labelled** (``), and never mixed with observed facts. Only hardware fit is derived.
- **Mobile first** (390 / 430 / 768 / 1440): no horizontal overflow, ≥ 44 px targets, DOM order = visual order (no `order:` tricks),
tables stack (`.data-table.stack`) or scroll (`scroll` prop) — never overflow the page.
- **Dark and light** must both look finished; use tokens only (never raw hex in components).
- **Not a SaaS template**: hairlines and spatial composition, not stacks of rounded cards. `.panel` only for dialogs/sheets/callouts.
- Every public page: `generateMetadata` (title, description, `alternates.canonical`, OG), loading + error + empty states, source attribution.
## 2. Design tokens (`src/app/globals.css`)
Themes live on `` (set before paint by `THEME_SCRIPT`, persisted in `localStorage['aia-theme']`,
system by default; `ThemeToggle` cycles system → light → dark). Tokens are CSS variables exposed to Tailwind through `@theme inline`:
| Family | Utilities | Use |
|---|---|---|
| Surfaces | `bg-canvas` (page) · `bg-surface` (inputs, dialogs) · `bg-surface-2` (hover wash, chips) · `bg-surface-3` (active) | Prefer canvas + hairlines; surfaces for interactive elements |
| Ink | `text-ink` · `text-ink-2` (secondary) · `text-ink-3` (labels, meta) | |
| Hairlines | `border-rule` · `border-rule-strong` · `.hairline` (top border) | Section separators, table rows |
| Accent | `accent` (atlas blue: links, active, focus) · `accent-ink` (text on accent) · `accent-soft` | Sparingly |
| Accent 2 | `accent-2` (amber) + `accent-2-soft` | **Money and change only** (prices, deltas) |
| Semantic | `positive` · `warning` · `danger` (+ `-soft`) | Status, health, confidence |
| Tiers | `tier-1` (green, official) · `tier-2` (blue) · `tier-3` (amber) · `tier-4` (grey) | Source quality (`TierBadge`) |
| Entity types | `type-model` `type-company` `type-paper` `type-provider` `type-benchmark` `type-hardware` `type-framework` `type-dataset` `type-tool` | `EntityBadge` (also `var(--type-)`) |
| Charts | `series-1..8` (`var(--series-n)`) | SVG charts |
Typography: **Geist Sans** for UI (`--font-sans`), **Geist Mono** for ids, numbers, telemetry (`.mono`). Base 15 px
(`--d-base`; 14 px in `compact`, 13.5 px in `dense` — see density below).
Utilities: `.eyebrow` (small caps label) · `.display` (headline) · `.mono` · `.tnum` (tabular numbers — use on every numeric cell)
· `.hairline` · `.link` · `.panel` (sparingly) · `.grid-bg` (hero) · `.scrollbar-thin` · `.no-scrollbar` · `.container-x`
· `.dot` / `.pulse` (live) · `.prose-atlas` · `.kv` (dense key–value grid, used by `KeyValue`)
· `.data-table` (+ `.stack` stacks rows < 768 px using `data-label`; `.compact`; `.num` right-aligned; `.primary` name cell;
`.wide` full-row cell when stacked; `.hide-stack`) · `.table-scroll` (horizontal scroll wrapper)
· `.section-y` (density-aware section padding, used by `Section`) · `.row-y` (density-aware feed-row padding) · `.evidence`
(dotted-underline evidence trigger) · `.hint` / `.hint-bubble` (pure-CSS tooltip) · `.ticker` / `.ticker-track` · `.heatmap` · `.terminal-rail` (sticky side rail).
Radii are deliberately small (4 px; 8 px for panels).
**Density** (2026-09-12): `` (absent = comfortable), persisted in `localStorage['aia-density']`,
applied before paint by `DENSITY_SCRIPT` (`lib/prepaint.ts`, embedded with the theme script in `layout.tsx`). It only changes CSS
variables — `--d-base` (body font size) · `--d-cell-y` / `--d-cell-x` (`.data-table` cell padding) · `--d-kv-y` (`.kv` rows) ·
`--d-row-y` (`.row-y`) · `--d-section-y` / `--d-section-y-md` (`.section-y`) · `--d-table-fs` — so any component built on those
classes is density-aware for free. Use `.row-y` instead of `py-2.5` on feed rows. `DensityToggle` (header) and the palette
command "Toggle density" cycle comfortable → compact → dense; `useDensity()` (`lib/density.ts`) → `[density, set, cycle]`.
**Pre-paint scripts live in `lib/prepaint.ts`** (server-safe). Never import a string export from a `'use client'` module into
a server component: Next hands the server a client-reference stub (this is why `THEME_SCRIPT` moved there).
**Brand** (2026-09-12) — the *atlas plate*: a rounded-square plate with a fine coordinate grid, three nodes + two edges drawing the
letter A as a graph, a timeline baseline whose right-hand accent node marks "now". One geometry (`components/brand/mark.tsx`:
`MARK_GEOMETRY`, `MarkArt` for in-page SVG, `markSvgString`/`markDataUri`/`MarkImg` for ImageResponse — satori cannot rasterise
nested React SVG trees, so OG/icon routes embed a data-URI `
`). Tokens `--brand-plate/-ink/-grid/-accent` invert per theme
(dark plate in light mode, light plate in dark mode). Assets: `public/logo.svg` (mark), `public/logo-lockup.svg` (+ `-light`),
`src/app/icon.svg` (simplified 2-line grid, crisp at 16/32), `apple-icon.tsx`, `icon-192.png/` + `icon-512.png/` routes,
`manifest.ts` (theme colours from `THEME_LIGHT/THEME_DARK` in `lib/site.ts`), `opengraph-image.tsx` (root "wallpaper" with four live
counters). `components/brand/logo.tsx`: `LogoMark({ size, variant: 'plate' | 'mono', title })`, `Wordmark({ markSize, textClassName })`
("AI" bold, "Atlas" medium). `components/brand/og.tsx`: `Frame Eyebrow Title Facts Fallback` (unchanged API) + `Wallpaper({ eyebrow,
title, subtitle, counters, footer, markPx })`, `Counters({ items })`, `Grid`, `Lockup`, `Mark` — per-type OG images (D1–D3) should use
`Wallpaper` with their own eyebrow/title/counters.
## 3. Data layer (`src/lib`)
- `types.ts` — mirrors `docs/API.md` (`EntitySummary`, `EntityDetail`, `Page`, `ChangeEvent`, `Price`, `BenchmarkResult`,
`Claim`, `SourceRef`, `Provenance`, `Stats`, `SearchPayload`, `DailyDigest`, `ComparePayload`, `DiffPayload`, `Methodology`, …).
Aggregates may arrive as strings → type `Num = number | string | null`; always go through `num()` / `fmt*`.
- `api.ts` (**server only**) — `api.()` for every public route, `ApiError` (`.notFound`, `.unavailable`), `safe(p)` → `null`
on any failure, `request(path, query, { revalidate })` (ISR default 300 s; `false` = no-store). `countOfType(type)`.
- `client-api.ts` (**browser**) — `clientApi.suggest / search / changes / view` against same-origin `/api/v1`.
- `format.ts` — `num fmtInt fmt1 fmt2 fmtCompact fmtParams (70B) fmtTokens (128K) fmtUsdPerM ($3.00) fmtUsd fmtPct fmtGb fmtBytes fmtScore
fmtDate fmtMonth fmtDateTime fmtAgo fmtYear fmtDuration titleCase humanize plural fmtValue(value, key)`; `DASH = '—'`.
- `site.ts` — `SITE_NAME SITE_URL TAGLINE DESCRIPTION CONTACT_EMAIL PUBLIC_API_BASE BOT_UA`, `routes.*` (`routes.entity(e)` gives the
canonical URL for any entity type), `TYPE_PATH` / `PATH_TYPES` (type ↔ URL segment ↔ API mount), `typeLabel(t, plural)`,
`TYPE_COLOR_KEY`, nav arrays (`exploreNav primaryNav moreNav`), `EVENT_TYPE_LABELS eventLabel eventTone`, `CATEGORY_LABELS categoryLabel`,
`IMPORTANCE_LABELS`, `PROPERTY_LABELS propertyLabel URL_KEYS PROSE_KEYS`, `PREDICATE_LABELS predicateLabel`, `OPENNESS_LABELS STATUS_LABELS TIER_LABELS`, `EXAMPLE_QUERIES`.
Page pattern:
```tsx
const [a, b] = await Promise.all([safe(api.x()), safe(api.y())]); // never let one panel crash the page
if (!a) return ;
```
Detail pages: `loadEntity(typePath, slug)` (`components/entity/load.ts`) → `notFound()` on API 404 **or** type mismatch;
`entityMetadata()` / `buildMetadata()` for `generateMetadata`; `permanentRedirect(routes.entity(d))` when the slug is not canonical.
Do **not** add `loading.tsx` to a segment that contains a dynamic `[slug]` route — it turns 404s into 200s (the listing is
in a route group `models/(list)/` for that reason).
## 4. Components
`components/ui/section.tsx` — `Container` (1280; `wide` = 1600) · `PageHeader` (eyebrow/title/lede/aside/children) · `Section`
(eyebrow/title/lede/action, `hairline`) · `Stat` (label/value/hint/delta/href) · `StatGrid` (cols 3–8) · `Note`.
`components/ui/badges.tsx` — `EntityBadge(type)` · `TierBadge(tier, withLabel)` · `ConfidenceBadge` · `StatusBadge` · `OpennessBadge`
· `ImportanceMark(0–3)` · `Chip(tone)` · `Estimated`.
`components/ui/unavailable.tsx` — `Unavailable(what, compact, reason)` · `Missing` · `EmptyState(title)`.
`components/ui/pagination.tsx` — `Pagination(total, limit, offset, makeHref)` · `withParams(base, current, patch)`.
`components/ui/tabs.tsx` (client) — `Tabs(tabs, mode='search'|'hash')` + `TabPanel(id)`; all panels are server-rendered, URL `?tab=` drives visibility.
`components/ui/key-value.tsx` — `KeyValue(rows, provenance, slug?, entity?)`: dense spec ``; each row `{ key, raw | value, label?, hint?, unit? }`, URLs auto-linked, provenance line under each value.
With `slug` every value that has provenance becomes an **evidence trigger** (opens the drawer, §9); without it the output is unchanged.
`components/ui/provenance.tsx` — `ProvenanceInline(p, slug?, property?, value?, entity?)` ("Source: host · T1 · observed 3 h ago · high · LLM-extracted · Evidence") · `SourceCell(url, tier, observedAt)`.
`components/ui/sheet.tsx` (client) — `Sheet({ open, onClose, title, eyebrow, side: 'auto' | 'right' | 'bottom', width, footer, id })`: right drawer ≥ lg / bottom sheet below (`auto`), focus trap, Esc, scroll lock. Shared by the evidence drawer, terminal sheets and the mobile More menu.
`components/ui/hint.tsx` — `Hint({ text, align, children? })`: pure-CSS definition tooltip (server-safe), used for "How counted".
`components/ui/data-table.tsx` — `DataTable(stack|scroll|compact)` · `Th(num)` · `Td(label, num, primary, wide, hideStack)` · `EmptyRow`.
`components/ui/entity.tsx` — `EntityLink` · `EntityRow` (badge · name · org · key attributes · quality) · `keyAttributes(e)` · `QualityMark` · `EntityInline`.
`components/ui/live.tsx` (client) — `Dot(pulse)` · `LiveAgo(at)` (re-renders every 30 s).
`components/charts/` (import from `@/components/charts`) — pure SVG, theme-aware: `Sparkline(values, variant: 'line' | 'trend', invert, format)` · `Bars` · `HBars` ·
`LineChart(series, yFormat, xTime, yScale, step, children)` · `StepChart` · `Legend` · `Heatmap` · `stepPoints()` · `lineLayout()`; client: `InteractiveLineChart` · `ScatterChart` · `TimelineLanes` (see §9).
`components/changes/` — `ChangeRow(e, dense, showDate, live)` · `Delta` · `groupByDay` · `LoadMore` (cursor `before=`).
`components/listing/` — `FilterBar` (GET form → URL params) · `Facets` · `ListingLayout` (facets aside / mobile ``) · `ActiveFilters` · `GenericListing(type, fetch?)`.
`components/entity/` — `EntityPage(d, canonical, related)` (header + type-aware tabs + JSON-LD + view beacon; actions row = `CompareButton` · `WatchButton` · Explore graph) and `blocks.tsx`:
`SpecTable Identity Capabilities ResultsTable(perspective) PricesTable(perspective) PriceHistory PriceSpark HardwareFitTable LineageBlock RelationsBlock EntityList ModelsTable TimelineList SourcesTable ProvenanceSummary`
(`SpecTable` and `Capabilities` pass `slug` to `KeyValue`, so their values open the evidence drawer).
`components/layout/` — `SiteHeader` (Models · Frontier · Benchmarks · Prices · Research · Graph · Changes · More ▾ | Search ⌘K | theme | density), `MobileTabBar`
(Home · Models · Changes · Search · More → grouped bottom sheet), `SiteFooter` (4 columns + credits line), `SearchDialog` (command palette), `DensityToggle`, `terminal.tsx` primitives (§9), `ViewBeacon(path)`.
`components/evidence/`, `components/watchlist/` — see §9.
`components/meta/sitemap-data.ts` — sitemap shards (`static`, `-` of 5 000 from `GET /sitemap`).
Do not fork these; extend with props or add new components in your own folder.
## 5. Routes built
`/` · `/search` · `/models` (+ facets, sort, Compare buttons) · `/models/[slug]` (+ `opengraph-image`) · `/companies` · `/companies/[slug]` (+ `opengraph-image`)
· `/[type]/[slug]` (providers, hardware, papers, frameworks, datasets, tools, repositories) · `/benchmarks/[slug]` (dedicated leaderboard — static segment shadows
`[type]/[slug]`) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` · `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` (typed tables)
· `/changes` · `/changes/[date]` · `/timeline?year=&category=&entity=` · `/prices?days=&sort=&provider=&model=&scale=` · `/compare?ids=` (picker + tray + matrix)
· `/hardware/fit?memory_gb=&quant=&context=&fits=` · `/diff?a=&b=&scope=` · `/graph/[slug]?depth=` · `/admin/**` (token-gated, noindex) · `/methodology` · `/sources`
· `/about` · `/developers` (live example responses) · `/bot` · `robots.ts` (disallows `/admin/`) · `/sitemap.xml` + `/sitemap/[shard].xml` · `manifest.ts`
· `icon-192.png` / `icon-512.png` (ImageResponse routes) · `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`.
## 6. Wave 2 (2026-09-11) — components and page patterns added
All numbers still come from the API; nothing below hardcodes counts, slugs or dates. Ownership of shared files stays as in §4 — extend, don't fork.
### Compare (`components/compare/`)
- `compare-store.ts` (client) — the **compare tray**: `localStorage['aia-compare']` (≤ 6 `TrayItem { slug, name, entity_type, organization? }`), same-tab
`aia-compare-change` event + cross-tab `storage`. `useCompareTray()` → `{ items, ready, add, remove, toggle, clear, replace, has, type, full, canCompare }`;
helpers `addToTray/removeFromTray/toggleTray/readTray/clearTray`, `trayType(t)` (company-like → `company`, library/runtime → `framework`, quantization → `model`),
`compareHref(items)`. The tray is type-homogeneous: adding another type **replaces** it (the API compares 2–6 entities of one type).
- `CompareButton({ e, size: 'sm' | 'md' })` (client) — toggle with `aria-pressed`; used in entity headers, `/models`, `/providers`, `/hardware`, `/frameworks`,
leaderboards and hardware-fit rows. `CompareTrayBar` (client) — fixed bar above the mobile tab bar / bottom-right on desktop, hidden when empty and on `/compare`;
mount it **once per page** that shows Compare buttons.
- `ComparePicker` + `CompareTray` (client, `compare-picker.tsx`) — `/search/suggest` autocomplete filtered to the tray's type, chips with remove/clear, "Compare n →";
seeds the tray from `?ids=` (URL wins) and mirrors tray changes back with `router.replace`.
- `CompareMatrix`, `SharedBenchmarks`, `ComparePrices` (server, `compare-matrix.tsx`) — dimension × entity table (sticky first column inside `.table-scroll`,
`kind`-aware cells via `lib/format`, best-per-row bold — lower is better for `*_per_mtok`, per-cell `T{n} · source · ago` provenance), benchmarks present for
every model, cheapest input/output per provider × entity. Gotcha: `.table-scroll .data-table td { white-space: nowrap }` out-specifies Tailwind
`whitespace-normal` — use an inline style for a wrapping sticky column.
### Timeline & prices
- `components/timeline/chip-row.tsx` — `ChipRow` (server link chips: years, categories, windows). Years derive from `stats.first_entity_at` and the months returned.
- `/timeline` — `Bars` month-density strip, sticky month headings, `ChangeRow showDate live={false}`, dynamic metadata (entity views are `noindex`).
- `components/prices/` — `ScaleToggle` (client: linear/log, `aria-pressed`, mirrors `?scale=log` with `replaceState`; the page renders both charts server-side),
`PriceMovers` (PRICE_CHANGED events as a table; their `old_value/new_value` are objects, so `Delta` would JSON-dump them — format them yourself).
- `LineChart` gained `yScale?: 'linear' | 'log'` (d3 `scaleLog`, points ≤ 0 dropped). Existing calls are unchanged.
- `/prices?model=` and `provider=` take **slugs** (API 404s on free text) — label fields accordingly.
### Benchmarks & hardware
- `components/benchmarks/leaderboard.tsx` — `Leaderboard` (rank, model, score + relative bar in `var(--type-benchmark)`, config summary, evaluated, source,
"History" link, `CompareButton`, `Pagination`), `ConfigChips` + `configChips(rows)` (`?config=`), `HistoryChart` (per-model `LineChart`, honest when < 2 points).
API note: `results?config=` is a **value substring match** (`config=v2.1`), not `key=value` or JSON. `evaluated_at` is often null (falls back to observed date).
- `/benchmarks/[slug]` tabs: Leaderboard · Definition (`KeyValue` with provenance) · Relations · History (`HistoryPanel`) · Timeline · Sources.
- `components/hardware/fit-form.tsx` — `FitForm` (GET form: memory preset `