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
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.0Env (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 <repo> && .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 <repo> && 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
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 creditsPlaywright 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<T>(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, IndicatorRowConventions (follow these on new pages)
- Server components fetch; client components interact. One
awaitfor the primary payload, thenPromise.all([safe(api.x()), …])for optional panels — each tolerates failure (null→ EmptyState). No client fetch waterfalls.export const revalidate = 900on pages. - Errors:
api.*throwsApiError. Pattern used by every page:tstry { data = await api.thing(id) } catch (e) { if (isNotFound(e)) notFound(); if (isNotBuilt(e)) return <NotBuiltState/>; throw e }isNotBuiltcovers 503 ("Data not built yet") and an unreachable API — the site renders a calm state, never crashes. - Strings: everything visible goes through
t('key')insrc/i18n/en.ts. Dynamic keys →tOpt. - Numbers/dates:
lib/format.tsonly, fixeden-USlocale (Intl hydration bug prevention). Display an API value withdisplayValue(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'sformatted(it carries the indicator precision thatMetricValuelacks). Never renderm.formattedalone.tnumutility on columns of numbers,pnumon 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)incharts/ranked-bars.tsx;Metric/IndicatorRow/snapshot cells always show it. - No cards.
Section(rule above, editorial heading, subtitle, right-side actions) +divide-y divide-rulelists. Grid children that contain long text needmin-w-0(Section already has it). - Never invent numbers. Missing →
EmptyState/ "No data" (t('common.noData')). - Metadata:
generateMetadatauses the API; titles follow"{Name} Data & Statistics"; canonical viaroutes.*. - Provenance everywhere: any value →
useProvenance().open(payload). Build payloads withpayloadFor(metricValue, country)or hand-roll{ indicator: {slug,name,format,unit,…}, value: {value, period, provenance}, country }. Chart source lines take the samepayloadprop. - Touch targets ≥ 44 px on mobile (
taputility ormin-h-[44px] md:min-h-[32px]),h-11 md:h-9for chips. - Mobile: bottom tab bar is fixed;
<body>reservespb-[calc(56px+env(safe-area-inset-bottom))] md:pb-0. Filters on phones →BottomSheet side="center". Horizontal scrollers usescrollbar-none -mx-4 px-4. - Reordering between breakpoints via CSS
orderis 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" + <title>/<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.ProvenanceSheetis mounted once in the layout; open it from anywhere withuseProvenance().open(payload).BottomSheet(native<dialog>):side="drawer"(right drawer on md+),"center","full"; drag-down to close.DataTable<T>→ definition list undersm(no horizontal overflow).TopicNavsticky under the header (52/56 px).ChangeList(feed with kind icon + severity chip) andTimeline(events by year) both takeChangeItem[].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#anchorhash opens it. Used by compare topic tabs (> 8 charts, first block open) and/countries/[slug]/[topic](> 12 indicators, first two open).SiteFooterrenders<FooterCredits/>("Made by Simon-Pierre Boucher · contact@spboucher.ai · Hosted on MacLustr") — reuseFooterCreditson/sourcesand/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:
/exploreWorld Explorer (full viewport:components/explorer/*, zoom/panmap-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 inlib/stories.ts, blocks incomponents/stories/*),/download(dataset builder;/dataredirects),/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 inlib/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 viaqa/favicons.mjs, dark map share cards (lib/og.tsx). - SEO:
generateSitemapsshards (/sitemap/<shard>.xml, listed in robots), parameterised explorer statesnoindex. - 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; plusqa/core-qa.mjs,qa/flagship-qa.mjs,qa/platform-qa.mjs. Run against the production build (pnpm build && pnpm start) withCA_ADMIN_TOKENin the web env so SSR fetches bypass the API rate limit.
Known issues / not in this round
/adminneedsCA_ADMIN_TOKENin 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
generateSitemapsbefore adding another per-country family. - Search hit types
country_topic/country_indicatorrender with the Topic/Indicator chip;/topics/*URLs the API may emit for bare topics are mapped to/indicators?topic=client-side (hrefFor). - The API
CountrySummaryhas noiso_numeric; the choropleth uses the static numeric→ISO3 table (lib/iso-numeric.ts) and will preferiso_numericautomatically 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.mjsagainst the real API before launch.