From f21969f018feb2f27f8987cd350755e27fb42d9b Mon Sep 17 00:00:00 2001
From: SinachPat
Date: Wed, 6 May 2026 18:51:17 +0100
Subject: [PATCH] made tiny updates
---
.../agent-bridge/src/adapters/claude-code.ts | 22 +-
packages/agent-bridge/src/index.ts | 2 +-
packages/agent-bridge/src/tools.ts | 184 ++--
.../app/src/app/api/agent-bridge/route.ts | 82 +-
.../app/src/app/api/design-language/route.ts | 104 ++-
packages/app/src/app/api/intent/route.ts | 55 +-
.../src/app/settings/design-language/page.tsx | 501 ++++++++---
.../src/components/inspector/DesignInputs.tsx | 250 +++++-
.../src/components/inspector/Inspector.tsx | 373 +-------
.../app/src/components/inspector/PropsTab.tsx | 331 +++++++
.../inspector/sections/FillSection.tsx | 823 +++++++++++++++++-
.../inspector/sections/FrameSection.tsx | 6 +-
.../inspector/sections/LayoutSection.tsx | 10 +-
.../inspector/sections/StrokeSection.tsx | 2 +-
.../inspector/sections/TypographySection.tsx | 8 +-
packages/app/src/lib/diff-generator.ts | 127 ++-
packages/app/tsconfig.tsbuildinfo | 2 +-
packages/cli/build.mjs | 22 +
packages/cli/package.json | 1 +
packages/cli/src/html2canvas.min.d.ts | 7 +
packages/cli/src/isolation-server.ts | 30 +-
packages/cli/src/proxy.ts | 19 +
packages/renderer/src/fiber-hook.ts | 9 +-
pnpm-lock.yaml | 39 +
.../migrations/013_design_language_files.sql | 96 ++
25 files changed, 2450 insertions(+), 655 deletions(-)
create mode 100644 packages/app/src/components/inspector/PropsTab.tsx
create mode 100644 packages/cli/src/html2canvas.min.d.ts
create mode 100644 supabase/migrations/013_design_language_files.sql
diff --git a/packages/agent-bridge/src/adapters/claude-code.ts b/packages/agent-bridge/src/adapters/claude-code.ts
index c0d05e2..8505567 100644
--- a/packages/agent-bridge/src/adapters/claude-code.ts
+++ b/packages/agent-bridge/src/adapters/claude-code.ts
@@ -42,18 +42,30 @@ The server is pre-configured in \`.claude/settings.json\`.
### Available MCP Tools
${toolLines}
+### How intents arrive
+The Origin canvas pushes design intent diffs in two ways:
+
+1. **SSE push (primary):** The MCP server sends an \`INTENT_RECEIVED\` event over the SSE
+ connection automatically when the designer exports a change. You do not need to poll.
+
+2. **Poll (fallback):** Call \`push_intent\` with \`{ workspace_id }\` to drain any pending
+ intents. Use this at session start or when you suspect a missed push.
+
### Implementation Workflow
-1. Call \`push_intent\` to receive any pending design intent diffs from the Origin canvas.
-2. Locate the component file using \`resolve_component\` (pass the \`nodeId\` from the intent).
-3. Apply the change to the source file — the intent's \`codeDiff\` contains the expected before/after.
+1. Wait for an \`INTENT_RECEIVED\` SSE event, or call \`push_intent\` to fetch pending intents.
+2. Each intent includes a \`component.name\` — call \`resolve_component\` with that name to locate
+ the source file and line number (e.g. \`{ component_name: "DashboardCard" }\`).
+3. Apply the change to the source file. When \`codeDiff\` is present and \`confidence\` is
+ \`"exact"\`, apply it verbatim. When \`"approximate"\`, use it as a guide and refine as needed.
4. After applying, call \`update_diff_status\` with \`status: "IMPLEMENTED"\` and the \`intentId\`.
5. If the diff cannot be applied for any reason, call \`update_diff_status\` with \`status: "BLOCKED"\`
and a \`reason\` string describing why (e.g. "Component not found in file", "File is read-only",
"Diff conflicts with current file state"). The designer will see this reason in the Origin canvas.
### Important: Always close the loop with update_diff_status
-Every intent received via \`push_intent\` MUST be closed with \`update_diff_status\` — either
-IMPLEMENTED or BLOCKED. An intent left in EXPORTED state will be retried on the next session.
+Every intent received — whether via SSE push or \`push_intent\` poll — MUST be closed with
+\`update_diff_status\` using either \`IMPLEMENTED\` or \`BLOCKED\`. An intent left in \`EXPORTED\`
+state will be retried on the next session.
### Design Language
When token keys are present in the intent changes (\`tokenKey\` field), write \`var(--token-name)\`
diff --git a/packages/agent-bridge/src/index.ts b/packages/agent-bridge/src/index.ts
index 49b3324..650c0b5 100644
--- a/packages/agent-bridge/src/index.ts
+++ b/packages/agent-bridge/src/index.ts
@@ -8,7 +8,7 @@ export type { RateLimitResult } from './rate-limiter.js';
export { checkRateLimit, getRateLimitStatus } from './rate-limiter.js';
export type { McpTool, ToolContext } from './tools.js';
-export { TOOLS, TOOL_MAP, getToolList, dispatchTool, storePendingIntent, drainPendingIntents, registerIndexer, getIndexerUrl, heartbeatIndexer } from './tools.js';
+export { TOOLS, TOOL_MAP, getToolList, dispatchTool, storePendingIntent, drainPendingIntents, registerIndexer, getIndexerUrl, heartbeatIndexer, registerSseClient } from './tools.js';
export type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js';
export { generateCursorConfig } from './adapters/cursor.js';
diff --git a/packages/agent-bridge/src/tools.ts b/packages/agent-bridge/src/tools.ts
index d020d88..9cf7104 100644
--- a/packages/agent-bridge/src/tools.ts
+++ b/packages/agent-bridge/src/tools.ts
@@ -13,7 +13,7 @@
*/
import { textResult, jsonResult } from './protocol.js';
-import type { ToolResult } from './protocol.js';
+import type { ToolResult, IntentMessage, IntentChange, IntentReceivedPush } from './protocol.js';
// ── Tool descriptor shape (MCP tools/list schema) ─────────────────────────────
@@ -66,13 +66,19 @@ const pendingIntents = new Map();
/**
* Store an intent pushed by the canvas (called from the /api/intent route).
- * Returns the generated intentId.
+ *
+ * @param supplyIntentId Pass the Supabase UUID when you've already created the
+ * DB row — the in-memory ID must match so that the agent's
+ * `update_diff_status` tool can call `.eq('id', intentId)`.
+ * If omitted, a local opaque ID is generated (dev/offline mode).
+ * Returns the intentId actually stored.
*/
export function storePendingIntent(
workspaceId: string,
intent: Omit,
+ supplyIntentId?: string,
): string {
- const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ const intentId = supplyIntentId ?? `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const record: IntentRecord = {
intentId,
workspaceId,
@@ -82,9 +88,79 @@ export function storePendingIntent(
const queue = pendingIntents.get(workspaceId) ?? [];
queue.push(record);
pendingIntents.set(workspaceId, queue);
+
+ // Push INTENT_RECEIVED to any SSE-connected agents for this workspace.
+ const push: IntentReceivedPush = {
+ type: 'INTENT_RECEIVED',
+ intent: recordToIntentMessage(record),
+ };
+ pushToSseClients(workspaceId, `data: ${JSON.stringify(push)}\n\n`);
+
return intentId;
}
+// ── SSE client registry ───────────────────────────────────────────────────────
+// Maps workspaceId → set of send callbacks registered by connected SSE clients.
+//
+// Same in-process design as pendingIntents / indexerRegistry — for production
+// multi-instance deployments, replace with Redis pub/sub or Supabase Realtime.
+
+type SseSendFn = (chunk: string) => void;
+
+const sseClients = new Map>();
+
+/**
+ * Register an SSE send callback for a workspace.
+ * Returns an unsubscribe function — call it when the SSE connection closes.
+ */
+export function registerSseClient(workspaceId: string, send: SseSendFn): () => void {
+ let set = sseClients.get(workspaceId);
+ if (!set) { set = new Set(); sseClients.set(workspaceId, set); }
+ set.add(send);
+ return () => {
+ set!.delete(send);
+ if (set!.size === 0) sseClients.delete(workspaceId);
+ };
+}
+
+/** Broadcast a raw SSE chunk to all connected clients for a workspace. */
+function pushToSseClients(workspaceId: string, chunk: string): void {
+ const clients = sseClients.get(workspaceId);
+ if (!clients) return;
+ for (const send of clients) {
+ try { send(chunk); } catch { /* client disconnected — will be deregistered on abort */ }
+ }
+}
+
+/** Convert an IntentRecord to the IntentMessage shape for SSE / push_intent JSON response. */
+function recordToIntentMessage(record: IntentRecord): IntentMessage {
+ let changes: IntentChange[] = [];
+ try {
+ const parsed = JSON.parse(record.patchJson) as unknown;
+ const patches: Array<{ property?: string; value?: string; previousValue?: string }> =
+ Array.isArray(parsed) ? parsed : ((parsed as { patches?: unknown[] }).patches ?? []);
+ changes = patches.map((p) => ({
+ type: 'style' as const,
+ cssProperty: p.property ?? '',
+ from: p.previousValue,
+ to: p.value ?? '',
+ confidence: 'approximate' as const,
+ }));
+ } catch { /* malformed patchJson — send empty changes list */ }
+
+ return {
+ intentId: record.intentId,
+ component: {
+ name: record.componentName,
+ // nodeId not stored in IntentRecord — agent uses component_name with
+ // resolve_component instead of a fiber-ID lookup.
+ nodeId: '',
+ props: {},
+ },
+ changes,
+ };
+}
+
/**
* Drain all pending intents for a workspace (called by push_intent tool handler
* or by the agent when polling).
@@ -184,26 +260,20 @@ const PUSH_INTENT_TOOL: McpTool = {
const RESOLVE_COMPONENT_TOOL: McpTool = {
name: 'resolve_component',
description:
- 'Resolve the source file location for a component identified by its fiber node ID. ' +
- 'Returns the file path and line number where the component is defined in the codebase, ' +
- 'enabling the agent to navigate directly to the component source.',
+ 'Resolve the source file location for a React component by name. ' +
+ 'Proxies to the CLI indexer running in the workspace and returns the file path, ' +
+ 'line number, props schema, and design tokens used by the component.',
inputSchema: {
type: 'object',
properties: {
- artboard_id: {
- type: 'string',
- description: 'The artboard that contains the component.',
- },
- node_id: {
- type: 'string',
- description: 'The fiber node ID of the component (from SelectionOverlay / fiber tree).',
- },
component_name: {
type: 'string',
- description: 'Display name of the component (used as a fallback hint).',
+ description:
+ 'The display name of the component to look up (e.g. "DashboardCard"). ' +
+ 'Case-sensitive; use the name exactly as it appears in the INTENT_RECEIVED message.',
},
},
- required: ['artboard_id', 'node_id'],
+ required: ['component_name'],
},
};
@@ -264,76 +334,72 @@ async function handlePushIntent(ctx: ToolContext): Promise>
return textResult('Error: workspace_id does not match authenticated workspace.');
}
- const intents = drainPendingIntents(wid);
+ const records = drainPendingIntents(wid);
- if (intents.length === 0) {
- return textResult('No pending design intents for this workspace.');
+ if (records.length === 0) {
+ return jsonResult({ intents: [], message: 'No pending design intents for this workspace.' });
}
- const summary = intents.map((intent) => {
- const patches = JSON.parse(intent.patchJson) as Array<{ property: string; value: string; previousValue?: string }>;
- const lines = patches.map(
- (p) => ` • ${p.property}: ${p.previousValue ?? '(unknown)'} → ${p.value}`,
- );
- return [
- `Intent ${intent.intentId} — ${intent.componentName} (${intent.strategy})`,
- intent.summary,
- ...lines,
- ` artboardId: ${intent.artboardId}`,
- ].join('\n');
- });
-
- return textResult(
- `${intents.length} pending design intent${intents.length !== 1 ? 's' : ''}:\n\n${summary.join('\n\n')}`,
- );
+ // Return the full IntentMessage array so the agent can act on each intent
+ // without a separate round-trip. The same data is pushed proactively over
+ // SSE as INTENT_RECEIVED — this poll path exists as a fallback and for
+ // agents that do not maintain a persistent SSE connection.
+ const intents = records.map(recordToIntentMessage);
+ return jsonResult({ intents });
}
async function handleResolveComponent(ctx: ToolContext): Promise> {
const params = ctx.params as Params;
- const nodeId = typeof params['node_id'] === 'string' ? params['node_id'] : null;
- const artboardId = typeof params['artboard_id'] === 'string' ? params['artboard_id'] : null;
- const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : 'Component';
+ const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : null;
- if (!nodeId || !artboardId) {
- return textResult('Error: artboard_id and node_id are required.');
+ if (!componentName) {
+ return textResult('Error: component_name is required.');
}
- // Check if a CLI indexer is registered for this workspace
+ // 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.',
+ 'Run "npx @originmain/cli dev" to enable component resolution.',
);
}
- // Proxy the resolution request to the CLI indexer
+ // Proxy to GET /components?name= on the CLI indexer.
+ // The indexer returns ComponentEntry[] — we return the first exact or best match.
try {
- const url = new URL('/resolve-component', indexerUrl);
- url.searchParams.set('nodeId', nodeId);
- url.searchParams.set('artboardId', artboardId);
- url.searchParams.set('componentName', componentName);
+ const url = new URL('/components', indexerUrl);
+ url.searchParams.set('name', componentName);
- const res = await fetch(url.toString(), {
- signal: AbortSignal.timeout(5000),
- });
+ 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.`);
+ const entries = (await res.json()) as Array<{
+ name: string;
+ definitionFile: string;
+ relativeFile: string;
+ lineNumber: number;
+ props: Array<{ name: string; type: string; optional: boolean }>;
+ tokensUsed: string[];
+ }>;
+
+ if (!entries.length) {
+ return textResult(`Component "${componentName}" not found in index. Make sure the CLI indexer has indexed this file.`);
}
+ // Prefer exact name match; fall back to first fuzzy result.
+ const match = entries.find((e) => e.name === componentName) ?? entries[0]!;
+
return jsonResult({
- filePath: data.filePath,
- lineNumber: data.lineNumber ?? 1,
- column: data.column ?? 1,
- nodeId,
- artboardId,
- componentName,
+ name: match.name,
+ definitionFile: match.definitionFile,
+ relativeFile: match.relativeFile,
+ lineNumber: match.lineNumber,
+ props: match.props,
+ tokensUsed: match.tokensUsed,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
diff --git a/packages/app/src/app/api/agent-bridge/route.ts b/packages/app/src/app/api/agent-bridge/route.ts
index d079493..a34f146 100644
--- a/packages/app/src/app/api/agent-bridge/route.ts
+++ b/packages/app/src/app/api/agent-bridge/route.ts
@@ -1,12 +1,88 @@
-// POST /api/agent-bridge
-// JSON-RPC 2.0 endpoint consumed by the Cursor / Claude Code MCP adapters.
+// GET /api/agent-bridge — SSE stream; server pushes INTENT_RECEIVED events
+// POST /api/agent-bridge — JSON-RPC 2.0; agent calls tools (push_intent, resolve_component, …)
+//
// Authentication: Bearer (HMAC-SHA256, issued by issueWorkspaceToken).
+// Both methods share the same auth scheme.
import { NextRequest, NextResponse } from 'next/server';
-import { verifyWorkspaceToken, TOOL_MAP, getToolList, dispatchTool } from '@originmain/agent-bridge';
+import { verifyWorkspaceToken, TOOL_MAP, getToolList, dispatchTool, registerSseClient } from '@originmain/agent-bridge';
import type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge';
import { serverClient } from '@/lib/supabase';
+// ── GET — SSE stream for real-time INTENT_RECEIVED push ─────────────────────
+// The agent connects once and holds this connection open. When the canvas
+// exports a style intent (via POST /api/intent), storePendingIntent() fires
+// pushToSseClients() which delivers the event immediately over this stream.
+//
+// Deployment note: this is a long-lived streaming response. In serverless
+// environments (Vercel Hobby / Functions) connections are cut at the platform
+// timeout (~30 s). For persistent SSE, deploy with Vercel Fluid Compute,
+// a Node.js server, or replace the in-process client registry with a
+// Supabase Realtime / Redis pub-sub channel.
+
+export async function GET(req: NextRequest) {
+ // ── Auth ──────────────────────────────────────────────────────────────────
+ const authHeader = req.headers.get('authorization') ?? '';
+ const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
+
+ if (!token) {
+ return new NextResponse('Missing Bearer token', { status: 401 });
+ }
+
+ const workspaceToken = verifyWorkspaceToken(token);
+ if (!workspaceToken) {
+ return new NextResponse('Invalid or expired token', { status: 401 });
+ }
+
+ const { workspaceId } = workspaceToken;
+ const enc = new TextEncoder();
+
+ // ── SSE stream ────────────────────────────────────────────────────────────
+ const stream = new ReadableStream({
+ start(controller) {
+ // Send the mandatory SSE preamble so the client knows the connection is live.
+ controller.enqueue(enc.encode(': connected\n\n'));
+
+ // Register callback — fires every time storePendingIntent() is called for
+ // this workspace (i.e., the canvas exported a new style intent).
+ const unsubscribe = registerSseClient(workspaceId, (chunk: string) => {
+ try {
+ controller.enqueue(enc.encode(chunk));
+ } catch {
+ // Controller may be closed if the client disconnected between the
+ // callback being fired and the enqueue call.
+ }
+ });
+
+ // Keep-alive comment every 25 s — prevents proxies from closing the
+ // connection after a 30 s idle timeout while the agent waits for intents.
+ const keepAliveTimer = setInterval(() => {
+ try {
+ controller.enqueue(enc.encode(`: keep-alive\n\n`));
+ } catch {
+ clearInterval(keepAliveTimer);
+ }
+ }, 25_000);
+
+ // Clean up when the client disconnects (connection abort or close).
+ req.signal.addEventListener('abort', () => {
+ clearInterval(keepAliveTimer);
+ unsubscribe();
+ try { controller.close(); } catch { /* already closed */ }
+ });
+ },
+ });
+
+ return new Response(stream, {
+ headers: {
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-cache, no-transform',
+ 'Connection': 'keep-alive',
+ 'X-Accel-Buffering': 'no', // Nginx: disable response buffering
+ },
+ });
+}
+
export async function POST(req: NextRequest) {
// ── Auth ────────────────────────────────────────────────────────────────────
const authHeader = req.headers.get('authorization') ?? '';
diff --git a/packages/app/src/app/api/design-language/route.ts b/packages/app/src/app/api/design-language/route.ts
index 86b07f5..b9b0e10 100644
--- a/packages/app/src/app/api/design-language/route.ts
+++ b/packages/app/src/app/api/design-language/route.ts
@@ -1,11 +1,12 @@
-// GET /api/design-language?workspaceId= → active design language file
-// POST /api/design-language → upsert (new version)
+// GET /api/design-language?workspaceId= → active design language file
+// GET /api/design-language?workspaceId=&all=1 → all versions (history)
+// POST /api/design-language → upload new version (deactivates prior)
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
-import type { InsertDesignLanguageFile } from '@originmain/origin-graph';
+import type { InsertDesignLanguageFile, DesignLanguageFile } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
@@ -14,8 +15,34 @@ export async function GET(req: NextRequest) {
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
+ const db = serverClient();
+
+ // Guard: caller must be a member of the target 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 NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ // ?all=1 returns the full version history, newest first.
+ if (req.nextUrl.searchParams.get('all') === '1') {
+ const { data, error } = await (db
+ .from('design_language_files')
+ .select('*')
+ .eq('workspace_id', workspaceId)
+ .order('version', { ascending: false }) as unknown as Promise<{
+ data: DesignLanguageFile[];
+ error: { message: string } | null;
+ }>);
+ if (error) return NextResponse.json({ error: error.message }, { status: 500 });
+ return NextResponse.json(data ?? []);
+ }
+
+ // Default: return the single active file.
try {
- const db = serverClient();
const file = await getActiveDesignLanguageFile(db, workspaceId);
if (!file) return NextResponse.json(null);
return NextResponse.json(file);
@@ -29,18 +56,69 @@ export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
- const body = (await req.json()) as InsertDesignLanguageFile;
+ let body: InsertDesignLanguageFile & { is_active?: boolean };
+ try {
+ body = (await req.json()) as InsertDesignLanguageFile & { is_active?: boolean };
+ } catch {
+ return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
+ }
+
+ if (!body.workspace_id || !body.name || !body.schema_jsonb) {
+ return NextResponse.json(
+ { error: 'Missing required fields: workspace_id, name, schema_jsonb' },
+ { status: 400 },
+ );
+ }
+
const db = serverClient();
- // Compute the next version: max(existing) + 1.
- const existing = await getActiveDesignLanguageFile(db, body.workspace_id);
- const version = existing ? existing.version + 1 : 1;
-
- const { data, error } = await db
- .from('design_language_files')
- .insert({ ...body, version })
- .select()
+ // Guard: caller must be a member of the target workspace.
+ const { data: postMember } = await db
+ .from('team_members')
+ .select('id')
+ .eq('workspace_id', body.workspace_id)
+ .eq('user_id', userId)
+ .limit(1)
.single();
+ if (!postMember) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+
+ // ── Compute next version number ────────────────────────────────────────────
+ const existing = await getActiveDesignLanguageFile(db, body.workspace_id);
+ const version = existing ? existing.version + 1 : 1;
+
+ // ── Deactivate all prior versions for this workspace ───────────────────────
+ // This must happen before the insert so the partial unique index
+ // (only one is_active = true per workspace) is not violated.
+ if (existing) {
+ const { error: deactivateError } = await (db
+ .from('design_language_files')
+ .update({ is_active: false })
+ .eq('workspace_id', body.workspace_id) as unknown as Promise<{
+ data: unknown;
+ error: { message: string } | null;
+ }>);
+ if (deactivateError) {
+ return NextResponse.json(
+ { error: `Failed to deactivate previous version: ${deactivateError.message}` },
+ { status: 500 },
+ );
+ }
+ }
+
+ // ── Insert new version as the active one ───────────────────────────────────
+ const { data, error } = await (db
+ .from('design_language_files')
+ .insert({
+ ...body,
+ version,
+ is_active: true,
+ created_by: userId,
+ })
+ .select()
+ .single() as unknown as Promise<{
+ data: DesignLanguageFile;
+ error: { message: string } | null;
+ }>);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data, { status: 201 });
diff --git a/packages/app/src/app/api/intent/route.ts b/packages/app/src/app/api/intent/route.ts
index 0e2bdd7..8857471 100644
--- a/packages/app/src/app/api/intent/route.ts
+++ b/packages/app/src/app/api/intent/route.ts
@@ -5,8 +5,11 @@
*
* 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
+ * 2. Persists the intent to the `intent_diffs` Supabase table (durable)
+ * 3. Enqueues the intent in the agent-bridge in-memory pending queue using
+ * the Supabase row UUID so the agent's update_diff_status tool can
+ * reference the same row by id
+ * 4. Returns the intentId (Supabase UUID) 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+).
@@ -15,6 +18,8 @@
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { storePendingIntent } from '@originmain/agent-bridge';
+import { createDiff } from '@originmain/origin-graph';
+import { serverClient } from '@/lib/supabase';
interface IntentPayload {
/** Supabase workspace ID. */
@@ -61,18 +66,48 @@ export async function POST(req: NextRequest) {
);
}
+ // ── 1. Persist to Supabase (durable) ──────────────────────────────────────
+ let intentId: string;
try {
- const intentId = storePendingIntent(workspaceId, {
- artboardId,
- componentName,
- patchJson,
- strategy,
- summary: summary ?? '',
+ const db = serverClient();
+
+ // Parse patchJson safely; fall back to wrapping it verbatim so the row
+ // is always writable even if the client sends a malformed payload.
+ let changesRecord: Record;
+ try {
+ changesRecord = JSON.parse(patchJson) as Record;
+ // Wrap plain arrays (StylePatch[]) under a "patches" key so the column
+ // type (jsonb object) is satisfied.
+ if (Array.isArray(changesRecord)) {
+ changesRecord = { patches: changesRecord, strategy };
+ }
+ } catch {
+ changesRecord = { raw: patchJson, strategy };
+ }
+
+ const row = await createDiff(db, {
+ artboard_id: artboardId,
+ author_id: userId,
+ changes: changesRecord,
+ aggregate_summary: summary ?? `${componentName} style changes`,
+ status: 'EXPORTED',
});
- return NextResponse.json({ intentId, status: 'EXPORTED' }, { status: 201 });
+ intentId = row.id;
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
- return NextResponse.json({ error: message }, { status: 500 });
+ return NextResponse.json({ error: `DB insert failed: ${message}` }, { status: 500 });
}
+
+ // ── 2. Enqueue in agent-bridge (real-time poll / WebSocket path) ───────────
+ // Pass the Supabase UUID so update_diff_status can reference the same row.
+ storePendingIntent(workspaceId, {
+ artboardId,
+ componentName,
+ patchJson,
+ strategy,
+ summary: summary ?? '',
+ }, intentId);
+
+ return NextResponse.json({ intentId, status: 'EXPORTED' }, { status: 201 });
}
diff --git a/packages/app/src/app/settings/design-language/page.tsx b/packages/app/src/app/settings/design-language/page.tsx
index 6ba042e..fa5d928 100644
--- a/packages/app/src/app/settings/design-language/page.tsx
+++ b/packages/app/src/app/settings/design-language/page.tsx
@@ -5,26 +5,29 @@
*
* 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
+ * 2. Fetch a token file from an HTTPS URL (via the SSRF-guarded proxy)
+ * 3. Validate the parsed token set before activating
+ * 4. Activate the tokens workspace-wide (stored in Supabase + canvas store)
+ * 5. View version history of previously uploaded token files + restore any version
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.5
*/
-import { useState, useCallback, useRef } from 'react';
+import { useState, useCallback, useRef, useEffect } from 'react';
import { useCanvas } from '@/store/canvas';
import type { DesignToken } from '@/store/canvas.types';
+import type { DesignLanguageFile } from '@originmain/origin-graph';
// ── Upload & validation states ─────────────────────────────────────────────────
type ParseState =
| { status: 'idle' }
| { status: 'parsing' }
- | { status: 'parsed'; tokens: DesignToken[]; filename: string }
+ | { status: 'parsed'; tokens: DesignToken[]; filename: string; rawJson: string }
| { status: 'error'; message: string };
type ActivateState = 'idle' | 'activating' | 'done' | 'error';
+type FetchUrlState = 'idle' | 'fetching' | 'error';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -42,32 +45,59 @@ function groupByCategory(tokens: DesignToken[]): Record {
return groups;
}
+function relativeTime(iso: string): string {
+ const diff = Date.now() - new Date(iso).getTime();
+ const mins = Math.floor(diff / 60_000);
+ const hours = Math.floor(diff / 3_600_000);
+ const days = Math.floor(diff / 86_400_000);
+ if (mins < 2) return 'just now';
+ if (mins < 60) return `${mins}m ago`;
+ if (hours < 24) return `${hours}h ago`;
+ return `${days}d ago`;
+}
+
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DesignLanguagePage() {
- const { designLanguageTokens, setDesignLanguageTokens } = useCanvas();
- const [parseState, setParseState] = useState({ status: 'idle' });
+ const { designLanguageTokens, setDesignLanguageTokens, workspaceId } = useCanvas();
+ const [parseState, setParseState] = useState({ status: 'idle' });
const [activateState, setActivateState] = useState('idle');
- const [dragOver, setDragOver] = useState(false);
+ const [dragOver, setDragOver] = useState(false);
+ const [fetchUrl, setFetchUrl] = useState('');
+ const [fetchUrlState, setFetchUrlState] = useState('idle');
+ const [fetchUrlError, setFetchUrlError] = useState('');
+ const [history, setHistory] = useState([]);
+ const [historyLoading, setHistoryLoading] = useState(false);
const fileInputRef = useRef(null);
+ // ── Load version history ────────────────────────────────────────────────────
+
+ const loadHistory = useCallback(async () => {
+ if (!workspaceId) return;
+ setHistoryLoading(true);
+ try {
+ const res = await fetch(`/api/design-language?workspaceId=${workspaceId}&all=1`);
+ if (res.ok) {
+ const data = await res.json() as DesignLanguageFile[];
+ setHistory(Array.isArray(data) ? data : []);
+ }
+ } catch { /* non-fatal */ }
+ finally { setHistoryLoading(false); }
+ }, [workspaceId]);
+
+ useEffect(() => { void loadHistory(); }, [loadHistory]);
+
// ── File handling ───────────────────────────────────────────────────────────
- const processFile = useCallback(async (file: File) => {
- if (!file.name.endsWith('.json')) {
- setParseState({ status: 'error', message: 'Only .json token files are supported.' });
- return;
- }
-
+ const processJsonText = useCallback(async (text: string, filename: string) => {
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 });
+ setParseState({ status: 'parsed', tokens, filename, rawJson: text });
setActivateState('idle');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -75,10 +105,18 @@ export default function DesignLanguagePage() {
}
}, []);
+ const processFile = useCallback(async (file: File) => {
+ if (!file.name.endsWith('.json')) {
+ setParseState({ status: 'error', message: 'Only .json token files are supported.' });
+ return;
+ }
+ const text = await file.text();
+ await processJsonText(text, file.name);
+ }, [processJsonText]);
+
const handleFileChange = useCallback((e: React.ChangeEvent) => {
const file = e.target.files?.[0];
if (file) void processFile(file);
- // Reset so the same file can be re-uploaded
e.target.value = '';
}, [processFile]);
@@ -89,18 +127,80 @@ export default function DesignLanguagePage() {
if (file) void processFile(file);
}, [processFile]);
+ // ── Fetch from URL ──────────────────────────────────────────────────────────
+
+ const handleFetchUrl = useCallback(async () => {
+ const url = fetchUrl.trim();
+ if (!url) return;
+ if (!url.startsWith('https://')) {
+ setFetchUrlState('error');
+ setFetchUrlError('Only https:// URLs are supported.');
+ return;
+ }
+ setFetchUrlState('fetching');
+ setFetchUrlError('');
+ try {
+ const res = await fetch('/api/design-language/fetch', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url }),
+ });
+ if (!res.ok) {
+ const body = await res.json() as { error?: string };
+ throw new Error(body.error ?? `HTTP ${res.status}`);
+ }
+ const { json } = await res.json() as { json: string };
+ const filename = url.split('/').pop() ?? 'tokens.json';
+ await processJsonText(json, filename);
+ setFetchUrlState('idle');
+ } catch (err) {
+ setFetchUrlState('error');
+ setFetchUrlError(err instanceof Error ? err.message : String(err));
+ }
+ }, [fetchUrl, processJsonText]);
+
// ── Activation ──────────────────────────────────────────────────────────────
- const activate = useCallback(() => {
+ const activate = useCallback(async () => {
if (parseState.status !== 'parsed') return;
+ if (!workspaceId) {
+ setActivateState('error');
+ return;
+ }
setActivateState('activating');
try {
+ // 1. Parse the raw JSON to a schema_jsonb object for Supabase.
+ const schemaObj = JSON.parse(parseState.rawJson) as Record;
+
+ // 2. Persist to Supabase — this triggers the Realtime subscription in
+ // AppChrome so all other sessions update their token store too.
+ const res = await fetch('/api/design-language', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ workspace_id: workspaceId,
+ name: parseState.filename,
+ schema_jsonb: schemaObj,
+ is_active: true,
+ }),
+ });
+
+ if (!res.ok) {
+ const body = await res.json() as { error?: string };
+ throw new Error(body.error ?? `HTTP ${res.status}`);
+ }
+
+ // 3. Update local canvas store immediately (don't wait for Realtime).
setDesignLanguageTokens(parseState.tokens);
setActivateState('done');
- } catch {
+
+ // 4. Refresh version history.
+ void loadHistory();
+ } catch (err) {
+ console.error('[DLF] Activation failed:', err);
setActivateState('error');
}
- }, [parseState, setDesignLanguageTokens]);
+ }, [parseState, workspaceId, setDesignLanguageTokens, loadHistory]);
const deactivate = useCallback(() => {
setDesignLanguageTokens(null);
@@ -108,6 +208,32 @@ export default function DesignLanguagePage() {
setActivateState('idle');
}, [setDesignLanguageTokens]);
+ // ── Restore a historical version ────────────────────────────────────────────
+
+ const restoreVersion = useCallback(async (row: DesignLanguageFile) => {
+ if (!workspaceId) return;
+ try {
+ const { parseTokenFile } = await import('@originmain/design-language');
+ const tokens = parseTokenFile(row.schema_jsonb as Record) as DesignToken[];
+
+ const res = await fetch('/api/design-language', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ workspace_id: workspaceId,
+ name: row.name,
+ schema_jsonb: row.schema_jsonb,
+ is_active: true,
+ }),
+ });
+ if (!res.ok) throw new Error('Restore failed');
+ setDesignLanguageTokens(tokens);
+ void loadHistory();
+ } catch (err) {
+ console.error('[DLF] Restore failed:', err);
+ }
+ }, [workspaceId, setDesignLanguageTokens, loadHistory]);
+
// ── Render ──────────────────────────────────────────────────────────────────
return (
@@ -137,6 +263,11 @@ export default function DesignLanguagePage() {
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.
+ {!workspaceId && (
+
+ ⚠ Open a workspace in the canvas first to enable saving token files.
+
+ )}
{/* Active token set status */}
@@ -160,7 +291,16 @@ export default function DesignLanguagePage() {
style={{ display: 'none' }}
/>
- {/* Parse state */}
+ {/* Fetch from URL */}
+ void handleFetchUrl()}
+ />
+
+ {/* Parse state feedback */}
{parseState.status === 'parsing' && (
@@ -179,7 +319,17 @@ export default function DesignLanguagePage() {
tokens={parseState.tokens}
filename={parseState.filename}
activateState={activateState}
- onActivate={activate}
+ workspaceId={workspaceId}
+ onActivate={() => void activate()}
+ />
+ )}
+
+ {/* Version history */}
+ {(history.length > 0 || historyLoading) && (
+ void restoreVersion(row)}
/>
)}
@@ -231,11 +381,7 @@ function ActiveTokenBanner({ tokens, onDeactivate }: { tokens: DesignToken[]; on
}
function UploadZone({
- dragOver,
- onDragOver,
- onDragLeave,
- onDrop,
- onClick,
+ dragOver, onDragOver, onDragLeave, onDrop, onClick,
}: {
dragOver: boolean;
onDragOver: () => void;
@@ -257,7 +403,7 @@ function UploadZone({
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,
+ marginBottom: 16,
}}
>
📂
@@ -271,6 +417,74 @@ function UploadZone({
);
}
+function FetchFromUrl({
+ value, onChange, state, error, onFetch,
+}: {
+ value: string;
+ onChange: (v: string) => void;
+ state: FetchUrlState;
+ error: string;
+ onFetch: () => void;
+}) {
+ return (
+
+
+ onChange(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') onFetch(); e.stopPropagation(); }}
+ style={{
+ flex: 1,
+ fontFamily: "'JetBrains Mono', monospace",
+ fontSize: '0.6875rem',
+ background: 'rgba(255,255,255,0.04)',
+ border: `1px solid ${state === 'error' ? 'rgba(255,80,80,0.4)' : 'rgba(255,255,255,0.12)'}`,
+ borderRadius: 8,
+ color: 'rgba(255,255,255,0.75)',
+ padding: '9px 14px',
+ outline: 'none',
+ transition: 'border-color 0.15s',
+ }}
+ onFocus={e => (e.target.style.borderColor = '#3385FF')}
+ onBlur={e => (e.target.style.borderColor = state === 'error' ? 'rgba(255,80,80,0.4)' : 'rgba(255,255,255,0.12)')}
+ />
+
+
+ {state === 'error' && error && (
+
+ ⚠ {error}
+
+ )}
+
+ );
+}
+
function StatusCard({ children, color, border }: { children: React.ReactNode; color?: string; border?: string }) {
return (
void;
}) {
const groups = groupByCategory(tokens);
@@ -330,12 +542,8 @@ function TokenPreview({
alignItems: 'center',
gap: 10,
}}>
-
- ✓ Parsed
-
-
- {filename}
-
+
✓ Parsed
+
{filename}
{tokens.length} tokens · {Object.keys(groups).length} groups
@@ -346,49 +554,26 @@ function TokenPreview({
-
{expanded === group && (
{groupTokens.map(token => (
-
+
{token.type === 'color' && (
)}
-
- {token.key}
-
-
- {token.rawValue}
-
+
{token.key}
+
{token.rawValue}
))}
@@ -400,7 +585,8 @@ function TokenPreview({
-
{activateState === 'error' && (
- Activation failed — try again
+ Save failed — check console for details
)}
@@ -430,6 +615,130 @@ function TokenPreview({
);
}
+function VersionHistory({
+ rows, loading, onRestore,
+}: {
+ rows: DesignLanguageFile[];
+ loading: boolean;
+ onRestore: (row: DesignLanguageFile) => void;
+}) {
+ if (loading && rows.length === 0) {
+ return (
+
+ Version History
+ Loading…
+
+ );
+ }
+ if (rows.length === 0) return null;
+
+ return (
+
+
Version History
+
+ {rows.map((row, i) => (
+
+ {/* Version badge */}
+
+ v{row.version}
+
+
+ {/* File name */}
+
+ {row.name}
+
+
+ {/* Timestamp */}
+
+ {relativeTime(row.created_at)}
+
+
+ {/* Active indicator OR restore button */}
+ {row.is_active ? (
+
ACTIVE
+ ) : (
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
function FormatReference() {
return (
@@ -445,44 +754,18 @@ function FormatReference() {
}}>
Supported formats ↓
-
{[
- {
- label: 'W3C DTCG',
- desc: '$value / $type fields',
- example: `{\n "color": {\n "primary": { "$value": "#0066FF", "$type": "color" }\n }\n}`,
- },
- {
- label: 'Style Dictionary',
- desc: 'Nested with value field',
- example: `{\n "color": {\n "primary": { "value": "#0066FF" }\n }\n}`,
- },
- {
- label: 'Flat CSS Variables',
- desc: 'All keys start with --',
- example: `{\n "--color-primary": "#0066FF",\n "--spacing-md": "16px"\n}`,
- },
+ { label: 'W3C DTCG', desc: '$value / $type fields', example: '{\n "color": {\n "primary": { "$value": "#0066FF", "$type": "color" }\n }\n}' },
+ { label: 'Style Dictionary', desc: 'Nested with value field', example: '{\n "color": {\n "primary": { "value": "#0066FF" }\n }\n}' },
+ { label: 'Flat CSS Variables', desc: 'All keys start with --', example: '{\n "--color-primary": "#0066FF",\n "--spacing-md": "16px"\n}' },
].map(({ label, desc, example }) => (
-
+
{label}
{desc}
-
+
{example}
diff --git a/packages/app/src/components/inspector/DesignInputs.tsx b/packages/app/src/components/inspector/DesignInputs.tsx
index 0952b7c..1441e5e 100644
--- a/packages/app/src/components/inspector/DesignInputs.tsx
+++ b/packages/app/src/components/inspector/DesignInputs.tsx
@@ -4,9 +4,132 @@
// Small, reusable input components for the Design Panel sections.
// All accept an `onPatch(property, value)` callback that flows up to
// useCanvas().patchStyleEdit → PATCH_ELEMENT_STYLE → fiber hook.
+//
+// When the `tokenAware` prop is set to true, NumInput and ColorInput render a
+// token-match badge (Phase 6) alongside the input. Clicking the badge opens a
+// TokenPicker so the designer can swap to a nearby design token value.
-import { useState, useRef } from 'react';
+import { useState, useRef, useEffect, useCallback } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
+import { useCanvas } from '@/store/canvas';
+import type { DesignToken, TokenMatch } from '@/store/canvas.types';
+
+// ── Token badge helper ────────────────────────────────────────────────────────
+// Lazy-resolves the closest design token for a CSS value and renders a small
+// clickable badge. Clicking opens a TokenPicker dropdown for the section input.
+// This is extracted so both NumInput and ColorInput can embed it.
+
+function useTokenMatch(
+ value: string,
+ propKey: string,
+ enabled: boolean,
+): { match: TokenMatch | null; tokens: DesignToken[] | null; rootFontSizePx: number } {
+ const { designLanguageTokens, artboardRootFontSize, selectedArtboardId } = useCanvas();
+ const rootFontSizePx = selectedArtboardId ? (artboardRootFontSize[selectedArtboardId] ?? 16) : 16;
+ const [match, setMatch] = useState
(null);
+
+ useEffect(() => {
+ if (!enabled || !designLanguageTokens || designLanguageTokens.length === 0) {
+ setMatch(null);
+ return;
+ }
+ let cancelled = false;
+ void (async () => {
+ try {
+ const { resolveValueToToken } = await import('@originmain/design-language');
+ const m = resolveValueToToken(value, designLanguageTokens, rootFontSizePx) as TokenMatch | null;
+ if (!cancelled) setMatch(m);
+ } catch { /* design-language package not available */ }
+ })();
+ return () => { cancelled = true; };
+ }, [value, designLanguageTokens, rootFontSizePx, enabled, propKey]);
+
+ return { match, tokens: enabled ? designLanguageTokens : null, rootFontSizePx };
+}
+
+function TokenBadge({
+ match,
+ tokens,
+ cssValue,
+ propKey,
+ rootFontSizePx,
+ onSelect,
+}: {
+ match: TokenMatch;
+ tokens: DesignToken[];
+ cssValue: string;
+ propKey: string;
+ rootFontSizePx: number;
+ onSelect: (token: DesignToken) => void;
+}) {
+ const T = useCanvasTheme();
+ const [pickerOpen, setPickerOpen] = useState(false);
+
+ // Lazy import TokenPicker to avoid a circular dependency at module load time.
+ const [TokenPickerComp, setTokenPickerComp] = useState void;
+ onClose: () => void;
+ }> | null>(null);
+
+ useEffect(() => {
+ if (pickerOpen && !TokenPickerComp) {
+ void import('./TokenPicker').then(m => {
+ setTokenPickerComp(() => m.TokenPicker);
+ });
+ }
+ }, [pickerOpen, TokenPickerComp]);
+
+ return (
+
+
+
+ {pickerOpen && TokenPickerComp && (
+
{ onSelect(t); setPickerOpen(false); }}
+ onClose={() => setPickerOpen(false)}
+ />
+ )}
+
+ );
+}
// ── Number / CSS value helpers ────────────────────────────────────────────────
@@ -126,6 +249,7 @@ export function NumInput({
inputWidth = 60,
readOnly,
title,
+ tokenAware = false,
}: {
value: string;
propKey: string;
@@ -133,6 +257,8 @@ export function NumInput({
inputWidth?: number;
readOnly?: boolean;
title?: string;
+ /** When true, resolves the value against loaded design tokens and shows a badge. */
+ tokenAware?: boolean;
}) {
const T = useCanvasTheme();
const unit = parseCssUnit(value);
@@ -144,12 +270,14 @@ export function NumInput({
setDraft(parseCssNum(value));
}
- const commit = (v: string) => {
+ const commit = useCallback((v: string) => {
const n = parseFloat(v);
if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
- };
+ }, [onPatch, propKey, unit]);
- return (
+ const { match, tokens, rootFontSizePx } = useTokenMatch(value, propKey, tokenAware && !readOnly);
+
+ const input = (
);
+
+ if (!tokenAware || !match || !tokens) return input;
+
+ return (
+
+ {input}
+ onPatch(propKey, t.rawValue)}
+ />
+
+ );
}
// ── Plain text input ──────────────────────────────────────────────────────────
@@ -241,10 +386,13 @@ export function ColorInput({
value,
propKey,
onPatch,
+ tokenAware = false,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
+ /** When true, resolves the color against loaded design tokens and shows a badge. */
+ tokenAware?: boolean;
}) {
const T = useCanvasTheme();
const colorRef = useRef(null);
@@ -256,13 +404,16 @@ export function ColorInput({
setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', ''));
}
- const commitHex = (v: string) => {
+ const commitHex = useCallback((v: string) => {
const cleaned = v.startsWith('#') ? v : `#${v}`;
onPatch(propKey, cleaned);
- };
+ }, [onPatch, propKey]);
+
+ const { match, tokens, rootFontSizePx } = useTokenMatch(value, propKey, tokenAware);
return (
+ {/* Color swatch / native picker */}
+
+ {/* Hex text input */}
+
+ {/* Token badge (only when a close token match exists) */}
+ {tokenAware && match && tokens && (
+ onPatch(propKey, t.rawValue)}
+ />
+ )}
+
+ );
+}
+
+// ── Section header with label + optional children ────────────────────────────
+
+export function Section({ label, children }: { label: string; children: React.ReactNode }) {
+ const T = useCanvasTheme();
+ return (
+
+
+ {label}
+
+ {children}
+
+ );
+}
+
+// ── Key/value prop row ────────────────────────────────────────────────────────
+
+export function PropRow({ label, value, color }: { label: string; value: string; color: string }) {
+ const T = useCanvasTheme();
+ return (
+
+
+ {label}
+
+
+ {value}
+
);
}
diff --git a/packages/app/src/components/inspector/Inspector.tsx b/packages/app/src/components/inspector/Inspector.tsx
index a5b0fc2..0562516 100644
--- a/packages/app/src/components/inspector/Inspector.tsx
+++ b/packages/app/src/components/inspector/Inspector.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
+import { useState, useCallback, useMemo, useEffect } from 'react';
import { Badge } from '@fluentui/react-components';
import { useCanvas } from '@/store/canvas';
import { useHistory } from '@/store/history';
@@ -171,377 +171,6 @@ export function Inspector() {
);
}
-/* ── Design tab ─────────────────────────────────────────────── */
-
-/** Strip the numeric portion from a CSS value: "320px" → "320", "1.5" → "1.5" */
-function parseCssNum(val: string | undefined): string {
- if (!val) return '';
- const m = val.match(/^(-?[\d.]+)/);
- return m?.[1] ?? '';
-}
-
-/** Extract the unit suffix: "320px" → "px", "1.5" → "", "14pt" → "pt" */
-function parseCssUnit(val: string | undefined): string {
- if (!val) return 'px';
- const m = val.match(/^-?[\d.]+(.*)$/);
- return m?.[1]?.trim() ?? '';
-}
-
-/** rgb()/rgba() → #RRGGBB */
-function rgbToHex(rgb: string): string {
- const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
- if (!m) return '#000000';
- return '#' + [m[1], m[2], m[3]]
- .map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
- .join('');
-}
-
-/** rgba(..., 0.8) → "80" (percent string, no %) */
-function rgbaAlpha(val: string): string {
- const m = val.match(/rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*([\d.]+))?\)/);
- if (!m) return '100';
- const a = m[1] !== undefined ? parseFloat(m[1]) : 1;
- return String(Math.round(a * 100));
-}
-
-/** Is the CSS color transparent / none? */
-function isTransparent(val: string | undefined): boolean {
- if (!val) return true;
- return val === 'transparent' || val === 'rgba(0, 0, 0, 0)';
-}
-
-// ── Compact numeric stepper input ────────────────────────────────
-
-function NumInput({
- value,
- propKey,
- onPatch,
- inputWidth = 60,
-}: {
- value: string;
- propKey: string;
- onPatch: (prop: string, val: string) => void;
- inputWidth?: number;
-}) {
- const T = useCanvasTheme();
- const unit = parseCssUnit(value);
- const numStr = parseCssNum(value);
- const [draft, setDraft] = useState(numStr);
- const prevRef = useRef(value);
-
- if (prevRef.current !== value) {
- prevRef.current = value;
- setDraft(parseCssNum(value));
- }
-
- const commit = (v: string) => {
- const n = parseFloat(v);
- if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
- };
-
- return (
-
setDraft(e.target.value)}
- onBlur={() => commit(draft)}
- onKeyDown={e => {
- if (e.key === 'Enter') { commit(draft); e.currentTarget.blur(); }
- if (e.key === 'Escape') setDraft(parseCssNum(value));
- if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
- e.preventDefault();
- const n = parseFloat(draft) || 0;
- const step = e.shiftKey ? 10 : 1;
- const next = e.key === 'ArrowUp' ? n + step : n - step;
- setDraft(String(next));
- onPatch(propKey, `${next}${unit}`);
- }
- 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 6px',
- width: inputWidth,
- outline: 'none',
- textAlign: 'right',
- boxSizing: 'border-box',
- }}
- />
- );
-}
-
-// ── Plain text input (e.g. font-family, box-shadow) ────────────────
-
-function TextInput({
- value,
- propKey,
- onPatch,
- fullWidth,
-}: {
- value: string;
- propKey: string;
- onPatch: (prop: string, val: string) => void;
- fullWidth?: boolean;
-}) {
- const T = useCanvasTheme();
- const [draft, setDraft] = useState(value);
- const prevRef = useRef(value);
- if (prevRef.current !== value) { prevRef.current = value; setDraft(value); }
-
- return (
-
{ setDraft(e.target.value); onPatch(propKey, e.target.value); }}
- onBlur={() => onPatch(propKey, draft)}
- onKeyDown={e => {
- if (e.key === 'Enter') { onPatch(propKey, draft); e.currentTarget.blur(); }
- if (e.key === 'Escape') { setDraft(value); onPatch(propKey, value); }
- 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 6px',
- width: fullWidth ? '100%' : undefined,
- outline: 'none',
- boxSizing: 'border-box',
- }}
- />
- );
-}
-
-// ── Color swatch + hex input ──────────────────────────────────────
-
-function ColorInput({
- value,
- propKey,
- onPatch,
-}: {
- value: string;
- propKey: string;
- onPatch: (prop: string, val: string) => void;
-}) {
- const T = useCanvasTheme();
- const colorRef = useRef
(null);
- const hex = value.startsWith('rgb') ? rgbToHex(value) : (value.startsWith('#') ? value : '#000000');
- const [hexDraft, setHexDraft] = useState(hex.replace('#', ''));
-
- const prevRef = useRef(value);
- if (prevRef.current !== value) {
- prevRef.current = value;
- setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', ''));
- }
-
- const commitHex = (v: string) => {
- const cleaned = v.startsWith('#') ? v : `#${v}`;
- onPatch(propKey, cleaned);
- };
-
- return (
-
- {/* Colour swatch — click to open native color picker */}
-
colorRef.current?.click()}
- >
- onPatch(propKey, e.target.value)}
- style={{
- position: 'absolute', inset: 0, opacity: 0,
- cursor: 'pointer', width: '100%', height: '100%',
- }}
- />
-
- {/* Hex text */}
-
{
- const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
- setHexDraft(v);
- if (v.length === 3 || v.length === 6) commitHex(v);
- }}
- onBlur={() => commitHex(hexDraft)}
- onKeyDown={e => {
- if (e.key === 'Enter') commitHex(hexDraft);
- 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 6px',
- width: 60,
- outline: 'none',
- }}
- />
-
- );
-}
-
-// ── Native select styled with design tokens ───────────────────────
-
-function CssSelect({
- value,
- propKey,
- options,
- onPatch,
-}: {
- value: string;
- propKey: string;
- options: Array<{ val: string; label: string }>;
- onPatch: (prop: string, val: string) => void;
-}) {
- const T = useCanvasTheme();
- return (
-
- );
-}
-
-// ── Toggle group for text-align ───────────────────────────────────
-
-function TextAlignToggle({ value, onPatch }: { value: string; onPatch: (v: string) => void }) {
- const T = useCanvasTheme();
- const opts = [
- { val: 'left', icon: '⬤ ◻ ◻ ◻' },
- { val: 'center', icon: '◻ ⬤ ⬤ ◻' },
- { val: 'right', icon: '◻ ◻ ◻ ⬤' },
- { val: 'justify', icon: '≡' },
- ] as const;
- const labels: Record = { left: 'L', center: 'C', right: 'R', justify: 'J' };
- return (
-
- {opts.map(o => (
-
- ))}
-
- );
-}
-
-// ── Toggle group for flex-direction ──────────────────────────────
-
-function FlexDirToggle({ value, onPatch }: { value: string; onPatch: (v: string) => void }) {
- const T = useCanvasTheme();
- const opts = [
- { val: 'row', icon: '→' },
- { val: 'column', icon: '↓' },
- { val: 'row-reverse', icon: '←' },
- { val: 'column-reverse', icon: '↑' },
- ] as const;
- return (
-
- {opts.map(o => (
-
- ))}
-
- );
-}
-
-// ── Sub-section label ─────────────────────────────────────────────
-
-function DesignSectionLabel({ children }: { children: React.ReactNode }) {
- const T = useCanvasTheme();
- return (
-
- {children}
-
- );
-}
-
-// ── Label above a field ───────────────────────────────────────────
-
-function FieldLabel({ children }: { children: React.ReactNode }) {
- const T = useCanvasTheme();
- return (
-
- {children}
-
- );
-}
-
// ── Main Design Tab ───────────────────────────────────────────────
function DesignTab({
diff --git a/packages/app/src/components/inspector/PropsTab.tsx b/packages/app/src/components/inspector/PropsTab.tsx
new file mode 100644
index 0000000..4f31ca0
--- /dev/null
+++ b/packages/app/src/components/inspector/PropsTab.tsx
@@ -0,0 +1,331 @@
+'use client';
+
+// ── Props Tab (Phase 2) ───────────────────────────────────────────────────────
+// Displays artboard metadata, editable render URL / route, selected component
+// props, and the Drift Report action. Extracted from Inspector.tsx as per
+// spec SOURCE-AWARE-CANVAS.md Phase 2.
+
+import { useState, useCallback } from 'react';
+import { useQueryClient } from '@tanstack/react-query';
+import { useCanvasTheme } from '@/store/canvasTheme';
+import { patchArtboard } from '@/hooks/useArtboards';
+import { HSep, Section, PropRow } from './DesignInputs';
+import type { FiberNode } from '@originmain/renderer';
+import type { Artboard } from '@originmain/origin-graph';
+
+const TYPE_COLORS: Record = {
+ s: '#7DD3A8',
+ n: '#7EB8FF',
+ b: '#FFBA7B',
+};
+
+interface PropsTabProps {
+ artboard: Artboard | null;
+ selectedComponentData: FiberNode | null;
+ workspaceId: string | null | undefined;
+ projectId: string | null | undefined;
+}
+
+export function PropsTab({
+ artboard,
+ selectedComponentData,
+ workspaceId,
+ projectId,
+}: PropsTabProps) {
+ const T = useCanvasTheme();
+ const queryClient = useQueryClient();
+ const [editingUrl, setEditingUrl] = useState(false);
+ const [urlDraft, setUrlDraft] = useState('');
+ const [editingRoute, setEditingRoute] = useState(false);
+ const [routeDraft, setRouteDraft] = useState('');
+
+ // Drift report
+ const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
+ const [driftReport, setDriftReport] = useState('');
+
+ const generateDriftReport = useCallback(async () => {
+ if (!artboard) return;
+ setDriftStatus('loading');
+ setDriftReport('');
+ try {
+ const res = await fetch('/api/ai/drift-report', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ artboard_id: artboard.id }),
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ const data = await res.json() as { report?: string; result?: string };
+ setDriftReport(data.report ?? data.result ?? '— No report returned');
+ setDriftStatus('done');
+ } catch {
+ setDriftStatus('error');
+ }
+ }, [artboard]);
+
+ const saveRenderUrl = useCallback(async () => {
+ if (!artboard) return;
+ const { renderUrl: _r, ...rest } = artboard.metadata_jsonb;
+ const meta: Record = urlDraft.trim()
+ ? { ...rest, renderUrl: urlDraft.trim() }
+ : { ...rest };
+ try {
+ await patchArtboard(artboard.id, { metadata_jsonb: meta });
+ queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
+ } catch (e) {
+ console.error('[PropsTab] patch renderUrl failed', e);
+ }
+ setEditingUrl(false);
+ }, [artboard, urlDraft, workspaceId, projectId, queryClient]);
+
+ const saveRoute = useCallback(async () => {
+ if (!artboard) return;
+ const cleaned = routeDraft.trim() || '/';
+ const { route: _r, ...rest } = artboard.metadata_jsonb;
+ const meta: Record =
+ cleaned === '/' ? { ...rest } : { ...rest, route: cleaned };
+ try {
+ await patchArtboard(artboard.id, { metadata_jsonb: meta });
+ queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
+ } catch (e) {
+ console.error('[PropsTab] patch route failed', e);
+ }
+ setEditingRoute(false);
+ }, [artboard, routeDraft, workspaceId, projectId, queryClient]);
+
+ if (!artboard) return null;
+
+ const meta = artboard.metadata_jsonb;
+ const N = TYPE_COLORS['n']!;
+ const B = TYPE_COLORS['b']!;
+ const S = TYPE_COLORS['s']!;
+
+ const canvasProps: Array<{ key: string; val: string; color: string }> = [
+ { key: 'x', val: String(meta['x'] ?? 0), color: N },
+ { key: 'y', val: String(meta['y'] ?? 0), color: N },
+ { key: 'width', val: String(meta['width'] ?? 0), color: N },
+ { key: 'height', val: String(meta['height'] ?? 0), color: N },
+ ];
+
+ const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl', 'route']);
+ const extraProps = Object.entries(meta)
+ .filter(([k]) => !reservedKeys.has(k))
+ .map(([k, v]) => {
+ const t = typeof v;
+ const color = t === 'number' ? N : t === 'boolean' ? B : S;
+ const val = t === 'string' ? `"${v as string}"` : String(v);
+ return { key: k, val, color };
+ });
+
+ const renderUrl = typeof meta['renderUrl'] === 'string' ? (meta['renderUrl'] as string) : '';
+ const currentRoute = typeof meta['route'] === 'string' ? (meta['route'] as string) : '/';
+
+ return (
+ <>
+ {/* ── Selected fiber component props ─────────────────────────────── */}
+ {selectedComponentData && (
+ <>
+
+ {Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => {
+ const t = typeof v;
+ const color = t === 'number' ? N : t === 'boolean' ? B : S;
+ const disp = t === 'string' ? `"${v as string}"` : String(v);
+ return ;
+ })}
+ {Object.keys(selectedComponentData.props ?? {}).length === 0 && (
+
+ No props
+
+ )}
+
+
+ >
+ )}
+
+ {/* ── Extra artboard metadata props ─────────────────────────────── */}
+ {extraProps.length > 0 && (
+ <>
+
+ {extraProps.map(({ key, val, color }) => (
+
+ ))}
+
+
+ >
+ )}
+
+ {/* ── Canvas position / size ─────────────────────────────────────── */}
+
+ {canvasProps.map(({ key, val, color }) => (
+
+ ))}
+
+
+
+ {/* ── Render target: URL + route ─────────────────────────────────── */}
+
+
+
+
+ {/* renderUrl — inline editable */}
+
+
+
+ url
+
+
+
+
+ {editingUrl ? (
+
+ setUrlDraft(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') void saveRenderUrl();
+ if (e.key === 'Escape') setEditingUrl(false);
+ }}
+ placeholder="http://localhost:3000"
+ style={{
+ flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace",
+ background: T.bgDeep, border: `1px solid ${T.border}`,
+ borderRadius: 5, padding: '4px 8px', color: T.fg, outline: 'none',
+ }}
+ />
+
+
+ ) : renderUrl ? (
+
+ {renderUrl}
+
+ ) : (
+
+ not connected
+
+ )}
+
+
+ {/* route */}
+
+
+
+ route
+
+
+
+
+ {editingRoute ? (
+
+ setRouteDraft(e.target.value)}
+ onKeyDown={e => {
+ if (e.key === 'Enter') void saveRoute();
+ if (e.key === 'Escape') setEditingRoute(false);
+ }}
+ placeholder="/dashboard"
+ style={{
+ flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace",
+ background: T.bgDeep, border: `1px solid ${T.border}`,
+ borderRadius: 5, padding: '4px 8px', color: T.fg, outline: 'none',
+ }}
+ />
+
+
+ ) : (
+
+ {currentRoute}
+
+ )}
+
+
+
+
+
+ {/* ── Drift Report ───────────────────────────────────────────────── */}
+
+
+
+ {driftStatus === 'error' && (
+
+ Report failed — try again
+
+ )}
+
+ {driftStatus === 'done' && driftReport && (
+
+ {driftReport}
+
+ )}
+
+ >
+ );
+}
diff --git a/packages/app/src/components/inspector/sections/FillSection.tsx b/packages/app/src/components/inspector/sections/FillSection.tsx
index 5de8ef6..f3b7a7e 100644
--- a/packages/app/src/components/inspector/sections/FillSection.tsx
+++ b/packages/app/src/components/inspector/sections/FillSection.tsx
@@ -1,64 +1,821 @@
'use client';
-// ── Fill Section — Background Color & Opacity ─────────────────────────────────
-// Shows fill color, opacity, and a "no fill" empty state.
+// ── Fill Section — Full Implementation (spec §5.4) ────────────────────────────
+// Multiple fill layers, per-layer type selector (None / Solid / Gradient / Image),
+// gradient bar with draggable color stops, linear/radial toggle, angle input,
+// and per-layer opacity.
-import { useState } from 'react';
-import { isTransparent, ColorInput, NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
+import { useState, useRef, useEffect, useCallback } from 'react';
+import { useCanvasTheme } from '@/store/canvasTheme';
+import { isTransparent, HSep } from '../DesignInputs';
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+type FillType = 'none' | 'solid' | 'gradient' | 'image';
+type GradientKind = 'linear' | 'radial';
+
+interface GradientStop {
+ id: string;
+ color: string; // hex e.g. "#3385ff"
+ position: number; // 0–100
+}
+
+interface FillLayer {
+ id: string;
+ type: FillType;
+ color: string; // hex (solid)
+ opacity: number; // 0–100
+ gradKind: GradientKind;
+ angle: number; // degrees (linear gradient)
+ stops: GradientStop[];
+ imageUrl: string; // CSS url(…)
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function uid(): string {
+ return Math.random().toString(36).slice(2, 9);
+}
+
+function hexFromCss(css: string): string {
+ if (css.startsWith('#')) return css.slice(0, 7).padEnd(7, '0');
+ const m = css.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
+ if (!m) return '#000000';
+ return '#' + [m[1], m[2], m[3]]
+ .map(n => parseInt(n ?? '0', 10).toString(16).padStart(2, '0'))
+ .join('');
+}
+
+function alphaFromCss(css: string): number {
+ const m = css.match(/rgba\(\d+,\s*\d+,\s*\d+,\s*([\d.]+)\)/);
+ return m ? Math.round(parseFloat(m[1] ?? '1') * 100) : 100;
+}
+
+function hexToRgb(hex: string): { r: number; g: number; b: number } {
+ const h = hex.replace('#', '');
+ return {
+ r: parseInt(h.slice(0, 2), 16) || 0,
+ g: parseInt(h.slice(2, 4), 16) || 0,
+ b: parseInt(h.slice(4, 6), 16) || 0,
+ };
+}
+
+function solidCss(layer: FillLayer): string {
+ if (layer.opacity >= 100) return layer.color;
+ const { r, g, b } = hexToRgb(layer.color);
+ return `rgba(${r},${g},${b},${(layer.opacity / 100).toFixed(2)})`;
+}
+
+function gradientCss(layer: FillLayer): string {
+ const sorted = [...layer.stops].sort((a, b) => a.position - b.position);
+ const stops = sorted.map(s => `${s.color} ${s.position.toFixed(0)}%`).join(', ');
+ return layer.gradKind === 'linear'
+ ? `linear-gradient(${layer.angle}deg, ${stops})`
+ : `radial-gradient(circle, ${stops})`;
+}
+
+function layerToCss(layer: FillLayer): string {
+ switch (layer.type) {
+ case 'solid': return solidCss(layer);
+ case 'gradient': return gradientCss(layer);
+ case 'image': return `url(${layer.imageUrl})`;
+ default: return '';
+ }
+}
+
+/** Best-effort parser for linear-gradient() / radial-gradient() CSS values. */
+function parseGradient(css: string): Pick | null {
+ const isLinear = /linear-gradient/i.test(css);
+ const isRadial = /radial-gradient/i.test(css);
+ if (!isLinear && !isRadial) return null;
+
+ // Extract the content inside the outermost parens
+ const m = css.match(/(?:linear|radial)-gradient\((.+)\)/i);
+ if (!m) return null;
+ const parts = m[1]!.split(',').map(s => s.trim());
+
+ let angle = 135;
+ let startIdx = 0;
+ const first = parts[0] ?? '';
+
+ if (/^\d+deg$/.test(first)) {
+ angle = parseInt(first, 10);
+ startIdx = 1;
+ } else if (/^to\s/.test(first)) {
+ const dir = first.slice(3).trim();
+ angle = dir === 'right' ? 90 : dir === 'bottom' ? 180 : dir === 'left' ? 270 : 0;
+ startIdx = 1;
+ } else if (/^circle/.test(first)) {
+ startIdx = 1;
+ }
+
+ const stops: GradientStop[] = [];
+ const total = parts.length - startIdx;
+ for (let i = startIdx; i < parts.length; i++) {
+ const part = parts[i] ?? '';
+ const pm = part.match(/^(.+?)\s+([\d.]+)%\s*$/);
+ const rawColor = (pm ? pm[1]!.trim() : part.trim());
+ const pos = pm ? parseFloat(pm[2]!) : ((i - startIdx) / Math.max(1, total - 1)) * 100;
+ stops.push({ id: uid(), color: hexFromCss(rawColor), position: Math.round(pos) });
+ }
+
+ return { gradKind: isRadial ? 'radial' : 'linear', angle, stops };
+}
+
+function parseFills(styles: Record): FillLayer[] {
+ const bgImg = styles['background-image'] ?? styles['background'] ?? '';
+ const bgColor = styles['background-color'] ?? '';
+
+ // Gradient
+ if (/gradient\(/.test(bgImg)) {
+ const g = parseGradient(bgImg);
+ return [{
+ id: uid(), type: 'gradient', color: '#ffffff', opacity: 100,
+ gradKind: g?.gradKind ?? 'linear',
+ angle: g?.angle ?? 135,
+ stops: g?.stops ?? [
+ { id: uid(), color: '#3385FF', position: 0 },
+ { id: uid(), color: '#7DD3A8', position: 100 },
+ ],
+ imageUrl: '',
+ }];
+ }
+
+ // Image url()
+ if (/url\(/.test(bgImg)) {
+ const urlM = bgImg.match(/url\(([^)]+)\)/);
+ return [{
+ id: uid(), type: 'image', color: '#000000', opacity: 100,
+ gradKind: 'linear', angle: 135,
+ stops: [{ id: uid(), color: '#000000', position: 0 }, { id: uid(), color: '#ffffff', position: 100 }],
+ imageUrl: urlM?.[1] ?? '',
+ }];
+ }
+
+ // Solid color
+ const colorSrc = bgColor || bgImg;
+ if (colorSrc && !isTransparent(colorSrc)) {
+ return [{
+ id: uid(), type: 'solid',
+ color: hexFromCss(colorSrc),
+ opacity: alphaFromCss(colorSrc),
+ gradKind: 'linear', angle: 135,
+ stops: [
+ { id: uid(), color: '#3385FF', position: 0 },
+ { id: uid(), color: '#7DD3A8', position: 100 },
+ ],
+ imageUrl: '',
+ }];
+ }
+
+ return [];
+}
+
+// ── Main component ─────────────────────────────────────────────────────────────
interface FillSectionProps {
- styles: Record;
+ styles: Record;
onPatch: (prop: string, val: string) => void;
}
export function FillSection({ styles, onPatch }: FillSectionProps) {
- const [open, setOpen] = useState(true);
- const bg = styles['background-color'] ?? '';
+ const T = useCanvasTheme();
+ const [open, setOpen] = useState(true);
+ const [layers, setLayers] = useState(() => parseFills(styles));
+
+ // Track a change-key so we re-init when a *different* component is selected.
+ const prevKeyRef = useRef(
+ (styles['background-color'] ?? '') +
+ (styles['background-image'] ?? '') +
+ (styles['background'] ?? ''),
+ );
+
+ useEffect(() => {
+ const key =
+ (styles['background-color'] ?? '') +
+ (styles['background-image'] ?? '') +
+ (styles['background'] ?? '');
+ if (prevKeyRef.current !== key) {
+ prevKeyRef.current = key;
+ setLayers(parseFills(styles));
+ }
+ }, [styles]);
+
+ // Commit layer array → CSS props
+ const commit = useCallback((next: FillLayer[]) => {
+ setLayers(next);
+ const active = next.filter(l => l.type !== 'none');
+ if (active.length === 0) {
+ onPatch('background-color', 'transparent');
+ onPatch('background-image', 'none');
+ return;
+ }
+ const hasDynamic = active.some(l => l.type === 'gradient' || l.type === 'image');
+ if (!hasDynamic && active.length === 1) {
+ onPatch('background-color', solidCss(active[0]!));
+ onPatch('background-image', 'none');
+ } else {
+ const bgImgLayers = active.filter(l => l.type !== 'solid').map(layerToCss).filter(Boolean);
+ const lastSolid = active.filter(l => l.type === 'solid').slice(-1)[0];
+ onPatch('background-image', bgImgLayers.length ? bgImgLayers.join(', ') : 'none');
+ onPatch('background-color', lastSolid ? solidCss(lastSolid) : 'transparent');
+ }
+ }, [onPatch]);
+
+ const addLayer = () => {
+ commit([
+ ...layers,
+ {
+ id: uid(), type: 'solid', color: '#ffffff', opacity: 100,
+ gradKind: 'linear', angle: 135,
+ stops: [
+ { id: uid(), color: '#3385FF', position: 0 },
+ { id: uid(), color: '#7DD3A8', position: 100 },
+ ],
+ imageUrl: '',
+ },
+ ]);
+ };
+
+ const removeLayer = (id: string) => commit(layers.filter(l => l.id !== id));
+
+ const updateLayer = (id: string, patch: Partial) =>
+ commit(layers.map(l => l.id === id ? { ...l, ...patch } : l));
+
+ const addStop = (layerId: string, position: number) => {
+ const layer = layers.find(l => l.id === layerId);
+ if (!layer) return;
+ const sorted = [...layer.stops].sort((a, b) => a.position - b.position);
+ const before = sorted.filter(s => s.position <= position).slice(-1)[0];
+ const after = sorted.filter(s => s.position >= position)[0];
+ let newColor = '#888888';
+ if (before && after && before !== after) {
+ const t = (position - before.position) / (after.position - before.position);
+ const br = hexToRgb(before.color);
+ const ar = hexToRgb(after.color);
+ newColor = '#' + [
+ Math.round(br.r + t * (ar.r - br.r)),
+ Math.round(br.g + t * (ar.g - br.g)),
+ Math.round(br.b + t * (ar.b - br.b)),
+ ].map(n => Math.max(0, Math.min(255, n)).toString(16).padStart(2, '0')).join('');
+ } else if (before) {
+ newColor = before.color;
+ } else if (after) {
+ newColor = after.color;
+ }
+ updateLayer(layerId, { stops: [...layer.stops, { id: uid(), color: newColor, position }] });
+ };
return (
<>
- setOpen(!open)} />
+ {/* Custom header row — uses a div wrapper so the + button can be a real