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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export const meta: DesignMeta = { title: 'My poster', theme: 'sunset', createdAt
export default [Poster] satisfies Scene[];
```

- `export default` is `Scene[]` — one component per artboard (most designs have one; several = a carousel / size set).
- `export default` is `Scene[]` — one component per artboard (most designs have one; several = a carousel / size set). Multiple boards flow in a row by default; `export const layout` (`{ wrap: 3 }`, `{ direction: 'column' }`) or a scene's `break` arranges them as a grid or a vertical stack.
- Objects: `<Box>`, `<Text>`, `<Ellipse>`, `<Line>`, `<ImageObject>`, `<Group>`, `<Icon>`, `<Illustration>` — positioned with `x/y/w/h`, rotated with `rotate`, themed with `var(--ox-*)` tokens.
- Themes: `ember`, `noir`, `sunset`, `mint`, `blueprint`, `bubblegum` (see `/themes`), or roll your own `DesignSystem`.

Expand Down
37 changes: 36 additions & 1 deletion apps/demo/.agents/skills/canva-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ export const meta: DesignMeta = { title: 'My poster', theme: 'sunset', createdAt
export default [Poster] satisfies Scene[];
```

- `export default` is a **non-empty array of zero-prop React components** (`Scene[]`), one per **artboard**, in order. Most designs have a single Scene; multiple Scenes form a carousel / multi-board set (laid side by side on the canvas).
- `export default` is a **non-empty array of zero-prop React components** (`Scene[]`), one per **artboard**, in order. Most designs have a single Scene; multiple Scenes form a carousel / multi-board set (auto-arranged on the canvas — one row by default, or a stack / grid, see **Arranging multiple boards**).
- Scene metadata is set as **static properties** on the component:
- `Scene.id` — stable id for the layers panel + deep links. Always set it.
- `Scene.label` — human name shown in the board switcher. **Never `Scene.name`** — `Function.name` is a read-only built-in and assigning to it throws.
- `Scene.artboard` — per-scene size override (e.g. one story board among square boards).
- `Scene.break` — start a new row (or column) at this board. See below.
- `export const artboard: Artboard = { w, h, background? }` — the default size for every scene. Use a preset size (see below). Omit `background` to inherit the theme's `--ox-bg`; set it (a color or gradient) to override.
- `export const meta: DesignMeta` — `title`, optional `theme` (a preset name — the `create-design` skill's theme catalog describes each one), and a **quoted ISO 8601 `createdAt`** (run `node -e "console.log(new Date().toISOString())"`; never type it from memory).
- Optional: `export const design: DesignSystem` to fully customize tokens instead of using a named `theme`.
Expand All @@ -61,6 +62,40 @@ Import `artboardPresets` or just write the numbers. Common sizes:
| `poster-a4-portrait` | 1240 × 1754 | Print posters |
| `business-card` | 1050 × 600 | Cards |

## Arranging multiple boards

Boards **auto-flow** on the canvas — by default a single horizontal row, left to right in `export default` order. `export const layout: BoardLayout` changes that, so a set can be a vertical stack or a grid (e.g. six versions in a row with a new version directly beneath them). You never position boards in pixels; you pick the flow and where it wraps.

```tsx
import type { BoardLayout } from '@opencanva/core';

export const layout: BoardLayout = { wrap: 3 }; // grid: 3 per row, wrapping downward
// { direction: 'column' } // one vertical stack, top → bottom
// { direction: 'column', wrap: 4 } // 4 per column, wrapping rightward
// { wrap: 6, justify: 'center', crossGap: 200 } // 6 per row; short rows centered, roomier row gap
```

| Field | Default | Meaning |
| --- | --- | --- |
| `direction` | `'row'` | Flow axis: `'row'` runs left→right, `'column'` stacks top→bottom. |
| `wrap` | none | Wrap onto a new line after N boards — boards per row (`row`) / per column (`column`). |
| `gap` | `96` | Gap along the flow axis, in artboard px. |
| `crossGap` | `gap` | Gap between rows (or columns). |
| `align` | `'center'` | How boards of unequal size sit **across** the flow axis inside their line (a short board among tall ones). `start` \| `center` \| `end`. |
| `justify` | `'start'` | How a **short line** sits along the flow axis relative to the longest one (one board under a row of six). `start` \| `center` \| `end`. |

To stack **without** touching the module layout, set `break` on the scene that should start the next line — the local, additive move when adding a new version below an existing row:

```tsx
const V7: Scene = () => (/* … */);
V7.id = 'v7';
V7.label = 'V7 — new version';
V7.break = true; // V7 (and everything after it) drops to the next row
export default [V1, V2, V3, V4, V5, V6, V7] satisfies Scene[];
```

Boards never overlap: each line is sized by its tallest (row) / widest (column) board. Board order in `export default` stays the export order and the layers-panel order regardless of arrangement.

## Object primitive kit (import from `@opencanva/core`)

Every object takes `x, y` (position), `w, h` (size), and optional `rotate` (deg, around center), `z` (stacking; later siblings are on top by default), `opacity`.
Expand Down
2 changes: 1 addition & 1 deletion apps/demo/.agents/skills/create-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Also decide the **logical structure**: which objects form self-contained units (

Read **`canva-authoring`** first, then write. Place objects with literal pixel coordinates; use `var(--ox-*)` tokens for color so the theme drives the palette. Add `export const artboard`, and `export const meta` with `title`, `theme`, and a real `createdAt` (run `node -e "console.log(new Date().toISOString())"`).

For a carousel/multi-board, export several Scenes; give each an `id` and `label`.
For a carousel/multi-board, export several Scenes; give each an `id` and `label`. They land in one row by default — add `export const layout` (`{ wrap: 3 }` for a grid, `{ direction: 'column' }` for a vertical stack) or set `break` on a scene to start a new row, e.g. when adding a new version beneath an existing row of boards. See "Arranging multiple boards" in `canva-authoring`.

## Step 5 — Self-review

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@opencanva/core",
"version": "0.3.0",
"version": "0.4.0",
"description": "OpenCanva runtime, Vite plugin, and CLI — the agent-native graphic design framework.",
"license": "Apache-2.0",
"author": "Daniel Lee",
Expand Down
37 changes: 36 additions & 1 deletion packages/core/skills/canva-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,12 @@ export const meta: DesignMeta = { title: 'My poster', theme: 'sunset', createdAt
export default [Poster] satisfies Scene[];
```

- `export default` is a **non-empty array of zero-prop React components** (`Scene[]`), one per **artboard**, in order. Most designs have a single Scene; multiple Scenes form a carousel / multi-board set (laid side by side on the canvas).
- `export default` is a **non-empty array of zero-prop React components** (`Scene[]`), one per **artboard**, in order. Most designs have a single Scene; multiple Scenes form a carousel / multi-board set (auto-arranged on the canvas — one row by default, or a stack / grid, see **Arranging multiple boards**).
- Scene metadata is set as **static properties** on the component:
- `Scene.id` — stable id for the layers panel + deep links. Always set it.
- `Scene.label` — human name shown in the board switcher. **Never `Scene.name`** — `Function.name` is a read-only built-in and assigning to it throws.
- `Scene.artboard` — per-scene size override (e.g. one story board among square boards).
- `Scene.break` — start a new row (or column) at this board. See below.
- `export const artboard: Artboard = { w, h, background? }` — the default size for every scene. Use a preset size (see below). Omit `background` to inherit the theme's `--ox-bg`; set it (a color or gradient) to override.
- `export const meta: DesignMeta` — `title`, optional `theme` (a preset name — the `create-design` skill's theme catalog describes each one), and a **quoted ISO 8601 `createdAt`** (run `node -e "console.log(new Date().toISOString())"`; never type it from memory).
- Optional: `export const design: DesignSystem` to fully customize tokens instead of using a named `theme`.
Expand All @@ -61,6 +62,40 @@ Import `artboardPresets` or just write the numbers. Common sizes:
| `poster-a4-portrait` | 1240 × 1754 | Print posters |
| `business-card` | 1050 × 600 | Cards |

## Arranging multiple boards

Boards **auto-flow** on the canvas — by default a single horizontal row, left to right in `export default` order. `export const layout: BoardLayout` changes that, so a set can be a vertical stack or a grid (e.g. six versions in a row with a new version directly beneath them). You never position boards in pixels; you pick the flow and where it wraps.

```tsx
import type { BoardLayout } from '@opencanva/core';

export const layout: BoardLayout = { wrap: 3 }; // grid: 3 per row, wrapping downward
// { direction: 'column' } // one vertical stack, top → bottom
// { direction: 'column', wrap: 4 } // 4 per column, wrapping rightward
// { wrap: 6, justify: 'center', crossGap: 200 } // 6 per row; short rows centered, roomier row gap
```

| Field | Default | Meaning |
| --- | --- | --- |
| `direction` | `'row'` | Flow axis: `'row'` runs left→right, `'column'` stacks top→bottom. |
| `wrap` | none | Wrap onto a new line after N boards — boards per row (`row`) / per column (`column`). |
| `gap` | `96` | Gap along the flow axis, in artboard px. |
| `crossGap` | `gap` | Gap between rows (or columns). |
| `align` | `'center'` | How boards of unequal size sit **across** the flow axis inside their line (a short board among tall ones). `start` \| `center` \| `end`. |
| `justify` | `'start'` | How a **short line** sits along the flow axis relative to the longest one (one board under a row of six). `start` \| `center` \| `end`. |

To stack **without** touching the module layout, set `break` on the scene that should start the next line — the local, additive move when adding a new version below an existing row:

```tsx
const V7: Scene = () => (/* … */);
V7.id = 'v7';
V7.label = 'V7 — new version';
V7.break = true; // V7 (and everything after it) drops to the next row
export default [V1, V2, V3, V4, V5, V6, V7] satisfies Scene[];
```

Boards never overlap: each line is sized by its tallest (row) / widest (column) board. Board order in `export default` stays the export order and the layers-panel order regardless of arrangement.

## Object primitive kit (import from `@opencanva/core`)

Every object takes `x, y` (position), `w, h` (size), and optional `rotate` (deg, around center), `z` (stacking; later siblings are on top by default), `opacity`.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/skills/create-design/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ Also decide the **logical structure**: which objects form self-contained units (

Read **`canva-authoring`** first, then write. Place objects with literal pixel coordinates; use `var(--ox-*)` tokens for color so the theme drives the palette. Add `export const artboard`, and `export const meta` with `title`, `theme`, and a real `createdAt` (run `node -e "console.log(new Date().toISOString())"`).

For a carousel/multi-board, export several Scenes; give each an `id` and `label`.
For a carousel/multi-board, export several Scenes; give each an `id` and `label`. They land in one row by default — add `export const layout` (`{ wrap: 3 }` for a grid, `{ direction: 'column' }` for a vertical stack) or set `break` on a scene to start a new row, e.g. when adding a new version beneath an existing row of boards. See "Arranging multiple boards" in `canva-authoring`.

## Step 5 — Self-review

Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/app/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ function DesignPage({ id }: { id: string }) {
const scenes = mod?.default ?? [];
const title = mod?.meta?.title ?? id;
const design = useMemo(() => resolveDesign({ design: mod?.design, theme: mod?.meta?.theme }), [mod]);
const layout = useMemo(() => layoutBoards(scenes, mod?.artboard), [scenes, mod]);
const layout = useMemo(() => layoutBoards(scenes, mod?.artboard, mod?.layout), [scenes, mod]);
const vp = useViewport(stageRef, { w: layout.w, h: layout.h });

// Focus a board: make it active (toolbar, layers panel) AND bring it into
Expand Down Expand Up @@ -329,6 +329,7 @@ function DesignPage({ id }: { id: string }) {
canvasRef={canvasRef}
scenes={scenes}
moduleArtboard={mod.artboard}
layout={mod.layout}
design={design}
viewport={vp}
/>
Expand Down
85 changes: 69 additions & 16 deletions packages/core/src/app/components/Stage.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,78 @@
import { type RefObject } from 'react';
import type { DesignSystem } from '../../design';
import { type Artboard, resolveArtboard, type Scene } from '../../sdk';
import { type Artboard, type BoardLayout, resolveArtboard, type Scene } from '../../sdk';
import type { Viewport } from '../lib/viewport';
import { Board } from './Board';

export const BOARD_GAP = 96; // px between boards on the canvas, in artboard units

/** Lay scenes out in a horizontal row; return content bounds + per-board offsets. */
export function layoutBoards(scenes: Scene[], moduleArtboard?: Artboard) {
let x = 0;
let maxH = 0;
const boards = scenes.map((scene, i) => {
interface PlacedBoard {
x: number;
y: number;
artboard: Artboard;
scene: Scene;
index: number;
}

/** Offset of a board (or a line) inside the space it is aligned within. */
function offsetFor(align: 'start' | 'center' | 'end', slack: number) {
return align === 'start' ? 0 : align === 'end' ? slack : slack / 2;
}

/**
* Auto-arrange scenes on the canvas; return content bounds + per-board offsets.
*
* Boards flow along one axis (`layout.direction`, default `row`) and wrap onto a
* new line every `layout.wrap` boards — or wherever a scene sets `break` — so a
* design can be a row, a vertical stack, or a grid (e.g. six boards in a row and
* a new version directly beneath them). Positions are baked in here so the Stage
* render and zoom-to-board (`viewport.fitTo`) share one source of truth.
*/
export function layoutBoards(scenes: Scene[], moduleArtboard?: Artboard, layout?: BoardLayout) {
const vertical = layout?.direction === 'column';
const gap = layout?.gap ?? BOARD_GAP;
const crossGap = layout?.crossGap ?? gap;
// Guard against 0 / negative / fractional wrap counts — they'd make every
// board its own line (or loop forever in the reader's head).
const wrap = layout?.wrap && layout.wrap >= 1 ? Math.floor(layout.wrap) : Infinity;
const align = layout?.align ?? 'center';
const justify = layout?.justify ?? 'start';

// Split into lines, keeping scene order (export + DOM order depend on it).
const lines: PlacedBoard[][] = [];
scenes.forEach((scene, i) => {
const artboard = resolveArtboard(scene, moduleArtboard);
const at = { x, y: 0, artboard, scene, index: i };
x += artboard.w + BOARD_GAP;
maxH = Math.max(maxH, artboard.h);
return at;
const line = lines[lines.length - 1];
if (!line || scene.break || line.length >= wrap) lines.push([{ x: 0, y: 0, artboard, scene, index: i }]);
else line.push({ x: 0, y: 0, artboard, scene, index: i });
});

// Main axis = the flow axis (x in a row, y in a column); cross = the other one.
const mainSize = (a: Artboard) => (vertical ? a.h : a.w);
const crossSize = (a: Artboard) => (vertical ? a.w : a.h);

const lineMains = lines.map((line) => line.reduce((sum, b) => sum + mainSize(b.artboard), 0) + gap * (line.length - 1));
const totalMain = lineMains.length ? Math.max(...lineMains) : 0;

let cross = 0;
lines.forEach((line, li) => {
const lineCross = Math.max(...line.map((b) => crossSize(b.artboard)));
let main = offsetFor(justify, totalMain - lineMains[li]);
for (const b of line) {
const off = cross + offsetFor(align, lineCross - crossSize(b.artboard));
b.x = vertical ? off : main;
b.y = vertical ? main : off;
main += mainSize(b.artboard) + gap;
}
cross += lineCross + crossGap;
});
// Shorter boards sit vertically centered in the row; bake that into y so the
// Stage render and zoom-to-board (viewport.fitTo) share one source of truth.
for (const b of boards) b.y = (maxH - b.artboard.h) / 2;
const w = boards.length ? x - BOARD_GAP : 0;
return { boards, w, h: maxH };
const totalCross = lines.length ? cross - crossGap : 0;

return {
boards: lines.flat(),
w: vertical ? totalCross : totalMain,
h: vertical ? totalMain : totalCross,
};
}

/**
Expand All @@ -33,17 +84,19 @@ export function Stage({
canvasRef,
scenes,
moduleArtboard,
layout,
design,
viewport,
}: {
stageRef: RefObject<HTMLDivElement>;
canvasRef: RefObject<HTMLDivElement>;
scenes: Scene[];
moduleArtboard?: Artboard;
layout?: BoardLayout;
design: DesignSystem;
viewport: Viewport;
}) {
const { boards } = layoutBoards(scenes, moduleArtboard);
const { boards } = layoutBoards(scenes, moduleArtboard, layout);
const total = scenes.length;

return (
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// Public API of @opencanva/core — what designs under designs/<id>/ import.
export type { Scene, DesignMeta, DesignModule, Artboard } from './sdk';
export type { Scene, DesignMeta, DesignModule, Artboard, BoardLayout } from './sdk';
export { DEFAULT_ARTBOARD, artboardPresets, resolveArtboard } from './sdk';
export type { DesignSystem } from './design';
export { defaultDesign, designPresets, designToCssVars, resolveDesign } from './design';
Expand Down
Loading
Loading