spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
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 */9import { chromium } from 'playwright';10import { mkdirSync, writeFileSync } from 'node:fs';11import { createRequire } from 'node:module';1213const BASE = process.argv[2] ?? process.env.BASE_URL ?? 'http://localhost:8290';14const OUT = new URL('./screens/', import.meta.url).pathname;15mkdirSync(OUT, { recursive: true });1617const PAGES = ['/', '/countries', '/countries/canada', '/countries/canada/economy'];18const WIDTHS = [320, 360, 375, 390, 414, 430, 1280, 1440];19const report = [];2021const browser = await chromium.launch();22for (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 viewport38 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 rasterisation54{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}73await browser.close();74writeFileSync(OUT + 'report.json', JSON.stringify(report, null, 2));75for (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}82