SPB Git forge

spb/ai-atlas

Public
41commits 1branches 0releases
4.6 MBsize
maindefault branch
12 days agolast push
HTML 77.2% TypeScript 10.5% Python 9.6% JavaScript 2.5%
33.0 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 (--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 — 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, 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'] (≤ 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 [].

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

tsx
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 in localStorage[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 under prefers-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)

  • LineChart gained step?: boolean, children (extra SVG layers) and width; lineLayout(props) exposes scales (x y xInvert series ticks). StepChart = step mode. Sparkline gained variant="trend" (+ invert for 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, /frontier Pareto 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-mix of 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.