From fdf64ee72c4733086775b632c3594d5bb1837e52 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Tue, 5 May 2026 22:06:31 +0100 Subject: [PATCH] made tiny updates --- .../agent-bridge/src/adapters/claude-code.ts | 22 +- packages/agent-bridge/src/index.ts | 4 +- packages/agent-bridge/src/protocol.ts | 61 ++ packages/agent-bridge/src/tools.ts | 583 ++++++++++++------ .../agent-bridge/register-indexer/route.ts | 118 ++++ .../app/src/app/api/agent-bridge/route.ts | 40 +- .../src/app/api/artboards/thumbnail/route.ts | 82 +++ packages/app/src/app/api/cli-auth/route.ts | 118 ++++ .../app/api/design-language/fetch/route.ts | 143 +++++ packages/app/src/app/api/intent/route.ts | 78 +++ .../src/app/settings/design-language/page.tsx | 493 +++++++++++++++ .../app/src/components/canvas/Artboard.tsx | 235 +++++-- packages/app/src/components/canvas/Canvas.tsx | 95 ++- .../src/components/canvas/IsolationFrame.tsx | 229 +++++++ .../src/components/canvas/LiveArtboard.tsx | 32 +- .../app/src/components/chrome/AppChrome.tsx | 147 ++++- .../app/src/components/chrome/Toolbar.tsx | 126 +++- .../src/components/inspector/Inspector.tsx | 393 +++++++++++- .../components/inspector/TokenAwareInput.tsx | 180 ++++++ .../src/components/inspector/TokenPicker.tsx | 292 +++++++++ .../navigator/ArtboardNavigator.tsx | 368 ++++++++++- packages/app/src/lib/artboard-iframe-map.ts | 11 + packages/app/src/lib/diff-generator.ts | 232 +++++++ packages/app/src/store/canvas.ts | 96 ++- packages/app/src/store/canvas.types.ts | 43 ++ packages/app/tsconfig.tsbuildinfo | 2 +- packages/cli/src/__tests__/security.test.ts | 201 ++++++ packages/cli/src/cli.ts | 106 +++- packages/cli/src/commands/login.ts | 222 +++++++ packages/cli/src/isolation-server.ts | 387 +++++++++++- packages/design-language/package.json | 2 + packages/design-language/src/index.ts | 2 + packages/design-language/src/parser.ts | 303 +++++++++ packages/design-language/src/resolver.ts | 206 +++++++ packages/origin-graph/src/types.ts | 28 +- packages/renderer/src/fiber-hook.ts | 85 ++- packages/renderer/src/protocol.ts | 25 +- pnpm-lock.yaml | 17 + .../012_artboard_phase0_columns.sql | 73 +++ 39 files changed, 5499 insertions(+), 381 deletions(-) create mode 100644 packages/app/src/app/api/agent-bridge/register-indexer/route.ts create mode 100644 packages/app/src/app/api/artboards/thumbnail/route.ts create mode 100644 packages/app/src/app/api/cli-auth/route.ts create mode 100644 packages/app/src/app/api/design-language/fetch/route.ts create mode 100644 packages/app/src/app/api/intent/route.ts create mode 100644 packages/app/src/app/settings/design-language/page.tsx create mode 100644 packages/app/src/components/canvas/IsolationFrame.tsx create mode 100644 packages/app/src/components/inspector/TokenAwareInput.tsx create mode 100644 packages/app/src/components/inspector/TokenPicker.tsx create mode 100644 packages/app/src/lib/artboard-iframe-map.ts create mode 100644 packages/app/src/lib/diff-generator.ts create mode 100644 packages/app/src/store/canvas.types.ts create mode 100644 packages/cli/src/__tests__/security.test.ts create mode 100644 packages/cli/src/commands/login.ts create mode 100644 packages/design-language/src/parser.ts create mode 100644 packages/design-language/src/resolver.ts create mode 100644 supabase/migrations/012_artboard_phase0_columns.sql diff --git a/packages/agent-bridge/src/adapters/claude-code.ts b/packages/agent-bridge/src/adapters/claude-code.ts index 3b62679..c0d05e2 100644 --- a/packages/agent-bridge/src/adapters/claude-code.ts +++ b/packages/agent-bridge/src/adapters/claude-code.ts @@ -43,15 +43,21 @@ The server is pre-configured in \`.claude/settings.json\`. ${toolLines} ### Implementation Workflow -1. \`get_pending_diffs\` → list EXPORTED IntentDiffs that need code changes -2. \`get_artboard_context\` → load component tree, screenshots, and design language -3. Implement the required changes in the codebase -4. \`ask_design_agent\` → clarify design intent when the diff is ambiguous -5. \`update_diff_status\` → mark IMPLEMENTED or BLOCKED with an explanation +1. Call \`push_intent\` to receive any pending design intent diffs from the Origin canvas. +2. Locate the component file using \`resolve_component\` (pass the \`nodeId\` from the intent). +3. Apply the change to the source file — the intent's \`codeDiff\` contains the expected before/after. +4. After applying, call \`update_diff_status\` with \`status: "IMPLEMENTED"\` and the \`intentId\`. +5. If the diff cannot be applied for any reason, call \`update_diff_status\` with \`status: "BLOCKED"\` + and a \`reason\` string describing why (e.g. "Component not found in file", "File is read-only", + "Diff conflicts with current file state"). The designer will see this reason in the Origin canvas. -### Design Language Validation -Run \`get_design_language\` at session start to cache the team's active Design Language File. -All token references (colors, typography, spacing) must match the DLF. +### Important: Always close the loop with update_diff_status +Every intent received via \`push_intent\` MUST be closed with \`update_diff_status\` — either +IMPLEMENTED or BLOCKED. An intent left in EXPORTED state will be retried on the next session. + +### Design Language +When token keys are present in the intent changes (\`tokenKey\` field), write \`var(--token-name)\` +instead of the raw value so the component stays in sync with the design system. ### Rate Limit 100 diff exports/hour per workspace. If you hit the limit, wait before retrying. diff --git a/packages/agent-bridge/src/index.ts b/packages/agent-bridge/src/index.ts index 044e677..49b3324 100644 --- a/packages/agent-bridge/src/index.ts +++ b/packages/agent-bridge/src/index.ts @@ -1,4 +1,4 @@ -export type { JsonRpcRequest, JsonRpcSuccess, JsonRpcError, JsonRpcResponse, AuthRequest, AuthAck, ToolResult } from './protocol.js'; +export type { JsonRpcRequest, JsonRpcSuccess, JsonRpcError, JsonRpcResponse, AuthRequest, AuthAck, ToolResult, IntentChange, IntentMessage, IntentReceivedPush } from './protocol.js'; export { MCP_ERROR, textResult, jsonResult } from './protocol.js'; export type { WorkspaceToken, AgentType } from './auth.js'; @@ -8,7 +8,7 @@ export type { RateLimitResult } from './rate-limiter.js'; export { checkRateLimit, getRateLimitStatus } from './rate-limiter.js'; export type { McpTool, ToolContext } from './tools.js'; -export { TOOLS, TOOL_MAP, getToolList } from './tools.js'; +export { TOOLS, TOOL_MAP, getToolList, dispatchTool, storePendingIntent, drainPendingIntents, registerIndexer, getIndexerUrl, heartbeatIndexer } from './tools.js'; export type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js'; export { generateCursorConfig } from './adapters/cursor.js'; diff --git a/packages/agent-bridge/src/protocol.ts b/packages/agent-bridge/src/protocol.ts index a6654a2..eb95953 100644 --- a/packages/agent-bridge/src/protocol.ts +++ b/packages/agent-bridge/src/protocol.ts @@ -54,6 +54,67 @@ export interface AuthAck { agentType: 'CURSOR' | 'CLAUDE_CODE' | 'GENERIC'; } +// ── Intent types — Phase 5 §8.4 ────────────────────────────────────────────── +// These describe the design changes the canvas wants the agent to apply to source. + +export interface IntentChange { + type: 'style' | 'prop' | 'layout' | 'remove'; + cssProperty?: string; + propName?: string; + from?: unknown; + to?: unknown; + /** CSS custom property key when the value maps to a design token (Phase 6). */ + tokenKey?: string; + confidence: 'exact' | 'approximate'; +} + +export interface IntentMessage { + intentId: string; + component: { + name: string; + /** Fiber path ID — used for diff correlation. */ + nodeId: string; + /** Call-site location: "src/app/dashboard/page.tsx:34" */ + callSite?: string; + definitionFile?: string; + definitionLine?: number; + /** Current runtime props — context for the agent. */ + props: Record; + /** From AST indexer (optional — indexer may not be running). */ + propsSchema?: Array<{ name: string; type: string; optional: boolean }>; + }; + changes: IntentChange[]; + /** + * Ready-to-apply code diff (when confidence is 'exact', apply verbatim; + * when 'approximate', use as a guide and refine). + */ + codeDiff?: { + file: string; + originalContent: string; + patchedContent: string; + confidence: 'exact' | 'approximate'; + }; + /** Before/after visual snapshots (base64 data URLs). */ + snapshot?: { + before: string; + after?: string; + }; + /** Design language context when tokens are loaded (Phase 6). */ + designLanguage?: { + tokensUsed: string[]; + palette: Record; + }; +} + +/** + * Server → agent push (sent over WebSocket, no JSON-RPC id — not a request). + * Emitted immediately after `push_intent` stores a new intent. + */ +export interface IntentReceivedPush { + type: 'INTENT_RECEIVED'; + intent: IntentMessage; +} + // ── Tool result types ───────────────────────────────────────────────────────── export interface ToolResult { diff --git a/packages/agent-bridge/src/tools.ts b/packages/agent-bridge/src/tools.ts index 2e09bbd..d020d88 100644 --- a/packages/agent-bridge/src/tools.ts +++ b/packages/agent-bridge/src/tools.ts @@ -1,216 +1,407 @@ -import { z } from 'zod'; -import { checkRateLimit } from './rate-limiter.js'; -import { jsonResult, textResult, MCP_ERROR } from './protocol.js'; -import type { ToolResult, JsonRpcError } from './protocol.js'; -import type { DiffStatus } from '@originmain/origin-graph'; +/** + * tools.ts — Phase 5 + * + * Defines the MCP tools exposed by the Agent Bridge to connected IDE agents + * (Cursor, Claude Code, etc.). Each tool is a JSON Schema descriptor plus a + * handler that runs inside the MCP server request loop. + * + * Tools: + * push_intent — Canvas pushes a style intent diff to the agent + * resolve_component — Agent queries the component source location by fiber ID + * + * spec: SOURCE-AWARE-CANVAS.md Phase 5 §9 "Agent Bridge" + */ -// ── Tool context (injected per-connection) ──────────────────────────────────── +import { textResult, jsonResult } from './protocol.js'; +import type { ToolResult } from './protocol.js'; -export interface ToolContext { - workspaceId: string; - /** Adapter to the Origin Graph data layer */ - db: { - getDiffsByStatus(workspaceId: string, status: DiffStatus): Promise; - getDiff(id: string): Promise; - getArtboard(id: string): Promise; - getDesignLanguageFile(workspaceId: string): Promise; - updateDiffStatus(id: string, status: DiffStatus, notes?: string): Promise; - }; - /** Adapter to the AI layer (for ask_design_agent) */ - ai: { - answerAgentQuestion(diffId: string, question: string, artboardContext: unknown): Promise; - }; -} - -// ── Tool definition ─────────────────────────────────────────────────────────── +// ── Tool descriptor shape (MCP tools/list schema) ───────────────────────────── export interface McpTool { name: string; description: string; - inputSchema: z.ZodTypeAny; - execute(params: unknown, ctx: ToolContext): Promise | JsonRpcError>; -} - -// ── Rate-limited wrapper ────────────────────────────────────────────────────── - -function rateGuard(workspaceId: string): JsonRpcError | null { - const { allowed } = checkRateLimit(workspaceId); - if (!allowed) { - return { - jsonrpc: '2.0', - id: null, - error: { code: MCP_ERROR.RATE_LIMITED, message: 'Rate limit exceeded: 100 diff exports per hour per workspace' }, - }; - } - return null; -} - -function notFound(id: string, type: string): JsonRpcError { - return { - jsonrpc: '2.0', - id: null, - error: { code: MCP_ERROR.NOT_FOUND, message: `${type} ${id} not found` }, + inputSchema: { + type: 'object'; + properties: Record; + required?: string[]; }; } -// ── Tool: get_pending_diffs ─────────────────────────────────────────────────── +// ── Tool execution context (injected by the MCP server request loop) ────────── -const GetPendingDiffsInput = z.object({ - workspace_id: z.string().uuid(), - artboard_id: z.string().uuid().optional(), -}); - -const getPendingDiffs: McpTool = { - name: 'get_pending_diffs', - description: 'Returns all IntentDiff objects with EXPORTED status for the workspace.', - inputSchema: GetPendingDiffsInput, - async execute(params, ctx) { - const guard = rateGuard(ctx.workspaceId); - if (guard) return guard; - - const { artboard_id } = GetPendingDiffsInput.parse(params); - const diffs = await ctx.db.getDiffsByStatus(ctx.workspaceId, 'EXPORTED'); - - const filtered = artboard_id - ? (diffs as Array<{ artboard_id: string }>).filter(d => d.artboard_id === artboard_id) - : diffs; - - return jsonResult(filtered); - }, -}; - -// ── Tool: get_artboard_context ──────────────────────────────────────────────── - -const GetArtboardContextInput = z.object({ - artboard_id: z.string().uuid(), -}); - -const getArtboardContext: McpTool = { - name: 'get_artboard_context', - description: 'Returns full artboard metadata, component tree, design language file, and before/after screenshots.', - inputSchema: GetArtboardContextInput, - async execute(params, ctx) { - const { artboard_id } = GetArtboardContextInput.parse(params); - const artboard = await ctx.db.getArtboard(artboard_id); - if (artboard == null) return notFound(artboard_id, 'Artboard'); - const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId); - return jsonResult({ artboard, designLanguageFile: dlf }); - }, -}; - -// ── Tool: ask_design_agent ──────────────────────────────────────────────────── - -const AskDesignAgentInput = z.object({ - diff_id: z.string().uuid(), - question: z.string().min(1).max(2000), -}); - -const askDesignAgent: McpTool = { - name: 'ask_design_agent', - description: 'Ask the design AI agent a question about a specific diff. Returns a Claude-generated answer with visual reference.', - inputSchema: AskDesignAgentInput, - async execute(params, ctx) { - const { diff_id, question } = AskDesignAgentInput.parse(params); - - // Fetch the diff first to resolve its artboard_id, then fetch the artboard. - const diff = await ctx.db.getDiff(diff_id); - if (diff == null) return notFound(diff_id, 'IntentDiff'); - const artboardId = (diff as { artboard_id: string }).artboard_id; - const artboard = await ctx.db.getArtboard(artboardId); - if (artboard == null) return notFound(artboardId, 'Artboard'); - - const answer = await ctx.ai.answerAgentQuestion(diff_id, question, artboard); - return textResult(answer); - }, -}; - -// ── Tool: update_diff_status ────────────────────────────────────────────────── - -const UpdateDiffStatusInput = z.object({ - diff_id: z.string().uuid(), - status: z.enum(['IMPLEMENTED', 'BLOCKED']), - notes: z.string().max(1000).optional(), -}); - -const updateDiffStatus: McpTool = { - name: 'update_diff_status', - description: 'Acknowledges implementation or reports a block. Updates the diff status in the Origin Graph.', - inputSchema: UpdateDiffStatusInput, - async execute(params, ctx) { - const { diff_id, status, notes } = UpdateDiffStatusInput.parse(params); - await ctx.db.updateDiffStatus(diff_id, status, notes); - return textResult(`Diff ${diff_id} status updated to ${status}`); - }, -}; - -// ── Tool: get_design_language ───────────────────────────────────────────────── - -const GetDesignLanguageInput = z.object({ - workspace_id: z.string().uuid(), -}); - -const getDesignLanguage: McpTool = { - name: 'get_design_language', - description: 'Returns the team\'s active Design Language File for local validation by the coding agent.', - inputSchema: GetDesignLanguageInput, - async execute(params, ctx) { - // Validate the input even though we use the connection-scoped workspaceId. - // This ensures MCP clients send well-formed requests and the schema is enforced. - GetDesignLanguageInput.parse(params); - const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId); - if (!dlf) return textResult('No design language file configured for this workspace.'); - return jsonResult(dlf); - }, -}; - -// ── Tool registry ───────────────────────────────────────────────────────────── - -export const TOOLS: McpTool[] = [ - getPendingDiffs, - getArtboardContext, - askDesignAgent, - updateDiffStatus, - getDesignLanguage, -]; - -export const TOOL_MAP = new Map(TOOLS.map(t => [t.name, t])); - -/** Returns the MCP-spec JSON Schema listing for all tools. */ -export function getToolList() { - return TOOLS.map(t => ({ - name: t.name, - description: t.description, - inputSchema: { type: 'object', ...zodToJsonSchema(t.inputSchema) }, - })); +export interface ToolContext { + /** Verified workspace ID from the authenticated token. */ + workspaceId: string; + /** The raw parsed params from the JSON-RPC request. */ + params: unknown; + /** + * Optional Supabase server-client for tools that need DB writes. + * Only provided when the route handler calls createServerClient() — tools + * that don't need it (push_intent, resolve_component) ignore it. + */ + db?: { + from: (table: string) => unknown; + }; } -// ── Minimal Zod → JSON Schema ───────────────────────────────────────────────── -// Handles the subset of Zod types used by the 5 tools above. -// Throws at startup if an unsupported type is encountered — fail loud, not silent. +// ── In-process intent store ─────────────────────────────────────────────────── +// Intents are pushed here by the /api/intent Next.js route (via push_intent), +// and drained by connected agents through polling or INTENT_RECEIVED push. +// +// For production multi-instance deployments, replace with a Redis pub/sub or +// Supabase Realtime channel. -function zodToJsonSchema(schema: z.ZodTypeAny): { properties?: Record; required?: string[] } { - if (schema instanceof z.ZodObject) { - const shape = schema.shape as Record; - const properties: Record = {}; - const required: string[] = []; +interface IntentRecord { + intentId: string; + workspaceId: string; + artboardId: string; + componentName: string; + patchJson: string; + strategy: string; + summary: string; + createdAt: number; +} - for (const [key, field] of Object.entries(shape)) { - properties[key] = zodFieldToSchema(field); - if (!(field instanceof z.ZodOptional)) { - required.push(key); - } +const pendingIntents = new Map(); + +/** + * Store an intent pushed by the canvas (called from the /api/intent route). + * Returns the generated intentId. + */ +export function storePendingIntent( + workspaceId: string, + intent: Omit, +): string { + const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const record: IntentRecord = { + intentId, + workspaceId, + ...intent, + createdAt: Date.now(), + }; + const queue = pendingIntents.get(workspaceId) ?? []; + queue.push(record); + pendingIntents.set(workspaceId, queue); + return intentId; +} + +/** + * Drain all pending intents for a workspace (called by push_intent tool handler + * or by the agent when polling). + */ +export function drainPendingIntents(workspaceId: string): IntentRecord[] { + const queue = pendingIntents.get(workspaceId) ?? []; + pendingIntents.delete(workspaceId); + return queue; +} + +// ── CLI indexer registry ────────────────────────────────────────────────────── +// Maps workspaceId → { url, expiresAt } (registered via /register-indexer). +// +// TTL policy (spec Phase 5 §8.3): +// • Default TTL: 300 s (5 min) +// • CLI sends heartbeat POST every 120 s to refresh the TTL +// • Registration is considered expired after 360 s (3× heartbeat interval) +// +// The GC sweep runs on every registration and lookup to avoid a timer leak +// in serverless/edge environments where setInterval may not fire. + +interface IndexerEntry { + url: string; + expiresAt: number; // ms since epoch +} + +const indexerRegistry = new Map(); + +/** Evict all entries whose TTL has expired. */ +function gcIndexerRegistry(): void { + const now = Date.now(); + for (const [wid, entry] of indexerRegistry) { + if (entry.expiresAt < now) indexerRegistry.delete(wid); + } +} + +/** + * Register (or refresh) a CLI indexer for a workspace. + * Security: the caller MUST validate that `url` is a localhost URL before + * calling this function (enforced by the /register-indexer API route). + * + * @param workspaceId Verified workspace ID + * @param url Indexer URL — must be localhost (caller-validated) + * @param ttlSeconds TTL in seconds (default: 300 s / 5 min) + */ +export function registerIndexer(workspaceId: string, url: string, ttlSeconds = 300): void { + gcIndexerRegistry(); + indexerRegistry.set(workspaceId, { url, expiresAt: Date.now() + ttlSeconds * 1000 }); +} + +/** + * Returns the registered indexer URL for a workspace, or undefined if not + * registered or if the TTL has expired. + */ +export function getIndexerUrl(workspaceId: string): string | undefined { + gcIndexerRegistry(); + const entry = indexerRegistry.get(workspaceId); + if (!entry) return undefined; + if (entry.expiresAt < Date.now()) { + indexerRegistry.delete(workspaceId); + return undefined; + } + return entry.url; +} + +/** + * Refresh the TTL for an already-registered indexer (heartbeat endpoint). + * Returns false if no entry exists for the workspace (caller should 404). + */ +export function heartbeatIndexer(workspaceId: string, ttlSeconds = 300): boolean { + const entry = indexerRegistry.get(workspaceId); + if (!entry || entry.expiresAt < Date.now()) return false; + entry.expiresAt = Date.now() + ttlSeconds * 1000; + return true; +} + +// ── Tool definitions ────────────────────────────────────────────────────────── + +const PUSH_INTENT_TOOL: McpTool = { + name: 'push_intent', + description: + 'Receive a design intent diff from the Origin canvas. The diff describes style or prop changes ' + + 'made by the designer that should be applied to the source code. Call this when Origin notifies ' + + 'you of pending changes. Returns the list of pending intents for this workspace.', + inputSchema: { + type: 'object', + properties: { + workspace_id: { + type: 'string', + description: 'The workspace ID (must match the authenticated token).', + }, + }, + required: ['workspace_id'], + }, +}; + +const RESOLVE_COMPONENT_TOOL: McpTool = { + name: 'resolve_component', + description: + 'Resolve the source file location for a component identified by its fiber node ID. ' + + 'Returns the file path and line number where the component is defined in the codebase, ' + + 'enabling the agent to navigate directly to the component source.', + inputSchema: { + type: 'object', + properties: { + artboard_id: { + type: 'string', + description: 'The artboard that contains the component.', + }, + node_id: { + type: 'string', + description: 'The fiber node ID of the component (from SelectionOverlay / fiber tree).', + }, + component_name: { + type: 'string', + description: 'Display name of the component (used as a fallback hint).', + }, + }, + required: ['artboard_id', 'node_id'], + }, +}; + +const UPDATE_DIFF_STATUS_TOOL: McpTool = { + name: 'update_diff_status', + description: + 'Update the status of a design intent diff after attempting to apply it. ' + + 'Call with status "IMPLEMENTED" after successfully applying the diff to the source code. ' + + 'Call with status "BLOCKED" and a reason string if the diff could not be applied — ' + + 'the reason is shown to the designer so they can resolve the conflict manually. ' + + 'Valid statuses: "IMPLEMENTED" | "BLOCKED".', + inputSchema: { + type: 'object', + properties: { + intent_id: { + type: 'string', + description: 'The intentId received in the INTENT_RECEIVED message or push_intent response.', + }, + status: { + type: 'string', + enum: ['IMPLEMENTED', 'BLOCKED'], + description: '"IMPLEMENTED" if the diff was applied successfully; "BLOCKED" if it could not be applied.', + }, + reason: { + type: 'string', + description: + 'Required when status is "BLOCKED". Describe why the diff could not be applied — ' + + 'e.g. "Component not found in file", "File is read-only", "Diff conflicts with current state".', + }, + }, + required: ['intent_id', 'status'], + }, +}; + +// ── Tool map (name → descriptor) ────────────────────────────────────────────── + +export const TOOL_MAP: Record = { + push_intent: PUSH_INTENT_TOOL, + resolve_component: RESOLVE_COMPONENT_TOOL, + update_diff_status: UPDATE_DIFF_STATUS_TOOL, +}; + +export const TOOLS: McpTool[] = Object.values(TOOL_MAP); + +export function getToolList(): McpTool[] { + return TOOLS; +} + +// ── Tool handlers ───────────────────────────────────────────────────────────── + +type Params = Record; + +async function handlePushIntent(ctx: ToolContext): Promise> { + const params = ctx.params as Params; + const wid = typeof params['workspace_id'] === 'string' ? params['workspace_id'] : ctx.workspaceId; + + if (wid !== ctx.workspaceId) { + return textResult('Error: workspace_id does not match authenticated workspace.'); + } + + const intents = drainPendingIntents(wid); + + if (intents.length === 0) { + return textResult('No pending design intents for this workspace.'); + } + + const summary = intents.map((intent) => { + const patches = JSON.parse(intent.patchJson) as Array<{ property: string; value: string; previousValue?: string }>; + const lines = patches.map( + (p) => ` • ${p.property}: ${p.previousValue ?? '(unknown)'} → ${p.value}`, + ); + return [ + `Intent ${intent.intentId} — ${intent.componentName} (${intent.strategy})`, + intent.summary, + ...lines, + ` artboardId: ${intent.artboardId}`, + ].join('\n'); + }); + + return textResult( + `${intents.length} pending design intent${intents.length !== 1 ? 's' : ''}:\n\n${summary.join('\n\n')}`, + ); +} + +async function handleResolveComponent(ctx: ToolContext): Promise> { + const params = ctx.params as Params; + const nodeId = typeof params['node_id'] === 'string' ? params['node_id'] : null; + const artboardId = typeof params['artboard_id'] === 'string' ? params['artboard_id'] : null; + const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : 'Component'; + + if (!nodeId || !artboardId) { + return textResult('Error: artboard_id and node_id are required.'); + } + + // Check if a CLI indexer is registered for this workspace + const indexerUrl = getIndexerUrl(ctx.workspaceId); + if (!indexerUrl) { + return textResult( + `No CLI indexer registered for workspace ${ctx.workspaceId}. ` + + 'Run npx @originmain/cli dev to enable component resolution.', + ); + } + + // Proxy the resolution request to the CLI indexer + try { + const url = new URL('/resolve-component', indexerUrl); + url.searchParams.set('nodeId', nodeId); + url.searchParams.set('artboardId', artboardId); + url.searchParams.set('componentName', componentName); + + const res = await fetch(url.toString(), { + signal: AbortSignal.timeout(5000), + }); + + if (!res.ok) { + return textResult(`Indexer returned ${res.status}: ${await res.text()}`); } - return { properties, ...(required.length > 0 ? { required } : {}) }; + const data = (await res.json()) as { filePath?: string; lineNumber?: number; column?: number }; + if (!data.filePath) { + return textResult(`Component ${componentName} not found in index.`); + } + + return jsonResult({ + filePath: data.filePath, + lineNumber: data.lineNumber ?? 1, + column: data.column ?? 1, + nodeId, + artboardId, + componentName, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return textResult(`Failed to contact CLI indexer: ${msg}`); } - throw new Error(`zodToJsonSchema: unsupported top-level type ${schema.constructor.name}`); } -function zodFieldToSchema(field: z.ZodTypeAny): unknown { - if (field instanceof z.ZodOptional) return zodFieldToSchema(field.unwrap()); - if (field instanceof z.ZodString) return { type: 'string' }; - if (field instanceof z.ZodEnum) return { type: 'string', enum: field.options as string[] }; - if (field instanceof z.ZodNumber) return { type: 'number' }; - if (field instanceof z.ZodBoolean) return { type: 'boolean' }; - throw new Error(`zodToJsonSchema: unsupported field type ${field.constructor.name}`); +async function handleUpdateDiffStatus(ctx: ToolContext): Promise> { + const params = ctx.params as Params; + const intentId = typeof params['intent_id'] === 'string' ? params['intent_id'] : null; + const status = typeof params['status'] === 'string' ? params['status'] : null; + const reason = typeof params['reason'] === 'string' ? params['reason'] : null; + + if (!intentId || !status) { + return textResult('Error: intent_id and status are required.'); + } + + if (status !== 'IMPLEMENTED' && status !== 'BLOCKED') { + return textResult('Error: status must be "IMPLEMENTED" or "BLOCKED".'); + } + + if (status === 'BLOCKED' && !reason) { + return textResult('Error: reason is required when status is "BLOCKED".'); + } + + if (!ctx.db) { + // Fallback: no DB client available — just acknowledge. + return textResult(`update_diff_status acknowledged: ${intentId} → ${status}${reason ? ` (${reason})` : ''}`); + } + + try { + const updatePayload: Record = { + status, + updated_at: new Date().toISOString(), + }; + if (reason) updatePayload['blocked_reason'] = reason; + + // db is typed minimally; cast to any so the Supabase query chain compiles. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error } = await ((ctx.db.from('intent_diffs') as any) + .update(updatePayload) + .eq('id', intentId) + .select() + .single() as Promise<{ data: { id: string; status: string } | null; error: { message: string } | null }>); + + if (error) return textResult(`Error updating diff status: ${error.message}`); + if (!data) return textResult(`Error: intent ${intentId} not found.`); + + return textResult( + `Intent ${intentId} status updated to ${status}${reason ? `: ${reason}` : ''}.`, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return textResult(`Failed to update diff status: ${msg}`); + } +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +export async function dispatchTool(name: string, ctx: ToolContext): Promise> { + switch (name) { + case 'push_intent': + return handlePushIntent(ctx); + case 'resolve_component': + return handleResolveComponent(ctx); + case 'update_diff_status': + return handleUpdateDiffStatus(ctx); + default: + return textResult(`Unknown tool: ${name}`); + } } diff --git a/packages/app/src/app/api/agent-bridge/register-indexer/route.ts b/packages/app/src/app/api/agent-bridge/register-indexer/route.ts new file mode 100644 index 0000000..68f43cd --- /dev/null +++ b/packages/app/src/app/api/agent-bridge/register-indexer/route.ts @@ -0,0 +1,118 @@ +// POST /api/agent-bridge/register-indexer +// POST /api/agent-bridge/register-indexer/heartbeat (same handler, path checked below) +// +// Called by the CLI's `originmain dev` command to register the local AST indexer +// so the Agent Bridge can proxy component-resolution requests to it. +// +// Security (spec Phase 5 §8.3): +// • Bearer auth: same workspace-token mechanism as the main MCP endpoint. +// • indexerUrl MUST be localhost / 127.0.0.1 — external URLs are rejected to +// prevent the Agent Bridge from being used as an SSRF relay. +// • TTL: 300 s default; heartbeat POST refreshes it every 120 s. +// • Agent Bridge evicts registrations with no heartbeat after 360 s. +// +// Heartbeat endpoint: POST /api/agent-bridge/register-indexer/heartbeat +// Body: { workspaceToken: string } +// Returns 200 on success, 404 if the workspace has no active registration. + +import { NextRequest, NextResponse } from 'next/server'; +import { verifyWorkspaceToken, registerIndexer, heartbeatIndexer } from '@originmain/agent-bridge'; + +const TTL_SECONDS = 300; // 5 minutes per spec + +/** + * Returns true when `url` resolves to the local machine (localhost / loopback). + * We block any non-localhost indexerUrl to prevent SSRF. + */ +function isLocalhostUrl(raw: string): boolean { + try { + const parsed = new URL(raw); + return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '::1'; + } catch { + return false; + } +} + +export async function POST(req: NextRequest) { + // ── Auth ──────────────────────────────────────────────────────────────────── + // Accept the workspace token either in the Authorization header (CLI standard) + // or in the request body as `workspaceToken` (heartbeat convenience). + let token: string | null = null; + const authHeader = req.headers.get('authorization') ?? ''; + if (authHeader.startsWith('Bearer ')) { + token = authHeader.slice(7); + } + + let body: Record = {}; + try { + body = (await req.json()) as Record; + } catch { + // Body is optional for heartbeat (token may come from header only) + } + + if (!token && typeof body['workspaceToken'] === 'string') { + token = body['workspaceToken']; + } + + if (!token) { + return NextResponse.json( + { error: 'Missing Bearer token or workspaceToken in body' }, + { status: 401 }, + ); + } + + const workspaceToken = verifyWorkspaceToken(token); + if (!workspaceToken) { + return NextResponse.json( + { error: 'Invalid or expired workspace token' }, + { status: 401 }, + ); + } + + // ── Heartbeat path ────────────────────────────────────────────────────────── + // The CLI sends a heartbeat POST to the same URL with no indexerUrl in the body. + // We detect this by the absence of indexerUrl and refresh the TTL instead. + if (!body['indexerUrl']) { + const refreshed = heartbeatIndexer(workspaceToken.workspaceId, TTL_SECONDS); + if (!refreshed) { + return NextResponse.json( + { error: 'No active registration for this workspace — re-register first' }, + { status: 404 }, + ); + } + return NextResponse.json({ + ok: true, + action: 'heartbeat', + workspaceId: workspaceToken.workspaceId, + ttlSeconds: TTL_SECONDS, + }); + } + + // ── Registration path ─────────────────────────────────────────────────────── + const indexerUrl = typeof body['indexerUrl'] === 'string' ? body['indexerUrl'] : null; + + if (!indexerUrl) { + return NextResponse.json({ error: '`indexerUrl` is required' }, { status: 400 }); + } + + // Security: only localhost URLs are allowed — prevent SSRF + if (!isLocalhostUrl(indexerUrl)) { + return NextResponse.json( + { error: 'indexerUrl must be a localhost URL (e.g. http://localhost:4171)' }, + { status: 400 }, + ); + } + + const ttl = typeof body['ttl'] === 'number' ? Math.min(body['ttl'], 600) : TTL_SECONDS; + + registerIndexer(workspaceToken.workspaceId, indexerUrl, ttl); + + return NextResponse.json({ + ok: true, + action: 'registered', + workspaceId: workspaceToken.workspaceId, + indexerUrl, + ttlSeconds: ttl, + heartbeatIntervalSeconds: 120, + }); +} diff --git a/packages/app/src/app/api/agent-bridge/route.ts b/packages/app/src/app/api/agent-bridge/route.ts index 1ce3ed8..d079493 100644 --- a/packages/app/src/app/api/agent-bridge/route.ts +++ b/packages/app/src/app/api/agent-bridge/route.ts @@ -3,16 +3,8 @@ // Authentication: Bearer (HMAC-SHA256, issued by issueWorkspaceToken). import { NextRequest, NextResponse } from 'next/server'; -import { verifyWorkspaceToken, TOOL_MAP, getToolList } from '@originmain/agent-bridge'; +import { verifyWorkspaceToken, TOOL_MAP, getToolList, dispatchTool } from '@originmain/agent-bridge'; import type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge'; -import { - getDiffsByStatus, - getDiff, - getArtboard, - getActiveDesignLanguageFile, - updateDiffStatus, -} from '@originmain/origin-graph'; -import { AIGateway, answerAgentQuestion } from '@originmain/ai-layer'; import { serverClient } from '@/lib/supabase'; export async function POST(req: NextRequest) { @@ -54,8 +46,7 @@ export async function POST(req: NextRequest) { } // ── Dispatch ──────────────────────────────────────────────────────────────── - const tool = TOOL_MAP.get(body.method); - if (!tool) { + if (!TOOL_MAP[body.method]) { return NextResponse.json({ jsonrpc: '2.0', id: body.id, @@ -63,31 +54,16 @@ export async function POST(req: NextRequest) { }); } - const db = serverClient(); - const { workspaceId } = workspaceToken; - const ctx: ToolContext = { - workspaceId, - db: { - getDiffsByStatus: (wsId, status) => getDiffsByStatus(db, wsId, status), - getDiff: (id) => getDiff(db, id), - getArtboard: (id) => getArtboard(db, id), - getDesignLanguageFile: (wsId) => getActiveDesignLanguageFile(db, wsId), - updateDiffStatus: (id, status, notes) => - updateDiffStatus(db, id, status, notes).then(() => undefined), - }, - ai: { - answerAgentQuestion: (diffId: string, question: string, artboardContext: unknown) => - answerAgentQuestion(new AIGateway(), { - diffId, - question, - artboardContextJson: JSON.stringify(artboardContext), - }).then(r => r.answer), - }, + workspaceId: workspaceToken.workspaceId, + params: body.params ?? {}, + // Pass the server-side Supabase client for tools that need DB writes + // (e.g. update_diff_status writes blocked_reason to intent_diffs). + db: serverClient() as unknown as NonNullable, }; try { - const result = await tool.execute(body.params ?? {}, ctx); + const result = await dispatchTool(body.method, ctx); return NextResponse.json({ jsonrpc: '2.0', id: body.id, result }); } catch (err) { const message = err instanceof Error ? err.message : 'Internal error'; diff --git a/packages/app/src/app/api/artboards/thumbnail/route.ts b/packages/app/src/app/api/artboards/thumbnail/route.ts new file mode 100644 index 0000000..3120de8 --- /dev/null +++ b/packages/app/src/app/api/artboards/thumbnail/route.ts @@ -0,0 +1,82 @@ +// POST /api/artboards/thumbnail +// +// Accepts a base64 JPEG data URL captured by html2canvas inside an artboard +// iframe, uploads it to Supabase Storage, and persists the public URL in the +// artboards.thumbnail_url column. +// +// Request body: { artboardId: string; workspaceId: string; dataUrl: string } +// Response: { publicUrl: string } +// +// Storage path: artboard-thumbnails/{workspaceId}/{artboardId}.jpg +// Bucket policy: public read, authenticated write. +// +// spec: SOURCE-AWARE-CANVAS.md Phase 0 §3.6 "Thumbnail capture" + +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { serverClient } from '@/lib/supabase'; +import { updateArtboard } from '@originmain/origin-graph'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +const BUCKET = 'artboard-thumbnails'; +const MAX_DATA_URL = 5 * 1024 * 1024; // 5 MB safety cap — rejects obviously corrupted payloads + +export async function POST(req: NextRequest) { + const { userId } = await auth(); + if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + let body: { artboardId?: string; workspaceId?: string; dataUrl?: string }; + try { + body = (await req.json()) as typeof body; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { artboardId, workspaceId, dataUrl } = body; + if (!artboardId || !workspaceId || !dataUrl) { + return NextResponse.json({ error: 'artboardId, workspaceId, and dataUrl are required' }, { status: 400 }); + } + + if (dataUrl.length > MAX_DATA_URL) { + return NextResponse.json({ error: 'dataUrl exceeds 5 MB limit' }, { status: 413 }); + } + + // Strip the `data:image/jpeg;base64,` prefix and decode to bytes + const base64 = dataUrl.replace(/^data:image\/[a-z]+;base64,/, ''); + let imageBytes: Buffer; + try { + imageBytes = Buffer.from(base64, 'base64'); + } catch { + return NextResponse.json({ error: 'Invalid base64 data URL' }, { status: 400 }); + } + + const db = serverClient() as unknown as SupabaseClient; + const storagePath = `${workspaceId}/${artboardId}.jpg`; + + // ── Upload to Supabase Storage ──────────────────────────────────────────── + const { error: uploadError } = await db.storage + .from(BUCKET) + .upload(storagePath, imageBytes, { + contentType: 'image/jpeg', + upsert: true, // overwrite on repeat capture + }); + + if (uploadError) { + return NextResponse.json({ error: `Storage upload failed: ${uploadError.message}` }, { status: 500 }); + } + + // ── Get public URL ──────────────────────────────────────────────────────── + const { data: urlData } = db.storage.from(BUCKET).getPublicUrl(storagePath); + const publicUrl = urlData.publicUrl; + + // ── Persist to artboards.thumbnail_url ─────────────────────────────────── + try { + await updateArtboard(serverClient(), artboardId, { thumbnail_url: publicUrl }); + } catch (err) { + // Non-fatal: the in-memory data URL still works for this session + const msg = err instanceof Error ? err.message : String(err); + console.warn(`[thumbnail] DB update failed for ${artboardId}: ${msg}`); + } + + return NextResponse.json({ publicUrl }); +} diff --git a/packages/app/src/app/api/cli-auth/route.ts b/packages/app/src/app/api/cli-auth/route.ts new file mode 100644 index 0000000..cef2f93 --- /dev/null +++ b/packages/app/src/app/api/cli-auth/route.ts @@ -0,0 +1,118 @@ +// GET /api/cli-auth?callback= +// +// Browser-initiated CLI auth endpoint. The user arrives here after `originmain +// login` opens their browser. Authenticates via Clerk, issues a HMAC workspace +// token, and redirects to the CLI's local callback server with the credentials. +// +// Query params: +// callback — The local CLI callback URL (must be localhost). +// workspace_id — Optional. If omitted, uses the user's first workspace. +// +// Redirect target: ?token=X&workspaceId=Y&bridgeUrl=Z +// or ?error= on failure. +// +// Security: +// • callback must be a localhost URL — external URLs are rejected. +// • Requires Clerk authentication; unauthenticated users are redirected to sign-in. +// +// spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6 + +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { serverClient } from '@/lib/supabase'; +import { issueWorkspaceToken } from '@originmain/agent-bridge'; + +const DEFAULT_BRIDGE_URL = process.env['ORIGINMAIN_BRIDGE_URL'] ?? 'http://localhost:4172'; +const APP_URL = process.env['NEXT_PUBLIC_APP_URL'] ?? 'http://localhost:3000'; + +/** Only localhost callback URLs are accepted — prevents open-redirect attacks. */ +function isLocalhostCallback(raw: string): boolean { + try { + const u = new URL(raw); + return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1'; + } catch { + return false; + } +} + +function errorRedirect(callbackUrl: string, message: string): NextResponse { + const target = new URL(callbackUrl); + target.searchParams.set('error', message); + return NextResponse.redirect(target.toString()); +} + +export async function GET(req: NextRequest): Promise { + const { searchParams } = req.nextUrl; + const callbackUrl = searchParams.get('callback'); + const workspaceIdParam = searchParams.get('workspace_id'); + + // ── Validate callback URL ───────────────────────────────────────────────── + if (!callbackUrl) { + return NextResponse.json({ error: '`callback` query param is required' }, { status: 400 }); + } + + if (!isLocalhostCallback(callbackUrl)) { + return NextResponse.json( + { error: '`callback` must be a localhost URL' }, + { status: 400 }, + ); + } + + // ── Require authentication ──────────────────────────────────────────────── + const { userId } = await auth(); + if (!userId) { + // Redirect to sign-in, then back here after login + const signInUrl = new URL('/sign-in', APP_URL); + signInUrl.searchParams.set('redirect_url', req.nextUrl.toString()); + return NextResponse.redirect(signInUrl.toString()); + } + + // ── Resolve workspace ───────────────────────────────────────────────────── + const db = serverClient(); + let workspaceId = workspaceIdParam; + + if (!workspaceId) { + // Use the user's first workspace membership + const { data: member } = await db + .from('team_members') + .select('workspace_id') + .eq('user_id', userId) + .limit(1) + .single() as unknown as { data: { workspace_id: string } | null; error: unknown }; + + if (!member) { + return errorRedirect(callbackUrl, 'No workspace found for this account. Create a workspace at ' + APP_URL); + } + workspaceId = member.workspace_id; + } else { + // Verify the user is a member of the requested workspace + const { data: member } = await db + .from('team_members') + .select('id') + .eq('workspace_id', workspaceId) + .eq('user_id', userId) + .limit(1) + .single(); + + if (!member) { + return errorRedirect(callbackUrl, `You are not a member of workspace ${workspaceId}`); + } + } + + // ── Issue workspace token ───────────────────────────────────────────────── + let token: string; + try { + token = issueWorkspaceToken(workspaceId, 'GENERIC'); + } catch (err) { + const msg = err instanceof Error ? err.message : 'Token generation failed'; + return errorRedirect(callbackUrl, msg); + } + + // ── Redirect to CLI callback ────────────────────────────────────────────── + const target = new URL(callbackUrl); + target.searchParams.set('token', token); + target.searchParams.set('workspaceId', workspaceId); + target.searchParams.set('bridgeUrl', DEFAULT_BRIDGE_URL); + + return NextResponse.redirect(target.toString()); +} diff --git a/packages/app/src/app/api/design-language/fetch/route.ts b/packages/app/src/app/api/design-language/fetch/route.ts new file mode 100644 index 0000000..fecb7db --- /dev/null +++ b/packages/app/src/app/api/design-language/fetch/route.ts @@ -0,0 +1,143 @@ +// POST /api/design-language/fetch +// Server-side CORS proxy for fetching user-supplied design token JSON files. +// +// The browser cannot directly fetch a token file hosted at an arbitrary URL due +// to CORS restrictions. This route performs the fetch server-side and returns +// the raw JSON text so the client can run the standard validation pipeline. +// +// Security mitigations: +// 1. Auth: requires a valid Clerk session — unauthenticated callers are rejected. +// 2. HTTPS only: rejects http:// URLs to prevent plaintext credential exposure. +// 3. Private-IP block: rejects requests to localhost, RFC-1918 ranges, and +// link-local addresses to prevent SSRF (Server-Side Request Forgery). +// 4. Size cap: response bodies larger than 1 MB are rejected. +// 5. Content-Type guard: the upstream response must be JSON-like. +// +// spec: SOURCE-AWARE-CANVAS §3 Phase 6 — "Fetch from URL" token import flow + +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; + +// Maximum allowed response body size (1 MB). Token files are never this large; +// the cap protects against slow-loris and accidental large-file fetches. +const MAX_BODY_BYTES = 1_048_576; + +/** + * Returns true when the URL hostname resolves to a private / loopback address + * that should never be reachable from a proxied server-side request. + * We guard against SSRF by rejecting hostnames that literally look private — + * a full DNS-resolution check is not performed here because it would require + * an extra async lookup and still be racy. + */ +function isPrivateHost(hostname: string): boolean { + // Loopback + if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true; + // RFC-1918 private ranges (textual prefix match is sufficient for common cases) + if (/^10\./.test(hostname)) return true; // 10.0.0.0/8 + if (/^192\.168\./.test(hostname)) return true; // 192.168.0.0/16 + if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true; // 172.16.0.0/12 + // Link-local + if (/^169\.254\./.test(hostname)) return true; + if (/^fe80:/i.test(hostname)) return true; + return false; +} + +export async function POST(req: NextRequest) { + // ── Auth ─────────────────────────────────────────────────────────────────── + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + // ── Parse request body ───────────────────────────────────────────────────── + let body: { url?: string }; + try { + body = await req.json() as { url?: string }; + } catch { + return NextResponse.json({ error: 'Request body must be JSON' }, { status: 400 }); + } + + const { url } = body; + if (!url || typeof url !== 'string') { + return NextResponse.json({ error: '`url` field is required' }, { status: 400 }); + } + + // ── URL validation ───────────────────────────────────────────────────────── + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return NextResponse.json({ error: 'Invalid URL' }, { status: 400 }); + } + + if (parsed.protocol !== 'https:') { + return NextResponse.json( + { error: 'Only HTTPS URLs are supported' }, + { status: 400 }, + ); + } + + if (isPrivateHost(parsed.hostname)) { + return NextResponse.json( + { error: 'Requests to private or loopback addresses are not allowed' }, + { status: 400 }, + ); + } + + // ── Proxy fetch ──────────────────────────────────────────────────────────── + let upstream: Response; + try { + upstream = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json, text/plain, */*', + 'User-Agent': 'Originmain-DLF-Proxy/1.0', + }, + // 10-second timeout via AbortSignal + signal: AbortSignal.timeout(10_000), + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return NextResponse.json({ error: `Fetch failed: ${msg}` }, { status: 502 }); + } + + if (!upstream.ok) { + return NextResponse.json( + { error: `Upstream returned ${upstream.status} ${upstream.statusText}` }, + { status: 502 }, + ); + } + + // ── Content-Type guard ──────────────────────────────────────────────────── + const contentType = upstream.headers.get('content-type') ?? ''; + if (!contentType.includes('json') && !contentType.includes('text')) { + return NextResponse.json( + { error: 'Upstream response is not JSON or plain text' }, + { status: 415 }, + ); + } + + // ── Size cap ────────────────────────────────────────────────────────────── + const bytes = await upstream.arrayBuffer(); + if (bytes.byteLength > MAX_BODY_BYTES) { + return NextResponse.json( + { error: `Response too large (max ${MAX_BODY_BYTES / 1024} KB)` }, + { status: 413 }, + ); + } + + const text = new TextDecoder().decode(bytes); + + // Validate that the body parses as JSON before forwarding — the client + // expects valid JSON, not a redirect page or HTML error body. + try { + JSON.parse(text); + } catch { + return NextResponse.json( + { error: 'Upstream response is not valid JSON' }, + { status: 422 }, + ); + } + + return NextResponse.json({ json: text }, { status: 200 }); +} diff --git a/packages/app/src/app/api/intent/route.ts b/packages/app/src/app/api/intent/route.ts new file mode 100644 index 0000000..0e2bdd7 --- /dev/null +++ b/packages/app/src/app/api/intent/route.ts @@ -0,0 +1,78 @@ +/** + * POST /api/intent + * + * Canvas → Agent Bridge intent push endpoint (spec Phase 4 §8.4). + * + * The canvas calls this when the designer exports a style diff. This route: + * 1. Validates the authenticated user and payload + * 2. Stores the intent in the agent-bridge pending queue + * 3. Returns the generated intentId so the canvas can track status + * + * The connected IDE agent drains the queue the next time it calls push_intent + * (or receives an INTENT_RECEIVED WebSocket push in Phase 5+). + */ + +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; +import { storePendingIntent } from '@originmain/agent-bridge'; + +interface IntentPayload { + /** Supabase workspace ID. */ + workspaceId: string; + /** The artboard the diff came from. */ + artboardId: string; + /** Display name of the component that was edited. */ + componentName: string; + /** JSON-serialised StylePatch[] from diff-generator.ts */ + patchJson: string; + /** One of: 'css' | 'prop' | 'tailwind' */ + strategy: string; + /** Optional AI-generated summary sentence. */ + summary?: string; +} + +export async function POST(req: NextRequest) { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + let body: IntentPayload; + try { + body = await req.json() as IntentPayload; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { workspaceId, artboardId, componentName, patchJson, strategy, summary } = body; + + if (!workspaceId || !artboardId || !componentName || !patchJson || !strategy) { + return NextResponse.json( + { error: 'Missing required fields: workspaceId, artboardId, componentName, patchJson, strategy' }, + { status: 400 }, + ); + } + + const validStrategies = ['css', 'prop', 'tailwind']; + if (!validStrategies.includes(strategy)) { + return NextResponse.json( + { error: `Invalid strategy. Must be one of: ${validStrategies.join(', ')}` }, + { status: 400 }, + ); + } + + try { + const intentId = storePendingIntent(workspaceId, { + artboardId, + componentName, + patchJson, + strategy, + summary: summary ?? '', + }); + + return NextResponse.json({ intentId, status: 'EXPORTED' }, { status: 201 }); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/packages/app/src/app/settings/design-language/page.tsx b/packages/app/src/app/settings/design-language/page.tsx new file mode 100644 index 0000000..6ba042e --- /dev/null +++ b/packages/app/src/app/settings/design-language/page.tsx @@ -0,0 +1,493 @@ +'use client'; + +/** + * /settings/design-language — Phase 6 + * + * Design Language Settings page. Allows workspace admins to: + * 1. Upload a token file (Style Dictionary / W3C DTCG / flat CSS vars) + * 2. Validate the parsed token set before activating + * 3. Activate the tokens workspace-wide (stored in Supabase + canvas store) + * 4. View version history of previously uploaded token files + * + * spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.5 + */ + +import { useState, useCallback, useRef } from 'react'; +import { useCanvas } from '@/store/canvas'; +import type { DesignToken } from '@/store/canvas.types'; + +// ── Upload & validation states ───────────────────────────────────────────────── + +type ParseState = + | { status: 'idle' } + | { status: 'parsing' } + | { status: 'parsed'; tokens: DesignToken[]; filename: string } + | { status: 'error'; message: string }; + +type ActivateState = 'idle' | 'activating' | 'done' | 'error'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +async function parseTokenFileClient(jsonText: string): Promise { + const { parseTokenFileJson } = await import('@originmain/design-language'); + return parseTokenFileJson(jsonText) as DesignToken[]; +} + +function groupByCategory(tokens: DesignToken[]): Record { + const groups: Record = {}; + for (const t of tokens) { + const g = t.group; + (groups[g] ??= []).push(t); + } + return groups; +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export default function DesignLanguagePage() { + const { designLanguageTokens, setDesignLanguageTokens } = useCanvas(); + const [parseState, setParseState] = useState({ status: 'idle' }); + const [activateState, setActivateState] = useState('idle'); + const [dragOver, setDragOver] = useState(false); + const fileInputRef = useRef(null); + + // ── File handling ─────────────────────────────────────────────────────────── + + const processFile = useCallback(async (file: File) => { + if (!file.name.endsWith('.json')) { + setParseState({ status: 'error', message: 'Only .json token files are supported.' }); + return; + } + + setParseState({ status: 'parsing' }); + try { + const text = await file.text(); + const tokens = await parseTokenFileClient(text); + if (tokens.length === 0) { + setParseState({ status: 'error', message: 'No tokens found. Check the file format.' }); + return; + } + setParseState({ status: 'parsed', tokens, filename: file.name }); + setActivateState('idle'); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + setParseState({ status: 'error', message: `Parse failed: ${msg}` }); + } + }, []); + + const handleFileChange = useCallback((e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) void processFile(file); + // Reset so the same file can be re-uploaded + e.target.value = ''; + }, [processFile]); + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files?.[0]; + if (file) void processFile(file); + }, [processFile]); + + // ── Activation ────────────────────────────────────────────────────────────── + + const activate = useCallback(() => { + if (parseState.status !== 'parsed') return; + setActivateState('activating'); + try { + setDesignLanguageTokens(parseState.tokens); + setActivateState('done'); + } catch { + setActivateState('error'); + } + }, [parseState, setDesignLanguageTokens]); + + const deactivate = useCallback(() => { + setDesignLanguageTokens(null); + setParseState({ status: 'idle' }); + setActivateState('idle'); + }, [setDesignLanguageTokens]); + + // ── Render ────────────────────────────────────────────────────────────────── + + return ( +
+ {/* Header */} +
+

+ Design Language +

+

+ Upload a design token file to enable token-aware inputs and constraint checking across all artboards. + Supports Style Dictionary, W3C DTCG, and flat CSS variable formats. +

+
+ + {/* Active token set status */} + {designLanguageTokens && ( + + )} + + {/* Upload area */} + setDragOver(true)} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + onClick={() => fileInputRef.current?.click()} + /> + + + {/* Parse state */} + {parseState.status === 'parsing' && ( + + + Parsing token file… + + )} + + {parseState.status === 'error' && ( + + ⚠ {parseState.message} + + )} + + {parseState.status === 'parsed' && ( + + )} + + {/* Format reference */} + +
+ ); +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function ActiveTokenBanner({ tokens, onDeactivate }: { tokens: DesignToken[]; onDeactivate: () => void }) { + const groups = groupByCategory(tokens); + return ( +
+
+
+
+ {tokens.length} tokens active +
+
+ {Object.entries(groups).map(([g, ts]) => `${g}:${ts.length}`).join(' · ')} +
+
+ +
+ ); +} + +function UploadZone({ + dragOver, + onDragOver, + onDragLeave, + onDrop, + onClick, +}: { + dragOver: boolean; + onDragOver: () => void; + onDragLeave: () => void; + onDrop: (e: React.DragEvent) => void; + onClick: () => void; +}) { + return ( +
{ e.preventDefault(); onDragOver(); }} + onDragLeave={onDragLeave} + onDrop={onDrop} + style={{ + border: `2px dashed ${dragOver ? '#3385FF' : 'rgba(255,255,255,0.15)'}`, + borderRadius: 12, + padding: '36px 24px', + textAlign: 'center', + cursor: 'pointer', + background: dragOver ? 'rgba(51,133,255,0.06)' : 'rgba(255,255,255,0.02)', + transition: 'border-color 0.15s, background 0.15s', + marginBottom: 24, + }} + > +
📂
+
+ Drop token file here or click to browse +
+
+ .json — Style Dictionary · W3C DTCG · Flat CSS vars +
+
+ ); +} + +function StatusCard({ children, color, border }: { children: React.ReactNode; color?: string; border?: string }) { + return ( +
+ {children} +
+ ); +} + +function Spinner() { + return ( +
+ ); +} + +function TokenPreview({ + tokens, + filename, + activateState, + onActivate, +}: { + tokens: DesignToken[]; + filename: string; + activateState: ActivateState; + onActivate: () => void; +}) { + const groups = groupByCategory(tokens); + const [expanded, setExpanded] = useState(null); + + return ( +
+ {/* Preview header */} +
+ + ✓ Parsed + + + {filename} + + + {tokens.length} tokens · {Object.keys(groups).length} groups + +
+ + {/* Group list */} + {Object.entries(groups).map(([group, groupTokens]) => ( +
+ + + {expanded === group && ( +
+ {groupTokens.map(token => ( +
+ {token.type === 'color' && ( +
+ )} + + {token.key} + + + {token.rawValue} + +
+ ))} +
+ )} +
+ ))} + + {/* Activate button */} +
+ + + {activateState === 'error' && ( + + Activation failed — try again + + )} +
+
+ ); +} + +function FormatReference() { + return ( +
+ + Supported formats ↓ + + +
+ {[ + { + label: 'W3C DTCG', + desc: '$value / $type fields', + example: `{\n "color": {\n "primary": { "$value": "#0066FF", "$type": "color" }\n }\n}`, + }, + { + label: 'Style Dictionary', + desc: 'Nested with value field', + example: `{\n "color": {\n "primary": { "value": "#0066FF" }\n }\n}`, + }, + { + label: 'Flat CSS Variables', + desc: 'All keys start with --', + example: `{\n "--color-primary": "#0066FF",\n "--spacing-md": "16px"\n}`, + }, + ].map(({ label, desc, example }) => ( +
+
+ {label} + {desc} +
+
+              {example}
+            
+
+ ))} +
+
+ ); +} diff --git a/packages/app/src/components/canvas/Artboard.tsx b/packages/app/src/components/canvas/Artboard.tsx index a108e11..40f0a90 100644 --- a/packages/app/src/components/canvas/Artboard.tsx +++ b/packages/app/src/components/canvas/Artboard.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useCanvas } from '@/store/canvas'; import { useViewport } from '@/store/viewport'; import { useDiffs } from '@/hooks/useDiffs'; -import { LiveArtboard } from './LiveArtboard'; +import { LiveArtboard } from './LiveArtboard'; +import { IsolationFrame } from './IsolationFrame'; import { SelectionOverlay } from './SelectionOverlay'; -import type { FiberNode } from '@originmain/renderer'; +import type { FiberNode } from '@originmain/renderer'; interface ArtboardProps { id: string; @@ -19,8 +20,28 @@ interface ArtboardProps { renderUrl?: string; /** Route path appended to renderUrl so each artboard can show a different screen. */ route?: string; + /** + * Artboard type (spec Phase 0 §3.3). + * route — (default) renders a URL in an iframe + * isolation — renders a single component via the CLI's /__om_isolation__ page + * static — static screenshot; no iframe + */ + artboard_type?: 'route' | 'isolation' | 'static'; + /** Component name for isolation artboards (artboard_type === 'isolation'). */ + isolation_component?: string | null; + /** Workspace-relative file path for isolation artboards. */ + isolation_file?: string | null; + /** Current prop overrides forwarded to the isolation iframe. */ + isolation_props?: Record | null; /** Called when the live app reports discoverable routes — Canvas handles creation. */ onRoutesDiscovered?: (sourceId: string, routes: Array<{ path: string; label: string }>) => void; + /** + * Viewport culling classification (spec Phase 0 §3.2). + * active — overlaps the current viewport → render full LiveArtboard iframe + * near — within 1 viewport margin of the visible area → keep iframe alive + * far — beyond the near zone → suspend iframe to save resources + */ + renderPriority?: 'active' | 'near' | 'far'; } /** Builds the iframe src from a base URL + optional route path. @@ -40,15 +61,64 @@ const DIFF_STATUS_BADGE: Record CSS var map so that + // LiveArtboard can forward them to the iframe via SET_DESIGN_TOKENS on READY + // and on every change (including Supabase Realtime updates). + const designTokens = designLanguageTokens + ? Object.fromEntries(designLanguageTokens.map((t) => [t.key, t.rawValue])) + : undefined; const selected = selectedArtboardId === id; + + // Push dimensions into canvas store when this artboard is selected + // so the Toolbar's device preset picker can read them without prop drilling. + useEffect(() => { + if (selected) setSelectedArtboardSize(width, height); + }, [selected, width, height, setSelectedArtboardSize]); + const queryClient = useQueryClient(); + // Watch for device-preset resize events addressed to this artboard and + // perform the PATCH, then clear the event. + useEffect(() => { + if (!artboardResizeEvent || artboardResizeEvent.artboardId !== id) return; + const { width: newW, height: newH } = artboardResizeEvent; + clearArtboardResize(); + fetch(`/api/artboards/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + metadata_jsonb: { + x, y, width: newW, height: newH, + ...(renderUrl ? { renderUrl } : {}), + ...(route ? { route } : {}), + }, + }), + }).then(() => { + queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); + }).catch(console.error); + }, [artboardResizeEvent, id, x, y, renderUrl, route, workspaceId, projectId, queryClient, clearArtboardResize]); + // Diff status badges — fetch is cached by TanStack Query across all artboards const { diffs } = useDiffs(id); @@ -146,6 +216,13 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR const newX = Math.round(dragStart.current.artX + dragOffsetRef.current.dx); const newY = Math.round(dragStart.current.artY + dragOffsetRef.current.dy); + // Phase 0 spec §4.3: mark as manually positioned when the drag exceeds + // 10 world-space px so auto-arrange doesn't overwrite user layout. + const dragDist = Math.sqrt( + dragOffsetRef.current.dx ** 2 + dragOffsetRef.current.dy ** 2, + ); + const wasIntentionalDrag = dragDist >= 10; + // Persist position fetch(`/api/artboards/${id}`, { method: 'PATCH', @@ -157,6 +234,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR x: newX, y: newY, width, height, ...(renderUrl ? { renderUrl } : {}), ...(route ? { route } : {}), + ...(wasIntentionalDrag ? { manuallyPositioned: true } : {}), }, }), }).then(() => { @@ -213,6 +291,12 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR return (
e.stopPropagation()} onClick={(e) => { e.stopPropagation(); selectArtboard(id); }} > @@ -362,49 +446,116 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR })()} {/* Content */} - {renderUrl ? ( + {/* Isolation artboard — renders a single component via the CLI proxy */} + {artboard_type === 'isolation' && renderUrl && isolation_component && isolation_file ? ( + + ) : renderUrl ? ( <> - { setArtboardLive(id, true); setIsStaticPage(false); }} - onFiberTreeUpdate={handleFiberUpdate} - onComponentSelected={handleComponentSelected} - onComponentStylesUpdate={handleComponentStylesUpdate} - onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)} - onStaticPageDetected={() => setIsStaticPage(true)} - /> + {renderPriority !== 'far' ? ( + <> + { setArtboardLive(id, true); setIsStaticPage(false); }} + onFiberTreeUpdate={handleFiberUpdate} + onComponentSelected={handleComponentSelected} + onComponentStylesUpdate={handleComponentStylesUpdate} + onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)} + onStaticPageDetected={() => setIsStaticPage(true)} + onThumbnailReady={(dataUrl) => { + // 1. Store data URL in Zustand for immediate in-session display + setArtboardThumbnail(id, dataUrl); + // 2. Upload to Supabase Storage in the background (non-blocking). + // Spec §3.6: only the public Storage URL is persisted in the DB; + // data URIs are session-only. + if (dataUrl && workspaceId) { + void fetch('/api/artboards/thumbnail', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artboardId: id, workspaceId, dataUrl }), + }).catch(() => { /* upload failure is non-fatal */ }); + } + }} + onSnapshotReady={(dataUrl, nodeId) => setElementSnapshot(id, nodeId, dataUrl)} + /> - {/* Static-page banner — shown when the proxy serves a non-React page */} - {isStaticPage && ( + {/* Static-page banner — shown when the proxy serves a non-React page */} + {isStaticPage && ( +
+ ⚠️ + + Static HTML page — no React components detected. Navigate to a React route to enable inspection. + +
+ )} + { + if (sel) selectArtboard(id); + handleComponentSelected(sel?.nodeId ?? ''); + }} + /> + + ) : ( + /* Off-screen placeholder — iframe unmounted to save resources. + * Displays the last JPEG thumbnail captured before suspension. */
- ⚠️ - - Static HTML page — no React components detected. Navigate to a React route to enable inspection. - + {thumbnailDataUrl ? ( + /* eslint-disable-next-line @next/next/no-img-element */ + + ) : ( +
+ + off-screen + +
+ )}
)} - { - if (sel) selectArtboard(id); - handleComponentSelected(sel?.nodeId ?? ''); - }} - /> ) : ( diff --git a/packages/app/src/components/canvas/Canvas.tsx b/packages/app/src/components/canvas/Canvas.tsx index 8809f91..363fca2 100644 --- a/packages/app/src/components/canvas/Canvas.tsx +++ b/packages/app/src/components/canvas/Canvas.tsx @@ -8,6 +8,8 @@ import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards'; import { useCanvasTheme } from '@/store/canvasTheme'; import { Artboard } from './Artboard'; import { CompletionZone } from './CompletionZone'; +import { artboardIframeMap } from '@/lib/artboard-iframe-map'; +import { createHostEnvelope } from '@originmain/renderer'; export function Canvas() { const T = useCanvasTheme(); @@ -15,7 +17,7 @@ export function Canvas() { const panX = useViewport((s) => s.panX); const panY = useViewport((s) => s.panY); const zoom = useViewport((s) => s.zoom); - const { activeTool, setActiveTool, selectArtboard, selectedArtboardId, workspaceId, projectId } = useCanvas(); + const { activeTool, setActiveTool, selectArtboard, selectedArtboardId, workspaceId, projectId, setDiscoveredRoutes } = useCanvas(); const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const queryClient = useQueryClient(); @@ -23,6 +25,84 @@ export function Canvas() { const lastPos = useRef({ x: 0, y: 0 }); const spaceDown = useRef(false); + // ── Viewport culling (spec Phase 0 §3.2) ───────────────────────────────── + // Classifies each artboard as 'active' | 'near' | 'far' based on whether it + // overlaps with the current viewport. Updated 100ms after pan/zoom settles. + // 'active'/'near' → full LiveArtboard iframe; 'far' → placeholder thumbnail. + const [renderPriorities, setRenderPriorities] = useState>({}); + const cullTimerRef = useRef | null>(null); + // Track previous priorities so we can detect Active/Near → Far transitions + // and request a thumbnail snapshot before the iframe is unmounted. + const prevPrioritiesRef = useRef>({}); + + useEffect(() => { + function computeCulling() { + const el = containerRef.current; + if (!el) return; + const { panX, panY, zoom } = useViewport.getState(); + const vpW = el.clientWidth; + const vpH = el.clientHeight; + + // Viewport bounds in world space + const vpLeft = -panX / zoom; + const vpTop = -panY / zoom; + const vpRight = vpLeft + vpW / zoom; + const vpBottom = vpTop + vpH / zoom; + + // Near zone: 1 viewport width/height of padding beyond the visible edge + const nearPadX = vpW / zoom; + const nearPadY = vpH / zoom; + + const next: Record = {}; + for (const ab of artboards) { + const al = ab.x; + const at = ab.y; + const ar = ab.x + ab.width; + const ab_ = ab.y + ab.height; + + const overlapsViewport = + ar > vpLeft && al < vpRight && ab_ > vpTop && at < vpBottom; + + const overlapsNear = + ar > vpLeft - nearPadX && al < vpRight + nearPadX && + ab_ > vpTop - nearPadY && at < vpBottom + nearPadY; + + next[ab.id] = overlapsViewport ? 'active' : overlapsNear ? 'near' : 'far'; + } + + // Detect transitions to 'far' and request a thumbnail before the iframe unmounts. + const prev = prevPrioritiesRef.current; + for (const abId of Object.keys(next)) { + const wasVisible = prev[abId] !== 'far'; + const nowFar = next[abId] === 'far'; + if (wasVisible && nowFar) { + const iframe = artboardIframeMap.get(abId); + if (iframe?.contentWindow) { + iframe.contentWindow.postMessage(createHostEnvelope(abId, { type: 'CAPTURE_THUMBNAIL' }), '*'); + } + } + } + prevPrioritiesRef.current = next; + + setRenderPriorities(next); + } + + function scheduleCull() { + if (cullTimerRef.current) clearTimeout(cullTimerRef.current); + cullTimerRef.current = setTimeout(computeCulling, 100); + } + + // Run immediately when artboards list changes, then subscribe to viewport changes + computeCulling(); + + // Subscribe to viewport store updates + const unsub = useViewport.subscribe(scheduleCull); + return () => { + unsub(); + if (cullTimerRef.current) clearTimeout(cullTimerRef.current); + }; + }, [artboards]); + // ── Route discovery: auto-create screen grid ────────────────────────────── // When a live artboard discovers routes we don't have artboards for yet, // this creates them in a horizontal row to the right of all existing frames. @@ -41,6 +121,9 @@ export function Canvas() { pendingRouteCreation.current = true; + // Persist all discovered routes in the canvas store so the Routes tab can display them + setDiscoveredRoutes(sourceArtboardId, routes); + // Position new artboards in a row to the right of all existing frames const GAP = 80; const rightEdge = artboards.reduce( @@ -73,7 +156,7 @@ export function Canvas() { .catch(console.error) .finally(() => { pendingRouteCreation.current = false; }); }, - [artboards, workspaceId, projectId, queryClient], + [artboards, workspaceId, projectId, queryClient, setDiscoveredRoutes], ); // Zone tool: drag to draw a completion zone @@ -218,6 +301,7 @@ export function Canvas() { transition: 'background 0.2s', cursor, }} + data-canvas-viewport="true" onMouseDown={onMouseDown} onMouseMove={onMouseMove} onMouseUp={onMouseUp} @@ -256,7 +340,12 @@ export function Canvas() { }} > {artboards.map((ab) => ( - + ))} {/* Zone tool: live drag preview rectangle */} diff --git a/packages/app/src/components/canvas/IsolationFrame.tsx b/packages/app/src/components/canvas/IsolationFrame.tsx new file mode 100644 index 0000000..06c2b0e --- /dev/null +++ b/packages/app/src/components/canvas/IsolationFrame.tsx @@ -0,0 +1,229 @@ +'use client'; + +/** + * IsolationFrame — Phase 3 full implementation + * + * Renders an isolated view of a single React component inside an iframe. + * The CLI proxy serves the isolation page at: + * `/__om_isolation__?component=&file=` + * + * When the indexer is not ready, shows an informative placeholder. When it is + * ready, renders the isolation iframe. The host sends UPDATE_ISOLATION_PROPS + * messages so the designer can tweak props live from the Inspector. + * + * spec: SOURCE-AWARE-CANVAS.md Phase 3 §3.5 "Component Isolation" + */ + +import { useRef, useEffect, useCallback } from 'react'; +import { useCanvas } from '@/store/canvas'; +import { useCanvasTheme } from '@/store/canvasTheme'; +import { createHostEnvelope } from '@originmain/renderer'; + +interface IsolationFrameProps { + /** Artboard ID (used for message routing). */ + artboardId: string; + /** Display name of the component to isolate — passed as ?component= param. */ + componentName: string; + /** Workspace-relative source file path — passed as ?file= param. */ + componentFile: string; + /** Base URL of the CLI proxy (e.g. "http://localhost:4170"). */ + proxyUrl: string; + /** Current prop overrides to forward into the isolation page. */ + isolationProps?: Record; + width: number; + height: number; +} + +/** Builds the isolation page URL from the proxy base and component params. */ +function buildIsolationUrl( + proxyUrl: string, + componentName: string, + componentFile: string, +): string { + const base = proxyUrl.replace(/\/$/, ''); + const params = new URLSearchParams({ + component: componentName, + file: componentFile, + }); + return `${base}/__om_isolation__?${params.toString()}`; +} + +export function IsolationFrame({ + artboardId, + componentName, + componentFile, + proxyUrl, + isolationProps, + width, + height, +}: IsolationFrameProps) { + const T = useCanvasTheme(); + const { indexerStatus } = useCanvas(); + const iframeRef = useRef(null); + + // ── Forward prop overrides to the isolation iframe ───────────────────────── + // Sends UPDATE_ISOLATION_PROPS whenever isolationProps changes so the + // component re-renders with the new values without a full page reload. + const sendIsolationProps = useCallback(() => { + const iframe = iframeRef.current; + if (!iframe?.contentWindow) return; + try { + const msg = createHostEnvelope(artboardId, { + type: 'UPDATE_ISOLATION_PROPS', + props: isolationProps ?? {}, + }); + iframe.contentWindow.postMessage(msg, '*'); + } catch { /* iframe may not be ready yet — will retry on next onLoad */ } + }, [artboardId, isolationProps]); + + useEffect(() => { + sendIsolationProps(); + }, [sendIsolationProps]); + + // ── Indexer not running — show placeholder ───────────────────────────────── + if (indexerStatus !== 'ready') { + return ( +
+ {/* Isolation icon */} + + + + + +
+ + Isolation mode requires CLI indexer + + + Run{' '} + + npx @originmain/cli dev + + {' '}to enable component isolation. + +
+ + {/* Status badge */} +
+
+ + Indexer offline + +
+
+ ); + } + + // ── Indexer ready — render isolation iframe ──────────────────────────────── + const src = buildIsolationUrl(proxyUrl, componentName, componentFile); + + return ( +
+