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
(--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): <html data-density="compact|dense"> (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 <img>). 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— 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, slug?, entity?): dense spec <dl>; 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 <details>) · 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, <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[].
9. Terminal shell (2026-09-12, stream D0) — contract for the page streams D1–D3
Everything below is built and QA'd (qa/screens.mjs shell sweep 320…1920 dark+light, qa/shell.mjs flows). Import paths are exact.
Pages must not fork these; extend with props. Every new route must exist in lib/site.ts (routes.*, nav arrays) — the shell already
links to /frontier /prices /graph /open /families /families/[slug] /time-machine /diff /pulse /calculator /run-locally /find-a-model /artifacts/[slug] /agents /licenses /claims/[id] /watchlist (routes.frontier() pulse() open() families() family(slug) timeMachine(date?) calculator() runLocally() findAModel() artifact(slug) agents() licenses() claim(id) watchlist() benchmarkMatrix() entityHistory(e, property?) graph(slug?)).
TYPE_PATH gained model_family → families, artifact → artifacts, license → licenses (+ PATH_TYPES, labels, colours).
9.1 Navigation (lib/site.ts)
primaryNav (7 desktop items) · moreGroups: NavGroup[] (Explore / Intelligence / Temporal / About — desktop More panel and mobile sheet) ·
moreNav (flat) · mobileTabs · footerGroups · signatureProducts (12 homepage links) · PALETTE_PREFIXES (m: b: o: p: h:).
Constants: TAGLINE = "The temporal knowledge graph of the AI ecosystem", AUTHOR_NAME, HOST_NAME, HOST_URL, THEME_LIGHT, THEME_DARK.
Header/menu QA hooks: [data-more-button] [data-more-menu] [data-open-palette] [data-density-toggle] [data-mobile-more].
9.2 Command palette (components/layout/search-dialog.tsx, opened by useOpenSearch() from search-context.tsx)
⌘K / Ctrl+K / /. Sections: Commands (fuzzy: prefix > word prefix > substring > subsequence) · Entities (/search/suggest, filtered
client-side by prefix type) · Recent (localStorage['aia-recent'], last 8) · "Search everything". > = commands only; m: b: o: p: h:
restrict entities. Commands with an argument ("Open benchmark …", "Find provider …", "Explore lineage of …" → graph) switch the input to the
prefix. To add a command: append to COMMANDS ({ id, label, hint, keywords, href | prefix (+then) | action }). Hooks: [data-palette] [data-palette-input] [data-palette-row="entity|command|recent|search"].
9.3 Evidence drawer (components/evidence)
import { Evidence, useEvidence, type EvidenceTarget } from '@/components/evidence';
<Evidence slug={d.slug} property="context_length" value={raw} display="200K tokens" unit="tokens" fallback={d.provenance.context_length} entity={{ name: d.name, entity_type: d.entity_type }}>
{children /* rendered as a dotted-underline button, data-evidence="slug:property" */}
</Evidence>
const { open, close, target } = useEvidence(); // open({ slug, property, value?, display?, unit?, label?, fallback?, entity? })EvidenceProvider + EvidenceDrawer are mounted once in app/layout.tsx; URL hash #evidence=<slug>:<property> opens the drawer on load.
Data order: GET /entities/{slug}/provenance/{property} (1.1, clientApi.provenance) → 404 → GET /entities/{slug}/history?property=
(clientApi.history: claim id, valid_from/to, conflicts) → the fallback ProvenanceEntry. Content: VALUE · SOURCE · TIER · OBSERVED ·
EXTRACTOR · CONFIDENCE · VALID SINCE · conflicts · View source · View history (routes.entityHistory(e, property)) · View conflicts · claim id.
Cheapest integration for a page: pass slug (+ entity) to KeyValue, or slug + property to ProvenanceInline. For table cells wrap the
value in <Evidence …>. Types: ProvenanceDetail (lib/types.ts), clientApi.provenance/history (lib/client-api.ts, ClientApiError.status).
9.4 Terminal layout primitives (components/layout/terminal.tsx, client)
TerminalLayout({ filters?, children, inspector?, filtersTitle, inspectorTitle, storageKey, filterCount, wide })— desktop 3-pane (filter rail 15rem sticky · main · inspector 22rem sticky, collapsible, state inlocalStorage[storageKey]); mobile: toolbar buttons[data-open-filters] [data-open-inspector]open bottom sheets. Filters/inspector are server-rendered nodes passed as props (GET forms work).FilterSheet({ open, onClose, title, children })— the mobile filter sheet (Done button).DataStrip({ items: StripItem[], dense })—StripItem { label, value, hint?, definition? (→ Hint tooltip), href?, delta?: { value, tone }, live? }; horizontal scroll < md, equal columns ≥ md.Ticker({ items: TickerItem[], speed, label })—TickerItem { id, label, href?, tone: 'new'|'price'|'warn'|'danger'|'bench'|'neutral', meta? }; pause button, paused on hover, static + scrollable underprefers-reduced-motion. Hook[data-ticker].SectionNav({ items: { id, label }[] })— sticky under the header, active section via IntersectionObserver (aria-current="location").
9.5 Charts (@/components/charts)
LineChartgainedstep?: boolean,children(extra SVG layers) andwidth;lineLayout(props)exposes scales (x y xInvert series ticks).StepChart= step mode.Sparklinegainedvariant="trend"(+invertfor lower-is-better,format,title).InteractiveLineChart(props & { xFormat })(client) — same SSR SVG + crosshair and nearest-point tooltip (pointer + touch). Hook[data-interactive-chart].ScatterChart({ points: ScatterPoint[], xScale, yScale: 'linear'|'log', xLabel, yLabel, xFormat, yFormat, frontier?: {x,y}[], highlight?: Set|string[], quadrant?: {x?,y?}, height, color, labelTop })(client) —ScatterPoint { id, x, y, r?, label, sub?, href?, color?, group? }; hover tooltip, sr-only link list. For/calculator,/open,/frontierPareto views.Heatmap({ rows, cols, cell(rowKey, colKey) → HeatCell | null, format, min, max, color, direction: 'higher'|'lower', caption, rowHeader })(server-safe) —HeatCell { value, label?, href?, title? }; sticky row/column headers inside.table-scroll, colour =color-mixof a token. For the benchmark matrix.TimelineLanes({ lanes: Lane[], events: LaneEvent[], from?, to?, height?, brushable?, onRange?(from, to), laneWidth, title })(client) —Lane { key, label, color? },LaneEvent { id, lane, at, importance?, label, href?, sub? }; dots sized by importance, hover tooltip, drag-brush. For Timeline 2.0. All charts: CSS-variable colours,tabular-nums,<title>/aria-label, no animation.
9.6 Watchlist (lib/watchlist.ts, components/watchlist/)
localStorage['aia-watchlist'] = WatchItem[] { slug, name, entity_type, org?, added_at? } (≤ WATCH_MAX = 12). useWatchlist() →
{ items, ready, add, remove, toggle, clear, has, full }; helpers readWatchlist isWatched addWatch removeWatch toggleWatch toWatchItem,
WATCH_EVENT_TYPES. WatchButton({ e, size: 'sm'|'md', label }) (star, aria-pressed, [data-watch-button]) sits next to CompareButton in
EntityPage; put it in listing rows the same way. /watchlist (app/watchlist/page.tsx, noindex) renders WatchlistView (client): items +
merged /changes?entity=<slug>&limit=20 feed filtered to material event types ([data-watchlist-items] [data-watchlist-feed]).
9.7 Homepage 3.0 (app/page.tsx, server, revalidate 60)
Compact hero → DataStrip from /stats (definitions from stats.definitions with local fallbacks) → Ticker (importance ≥ 2) → 2-column
editorial grid: Today in AI (/changes/daily sections, honesty line from backfill_excluded when present), Frontier moves (/frontier .recent_frontier_movements else importance-3 events), Benchmark leaders (/benchmarks top + trust when present) | Price moves (/prices/index
movers + median trend sparkline when ≥ 2 points), Open weights (/open?days=30 else /models?openness=open-weights&sort=release), Research
(/papers?sort=published) → TimelineLanes (12 months of NEW_MODEL/RELEASE by organization, from /timeline?category=model) → graph teaser →
12 signature products. Every fetch through safe(); every panel has an honest empty state. api.frontier/pulse/open/provenance/claim exist in
lib/api.ts (1.1) — always safe() them until the API stream ships.
9.8 QA
node qa/screens.mjs now also sweeps /, /about, /watchlist and the open palette at 320 · 360 · 390 · 430 · 768 · 1366 · 1440 · 1920 (dark + light,
overflow, console errors, touch targets ≥ 44 px on mobile widths). node qa/shell.mjs runs the flows: palette (m:claude → select, > commands →
density), More menu keyboard, density persistence, evidence drawer on the first model, watchlist add → /watchlist, favicon 16/32, brand routes.
Screenshots: qa/screens/shell-*.png, qa/screens/shell/*.png.