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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ node_modules/

# Build output
dist/
storybook-static/

# Verification artifacts (Playwright MCP screenshots)
screenshots/

# Test coverage
coverage/
Expand Down
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Changelog

## [3.0.0-beta.0](https://github.com/wireweave/core/compare/v2.8.0...v3.0.0-beta.0) (2026-05-08)

### ⚠ BREAKING CHANGES

* **renderer:** renderCanvas no longer emits chrome / grid / labels.
The renderer now produces a single bounded `<div class="wf-canvas">` of the
exact layout extent containing absolutely-positioned `<div class="wf-canvas-board">`
wrappers. Hosts (dashboard infinite-canvas viewer, markdown-plugin, vscode-extension,
SVG export) are responsible for chrome, grid, pan-zoom, and labels.

Removed:
- `CanvasChrome` type and `chrome` / `canvasBackground` options on `CanvasOptions`
- chrome routing in `render()` / `renderToSvg()`

Added:
- `page "X" at(x, y) { ... }` grammar — canvas coordinates resolved into
`Page.x` / `Page.y` for explicit placement; pages without `at(...)` auto-flow
horizontally with configurable gap (default 64px)
- `renderPage(page)` — pure single-page export primitive
- `renderCanvas(doc, opts?)` — multi-page composition (gap-only options)
- `layoutCanvas(pages, gap?)` — pure utility that returns `{ placed, width, height }`
so hosts can compose their own DOM
- `PlacedPage` public type
- `data-page-x` / `data-page-y` / `data-page-w` / `data-page-h` /
`data-page-title` attributes on each board for host-DOM communication

Migration:
- `renderCanvas(doc, { chrome: 'editor' })` → `renderCanvas(doc)` and let the host
apply its own chrome (Figma-style grid is the dashboard editor's responsibility)
- `chrome: 'preview'` consumers (markdown-plugin, vscode-extension preview)
already work — they don't pass `chrome` and now receive bare bounded layout

### Features

* **renderer:** multi-page canvas with bounded layout output ([a93548f](https://github.com/wireweave/core/commit/a93548fd484505da098d8e463da9d3df080572a1))

## [2.8.0](https://github.com/wireweave/core/compare/v2.7.1...v2.8.0) (2026-05-08)

### Features
Expand Down
246 changes: 246 additions & 0 deletions __tests__/renderer-canvas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
/**
* Multi-page canvas + page-renderer scenarios
*/

import { describe, it, expect } from 'vitest';
import { parse } from '../src';
import {
render,
renderPage,
renderCanvas,
layoutCanvas,
renderToSvg,
resolvePageDimensions,
} from '../src/renderer';
import type { WireframeDocument, PageNode } from '../src/ast/types';

const sampleDoc = (src: string): WireframeDocument => parse(src) as WireframeDocument;

describe('grammar: page at(x, y)', () => {
it('parses at(x, y) into Page.x / Page.y', () => {
const doc = sampleDoc(`
page "Login" at(0, 0) viewport="1280x800" { text "hi" }
page "Dashboard" at(1344, 0) viewport="1280x800" { text "db" }
`);
expect(doc.children).toHaveLength(2);
expect(doc.children[0].x).toBe(0);
expect(doc.children[0].y).toBe(0);
expect(doc.children[1].x).toBe(1344);
expect(doc.children[1].y).toBe(0);
});

it('omits x / y when at() is absent (auto-grid candidate)', () => {
const doc = sampleDoc(`page "P" { text "x" }`);
expect(doc.children[0].x).toBeUndefined();
expect(doc.children[0].y).toBeUndefined();
});

it('parses negative coordinates', () => {
const doc = sampleDoc(`page "P" at(-200, -100) { text "x" }`);
expect(doc.children[0].x).toBe(-200);
expect(doc.children[0].y).toBe(-100);
});

it('accepts multiple top-level pages separated by whitespace', () => {
const doc = sampleDoc(`
page "A" { text "1" }

page "B" { text "2" }


page "C" { text "3" }
`);
expect(doc.children.map((p) => p.title)).toEqual(['A', 'B', 'C']);
});
});

describe('renderPage(): pure single-page primitive', () => {
it('produces identical HTML regardless of sibling pages in the source', () => {
const onlyPage = sampleDoc(`page "Solo" viewport="1280x800" { text "x" }`).children[0];
const sibling = sampleDoc(`
page "Other" viewport="1280x800" { text "y" }
page "Solo" viewport="1280x800" { text "x" }
`).children[1];

const a = renderPage(onlyPage as PageNode);
const b = renderPage(sibling as PageNode);
expect(a.html).toBe(b.html);
expect(a.css).toBe(b.css);
expect(a.width).toBe(b.width);
expect(a.height).toBe(b.height);
});

it('returns resolved pixel dimensions from viewport string', () => {
const page = sampleDoc(`page "P" viewport="1280x800" { }`).children[0];
const r = renderPage(page as PageNode);
expect(r.width).toBe(1280);
expect(r.height).toBe(800);
});

it('prefers explicit numeric w/h over viewport', () => {
const page = sampleDoc(`page "P" w=900 h=600 viewport="1280x800" { }`).children[0];
const r = renderPage(page as PageNode);
expect(r.width).toBe(900);
expect(r.height).toBe(600);
});
});

describe('layoutCanvas(): coordinate resolution', () => {
it('respects explicit at() coordinates', () => {
const doc = sampleDoc(`
page "A" at(0, 0) viewport="1280x800" { }
page "B" at(1344, 0) viewport="1280x800" { }
`);
const { placed, width, height } = layoutCanvas(doc.children);
expect(placed.map((p) => [p.x, p.y])).toEqual([
[0, 0],
[1344, 0],
]);
expect(width).toBe(1344 + 1280);
expect(height).toBe(800);
});

it('auto-flows pages without coordinates in a row', () => {
const doc = sampleDoc(`
page "A" viewport="1280x800" { }
page "B" viewport="1280x800" { }
page "C" viewport="375x812" { }
`);
const { placed, width, height } = layoutCanvas(doc.children, 64);
// A: x=0, B: x=1280+64=1344, C: x=1344+1280+64=2688
expect(placed[0].x).toBe(0);
expect(placed[1].x).toBe(1344);
expect(placed[2].x).toBe(2688);
expect(width).toBe(2688 + 375);
// tallest page is 812 (C) — but C starts at y=0, so canvas height = max(800, 812) = 812
expect(height).toBe(812);
});

it('uses the requested gap', () => {
const doc = sampleDoc(`
page "A" viewport="1280x800" { }
page "B" viewport="1280x800" { }
`);
const a = layoutCanvas(doc.children, 0);
expect(a.placed[1].x).toBe(1280);
const b = layoutCanvas(doc.children, 100);
expect(b.placed[1].x).toBe(1380);
});
});

describe('renderCanvas(): bounded layout output', () => {
const doc = () =>
sampleDoc(`
page "Login" at(0, 0) viewport="1280x800" { text "x" }
page "Dashboard" at(1344, 0) viewport="1280x800" { text "y" }
`);

it('places boards at exact coordinates with no decoration', () => {
const r = renderCanvas(doc());
expect(r.width).toBe(1344 + 1280);
expect(r.height).toBe(800);
expect(r.html).toContain('wf-canvas');
expect(r.html).toContain('wf-canvas-board');
expect(r.html).toContain(`width: ${1344 + 1280}px`);
expect(r.html).toContain('height: 800px');
});

it('emits absolute-positioned boards at the layoutCanvas coordinates', () => {
const r = renderCanvas(doc());
expect(r.html).toContain('left: 0px');
expect(r.html).toContain('left: 1344px');
expect(r.html).toContain('width: 1280px');
});

it('exposes page metadata via data-* attributes for hosts', () => {
const r = renderCanvas(doc());
expect(r.html).toContain('data-page-count="2"');
expect(r.html).toContain('data-page-x="0"');
expect(r.html).toContain('data-page-x="1344"');
expect(r.html).toContain('data-page-w="1280"');
expect(r.html).toContain('data-page-h="800"');
expect(r.html).toContain('data-page-title="Login"');
expect(r.html).toContain('data-page-title="Dashboard"');
});

it('emits no chrome / decoration / grid markup', () => {
const r = renderCanvas(doc());
expect(r.html).not.toContain('wf-canvas-board-frame');
expect(r.html).not.toContain('wf-canvas-board-label');
expect(r.html).not.toContain('linear-gradient');
expect(r.html).not.toContain('data-chrome');
});

it('returns an empty bounded canvas for a doc with no pages', () => {
const empty: WireframeDocument = { type: 'Document', children: [] };
const r = renderCanvas(empty);
expect(r.width).toBe(0);
expect(r.height).toBe(0);
expect(r.html).toContain('data-empty="true"');
expect(r.html).not.toContain('wf-canvas-board');
});

it('handles mixed viewports (desktop + mobile) on one canvas', () => {
const mixed = sampleDoc(`
page "Desk" at(0, 0) viewport="1280x800" { }
page "Mob" at(0, 832) viewport="375x812" { }
`);
const r = renderCanvas(mixed);
expect(r.width).toBe(1280);
expect(r.height).toBe(832 + 812);
});

it('omits styles when includeStyles=false', () => {
const r = renderCanvas(doc(), { includeStyles: false });
expect(r.css).toBe('');
expect(r.html).toContain('wf-canvas');
});
});

describe('render(): page-count routing', () => {
it('single page → legacy mode (no canvas wrapper)', () => {
const doc = sampleDoc(`page "Solo" viewport="1280x800" { text "x" }`);
const r = render(doc);
expect(r.html).not.toContain('wf-canvas');
expect(r.html).toContain('wf-page');
});

it('multi-page → canvas wrapper with bounded layout', () => {
const doc = sampleDoc(`
page "A" viewport="1280x800" { }
page "B" viewport="1280x800" { }
`);
const r = render(doc);
expect(r.html).toContain('wf-canvas');
expect(r.html).toContain('wf-canvas-board');
expect(r.html).not.toContain('data-chrome');
});
});

describe('renderToSvg(): multi-page sizing', () => {
it('single page → uses page dimensions for viewBox', () => {
const doc = sampleDoc(`page "A" viewport="1280x800" { }`);
const r = renderToSvg(doc);
expect(r.width).toBe(1280);
expect(r.height).toBe(800);
});

it('multi-page → uses canvas bounding box for viewBox', () => {
const doc = sampleDoc(`
page "A" at(0, 0) viewport="1280x800" { }
page "B" at(1344, 0) viewport="1280x800" { }
`);
const r = renderToSvg(doc);
expect(r.width).toBe(1344 + 1280);
expect(r.height).toBe(800);
// wraps the canvas div, not a single page
expect(r.svg).toContain('wf-canvas');
});
});

describe('resolvePageDimensions()', () => {
it('falls back to viewport when w/h not numeric', () => {
const page = sampleDoc(`page "P" viewport="1440x900" { }`).children[0];
expect(resolvePageDimensions(page as PageNode)).toEqual({ width: 1440, height: 900 });
});
});
8 changes: 5 additions & 3 deletions __tests__/renderer-svg.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ describe('SVG Renderer', () => {
const doc = createDocument([]);
const result = renderToSvg(doc);

// No explicit viewport on the page → resolves to DEFAULT_VIEWPORT (1440x900).
// SVG sizes itself to the page's natural dimensions, not an arbitrary canvas.
expect(result.svg).toContain('<?xml version="1.0" encoding="UTF-8"?>');
expect(result.svg).toContain('<svg xmlns="http://www.w3.org/2000/svg"');
expect(result.svg).toContain('viewBox="0 0 800 600"');
expect(result.svg).toContain('viewBox="0 0 1440 900"');
expect(result.svg).toContain('<foreignObject');
expect(result.width).toBe(800);
expect(result.height).toBe(600);
expect(result.width).toBe(1440);
expect(result.height).toBe(900);
});

it('should render with custom dimensions', () => {
Expand Down
Loading