SPB Git

spb/countryatlas

Public

Pipeline: recent-only changes, series-level source merge, severity weights, OWID freshness; registry split central/general government

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Sep 11, 2026) parent 7d7b316 Browse files

111 changed files +2,290 −63

111 changed files
added apps/web/AGENTS.md +9 −0
@@ -0,0 +1,9 @@
1+<!-- BEGIN:nextjs-agent-rules -->
2+
3+# This is NOT the Next.js you know
4+
5+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
6+
7+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
8+
9+<!-- END:nextjs-agent-rules -->
added apps/web/CLAUDE.md +1 −0
@@ -0,0 +1 @@
1+@AGENTS.md
added apps/web/qa/screens.mjs +81 −0
@@ -0,0 +1,81 @@
1+/**
2+ * Mobile-first QA: screenshots of the key pages at phone + desktop widths, plus automated checks:
3+ * - no horizontal overflow (scrollWidth <= innerWidth)
4+ * - interactive elements >= 44 px tall (buttons/links in header, tab bar, chips, metrics)
5+ * - fixed bottom tab bar does not overlap the footer (body padding-bottom)
6+ * - layout shift after fonts/charts settle (compare heights before/after)
7+ * Run: NODE_PATH=/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules node qa/screens.mjs [BASE_URL]
8+ */
9+import { chromium } from 'playwright';
10+import { mkdirSync, writeFileSync } from 'node:fs';
11+import { createRequire } from 'node:module';
12+
13+const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290';
14+const OUT = new URL('./screens/', import.meta.url).pathname;
15+mkdirSync(OUT, { recursive: true });
16+
17+const PAGES = ['/', '/countries', '/countries/canada', '/countries/canada/economy'];
18+const WIDTHS = [320, 360, 375, 390, 414, 430, 1280, 1440];
19+const report = [];
20+
21+const browser = await chromium.launch();
22+for (const width of WIDTHS) {
23+ const mobile = width < 768;
24+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 800 : 900 }, deviceScaleFactor: 1, isMobile: mobile, hasTouch: mobile, colorScheme: 'light' });
25+ const page = await ctx.newPage();
26+ const errors = [];
27+ page.on('pageerror', (e) => errors.push(String(e)));
28+ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
29+ for (const path of PAGES) {
30+ await page.goto(BASE + path, { waitUntil: 'networkidle' });
31+ await page.evaluate(() => document.fonts.ready);
32+ const h1 = await page.evaluate(() => document.documentElement.scrollHeight);
33+ await page.waitForTimeout(600);
34+ const metrics = await page.evaluate(() => {
35+ const de = document.documentElement;
36+ const overflow = de.scrollWidth - de.clientWidth;
37+ // elements wider than the viewport
38+ const wide = [...document.querySelectorAll('body *')].filter((el) => { const r = el.getBoundingClientRect(); return r.right > de.clientWidth + 1 && r.width > 0; }).slice(0, 8).map((el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 3).join('.')} right=${Math.round(el.getBoundingClientRect().right)}`);
39+ const small = [...document.querySelectorAll('a,button,[role=button],input,select,summary')].filter((el) => { const r = el.getBoundingClientRect(); if (r.width === 0 || r.height === 0) return false; const cs = getComputedStyle(el); if (cs.visibility === 'hidden') return false; return r.height < 44 && r.width < 44; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" ${Math.round(el.getBoundingClientRect().width)}x${Math.round(el.getBoundingClientRect().height)}`);
40+ const smallH = [...document.querySelectorAll('a,button,[role=button],input,select,summary')].filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0 && r.height < 32; }).map((el) => `${el.tagName.toLowerCase()} "${(el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 30)}" h=${Math.round(el.getBoundingClientRect().height)}`);
41+ const tab = document.querySelector('nav.fixed');
42+ const bodyPad = parseFloat(getComputedStyle(document.body).paddingBottom);
43+ const footer = document.querySelector('footer');
44+ return { overflow, wide, small, smallH: smallH.slice(0, 12), tabBar: tab ? tab.getBoundingClientRect().height : 0, bodyPad, footerBottom: footer ? footer.getBoundingClientRect().bottom + window.scrollY : 0, docH: de.scrollHeight };
45+ });
46+ const h2 = metrics.docH;
47+ const name = `${path === '/' ? 'home' : path.slice(1).replace(/\//g, '_')}-${width}.png`;
48+ await page.screenshot({ path: OUT + name, fullPage: true });
49+ report.push({ path, width, overflow: metrics.overflow, wide: metrics.wide, small: metrics.small.slice(0, 10), smallH: metrics.smallH, tabBar: metrics.tabBar, bodyPad: metrics.bodyPad, cls: h2 - h1, errors: errors.splice(0), file: name });
50+ }
51+ await ctx.close();
52+}
53+// Dark-mode sample + favicon rasterisation
54+{
55+ const ctx = await browser.newContext({ viewport: { width: 390, height: 800 }, colorScheme: 'dark', isMobile: true, hasTouch: true });
56+ const page = await ctx.newPage();
57+ await page.goto(BASE + '/countries/canada', { waitUntil: 'networkidle' });
58+ await page.screenshot({ path: OUT + 'countries_canada-390-dark.png', fullPage: true });
59+ await ctx.close();
60+}
61+{
62+ const require = createRequire(import.meta.url);
63+ const { readFileSync } = require('node:fs');
64+ const svg = readFileSync(new URL('../src/app/icon.svg', import.meta.url), 'utf8');
65+ for (const [size, file] of [[512, '../src/app/icon.png'], [180, '../src/app/apple-icon.png']]) {
66+ const ctx = await browser.newContext({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
67+ const page = await ctx.newPage();
68+ await page.setContent(`<html><body style="margin:0;background:transparent">${svg.replace(/width="\d+" height="\d+"/, `width="${size}" height="${size}"`)}</body></html>`);
69+ await page.screenshot({ path: new URL(file, import.meta.url).pathname, omitBackground: true, clip: { x: 0, y: 0, width: size, height: size } });
70+ await ctx.close();
71+ }
72+}
73+await browser.close();
74+writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));
75+for (const r of report) {
76+ const flags = [r.overflow > 0 ? `OVERFLOW +${r.overflow}px` : 'ok', r.small.length ? `${r.small.length} small targets` : '', r.cls ? `Δh ${r.cls}` : '', r.errors.length ? `${r.errors.length} console errors` : ''].filter(Boolean).join(' · ');
77+ console.log(`${r.width.toString().padStart(4)} ${r.path.padEnd(28)} ${flags}`);
78+ if (r.overflow > 0) console.log(' wide:', r.wide.join(' | '));
79+ if (r.small.length) console.log(' small:', r.small.slice(0, 6).join(' | '));
80+ if (r.errors.length) console.log(' errors:', r.errors.slice(0, 3).join(' | '));
81+}
added apps/web/qa/screens/countries-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-390-dark.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/countries_canada_economy-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-1280.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-1440.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-320.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-360.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-375.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-390.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-414.png +0 −0

Binary file not shown.

added apps/web/qa/screens/home-430.png +0 −0

Binary file not shown.

added apps/web/qa/screens/og-canada.png +0 −0

Binary file not shown.

added apps/web/qa/screens/og-default.png +0 −0

Binary file not shown.

added apps/web/qa/screens/report.json +1104 −0
@@ -0,0 +1,1104 @@
1+[
2+ {
3+ "path": "/",
4+ "width": 320,
5+ "overflow": 0,
6+ "wide": [
7+ "li. right=357",
8+ "a.inline-flex.h-9.items-center right=357",
9+ "li. right=501",
10+ "a.inline-flex.h-9.items-center right=501",
11+ "li. right=585",
12+ "a.inline-flex.h-9.items-center right=585",
13+ "li. right=690",
14+ "a.inline-flex.h-9.items-center right=690"
15+ ],
16+ "small": [
17+ "a \"Skip to content\" 1x1",
18+ "a \"API\" 22x32"
19+ ],
20+ "smallH": [
21+ "a \"Skip to content\" h=1",
22+ "a \"See all →\" h=21",
23+ "a \"🇨🇦Canada\" h=21",
24+ "a \"🇰🇷South Korea\" h=21",
25+ "a \"🇦🇪United Arab Emirates\" h=21",
26+ "a \"🇨🇱Chile\" h=21",
27+ "a \"🇸🇦Saudi Arabia\" h=21",
28+ "a \"🇲🇳Mongolia\" h=21",
29+ "a \"🇿🇦South Africa\" h=21",
30+ "a \"🇩🇿Algeria\" h=21",
31+ "a \"🇿🇲Zambia\" h=21",
32+ "a \"🇵🇰Pakistan\" h=21"
33+ ],
34+ "tabBar": 57,
35+ "bodyPad": 56,
36+ "cls": 0,
37+ "errors": [],
38+ "file": "home-320.png"
39+ },
40+ {
41+ "path": "/countries",
42+ "width": 320,
43+ "overflow": 0,
44+ "wide": [],
45+ "small": [
46+ "a \"Skip to content\" 1x1",
47+ "a \"API\" 22x32"
48+ ],
49+ "smallH": [
50+ "a \"Skip to content\" h=1",
51+ "select \"NamePopulationGDP per capitaCo\" h=19",
52+ "a \"contact@spboucher.ai\" h=15",
53+ "a \"MacLustr\" h=15"
54+ ],
55+ "tabBar": 57,
56+ "bodyPad": 56,
57+ "cls": 0,
58+ "errors": [],
59+ "file": "countries-320.png"
60+ },
61+ {
62+ "path": "/countries/canada",
63+ "width": 320,
64+ "overflow": 0,
65+ "wide": [
66+ "li.snap-start right=330",
67+ "a.inline-flex.h-9.items-center right=330",
68+ "li.snap-start right=444",
69+ "a.inline-flex.h-9.items-center right=444",
70+ "span.tnum.text-2xs.text-ink-3 right=434",
71+ "li.snap-start right=526",
72+ "a.inline-flex.h-9.items-center right=526",
73+ "span.tnum.text-2xs.text-ink-3 right=516"
74+ ],
75+ "small": [
76+ "a \"Skip to content\" 1x1",
77+ "a \"API\" 22x32"
78+ ],
79+ "smallH": [
80+ "a \"Skip to content\" h=1",
81+ "a \"Internet users at a 10-year hi\" h=19",
82+ "a \"Learning poverty turned positi\" h=19",
83+ "a \"🇱🇺Luxembourg\" h=21",
84+ "a \"🇸🇪Sweden\" h=21",
85+ "a \"🇳🇷Nauru\" h=21",
86+ "a \"🇳🇴Norway\" h=21",
87+ "a \"🇨🇾Cyprus\" h=21",
88+ "a \"🇩🇪Germany\" h=21",
89+ "a \"🇳🇨New Caledonia\" h=21",
90+ "a \"🇬🇮Gibraltar\" h=21",
91+ "a \"🇺🇸 United States\" h=17"
92+ ],
93+ "tabBar": 57,
94+ "bodyPad": 56,
95+ "cls": 0,
96+ "errors": [],
97+ "file": "countries_canada-320.png"
98+ },
99+ {
100+ "path": "/countries/canada/economy",
101+ "width": 320,
102+ "overflow": 0,
103+ "wide": [
104+ "li.snap-start right=383",
105+ "a.inline-flex.h-9.items-center right=383",
106+ "li.snap-start right=445",
107+ "a.inline-flex.h-9.items-center right=445",
108+ "li.snap-start right=518",
109+ "a.inline-flex.h-9.items-center right=518",
110+ "li.snap-start right=597",
111+ "a.inline-flex.h-9.items-center right=597"
112+ ],
113+ "small": [
114+ "a \"Skip to content\" 1x1",
115+ "a \"REER\" 40x20",
116+ "a \"API\" 22x32"
117+ ],
118+ "smallH": [
119+ "a \"Skip to content\" h=1",
120+ "a \"Countries\" h=15",
121+ "a \"🇨🇦 Canada\" h=15",
122+ "a \"GDP (current US$)\" h=20",
123+ "a \"GDP, PPP (current internationa\" h=20",
124+ "a \"GDP per capita (current US$)\" h=20",
125+ "a \"Real GDP\" h=20",
126+ "a \"Industrial production index\" h=20",
127+ "a \"GDP growth\" h=20",
128+ "a \"GDP per capita growth\" h=20",
129+ "a \"Inflation\" h=20",
130+ "a \"Inflation, GDP deflator\" h=20"
131+ ],
132+ "tabBar": 57,
133+ "bodyPad": 56,
134+ "cls": 0,
135+ "errors": [],
136+ "file": "countries_canada_economy-320.png"
137+ },
138+ {
139+ "path": "/",
140+ "width": 360,
141+ "overflow": 0,
142+ "wide": [
143+ "li. right=501",
144+ "a.inline-flex.h-9.items-center right=501",
145+ "li. right=585",
146+ "a.inline-flex.h-9.items-center right=585",
147+ "li. right=690",
148+ "a.inline-flex.h-9.items-center right=690",
149+ "li. right=846",
150+ "a.inline-flex.h-9.items-center right=846"
151+ ],
152+ "small": [
153+ "a \"Skip to content\" 1x1",
154+ "a \"API\" 22x32"
155+ ],
156+ "smallH": [
157+ "a \"Skip to content\" h=1",
158+ "a \"See all →\" h=21",
159+ "a \"🇨🇦Canada\" h=21",
160+ "a \"🇰🇷South Korea\" h=21",
161+ "a \"🇦🇪United Arab Emirates\" h=21",
162+ "a \"🇨🇱Chile\" h=21",
163+ "a \"🇸🇦Saudi Arabia\" h=21",
164+ "a \"🇲🇳Mongolia\" h=21",
165+ "a \"🇿🇦South Africa\" h=21",
166+ "a \"🇩🇿Algeria\" h=21",
167+ "a \"🇿🇲Zambia\" h=21",
168+ "a \"🇵🇰Pakistan\" h=21"
169+ ],
170+ "tabBar": 57,
171+ "bodyPad": 56,
172+ "cls": 0,
173+ "errors": [],
174+ "file": "home-360.png"
175+ },
176+ {
177+ "path": "/countries",
178+ "width": 360,
179+ "overflow": 0,
180+ "wide": [],
181+ "small": [
182+ "a \"Skip to content\" 1x1",
183+ "a \"API\" 22x32"
184+ ],
185+ "smallH": [
186+ "a \"Skip to content\" h=1",
187+ "select \"NamePopulationGDP per capitaCo\" h=19",
188+ "a \"contact@spboucher.ai\" h=15",
189+ "a \"MacLustr\" h=15"
190+ ],
191+ "tabBar": 57,
192+ "bodyPad": 56,
193+ "cls": 0,
194+ "errors": [],
195+ "file": "countries-360.png"
196+ },
197+ {
198+ "path": "/countries/canada",
199+ "width": 360,
200+ "overflow": 0,
201+ "wide": [
202+ "li.snap-start right=444",
203+ "a.inline-flex.h-9.items-center right=444",
204+ "span.tnum.text-2xs.text-ink-3 right=434",
205+ "li.snap-start right=526",
206+ "a.inline-flex.h-9.items-center right=526",
207+ "span.tnum.text-2xs.text-ink-3 right=516",
208+ "li.snap-start right=612",
209+ "a.inline-flex.h-9.items-center right=612"
210+ ],
211+ "small": [
212+ "a \"Skip to content\" 1x1",
213+ "a \"API\" 22x32"
214+ ],
215+ "smallH": [
216+ "a \"Skip to content\" h=1",
217+ "a \"Internet users at a 10-year hi\" h=19",
218+ "a \"Learning poverty turned positi\" h=19",
219+ "a \"🇱🇺Luxembourg\" h=21",
220+ "a \"🇸🇪Sweden\" h=21",
221+ "a \"🇳🇷Nauru\" h=21",
222+ "a \"🇳🇴Norway\" h=21",
223+ "a \"🇨🇾Cyprus\" h=21",
224+ "a \"🇩🇪Germany\" h=21",
225+ "a \"🇳🇨New Caledonia\" h=21",
226+ "a \"🇬🇮Gibraltar\" h=21",
227+ "a \"🇺🇸 United States\" h=17"
228+ ],
229+ "tabBar": 57,
230+ "bodyPad": 56,
231+ "cls": 0,
232+ "errors": [],
233+ "file": "countries_canada-360.png"
234+ },
235+ {
236+ "path": "/countries/canada/economy",
237+ "width": 360,
238+ "overflow": 0,
239+ "wide": [
240+ "li.snap-start right=383",
241+ "a.inline-flex.h-9.items-center right=383",
242+ "li.snap-start right=445",
243+ "a.inline-flex.h-9.items-center right=445",
244+ "li.snap-start right=518",
245+ "a.inline-flex.h-9.items-center right=518",
246+ "li.snap-start right=597",
247+ "a.inline-flex.h-9.items-center right=597"
248+ ],
249+ "small": [
250+ "a \"Skip to content\" 1x1",
251+ "a \"REER\" 40x20",
252+ "a \"API\" 22x32"
253+ ],
254+ "smallH": [
255+ "a \"Skip to content\" h=1",
256+ "a \"Countries\" h=15",
257+ "a \"🇨🇦 Canada\" h=15",
258+ "a \"GDP (current US$)\" h=20",
259+ "a \"GDP, PPP (current internationa\" h=20",
260+ "a \"GDP per capita (current US$)\" h=20",
261+ "a \"Real GDP\" h=20",
262+ "a \"Industrial production index\" h=20",
263+ "a \"GDP growth\" h=20",
264+ "a \"GDP per capita growth\" h=20",
265+ "a \"Inflation\" h=20",
266+ "a \"Inflation, GDP deflator\" h=20"
267+ ],
268+ "tabBar": 57,
269+ "bodyPad": 56,
270+ "cls": 0,
271+ "errors": [],
272+ "file": "countries_canada_economy-360.png"
273+ },
274+ {
275+ "path": "/",
276+ "width": 375,
277+ "overflow": 0,
278+ "wide": [
279+ "li. right=501",
280+ "a.inline-flex.h-9.items-center right=501",
281+ "li. right=585",
282+ "a.inline-flex.h-9.items-center right=585",
283+ "li. right=690",
284+ "a.inline-flex.h-9.items-center right=690",
285+ "li. right=846",
286+ "a.inline-flex.h-9.items-center right=846"
287+ ],
288+ "small": [
289+ "a \"Skip to content\" 1x1",
290+ "a \"API\" 22x32"
291+ ],
292+ "smallH": [
293+ "a \"Skip to content\" h=1",
294+ "a \"See all →\" h=21",
295+ "a \"🇨🇦Canada\" h=21",
296+ "a \"🇰🇷South Korea\" h=21",
297+ "a \"🇦🇪United Arab Emirates\" h=21",
298+ "a \"🇨🇱Chile\" h=21",
299+ "a \"🇸🇦Saudi Arabia\" h=21",
300+ "a \"🇲🇳Mongolia\" h=21",
301+ "a \"🇿🇦South Africa\" h=21",
302+ "a \"🇩🇿Algeria\" h=21",
303+ "a \"🇿🇲Zambia\" h=21",
304+ "a \"🇵🇰Pakistan\" h=21"
305+ ],
306+ "tabBar": 57,
307+ "bodyPad": 56,
308+ "cls": 0,
309+ "errors": [],
310+ "file": "home-375.png"
311+ },
312+ {
313+ "path": "/countries",
314+ "width": 375,
315+ "overflow": 0,
316+ "wide": [],
317+ "small": [
318+ "a \"Skip to content\" 1x1",
319+ "a \"API\" 22x32"
320+ ],
321+ "smallH": [
322+ "a \"Skip to content\" h=1",
323+ "select \"NamePopulationGDP per capitaCo\" h=19",
324+ "a \"contact@spboucher.ai\" h=15",
325+ "a \"MacLustr\" h=15"
326+ ],
327+ "tabBar": 57,
328+ "bodyPad": 56,
329+ "cls": 0,
330+ "errors": [],
331+ "file": "countries-375.png"
332+ },
333+ {
334+ "path": "/countries/canada",
335+ "width": 375,
336+ "overflow": 0,
337+ "wide": [
338+ "li.snap-start right=444",
339+ "a.inline-flex.h-9.items-center right=444",
340+ "span.tnum.text-2xs.text-ink-3 right=434",
341+ "li.snap-start right=526",
342+ "a.inline-flex.h-9.items-center right=526",
343+ "span.tnum.text-2xs.text-ink-3 right=516",
344+ "li.snap-start right=612",
345+ "a.inline-flex.h-9.items-center right=612"
346+ ],
347+ "small": [
348+ "a \"Skip to content\" 1x1",
349+ "a \"API\" 22x32"
350+ ],
351+ "smallH": [
352+ "a \"Skip to content\" h=1",
353+ "a \"Internet users at a 10-year hi\" h=19",
354+ "a \"Learning poverty turned positi\" h=19",
355+ "a \"🇱🇺Luxembourg\" h=21",
356+ "a \"🇸🇪Sweden\" h=21",
357+ "a \"🇳🇷Nauru\" h=21",
358+ "a \"🇳🇴Norway\" h=21",
359+ "a \"🇨🇾Cyprus\" h=21",
360+ "a \"🇩🇪Germany\" h=21",
361+ "a \"🇳🇨New Caledonia\" h=21",
362+ "a \"🇬🇮Gibraltar\" h=21",
363+ "a \"🇺🇸 United States\" h=17"
364+ ],
365+ "tabBar": 57,
366+ "bodyPad": 56,
367+ "cls": 0,
368+ "errors": [],
369+ "file": "countries_canada-375.png"
370+ },
371+ {
372+ "path": "/countries/canada/economy",
373+ "width": 375,
374+ "overflow": 0,
375+ "wide": [
376+ "li.snap-start right=383",
377+ "a.inline-flex.h-9.items-center right=383",
378+ "li.snap-start right=445",
379+ "a.inline-flex.h-9.items-center right=445",
380+ "li.snap-start right=518",
381+ "a.inline-flex.h-9.items-center right=518",
382+ "li.snap-start right=597",
383+ "a.inline-flex.h-9.items-center right=597"
384+ ],
385+ "small": [
386+ "a \"Skip to content\" 1x1",
387+ "a \"REER\" 40x20",
388+ "a \"API\" 22x32"
389+ ],
390+ "smallH": [
391+ "a \"Skip to content\" h=1",
392+ "a \"Countries\" h=15",
393+ "a \"🇨🇦 Canada\" h=15",
394+ "a \"GDP (current US$)\" h=20",
395+ "a \"GDP, PPP (current internationa\" h=20",
396+ "a \"GDP per capita (current US$)\" h=20",
397+ "a \"Real GDP\" h=20",
398+ "a \"Industrial production index\" h=20",
399+ "a \"GDP growth\" h=20",
400+ "a \"GDP per capita growth\" h=20",
401+ "a \"Inflation\" h=20",
402+ "a \"Inflation, GDP deflator\" h=20"
403+ ],
404+ "tabBar": 57,
405+ "bodyPad": 56,
406+ "cls": 0,
407+ "errors": [],
408+ "file": "countries_canada_economy-375.png"
409+ },
410+ {
411+ "path": "/",
412+ "width": 390,
413+ "overflow": 0,
414+ "wide": [
415+ "li. right=501",
416+ "a.inline-flex.h-9.items-center right=501",
417+ "li. right=585",
418+ "a.inline-flex.h-9.items-center right=585",
419+ "li. right=690",
420+ "a.inline-flex.h-9.items-center right=690",
421+ "li. right=846",
422+ "a.inline-flex.h-9.items-center right=846"
423+ ],
424+ "small": [
425+ "a \"Skip to content\" 1x1",
426+ "a \"API\" 22x32"
427+ ],
428+ "smallH": [
429+ "a \"Skip to content\" h=1",
430+ "a \"See all →\" h=21",
431+ "a \"🇨🇦Canada\" h=21",
432+ "a \"🇰🇷South Korea\" h=21",
433+ "a \"🇦🇪United Arab Emirates\" h=21",
434+ "a \"🇨🇱Chile\" h=21",
435+ "a \"🇸🇦Saudi Arabia\" h=21",
436+ "a \"🇲🇳Mongolia\" h=21",
437+ "a \"🇿🇦South Africa\" h=21",
438+ "a \"🇩🇿Algeria\" h=21",
439+ "a \"🇿🇲Zambia\" h=21",
440+ "a \"🇵🇰Pakistan\" h=21"
441+ ],
442+ "tabBar": 57,
443+ "bodyPad": 56,
444+ "cls": 0,
445+ "errors": [],
446+ "file": "home-390.png"
447+ },
448+ {
449+ "path": "/countries",
450+ "width": 390,
451+ "overflow": 0,
452+ "wide": [],
453+ "small": [
454+ "a \"Skip to content\" 1x1",
455+ "a \"API\" 22x32"
456+ ],
457+ "smallH": [
458+ "a \"Skip to content\" h=1",
459+ "select \"NamePopulationGDP per capitaCo\" h=19",
460+ "a \"contact@spboucher.ai\" h=15",
461+ "a \"MacLustr\" h=15"
462+ ],
463+ "tabBar": 57,
464+ "bodyPad": 56,
465+ "cls": 0,
466+ "errors": [],
467+ "file": "countries-390.png"
468+ },
469+ {
470+ "path": "/countries/canada",
471+ "width": 390,
472+ "overflow": 0,
473+ "wide": [
474+ "li.snap-start right=444",
475+ "a.inline-flex.h-9.items-center right=444",
476+ "span.tnum.text-2xs.text-ink-3 right=434",
477+ "li.snap-start right=526",
478+ "a.inline-flex.h-9.items-center right=526",
479+ "span.tnum.text-2xs.text-ink-3 right=516",
480+ "li.snap-start right=612",
481+ "a.inline-flex.h-9.items-center right=612"
482+ ],
483+ "small": [
484+ "a \"Skip to content\" 1x1",
485+ "a \"API\" 22x32"
486+ ],
487+ "smallH": [
488+ "a \"Skip to content\" h=1",
489+ "a \"Internet users at a 10-year hi\" h=19",
490+ "a \"Learning poverty turned positi\" h=19",
491+ "a \"🇱🇺Luxembourg\" h=21",
492+ "a \"🇸🇪Sweden\" h=21",
493+ "a \"🇳🇷Nauru\" h=21",
494+ "a \"🇳🇴Norway\" h=21",
495+ "a \"🇨🇾Cyprus\" h=21",
496+ "a \"🇩🇪Germany\" h=21",
497+ "a \"🇳🇨New Caledonia\" h=21",
498+ "a \"🇬🇮Gibraltar\" h=21",
499+ "a \"🇺🇸 United States\" h=17"
500+ ],
501+ "tabBar": 57,
502+ "bodyPad": 56,
503+ "cls": 0,
504+ "errors": [],
505+ "file": "countries_canada-390.png"
506+ },
507+ {
508+ "path": "/countries/canada/economy",
509+ "width": 390,
510+ "overflow": 0,
511+ "wide": [
512+ "li.snap-start right=445",
513+ "a.inline-flex.h-9.items-center right=445",
514+ "li.snap-start right=518",
515+ "a.inline-flex.h-9.items-center right=518",
516+ "li.snap-start right=597",
517+ "a.inline-flex.h-9.items-center right=597",
518+ "li.snap-start right=664",
519+ "a.inline-flex.h-9.items-center right=664"
520+ ],
521+ "small": [
522+ "a \"Skip to content\" 1x1",
523+ "a \"REER\" 40x20",
524+ "a \"API\" 22x32"
525+ ],
526+ "smallH": [
527+ "a \"Skip to content\" h=1",
528+ "a \"Countries\" h=15",
529+ "a \"🇨🇦 Canada\" h=15",
530+ "a \"GDP (current US$)\" h=20",
531+ "a \"GDP, PPP (current internationa\" h=20",
532+ "a \"GDP per capita (current US$)\" h=20",
533+ "a \"GDP per capita, PPP (current i\" h=20",
534+ "a \"Real GDP\" h=20",
535+ "a \"Industrial production index\" h=20",
536+ "a \"GDP growth\" h=20",
537+ "a \"GDP per capita growth\" h=20",
538+ "a \"Inflation\" h=20"
539+ ],
540+ "tabBar": 57,
541+ "bodyPad": 56,
542+ "cls": 0,
543+ "errors": [],
544+ "file": "countries_canada_economy-390.png"
545+ },
546+ {
547+ "path": "/",
548+ "width": 414,
549+ "overflow": 0,
550+ "wide": [
551+ "li. right=501",
552+ "a.inline-flex.h-9.items-center right=501",
553+ "li. right=585",
554+ "a.inline-flex.h-9.items-center right=585",
555+ "li. right=690",
556+ "a.inline-flex.h-9.items-center right=690",
557+ "li. right=846",
558+ "a.inline-flex.h-9.items-center right=846"
559+ ],
560+ "small": [
561+ "a \"Skip to content\" 1x1",
562+ "a \"API\" 22x32"
563+ ],
564+ "smallH": [
565+ "a \"Skip to content\" h=1",
566+ "a \"See all →\" h=21",
567+ "a \"🇨🇦Canada\" h=21",
568+ "a \"🇰🇷South Korea\" h=21",
569+ "a \"🇦🇪United Arab Emirates\" h=21",
570+ "a \"🇨🇱Chile\" h=21",
571+ "a \"🇸🇦Saudi Arabia\" h=21",
572+ "a \"🇲🇳Mongolia\" h=21",
573+ "a \"🇿🇦South Africa\" h=21",
574+ "a \"🇩🇿Algeria\" h=21",
575+ "a \"🇿🇲Zambia\" h=21",
576+ "a \"🇵🇰Pakistan\" h=21"
577+ ],
578+ "tabBar": 57,
579+ "bodyPad": 56,
580+ "cls": 0,
581+ "errors": [],
582+ "file": "home-414.png"
583+ },
584+ {
585+ "path": "/countries",
586+ "width": 414,
587+ "overflow": 0,
588+ "wide": [],
589+ "small": [
590+ "a \"Skip to content\" 1x1",
591+ "a \"API\" 22x32"
592+ ],
593+ "smallH": [
594+ "a \"Skip to content\" h=1",
595+ "select \"NamePopulationGDP per capitaCo\" h=19",
596+ "a \"contact@spboucher.ai\" h=15",
597+ "a \"MacLustr\" h=15"
598+ ],
599+ "tabBar": 57,
600+ "bodyPad": 56,
601+ "cls": 0,
602+ "errors": [],
603+ "file": "countries-414.png"
604+ },
605+ {
606+ "path": "/countries/canada",
607+ "width": 414,
608+ "overflow": 0,
609+ "wide": [
610+ "li.snap-start right=444",
611+ "a.inline-flex.h-9.items-center right=444",
612+ "span.tnum.text-2xs.text-ink-3 right=434",
613+ "li.snap-start right=526",
614+ "a.inline-flex.h-9.items-center right=526",
615+ "span.tnum.text-2xs.text-ink-3 right=516",
616+ "li.snap-start right=612",
617+ "a.inline-flex.h-9.items-center right=612"
618+ ],
619+ "small": [
620+ "a \"Skip to content\" 1x1",
621+ "a \"API\" 22x32"
622+ ],
623+ "smallH": [
624+ "a \"Skip to content\" h=1",
625+ "a \"Internet users at a 10-year hi\" h=19",
626+ "a \"Learning poverty turned positi\" h=19",
627+ "a \"🇱🇺Luxembourg\" h=21",
628+ "a \"🇸🇪Sweden\" h=21",
629+ "a \"🇳🇷Nauru\" h=21",
630+ "a \"🇳🇴Norway\" h=21",
631+ "a \"🇨🇾Cyprus\" h=21",
632+ "a \"🇩🇪Germany\" h=21",
633+ "a \"🇳🇨New Caledonia\" h=21",
634+ "a \"🇬🇮Gibraltar\" h=21",
635+ "a \"🇺🇸 United States\" h=17"
636+ ],
637+ "tabBar": 57,
638+ "bodyPad": 56,
639+ "cls": 0,
640+ "errors": [],
641+ "file": "countries_canada-414.png"
642+ },
643+ {
644+ "path": "/countries/canada/economy",
645+ "width": 414,
646+ "overflow": 0,
647+ "wide": [
648+ "li.snap-start right=445",
649+ "a.inline-flex.h-9.items-center right=445",
650+ "li.snap-start right=518",
651+ "a.inline-flex.h-9.items-center right=518",
652+ "li.snap-start right=597",
653+ "a.inline-flex.h-9.items-center right=597",
654+ "li.snap-start right=664",
655+ "a.inline-flex.h-9.items-center right=664"
656+ ],
657+ "small": [
658+ "a \"Skip to content\" 1x1",
659+ "a \"REER\" 40x20",
660+ "a \"API\" 22x32"
661+ ],
662+ "smallH": [
663+ "a \"Skip to content\" h=1",
664+ "a \"Countries\" h=15",
665+ "a \"🇨🇦 Canada\" h=15",
666+ "a \"GDP (current US$)\" h=20",
667+ "a \"GDP, PPP (current internationa\" h=20",
668+ "a \"GDP per capita (current US$)\" h=20",
669+ "a \"GDP per capita, PPP (current i\" h=20",
670+ "a \"Real GDP\" h=20",
671+ "a \"Industrial production index\" h=20",
672+ "a \"GDP growth\" h=20",
673+ "a \"GDP per capita growth\" h=20",
674+ "a \"Inflation\" h=20"
675+ ],
676+ "tabBar": 57,
677+ "bodyPad": 56,
678+ "cls": 0,
679+ "errors": [],
680+ "file": "countries_canada_economy-414.png"
681+ },
682+ {
683+ "path": "/",
684+ "width": 430,
685+ "overflow": 0,
686+ "wide": [
687+ "li. right=501",
688+ "a.inline-flex.h-9.items-center right=501",
689+ "li. right=585",
690+ "a.inline-flex.h-9.items-center right=585",
691+ "li. right=690",
692+ "a.inline-flex.h-9.items-center right=690",
693+ "li. right=846",
694+ "a.inline-flex.h-9.items-center right=846"
695+ ],
696+ "small": [
697+ "a \"Skip to content\" 1x1",
698+ "a \"API\" 22x32"
699+ ],
700+ "smallH": [
701+ "a \"Skip to content\" h=1",
702+ "a \"See all →\" h=21",
703+ "a \"🇨🇦Canada\" h=21",
704+ "a \"🇰🇷South Korea\" h=21",
705+ "a \"🇦🇪United Arab Emirates\" h=21",
706+ "a \"🇨🇱Chile\" h=21",
707+ "a \"🇸🇦Saudi Arabia\" h=21",
708+ "a \"🇲🇳Mongolia\" h=21",
709+ "a \"🇿🇦South Africa\" h=21",
710+ "a \"🇩🇿Algeria\" h=21",
711+ "a \"🇿🇲Zambia\" h=21",
712+ "a \"🇵🇰Pakistan\" h=21"
713+ ],
714+ "tabBar": 57,
715+ "bodyPad": 56,
716+ "cls": 0,
717+ "errors": [],
718+ "file": "home-430.png"
719+ },
720+ {
721+ "path": "/countries",
722+ "width": 430,
723+ "overflow": 0,
724+ "wide": [],
725+ "small": [
726+ "a \"Skip to content\" 1x1",
727+ "a \"API\" 22x32"
728+ ],
729+ "smallH": [
730+ "a \"Skip to content\" h=1",
731+ "select \"NamePopulationGDP per capitaCo\" h=19",
732+ "a \"contact@spboucher.ai\" h=15",
733+ "a \"MacLustr\" h=15"
734+ ],
735+ "tabBar": 57,
736+ "bodyPad": 56,
737+ "cls": 0,
738+ "errors": [],
739+ "file": "countries-430.png"
740+ },
741+ {
742+ "path": "/countries/canada",
743+ "width": 430,
744+ "overflow": 0,
745+ "wide": [
746+ "li.snap-start right=444",
747+ "a.inline-flex.h-9.items-center right=444",
748+ "span.tnum.text-2xs.text-ink-3 right=434",
749+ "li.snap-start right=526",
750+ "a.inline-flex.h-9.items-center right=526",
751+ "span.tnum.text-2xs.text-ink-3 right=516",
752+ "li.snap-start right=612",
753+ "a.inline-flex.h-9.items-center right=612"
754+ ],
755+ "small": [
756+ "a \"Skip to content\" 1x1",
757+ "a \"API\" 22x32"
758+ ],
759+ "smallH": [
760+ "a \"Skip to content\" h=1",
761+ "a \"Internet users at a 10-year hi\" h=19",
762+ "a \"Learning poverty turned positi\" h=19",
763+ "a \"🇱🇺Luxembourg\" h=21",
764+ "a \"🇸🇪Sweden\" h=21",
765+ "a \"🇳🇷Nauru\" h=21",
766+ "a \"🇳🇴Norway\" h=21",
767+ "a \"🇨🇾Cyprus\" h=21",
768+ "a \"🇩🇪Germany\" h=21",
769+ "a \"🇳🇨New Caledonia\" h=21",
770+ "a \"🇬🇮Gibraltar\" h=21",
771+ "a \"🇺🇸 United States\" h=17"
772+ ],
773+ "tabBar": 57,
774+ "bodyPad": 56,
775+ "cls": 0,
776+ "errors": [],
777+ "file": "countries_canada-430.png"
778+ },
779+ {
780+ "path": "/countries/canada/economy",
781+ "width": 430,
782+ "overflow": 0,
783+ "wide": [
784+ "li.snap-start right=445",
785+ "a.inline-flex.h-9.items-center right=445",
786+ "li.snap-start right=518",
787+ "a.inline-flex.h-9.items-center right=518",
788+ "li.snap-start right=597",
789+ "a.inline-flex.h-9.items-center right=597",
790+ "li.snap-start right=664",
791+ "a.inline-flex.h-9.items-center right=664"
792+ ],
793+ "small": [
794+ "a \"Skip to content\" 1x1",
795+ "a \"REER\" 40x20",
796+ "a \"API\" 22x32"
797+ ],
798+ "smallH": [
799+ "a \"Skip to content\" h=1",
800+ "a \"Countries\" h=15",
801+ "a \"🇨🇦 Canada\" h=15",
802+ "a \"GDP (current US$)\" h=20",
803+ "a \"GDP, PPP (current internationa\" h=20",
804+ "a \"GDP per capita (current US$)\" h=20",
805+ "a \"GDP per capita, PPP (current i\" h=20",
806+ "a \"Real GDP\" h=20",
807+ "a \"Industrial production index\" h=20",
808+ "a \"GDP growth\" h=20",
809+ "a \"GDP per capita growth\" h=20",
810+ "a \"Inflation\" h=20"
811+ ],
812+ "tabBar": 57,
813+ "bodyPad": 56,
814+ "cls": 0,
815+ "errors": [],
816+ "file": "countries_canada_economy-430.png"
817+ },
818+ {
819+ "path": "/",
820+ "width": 1280,
821+ "overflow": 0,
822+ "wide": [],
823+ "small": [
824+ "a \"Skip to content\" 1x1",
825+ "a \"API\" 22x32"
826+ ],
827+ "smallH": [
828+ "a \"Skip to content\" h=1",
829+ "a \"See all →\" h=21",
830+ "a \"🇨🇦Canada\" h=21",
831+ "a \"🇰🇷South Korea\" h=21",
832+ "a \"🇦🇪United Arab Emirates\" h=21",
833+ "a \"🇨🇱Chile\" h=21",
834+ "a \"🇸🇦Saudi Arabia\" h=21",
835+ "a \"🇲🇳Mongolia\" h=21",
836+ "a \"🇿🇦South Africa\" h=21",
837+ "a \"🇩🇿Algeria\" h=21",
838+ "a \"🇿🇲Zambia\" h=21",
839+ "a \"🇵🇰Pakistan\" h=21"
840+ ],
841+ "tabBar": 0,
842+ "bodyPad": 0,
843+ "cls": 0,
844+ "errors": [],
845+ "file": "home-1280.png"
846+ },
847+ {
848+ "path": "/countries",
849+ "width": 1280,
850+ "overflow": 0,
851+ "wide": [],
852+ "small": [
853+ "a \"Skip to content\" 1x1",
854+ "button \"All\" 38x36",
855+ "button \"All\" 38x36",
856+ "a \"A\" 24x20",
857+ "a \"B\" 24x20",
858+ "a \"C\" 24x20",
859+ "a \"D\" 24x20",
860+ "a \"E\" 24x20",
861+ "a \"F\" 24x20",
862+ "a \"G\" 24x20"
863+ ],
864+ "smallH": [
865+ "a \"Skip to content\" h=1",
866+ "select \"NamePopulationGDP per capitaCo\" h=19",
867+ "a \"A\" h=20",
868+ "a \"B\" h=20",
869+ "a \"C\" h=20",
870+ "a \"D\" h=20",
871+ "a \"E\" h=20",
872+ "a \"F\" h=20",
873+ "a \"G\" h=20",
874+ "a \"H\" h=20",
875+ "a \"I\" h=20",
876+ "a \"J\" h=20"
877+ ],
878+ "tabBar": 0,
879+ "bodyPad": 0,
880+ "cls": 0,
881+ "errors": [],
882+ "file": "countries-1280.png"
883+ },
884+ {
885+ "path": "/countries/canada",
886+ "width": 1280,
887+ "overflow": 0,
888+ "wide": [
889+ "li.snap-start right=1311",
890+ "a.inline-flex.h-9.items-center right=1311",
891+ "span.tnum.text-2xs.text-ink-3 right=1301",
892+ "li.snap-start right=1438",
893+ "a.inline-flex.h-9.items-center right=1438",
894+ "span.tnum.text-2xs.text-ink-3 right=1428",
895+ "li.snap-start right=1516",
896+ "a.inline-flex.h-9.items-center right=1516"
897+ ],
898+ "small": [
899+ "a \"Skip to content\" 1x1",
900+ "a \"API\" 22x32"
901+ ],
902+ "smallH": [
903+ "a \"Skip to content\" h=1",
904+ "a \"Exports of goods and services \" h=19",
905+ "a \"Internet users at a 10-year hi\" h=19",
906+ "a \"Gross debt / GDP accelerating \" h=19",
907+ "a \"ICT goods exports (% of total \" h=19",
908+ "a \"Imports of goods and services \" h=19",
909+ "a \"Industrial production index hi\" h=19",
910+ "a \"Learning poverty turned positi\" h=19",
911+ "a \"🇱🇺Luxembourg\" h=21",
912+ "a \"🇸🇪Sweden\" h=21",
913+ "a \"🇳🇷Nauru\" h=21",
914+ "a \"🇳🇴Norway\" h=21"
915+ ],
916+ "tabBar": 0,
917+ "bodyPad": 0,
918+ "cls": 0,
919+ "errors": [],
920+ "file": "countries_canada-1280.png"
921+ },
922+ {
923+ "path": "/countries/canada/economy",
924+ "width": 1280,
925+ "overflow": 0,
926+ "wide": [
927+ "li.snap-start right=1347",
928+ "a.inline-flex.h-9.items-center right=1347",
929+ "li.snap-start right=1444",
930+ "a.inline-flex.h-9.items-center right=1444",
931+ "li.snap-start right=1521",
932+ "a.inline-flex.h-9.items-center right=1521",
933+ "li.snap-start right=1600",
934+ "a.inline-flex.h-9.items-center right=1600"
935+ ],
936+ "small": [
937+ "a \"Skip to content\" 1x1",
938+ "a \"REER\" 40x20",
939+ "a \"API\" 22x32"
940+ ],
941+ "smallH": [
942+ "a \"Skip to content\" h=1",
943+ "a \"Countries\" h=15",
944+ "a \"🇨🇦 Canada\" h=15",
945+ "a \"GDP (current US$)\" h=20",
946+ "a \"GDP, PPP (current internationa\" h=20",
947+ "a \"GDP per capita (current US$)\" h=20",
948+ "a \"Real GDP\" h=20",
949+ "a \"Industrial production index\" h=20",
950+ "a \"GDP growth\" h=20",
951+ "a \"GDP per capita growth\" h=20",
952+ "a \"Inflation\" h=20",
953+ "a \"Inflation, GDP deflator\" h=20"
954+ ],
955+ "tabBar": 0,
956+ "bodyPad": 0,
957+ "cls": 0,
958+ "errors": [],
959+ "file": "countries_canada_economy-1280.png"
960+ },
961+ {
962+ "path": "/",
963+ "width": 1440,
964+ "overflow": 0,
965+ "wide": [],
966+ "small": [
967+ "a \"Skip to content\" 1x1",
968+ "a \"API\" 22x32"
969+ ],
970+ "smallH": [
971+ "a \"Skip to content\" h=1",
972+ "a \"See all →\" h=21",
973+ "a \"🇨🇦Canada\" h=21",
974+ "a \"🇰🇷South Korea\" h=21",
975+ "a \"🇦🇪United Arab Emirates\" h=21",
976+ "a \"🇨🇱Chile\" h=21",
977+ "a \"🇸🇦Saudi Arabia\" h=21",
978+ "a \"🇲🇳Mongolia\" h=21",
979+ "a \"🇿🇦South Africa\" h=21",
980+ "a \"🇩🇿Algeria\" h=21",
981+ "a \"🇿🇲Zambia\" h=21",
982+ "a \"🇵🇰Pakistan\" h=21"
983+ ],
984+ "tabBar": 0,
985+ "bodyPad": 0,
986+ "cls": 0,
987+ "errors": [],
988+ "file": "home-1440.png"
989+ },
990+ {
991+ "path": "/countries",
992+ "width": 1440,
993+ "overflow": 0,
994+ "wide": [],
995+ "small": [
996+ "a \"Skip to content\" 1x1",
997+ "button \"All\" 38x36",
998+ "button \"All\" 38x36",
999+ "a \"A\" 24x20",
1000+ "a \"B\" 24x20",
1001+ "a \"C\" 24x20",
1002+ "a \"D\" 24x20",
1003+ "a \"E\" 24x20",
1004+ "a \"F\" 24x20",
1005+ "a \"G\" 24x20"
1006+ ],
1007+ "smallH": [
1008+ "a \"Skip to content\" h=1",
1009+ "select \"NamePopulationGDP per capitaCo\" h=19",
1010+ "a \"A\" h=20",
1011+ "a \"B\" h=20",
1012+ "a \"C\" h=20",
1013+ "a \"D\" h=20",
1014+ "a \"E\" h=20",
1015+ "a \"F\" h=20",
1016+ "a \"G\" h=20",
1017+ "a \"H\" h=20",
1018+ "a \"I\" h=20",
1019+ "a \"J\" h=20"
1020+ ],
1021+ "tabBar": 0,
1022+ "bodyPad": 0,
1023+ "cls": 0,
1024+ "errors": [],
1025+ "file": "countries-1440.png"
1026+ },
1027+ {
1028+ "path": "/countries/canada",
1029+ "width": 1440,
1030+ "overflow": 0,
1031+ "wide": [
1032+ "li.snap-start right=1458",
1033+ "a.inline-flex.h-9.items-center right=1458",
1034+ "span.tnum.text-2xs.text-ink-3 right=1448",
1035+ "li.snap-start right=1536",
1036+ "a.inline-flex.h-9.items-center right=1536",
1037+ "span.tnum.text-2xs.text-ink-3 right=1526",
1038+ "li.snap-start right=1642",
1039+ "a.inline-flex.h-9.items-center right=1642"
1040+ ],
1041+ "small": [
1042+ "a \"Skip to content\" 1x1",
1043+ "a \"API\" 22x32"
1044+ ],
1045+ "smallH": [
1046+ "a \"Skip to content\" h=1",
1047+ "a \"Exports of goods and services \" h=19",
1048+ "a \"Internet users at a 10-year hi\" h=19",
1049+ "a \"Gross debt / GDP accelerating \" h=19",
1050+ "a \"ICT goods exports (% of total \" h=19",
1051+ "a \"High-technology exports (curre\" h=19",
1052+ "a \"Imports of goods and services \" h=19",
1053+ "a \"Industrial production index hi\" h=19",
1054+ "a \"Learning poverty turned positi\" h=19",
1055+ "a \"🇱🇺Luxembourg\" h=21",
1056+ "a \"🇸🇪Sweden\" h=21",
1057+ "a \"🇳🇷Nauru\" h=21"
1058+ ],
1059+ "tabBar": 0,
1060+ "bodyPad": 0,
1061+ "cls": 0,
1062+ "errors": [],
1063+ "file": "countries_canada-1440.png"
1064+ },
1065+ {
1066+ "path": "/countries/canada/economy",
1067+ "width": 1440,
1068+ "overflow": 0,
1069+ "wide": [
1070+ "li.snap-start right=1464",
1071+ "a.inline-flex.h-9.items-center right=1464",
1072+ "li.snap-start right=1541",
1073+ "a.inline-flex.h-9.items-center right=1541",
1074+ "li.snap-start right=1620",
1075+ "a.inline-flex.h-9.items-center right=1620",
1076+ "li.snap-start right=1731",
1077+ "a.inline-flex.h-9.items-center right=1731"
1078+ ],
1079+ "small": [
1080+ "a \"Skip to content\" 1x1",
1081+ "a \"REER\" 40x20",
1082+ "a \"API\" 22x32"
1083+ ],
1084+ "smallH": [
1085+ "a \"Skip to content\" h=1",
1086+ "a \"Countries\" h=15",
1087+ "a \"🇨🇦 Canada\" h=15",
1088+ "a \"GDP (current US$)\" h=20",
1089+ "a \"GDP, PPP (current internationa\" h=20",
1090+ "a \"GDP per capita (current US$)\" h=20",
1091+ "a \"Real GDP\" h=20",
1092+ "a \"Industrial production index\" h=20",
1093+ "a \"GDP growth\" h=20",
1094+ "a \"GDP per capita growth\" h=20",
1095+ "a \"Inflation\" h=20",
1096+ "a \"Inflation, GDP deflator\" h=20"
1097+ ],
1098+ "tabBar": 0,
1099+ "bodyPad": 0,
1100+ "cls": 0,
1101+ "errors": [],
1102+ "file": "countries_canada_economy-1440.png"
1103+ }
1104+]
\ No newline at end of file
added apps/web/qa/screens/seg/countries-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/countries_canada_economy-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-1440-4.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-0.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-1.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-2.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-3.png +0 −0

Binary file not shown.

added apps/web/qa/screens/seg/home-390-4.png +0 −0

Binary file not shown.

added apps/web/qa/segments.mjs +28 −0
@@ -0,0 +1,28 @@
1+// Viewport-sized segments for visual review (top of page + scroll offsets).
2+import { chromium } from 'playwright';
3+const BASE = process.argv[2] ?? 'http://localhost:8290';
4+const OUT = new URL('./screens/seg/', import.meta.url).pathname;
5+import { mkdirSync } from 'node:fs';
6+mkdirSync(OUT, { recursive: true });
7+const PAGES = ['/', '/countries', '/countries/canada', '/countries/canada/economy'];
8+const browser = await chromium.launch();
9+for (const width of [390, 1440]) {
10+ const mobile = width < 768;
11+ const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, isMobile: mobile, hasTouch: mobile });
12+ const page = await ctx.newPage();
13+ for (const path of PAGES) {
14+ await page.goto(BASE + path, { waitUntil: 'networkidle' });
15+ await page.evaluate(() => document.fonts.ready);
16+ const h = await page.evaluate(() => document.documentElement.scrollHeight);
17+ const vh = mobile ? 844 : 900;
18+ const offsets = [0, Math.round(h * 0.25), Math.round(h * 0.5), Math.round(h * 0.75), h - vh].filter((o, i, a) => a.indexOf(o) === i);
19+ const name = path === '/' ? 'home' : path.slice(1).replace(/\//g, '_');
20+ for (const [i, o] of offsets.entries()) {
21+ await page.evaluate((y) => window.scrollTo(0, y), o);
22+ await page.waitForTimeout(400);
23+ await page.screenshot({ path: `${OUT}${name}-${width}-${i}.png` });
24+ }
25+ }
26+ await ctx.close();
27+}
28+await browser.close();
added apps/web/src/app/(home)/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1+import { t } from '@/i18n';
2+
3+/** Root loading UI: a calm, low-contrast skeleton with reserved heights (no spinner, no layout jump). */
4+export default function Loading() {
5+ return (
6+ <div className="animate-fade py-8" aria-busy="true" aria-label={t('common.loading')}>
7+ <div className="h-8 w-2/3 max-w-md rounded-sm bg-surface-2" />
8+ <div className="mt-3 h-4 w-1/2 max-w-sm rounded-sm bg-surface-2" />
9+ <div className="mt-8 grid gap-6 min-[361px]:grid-cols-2 md:grid-cols-4">
10+ {Array.from({ length: 8 }, (_, i) => (
11+ <div key={i} className="border-t border-rule pt-3">
12+ <div className="h-3 w-24 rounded-sm bg-surface-2" />
13+ <div className="mt-3 h-7 w-28 rounded-sm bg-surface-2" />
14+ <div className="mt-2 h-3 w-20 rounded-sm bg-surface-2" />
15+ </div>
16+ ))}
17+ </div>
18+ </div>
19+ );
20+}
renamed apps/web/src/app/page.tsx → apps/web/src/app/(home)/page.tsx +0 −0
added apps/web/src/app/apple-icon.png +0 −0

Binary file not shown.

added apps/web/src/app/countries/(list)/loading.tsx +20 −0
@@ -0,0 +1,20 @@
1+import { t } from '@/i18n';
2+
3+/** Root loading UI: a calm, low-contrast skeleton with reserved heights (no spinner, no layout jump). */
4+export default function Loading() {
5+ return (
6+ <div className="animate-fade py-8" aria-busy="true" aria-label={t('common.loading')}>
7+ <div className="h-8 w-2/3 max-w-md rounded-sm bg-surface-2" />
8+ <div className="mt-3 h-4 w-1/2 max-w-sm rounded-sm bg-surface-2" />
9+ <div className="mt-8 grid gap-6 min-[361px]:grid-cols-2 md:grid-cols-4">
10+ {Array.from({ length: 8 }, (_, i) => (
11+ <div key={i} className="border-t border-rule pt-3">
12+ <div className="h-3 w-24 rounded-sm bg-surface-2" />
13+ <div className="mt-3 h-7 w-28 rounded-sm bg-surface-2" />
14+ <div className="mt-2 h-3 w-20 rounded-sm bg-surface-2" />
15+ </div>
16+ ))}
17+ </div>
18+ </div>
19+ );
20+}
renamed apps/web/src/app/countries/page.tsx → apps/web/src/app/countries/(list)/page.tsx +0 −0
added apps/web/src/app/countries/[slug]/[topic]/page.tsx +137 −0
@@ -0,0 +1,137 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { t } from '@/i18n';
5+import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6+import { routes } from '@/lib/site';
7+import { TOPICS, isTopicId, topicById } from '@/lib/topics';
8+import type { CountryTopicResponse, MetricValue, SeriesResponse } from '@/lib/types';
9+import { IndicatorRow } from '@/components/country/indicator-row';
10+import { NotBuiltState } from '@/components/data/empty-state';
11+import { Section } from '@/components/data/section';
12+import { TopicNav } from '@/components/data/topic-nav';
13+
14+export const revalidate = 900;
15+
16+type Params = { slug: string; topic: string };
17+const EAGER = 4; // charts rendered with server-fetched full history; the rest mount on scroll
18+
19+async function load(slug: string, topic: string): Promise<CountryTopicResponse | 'not-built' | null> {
20+ if (!isTopicId(topic)) return null;
21+ try {
22+ return await api.countryTopic(slug, topic);
23+ } catch (e) {
24+ if (isNotFound(e)) return null;
25+ if (isNotBuilt(e)) return 'not-built';
26+ throw e;
27+ }
28+}
29+
30+export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
31+ const { slug, topic } = await params;
32+ const data = await load(slug, topic);
33+ if (!data || data === 'not-built') return { title: t('topic.notFound'), robots: { index: false } };
34+ const country = data.country.name ?? slug;
35+ const list = data.subtopics
36+ .flatMap((b) => b.indicators)
37+ .slice(0, 4)
38+ .map((m) => (m.indicator_name ?? m.indicator).toLowerCase())
39+ .join(', ');
40+ const title = t('topic.title', { country, topic: data.topic.name });
41+ const canonical = routes.countryTopic(data.country.slug ?? slug, topic);
42+ return {
43+ title,
44+ description: t('topic.description', { topic: data.topic.name, country, list }),
45+ alternates: { canonical },
46+ openGraph: { title: `${title} — ${t('site.name')}`, url: canonical, type: 'article' },
47+ };
48+}
49+
50+export default async function CountryTopicPage({ params }: { params: Promise<Params> }) {
51+ const { slug, topic } = await params;
52+ const data = await load(slug, topic);
53+ if (data === null) notFound();
54+ if (data === 'not-built') return <NotBuiltState />;
55+
56+ const c = data.country;
57+ const countryRef = { id: c.id, slug: c.slug ?? slug, name: c.name ?? c.id, flag: c.flag };
58+ const all = data.subtopics.flatMap((b) => b.indicators);
59+ const withData = all.filter((m) => m.has_data);
60+ const noData = all.filter((m) => !m.has_data);
61+ const eagerIds = withData.slice(0, EAGER).map((m) => m.indicator);
62+ const eagerSeries = await Promise.all(eagerIds.map((iid) => safe(api.countrySeries(c.id, iid))));
63+ const seriesById = new Map<string, SeriesResponse | null>(eagerIds.map((iid, i) => [iid, eagerSeries[i] ?? null]));
64+ const def = topicById(topic);
65+ const others = TOPICS.filter((tp) => tp.id !== topic);
66+
67+ return (
68+ <>
69+ <header className="pb-3 pt-6 md:pt-10">
70+ <nav aria-label="Breadcrumb" className="text-xs text-ink-3">
71+ <Link href={routes.countries()} className="hover:text-accent">
72+ {t('nav.countries')}
73+ </Link>
74+ <span className="mx-1.5">/</span>
75+ <Link href={routes.country(countryRef.slug)} className="hover:text-accent">
76+ <span aria-hidden>{c.flag} </span>
77+ {countryRef.name}
78+ </Link>
79+ </nav>
80+ <h1 className="display mt-2 text-3xl leading-tight text-ink md:text-4xl">
81+ {countryRef.name} <span className="text-ink-3">·</span> {data.topic.name}
82+ </h1>
83+ <p className="mt-2 max-w-prose text-sm text-ink-2 md:text-base">{data.topic.blurb ?? def?.blurb}</p>
84+ <p className="tnum mt-1 text-xs text-ink-3">
85+ {t('topic.indicators', { n: data.n_indicators })} · {t('topic.withData', { n: data.n_with_data })}
86+ </p>
87+ </header>
88+ <TopicNav slug={countryRef.slug} />
89+
90+ {data.subtopics.map((block) => {
91+ const rows = block.indicators.filter((m) => m.has_data);
92+ if (rows.length === 0) return null;
93+ return (
94+ <Section key={block.subtopic} id={`sub-${block.subtopic.toLowerCase().replace(/\W+/g, '-')}`} title={block.subtopic} level={3} tight className="pb-2">
95+ <div>
96+ {rows.map((m: MetricValue) => (
97+ <IndicatorRow key={m.indicator} metric={m} country={countryRef} regionName={c.region_name} series={seriesById.get(m.indicator)} eager={seriesById.has(m.indicator)} />
98+ ))}
99+ </div>
100+ </Section>
101+ );
102+ })}
103+
104+ {noData.length ? (
105+ <details className="hairline group py-4">
106+ <summary className="flex min-h-[44px] cursor-pointer list-none items-center gap-2 text-sm font-medium text-ink-2 hover:text-ink">
107+ <span className="inline-block transition-transform group-open:rotate-90">›</span>
108+ {t('topic.noData', { n: noData.length })}
109+ </summary>
110+ <p className="mt-1 text-xs text-ink-3">{t('topic.noDataHint', { country: countryRef.name })}</p>
111+ <ul className="mt-2 grid gap-x-6 sm:grid-cols-2 lg:grid-cols-3">
112+ {noData.map((m) => (
113+ <li key={m.indicator} className="flex justify-between gap-3 border-t border-rule py-2 text-sm">
114+ <Link href={routes.indicator(m.indicator)} className="link-quiet truncate text-ink-2">
115+ {m.indicator_name ?? m.indicator}
116+ </Link>
117+ <span className="shrink-0 text-xs text-ink-3">{t('common.noData')}</span>
118+ </li>
119+ ))}
120+ </ul>
121+ </details>
122+ ) : null}
123+
124+ <Section id="other-topics" title={t('topic.otherTopics')} level={3} tight>
125+ <ul className="flex flex-wrap gap-1.5">
126+ {others.map((tp) => (
127+ <li key={tp.id}>
128+ <Link href={routes.countryTopic(countryRef.slug, tp.id)} className="inline-flex h-9 items-center rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-accent hover:text-accent">
129+ {tp.short}
130+ </Link>
131+ </li>
132+ ))}
133+ </ul>
134+ </Section>
135+ </>
136+ );
137+}
added apps/web/src/app/countries/[slug]/opengraph-image.tsx +48 −0
@@ -0,0 +1,48 @@
1+import { ImageResponse } from 'next/og';
2+import { t } from '@/i18n';
3+import { api, safe } from '@/lib/api';
4+import { OG_INK2, OG_INK3, OG_RULE, OG_SIZE_H, OG_SIZE_W, OgFrame, OgWordmark } from '../../og-shared';
5+
6+export const alt = 'Country statistics on CountryAtlas';
7+export const size = { width: OG_SIZE_W, height: OG_SIZE_H };
8+export const contentType = 'image/png';
9+export const revalidate = 3600;
10+
11+/** Per-country social image: flag + name + three headline values (population, GDP per capita, life expectancy). */
12+export default async function CountryOgImage({ params }: { params: Promise<{ slug: string }> }) {
13+ const { slug } = await params;
14+ const data = await safe(api.country(slug));
15+ const c = data?.country;
16+ const name = c?.name ?? slug.replace(/-/g, ' ');
17+ const pick = (id: string) => data?.headline.find((m) => m.indicator === id && m.has_data) ?? null;
18+ const facts = [pick('population'), pick('gdp-per-capita'), pick('life-expectancy')].filter((m): m is NonNullable<typeof m> => !!m).slice(0, 3);
19+ const sub = [c?.capital, c?.region_name, c?.income_name].filter(Boolean).join(' · ');
20+ return new ImageResponse(
21+ (
22+ <OgFrame>
23+ <OgWordmark size={28} />
24+ <div style={{ display: 'flex', alignItems: 'center', gap: 28, marginTop: 56 }}>
25+ <div style={{ fontSize: 120, lineHeight: 1, display: 'flex' }}>{c?.flag ?? ''}</div>
26+ <div style={{ display: 'flex', flexDirection: 'column' }}>
27+ <div style={{ fontSize: 68, fontWeight: 600, letterSpacing: -1.5, lineHeight: 1.05 }}>{name}</div>
28+ {sub ? <div style={{ marginTop: 10, fontSize: 24, color: OG_INK2 }}>{sub}</div> : null}
29+ </div>
30+ </div>
31+ <div style={{ display: 'flex', gap: 48, marginTop: 'auto', paddingTop: 24, borderTop: `1px solid ${OG_RULE}` }}>
32+ {facts.length === 0 ? (
33+ <div style={{ fontSize: 24, color: OG_INK2 }}>{t('country.description', { name }).split(':')[1]?.trim() ?? ''}</div>
34+ ) : null}
35+ {facts.map((m) => (
36+ <div key={m.indicator} style={{ display: 'flex', flexDirection: 'column' }}>
37+ <div style={{ fontSize: 18, color: OG_INK3, textTransform: 'uppercase', letterSpacing: 1.5 }}>{m.indicator_name ?? m.indicator}</div>
38+ <div style={{ fontSize: 44, fontWeight: 600, marginTop: 6 }}>{m.formatted ?? String(m.value ?? '')}</div>
39+ <div style={{ fontSize: 18, color: OG_INK3, marginTop: 2 }}>{String(m.year ?? '')}</div>
40+ </div>
41+ ))}
42+ </div>
43+ <div style={{ position: 'absolute', right: 64, bottom: 24, fontSize: 20, color: OG_INK3 }}>{t('og.site')}</div>
44+ </OgFrame>
45+ ),
46+ { ...size },
47+ );
48+}
added apps/web/src/app/countries/[slug]/page.tsx +135 −0
@@ -0,0 +1,135 @@
1+import type { Metadata } from 'next';
2+import Link from 'next/link';
3+import { notFound } from 'next/navigation';
4+import { t } from '@/i18n';
5+import { api, isNotBuilt, isNotFound, safe } from '@/lib/api';
6+import { routes } from '@/lib/site';
7+import { HEADLINE_TOPIC, type HEADLINE_INDICATORS } from '@/lib/topics';
8+import type { CountryResponse } from '@/lib/types';
9+import { DnaRadial } from '@/components/charts/dna-radial';
10+import { CountryHeader } from '@/components/country/country-header';
11+import { KeyFacts } from '@/components/country/key-facts';
12+import { SimilarPanel } from '@/components/country/similar-panel';
13+import { Timeline } from '@/components/country/timeline';
14+import { CountryTopicsGrid } from '@/components/country/topics-grid';
15+import { ChangeList } from '@/components/data/change-list';
16+import { NotBuiltState } from '@/components/data/empty-state';
17+import { Metric, MetricGrid } from '@/components/data/metric';
18+import { Section } from '@/components/data/section';
19+import { TopicNav } from '@/components/data/topic-nav';
20+
21+export const revalidate = 900;
22+
23+type Params = { slug: string };
24+
25+async function loadCountry(slug: string): Promise<CountryResponse | 'not-built' | null> {
26+ try {
27+ return await api.country(slug);
28+ } catch (e) {
29+ if (isNotFound(e)) return null;
30+ if (isNotBuilt(e)) return 'not-built';
31+ throw e;
32+ }
33+}
34+
35+export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
36+ const { slug } = await params;
37+ const data = await loadCountry(slug);
38+ if (!data || data === 'not-built') return { title: t('country.notFound'), robots: { index: false } };
39+ const name = data.country.name ?? slug;
40+ const title = t('country.title', { name });
41+ const description = t('country.description', { name });
42+ const canonical = routes.country(data.country.slug ?? slug);
43+ return {
44+ title,
45+ description,
46+ alternates: { canonical },
47+ openGraph: { title: `${title} — ${t('site.name')}`, description, url: canonical, type: 'article' },
48+ twitter: { card: 'summary_large_image', title, description },
49+ };
50+}
51+
52+export default async function CountryPage({ params }: { params: Promise<Params> }) {
53+ const { slug } = await params;
54+ const data = await loadCountry(slug);
55+ if (data === null) notFound();
56+ if (data === 'not-built') return <NotBuiltState />;
57+
58+ const c = data.country;
59+ const id = c.id;
60+ const name = c.name ?? id;
61+ const countryRef = { id, slug: c.slug ?? slug, name, flag: c.flag };
62+ // Optional panels in parallel; each tolerates failure independently.
63+ const [changes, similar, insights, dna, events] = await Promise.all([
64+ safe(api.countryChanges(id, 8)),
65+ safe(api.countrySimilar(id, 'overall', 8)),
66+ safe(api.countryInsights(id)),
67+ safe(api.countryDna(id)),
68+ safe(api.countryEvents(id, 30)),
69+ ]);
70+ const counts = Object.fromEntries(data.topics.map((tp) => [tp.id, tp.n_with_data]));
71+ const withData = data.topics.reduce((a, tp) => a + tp.n_with_data, 0);
72+
73+ return (
74+ <>
75+ <CountryHeader data={data} />
76+ <TopicNav slug={c.slug ?? slug} counts={counts} />
77+
78+ <Section id="headline" title={t('country.headline.title')} subtitle={t('country.headline.sub')} className="border-t-0">
79+ <MetricGrid>
80+ {data.headline.map((m) => {
81+ const topic = HEADLINE_TOPIC[m.indicator as (typeof HEADLINE_INDICATORS)[number]];
82+ return <Metric key={m.indicator} metric={m} country={countryRef} regionName={c.region_name} href={topic ? routes.countryIndicator(c.slug ?? slug, topic, m.indicator) : null} />;
83+ })}
84+ </MetricGrid>
85+ </Section>
86+
87+ <div className="grid gap-x-10 lg:grid-cols-2">
88+ <Section id="changes" title={t('country.changes.title', { name })} subtitle={t('country.changes.sub')}>
89+ <ChangeList items={changes?.items ?? []} />
90+ </Section>
91+ <Section id="similar" title={t('country.similar.title', { name })} subtitle={t('country.similar.sub')}>
92+ <SimilarPanel countryId={id} initial={similar} />
93+ </Section>
94+ </div>
95+
96+ <div className="grid gap-x-10 lg:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
97+ <Section id="dna" title={t('country.dna.title')} subtitle={t('country.dna.sub')}>
98+ <DnaRadial dna={dna} name={name} size={360} />
99+ {dna?.year_ref ? <p className="tnum mt-2 text-center text-2xs text-ink-3">{dna.year_ref}</p> : null}
100+ </Section>
101+ <Section id="facts" title={t('country.facts.title')} subtitle={t('country.facts.sub')}>
102+ <KeyFacts items={insights?.items ?? []} country={countryRef} />
103+ {data.neighbours.length ? (
104+ <p className="mt-4 text-sm text-ink-2">
105+ <span className="text-ink-3">{t('country.borders')}: </span>
106+ {data.neighbours.map((n, i) => (
107+ <span key={n.id}>
108+ {i > 0 ? ', ' : ''}
109+ <Link href={routes.country(n.slug ?? n.id)} className="link-quiet text-ink hover:text-accent">
110+ <span aria-hidden>{n.flag} </span>
111+ {n.name}
112+ </Link>
113+ </span>
114+ ))}
115+ </p>
116+ ) : null}
117+ {c.languages?.length ? (
118+ <p className="mt-1 text-sm text-ink-2">
119+ <span className="text-ink-3">{t('country.languages')}: </span>
120+ {c.languages.join(', ')}
121+ </p>
122+ ) : null}
123+ </Section>
124+ </div>
125+
126+ <Section id="timeline" title={t('country.timeline.title')} subtitle={t('country.timeline.sub')}>
127+ <Timeline items={events?.items ?? []} slug={c.slug ?? slug} />
128+ </Section>
129+
130+ <Section id="topics" title={t('country.topics.title', { name })} subtitle={t('country.topics.sub', { n: data.topics.length, m: withData })}>
131+ <CountryTopicsGrid slug={c.slug ?? slug} topics={data.topics} />
132+ </Section>
133+ </>
134+ );
135+}
added apps/web/src/app/error.tsx +20 −0
Large diff (20 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/icon.png +0 −0

Binary file not shown.

added apps/web/src/app/not-found.tsx +21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/og-shared.tsx +3 −0
Large diff (3 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/opengraph-image.tsx +24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/robots.ts +10 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/sitemap.ts +26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/app/twitter-image.tsx +6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/country/indicator-row.tsx +134 −0
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/country/timeline.tsx +47 −0
Large diff (47 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/components/country/topics-grid.tsx +31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified apps/web/src/lib/fonts.ts +3 −2
Large diff (11 lines) is not loaded by default to keep the page fast.Load diff
added apps/web/src/lib/og.tsx +64 −0
Large diff (64 lines) is not loaded by default to keep the page fast.Load diff
modified docs/ARCHITECTURE.md +30 −11
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
modified docs/PIPELINE.md +13 −6
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified registry/indicators.yaml +31 −6
Large diff (62 lines) is not loaded by default to keep the page fast.Load diff
modified registry/similarity.yaml +3 −3
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified registry/topics.yaml +1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified src/countryatlas/connectors/owid.py +15 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified src/countryatlas/pipeline/build.py +83 −11
Large diff (134 lines) is not loaded by default to keep the page fast.Load diff
modified src/countryatlas/pipeline/changes.py +83 −16
Large diff (171 lines) is not loaded by default to keep the page fast.Load diff
modified tests/test_build.py +25 −7
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
modified tests/test_changes.py +34 −0
Large diff (53 lines) is not loaded by default to keep the page fast.Load diff
Menu

Theme