diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index a1a0d38..feb87ed 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -12,7 +12,9 @@
"Bash(curl -s \"https://www.figma.com/draw/\")",
"Bash(curl -s \"https://www.figma.com/make/\")",
"Bash(curl -s \"https://github.githubassets.com/assets/primer-primitives-0f570a01cbe448fb.css\")",
- "Bash(pnpm --filter @originmain/app run build)"
+ "Bash(pnpm --filter @originmain/app run build)",
+ "Bash(pnpm --filter @originmain/app install)",
+ "Bash(pnpm add *)"
]
}
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 510eb36..3dafa3b 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -14,6 +14,7 @@
"@originmain/origin-graph": "workspace:*",
"@originmain/renderer": "workspace:*",
"@fluentui/react-components": "^9.54.0",
+ "@fluentui/react-icons": "^2.0.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
diff --git a/packages/app/src/app/canvas/page.tsx b/packages/app/src/app/canvas/page.tsx
new file mode 100644
index 0000000..8cb1de5
--- /dev/null
+++ b/packages/app/src/app/canvas/page.tsx
@@ -0,0 +1,7 @@
+import { AppChrome } from '@/components/chrome/AppChrome';
+
+export const metadata = { title: 'Canvas — Originmain' };
+
+export default function CanvasPage() {
+ return ;
+}
diff --git a/packages/app/src/components/canvas/Artboard.tsx b/packages/app/src/components/canvas/Artboard.tsx
new file mode 100644
index 0000000..8f6dac0
--- /dev/null
+++ b/packages/app/src/components/canvas/Artboard.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { useCanvas } from '@/store/canvas';
+
+interface ArtboardProps {
+ id: string;
+ label: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+export function Artboard({ id, label, x, y, width, height }: ArtboardProps) {
+ const { selectedArtboardId, selectArtboard } = useCanvas();
+ const selected = selectedArtboardId === id;
+
+ return (
+
{ e.stopPropagation(); selectArtboard(id); }}
+ >
+ {/* Label above artboard */}
+
+ {label}
+
+
+ {/* Artboard frame */}
+
+ {/* Selection handles */}
+ {selected && (
+ <>
+
+
+
+
+ >
+ )}
+
+ {/* Placeholder content */}
+
+
+
+ Revenue Overview
+
+
+ Live
+
+
+
+ $12,450
+
+
+ ↑ +2.4% vs last month
+
+
+
+ Q4 2024
+ MRR
+
+
+
+
+ );
+}
+
+function Handle({ pos }: { pos: React.CSSProperties }) {
+ return (
+
+ );
+}
+
+function Chip({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/app/src/components/canvas/Canvas.tsx b/packages/app/src/components/canvas/Canvas.tsx
new file mode 100644
index 0000000..db3c1d8
--- /dev/null
+++ b/packages/app/src/components/canvas/Canvas.tsx
@@ -0,0 +1,115 @@
+'use client';
+
+import { useRef, useEffect, useCallback } from 'react';
+import { useViewport } from '@/store/viewport';
+import { useCanvas } from '@/store/canvas';
+import { Artboard } from './Artboard';
+
+const ARTBOARDS = [
+ { id: 'dashboard-card', label: 'DashboardCard', x: 120, y: 120, width: 280, height: 200 },
+ { id: 'user-profile', label: 'UserProfile', x: 480, y: 120, width: 240, height: 280 },
+ { id: 'nav-sidebar', label: 'NavSidebar', x: 120, y: 400, width: 200, height: 360 },
+ { id: 'data-table', label: 'DataTable', x: 400, y: 420, width: 420, height: 300 },
+];
+
+export function Canvas() {
+ const containerRef = useRef(null);
+ const panX = useViewport((s) => s.panX);
+ const panY = useViewport((s) => s.panY);
+ const zoom = useViewport((s) => s.zoom);
+ const { activeTool, selectArtboard } = useCanvas();
+
+ const isPanning = useRef(false);
+ const lastPos = useRef({ x: 0, y: 0 });
+ const spaceDown = useRef(false);
+
+ // Wheel: pan or zoom
+ useEffect(() => {
+ const el = containerRef.current;
+ if (!el) return;
+ const onWheel = (e: WheelEvent) => {
+ e.preventDefault();
+ const { zoom, panX, panY, setPan, setZoom } = useViewport.getState();
+ if (e.ctrlKey || e.metaKey) {
+ const rect = el.getBoundingClientRect();
+ const ox = e.clientX - rect.left;
+ const oy = e.clientY - rect.top;
+ setZoom(zoom * (e.deltaY > 0 ? 0.92 : 1.09), ox, oy);
+ } else {
+ setPan(panX - e.deltaX, panY - e.deltaY);
+ }
+ };
+ el.addEventListener('wheel', onWheel, { passive: false });
+ return () => el.removeEventListener('wheel', onWheel);
+ }, []);
+
+ // Space key: temporary pan mode
+ useEffect(() => {
+ const down = (e: KeyboardEvent) => { if (e.code === 'Space' && e.target === document.body) spaceDown.current = true; };
+ const up = (e: KeyboardEvent) => { if (e.code === 'Space') spaceDown.current = false; };
+ window.addEventListener('keydown', down);
+ window.addEventListener('keyup', up);
+ return () => { window.removeEventListener('keydown', down); window.removeEventListener('keyup', up); };
+ }, []);
+
+ const onMouseDown = useCallback((e: React.MouseEvent) => {
+ const startPan = e.button === 1 || spaceDown.current || activeTool === 'pan';
+ if (startPan) {
+ e.preventDefault();
+ isPanning.current = true;
+ lastPos.current = { x: e.clientX, y: e.clientY };
+ } else if (e.target === e.currentTarget) {
+ selectArtboard(null);
+ }
+ }, [activeTool, selectArtboard]);
+
+ const onMouseMove = useCallback((e: React.MouseEvent) => {
+ if (!isPanning.current) return;
+ const dx = e.clientX - lastPos.current.x;
+ const dy = e.clientY - lastPos.current.y;
+ lastPos.current = { x: e.clientX, y: e.clientY };
+ const { panX, panY, setPan } = useViewport.getState();
+ setPan(panX + dx, panY + dy);
+ }, []);
+
+ const onMouseUp = useCallback(() => { isPanning.current = false; }, []);
+
+ const gridSize = Math.max(8, 22 * zoom);
+ const cursor = activeTool === 'pan' || isPanning.current ? 'grab' : 'default';
+
+ return (
+
+ {/* Dot grid — shifts with pan, scales with zoom */}
+
+
+ {/* Transform layer */}
+
+ {ARTBOARDS.map((ab) => (
+
+ ))}
+
+
+ );
+}
diff --git a/packages/app/src/components/chrome/AppChrome.tsx b/packages/app/src/components/chrome/AppChrome.tsx
new file mode 100644
index 0000000..cbdbdce
--- /dev/null
+++ b/packages/app/src/components/chrome/AppChrome.tsx
@@ -0,0 +1,25 @@
+'use client';
+
+import { Toolbar } from './Toolbar';
+import { ArtboardNavigator } from '../navigator/ArtboardNavigator';
+import { Canvas } from '../canvas/Canvas';
+import { Inspector } from '../inspector/Inspector';
+
+export function AppChrome() {
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/chrome/Toolbar.tsx b/packages/app/src/components/chrome/Toolbar.tsx
new file mode 100644
index 0000000..4ceb79e
--- /dev/null
+++ b/packages/app/src/components/chrome/Toolbar.tsx
@@ -0,0 +1,123 @@
+'use client';
+
+import {
+ ToolbarButton,
+ ToolbarDivider,
+ Tooltip,
+ Badge,
+} from '@fluentui/react-components';
+import {
+ CursorClickRegular,
+ HandLeftRegular,
+ SquareRegular,
+ SelectObjectRegular,
+ DataTrendingRegular,
+ CircleHintHalfVerticalRegular,
+ ZoomInRegular,
+ ZoomOutRegular,
+} from '@fluentui/react-icons';
+import { useCanvas, type Tool } from '@/store/canvas';
+import { useViewport } from '@/store/viewport';
+
+export function Toolbar() {
+ const { activeTool, setActiveTool } = useCanvas();
+ const zoom = useViewport((s) => s.zoom);
+ const setZoom = useViewport((s) => s.setZoom);
+ const reset = useViewport((s) => s.reset);
+
+ const ap = (id: Tool): 'primary' | 'subtle' => activeTool === id ? 'primary' : 'subtle';
+
+ return (
+
+ {/* Wordmark */}
+
+ Om
+
+
+
+
+ {/* Select */}
+
+ } onClick={() => setActiveTool('select')} />
+
+ {/* Pan */}
+
+ } onClick={() => setActiveTool('pan')} />
+
+ {/* Artboard */}
+
+ } onClick={() => setActiveTool('artboard')} />
+
+ {/* Zone */}
+
+ } onClick={() => setActiveTool('zone')} />
+
+
+
+
+ {/* View tools */}
+
+ } />
+
+
+ } />
+
+
+ {/* Right — zoom + live status */}
+
+
+ } onClick={() => setZoom(zoom * 0.8)} />
+
+
+
+ } onClick={() => setZoom(zoom * 1.25)} />
+
+
+
+
+
+
+ Live
+
+
+
+ );
+}
diff --git a/packages/app/src/components/inspector/Inspector.tsx b/packages/app/src/components/inspector/Inspector.tsx
new file mode 100644
index 0000000..c91b53e
--- /dev/null
+++ b/packages/app/src/components/inspector/Inspector.tsx
@@ -0,0 +1,113 @@
+'use client';
+
+import { TabList, Tab, Divider } from '@fluentui/react-components';
+import { useCanvas } from '@/store/canvas';
+
+const PROPS = [
+ { key: 'title', val: '"Revenue Overview"', type: 's' },
+ { key: 'value', val: '"$12,450"', type: 's' },
+ { key: 'delta', val: '+2.4', type: 'n' },
+ { key: 'period', val: '"monthly"', type: 's' },
+ { key: 'loading', val: 'false', type: 'b' },
+];
+
+const DIFF = [
+ { op: 'del', text: '− borderRadius: 8px' },
+ { op: 'add', text: '+ borderRadius: 12px' },
+ { op: 'del', text: '− accentColor: #2A6CD4' },
+ { op: 'add', text: '+ accentColor: #0F52BA' },
+];
+
+const TYPE_COLORS: Record = {
+ s: '#16a34a',
+ n: '#2563eb',
+ b: '#d97706',
+};
+
+export function Inspector() {
+ const { selectedArtboardId } = useCanvas();
+
+ return (
+
+
+ Props
+ Diff
+ Graph
+
+
+ {selectedArtboardId ? (
+ <>
+
+ {PROPS.map(({ key, val, type }) => (
+
+ ))}
+
+
+
+
+
+ {DIFF.map((d, i) => (
+
+ {d.text}
+
+ ))}
+
+
+
+
+
+ >
+ ) : (
+
+ Select an artboard
+
+ )}
+
+ );
+}
+
+function Section({ label, children }: { label: string; children: React.ReactNode }) {
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+function PropRow({ label, value, color }: { label: string; value: string; color: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
diff --git a/packages/app/src/components/navigator/ArtboardNavigator.tsx b/packages/app/src/components/navigator/ArtboardNavigator.tsx
new file mode 100644
index 0000000..283737f
--- /dev/null
+++ b/packages/app/src/components/navigator/ArtboardNavigator.tsx
@@ -0,0 +1,118 @@
+'use client';
+
+import {
+ Tree,
+ TreeItem,
+ TreeItemLayout,
+ Text,
+} from '@fluentui/react-components';
+import {
+ SquareRegular,
+ DocumentRegular,
+ FolderRegular,
+ FolderOpenRegular,
+} from '@fluentui/react-icons';
+import { useCanvas } from '@/store/canvas';
+
+const ARTBOARDS = [
+ { id: 'dashboard-card', label: 'DashboardCard' },
+ { id: 'user-profile', label: 'UserProfile' },
+ { id: 'nav-sidebar', label: 'NavSidebar' },
+ { id: 'data-table', label: 'DataTable' },
+];
+
+export function ArtboardNavigator() {
+ const { selectedArtboardId, selectArtboard } = useCanvas();
+
+ return (
+
+ {/* Artboards section */}
+
Artboards
+
+ {ARTBOARDS.map((ab) => (
+ selectArtboard(ab.id)}
+ >
+ }>
+ {ab.label}
+
+
+ ))}
+
+
+
+
+ {/* Codebase section */}
+
Files
+
+
+ }>
+ src
+
+
+
+ }>
+ components
+
+
+ {ARTBOARDS.map((ab) => (
+
+ }>
+
+ {ab.label}.tsx
+
+
+
+ ))}
+
+
+
+ }>
+ page.tsx
+
+
+
+
+
+
+ );
+}
+
+function SectionLabel({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+function Divider() {
+ return ;
+}
diff --git a/packages/app/src/store/canvas.ts b/packages/app/src/store/canvas.ts
new file mode 100644
index 0000000..82a6da2
--- /dev/null
+++ b/packages/app/src/store/canvas.ts
@@ -0,0 +1,17 @@
+import { create } from 'zustand';
+
+export type Tool = 'select' | 'pan' | 'artboard' | 'zone';
+
+interface CanvasStore {
+ activeTool: Tool;
+ selectedArtboardId: string | null;
+ setActiveTool: (tool: Tool) => void;
+ selectArtboard: (id: string | null) => void;
+}
+
+export const useCanvas = create((set) => ({
+ activeTool: 'select',
+ selectedArtboardId: 'dashboard-card',
+ setActiveTool: (tool) => set({ activeTool: tool }),
+ selectArtboard: (id) => set({ selectedArtboardId: id }),
+}));
diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json
index a5ae446..78b5222 100644
--- a/packages/app/tsconfig.json
+++ b/packages/app/tsconfig.json
@@ -8,6 +8,9 @@
],
"noEmit": true,
"allowJs": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ },
"incremental": true,
"esModuleInterop": true,
"resolveJsonModule": true,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index c45ab08..a6fbefc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -64,6 +64,9 @@ importers:
'@fluentui/react-components':
specifier: ^9.54.0
version: 9.73.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(scheduler@0.27.0)
+ '@fluentui/react-icons':
+ specifier: ^2.0.0
+ version: 2.0.324(react@19.2.5)
'@originmain/diff-engine':
specifier: workspace:*
version: link:../diff-engine