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