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