From 5fafad9c6f282413ca8d73a507f79ca55ceff395 Mon Sep 17 00:00:00 2001 From: Ivan Cheung Date: Thu, 11 Jun 2026 20:20:10 +0000 Subject: [PATCH] fix(viewer): keep the UI on-screen at every window width (responsive audit) Audited the viewer at runtime (Playwright, real server, widths 320-1920 + short/narrow windows + a live resize). Most surfaces were already responsive; this fixes the breaks that remained: - Header action bar overflowed the page at <=320px on a writable workspace: the full cluster (Follow / Console / History / Share / Export / Templates + theme) can't fit one nowrap row that narrow, so the page scrolled sideways (+16px at 320px). Let the cluster wrap onto a second strip below 380px (still >=44px tap targets). The demo hides Share/Export/Templates, so this only bit real boards. - Popover positioning (Share / Export / Templates) only clamped the LEFT edge. Add a shared positionPopover() that clamps into the viewport on BOTH axes -- flips above the trigger if it would overflow the bottom, pins to the margin and relies on max-height otherwise -- so a popover never spills past any edge at any window size. - .export-menu and .share-popover had no max-height (only .templates-popover did), so a short window could clip them. Cap both at min(70vh, 520px) with internal scroll. Adds e2e/responsive.e2e.mjs (no page h-overflow across the width matrix on the demo boards + welcome gallery; popovers on-screen on narrow/short windows; a live small->large->small resize) and chains it into test:e2e / test:e2e:nobuild. --- packages/viewer/e2e/responsive.e2e.mjs | 194 +++++++++++++++++++++++++ packages/viewer/package.json | 4 +- packages/viewer/src/client/style.css | 15 +- packages/viewer/src/client/viewer.ts | 40 +++-- 4 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 packages/viewer/e2e/responsive.e2e.mjs diff --git a/packages/viewer/e2e/responsive.e2e.mjs b/packages/viewer/e2e/responsive.e2e.mjs new file mode 100644 index 0000000..10dd4da --- /dev/null +++ b/packages/viewer/e2e/responsive.e2e.mjs @@ -0,0 +1,194 @@ +// End-to-end responsive test: at no window width may the PAGE scroll sideways, and the header +// popovers (Share / Export / Templates) must open fully inside the viewport — including on narrow +// and short windows. Boots dist/server.js, drives a real browser across a width matrix on the +// seeded /w/demo/ boards + the welcome gallery, then exercises the popovers on a writable workspace +// (the demo hides those controls). Also does one live small→large→small resize. +// +// node e2e/responsive.e2e.mjs (run `npm run build` first, or use `npm run test:e2e`) +// +// Exit 0 = all assertions passed, 1 = a failure. No network/cloud needed. +import { chromium } from "playwright"; +import { spawn } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const VIEWER = join(HERE, ".."); +const PORT = Number(process.env.E2E_PORT ?? 8201); +const TOKEN = "e2e-token"; +const BASE = `http://127.0.0.1:${PORT}`; +const WSID = "resp"; // a normal writable workspace (the demo hides Share/Export/Templates) + +const results = []; +const ok = (n, c) => { results.push(c); console.log(`${c ? "PASS" : "FAIL"} ${n}`); }; +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Odd sizes + extremes, incl. the narrow phones where the writable toolbar used to overflow. +const WIDTHS = [320, 360, 375, 414, 480, 511, 600, 768, 834, 1024, 1280, 1440, 1920]; +// A representative board per renderer family on the seeded demo (flow / panes / component / +// datatable / calltree), keyed by scope (project/agent) — these cover the layout-heavy surfaces. +const DEMO_BOARDS = [ + "data/lineage", // flow + "ops/order-fulfillment", // flow (swimlanes) + "platform/acme-shop", // panes + "sre/observability", // panes (charts) + "compare/headphones", // component (SimpleGrid product comparison) + "review/cache-pr", // component (before/after) + "finops/cloud-spend", // datatable + "dev/checkout-calls", // calltree +]; + +async function push(agent, type, content) { + const r = await fetch(`${BASE}/w/${WSID}/push`, { + method: "POST", + headers: { "content-type": "application/json", authorization: `Bearer ${TOKEN}` }, + body: JSON.stringify({ project: "p", agent, type, content, description: agent }), + }); + if (!r.ok) throw new Error(`push ${agent} -> ${r.status}`); +} + +async function waitForServer(timeoutMs = 15000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { const r = await fetch(`${BASE}/healthz`); if (r.ok) return; } catch { /* not up */ } + await sleep(200); + } + throw new Error("server did not start"); +} + +// document overflows sideways if the scroll width exceeds the client width (1px tolerance). +const pageOverflow = (p) => p.evaluate(() => { + const d = document.documentElement; + return d.scrollWidth - d.clientWidth; +}); +// A fixed popover is off-screen if any edge crosses the viewport box (1px tolerance). +const offscreen = (box, iw, ih) => { + const bad = []; + if (box.x < -1) bad.push(`left ${Math.round(box.x)}`); + if (box.y < -1) bad.push(`top ${Math.round(box.y)}`); + if (box.x + box.width > iw + 1) bad.push(`right +${Math.round(box.x + box.width - iw)}`); + if (box.y + box.height > ih + 1) bad.push(`bottom +${Math.round(box.y + box.height - ih)}`); + return bad; +}; + +const srv = spawn("node", [join(VIEWER, "dist", "server.js")], { + env: { ...process.env, PORT: String(PORT), PUSH_TOKEN: TOKEN }, + stdio: ["ignore", "ignore", "inherit"], +}); + +let browser; +try { + await waitForServer(); + // A writable board so Share / Export / Templates are visible (the demo hides them). A few saved + // templates make the Templates popover genuinely tall, so the short-window clamp is exercised. + await push("spend", "datatable", JSON.stringify({ + title: "Cloud spend", columns: [{ key: "svc", label: "Service" }, { key: "cost", label: "Cost", align: "right" }], + rows: [["Compute", 1200], ["Storage", 340], ["Network", 90]], + })); + for (let i = 0; i < 6; i++) { + const r = await fetch(`${BASE}/w/${WSID}/template`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ project: "p", agent: "spend", name: `Template ${i}`, hints: "reuse me" }), + }); + if (!r.ok) throw new Error(`save template -> ${r.status}`); + } + + browser = await chromium.launch({ args: ["--no-sandbox", "--disable-dev-shm-usage"] }); + + // --- 1) No PAGE horizontal overflow across the width matrix on every demo board. --- + let worstDemo = -Infinity, worstDemoAt = ""; + for (const board of DEMO_BOARDS) { + const p = await browser.newPage({ viewport: { width: 1024, height: 800 } }); + await p.goto(`${BASE}/w/demo/#${encodeURIComponent(board)}`, { waitUntil: "domcontentloaded", timeout: 20000 }); + await sleep(1000); // let the lazy renderer chunk mount + let boardOk = true; + for (const w of WIDTHS) { + await p.setViewportSize({ width: w, height: 800 }); + await sleep(250); + const over = await pageOverflow(p); + if (over > worstDemo) { worstDemo = over; worstDemoAt = `${board} @ ${w}px`; } + if (over > 1) boardOk = false; + } + ok(`no page h-overflow across widths: ${board}`, boardOk); + await p.close(); + } + console.log(` (worst demo overflow: ${worstDemo}px at ${worstDemoAt})`); + + // --- 2) No PAGE horizontal overflow on the welcome gallery. --- + { + const p = await browser.newPage({ viewport: { width: 1024, height: 800 } }); + await p.goto(`${BASE}/w/demo/`, { waitUntil: "domcontentloaded", timeout: 20000 }); + await sleep(900); + let galOk = true; + for (const w of WIDTHS) { + await p.setViewportSize({ width: w, height: 800 }); + await sleep(220); + if ((await pageOverflow(p)) > 1) galOk = false; + } + ok("no page h-overflow across widths: welcome gallery", galOk); + await p.close(); + } + + // --- 3) Writable workspace: the FULL action toolbar (6 buttons + theme) must not overflow the + // page on narrow phones (regression guard — this used to spill +16px at 320px). --- + { + const p = await browser.newPage({ viewport: { width: 320, height: 800 } }); + await p.goto(`${BASE}/w/${WSID}/#p/spend`, { waitUntil: "domcontentloaded", timeout: 20000 }); + await p.waitForFunction(() => !document.getElementById("export")?.hidden, null, { timeout: 15000 }); + await sleep(400); + let toolbarOk = true, worst = -Infinity; + for (const w of [320, 340, 360, 375, 414, 480]) { + await p.setViewportSize({ width: w, height: 800 }); + await sleep(250); + const over = await pageOverflow(p); + if (over > worst) worst = over; + if (over > 1) toolbarOk = false; + } + ok("no page h-overflow with full writable toolbar (320–480px)", toolbarOk); + console.log(` (worst writable-toolbar overflow: ${worst}px)`); + await p.close(); + } + + // --- 4) Popovers (Share / Export / Templates) open fully inside the viewport on narrow AND + // short windows — clamped on both axes (regression guard for the positioning fix). --- + for (const vp of [{ width: 360, height: 740 }, { width: 768, height: 380 }, { width: 320, height: 900 }]) { + const p = await browser.newPage({ viewport: vp }); + await p.goto(`${BASE}/w/${WSID}/#p/spend`, { waitUntil: "domcontentloaded", timeout: 20000 }); + await p.waitForFunction(() => !document.getElementById("templates")?.hidden, null, { timeout: 15000 }); + await sleep(400); + for (const [btn, sel] of [["#export", ".export-menu"], ["#share", ".share-popover"], ["#templates", ".templates-popover"]]) { + await p.locator(btn).click(); + await sleep(400); + const box = await p.locator(sel).first().boundingBox().catch(() => null); + const bad = box ? offscreen(box, vp.width, vp.height) : ["did not open"]; + ok(`${sel} on-screen @ ${vp.width}x${vp.height}`, bad.length === 0 || (console.log(" off:", bad.join(", ")), false)); + await p.locator(btn).click().catch(() => {}); // close before opening the next + await sleep(150); + } + await p.close(); + } + + // --- 5) Live resize small→large→small must never leave the page scrolling sideways. --- + { + const p = await browser.newPage({ viewport: { width: 360, height: 740 } }); + await p.goto(`${BASE}/w/demo/#${encodeURIComponent("data/lineage")}`, { waitUntil: "domcontentloaded", timeout: 20000 }); + await sleep(1200); + let liveOk = true; + for (const [w, h] of [[360, 740], [1440, 900], [360, 740], [1024, 420]]) { + await p.setViewportSize({ width: w, height: h }); + await sleep(500); + if ((await pageOverflow(p)) > 1) liveOk = false; + } + ok("no page h-overflow through a live small→large→small resize", liveOk); + await p.close(); + } +} catch (e) { + ok(`harness error: ${e.message}`, false); +} finally { + if (browser) await browser.close(); + srv.kill("SIGTERM"); +} + +const failed = results.filter((r) => !r).length; +console.log(`\n=== ${results.length - failed}/${results.length} passed (responsive e2e) ===`); +process.exit(failed ? 1 : 0); diff --git a/packages/viewer/package.json b/packages/viewer/package.json index 17b2a8d..6231a76 100644 --- a/packages/viewer/package.json +++ b/packages/viewer/package.json @@ -25,8 +25,8 @@ "start": "node dist/server.js", "test": "vitest run", "test:watch": "vitest", - "test:e2e": "npm run build && node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs && node e2e/template.e2e.mjs", - "test:e2e:nobuild": "node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs && node e2e/template.e2e.mjs", + "test:e2e": "npm run build && node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs && node e2e/template.e2e.mjs && node e2e/responsive.e2e.mjs", + "test:e2e:nobuild": "node e2e/rich.e2e.mjs && node e2e/board-sort.e2e.mjs && node e2e/template.e2e.mjs && node e2e/responsive.e2e.mjs", "test:overlap": "npm run build && node e2e/overlap.e2e.mjs", "test:overlap:nobuild": "node e2e/overlap.e2e.mjs", "test:stress": "npm run build && node e2e/stress.e2e.mjs" diff --git a/packages/viewer/src/client/style.css b/packages/viewer/src/client/style.css index ba1e135..f128969 100644 --- a/packages/viewer/src/client/style.css +++ b/packages/viewer/src/client/style.css @@ -193,9 +193,9 @@ header { display: flex; gap: 16px; align-items: center; padding: 8px 12px; borde opened panel's header. We deliberately DON'T put a corner dot here: a colored dot top-right reads as an unread-notification badge (and on some themes matches the on-state accent), which misleads. */ .tc-tool-sep { flex: none; align-self: center; width: 1px; height: 22px; background: var(--border); margin: 0 2px; } -.export-menu { position: fixed; z-index: 1000; display: flex; flex-direction: column; gap: 4px; padding: 6px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.35); } +.export-menu { position: fixed; z-index: 1000; max-height: min(70vh, 520px); overflow: auto; display: flex; flex-direction: column; gap: 4px; padding: 6px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 8px 28px rgba(0,0,0,.35); } .export-menu .tc-btn { justify-content: flex-start; } -.share-popover { position: fixed; z-index: 1000; width: min(360px, calc(100vw - 16px)); display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--surface); color: var(--fg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.35); } +.share-popover { position: fixed; z-index: 1000; width: min(360px, calc(100vw - 16px)); max-height: min(70vh, 520px); overflow: auto; display: flex; flex-direction: column; gap: 8px; padding: 12px; background: var(--surface); color: var(--fg); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 28px rgba(0,0,0,.35); } .share-pop-note { margin: 0; font-size: 12.5px; line-height: 1.45; color: var(--muted); } .share-pop-row { display: flex; gap: 6px; } .share-pop-url { flex: 1; min-width: 0; padding: 6px 8px; font: inherit; font-size: 12px; color: var(--fg); background: var(--surface-2); border: 1px solid var(--border); border-radius: 6px; } @@ -607,6 +607,17 @@ body.history-open #tc-history { display: flex; } .tc-checklist--editable [class*="Checkbox-labelWrapper"] { flex: 1; } } +/* Very narrow phones (≤380px): a writable board shows the FULL action cluster — Follow · Console · + History · Share · Export · Templates (six ≥44px icon buttons) + the theme select. That can't fit + on one nowrap row at this width (6×44 + gaps alone > the ~300px available), so the page would + scroll sideways. Let the cluster WRAP onto a second strip instead — the buttons stay ≥44px tap + targets and the header simply grows a row, which beats horizontal overflow. (At ≥381px the 640px + rule's nowrap holds, keeping the tidy 2-row header where it fits.) */ +@media (max-width: 380px) { + .header-actions { flex-wrap: wrap; } + #theme { flex: 1 1 auto; max-width: none; } /* on its own wrapped row it can use the full width */ +} + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; animation: none !important; } } diff --git a/packages/viewer/src/client/viewer.ts b/packages/viewer/src/client/viewer.ts index 512e255..48172aa 100644 --- a/packages/viewer/src/client/viewer.ts +++ b/packages/viewer/src/client/viewer.ts @@ -631,6 +631,34 @@ function updateExportBtn(scopeKey: string): void { if (btn) btn.hidden = !scopeKey || scopeKey === INTRO; } +/** + * Place a fixed-position popover/menu relative to its trigger button, clamped fully inside the + * viewport on BOTH axes. Opens below the trigger by default; flips ABOVE if that would overflow + * the bottom (and there's more room up top), then pins to the top margin as a last resort and + * relies on the element's own `max-height` + internal scroll. `left` is clamped between the left + * and right margins so the popover never spills past either edge — at any window size, including a + * trigger near an edge or a short window. The element must already be in the DOM (so offsetWidth/ + * offsetHeight are measured) before calling. + */ +function positionPopover(el: HTMLElement, btn: HTMLElement, gap = 6, margin = 8): void { + const r = btn.getBoundingClientRect(); + const vw = window.innerWidth, vh = window.innerHeight; + const w = el.offsetWidth, h = el.offsetHeight; + // Horizontal: align the right edge to the trigger, then clamp into [margin, vw - w - margin]. + // max(margin, …) wins when the popover is wider than the viewport so it pins to the left margin. + const left = Math.min(Math.max(margin, r.right - w), Math.max(margin, vw - w - margin)); + // Vertical: prefer below; flip above if it overflows the bottom and there's more room above; + // otherwise pin to the top margin (the element's max-height keeps it on-screen with a scroll). + const below = r.bottom + gap; + const above = r.top - gap - h; + let top: number; + if (below + h <= vh - margin) top = below; + else if (above >= margin && r.top > vh - r.bottom) top = above; + else top = margin; + el.style.left = `${Math.round(left)}px`; + el.style.top = `${Math.round(top)}px`; +} + // --- Export the current board (client-side: PNG via DOM-to-canvas, PDF via the print dialog) ------ let exportMenu: HTMLElement | null = null; function closeExportMenu(): void { @@ -653,9 +681,7 @@ function openExportMenu(btn: HTMLButtonElement): void { (m.querySelector(".export-png") as HTMLButtonElement).onclick = () => { closeExportMenu(); void exportPng(); }; (m.querySelector(".export-pdf") as HTMLButtonElement).onclick = () => { closeExportMenu(); window.print(); }; document.body.appendChild(m); - const r = btn.getBoundingClientRect(); - m.style.top = `${Math.round(r.bottom + 6)}px`; - m.style.left = `${Math.round(Math.max(8, r.right - m.offsetWidth))}px`; + positionPopover(m, btn); exportMenu = m; setTimeout(() => document.addEventListener("click", onDocClickExport, true), 0); } @@ -747,9 +773,7 @@ async function openSharePopover(btn: HTMLButtonElement): Promise { closeSharePopover(); }; document.body.appendChild(pop); - const r = btn.getBoundingClientRect(); - pop.style.top = `${Math.round(r.bottom + 6)}px`; - pop.style.left = `${Math.round(Math.max(8, r.right - pop.offsetWidth))}px`; + positionPopover(pop, btn); sharePopover = pop; (pop.querySelector(".share-pop-url") as HTMLInputElement).select(); setTimeout(() => document.addEventListener("click", onDocClickShare, true), 0); @@ -843,9 +867,7 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise { pop.appendChild(listWrap); document.body.appendChild(pop); - const r = btn.getBoundingClientRect(); - pop.style.top = `${Math.round(r.bottom + 6)}px`; - pop.style.left = `${Math.round(Math.max(8, r.right - pop.offsetWidth))}px`; + positionPopover(pop, btn); templatesPopover = pop; setTimeout(() => document.addEventListener("click", onDocClickTemplates, true), 0); await renderTemplateList(listWrap);