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