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"}}
@@ -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);