From cd2f2c22419c6f8a5c3d53e7f3f91bb2b190c1c4 Mon Sep 17 00:00:00 2001 From: Victor Velazquez Date: Sat, 16 May 2026 17:41:00 +0200 Subject: [PATCH] Phase 5 item #7: viewer Compare view (side-by-side breakpoints) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Compare" toolbar button puts mobile (390×844), tablet (768×1024), and desktop (canvas natural width/height) iframes side by side in the viewport area. Each iframe still loads through the per-breakpoint render route added in PR #13, so the reflow is real — not a CSS-scaled snapshot of the desktop layout. Each cell uses CSS `transform: scale()` with --bp-w / --bp-h / --scale variables to fit at ~0.35 scale on a typical viewer width, dropping to 0.28 / 0.22 / 0.18 on narrower viewers via media queries. - `setCompareMode()` puts the .viewport in `compare` class. CSS hides the single #frame and reveals .compare-grid. - `setViewport()` removes the `compare` class so the toggle is mutually exclusive with single-breakpoint modes. - Auto-refresh now goes through a `refreshFrames()` helper that picks the right iframe(s) based on current mode — single frame in normal mode, all three cells in compare mode. - `Fit` button is dimmed (pointer-events: none) in compare mode where it doesn't make sense. - `renderDetailPage` is now exported so the smoke can render it directly without spinning up the HTTP server (which tends to cache stale code across long-running tsx processes). `test-viewer-compare.ts` covers 9 markup checks + 2 runtime checks (initial state has single iframe visible / grid hidden; clicking Compare swaps them and marks the button active). 11/11 pass. All six existing smokes still pass — no regression on PRs #6-13. --- VISION.md | 2 +- src/viewer.ts | 56 +++++++++++++++++++++++++++-- test-viewer-compare.ts | 81 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 4 deletions(-) create mode 100644 test-viewer-compare.ts diff --git a/VISION.md b/VISION.md index 38547d7..4fa4856 100644 --- a/VISION.md +++ b/VISION.md @@ -244,7 +244,7 @@ Authoring model: **desktop-first, adapt down.** Responsive behavior is expressed - [x] Root document fills/centers the viewport cleanly — no dead white canvas on wide screens - [x] AI guidance — tool descriptions / guidelines steer the assistant toward fluid widths + `responsive` hints instead of hardcoded px - [x] `screenshot_responsive` + viewer reflect true reflow, not just an iframe resize -- [ ] Viewer shows the adaptation clearly — side-by-side breakpoint comparison, not just toggle buttons +- [x] Viewer shows the adaptation clearly — side-by-side breakpoint comparison, not just toggle buttons - [ ] (Optional / stretch) per-breakpoint override map as an escape hatch for nodes needing precise control ### Phase 6 — Evaluation & AI Loops (v0.6) diff --git a/src/viewer.ts b/src/viewer.ts index 9763178..96dba11 100644 --- a/src/viewer.ts +++ b/src/viewer.ts @@ -226,7 +226,7 @@ function renderGalleryPage(port: number): string { `; } -function renderDetailPage(canvas: Canvas, port: number): string { +export function renderDetailPage(canvas: Canvas, port: number): string { const w = typeof canvas.root.width === 'number' ? canvas.root.width : 1440; const h = typeof canvas.root.height === 'number' ? canvas.root.height : 900; @@ -252,15 +252,26 @@ function renderDetailPage(canvas: Canvas, port: number): string { .viewport { flex: 1; display: flex; align-items: flex-start; justify-content: center; overflow: auto; background: #0a0a0a; padding: 24px 0; } .viewport iframe { border: none; background: #fff; transition: width 0.3s, height 0.3s; transform-origin: top center; } .viewport.fit iframe { width: 100% !important; height: 100% !important; } + .viewport.compare #frame { display: none; } + .viewport.compare .compare-grid { display: flex; } + .viewport.compare #btn-fit { pointer-events: none; opacity: 0.4; } + .compare-grid { display: none; gap: 24px; padding: 24px; align-items: flex-start; --scale: 0.35; } + .compare-cell { display: flex; flex-direction: column; gap: 10px; flex-shrink: 0; } + .bp-label { font-size: 11px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: 0.5px; text-align: center; } + .iframe-wrap { background: #fff; overflow: hidden; border-radius: 6px; border: 1px solid #222; width: calc(var(--bp-w) * var(--scale) * 1px); height: calc(var(--bp-h) * var(--scale) * 1px); } + .iframe-wrap iframe { border: 0; background: #fff; width: calc(var(--bp-w) * 1px); height: calc(var(--bp-h) * 1px); transform: scale(var(--scale)); transform-origin: top left; } .json-panel { display: none; position: fixed; top: 52px; right: 0; bottom: 0; width: 480px; background: #111; border-left: 1px solid #222; overflow: auto; z-index: 10; } .json-panel.open { display: block; } .json-panel pre { padding: 20px; font-size: 12px; color: #a0a0a0; font-family: 'JetBrains Mono', 'Fira Code', monospace; white-space: pre-wrap; word-break: break-all; } + @media (max-width: 1100px) { .compare-grid { --scale: 0.28; gap: 18px; } } + @media (max-width: 900px) { .compare-grid { --scale: 0.22; gap: 16px; } } @media (max-width: 640px) { .toolbar { flex-wrap: wrap; height: auto; min-height: 52px; padding: 8px 12px; gap: 8px 10px; } .toolbar .dim { display: none; } .toolbar .spacer { flex-basis: 100%; height: 0; } .toolbar .btn { flex: 1; min-width: 0; padding: 6px 2px; font-size: 11px; white-space: nowrap; } .json-panel { width: 100%; } + .compare-grid { --scale: 0.18; gap: 12px; padding: 12px; } } @@ -273,12 +284,27 @@ function renderDetailPage(canvas: Canvas, port: number): string { +
+
+
+
Mobile · 390×844
+
+
+
+
Tablet · 768×1024
+
+
+
+
Desktop · ${w}×${h}
+
+
+
Loading...
@@ -296,7 +322,7 @@ function renderDetailPage(canvas: Canvas, port: number): string { document.getElementById('status').className = 'status'; if (meta.lastModified !== lastModified) { lastModified = meta.lastModified; - document.getElementById('frame').src = '/canvas/' + canvasId + '/html?t=' + Date.now(); + refreshFrames(); // Update JSON if panel is open if (document.getElementById('json-panel').classList.contains('open')) loadJson(); } @@ -305,13 +331,27 @@ function renderDetailPage(canvas: Canvas, port: number): string { } }, 2000); + function refreshFrames() { + const t = Date.now(); + const bump = (frame) => { + const url = new URL(frame.src, location.href); + url.searchParams.set('t', t); + frame.src = url.toString(); + }; + if (document.getElementById('viewport').classList.contains('compare')) { + document.querySelectorAll('.compare-cell iframe').forEach(bump); + } else { + bump(document.getElementById('frame')); + } + } + const canvasW = ${w}; const canvasH = ${h}; function setViewport(w, h) { const frame = document.getElementById('frame'); const vp = document.getElementById('viewport'); - vp.classList.remove('fit'); + vp.classList.remove('fit', 'compare'); document.getElementById('btn-fit').classList.remove('active'); // Reload iframe at the new viewport size so content reflows @@ -332,6 +372,16 @@ function renderDetailPage(canvas: Canvas, port: number): string { else { document.getElementById('bp-desktop').classList.add('active'); currentBp = 'desktop'; } } + function setCompareMode() { + const vp = document.getElementById('viewport'); + vp.classList.remove('fit'); + vp.classList.add('compare'); + document.getElementById('btn-fit').classList.remove('active'); + document.querySelectorAll('.btn').forEach(b => b.classList.remove('active')); + document.getElementById('bp-compare').classList.add('active'); + currentBp = 'compare'; + } + function toggleFit() { const vp = document.getElementById('viewport'); const btn = document.getElementById('btn-fit'); diff --git a/test-viewer-compare.ts b/test-viewer-compare.ts new file mode 100644 index 0000000..f6dab00 --- /dev/null +++ b/test-viewer-compare.ts @@ -0,0 +1,81 @@ +// Smoke for Phase 5 item #7: viewer detail page exposes a side-by-side +// "Compare" view with three iframes (mobile / tablet / desktop) that point at +// the same per-breakpoint render route added in PR #13. Renders the detail page +// directly (no HTTP / no long-running viewer process — those tend to cache +// stale code) and asserts the expected markup + CSS hooks are present. +// +// Usage: npx tsx test-viewer-compare.ts + +import puppeteer from 'puppeteer'; +import { renderDetailPage } from './src/viewer.js'; +import type { Canvas } from './src/types.js'; + +const canvas: Canvas = { + id: 'test-canvas-id', + name: 'compare-smoke', + root: { id: 'doc', type: 'document', width: 1440, height: 900 }, + variables: {}, + components: {}, + createdAt: '2026-05-16T00:00:00Z', + lastModified: '2026-05-16T00:00:00Z', +}; + +const html = renderDetailPage(canvas, 3001); + +const markupChecks: Array<{ name: string; needle: string | RegExp }> = [ + { name: 'Compare toolbar button', needle: 'id="bp-compare"' }, + { name: 'compare-grid container', needle: 'id="compare-grid"' }, + { name: 'mobile cell points at ?w=390&h=844', needle: '/canvas/test-canvas-id/html?w=390&h=844' }, + { name: 'tablet cell points at ?w=768&h=1024', needle: '/canvas/test-canvas-id/html?w=768&h=1024' }, + { name: 'desktop cell uses canvas natural width/height', needle: '/canvas/test-canvas-id/html?w=1440&h=1024' === '/canvas/test-canvas-id/html?w=1440&h=1024' ? /\/canvas\/test-canvas-id\/html\?w=1440&h=900\b/ : '' }, + { name: 'setCompareMode handler defined', needle: 'function setCompareMode' }, + { name: 'refreshFrames helper defined', needle: 'function refreshFrames' }, + { name: '.viewport.compare CSS rule', needle: '.viewport.compare' }, + { name: '--bp-w CSS variable wiring', needle: '--bp-w' }, +]; + +let allPass = true; +for (const c of markupChecks) { + const ok = typeof c.needle === 'string' ? html.includes(c.needle) : c.needle.test(html); + if (!ok) allPass = false; + console.log(`${ok ? 'PASS' : 'FAIL'} ${c.name}`); +} + +// Runtime check: load the HTML in puppeteer, simulate clicking Compare, assert +// that the compare grid becomes visible and the single iframe hides. +const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] }); +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1440, height: 900, deviceScaleFactor: 1 }); + await page.setContent(html, { waitUntil: 'domcontentloaded' }); + // Block child iframe loads — we're testing the parent page's toggle logic, + // not the canvas render path (covered by test-responsive-reflow.ts). + await page.setRequestInterception(true); + page.on('request', (req) => (req.url().includes('/canvas/test-canvas-id/html') ? req.abort() : req.continue())); + + const before = await page.evaluate(() => ({ + singleVisible: getComputedStyle(document.getElementById('frame')!).display !== 'none', + gridVisible: getComputedStyle(document.getElementById('compare-grid')!).display !== 'none', + })); + + await page.click('#bp-compare'); + await new Promise((r) => setTimeout(r, 50)); + + const after = await page.evaluate(() => ({ + singleVisible: getComputedStyle(document.getElementById('frame')!).display !== 'none', + gridVisible: getComputedStyle(document.getElementById('compare-grid')!).display !== 'none', + cells: document.querySelectorAll('.compare-cell').length, + activeBtn: document.querySelector('.btn.active')?.id ?? null, + })); + + const beforeOk = before.singleVisible === true && before.gridVisible === false; + const afterOk = after.singleVisible === false && after.gridVisible === true && after.cells === 3 && after.activeBtn === 'bp-compare'; + if (!beforeOk) allPass = false; + if (!afterOk) allPass = false; + console.log(`${beforeOk ? 'PASS' : 'FAIL'} initial state: single iframe visible, grid hidden`); + console.log(`${afterOk ? 'PASS' : 'FAIL'} after Compare click: grid visible (${after.cells} cells), single hidden, button active`); +} finally { + await browser.close(); +} + +process.exit(allPass ? 0 : 1);