SPB Git

spb/ai-atlas

Public
20.7 KB · 207 lines markdown
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 px.44Utilities: `.eyebrow` (small caps label) · `.display` (headline) · `.mono` · `.tnum` (tabular numbers — use on every numeric cell)45· `.hairline` · `.link` · `.panel` (sparingly) · `.grid-bg` (hero) · `.scrollbar-thin` · `.no-scrollbar` · `.container-x`46· `.dot` / `.pulse` (live) · `.prose-atlas` · `.kv` (dense key–value grid, used by `KeyValue`)47· `.data-table` (+ `.stack` stacks rows < 768 px using `data-label`; `.compact`; `.num` right-aligned; `.primary` name cell;48  `.wide` full-row cell when stacked; `.hide-stack`) · `.table-scroll` (horizontal scroll wrapper).49Radii are deliberately small (4 px; 8 px for panels). Brand: `public/logo.svg`, `src/app/icon.svg`, `apple-icon.tsx`,50`opengraph-image.tsx` (live counters when the API answers), `components/brand/logo.tsx` (`LogoMark`, `Wordmark`).5152## 3. Data layer (`src/lib`)5354- `types.ts` — mirrors `docs/API.md` (`EntitySummary`, `EntityDetail`, `Page<T>`, `ChangeEvent`, `Price`, `BenchmarkResult`,55  `Claim`, `SourceRef`, `Provenance`, `Stats`, `SearchPayload`, `DailyDigest`, `ComparePayload`, `DiffPayload`, `Methodology`, …).56  Aggregates may arrive as strings → type `Num = number | string | null`; always go through `num()` / `fmt*`.57- `api.ts` (**server only**) — `api.<route>()` for every public route, `ApiError` (`.notFound`, `.unavailable`), `safe(p)` → `null`58  on any failure, `request(path, query, { revalidate })` (ISR default 300 s; `false` = no-store). `countOfType(type)`.59- `client-api.ts` (**browser**) — `clientApi.suggest / search / changes / view` against same-origin `/api/v1`.60- `format.ts` — `num fmtInt fmt1 fmt2 fmtCompact fmtParams (70B) fmtTokens (128K) fmtUsdPerM ($3.00) fmtUsd fmtPct fmtGb fmtBytes fmtScore61  fmtDate fmtMonth fmtDateTime fmtAgo fmtYear fmtDuration titleCase humanize plural fmtValue(value, key)`; `DASH = '—'`.62- `site.ts` — `SITE_NAME SITE_URL TAGLINE DESCRIPTION CONTACT_EMAIL PUBLIC_API_BASE BOT_UA`, `routes.*` (`routes.entity(e)` gives the63  canonical URL for any entity type), `TYPE_PATH` / `PATH_TYPES` (type ↔ URL segment ↔ API mount), `typeLabel(t, plural)`,64  `TYPE_COLOR_KEY`, nav arrays (`exploreNav primaryNav moreNav`), `EVENT_TYPE_LABELS eventLabel eventTone`, `CATEGORY_LABELS categoryLabel`,65  `IMPORTANCE_LABELS`, `PROPERTY_LABELS propertyLabel URL_KEYS PROSE_KEYS`, `PREDICATE_LABELS predicateLabel`, `OPENNESS_LABELS STATUS_LABELS TIER_LABELS`, `EXAMPLE_QUERIES`.6667Page pattern:6869```tsx70const [a, b] = await Promise.all([safe(api.x()), safe(api.y())]);   // never let one panel crash the page71if (!a) return <Unavailable what="…"/>;72```7374Detail pages: `loadEntity(typePath, slug)` (`components/entity/load.ts`) → `notFound()` on API 404 **or** type mismatch;75`entityMetadata()` / `buildMetadata()` for `generateMetadata`; `permanentRedirect(routes.entity(d))` when the slug is not canonical.76Do **not** add `loading.tsx` to a segment that contains a dynamic `[slug]` route — it turns 404s into 200s (the listing is77in a route group `models/(list)/` for that reason).7879## 4. Components8081`components/ui/section.tsx` — `Container` (1280; `wide` = 1600) · `PageHeader` (eyebrow/title/lede/aside/children) · `Section`82(eyebrow/title/lede/action, `hairline`) · `Stat` (label/value/hint/delta/href) · `StatGrid` (cols 3–8) · `Note`.83`components/ui/badges.tsx` — `EntityBadge(type)` · `TierBadge(tier, withLabel)` · `ConfidenceBadge` · `StatusBadge` · `OpennessBadge`84· `ImportanceMark(0–3)` · `Chip(tone)` · `Estimated`.85`components/ui/unavailable.tsx` — `Unavailable(what, compact, reason)` · `Missing` · `EmptyState(title)`.86`components/ui/pagination.tsx` — `Pagination(total, limit, offset, makeHref)` · `withParams(base, current, patch)`.87`components/ui/tabs.tsx` (client) — `Tabs(tabs, mode='search'|'hash')` + `TabPanel(id)`; all panels are server-rendered, URL `?tab=` drives visibility.88`components/ui/key-value.tsx` — `KeyValue(rows, provenance)`: dense spec `<dl>`; each row `{ key, raw | value, label?, hint? }`, URLs auto-linked, provenance line under each value.89`components/ui/provenance.tsx` — `ProvenanceInline(p)` ("Source: host · T1 · observed 3 h ago · high · LLM-extracted") · `SourceCell(url, tier, observedAt)`.90`components/ui/data-table.tsx` — `DataTable(stack|scroll|compact)` · `Th(num)` · `Td(label, num, primary, wide, hideStack)` · `EmptyRow`.91`components/ui/entity.tsx` — `EntityLink` · `EntityRow` (badge · name · org · key attributes · quality) · `keyAttributes(e)` · `QualityMark` · `EntityInline`.92`components/ui/live.tsx` (client) — `Dot(pulse)` · `LiveAgo(at)` (re-renders every 30 s).93`components/charts/charts.tsx` — pure SVG, theme-aware: `Sparkline` · `Bars` · `HBars` · `LineChart(series, yFormat, xTime)` · `Legend` · `stepPoints()` for price steps.94`components/changes/` — `ChangeRow(e, dense, showDate, live)` · `Delta` · `groupByDay` · `LoadMore` (cursor `before=`).95`components/listing/` — `FilterBar` (GET form → URL params) · `Facets` · `ListingLayout` (facets aside / mobile `<details>`) · `ActiveFilters` · `GenericListing(type, fetch?)`.96`components/entity/` — `EntityPage(d, canonical, related)` (header + type-aware tabs + JSON-LD + view beacon) and `blocks.tsx`:97`SpecTable Identity Capabilities ResultsTable(perspective) PricesTable(perspective) PriceHistory PriceSpark HardwareFitTable LineageBlock RelationsBlock EntityList ModelsTable TimelineList SourcesTable ProvenanceSummary`.98`components/layout/` — header (Explore ▾, ⌘K search → `/search/suggest`, theme toggle), `MobileTabBar` (Home · Explore · Changes · Search · More), `SiteFooter`, `SearchDialog`, `ViewBeacon(path)`.99`components/meta/sitemap-data.ts` — sitemap shards (`static`, `<type>-<n>` of 5 000 from `GET /sitemap`).100101Do not fork these; extend with props or add new components in your own folder.102103## 5. Routes built104105`/` · `/search` · `/models` (+ facets, sort, Compare buttons) · `/models/[slug]` (+ `opengraph-image`) · `/companies` · `/companies/[slug]` (+ `opengraph-image`)106· `/[type]/[slug]` (providers, hardware, papers, frameworks, datasets, tools, repositories) · `/benchmarks/[slug]` (dedicated leaderboard — static segment shadows107`[type]/[slug]`) · `/explore` · `/explore/[type]` · `/explore/[type]/[slug]` · `/papers /providers /benchmarks /hardware /frameworks /datasets /tools` (typed tables)108· `/changes` · `/changes/[date]` · `/timeline?year=&category=&entity=` · `/prices?days=&sort=&provider=&model=&scale=` · `/compare?ids=` (picker + tray + matrix)109· `/hardware/fit?memory_gb=&quant=&context=&fits=` · `/diff?a=&b=&scope=` · `/graph/[slug]?depth=` · `/admin/**` (token-gated, noindex) · `/methodology` · `/sources`110· `/about` · `/developers` (live example responses) · `/bot` · `robots.ts` (disallows `/admin/`) · `/sitemap.xml` + `/sitemap/[shard].xml` · `manifest.ts`111· `icon-192.png` / `icon-512.png` (ImageResponse routes) · `not-found.tsx` · `error.tsx` · `icon.svg` · `apple-icon` · `opengraph-image`.112113## 6. Wave 2 (2026-09-11) — components and page patterns added114115All 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.116117### Compare (`components/compare/`)118- `compare-store.ts` (client) — the **compare tray**: `localStorage['aia-compare']` (≤ 6 `TrayItem { slug, name, entity_type, organization? }`), same-tab119  `aia-compare-change` event + cross-tab `storage`. `useCompareTray()` → `{ items, ready, add, remove, toggle, clear, replace, has, type, full, canCompare }`;120  helpers `addToTray/removeFromTray/toggleTray/readTray/clearTray`, `trayType(t)` (company-like → `company`, library/runtime → `framework`, quantization → `model`),121  `compareHref(items)`. The tray is type-homogeneous: adding another type **replaces** it (the API compares 2–6 entities of one type).122- `CompareButton({ e, size: 'sm' | 'md' })` (client) — toggle with `aria-pressed`; used in entity headers, `/models`, `/providers`, `/hardware`, `/frameworks`,123  leaderboards and hardware-fit rows. `CompareTrayBar` (client) — fixed bar above the mobile tab bar / bottom-right on desktop, hidden when empty and on `/compare`;124  mount it **once per page** that shows Compare buttons.125- `ComparePicker` + `CompareTray` (client, `compare-picker.tsx`) — `/search/suggest` autocomplete filtered to the tray's type, chips with remove/clear, "Compare n →";126  seeds the tray from `?ids=` (URL wins) and mirrors tray changes back with `router.replace`.127- `CompareMatrix`, `SharedBenchmarks`, `ComparePrices` (server, `compare-matrix.tsx`) — dimension × entity table (sticky first column inside `.table-scroll`,128  `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 for129  every model, cheapest input/output per provider × entity. Gotcha: `.table-scroll .data-table td { white-space: nowrap }` out-specifies Tailwind130  `whitespace-normal` — use an inline style for a wrapping sticky column.131132### Timeline & prices133- `components/timeline/chip-row.tsx` — `ChipRow` (server link chips: years, categories, windows). Years derive from `stats.first_entity_at` and the months returned.134- `/timeline` — `Bars` month-density strip, sticky month headings, `ChangeRow showDate live={false}`, dynamic metadata (entity views are `noindex`).135- `components/prices/` — `ScaleToggle` (client: linear/log, `aria-pressed`, mirrors `?scale=log` with `replaceState`; the page renders both charts server-side),136  `PriceMovers` (PRICE_CHANGED events as a table; their `old_value/new_value` are objects, so `Delta` would JSON-dump them — format them yourself).137- `LineChart` gained `yScale?: 'linear' | 'log'` (d3 `scaleLog`, points ≤ 0 dropped). Existing calls are unchanged.138- `/prices?model=` and `provider=` take **slugs** (API 404s on free text) — label fields accordingly.139140### Benchmarks & hardware141- `components/benchmarks/leaderboard.tsx` — `Leaderboard` (rank, model, score + relative bar in `var(--type-benchmark)`, config summary, evaluated, source,142  "History" link, `CompareButton`, `Pagination`), `ConfigChips` + `configChips(rows)` (`?config=`), `HistoryChart` (per-model `LineChart`, honest when < 2 points).143  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).144- `/benchmarks/[slug]` tabs: Leaderboard · Definition (`KeyValue` with provenance) · Relations · History (`HistoryPanel`) · Timeline · Sources.145- `components/hardware/fit-form.tsx` — `FitForm` (GET form: memory preset `<select>` with Apple/NVIDIA optgroups + free numeric field, `quant`, `context`;146  exports `APPLE_PRESETS NVIDIA_PRESETS QUANTS CONTEXTS`). `/hardware/fit` shows `Estimated` prominently, the API `assumptions`, `counts.fits/evaluated`,147  `?fits=1` (filtered in the page — the API has no such param). Hardware entity headers link "What can this run?" prefilled with `memory_gb`.148149### Typed listings (`components/listing/typed-listing.tsx`)150- `TypedListing({ title, eyebrow, lede, basePath, searchParams, fetch, columns: Column[], sorts, filters, emptyTitle, emptyHint, note, compare, headerAside })` — column-driven paged table (FilterBar + Pagination +151  Unavailable/EmptyState) used by `/papers /frameworks /datasets /tools`; helpers `Dash str list`. `/explore/{type}` supports `sort=updated|name|quality|first_seen|release|stars`.152  Keep `GenericListing` for row-style lists; it now accepts `rowTrailing(e)` and `headerAside`.153154### History mode, diff, graph155- `EntityPage` is now `async` and takes `asof?` and `historyProperty?` (pages pass `searchParams.asof/property`). Header actions row: `CompareButton`,156  "Explore graph" → `routes.graph(slug)`, hardware-only "What can this run?". New **History** tab (before Timeline) → `components/entity/history.tsx`:157  `HistoryPanel({ d, asof, asofPayload, claims, property })` = `AsOfPicker` (client date input, `router.replace` to `?tab=history&asof=`) + `AsOfBlock`158  (banner "Viewing AI Atlas as of …" on `bg-accent-soft`, `existed: false` state, attributes as known then via `KeyValue`) + `ClaimHistory` (claims grouped by159  property: value, valid_from → valid_to/"current", source, tier, confidence, extractor, status chip — `conflicting` rows in danger with `border-l-2`;160  `?property=` filters via `api.entityHistory(slug, property)`). Fetch history always for the tab; as-of only when `?asof`.161- `/diff` — GET form (from/to dates, scope select `all | models | org:<slug>` from `/companies?sort=models`, custom `family:<name>`), `StatGrid` from `counts`,162  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.163- `/graph/[slug]` + `components/graph/graph-explorer.tsx` (client) — d3-force layout run after mount (server renders the frame only → stable SSR), nodes coloured164  `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,165  cap 80 nodes + note, type and predicate legends, accessible text fallback list. `routes.graph(slug, depth)`.166167### Admin (`/admin`, `lib/admin/`, `components/admin/`)168- Cookie `aia-admin` (httpOnly, sameSite=lax, secure in prod, `path=/admin`, 12 h) set by the `loginAction` server action after validating the token against169  `GET /admin/overview`. `lib/admin/admin-api.ts` (`server-only`): `adminRequest` adds `x-aia-admin-token` from the cookie, `no-store`; 401/403 → `AdminAuthError`170  → `redirect('/admin?expired=1')`; `requireAdmin()`; typed `adminApi.*` for every admin route. **The token never reaches the client** (grep the HTML for it in QA).171- `lib/admin/actions.ts` (`'use server'`): login/logout, run/toggle connector, retry job, requeue dead, review approve/reject/keep-side, merge, flush cache,172  recompute stats/quality — each revalidates and redirects back with `?notice=&level=` (works without JS).173- `components/admin/`: `shell.tsx` (header + sticky left nav ≥ lg), `nav-select.tsx` (mobile select), `login-form.tsx` (`useActionState`), `ui.tsx`174  (`StatusChip KindChip Bool Notice JsonPre ActionButton AdminTitle AdminFilters Mono Trunc`). Pages: overview, connectors, runs, errors, documents(+[id]),175  snapshots/[id], jobs, llm-jobs, review, entities/duplicates, infrastructure, cache — all `force-dynamic`, `robots: noindex`, `DataTable compact` (+ `scroll`).176- Conflict review items carry no claim ids; "Keep this" resolves the claim through `/entities/{slug}/history?property=` before approving with `resolution.keep_claim_id`.177178### Polish179- `manifest.ts` (standalone, shortcuts, svg + 192/512 PNG icons from `icon-192.png/route.tsx` + `icon-512.png/route.tsx`), `components/brand/og.tsx`180  (`Frame Eyebrow Title Facts Fallback` — shared OG chrome; hex constants are allowed there like in the other ImageResponse files), per-type `opengraph-image.tsx`181  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`182  and one model response plus Compare/Diff/history/asof/graph/fit/price-index rows and response conventions.183184### Left for the next agent185- 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.186- `/prices/index` needs ≥ 2 daily snapshots before the chart draws (the DB is one day old); movers and the offers table already work.187- Per-model price sparkline in `/models` rows (`PriceSpark` exists) and pan/zoom on the graph remain optional.188189## 7. Verification before you report1901911. `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).1922. 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**193   `node qa/screens-wave2.mjs [BASE] [API] [ADMIN_TOKEN]` (wave-2 routes + flows: compare tray, hardware-fit submit, admin login → connectors → Run now,194   diff with two dates, graph nodes; slugs discovered live). Both check HTTP status (404 for missing slugs), zero console errors, `scrollWidth <= clientWidth`195   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/`.1963. Look at the screenshots. Dense but readable; no card walls; numbers tabular; provenance visible; empty states honest.1974. `curl -sI localhost:8330/<type>/does-not-exist` → 404. `curl -s localhost:8330/admin | grep -c <token>` → 0.198199## 8. API notes observed live (2026-09-11) — keep in mind200201- Provider slugs can collide with company slugs (`anthropic` company vs `anthropic-2` provider "Anthropic API").202- `EntitySummary.status` may be `available` while `attributes.status` is `active`; `StatusBadge` shows unknown values neutrally.203- `quality` and `counts` are often `{}` (score not computed yet) — `QualityMark` renders nothing then.204- `provenance[*]` currently lacks `source_name`; the UI falls back to the URL host. `extractor` is `llm` or `deterministic`.205- `/search` `query` is flat: `{ text, entity_type, openness, params_min, filters: { residual }, semantic }` — `understood()` in `search/page.tsx` renders it.206- `/methodology.event_types` items are `{ event_type, category, count, last_seen_at }` (no label/importance); `metrics` is `[]`.207
Files · 9f1db171c786cf4a3b99436329f29b145c02aeec
Menu

Theme