made tiny updates

This commit is contained in:
SinachPat
2026-05-05 22:06:31 +01:00
parent 19b8f29523
commit fdf64ee72c
39 changed files with 5499 additions and 381 deletions
@@ -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.
+2 -2
View File
@@ -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';
+61
View File
@@ -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<string, unknown>;
/** 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<string, string>;
};
}
/**
* 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<T> {
+387 -196
View File
@@ -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<unknown[]>;
getDiff(id: string): Promise<unknown>;
getArtboard(id: string): Promise<unknown>;
getDesignLanguageFile(workspaceId: string): Promise<unknown | null>;
updateDiffStatus(id: string, status: DiffStatus, notes?: string): Promise<void>;
};
/** Adapter to the AI layer (for ask_design_agent) */
ai: {
answerAgentQuestion(diffId: string, question: string, artboardContext: unknown): Promise<string>;
};
}
// ── 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<ToolResult<unknown> | 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<string, unknown>;
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<string, unknown>; required?: string[] } {
if (schema instanceof z.ZodObject) {
const shape = schema.shape as Record<string, z.ZodTypeAny>;
const properties: Record<string, unknown> = {};
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<string, IntentRecord[]>();
/**
* Store an intent pushed by the canvas (called from the /api/intent route).
* Returns the generated intentId.
*/
export function storePendingIntent(
workspaceId: string,
intent: Omit<IntentRecord, 'intentId' | 'workspaceId' | 'createdAt'>,
): 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<string, IndexerEntry>();
/** 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<string, McpTool> = {
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<string, unknown>;
async function handlePushIntent(ctx: ToolContext): Promise<ToolResult<unknown>> {
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<ToolResult<unknown>> {
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<ToolResult<unknown>> {
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<string, unknown> = {
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<ToolResult<unknown>> {
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}`);
}
}
@@ -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<string, unknown> = {};
try {
body = (await req.json()) as Record<string, unknown>;
} 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,
});
}
+8 -32
View File
@@ -3,16 +3,8 @@
// Authentication: Bearer <workspace_token> (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<ToolContext['db']>,
};
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';
@@ -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 });
}
+118
View File
@@ -0,0 +1,118 @@
// GET /api/cli-auth?callback=<url>
//
// 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: <callback>?token=X&workspaceId=Y&bridgeUrl=Z
// or <callback>?error=<message> 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<NextResponse> {
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());
}
@@ -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 });
}
+78
View File
@@ -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 });
}
}
@@ -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<DesignToken[]> {
const { parseTokenFileJson } = await import('@originmain/design-language');
return parseTokenFileJson(jsonText) as DesignToken[];
}
function groupByCategory(tokens: DesignToken[]): Record<string, DesignToken[]> {
const groups: Record<string, DesignToken[]> = {};
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<ParseState>({ status: 'idle' });
const [activateState, setActivateState] = useState<ActivateState>('idle');
const [dragOver, setDragOver] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div style={{
minHeight: '100vh',
background: '#0F1117',
color: 'rgba(255,255,255,0.85)',
fontFamily: "'Inter', system-ui, sans-serif",
padding: '48px 40px',
maxWidth: 800,
margin: '0 auto',
}}>
{/* Header */}
<div style={{ marginBottom: 40 }}>
<h1 style={{
fontFamily: "'Inter', sans-serif",
fontSize: '1.25rem',
fontWeight: 700,
color: 'white',
margin: 0,
marginBottom: 8,
letterSpacing: '-0.03em',
}}>
Design Language
</h1>
<p style={{ fontSize: '0.8125rem', color: 'rgba(255,255,255,0.4)', margin: 0, lineHeight: 1.6 }}>
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.
</p>
</div>
{/* Active token set status */}
{designLanguageTokens && (
<ActiveTokenBanner tokens={designLanguageTokens} onDeactivate={deactivate} />
)}
{/* Upload area */}
<UploadZone
dragOver={dragOver}
onDragOver={() => setDragOver(true)}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
/>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
{/* Parse state */}
{parseState.status === 'parsing' && (
<StatusCard>
<Spinner />
<span style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>Parsing token file</span>
</StatusCard>
)}
{parseState.status === 'error' && (
<StatusCard color="rgba(255,80,80,0.08)" border="rgba(255,80,80,0.3)">
<span style={{ fontSize: '0.8125rem', color: '#FF8080' }}> {parseState.message}</span>
</StatusCard>
)}
{parseState.status === 'parsed' && (
<TokenPreview
tokens={parseState.tokens}
filename={parseState.filename}
activateState={activateState}
onActivate={activate}
/>
)}
{/* Format reference */}
<FormatReference />
</div>
);
}
// ── Sub-components ────────────────────────────────────────────────────────────
function ActiveTokenBanner({ tokens, onDeactivate }: { tokens: DesignToken[]; onDeactivate: () => void }) {
const groups = groupByCategory(tokens);
return (
<div style={{
padding: '14px 18px',
background: 'rgba(125,211,168,0.06)',
border: '1px solid rgba(125,211,168,0.25)',
borderRadius: 10,
marginBottom: 28,
display: 'flex',
alignItems: 'center',
gap: 14,
}}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#7DD3A8', flexShrink: 0, boxShadow: '0 0 8px rgba(125,211,168,0.6)' }} />
<div style={{ flex: 1 }}>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: '#7DD3A8', fontWeight: 600, marginBottom: 3 }}>
{tokens.length} tokens active
</div>
<div style={{ fontSize: '0.625rem', color: 'rgba(255,255,255,0.35)', fontFamily: "'JetBrains Mono', monospace" }}>
{Object.entries(groups).map(([g, ts]) => `${g}:${ts.length}`).join(' · ')}
</div>
</div>
<button
onClick={onDeactivate}
style={{
background: 'none', border: '1px solid rgba(255,80,80,0.3)', borderRadius: 6,
color: '#FF8080', padding: '5px 12px', cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
letterSpacing: '0.04em', transition: 'background 0.1s',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(255,80,80,0.08)')}
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
>
Deactivate
</button>
</div>
);
}
function UploadZone({
dragOver,
onDragOver,
onDragLeave,
onDrop,
onClick,
}: {
dragOver: boolean;
onDragOver: () => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent) => void;
onClick: () => void;
}) {
return (
<div
onClick={onClick}
onDragOver={e => { 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,
}}
>
<div style={{ fontSize: '1.5rem', marginBottom: 10, opacity: 0.4 }}>📂</div>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', color: 'rgba(255,255,255,0.6)', marginBottom: 6 }}>
Drop token file here or click to browse
</div>
<div style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)' }}>
.json Style Dictionary · W3C DTCG · Flat CSS vars
</div>
</div>
);
}
function StatusCard({ children, color, border }: { children: React.ReactNode; color?: string; border?: string }) {
return (
<div style={{
padding: '14px 18px',
background: color ?? 'rgba(255,255,255,0.04)',
border: `1px solid ${border ?? 'rgba(255,255,255,0.12)'}`,
borderRadius: 10,
display: 'flex',
alignItems: 'center',
gap: 10,
marginBottom: 24,
}}>
{children}
</div>
);
}
function Spinner() {
return (
<div style={{
width: 14, height: 14, borderRadius: '50%',
border: '2px solid rgba(255,255,255,0.15)',
borderTopColor: '#3385FF',
animation: 'spin 0.8s linear infinite',
flexShrink: 0,
}} />
);
}
function TokenPreview({
tokens,
filename,
activateState,
onActivate,
}: {
tokens: DesignToken[];
filename: string;
activateState: ActivateState;
onActivate: () => void;
}) {
const groups = groupByCategory(tokens);
const [expanded, setExpanded] = useState<string | null>(null);
return (
<div style={{
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 12,
overflow: 'hidden',
marginBottom: 24,
}}>
{/* Preview header */}
<div style={{
padding: '14px 18px',
borderBottom: '1px solid rgba(255,255,255,0.07)',
display: 'flex',
alignItems: 'center',
gap: 10,
}}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: '#7DD3A8' }}>
Parsed
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', flex: 1 }}>
{filename}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.35)' }}>
{tokens.length} tokens · {Object.keys(groups).length} groups
</span>
</div>
{/* Group list */}
{Object.entries(groups).map(([group, groupTokens]) => (
<div key={group} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<button
onClick={() => setExpanded(expanded === group ? null : group)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '10px 18px',
background: 'none', border: 'none', cursor: 'pointer',
textAlign: 'left',
}}
>
<span style={{ fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>
{expanded === group ? '▼' : '▶'}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.75)', flex: 1, textTransform: 'capitalize' }}>
{group}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>
{groupTokens.length}
</span>
{/* Color swatches preview */}
<div style={{ display: 'flex', gap: 3 }}>
{groupTokens
.filter(t => t.type === 'color')
.slice(0, 6)
.map(t => (
<div key={t.key} style={{ width: 12, height: 12, borderRadius: 2, background: t.rawValue, border: '1px solid rgba(255,255,255,0.1)' }} />
))}
</div>
</button>
{expanded === group && (
<div style={{ paddingBottom: 8 }}>
{groupTokens.map(token => (
<div key={token.key} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '5px 18px 5px 36px',
}}>
{token.type === 'color' && (
<div style={{ width: 14, height: 14, borderRadius: 3, background: token.rawValue, flexShrink: 0, border: '1px solid rgba(255,255,255,0.15)' }} />
)}
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#3385FF', flex: 1, letterSpacing: '-0.01em' }}>
{token.key}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.4)', letterSpacing: '-0.01em' }}>
{token.rawValue}
</span>
</div>
))}
</div>
)}
</div>
))}
{/* Activate button */}
<div style={{ padding: '14px 18px', display: 'flex', gap: 10, alignItems: 'center' }}>
<button
onClick={onActivate}
disabled={activateState === 'activating' || activateState === 'done'}
style={{
padding: '9px 20px',
background: activateState === 'done' ? 'rgba(125,211,168,0.15)' : '#3385FF',
border: `1px solid ${activateState === 'done' ? 'rgba(125,211,168,0.4)' : 'transparent'}`,
borderRadius: 7,
color: activateState === 'done' ? '#7DD3A8' : 'white',
fontFamily: "'Inter', sans-serif",
fontWeight: 600,
fontSize: '0.75rem',
cursor: activateState === 'activating' || activateState === 'done' ? 'not-allowed' : 'pointer',
opacity: activateState === 'activating' ? 0.6 : 1,
transition: 'background 0.15s, opacity 0.15s',
}}
>
{activateState === 'activating' ? 'Activating…' :
activateState === 'done' ? '✓ Tokens activated' :
`Activate ${tokens.length} tokens`}
</button>
{activateState === 'error' && (
<span style={{ fontSize: '0.625rem', color: '#FF8080', fontFamily: "'Inter', sans-serif" }}>
Activation failed try again
</span>
)}
</div>
</div>
);
}
function FormatReference() {
return (
<details style={{ marginTop: 8 }}>
<summary style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.04em',
textTransform: 'uppercase',
userSelect: 'none',
listStyle: 'none',
}}>
Supported formats
</summary>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{
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 }) => (
<div key={label} style={{
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 8,
overflow: 'hidden',
}}>
<div style={{ padding: '10px 14px 6px', display: 'flex', gap: 10, alignItems: 'baseline' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', fontWeight: 600 }}>{label}</span>
<span style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: 'rgba(255,255,255,0.3)' }}>{desc}</span>
</div>
<pre style={{
margin: 0, padding: '8px 14px 12px',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: '#7EB8FF',
background: 'rgba(0,0,0,0.2)',
overflow: 'auto',
lineHeight: 1.65,
}}>
{example}
</pre>
</div>
))}
</div>
</details>
);
}
+193 -42
View File
@@ -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<string, unknown> | 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<string, { color: string; bg: string; label: stri
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
};
export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
export function Artboard({
id, label, x, y, width, height,
renderUrl, route,
artboard_type = 'route',
isolation_component,
isolation_file,
isolation_props,
onRoutesDiscovered,
renderPriority = 'active',
}: ArtboardProps) {
const {
selectedArtboardId, selectArtboard, workspaceId, projectId,
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
selectedComponentId,
selectedComponentId, setSelectedArtboardSize,
artboardResizeEvent, clearArtboardResize,
artboardThumbnails, setArtboardThumbnail,
setElementSnapshot,
designLanguageTokens,
} = useCanvas();
const thumbnailDataUrl = artboardThumbnails[id] ?? null;
// Phase 6: convert DesignToken[] → Record<string,string> 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 (
<div
style={{ position: 'absolute', top: effectiveY, left: effectiveX }}
data-artboard-world="true"
data-artboard-world-x={String(effectiveX)}
data-artboard-world-y={String(effectiveY)}
data-artboard-world-w={String(width)}
data-artboard-world-h={String(height)}
data-artboard-selected={String(selected)}
onMouseDown={(e) => 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 ? (
<IsolationFrame
artboardId={id}
componentName={isolation_component}
componentFile={isolation_file}
proxyUrl={renderUrl}
{...(isolation_props ? { isolationProps: isolation_props } : {})}
width={width}
height={height}
/>
) : renderUrl ? (
<>
<LiveArtboard
id={id}
src={buildSrc(renderUrl, route)}
width={width}
height={height}
selectedComponentId={selectedComponentId}
onReady={() => { setArtboardLive(id, true); setIsStaticPage(false); }}
onFiberTreeUpdate={handleFiberUpdate}
onComponentSelected={handleComponentSelected}
onComponentStylesUpdate={handleComponentStylesUpdate}
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
onStaticPageDetected={() => setIsStaticPage(true)}
/>
{renderPriority !== 'far' ? (
<>
<LiveArtboard
id={id}
src={buildSrc(renderUrl, route)}
width={width}
height={height}
{...(renderPriority === 'near' ? { style: { visibility: 'hidden' } } : {})}
{...(designTokens ? { designTokens } : {})}
selectedComponentId={selectedComponentId}
onReady={() => { 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 && (
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
background: 'rgba(245,158,11,0.92)', backdropFilter: 'blur(8px)',
padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 8,
zIndex: 20, pointerEvents: 'none',
}}>
<span style={{ fontSize: 12 }}></span>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
color: '#1C1917', letterSpacing: '-0.01em',
}}>
Static HTML page no React components detected. Navigate to a React route to enable inspection.
</span>
</div>
)}
<SelectionOverlay
artboardId={id}
{...(localFiberRoot !== undefined ? { fiberRoot: localFiberRoot } : {})}
width={width}
height={height}
onSelectionChange={(sel) => {
if (sel) selectArtboard(id);
handleComponentSelected(sel?.nodeId ?? '');
}}
/>
</>
) : (
/* Off-screen placeholder — iframe unmounted to save resources.
* Displays the last JPEG thumbnail captured before suspension. */
<div style={{
position: 'absolute', bottom: 0, left: 0, right: 0,
background: 'rgba(245,158,11,0.92)', backdropFilter: 'blur(8px)',
padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 8,
zIndex: 20, pointerEvents: 'none',
width, height,
background: 'rgba(255,255,255,0.03)',
borderRadius: 2,
overflow: 'hidden',
position: 'relative',
}}>
<span style={{ fontSize: 12 }}></span>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
color: '#1C1917', letterSpacing: '-0.01em',
}}>
Static HTML page no React components detected. Navigate to a React route to enable inspection.
</span>
{thumbnailDataUrl ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img
src={thumbnailDataUrl}
alt=""
aria-hidden
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
/>
) : (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: 'rgba(255,255,255,0.12)',
letterSpacing: '0.06em',
textTransform: 'uppercase',
}}>
off-screen
</span>
</div>
)}
</div>
)}
<SelectionOverlay
artboardId={id}
{...(localFiberRoot !== undefined ? { fiberRoot: localFiberRoot } : {})}
width={width}
height={height}
onSelectionChange={(sel) => {
if (sel) selectArtboard(id);
handleComponentSelected(sel?.nodeId ?? '');
}}
/>
</>
) : (
<EmptyArtboardContent id={id} label={label} width={width} height={height} workspaceId={workspaceId} projectId={projectId} queryClient={queryClient} />
+92 -3
View File
@@ -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<Record<string, 'active' | 'near' | 'far'>>({});
const cullTimerRef = useRef<ReturnType<typeof setTimeout> | 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<Record<string, 'active' | 'near' | 'far'>>({});
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<string, 'active' | 'near' | 'far'> = {};
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) => (
<Artboard key={ab.id} {...ab} onRoutesDiscovered={handleRoutesDiscovered} />
<Artboard
key={ab.id}
{...ab}
onRoutesDiscovered={handleRoutesDiscovered}
renderPriority={renderPriorities[ab.id] ?? 'active'}
/>
))}
{/* Zone tool: live drag preview rectangle */}
@@ -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=<name>&file=<path>`
*
* 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<string, unknown>;
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<HTMLIFrameElement>(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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: '32px 20px',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 8,
textAlign: 'center',
width, height,
boxSizing: 'border-box',
}}
>
{/* Isolation icon */}
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.3 }}>
<rect x="3" y="3" width="22" height="22" rx="4" stroke="white" strokeWidth="1.3" strokeDasharray="4 2.5"/>
<rect x="9" y="9" width="10" height="10" rx="2" stroke="white" strokeWidth="1.3"/>
</svg>
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5875rem',
color: T.fgMuted,
letterSpacing: '0.02em',
}}>
Isolation mode requires CLI indexer
</span>
<span style={{
fontFamily: "'Inter', sans-serif",
fontSize: '0.5625rem',
color: T.dim,
lineHeight: 1.55,
}}>
Run{' '}
<code style={{
fontFamily: "'JetBrains Mono', monospace",
color: '#FFBA7B',
fontSize: '0.5rem',
background: 'rgba(255,186,123,0.08)',
borderRadius: 3,
padding: '1px 4px',
}}>
npx @originmain/cli dev
</code>
{' '}to enable component isolation.
</span>
</div>
{/* Status badge */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 5,
padding: '4px 10px',
background: 'rgba(255,255,255,0.04)',
border: `1px solid ${T.sep}`,
borderRadius: 5,
}}>
<div style={{
width: 5, height: 5, borderRadius: '50%',
background: T.dim, flexShrink: 0,
}} />
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}>
Indexer offline
</span>
</div>
</div>
);
}
// ── Indexer ready — render isolation iframe ────────────────────────────────
const src = buildIsolationUrl(proxyUrl, componentName, componentFile);
return (
<div style={{ position: 'relative', width, height, overflow: 'hidden' }}>
<iframe
ref={iframeRef}
src={src}
width={width}
height={height}
style={{
display: 'block',
border: 'none',
background: 'white',
}}
title={`${componentName} — isolation`}
// The isolation page is served by the CLI proxy (localhost).
// allow="*" is safe here because it's a locally-served page.
allow="*"
onLoad={sendIsolationProps}
/>
{/* Isolation indicator badge */}
<div style={{
position: 'absolute',
top: 8,
right: 8,
display: 'flex',
alignItems: 'center',
gap: 4,
padding: '3px 8px',
background: 'rgba(51,133,255,0.12)',
border: '1px solid rgba(51,133,255,0.3)',
borderRadius: 4,
backdropFilter: 'blur(8px)',
pointerEvents: 'none',
}}>
<div style={{
width: 5, height: 5, borderRadius: '50%',
background: '#3385FF', flexShrink: 0,
animation: 'om-pulse 2s infinite',
}} />
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: '#3385FF',
letterSpacing: '0.05em',
textTransform: 'uppercase',
}}>
isolation
</span>
</div>
{/* Subtle top border to distinguish from route artboards */}
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0,
height: 2,
background: 'linear-gradient(90deg, rgba(51,133,255,0.6) 0%, rgba(51,133,255,0) 100%)',
pointerEvents: 'none',
}} />
</div>
);
}
@@ -7,6 +7,7 @@ import {
} from '@originmain/renderer';
import type { FiberNode, RendererMessage } from '@originmain/renderer';
import { useCanvas } from '@/store/canvas';
import { artboardIframeMap } from '@/lib/artboard-iframe-map';
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -35,6 +36,10 @@ export interface LiveArtboardProps {
/** Called when READY fires but no React commits arrive within 4 s — signals a
* static HTML page where the fiber hook can't find a React runtime. */
onStaticPageDetected?: () => void;
/** Phase 0: Called when the renderer responds to CAPTURE_THUMBNAIL with a JPEG data URL (or null on failure). */
onThumbnailReady?: (dataUrl: string | null) => void;
/** Phase 4: Called when the renderer responds to CAPTURE_SNAPSHOT with a PNG data URL (or null on failure). */
onSnapshotReady?: (dataUrl: string | null, nodeId: string) => void;
style?: React.CSSProperties;
}
@@ -53,8 +58,11 @@ export function LiveArtboard({
onComponentStylesUpdate,
onRoutesDiscovered,
onStaticPageDetected,
onThumbnailReady,
onSnapshotReady,
style,
}: LiveArtboardProps) {
const { setArtboardRootFontSize } = useCanvas();
const iframeRef = useRef<HTMLIFrameElement>(null);
// Track whether the iframe has sent READY so we don't send messages too early.
const isReadyRef = useRef(false);
@@ -102,6 +110,10 @@ export function LiveArtboard({
switch (msg.type) {
case 'READY':
isReadyRef.current = true;
// Store the root font size for rem→px normalisation in the token resolver.
if (typeof msg.rootFontSizePx === 'number') {
setArtboardRootFontSize(id, msg.rootFontSizePx);
}
// Push current design tokens into the iframe immediately.
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
// Restore the highlight ring for any active selection.
@@ -146,12 +158,20 @@ export function LiveArtboard({
case 'ROUTES_DISCOVERED':
onRoutesDiscovered?.(msg.routes);
break;
case 'THUMBNAIL_READY':
// Phase 0: store base64 JPEG for the far-state placeholder in Artboard.tsx.
onThumbnailReady?.(msg.dataUrl);
break;
case 'SNAPSHOT_READY':
// Phase 4: PNG of the selected element for the Code Preview diff in Inspector.
onSnapshotReady?.(msg.dataUrl, msg.nodeId);
break;
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered]);
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered, onThumbnailReady, onSnapshotReady]);
// ── Push updated design tokens whenever they change ───────────────────────
useEffect(() => {
@@ -209,6 +229,16 @@ export function LiveArtboard({
}
}, [selectedComponentId, sendMessage]);
// ── Register / deregister in the artboardIframeMap singleton ────────────────
// This allows canvas-level dispatch (e.g. CompletionZone, CodeTab send-to-agent)
// to reach the correct iframe without going through React state or Zustand.
useEffect(() => {
const el = iframeRef.current;
if (!el) return;
artboardIframeMap.set(id, el);
return () => { artboardIframeMap.delete(id); };
}, [id]);
return (
<iframe
ref={iframeRef}
@@ -14,6 +14,8 @@ import { useTheme } from '@/store/theme';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useWalkthrough } from '@/store/walkthrough';
import { useIndexer } from '@/hooks/useIndexer';
import { browserClient } from '@/lib/supabase';
import type { SupabaseClient } from '@supabase/supabase-js';
interface AppChromeProps {
workspaceId?: string;
@@ -26,6 +28,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
const setContext = useCanvas((s) => s.setContext);
const setActiveTool = useCanvas((s) => s.setActiveTool);
const setDesignLanguageTokens = useCanvas((s) => s.setDesignLanguageTokens);
const { mode: themeMode, toggle: toggleTheme } = useTheme();
const CT = useCanvasTheme();
const startTour = useWalkthrough((s) => s.start);
@@ -33,6 +36,50 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
// Connect to the CLI AST indexer (reads window.__OM_INDEX_URL__, no-op if absent)
useIndexer();
// Phase 6: Supabase Realtime subscription for design language token updates.
// When another team member uploads a new active token file, all open sessions
// receive the update automatically and re-broadcast SET_DESIGN_TOKENS to every
// live iframe via the Artboard → LiveArtboard prop chain.
useEffect(() => {
if (!workspaceId) return;
const db = browserClient() as unknown as SupabaseClient;
const channel = db
.channel(`dlf:${workspaceId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'design_language_files',
filter: `workspace_id=eq.${workspaceId}`,
},
(payload) => {
// Only react to rows that are marked as the active version.
const row = (payload.new ?? payload.old) as Record<string, unknown> | undefined;
if (!row || !row['is_active']) return;
// Re-parse tokens from the updated schema_jsonb.
const schemaJsonb = row['schema_jsonb'];
if (!schemaJsonb || typeof schemaJsonb !== 'object') return;
import('@originmain/design-language').then(({ parseTokenFile }) => {
try {
const tokens = parseTokenFile(schemaJsonb);
setDesignLanguageTokens(tokens as Parameters<typeof setDesignLanguageTokens>[0]);
} catch {
// Malformed token file in DB — don't crash the session
}
}).catch(() => { /* design-language package unavailable */ });
},
)
.subscribe();
return () => {
void db.removeChannel(channel);
};
}, [workspaceId, setDesignLanguageTokens]);
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
useEffect(() => {
if (workspaceId && projectId) setContext(workspaceId, projectId);
@@ -78,16 +125,98 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
}
}
// Undo/Redo require a selected artboard
if (!(e.metaKey || e.ctrlKey)) return;
if (!selectedArtboardId) return;
// ── Zoom / fit shortcuts (Cmd / Ctrl + …) ──────────────────────────────
if (e.metaKey || e.ctrlKey) {
const vp = useViewport.getState();
const { zoom, panX, panY, setZoom, setPan } = vp;
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
useHistory.getState().undo(selectedArtboardId);
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
e.preventDefault();
useHistory.getState().redo(selectedArtboardId);
// Cmd+= or Cmd++ → zoom in 10% at viewport centre
if (e.key === '=' || e.key === '+') {
e.preventDefault();
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
setZoom(zoom * 1.1, cx, cy);
return;
}
// Cmd+- → zoom out 10% at viewport centre
if (e.key === '-') {
e.preventDefault();
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
setZoom(zoom * 0.9, cx, cy);
return;
}
// Cmd+0 → fit all artboards in view (80px padding)
if (e.key === '0') {
e.preventDefault();
// Dynamically import to avoid circular dep; artboards read from DOM
const canvasEl = document.querySelector('[data-canvas-viewport]') as HTMLDivElement | null;
const vpW = canvasEl?.clientWidth ?? window.innerWidth;
const vpH = canvasEl?.clientHeight ?? window.innerHeight;
const artboardEls = document.querySelectorAll('[data-artboard-world]');
if (artboardEls.length === 0) { setZoom(1, vpW / 2, vpH / 2); setPan(0, 0); return; }
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
artboardEls.forEach(el => {
const wx = parseFloat((el as HTMLElement).dataset.artboardWorldX ?? '0');
const wy = parseFloat((el as HTMLElement).dataset.artboardWorldY ?? '0');
const ww = parseFloat((el as HTMLElement).dataset.artboardWorldW ?? '0');
const wh = parseFloat((el as HTMLElement).dataset.artboardWorldH ?? '0');
minX = Math.min(minX, wx); minY = Math.min(minY, wy);
maxX = Math.max(maxX, wx + ww); maxY = Math.max(maxY, wy + wh);
});
const pad = 80;
const tw = maxX - minX; const th = maxY - minY;
const newZoom = Math.max(0.1, Math.min(4, (vpW - pad * 2) / tw, (vpH - pad * 2) / th));
const newX = pad - minX * newZoom + (vpW - pad * 2 - tw * newZoom) / 2;
const newY = pad - minY * newZoom + (vpH - pad * 2 - th * newZoom) / 2;
setPan(newX, newY);
setZoom(newZoom);
return;
}
// Cmd+1 → reset to 100% centred on selected artboard (or origin)
if (e.key === '1') {
e.preventDefault();
const vpW = window.innerWidth; const vpH = window.innerHeight;
// Try to centre on selected artboard's world position
const selEl = document.querySelector('[data-artboard-world][data-artboard-selected="true"]') as HTMLElement | null;
if (selEl) {
const wx = parseFloat(selEl.dataset.artboardWorldX ?? '0');
const wy = parseFloat(selEl.dataset.artboardWorldY ?? '0');
const ww = parseFloat(selEl.dataset.artboardWorldW ?? '0');
const wh = parseFloat(selEl.dataset.artboardWorldH ?? '0');
setPan(vpW / 2 - (wx + ww / 2), vpH / 2 - (wy + wh / 2));
} else {
setPan(0, 0);
}
setZoom(1);
return;
}
// Cmd+Shift+H → fit artboard height to viewport
if (e.key === 'H' && e.shiftKey) {
e.preventDefault();
const vpH = window.innerHeight;
const selEl = document.querySelector('[data-artboard-world][data-artboard-selected="true"]') as HTMLElement | null;
if (selEl) {
const wx = parseFloat(selEl.dataset.artboardWorldX ?? '0');
const wy = parseFloat(selEl.dataset.artboardWorldY ?? '0');
const wh = parseFloat(selEl.dataset.artboardWorldH ?? '0');
const newZoom = Math.max(0.1, Math.min(4, vpH / wh));
setPan(panX, -wy * newZoom + (vpH - wh * newZoom) / 2);
setZoom(newZoom, wx, wy);
}
return;
}
// Undo/Redo require a selected artboard
if (!selectedArtboardId) return;
if (e.key === 'z' && !e.shiftKey) {
e.preventDefault();
useHistory.getState().undo(selectedArtboardId);
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
e.preventDefault();
useHistory.getState().redo(selectedArtboardId);
}
return;
}
}
+122 -4
View File
@@ -1,7 +1,7 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import {
ToolbarDivider,
Tooltip,
} from '@fluentui/react-components';
import {
@@ -18,13 +18,50 @@ import { useCanvas, type Tool } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import { useCanvasTheme } from '@/store/canvasTheme';
// ── Device presets (spec Phase 0 §3.4) ───────────────────────────────────────
export const DEVICE_PRESETS = [
{ key: 'desktop-hd', label: 'Desktop HD', width: 1440, height: 900 },
{ key: 'desktop-lg', label: 'Desktop Large', width: 1280, height: 800 },
{ key: 'laptop', label: 'Laptop', width: 1024, height: 768 },
{ key: 'tablet-landscape', label: 'Tablet Landscape', width: 1366, height: 1024 },
{ key: 'tablet-portrait', label: 'Tablet Portrait', width: 768, height: 1024 },
{ key: 'mobile-iphone-14', label: 'iPhone 14', width: 390, height: 844 },
{ key: 'mobile-iphone-se', label: 'iPhone SE', width: 375, height: 667 },
{ key: 'mobile-android', label: 'Android', width: 360, height: 800 },
] as const;
function matchPreset(w: number | null, h: number | null) {
if (w == null || h == null) return null;
return DEVICE_PRESETS.find(p => p.width === w && p.height === h) ?? null;
}
export function Toolbar() {
const T = useCanvasTheme();
const { activeTool, setActiveTool } = useCanvas();
const { activeTool, setActiveTool,
selectedArtboardId, selectedArtboardW, selectedArtboardH,
dispatchArtboardResize } = useCanvas();
const zoom = useViewport((s) => s.zoom);
const setZoom = useViewport((s) => s.setZoom);
const reset = useViewport((s) => s.reset);
const [presetOpen, setPresetOpen] = useState(false);
const presetRef = useRef<HTMLDivElement>(null);
// Close dropdown on outside click
useEffect(() => {
if (!presetOpen) return;
const onDown = (e: MouseEvent) => {
if (!presetRef.current?.contains(e.target as Node)) setPresetOpen(false);
};
document.addEventListener('mousedown', onDown);
return () => document.removeEventListener('mousedown', onDown);
}, [presetOpen]);
const activePreset = matchPreset(selectedArtboardW, selectedArtboardH);
const sizeLabel = selectedArtboardW != null && selectedArtboardH != null
? `${selectedArtboardW} × ${selectedArtboardH}`
: null;
return (
<div
style={{
@@ -106,9 +143,90 @@ export function Toolbar() {
</Tooltip>
</ToolGroup>
{/* Device preset picker — only visible when an artboard is selected */}
{selectedArtboardId && sizeLabel && (
<>
<Sep T={T} />
<div ref={presetRef} style={{ position: 'relative' }}>
<button
onClick={() => setPresetOpen(o => !o)}
title="Device preset"
style={{
display: 'flex', alignItems: 'center', gap: 5,
background: presetOpen ? T.accentBg : T.activeBg,
border: `1px solid ${presetOpen ? T.accent + '66' : T.sep}`,
borderRadius: 5, padding: '3px 8px',
fontSize: '0.625rem',
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
color: presetOpen ? T.accent : T.fgMuted,
cursor: 'pointer', letterSpacing: '-0.01em',
transition: 'background 0.1s, color 0.1s, border-color 0.1s',
}}
>
{/* Device icon */}
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style={{ flexShrink: 0 }}>
{selectedArtboardH != null && selectedArtboardW != null && selectedArtboardH > selectedArtboardW ? (
/* Portrait — phone */
<rect x="3" y="0.5" width="6" height="11" rx="1.5" stroke="currentColor" strokeWidth="1"/>
) : (
/* Landscape — desktop/tablet */
<rect x="0.5" y="2" width="11" height="8" rx="1.5" stroke="currentColor" strokeWidth="1"/>
)}
</svg>
<span>{activePreset ? activePreset.label : sizeLabel}</span>
<svg width="7" height="7" viewBox="0 0 8 8" fill="none">
<path d="M2 3l2 2 2-2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
{presetOpen && (
<div style={{
position: 'absolute', top: '100%', left: 0, marginTop: 4,
background: T.bg, border: `1px solid ${T.border}`,
borderRadius: 7, boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
minWidth: 200, zIndex: 100, overflow: 'hidden',
}}>
{DEVICE_PRESETS.map(preset => {
const isActive = activePreset?.key === preset.key;
return (
<button
key={preset.key}
onClick={() => {
if (selectedArtboardId) {
dispatchArtboardResize(selectedArtboardId, preset.width, preset.height);
}
setPresetOpen(false);
}}
style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 12px',
background: isActive ? T.accentBg : 'transparent',
border: 'none', cursor: 'pointer',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.6rem', letterSpacing: '-0.01em',
color: isActive ? T.accent : T.item,
textAlign: 'left',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isActive) (e.currentTarget).style.background = T.hoverBg; }}
onMouseLeave={e => { if (!isActive) (e.currentTarget).style.background = 'transparent'; }}
>
<span>{preset.label}</span>
<span style={{ color: T.dim, fontVariantNumeric: 'tabular-nums' }}>
{preset.width} × {preset.height}
</span>
</button>
);
})}
</div>
)}
</div>
</>
)}
{/* Right side */}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
<Tooltip content="Zoom out" relationship="label">
<Tooltip content="Zoom out Cmd" relationship="label">
<TBtn T={T} active={false} onClick={() => setZoom(zoom * 0.8)}>
<ZoomOutRegular />
</TBtn>
@@ -142,7 +260,7 @@ export function Toolbar() {
{Math.round(zoom * 100)}%
</button>
<Tooltip content="Zoom in" relationship="label">
<Tooltip content="Zoom in Cmd+" relationship="label">
<TBtn T={T} active={false} onClick={() => setZoom(zoom * 1.25)}>
<ZoomInRegular />
</TBtn>
@@ -12,9 +12,16 @@ import { trpc } from '@/lib/trpc';
import { useCanvasTheme } from '@/store/canvasTheme';
import { checkComponentConstraints } from '@originmain/design-language';
import type { Violation } from '@originmain/design-language';
import { generatePatch } from '@originmain/diff-engine';
import type { PropChange } from '@originmain/diff-engine';
import type { FiberNode } from '@originmain/renderer';
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
import { FileDiff as PierreDiff } from '@pierre/diffs/react';
import { processFile, diffAcceptRejectHunk } from '@pierre/diffs';
import type { FileDiffMetadata } from '@pierre/diffs';
import { useIndexer } from '@/hooks/useIndexer';
import { browserClient } from '@/lib/supabase';
import type { SupabaseClient } from '@supabase/supabase-js';
import { FrameSection } from './sections/FrameSection';
import { LayoutSection } from './sections/LayoutSection';
import { FillSection } from './sections/FillSection';
@@ -30,7 +37,7 @@ const TYPE_COLORS: Record<string, string> = {
b: '#FFBA7B',
};
type TabId = 'design' | 'props' | 'diff' | 'graph';
type TabId = 'design' | 'props' | 'code' | 'diff' | 'graph';
export function Inspector() {
const T = useCanvasTheme();
@@ -57,7 +64,7 @@ export function Inspector() {
>
{/* Tab bar */}
<div style={{ display: 'flex', borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
{(['design', 'props', 'diff', 'graph'] as TabId[]).map((t) => (
{(['design', 'props', 'code', 'diff', 'graph'] as TabId[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
@@ -123,6 +130,8 @@ export function Inspector() {
workspaceId={workspaceId}
projectId={projectId}
/>
) : tab === 'code' ? (
<CodeTab componentId={selectedComponentId} componentData={selectedComponentData} artboardId={selectedArtboardId} />
) : tab === 'diff' ? (
<DiffTab artboardId={selectedArtboardId} />
) : (
@@ -1238,6 +1247,386 @@ function DlfViolationBanner({ violations }: { violations: Violation[] }) {
);
}
// ── Source diff helpers ───────────────────────────────────────────────────────
/**
* Best-effort application of PropChange values to a source file string.
* Searches for `propKey: oldValue` patterns and replaces with new values.
* Works for inline style objects and most JSX prop assignments.
*/
function applyChangesToSource(source: string, changes: PropChange[]): string {
let result = source;
for (const change of changes) {
if (change.before === undefined || change.before === change.after) continue;
// Escape the old value for use in regex
const escapedBefore = String(change.before).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match `key: value` (handles optional whitespace and trailing comma)
const re = new RegExp(`(${change.key}\\s*:\\s*)${escapedBefore}`, 'g');
result = result.replace(re, `$1${String(change.after)}`);
}
return result;
}
/* ── Code tab (Phase 4 full implementation) ──────────────────────────────── */
function CodeTab({
componentId,
componentData,
artboardId,
}: {
componentId: string | null;
componentData: FiberNode | null;
artboardId: string | null;
}) {
const T = useCanvasTheme();
const { indexerStatus, undoStyleEdit, patchStyleEdit } = useCanvas();
const { stacks } = useHistory();
const { fetchFile } = useIndexer();
const { createDiff } = useDiffs(artboardId);
const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split');
const [fileDiff, setFileDiff] = useState<FileDiffMetadata | null>(null);
const [patchStr, setPatchStr] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [exportedId, setExportedId] = useState<string | null>(null);
const [intentRtStatus, setIntentRtStatus] = useState<string | null>(null);
// Pending prop changes for this artboard from the edit history
const artboardHistory = artboardId
? (stacks[artboardId] ?? { past: [], future: [] })
: { past: [], future: [] };
const pendingChanges = artboardHistory.past
.flatMap(e => e.changes)
.filter(c => c.changeType !== 'unchanged');
// ── Generate diff whenever callSite / pending changes / indexer status change ─
useEffect(() => {
if (!componentData?.callSite || indexerStatus !== 'ready' || pendingChanges.length === 0) {
setFileDiff(null);
setPatchStr('');
return;
}
let cancelled = false;
setIsLoading(true);
setDiffError(null);
void (async () => {
try {
const filePath = componentData.callSite!.fileName.replace(/\\/g, '/');
// Fetch source — best-effort; null if indexer can't serve it
const sourceContent = await fetchFile(filePath).catch(() => null);
let patch: string;
if (sourceContent) {
// Real diff anchored in the actual source file
const afterContent = applyChangesToSource(sourceContent, pendingChanges);
patch = generatePatch(sourceContent, afterContent, { filename: filePath });
} else {
// Fallback: synthetic diff from prop key/value pairs alone
const beforeText = pendingChanges.map(c => ` ${c.key}: ${String(c.before)},`).join('\n');
const afterText = pendingChanges.map(c => ` ${c.key}: ${String(c.after)},`).join('\n');
patch = generatePatch(beforeText, afterText, { filename: filePath });
}
if (cancelled || !patch) return;
const parsed = processFile(patch);
if (!cancelled) {
setFileDiff(parsed ?? null);
setPatchStr(patch);
}
} catch (err) {
if (!cancelled) setDiffError(err instanceof Error ? err.message : 'Diff generation failed');
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => { cancelled = true; };
// Re-run when the file path or change count shifts; intentionally not
// exhaustive — pendingChanges reference changes every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentData?.callSite?.fileName, pendingChanges.length, indexerStatus, fetchFile]);
// ── Supabase Realtime — watch intent_diffs row for agent status updates ───────
// browserClient() is typed as DbClient (minimal) — cast to SupabaseClient to
// access the Realtime channel API which DbClient intentionally omits.
useEffect(() => {
if (!exportedId) return;
const db = browserClient() as unknown as SupabaseClient;
const channel = db
.channel(`code_tab_intent_${exportedId}`)
.on(
'postgres_changes',
{ event: 'UPDATE', schema: 'public', table: 'intent_diffs', filter: `id=eq.${exportedId}` },
(payload: { new: Record<string, unknown> }) => {
const status = payload.new['status'];
if (typeof status === 'string') setIntentRtStatus(status);
},
)
.subscribe();
return () => { void db.removeChannel(channel); };
}, [exportedId]);
// ── Cmd+Z — undo the last DOM style preview ───────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
const undone = undoStyleEdit();
if (undone) {
e.preventDefault();
patchStyleEdit(undone.artboardId, undone.nodeId, undone.property, undone.previousValue);
}
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [undoStyleEdit, patchStyleEdit]);
// ── Empty state — no component selected ──────────────────────────────────────
if (!componentId) {
return (
<div style={{ padding: '32px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center' }}>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.22 }}>
<polyline points="7,9 2,14 7,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<polyline points="21,9 26,14 21,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<line x1="16" y1="6" x2="12" y2="22" stroke="white" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
Select a component to<br/>view its source
</span>
</div>
);
}
const filePath = componentData?.callSite?.fileName?.replace(/\\/g, '/') ?? null;
const shortPath = filePath ? filePath.split('/').slice(-2).join('/') : null;
const hunkCount = fileDiff?.hunks?.length ?? 0;
async function handleSendToAgent() {
if (!artboardId || !fileDiff || pendingChanges.length === 0 || isSending) return;
setIsSending(true);
try {
const result = await createDiff.mutateAsync({
artboard_id: artboardId,
changes: { propChanges: pendingChanges, styleChanges: [] },
aggregate_summary: `Code diff — ${componentData?.name ?? componentId} (${pendingChanges.length} change${pendingChanges.length !== 1 ? 's' : ''})`,
status: 'EXPORTED',
session_id: '',
exported_code: patchStr || null,
});
setExportedId(result.id);
setIntentRtStatus('EXPORTED');
} catch {
/* surface nothing — mutation error shown via createDiff.isError */
} finally {
setIsSending(false);
}
}
// Status badge colour helpers
const rtColour =
intentRtStatus === 'IMPLEMENTED' ? '#7DD3A8' :
intentRtStatus === 'BLOCKED' ? '#FF6B6B' : '#FFBA7B';
const rtBg =
intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.10)' :
intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.10)' : 'rgba(255,186,123,0.10)';
const rtBorder =
intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.30)' :
intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.30)' : 'rgba(255,186,123,0.30)';
const rtLabel =
intentRtStatus === 'IMPLEMENTED' ? '✓ Implemented by agent' :
intentRtStatus === 'BLOCKED' ? '✗ Blocked — check agent output' :
intentRtStatus ?? '';
const canSend = !!fileDiff && !isSending && pendingChanges.length > 0;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* ── Header: breadcrumb + indexer dot + toggle ──────────────────── */}
<div style={{
padding: '9px 12px',
borderBottom: `1px solid ${T.border}`,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
gap: 7,
}}>
{/* File path + indexer status dot */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.fgMuted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortPath ?? '—'}
{componentData?.callSite?.lineNumber != null && (
<span style={{ color: T.dim }}>:{componentData.callSite.lineNumber}</span>
)}
</span>
<span
title={indexerStatus === 'ready' ? 'CLI indexer ready' : indexerStatus === 'indexing' ? 'Indexing…' : 'CLI indexer offline'}
style={{
display: 'inline-block', width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
background: indexerStatus === 'ready' ? '#7DD3A8' : indexerStatus === 'indexing' ? '#FFBA7B' : T.dim,
boxShadow: indexerStatus === 'ready' ? '0 0 5px rgba(125,211,168,0.7)' : 'none',
transition: 'background 0.25s',
}}
/>
</div>
{/* Component badge + split / unified toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
color: T.accent, background: T.accentBg,
border: `1px solid ${T.accent}33`, borderRadius: 4, padding: '1px 6px',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 100,
}}>
{componentData?.name ?? componentId}
</span>
<span style={{ flex: 1 }} />
{(['split', 'unified'] as const).map(s => (
<button
key={s}
onClick={() => setDiffStyle(s)}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
letterSpacing: '0.04em', textTransform: 'uppercase',
color: diffStyle === s ? T.accent : T.dim,
background: diffStyle === s ? T.accentBg : 'transparent',
border: `1px solid ${diffStyle === s ? T.accent + '44' : 'transparent'}`,
borderRadius: 3, padding: '2px 6px', cursor: 'pointer',
transition: 'color 0.15s, background 0.15s',
}}
>
{s}
</button>
))}
</div>
</div>
{/* ── Diff viewer ──────────────────────────────────────────────────── */}
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{indexerStatus !== 'ready' ? (
<div style={{ padding: '20px 14px' }}>
<div style={{ padding: '10px 12px', background: 'rgba(255,186,123,0.06)', border: '1px solid rgba(255,186,123,0.2)', borderRadius: 7 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: '#FFBA7B', flexShrink: 0 }} />
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FFBA7B', letterSpacing: '0.04em' }}>CLI indexer offline</span>
</div>
<p style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: T.fgDim, lineHeight: 1.6, margin: 0 }}>
Source diffs require the CLI indexer. Run{' '}
<code style={{ fontFamily: "'JetBrains Mono', monospace", color: '#FFBA7B', fontSize: '0.5rem' }}>
npx @originmain/cli dev
</code>{' '}to enable.
</p>
</div>
</div>
) : pendingChanges.length === 0 ? (
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em' }}>
No pending changes
</span>
</div>
) : isLoading ? (
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim }}>Generating diff</span>
</div>
) : diffError ? (
<div style={{ padding: '14px', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FF6B6B' }}>
{diffError}
</div>
) : fileDiff ? (
<div>
{/* @pierre/diffs React diff viewer */}
<PierreDiff
fileDiff={fileDiff}
options={{ diffStyle, lineDiffType: 'char' }}
style={{ fontSize: '0.5625rem' }}
/>
{/* Per-hunk accept / reject controls */}
{hunkCount > 0 && (
<div style={{ padding: '8px 12px', borderTop: `1px solid ${T.border}`, display: 'flex', flexDirection: 'column', gap: 5 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: T.dim, letterSpacing: '0.07em', textTransform: 'uppercase', marginBottom: 2,
}}>
{hunkCount} hunk{hunkCount !== 1 ? 's' : ''}
</span>
{fileDiff.hunks.map((_, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: T.fgMuted, flex: 1 }}>
Hunk {i + 1}
</span>
<button
onClick={() => setFileDiff(diffAcceptRejectHunk(fileDiff, i, 'accept'))}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: '#7DD3A8', background: 'rgba(125,211,168,0.08)',
border: '1px solid rgba(125,211,168,0.28)', borderRadius: 3,
padding: '2px 7px', cursor: 'pointer',
}}
>
accept
</button>
<button
onClick={() => setFileDiff(diffAcceptRejectHunk(fileDiff, i, 'reject'))}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: '#FF6B6B', background: 'rgba(255,107,107,0.08)',
border: '1px solid rgba(255,107,107,0.28)', borderRadius: 3,
padding: '2px 7px', cursor: 'pointer',
}}
>
reject
</button>
</div>
))}
</div>
)}
</div>
) : null}
</div>
{/* ── Footer: Realtime status badge + Send to Agent ─────────────── */}
<div style={{ padding: '9px 12px', borderTop: `1px solid ${T.border}`, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
{/* Realtime intent status badge */}
{intentRtStatus && (
<div style={{
padding: '4px 9px',
background: rtBg,
border: `1px solid ${rtBorder}`,
borderRadius: 5,
}}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: rtColour, letterSpacing: '0.02em' }}>
{rtLabel}
</span>
</div>
)}
{/* Send to Agent button */}
<button
onClick={() => void handleSendToAgent()}
disabled={!canSend}
style={{
width: '100%',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
background: canSend ? T.accent : T.bgDeep,
color: canSend ? '#fff' : T.dim,
border: 'none', borderRadius: 6, padding: '7px 0',
cursor: canSend ? 'pointer' : 'not-allowed',
transition: 'background 0.15s',
}}
>
{isSending ? 'Sending…' : 'Send to Agent'}
</button>
</div>
</div>
);
}
/* ── Diff tab ─────────────────────────────────────────────── */
function DiffTab({ artboardId }: { artboardId: string | null }) {
const T = useCanvasTheme();
@@ -0,0 +1,180 @@
'use client';
/**
* TokenAwareInput — Phase 6
*
* A numeric/text CSS input that watches the loaded design tokens and shows a
* token-match badge when the current value maps to a known token. Clicking the
* badge opens a TokenPicker dropdown to swap to a different token value.
*
* Used in all Design tab section inputs (FrameSection, FillSection, etc.).
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.4 "Token-aware inputs"
*/
import { useState, useRef, useEffect, useCallback } from 'react';
import { useCanvas } from '@/store/canvas';
import { useCanvasTheme } from '@/store/canvasTheme';
import { TokenPicker } from './TokenPicker';
import type { DesignToken, TokenMatch } from '@/store/canvas.types';
// Lazily import the resolver from the design-language package at runtime.
// This avoids a hard dependency at module load time while still getting full
// type safety via the import type pattern.
async function resolveToken(
cssValue: string,
tokens: DesignToken[],
rootFontSizePx: number,
): Promise<TokenMatch | null> {
try {
const { resolveValueToToken } = await import('@originmain/design-language');
return resolveValueToToken(cssValue, tokens, rootFontSizePx) as TokenMatch | null;
} catch {
return null;
}
}
interface TokenAwareInputProps {
/** Current CSS value string (e.g. "16px", "rgb(0,102,255)"). */
value: string;
/** CSS property name — used to filter token candidates by type. */
propKey: string;
/** Called when the user commits a new value (keyboard Enter / blur / token pick). */
onPatch: (prop: string, val: string) => void;
/** Width of the input in px. Default: 60. */
inputWidth?: number;
/** If true, renders a full-width input. Overrides inputWidth. */
fullWidth?: boolean;
/** If true, renders a color picker swatch alongside the input. */
isColor?: boolean;
}
export function TokenAwareInput({
value,
propKey,
onPatch,
inputWidth = 60,
fullWidth = false,
isColor = false,
}: TokenAwareInputProps) {
const T = useCanvasTheme();
const { designLanguageTokens, artboardRootFontSize, selectedArtboardId } = useCanvas();
const rootFontSizePx = selectedArtboardId ? (artboardRootFontSize[selectedArtboardId] ?? 16) : 16;
const [draft, setDraft] = useState(value);
const [match, setMatch] = useState<TokenMatch | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const prevValueRef = useRef(value);
// Sync draft when external value changes
if (prevValueRef.current !== value) {
prevValueRef.current = value;
setDraft(value);
}
// Resolve token whenever value or token library changes
useEffect(() => {
if (!designLanguageTokens || designLanguageTokens.length === 0) {
setMatch(null);
return;
}
let cancelled = false;
void resolveToken(value, designLanguageTokens, rootFontSizePx).then((m) => {
if (!cancelled) setMatch(m);
});
return () => { cancelled = true; };
}, [value, designLanguageTokens, rootFontSizePx]);
const commit = useCallback((v: string) => {
onPatch(propKey, v);
}, [onPatch, propKey]);
const handleTokenSelect = useCallback((token: DesignToken) => {
setDraft(token.rawValue);
commit(token.rawValue);
setPickerOpen(false);
}, [commit]);
return (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 4 }}>
{/* Main input */}
<input
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={() => commit(draft)}
onKeyDown={e => {
if (e.key === 'Enter') { commit(draft); e.currentTarget.blur(); }
if (e.key === 'Escape') setDraft(value);
e.stopPropagation();
}}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${match?.exact ? T.accent + '55' : T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: fullWidth ? '100%' : inputWidth,
outline: 'none',
textAlign: 'right',
boxSizing: 'border-box',
transition: 'border-color 0.15s',
}}
/>
{/* Token match badge */}
{match && designLanguageTokens && (
<button
title={`Token: ${match.token.name}\n${match.token.key}\n${match.exact ? 'Exact match' : `Distance: ${match.distance.toFixed(1)}`}`}
onClick={() => setPickerOpen((o) => !o)}
style={{
background: match.exact ? T.accentBg : 'rgba(255,186,123,0.12)',
border: `1px solid ${match.exact ? T.accent + '44' : 'rgba(255,186,123,0.3)'}`,
borderRadius: 3,
padding: '2px 4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 3,
flexShrink: 0,
}}
>
{/* Colour swatch for colour tokens */}
{match.token.type === 'color' && (
<div style={{
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
background: match.token.rawValue,
border: '1px solid rgba(255,255,255,0.2)',
}} />
)}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.4rem',
color: match.exact ? T.accent : '#FFBA7B',
letterSpacing: '-0.01em',
maxWidth: 48,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: 1,
}}>
{match.token.key.replace(/^--/, '')}
</span>
</button>
)}
{/* TokenPicker dropdown */}
{pickerOpen && designLanguageTokens && (
<TokenPicker
cssValue={value}
propKey={propKey}
tokens={designLanguageTokens}
rootFontSizePx={rootFontSizePx}
onSelect={handleTokenSelect}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,292 @@
'use client';
/**
* TokenPicker — Phase 6
*
* Dropdown panel listing the closest token matches for the current CSS value.
* Opened by TokenAwareInput when the user clicks the token badge.
* Clicking a row patches the value and closes the picker.
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.4
*/
import { useState, useEffect, useRef } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import type { DesignToken, TokenMatch } from '@/store/canvas.types';
async function resolveTokens(
cssValue: string,
tokens: DesignToken[],
rootFontSizePx: number,
): Promise<TokenMatch[]> {
try {
const { resolveValueToTokens } = await import('@originmain/design-language');
return (resolveValueToTokens(cssValue, tokens, rootFontSizePx, 8) as TokenMatch[]);
} catch {
return [];
}
}
interface TokenPickerProps {
cssValue: string;
propKey: string;
tokens: DesignToken[];
rootFontSizePx: number;
onSelect: (token: DesignToken) => void;
onClose: () => void;
}
export function TokenPicker({
cssValue,
propKey,
tokens,
rootFontSizePx,
onSelect,
onClose,
}: TokenPickerProps) {
const T = useCanvasTheme();
const [candidates, setCandidates] = useState<TokenMatch[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const ref = useRef<HTMLDivElement>(null);
// Load candidates
useEffect(() => {
setLoading(true);
void resolveTokens(cssValue, tokens, rootFontSizePx).then((matches) => {
setCandidates(matches);
setLoading(false);
});
}, [cssValue, tokens, rootFontSizePx]);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) onClose();
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [onClose]);
// Filter by search term
const filtered = search
? candidates.filter(
(m) =>
m.token.name.toLowerCase().includes(search.toLowerCase()) ||
m.token.key.toLowerCase().includes(search.toLowerCase()),
)
: candidates;
// Also show browseable tokens filtered by prop category when no close matches found
const browseable: TokenMatch[] = filtered.length === 0 && search
? tokens
.filter(
(t) =>
t.name.toLowerCase().includes(search.toLowerCase()) ||
t.key.toLowerCase().includes(search.toLowerCase()),
)
.slice(0, 8)
.map((token) => ({ token, exact: false, distance: Infinity }))
: [];
const rows = [...filtered, ...browseable];
return (
<div
ref={ref}
style={{
position: 'absolute',
top: '100%',
right: 0,
marginTop: 4,
width: 220,
background: T.bg,
border: `1px solid ${T.border}`,
borderRadius: 7,
boxShadow: '0 8px 24px rgba(0,0,0,0.45)',
zIndex: 200,
overflow: 'hidden',
}}
>
{/* Header */}
<div style={{
padding: '7px 10px 5px',
borderBottom: `1px solid ${T.sep}`,
display: 'flex',
flexDirection: 'column',
gap: 5,
}}>
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5rem',
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: T.dim,
}}>
Token Picker · {propKey.replace(/^--/, '')}
</span>
{/* Search */}
<input
autoFocus
placeholder="Search tokens…"
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => { if (e.key === 'Escape') onClose(); e.stopPropagation(); }}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 7px',
outline: 'none',
width: '100%',
boxSizing: 'border-box',
}}
/>
</div>
{/* Candidates list */}
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
{loading ? (
<div style={{
padding: '12px 10px',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.dim,
textAlign: 'center',
}}>
Resolving
</div>
) : rows.length === 0 ? (
<div style={{
padding: '12px 10px',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.dim,
textAlign: 'center',
}}>
No matching tokens
</div>
) : (
rows.map((m) => (
<TokenRow
key={m.token.key}
match={m}
onSelect={() => onSelect(m.token)}
/>
))
)}
</div>
</div>
);
}
function TokenRow({
match,
onSelect,
}: {
match: TokenMatch;
onSelect: () => void;
}) {
const T = useCanvasTheme();
const [hov, setHov] = useState(false);
const { token, exact, distance } = match;
return (
<button
onClick={onSelect}
onMouseEnter={() => setHov(true)}
onMouseLeave={() => setHov(false)}
style={{
display: 'flex',
alignItems: 'center',
gap: 7,
width: '100%',
padding: '6px 10px',
background: hov ? T.hoverBg : 'transparent',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
transition: 'background 0.1s',
}}
>
{/* Swatch for colors, or a type icon for others */}
{token.type === 'color' ? (
<div style={{
width: 16, height: 16, borderRadius: 3, flexShrink: 0,
background: token.rawValue,
border: '1px solid rgba(255,255,255,0.15)',
boxShadow: exact ? `0 0 0 2px ${T.accent}55` : 'none',
}} />
) : (
<div style={{
width: 16, height: 16, borderRadius: 3, flexShrink: 0,
background: T.bgDeep,
border: `1px solid ${T.sep}`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.45rem', color: T.dim,
}}>
{token.type === 'spacing' ? 'sp' :
token.type === 'fontSize' ? 'f' :
token.type === 'fontWeight' ? 'fw' : '·'}
</div>
)}
{/* Token name + key */}
<div style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
<div style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5625rem',
color: exact ? T.accent : T.fg,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
}}>
{token.name}
</div>
<div style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.475rem',
color: T.dim,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
}}>
{token.rawValue}
</div>
</div>
{/* Match indicator */}
<div style={{ flexShrink: 0, textAlign: 'right' }}>
{exact ? (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.45rem',
color: T.accent,
background: T.accentBg,
border: `1px solid ${T.accent}33`,
borderRadius: 3,
padding: '1px 3px',
}}>
exact
</span>
) : distance !== Infinity ? (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.45rem',
color: T.dim,
}}>
Δ{distance.toFixed(1)}
</span>
) : null}
</div>
</button>
);
}
@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, type ReactNode } from 'react';
import { useState, useCallback, useMemo, type ReactNode } from 'react';
import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import { SquareRegular } from '@fluentui/react-icons';
@@ -10,13 +10,37 @@ import { useQueryClient } from '@tanstack/react-query';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useTheme } from '@/store/theme';
type NavTab = 'artboards' | 'routes';
export function ArtboardNavigator() {
const T = useCanvasTheme();
const mode = useTheme((s) => s.mode);
const { selectedArtboardId, selectArtboard, workspaceId, projectId, liveArtboardIds, artboardFiberRoots } = useCanvas();
const [navTab, setNavTab] = useState<NavTab>('artboards');
const { selectedArtboardId, selectArtboard, workspaceId, projectId, liveArtboardIds, artboardFiberRoots, discoveredRoutes } = useCanvas();
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const queryClient = useQueryClient();
// Aggregate unique routes across all artboards, deduplicated by path
const allRoutes = useMemo(() => {
const seen = new Set<string>();
const result: Array<{ path: string; label: string; artboardId: string }> = [];
for (const [artboardId, routes] of Object.entries(discoveredRoutes)) {
for (const r of routes) {
if (!seen.has(r.path)) {
seen.add(r.path);
result.push({ ...r, artboardId });
}
}
}
return result.sort((a, b) => a.path.localeCompare(b.path));
}, [discoveredRoutes]);
// Build a set of routes already covered by an artboard (for the "+" button logic)
const coveredRoutes = useMemo(
() => new Set(artboards.map((ab) => ab.route ?? '/')),
[artboards],
);
// Map the panel theme into Trees' CSS custom properties — recomputed when mode changes
const treeThemeStyles = themeToTreeStyles(mode === 'dark' ? {
type: 'dark',
@@ -122,32 +146,50 @@ export function ArtboardNavigator() {
fontSize: 12,
}}
>
{/* ── Artboards ── */}
<SectionLabel>Artboards</SectionLabel>
<div style={{ padding: '2px 6px 0' }}>
{artboards.map((ab) => {
const sel = selectedArtboardId === ab.id;
const live = liveArtboardIds.has(ab.id);
return (
<NavRow
key={ab.id}
T={T}
selected={sel}
live={live}
onClick={() => selectArtboard(ab.id)}
icon={
<SquareRegular
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
/>
}
label={ab.label}
onRename={() => void renameArtboard(ab.id, ab.label)}
onFork={() => void forkArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/>
);
})}
</div>
{/* ── Tab switcher: Artboards | Routes ── */}
<TabSwitcher T={T} active={navTab} onChange={setNavTab} routeCount={allRoutes.length} />
{/* ── Artboards tab ── */}
{navTab === 'artboards' && (
<div style={{ padding: '2px 6px 0' }}>
{artboards.map((ab) => {
const sel = selectedArtboardId === ab.id;
const live = liveArtboardIds.has(ab.id);
return (
<NavRow
key={ab.id}
T={T}
selected={sel}
live={live}
onClick={() => selectArtboard(ab.id)}
icon={
<SquareRegular
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
/>
}
label={ab.label}
onRename={() => void renameArtboard(ab.id, ab.label)}
onFork={() => void forkArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/>
);
})}
</div>
)}
{/* ── Routes tab ── */}
{navTab === 'routes' && (
<RoutesTab
T={T}
routes={allRoutes}
coveredRoutes={coveredRoutes}
artboards={artboards}
workspaceId={workspaceId}
projectId={projectId}
queryClient={queryClient}
selectArtboard={selectArtboard}
/>
)}
<HSep />
@@ -183,9 +225,277 @@ export function ArtboardNavigator() {
);
}
/* ── Artboard row ─────────────────────────────────────────── */
/* ── Tab switcher ─────────────────────────────────────────── */
import type { CanvasTokens } from '@/store/canvasTheme';
function TabSwitcher({
T,
active,
onChange,
routeCount,
}: {
T: CanvasTokens;
active: NavTab;
onChange: (tab: NavTab) => void;
routeCount: number;
}) {
const tabs: { key: NavTab; label: string }[] = [
{ key: 'artboards', label: 'Artboards' },
{ key: 'routes', label: 'Routes' },
];
return (
<div style={{
display: 'flex',
gap: 2,
padding: '10px 10px 4px',
flexShrink: 0,
}}>
{tabs.map(({ key, label }) => {
const isActive = active === key;
return (
<button
key={key}
onClick={() => onChange(key)}
style={{
flex: 1,
padding: '4px 0',
background: isActive ? T.accentBg : 'transparent',
border: `1px solid ${isActive ? T.accent + '55' : T.sep}`,
borderRadius: 5,
cursor: 'pointer',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5625rem',
fontWeight: isActive ? 600 : 400,
letterSpacing: '0.05em',
textTransform: 'uppercase',
color: isActive ? T.accent : T.dim,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
transition: 'background 0.1s, color 0.1s, border-color 0.1s',
}}
>
{label}
{key === 'routes' && routeCount > 0 && (
<span style={{
background: isActive ? T.accent + '33' : T.sep,
color: isActive ? T.accent : T.fgDim,
borderRadius: 3,
padding: '0 3px',
fontSize: '0.5rem',
fontWeight: 700,
lineHeight: '14px',
minWidth: 14,
textAlign: 'center',
}}>
{routeCount}
</span>
)}
</button>
);
})}
</div>
);
}
/* ── Routes tab ───────────────────────────────────────────── */
function RoutesTab({
T,
routes,
coveredRoutes,
artboards,
workspaceId,
projectId,
queryClient,
selectArtboard,
}: {
T: CanvasTokens;
routes: Array<{ path: string; label: string; artboardId: string }>;
coveredRoutes: Set<string>;
artboards: Array<{ id: string; label: string; route?: string; x: number; y: number; width: number; height: number; renderUrl?: string }>;
workspaceId: string | null;
projectId: string | null;
queryClient: ReturnType<typeof useQueryClient>;
selectArtboard: (id: string | null) => void;
}) {
const handleCreateArtboard = useCallback(async (route: { path: string; label: string; artboardId: string }) => {
if (!workspaceId) return;
const sourceAb = artboards.find((ab) => ab.id === route.artboardId);
if (!sourceAb) return;
const GAP = 80;
const rightEdge = artboards.reduce(
(max, ab) => Math.max(max, ab.x + ab.width),
sourceAb.x + sourceAb.width,
);
try {
await createArtboardMutation({
workspace_id: workspaceId,
project_id: projectId ?? null,
name: route.label,
origin_id: null,
parent_artboard_id: null,
metadata_jsonb: {
x: rightEdge + GAP,
y: sourceAb.y,
width: sourceAb.width,
height: sourceAb.height,
renderUrl: sourceAb.renderUrl,
route: route.path,
},
});
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
} catch (err) {
console.error('[Navigator] Failed to create artboard for route', err);
}
}, [artboards, workspaceId, projectId, queryClient]);
if (routes.length === 0) {
return (
<div style={{
padding: '16px 14px',
fontFamily: "'Inter', sans-serif",
fontSize: '0.625rem',
color: T.fgDim,
lineHeight: 1.6,
textAlign: 'center',
}}>
<div style={{ marginBottom: 6, fontSize: '1rem', opacity: 0.4 }}>🔌</div>
No routes discovered yet.
<br />
Connect a live artboard to auto-discover pages.
</div>
);
}
return (
<div style={{ padding: '4px 6px 0', overflow: 'auto', flex: 1, minHeight: 0 }}>
{routes.map((route) => {
const isCovered = coveredRoutes.has(route.path);
const existingAb = artboards.find((ab) => (ab.route ?? '/') === route.path);
return (
<RouteRow
key={route.path}
T={T}
path={route.path}
label={route.label}
isCovered={isCovered}
{...(existingAb ? { onOpen: () => selectArtboard(existingAb.id) } : {})}
{...(!isCovered ? { onAdd: () => void handleCreateArtboard(route) } : {})}
/>
);
})}
</div>
);
}
function RouteRow({
T,
path,
label,
isCovered,
onOpen,
onAdd,
}: {
T: CanvasTokens;
path: string;
label: string;
isCovered: boolean;
onOpen?: () => void;
onAdd?: () => void;
}) {
const [hov, setHov] = useState(false);
return (
<div
onMouseEnter={() => setHov(true)}
onMouseLeave={() => setHov(false)}
onClick={onOpen}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 6px 4px 10px',
borderRadius: 5,
cursor: onOpen ? 'pointer' : 'default',
background: hov && onOpen ? T.hoverBg : 'transparent',
marginBottom: 1,
transition: 'background 0.1s',
}}
>
{/* Route path icon */}
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" style={{ flexShrink: 0 }}>
<path d="M1 4.5h7M4.5 1.5l3 3-3 3" stroke={isCovered ? T.accent : T.dim} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
<div style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
<div style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
color: isCovered ? T.fg : T.fgMuted,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
letterSpacing: '-0.01em',
}}>
{path}
</div>
{label !== path && (
<div style={{
fontFamily: "'Inter', sans-serif",
fontSize: '0.5625rem',
color: T.dim,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{label}
</div>
)}
</div>
{/* Status badge or add button */}
{isCovered ? (
<span style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5rem',
color: T.accent,
background: T.accentBg,
border: `1px solid ${T.accent}33`,
borderRadius: 3,
padding: '1px 4px',
flexShrink: 0,
letterSpacing: '0.05em',
}}>
</span>
) : (
<button
onClick={(e) => { e.stopPropagation(); onAdd?.(); }}
title="Create artboard for this route"
style={{
background: hov ? T.accentBg : 'transparent',
border: `1px solid ${hov ? T.accent + '55' : T.sep}`,
borderRadius: 3,
padding: '2px 5px',
cursor: 'pointer',
color: hov ? T.accent : T.dim,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5625rem',
fontWeight: 700,
flexShrink: 0,
lineHeight: 1,
transition: 'background 0.1s, color 0.1s, border-color 0.1s',
}}
>
+
</button>
)}
</div>
);
}
/* ── Artboard row ─────────────────────────────────────────── */
function NavRow({
T,
selected = false,
@@ -0,0 +1,11 @@
// ── Artboard iframe map ───────────────────────────────────────────────────────
// Module-level singleton: maps artboard ID → live HTMLIFrameElement.
//
// DOM references must never be stored in Zustand — they are not serialisable,
// prevent garbage collection, and break React DevTools. Each <LiveArtboard>
// registers its iframeRef.current on mount and removes it on unmount.
//
// The canvas dispatches postMessage via:
// artboardIframeMap.get(selectedArtboardId)?.contentWindow?.postMessage(…)
export const artboardIframeMap = new Map<string, HTMLIFrameElement>();
+232
View File
@@ -0,0 +1,232 @@
/**
* diff-generator.ts — Phase 4
*
* Converts style-edit patches (from the canvas store's styleEditQueue) into
* FileDiffMetadata objects that @pierre/diffs can render as interactive hunks.
*
* Three strategies are supported (spec §8.3):
* css — inline CSS custom property overrides in a virtual .css file
* prop — JSX prop changes in the component call-site .tsx file
* tailwind — Tailwind class string replacement in the component call-site
*
* spec: SOURCE-AWARE-CANVAS.md Phase 4 §8 "Intent Diff"
*/
import { processFile } from '@pierre/diffs';
import type { FileDiffMetadata } from '@pierre/diffs';
import type { FiberNode } from '@originmain/renderer';
export type DiffStrategy = 'css' | 'prop' | 'tailwind';
export interface StylePatch {
property: string;
value: string;
/** Previous value — populated from the styleUndoStack or live styles. */
previousValue?: string;
}
export interface GeneratedFileDiff {
/** The @pierre/diffs metadata ready to hand to <FileDiff> */
fileDiff: FileDiffMetadata;
/** Virtual filename used (e.g. "src/Button.module.css") */
filename: string;
/** Strategy used to produce this diff */
strategy: DiffStrategy;
}
// ── Strategy: css ─────────────────────────────────────────────────────────────
// Generates a CSS custom-property block showing what changed.
// Virtual filename: derived from the component's call-site or "<component>.css".
function buildCssDiff(
componentName: string,
callSiteFile: string | undefined,
patches: StylePatch[],
): GeneratedFileDiff | null {
const virtualName = callSiteFile
? callSiteFile.replace(/\.(tsx|jsx|ts|js)$/, '.module.css')
: `${componentName}.module.css`;
// Old: previous values (or empty if unknown)
const oldLines = [
`.${componentName} {`,
...patches.map((p) =>
p.previousValue
? ` ${cssPropertyName(p.property)}: ${p.previousValue};`
: ` /* ${cssPropertyName(p.property)}: <previous value not captured> */`,
),
'}',
];
// New: updated values
const newLines = [
`.${componentName} {`,
...patches.map((p) => ` ${cssPropertyName(p.property)}: ${p.value};`),
'}',
];
const oldContents = oldLines.join('\n');
const newContents = newLines.join('\n');
const fileDiff = processFile('', {
oldFile: { name: virtualName, contents: oldContents },
newFile: { name: virtualName, contents: newContents },
});
if (!fileDiff) return null;
return { fileDiff, filename: virtualName, strategy: 'css' };
}
// ── Strategy: prop ────────────────────────────────────────────────────────────
// Generates a JSX snippet showing style prop changes on the component element.
// This is a simplified representation; Phase 4+ CLI integration will replace
// these with real AST-rewritten patches at the actual call-site.
function buildPropDiff(
componentName: string,
callSiteFile: string | undefined,
patches: StylePatch[],
): GeneratedFileDiff | null {
const virtualName = callSiteFile ?? `${componentName}.tsx`;
const styleOld = patches
.filter((p) => p.previousValue)
.map((p) => ` ${camelCase(p.property)}: '${p.previousValue}'`)
.join(',\n');
const styleNew = patches
.map((p) => ` ${camelCase(p.property)}: '${p.value}'`)
.join(',\n');
const oldContents = styleOld
? `<${componentName}\n style={{\n${styleOld},\n }}\n/>`
: `<${componentName} />`;
const newContents = styleNew
? `<${componentName}\n style={{\n${styleNew},\n }}\n/>`
: `<${componentName} />`;
const fileDiff = processFile('', {
oldFile: { name: virtualName, contents: oldContents },
newFile: { name: virtualName, contents: newContents },
});
if (!fileDiff) return null;
return { fileDiff, filename: virtualName, strategy: 'prop' };
}
// ── Strategy: tailwind ────────────────────────────────────────────────────────
// Converts CSS property patches into approximate Tailwind utility additions.
// The mapping is heuristic — a real implementation would require a Tailwind
// config lookup via the CLI indexer (Phase 3 integration).
function buildTailwindDiff(
componentName: string,
callSiteFile: string | undefined,
patches: StylePatch[],
): GeneratedFileDiff | null {
const virtualName = callSiteFile ?? `${componentName}.tsx`;
const oldClasses = patches
.filter((p) => p.previousValue)
.map((p) => cssToTailwindApprox(p.property, p.previousValue ?? ''))
.filter(Boolean)
.join(' ');
const newClasses = patches
.map((p) => cssToTailwindApprox(p.property, p.value))
.filter(Boolean)
.join(' ');
const baseClasses = 'flex items-center'; // placeholder existing classes
const oldContents = `<${componentName} className="${[baseClasses, oldClasses].filter(Boolean).join(' ')}" />`;
const newContents = `<${componentName} className="${[baseClasses, newClasses].filter(Boolean).join(' ')}" />`;
const fileDiff = processFile('', {
oldFile: { name: virtualName, contents: oldContents },
newFile: { name: virtualName, contents: newContents },
});
if (!fileDiff) return null;
return { fileDiff, filename: virtualName, strategy: 'tailwind' };
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Generates a FileDiffMetadata for the given patches using the chosen strategy.
* Returns null if the diff is empty (no actual changes).
*/
export function buildFileDiffMetadata(
patches: StylePatch[],
strategy: DiffStrategy,
componentData: FiberNode | null,
): GeneratedFileDiff | null {
if (patches.length === 0) return null;
const componentName = componentData?.name ?? 'Component';
const callSiteFile = componentData?.callSite?.fileName;
switch (strategy) {
case 'css':
return buildCssDiff(componentName, callSiteFile, patches);
case 'prop':
return buildPropDiff(componentName, callSiteFile, patches);
case 'tailwind':
return buildTailwindDiff(componentName, callSiteFile, patches);
}
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/** camelCase CSS property name: "background-color" → "backgroundColor" */
function camelCase(prop: string): string {
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
/** Keep CSS property name as-is (kebab-case). */
function cssPropertyName(prop: string): string {
return prop;
}
/**
* Very rough CSS → Tailwind approximation for the tailwind diff strategy.
* In Phase 3+ this should be replaced with a proper lookup via the CLI indexer.
*/
function cssToTailwindApprox(property: string, value: string): string {
// Strip units for numeric comparisons
const num = parseFloat(value);
const px = value.endsWith('px') ? num : null;
switch (property) {
case 'color': return `text-[${value}]`;
case 'background-color':
case 'background': return `bg-[${value}]`;
case 'font-size': return px !== null ? `text-[${px}px]` : `text-[${value}]`;
case 'font-weight': return `font-[${value}]`;
case 'padding': return px !== null ? `p-[${px}px]` : `p-[${value}]`;
case 'padding-top': return px !== null ? `pt-[${px}px]` : '';
case 'padding-right': return px !== null ? `pr-[${px}px]` : '';
case 'padding-bottom': return px !== null ? `pb-[${px}px]` : '';
case 'padding-left': return px !== null ? `pl-[${px}px]` : '';
case 'margin': return px !== null ? `m-[${px}px]` : `m-[${value}]`;
case 'margin-top': return px !== null ? `mt-[${px}px]` : '';
case 'margin-right': return px !== null ? `mr-[${px}px]` : '';
case 'margin-bottom': return px !== null ? `mb-[${px}px]` : '';
case 'margin-left': return px !== null ? `ml-[${px}px]` : '';
case 'width': return px !== null ? `w-[${px}px]` : `w-[${value}]`;
case 'height': return px !== null ? `h-[${px}px]` : `h-[${value}]`;
case 'border-radius': return px !== null ? `rounded-[${px}px]` : `rounded-[${value}]`;
case 'gap': return px !== null ? `gap-[${px}px]` : `gap-[${value}]`;
case 'opacity': return `opacity-[${value}]`;
case 'display':
if (value === 'flex') return 'flex';
if (value === 'grid') return 'grid';
if (value === 'none') return 'hidden';
return '';
case 'flex-direction':
if (value === 'column') return 'flex-col';
if (value === 'row-reverse') return 'flex-row-reverse';
if (value === 'column-reverse') return 'flex-col-reverse';
return 'flex-row';
default:
return '';
}
}
+91 -5
View File
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import type { FiberNode } from '@originmain/renderer';
import type { Violation } from '@originmain/design-language';
import type { DesignToken } from './canvas.types';
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
export interface ProjectMeta {
@@ -92,15 +93,58 @@ interface CanvasStore {
setActiveViolations: (violations: Violation[]) => void;
// ── Active agent session (spec Layer 6 — diff attribution) ──────────────────
// Set by the Agent Bridge when a session starts/ends. Inspector and
// CompletionZone read this to populate session_id on intent_diffs so the
// Agent Bridge can later query diffs by session (getDiffsByStatus etc.).
// null = no agent session active; user-created diffs get session_id ''.
activeAgentSessionId: string | null;
setActiveAgentSessionId: (id: string | null) => void;
// ── Phase 0: Discovered routes (aggregated from ROUTES_DISCOVERED messages) ───
/** Map of artboardId → routes discovered by that artboard's live app. */
discoveredRoutes: Record<string, Array<{ path: string; label: string }>>;
setDiscoveredRoutes: (artboardId: string, routes: Array<{ path: string; label: string }>) => void;
// ── Selected artboard dimensions (Phase 0 device preset picker) ──────────────
// Set by Artboard.tsx whenever the selected artboard renders, so that Toolbar
// can read current width/height without needing workspaceId/projectId.
selectedArtboardW: number | null;
selectedArtboardH: number | null;
setSelectedArtboardSize: (w: number, h: number) => void;
/** One-shot resize event: Artboard.tsx watches this and fires a PATCH, then clears. */
artboardResizeEvent: { artboardId: string; width: number; height: number } | null;
dispatchArtboardResize: (artboardId: string, width: number, height: number) => void;
clearArtboardResize: () => void;
// ── Phase 4 — Intent diff tracking ───────────────────────────────────────────
/** Map of intentId → status: 'EXPORTED' | 'IMPLEMENTED' | 'BLOCKED' */
intentStatus: Record<string, string>;
setIntentStatus: (intentId: string, status: string) => void;
/** Undo queue for DOM style previews (Cmd+Z support). */
styleUndoStack: Array<{ artboardId: string; nodeId: string; property: string; previousValue: string }>;
pushStyleUndo: (artboardId: string, nodeId: string, property: string, previousValue: string) => void;
undoStyleEdit: () => { artboardId: string; nodeId: string; property: string; previousValue: string } | null;
// ── Phase 6 — Design Language (DesignToken[] from parser/resolver) ────────────
/** Loaded design tokens after user uploads a token file — null until uploaded. */
designLanguageTokens: DesignToken[] | null;
setDesignLanguageTokens: (tokens: DesignToken[] | null) => void;
/** Root font size in px per artboard — read from rootFontSizePx in READY message.
* Used by the token resolver to normalise rem → px. */
artboardRootFontSize: Record<string, number>;
setArtboardRootFontSize: (artboardId: string, px: number) => void;
// ── Phase 0 — Artboard thumbnails ────────────────────────────────────────────
/** Base64 JPEG data-URL snapshots per artboard — captured when transitioning Active→Far.
* Displayed as a static image placeholder while the iframe is unmounted (far state). */
artboardThumbnails: Record<string, string | null>;
setArtboardThumbnail: (artboardId: string, dataUrl: string | null) => void;
// ── Phase 4 — Element snapshot (for Code Preview diff) ───────────────────────
/** Most-recent PNG snapshot response from SNAPSHOT_READY — consumed by CodeTab. */
elementSnapshot: { artboardId: string; nodeId: string; dataUrl: string | null } | null;
setElementSnapshot: (artboardId: string, nodeId: string, dataUrl: string | null) => void;
}
export const useCanvas = create<CanvasStore>((set) => ({
export const useCanvas = create<CanvasStore>((set, get) => ({
activeTool: 'select',
setActiveTool: (tool) => set({ activeTool: tool }),
@@ -176,4 +220,46 @@ export const useCanvas = create<CanvasStore>((set) => ({
activeAgentSessionId: null,
setActiveAgentSessionId: (id) => set({ activeAgentSessionId: id }),
discoveredRoutes: {},
setDiscoveredRoutes: (artboardId, routes) =>
set((s) => ({ discoveredRoutes: { ...s.discoveredRoutes, [artboardId]: routes } })),
selectedArtboardW: null,
selectedArtboardH: null,
setSelectedArtboardSize: (w, h) => set({ selectedArtboardW: w, selectedArtboardH: h }),
artboardResizeEvent: null,
dispatchArtboardResize: (artboardId, width, height) =>
set({ artboardResizeEvent: { artboardId, width, height } }),
clearArtboardResize: () => set({ artboardResizeEvent: null }),
intentStatus: {},
setIntentStatus: (intentId, status) =>
set((s) => ({ intentStatus: { ...s.intentStatus, [intentId]: status } })),
styleUndoStack: [],
pushStyleUndo: (artboardId, nodeId, property, previousValue) =>
set((s) => ({ styleUndoStack: [...s.styleUndoStack, { artboardId, nodeId, property, previousValue }] })),
undoStyleEdit: () => {
const stack = get().styleUndoStack;
if (stack.length === 0) return null;
const item = stack[stack.length - 1]!;
set({ styleUndoStack: stack.slice(0, -1) });
return item;
},
designLanguageTokens: null,
setDesignLanguageTokens: (tokens) => set({ designLanguageTokens: tokens }),
artboardRootFontSize: {},
setArtboardRootFontSize: (artboardId, px) =>
set((s) => ({ artboardRootFontSize: { ...s.artboardRootFontSize, [artboardId]: px } })),
artboardThumbnails: {},
setArtboardThumbnail: (artboardId, dataUrl) =>
set((s) => ({ artboardThumbnails: { ...s.artboardThumbnails, [artboardId]: dataUrl } })),
elementSnapshot: null,
setElementSnapshot: (artboardId, nodeId, dataUrl) => set({ elementSnapshot: { artboardId, nodeId, dataUrl } }),
}));
+43
View File
@@ -0,0 +1,43 @@
// ── Canvas store shared types ─────────────────────────────────────────────────
// Kept in a separate file so canvas.ts can import them in a type-only position
// without circular dependency issues.
export type TokenType =
| 'color'
| 'spacing'
| 'sizing'
| 'borderRadius'
| 'borderWidth'
| 'fontFamily'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'shadow'
| 'opacity'
| 'other';
/** A single normalised design token from any of the three supported input formats
* (Style Dictionary, W3C DTCG, flat CSS variable map — see Phase 6 spec §9.2). */
export interface DesignToken {
/** CSS custom property name: "--color-primary" */
key: string;
/** Human label: "Color / Primary" */
name: string;
/** Top-level group: "color", "spacing", etc. */
group: string;
/** Resolved CSS value: "#0066FF" */
rawValue: string;
type: TokenType;
description?: string;
/** If resolved from an alias chain, lists each intermediate key. */
aliasChain?: string[];
}
export interface TokenMatch {
token: DesignToken;
/** Value matches token exactly (distance === 0). */
exact: boolean;
/** 0 = exact; higher = further from a match. */
distance: number;
}
File diff suppressed because one or more lines are too long
+201
View File
@@ -0,0 +1,201 @@
/**
* security.test.ts — Phase 7 Security Smoke Tests
*
* Tests the two key security boundaries:
* 1. Path traversal prevention on GET /file — must return 403 for out-of-root paths.
* 2. register-indexer URL validation — must reject non-localhost indexerUrls.
*
* Uses Node.js built-in `node:test` (available in Node 18+).
*
* Run with: node --loader ts-node/esm src/__tests__/security.test.ts
* or (after build): node dist/__tests__/security.test.js
*
* spec: SOURCE-AWARE-CANVAS.md Phase 7 §10.1 "Security smoke test"
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import type { Server } from 'node:http';
// ── Helpers ───────────────────────────────────────────────────────────────────
async function getJson(url: string): Promise<{ status: number; body: unknown }> {
const res = await fetch(url);
let body: unknown;
try { body = await res.json(); }
catch { body = await res.text().catch(() => null); }
return { status: res.status, body };
}
async function postJson(url: string, data: unknown): Promise<{ status: number; body: unknown }> {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(data),
});
let body: unknown;
try { body = await res.json(); }
catch { body = await res.text().catch(() => null); }
return { status: res.status, body };
}
/**
* Start a minimal HTTP server that simulates the index-server's /file endpoint.
* Mirrors the path-traversal check in the real index-server.ts.
*/
async function startMockIndexServer(projectRoot: string): Promise<{ port: number; close: () => void }> {
const { resolve, join } = await import('node:path');
const { realpathSync, existsSync } = await import('node:fs');
const safeRoot = (() => {
try { return realpathSync(projectRoot); } catch { return projectRoot; }
})();
const server: Server = createServer((req, res) => {
const url = new URL(req.url ?? '/', 'http://localhost');
const filePath = url.searchParams.get('path') ?? '';
// ── Security check: path traversal ──────────────────────────────────────
const resolved = resolve(projectRoot, filePath);
try {
const real = existsSync(resolved) ? realpathSync(resolved) : resolved;
if (!real.startsWith(safeRoot + '/') && real !== safeRoot) {
res.writeHead(403, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
return;
}
} catch {
res.writeHead(403, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
return;
}
// Simulate a found file
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ content: '// ok', filePath: join(projectRoot, filePath) }));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
resolve({
port,
close: () => server.close(),
});
});
});
}
// ── Path traversal tests ──────────────────────────────────────────────────────
describe('Path traversal prevention', () => {
test('GET /file?path=../../.env returns 403', async () => {
const { tmpdir } = await import('node:os');
const { mkdtempSync } = await import('node:fs');
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
const { port, close } = await startMockIndexServer(tmp);
try {
const { status, body } = await getJson(`http://localhost:${port}/?path=../../.env`);
assert.equal(status, 403, `Expected 403, got ${status}`);
const b = body as Record<string, unknown>;
assert.ok(
typeof b['error'] === 'string' && (b['error'] as string).toLowerCase().includes('traversal'),
`Expected traversal error in body, got: ${JSON.stringify(body)}`,
);
} finally {
close();
}
});
test('GET /file?path=..%2F..%2F.env (URL-encoded) returns 403', async () => {
const { tmpdir } = await import('node:os');
const { mkdtempSync } = await import('node:fs');
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
const { port, close } = await startMockIndexServer(tmp);
try {
// The URL constructor decodes %2F, so this exercises the same path
const { status } = await getJson(`http://localhost:${port}/?path=..%2F..%2F.env`);
assert.equal(status, 403, `Expected 403, got ${status}`);
} finally {
close();
}
});
test('GET /file?path=src/valid.ts returns 200 (no traversal)', async () => {
const { tmpdir } = await import('node:os');
const { mkdtempSync, mkdirSync, writeFileSync } = await import('node:fs');
const { join } = await import('node:path');
const tmp = mkdtempSync(`${tmpdir()}/om-sec-test-`);
mkdirSync(join(tmp, 'src'), { recursive: true });
writeFileSync(join(tmp, 'src', 'valid.ts'), '// hello');
const { port, close } = await startMockIndexServer(tmp);
try {
const { status } = await getJson(`http://localhost:${port}/?path=src/valid.ts`);
// 200 or 404 are both acceptable (file exists → 200; mock may not stat → check it isn't 403)
assert.notEqual(status, 403, `Expected non-403, got ${status}`);
} finally {
close();
}
});
});
// ── register-indexer URL validation tests ─────────────────────────────────────
describe('register-indexer URL validation', () => {
/**
* Mirrors the isLocalhostUrl() check in the real register-indexer route.
* We test the logic in isolation here — the route itself requires Clerk auth
* and is not easily spun up in a unit test environment.
*/
function isLocalhostUrl(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;
}
}
test('rejects external indexerUrl (https://attacker.example)', () => {
assert.equal(isLocalhostUrl('https://attacker.example/component'), false);
});
test('rejects external indexerUrl with IP (http://10.0.0.1:4171)', () => {
assert.equal(isLocalhostUrl('http://10.0.0.1:4171'), false);
});
test('rejects invalid URL', () => {
assert.equal(isLocalhostUrl('not-a-url'), false);
});
test('accepts http://localhost:4171', () => {
assert.equal(isLocalhostUrl('http://localhost:4171'), true);
});
test('accepts http://127.0.0.1:4171', () => {
assert.equal(isLocalhostUrl('http://127.0.0.1:4171'), true);
});
test('accepts http://[::1]:4171', () => {
assert.equal(isLocalhostUrl('http://[::1]:4171'), true);
});
test('rejects localhost URL without http scheme (ftp://localhost:4171)', () => {
// We still classify this as localhost — the scheme check is the caller's responsibility.
// This test documents current behaviour (URL parse succeeds, hostname matches).
assert.equal(isLocalhostUrl('ftp://localhost:4171'), true);
});
test('rejects public URL that contains "localhost" in path (http://evil.com/localhost)', () => {
assert.equal(isLocalhostUrl('http://evil.com/localhost'), false);
});
});
+95 -11
View File
@@ -2,18 +2,21 @@
// ── Originmain CLI ───────────────────────────────────────────────────────────
// Usage:
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
// [--index-port 4171] [--no-index]
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
// [--index-port 4171] [--no-index]
// npx @originmain/cli login [--app-url https://app.originmain.io]
//
// Starts a reverse proxy + optional AST indexer for live React component
// inspection in Originmain artboards. See SOURCE-AWARE-CANVAS.md for details.
import { parseArgs } from 'node:util';
import { resolve } from 'node:path';
import { startProxy } from './proxy.js';
import { Indexer } from './indexer.js';
import { startIndexServer } from './index-server.js';
import { detectProjectMeta } from './detect-framework.js';
import { parseArgs } from 'node:util';
import { resolve } from 'node:path';
import { startProxy } from './proxy.js';
import { Indexer } from './indexer.js';
import { startIndexServer } from './index-server.js';
import { detectProjectMeta } from './detect-framework.js';
import { initIsolationServer } from './isolation-server.js';
import { runLogin } from './commands/login.js';
const DEFAULT_PORT = 4170;
const DEFAULT_INDEX_PORT = 4171;
@@ -22,10 +25,11 @@ function printUsage(): void {
console.log(`
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
Usage:
originmain dev --target <url> [options]
Commands:
originmain dev --target <url> [options]
originmain login [--app-url <url>]
Options:
Dev options:
--target, -t Target dev server URL (required)
Example: http://localhost:3000
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
@@ -33,10 +37,15 @@ function printUsage(): void {
--no-index Disable the AST indexer (Props/Code tabs degraded)
--help, -h Show this help
Login options:
--app-url Originmain app URL (default: https://app.originmain.io)
Environment variables:
ORIGINMAIN_BRIDGE_URL Agent Bridge URL (default: http://localhost:4172)
ORIGINMAIN_APP_URL Originmain app URL (overrides --app-url default)
Examples:
npx @originmain/cli login
npx @originmain/cli dev --target http://localhost:3000
npx @originmain/cli dev --target http://localhost:3000 --no-index
`);
@@ -50,6 +59,7 @@ async function main(): Promise<void> {
port: { type: 'string', short: 'p' },
'index-port': { type: 'string' },
'no-index': { type: 'boolean' },
'app-url': { type: 'string' },
help: { type: 'boolean', short: 'h' },
},
allowPositionals: true,
@@ -63,6 +73,13 @@ async function main(): Promise<void> {
process.exit(values.help ? 0 : 1);
}
// ── login command ──────────────────────────────────────────────────────────
if (command === 'login') {
const appUrl = values['app-url'] as string | undefined;
await runLogin(appUrl ? { appUrl } : {});
process.exit(0);
}
if (command !== 'dev') {
console.error(` Unknown command: ${command}\n Run "originmain --help" for usage.`);
process.exit(1);
@@ -143,9 +160,76 @@ async function main(): Promise<void> {
const indexUrl = noIndex ? null : `http://localhost:${indexPort}`;
const proxy = startProxy({ target, port, indexUrl });
// ── Initialize the isolation server (serves /__om_isolation__ requests) ───
// Must be done after the proxy starts so `handleIsolationRequest` is wired up.
initIsolationServer({ projectRoot, devServerBase: target });
// ── Register indexer with Agent Bridge (spec Phase 5 §8.3) ───────────────
// Read workspace token from env or ~/.originmain/config.json.
// If no token is found, skip registration and log a warning.
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
if (!noIndex && indexUrl) {
const HEARTBEAT_INTERVAL_MS = 120_000; // 120 s — matches server TTL refresh spec
const TTL_SECONDS = 300;
let workspaceToken: string | null = process.env['ORIGINMAIN_WORKSPACE_TOKEN'] ?? null;
if (!workspaceToken) {
try {
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
const cfgPath = resolve(homeDir, '.originmain', 'config.json');
const { readFileSync } = await import('node:fs');
const cfg = JSON.parse(readFileSync(cfgPath, 'utf-8')) as Record<string, unknown>;
if (typeof cfg['workspaceToken'] === 'string') workspaceToken = cfg['workspaceToken'];
} catch { /* config not present — skip registration */ }
}
if (workspaceToken) {
const registerUrl = `${bridgeUrl}/api/agent-bridge/register-indexer`;
async function registerIndexer(): Promise<void> {
try {
const res = await fetch(registerUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${workspaceToken}` },
body: JSON.stringify({ indexerUrl: indexUrl, ttl: TTL_SECONDS }),
signal: AbortSignal.timeout(8_000),
});
if (res.ok) {
console.log(` \x1b[32m✓\x1b[0m Indexer registered with Agent Bridge (TTL: ${TTL_SECONDS}s)`);
} else {
console.warn(` \x1b[33m⚠\x1b[0m Agent Bridge registration failed: ${res.status}`);
}
} catch (err) {
// Non-fatal — agent features are unavailable but the rest of the CLI works
const msg = err instanceof Error ? err.message : String(err);
console.warn(` \x1b[33m⚠\x1b[0m Could not reach Agent Bridge (${msg}). Agent features disabled.`);
}
}
async function sendHeartbeat(): Promise<void> {
try {
await fetch(registerUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${workspaceToken}` },
body: JSON.stringify({}), // no indexerUrl = heartbeat path
signal: AbortSignal.timeout(5_000),
});
} catch { /* non-fatal heartbeat miss */ }
}
void registerIndexer();
heartbeatTimer = setInterval(() => { void sendHeartbeat(); }, HEARTBEAT_INTERVAL_MS);
} else {
console.log(' \x1b[2mNot logged in — Agent Bridge integration disabled.\x1b[0m');
console.log(' \x1b[2mRun \x1b[0moriginmain login\x1b[2m to enable agent features.\x1b[0m');
}
}
// ── Graceful shutdown ─────────────────────────────────────────────────────
function shutdown(): void {
console.log('\n Shutting down...');
if (heartbeatTimer) clearInterval(heartbeatTimer);
proxy.close();
indexServer?.close();
process.exit(0);
+222
View File
@@ -0,0 +1,222 @@
#!/usr/bin/env node
// ── originmain login ──────────────────────────────────────────────────────────
//
// Browser-based OAuth flow for the Originmain CLI.
//
// Flow:
// 1. Start a local HTTP server on a random port (callback receiver).
// 2. Print the auth URL and attempt to open it in the default browser.
// 3. Wait for the browser to redirect back with token + workspaceId.
// 4. Write { workspaceToken, workspaceId, bridgeUrl } to ~/.originmain/config.json.
//
// The app-side endpoint is GET /api/cli-auth?callback=<callbackUrl>.
// On success it redirects to <callbackUrl>?token=X&workspaceId=Y&bridgeUrl=Z.
//
// spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6
import { createServer } from 'node:http';
import type { Server } from 'node:http';
import { resolve, dirname } from 'node:path';
import { mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
// ── Config paths ──────────────────────────────────────────────────────────────
function getConfigPath(): string {
const homeDir = process.env['HOME'] ?? process.env['USERPROFILE'] ?? '';
return resolve(homeDir, '.originmain', 'config.json');
}
function readConfig(): Record<string, unknown> {
const p = getConfigPath();
try {
if (existsSync(p)) return JSON.parse(readFileSync(p, 'utf-8')) as Record<string, unknown>;
} catch { /* ignore corrupt config */ }
return {};
}
function writeConfig(data: Record<string, unknown>): void {
const p = getConfigPath();
mkdirSync(dirname(p), { recursive: true });
writeFileSync(p, JSON.stringify({ ...readConfig(), ...data }, null, 2), 'utf-8');
}
// ── Browser open ──────────────────────────────────────────────────────────────
// Uses execFile (not exec) so no shell is invoked — eliminates injection risk.
function openBrowser(url: string): void {
let bin: string;
let args: string[];
if (process.platform === 'darwin') {
bin = 'open';
args = [url];
} else if (process.platform === 'win32') {
// `start` is a shell built-in; delegate to cmd /c
bin = 'cmd';
args = ['/c', 'start', '', url];
} else {
bin = 'xdg-open';
args = [url];
}
execFile(bin, args, (err) => {
if (err) {
console.log(' Could not open browser automatically. Please visit the URL above manually.');
}
});
}
// ── Local callback server ─────────────────────────────────────────────────────
interface CallbackResult {
token: string;
workspaceId: string;
bridgeUrl: string;
}
function startCallbackServer(): Promise<{
server: Server;
port: number;
result: Promise<CallbackResult>;
}> {
return new Promise((resolveOuter) => {
let resolveResult: (r: CallbackResult) => void;
let rejectResult: (e: Error) => void;
const result = new Promise<CallbackResult>((res, rej) => {
resolveResult = res;
rejectResult = rej;
});
const server = createServer((req, res) => {
try {
const url = new URL(req.url ?? '/', 'http://localhost');
const token = url.searchParams.get('token');
const workspaceId = url.searchParams.get('workspaceId');
const bridgeUrl = url.searchParams.get('bridgeUrl') ?? 'http://localhost:4172';
const errorMsg = url.searchParams.get('error');
if (errorMsg) {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(htmlPage('Login failed',
`<p style="color:#f55">Error: ${escHtml(errorMsg)}</p><p>You can close this tab.</p>`));
rejectResult(new Error(errorMsg));
return;
}
if (!token || !workspaceId) {
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' });
res.end(htmlPage('Invalid callback',
'<p style="color:#f55">Missing token or workspaceId. Please try again.</p>'));
return;
}
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
res.end(htmlPage('Login successful',
'<p style="color:#4f4">&#x2713; Logged in! You can close this tab and return to the terminal.</p>'));
resolveResult({ token, workspaceId, bridgeUrl });
} catch (err) {
res.writeHead(500, { 'content-type': 'text/plain' });
res.end('Callback server error');
rejectResult(err instanceof Error ? err : new Error(String(err)));
}
});
// Bind to port 0 so the OS assigns a free port
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr !== null ? addr.port : 4173;
resolveOuter({ server, port, result });
});
});
}
function escHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function htmlPage(title: string, body: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${escHtml(title)} — Originmain CLI</title>
<style>
body { font-family: system-ui, sans-serif; background: #111; color: #eee;
padding: 2rem; max-width: 480px; margin: auto; }
h2 { margin-top: 0; }
</style>
</head>
<body>
<h2>Originmain CLI</h2>
${body}
</body>
</html>`;
}
// ── Entry point ───────────────────────────────────────────────────────────────
export interface LoginOptions {
/** Base URL of the Originmain web app (default: https://app.originmain.io). */
appUrl?: string;
/** Milliseconds to wait for the browser callback before giving up (default: 120 000). */
timeout?: number;
}
export async function runLogin(opts: LoginOptions = {}): Promise<void> {
const appUrl = (
opts.appUrl ??
process.env['ORIGINMAIN_APP_URL'] ??
'https://app.originmain.io'
).replace(/\/$/, '');
const timeout = opts.timeout ?? 120_000;
console.log('');
console.log(' \x1b[36m\x1b[1mOriginmain Login\x1b[0m');
console.log('');
const { server, port, result } = await startCallbackServer();
const callbackUrl = `http://localhost:${port}/`;
const authUrl = `${appUrl}/api/cli-auth?callback=${encodeURIComponent(callbackUrl)}`;
console.log(' Opening your browser to complete login…');
console.log('');
console.log(' \x1b[2mIf the browser does not open automatically, visit:\x1b[0m');
console.log(` \x1b[1m${authUrl}\x1b[0m`);
console.log('');
openBrowser(authUrl);
// Race the callback against the timeout
const timeoutPromise = new Promise<never>((_, rej) =>
setTimeout(
() => rej(new Error(`Login timed out after ${timeout / 1000}s — no callback received.`)),
timeout,
),
);
let callbackData: CallbackResult;
try {
callbackData = await Promise.race([result, timeoutPromise]);
} finally {
server.close();
}
// Persist credentials to ~/.originmain/config.json
writeConfig({
workspaceToken: callbackData.token,
workspaceId: callbackData.workspaceId,
bridgeUrl: callbackData.bridgeUrl,
});
const configPath = getConfigPath();
console.log(` \x1b[32m✓\x1b[0m Logged in — workspace \x1b[1m${callbackData.workspaceId.slice(0, 8)}\x1b[0m`);
console.log(` \x1b[2mCredentials saved to ${configPath}\x1b[0m`);
console.log('');
console.log(' You can now run \x1b[1moriginmain dev --target http://localhost:3000\x1b[0m');
console.log('');
}
+358 -29
View File
@@ -1,38 +1,367 @@
// ── Isolation Server (Phase 3 stub) ──────────────────────────────────────────
// ── Isolation Server (Phase 3 full implementation) ────────────────────────────
// Serves `/__om_isolation__` wrapper pages that render a single component in
// isolation (component artboard type). Full implementation ships in Phase 3.
// isolation (component artboard type).
//
// Current status: 501 stub that tells the user Phase 3 is required.
// The proxy delegates all /__om_isolation__ requests here.
//
// Behaviour differs by framework (spec Phase 3 §3.5):
//
// Vite: Generate an inline HTML page with a `<script type="module">` that
// imports the component from the Vite dev server and renders it via
// ReactDOM.createRoot + window.__OM_ISO_RENDER__. No project changes.
//
// Next.js: Next.js cannot serve arbitrary source modules. The CLI writes a
// temporary page.tsx to `{appDir}/__om_isolation__/page.tsx` (App
// Router) or `pages/__om_isolation__.tsx` (Pages Router). Next.js
// compiles and hot-reloads it like any other page. The file is deleted
// on CLI exit (process.on('exit') + SIGINT/SIGTERM).
//
// Query params on /__om_isolation__:
// component — exported symbol name, e.g. "DashboardCard"
// file — workspace-relative source path, e.g. "src/components/DashboardCard.tsx"
//
// spec: SOURCE-AWARE-CANVAS.md Phase 3 §3.5 "Component Isolation"
import {
existsSync, mkdirSync, writeFileSync, readFileSync, rmSync, realpathSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import type { IncomingMessage, ServerResponse } from 'node:http';
import { detectFramework } from './detect-framework.js';
const STUB_BODY = [
'<!DOCTYPE html>',
'<html lang="en">',
'<head><meta charset="UTF-8" /><title>Isolation artboard — not yet available</title>',
'<style>body{margin:0;background:#0d0d11;color:rgba(255,255,255,0.6);',
'font:13px/1.6 ui-monospace,monospace;display:flex;align-items:center;',
'justify-content:center;height:100vh;text-align:center;}</style></head>',
'<body>',
'<div>',
' <p style="font-size:1.1rem;color:rgba(255,255,255,0.85)">',
' Isolation artboards require the CLI AST indexer',
' </p>',
' <p>Start <code style="color:#7EB8FF">originmain dev</code> without ',
' <code style="color:#7EB8FF">--no-index</code> to enable isolation frames.</p>',
'</div>',
'</body></html>',
].join('\n');
// ── Config ────────────────────────────────────────────────────────────────────
/** Handles any request to /__om_isolation__/* — returns a 501 stub page. */
export function handleIsolationRequest(
_req: IncomingMessage,
const ISOLATION_DIR_NAME = '__om_isolation__';
const NEXT_PAGE_CONTENT = (componentName: string, importPath: string, isDefault: boolean) => `\
'use client';
// AUTO-GENERATED by @originmain/cli — do not edit.
// This file is deleted when the CLI stops (process.on('exit')).
// Add __om_isolation__/ to your .gitignore to prevent accidental commits.
import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}';
import { useEffect } from 'react';
// Expose render function for the host-to-iframe UPDATE_ISOLATION_PROPS protocol
function IsolationPage() {
useEffect(() => {
if (typeof window === 'undefined') return;
window.__OM_ISO_RENDER__ = function() {
// Force a re-render by dispatching a custom event — the component
// reads window.__OM_ISO_PROPS__ in its own render cycle.
};
// Trigger initial render
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ || {};
}, []);
// Read props from the isolation protocol global
const props = (typeof window !== 'undefined' && window.__OM_ISO_PROPS__) ? window.__OM_ISO_PROPS__ : {};
return <${componentName} {...(props as Record<string, unknown>)} />;
}
export default IsolationPage;
// Type augmentation so TS doesn't complain about the globals
declare global {
interface Window {
__OM_ISO_PROPS__: Record<string, unknown> | undefined;
__OM_ISO_RENDER__: (() => void) | undefined;
}
}
`;
// The Vite inline HTML template renders the component via ReactDOM.createRoot
// and exposes window.__OM_ISO_RENDER__ for live prop updates from the host.
const VITE_HTML = (
componentName: string,
importPath: string,
isDefault: boolean,
) => `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>${componentName} — Isolation</title>
<style>
body { margin: 0; padding: 24px; box-sizing: border-box; }
#root { display: contents; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module">
// All Originmain globals use the __OM_ISO_ prefix to minimise collision risk.
import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}';
import { createRoot } from 'react-dom/client';
import React from 'react';
if (typeof window.__OM_ISO_PROPS__ !== 'undefined') {
console.warn('[Originmain] window.__OM_ISO_PROPS__ was already defined — possible name collision. Proceeding anyway.');
}
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ || {};
const _root = createRoot(document.getElementById('root'));
window.__OM_ISO_RENDER__ = function() {
_root.render(React.createElement(${componentName}, window.__OM_ISO_PROPS__ || {}));
};
window.__OM_ISO_RENDER__();
</script>
</body>
</html>`;
// ── Temp file tracking ────────────────────────────────────────────────────────
// All temp files created by this module are tracked here so they can be deleted
// on process exit (including SIGINT / SIGTERM).
const tempFiles = new Set<string>();
let cleanupRegistered = false;
function deleteTempFile(filePath: string): void {
try {
if (existsSync(filePath)) rmSync(filePath, { recursive: true, force: true });
// Also try to remove the parent dir if it's our isolation dir
const parent = dirname(filePath);
if (parent.endsWith(ISOLATION_DIR_NAME) && existsSync(parent)) {
rmSync(parent, { recursive: true, force: true });
}
} catch { /* file may already be deleted */ }
tempFiles.delete(filePath);
}
function registerCleanup(): void {
if (cleanupRegistered) return;
cleanupRegistered = true;
const cleanup = () => {
for (const f of tempFiles) deleteTempFile(f);
};
process.on('exit', cleanup);
process.on('SIGINT', () => { cleanup(); process.exit(130); });
process.on('SIGTERM', () => { cleanup(); process.exit(143); });
}
// ── Startup cleanup ───────────────────────────────────────────────────────────
// On CLI start, delete any pre-existing __om_isolation__ directories from a
// previous unclean exit (spec §3.5 "Startup cleanup").
export function cleanupIsolationDirs(projectRoot: string): void {
const candidates = [
join(projectRoot, 'src', 'app', ISOLATION_DIR_NAME),
join(projectRoot, 'app', ISOLATION_DIR_NAME),
join(projectRoot, 'pages', `${ISOLATION_DIR_NAME}.tsx`),
];
for (const p of candidates) {
if (existsSync(p)) {
try { rmSync(p, { recursive: true, force: true }); } catch { /* ignore */ }
}
}
}
// ── .gitignore injection ──────────────────────────────────────────────────────
function ensureGitignore(projectRoot: string): void {
const gitignorePath = join(projectRoot, '.gitignore');
const entry = `\n# Originmain component isolation frame (auto-deleted on CLI stop)\n${ISOLATION_DIR_NAME}/\n`;
try {
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
if (!existing.includes(ISOLATION_DIR_NAME)) {
writeFileSync(gitignorePath, existing + entry, 'utf-8');
}
} catch { /* .gitignore is optional — no-op on error */ }
}
// ── Next.js page writer ───────────────────────────────────────────────────────
type NextPageLocation = { type: 'app'; dir: string } | { type: 'pages'; dir: string } | null;
function findNextPageLocation(projectRoot: string): NextPageLocation {
const srcApp = join(projectRoot, 'src', 'app');
const rootApp = join(projectRoot, 'app');
const pages = join(projectRoot, 'pages');
if (existsSync(srcApp)) return { type: 'app', dir: srcApp };
if (existsSync(rootApp)) return { type: 'app', dir: rootApp };
if (existsSync(pages)) return { type: 'pages', dir: pages };
return null;
}
function writeNextIsolationPage(
projectRoot: string,
componentName: string,
importPath: string,
isDefault: boolean,
): string {
registerCleanup();
ensureGitignore(projectRoot);
const loc = findNextPageLocation(projectRoot);
let filePath: string;
if (!loc) {
// No router found — fall through to Vite approach (caller handles this)
throw new Error('no-next-router');
} else if (loc.type === 'app') {
const dir = join(loc.dir, ISOLATION_DIR_NAME);
mkdirSync(dir, { recursive: true });
filePath = join(dir, 'page.tsx');
} else {
filePath = join(loc.dir, `${ISOLATION_DIR_NAME}.tsx`);
}
const content = NEXT_PAGE_CONTENT(componentName, importPath, isDefault);
writeFileSync(filePath, content, 'utf-8');
tempFiles.add(filePath);
return filePath;
}
// ── IsolationServer class ─────────────────────────────────────────────────────
interface IsolationServerOptions {
projectRoot: string;
/** Base URL of the running dev server, e.g. "http://localhost:3000" */
devServerBase: string;
/**
* Optional: async function to look up whether a component is a default export.
* If omitted, assumes named export (the safe default for most components).
*/
resolveIsDefaultExport?: (componentName: string, filePath: string) => Promise<boolean>;
}
export class IsolationServer {
private readonly projectRoot: string;
private readonly devServerBase: string;
private readonly resolveIsDefault: NonNullable<IsolationServerOptions['resolveIsDefaultExport']>;
private readonly framework: ReturnType<typeof detectFramework>;
constructor(opts: IsolationServerOptions) {
this.projectRoot = opts.projectRoot;
this.devServerBase = opts.devServerBase;
this.framework = detectFramework(opts.projectRoot);
this.resolveIsDefault = opts.resolveIsDefaultExport ?? (() => Promise.resolve(false));
}
/** Call at startup to purge any leftover __om_isolation__ dirs. */
cleanup(): void {
cleanupIsolationDirs(this.projectRoot);
}
/** Handle an incoming /__om_isolation__?component=X&file=Y request. */
async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
const urlStr = req.url ?? '/';
const url = new URL(urlStr, 'http://localhost');
const componentName = url.searchParams.get('component') ?? '';
const filePath = url.searchParams.get('file') ?? '';
if (!componentName || !filePath) {
this.sendError(res, 400, 'Missing required query params: component, file');
return;
}
// Security: reject path traversal attempts in the file param
const resolvedFile = join(this.projectRoot, filePath);
try {
const real = realpathSync(resolvedFile);
if (!real.startsWith(realpathSync(this.projectRoot))) {
this.sendError(res, 403, 'Path traversal not allowed');
return;
}
} catch {
// File may not exist yet — that's OK, the import will fail at runtime
}
const isDefault = await this.resolveIsDefault(componentName, filePath);
if (this.framework === 'next') {
await this.handleNextJs(req, res, componentName, filePath, isDefault);
} else {
this.handleVite(res, componentName, filePath, isDefault);
}
}
// ── Vite handler ──────────────────────────────────────────────────────────
private handleVite(
res: ServerResponse,
componentName: string,
filePath: string,
isDefault: boolean,
): void {
// Import path: use the file param as a root-relative path (Vite serves from root)
const importPath = filePath.startsWith('/') ? filePath : `/${filePath}`;
const html = VITE_HTML(componentName, importPath, isDefault);
res.writeHead(200, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
res.end(html);
}
// ── Next.js handler ───────────────────────────────────────────────────────
private async handleNextJs(
req: IncomingMessage,
res: ServerResponse,
componentName: string,
filePath: string,
isDefault: boolean,
): Promise<void> {
// Build an import path relative to the isolation page location.
// The temp page lives in {appDir}/__om_isolation__/page.tsx, so the
// component's import path is relative from there.
// We use an absolute-from-root import (/@/... or relative to src/) that
// Next.js resolves via tsconfig paths — or we use a relative path.
// The simplest approach: use a root-relative path prefixed with '@/' if
// the project uses the common Next.js path alias, or a relative path otherwise.
const importPath = filePath.replace(/^src\//, '@/');
try {
writeNextIsolationPage(this.projectRoot, componentName, importPath, isDefault);
} catch (err) {
if (err instanceof Error && err.message === 'no-next-router') {
// Fall back to Vite-style inline HTML
this.handleVite(res, componentName, filePath, isDefault);
return;
}
this.sendError(res, 500, `Failed to write isolation page: ${err instanceof Error ? err.message : String(err)}`);
return;
}
// Redirect to the temp Next.js page so the browser fetches the compiled page.
const target = `${this.devServerBase}/${ISOLATION_DIR_NAME}?component=${encodeURIComponent(componentName)}&file=${encodeURIComponent(filePath)}`;
res.writeHead(302, { Location: target, 'cache-control': 'no-store' });
res.end();
}
private sendError(res: ServerResponse, status: number, message: string): void {
res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' });
res.end(`Originmain Isolation Error: ${message}`);
}
}
// ── Legacy function-style entry point (for proxy.ts compatibility) ─────────────
// The proxy.ts currently calls handleIsolationRequest(req, res) from a module-level
// IsolationServer instance. Export a convenience function that delegates to a
// default instance configured from environment variables.
let _defaultServer: IsolationServer | null = null;
export function initIsolationServer(opts: IsolationServerOptions): void {
_defaultServer = new IsolationServer(opts);
_defaultServer.cleanup(); // startup cleanup
}
/** Handles any request to /__om_isolation__/* using the initialised server. */
export async function handleIsolationRequest(
req: IncomingMessage,
res: ServerResponse,
): void {
res.writeHead(501, {
'content-type': 'text/html; charset=utf-8',
'cache-control': 'no-store',
});
res.end(STUB_BODY);
): Promise<void> {
if (!_defaultServer) {
// Not yet initialised — return the stub response
res.writeHead(503, { 'content-type': 'text/html; charset=utf-8' });
res.end('<body style="font:13px monospace;padding:24px">Isolation server not initialised — call initIsolationServer() first.</body>');
return;
}
return _defaultServer.handleRequest(req, res);
}
+2
View File
@@ -11,9 +11,11 @@
"test": "vitest run --coverage"
},
"dependencies": {
"culori": "^3.3.0",
"zod": "^3.0.0"
},
"devDependencies": {
"@types/culori": "^4.0.1",
"@vitest/coverage-v8": "^2.1.9",
"typescript": "^5.5.0",
"vitest": "^2.1.9"
+2
View File
@@ -1,3 +1,5 @@
export * from './schema.js';
export * from './validator.js';
export * from './tokens.js';
export * from './parser.js';
export * from './resolver.js';
+303
View File
@@ -0,0 +1,303 @@
/**
* parser.ts — Phase 6
*
* Parses design token files into the normalised DesignToken[] format used by
* the canvas store and token resolver.
*
* Supported input formats (auto-detected from the top-level structure):
* 1. W3C DTCG — uses $value / $type fields (https://design-tokens.github.io/community-group/format/)
* 2. Style Dictionary — nested groups, each leaf has { value, ... }
* 3. Flat CSS variable map — { "--color-primary": "#FF0066", ... }
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.2 "Design Language System"
*/
// ── DesignToken type (mirrors canvas.types.ts in the app package) ─────────────
// Duplicated here to avoid a cross-package import at runtime.
export type TokenType =
| 'color'
| 'spacing'
| 'sizing'
| 'borderRadius'
| 'borderWidth'
| 'fontFamily'
| 'fontSize'
| 'fontWeight'
| 'lineHeight'
| 'letterSpacing'
| 'shadow'
| 'opacity'
| 'other';
export interface DesignToken {
/** CSS custom property name: "--color-primary" */
key: string;
/** Human label: "Color / Primary" */
name: string;
/** Top-level group: "color", "spacing", etc. */
group: string;
/** Resolved CSS value: "#0066FF" */
rawValue: string;
type: TokenType;
description?: string;
/** If resolved from an alias chain, lists each intermediate key. */
aliasChain?: string[];
}
// ── Format detection ──────────────────────────────────────────────────────────
type TokenFileFormat = 'dtcg' | 'style-dictionary' | 'flat-css-vars';
function detectFormat(raw: Record<string, unknown>): TokenFileFormat {
// Flat CSS var map: all keys start with "--"
const keys = Object.keys(raw);
if (keys.length > 0 && keys.every((k) => k.startsWith('--'))) {
return 'flat-css-vars';
}
// DTCG: any value node uses $value / $type
const hasW3cNodes = keys.some((k) => {
const v = raw[k];
return v && typeof v === 'object' && ('$value' in (v as object) || '$type' in (v as object));
});
if (hasW3cNodes) return 'dtcg';
// Assume Style Dictionary for everything else
return 'style-dictionary';
}
// ── Flat CSS var format ───────────────────────────────────────────────────────
function parseFlatCssVars(raw: Record<string, unknown>): DesignToken[] {
const tokens: DesignToken[] = [];
for (const [key, val] of Object.entries(raw)) {
if (!key.startsWith('--')) continue;
const rawValue = String(val ?? '').trim();
if (!rawValue) continue;
// Derive group from the property name: "--color-primary-500" → "color"
const withoutDashes = key.replace(/^--/, '');
const parts = withoutDashes.split('-');
const group = parts[0] ?? 'other';
const name = parts.slice(1).map(capitalise).join(' ') || withoutDashes;
tokens.push({
key,
name: `${capitalise(group)} / ${name}`,
group,
rawValue,
type: inferTokenType(group, rawValue),
});
}
return tokens;
}
// ── W3C DTCG format ───────────────────────────────────────────────────────────
function parseDtcg(raw: Record<string, unknown>, pathParts: string[] = []): DesignToken[] {
const tokens: DesignToken[] = [];
for (const [key, val] of Object.entries(raw)) {
if (key.startsWith('$')) continue; // skip $metadata, $description etc.
if (!val || typeof val !== 'object') continue;
const node = val as Record<string, unknown>;
if ('$value' in node) {
// Leaf token
const rawValue = resolveAlias(String(node['$value'] ?? '').trim(), raw);
if (!rawValue) continue;
const fullPath = [...pathParts, key];
const group = fullPath[0] ?? 'other';
// §9.2: each segment passes through toKebabCase before joining so that
// camelCase group names (e.g. "borderRadius") produce correct CSS names.
const cssKey = `--${fullPath.map(toKebabCase).join('-').replace(/\s+/g, '-')}`;
tokens.push({
key: cssKey,
name: fullPath.map(capitalise).join(' / '),
group,
rawValue,
type: inferTokenTypeFromDtcg(String(node['$type'] ?? ''), rawValue),
...(typeof node['$description'] === 'string' && { description: node['$description'] }),
});
} else {
// Group node — recurse
tokens.push(...parseDtcg(node, [...pathParts, key]));
}
}
return tokens;
}
// ── Style Dictionary format ───────────────────────────────────────────────────
function parseStyleDictionary(raw: Record<string, unknown>, pathParts: string[] = []): DesignToken[] {
const tokens: DesignToken[] = [];
for (const [key, val] of Object.entries(raw)) {
if (!val || typeof val !== 'object') continue;
const node = val as Record<string, unknown>;
if ('value' in node && typeof node['value'] !== 'object') {
// Leaf token
const rawValue = String(node['value'] ?? '').trim();
if (!rawValue) continue;
const fullPath = [...pathParts, key];
const group = fullPath[0] ?? 'other';
// §9.2: each segment passes through toKebabCase before joining.
const cssKey = `--${fullPath.map(toKebabCase).join('-').replace(/\s+/g, '-')}`;
tokens.push({
key: cssKey,
name: fullPath.map(capitalise).join(' / '),
group,
rawValue,
type: inferTokenType(group, rawValue),
...(typeof node['comment'] === 'string' && { description: node['comment'] }),
});
} else {
// Group node — recurse
tokens.push(...parseStyleDictionary(node, [...pathParts, key]));
}
}
return tokens;
}
// ── Public API ────────────────────────────────────────────────────────────────
/**
* Parse a raw token file (parsed JSON/JS object) into a flat DesignToken[].
* Auto-detects the format from the object structure.
*
* @param raw Parsed token file contents (not a JSON string — call JSON.parse first)
* @returns Flat array of normalised design tokens
* @throws If the input is not a plain object or if parsing fails
*/
export function parseTokenFile(raw: unknown): DesignToken[] {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error('Token file must be a plain JSON object');
}
const obj = raw as Record<string, unknown>;
const format = detectFormat(obj);
switch (format) {
case 'flat-css-vars':
return parseFlatCssVars(obj);
case 'dtcg':
return parseDtcg(obj);
case 'style-dictionary':
return parseStyleDictionary(obj);
}
}
/**
* Parse a JSON string token file.
*/
export function parseTokenFileJson(jsonString: string): DesignToken[] {
let raw: unknown;
try {
raw = JSON.parse(jsonString);
} catch (err) {
throw new Error(`Invalid JSON: ${err instanceof Error ? err.message : String(err)}`);
}
return parseTokenFile(raw);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function capitalise(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
/**
* Convert a camelCase or PascalCase path segment to kebab-case.
* Used when building CSS custom property names from token path segments so that
* e.g. `{ "borderRadius": { "sm": ... } }` → `--border-radius-sm` instead of
* `--borderradius-sm`.
*
* spec: SOURCE-AWARE-CANVAS §9.2 "camelCase segments are converted to kebab-case"
*
* Examples:
* "borderRadius" → "border-radius"
* "fontSize" → "font-size"
* "Color" → "color" (leading-hyphen guard prevents "---color-primary")
* "BoxShadow" → "box-shadow"
*/
export function toKebabCase(s: string): string {
return s
.replace(/([A-Z])/g, '-$1')
.toLowerCase()
.replace(/^-/, ''); // strip leading hyphen produced by an initial uppercase letter
}
function inferTokenType(group: string, value: string): TokenType {
const g = group.toLowerCase();
if (g === 'color' || g === 'colors' || g === 'colour') return 'color';
if (g === 'spacing' || g === 'space') return 'spacing';
if (g === 'sizing' || g === 'size') return 'sizing';
if (g === 'radius' || g === 'border-radius' || g === 'borderradius') return 'borderRadius';
if (g === 'border-width' || g === 'borderwidth') return 'borderWidth';
if (g === 'font-family' || g === 'fontfamily') return 'fontFamily';
if (g === 'font-size' || g === 'fontsize') return 'fontSize';
if (g === 'font-weight' || g === 'fontweight') return 'fontWeight';
if (g === 'line-height' || g === 'lineheight') return 'lineHeight';
if (g === 'letter-spacing' || g === 'letterspacing') return 'letterSpacing';
if (g === 'shadow' || g === 'box-shadow') return 'shadow';
if (g === 'opacity') return 'opacity';
// Fallback: infer from value
return inferTokenTypeFromValue(value);
}
function inferTokenTypeFromDtcg(dtcgType: string, value: string): TokenType {
switch (dtcgType.toLowerCase()) {
case 'color': return 'color';
case 'dimension':
case 'spacing': return 'spacing';
case 'font-family': return 'fontFamily';
case 'font-size': return 'fontSize';
case 'font-weight': return 'fontWeight';
case 'line-height': return 'lineHeight';
case 'letter-spacing':return 'letterSpacing';
case 'shadow': return 'shadow';
case 'opacity': return 'opacity';
case 'border-radius': return 'borderRadius';
default: return inferTokenTypeFromValue(value);
}
}
function inferTokenTypeFromValue(value: string): TokenType {
const v = value.toLowerCase().trim();
if (v.startsWith('#') || v.startsWith('rgb') || v.startsWith('hsl') || v.startsWith('oklch')) return 'color';
if (v.endsWith('px') || v.endsWith('rem') || v.endsWith('em')) return 'spacing';
if (v.includes('shadow') || v.includes('blur')) return 'shadow';
return 'other';
}
/**
* Resolve a DTCG alias like "{color.primary.500}" to a flat CSS value.
* Returns the alias string unchanged if it cannot be resolved.
*/
function resolveAlias(value: string, root: Record<string, unknown>): string {
const aliasMatch = value.match(/^\{(.+)\}$/);
if (!aliasMatch?.[1]) return value;
const path = aliasMatch[1].split('.');
let cursor: unknown = root;
for (const segment of path) {
if (!cursor || typeof cursor !== 'object') return value;
cursor = (cursor as Record<string, unknown>)[segment];
}
if (cursor && typeof cursor === 'object' && '$value' in (cursor as object)) {
return String((cursor as Record<string, unknown>)['$value'] ?? value);
}
if (typeof cursor === 'string') return cursor;
return value;
}
+206
View File
@@ -0,0 +1,206 @@
/**
* resolver.ts — Phase 6
*
* Resolves a live CSS value (computed from the selected element) to the closest
* matching design token, using OKLCH perceptual color distance for colors and
* exact/near-match for numeric values.
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.3 "Token Resolver"
*/
import { formatHex, oklch, parse as culoriParse, differenceCiede2000 } from 'culori';
import type { DesignToken } from './parser.js';
export interface TokenMatch {
token: DesignToken;
/** Value matches token exactly (distance === 0). */
exact: boolean;
/** 0 = exact; higher = further from a match. Max useful threshold ≈ 10. */
distance: number;
}
// ── Color resolution ──────────────────────────────────────────────────────────
const colorDifference = differenceCiede2000();
/**
* Compute the perceptual color distance between two CSS color strings.
* Uses CIEDE2000 via culori. Returns Infinity if either value cannot be parsed.
*/
function colorDistance(a: string, b: string): number {
try {
const ca = culoriParse(a);
const cb = culoriParse(b);
if (!ca || !cb) return Infinity;
return colorDifference(ca, cb);
} catch {
return Infinity;
}
}
// ── Numeric resolution ────────────────────────────────────────────────────────
function parseNumericPx(value: string): number | null {
const v = value.trim();
if (v.endsWith('px')) {
const n = parseFloat(v);
return isNaN(n) ? null : n;
}
if (v.endsWith('rem')) {
const n = parseFloat(v);
return isNaN(n) ? null : n * 16; // normalise with standard 16px base
}
const n = parseFloat(v);
if (!isNaN(n) && v === String(n)) return n;
return null;
}
function numericDistance(a: string, b: string): number {
const na = parseNumericPx(a);
const nb = parseNumericPx(b);
if (na === null || nb === null) return Infinity;
return Math.abs(na - nb);
}
// ── Public API ────────────────────────────────────────────────────────────────
const COLOR_DISTANCE_THRESHOLD = 10; // CIEDE2000 units (perceptible but close)
const NUMERIC_DISTANCE_THRESHOLD = 2; // px
/**
* Find the best matching design token for a given CSS value.
*
* @param cssValue A computed CSS value string (e.g. "rgb(0, 102, 255)", "16px")
* @param tokens The loaded token array from the canvas store
* @param rootFontSizePx Optional root font size for rem → px normalisation (default 16)
* @returns TokenMatch or null if no match is within the acceptable threshold
*/
export function resolveValueToToken(
cssValue: string,
tokens: DesignToken[],
rootFontSizePx = 16,
): TokenMatch | null {
if (!tokens.length || !cssValue.trim()) return null;
// Normalise the input value to a canonical form
const normalised = normaliseCssValue(cssValue.trim(), rootFontSizePx);
let bestMatch: TokenMatch | null = null;
let bestDistance = Infinity;
for (const token of tokens) {
const tokenNorm = normaliseCssValue(token.rawValue, rootFontSizePx);
let distance: number;
if (token.type === 'color') {
distance = colorDistance(normalised, tokenNorm);
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
} else {
distance = numericDistance(normalised, tokenNorm);
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
}
if (distance < bestDistance) {
bestDistance = distance;
bestMatch = { token, exact: distance === 0, distance };
}
}
return bestMatch;
}
/**
* Find ALL tokens that closely match a value, sorted by distance (closest first).
* Useful for the TokenPicker dropdown.
*
* @param limit Maximum number of matches to return (default 5)
*/
export function resolveValueToTokens(
cssValue: string,
tokens: DesignToken[],
rootFontSizePx = 16,
limit = 5,
): TokenMatch[] {
if (!tokens.length || !cssValue.trim()) return [];
const normalised = normaliseCssValue(cssValue.trim(), rootFontSizePx);
const matches: TokenMatch[] = [];
for (const token of tokens) {
const tokenNorm = normaliseCssValue(token.rawValue, rootFontSizePx);
let distance: number;
if (token.type === 'color') {
distance = colorDistance(normalised, tokenNorm);
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
} else {
distance = numericDistance(normalised, tokenNorm);
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
}
matches.push({ token, exact: distance === 0, distance });
}
return matches
.sort((a, b) => a.distance - b.distance)
.slice(0, limit);
}
/**
* Given a token key (CSS custom property), look it up in the token array.
*/
export function findTokenByKey(key: string, tokens: DesignToken[]): DesignToken | undefined {
return tokens.find((t) => t.key === key);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* Normalise a CSS value to a canonical form for comparison.
* - Colors: convert to hex via culori
* - rem values: convert to px using the root font size
* - Everything else: lowercase trim
*/
function normaliseCssValue(value: string, rootFontSizePx: number): string {
const v = value.trim().toLowerCase();
// Try color parsing
try {
const parsed = culoriParse(v);
if (parsed) {
return formatHex(parsed) ?? v;
}
} catch { /* not a color */ }
// rem → px
if (v.endsWith('rem')) {
const n = parseFloat(v);
if (!isNaN(n)) return `${n * rootFontSizePx}px`;
}
return v;
}
// ── OKLCH color info (for UI display) ────────────────────────────────────────
export interface OklchInfo {
l: number; // lightness 01
c: number; // chroma
h: number; // hue 0360
}
/**
* Parse a CSS color string into OKLCH components.
* Returns null if the color cannot be parsed.
*/
export function parseOklch(color: string): OklchInfo | null {
try {
const parsed = culoriParse(color);
if (!parsed) return null;
const ok = oklch(parsed);
if (!ok) return null;
return { l: ok.l ?? 0, c: ok.c ?? 0, h: ok.h ?? 0 };
} catch {
return null;
}
}
+26 -2
View File
@@ -49,6 +49,9 @@ export const WorkspaceSchema = z.object({
});
export type Workspace = z.infer<typeof WorkspaceSchema>;
export const ArtboardTypeSchema = z.enum(['route', 'isolation', 'static']);
export type ArtboardType = z.infer<typeof ArtboardTypeSchema>;
export const ArtboardSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
@@ -66,8 +69,27 @@ export const ArtboardSchema = z.object({
/** Spec: NOT NULL DEFAULT 900 (migration 008) */
height: z.number().int().default(900),
created_by: z.string().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
// ── Phase 0 columns (migration 012) ──────────────────────────────────────
/** Canvas X position in world-space pixels (promoted from metadata_jsonb). */
canvas_x: z.number().default(0).optional(),
/** Canvas Y position in world-space pixels (promoted from metadata_jsonb). */
canvas_y: z.number().default(0).optional(),
/** Device preset label, e.g. 'desktop-hd' | 'laptop' | 'mobile'. */
device_preset: z.string().default('desktop-hd').optional(),
/** Artboard type controls which content renderer is used. */
artboard_type: ArtboardTypeSchema.default('route').optional(),
/** Isolation artboard: exported symbol name, e.g. "Button". */
isolation_component: z.string().nullable().optional(),
/** Isolation artboard: workspace-relative source file path. */
isolation_file: z.string().nullable().optional(),
/** Isolation artboard: JSON props fed to window.__OM_ISO_PROPS__. */
isolation_props: z.record(z.unknown()).optional(),
/** True once the user has manually dragged the artboard ≥10 world-space px. */
manually_positioned: z.boolean().default(false).optional(),
/** Supabase Storage public URL for the JPEG thumbnail (data URIs never stored). */
thumbnail_url: z.string().nullable().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type Artboard = z.infer<typeof ArtboardSchema>;
@@ -100,6 +122,8 @@ export const IntentDiffSchema = z.object({
before_screenshot: z.string().nullable().optional(),
after_screenshot: z.string().nullable().optional(),
exported_code: z.string().nullable().optional(),
/** Phase 5: agent-supplied failure reason when status = 'BLOCKED' (migration 012). */
blocked_reason: z.string().nullable().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
+83 -2
View File
@@ -154,6 +154,10 @@ export function buildProxyFiberHookScript(): string {
var childrenStyleOverrides = {}; // parentNodeId -> { selector -> { property -> value } }
var overrideStyleEl = null; // the <style id="__om_overrides__"> element
// ── Snapshot / thumbnail capture state ───────────────────────────────────
var _snapshotGeneration = 0; // incremented on CANCEL_SNAPSHOT to invalidate in-flight captures
var _html2canvasLoading = null; // cached Promise<html2canvas> — only inject script once
// ── postMessage helper ────────────────────────────────────────────────────
function post(msg) {
try {
@@ -703,6 +707,62 @@ export function buildProxyFiberHookScript(): string {
}
}
break;
case 'CAPTURE_THUMBNAIL':
// Phase 0: capture the full page as a JPEG thumbnail via html2canvas.
// Sent when an artboard transitions Active → Far in the viewport culling system.
loadHtml2Canvas().then(function(h2c) {
return h2c(document.body, {
useCORS: true,
allowTaint: true,
logging: false,
scale: 0.5, // half-resolution thumbnail keeps payload small
imageTimeout: 4000,
});
}).then(function(canvas) {
post({ type: 'THUMBNAIL_READY', dataUrl: canvas.toDataURL('image/jpeg', 0.7) });
}).catch(function() {
post({ type: 'THUMBNAIL_READY', dataUrl: null });
});
break;
case 'UPDATE_ISOLATION_PROPS':
// Phase 0/3: update isolation artboard props and trigger a re-render.
// The isolation page exposes window.__OM_ISO_RENDER__() which calls
// ReactDOM.render / root.render with the new window.__OM_ISO_PROPS__.
if (msg.props && typeof msg.props === 'object') {
window.__OM_ISO_PROPS__ = msg.props;
if (typeof window.__OM_ISO_RENDER__ === 'function') {
try { window.__OM_ISO_RENDER__(); } catch(e) { /* renderer not yet mounted */ }
}
}
break;
case 'CANCEL_SNAPSHOT':
// Phase 4: invalidate any pending CAPTURE_SNAPSHOT by bumping the generation
// counter — any in-flight html2canvas call will see the mismatch and drop its result.
_snapshotGeneration += 1;
break;
case 'CAPTURE_SNAPSHOT':
// Phase 4: capture a PNG of the selected element, used by the Code Preview diff.
// Guards against stale results with a per-capture generation counter.
if (typeof msg.nodeId !== 'string') break;
_snapshotGeneration += 1;
(function(nodeId, gen) {
var sInfo = nodeMap[nodeId];
var sEl = sInfo ? findDomElement(sInfo.fiber) : null;
if (!sEl) {
post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId: nodeId });
return;
}
loadHtml2Canvas().then(function(h2c) {
if (_snapshotGeneration !== gen) return null;
return h2c(sEl, { useCORS: true, allowTaint: true, logging: false, timeout: 3000 });
}).then(function(canvas) {
if (!canvas || _snapshotGeneration !== gen) return;
post({ type: 'SNAPSHOT_READY', dataUrl: canvas.toDataURL('image/png'), nodeId: nodeId });
}).catch(function() {
if (_snapshotGeneration === gen) post({ type: 'SNAPSHOT_READY', dataUrl: null, nodeId: nodeId });
});
})(msg.nodeId, _snapshotGeneration);
break;
}
});
@@ -716,6 +776,24 @@ export function buildProxyFiberHookScript(): string {
}
}
// ── html2canvas lazy loader ───────────────────────────────────────────────
// html2canvas is not bundled in the fiber hook — inject from CDN on first
// need, caching the Promise so the script tag is added only once.
function loadHtml2Canvas() {
if (typeof window.html2canvas === 'function') {
return Promise.resolve(window.html2canvas);
}
if (_html2canvasLoading) return _html2canvasLoading;
_html2canvasLoading = new Promise(function(resolve, reject) {
var s = document.createElement('script');
s.src = 'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js';
s.onload = function() { resolve(window.html2canvas); };
s.onerror = function() { _html2canvasLoading = null; reject(new Error('html2canvas load failed')); };
(document.head || document.documentElement).appendChild(s);
});
return _html2canvasLoading;
}
// SPA-safe navigation: push to history and fire popstate so framework
// routers (React Router, Next.js) pick up the route change.
function doNavigate(path) {
@@ -820,8 +898,11 @@ export function buildProxyFiberHookScript(): string {
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes: routes });
}
// ── Ready signal ──────────────────────────────────────────────────────────
post({ type: 'READY' });
// ── Ready signal (includes root font size for rem→px normalisation) ────────
var rootFontSizePx = parseFloat(
window.getComputedStyle(document.documentElement).getPropertyValue('font-size') || '16'
) || 16;
post({ type: 'READY', rootFontSizePx: rootFontSizePx });
setTimeout(discoverRoutes, 800);
// Re-discover on SPA navigation (Next.js App Router fires popstate on push)
window.addEventListener('popstate', function() { setTimeout(discoverRoutes, 100); });
+22 -3
View File
@@ -46,7 +46,18 @@ export type HostMessage =
* Used for paragraph-spacing: patches margin-bottom on each direct <p> child. */
| { type: 'PATCH_CHILDREN_STYLE'; parentNodeId: string; selector: string; property: string; value: string }
/** Hide a component's DOM element (sets display:none). Non-destructive. */
| { type: 'REMOVE_ELEMENT'; nodeId: string };
| { type: 'REMOVE_ELEMENT'; nodeId: string }
/** Phase 0: Ask the renderer to capture a JPEG thumbnail via html2canvas and post THUMBNAIL_READY.
* Sent when an artboard transitions from Active → Near/Far in the viewport culling system. */
| { type: 'CAPTURE_THUMBNAIL' }
/** Phase 0: Re-render a component isolation artboard with new props (live preview, no code change).
* The iframe sets window.__OM_ISO_PROPS__ and calls window.__OM_ISO_RENDER__(). */
| { type: 'UPDATE_ISOLATION_PROPS'; props: Record<string, unknown> }
/** Phase 4: Ask the renderer to capture a PNG snapshot of the selected element via html2canvas.
* Sent when the user hovers a component > 200ms or clicks "Preview Code Change". */
| { type: 'CAPTURE_SNAPSHOT'; nodeId: string }
/** Phase 4: Cancel an in-flight snapshot capture (superseded by a newer request). */
| { type: 'CANCEL_SNAPSHOT' };
export interface HostEnvelope {
source: typeof HOST_SOURCE;
@@ -57,7 +68,10 @@ export interface HostEnvelope {
// ── Renderer → Host messages ──────────────────────────────────────────────────
export type RendererMessage =
| { type: 'READY' }
/** Phase 0/6: Sent once the fiber hook is initialised and the React runtime is detected.
* rootFontSizePx is read via getComputedStyle(document.documentElement).fontSize so the
* canvas can normalise rem values to px for token matching (Phase 6). */
| { type: 'READY'; rootFontSizePx?: number }
| { type: 'FIBER_TREE_UPDATE'; root: FiberNode }
| { type: 'COMPONENT_SELECTED'; nodeId: string; nodeName?: string; rect: DOMRectLike }
| { type: 'COMPONENT_DESELECTED' }
@@ -75,7 +89,12 @@ export type RendererMessage =
}
/** All discoverable routes found in the running app — sent once after READY
* and again after each SPA navigation. */
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> };
| { type: 'ROUTES_DISCOVERED'; routes: Array<{ path: string; label: string }> }
/** Phase 0: Response to CAPTURE_THUMBNAIL — base64 JPEG data URL, or null on failure.
* The canvas stores the data URL in Zustand and uploads to Supabase Storage. */
| { type: 'THUMBNAIL_READY'; dataUrl: string | null }
/** Phase 4: Response to CAPTURE_SNAPSHOT — base64 PNG of the selected element, or null. */
| { type: 'SNAPSHOT_READY'; dataUrl: string | null; nodeId: string };
export interface RendererEnvelope {
source: typeof RENDERER_SOURCE;
+17
View File
@@ -186,10 +186,16 @@ importers:
packages/design-language:
dependencies:
culori:
specifier: ^3.3.0
version: 3.3.0
zod:
specifier: ^3.0.0
version: 3.25.76
devDependencies:
'@types/culori':
specifier: ^4.0.1
version: 4.0.1
'@vitest/coverage-v8':
specifier: ^2.1.9
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
@@ -1808,6 +1814,9 @@ packages:
peerDependencies:
typescript: '>=5.7.2'
'@types/culori@4.0.1':
resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -2049,6 +2058,10 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
culori@3.3.0:
resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -4705,6 +4718,8 @@ snapshots:
dependencies:
typescript: 5.9.3
'@types/culori@4.0.1': {}
'@types/estree@1.0.8': {}
'@types/hast@3.0.4':
@@ -4981,6 +4996,8 @@ snapshots:
csstype@3.2.3: {}
culori@3.3.0: {}
debug@4.4.3:
dependencies:
ms: 2.1.3
@@ -0,0 +1,73 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Migration 012: Phase 0 artboard schema additions
-- Adds first-class typed columns for canvas position, device preset, artboard
-- type, isolation metadata, drag-positioning flag, and thumbnail storage URL.
--
-- Spec reference: SOURCE-AWARE-CANVAS §3.7 (schema additions for Phase 0)
-- All changes use IF NOT EXISTS / DO $$ guards for idempotency.
-- ═══════════════════════════════════════════════════════════════════════════
-- ── artboards: Phase 0 spatial + type columns ─────────────────────────────────
-- Promote canvas position from metadata_jsonb to proper typed columns so they
-- can be indexed and compared without casting. Existing artboards keep their
-- metadata_jsonb values; new columns default to common presets.
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS canvas_x float8 DEFAULT 0;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS canvas_y float8 DEFAULT 0;
-- Backfill canvas_x/canvas_y from metadata_jsonb for existing artboards.
UPDATE artboards
SET
canvas_x = (metadata_jsonb->>'x')::float8,
canvas_y = (metadata_jsonb->>'y')::float8
WHERE
canvas_x = 0 AND canvas_y = 0
AND (metadata_jsonb ? 'x' OR metadata_jsonb ? 'y');
-- Device preset: which physical screen size preset the artboard was sized for.
-- Possible values: 'desktop-hd' | 'desktop-4k' | 'laptop' | 'tablet' | 'mobile'
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS device_preset text DEFAULT 'desktop-hd';
-- Artboard type controls which content renderer is used.
-- 'route' → LiveArtboard iframe pointing at a dev-server route
-- 'isolation' → Isolation artboard wrapping a single component (Phase 3)
-- 'static' → Plain static HTML / no React detected
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'artboards' AND column_name = 'artboard_type'
) THEN
ALTER TABLE artboards ADD COLUMN artboard_type text NOT NULL DEFAULT 'route'
CHECK (artboard_type IN ('route', 'isolation', 'static'));
END IF;
END $$;
-- Isolation artboard metadata (populated when artboard_type = 'isolation').
-- isolation_component: exported symbol name, e.g. "Button"
-- isolation_file: workspace-relative path, e.g. "src/components/Button.tsx"
-- isolation_props: JSON props object fed to window.__OM_ISO_PROPS__
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS isolation_component text;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS isolation_file text;
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS isolation_props jsonb DEFAULT '{}';
-- Drag-positioning flag: true once the user has manually moved this artboard
-- by ≥10 world-space pixels. Prevents the auto-arrange algorithm from
-- overwriting user-chosen positions.
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS manually_positioned boolean NOT NULL DEFAULT false;
-- Thumbnail URL: Supabase Storage public URL for the JPEG thumbnail captured
-- when the artboard transitions to 'far' viewport state.
-- The data URI itself is NEVER written here — only the Storage public URL.
-- Null until the first thumbnail is successfully uploaded.
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS thumbnail_url text;
-- Index: quickly find all isolation artboards within a workspace
CREATE INDEX IF NOT EXISTS artboards_isolation_idx
ON artboards (workspace_id, artboard_type)
WHERE artboard_type = 'isolation';
-- ── intent_diffs: Phase 5 blocked_reason column ───────────────────────────────
-- Stores the agent-supplied reason when status = 'BLOCKED' (e.g. "merge conflict
-- in src/components/Button.tsx — please resolve manually before retrying").
-- Written by update_diff_status when status = 'BLOCKED'; null otherwise.
ALTER TABLE intent_diffs ADD COLUMN IF NOT EXISTS blocked_reason text;