# 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. 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). Radii are deliberately small (4 px; 8 px for panels). Brand: `public/logo.svg`, `src/app/icon.svg`, `apple-icon.tsx`, `opengraph-image.tsx` (live counters when the API answers), `components/brand/logo.tsx` (`LogoMark`, `Wordmark`). ## 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)`: dense spec `
`; each row `{ key, raw | value, label?, hint? }`, URLs auto-linked, provenance line under each value. `components/ui/provenance.tsx` — `ProvenanceInline(p)` ("Source: host · T1 · observed 3 h ago · high · LLM-extracted") · `SourceCell(url, tier, observedAt)`. `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/charts.tsx` — pure SVG, theme-aware: `Sparkline` · `Bars` · `HBars` · `LineChart(series, yFormat, xTime)` · `Legend` · `stepPoints()` for price steps. `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) and `blocks.tsx`: `SpecTable Identity Capabilities ResultsTable(perspective) PricesTable(perspective) PriceHistory PriceSpark HardwareFitTable LineageBlock RelationsBlock EntityList ModelsTable TimelineList SourcesTable ProvenanceSummary`. `components/layout/` — header (Explore ▾, ⌘K search → `/search/suggest`, theme toggle), `MobileTabBar` (Home · Explore · Changes · Search · More), `SiteFooter`, `SearchDialog`, `ViewBeacon(path)`. `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 `