SPB Git forge

spb/countryatlas

Public
20commits 1branches 0releases
268.3 MBsize
maindefault branch
12 days agolast push
TypeScript 57% Python 38.6% JavaScript 3.6% CSS 0.6%
21.2 KB

# 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 <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

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/

text
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, 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 <NotBuiltState/>; 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; <body> 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" + <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.
  • 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.