diff --git a/docs/contributing/frontend.md b/docs/contributing/frontend.md index 19c26fc..bd098dd 100644 --- a/docs/contributing/frontend.md +++ b/docs/contributing/frontend.md @@ -71,7 +71,9 @@ a permalink shows the same to everyone. from the home page's "Entdecken" cards. 4. Page modules may only export Next.js page fields (`default`, `generateMetadata`, …); keep helpers and constants module-local or in `src/components`. -5. Check it at 390 px and in both themes (design-system.md §7). +5. Check it at 390 px and in both themes (design-system.md §7). `node web/scripts/mobile-audit.mjs` emulates a + phone, reports pages that scroll sideways (and which elements cause it) and saves screenshots; the + script's header shows how to call it. All pages passed on 2026-11-12. ## Showing a new claim type diff --git a/web/scripts/mobile-audit.mjs b/web/scripts/mobile-audit.mjs new file mode 100644 index 0000000..e7dabd7 --- /dev/null +++ b/web/scripts/mobile-audit.mjs @@ -0,0 +1,72 @@ +// Phone-width audit: emulate a 390x844 phone through the DevTools protocol (a plain headless window can't go +// below ~500 px and fakes overflow), then for each page report horizontal overflow, the elements causing it and +// small tap targets, and save a full-page screenshot. Needs Node 22+ and any Chromium browser (Chrome, Edge). +// Usage: node web/scripts/mobile-audit.mjs ... +// Example: node web/scripts/mobile-audit.mjs "/usr/bin/chromium" /tmp/audit http://localhost:3000 / /personen /geld +// From Git Bash, prefix MSYS_NO_PATHCONV=1, or it rewrites "/personen" into a Windows path. +import {spawn} from 'node:child_process'; +import {mkdirSync, writeFileSync} from 'node:fs'; + +const [edge, outDir, base, ...paths] = process.argv.slice(2); +mkdirSync(outDir, {recursive: true}); +const port = 9333; +const proc = spawn(edge, ['--headless=new', `--remote-debugging-port=${port}`, '--disable-gpu', '--no-first-run', + `--user-data-dir=${outDir}/profile`, 'about:blank'], {stdio: 'ignore'}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let target; +for (let i = 0; i < 40 && !target; i++) { + try { target = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()).find((t) => t.type === 'page'); } + catch { await sleep(250); } +} +const ws = new WebSocket(target.webSocketDebuggerUrl); +await new Promise((r) => ws.addEventListener('open', r, {once: true})); +let seq = 0; const pending = new Map(); +ws.addEventListener('message', (ev) => { + const msg = JSON.parse(ev.data); + if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } +}); +const send = (method, params = {}) => new Promise((resolve) => { + const id = ++seq; pending.set(id, resolve); ws.send(JSON.stringify({id, method, params})); +}); +const evaluate = async (expression) => (await send('Runtime.evaluate', {expression, returnByValue: true})).result?.result?.value; + +await send('Page.enable'); +await send('Emulation.setDeviceMetricsOverride', {width: 390, height: 844, deviceScaleFactor: 1, mobile: true}); +await send('Emulation.setTouchEmulationEnabled', {enabled: true}); +await send('Network.enable'); +await send('Network.setUserAgentOverride', {userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148'}); + +const report = []; +for (const path of paths) { + await send('Page.navigate', {url: base + path}); + await sleep(4000); + const info = await evaluate(`(() => { + const vw = document.documentElement.clientWidth; + const offenders = []; + for (const el of document.querySelectorAll('body *')) { + const r = el.getBoundingClientRect(); + if (r.width > 0 && r.right > vw + 1) { + const style = getComputedStyle(el); + let p = el.parentElement, clipped = false; + while (p) { const s = getComputedStyle(p); if (/(auto|scroll|hidden|clip)/.test(s.overflowX)) { clipped = true; break; } p = p.parentElement; } + if (!clipped) offenders.push({tag: el.tagName.toLowerCase(), cls: String(el.className).slice(0, 60), right: Math.round(r.right), text: (el.textContent || '').trim().slice(0, 50)}); + } + } + // keep only the outermost offenders + return {vw, scrollWidth: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight, + offenders: offenders.slice(0, 12), tapTargetsTooSmall: [...document.querySelectorAll('a,button,input,select,summary')] + .filter((e) => { const r = e.getBoundingClientRect(); return r.width > 0 && r.height > 0 && (r.height < 24 || r.width < 24); }).length}; + })()`); + const name = path.replace(/[^a-z0-9]+/gi, '_').replace(/^_|_$/g, '') || 'home'; + const height = Math.min(info?.height ?? 2000, 4000); + await send('Emulation.setDeviceMetricsOverride', {width: 390, height, deviceScaleFactor: 1, mobile: true}); + await sleep(500); + const shot = await send('Page.captureScreenshot', {format: 'png'}); + writeFileSync(`${outDir}/${name}.png`, Buffer.from(shot.result.data, 'base64')); + await send('Emulation.setDeviceMetricsOverride', {width: 390, height: 844, deviceScaleFactor: 1, mobile: true}); + report.push({path, ...info}); +} +console.log(JSON.stringify(report, null, 1)); +ws.close(); proc.kill(); +process.exit(0);