diff --git a/package-lock.json b/package-lock.json
index f85c4b6..f2615fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2783,6 +2783,7 @@
"@babel/parser": "^7.29.2",
"@vitejs/plugin-react": "^4.7.0",
"fast-glob": "^3.3.2",
+ "fflate": "^0.8.3",
"jspdf": "^4.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/packages/core/package.json b/packages/core/package.json
index cdccf89..37796cf 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -34,6 +34,7 @@
"@babel/parser": "^7.29.2",
"@vitejs/plugin-react": "^4.7.0",
"fast-glob": "^3.3.2",
+ "fflate": "^0.8.3",
"jspdf": "^4.2.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
diff --git a/packages/core/src/app/app.tsx b/packages/core/src/app/app.tsx
index f0ebb1e..8927cfc 100644
--- a/packages/core/src/app/app.tsx
+++ b/packages/core/src/app/app.tsx
@@ -9,7 +9,7 @@ import { useAgentSocketConnected } from './lib/use-agent-socket';
import { navigate } from './lib/navigate';
import { findLayoutIssues } from './lib/overflow';
import { useViewport } from './lib/viewport';
-import { boardToPngDataUrl, exportPdf, exportPng, exportSvg } from './lib/export';
+import { boardToPngDataUrl, exportPdf, exportPdfPages, exportPng, exportSvg, exportZip } from './lib/export';
import { Stage, layoutBoards } from './components/Stage';
import { Inspector } from './components/Inspector';
import { LayersPanel } from './components/LayersPanel';
@@ -17,7 +17,7 @@ import { AssetsPanel } from './components/AssetsPanel';
import { TokensPanel } from './components/TokensPanel';
import { Home, ThemesPage, ThemeDetail, ThemeToggle } from './components/Workspace';
import { ToastHost, toast } from './components/ui/toast';
-import { Menu, SelectMenu } from './components/ui/Menu';
+import { Menu } from './components/ui/Menu';
import { Icon } from './components/icons';
const isDev = import.meta.env.DEV;
@@ -100,6 +100,19 @@ function DesignPage({ id }: { id: string }) {
const layout = useMemo(() => layoutBoards(scenes, mod?.artboard), [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
+ // view — zoom-to-board, so picking a board visibly navigates the canvas.
+ const focusBoard = (i: number) => {
+ setActiveBoard(i);
+ const b = layout.boards[i];
+ if (!b) return;
+ // Include the caption floating above the board (bottom: 100% + 12px margin,
+ // ~29 canvas px) in the fitted rect, or small boards clip their name off
+ // the top of the stage.
+ const captionH = 32;
+ vp.fitTo({ x: b.x, y: b.y - captionH, w: b.artboard.w, h: b.artboard.h + captionH });
+ };
+
// Report the editor's cursor to .opencanva/current.json (the current-design skill).
useEffect(() => {
const hot = import.meta.hot;
@@ -164,6 +177,10 @@ function DesignPage({ id }: { id: string }) {
}
if (!mod) return
Loading {id}…
;
+ const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
+ const slug = slugify(title) || id;
+ const boardName = (i: number) => scenes[i]?.label ?? scenes[i]?.id ?? `Board ${i + 1}`;
+
const doExport = async (kind: 'png' | 'svg' | 'pdf') => {
if (exporting) return; // lock out concurrent exports
// Scope to the live canvas: LayersPanel thumbnails also render real `.ox-board`
@@ -175,7 +192,6 @@ function DesignPage({ id }: { id: string }) {
toast.err('Nothing to export — no board is rendered.');
return;
}
- const slug = (title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || id);
const fname = `${slug}${scenes.length > 1 ? `-${activeBoard + 1}` : ''}`;
setExporting(kind);
try {
@@ -190,6 +206,36 @@ function DesignPage({ id }: { id: string }) {
}
};
+ // Export every board: PDF gets one page per board; PNG/SVG get one file per
+ // board bundled in a .zip (browsers throttle multi-file downloads).
+ const doExportAll = async (kind: 'png' | 'svg' | 'pdf') => {
+ if (exporting) return;
+ const boardEls = document.querySelectorAll('.ox-canvas .ox-board');
+ const items = layout.boards
+ .map((b, i) => ({
+ el: boardEls[i],
+ w: b.artboard.w,
+ h: b.artboard.h,
+ name: `${String(i + 1).padStart(2, '0')}-${slugify(boardName(i)) || `board-${i + 1}`}`,
+ }))
+ .filter((it) => it.el);
+ if (!items.length) {
+ toast.err('Nothing to export — no board is rendered.');
+ return;
+ }
+ setExporting(kind);
+ try {
+ const fname = kind === 'pdf' ? slug : `${slug}-boards`;
+ if (kind === 'pdf') await exportPdfPages(items, fname);
+ else await exportZip(items, fname, kind);
+ toast.ok(`Exported ${items.length} boards → ${fname}.${kind === 'pdf' ? 'pdf' : 'zip'}`);
+ } catch (err) {
+ toast.err(`Export failed: ${String((err as Error)?.message ?? err)}`);
+ } finally {
+ setExporting(null);
+ }
+ };
+
// Dev fidelity hook: returns the real export PNG as a data URL so the rendered
// output can be compared against the on-screen board (no drift).
if (isDev) {
@@ -226,15 +272,6 @@ function DesignPage({ id }: { id: string }) {
- {scenes.length > 1 && (
- ({ value: i, label: s.label ?? s.id ?? `Board ${i + 1}` }))}
- onChange={setActiveBoard}
- />
- )}
-
{isDev && (