# 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 `