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 @@ -241,7 +241,7 @@ Authoring model: **desktop-first, adapt down.** Responsive behavior is expressed
- [x] `responsive` hint on containers — `stack` (horizontal → vertical below breakpoint), `wrap` (children wrap instead of overflowing), `fixed` (never reflows, e.g. toolbars)
- [x] Renderer maps the `responsive` hint to CSS (media queries, `flex-wrap`, `flex-direction`)
- [x] Fluid widths — support `minWidth` / `maxWidth` alongside percentage `width` strings so containers shrink within bounds instead of clipping
- [ ] Root document fills/centers the viewport cleanly — no dead white canvas on wide screens
- [x] Root document fills/centers the viewport cleanly — no dead white canvas on wide screens
- [ ] AI guidance — tool descriptions / guidelines steer the assistant toward fluid widths + `responsive` hints instead of hardcoded px
- [ ] `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
Expand Down
Binary file modified docs/hero.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 17 additions & 1 deletion src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import { getIconSvg } from './icons.js';
export function renderToHtml(root: SceneNode, width = 1440, height = 900, canvas?: Canvas): string {
const body = renderNode(root, canvas);
const responsiveCss = buildResponsiveStylesheet(root, canvas);
// Hoist the root's fill/gradient to <html> so wide viewports show the design
// background instead of browser-default white on the sidebars.
const rootBg = rootBackgroundCss(root);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { width: 100%; max-width: ${width}px; min-height: ${height}px; overflow-x: hidden; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
html { min-height: 100vh;${rootBg ? ` ${rootBg};` : ''} }
body { width: 100%; max-width: ${width}px; min-height: ${height}px; margin: 0 auto; overflow-x: hidden; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
img { display: block; max-width: 100%; }
p { overflow-wrap: break-word; word-wrap: break-word; }
${responsiveCss}
Expand All @@ -22,6 +26,18 @@ ${body}
</html>`;
}

function rootBackgroundCss(root: SceneNode): string {
if (root.gradient) {
const g = root.gradient;
const stops = g.stops.map((st) => st.position !== undefined ? `${st.color} ${st.position}%` : st.color).join(', ');
return g.type === 'linear'
? `background: linear-gradient(${g.angle ?? 180}deg, ${stops})`
: `background: radial-gradient(${stops})`;
}
if (root.fill) return `background-color: ${root.fill}`;
return '';
}

function renderNode(node: SceneNode, canvas?: Canvas): string {
// Resolve instances: clone the component tree and apply overrides
if (node.type === 'instance' && node.componentId && canvas) {
Expand Down
66 changes: 66 additions & 0 deletions test-root-centering.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Smoke for Phase 5 item #4: root document centers horizontally and the root
// fill/gradient extends to the full viewport (no dead white sidebars on wide
// screens).
//
// Usage: npx tsx test-root-centering.ts

import puppeteer from 'puppeteer';
import { renderToHtml } from './src/renderer.js';
import type { SceneNode } from './src/types.js';

const SOLID: SceneNode = {
id: 'doc-solid', type: 'document', fill: '#1E293B',
width: 1200, height: 600,
children: [{ id: 'child', type: 'text', content: 'centered', color: '#FFFFFF' }],
};

const GRADIENT: SceneNode = {
id: 'doc-gradient', type: 'document',
gradient: { type: 'linear', angle: 135, stops: [{ color: '#0F172A', position: 0 }, { color: '#1E293B', position: 100 }] },
width: 1200, height: 600,
children: [{ id: 'child', type: 'text', content: 'centered', color: '#FFFFFF' }],
};

const PLAIN: SceneNode = {
id: 'doc-plain', type: 'document',
width: 800, height: 400,
children: [{ id: 'child', type: 'text', content: 'no bg' }],
};

const cases = [
{ name: 'solid fill, wide viewport', root: SOLID, renderW: 1200, vpW: 1920, expectBodyX: 360, expectHtmlBg: 'rgb(30, 41, 59)', expectHtmlImage: 'none' },
{ name: 'gradient, wide viewport', root: GRADIENT, renderW: 1200, vpW: 1920, expectBodyX: 360, expectHtmlBg: 'rgba(0, 0, 0, 0)', expectHtmlImage: /linear-gradient/ },
{ name: 'no bg, wide viewport', root: PLAIN, renderW: 800, vpW: 1920, expectBodyX: 560, expectHtmlBg: 'rgba(0, 0, 0, 0)', expectHtmlImage: 'none' },
{ name: 'solid fill, narrow viewport (no overflow)', root: SOLID, renderW: 1200, vpW: 1200, expectBodyX: 0, expectHtmlBg: 'rgb(30, 41, 59)', expectHtmlImage: 'none' },
];

const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] });
let allPass = true;
try {
for (const c of cases) {
const html = renderToHtml(c.root, c.renderW, 600);
const page = await browser.newPage();
await page.setViewport({ width: c.vpW, height: 800, deviceScaleFactor: 1 });
await page.setContent(html, { waitUntil: 'domcontentloaded' });
const r = await page.evaluate(() => ({
bodyX: Math.round(document.body.getBoundingClientRect().x),
bodyWidth: Math.round(document.body.getBoundingClientRect().width),
htmlBg: getComputedStyle(document.documentElement).backgroundColor,
htmlImage: getComputedStyle(document.documentElement).backgroundImage,
}));
await page.close();

const xOk = r.bodyX === c.expectBodyX;
const bgOk = r.htmlBg === c.expectHtmlBg;
const imgOk = c.expectHtmlImage instanceof RegExp ? c.expectHtmlImage.test(r.htmlImage) : r.htmlImage === c.expectHtmlImage;
const pass = xOk && bgOk && imgOk;
if (!pass) allPass = false;
console.log(`[${c.name}]`);
console.log(` bodyX: ${r.bodyX} (expected ${c.expectBodyX}) — ${xOk ? 'PASS' : 'FAIL'}`);
console.log(` htmlBg: ${r.htmlBg} (expected ${c.expectHtmlBg}) — ${bgOk ? 'PASS' : 'FAIL'}`);
console.log(` htmlImage: ${r.htmlImage.slice(0, 80)} — ${imgOk ? 'PASS' : 'FAIL'}`);
}
} finally {
await browser.close();
}
process.exit(allPass ? 0 : 1);