made tiny updates
This commit is contained in:
@@ -43,15 +43,21 @@ The server is pre-configured in \`.claude/settings.json\`.
|
|||||||
${toolLines}
|
${toolLines}
|
||||||
|
|
||||||
### Implementation Workflow
|
### Implementation Workflow
|
||||||
1. \`get_pending_diffs\` → list EXPORTED IntentDiffs that need code changes
|
1. Call \`push_intent\` to receive any pending design intent diffs from the Origin canvas.
|
||||||
2. \`get_artboard_context\` → load component tree, screenshots, and design language
|
2. Locate the component file using \`resolve_component\` (pass the \`nodeId\` from the intent).
|
||||||
3. Implement the required changes in the codebase
|
3. Apply the change to the source file — the intent's \`codeDiff\` contains the expected before/after.
|
||||||
4. \`ask_design_agent\` → clarify design intent when the diff is ambiguous
|
4. After applying, call \`update_diff_status\` with \`status: "IMPLEMENTED"\` and the \`intentId\`.
|
||||||
5. \`update_diff_status\` → mark IMPLEMENTED or BLOCKED with an explanation
|
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
|
### Important: Always close the loop with update_diff_status
|
||||||
Run \`get_design_language\` at session start to cache the team's active Design Language File.
|
Every intent received via \`push_intent\` MUST be closed with \`update_diff_status\` — either
|
||||||
All token references (colors, typography, spacing) must match the DLF.
|
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
|
### Rate Limit
|
||||||
100 diff exports/hour per workspace. If you hit the limit, wait before retrying.
|
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 { MCP_ERROR, textResult, jsonResult } from './protocol.js';
|
||||||
|
|
||||||
export type { WorkspaceToken, AgentType } from './auth.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 { checkRateLimit, getRateLimitStatus } from './rate-limiter.js';
|
||||||
|
|
||||||
export type { McpTool, ToolContext } from './tools.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 type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js';
|
||||||
export { generateCursorConfig } from './adapters/cursor.js';
|
export { generateCursorConfig } from './adapters/cursor.js';
|
||||||
|
|||||||
@@ -54,6 +54,67 @@ export interface AuthAck {
|
|||||||
agentType: 'CURSOR' | 'CLAUDE_CODE' | 'GENERIC';
|
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 ─────────────────────────────────────────────────────────
|
// ── Tool result types ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ToolResult<T> {
|
export interface ToolResult<T> {
|
||||||
|
|||||||
+371
-180
@@ -1,216 +1,407 @@
|
|||||||
import { z } from 'zod';
|
/**
|
||||||
import { checkRateLimit } from './rate-limiter.js';
|
* tools.ts — Phase 5
|
||||||
import { jsonResult, textResult, MCP_ERROR } from './protocol.js';
|
*
|
||||||
import type { ToolResult, JsonRpcError } from './protocol.js';
|
* Defines the MCP tools exposed by the Agent Bridge to connected IDE agents
|
||||||
import type { DiffStatus } from '@originmain/origin-graph';
|
* (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 {
|
// ── Tool descriptor shape (MCP tools/list schema) ─────────────────────────────
|
||||||
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 ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface McpTool {
|
export interface McpTool {
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
inputSchema: z.ZodTypeAny;
|
inputSchema: {
|
||||||
execute(params: unknown, ctx: ToolContext): Promise<ToolResult<unknown> | JsonRpcError>;
|
type: 'object';
|
||||||
}
|
properties: Record<string, unknown>;
|
||||||
|
required?: string[];
|
||||||
// ── Rate-limited wrapper ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function rateGuard(workspaceId: string): JsonRpcError | null {
|
|
||||||
const { allowed } = checkRateLimit(workspaceId);
|
|
||||||
if (!allowed) {
|
|
||||||
return {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: null,
|
|
||||||
error: { code: MCP_ERROR.RATE_LIMITED, message: 'Rate limit exceeded: 100 diff exports per hour per workspace' },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function notFound(id: string, type: string): JsonRpcError {
|
|
||||||
return {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
id: null,
|
|
||||||
error: { code: MCP_ERROR.NOT_FOUND, message: `${type} ${id} not found` },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tool: get_pending_diffs ───────────────────────────────────────────────────
|
// ── Tool execution context (injected by the MCP server request loop) ──────────
|
||||||
|
|
||||||
const GetPendingDiffsInput = z.object({
|
export interface ToolContext {
|
||||||
workspace_id: z.string().uuid(),
|
/** Verified workspace ID from the authenticated token. */
|
||||||
artboard_id: z.string().uuid().optional(),
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const getPendingDiffs: McpTool = {
|
// ── In-process intent store ───────────────────────────────────────────────────
|
||||||
name: 'get_pending_diffs',
|
// Intents are pushed here by the /api/intent Next.js route (via push_intent),
|
||||||
description: 'Returns all IntentDiff objects with EXPORTED status for the workspace.',
|
// and drained by connected agents through polling or INTENT_RECEIVED push.
|
||||||
inputSchema: GetPendingDiffsInput,
|
//
|
||||||
async execute(params, ctx) {
|
// For production multi-instance deployments, replace with a Redis pub/sub or
|
||||||
const guard = rateGuard(ctx.workspaceId);
|
// Supabase Realtime channel.
|
||||||
if (guard) return guard;
|
|
||||||
|
|
||||||
const { artboard_id } = GetPendingDiffsInput.parse(params);
|
interface IntentRecord {
|
||||||
const diffs = await ctx.db.getDiffsByStatus(ctx.workspaceId, 'EXPORTED');
|
intentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
artboardId: string;
|
||||||
|
componentName: string;
|
||||||
|
patchJson: string;
|
||||||
|
strategy: string;
|
||||||
|
summary: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
const filtered = artboard_id
|
const pendingIntents = new Map<string, IntentRecord[]>();
|
||||||
? (diffs as Array<{ artboard_id: string }>).filter(d => d.artboard_id === artboard_id)
|
|
||||||
: diffs;
|
|
||||||
|
|
||||||
return jsonResult(filtered);
|
/**
|
||||||
|
* Store an intent pushed by the canvas (called from the /api/intent route).
|
||||||
|
* Returns the generated intentId.
|
||||||
|
*/
|
||||||
|
export function storePendingIntent(
|
||||||
|
workspaceId: string,
|
||||||
|
intent: Omit<IntentRecord, 'intentId' | 'workspaceId' | 'createdAt'>,
|
||||||
|
): string {
|
||||||
|
const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const record: IntentRecord = {
|
||||||
|
intentId,
|
||||||
|
workspaceId,
|
||||||
|
...intent,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
const queue = pendingIntents.get(workspaceId) ?? [];
|
||||||
|
queue.push(record);
|
||||||
|
pendingIntents.set(workspaceId, queue);
|
||||||
|
return intentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drain all pending intents for a workspace (called by push_intent tool handler
|
||||||
|
* or by the agent when polling).
|
||||||
|
*/
|
||||||
|
export function drainPendingIntents(workspaceId: string): IntentRecord[] {
|
||||||
|
const queue = pendingIntents.get(workspaceId) ?? [];
|
||||||
|
pendingIntents.delete(workspaceId);
|
||||||
|
return queue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI indexer registry ──────────────────────────────────────────────────────
|
||||||
|
// Maps workspaceId → { url, expiresAt } (registered via /register-indexer).
|
||||||
|
//
|
||||||
|
// TTL policy (spec Phase 5 §8.3):
|
||||||
|
// • Default TTL: 300 s (5 min)
|
||||||
|
// • CLI sends heartbeat POST every 120 s to refresh the TTL
|
||||||
|
// • Registration is considered expired after 360 s (3× heartbeat interval)
|
||||||
|
//
|
||||||
|
// The GC sweep runs on every registration and lookup to avoid a timer leak
|
||||||
|
// in serverless/edge environments where setInterval may not fire.
|
||||||
|
|
||||||
|
interface IndexerEntry {
|
||||||
|
url: string;
|
||||||
|
expiresAt: number; // ms since epoch
|
||||||
|
}
|
||||||
|
|
||||||
|
const indexerRegistry = new Map<string, IndexerEntry>();
|
||||||
|
|
||||||
|
/** Evict all entries whose TTL has expired. */
|
||||||
|
function gcIndexerRegistry(): void {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [wid, entry] of indexerRegistry) {
|
||||||
|
if (entry.expiresAt < now) indexerRegistry.delete(wid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register (or refresh) a CLI indexer for a workspace.
|
||||||
|
* Security: the caller MUST validate that `url` is a localhost URL before
|
||||||
|
* calling this function (enforced by the /register-indexer API route).
|
||||||
|
*
|
||||||
|
* @param workspaceId Verified workspace ID
|
||||||
|
* @param url Indexer URL — must be localhost (caller-validated)
|
||||||
|
* @param ttlSeconds TTL in seconds (default: 300 s / 5 min)
|
||||||
|
*/
|
||||||
|
export function registerIndexer(workspaceId: string, url: string, ttlSeconds = 300): void {
|
||||||
|
gcIndexerRegistry();
|
||||||
|
indexerRegistry.set(workspaceId, { url, expiresAt: Date.now() + ttlSeconds * 1000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the registered indexer URL for a workspace, or undefined if not
|
||||||
|
* registered or if the TTL has expired.
|
||||||
|
*/
|
||||||
|
export function getIndexerUrl(workspaceId: string): string | undefined {
|
||||||
|
gcIndexerRegistry();
|
||||||
|
const entry = indexerRegistry.get(workspaceId);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
if (entry.expiresAt < Date.now()) {
|
||||||
|
indexerRegistry.delete(workspaceId);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return entry.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refresh the TTL for an already-registered indexer (heartbeat endpoint).
|
||||||
|
* Returns false if no entry exists for the workspace (caller should 404).
|
||||||
|
*/
|
||||||
|
export function heartbeatIndexer(workspaceId: string, ttlSeconds = 300): boolean {
|
||||||
|
const entry = indexerRegistry.get(workspaceId);
|
||||||
|
if (!entry || entry.expiresAt < Date.now()) return false;
|
||||||
|
entry.expiresAt = Date.now() + ttlSeconds * 1000;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool definitions ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const PUSH_INTENT_TOOL: McpTool = {
|
||||||
|
name: 'push_intent',
|
||||||
|
description:
|
||||||
|
'Receive a design intent diff from the Origin canvas. The diff describes style or prop changes ' +
|
||||||
|
'made by the designer that should be applied to the source code. Call this when Origin notifies ' +
|
||||||
|
'you of pending changes. Returns the list of pending intents for this workspace.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
workspace_id: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The workspace ID (must match the authenticated token).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['workspace_id'],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Tool: get_artboard_context ────────────────────────────────────────────────
|
const RESOLVE_COMPONENT_TOOL: McpTool = {
|
||||||
|
name: 'resolve_component',
|
||||||
const GetArtboardContextInput = z.object({
|
description:
|
||||||
artboard_id: z.string().uuid(),
|
'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.',
|
||||||
const getArtboardContext: McpTool = {
|
inputSchema: {
|
||||||
name: 'get_artboard_context',
|
type: 'object',
|
||||||
description: 'Returns full artboard metadata, component tree, design language file, and before/after screenshots.',
|
properties: {
|
||||||
inputSchema: GetArtboardContextInput,
|
artboard_id: {
|
||||||
async execute(params, ctx) {
|
type: 'string',
|
||||||
const { artboard_id } = GetArtboardContextInput.parse(params);
|
description: 'The artboard that contains the component.',
|
||||||
const artboard = await ctx.db.getArtboard(artboard_id);
|
},
|
||||||
if (artboard == null) return notFound(artboard_id, 'Artboard');
|
node_id: {
|
||||||
const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId);
|
type: 'string',
|
||||||
return jsonResult({ artboard, designLanguageFile: dlf });
|
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 UPDATE_DIFF_STATUS_TOOL: McpTool = {
|
||||||
|
|
||||||
const AskDesignAgentInput = z.object({
|
|
||||||
diff_id: z.string().uuid(),
|
|
||||||
question: z.string().min(1).max(2000),
|
|
||||||
});
|
|
||||||
|
|
||||||
const askDesignAgent: McpTool = {
|
|
||||||
name: 'ask_design_agent',
|
|
||||||
description: 'Ask the design AI agent a question about a specific diff. Returns a Claude-generated answer with visual reference.',
|
|
||||||
inputSchema: AskDesignAgentInput,
|
|
||||||
async execute(params, ctx) {
|
|
||||||
const { diff_id, question } = AskDesignAgentInput.parse(params);
|
|
||||||
|
|
||||||
// Fetch the diff first to resolve its artboard_id, then fetch the artboard.
|
|
||||||
const diff = await ctx.db.getDiff(diff_id);
|
|
||||||
if (diff == null) return notFound(diff_id, 'IntentDiff');
|
|
||||||
const artboardId = (diff as { artboard_id: string }).artboard_id;
|
|
||||||
const artboard = await ctx.db.getArtboard(artboardId);
|
|
||||||
if (artboard == null) return notFound(artboardId, 'Artboard');
|
|
||||||
|
|
||||||
const answer = await ctx.ai.answerAgentQuestion(diff_id, question, artboard);
|
|
||||||
return textResult(answer);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Tool: update_diff_status ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const UpdateDiffStatusInput = z.object({
|
|
||||||
diff_id: z.string().uuid(),
|
|
||||||
status: z.enum(['IMPLEMENTED', 'BLOCKED']),
|
|
||||||
notes: z.string().max(1000).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateDiffStatus: McpTool = {
|
|
||||||
name: 'update_diff_status',
|
name: 'update_diff_status',
|
||||||
description: 'Acknowledges implementation or reports a block. Updates the diff status in the Origin Graph.',
|
description:
|
||||||
inputSchema: UpdateDiffStatusInput,
|
'Update the status of a design intent diff after attempting to apply it. ' +
|
||||||
async execute(params, ctx) {
|
'Call with status "IMPLEMENTED" after successfully applying the diff to the source code. ' +
|
||||||
const { diff_id, status, notes } = UpdateDiffStatusInput.parse(params);
|
'Call with status "BLOCKED" and a reason string if the diff could not be applied — ' +
|
||||||
await ctx.db.updateDiffStatus(diff_id, status, notes);
|
'the reason is shown to the designer so they can resolve the conflict manually. ' +
|
||||||
return textResult(`Diff ${diff_id} status updated to ${status}`);
|
'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({
|
export const TOOL_MAP: Record<string, McpTool> = {
|
||||||
workspace_id: z.string().uuid(),
|
push_intent: PUSH_INTENT_TOOL,
|
||||||
|
resolve_component: RESOLVE_COMPONENT_TOOL,
|
||||||
|
update_diff_status: UPDATE_DIFF_STATUS_TOOL,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TOOLS: McpTool[] = Object.values(TOOL_MAP);
|
||||||
|
|
||||||
|
export function getToolList(): McpTool[] {
|
||||||
|
return TOOLS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Tool handlers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Params = Record<string, unknown>;
|
||||||
|
|
||||||
|
async function handlePushIntent(ctx: ToolContext): Promise<ToolResult<unknown>> {
|
||||||
|
const params = ctx.params as Params;
|
||||||
|
const wid = typeof params['workspace_id'] === 'string' ? params['workspace_id'] : ctx.workspaceId;
|
||||||
|
|
||||||
|
if (wid !== ctx.workspaceId) {
|
||||||
|
return textResult('Error: workspace_id does not match authenticated workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const intents = drainPendingIntents(wid);
|
||||||
|
|
||||||
|
if (intents.length === 0) {
|
||||||
|
return textResult('No pending design intents for this workspace.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = intents.map((intent) => {
|
||||||
|
const patches = JSON.parse(intent.patchJson) as Array<{ property: string; value: string; previousValue?: string }>;
|
||||||
|
const lines = patches.map(
|
||||||
|
(p) => ` • ${p.property}: ${p.previousValue ?? '(unknown)'} → ${p.value}`,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
`Intent ${intent.intentId} — ${intent.componentName} (${intent.strategy})`,
|
||||||
|
intent.summary,
|
||||||
|
...lines,
|
||||||
|
` artboardId: ${intent.artboardId}`,
|
||||||
|
].join('\n');
|
||||||
});
|
});
|
||||||
|
|
||||||
const getDesignLanguage: McpTool = {
|
return textResult(
|
||||||
name: 'get_design_language',
|
`${intents.length} pending design intent${intents.length !== 1 ? 's' : ''}:\n\n${summary.join('\n\n')}`,
|
||||||
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.
|
async function handleResolveComponent(ctx: ToolContext): Promise<ToolResult<unknown>> {
|
||||||
// This ensures MCP clients send well-formed requests and the schema is enforced.
|
const params = ctx.params as Params;
|
||||||
GetDesignLanguageInput.parse(params);
|
const nodeId = typeof params['node_id'] === 'string' ? params['node_id'] : null;
|
||||||
const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId);
|
const artboardId = typeof params['artboard_id'] === 'string' ? params['artboard_id'] : null;
|
||||||
if (!dlf) return textResult('No design language file configured for this workspace.');
|
const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : 'Component';
|
||||||
return jsonResult(dlf);
|
|
||||||
},
|
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;
|
||||||
|
|
||||||
// ── Tool registry ─────────────────────────────────────────────────────────────
|
// 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 }>);
|
||||||
|
|
||||||
export const TOOLS: McpTool[] = [
|
if (error) return textResult(`Error updating diff status: ${error.message}`);
|
||||||
getPendingDiffs,
|
if (!data) return textResult(`Error: intent ${intentId} not found.`);
|
||||||
getArtboardContext,
|
|
||||||
askDesignAgent,
|
|
||||||
updateDiffStatus,
|
|
||||||
getDesignLanguage,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const TOOL_MAP = new Map(TOOLS.map(t => [t.name, t]));
|
return textResult(
|
||||||
|
`Intent ${intentId} status updated to ${status}${reason ? `: ${reason}` : ''}.`,
|
||||||
/** Returns the MCP-spec JSON Schema listing for all tools. */
|
);
|
||||||
export function getToolList() {
|
} catch (err) {
|
||||||
return TOOLS.map(t => ({
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
name: t.name,
|
return textResult(`Failed to update diff status: ${msg}`);
|
||||||
description: t.description,
|
|
||||||
inputSchema: { type: 'object', ...zodToJsonSchema(t.inputSchema) },
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 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.
|
|
||||||
|
|
||||||
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[] = [];
|
|
||||||
|
|
||||||
for (const [key, field] of Object.entries(shape)) {
|
|
||||||
properties[key] = zodFieldToSchema(field);
|
|
||||||
if (!(field instanceof z.ZodOptional)) {
|
|
||||||
required.push(key);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { properties, ...(required.length > 0 ? { required } : {}) };
|
// ── Dispatch ──────────────────────────────────────────────────────────────────
|
||||||
}
|
|
||||||
throw new Error(`zodToJsonSchema: unsupported top-level type ${schema.constructor.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function zodFieldToSchema(field: z.ZodTypeAny): unknown {
|
export async function dispatchTool(name: string, ctx: ToolContext): Promise<ToolResult<unknown>> {
|
||||||
if (field instanceof z.ZodOptional) return zodFieldToSchema(field.unwrap());
|
switch (name) {
|
||||||
if (field instanceof z.ZodString) return { type: 'string' };
|
case 'push_intent':
|
||||||
if (field instanceof z.ZodEnum) return { type: 'string', enum: field.options as string[] };
|
return handlePushIntent(ctx);
|
||||||
if (field instanceof z.ZodNumber) return { type: 'number' };
|
case 'resolve_component':
|
||||||
if (field instanceof z.ZodBoolean) return { type: 'boolean' };
|
return handleResolveComponent(ctx);
|
||||||
throw new Error(`zodToJsonSchema: unsupported field type ${field.constructor.name}`);
|
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).
|
// Authentication: Bearer <workspace_token> (HMAC-SHA256, issued by issueWorkspaceToken).
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 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';
|
import { serverClient } from '@/lib/supabase';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
@@ -54,8 +46,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Dispatch ────────────────────────────────────────────────────────────────
|
// ── Dispatch ────────────────────────────────────────────────────────────────
|
||||||
const tool = TOOL_MAP.get(body.method);
|
if (!TOOL_MAP[body.method]) {
|
||||||
if (!tool) {
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: body.id,
|
id: body.id,
|
||||||
@@ -63,31 +54,16 @@ export async function POST(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = serverClient();
|
|
||||||
const { workspaceId } = workspaceToken;
|
|
||||||
|
|
||||||
const ctx: ToolContext = {
|
const ctx: ToolContext = {
|
||||||
workspaceId,
|
workspaceId: workspaceToken.workspaceId,
|
||||||
db: {
|
params: body.params ?? {},
|
||||||
getDiffsByStatus: (wsId, status) => getDiffsByStatus(db, wsId, status),
|
// Pass the server-side Supabase client for tools that need DB writes
|
||||||
getDiff: (id) => getDiff(db, id),
|
// (e.g. update_diff_status writes blocked_reason to intent_diffs).
|
||||||
getArtboard: (id) => getArtboard(db, id),
|
db: serverClient() as unknown as NonNullable<ToolContext['db']>,
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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 });
|
return NextResponse.json({ jsonrpc: '2.0', id: body.id, result });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : 'Internal error';
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useCallback, useRef } from 'react';
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas } from '@/store/canvas';
|
||||||
import { useViewport } from '@/store/viewport';
|
import { useViewport } from '@/store/viewport';
|
||||||
import { useDiffs } from '@/hooks/useDiffs';
|
import { useDiffs } from '@/hooks/useDiffs';
|
||||||
import { LiveArtboard } from './LiveArtboard';
|
import { LiveArtboard } from './LiveArtboard';
|
||||||
|
import { IsolationFrame } from './IsolationFrame';
|
||||||
import { SelectionOverlay } from './SelectionOverlay';
|
import { SelectionOverlay } from './SelectionOverlay';
|
||||||
import type { FiberNode } from '@originmain/renderer';
|
import type { FiberNode } from '@originmain/renderer';
|
||||||
|
|
||||||
@@ -19,8 +20,28 @@ interface ArtboardProps {
|
|||||||
renderUrl?: string;
|
renderUrl?: string;
|
||||||
/** Route path appended to renderUrl so each artboard can show a different screen. */
|
/** Route path appended to renderUrl so each artboard can show a different screen. */
|
||||||
route?: string;
|
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. */
|
/** Called when the live app reports discoverable routes — Canvas handles creation. */
|
||||||
onRoutesDiscovered?: (sourceId: string, routes: Array<{ path: string; label: string }>) => void;
|
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.
|
/** 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' },
|
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 {
|
const {
|
||||||
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
||||||
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
|
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
|
||||||
selectedComponentId,
|
selectedComponentId, setSelectedArtboardSize,
|
||||||
|
artboardResizeEvent, clearArtboardResize,
|
||||||
|
artboardThumbnails, setArtboardThumbnail,
|
||||||
|
setElementSnapshot,
|
||||||
|
designLanguageTokens,
|
||||||
} = useCanvas();
|
} = 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;
|
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();
|
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
|
// Diff status badges — fetch is cached by TanStack Query across all artboards
|
||||||
const { diffs } = useDiffs(id);
|
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 newX = Math.round(dragStart.current.artX + dragOffsetRef.current.dx);
|
||||||
const newY = Math.round(dragStart.current.artY + dragOffsetRef.current.dy);
|
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
|
// Persist position
|
||||||
fetch(`/api/artboards/${id}`, {
|
fetch(`/api/artboards/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -157,6 +234,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
|||||||
x: newX, y: newY, width, height,
|
x: newX, y: newY, width, height,
|
||||||
...(renderUrl ? { renderUrl } : {}),
|
...(renderUrl ? { renderUrl } : {}),
|
||||||
...(route ? { route } : {}),
|
...(route ? { route } : {}),
|
||||||
|
...(wasIntentionalDrag ? { manuallyPositioned: true } : {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
@@ -213,6 +291,12 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ position: 'absolute', top: effectiveY, left: effectiveX }}
|
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()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
onClick={(e) => { e.stopPropagation(); selectArtboard(id); }}
|
onClick={(e) => { e.stopPropagation(); selectArtboard(id); }}
|
||||||
>
|
>
|
||||||
@@ -362,13 +446,28 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
|||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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
|
<LiveArtboard
|
||||||
id={id}
|
id={id}
|
||||||
src={buildSrc(renderUrl, route)}
|
src={buildSrc(renderUrl, route)}
|
||||||
width={width}
|
width={width}
|
||||||
height={height}
|
height={height}
|
||||||
|
{...(renderPriority === 'near' ? { style: { visibility: 'hidden' } } : {})}
|
||||||
|
{...(designTokens ? { designTokens } : {})}
|
||||||
selectedComponentId={selectedComponentId}
|
selectedComponentId={selectedComponentId}
|
||||||
onReady={() => { setArtboardLive(id, true); setIsStaticPage(false); }}
|
onReady={() => { setArtboardLive(id, true); setIsStaticPage(false); }}
|
||||||
onFiberTreeUpdate={handleFiberUpdate}
|
onFiberTreeUpdate={handleFiberUpdate}
|
||||||
@@ -376,6 +475,21 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
|||||||
onComponentStylesUpdate={handleComponentStylesUpdate}
|
onComponentStylesUpdate={handleComponentStylesUpdate}
|
||||||
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
|
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
|
||||||
onStaticPageDetected={() => setIsStaticPage(true)}
|
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 */}
|
{/* 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} />
|
<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 { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
import { Artboard } from './Artboard';
|
import { Artboard } from './Artboard';
|
||||||
import { CompletionZone } from './CompletionZone';
|
import { CompletionZone } from './CompletionZone';
|
||||||
|
import { artboardIframeMap } from '@/lib/artboard-iframe-map';
|
||||||
|
import { createHostEnvelope } from '@originmain/renderer';
|
||||||
|
|
||||||
export function Canvas() {
|
export function Canvas() {
|
||||||
const T = useCanvasTheme();
|
const T = useCanvasTheme();
|
||||||
@@ -15,7 +17,7 @@ export function Canvas() {
|
|||||||
const panX = useViewport((s) => s.panX);
|
const panX = useViewport((s) => s.panX);
|
||||||
const panY = useViewport((s) => s.panY);
|
const panY = useViewport((s) => s.panY);
|
||||||
const zoom = useViewport((s) => s.zoom);
|
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 { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -23,6 +25,84 @@ export function Canvas() {
|
|||||||
const lastPos = useRef({ x: 0, y: 0 });
|
const lastPos = useRef({ x: 0, y: 0 });
|
||||||
const spaceDown = useRef(false);
|
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 ──────────────────────────────
|
// ── Route discovery: auto-create screen grid ──────────────────────────────
|
||||||
// When a live artboard discovers routes we don't have artboards for yet,
|
// 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.
|
// this creates them in a horizontal row to the right of all existing frames.
|
||||||
@@ -41,6 +121,9 @@ export function Canvas() {
|
|||||||
|
|
||||||
pendingRouteCreation.current = true;
|
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
|
// Position new artboards in a row to the right of all existing frames
|
||||||
const GAP = 80;
|
const GAP = 80;
|
||||||
const rightEdge = artboards.reduce(
|
const rightEdge = artboards.reduce(
|
||||||
@@ -73,7 +156,7 @@ export function Canvas() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => { pendingRouteCreation.current = false; });
|
.finally(() => { pendingRouteCreation.current = false; });
|
||||||
},
|
},
|
||||||
[artboards, workspaceId, projectId, queryClient],
|
[artboards, workspaceId, projectId, queryClient, setDiscoveredRoutes],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Zone tool: drag to draw a completion zone
|
// Zone tool: drag to draw a completion zone
|
||||||
@@ -218,6 +301,7 @@ export function Canvas() {
|
|||||||
transition: 'background 0.2s',
|
transition: 'background 0.2s',
|
||||||
cursor,
|
cursor,
|
||||||
}}
|
}}
|
||||||
|
data-canvas-viewport="true"
|
||||||
onMouseDown={onMouseDown}
|
onMouseDown={onMouseDown}
|
||||||
onMouseMove={onMouseMove}
|
onMouseMove={onMouseMove}
|
||||||
onMouseUp={onMouseUp}
|
onMouseUp={onMouseUp}
|
||||||
@@ -256,7 +340,12 @@ export function Canvas() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{artboards.map((ab) => (
|
{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 */}
|
{/* 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';
|
} from '@originmain/renderer';
|
||||||
import type { FiberNode, RendererMessage } from '@originmain/renderer';
|
import type { FiberNode, RendererMessage } from '@originmain/renderer';
|
||||||
import { useCanvas } from '@/store/canvas';
|
import { useCanvas } from '@/store/canvas';
|
||||||
|
import { artboardIframeMap } from '@/lib/artboard-iframe-map';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -35,6 +36,10 @@ export interface LiveArtboardProps {
|
|||||||
/** Called when READY fires but no React commits arrive within 4 s — signals a
|
/** 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. */
|
* static HTML page where the fiber hook can't find a React runtime. */
|
||||||
onStaticPageDetected?: () => void;
|
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;
|
style?: React.CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +58,11 @@ export function LiveArtboard({
|
|||||||
onComponentStylesUpdate,
|
onComponentStylesUpdate,
|
||||||
onRoutesDiscovered,
|
onRoutesDiscovered,
|
||||||
onStaticPageDetected,
|
onStaticPageDetected,
|
||||||
|
onThumbnailReady,
|
||||||
|
onSnapshotReady,
|
||||||
style,
|
style,
|
||||||
}: LiveArtboardProps) {
|
}: LiveArtboardProps) {
|
||||||
|
const { setArtboardRootFontSize } = useCanvas();
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
// Track whether the iframe has sent READY so we don't send messages too early.
|
// Track whether the iframe has sent READY so we don't send messages too early.
|
||||||
const isReadyRef = useRef(false);
|
const isReadyRef = useRef(false);
|
||||||
@@ -102,6 +110,10 @@ export function LiveArtboard({
|
|||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'READY':
|
case 'READY':
|
||||||
isReadyRef.current = true;
|
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.
|
// Push current design tokens into the iframe immediately.
|
||||||
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
||||||
// Restore the highlight ring for any active selection.
|
// Restore the highlight ring for any active selection.
|
||||||
@@ -146,12 +158,20 @@ export function LiveArtboard({
|
|||||||
case 'ROUTES_DISCOVERED':
|
case 'ROUTES_DISCOVERED':
|
||||||
onRoutesDiscovered?.(msg.routes);
|
onRoutesDiscovered?.(msg.routes);
|
||||||
break;
|
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);
|
window.addEventListener('message', handleMessage);
|
||||||
return () => window.removeEventListener('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 ───────────────────────
|
// ── Push updated design tokens whenever they change ───────────────────────
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -209,6 +229,16 @@ export function LiveArtboard({
|
|||||||
}
|
}
|
||||||
}, [selectedComponentId, sendMessage]);
|
}, [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 (
|
return (
|
||||||
<iframe
|
<iframe
|
||||||
ref={iframeRef}
|
ref={iframeRef}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { useTheme } from '@/store/theme';
|
|||||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
import { useWalkthrough } from '@/store/walkthrough';
|
import { useWalkthrough } from '@/store/walkthrough';
|
||||||
import { useIndexer } from '@/hooks/useIndexer';
|
import { useIndexer } from '@/hooks/useIndexer';
|
||||||
|
import { browserClient } from '@/lib/supabase';
|
||||||
|
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
interface AppChromeProps {
|
interface AppChromeProps {
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
@@ -26,6 +28,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
||||||
const setContext = useCanvas((s) => s.setContext);
|
const setContext = useCanvas((s) => s.setContext);
|
||||||
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
||||||
|
const setDesignLanguageTokens = useCanvas((s) => s.setDesignLanguageTokens);
|
||||||
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
||||||
const CT = useCanvasTheme();
|
const CT = useCanvasTheme();
|
||||||
const startTour = useWalkthrough((s) => s.start);
|
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)
|
// Connect to the CLI AST indexer (reads window.__OM_INDEX_URL__, no-op if absent)
|
||||||
useIndexer();
|
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.
|
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (workspaceId && projectId) setContext(workspaceId, projectId);
|
if (workspaceId && projectId) setContext(workspaceId, projectId);
|
||||||
@@ -78,10 +125,90 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo/Redo require a selected artboard
|
// ── Zoom / fit shortcuts (Cmd / Ctrl + …) ──────────────────────────────
|
||||||
if (!(e.metaKey || e.ctrlKey)) return;
|
if (e.metaKey || e.ctrlKey) {
|
||||||
if (!selectedArtboardId) return;
|
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) {
|
if (e.key === 'z' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useHistory.getState().undo(selectedArtboardId);
|
useHistory.getState().undo(selectedArtboardId);
|
||||||
@@ -89,6 +216,8 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
useHistory.getState().redo(selectedArtboardId);
|
useHistory.getState().redo(selectedArtboardId);
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('keydown', onKeyDown);
|
window.addEventListener('keydown', onKeyDown);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
ToolbarDivider,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@fluentui/react-components';
|
} from '@fluentui/react-components';
|
||||||
import {
|
import {
|
||||||
@@ -18,13 +18,50 @@ import { useCanvas, type Tool } from '@/store/canvas';
|
|||||||
import { useViewport } from '@/store/viewport';
|
import { useViewport } from '@/store/viewport';
|
||||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
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() {
|
export function Toolbar() {
|
||||||
const T = useCanvasTheme();
|
const T = useCanvasTheme();
|
||||||
const { activeTool, setActiveTool } = useCanvas();
|
const { activeTool, setActiveTool,
|
||||||
|
selectedArtboardId, selectedArtboardW, selectedArtboardH,
|
||||||
|
dispatchArtboardResize } = useCanvas();
|
||||||
const zoom = useViewport((s) => s.zoom);
|
const zoom = useViewport((s) => s.zoom);
|
||||||
const setZoom = useViewport((s) => s.setZoom);
|
const setZoom = useViewport((s) => s.setZoom);
|
||||||
const reset = useViewport((s) => s.reset);
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -106,9 +143,90 @@ export function Toolbar() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ToolGroup>
|
</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 */}
|
{/* Right side */}
|
||||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
|
<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)}>
|
<TBtn T={T} active={false} onClick={() => setZoom(zoom * 0.8)}>
|
||||||
<ZoomOutRegular />
|
<ZoomOutRegular />
|
||||||
</TBtn>
|
</TBtn>
|
||||||
@@ -142,7 +260,7 @@ export function Toolbar() {
|
|||||||
{Math.round(zoom * 100)}%
|
{Math.round(zoom * 100)}%
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Tooltip content="Zoom in" relationship="label">
|
<Tooltip content="Zoom in Cmd+" relationship="label">
|
||||||
<TBtn T={T} active={false} onClick={() => setZoom(zoom * 1.25)}>
|
<TBtn T={T} active={false} onClick={() => setZoom(zoom * 1.25)}>
|
||||||
<ZoomInRegular />
|
<ZoomInRegular />
|
||||||
</TBtn>
|
</TBtn>
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import { trpc } from '@/lib/trpc';
|
|||||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
import { checkComponentConstraints } from '@originmain/design-language';
|
import { checkComponentConstraints } from '@originmain/design-language';
|
||||||
import type { Violation } 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 { PropChange } from '@originmain/diff-engine';
|
||||||
import type { FiberNode } from '@originmain/renderer';
|
import type { FiberNode } from '@originmain/renderer';
|
||||||
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
|
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 { FrameSection } from './sections/FrameSection';
|
||||||
import { LayoutSection } from './sections/LayoutSection';
|
import { LayoutSection } from './sections/LayoutSection';
|
||||||
import { FillSection } from './sections/FillSection';
|
import { FillSection } from './sections/FillSection';
|
||||||
@@ -30,7 +37,7 @@ const TYPE_COLORS: Record<string, string> = {
|
|||||||
b: '#FFBA7B',
|
b: '#FFBA7B',
|
||||||
};
|
};
|
||||||
|
|
||||||
type TabId = 'design' | 'props' | 'diff' | 'graph';
|
type TabId = 'design' | 'props' | 'code' | 'diff' | 'graph';
|
||||||
|
|
||||||
export function Inspector() {
|
export function Inspector() {
|
||||||
const T = useCanvasTheme();
|
const T = useCanvasTheme();
|
||||||
@@ -57,7 +64,7 @@ export function Inspector() {
|
|||||||
>
|
>
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div style={{ display: 'flex', borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
|
<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
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
@@ -123,6 +130,8 @@ export function Inspector() {
|
|||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
|
) : tab === 'code' ? (
|
||||||
|
<CodeTab componentId={selectedComponentId} componentData={selectedComponentData} artboardId={selectedArtboardId} />
|
||||||
) : tab === 'diff' ? (
|
) : tab === 'diff' ? (
|
||||||
<DiffTab artboardId={selectedArtboardId} />
|
<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 ─────────────────────────────────────────────── */
|
/* ── Diff tab ─────────────────────────────────────────────── */
|
||||||
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||||
const T = useCanvasTheme();
|
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';
|
'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 { useFileTree, FileTree } from '@pierre/trees/react';
|
||||||
import { themeToTreeStyles } from '@pierre/trees';
|
import { themeToTreeStyles } from '@pierre/trees';
|
||||||
import { SquareRegular } from '@fluentui/react-icons';
|
import { SquareRegular } from '@fluentui/react-icons';
|
||||||
@@ -10,13 +10,37 @@ import { useQueryClient } from '@tanstack/react-query';
|
|||||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||||
import { useTheme } from '@/store/theme';
|
import { useTheme } from '@/store/theme';
|
||||||
|
|
||||||
|
type NavTab = 'artboards' | 'routes';
|
||||||
|
|
||||||
export function ArtboardNavigator() {
|
export function ArtboardNavigator() {
|
||||||
const T = useCanvasTheme();
|
const T = useCanvasTheme();
|
||||||
const mode = useTheme((s) => s.mode);
|
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 { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||||
const queryClient = useQueryClient();
|
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
|
// Map the panel theme into Trees' CSS custom properties — recomputed when mode changes
|
||||||
const treeThemeStyles = themeToTreeStyles(mode === 'dark' ? {
|
const treeThemeStyles = themeToTreeStyles(mode === 'dark' ? {
|
||||||
type: 'dark',
|
type: 'dark',
|
||||||
@@ -122,8 +146,11 @@ export function ArtboardNavigator() {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Artboards ── */}
|
{/* ── Tab switcher: Artboards | Routes ── */}
|
||||||
<SectionLabel>Artboards</SectionLabel>
|
<TabSwitcher T={T} active={navTab} onChange={setNavTab} routeCount={allRoutes.length} />
|
||||||
|
|
||||||
|
{/* ── Artboards tab ── */}
|
||||||
|
{navTab === 'artboards' && (
|
||||||
<div style={{ padding: '2px 6px 0' }}>
|
<div style={{ padding: '2px 6px 0' }}>
|
||||||
{artboards.map((ab) => {
|
{artboards.map((ab) => {
|
||||||
const sel = selectedArtboardId === ab.id;
|
const sel = selectedArtboardId === ab.id;
|
||||||
@@ -148,6 +175,21 @@ export function ArtboardNavigator() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Routes tab ── */}
|
||||||
|
{navTab === 'routes' && (
|
||||||
|
<RoutesTab
|
||||||
|
T={T}
|
||||||
|
routes={allRoutes}
|
||||||
|
coveredRoutes={coveredRoutes}
|
||||||
|
artboards={artboards}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
projectId={projectId}
|
||||||
|
queryClient={queryClient}
|
||||||
|
selectArtboard={selectArtboard}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<HSep />
|
<HSep />
|
||||||
|
|
||||||
@@ -183,9 +225,277 @@ export function ArtboardNavigator() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Artboard row ─────────────────────────────────────────── */
|
/* ── Tab switcher ─────────────────────────────────────────── */
|
||||||
import type { CanvasTokens } from '@/store/canvasTheme';
|
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({
|
function NavRow({
|
||||||
T,
|
T,
|
||||||
selected = false,
|
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 { create } from 'zustand';
|
||||||
import type { FiberNode } from '@originmain/renderer';
|
import type { FiberNode } from '@originmain/renderer';
|
||||||
import type { Violation } from '@originmain/design-language';
|
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. */
|
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
|
||||||
export interface ProjectMeta {
|
export interface ProjectMeta {
|
||||||
@@ -92,15 +93,58 @@ interface CanvasStore {
|
|||||||
setActiveViolations: (violations: Violation[]) => void;
|
setActiveViolations: (violations: Violation[]) => void;
|
||||||
|
|
||||||
// ── Active agent session (spec Layer 6 — diff attribution) ──────────────────
|
// ── 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;
|
activeAgentSessionId: string | null;
|
||||||
setActiveAgentSessionId: (id: string | null) => void;
|
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',
|
activeTool: 'select',
|
||||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||||
|
|
||||||
@@ -176,4 +220,46 @@ export const useCanvas = create<CanvasStore>((set) => ({
|
|||||||
|
|
||||||
activeAgentSessionId: null,
|
activeAgentSessionId: null,
|
||||||
setActiveAgentSessionId: (id) => set({ activeAgentSessionId: id }),
|
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:
|
// Usage:
|
||||||
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
|
// npx @originmain/cli dev --target http://localhost:3000 [--port 4170]
|
||||||
// [--index-port 4171] [--no-index]
|
// [--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
|
// Starts a reverse proxy + optional AST indexer for live React component
|
||||||
// inspection in Originmain artboards. See SOURCE-AWARE-CANVAS.md for details.
|
// 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 { Indexer } from './indexer.js';
|
||||||
import { startIndexServer } from './index-server.js';
|
import { startIndexServer } from './index-server.js';
|
||||||
import { detectProjectMeta } from './detect-framework.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_PORT = 4170;
|
||||||
const DEFAULT_INDEX_PORT = 4171;
|
const DEFAULT_INDEX_PORT = 4171;
|
||||||
@@ -22,10 +25,11 @@ function printUsage(): void {
|
|||||||
console.log(`
|
console.log(`
|
||||||
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
|
\x1b[36m\x1b[1mOriginmain CLI\x1b[0m
|
||||||
|
|
||||||
Usage:
|
Commands:
|
||||||
originmain dev --target <url> [options]
|
originmain dev --target <url> [options]
|
||||||
|
originmain login [--app-url <url>]
|
||||||
|
|
||||||
Options:
|
Dev options:
|
||||||
--target, -t Target dev server URL (required)
|
--target, -t Target dev server URL (required)
|
||||||
Example: http://localhost:3000
|
Example: http://localhost:3000
|
||||||
--port, -p Proxy listen port (default: ${DEFAULT_PORT})
|
--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)
|
--no-index Disable the AST indexer (Props/Code tabs degraded)
|
||||||
--help, -h Show this help
|
--help, -h Show this help
|
||||||
|
|
||||||
|
Login options:
|
||||||
|
--app-url Originmain app URL (default: https://app.originmain.io)
|
||||||
|
|
||||||
Environment variables:
|
Environment variables:
|
||||||
ORIGINMAIN_BRIDGE_URL Agent Bridge URL (default: http://localhost:4172)
|
ORIGINMAIN_BRIDGE_URL Agent Bridge URL (default: http://localhost:4172)
|
||||||
|
ORIGINMAIN_APP_URL Originmain app URL (overrides --app-url default)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
npx @originmain/cli login
|
||||||
npx @originmain/cli dev --target http://localhost:3000
|
npx @originmain/cli dev --target http://localhost:3000
|
||||||
npx @originmain/cli dev --target http://localhost:3000 --no-index
|
npx @originmain/cli dev --target http://localhost:3000 --no-index
|
||||||
`);
|
`);
|
||||||
@@ -50,6 +59,7 @@ async function main(): Promise<void> {
|
|||||||
port: { type: 'string', short: 'p' },
|
port: { type: 'string', short: 'p' },
|
||||||
'index-port': { type: 'string' },
|
'index-port': { type: 'string' },
|
||||||
'no-index': { type: 'boolean' },
|
'no-index': { type: 'boolean' },
|
||||||
|
'app-url': { type: 'string' },
|
||||||
help: { type: 'boolean', short: 'h' },
|
help: { type: 'boolean', short: 'h' },
|
||||||
},
|
},
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
@@ -63,6 +73,13 @@ async function main(): Promise<void> {
|
|||||||
process.exit(values.help ? 0 : 1);
|
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') {
|
if (command !== 'dev') {
|
||||||
console.error(` Unknown command: ${command}\n Run "originmain --help" for usage.`);
|
console.error(` Unknown command: ${command}\n Run "originmain --help" for usage.`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -143,9 +160,76 @@ async function main(): Promise<void> {
|
|||||||
const indexUrl = noIndex ? null : `http://localhost:${indexPort}`;
|
const indexUrl = noIndex ? null : `http://localhost:${indexPort}`;
|
||||||
const proxy = startProxy({ target, port, indexUrl });
|
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 ─────────────────────────────────────────────────────
|
// ── Graceful shutdown ─────────────────────────────────────────────────────
|
||||||
function shutdown(): void {
|
function shutdown(): void {
|
||||||
console.log('\n Shutting down...');
|
console.log('\n Shutting down...');
|
||||||
|
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
||||||
proxy.close();
|
proxy.close();
|
||||||
indexServer?.close();
|
indexServer?.close();
|
||||||
process.exit(0);
|
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
|
// 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.
|
// 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 type { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
|
import { detectFramework } from './detect-framework.js';
|
||||||
|
|
||||||
const STUB_BODY = [
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
'<!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');
|
|
||||||
|
|
||||||
/** Handles any request to /__om_isolation__/* — returns a 501 stub page. */
|
const ISOLATION_DIR_NAME = '__om_isolation__';
|
||||||
export function handleIsolationRequest(
|
const NEXT_PAGE_CONTENT = (componentName: string, importPath: string, isDefault: boolean) => `\
|
||||||
_req: IncomingMessage,
|
'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,
|
res: ServerResponse,
|
||||||
|
componentName: string,
|
||||||
|
filePath: string,
|
||||||
|
isDefault: boolean,
|
||||||
): void {
|
): void {
|
||||||
res.writeHead(501, {
|
// 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',
|
'content-type': 'text/html; charset=utf-8',
|
||||||
'cache-control': 'no-store',
|
'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"
|
"test": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"culori": "^3.3.0",
|
||||||
"zod": "^3.0.0"
|
"zod": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/culori": "^4.0.1",
|
||||||
"@vitest/coverage-v8": "^2.1.9",
|
"@vitest/coverage-v8": "^2.1.9",
|
||||||
"typescript": "^5.5.0",
|
"typescript": "^5.5.0",
|
||||||
"vitest": "^2.1.9"
|
"vitest": "^2.1.9"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './schema.js';
|
export * from './schema.js';
|
||||||
export * from './validator.js';
|
export * from './validator.js';
|
||||||
export * from './tokens.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 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({
|
export const ArtboardSchema = z.object({
|
||||||
id: z.string().uuid(),
|
id: z.string().uuid(),
|
||||||
workspace_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) */
|
/** Spec: NOT NULL DEFAULT 900 (migration 008) */
|
||||||
height: z.number().int().default(900),
|
height: z.number().int().default(900),
|
||||||
created_by: z.string().optional(),
|
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(),
|
created_at: z.string().datetime(),
|
||||||
updated_at: z.string().datetime(),
|
updated_at: z.string().datetime(),
|
||||||
});
|
});
|
||||||
@@ -100,6 +122,8 @@ export const IntentDiffSchema = z.object({
|
|||||||
before_screenshot: z.string().nullable().optional(),
|
before_screenshot: z.string().nullable().optional(),
|
||||||
after_screenshot: z.string().nullable().optional(),
|
after_screenshot: z.string().nullable().optional(),
|
||||||
exported_code: 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(),
|
created_at: z.string().datetime(),
|
||||||
updated_at: z.string().datetime(),
|
updated_at: z.string().datetime(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -154,6 +154,10 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
var childrenStyleOverrides = {}; // parentNodeId -> { selector -> { property -> value } }
|
var childrenStyleOverrides = {}; // parentNodeId -> { selector -> { property -> value } }
|
||||||
var overrideStyleEl = null; // the <style id="__om_overrides__"> element
|
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 ────────────────────────────────────────────────────
|
// ── postMessage helper ────────────────────────────────────────────────────
|
||||||
function post(msg) {
|
function post(msg) {
|
||||||
try {
|
try {
|
||||||
@@ -703,6 +707,62 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
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
|
// SPA-safe navigation: push to history and fire popstate so framework
|
||||||
// routers (React Router, Next.js) pick up the route change.
|
// routers (React Router, Next.js) pick up the route change.
|
||||||
function doNavigate(path) {
|
function doNavigate(path) {
|
||||||
@@ -820,8 +898,11 @@ export function buildProxyFiberHookScript(): string {
|
|||||||
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes: routes });
|
if (routes.length > 0) post({ type: 'ROUTES_DISCOVERED', routes: routes });
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Ready signal ──────────────────────────────────────────────────────────
|
// ── Ready signal (includes root font size for rem→px normalisation) ────────
|
||||||
post({ type: 'READY' });
|
var rootFontSizePx = parseFloat(
|
||||||
|
window.getComputedStyle(document.documentElement).getPropertyValue('font-size') || '16'
|
||||||
|
) || 16;
|
||||||
|
post({ type: 'READY', rootFontSizePx: rootFontSizePx });
|
||||||
setTimeout(discoverRoutes, 800);
|
setTimeout(discoverRoutes, 800);
|
||||||
// Re-discover on SPA navigation (Next.js App Router fires popstate on push)
|
// Re-discover on SPA navigation (Next.js App Router fires popstate on push)
|
||||||
window.addEventListener('popstate', function() { setTimeout(discoverRoutes, 100); });
|
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. */
|
* Used for paragraph-spacing: patches margin-bottom on each direct <p> child. */
|
||||||
| { type: 'PATCH_CHILDREN_STYLE'; parentNodeId: string; selector: string; property: string; value: string }
|
| { type: 'PATCH_CHILDREN_STYLE'; parentNodeId: string; selector: string; property: string; value: string }
|
||||||
/** Hide a component's DOM element (sets display:none). Non-destructive. */
|
/** 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 {
|
export interface HostEnvelope {
|
||||||
source: typeof HOST_SOURCE;
|
source: typeof HOST_SOURCE;
|
||||||
@@ -57,7 +68,10 @@ export interface HostEnvelope {
|
|||||||
// ── Renderer → Host messages ──────────────────────────────────────────────────
|
// ── Renderer → Host messages ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export type RendererMessage =
|
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: 'FIBER_TREE_UPDATE'; root: FiberNode }
|
||||||
| { type: 'COMPONENT_SELECTED'; nodeId: string; nodeName?: string; rect: DOMRectLike }
|
| { type: 'COMPONENT_SELECTED'; nodeId: string; nodeName?: string; rect: DOMRectLike }
|
||||||
| { type: 'COMPONENT_DESELECTED' }
|
| { type: 'COMPONENT_DESELECTED' }
|
||||||
@@ -75,7 +89,12 @@ export type RendererMessage =
|
|||||||
}
|
}
|
||||||
/** All discoverable routes found in the running app — sent once after READY
|
/** All discoverable routes found in the running app — sent once after READY
|
||||||
* and again after each SPA navigation. */
|
* 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 {
|
export interface RendererEnvelope {
|
||||||
source: typeof RENDERER_SOURCE;
|
source: typeof RENDERER_SOURCE;
|
||||||
|
|||||||
Generated
+17
@@ -186,10 +186,16 @@ importers:
|
|||||||
|
|
||||||
packages/design-language:
|
packages/design-language:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
culori:
|
||||||
|
specifier: ^3.3.0
|
||||||
|
version: 3.3.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.0.0
|
specifier: ^3.0.0
|
||||||
version: 3.25.76
|
version: 3.25.76
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/culori':
|
||||||
|
specifier: ^4.0.1
|
||||||
|
version: 4.0.1
|
||||||
'@vitest/coverage-v8':
|
'@vitest/coverage-v8':
|
||||||
specifier: ^2.1.9
|
specifier: ^2.1.9
|
||||||
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
|
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
|
||||||
@@ -1808,6 +1814,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
typescript: '>=5.7.2'
|
typescript: '>=5.7.2'
|
||||||
|
|
||||||
|
'@types/culori@4.0.1':
|
||||||
|
resolution: {integrity: sha512-43M51r/22CjhbOXyGT361GZ9vncSVQ39u62x5eJdBQFviI8zWp2X5jzqg7k4M6PVgDQAClpy2bUe2dtwEgEDVQ==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
@@ -2049,6 +2058,10 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
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:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -4705,6 +4718,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
'@types/culori@4.0.1': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/hast@3.0.4':
|
'@types/hast@3.0.4':
|
||||||
@@ -4981,6 +4996,8 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
|
culori@3.3.0: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
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