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
2 changes: 1 addition & 1 deletion VISION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 53 additions & 3 deletions src/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ function renderGalleryPage(port: number): string {
</html>`;
}

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;

Expand All @@ -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; }
}
</style>
</head>
Expand All @@ -273,12 +284,27 @@ function renderDetailPage(canvas: Canvas, port: number): string {
<button class="btn" onclick="setViewport(390, 844)" id="bp-mobile">Mobile</button>
<button class="btn" onclick="setViewport(768, 1024)" id="bp-tablet">Tablet</button>
<button class="btn active" onclick="setViewport(${w}, ${h})" id="bp-desktop">Desktop</button>
<button class="btn" onclick="setCompareMode()" id="bp-compare">Compare</button>
<button class="btn" onclick="toggleFit()" id="btn-fit">Fit</button>
<button class="btn" onclick="toggleJson()" id="btn-json">JSON</button>
<div class="status" id="status" title="Auto-refresh active"></div>
</div>
<div class="viewport" id="viewport">
<iframe id="frame" src="/canvas/${canvas.id}/html" width="${w}" height="${h}"></iframe>
<div class="compare-grid" id="compare-grid">
<div class="compare-cell" style="--bp-w: 390; --bp-h: 844;">
<div class="bp-label">Mobile · 390×844</div>
<div class="iframe-wrap"><iframe src="/canvas/${canvas.id}/html?w=390&h=844" data-bp="mobile"></iframe></div>
</div>
<div class="compare-cell" style="--bp-w: 768; --bp-h: 1024;">
<div class="bp-label">Tablet · 768×1024</div>
<div class="iframe-wrap"><iframe src="/canvas/${canvas.id}/html?w=768&h=1024" data-bp="tablet"></iframe></div>
</div>
<div class="compare-cell" style="--bp-w: ${w}; --bp-h: ${h};">
<div class="bp-label">Desktop · ${w}×${h}</div>
<div class="iframe-wrap"><iframe src="/canvas/${canvas.id}/html?w=${w}&h=${h}" data-bp="desktop"></iframe></div>
</div>
</div>
</div>
<div class="json-panel" id="json-panel">
<pre id="json-content">Loading...</pre>
Expand All @@ -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();
}
Expand All @@ -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
Expand All @@ -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');
Expand Down
81 changes: 81 additions & 0 deletions test-viewer-compare.ts
Original file line number Diff line number Diff line change
@@ -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);