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.
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/>/—/ "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 (
<Estimated/>), 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 (scrollprop) — 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.
.panelonly 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 <html data-theme="light|dark"> (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-<key>)) |
| 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— mirrorsdocs/API.md(EntitySummary,EntityDetail,Page<T>,ChangeEvent,Price,BenchmarkResult,Claim,SourceRef,Provenance,Stats,SearchPayload,DailyDigest,ComparePayload,DiffPayload,Methodology, …). Aggregates may arrive as strings → typeNum = number | string | null; always go throughnum()/fmt*.api.ts(server only) —api.<route>()for every public route,ApiError(.notFound,.unavailable),safe(p)→nullon any failure,request(path, query, { revalidate })(ISR default 300 s;false= no-store).countOfType(type).client-api.ts(browser) —clientApi.suggest / search / changes / viewagainst 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:
const [a, b] = await Promise.all([safe(api.x()), safe(api.y())]); // never let one panel crash the page
if (!a) return <Unavailable what="…"/>;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 <dl>; 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 <details>) · 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, <type>-<n> 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'](≤ 6TrayItem { slug, name, entity_type, organization? }), same-tabaia-compare-changeevent + cross-tabstorage.useCompareTray()→{ items, ready, add, remove, toggle, clear, replace, has, type, full, canCompare }; helpersaddToTray/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 witharia-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/suggestautocomplete filtered to the tray's type, chips with remove/clear, "Compare n →"; seeds the tray from?ids=(URL wins) and mirrors tray changes back withrouter.replace.CompareMatrix,SharedBenchmarks,ComparePrices(server,compare-matrix.tsx) — dimension × entity table (sticky first column inside.table-scroll,kind-aware cells vialib/format, best-per-row bold — lower is better for*_per_mtok, per-cellT{n} · source · agoprovenance), benchmarks present for every model, cheapest input/output per provider × entity. Gotcha:.table-scroll .data-table td { white-space: nowrap }out-specifies Tailwindwhitespace-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 fromstats.first_entity_atand the months returned./timeline—Barsmonth-density strip, sticky month headings,ChangeRow showDate live={false}, dynamic metadata (entity views arenoindex).components/prices/—ScaleToggle(client: linear/log,aria-pressed, mirrors?scale=logwithreplaceState; the page renders both charts server-side),PriceMovers(PRICE_CHANGED events as a table; theirold_value/new_valueare objects, soDeltawould JSON-dump them — format them yourself).LineChartgainedyScale?: 'linear' | 'log'(d3scaleLog, points ≤ 0 dropped). Existing calls are unchanged./prices?model=andprovider=take slugs (API 404s on free text) — label fields accordingly.
Benchmarks & hardware
components/benchmarks/leaderboard.tsx—Leaderboard(rank, model, score + relative bar invar(--type-benchmark), config summary, evaluated, source, "History" link,CompareButton,Pagination),ConfigChips+configChips(rows)(?config=),HistoryChart(per-modelLineChart, honest when < 2 points). API note:results?config=is a value substring match (config=v2.1), notkey=valueor JSON.evaluated_atis often null (falls back to observed date)./benchmarks/[slug]tabs: Leaderboard · Definition (KeyValuewith provenance) · Relations · History (HistoryPanel) · Timeline · Sources.components/hardware/fit-form.tsx—FitForm(GET form: memory preset<select>with Apple/NVIDIA optgroups + free numeric field,quant,context; exportsAPPLE_PRESETS NVIDIA_PRESETS QUANTS CONTEXTS)./hardware/fitshowsEstimatedprominently, the APIassumptions,counts.fits/evaluated,?fits=1(filtered in the page — the API has no such param). Hardware entity headers link "What can this run?" prefilled withmemory_gb.
Typed listings (components/listing/typed-listing.tsx)
TypedListing({ title, eyebrow, lede, basePath, searchParams, fetch, columns: Column[], sorts, filters, emptyTitle, emptyHint, note, compare, headerAside })— column-driven paged table (FilterBar + Pagination + Unavailable/EmptyState) used by/papers /frameworks /datasets /tools; helpersDash str list./explore/{type}supportssort=updated|name|quality|first_seen|release|stars. KeepGenericListingfor row-style lists; it now acceptsrowTrailing(e)andheaderAside.
History mode, diff, graph
EntityPageis nowasyncand takesasof?andhistoryProperty?(pages passsearchParams.asof/property). Header actions row:CompareButton, "Explore graph" →routes.graph(slug), hardware-only "What can this run?". New History tab (before Timeline) →components/entity/history.tsx:HistoryPanel({ d, asof, asofPayload, claims, property })=AsOfPicker(client date input,router.replaceto?tab=history&asof=) +AsOfBlock(banner "Viewing AI Atlas as of …" onbg-accent-soft,existed: falsestate, attributes as known then viaKeyValue) +ClaimHistory(claims grouped by property: value, valid_from → valid_to/"current", source, tier, confidence, extractor, status chip —conflictingrows in danger withborder-l-2;?property=filters viaapi.entityHistory(slug, property)). Fetch history always for the tab; as-of only when?asof./diff— GET form (from/to dates, scope selectall | models | org:<slug>from/companies?sort=models, customfamily:<name>),StatGridfromcounts, sections New / Gone (EntityRow) and Property / Price / Benchmark changes (ChangeRow); API lists are capped at 200 — say "first N of total". a ≥ b → API 400 → honest message./graph/[slug]+components/graph/graph-explorer.tsx(client) — d3-force layout run after mount (server renders the frame only → stable SSR), nodes colouredvar(--type-*), root pinned, edge labels always ≤ 40 edges else on hover, hover/focus highlights the neighbourhood, click/Enter →routes.entity, depth 1/2 links, cap 80 nodes + note, type and predicate legends, accessible text fallback list.routes.graph(slug, depth).
Admin (/admin, lib/admin/, components/admin/)
- Cookie
aia-admin(httpOnly, sameSite=lax, secure in prod,path=/admin, 12 h) set by theloginActionserver action after validating the token againstGET /admin/overview.lib/admin/admin-api.ts(server-only):adminRequestaddsx-aia-admin-tokenfrom the cookie,no-store; 401/403 →AdminAuthError→redirect('/admin?expired=1');requireAdmin(); typedadminApi.*for every admin route. The token never reaches the client (grep the HTML for it in QA). lib/admin/actions.ts('use server'): login/logout, run/toggle connector, retry job, requeue dead, review approve/reject/keep-side, merge, flush cache, recompute stats/quality — each revalidates and redirects back with?notice=&level=(works without JS).components/admin/:shell.tsx(header + sticky left nav ≥ lg),nav-select.tsx(mobile select),login-form.tsx(useActionState),ui.tsx(StatusChip KindChip Bool Notice JsonPre ActionButton AdminTitle AdminFilters Mono Trunc). Pages: overview, connectors, runs, errors, documents(+[id]), snapshots/[id], jobs, llm-jobs, review, entities/duplicates, infrastructure, cache — allforce-dynamic,robots: noindex,DataTable compact(+scroll).- Conflict review items carry no claim ids; "Keep this" resolves the claim through
/entities/{slug}/history?property=before approving withresolution.keep_claim_id.
Polish
manifest.ts(standalone, shortcuts, svg + 192/512 PNG icons fromicon-192.png/route.tsx+icon-512.png/route.tsx),components/brand/og.tsx(Frame Eyebrow Title Facts Fallback— shared OG chrome; hex constants are allowed there like in the other ImageResponse files), per-typeopengraph-image.tsxfor models and companies (3 key facts, brand-only fallback when the slug is unknown — returns 200),not-found.tsxwith a search form,/developerswith live trimmed/statsand one model response plus Compare/Diff/history/asof/graph/fit/price-index rows and response conventions.
Left for the next agent
- Graph: no pan/zoom; edge labels can overlap on near-collinear edges. Timeline:
totalfrom the API equals the returned count, so "N of M" is impossible. /prices/indexneeds ≥ 2 daily snapshots before the chart draws (the DB is one day old); movers and the offers table already work.- Per-model price sparkline in
/modelsrows (PriceSparkexists) and pan/zoom on the graph remain optional.
7. Verification before you report
cd apps/web && pnpm typecheck— zero errors.pnpm buildmust pass (Turbopack works; fall back tonext build --webpackonly if needed and say so).- Dev server on :8330 (
pnpm dev). API on :8331 (.venv/bin/aia api). Runnode qa/screens.mjs(wave-1 routes; add yours toPAGES) andnode qa/screens-wave2.mjs [BASE] [API] [ADMIN_TOKEN](wave-2 routes + flows: compare tray, hardware-fit submit, admin login → connectors → Run now, diff with two dates, graph nodes; slugs discovered live). Both check HTTP status (404 for missing slugs), zero console errors,scrollWidth <= clientWidthat 390 and 1440 px, dark and light; screens.mjs also checks homepage counters againstGET /api/v1/stats. Screenshots:qa/screens/andqa/screens/wave2/. - Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest.
curl -sI localhost:8330/<type>/does-not-exist→ 404.curl -s localhost:8330/admin | grep -c <token>→ 0.
8. API notes observed live (2026-09-11) — keep in mind
- Provider slugs can collide with company slugs (
anthropiccompany vsanthropic-2provider "Anthropic API"). EntitySummary.statusmay beavailablewhileattributes.statusisactive;StatusBadgeshows unknown values neutrally.qualityandcountsare often{}(score not computed yet) —QualityMarkrenders nothing then.provenance[*]currently lackssource_name; the UI falls back to the URL host.extractorisllmordeterministic./searchqueryis flat:{ text, entity_type, openness, params_min, filters: { residual }, semantic }—understood()insearch/page.tsxrenders it./methodology.event_typesitems are{ event_type, category, count, last_seen_at }(no label/importance);metricsis[].