made tiny updates
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+365
-174
@@ -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>;
|
||||
inputSchema: {
|
||||
type: 'object';
|
||||
properties: Record<string, unknown>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
// ── Rate-limited wrapper ──────────────────────────────────────────────────────
|
||||
// ── Tool execution context (injected by the MCP server request loop) ──────────
|
||||
|
||||
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' },
|
||||
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;
|
||||
};
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
|
||||
interface IntentRecord {
|
||||
intentId: string;
|
||||
workspaceId: string;
|
||||
artboardId: string;
|
||||
componentName: string;
|
||||
patchJson: string;
|
||||
strategy: string;
|
||||
summary: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
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` },
|
||||
};
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
|
||||
// ── Tool: get_pending_diffs ───────────────────────────────────────────────────
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
const GetPendingDiffsInput = z.object({
|
||||
workspace_id: z.string().uuid(),
|
||||
artboard_id: z.string().uuid().optional(),
|
||||
});
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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;
|
||||
// ── Tool definitions ──────────────────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
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'],
|
||||
},
|
||||
};
|
||||
|
||||
// ── 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 });
|
||||
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'],
|
||||
},
|
||||
};
|
||||
|
||||
// ── 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 = {
|
||||
const UPDATE_DIFF_STATUS_TOOL: 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}`);
|
||||
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: get_design_language ─────────────────────────────────────────────────
|
||||
// ── Tool map (name → descriptor) ──────────────────────────────────────────────
|
||||
|
||||
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);
|
||||
},
|
||||
export const TOOL_MAP: Record<string, McpTool> = {
|
||||
push_intent: PUSH_INTENT_TOOL,
|
||||
resolve_component: RESOLVE_COMPONENT_TOOL,
|
||||
update_diff_status: UPDATE_DIFF_STATUS_TOOL,
|
||||
};
|
||||
|
||||
// ── Tool registry ─────────────────────────────────────────────────────────────
|
||||
export const TOOLS: McpTool[] = Object.values(TOOL_MAP);
|
||||
|
||||
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 function getToolList(): McpTool[] {
|
||||
return TOOLS;
|
||||
}
|
||||
|
||||
// ── 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.
|
||||
// ── Tool handlers ─────────────────────────────────────────────────────────────
|
||||
|
||||
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[] = [];
|
||||
type Params = Record<string, unknown>;
|
||||
|
||||
for (const [key, field] of Object.entries(shape)) {
|
||||
properties[key] = zodFieldToSchema(field);
|
||||
if (!(field instanceof z.ZodOptional)) {
|
||||
required.push(key);
|
||||
}
|
||||
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.');
|
||||
}
|
||||
|
||||
return { properties, ...(required.length > 0 ? { required } : {}) };
|
||||
const intents = drainPendingIntents(wid);
|
||||
|
||||
if (intents.length === 0) {
|
||||
return textResult('No pending design intents for this workspace.');
|
||||
}
|
||||
throw new Error(`zodToJsonSchema: unsupported top-level type ${schema.constructor.name}`);
|
||||
|
||||
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')}`,
|
||||
);
|
||||
}
|
||||
|
||||
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 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()}`);
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
'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 { IsolationFrame } from './IsolationFrame';
|
||||
import { SelectionOverlay } from './SelectionOverlay';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
|
||||
@@ -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,13 +446,28 @@ 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 ? (
|
||||
<>
|
||||
{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}
|
||||
@@ -376,6 +475,21 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
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 */}
|
||||
@@ -406,6 +520,43 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* Off-screen placeholder — iframe unmounted to save resources.
|
||||
* Displays the last JPEG thumbnail captured before suspension. */
|
||||
<div style={{
|
||||
width, height,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}>
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<EmptyArtboardContent id={id} label={label} width={width} height={height} workspaceId={workspaceId} projectId={projectId} queryClient={queryClient} />
|
||||
)}
|
||||
|
||||
@@ -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,10 +125,90 @@ 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;
|
||||
|
||||
// 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);
|
||||
@@ -89,6 +216,8 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
e.preventDefault();
|
||||
useHistory.getState().redo(selectedArtboardId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
|
||||
@@ -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,8 +146,11 @@ export function ArtboardNavigator() {
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{/* ── Artboards ── */}
|
||||
<SectionLabel>Artboards</SectionLabel>
|
||||
{/* ── 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;
|
||||
@@ -148,6 +175,21 @@ export function ArtboardNavigator() {
|
||||
);
|
||||
})}
|
||||
</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>();
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
@@ -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 } }),
|
||||
}));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
+86
-2
@@ -4,6 +4,7 @@
|
||||
// Usage:
|
||||
// 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.
|
||||
@@ -14,6 +15,8 @@ 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:
|
||||
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);
|
||||
|
||||
@@ -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">✓ 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
@@ -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,
|
||||
): void {
|
||||
res.writeHead(501, {
|
||||
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(STUB_BODY);
|
||||
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,
|
||||
): 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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
export * from './schema.js';
|
||||
export * from './validator.js';
|
||||
export * from './tokens.js';
|
||||
export * from './parser.js';
|
||||
export * from './resolver.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 0–1
|
||||
c: number; // chroma
|
||||
h: number; // hue 0–360
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
@@ -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,6 +69,25 @@ export const ArtboardSchema = z.object({
|
||||
/** Spec: NOT NULL DEFAULT 900 (migration 008) */
|
||||
height: z.number().int().default(900),
|
||||
created_by: z.string().optional(),
|
||||
// ── 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(),
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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); });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
+17
@@ -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;
|
||||
Reference in New Issue
Block a user