Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions packages/viewer/e2e/responsive.e2e.mjs
Original file line number Diff line number Diff line change
@@ -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);
4 changes: 2 additions & 2 deletions packages/viewer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 13 additions & 2 deletions packages/viewer/src/client/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down Expand Up @@ -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; }
}
Expand Down
40 changes: 31 additions & 9 deletions packages/viewer/src/client/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down Expand Up @@ -747,9 +773,7 @@ async function openSharePopover(btn: HTMLButtonElement): Promise<void> {
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);
Expand Down Expand Up @@ -843,9 +867,7 @@ async function openTemplatesPopover(btn: HTMLButtonElement): Promise<void> {
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);
Expand Down
Loading