SPB Git

spb/ai-atlas

Public
20.7 KB

# 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/> / — / "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 (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 <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 — mirrors docs/API.md (EntitySummary, EntityDetail, Page<T>, 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.<route>() 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 <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'] (≤ 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 <select> with Apple/NVIDIA optgroups + free numeric field, quant, context; exports APPLE_PRESETS NVIDIA_PRESETS QUANTS CONTEXTS). /hardware/fit shows Estimated prominently, the API assumptions, counts.fits/evaluated, ?fits=1 (filtered in the page — the API has no such param). Hardware entity headers link "What can this run?" prefilled with memory_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; helpers Dash str list. /explore/{type} supports sort=updated|name|quality|first_seen|release|stars. Keep GenericListing for row-style lists; it now accepts rowTrailing(e) and headerAside.

# History mode, diff, graph

  • EntityPage is now async and takes asof? and historyProperty? (pages pass searchParams.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.replace to ?tab=history&asof=) + AsOfBlock (banner "Viewing AI Atlas as of …" on bg-accent-soft, existed: false state, attributes as known then via KeyValue) + ClaimHistory (claims grouped by property: value, valid_from → valid_to/"current", source, tier, confidence, extractor, status chip — conflicting rows in danger with border-l-2; ?property= filters via api.entityHistory(slug, property)). Fetch history always for the tab; as-of only when ?asof.
  • /diff — GET form (from/to dates, scope select all | models | org:<slug> from /companies?sort=models, custom family:<name>), StatGrid from counts, 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 coloured var(--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 the loginAction server action after validating the token against GET /admin/overview. lib/admin/admin-api.ts (server-only): adminRequest adds x-aia-admin-token from the cookie, no-store; 401/403 → AdminAuthError → redirect('/admin?expired=1'); requireAdmin(); typed adminApi.* 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 — all force-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 with resolution.keep_claim_id.

# Polish

  • manifest.ts (standalone, shortcuts, svg + 192/512 PNG icons from icon-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-type opengraph-image.tsx for models and companies (3 key facts, brand-only fallback when the slug is unknown — returns 200), not-found.tsx with a search form, /developers with live trimmed /stats and 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: total from the API equals the returned count, so "N of M" is impossible.
  • /prices/index needs ≥ 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 /models rows (PriceSpark exists) and pan/zoom on the graph remain optional.

# 7. Verification before you report

  1. cd apps/web && pnpm typecheck — zero errors. pnpm build must pass (Turbopack works; fall back to next build --webpack only if needed and say so).
  2. Dev server on :8330 (pnpm dev). API on :8331 (.venv/bin/aia api). Run node qa/screens.mjs (wave-1 routes; add yours to PAGES) and node 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 <= clientWidth at 390 and 1440 px, dark and light; screens.mjs also checks homepage counters against GET /api/v1/stats. Screenshots: qa/screens/ and qa/screens/wave2/.
  3. Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest.
  4. 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 (anthropic company vs anthropic-2 provider "Anthropic API").
  • EntitySummary.status may be available while attributes.status is active; StatusBadge shows unknown values neutrally.
  • quality and counts are often {} (score not computed yet) — QualityMark renders nothing then.
  • provenance[*] currently lacks source_name; the UI falls back to the URL host. extractor is llm or deterministic.
  • /search query is flat: { text, entity_type, openness, params_min, filters: { residual }, semantic } — understood() in search/page.tsx renders it.
  • /methodology.event_types items are { event_type, category, count, last_seen_at } (no label/importance); metrics is [].
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme