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 · 319 lines markdown
Rendered Raw Blame History
1# AI Atlas web — frontend guide (`apps/web`)23Next 16.3 (App Router, React 19, TypeScript strict, Tailwind v4, Geist). Server components by default; client components4only for interactivity (`'use client'`). The FastAPI service runs on `http://127.0.0.1:8331` in dev (`:8321` in prod); the5browser talks to the same origin — `/api/v1/*` and `/health` are rewritten to the API in `next.config.ts`. Root `.env` is6loaded by `next.config.ts` (`process.loadEnvFile`), so `API_URL` / `NEXT_PUBLIC_SITE_URL` live at the repo root.78```bash9pnpm install                    # repo root10cd apps/web && pnpm dev         # http://localhost:8330 (prod: pnpm start → :8320)11pnpm typecheck && pnpm build    # must both pass before you report12node qa/screens.mjs             # Playwright sweep → qa/screens/*.png (see "Verification")13```1415## 1. Principles (non-negotiable, from CLAUDE.md)1617- **Every number comes from the API.** No hardcoded counts, dates or sample rows. Missing → `<Unavailable/>` / `—` / "Unavailable".18- **Provenance is a feature.** Show source, tier, observed time (and confidence) wherever a value is displayed in detail; link to `/methodology`.19- **Estimates are labelled** (`<Estimated/>`), and never mixed with observed facts. Only hardware fit is derived.20- **Mobile first** (390 / 430 / 768 / 1440): no horizontal overflow, ≥ 44 px targets, DOM order = visual order (no `order:` tricks),21  tables stack (`.data-table.stack`) or scroll (`scroll` prop) — never overflow the page.22- **Dark and light** must both look finished; use tokens only (never raw hex in components).23- **Not a SaaS template**: hairlines and spatial composition, not stacks of rounded cards. `.panel` only for dialogs/sheets/callouts.24- Every public page: `generateMetadata` (title, description, `alternates.canonical`, OG), loading + error + empty states, source attribution.2526## 2. Design tokens (`src/app/globals.css`)2728Themes live on `<html data-theme="light|dark">` (set before paint by `THEME_SCRIPT`, persisted in `localStorage['aia-theme']`,29system by default; `ThemeToggle` cycles system → light → dark). Tokens are CSS variables exposed to Tailwind through `@theme inline`:3031| Family | Utilities | Use |32|---|---|---|33| 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 |34| Ink | `text-ink` · `text-ink-2` (secondary) · `text-ink-3` (labels, meta) | |35| Hairlines | `border-rule` · `border-rule-strong` · `.hairline` (top border) | Section separators, table rows |36| Accent | `accent` (atlas blue: links, active, focus) · `accent-ink` (text on accent) · `accent-soft` | Sparingly |37| Accent 2 | `accent-2` (amber) + `accent-2-soft` | **Money and change only** (prices, deltas) |38| Semantic | `positive` · `warning` · `danger` (+ `-soft`) | Status, health, confidence |39| Tiers | `tier-1` (green, official) · `tier-2` (blue) · `tier-3` (amber) · `tier-4` (grey) | Source quality (`TierBadge`) |40| 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>)`) |41| Charts | `series-1..8` (`var(--series-n)`) | SVG charts |4243Typography: **Geist Sans** for UI (`--font-sans`), **Geist Mono** for ids, numbers, telemetry (`.mono`). Base 15 px44(`--d-base`; 14 px in `compact`, 13.5 px in `dense` — see density below).45Utilities: `.eyebrow` (small caps label) · `.display` (headline) · `.mono` · `.tnum` (tabular numbers — use on every numeric cell)46· `.hairline` · `.link` · `.panel` (sparingly) · `.grid-bg` (hero) · `.scrollbar-thin` · `.no-scrollbar` · `.container-x`47· `.dot` / `.pulse` (live) · `.prose-atlas` · `.kv` (dense key–value grid, used by `KeyValue`)48· `.data-table` (+ `.stack` stacks rows < 768 px using `data-label`; `.compact`; `.num` right-aligned; `.primary` name cell;49  `.wide` full-row cell when stacked; `.hide-stack`) · `.table-scroll` (horizontal scroll wrapper)50· `.section-y` (density-aware section padding, used by `Section`) · `.row-y` (density-aware feed-row padding) · `.evidence`51  (dotted-underline evidence trigger) · `.hint` / `.hint-bubble` (pure-CSS tooltip) · `.ticker` / `.ticker-track` · `.heatmap` · `.terminal-rail` (sticky side rail).52Radii are deliberately small (4 px; 8 px for panels).5354**Density** (2026-09-12): `<html data-density="compact|dense">` (absent = comfortable), persisted in `localStorage['aia-density']`,55applied before paint by `DENSITY_SCRIPT` (`lib/prepaint.ts`, embedded with the theme script in `layout.tsx`). It only changes CSS56variables — `--d-base` (body font size) · `--d-cell-y` / `--d-cell-x` (`.data-table` cell padding) · `--d-kv-y` (`.kv` rows) ·57`--d-row-y` (`.row-y`) · `--d-section-y` / `--d-section-y-md` (`.section-y`) · `--d-table-fs` — so any component built on those58classes is density-aware for free. Use `.row-y` instead of `py-2.5` on feed rows. `DensityToggle` (header) and the palette59command "Toggle density" cycle comfortable → compact → dense; `useDensity()` (`lib/density.ts`) → `[density, set, cycle]`.60**Pre-paint scripts live in `lib/prepaint.ts`** (server-safe). Never import a string export from a `'use client'` module into61a server component: Next hands the server a client-reference stub (this is why `THEME_SCRIPT` moved there).6263**Brand** (2026-09-12) — the *atlas plate*: a rounded-square plate with a fine coordinate grid, three nodes + two edges drawing the64letter A as a graph, a timeline baseline whose right-hand accent node marks "now". One geometry (`components/brand/mark.tsx`:65`MARK_GEOMETRY`, `MarkArt` for in-page SVG, `markSvgString`/`markDataUri`/`MarkImg` for ImageResponse — satori cannot rasterise66nested React SVG trees, so OG/icon routes embed a data-URI `<img>`). Tokens `--brand-plate/-ink/-grid/-accent` invert per theme67(dark plate in light mode, light plate in dark mode). Assets: `public/logo.svg` (mark), `public/logo-lockup.svg` (+ `-light`),68`src/app/icon.svg` (simplified 2-line grid, crisp at 16/32), `apple-icon.tsx`, `icon-192.png/` + `icon-512.png/` routes,69`manifest.ts` (theme colours from `THEME_LIGHT/THEME_DARK` in `lib/site.ts`), `opengraph-image.tsx` (root "wallpaper" with four live70counters). `components/brand/logo.tsx`: `LogoMark({ size, variant: 'plate' | 'mono', title })`, `Wordmark({ markSize, textClassName })`71("AI" bold, "Atlas" medium). `components/brand/og.tsx`: `Frame Eyebrow Title Facts Fallback` (unchanged API) + `Wallpaper({ eyebrow,72title, subtitle, counters, footer, markPx })`, `Counters({ items })`, `Grid`, `Lockup`, `Mark` — per-type OG images (D1–D3) should use73`Wallpaper` with their own eyebrow/title/counters.7475## 3. Data layer (`src/lib`)7677- `types.ts` — mirrors `docs/API.md` (`EntitySummary`, `EntityDetail`, `Page<T>`, `ChangeEvent`, `Price`, `BenchmarkResult`,78  `Claim`, `SourceRef`, `Provenance`, `Stats`, `SearchPayload`, `DailyDigest`, `ComparePayload`, `DiffPayload`, `Methodology`, …).79  Aggregates may arrive as strings → type `Num = number | string | null`; always go through `num()` / `fmt*`.80- `api.ts` (**server only**) — `api.<route>()` for every public route, `ApiError` (`.notFound`, `.unavailable`), `safe(p)` → `null`81  on any failure, `request(path, query, { revalidate })` (ISR default 300 s; `false` = no-store). `countOfType(type)`.82- `client-api.ts` (**browser**) — `clientApi.suggest / search / changes / view` against same-origin `/api/v1`.83- `format.ts` — `num fmtInt fmt1 fmt2 fmtCompact fmtParams (70B) fmtTokens (128K) fmtUsdPerM ($3.00) fmtUsd fmtPct fmtGb fmtBytes fmtScore84  fmtDate fmtMonth fmtDateTime fmtAgo fmtYear fmtDuration titleCase humanize plural fmtValue(value, key)`; `DASH = '—'`.85- `site.ts` — `SITE_NAME SITE_URL TAGLINE DESCRIPTION CONTACT_EMAIL PUBLIC_API_BASE BOT_UA`, `routes.*` (`routes.entity(e)` gives the86  canonical URL for any entity type), `TYPE_PATH` / `PATH_TYPES` (type ↔ URL segment ↔ API mount), `typeLabel(t, plural)`,87  `TYPE_COLOR_KEY`, nav arrays (`exploreNav primaryNav moreNav`), `EVENT_TYPE_LABELS eventLabel eventTone`, `CATEGORY_LABELS categoryLabel`,88  `IMPORTANCE_LABELS`, `PROPERTY_LABELS propertyLabel URL_KEYS PROSE_KEYS`, `PREDICATE_LABELS predicateLabel`, `OPENNESS_LABELS STATUS_LABELS TIER_LABELS`, `EXAMPLE_QUERIES`.8990Page pattern:9192```tsx93const [a, b] = await Promise.all([safe(api.x()), safe(api.y())]);   // never let one panel crash the page94if (!a) return <Unavailable what="…"/>;95```9697Detail pages: `loadEntity(typePath, slug)` (`components/entity/load.ts`) → `notFound()` on API 404 **or** type mismatch;98`entityMetadata()` / `buildMetadata()` for `generateMetadata`; `permanentRedirect(routes.entity(d))` when the slug is not canonical.99Do **not** add `loading.tsx` to a segment that contains a dynamic `[slug]` route — it turns 404s into 200s (the listing is100in a route group `models/(list)/` for that reason).101102## 4. Components103104`components/ui/section.tsx` — `Container` (1280; `wide` = 1600) · `PageHeader` (eyebrow/title/lede/aside/children) · `Section`105(eyebrow/title/lede/action, `hairline`) · `Stat` (label/value/hint/delta/href) · `StatGrid` (cols 3–8) · `Note`.106`components/ui/badges.tsx` — `EntityBadge(type)` · `TierBadge(tier, withLabel)` · `ConfidenceBadge` · `StatusBadge` · `OpennessBadge`107· `ImportanceMark(0–3)` · `Chip(tone)` · `Estimated`.108`components/ui/unavailable.tsx` — `Unavailable(what, compact, reason)` · `Missing` · `EmptyState(title)`.109`components/ui/pagination.tsx` — `Pagination(total, limit, offset, makeHref)` · `withParams(base, current, patch)`.110`components/ui/tabs.tsx` (client) — `Tabs(tabs, mode='search'|'hash')` + `TabPanel(id)`; all panels are server-rendered, URL `?tab=` drives visibility.111`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.112With `slug` every value that has provenance becomes an **evidence trigger** (opens the drawer, §9); without it the output is unchanged.113`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)`.114`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.115`components/ui/hint.tsx` — `Hint({ text, align, children? })`: pure-CSS definition tooltip (server-safe), used for "How counted".116`components/ui/data-table.tsx` — `DataTable(stack|scroll|compact)` · `Th(num)` · `Td(label, num, primary, wide, hideStack)` · `EmptyRow`.117`components/ui/entity.tsx` — `EntityLink` · `EntityRow` (badge · name · org · key attributes · quality) · `keyAttributes(e)` · `QualityMark` · `EntityInline`.118`components/ui/live.tsx` (client) — `Dot(pulse)` · `LiveAgo(at)` (re-renders every 30 s).119`components/charts/` (import from `@/components/charts`) — pure SVG, theme-aware: `Sparkline(values, variant: 'line' | 'trend', invert, format)` · `Bars` · `HBars` ·120`LineChart(series, yFormat, xTime, yScale, step, children)` · `StepChart` · `Legend` · `Heatmap` · `stepPoints()` · `lineLayout()`; client: `InteractiveLineChart` · `ScatterChart` · `TimelineLanes` (see §9).121`components/changes/` — `ChangeRow(e, dense, showDate, live)` · `Delta` · `groupByDay` · `LoadMore` (cursor `before=`).122`components/listing/` — `FilterBar` (GET form → URL params) · `Facets` · `ListingLayout` (facets aside / mobile `<details>`) · `ActiveFilters` · `GenericListing(type, fetch?)`.123`components/entity/` — `EntityPage(d, canonical, related)` (header + type-aware tabs + JSON-LD + view beacon; actions row = `CompareButton` · `WatchButton` · Explore graph) and `blocks.tsx`:124`SpecTable Identity Capabilities ResultsTable(perspective) PricesTable(perspective) PriceHistory PriceSpark HardwareFitTable LineageBlock RelationsBlock EntityList ModelsTable TimelineList SourcesTable ProvenanceSummary`125(`SpecTable` and `Capabilities` pass `slug` to `KeyValue`, so their values open the evidence drawer).126`components/layout/` — `SiteHeader` (Models · Frontier · Benchmarks · Prices · Research · Graph · Changes · More ▾ | Search ⌘K | theme | density), `MobileTabBar`127(Home · Models · Changes · Search · More → grouped bottom sheet), `SiteFooter` (4 columns + credits line), `SearchDialog` (command palette), `DensityToggle`, `terminal.tsx` primitives (§9), `ViewBeacon(path)`.128`components/evidence/`, `components/watchlist/` — see §9.129`components/meta/sitemap-data.ts` — sitemap shards (`static`, `<type>-<n>` of 5 000 from `GET /sitemap`).130131Do not fork these; extend with props or add new components in your own folder.132133## 5. Routes built134135`/` · `/search` · `/models` (+ facets, sort, Compare buttons) · `/models/[slug]` (+ `opengraph-image`) · `/companies` · `/companies/[slug]` (+ `opengraph-image`)136· `/[type]/[slug]` (providers, hardware, papers, frameworks, datasets, tools, repositories) · `/benchmarks/[slug]` (dedicated leaderboard — static segment shadows137`[type]/[slug]`) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` · `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` (typed tables)138· `/changes` · `/changes/[date]` · `/timeline?year=&category=&entity=` · `/prices?days=&sort=&provider=&model=&scale=` · `/compare?ids=` (picker + tray + matrix)139· `/hardware/fit?memory_gb=&quant=&context=&fits=` · `/diff?a=&b=&scope=` · `/graph/[slug]?depth=` · `/admin/**` (token-gated, noindex) · `/methodology` · `/sources`140· `/about` · `/developers` (live example responses) · `/bot` · `robots.ts` (disallows `/admin/`) · `/sitemap.xml` + `/sitemap/[shard].xml` · `manifest.ts`141· `icon-192.png` / `icon-512.png` (ImageResponse routes) · `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`.142143## 6. Wave 2 (2026-09-11) — components and page patterns added144145All 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.146147### Compare (`components/compare/`)148- `compare-store.ts` (client) — the **compare tray**: `localStorage['aia-compare']` (≤ 6 `TrayItem { slug, name, entity_type, organization? }`), same-tab149  `aia-compare-change` event + cross-tab `storage`. `useCompareTray()` → `{ items, ready, add, remove, toggle, clear, replace, has, type, full, canCompare }`;150  helpers `addToTray/removeFromTray/toggleTray/readTray/clearTray`, `trayType(t)` (company-like → `company`, library/runtime → `framework`, quantization → `model`),151  `compareHref(items)`. The tray is type-homogeneous: adding another type **replaces** it (the API compares 2–6 entities of one type).152- `CompareButton({ e, size: 'sm' | 'md' })` (client) — toggle with `aria-pressed`; used in entity headers, `/models`, `/providers`, `/hardware`, `/frameworks`,153  leaderboards and hardware-fit rows. `CompareTrayBar` (client) — fixed bar above the mobile tab bar / bottom-right on desktop, hidden when empty and on `/compare`;154  mount it **once per page** that shows Compare buttons.155- `ComparePicker` + `CompareTray` (client, `compare-picker.tsx`) — `/search/suggest` autocomplete filtered to the tray's type, chips with remove/clear, "Compare n →";156  seeds the tray from `?ids=` (URL wins) and mirrors tray changes back with `router.replace`.157- `CompareMatrix`, `SharedBenchmarks`, `ComparePrices` (server, `compare-matrix.tsx`) — dimension × entity table (sticky first column inside `.table-scroll`,158  `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 for159  every model, cheapest input/output per provider × entity. Gotcha: `.table-scroll .data-table td { white-space: nowrap }` out-specifies Tailwind160  `whitespace-normal` — use an inline style for a wrapping sticky column.161162### Timeline & prices163- `components/timeline/chip-row.tsx` — `ChipRow` (server link chips: years, categories, windows). Years derive from `stats.first_entity_at` and the months returned.164- `/timeline` — `Bars` month-density strip, sticky month headings, `ChangeRow showDate live={false}`, dynamic metadata (entity views are `noindex`).165- `components/prices/` — `ScaleToggle` (client: linear/log, `aria-pressed`, mirrors `?scale=log` with `replaceState`; the page renders both charts server-side),166  `PriceMovers` (PRICE_CHANGED events as a table; their `old_value/new_value` are objects, so `Delta` would JSON-dump them — format them yourself).167- `LineChart` gained `yScale?: 'linear' | 'log'` (d3 `scaleLog`, points ≤ 0 dropped). Existing calls are unchanged.168- `/prices?model=` and `provider=` take **slugs** (API 404s on free text) — label fields accordingly.169170### Benchmarks & hardware171- `components/benchmarks/leaderboard.tsx` — `Leaderboard` (rank, model, score + relative bar in `var(--type-benchmark)`, config summary, evaluated, source,172  "History" link, `CompareButton`, `Pagination`), `ConfigChips` + `configChips(rows)` (`?config=`), `HistoryChart` (per-model `LineChart`, honest when < 2 points).173  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).174- `/benchmarks/[slug]` tabs: Leaderboard · Definition (`KeyValue` with provenance) · Relations · History (`HistoryPanel`) · Timeline · Sources.175- `components/hardware/fit-form.tsx` — `FitForm` (GET form: memory preset `<select>` with Apple/NVIDIA optgroups + free numeric field, `quant`, `context`;176  exports `APPLE_PRESETS NVIDIA_PRESETS QUANTS CONTEXTS`). `/hardware/fit` shows `Estimated` prominently, the API `assumptions`, `counts.fits/evaluated`,177  `?fits=1` (filtered in the page — the API has no such param). Hardware entity headers link "What can this run?" prefilled with `memory_gb`.178179### Typed listings (`components/listing/typed-listing.tsx`)180- `TypedListing({ title, eyebrow, lede, basePath, searchParams, fetch, columns: Column[], sorts, filters, emptyTitle, emptyHint, note, compare, headerAside })` — column-driven paged table (FilterBar + Pagination +181  Unavailable/EmptyState) used by `/papers /frameworks /datasets /tools`; helpers `Dash str list`. `/explore/{type}` supports `sort=updated|name|quality|first_seen|release|stars`.182  Keep `GenericListing` for row-style lists; it now accepts `rowTrailing(e)` and `headerAside`.183184### History mode, diff, graph185- `EntityPage` is now `async` and takes `asof?` and `historyProperty?` (pages pass `searchParams.asof/property`). Header actions row: `CompareButton`,186  "Explore graph" → `routes.graph(slug)`, hardware-only "What can this run?". New **History** tab (before Timeline) → `components/entity/history.tsx`:187  `HistoryPanel({ d, asof, asofPayload, claims, property })` = `AsOfPicker` (client date input, `router.replace` to `?tab=history&asof=`) + `AsOfBlock`188  (banner "Viewing AI Atlas as of …" on `bg-accent-soft`, `existed: false` state, attributes as known then via `KeyValue`) + `ClaimHistory` (claims grouped by189  property: value, valid_from → valid_to/"current", source, tier, confidence, extractor, status chip — `conflicting` rows in danger with `border-l-2`;190  `?property=` filters via `api.entityHistory(slug, property)`). Fetch history always for the tab; as-of only when `?asof`.191- `/diff` — GET form (from/to dates, scope select `all | models | org:<slug>` from `/companies?sort=models`, custom `family:<name>`), `StatGrid` from `counts`,192  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.193- `/graph/[slug]` + `components/graph/graph-explorer.tsx` (client) — d3-force layout run after mount (server renders the frame only → stable SSR), nodes coloured194  `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,195  cap 80 nodes + note, type and predicate legends, accessible text fallback list. `routes.graph(slug, depth)`.196197### Admin (`/admin`, `lib/admin/`, `components/admin/`)198- Cookie `aia-admin` (httpOnly, sameSite=lax, secure in prod, `path=/admin`, 12 h) set by the `loginAction` server action after validating the token against199  `GET /admin/overview`. `lib/admin/admin-api.ts` (`server-only`): `adminRequest` adds `x-aia-admin-token` from the cookie, `no-store`; 401/403 → `AdminAuthError`200  → `redirect('/admin?expired=1')`; `requireAdmin()`; typed `adminApi.*` for every admin route. **The token never reaches the client** (grep the HTML for it in QA).201- `lib/admin/actions.ts` (`'use server'`): login/logout, run/toggle connector, retry job, requeue dead, review approve/reject/keep-side, merge, flush cache,202  recompute stats/quality — each revalidates and redirects back with `?notice=&level=` (works without JS).203- `components/admin/`: `shell.tsx` (header + sticky left nav ≥ lg), `nav-select.tsx` (mobile select), `login-form.tsx` (`useActionState`), `ui.tsx`204  (`StatusChip KindChip Bool Notice JsonPre ActionButton AdminTitle AdminFilters Mono Trunc`). Pages: overview, connectors, runs, errors, documents(+[id]),205  snapshots/[id], jobs, llm-jobs, review, entities/duplicates, infrastructure, cache — all `force-dynamic`, `robots: noindex`, `DataTable compact` (+ `scroll`).206- Conflict review items carry no claim ids; "Keep this" resolves the claim through `/entities/{slug}/history?property=` before approving with `resolution.keep_claim_id`.207208### Polish209- `manifest.ts` (standalone, shortcuts, svg + 192/512 PNG icons from `icon-192.png/route.tsx` + `icon-512.png/route.tsx`), `components/brand/og.tsx`210  (`Frame Eyebrow Title Facts Fallback` — shared OG chrome; hex constants are allowed there like in the other ImageResponse files), per-type `opengraph-image.tsx`211  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`212  and one model response plus Compare/Diff/history/asof/graph/fit/price-index rows and response conventions.213214### Left for the next agent215- 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.216- `/prices/index` needs ≥ 2 daily snapshots before the chart draws (the DB is one day old); movers and the offers table already work.217- Per-model price sparkline in `/models` rows (`PriceSpark` exists) and pan/zoom on the graph remain optional.218219## 7. Verification before you report2202211. `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).2222. 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**223   `node qa/screens-wave2.mjs [BASE] [API] [ADMIN_TOKEN]` (wave-2 routes + flows: compare tray, hardware-fit submit, admin login → connectors → Run now,224   diff with two dates, graph nodes; slugs discovered live). Both check HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth`225   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/`.2263. Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest.2274. `curl -sI localhost:8330/<type>/does-not-exist` → 404. `curl -s localhost:8330/admin | grep -c <token>` → 0.228229## 8. API notes observed live (2026-09-11) — keep in mind230231- Provider slugs can collide with company slugs (`anthropic` company vs `anthropic-2` provider "Anthropic API").232- `EntitySummary.status` may be `available` while `attributes.status` is `active`; `StatusBadge` shows unknown values neutrally.233- `quality` and `counts` are often `{}` (score not computed yet) — `QualityMark` renders nothing then.234- `provenance[*]` currently lacks `source_name`; the UI falls back to the URL host. `extractor` is `llm` or `deterministic`.235- `/search` `query` is flat: `{ text, entity_type, openness, params_min, filters: { residual }, semantic }` — `understood()` in `search/page.tsx` renders it.236- `/methodology.event_types` items are `{ event_type, category, count, last_seen_at }` (no label/importance); `metrics` is `[]`.237238## 9. Terminal shell (2026-09-12, stream D0) — contract for the page streams D1–D3239240Everything below is built and QA'd (`qa/screens.mjs` shell sweep 320…1920 dark+light, `qa/shell.mjs` flows). Import paths are exact.241Pages must not fork these; extend with props. Every new route must exist in `lib/site.ts` (`routes.*`, nav arrays) — the shell already242links to `/frontier /prices /graph /open /families /families/[slug] /time-machine /diff /pulse /calculator /run-locally /find-a-model243/artifacts/[slug] /agents /licenses /claims/[id] /watchlist` (`routes.frontier() pulse() open() families() family(slug) timeMachine(date?)244calculator() runLocally() findAModel() artifact(slug) agents() licenses() claim(id) watchlist() benchmarkMatrix() entityHistory(e, property?) graph(slug?)`).245`TYPE_PATH` gained `model_family → families`, `artifact → artifacts`, `license → licenses` (+ `PATH_TYPES`, labels, colours).246247### 9.1 Navigation (`lib/site.ts`)248`primaryNav` (7 desktop items) · `moreGroups: NavGroup[]` (Explore / Intelligence / Temporal / About — desktop More panel and mobile sheet) ·249`moreNav` (flat) · `mobileTabs` · `footerGroups` · `signatureProducts` (12 homepage links) · `PALETTE_PREFIXES` (`m: b: o: p: h:`).250Constants: `TAGLINE` = "The temporal knowledge graph of the AI ecosystem", `AUTHOR_NAME`, `HOST_NAME`, `HOST_URL`, `THEME_LIGHT`, `THEME_DARK`.251Header/menu QA hooks: `[data-more-button] [data-more-menu] [data-open-palette] [data-density-toggle] [data-mobile-more]`.252253### 9.2 Command palette (`components/layout/search-dialog.tsx`, opened by `useOpenSearch()` from `search-context.tsx`)254⌘K / Ctrl+K / `/`. Sections: Commands (fuzzy: prefix > word prefix > substring > subsequence) · Entities (`/search/suggest`, filtered255client-side by prefix type) · Recent (`localStorage['aia-recent']`, last 8) · "Search everything". `>` = commands only; `m:` `b:` `o:` `p:` `h:`256restrict entities. Commands with an argument ("Open benchmark …", "Find provider …", "Explore lineage of …" → graph) switch the input to the257prefix. To add a command: append to `COMMANDS` (`{ id, label, hint, keywords, href | prefix (+then) | action }`). Hooks: `[data-palette]258[data-palette-input] [data-palette-row="entity|command|recent|search"]`.259260### 9.3 Evidence drawer (`components/evidence`)261```tsx262import { Evidence, useEvidence, type EvidenceTarget } from '@/components/evidence';263<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 }}>264  {children /* rendered as a dotted-underline button, data-evidence="slug:property" */}265</Evidence>266const { open, close, target } = useEvidence();   // open({ slug, property, value?, display?, unit?, label?, fallback?, entity? })267```268`EvidenceProvider` + `EvidenceDrawer` are mounted once in `app/layout.tsx`; URL hash `#evidence=<slug>:<property>` opens the drawer on load.269Data order: `GET /entities/{slug}/provenance/{property}` (1.1, `clientApi.provenance`) → 404 → `GET /entities/{slug}/history?property=`270(`clientApi.history`: claim id, valid_from/to, conflicts) → the `fallback` `ProvenanceEntry`. Content: VALUE · SOURCE · TIER · OBSERVED ·271EXTRACTOR · CONFIDENCE · VALID SINCE · conflicts · View source · View history (`routes.entityHistory(e, property)`) · View conflicts · claim id.272Cheapest integration for a page: pass `slug` (+ `entity`) to `KeyValue`, or `slug` + `property` to `ProvenanceInline`. For table cells wrap the273value in `<Evidence …>`. Types: `ProvenanceDetail` (`lib/types.ts`), `clientApi.provenance/history` (`lib/client-api.ts`, `ClientApiError.status`).274275### 9.4 Terminal layout primitives (`components/layout/terminal.tsx`, client)276- `TerminalLayout({ filters?, children, inspector?, filtersTitle, inspectorTitle, storageKey, filterCount, wide })` — desktop 3-pane277  (filter rail 15rem sticky · main · inspector 22rem sticky, collapsible, state in `localStorage[storageKey]`); mobile: toolbar buttons278  `[data-open-filters] [data-open-inspector]` open bottom sheets. Filters/inspector are server-rendered nodes passed as props (GET forms work).279- `FilterSheet({ open, onClose, title, children })` — the mobile filter sheet (Done button).280- `DataStrip({ items: StripItem[], dense })` — `StripItem { label, value, hint?, definition? (→ Hint tooltip), href?, delta?: { value, tone }, live? }`;281  horizontal scroll < md, equal columns ≥ md.282- `Ticker({ items: TickerItem[], speed, label })` — `TickerItem { id, label, href?, tone: 'new'|'price'|'warn'|'danger'|'bench'|'neutral', meta? }`;283  pause button, paused on hover, static + scrollable under `prefers-reduced-motion`. Hook `[data-ticker]`.284- `SectionNav({ items: { id, label }[] })` — sticky under the header, active section via IntersectionObserver (`aria-current="location"`).285286### 9.5 Charts (`@/components/charts`)287- `LineChart` gained `step?: boolean`, `children` (extra SVG layers) and `width`; `lineLayout(props)` exposes scales (`x y xInvert series ticks`).288  `StepChart` = step mode. `Sparkline` gained `variant="trend"` (+ `invert` for lower-is-better, `format`, `title`).289- `InteractiveLineChart(props & { xFormat })` (client) — same SSR SVG + crosshair and nearest-point tooltip (pointer + touch). Hook `[data-interactive-chart]`.290- `ScatterChart({ points: ScatterPoint[], xScale, yScale: 'linear'|'log', xLabel, yLabel, xFormat, yFormat, frontier?: {x,y}[], highlight?: Set|string[], quadrant?: {x?,y?}, height, color, labelTop })`291  (client) — `ScatterPoint { id, x, y, r?, label, sub?, href?, color?, group? }`; hover tooltip, sr-only link list. For `/calculator`, `/open`, `/frontier` Pareto views.292- `Heatmap({ rows, cols, cell(rowKey, colKey) → HeatCell | null, format, min, max, color, direction: 'higher'|'lower', caption, rowHeader })` (server-safe) —293  `HeatCell { value, label?, href?, title? }`; sticky row/column headers inside `.table-scroll`, colour = `color-mix` of a token. For the benchmark matrix.294- `TimelineLanes({ lanes: Lane[], events: LaneEvent[], from?, to?, height?, brushable?, onRange?(from, to), laneWidth, title })` (client) —295  `Lane { key, label, color? }`, `LaneEvent { id, lane, at, importance?, label, href?, sub? }`; dots sized by importance, hover tooltip, drag-brush. For Timeline 2.0.296All charts: CSS-variable colours, `tabular-nums`, `<title>`/`aria-label`, no animation.297298### 9.6 Watchlist (`lib/watchlist.ts`, `components/watchlist/`)299`localStorage['aia-watchlist']` = `WatchItem[] { slug, name, entity_type, org?, added_at? }` (≤ `WATCH_MAX` = 12). `useWatchlist()` →300`{ items, ready, add, remove, toggle, clear, has, full }`; helpers `readWatchlist isWatched addWatch removeWatch toggleWatch toWatchItem`,301`WATCH_EVENT_TYPES`. `WatchButton({ e, size: 'sm'|'md', label })` (star, `aria-pressed`, `[data-watch-button]`) sits next to `CompareButton` in302`EntityPage`; put it in listing rows the same way. `/watchlist` (`app/watchlist/page.tsx`, noindex) renders `WatchlistView` (client): items +303merged `/changes?entity=<slug>&limit=20` feed filtered to material event types (`[data-watchlist-items] [data-watchlist-feed]`).304305### 9.7 Homepage 3.0 (`app/page.tsx`, server, revalidate 60)306Compact hero → `DataStrip` from `/stats` (definitions from `stats.definitions` with local fallbacks) → `Ticker` (importance ≥ 2) → 2-column307editorial grid: Today in AI (`/changes/daily` sections, honesty line from `backfill_excluded` when present), Frontier moves (`/frontier308.recent_frontier_movements` else importance-3 events), Benchmark leaders (`/benchmarks` top + trust when present) | Price moves (`/prices/index`309movers + median trend sparkline when ≥ 2 points), Open weights (`/open?days=30` else `/models?openness=open-weights&sort=release`), Research310(`/papers?sort=published`) → `TimelineLanes` (12 months of NEW_MODEL/RELEASE by organization, from `/timeline?category=model`) → graph teaser →31112 signature products. Every fetch through `safe()`; every panel has an honest empty state. `api.frontier/pulse/open/provenance/claim` exist in312`lib/api.ts` (1.1) — always `safe()` them until the API stream ships.313314### 9.8 QA315`node qa/screens.mjs` now also sweeps `/`, `/about`, `/watchlist` and the open palette at 320 · 360 · 390 · 430 · 768 · 1366 · 1440 · 1920 (dark + light,316overflow, console errors, touch targets ≥ 44 px on mobile widths). `node qa/shell.mjs` runs the flows: palette (`m:claude` → select, `>` commands →317density), More menu keyboard, density persistence, evidence drawer on the first model, watchlist add → `/watchlist`, favicon 16/32, brand routes.318Screenshots: `qa/screens/shell-*.png`, `qa/screens/shell/*.png`.319