made tiny updates

This commit is contained in:
SinachPat
2026-05-06 18:51:17 +01:00
parent fdf64ee72c
commit f21969f018
25 changed files with 2450 additions and 655 deletions
@@ -42,18 +42,30 @@ The server is pre-configured in \`.claude/settings.json\`.
### Available MCP Tools ### Available MCP Tools
${toolLines} ${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 ### Implementation Workflow
1. Call \`push_intent\` to receive any pending design intent diffs from the Origin canvas. 1. Wait for an \`INTENT_RECEIVED\` SSE event, or call \`push_intent\` to fetch pending intents.
2. Locate the component file using \`resolve_component\` (pass the \`nodeId\` from the intent). 2. Each intent includes a \`component.name\` — call \`resolve_component\` with that name to locate
3. Apply the change to the source file — the intent's \`codeDiff\` contains the expected before/after. 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\`. 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"\` 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", 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. "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 ### Important: Always close the loop with update_diff_status
Every intent received via \`push_intent\` MUST be closed with \`update_diff_status\` — either Every intent received — whether via SSE push or \`push_intent\` poll — MUST be closed with
IMPLEMENTED or BLOCKED. An intent left in EXPORTED state will be retried on the next session. \`update_diff_status\` using either \`IMPLEMENTED\` or \`BLOCKED\`. An intent left in \`EXPORTED\`
state will be retried on the next session.
### Design Language ### Design Language
When token keys are present in the intent changes (\`tokenKey\` field), write \`var(--token-name)\` When token keys are present in the intent changes (\`tokenKey\` field), write \`var(--token-name)\`
+1 -1
View File
@@ -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, 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 type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js';
export { generateCursorConfig } from './adapters/cursor.js'; export { generateCursorConfig } from './adapters/cursor.js';
+125 -59
View File
@@ -13,7 +13,7 @@
*/ */
import { textResult, jsonResult } from './protocol.js'; 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) ───────────────────────────── // ── Tool descriptor shape (MCP tools/list schema) ─────────────────────────────
@@ -66,13 +66,19 @@ const pendingIntents = new Map<string, IntentRecord[]>();
/** /**
* Store an intent pushed by the canvas (called from the /api/intent route). * 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( export function storePendingIntent(
workspaceId: string, workspaceId: string,
intent: Omit<IntentRecord, 'intentId' | 'workspaceId' | 'createdAt'>, intent: Omit<IntentRecord, 'intentId' | 'workspaceId' | 'createdAt'>,
supplyIntentId?: string,
): 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 = { const record: IntentRecord = {
intentId, intentId,
workspaceId, workspaceId,
@@ -82,9 +88,79 @@ export function storePendingIntent(
const queue = pendingIntents.get(workspaceId) ?? []; const queue = pendingIntents.get(workspaceId) ?? [];
queue.push(record); queue.push(record);
pendingIntents.set(workspaceId, queue); 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; 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<string, Set<SseSendFn>>();
/**
* 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 * Drain all pending intents for a workspace (called by push_intent tool handler
* or by the agent when polling). * or by the agent when polling).
@@ -184,26 +260,20 @@ const PUSH_INTENT_TOOL: McpTool = {
const RESOLVE_COMPONENT_TOOL: McpTool = { const RESOLVE_COMPONENT_TOOL: McpTool = {
name: 'resolve_component', name: 'resolve_component',
description: description:
'Resolve the source file location for a component identified by its fiber node ID. ' + 'Resolve the source file location for a React component by name. ' +
'Returns the file path and line number where the component is defined in the codebase, ' + 'Proxies to the CLI indexer running in the workspace and returns the file path, ' +
'enabling the agent to navigate directly to the component source.', 'line number, props schema, and design tokens used by the component.',
inputSchema: { inputSchema: {
type: 'object', type: 'object',
properties: { 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: { component_name: {
type: 'string', 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<ToolResult<unknown>>
return textResult('Error: workspace_id does not match authenticated workspace.'); return textResult('Error: workspace_id does not match authenticated workspace.');
} }
const intents = drainPendingIntents(wid); const records = drainPendingIntents(wid);
if (intents.length === 0) { if (records.length === 0) {
return textResult('No pending design intents for this workspace.'); return jsonResult({ intents: [], message: 'No pending design intents for this workspace.' });
} }
const summary = intents.map((intent) => { // Return the full IntentMessage array so the agent can act on each intent
const patches = JSON.parse(intent.patchJson) as Array<{ property: string; value: string; previousValue?: string }>; // without a separate round-trip. The same data is pushed proactively over
const lines = patches.map( // SSE as INTENT_RECEIVED — this poll path exists as a fallback and for
(p) => `${p.property}: ${p.previousValue ?? '(unknown)'}${p.value}`, // agents that do not maintain a persistent SSE connection.
); const intents = records.map(recordToIntentMessage);
return [ return jsonResult({ intents });
`Intent ${intent.intentId}${intent.componentName} (${intent.strategy})`,
intent.summary,
...lines,
` artboardId: ${intent.artboardId}`,
].join('\n');
});
return textResult(
`${intents.length} pending design intent${intents.length !== 1 ? 's' : ''}:\n\n${summary.join('\n\n')}`,
);
} }
async function handleResolveComponent(ctx: ToolContext): Promise<ToolResult<unknown>> { async function handleResolveComponent(ctx: ToolContext): Promise<ToolResult<unknown>> {
const params = ctx.params as Params; const params = ctx.params as Params;
const nodeId = typeof params['node_id'] === 'string' ? params['node_id'] : null; const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : null;
const artboardId = typeof params['artboard_id'] === 'string' ? params['artboard_id'] : null;
const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : 'Component';
if (!nodeId || !artboardId) { if (!componentName) {
return textResult('Error: artboard_id and node_id are required.'); 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); const indexerUrl = getIndexerUrl(ctx.workspaceId);
if (!indexerUrl) { if (!indexerUrl) {
return textResult( return textResult(
`No CLI indexer registered for workspace ${ctx.workspaceId}. ` + `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=<componentName> on the CLI indexer.
// The indexer returns ComponentEntry[] — we return the first exact or best match.
try { try {
const url = new URL('/resolve-component', indexerUrl); const url = new URL('/components', indexerUrl);
url.searchParams.set('nodeId', nodeId); url.searchParams.set('name', componentName);
url.searchParams.set('artboardId', artboardId);
url.searchParams.set('componentName', componentName);
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), { signal: AbortSignal.timeout(5000) });
signal: AbortSignal.timeout(5000),
});
if (!res.ok) { if (!res.ok) {
return textResult(`Indexer returned ${res.status}: ${await res.text()}`); return textResult(`Indexer returned ${res.status}: ${await res.text()}`);
} }
const data = (await res.json()) as { filePath?: string; lineNumber?: number; column?: number }; const entries = (await res.json()) as Array<{
if (!data.filePath) { name: string;
return textResult(`Component ${componentName} not found in index.`); 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({ return jsonResult({
filePath: data.filePath, name: match.name,
lineNumber: data.lineNumber ?? 1, definitionFile: match.definitionFile,
column: data.column ?? 1, relativeFile: match.relativeFile,
nodeId, lineNumber: match.lineNumber,
artboardId, props: match.props,
componentName, tokensUsed: match.tokensUsed,
}); });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
+79 -3
View File
@@ -1,12 +1,88 @@
// POST /api/agent-bridge // GET /api/agent-bridge — SSE stream; server pushes INTENT_RECEIVED events
// JSON-RPC 2.0 endpoint consumed by the Cursor / Claude Code MCP adapters. // POST /api/agent-bridge — JSON-RPC 2.0; agent calls tools (push_intent, resolve_component, …)
//
// Authentication: Bearer <workspace_token> (HMAC-SHA256, issued by issueWorkspaceToken). // Authentication: Bearer <workspace_token> (HMAC-SHA256, issued by issueWorkspaceToken).
// Both methods share the same auth scheme.
import { NextRequest, NextResponse } from 'next/server'; 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 type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge';
import { serverClient } from '@/lib/supabase'; 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) { export async function POST(req: NextRequest) {
// ── Auth ──────────────────────────────────────────────────────────────────── // ── Auth ────────────────────────────────────────────────────────────────────
const authHeader = req.headers.get('authorization') ?? ''; const authHeader = req.headers.get('authorization') ?? '';
@@ -1,11 +1,12 @@
// GET /api/design-language?workspaceId=<uuid> → active design language file // GET /api/design-language?workspaceId=<uuid> → active design language file
// POST /api/design-language → upsert (new version) // GET /api/design-language?workspaceId=<uuid>&all=1 → all versions (history)
// POST /api/design-language → upload new version (deactivates prior)
import { auth } from '@clerk/nextjs/server'; import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase'; import { serverClient } from '@/lib/supabase';
import { getActiveDesignLanguageFile } from '@originmain/origin-graph'; 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) { export async function GET(req: NextRequest) {
const { userId } = await auth(); const { userId } = await auth();
@@ -14,8 +15,34 @@ export async function GET(req: NextRequest) {
const workspaceId = req.nextUrl.searchParams.get('workspaceId'); const workspaceId = req.nextUrl.searchParams.get('workspaceId');
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }); 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 { try {
const db = serverClient();
const file = await getActiveDesignLanguageFile(db, workspaceId); const file = await getActiveDesignLanguageFile(db, workspaceId);
if (!file) return NextResponse.json(null); if (!file) return NextResponse.json(null);
return NextResponse.json(file); return NextResponse.json(file);
@@ -29,18 +56,69 @@ export async function POST(req: NextRequest) {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 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(); const db = serverClient();
// Compute the next version: max(existing) + 1. // Guard: caller must be a member of the target workspace.
const existing = await getActiveDesignLanguageFile(db, body.workspace_id); const { data: postMember } = await db
const version = existing ? existing.version + 1 : 1; .from('team_members')
.select('id')
const { data, error } = await db .eq('workspace_id', body.workspace_id)
.from('design_language_files') .eq('user_id', userId)
.insert({ ...body, version }) .limit(1)
.select()
.single(); .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 }); if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data, { status: 201 }); return NextResponse.json(data, { status: 201 });
+45 -10
View File
@@ -5,8 +5,11 @@
* *
* The canvas calls this when the designer exports a style diff. This route: * The canvas calls this when the designer exports a style diff. This route:
* 1. Validates the authenticated user and payload * 1. Validates the authenticated user and payload
* 2. Stores the intent in the agent-bridge pending queue * 2. Persists the intent to the `intent_diffs` Supabase table (durable)
* 3. Returns the generated intentId so the canvas can track status * 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 * The connected IDE agent drains the queue the next time it calls push_intent
* (or receives an INTENT_RECEIVED WebSocket push in Phase 5+). * (or receives an INTENT_RECEIVED WebSocket push in Phase 5+).
@@ -15,6 +18,8 @@
import { auth } from '@clerk/nextjs/server'; import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { storePendingIntent } from '@originmain/agent-bridge'; import { storePendingIntent } from '@originmain/agent-bridge';
import { createDiff } from '@originmain/origin-graph';
import { serverClient } from '@/lib/supabase';
interface IntentPayload { interface IntentPayload {
/** Supabase workspace ID. */ /** Supabase workspace ID. */
@@ -61,18 +66,48 @@ export async function POST(req: NextRequest) {
); );
} }
// ── 1. Persist to Supabase (durable) ──────────────────────────────────────
let intentId: string;
try { try {
const intentId = storePendingIntent(workspaceId, { const db = serverClient();
artboardId,
componentName, // Parse patchJson safely; fall back to wrapping it verbatim so the row
patchJson, // is always writable even if the client sends a malformed payload.
strategy, let changesRecord: Record<string, unknown>;
summary: summary ?? '', try {
changesRecord = JSON.parse(patchJson) as Record<string, unknown>;
// 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) { } catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error'; 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 });
} }
@@ -5,26 +5,29 @@
* *
* Design Language Settings page. Allows workspace admins to: * Design Language Settings page. Allows workspace admins to:
* 1. Upload a token file (Style Dictionary / W3C DTCG / flat CSS vars) * 1. Upload a token file (Style Dictionary / W3C DTCG / flat CSS vars)
* 2. Validate the parsed token set before activating * 2. Fetch a token file from an HTTPS URL (via the SSRF-guarded proxy)
* 3. Activate the tokens workspace-wide (stored in Supabase + canvas store) * 3. Validate the parsed token set before activating
* 4. View version history of previously uploaded token files * 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 * 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 { useCanvas } from '@/store/canvas';
import type { DesignToken } from '@/store/canvas.types'; import type { DesignToken } from '@/store/canvas.types';
import type { DesignLanguageFile } from '@originmain/origin-graph';
// ── Upload & validation states ───────────────────────────────────────────────── // ── Upload & validation states ─────────────────────────────────────────────────
type ParseState = type ParseState =
| { status: 'idle' } | { status: 'idle' }
| { status: 'parsing' } | { status: 'parsing' }
| { status: 'parsed'; tokens: DesignToken[]; filename: string } | { status: 'parsed'; tokens: DesignToken[]; filename: string; rawJson: string }
| { status: 'error'; message: string }; | { status: 'error'; message: string };
type ActivateState = 'idle' | 'activating' | 'done' | 'error'; type ActivateState = 'idle' | 'activating' | 'done' | 'error';
type FetchUrlState = 'idle' | 'fetching' | 'error';
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -42,32 +45,59 @@ function groupByCategory(tokens: DesignToken[]): Record<string, DesignToken[]> {
return groups; 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 ────────────────────────────────────────────────────────────────────── // ── Page ──────────────────────────────────────────────────────────────────────
export default function DesignLanguagePage() { export default function DesignLanguagePage() {
const { designLanguageTokens, setDesignLanguageTokens } = useCanvas(); const { designLanguageTokens, setDesignLanguageTokens, workspaceId } = useCanvas();
const [parseState, setParseState] = useState<ParseState>({ status: 'idle' }); const [parseState, setParseState] = useState<ParseState>({ status: 'idle' });
const [activateState, setActivateState] = useState<ActivateState>('idle'); const [activateState, setActivateState] = useState<ActivateState>('idle');
const [dragOver, setDragOver] = useState(false); const [dragOver, setDragOver] = useState(false);
const [fetchUrl, setFetchUrl] = useState('');
const [fetchUrlState, setFetchUrlState] = useState<FetchUrlState>('idle');
const [fetchUrlError, setFetchUrlError] = useState('');
const [history, setHistory] = useState<DesignLanguageFile[]>([]);
const [historyLoading, setHistoryLoading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(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 ─────────────────────────────────────────────────────────── // ── File handling ───────────────────────────────────────────────────────────
const processFile = useCallback(async (file: File) => { const processJsonText = useCallback(async (text: string, filename: string) => {
if (!file.name.endsWith('.json')) {
setParseState({ status: 'error', message: 'Only .json token files are supported.' });
return;
}
setParseState({ status: 'parsing' }); setParseState({ status: 'parsing' });
try { try {
const text = await file.text();
const tokens = await parseTokenFileClient(text); const tokens = await parseTokenFileClient(text);
if (tokens.length === 0) { if (tokens.length === 0) {
setParseState({ status: 'error', message: 'No tokens found. Check the file format.' }); setParseState({ status: 'error', message: 'No tokens found. Check the file format.' });
return; return;
} }
setParseState({ status: 'parsed', tokens, filename: file.name }); setParseState({ status: 'parsed', tokens, filename, rawJson: text });
setActivateState('idle'); setActivateState('idle');
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(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<HTMLInputElement>) => { const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) void processFile(file); if (file) void processFile(file);
// Reset so the same file can be re-uploaded
e.target.value = ''; e.target.value = '';
}, [processFile]); }, [processFile]);
@@ -89,18 +127,80 @@ export default function DesignLanguagePage() {
if (file) void processFile(file); if (file) void processFile(file);
}, [processFile]); }, [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 ────────────────────────────────────────────────────────────── // ── Activation ──────────────────────────────────────────────────────────────
const activate = useCallback(() => { const activate = useCallback(async () => {
if (parseState.status !== 'parsed') return; if (parseState.status !== 'parsed') return;
if (!workspaceId) {
setActivateState('error');
return;
}
setActivateState('activating'); setActivateState('activating');
try { try {
// 1. Parse the raw JSON to a schema_jsonb object for Supabase.
const schemaObj = JSON.parse(parseState.rawJson) as Record<string, unknown>;
// 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); setDesignLanguageTokens(parseState.tokens);
setActivateState('done'); setActivateState('done');
} catch {
// 4. Refresh version history.
void loadHistory();
} catch (err) {
console.error('[DLF] Activation failed:', err);
setActivateState('error'); setActivateState('error');
} }
}, [parseState, setDesignLanguageTokens]); }, [parseState, workspaceId, setDesignLanguageTokens, loadHistory]);
const deactivate = useCallback(() => { const deactivate = useCallback(() => {
setDesignLanguageTokens(null); setDesignLanguageTokens(null);
@@ -108,6 +208,32 @@ export default function DesignLanguagePage() {
setActivateState('idle'); setActivateState('idle');
}, [setDesignLanguageTokens]); }, [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<string, unknown>) 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 ────────────────────────────────────────────────────────────────── // ── Render ──────────────────────────────────────────────────────────────────
return ( 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. 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. Supports Style Dictionary, W3C DTCG, and flat CSS variable formats.
</p> </p>
{!workspaceId && (
<div style={{ marginTop: 12, padding: '8px 12px', background: 'rgba(255,186,0,0.08)', border: '1px solid rgba(255,186,0,0.25)', borderRadius: 8, fontSize: '0.75rem', color: '#FFBA7B' }}>
Open a workspace in the canvas first to enable saving token files.
</div>
)}
</div> </div>
{/* Active token set status */} {/* Active token set status */}
@@ -160,7 +291,16 @@ export default function DesignLanguagePage() {
style={{ display: 'none' }} style={{ display: 'none' }}
/> />
{/* Parse state */} {/* Fetch from URL */}
<FetchFromUrl
value={fetchUrl}
onChange={setFetchUrl}
state={fetchUrlState}
error={fetchUrlError}
onFetch={() => void handleFetchUrl()}
/>
{/* Parse state feedback */}
{parseState.status === 'parsing' && ( {parseState.status === 'parsing' && (
<StatusCard> <StatusCard>
<Spinner /> <Spinner />
@@ -179,7 +319,17 @@ export default function DesignLanguagePage() {
tokens={parseState.tokens} tokens={parseState.tokens}
filename={parseState.filename} filename={parseState.filename}
activateState={activateState} activateState={activateState}
onActivate={activate} workspaceId={workspaceId}
onActivate={() => void activate()}
/>
)}
{/* Version history */}
{(history.length > 0 || historyLoading) && (
<VersionHistory
rows={history}
loading={historyLoading}
onRestore={(row) => void restoreVersion(row)}
/> />
)} )}
@@ -231,11 +381,7 @@ function ActiveTokenBanner({ tokens, onDeactivate }: { tokens: DesignToken[]; on
} }
function UploadZone({ function UploadZone({
dragOver, dragOver, onDragOver, onDragLeave, onDrop, onClick,
onDragOver,
onDragLeave,
onDrop,
onClick,
}: { }: {
dragOver: boolean; dragOver: boolean;
onDragOver: () => void; onDragOver: () => void;
@@ -257,7 +403,7 @@ function UploadZone({
cursor: 'pointer', cursor: 'pointer',
background: dragOver ? 'rgba(51,133,255,0.06)' : 'rgba(255,255,255,0.02)', background: dragOver ? 'rgba(51,133,255,0.06)' : 'rgba(255,255,255,0.02)',
transition: 'border-color 0.15s, background 0.15s', transition: 'border-color 0.15s, background 0.15s',
marginBottom: 24, marginBottom: 16,
}} }}
> >
<div style={{ fontSize: '1.5rem', marginBottom: 10, opacity: 0.4 }}>📂</div> <div style={{ fontSize: '1.5rem', marginBottom: 10, opacity: 0.4 }}>📂</div>
@@ -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 (
<div style={{ marginBottom: 24 }}>
<div style={{
display: 'flex',
gap: 8,
alignItems: 'center',
}}>
<input
type="url"
placeholder="Import from URL https://cdn.example.com/tokens.json"
value={value}
onChange={e => 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)')}
/>
<button
onClick={onFetch}
disabled={state === 'fetching' || !value.trim()}
style={{
padding: '9px 18px',
background: 'rgba(255,255,255,0.06)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 8,
color: 'rgba(255,255,255,0.7)',
fontFamily: "'Inter', sans-serif",
fontSize: '0.75rem',
cursor: state === 'fetching' || !value.trim() ? 'not-allowed' : 'pointer',
opacity: state === 'fetching' || !value.trim() ? 0.5 : 1,
whiteSpace: 'nowrap',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (state !== 'fetching') e.currentTarget.style.background = 'rgba(255,255,255,0.1)'; }}
onMouseLeave={e => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
>
{state === 'fetching' ? 'Fetching…' : 'Fetch'}
</button>
</div>
{state === 'error' && error && (
<div style={{ marginTop: 6, fontSize: '0.6875rem', color: '#FF8080', fontFamily: "'Inter', sans-serif" }}>
{error}
</div>
)}
</div>
);
}
function StatusCard({ children, color, border }: { children: React.ReactNode; color?: string; border?: string }) { function StatusCard({ children, color, border }: { children: React.ReactNode; color?: string; border?: string }) {
return ( return (
<div style={{ <div style={{
@@ -301,14 +515,12 @@ function Spinner() {
} }
function TokenPreview({ function TokenPreview({
tokens, tokens, filename, activateState, workspaceId, onActivate,
filename,
activateState,
onActivate,
}: { }: {
tokens: DesignToken[]; tokens: DesignToken[];
filename: string; filename: string;
activateState: ActivateState; activateState: ActivateState;
workspaceId: string | null;
onActivate: () => void; onActivate: () => void;
}) { }) {
const groups = groupByCategory(tokens); const groups = groupByCategory(tokens);
@@ -330,12 +542,8 @@ function TokenPreview({
alignItems: 'center', alignItems: 'center',
gap: 10, gap: 10,
}}> }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: '#7DD3A8' }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: '#7DD3A8' }}> Parsed</span>
Parsed <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', flex: 1 }}>{filename}</span>
</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)' }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.35)' }}>
{tokens.length} tokens · {Object.keys(groups).length} groups {tokens.length} tokens · {Object.keys(groups).length} groups
</span> </span>
@@ -346,49 +554,26 @@ function TokenPreview({
<div key={group} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}> <div key={group} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<button <button
onClick={() => setExpanded(expanded === group ? null : group)} onClick={() => setExpanded(expanded === group ? null : group)}
style={{ style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '10px 18px', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left' }}
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)' }}> <span style={{ fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>{expanded === group ? '▼' : '▶'}</span>
{expanded === group ? '▼' : '▶'} <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.75)', flex: 1, textTransform: 'capitalize' }}>{group}</span>
</span> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>{groupTokens.length}</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 }}> <div style={{ display: 'flex', gap: 3 }}>
{groupTokens {groupTokens.filter(t => t.type === 'color').slice(0, 6).map(t => (
.filter(t => t.type === 'color') <div key={t.key} style={{ width: 12, height: 12, borderRadius: 2, background: t.rawValue, border: '1px solid rgba(255,255,255,0.1)' }} />
.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> </div>
</button> </button>
{expanded === group && ( {expanded === group && (
<div style={{ paddingBottom: 8 }}> <div style={{ paddingBottom: 8 }}>
{groupTokens.map(token => ( {groupTokens.map(token => (
<div key={token.key} style={{ <div key={token.key} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 18px 5px 36px' }}>
display: 'flex', alignItems: 'center', gap: 10,
padding: '5px 18px 5px 36px',
}}>
{token.type === 'color' && ( {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)' }} /> <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' }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#3385FF', flex: 1, letterSpacing: '-0.01em' }}>{token.key}</span>
{token.key} <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.4)', letterSpacing: '-0.01em' }}>{token.rawValue}</span>
</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> </div>
@@ -400,7 +585,8 @@ function TokenPreview({
<div style={{ padding: '14px 18px', display: 'flex', gap: 10, alignItems: 'center' }}> <div style={{ padding: '14px 18px', display: 'flex', gap: 10, alignItems: 'center' }}>
<button <button
onClick={onActivate} onClick={onActivate}
disabled={activateState === 'activating' || activateState === 'done'} disabled={activateState === 'activating' || activateState === 'done' || !workspaceId}
title={!workspaceId ? 'Open a workspace in the canvas first' : undefined}
style={{ style={{
padding: '9px 20px', padding: '9px 20px',
background: activateState === 'done' ? 'rgba(125,211,168,0.15)' : '#3385FF', background: activateState === 'done' ? 'rgba(125,211,168,0.15)' : '#3385FF',
@@ -410,19 +596,18 @@ function TokenPreview({
fontFamily: "'Inter', sans-serif", fontFamily: "'Inter', sans-serif",
fontWeight: 600, fontWeight: 600,
fontSize: '0.75rem', fontSize: '0.75rem',
cursor: activateState === 'activating' || activateState === 'done' ? 'not-allowed' : 'pointer', cursor: (activateState === 'activating' || activateState === 'done' || !workspaceId) ? 'not-allowed' : 'pointer',
opacity: activateState === 'activating' ? 0.6 : 1, opacity: (activateState === 'activating' || !workspaceId) ? 0.6 : 1,
transition: 'background 0.15s, opacity 0.15s', transition: 'background 0.15s, opacity 0.15s',
}} }}
> >
{activateState === 'activating' ? 'Activating…' : {activateState === 'activating' ? 'Saving…' :
activateState === 'done' ? '✓ Tokens activated' : activateState === 'done' ? '✓ Tokens saved & activated' :
`Activate ${tokens.length} tokens`} `Activate ${tokens.length} tokens`}
</button> </button>
{activateState === 'error' && ( {activateState === 'error' && (
<span style={{ fontSize: '0.625rem', color: '#FF8080', fontFamily: "'Inter', sans-serif" }}> <span style={{ fontSize: '0.625rem', color: '#FF8080', fontFamily: "'Inter', sans-serif" }}>
Activation failed try again Save failed check console for details
</span> </span>
)} )}
</div> </div>
@@ -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 (
<div style={{ marginBottom: 24 }}>
<SectionTitle>Version History</SectionTitle>
<StatusCard><Spinner /><span style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.4)' }}>Loading</span></StatusCard>
</div>
);
}
if (rows.length === 0) return null;
return (
<div style={{ marginBottom: 32 }}>
<SectionTitle>Version History</SectionTitle>
<div style={{
background: 'rgba(255,255,255,0.02)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 10,
overflow: 'hidden',
}}>
{rows.map((row, i) => (
<div
key={row.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '10px 16px',
borderBottom: i < rows.length - 1 ? '1px solid rgba(255,255,255,0.05)' : 'none',
background: row.is_active ? 'rgba(125,211,168,0.04)' : 'transparent',
}}
>
{/* Version badge */}
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: row.is_active ? '#7DD3A8' : 'rgba(255,255,255,0.3)',
background: row.is_active ? 'rgba(125,211,168,0.12)' : 'rgba(255,255,255,0.06)',
border: `1px solid ${row.is_active ? 'rgba(125,211,168,0.3)' : 'rgba(255,255,255,0.1)'}`,
borderRadius: 4,
padding: '2px 7px',
flexShrink: 0,
minWidth: 36,
textAlign: 'center',
}}>
v{row.version}
</div>
{/* File name */}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.6875rem',
color: row.is_active ? 'rgba(255,255,255,0.85)' : 'rgba(255,255,255,0.5)',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{row.name}
</span>
{/* Timestamp */}
<span style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: 'rgba(255,255,255,0.25)', flexShrink: 0 }}>
{relativeTime(row.created_at)}
</span>
{/* Active indicator OR restore button */}
{row.is_active ? (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: '#7DD3A8', letterSpacing: '0.06em' }}>ACTIVE</span>
) : (
<button
onClick={() => onRestore(row)}
style={{
background: 'none',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: 5,
color: 'rgba(255,255,255,0.45)',
padding: '3px 10px',
cursor: 'pointer',
fontFamily: "'Inter', sans-serif",
fontSize: '0.5875rem',
transition: 'border-color 0.1s, color 0.1s',
flexShrink: 0,
}}
onMouseEnter={e => {
e.currentTarget.style.borderColor = '#3385FF';
e.currentTarget.style.color = '#3385FF';
}}
onMouseLeave={e => {
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.15)';
e.currentTarget.style.color = 'rgba(255,255,255,0.45)';
}}
>
Restore
</button>
)}
</div>
))}
</div>
</div>
);
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
color: 'rgba(255,255,255,0.3)',
letterSpacing: '0.08em',
textTransform: 'uppercase',
marginBottom: 12,
}}>
{children}
</div>
);
}
function FormatReference() { function FormatReference() {
return ( return (
<details style={{ marginTop: 8 }}> <details style={{ marginTop: 8 }}>
@@ -445,44 +754,18 @@ function FormatReference() {
}}> }}>
Supported formats Supported formats
</summary> </summary>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}> <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: 'W3C DTCG', { label: 'Style Dictionary', desc: 'Nested with value field', example: '{\n "color": {\n "primary": { "value": "#0066FF" }\n }\n}' },
desc: '$value / $type fields', { label: 'Flat CSS Variables', desc: 'All keys start with --', example: '{\n "--color-primary": "#0066FF",\n "--spacing-md": "16px"\n}' },
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 }) => ( ].map(({ label, desc, example }) => (
<div key={label} style={{ <div key={label} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, overflow: 'hidden' }}>
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' }}> <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: "'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> <span style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: 'rgba(255,255,255,0.3)' }}>{desc}</span>
</div> </div>
<pre style={{ <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 }}>
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} {example}
</pre> </pre>
</div> </div>
@@ -4,9 +4,132 @@
// Small, reusable input components for the Design Panel sections. // Small, reusable input components for the Design Panel sections.
// All accept an `onPatch(property, value)` callback that flows up to // All accept an `onPatch(property, value)` callback that flows up to
// useCanvas().patchStyleEdit → PATCH_ELEMENT_STYLE → fiber hook. // 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 { 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<TokenMatch | null>(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<React.ComponentType<{
cssValue: string;
propKey: string;
tokens: DesignToken[];
rootFontSizePx: number;
onSelect: (t: DesignToken) => void;
onClose: () => void;
}> | null>(null);
useEffect(() => {
if (pickerOpen && !TokenPickerComp) {
void import('./TokenPicker').then(m => {
setTokenPickerComp(() => m.TokenPicker);
});
}
}, [pickerOpen, TokenPickerComp]);
return (
<div style={{ position: 'relative', display: 'inline-flex', flexShrink: 0 }}>
<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,
}}
>
{match.token.type === 'color' && (
<div style={{ width: 8, height: 8, borderRadius: '50%', background: match.token.rawValue, border: '1px solid rgba(255,255,255,0.2)', flexShrink: 0 }} />
)}
<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>
{pickerOpen && TokenPickerComp && (
<TokenPickerComp
cssValue={cssValue}
propKey={propKey}
tokens={tokens}
rootFontSizePx={rootFontSizePx}
onSelect={t => { onSelect(t); setPickerOpen(false); }}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
);
}
// ── Number / CSS value helpers ──────────────────────────────────────────────── // ── Number / CSS value helpers ────────────────────────────────────────────────
@@ -126,6 +249,7 @@ export function NumInput({
inputWidth = 60, inputWidth = 60,
readOnly, readOnly,
title, title,
tokenAware = false,
}: { }: {
value: string; value: string;
propKey: string; propKey: string;
@@ -133,6 +257,8 @@ export function NumInput({
inputWidth?: number; inputWidth?: number;
readOnly?: boolean; readOnly?: boolean;
title?: string; title?: string;
/** When true, resolves the value against loaded design tokens and shows a badge. */
tokenAware?: boolean;
}) { }) {
const T = useCanvasTheme(); const T = useCanvasTheme();
const unit = parseCssUnit(value); const unit = parseCssUnit(value);
@@ -144,12 +270,14 @@ export function NumInput({
setDraft(parseCssNum(value)); setDraft(parseCssNum(value));
} }
const commit = (v: string) => { const commit = useCallback((v: string) => {
const n = parseFloat(v); const n = parseFloat(v);
if (!isNaN(n)) onPatch(propKey, `${n}${unit}`); if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
}; }, [onPatch, propKey, unit]);
return ( const { match, tokens, rootFontSizePx } = useTokenMatch(value, propKey, tokenAware && !readOnly);
const input = (
<input <input
readOnly={readOnly} readOnly={readOnly}
title={title} title={title}
@@ -173,8 +301,8 @@ export function NumInput({
style={{ style={{
fontFamily: "'JetBrains Mono', monospace", fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem', fontSize: '0.5875rem',
background: readOnly ? T.bgDeep : T.bgDeep, background: T.bgDeep,
border: `1px solid ${T.border}`, border: `1px solid ${match?.exact ? T.accent + '55' : T.border}`,
borderRadius: 4, borderRadius: 4,
color: readOnly ? T.dim : T.fg, color: readOnly ? T.dim : T.fg,
padding: '3px 6px', padding: '3px 6px',
@@ -183,9 +311,26 @@ export function NumInput({
textAlign: 'right', textAlign: 'right',
boxSizing: 'border-box', boxSizing: 'border-box',
cursor: readOnly ? 'default' : 'text', cursor: readOnly ? 'default' : 'text',
transition: 'border-color 0.15s',
}} }}
/> />
); );
if (!tokenAware || !match || !tokens) return input;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3 }}>
{input}
<TokenBadge
match={match}
tokens={tokens}
cssValue={value}
propKey={propKey}
rootFontSizePx={rootFontSizePx}
onSelect={t => onPatch(propKey, t.rawValue)}
/>
</div>
);
} }
// ── Plain text input ────────────────────────────────────────────────────────── // ── Plain text input ──────────────────────────────────────────────────────────
@@ -241,10 +386,13 @@ export function ColorInput({
value, value,
propKey, propKey,
onPatch, onPatch,
tokenAware = false,
}: { }: {
value: string; value: string;
propKey: string; propKey: string;
onPatch: (prop: string, val: string) => void; 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 T = useCanvasTheme();
const colorRef = useRef<HTMLInputElement>(null); const colorRef = useRef<HTMLInputElement>(null);
@@ -256,13 +404,16 @@ export function ColorInput({
setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', '')); setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', ''));
} }
const commitHex = (v: string) => { const commitHex = useCallback((v: string) => {
const cleaned = v.startsWith('#') ? v : `#${v}`; const cleaned = v.startsWith('#') ? v : `#${v}`;
onPatch(propKey, cleaned); onPatch(propKey, cleaned);
}; }, [onPatch, propKey]);
const { match, tokens, rootFontSizePx } = useTokenMatch(value, propKey, tokenAware);
return ( return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1 }}>
{/* Color swatch / native picker */}
<div <div
title="Pick color" title="Pick color"
style={{ style={{
@@ -280,6 +431,8 @@ export function ColorInput({
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }} style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
/> />
</div> </div>
{/* Hex text input */}
<input <input
value={hexDraft.toUpperCase()} value={hexDraft.toUpperCase()}
maxLength={6} maxLength={6}
@@ -294,14 +447,93 @@ export function ColorInput({
fontFamily: "'JetBrains Mono', monospace", fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem', fontSize: '0.5875rem',
background: T.bgDeep, background: T.bgDeep,
border: `1px solid ${T.border}`, border: `1px solid ${match?.exact ? T.accent + '55' : T.border}`,
borderRadius: 4, borderRadius: 4,
color: T.fg, color: T.fg,
padding: '3px 6px', padding: '3px 6px',
width: 60, width: 60,
outline: 'none', outline: 'none',
transition: 'border-color 0.15s',
}} }}
/> />
{/* Token badge (only when a close token match exists) */}
{tokenAware && match && tokens && (
<TokenBadge
match={match}
tokens={tokens}
cssValue={value}
propKey={propKey}
rootFontSizePx={rootFontSizePx}
onSelect={t => onPatch(propKey, t.rawValue)}
/>
)}
</div>
);
}
// ── Section header with label + optional children ────────────────────────────
export function Section({ label, children }: { label: string; children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<div style={{ padding: '12px 14px' }}>
<div
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5rem',
fontWeight: 500,
letterSpacing: '0.12em',
textTransform: 'uppercase',
color: T.label,
marginBottom: 10,
}}
>
{label}
</div>
{children}
</div>
);
}
// ── Key/value prop row ────────────────────────────────────────────────────────
export function PropRow({ label, value, color }: { label: string; value: string; color: string }) {
const T = useCanvasTheme();
return (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'baseline',
marginBottom: 8,
gap: 8,
}}
>
<span
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
color: T.key,
flexShrink: 0,
}}
>
{label}
</span>
<span
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
color,
textAlign: 'right',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '60%',
}}
>
{value}
</span>
</div> </div>
); );
} }
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { useState, useCallback, useMemo, useEffect } from 'react';
import { Badge } from '@fluentui/react-components'; import { Badge } from '@fluentui/react-components';
import { useCanvas } from '@/store/canvas'; import { useCanvas } from '@/store/canvas';
import { useHistory } from '@/store/history'; 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 (
<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(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 (
<input
value={draft}
onChange={e => { 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<HTMLInputElement>(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 (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1 }}>
{/* Colour swatch — click to open native color picker */}
<div
title="Pick colour"
style={{
width: 20, height: 20, borderRadius: 3, flexShrink: 0,
background: hex,
border: '1px solid rgba(255,255,255,0.15)',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
}}
onClick={() => colorRef.current?.click()}
>
<input
ref={colorRef}
type="color"
value={hex}
onChange={e => onPatch(propKey, e.target.value)}
style={{
position: 'absolute', inset: 0, opacity: 0,
cursor: 'pointer', width: '100%', height: '100%',
}}
/>
</div>
{/* Hex text */}
<input
value={hexDraft.toUpperCase()}
maxLength={6}
onChange={e => {
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',
}}
/>
</div>
);
}
// ── 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 (
<select
value={value}
onChange={e => onPatch(propKey, e.target.value)}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 5px',
cursor: 'pointer',
flex: 1,
outline: 'none',
}}
>
{options.map(o => (
<option key={o.val} value={o.val}>{o.label}</option>
))}
</select>
);
}
// ── 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<string, string> = { left: 'L', center: 'C', right: 'R', justify: 'J' };
return (
<div style={{ display: 'flex', gap: 2, marginLeft: 'auto' }}>
{opts.map(o => (
<button
key={o.val}
title={o.val}
onClick={() => onPatch(o.val)}
style={{
width: 22, height: 22,
background: o.val === value ? T.accent : T.bgDeep,
border: `1px solid ${o.val === value ? T.accent : T.border}`,
borderRadius: 3, cursor: 'pointer',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: o.val === value ? '#fff' : T.fgMuted,
}}
>
{labels[o.val]}
</button>
))}
</div>
);
}
// ── 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 (
<div style={{ display: 'flex', gap: 2 }}>
{opts.map(o => (
<button
key={o.val}
title={o.val}
onClick={() => onPatch(o.val)}
style={{
width: 22, height: 22,
background: o.val === value ? T.accent : T.bgDeep,
border: `1px solid ${o.val === value ? T.accent : T.border}`,
borderRadius: 3, cursor: 'pointer', fontSize: '0.625rem',
color: o.val === value ? '#fff' : T.fgMuted,
}}
>
{o.icon}
</button>
))}
</div>
);
}
// ── Sub-section label ─────────────────────────────────────────────
function DesignSectionLabel({ children }: { children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: T.label,
marginBottom: 7,
}}>
{children}
</div>
);
}
// ── Label above a field ───────────────────────────────────────────
function FieldLabel({ children }: { children: React.ReactNode }) {
const T = useCanvasTheme();
return (
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
color: T.dim,
letterSpacing: '0.04em',
userSelect: 'none',
}}>
{children}
</span>
);
}
// ── Main Design Tab ─────────────────────────────────────────────── // ── Main Design Tab ───────────────────────────────────────────────
function DesignTab({ function DesignTab({
@@ -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<string, string> = {
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<string, unknown> = 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<string, unknown> =
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 && (
<>
<Section label={`${selectedComponentData.name}`}>
{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 <PropRow key={k} label={k} value={disp} color={color} />;
})}
{Object.keys(selectedComponentData.props ?? {}).length === 0 && (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
No props
</span>
)}
</Section>
<HSep />
</>
)}
{/* ── Extra artboard metadata props ─────────────────────────────── */}
{extraProps.length > 0 && (
<>
<Section label="Component Props">
{extraProps.map(({ key, val, color }) => (
<PropRow key={key} label={key} value={val} color={color} />
))}
</Section>
<HSep />
</>
)}
{/* ── Canvas position / size ─────────────────────────────────────── */}
<Section label="Canvas">
{canvasProps.map(({ key, val, color }) => (
<PropRow key={key} label={key} value={val} color={color} />
))}
</Section>
<HSep />
{/* ── Render target: URL + route ─────────────────────────────────── */}
<Section label="Render Target">
<PropRow label="name" value={artboard.name} color="#7EB8FF" />
<PropRow label="id" value={artboard.id.slice(0, 8) + '…'} color={T.key} />
{/* renderUrl — inline editable */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: editingUrl ? 6 : 0 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.key }}>
url
</span>
<button
onClick={() => { setUrlDraft(renderUrl); setEditingUrl(true); }}
style={{
fontSize: '0.5rem', fontFamily: "'JetBrains Mono', monospace",
background: 'none', border: 'none', color: T.accent,
cursor: 'pointer', padding: 0, letterSpacing: '0.06em',
display: editingUrl ? 'none' : 'block',
}}
>
{renderUrl ? 'edit' : '+ set'}
</button>
</div>
{editingUrl ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus value={urlDraft}
onChange={e => 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',
}}
/>
<button
onClick={() => void saveRenderUrl()}
style={{
fontSize: '0.5625rem', fontFamily: "'JetBrains Mono', monospace",
background: T.accent, border: 'none', borderRadius: 5,
color: '#fff', padding: '4px 8px', cursor: 'pointer', flexShrink: 0,
}}
>
</button>
</div>
) : renderUrl ? (
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem',
color: '#7DD3A8', overflow: 'hidden', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', display: 'block', maxWidth: '100%',
}}>
{renderUrl}
</span>
) : (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
not connected
</span>
)}
</div>
{/* route */}
<div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: editingRoute ? 6 : 0 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.key }}>
route
</span>
<button
onClick={() => { setRouteDraft(currentRoute); setEditingRoute(true); }}
style={{
fontSize: '0.5rem', fontFamily: "'JetBrains Mono', monospace",
background: 'none', border: 'none', color: T.accent,
cursor: 'pointer', padding: 0, letterSpacing: '0.06em',
display: editingRoute ? 'none' : 'block',
}}
>
edit
</button>
</div>
{editingRoute ? (
<div style={{ display: 'flex', gap: 4 }}>
<input
autoFocus value={routeDraft}
onChange={e => 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',
}}
/>
<button
onClick={() => void saveRoute()}
style={{
fontSize: '0.5625rem', fontFamily: "'JetBrains Mono', monospace",
background: T.accent, border: 'none', borderRadius: 5,
color: '#fff', padding: '4px 8px', cursor: 'pointer', flexShrink: 0,
}}
>
</button>
</div>
) : (
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem',
color: currentRoute === '/' ? T.dim : '#7DD3A8',
}}>
{currentRoute}
</span>
)}
</div>
</Section>
<HSep />
{/* ── Drift Report ───────────────────────────────────────────────── */}
<Section label="Drift Report">
<button
onClick={() => void generateDriftReport()}
disabled={driftStatus === 'loading'}
style={{
width: '100%',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
background: driftStatus === 'loading' ? T.activeBg : T.accentBg,
border: `1px solid ${driftStatus === 'loading' ? T.border : T.accent}`,
borderRadius: 6, padding: '6px 0',
color: driftStatus === 'loading' ? T.fgDim : T.accent,
cursor: driftStatus === 'loading' ? 'wait' : 'pointer',
letterSpacing: '0.04em',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
>
{driftStatus === 'loading' ? 'Analysing…' : '↻ Generate drift report'}
</button>
{driftStatus === 'error' && (
<div style={{ marginTop: 6, fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace' }}>
Report failed try again
</div>
)}
{driftStatus === 'done' && driftReport && (
<div style={{
marginTop: 8, padding: '8px 10px',
background: T.bgDeep, border: `1px solid ${T.border}`,
borderRadius: 6, maxHeight: 220, overflowY: 'auto',
fontSize: '0.5875rem', fontFamily: "'Inter', sans-serif",
color: T.fgMuted, lineHeight: 1.65, whiteSpace: 'pre-wrap',
wordBreak: 'break-word', scrollbarWidth: 'thin',
scrollbarColor: `${T.dim} transparent`,
} as React.CSSProperties}>
{driftReport}
</div>
)}
</Section>
</>
);
}
@@ -1,64 +1,821 @@
'use client'; 'use client';
// ── Fill Section — Background Color & Opacity ───────────────────────────────── // ── Fill Section — Full Implementation (spec §5.4) ────────────────────────────
// Shows fill color, opacity, and a "no fill" empty state. // 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 { useState, useRef, useEffect, useCallback } from 'react';
import { isTransparent, ColorInput, NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs'; 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; // 0100
}
interface FillLayer {
id: string;
type: FillType;
color: string; // hex (solid)
opacity: number; // 0100
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<FillLayer, 'gradKind' | 'angle' | 'stops'> | 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<string, string>): 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 { interface FillSectionProps {
styles: Record<string, string>; styles: Record<string, string>;
onPatch: (prop: string, val: string) => void; onPatch: (prop: string, val: string) => void;
} }
export function FillSection({ styles, onPatch }: FillSectionProps) { export function FillSection({ styles, onPatch }: FillSectionProps) {
const [open, setOpen] = useState(true); const T = useCanvasTheme();
const bg = styles['background-color'] ?? ''; const [open, setOpen] = useState(true);
const [layers, setLayers] = useState<FillLayer[]>(() => 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<FillLayer>) =>
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 ( return (
<> <>
<SectionHeader label="Fill" expanded={open} onToggle={() => setOpen(!open)} /> {/* Custom header row uses a div wrapper so the + button can be a real <button>
(nested <button> inside <button> is invalid HTML). */}
<div style={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<button
onClick={() => setOpen(o => !o)}
style={{
flex: 1, display: 'flex', alignItems: 'center',
padding: '7px 14px', background: 'transparent', border: 'none',
cursor: 'pointer', userSelect: 'none', textAlign: 'left',
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
fontWeight: 500, letterSpacing: '0.1em', textTransform: 'uppercase',
color: T.label, flex: 1,
}}>
Fill
</span>
<span style={{ color: T.dim, fontSize: '0.55rem', marginLeft: 6 }}>
{open ? '▾' : '▸'}
</span>
</button>
{open && (
<button
onClick={addLayer}
title="Add fill layer"
style={{
background: 'none', border: 'none', cursor: 'pointer',
padding: '7px 14px 7px 4px',
color: T.dim, fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.75rem', lineHeight: 1,
}}
>
+
</button>
)}
</div>
{open && ( {open && (
<div style={{ padding: '0 14px 10px' }}> <div style={{ padding: '0 0 6px' }}>
{isTransparent(bg) ? ( {layers.length === 0 ? (
<div style={{ display: 'flex', gap: 8 }}> <div style={{ padding: '0 14px 4px' }}>
<button <button
onClick={() => onPatch('background-color', '#ffffff')} onClick={addLayer}
style={{ style={{
fontFamily: "'JetBrains Mono', monospace", fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
fontSize: '0.5rem',
padding: '4px 8px', padding: '4px 8px',
background: 'rgba(255,255,255,0.05)', background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)', border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4, borderRadius: 4, color: 'rgba(255,255,255,0.3)',
color: 'rgba(255,255,255,0.3)', cursor: 'pointer', letterSpacing: '0.06em',
cursor: 'pointer',
letterSpacing: '0.06em',
}} }}
> >
+ Add fill + Add fill
</button> </button>
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> layers.map(layer => (
<ColorInput value={bg} propKey="background-color" onPatch={onPatch} /> <FillLayerRow
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}> key={layer.id}
<FieldLabel>Opacity</FieldLabel> layer={layer}
<NumInput onAddStop={(pos) => addStop(layer.id, pos)}
value={styles['opacity'] !== undefined ? `${Math.round(parseFloat(styles['opacity'] ?? '1') * 100)}%` : '100%'} onChange={patch => updateLayer(layer.id, patch)}
propKey="_opacity" onRemove={() => removeLayer(layer.id)}
onPatch={(_, v) => { />
const pct = parseFloat(v.replace('%', '')); ))
if (!isNaN(pct)) onPatch('opacity', String(Math.min(1, Math.max(0, pct / 100))));
}}
inputWidth={44}
/>
</div>
</div>
)} )}
</div> </div>
)} )}
<HSep /> <HSep />
</> </>
); );
} }
// ── Per-layer row ──────────────────────────────────────────────────────────────
const FILL_TYPES: FillType[] = ['none', 'solid', 'gradient', 'image'];
const TYPE_LABEL: Record<FillType, string> = {
none: 'None', solid: 'Solid', gradient: 'Grad', image: 'Img',
};
function FillLayerRow({
layer,
onAddStop,
onChange,
onRemove,
}: {
layer: FillLayer;
onAddStop: (position: number) => void;
onChange: (patch: Partial<FillLayer>) => void;
onRemove: () => void;
}) {
const T = useCanvasTheme();
const [selStopId, setSelStopId] = useState<string | null>(layer.stops[0]?.id ?? null);
// Keep selectedStopId valid when stops list changes
const selStop = layer.stops.find(s => s.id === selStopId) ?? layer.stops[0] ?? null;
// Preview swatch CSS
const swatchBg =
layer.type === 'none' ? undefined :
layer.type === 'solid' ? solidCss(layer) :
layer.type === 'gradient' ? gradientCss(layer) :
'#555';
const checkerBg = `repeating-conic-gradient(#666 0% 25%, #444 0% 50%) 0 0 / 6px 6px`;
return (
<div style={{ padding: '4px 14px 6px', borderBottom: `1px solid ${T.sep}22` }}>
{/* Row: swatch · type segmented · opacity · × */}
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 6 }}>
{/* Color / gradient preview swatch */}
<div style={{
width: 18, height: 18, borderRadius: 3, flexShrink: 0,
border: '1px solid rgba(255,255,255,0.15)',
background: layer.type === 'none' ? checkerBg : swatchBg,
}} />
{/* Segmented type selector */}
<div style={{ display: 'flex', flex: 1, gap: 1 }}>
{FILL_TYPES.map(t => (
<button
key={t}
onClick={() => onChange({ type: t })}
style={{
flex: 1,
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.4375rem',
letterSpacing: '0.02em', padding: '3px 0',
background: layer.type === t ? 'rgba(51,133,255,0.18)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${layer.type === t ? 'rgba(51,133,255,0.45)' : 'rgba(255,255,255,0.08)'}`,
borderRadius: 3,
color: layer.type === t ? '#3385FF' : T.fgMuted,
cursor: 'pointer', transition: 'background 0.1s, color 0.1s',
}}
>
{TYPE_LABEL[t]}
</button>
))}
</div>
{/* Opacity (solid / gradient) */}
{(layer.type === 'solid' || layer.type === 'gradient') && (
<OpacityInput value={layer.opacity} onChange={v => onChange({ opacity: v })} />
)}
{/* Remove fill layer */}
<button
onClick={onRemove}
title="Remove fill"
style={{
background: 'none', border: 'none', color: T.dim,
cursor: 'pointer', padding: '0 2px', lineHeight: 1,
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', flexShrink: 0,
}}
>
×
</button>
</div>
{/* Type-specific editor */}
{layer.type === 'solid' && (
<SolidEditor color={layer.color} onChange={c => onChange({ color: c })} />
)}
{layer.type === 'gradient' && (
<GradientEditor
layer={layer}
selectedStopId={selStop?.id ?? null}
onSelectStop={setSelStopId}
onAddStop={onAddStop}
onChange={onChange}
selectedStop={selStop}
/>
)}
{layer.type === 'image' && (
<ImageEditor url={layer.imageUrl} onChange={url => onChange({ imageUrl: url })} />
)}
</div>
);
}
// ── Solid color editor ─────────────────────────────────────────────────────────
function SolidEditor({ color, onChange }: { color: string; onChange: (c: string) => void }) {
const T = useCanvasTheme();
const colorRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState(color.replace('#', ''));
const prevRef = useRef(color);
if (prevRef.current !== color) { prevRef.current = color; setDraft(color.replace('#', '')); }
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<div
style={{
width: 20, height: 20, borderRadius: 3, flexShrink: 0,
background: color, border: '1px solid rgba(255,255,255,0.15)',
cursor: 'pointer', position: 'relative', overflow: 'hidden',
}}
onClick={() => colorRef.current?.click()}
>
<input
ref={colorRef} type="color" value={color}
onChange={e => { onChange(e.target.value); setDraft(e.target.value.replace('#', '')); }}
style={{ position: 'absolute', inset: 0, opacity: 0, width: '100%', height: '100%', cursor: 'pointer' }}
/>
</div>
<input
value={draft.toUpperCase()} maxLength={6}
onChange={e => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
setDraft(v);
if (v.length === 3 || v.length === 6) onChange(`#${v}`);
}}
onBlur={() => { if (draft.length === 6) onChange(`#${draft}`); }}
onKeyDown={e => { if (e.key === 'Enter') onChange(`#${draft}`); 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',
boxSizing: 'border-box',
}}
/>
</div>
);
}
// ── Opacity input ──────────────────────────────────────────────────────────────
function OpacityInput({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const T = useCanvasTheme();
const [draft, setDraft] = useState(String(Math.round(value)));
const prevRef = useRef(value);
if (prevRef.current !== value) { prevRef.current = value; setDraft(String(Math.round(value))); }
const commit = () => {
const n = Math.max(0, Math.min(100, parseFloat(draft) || 0));
onChange(n);
setDraft(String(Math.round(n)));
};
return (
<input
value={`${draft}%`} size={4}
onChange={e => setDraft(e.target.value.replace('%', '').trim())}
onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') commit(); e.stopPropagation(); }}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
background: T.bgDeep, border: `1px solid ${T.border}`,
borderRadius: 3, color: T.fg, padding: '2px 4px',
width: 38, outline: 'none', textAlign: 'right',
boxSizing: 'border-box', flexShrink: 0,
}}
/>
);
}
// ── Gradient editor ────────────────────────────────────────────────────────────
function GradientEditor({
layer,
selectedStopId,
onSelectStop,
onAddStop,
onChange,
selectedStop,
}: {
layer: FillLayer;
selectedStopId: string | null;
onSelectStop: (id: string) => void;
onAddStop: (pos: number) => void;
onChange: (patch: Partial<FillLayer>) => void;
selectedStop: GradientStop | null;
}) {
const T = useCanvasTheme();
const moveStop = useCallback((id: string, position: number) => {
onChange({
stops: layer.stops.map(s =>
s.id === id ? { ...s, position: Math.max(0, Math.min(100, position)) } : s,
),
});
}, [layer.stops, onChange]);
const updateStopColor = (id: string, color: string) =>
onChange({ stops: layer.stops.map(s => s.id === id ? { ...s, color } : s) });
const removeStop = (id: string) => {
if (layer.stops.length <= 2) return;
onChange({ stops: layer.stops.filter(s => s.id !== id) });
};
return (
<div>
{/* Gradient bar */}
<GradientBar
layer={layer}
selectedStopId={selectedStopId}
onSelectStop={onSelectStop}
onMoveStop={moveStop}
onAddStop={onAddStop}
/>
{/* Selected stop controls */}
{selectedStop && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 7 }}>
<StopColorPicker
color={selectedStop.color}
onChange={c => updateStopColor(selectedStop.id, c)}
/>
<input
value={`${Math.round(selectedStop.position)}%`} size={4}
onChange={e => {
const v = parseFloat(e.target.value.replace('%', ''));
if (!isNaN(v)) moveStop(selectedStop.id, v);
}}
onKeyDown={e => e.stopPropagation()}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
background: T.bgDeep, border: `1px solid ${T.border}`,
borderRadius: 3, color: T.fg, padding: '2px 4px',
width: 36, outline: 'none', textAlign: 'right', boxSizing: 'border-box',
}}
/>
{layer.stops.length > 2 && (
<button
onClick={() => removeStop(selectedStop.id)}
style={{
background: 'none', border: 'none', color: T.dim,
cursor: 'pointer', padding: 0, lineHeight: 1, flexShrink: 0,
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem',
}}
>
×
</button>
)}
</div>
)}
{/* Linear/Radial toggle + angle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 2 }}>
{(['linear', 'radial'] as GradientKind[]).map(k => (
<button
key={k}
onClick={() => onChange({ gradKind: k })}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.4375rem',
letterSpacing: '0.04em', padding: '2px 6px',
background: layer.gradKind === k ? 'rgba(51,133,255,0.18)' : 'rgba(255,255,255,0.04)',
border: `1px solid ${layer.gradKind === k ? 'rgba(51,133,255,0.4)' : 'rgba(255,255,255,0.08)'}`,
borderRadius: 3, color: layer.gradKind === k ? '#3385FF' : T.fgMuted,
cursor: 'pointer',
}}
>
{k}
</button>
))}
{layer.gradKind === 'linear' && (
<>
<span style={{ flex: 1 }} />
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.4375rem',
color: T.dim, letterSpacing: '0.04em',
}}>
angle
</span>
<input
value={`${layer.angle}°`} size={4}
onChange={e => {
const v = parseFloat(e.target.value.replace('°', ''));
if (!isNaN(v)) onChange({ angle: ((Math.round(v) % 360) + 360) % 360 });
}}
onKeyDown={e => e.stopPropagation()}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
background: T.bgDeep, border: `1px solid ${T.border}`,
borderRadius: 3, color: T.fg, padding: '2px 4px',
width: 42, outline: 'none', textAlign: 'right', boxSizing: 'border-box',
}}
/>
</>
)}
</div>
</div>
);
}
// ── Gradient bar with draggable stop markers ───────────────────────────────────
function GradientBar({
layer,
selectedStopId,
onSelectStop,
onMoveStop,
onAddStop,
}: {
layer: FillLayer;
selectedStopId: string | null;
onSelectStop: (id: string) => void;
onMoveStop: (id: string, position: number) => void;
onAddStop: (position: number) => void;
}) {
const barRef = useRef<HTMLDivElement>(null);
const draggingRef = useRef<string | null>(null);
const posFromEvent = useCallback((e: MouseEvent): number => {
const bar = barRef.current;
if (!bar) return 0;
const rect = bar.getBoundingClientRect();
return Math.round(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)));
}, []);
const handleStopMouseDown = useCallback((id: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
draggingRef.current = id;
onSelectStop(id);
const onMove = (ev: MouseEvent) => {
if (draggingRef.current) onMoveStop(draggingRef.current, posFromEvent(ev));
};
const onUp = () => {
draggingRef.current = null;
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}, [onSelectStop, onMoveStop, posFromEvent]);
// Always render the gradient left-to-right in the bar preview
const sorted = [...layer.stops].sort((a, b) => a.position - b.position);
const barGrad = `linear-gradient(90deg, ${sorted.map(s => `${s.color} ${s.position}%`).join(', ')})`;
return (
<div style={{ marginBottom: 7 }}>
<div
ref={barRef}
style={{
height: 20, borderRadius: 4,
background: barGrad,
border: '1px solid rgba(255,255,255,0.12)',
position: 'relative', cursor: 'crosshair', userSelect: 'none',
}}
onClick={e => {
// Ignore if we were dragging
if (draggingRef.current) return;
const bar = barRef.current;
if (!bar) return;
const rect = bar.getBoundingClientRect();
const pos = Math.round(Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100)));
onAddStop(pos);
}}
>
{layer.stops.map(stop => (
<div
key={stop.id}
onMouseDown={e => handleStopMouseDown(stop.id, e)}
onClick={e => { e.stopPropagation(); onSelectStop(stop.id); }}
title={`${stop.color} @ ${Math.round(stop.position)}%`}
style={{
position: 'absolute',
left: `${stop.position}%`,
top: '50%',
transform: 'translate(-50%, -50%)',
width: 11, height: 11, borderRadius: '50%',
background: stop.color,
border: `2px solid ${stop.id === selectedStopId ? '#ffffff' : 'rgba(255,255,255,0.55)'}`,
boxShadow: stop.id === selectedStopId
? '0 0 0 1.5px #3385FF, 0 1px 4px rgba(0,0,0,0.6)'
: '0 1px 3px rgba(0,0,0,0.5)',
cursor: 'grab', zIndex: 2,
}}
/>
))}
</div>
</div>
);
}
// ── Gradient stop color picker ─────────────────────────────────────────────────
function StopColorPicker({ color, onChange }: { color: string; onChange: (c: string) => void }) {
const T = useCanvasTheme();
const colorRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState(color.replace('#', ''));
const prevRef = useRef(color);
if (prevRef.current !== color) { prevRef.current = color; setDraft(color.replace('#', '')); }
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<div
style={{
width: 18, height: 18, borderRadius: 3, flexShrink: 0,
background: color, border: '1px solid rgba(255,255,255,0.2)',
cursor: 'pointer', position: 'relative', overflow: 'hidden',
}}
onClick={() => colorRef.current?.click()}
>
<input
ref={colorRef} type="color" value={color}
onChange={e => { onChange(e.target.value); setDraft(e.target.value.replace('#', '')); }}
style={{ position: 'absolute', inset: 0, opacity: 0, width: '100%', height: '100%', cursor: 'pointer' }}
/>
</div>
<input
value={draft.toUpperCase()} maxLength={6}
onChange={e => {
const v = e.target.value.replace(/[^0-9a-fA-F]/g, '');
setDraft(v);
if (v.length === 3 || v.length === 6) onChange(`#${v}`);
}}
onBlur={() => { if (draft.length === 6) onChange(`#${draft}`); }}
onKeyDown={e => { if (e.key === 'Enter') onChange(`#${draft}`); e.stopPropagation(); }}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
background: T.bgDeep, border: `1px solid ${T.border}`,
borderRadius: 3, color: T.fg, padding: '2px 5px',
width: 52, outline: 'none', boxSizing: 'border-box',
}}
/>
</div>
);
}
// ── Image fill editor ──────────────────────────────────────────────────────────
function ImageEditor({ url, onChange }: { url: string; onChange: (v: string) => void }) {
const T = useCanvasTheme();
const [draft, setDraft] = useState(url);
const prevRef = useRef(url);
if (prevRef.current !== url) { prevRef.current = url; setDraft(url); }
return (
<input
value={draft} placeholder="https://…"
onChange={e => setDraft(e.target.value)}
onBlur={() => onChange(draft)}
onKeyDown={e => { if (e.key === 'Enter') onChange(draft); e.stopPropagation(); }}
style={{
width: '100%', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
background: T.bgDeep, border: `1px solid ${T.border}`,
borderRadius: 4, color: T.fg, padding: '4px 6px',
outline: 'none', boxSizing: 'border-box',
}}
/>
);
}
@@ -84,11 +84,11 @@ export function FrameSection({ styles, onPatch }: FrameSectionProps) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>W</FieldLabel> <FieldLabel>W</FieldLabel>
<NumInput value={styles['width'] ?? '0px'} propKey="width" onPatch={onPatch} inputWidth={80} /> <NumInput value={styles['width'] ?? '0px'} propKey="width" onPatch={onPatch} inputWidth={80} tokenAware />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>H</FieldLabel> <FieldLabel>H</FieldLabel>
<NumInput value={styles['height'] ?? '0px'} propKey="height" onPatch={onPatch} inputWidth={80} /> <NumInput value={styles['height'] ?? '0px'} propKey="height" onPatch={onPatch} inputWidth={80} tokenAware />
</div> </div>
</div> </div>
@@ -106,7 +106,7 @@ export function FrameSection({ styles, onPatch }: FrameSectionProps) {
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Radius</FieldLabel> <FieldLabel>Radius</FieldLabel>
<NumInput value={borderRadius} propKey="border-radius" onPatch={onPatch} inputWidth={80} /> <NumInput value={borderRadius} propKey="border-radius" onPatch={onPatch} inputWidth={80} tokenAware />
</div> </div>
</div> </div>
@@ -123,7 +123,7 @@ export function LayoutSection({ styles, onPatch }: LayoutSectionProps) {
{/* Gap */} {/* Gap */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<FieldLabel>Gap</FieldLabel> <FieldLabel>Gap</FieldLabel>
<NumInput value={styles['gap'] ?? '0px'} propKey="gap" onPatch={onPatch} inputWidth={60} /> <NumInput value={styles['gap'] ?? '0px'} propKey="gap" onPatch={onPatch} inputWidth={60} tokenAware />
</div> </div>
</> </>
)} )}
@@ -168,19 +168,19 @@ export function LayoutSection({ styles, onPatch }: LayoutSectionProps) {
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 6px', marginTop: 4 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 6px', marginTop: 4 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Top</FieldLabel> <FieldLabel>Top</FieldLabel>
<NumInput value={styles['padding-top'] ?? '0px'} propKey="padding-top" onPatch={onPatch} inputWidth={72} /> <NumInput value={styles['padding-top'] ?? '0px'} propKey="padding-top" onPatch={onPatch} inputWidth={72} tokenAware />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Right</FieldLabel> <FieldLabel>Right</FieldLabel>
<NumInput value={styles['padding-right'] ?? '0px'} propKey="padding-right" onPatch={onPatch} inputWidth={72} /> <NumInput value={styles['padding-right'] ?? '0px'} propKey="padding-right" onPatch={onPatch} inputWidth={72} tokenAware />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Bottom</FieldLabel> <FieldLabel>Bottom</FieldLabel>
<NumInput value={styles['padding-bottom'] ?? '0px'} propKey="padding-bottom" onPatch={onPatch} inputWidth={72} /> <NumInput value={styles['padding-bottom'] ?? '0px'} propKey="padding-bottom" onPatch={onPatch} inputWidth={72} tokenAware />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Left</FieldLabel> <FieldLabel>Left</FieldLabel>
<NumInput value={styles['padding-left'] ?? '0px'} propKey="padding-left" onPatch={onPatch} inputWidth={72} /> <NumInput value={styles['padding-left'] ?? '0px'} propKey="padding-left" onPatch={onPatch} inputWidth={72} tokenAware />
</div> </div>
</div> </div>
</div> </div>
@@ -45,7 +45,7 @@ export function StrokeSection({ styles, onPatch }: StrokeSectionProps) {
) : ( ) : (
<> <>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<ColorInput value={styles['border-color'] ?? ''} propKey="border-color" onPatch={onPatch} /> <ColorInput value={styles['border-color'] ?? ''} propKey="border-color" onPatch={onPatch} tokenAware />
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
@@ -50,15 +50,15 @@ export function TypographySection({
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px 6px', marginBottom: 6 }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px 6px', marginBottom: 6 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Size</FieldLabel> <FieldLabel>Size</FieldLabel>
<NumInput value={styles['font-size'] ?? '14px'} propKey="font-size" onPatch={onPatch} /> <NumInput value={styles['font-size'] ?? '14px'} propKey="font-size" onPatch={onPatch} tokenAware />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Weight</FieldLabel> <FieldLabel>Weight</FieldLabel>
<NumInput value={styles['font-weight'] ?? '400'} propKey="font-weight" onPatch={onPatch} /> <NumInput value={styles['font-weight'] ?? '400'} propKey="font-weight" onPatch={onPatch} tokenAware />
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<FieldLabel>Line H</FieldLabel> <FieldLabel>Line H</FieldLabel>
<NumInput value={styles['line-height'] ?? 'normal'} propKey="line-height" onPatch={onPatch} /> <NumInput value={styles['line-height'] ?? 'normal'} propKey="line-height" onPatch={onPatch} tokenAware />
</div> </div>
</div> </div>
@@ -83,7 +83,7 @@ export function TypographySection({
{/* Color */} {/* Color */}
<div style={{ marginBottom: 6 }}> <div style={{ marginBottom: 6 }}>
<FieldLabel>Color</FieldLabel> <FieldLabel>Color</FieldLabel>
<ColorInput value={styles['color'] ?? '#000000'} propKey="color" onPatch={onPatch} /> <ColorInput value={styles['color'] ?? '#000000'} propKey="color" onPatch={onPatch} tokenAware />
</div> </div>
{/* Decoration + Transform */} {/* Decoration + Transform */}
+114 -13
View File
@@ -9,6 +9,11 @@
* prop JSX prop changes in the component call-site .tsx file * prop JSX prop changes in the component call-site .tsx file
* tailwind Tailwind class string replacement in the component call-site * tailwind Tailwind class string replacement in the component call-site
* *
* When `fetchFile` is supplied and the FiberNode has a `callSite.fileName`,
* the prop and tailwind strategies fetch the real source file from the CLI
* indexer (`GET /file?path=<relative>`) and produce a diff against the actual
* line, rather than a synthetic snippet.
*
* spec: SOURCE-AWARE-CANVAS.md Phase 4 §8 "Intent Diff" * spec: SOURCE-AWARE-CANVAS.md Phase 4 §8 "Intent Diff"
*/ */
@@ -34,6 +39,13 @@ export interface GeneratedFileDiff {
strategy: DiffStrategy; strategy: DiffStrategy;
} }
/**
* Optional async file fetcher wired up by the CodeTab to the CLI indexer's
* GET /file?path=<relative> endpoint. When supplied, prop/tailwind strategies
* operate on real source text instead of synthetic snippets.
*/
export type FileFetcher = (relativePath: string) => Promise<string>;
// ── Strategy: css ───────────────────────────────────────────────────────────── // ── Strategy: css ─────────────────────────────────────────────────────────────
// Generates a CSS custom-property block showing what changed. // Generates a CSS custom-property block showing what changed.
// Virtual filename: derived from the component's call-site or "<component>.css". // Virtual filename: derived from the component's call-site or "<component>.css".
@@ -78,15 +90,52 @@ function buildCssDiff(
// ── Strategy: prop ──────────────────────────────────────────────────────────── // ── Strategy: prop ────────────────────────────────────────────────────────────
// Generates a JSX snippet showing style prop changes on the component element. // Generates a JSX snippet showing style prop changes on the component element.
// This is a simplified representation; Phase 4+ CLI integration will replace // When `realSource` is provided (fetched from the CLI indexer), the diff is
// these with real AST-rewritten patches at the actual call-site. // produced against the actual call-site line in the real file. Otherwise falls
// back to a synthetic snippet that approximates the change.
function buildPropDiff( function buildPropDiff(
componentName: string, componentName: string,
callSiteFile: string | undefined, callSiteFile: string | undefined,
callSiteLine: number | undefined,
patches: StylePatch[], patches: StylePatch[],
realSource?: string,
): GeneratedFileDiff | null { ): GeneratedFileDiff | null {
const virtualName = callSiteFile ?? `${componentName}.tsx`; const virtualName = callSiteFile ?? `${componentName}.tsx`;
// ── Real-source path: patch the actual call-site line ──────────────────────
if (realSource && callSiteLine !== undefined) {
const lines = realSource.split('\n');
const lineIdx = Math.max(0, callSiteLine - 1); // 0-based
// Build the style attribute additions
const styleEntries = patches
.map((p) => `${camelCase(p.property)}: '${p.value}'`)
.join(', ');
const oldLine = lines[lineIdx] ?? '';
let newLine: string;
// If the line already has a style prop, replace its content; otherwise inject.
if (oldLine.includes('style={{')) {
newLine = oldLine.replace(/style=\{\{[^}]*\}\}/, `style={{ ${styleEntries} }}`);
} else if (oldLine.includes('/>')) {
newLine = oldLine.replace('/>', ` style={{ ${styleEntries} }} />`);
} else {
newLine = oldLine + ` style={{ ${styleEntries} }}`;
}
const newLines = [...lines];
newLines[lineIdx] = newLine;
const fileDiff = processFile('', {
oldFile: { name: virtualName, contents: realSource },
newFile: { name: virtualName, contents: newLines.join('\n') },
});
if (!fileDiff) return null;
return { fileDiff, filename: virtualName, strategy: 'prop' };
}
// ── Synthetic fallback ─────────────────────────────────────────────────────
const styleOld = patches const styleOld = patches
.filter((p) => p.previousValue) .filter((p) => p.previousValue)
.map((p) => ` ${camelCase(p.property)}: '${p.previousValue}'`) .map((p) => ` ${camelCase(p.property)}: '${p.previousValue}'`)
@@ -115,26 +164,59 @@ function buildPropDiff(
// ── Strategy: tailwind ──────────────────────────────────────────────────────── // ── Strategy: tailwind ────────────────────────────────────────────────────────
// Converts CSS property patches into approximate Tailwind utility additions. // Converts CSS property patches into approximate Tailwind utility additions.
// The mapping is heuristic — a real implementation would require a Tailwind // When `realSource` is provided, injects utility classes at the real call-site
// config lookup via the CLI indexer (Phase 3 integration). // line. Otherwise uses a synthetic snippet with placeholder base classes.
function buildTailwindDiff( function buildTailwindDiff(
componentName: string, componentName: string,
callSiteFile: string | undefined, callSiteFile: string | undefined,
callSiteLine: number | undefined,
patches: StylePatch[], patches: StylePatch[],
realSource?: string,
): GeneratedFileDiff | null { ): GeneratedFileDiff | null {
const virtualName = callSiteFile ?? `${componentName}.tsx`; const virtualName = callSiteFile ?? `${componentName}.tsx`;
const newClasses = patches
.map((p) => cssToTailwindApprox(p.property, p.value))
.filter(Boolean)
.join(' ');
const oldClasses = patches const oldClasses = patches
.filter((p) => p.previousValue) .filter((p) => p.previousValue)
.map((p) => cssToTailwindApprox(p.property, p.previousValue ?? '')) .map((p) => cssToTailwindApprox(p.property, p.previousValue ?? ''))
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
const newClasses = patches // ── Real-source path ───────────────────────────────────────────────────────
.map((p) => cssToTailwindApprox(p.property, p.value)) if (realSource && callSiteLine !== undefined) {
.filter(Boolean) const lines = realSource.split('\n');
.join(' '); const lineIdx = Math.max(0, callSiteLine - 1);
const oldLine = lines[lineIdx] ?? '';
// Replace existing utility classes for the changed properties, or append
// new ones. We look for a className="..." or className={...} attribute.
let newLine = oldLine;
if (oldClasses && oldLine.includes(oldClasses)) {
newLine = oldLine.replace(oldClasses, newClasses);
} else if (oldLine.includes('className="')) {
newLine = oldLine.replace(/className="([^"]*)"/, (_, existing: string) =>
`className="${existing.trim()} ${newClasses}".trim()`,
);
} else if (oldLine.includes('/>')) {
newLine = oldLine.replace('/>', ` className="${newClasses}" />`);
}
const newLines = [...lines];
newLines[lineIdx] = newLine;
const fileDiff = processFile('', {
oldFile: { name: virtualName, contents: realSource },
newFile: { name: virtualName, contents: newLines.join('\n') },
});
if (!fileDiff) return null;
return { fileDiff, filename: virtualName, strategy: 'tailwind' };
}
// ── Synthetic fallback ─────────────────────────────────────────────────────
const baseClasses = 'flex items-center'; // placeholder existing classes const baseClasses = 'flex items-center'; // placeholder existing classes
const oldContents = `<${componentName} className="${[baseClasses, oldClasses].filter(Boolean).join(' ')}" />`; const oldContents = `<${componentName} className="${[baseClasses, oldClasses].filter(Boolean).join(' ')}" />`;
const newContents = `<${componentName} className="${[baseClasses, newClasses].filter(Boolean).join(' ')}" />`; const newContents = `<${componentName} className="${[baseClasses, newClasses].filter(Boolean).join(' ')}" />`;
@@ -153,24 +235,43 @@ function buildTailwindDiff(
/** /**
* Generates a FileDiffMetadata for the given patches using the chosen strategy. * Generates a FileDiffMetadata for the given patches using the chosen strategy.
* Returns null if the diff is empty (no actual changes). * Returns null if the diff is empty (no actual changes).
*
* @param fetchFile Optional async file fetcher (e.g. from useIndexer().fetchFile).
* When supplied and `componentData.callSite.fileName` is set, the
* prop and tailwind strategies diff against the *real* source file
* instead of a synthetic snippet. The css strategy always uses
* a virtual CSS block (it targets the component's CSS module, not
* the call site).
*/ */
export function buildFileDiffMetadata( export async function buildFileDiffMetadata(
patches: StylePatch[], patches: StylePatch[],
strategy: DiffStrategy, strategy: DiffStrategy,
componentData: FiberNode | null, componentData: FiberNode | null,
): GeneratedFileDiff | null { fetchFile?: FileFetcher,
): Promise<GeneratedFileDiff | null> {
if (patches.length === 0) return null; if (patches.length === 0) return null;
const componentName = componentData?.name ?? 'Component'; const componentName = componentData?.name ?? 'Component';
const callSiteFile = componentData?.callSite?.fileName; const callSiteFile = componentData?.callSite?.fileName;
const callSiteLine = componentData?.callSite?.lineNumber;
// Fetch the real source for strategies that operate on the call-site file.
let realSource: string | undefined;
if (fetchFile && callSiteFile && (strategy === 'prop' || strategy === 'tailwind')) {
try {
realSource = await fetchFile(callSiteFile);
} catch {
// Non-fatal: fall back to synthetic snippet below.
}
}
switch (strategy) { switch (strategy) {
case 'css': case 'css':
return buildCssDiff(componentName, callSiteFile, patches); return buildCssDiff(componentName, callSiteFile, patches);
case 'prop': case 'prop':
return buildPropDiff(componentName, callSiteFile, patches); return buildPropDiff(componentName, callSiteFile, callSiteLine, patches, realSource);
case 'tailwind': case 'tailwind':
return buildTailwindDiff(componentName, callSiteFile, patches); return buildTailwindDiff(componentName, callSiteFile, callSiteLine, patches, realSource);
} }
} }
File diff suppressed because one or more lines are too long
+22
View File
@@ -9,6 +9,9 @@
import { build } from 'esbuild'; import { build } from 'esbuild';
import { readFileSync, writeFileSync, chmodSync } from 'node:fs'; import { readFileSync, writeFileSync, chmodSync } from 'node:fs';
import { createRequire } from 'node:module';
const req = createRequire(import.meta.url);
// Node.js built-in module names (without and with the node: prefix). // Node.js built-in module names (without and with the node: prefix).
// Both forms must be listed so esbuild leaves them as-is whether the // Both forms must be listed so esbuild leaves them as-is whether the
@@ -23,12 +26,31 @@ const NODE_BUILTINS = [
'vm', 'worker_threads', 'zlib', 'vm', 'worker_threads', 'zlib',
]; ];
// ── html2canvas text-embed plugin ────────────────────────────────────────────
// Intercepts the import of html2canvas/dist/html2canvas.min.js and returns
// its content as a default-exported string, so the proxy can serve it
// locally (GET /__om_h2c__.js) without any CDN or runtime file reads.
const html2canvasTextPlugin = {
name: 'html2canvas-text',
setup(build) {
build.onResolve({ filter: /html2canvas\.min\.js$/ }, (args) => ({
path: req.resolve('html2canvas/dist/html2canvas.min.js'),
namespace: 'h2c-text',
}));
build.onLoad({ filter: /.*/, namespace: 'h2c-text' }, (args) => {
const text = readFileSync(args.path, 'utf-8');
return { contents: `export default ${JSON.stringify(text)}`, loader: 'js' };
});
},
};
const SHARED_OPTIONS = { const SHARED_OPTIONS = {
bundle: true, bundle: true,
platform: 'node', platform: 'node',
format: 'esm', format: 'esm',
target: 'node22', target: 'node22',
external: NODE_BUILTINS, external: NODE_BUILTINS,
plugins: [html2canvasTextPlugin],
logLevel: 'info', logLevel: 'info',
}; };
+1
View File
@@ -30,6 +30,7 @@
"@originmain/renderer": "workspace:*", "@originmain/renderer": "workspace:*",
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"html2canvas": "^1.4.1",
"typescript": "^5.5.0" "typescript": "^5.5.0"
}, },
"engines": { "engines": {
+7
View File
@@ -0,0 +1,7 @@
// Declaration for the html2canvas minified bundle imported as a plain string.
// The actual content is embedded at build time by the html2canvas-text esbuild
// plugin in build.mjs — this file is only here to satisfy the TypeScript compiler.
declare module 'html2canvas/dist/html2canvas.min.js' {
const source: string;
export default source;
}
+20 -10
View File
@@ -38,23 +38,33 @@ const NEXT_PAGE_CONTENT = (componentName: string, importPath: string, isDefau
// This file is deleted when the CLI stops (process.on('exit')). // This file is deleted when the CLI stops (process.on('exit')).
// Add __om_isolation__/ to your .gitignore to prevent accidental commits. // Add __om_isolation__/ to your .gitignore to prevent accidental commits.
import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}'; import ${isDefault ? componentName : `{ ${componentName} }`} from '${importPath}';
import { useEffect } from 'react'; import { useState, useEffect } from 'react';
// Expose render function for the host-to-iframe UPDATE_ISOLATION_PROPS protocol // IsolationPage renders the component in isolation and exposes
// window.__OM_ISO_RENDER__ for the host-to-iframe UPDATE_ISOLATION_PROPS protocol.
// Props are held in React state so calling __OM_ISO_RENDER__() causes a real re-render.
function IsolationPage() { function IsolationPage() {
// Initialise from window.__OM_ISO_PROPS__ if the parent frame already set it.
const [isoProps, setIsoProps] = useState<Record<string, unknown>>(
() => (typeof window !== 'undefined' ? (window.__OM_ISO_PROPS__ ?? {}) : {})
);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;
window.__OM_ISO_RENDER__ = function() { // Make sure the global is initialised before the first render.
// Force a re-render by dispatching a custom event — the component window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ ?? {};
// reads window.__OM_ISO_PROPS__ in its own render cycle. // Register the render trigger. The host writes to window.__OM_ISO_PROPS__
// then calls window.__OM_ISO_RENDER__(). setState with a new object reference
// is what actually causes React to re-render the component with new props.
window.__OM_ISO_RENDER__ = function () {
setIsoProps({ ...(window.__OM_ISO_PROPS__ ?? {}) });
};
return () => {
window.__OM_ISO_RENDER__ = undefined;
}; };
// Trigger initial render
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ || {};
}, []); }, []);
// Read props from the isolation protocol global return <${componentName} {...isoProps} />;
const props = (typeof window !== 'undefined' && window.__OM_ISO_PROPS__) ? window.__OM_ISO_PROPS__ : {};
return <${componentName} {...(props as Record<string, unknown>)} />;
} }
export default IsolationPage; export default IsolationPage;
+19
View File
@@ -16,6 +16,7 @@ import type { IncomingMessage, ServerResponse,
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { injectFiberHook } from './inject.js'; import { injectFiberHook } from './inject.js';
import { handleIsolationRequest } from './isolation-server.js'; import { handleIsolationRequest } from './isolation-server.js';
import html2canvasSource from 'html2canvas/dist/html2canvas.min.js';
export interface ProxyOptions { export interface ProxyOptions {
/** Target dev server URL, e.g. "http://localhost:3000" */ /** Target dev server URL, e.g. "http://localhost:3000" */
@@ -68,7 +69,25 @@ export function startProxy(opts: ProxyOptions): { close: () => void } {
// Default port: 443 for HTTPS, 80 for HTTP — matches browser behaviour. // Default port: 443 for HTTPS, 80 for HTTP — matches browser behaviour.
const targetPort = parseInt(targetUrl.port || (isHttps ? '443' : '80'), 10); const targetPort = parseInt(targetUrl.port || (isHttps ? '443' : '80'), 10);
// html2canvas source is embedded at build time by build.mjs's html2canvas-text
// plugin. Convert to a Buffer once so repeated requests don't re-encode.
const html2canvasBuf = Buffer.from(html2canvasSource, 'utf-8');
const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => { const server = createServer((clientReq: IncomingMessage, clientRes: ServerResponse) => {
// ── Serve embedded html2canvas bundle (replaces CDN dependency) ───────
// The fiber hook loads html2canvas via /__om_h2c__.js instead of unpkg so
// the proxy works offline and is not blocked by restrictive CSP policies.
if (clientReq.url === '/__om_h2c__.js') {
clientRes.writeHead(200, {
'content-type': 'application/javascript; charset=utf-8',
'content-length': String(html2canvasBuf.byteLength),
'cache-control': 'public, max-age=86400, immutable',
...CORS_HEADERS,
});
clientRes.end(html2canvasBuf);
return;
}
// ── Intercept /__om_isolation__/* requests ──────────────────────────── // ── Intercept /__om_isolation__/* requests ────────────────────────────
if (clientReq.url?.startsWith('/__om_isolation__')) { if (clientReq.url?.startsWith('/__om_isolation__')) {
handleIsolationRequest(clientReq, clientRes); handleIsolationRequest(clientReq, clientRes);
+5 -4
View File
@@ -777,8 +777,9 @@ export function buildProxyFiberHookScript(): string {
} }
// ── html2canvas lazy loader ─────────────────────────────────────────────── // ── html2canvas lazy loader ───────────────────────────────────────────────
// html2canvas is not bundled in the fiber hook — inject from CDN on first // The Originmain proxy serves html2canvas at /__om_h2c__.js (embedded at
// need, caching the Promise so the script tag is added only once. // build time in the CLI bundle — no CDN, no external network dependency).
// The script tag uses the same origin as the proxy, so no CORS or CSP issues.
function loadHtml2Canvas() { function loadHtml2Canvas() {
if (typeof window.html2canvas === 'function') { if (typeof window.html2canvas === 'function') {
return Promise.resolve(window.html2canvas); return Promise.resolve(window.html2canvas);
@@ -786,9 +787,9 @@ export function buildProxyFiberHookScript(): string {
if (_html2canvasLoading) return _html2canvasLoading; if (_html2canvasLoading) return _html2canvasLoading;
_html2canvasLoading = new Promise(function(resolve, reject) { _html2canvasLoading = new Promise(function(resolve, reject) {
var s = document.createElement('script'); var s = document.createElement('script');
s.src = 'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js'; s.src = '/__om_h2c__.js';
s.onload = function() { resolve(window.html2canvas); }; s.onload = function() { resolve(window.html2canvas); };
s.onerror = function() { _html2canvasLoading = null; reject(new Error('html2canvas load failed')); }; s.onerror = function() { _html2canvasLoading = null; reject(new Error('html2canvas load failed (/__om_h2c__.js)')); };
(document.head || document.documentElement).appendChild(s); (document.head || document.documentElement).appendChild(s);
}); });
return _html2canvasLoading; return _html2canvasLoading;
+39
View File
@@ -180,6 +180,9 @@ importers:
esbuild: esbuild:
specifier: ^0.28.0 specifier: ^0.28.0
version: 0.28.0 version: 0.28.0
html2canvas:
specifier: ^1.4.1
version: 1.4.1
typescript: typescript:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
@@ -1989,6 +1992,10 @@ packages:
resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
engines: {node: 18 || 20 || >=22} engines: {node: 18 || 20 || >=22}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
brace-expansion@1.1.14: brace-expansion@1.1.14:
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
@@ -2055,6 +2062,9 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
csstype@3.2.3: csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -2267,6 +2277,10 @@ packages:
html-void-elements@3.0.0: html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
iceberg-js@0.8.1: iceberg-js@0.8.1:
resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
engines: {node: '>=20.0.0'} engines: {node: '>=20.0.0'}
@@ -2664,6 +2678,9 @@ packages:
resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==}
engines: {node: '>=18'} engines: {node: '>=18'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
tinybench@2.9.0: tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -2736,6 +2753,9 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
vfile-message@4.0.3: vfile-message@4.0.3:
resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==}
@@ -4932,6 +4952,8 @@ snapshots:
balanced-match@4.0.4: {} balanced-match@4.0.4: {}
base64-arraybuffer@1.0.2: {}
brace-expansion@1.1.14: brace-expansion@1.1.14:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
@@ -4994,6 +5016,10 @@ snapshots:
shebang-command: 2.0.0 shebang-command: 2.0.0
which: 2.0.2 which: 2.0.2
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
csstype@3.2.3: {} csstype@3.2.3: {}
culori@3.3.0: {} culori@3.3.0: {}
@@ -5246,6 +5272,11 @@ snapshots:
html-void-elements@3.0.0: {} html-void-elements@3.0.0: {}
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
iceberg-js@0.8.1: {} iceberg-js@0.8.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
@@ -5682,6 +5713,10 @@ snapshots:
glob: 10.5.0 glob: 10.5.0
minimatch: 10.2.5 minimatch: 10.2.5
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
tinybench@2.9.0: {} tinybench@2.9.0: {}
tinyexec@0.3.2: {} tinyexec@0.3.2: {}
@@ -5746,6 +5781,10 @@ snapshots:
dependencies: dependencies:
react: 19.2.5 react: 19.2.5
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
vfile-message@4.0.3: vfile-message@4.0.3:
dependencies: dependencies:
'@types/unist': 3.0.3 '@types/unist': 3.0.3
@@ -0,0 +1,96 @@
-- ═══════════════════════════════════════════════════════════════════════════
-- Migration 013: Phase 6 — Design Language Files table
--
-- Creates the `design_language_files` table used by the DLF token system.
-- Each row is one uploaded version of a workspace's token/constraint file.
-- Only one row per workspace is "active" at a time (enforced by partial index).
--
-- Spec reference: SOURCE-AWARE-CANVAS Phase 6 §9.2
-- All changes use IF NOT EXISTS / DO $$ guards for idempotency.
-- ═══════════════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS design_language_files (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name text NOT NULL,
-- Full parsed token + constraint document stored as JSONB.
-- Schema validated in-app before insert via DesignLanguageFileBodySchema.
schema_jsonb jsonb NOT NULL,
version integer NOT NULL DEFAULT 1,
-- Only one row per workspace should be active. The partial unique index below
-- enforces this at the DB level. Use the deactivation step in the POST route
-- to set this to false on all prior rows before inserting a new active one.
is_active boolean NOT NULL DEFAULT false,
created_by text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── Indexes ───────────────────────────────────────────────────────────────────
-- Fast lookup of the latest active file for a workspace.
CREATE INDEX IF NOT EXISTS design_language_files_workspace_version_idx
ON design_language_files (workspace_id, version DESC);
-- Enforce at most one active row per workspace.
-- Partial unique index: only rows where is_active = true are checked.
CREATE UNIQUE INDEX IF NOT EXISTS design_language_files_one_active_per_workspace_idx
ON design_language_files (workspace_id)
WHERE (is_active = true);
-- ── Auto-update updated_at ────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION update_design_language_files_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_design_language_files_updated_at ON design_language_files;
CREATE TRIGGER trg_design_language_files_updated_at
BEFORE UPDATE ON design_language_files
FOR EACH ROW EXECUTE FUNCTION update_design_language_files_updated_at();
-- ── Row Level Security ────────────────────────────────────────────────────────
-- Service-role key (used by API routes) bypasses RLS entirely.
-- Browser clients (anon or authenticated) are restricted to their workspace.
ALTER TABLE design_language_files ENABLE ROW LEVEL SECURITY;
-- Workspace members may read design language files for their workspace.
-- Relies on the `team_members` table introduced in migration 001.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_language_files' AND policyname = 'dlf_workspace_select'
) THEN
CREATE POLICY dlf_workspace_select ON design_language_files
FOR SELECT
USING (
workspace_id IN (
SELECT workspace_id FROM team_members
WHERE user_id = auth.uid()::text
)
);
END IF;
END $$;
-- Only workspace owners / designers may insert / update.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_language_files' AND policyname = 'dlf_workspace_write'
) THEN
CREATE POLICY dlf_workspace_write ON design_language_files
FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM team_members
WHERE user_id = auth.uid()::text
AND role IN ('OWNER', 'DESIGNER')
)
);
END IF;
END $$;