# CountryAtlas — web app (`@countryatlas/web`) Next.js 16.3.4 · React 19.2.8 · TypeScript strict · Tailwind v4 · own SVG chart kit (d3-geo/scale/shape only for math). Mobile-first, editorial, provenance-first. The country is the primary unit of navigation. This README is the contract for whoever adds pages next (compare / rankings / indicators / regions / sources / methodology / api / admin). Reuse what is here; do not add a second design system. ## Run ```bash pnpm install # from the repo root (pnpm workspace: apps/*) cd apps/web pnpm dev # http://localhost:8290 — API_URL from .env.local (default http://127.0.0.1:8291) pnpm typecheck && pnpm build && pnpm start # production: next start -p 8290 -H 0.0.0.0 ``` Env (see `.env.example`): `API_URL` (server-side base of the FastAPI service, loopback), `NEXT_PUBLIC_SITE_URL` (canonical/OG/sitemap). Browser code never talks to the API host directly: `next.config.ts` rewrites `/api/v1/:path*` → `${API_URL}/api/v1/:path*`. Without the real snapshot you can run the **fixture API** (synthetic, deterministic, every provenance says "Fixture (mock data)"): `cd && .venv/bin/python apps/web/qa/mock_api.py` (port 8299) and set `API_URL=http://127.0.0.1:8299` in `apps/web/.env.local`. Never point production at it. Real API once the DB exists: `cd && CA_DATA_DIR=~/countryatlas-data .venv/bin/python -m uvicorn countryatlas.api.main:app --port 8293` then `API_URL=http://127.0.0.1:8293`. ## QA ```bash node qa/screens.mjs [BASE_URL] # screenshots 320/360/375/390/414/430/1280/1440 → qa/screens/, report.json: # overflow (scrollWidth>clientWidth), tap targets <44px, tab-bar/footer overlap, CLS, console errors node qa/segments.mjs [BASE_URL] # viewport-sized segments (top + scroll offsets) → qa/screens/seg/ for visual review node qa/favicons.mjs # re-rasterise src/app/icon.svg → icon.png (512) + apple-icon.png (180) node qa/polish-sweep.mjs [BASE] [--quick] # 25 routes × 320…1440 × light/dark → qa/screens/polish/ (+ report.json): # overflow, tap targets, console errors, CLS after settle, clipped text, "undefined/NaN/null", footer credits ``` Playwright comes from `~/Desktop/uqo-eval/node_modules` (symlinked into `node_modules/playwright[-core]`; ESM ignores `NODE_PATH`). Last run (2026-09-11, prod build, fixture API): 32 page×width renders, **0 overflow, 0 console errors, 0 layout shift**, only the sr-only skip link below 44 px. Unknown country / topic return a **true HTTP 404**. **Soft-404 gotcha (fixed here, keep it that way):** a `loading.tsx` that wraps a segment which calls `notFound()` streams a 200 shell before the 404 is known. Loading files therefore live only in route groups that never contain dynamic segments: `app/(home)/loading.tsx`, `app/countries/(list)/loading.tsx`. Do **not** add `loading.tsx` at `app/`, `app/countries/` or inside `[slug]`. Same rule for your new dynamic routes. ## Layout of `src/` ``` app/ layout.tsx fonts, metadata template "%s — CountryAtlas", theme boot script, providers, header/footer/tab bar globals.css design tokens (@theme inline) + chart chrome classes (home)/page.tsx / countries/(list)/page.tsx /countries countries/[slug]/page.tsx + [topic]/page.tsx + opengraph-image.tsx (dynamic per-country OG) indicators/page.tsx (?topic=) indicators/[slug]/page.tsx + opengraph-image.tsx regions/page.tsx regions/[slug]/page.tsx explore/ changes/ data/ sources/ + sources/[id]/ methodology/ api/ (developer landing, live examples) admin/login/page.tsx + admin/actions.ts (server actions) + admin/(panel)/{layout,page,coverage,runs,issues,raw} (cookie gate) opengraph-image.tsx twitter-image.tsx og-shared.tsx default social image (static) icon.svg icon.png apple-icon.png manifest.ts robots.ts sitemap.ts not-found.tsx error.tsx lib/ api.ts server-only typed fetch: api.home() api.countries() api.country(id) api.countryTopic() api.countrySeries() api.countryChanges/Events/Similar/Insights/Dna() api.indicatorMap() api.ranking() api.search() api.changes() request(path, query, {revalidate}) for new endpoints · ApiError · safe() · isNotBuilt() · isNotFound() client-api.ts browser fetches to same-origin /api/v1 (search, series, similar) — keep small api-explore.ts / client-api-explore.ts / types-explore.ts indicators, trend, series bundle, regions, changes, sources, methodology, admin payloads (+ browser fetches for the map year slider, trend groups, compare, similar) admin-auth.ts CA_ADMIN_TOKEN gate: constant-time compare, httpOnly cookie `ca_admin` (sha256 of the token, path /admin) admin-api.ts X-Admin-Token fetches (no-store) to ADMIN_API_URL ?? API_URL — server components/actions only types.ts mirror of src/countryatlas/api/schemas.py (see "Types") format.ts formatValue / formatChange / formatPeriod / formatRank / formatDate / relativeFreshness / formatTick / compact site.ts SITE_URL, SITE_NAME, TAGLINE, routes.* (every internal href goes through routes) topics.ts static mirror of registry/topics.yaml (19 topics, HEADLINE_INDICATORS, HEADLINE_TOPIC) regions.ts WB_REGIONS (7) + INCOME_GROUPS (4) with slugs/short names map-geo.ts world-atlas 110m → Equal Earth paths (cached per process), classIndex(); iso-numeric.ts numeric→ISO3 og.tsx Satori-safe building blocks for OG images (OgFrame, OgWordmark, OgMark, OgMeridians) fonts.ts Inter (--font-ui) + Newsreader (--font-display) via next/font/google; fonts.system.ts = offline fallback severity.ts 0–1 severity → high/medium/low + label i18n/ en.ts the dictionary (flat dotted keys) · index.ts: t(key, params), tOpt(dynamicKey, fallback), LOCALE='en-US' components/ brand/Logo.tsx Logo variant="full|mark|wordmark", LogoMark, Wordmark, logoMarkSvg() layout/ SiteHeader (PRIMARY_NAV), NavLinks, MobileTabBar, SiteFooter (+ FooterCredits), ThemeToggle, SearchProvider/useSearch/useOpenSearch, SearchTrigger (field|icon|hero), SearchDialog (lazy) charts/ see "Chart kit" data/ Section, Metric (+MetricGrid, payloadFor), ChangeChip, RankBadge, FreshnessBadge/StatusBadge, TopicNav, CountryChip, DataTable, EmptyState/NotBuiltState, BottomSheet, ChangeList, ProvenanceProvider/useProvenance/ProvenanceSheet home/ Hero, SnapshotStrip, RegionChips, CompareTeaser (COMPARE_PRESETS), TopicsGrid, IndicatorList countries/directory.tsx client filter/sort/A–Z directory (URL-synced) indicators/ IndicatorDirectory (topic nav + filter), IndicatorMap (year slider → /map?year=), IndicatorTrend (group selector → /trend), IndicatorCompare (≤ 5 countries → /series), map-geometry.ts (server) regions/ MembersTable (sortable), MemberRanking (indicator picker → /rankings?group=), MemberMap (static SVG) explore/ EntityPicker (typeahead country|indicator), EntityPickerNav, CopyButton/CodeBlock, PageHeader, DownloadPicker, SimilarityPlayground changes/changes-feed.tsx global feed: kind/indicator/region/severity filters (BottomSheet on phones), grouped by year methodology/toc.tsx sticky TOC with IntersectionObserver highlight admin/ AdminNav, admin-ui (AdminSection, Kv, AdminTable, Pill), CoverageTable (sortable) country/ CountryHeader, ShareButton, SimilarPanel, KeyFacts, Timeline, CountryTopicsGrid, IndicatorRow ``` ## Conventions (follow these on new pages) * **Server components fetch; client components interact.** One `await` for the primary payload, then `Promise.all([safe(api.x()), …])` for optional panels — each tolerates failure (`null` → EmptyState). No client fetch waterfalls. `export const revalidate = 900` on pages. * **Errors:** `api.*` throws `ApiError`. Pattern used by every page: ```ts try { data = await api.thing(id) } catch (e) { if (isNotFound(e)) notFound(); if (isNotBuilt(e)) return ; throw e } ``` `isNotBuilt` covers 503 ("Data not built yet") **and** an unreachable API — the site renders a calm state, never crashes. * **Strings:** everything visible goes through `t('key')` in `src/i18n/en.ts`. Dynamic keys → `tOpt`. * **Numbers/dates:** `lib/format.ts` only, fixed `en-US` locale (Intl hydration bug prevention). Display an API value with `displayValue(m.value, m, m.formatted)`: currencies ALWAYS go through the client formatter (unit prefix guaranteed — "US$55.7k", "intl $66.7k"), other formats prefer the API's `formatted` (it carries the indicator precision that `MetricValue` lacks). Never render `m.formatted` alone. `tnum` utility on columns of numbers, `pnum` on hero figures. * **Freshness honesty:** a value whose year is ≥ 2 years older than the reference year of its list (ranking rows, home lists, member rankings, top-3 previews, compact subtopic tables) shows its year next to the value — `staleYear(year, reference)` in `charts/ranked-bars.tsx`; `Metric`/`IndicatorRow`/snapshot cells always show it. * **No cards.** `Section` (rule above, editorial heading, subtitle, right-side actions) + `divide-y divide-rule` lists. Grid children that contain long text need `min-w-0` (Section already has it). * **Never invent numbers.** Missing → `EmptyState` / "No data" (`t('common.noData')`). * **Metadata:** `generateMetadata` uses the API; titles follow `"{Name} Data & Statistics"`; canonical via `routes.*`. * **Provenance everywhere:** any value → `useProvenance().open(payload)`. Build payloads with `payloadFor(metricValue, country)` or hand-roll `{ indicator: {slug,name,format,unit,…}, value: {value, period, provenance}, country }`. Chart source lines take the same `payload` prop. * **Touch targets ≥ 44 px on mobile** (`tap` utility or `min-h-[44px] md:min-h-[32px]`), `h-11 md:h-9` for chips. * **Mobile:** bottom tab bar is fixed; `` reserves `pb-[calc(56px+env(safe-area-inset-bottom))] md:pb-0`. Filters on phones → `BottomSheet side="center"`. Horizontal scrollers use `scrollbar-none -mx-4 px-4`. * **Reordering between breakpoints via CSS `order` is forbidden** for page sections (DOM order = visual order); it is acceptable only inside a single small widget row (SimilarPanel bar). ## Design tokens (`globals.css`) Raw values on `:root` / `.dark` (class toggled by `ThemeToggle`, stored in `localStorage['ca-theme']`, system default applied before paint by `THEME_BOOT`). Tailwind reads them through `@theme inline`: | Token | Utility | Light / Dark | |---|---|---| | paper / surface / surface-2 | `bg-paper` `bg-surface` `bg-surface-2` | #fbfaf7 / #151513 · #fff / #1c1c1a · #f3f1ec / #24241f | | ink / ink-2 / ink-3 | `text-ink*` | #1a1917 / #f2f0ea · #5c5a55 / #c9c6bd · #8a877f | | rule / rule-strong | `border-rule` `divide-rule` | #e6e3dc / #2c2b28 · #c9c6bd / #3a3935 | | accent / accent-soft / accent-ink | `text-accent` `bg-accent-soft` | #1c5cab / #5598e7 · #e8f0fb / #17304f | | up / down / warn | `text-up` `text-down` | #006300 / #0ca30c · #b02a2a / #e66767 · #9a6a00 / #e0a100 | | series-1…8 | `bg-series-1`, `seriesVar(i)` | validated dataviz palette (blue, orange, aqua, yellow, magenta, green, violet, red) | | seq-1…7 | `seqVar(k)` | blue sequential ramp (choropleths); reversed in dark | | nodata | `no-data-hatch` | hatched grey | Palette validated with the dataviz skill's `validate_palette.js` on both surfaces (adjacent ΔE ≥ 8.4, all-pairs OK for the first 3 slots). Light-mode aqua/yellow/magenta are < 3:1 on paper → charts always ship direct labels + the table toggle (relief rule). Positive/negative colours are never used alone: `ChangeChip` pairs them with ↑/↓ glyphs. Type: `display` (Newsreader) for headings, Inter for UI, scale `text-2xs … text-5xl`; utilities `eyebrow`, `hairline`, `tap`, `tnum`, `pnum`, `scrollbar-none`, `safe-bottom`, `container-x`, `link-quiet`. Motion respects `prefers-reduced-motion`. ## Chart kit (`components/charts/`) All charts: SVG, `role="img"` + `/<desc>` with an auto summary (`summary.ts`), a `ChartFrame` with source line (`SourceLine`, clickable → provenance), accessible data-table toggle, legend only for ≥ 2 series. Colours via CSS vars (`palette.ts`), fixed slot order — colour follows the entity, never its rank; never more than 8 series (fold/facet). Marks per dataviz spec (`MARK`): 2 px lines, ≥ 8 px end dots with surface ring, bars ≤ 24 px with 4 px rounded end, hairline solid grid. Points type: `SeriesPoint {period, year, value, is_forecast}` — build with `pointsFromSpark(m.sparkline)` (API `[year,value]` pairs) or `pointsFromSeries(series.values)`. | Component | Kind | Notes | |---|---|---| | `LineChart` / `AreaChart` / `StackedArea` | client | multi-series, dashed `is_forecast`, `log`, crosshair + one tooltip for all series, end labels ≤ 4 series, auto left margin. **Y domain** (`scales.ts::lineDomain`): line/area use the padded (≈ 6 %) nice extent of the data; zero is included only if min ≤ 0, min < 0.3·max, or a percent/index share spanning > ½ of [0, max]. Stacked keeps the zero baseline. | | `Sparkline` | server | tiny, no axes, end dot coloured by `direction` | | `RankedBars` (+`rankedRowFromCountry`) | server, HTML | horizontal bars with flags, `highlightId`, values at the tip | | `Scatter` | client | bubble `size`, labels for highlighted + extremes only, 24 px hit radius | | `SlopeChart` | client | two periods, highlighted row in accent | | `SmallMultiples` | server | grid wrapper (1 → 2 → 3/4 cols) | | `PopulationPyramid` | server | 0–14 / 15–64 / 65+ stacked bar (ordinal ramp) | | `DnaRadial` | server | 9 dims 0–100 fingerprint | | `Choropleth` → `ChoroplethView` | server → client | Equal Earth 960×470, quantile classes from API `legend.breaks`, hatch for no data, hover / tap label / click → country | `useMeasure(defaultWidth)` gives responsive widths with a stable SSR width; reserve heights via `height`/`minHeight`. ## Data components (`components/data/`) * `Metric` — label(link) + sparkline, big value (click → provenance), period, `ChangeChip`, `RankBadge`. `MetricGrid` = 1 col ≤ 360 px, 2 on phones, 3 md, 4 xl. * `ProvenanceSheet` is mounted once in the layout; open it from anywhere with `useProvenance().open(payload)`. * `BottomSheet` (native `<dialog>`): `side="drawer"` (right drawer on md+), `"center"`, `"full"`; drag-down to close. * `DataTable<T>` → definition list under `sm` (no horizontal overflow). `TopicNav` sticky under the header (52/56 px). * `ChangeList` (feed with kind icon + severity chip) and `Timeline` (events by year) both take `ChangeItem[]`. * `CollapsibleGroup` (+ `SubtopicJumpNav`, `lib/anchors.ts::subtopicAnchor`) — subtopic block for long chart pages: collapsed → `summary` (compact latest-values table: `compare/SnapshotCompact`, `country/TopicCompact`), expanded → charts (still lazy on scroll); a `#anchor` hash opens it. Used by compare topic tabs (> 8 charts, first block open) and `/countries/[slug]/[topic]` (> 12 indicators, first two open). * `SiteFooter` renders `<FooterCredits/>` ("Made by Simon-Pierre Boucher · contact@spboucher.ai · Hosted on MacLustr") — reuse `FooterCredits` on `/sources` and `/api`. ## Types — `src/lib/types.ts` (extend this file) Aligned 2026-09-11 with `src/countryatlas/api/schemas.py`. Responses are flat with `meta`; collections are `items` (`/countries`, `/changes`, `/events`, `/insights`), `hits` (`/search`), `peers` (`/similar`), `rows` (rankings/home lists). Value objects: `MetricValue` (headline/topic), `SeriesValue` (series), `RankingRow`, `HomeListRow`; all carry `provenance`. Sparklines are `SparkPoint = [year, value]`. Pydantic models already defined but **not yet mirrored** (add them when you build the pages): `CompareResponse`, `CompareSnapshotResponse`, `RankingsListResponse`, `RankHistoryResponse`, `IndicatorsResponse`, `IndicatorResponse` (+`IndicatorSource`, `WorldLatest`, `RankedValue`), `TrendResponse`, `RegionsResponse`, `RegionResponse`, `SourcesResponse`, `SourceResponse`, methodology/admin payloads. Add the matching `api.*` methods in `lib/api.ts` with `request<T>()`, routes in `lib/site.ts`, strings in `i18n/en.ts`, and sitemap entries in `app/sitemap.ts`. ## Brand `components/brand/Logo.tsx`: a globe ring whose equator is the crossbar of an "A" (two meridian-like legs to one apex). Strokes only, `currentColor`, monochrome-capable. `src/app/icon.svg` (rounded paper tile) → `icon.png` 512 / `apple-icon.png` 180 via `qa/favicons.mjs`; `manifest.ts` theme colour #1c5cab. Social images: `app/opengraph-image.tsx` (+ `twitter-image.tsx`) and `app/countries/[slug]/opengraph-image.tsx` (flag + name + 3 headline values). Satori gotchas: children of a multi-child `<div>` need `display:flex`; numeric children must be `String()`-ed. ## 2.0 upgrade (2026-09-12) — what was added * **Routes**: `/explore` World Explorer (full viewport: `components/explorer/*`, zoom/pan `map-canvas.tsx`, views map/rank/trend/ distribution, country drawer, time machine from `/indicators/{slug}/frames`), `/trajectories`, `/scatter`, `/finder`, `/extremes`, `/peers`, `/regions/compare`, `/stories` + `/stories/[slug]` (declarative templates in `lib/stories.ts`, blocks in `components/stories/*`), `/download` (dataset builder; `/data` redirects), `/updates`, `/api` (interactive endpoint explorer). * **Home 2.0**: hero map with indicator chips + year slider (`components/home/hero-map.tsx`), snapshot ticker, World Pulse (`world-pulse.tsx`), Biggest movers (`movers.tsx`), trajectory teaser, latest updates, transparency block. * **Country 2.0**: mini locator map, story (`country-story.tsx`), timeline 2.0 (decade rail, topic filters, new kinds), DNA with reference polygon (`dna-panel.tsx`), similar 2.0 with qualitative contribution labels and raw values, quality strip, Copy API. * **Compare 2.0**: head-to-head (`head-to-head.tsx`), Percentile / Change-since modes, PNG export (`charts/export-png.ts`). * **Rankings 2.0**: income / min population / min coverage filters, Table · Bars · Map views, rank race (`charts/rank-race.tsx`). * **Indicator 2.0**: frames-driven map, decade movers, distribution (`indicators/distribution-panel.tsx` + `charts/histogram.tsx`), related indicators (`related-table.tsx`), quality badges, Dataset JSON-LD. * **Shared**: `components/controls/{year-slider,indicator-select}.tsx`, `charts/bubble-chart.tsx`, `lib/url-state.ts` / `lib/url-params.ts` (server-safe parsers), `lib/indicator-options.ts`, `lib/api-analytics.ts` + `lib/client-api-analytics.ts` (typed clients for API 1.1, types in `lib/types-analytics.ts`), `lib/seo.ts` (spec titles + JSON-LD), `lib/api-platform.ts`, `components/data/quality-badge.tsx`, provenance sheet 2.0 (API copy + download series + quality). * **Navigation**: `layout/site-header.tsx` (PRIMARY_NAV + MORE_NAV, `more-menu.tsx`), `mobile-tab-bar.tsx`, `main-frame.tsx` (full bleed for explorers). i18n split: `en.ts` (base) + `en.compare.ts` + `en.explore.ts` + `en.core.ts` + `en.flagship.ts` + `en.platform.ts`. * **Brand**: graticule-A mark (`components/brand/Logo.tsx`), `app/icon.svg` (dark-mode aware), PNG favicons via `qa/favicons.mjs`, dark map share cards (`lib/og.tsx`). * **SEO**: `generateSitemaps` shards (`/sitemap/<shard>.xml`, listed in robots), parameterised explorer states `noindex`. * **QA**: `qa/final-sweep.mjs [BASE] [--quick]` — every route × 320/375/390/430/768/1440/1920 (dark at 390/1440): overflow, console errors, failed API requests, bad text, footer credits, internal links; plus `qa/core-qa.mjs`, `qa/flagship-qa.mjs`, `qa/platform-qa.mjs`. Run against the production build (`pnpm build && pnpm start`) with `CA_ADMIN_TOKEN` in the web env so SSR fetches bypass the API rate limit. ## Known issues / not in this round * `/admin` needs `CA_ADMIN_TOKEN` in the **web** process env (the mld manifest passes it); without it the login page says the panel is disabled. `ADMIN_API_URL` (optional) points admin fetches at another API instance. QA for the explore routes: `node qa/shots-explore.mjs` → `qa/screens/explore/` (+ report.json, touch test of the map year slider). * Sitemap ≈ 4.9k URLs (countries × topics + indicators + regions + sources + rankings) — switch to `generateSitemaps` before adding another per-country family. * Search hit types `country_topic` / `country_indicator` render with the Topic/Indicator chip; `/topics/*` URLs the API may emit for bare topics are mapped to `/indicators?topic=` client-side (`hrefFor`). * The API `CountrySummary` has no `iso_numeric`; the choropleth uses the static numeric→ISO3 table (`lib/iso-numeric.ts`) and will prefer `iso_numeric` automatically if the API adds it. * Topic page renders the first 4 charts with server-fetched history; the rest fetch on scroll (client). With a slow API the fold could show "Loading…" placeholders of reserved height (no CLS). * Fixture screenshots contain synthetic values by design; re-run `qa/screens.mjs` against the real API before launch.