From 864facb470c763cea86df7e9c71bba988f3896dc Mon Sep 17 00:00:00 2001 From: Indra Gunawan Date: Thu, 15 Oct 2026 19:49:33 +0700 Subject: [PATCH] feat: UI check script, lazy-load xterm/charts/htmx, perf reports Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 4 + docs/ui-revamp/perf-sebelum.md | 65 ++++++ docs/ui-revamp/perf-sesudah.md | 65 ++++++ internal/http/static/app.css | 3 +- internal/http/static/app.js | 23 ++ .../live_charts.html => static/charts.js} | 6 +- internal/http/templates/apps_detail.html | 4 +- internal/http/templates/layout.html | 10 +- internal/http/templates/servers_detail.html | 2 +- tests/e2e/ui.mjs | 208 ++++++++++++++++++ 10 files changed, 373 insertions(+), 17 deletions(-) create mode 100644 docs/ui-revamp/perf-sebelum.md create mode 100644 docs/ui-revamp/perf-sesudah.md rename internal/http/{templates/live_charts.html => static/charts.js} (99%) create mode 100644 tests/e2e/ui.mjs diff --git a/AGENTS.md b/AGENTS.md index 9a3bdbc..30fb3c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ DOCKIFY_HOST=127.0.0.1 DOCKIFY_DATA_DIR=./data go run ./cmd/dockify serve # Verify templates parse go test ./internal/http/... -run TestTemplates +# UI check (screenshots, overflow, nodes, nav timing, contrast, a11y); needs Playwright in ~/.local/share/pw-tools +DOCKIFY_DEV_MOCK=true DOCKIFY_PORT=18090 go run ./cmd/dockify serve & # optional: DOCKIFY_ADMIN_PASSWORD=uji123 for the login screen +node tests/e2e/ui.mjs [BASE_URL] [OUT] # env UI_USER/UI_PASS when auth is on; exit 1 on overflow/contrast/a11y failure + # Build and run with Docker docker build -t dockify . docker run -p 127.0.0.1:8080:8080 -e DOCKIFY_ALLOW_NO_AUTH=true -v $(pwd)/data:/var/lib/dockify dockify diff --git a/docs/ui-revamp/perf-sebelum.md b/docs/ui-revamp/perf-sebelum.md new file mode 100644 index 0000000..144f673 --- /dev/null +++ b/docs/ui-revamp/perf-sebelum.md @@ -0,0 +1,65 @@ +# Perf sebelum S7 (main setelah S6, build mock, `node tests/e2e/ui.mjs`) + +# Dockify UI report + +Base: http://127.0.0.1:18091 · 2026-10-15T12:46:28.151Z · runs=5 + +## Navigation (CPU 4x, +80 ms per request, median) + +| page | DOMContentLoaded ms | load ms | +| --- | --- | --- | +| dashboard | 327 | 400 | +| servers | 324 | 389 | +| apps | 326 | 380 | +| app-detail | 338 | 397 | + + +## Transfer (cold, on the wire) and DOM nodes @390 + +| page | KB | requests | DOM nodes | nodes/row | +| --- | --- | --- | --- | --- | +| dashboard | 467 | 11 | 253 | 14 | +| servers | 461 | 11 | 146 | 14 | +| apps | 463 | 11 | 174 | 12 | +| app-detail | 473 | 12 | 268 | 7 | +| app-draft | - | - | 185 | 0 | +| server-detail | - | - | 339 | 11 | +| apps-add | - | - | 299 | 0 | +| settings | - | - | 121 | 0 | + + +## Overflow @360/390 + +none (0) + + +## Contrast (WCAG, 19 pairs) + +| palette | theme | pass | +| --- | --- | --- | +| indigo | light | 19/19 | +| indigo | dark | 19/19 | +| navy | light | 19/19 | +| navy | dark | 19/19 | +| teal | light | 19/19 | +| teal | dark | 19/19 | + + +## A11y quick + +| page | unnamed controls | dialogs w/o label | first Tab focus | +| --- | --- | --- | --- | +| dashboard | 0 | 0 | ok | +| servers | 0 | 0 | ok | +| apps | 0 | 0 | ok | +| app-detail | 0 | 0 | ok | +| app-draft | 0 | 0 | ok | +| server-detail | 0 | 0 | ok | +| apps-add | 0 | 0 | ok | +| settings | 0 | 0 | ok | + + +## Result + +PASS + diff --git a/docs/ui-revamp/perf-sesudah.md b/docs/ui-revamp/perf-sesudah.md new file mode 100644 index 0000000..f7a8294 --- /dev/null +++ b/docs/ui-revamp/perf-sesudah.md @@ -0,0 +1,65 @@ +# Perf sesudah S7 (cabang feat/ui-s7-measure, build mock, `node tests/e2e/ui.mjs`) + +# Dockify UI report + +Base: http://127.0.0.1:18090 · 2026-10-15T12:48:49.209Z · runs=5 + +## Navigation (CPU 4x, +80 ms per request, median) + +| page | DOMContentLoaded ms | load ms | +| --- | --- | --- | +| dashboard | 300 | 315 | +| servers | 301 | 311 | +| apps | 301 | 309 | +| app-detail | 328 | 336 | + + +## Transfer (cold, on the wire) and DOM nodes @390 + +| page | KB | requests | DOM nodes | nodes/row | +| --- | --- | --- | --- | --- | +| dashboard | 126 | 7 | 248 | 14 | +| servers | 120 | 7 | 141 | 14 | +| apps | 122 | 7 | 169 | 12 | +| app-detail | 192 | 10 | 266 | 7 | +| app-draft | - | - | 183 | 0 | +| server-detail | - | - | 337 | 11 | +| apps-add | - | - | 294 | 0 | +| settings | - | - | 116 | 0 | + + +## Overflow @360/390 + +none (0) + + +## Contrast (WCAG, 19 pairs) + +| palette | theme | pass | +| --- | --- | --- | +| indigo | light | 19/19 | +| indigo | dark | 19/19 | +| navy | light | 19/19 | +| navy | dark | 19/19 | +| teal | light | 19/19 | +| teal | dark | 19/19 | + + +## A11y quick + +| page | unnamed controls | dialogs w/o label | first Tab focus | +| --- | --- | --- | --- | +| dashboard | 0 | 0 | ok | +| servers | 0 | 0 | ok | +| apps | 0 | 0 | ok | +| app-detail | 0 | 0 | ok | +| app-draft | 0 | 0 | ok | +| server-detail | 0 | 0 | ok | +| apps-add | 0 | 0 | ok | +| settings | 0 | 0 | ok | + + +## Result + +PASS + diff --git a/internal/http/static/app.css b/internal/http/static/app.css index 689eb1e..89200d0 100644 --- a/internal/http/static/app.css +++ b/internal/http/static/app.css @@ -639,7 +639,6 @@ summary { cursor: pointer; font-size: 13px; font-weight: 600; color: var(--accen .stat small { font-size: 12.8px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; } /* Breadcrumbs */ -.breadcrumb { display: flex; align-items: center; gap: 0.4em; font-size: 0.88em; color: var(--ink2); margin-bottom: 1em; } .breadcrumb a { color: var(--ink2); font-weight: 500; } .breadcrumb a:hover { color: var(--accent); } @@ -793,7 +792,7 @@ pre { .login-page .theme-btn { position: fixed; top: 12px; right: 12px; } /* Detail pages (DESIGN-SYSTEM §6) */ -.breadcrumb { font-size: 12.8px; color: var(--muted); margin-bottom: 8px; display: flex; gap: 6px; } +.breadcrumb { display: flex; align-items: center; gap: 6px; font-size: 12.8px; color: var(--muted); margin-bottom: 8px; } .page-head { margin-bottom: 12px; } .page-head h1 { font-size: 22px; font-weight: 800; margin: 0 0 4px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } .page-head .meta { margin: 0; font-size: 12.8px; color: var(--muted); } diff --git a/internal/http/static/app.js b/internal/http/static/app.js index be378e4..8b72c08 100644 --- a/internal/http/static/app.js +++ b/internal/http/static/app.js @@ -58,3 +58,26 @@ document.addEventListener('click', function (e) { if (navigator.clipboard && window.isSecureContext) navigator.clipboard.writeText(text).then(function () { done(true); }, fallback); else fallback(); }); + +// xterm is only needed for the SSH console: load it on first use. +var xtermLoading = null; +function loadXterm(cb) { + if (window.Terminal && window.FitAddon) return cb(); + if (!xtermLoading) { + xtermLoading = new Promise(function (resolve, reject) { + var l = document.createElement('link'); + l.rel = 'stylesheet'; + l.href = 'static/xterm.min.css'; + document.head.appendChild(l); + function add(src, next) { + var s = document.createElement('script'); + s.src = src; + s.onload = next; + s.onerror = reject; + document.head.appendChild(s); + } + add('static/xterm.min.js', function () { add('static/xterm-addon-fit.min.js', resolve); }); + }); + } + xtermLoading.then(cb, function () { xtermLoading = null; }); +} diff --git a/internal/http/templates/live_charts.html b/internal/http/static/charts.js similarity index 99% rename from internal/http/templates/live_charts.html rename to internal/http/static/charts.js index f7dc99b..a454bd5 100644 --- a/internal/http/templates/live_charts.html +++ b/internal/http/static/charts.js @@ -1,5 +1,4 @@ -{{define "live_charts"}} - -{{end}} \ No newline at end of file diff --git a/internal/http/templates/apps_detail.html b/internal/http/templates/apps_detail.html index 7550ad2..ce6e0a8 100644 --- a/internal/http/templates/apps_detail.html +++ b/internal/http/templates/apps_detail.html @@ -1,6 +1,6 @@ -{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}} +{{template "head" .}}{{if eq .App.Status "deploying"}}{{end}} {{template "nav" .}}
@@ -203,7 +203,7 @@

Danger zone

var container = document.getElementById('app-console-container'); if (container.hidden) { container.hidden = false; - connectAppConsole(); + loadXterm(connectAppConsole); } else { container.hidden = true; disconnectAppConsole(); diff --git a/internal/http/templates/layout.html b/internal/http/templates/layout.html index 763294e..0b0c79a 100644 --- a/internal/http/templates/layout.html +++ b/internal/http/templates/layout.html @@ -39,7 +39,8 @@ - + + {{end}} @@ -49,13 +50,8 @@ {{.Title}} - Dockify {{template "theme_head" .}} - - - - -{{template "live_charts"}} {{end}} {{define "nav"}} @@ -97,7 +93,7 @@ if (container.hidden) { container.hidden = false; document.getElementById('console-toggle').textContent = 'Close Console'; - connectConsole(); + loadXterm(connectConsole); } else { container.hidden = true; document.getElementById('console-toggle').textContent = 'Console'; diff --git a/internal/http/templates/servers_detail.html b/internal/http/templates/servers_detail.html index ae5a63b..f080d0b 100644 --- a/internal/http/templates/servers_detail.html +++ b/internal/http/templates/servers_detail.html @@ -1,6 +1,6 @@ -{{template "head" .}}{{if eq .Server.Status "initializing"}}{{end}} +{{template "head" .}}{{if eq .Server.Status "initializing"}}{{end}} {{template "nav" .}}
diff --git a/tests/e2e/ui.mjs b/tests/e2e/ui.mjs new file mode 100644 index 0000000..9353c85 --- /dev/null +++ b/tests/e2e/ui.mjs @@ -0,0 +1,208 @@ +// Dockify UI check (S7, K8): screenshots, overflow, DOM nodes, navigation timing, contrast, quick a11y. +// Usage: node tests/e2e/ui.mjs [BASE_URL] [OUT] (env: UI_USER/UI_PASS when auth is on, PW_PATH, UI_RUNS) +// Needs Playwright (default ~/.local/share/pw-tools/node_modules/playwright). Seeds "ZZ Uji" data when missing. +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { homedir } from 'node:os'; + +const BASE = (process.argv[2] || process.env.BASE_URL || 'http://127.0.0.1:18090').replace(/\/$/, ''); +const OUT = process.argv[3] || process.env.OUT || '/tmp/claude-1000/dockify-ui'; +const RUNS = Number(process.env.UI_RUNS || 5); +const PW = process.env.PW_PATH || join(homedir(), '.local/share/pw-tools/node_modules/playwright/index.mjs'); +const CSS = join(dirname(fileURLToPath(import.meta.url)), '../../internal/http/static/app.css'); +const { chromium } = await import(PW); +mkdirSync(OUT, { recursive: true }); + +const failures = []; +const fail = (m) => { failures.push(m); console.error('FAIL', m); }; +const md = (head, rows) => `| ${head.join(' | ')} |\n|${head.map(() => ' --- ').join('|')}|\n` + rows.map((r) => `| ${r.join(' | ')} |`).join('\n') + '\n'; +const median = (a) => { const s = [...a].sort((x, y) => x - y); return s[Math.floor(s.length / 2)]; }; + +const br = await chromium.launch(); +async function newCtx(opts = {}) { + const ctx = await br.newContext({ viewport: { width: 390, height: 844 }, ...opts }); + const p = await ctx.newPage(); + if (process.env.UI_PASS) { + await p.goto(BASE + '/login'); + if (await p.locator('input[name=user]').count()) { + await p.fill('input[name=user]', process.env.UI_USER || 'admin'); + await p.fill('input[name=pass]', process.env.UI_PASS); + await Promise.all([p.waitForNavigation(), p.click('button[type=submit]')]); + } + } + return [ctx, p]; +} + +// ---------- seed ---------- +async function post(p, path, form) { + return p.evaluate(async ([u, f]) => (await fetch(u, { method: 'POST', body: new URLSearchParams(f), redirect: 'manual' })).status, [BASE + path, form]); +} +async function rows(p, path) { + await p.goto(BASE + path); + return p.evaluate(() => [...document.querySelectorAll('tr[data-name]')].map((r) => ({ name: r.dataset.name, href: r.querySelector('a').getAttribute('href') }))); +} +let [seedCtx, sp] = await newCtx(); +await sp.goto(BASE + '/'); +let servers = (await rows(sp, '/servers')).filter((r) => r.name.startsWith('ZZ Uji')); +if (!servers.length) { + for (const n of [1, 2]) await post(sp, '/servers/add', { name: `ZZ Uji server ${n}`, host: `10.9.0.${n}`, user: 'root', port: '22' }); + servers = (await rows(sp, '/servers')).filter((r) => r.name.startsWith('ZZ Uji')); + for (const s of servers) await post(sp, '/' + s.href + '/init', {}); +} +let apps = (await rows(sp, '/apps')).filter((r) => r.name.startsWith('ZZ Uji')); +if (!apps.length) { + const sid = servers[0].href.split('/').pop(); + const base = { port: '3000', server_id: sid, mode: 'simple', image: 'nginx:latest', git_branch: 'main' }; + await post(sp, '/apps/add', { ...base, name: 'ZZ Uji app 1', domain: 'one.example.com' }); + await post(sp, '/apps/add', { ...base, name: 'ZZ Uji app 2', domain: 'two.example.com' }); + await post(sp, '/apps/add', { ...base, name: 'ZZ Uji draft', domain: 'draft.example.com', draft: '1' }); + await sp.waitForTimeout(1500); + apps = (await rows(sp, '/apps')).filter((r) => r.name.startsWith('ZZ Uji')); +} +await seedCtx.close(); +const appRun = apps.find((a) => a.name.endsWith('app 1')) || apps[0]; +const appDraft = apps.find((a) => a.name.endsWith('draft')) || apps[apps.length - 1]; +const PAGES = [ + ['dashboard', '/'], ['servers', '/servers'], ['apps', '/apps'], ['app-detail', '/' + appRun.href], + ['app-draft', '/' + appDraft.href], ['server-detail', '/' + servers[0].href], ['apps-add', '/apps/add'], ['settings', '/settings'], +]; + +// ---------- screenshots, overflow, nodes ---------- +const VIEWS = [[360, 740], [390, 844], [1280, 800]]; +const nodes = {}, overflow = []; +async function shot(p, name, w, theme, pal) { + await p.waitForTimeout(250); + await p.screenshot({ path: join(OUT, `${name}-${w}-${theme}-${pal}.png`) }); +} +for (const [w, h] of VIEWS) for (const theme of ['light', 'dark']) { + const [ctx, p] = await newCtx({ viewport: { width: w, height: h }, colorScheme: theme }); + for (const [name, path] of PAGES) { + await p.goto(BASE + path); + await shot(p, name, w, theme, 'indigo'); + const m = await p.evaluate(() => ({ + sw: document.documentElement.scrollWidth, cw: document.documentElement.clientWidth, + n: document.querySelectorAll('*').length, + row: (() => { const r = document.querySelector('table.rows tbody tr'); return r ? r.querySelectorAll('*').length + 1 : 0; })(), + })); + if (w < 700 && m.sw > m.cw) { overflow.push(`${name}@${w}`); fail(`overflow ${name} @${w} (${m.sw}>${m.cw})`); } + if (w === 390 && theme === 'light') nodes[name] = m; + } + await ctx.close(); +} +// login (unauthenticated) +for (const theme of ['light', 'dark']) { + const ctx = await br.newContext({ viewport: { width: 390, height: 844 }, colorScheme: theme }); + const p = await ctx.newPage(); + await p.goto(BASE + '/login'); + const isLogin = await p.locator('input[name=user]').count(); + if (isLogin) await shot(p, 'login', 390, theme, 'indigo'); else if (theme === 'light') console.log('login: auth disabled (redirect), skipped; set DOCKIFY_ADMIN_PASSWORD on the test server'); + await ctx.close(); +} +// other palettes +for (const pal of ['navy', 'teal']) { + const ctx = await br.newContext({ viewport: { width: 390, height: 844 }, colorScheme: 'dark' }); + await ctx.addInitScript((v) => { localStorage.setItem('dockify_palette', v); localStorage.setItem('dockify_theme', 'dark'); }, pal); + const [, p] = [null, await ctx.newPage()]; + if (process.env.UI_PASS) { await p.goto(BASE + '/login'); if (await p.locator('input[name=user]').count()) { await p.fill('input[name=user]', process.env.UI_USER || 'admin'); await p.fill('input[name=pass]', process.env.UI_PASS); await Promise.all([p.waitForNavigation(), p.click('button[type=submit]')]); } } + for (const [name, path] of [PAGES[0], PAGES[3]]) { await p.goto(BASE + path); await shot(p, name, 390, 'dark', pal); } + await ctx.close(); +} + +// ---------- transfer size (cold) ---------- +const transfer = {}; +const [lc] = await newCtx(); +const state = await lc.storageState(); +await lc.close(); +for (const [name, path] of PAGES.slice(0, 4)) { + const ctx = await br.newContext({ viewport: { width: 390, height: 844 }, storageState: state }); // fresh cache + const p = await ctx.newPage(); + const list = []; + p.on('request', (rq) => list.push(rq)); + await p.goto(BASE + path, { waitUntil: 'networkidle' }).catch(() => p.waitForTimeout(1500)); + await p.waitForTimeout(300); + let bytes = 0; + for (const rq of list) { const z = await rq.sizes().catch(() => null); if (z) bytes += z.responseBodySize + z.responseHeadersSize; } + transfer[name] = { kb: Math.round(bytes / 1024), reqs: list.length }; + await ctx.close(); +} + +// ---------- navigation timing (CPU 4x, +80 ms per request) ---------- +const timing = {}; +{ + const [ctx, p] = await newCtx(); + const cdp = await ctx.newCDPSession(p); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: 4 }); + await p.route('**/*', async (route) => { await new Promise((s) => setTimeout(s, 80)); route.continue().catch(() => {}); }); + for (const [name, path] of [PAGES[0], PAGES[1], PAGES[2], PAGES[3]]) { + await p.goto(BASE + '/settings'); // navigate from another page + await p.goto(BASE + path); // warm cache + const dcl = [], load = []; + for (let i = 0; i < RUNS; i++) { + await p.goto(BASE + '/settings'); + await p.goto(BASE + path, { waitUntil: 'load' }); + const t = await p.evaluate(() => { const n = performance.getEntriesByType('navigation')[0]; return [n.domContentLoadedEventEnd, n.loadEventEnd]; }); + dcl.push(Math.round(t[0])); load.push(Math.round(t[1])); + } + timing[name] = { dcl: median(dcl), load: median(load) }; + } + await ctx.close(); +} + +// ---------- contrast ---------- +const css = readFileSync(CSS, 'utf8'); +const lum = (hex) => { const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4)); return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]; }; +const ratio = (a, b) => { const [x, y] = [lum(a), lum(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; +const PAIRS = [ + ['ink', 'bg', 4.5], ['ink', 'surface', 4.5], ['ink2', 'surface', 4.5], ['muted', 'surface', 4.5], ['control', 'surface', 3], + ['on-accent', 'accent-fill', 4.5], ['accent', 'surface', 4.5], ['danger', 'surface', 4.5], ['danger-text', 'danger-bg', 4.5], + ['ok-text', 'ok-bg', 4.5], ['warn-text', 'warn-bg', 4.5], ['on-brand2', 'brand2-fill', 4.5], ['muted', 'bg', 4.5], + ['accent', 'accent-soft', 4.5], ['ink2', 'sunk', 4.5], ['ok', 'surface', 4.5], ['on-accent', 'danger-fill', 4.5], + ['brand2', 'surface', 4.5], ['chart', 'chart-soft', 3], +]; +function tokens(sel) { + const m = css.match(new RegExp('^' + sel.replace(/[[\]]/g, '\\$&') + ' \\{([^}]*)\\}', 'm')); + if (!m) throw new Error('token block not found: ' + sel); + return Object.fromEntries([...m[1].matchAll(/--([a-z0-9-]+):\s*(#[0-9a-fA-F]{6})/g)].map((x) => [x[1], x[2]])); +} +const contrast = []; +for (const pal of ['indigo', 'navy', 'teal']) for (const theme of ['light', 'dark']) { + const t = tokens(`:root[data-palette=${pal}]` + (theme === 'dark' ? '[data-theme=dark]' : '')); + let bad = 0; + for (const [a, b, min] of PAIRS) { const r = ratio(t[a], t[b]); if (r < min) { bad++; fail(`contrast ${pal}/${theme} ${a} on ${b} = ${r.toFixed(2)} < ${min}`); } } + contrast.push([pal, theme, `${PAIRS.length - bad}/${PAIRS.length}`]); +} + +// ---------- a11y quick ---------- +const a11y = []; +{ + const [ctx, p] = await newCtx(); + for (const [name, path] of PAGES) { + await p.goto(BASE + path); + const r = await p.evaluate(() => { + const unnamed = [...document.querySelectorAll('button, a')].filter((e) => !e.closest('template') && !e.hidden && !e.textContent.trim() && !e.getAttribute('aria-label') && !e.getAttribute('aria-labelledby')).map((e) => e.outerHTML.slice(0, 80)); + const dlg = [...document.querySelectorAll('dialog')].filter((d) => !d.getAttribute('aria-labelledby')).map((d) => d.id); + return { unnamed, dlg }; + }); + await p.evaluate(() => document.activeElement && document.activeElement.blur()); + await p.keyboard.press('Tab'); + const focus = await p.evaluate(() => { const e = document.activeElement; if (!e || e === document.body) return 'none'; const s = getComputedStyle(e); return s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0 ? 'ok' : 'no-outline'; }); + const bad = r.unnamed.length + r.dlg.length + (focus === 'ok' ? 0 : 1); + if (bad) fail(`a11y ${name}: unnamed=${r.unnamed.length} dialogs=${r.dlg.join(',')} focus=${focus}`); + a11y.push([name, r.unnamed.length, r.dlg.length, focus]); + } + await ctx.close(); +} +await br.close(); + +// ---------- report ---------- +const rep = [`# Dockify UI report\n\nBase: ${BASE} · ${new Date().toISOString()} · runs=${RUNS}\n`, + '## Navigation (CPU 4x, +80 ms per request, median)\n', md(['page', 'DOMContentLoaded ms', 'load ms'], Object.entries(timing).map(([k, v]) => [k, v.dcl, v.load])), + '\n## Transfer (cold, on the wire) and DOM nodes @390\n', md(['page', 'KB', 'requests', 'DOM nodes', 'nodes/row'], Object.keys(nodes).map((k) => [k, transfer[k]?.kb ?? '-', transfer[k]?.reqs ?? '-', nodes[k].n, nodes[k].row])), + `\n## Overflow @360/390\n\n${overflow.length ? overflow.join(', ') : 'none (0)'}\n`, + '\n## Contrast (WCAG, 19 pairs)\n', md(['palette', 'theme', 'pass'], contrast), + '\n## A11y quick\n', md(['page', 'unnamed controls', 'dialogs w/o label', 'first Tab focus'], a11y), + `\n## Result\n\n${failures.length ? 'FAIL\n\n' + failures.map((f) => '- ' + f).join('\n') : 'PASS'}\n`].join('\n'); +writeFileSync(join(OUT, 'report.md'), rep); +console.log(rep); +process.exit(failures.length ? 1 : 0);