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
${toolLines}
### How intents arrive
The Origin canvas pushes design intent diffs in two ways:
1. **SSE push (primary):** The MCP server sends an \`INTENT_RECEIVED\` event over the SSE
connection automatically when the designer exports a change. You do not need to poll.
2. **Poll (fallback):** Call \`push_intent\` with \`{ workspace_id }\` to drain any pending
intents. Use this at session start or when you suspect a missed push.
### Implementation Workflow
1. Call \`push_intent\` to receive any pending design intent diffs from the Origin canvas.
2. Locate the component file using \`resolve_component\` (pass the \`nodeId\` from the intent).
3. Apply the change to the source file — the intent's \`codeDiff\` contains the expected before/after.
1. Wait for an \`INTENT_RECEIVED\` SSE event, or call \`push_intent\` to fetch pending intents.
2. Each intent includes a \`component.name\` — call \`resolve_component\` with that name to locate
the source file and line number (e.g. \`{ component_name: "DashboardCard" }\`).
3. Apply the change to the source file. When \`codeDiff\` is present and \`confidence\` is
\`"exact"\`, apply it verbatim. When \`"approximate"\`, use it as a guide and refine as needed.
4. After applying, call \`update_diff_status\` with \`status: "IMPLEMENTED"\` and the \`intentId\`.
5. If the diff cannot be applied for any reason, call \`update_diff_status\` with \`status: "BLOCKED"\`
and a \`reason\` string describing why (e.g. "Component not found in file", "File is read-only",
"Diff conflicts with current file state"). The designer will see this reason in the Origin canvas.
### Important: Always close the loop with update_diff_status
Every intent received via \`push_intent\` MUST be closed with \`update_diff_status\` — either
IMPLEMENTED or BLOCKED. An intent left in EXPORTED state will be retried on the next session.
Every intent received — whether via SSE push or \`push_intent\` poll — MUST be closed with
\`update_diff_status\` using either \`IMPLEMENTED\` or \`BLOCKED\`. An intent left in \`EXPORTED\`
state will be retried on the next session.
### Design Language
When token keys are present in the intent changes (\`tokenKey\` field), write \`var(--token-name)\`
+1 -1
View File
@@ -8,7 +8,7 @@ export type { RateLimitResult } from './rate-limiter.js';
export { checkRateLimit, getRateLimitStatus } from './rate-limiter.js';
export type { McpTool, ToolContext } from './tools.js';
export { TOOLS, TOOL_MAP, getToolList, dispatchTool, storePendingIntent, drainPendingIntents, registerIndexer, getIndexerUrl, heartbeatIndexer } from './tools.js';
export { TOOLS, TOOL_MAP, getToolList, dispatchTool, storePendingIntent, drainPendingIntents, registerIndexer, getIndexerUrl, heartbeatIndexer, registerSseClient } from './tools.js';
export type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js';
export { generateCursorConfig } from './adapters/cursor.js';
+125 -59
View File
@@ -13,7 +13,7 @@
*/
import { textResult, jsonResult } from './protocol.js';
import type { ToolResult } from './protocol.js';
import type { ToolResult, IntentMessage, IntentChange, IntentReceivedPush } from './protocol.js';
// ── Tool descriptor shape (MCP tools/list schema) ─────────────────────────────
@@ -66,13 +66,19 @@ const pendingIntents = new Map<string, IntentRecord[]>();
/**
* Store an intent pushed by the canvas (called from the /api/intent route).
* Returns the generated intentId.
*
* @param supplyIntentId Pass the Supabase UUID when you've already created the
* DB row — the in-memory ID must match so that the agent's
* `update_diff_status` tool can call `.eq('id', intentId)`.
* If omitted, a local opaque ID is generated (dev/offline mode).
* Returns the intentId actually stored.
*/
export function storePendingIntent(
workspaceId: string,
intent: Omit<IntentRecord, 'intentId' | 'workspaceId' | 'createdAt'>,
supplyIntentId?: string,
): string {
const intentId = `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const intentId = supplyIntentId ?? `intent_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
const record: IntentRecord = {
intentId,
workspaceId,
@@ -82,9 +88,79 @@ export function storePendingIntent(
const queue = pendingIntents.get(workspaceId) ?? [];
queue.push(record);
pendingIntents.set(workspaceId, queue);
// Push INTENT_RECEIVED to any SSE-connected agents for this workspace.
const push: IntentReceivedPush = {
type: 'INTENT_RECEIVED',
intent: recordToIntentMessage(record),
};
pushToSseClients(workspaceId, `data: ${JSON.stringify(push)}\n\n`);
return intentId;
}
// ── SSE client registry ───────────────────────────────────────────────────────
// Maps workspaceId → set of send callbacks registered by connected SSE clients.
//
// Same in-process design as pendingIntents / indexerRegistry — for production
// multi-instance deployments, replace with Redis pub/sub or Supabase Realtime.
type SseSendFn = (chunk: string) => void;
const sseClients = new Map<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
* or by the agent when polling).
@@ -184,26 +260,20 @@ const PUSH_INTENT_TOOL: McpTool = {
const RESOLVE_COMPONENT_TOOL: McpTool = {
name: 'resolve_component',
description:
'Resolve the source file location for a component identified by its fiber node ID. ' +
'Returns the file path and line number where the component is defined in the codebase, ' +
'enabling the agent to navigate directly to the component source.',
'Resolve the source file location for a React component by name. ' +
'Proxies to the CLI indexer running in the workspace and returns the file path, ' +
'line number, props schema, and design tokens used by the component.',
inputSchema: {
type: 'object',
properties: {
artboard_id: {
type: 'string',
description: 'The artboard that contains the component.',
},
node_id: {
type: 'string',
description: 'The fiber node ID of the component (from SelectionOverlay / fiber tree).',
},
component_name: {
type: 'string',
description: 'Display name of the component (used as a fallback hint).',
description:
'The display name of the component to look up (e.g. "DashboardCard"). ' +
'Case-sensitive; use the name exactly as it appears in the INTENT_RECEIVED message.',
},
},
required: ['artboard_id', 'node_id'],
required: ['component_name'],
},
};
@@ -264,76 +334,72 @@ async function handlePushIntent(ctx: ToolContext): Promise<ToolResult<unknown>>
return textResult('Error: workspace_id does not match authenticated workspace.');
}
const intents = drainPendingIntents(wid);
const records = drainPendingIntents(wid);
if (intents.length === 0) {
return textResult('No pending design intents for this workspace.');
if (records.length === 0) {
return jsonResult({ intents: [], message: 'No pending design intents for this workspace.' });
}
const summary = intents.map((intent) => {
const patches = JSON.parse(intent.patchJson) as Array<{ property: string; value: string; previousValue?: string }>;
const lines = patches.map(
(p) => `${p.property}: ${p.previousValue ?? '(unknown)'}${p.value}`,
);
return [
`Intent ${intent.intentId}${intent.componentName} (${intent.strategy})`,
intent.summary,
...lines,
` artboardId: ${intent.artboardId}`,
].join('\n');
});
return textResult(
`${intents.length} pending design intent${intents.length !== 1 ? 's' : ''}:\n\n${summary.join('\n\n')}`,
);
// Return the full IntentMessage array so the agent can act on each intent
// without a separate round-trip. The same data is pushed proactively over
// SSE as INTENT_RECEIVED — this poll path exists as a fallback and for
// agents that do not maintain a persistent SSE connection.
const intents = records.map(recordToIntentMessage);
return jsonResult({ intents });
}
async function handleResolveComponent(ctx: ToolContext): Promise<ToolResult<unknown>> {
const params = ctx.params as Params;
const nodeId = typeof params['node_id'] === 'string' ? params['node_id'] : null;
const artboardId = typeof params['artboard_id'] === 'string' ? params['artboard_id'] : null;
const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : 'Component';
const componentName = typeof params['component_name'] === 'string' ? params['component_name'] : null;
if (!nodeId || !artboardId) {
return textResult('Error: artboard_id and node_id are required.');
if (!componentName) {
return textResult('Error: component_name is required.');
}
// Check if a CLI indexer is registered for this workspace
// Check if a CLI indexer is registered for this workspace.
const indexerUrl = getIndexerUrl(ctx.workspaceId);
if (!indexerUrl) {
return textResult(
`No CLI indexer registered for workspace ${ctx.workspaceId}. ` +
'Run npx @originmain/cli dev to enable component resolution.',
'Run "npx @originmain/cli dev" to enable component resolution.',
);
}
// Proxy the resolution request to the CLI indexer
// Proxy to GET /components?name=<componentName> on the CLI indexer.
// The indexer returns ComponentEntry[] — we return the first exact or best match.
try {
const url = new URL('/resolve-component', indexerUrl);
url.searchParams.set('nodeId', nodeId);
url.searchParams.set('artboardId', artboardId);
url.searchParams.set('componentName', componentName);
const url = new URL('/components', indexerUrl);
url.searchParams.set('name', componentName);
const res = await fetch(url.toString(), {
signal: AbortSignal.timeout(5000),
});
const res = await fetch(url.toString(), { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
return textResult(`Indexer returned ${res.status}: ${await res.text()}`);
}
const data = (await res.json()) as { filePath?: string; lineNumber?: number; column?: number };
if (!data.filePath) {
return textResult(`Component ${componentName} not found in index.`);
const entries = (await res.json()) as Array<{
name: string;
definitionFile: string;
relativeFile: string;
lineNumber: number;
props: Array<{ name: string; type: string; optional: boolean }>;
tokensUsed: string[];
}>;
if (!entries.length) {
return textResult(`Component "${componentName}" not found in index. Make sure the CLI indexer has indexed this file.`);
}
// Prefer exact name match; fall back to first fuzzy result.
const match = entries.find((e) => e.name === componentName) ?? entries[0]!;
return jsonResult({
filePath: data.filePath,
lineNumber: data.lineNumber ?? 1,
column: data.column ?? 1,
nodeId,
artboardId,
componentName,
name: match.name,
definitionFile: match.definitionFile,
relativeFile: match.relativeFile,
lineNumber: match.lineNumber,
props: match.props,
tokensUsed: match.tokensUsed,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
+79 -3
View File
@@ -1,12 +1,88 @@
// POST /api/agent-bridge
// JSON-RPC 2.0 endpoint consumed by the Cursor / Claude Code MCP adapters.
// GET /api/agent-bridge — SSE stream; server pushes INTENT_RECEIVED events
// POST /api/agent-bridge — JSON-RPC 2.0; agent calls tools (push_intent, resolve_component, …)
//
// Authentication: Bearer <workspace_token> (HMAC-SHA256, issued by issueWorkspaceToken).
// Both methods share the same auth scheme.
import { NextRequest, NextResponse } from 'next/server';
import { verifyWorkspaceToken, TOOL_MAP, getToolList, dispatchTool } from '@originmain/agent-bridge';
import { verifyWorkspaceToken, TOOL_MAP, getToolList, dispatchTool, registerSseClient } from '@originmain/agent-bridge';
import type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge';
import { serverClient } from '@/lib/supabase';
// ── GET — SSE stream for real-time INTENT_RECEIVED push ─────────────────────
// The agent connects once and holds this connection open. When the canvas
// exports a style intent (via POST /api/intent), storePendingIntent() fires
// pushToSseClients() which delivers the event immediately over this stream.
//
// Deployment note: this is a long-lived streaming response. In serverless
// environments (Vercel Hobby / Functions) connections are cut at the platform
// timeout (~30 s). For persistent SSE, deploy with Vercel Fluid Compute,
// a Node.js server, or replace the in-process client registry with a
// Supabase Realtime / Redis pub-sub channel.
export async function GET(req: NextRequest) {
// ── Auth ──────────────────────────────────────────────────────────────────
const authHeader = req.headers.get('authorization') ?? '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!token) {
return new NextResponse('Missing Bearer token', { status: 401 });
}
const workspaceToken = verifyWorkspaceToken(token);
if (!workspaceToken) {
return new NextResponse('Invalid or expired token', { status: 401 });
}
const { workspaceId } = workspaceToken;
const enc = new TextEncoder();
// ── SSE stream ────────────────────────────────────────────────────────────
const stream = new ReadableStream({
start(controller) {
// Send the mandatory SSE preamble so the client knows the connection is live.
controller.enqueue(enc.encode(': connected\n\n'));
// Register callback — fires every time storePendingIntent() is called for
// this workspace (i.e., the canvas exported a new style intent).
const unsubscribe = registerSseClient(workspaceId, (chunk: string) => {
try {
controller.enqueue(enc.encode(chunk));
} catch {
// Controller may be closed if the client disconnected between the
// callback being fired and the enqueue call.
}
});
// Keep-alive comment every 25 s — prevents proxies from closing the
// connection after a 30 s idle timeout while the agent waits for intents.
const keepAliveTimer = setInterval(() => {
try {
controller.enqueue(enc.encode(`: keep-alive\n\n`));
} catch {
clearInterval(keepAliveTimer);
}
}, 25_000);
// Clean up when the client disconnects (connection abort or close).
req.signal.addEventListener('abort', () => {
clearInterval(keepAliveTimer);
unsubscribe();
try { controller.close(); } catch { /* already closed */ }
});
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Nginx: disable response buffering
},
});
}
export async function POST(req: NextRequest) {
// ── Auth ────────────────────────────────────────────────────────────────────
const authHeader = req.headers.get('authorization') ?? '';
@@ -1,11 +1,12 @@
// GET /api/design-language?workspaceId=<uuid> → active design language file
// POST /api/design-language → upsert (new version)
// GET /api/design-language?workspaceId=<uuid> → active design language file
// 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 { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
import type { InsertDesignLanguageFile } from '@originmain/origin-graph';
import type { InsertDesignLanguageFile, DesignLanguageFile } from '@originmain/origin-graph';
export async function GET(req: NextRequest) {
const { userId } = await auth();
@@ -14,8 +15,34 @@ export async function GET(req: NextRequest) {
const workspaceId = req.nextUrl.searchParams.get('workspaceId');
if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 });
const db = serverClient();
// Guard: caller must be a member of the target workspace.
const { data: member } = await db
.from('team_members')
.select('id')
.eq('workspace_id', workspaceId)
.eq('user_id', userId)
.limit(1)
.single();
if (!member) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// ?all=1 returns the full version history, newest first.
if (req.nextUrl.searchParams.get('all') === '1') {
const { data, error } = await (db
.from('design_language_files')
.select('*')
.eq('workspace_id', workspaceId)
.order('version', { ascending: false }) as unknown as Promise<{
data: DesignLanguageFile[];
error: { message: string } | null;
}>);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data ?? []);
}
// Default: return the single active file.
try {
const db = serverClient();
const file = await getActiveDesignLanguageFile(db, workspaceId);
if (!file) return NextResponse.json(null);
return NextResponse.json(file);
@@ -29,18 +56,69 @@ export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as InsertDesignLanguageFile;
let body: InsertDesignLanguageFile & { is_active?: boolean };
try {
body = (await req.json()) as InsertDesignLanguageFile & { is_active?: boolean };
} catch {
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body.workspace_id || !body.name || !body.schema_jsonb) {
return NextResponse.json(
{ error: 'Missing required fields: workspace_id, name, schema_jsonb' },
{ status: 400 },
);
}
const db = serverClient();
// Compute the next version: max(existing) + 1.
const existing = await getActiveDesignLanguageFile(db, body.workspace_id);
const version = existing ? existing.version + 1 : 1;
const { data, error } = await db
.from('design_language_files')
.insert({ ...body, version })
.select()
// Guard: caller must be a member of the target workspace.
const { data: postMember } = await db
.from('team_members')
.select('id')
.eq('workspace_id', body.workspace_id)
.eq('user_id', userId)
.limit(1)
.single();
if (!postMember) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
// ── Compute next version number ────────────────────────────────────────────
const existing = await getActiveDesignLanguageFile(db, body.workspace_id);
const version = existing ? existing.version + 1 : 1;
// ── Deactivate all prior versions for this workspace ───────────────────────
// This must happen before the insert so the partial unique index
// (only one is_active = true per workspace) is not violated.
if (existing) {
const { error: deactivateError } = await (db
.from('design_language_files')
.update({ is_active: false })
.eq('workspace_id', body.workspace_id) as unknown as Promise<{
data: unknown;
error: { message: string } | null;
}>);
if (deactivateError) {
return NextResponse.json(
{ error: `Failed to deactivate previous version: ${deactivateError.message}` },
{ status: 500 },
);
}
}
// ── Insert new version as the active one ───────────────────────────────────
const { data, error } = await (db
.from('design_language_files')
.insert({
...body,
version,
is_active: true,
created_by: userId,
})
.select()
.single() as unknown as Promise<{
data: DesignLanguageFile;
error: { message: string } | null;
}>);
if (error) return NextResponse.json({ error: error.message }, { status: 500 });
return NextResponse.json(data, { status: 201 });
+45 -10
View File
@@ -5,8 +5,11 @@
*
* The canvas calls this when the designer exports a style diff. This route:
* 1. Validates the authenticated user and payload
* 2. Stores the intent in the agent-bridge pending queue
* 3. Returns the generated intentId so the canvas can track status
* 2. Persists the intent to the `intent_diffs` Supabase table (durable)
* 3. Enqueues the intent in the agent-bridge in-memory pending queue using
* the Supabase row UUID so the agent's update_diff_status tool can
* reference the same row by id
* 4. Returns the intentId (Supabase UUID) so the canvas can track status
*
* The connected IDE agent drains the queue the next time it calls push_intent
* (or receives an INTENT_RECEIVED WebSocket push in Phase 5+).
@@ -15,6 +18,8 @@
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { storePendingIntent } from '@originmain/agent-bridge';
import { createDiff } from '@originmain/origin-graph';
import { serverClient } from '@/lib/supabase';
interface IntentPayload {
/** Supabase workspace ID. */
@@ -61,18 +66,48 @@ export async function POST(req: NextRequest) {
);
}
// ── 1. Persist to Supabase (durable) ──────────────────────────────────────
let intentId: string;
try {
const intentId = storePendingIntent(workspaceId, {
artboardId,
componentName,
patchJson,
strategy,
summary: summary ?? '',
const db = serverClient();
// Parse patchJson safely; fall back to wrapping it verbatim so the row
// is always writable even if the client sends a malformed payload.
let changesRecord: Record<string, unknown>;
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) {
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:
* 1. Upload a token file (Style Dictionary / W3C DTCG / flat CSS vars)
* 2. Validate the parsed token set before activating
* 3. Activate the tokens workspace-wide (stored in Supabase + canvas store)
* 4. View version history of previously uploaded token files
* 2. Fetch a token file from an HTTPS URL (via the SSRF-guarded proxy)
* 3. Validate the parsed token set before activating
* 4. Activate the tokens workspace-wide (stored in Supabase + canvas store)
* 5. View version history of previously uploaded token files + restore any version
*
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.5
*/
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useCanvas } from '@/store/canvas';
import type { DesignToken } from '@/store/canvas.types';
import type { DesignLanguageFile } from '@originmain/origin-graph';
// ── Upload & validation states ─────────────────────────────────────────────────
type ParseState =
| { status: 'idle' }
| { status: 'parsing' }
| { status: 'parsed'; tokens: DesignToken[]; filename: string }
| { status: 'parsed'; tokens: DesignToken[]; filename: string; rawJson: string }
| { status: 'error'; message: string };
type ActivateState = 'idle' | 'activating' | 'done' | 'error';
type FetchUrlState = 'idle' | 'fetching' | 'error';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -42,32 +45,59 @@ function groupByCategory(tokens: DesignToken[]): Record<string, DesignToken[]> {
return groups;
}
function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (mins < 2) return 'just now';
if (mins < 60) return `${mins}m ago`;
if (hours < 24) return `${hours}h ago`;
return `${days}d ago`;
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DesignLanguagePage() {
const { designLanguageTokens, setDesignLanguageTokens } = useCanvas();
const [parseState, setParseState] = useState<ParseState>({ status: 'idle' });
const { designLanguageTokens, setDesignLanguageTokens, workspaceId } = useCanvas();
const [parseState, setParseState] = useState<ParseState>({ status: '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);
// ── Load version history ────────────────────────────────────────────────────
const loadHistory = useCallback(async () => {
if (!workspaceId) return;
setHistoryLoading(true);
try {
const res = await fetch(`/api/design-language?workspaceId=${workspaceId}&all=1`);
if (res.ok) {
const data = await res.json() as DesignLanguageFile[];
setHistory(Array.isArray(data) ? data : []);
}
} catch { /* non-fatal */ }
finally { setHistoryLoading(false); }
}, [workspaceId]);
useEffect(() => { void loadHistory(); }, [loadHistory]);
// ── File handling ───────────────────────────────────────────────────────────
const processFile = useCallback(async (file: File) => {
if (!file.name.endsWith('.json')) {
setParseState({ status: 'error', message: 'Only .json token files are supported.' });
return;
}
const processJsonText = useCallback(async (text: string, filename: string) => {
setParseState({ status: 'parsing' });
try {
const text = await file.text();
const tokens = await parseTokenFileClient(text);
if (tokens.length === 0) {
setParseState({ status: 'error', message: 'No tokens found. Check the file format.' });
return;
}
setParseState({ status: 'parsed', tokens, filename: file.name });
setParseState({ status: 'parsed', tokens, filename, rawJson: text });
setActivateState('idle');
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -75,10 +105,18 @@ export default function DesignLanguagePage() {
}
}, []);
const processFile = useCallback(async (file: File) => {
if (!file.name.endsWith('.json')) {
setParseState({ status: 'error', message: 'Only .json token files are supported.' });
return;
}
const text = await file.text();
await processJsonText(text, file.name);
}, [processJsonText]);
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) void processFile(file);
// Reset so the same file can be re-uploaded
e.target.value = '';
}, [processFile]);
@@ -89,18 +127,80 @@ export default function DesignLanguagePage() {
if (file) void processFile(file);
}, [processFile]);
// ── Fetch from URL ──────────────────────────────────────────────────────────
const handleFetchUrl = useCallback(async () => {
const url = fetchUrl.trim();
if (!url) return;
if (!url.startsWith('https://')) {
setFetchUrlState('error');
setFetchUrlError('Only https:// URLs are supported.');
return;
}
setFetchUrlState('fetching');
setFetchUrlError('');
try {
const res = await fetch('/api/design-language/fetch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url }),
});
if (!res.ok) {
const body = await res.json() as { error?: string };
throw new Error(body.error ?? `HTTP ${res.status}`);
}
const { json } = await res.json() as { json: string };
const filename = url.split('/').pop() ?? 'tokens.json';
await processJsonText(json, filename);
setFetchUrlState('idle');
} catch (err) {
setFetchUrlState('error');
setFetchUrlError(err instanceof Error ? err.message : String(err));
}
}, [fetchUrl, processJsonText]);
// ── Activation ──────────────────────────────────────────────────────────────
const activate = useCallback(() => {
const activate = useCallback(async () => {
if (parseState.status !== 'parsed') return;
if (!workspaceId) {
setActivateState('error');
return;
}
setActivateState('activating');
try {
// 1. Parse the raw JSON to a schema_jsonb object for Supabase.
const schemaObj = JSON.parse(parseState.rawJson) as Record<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);
setActivateState('done');
} catch {
// 4. Refresh version history.
void loadHistory();
} catch (err) {
console.error('[DLF] Activation failed:', err);
setActivateState('error');
}
}, [parseState, setDesignLanguageTokens]);
}, [parseState, workspaceId, setDesignLanguageTokens, loadHistory]);
const deactivate = useCallback(() => {
setDesignLanguageTokens(null);
@@ -108,6 +208,32 @@ export default function DesignLanguagePage() {
setActivateState('idle');
}, [setDesignLanguageTokens]);
// ── Restore a historical version ────────────────────────────────────────────
const restoreVersion = useCallback(async (row: DesignLanguageFile) => {
if (!workspaceId) return;
try {
const { parseTokenFile } = await import('@originmain/design-language');
const tokens = parseTokenFile(row.schema_jsonb as Record<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 ──────────────────────────────────────────────────────────────────
return (
@@ -137,6 +263,11 @@ export default function DesignLanguagePage() {
Upload a design token file to enable token-aware inputs and constraint checking across all artboards.
Supports Style Dictionary, W3C DTCG, and flat CSS variable formats.
</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>
{/* Active token set status */}
@@ -160,7 +291,16 @@ export default function DesignLanguagePage() {
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' && (
<StatusCard>
<Spinner />
@@ -179,7 +319,17 @@ export default function DesignLanguagePage() {
tokens={parseState.tokens}
filename={parseState.filename}
activateState={activateState}
onActivate={activate}
workspaceId={workspaceId}
onActivate={() => void activate()}
/>
)}
{/* Version history */}
{(history.length > 0 || historyLoading) && (
<VersionHistory
rows={history}
loading={historyLoading}
onRestore={(row) => void restoreVersion(row)}
/>
)}
@@ -231,11 +381,7 @@ function ActiveTokenBanner({ tokens, onDeactivate }: { tokens: DesignToken[]; on
}
function UploadZone({
dragOver,
onDragOver,
onDragLeave,
onDrop,
onClick,
dragOver, onDragOver, onDragLeave, onDrop, onClick,
}: {
dragOver: boolean;
onDragOver: () => void;
@@ -257,7 +403,7 @@ function UploadZone({
cursor: 'pointer',
background: dragOver ? 'rgba(51,133,255,0.06)' : 'rgba(255,255,255,0.02)',
transition: 'border-color 0.15s, background 0.15s',
marginBottom: 24,
marginBottom: 16,
}}
>
<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 }) {
return (
<div style={{
@@ -301,14 +515,12 @@ function Spinner() {
}
function TokenPreview({
tokens,
filename,
activateState,
onActivate,
tokens, filename, activateState, workspaceId, onActivate,
}: {
tokens: DesignToken[];
filename: string;
activateState: ActivateState;
workspaceId: string | null;
onActivate: () => void;
}) {
const groups = groupByCategory(tokens);
@@ -330,12 +542,8 @@ function TokenPreview({
alignItems: 'center',
gap: 10,
}}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: '#7DD3A8' }}>
Parsed
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', flex: 1 }}>
{filename}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: '#7DD3A8' }}> Parsed</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', flex: 1 }}>{filename}</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.35)' }}>
{tokens.length} tokens · {Object.keys(groups).length} groups
</span>
@@ -346,49 +554,26 @@ function TokenPreview({
<div key={group} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
<button
onClick={() => setExpanded(expanded === group ? null : group)}
style={{
display: 'flex', alignItems: 'center', gap: 8,
width: '100%', padding: '10px 18px',
background: 'none', border: 'none', cursor: 'pointer',
textAlign: 'left',
}}
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '10px 18px', background: 'none', border: 'none', cursor: 'pointer', textAlign: 'left' }}
>
<span style={{ fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>
{expanded === group ? '▼' : '▶'}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.75)', flex: 1, textTransform: 'capitalize' }}>
{group}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>
{groupTokens.length}
</span>
{/* Color swatches preview */}
<span style={{ fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>{expanded === group ? '▼' : '▶'}</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.75)', flex: 1, textTransform: 'capitalize' }}>{group}</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>{groupTokens.length}</span>
<div style={{ display: 'flex', gap: 3 }}>
{groupTokens
.filter(t => t.type === 'color')
.slice(0, 6)
.map(t => (
<div key={t.key} style={{ width: 12, height: 12, borderRadius: 2, background: t.rawValue, border: '1px solid rgba(255,255,255,0.1)' }} />
))}
{groupTokens.filter(t => t.type === 'color').slice(0, 6).map(t => (
<div key={t.key} style={{ width: 12, height: 12, borderRadius: 2, background: t.rawValue, border: '1px solid rgba(255,255,255,0.1)' }} />
))}
</div>
</button>
{expanded === group && (
<div style={{ paddingBottom: 8 }}>
{groupTokens.map(token => (
<div key={token.key} style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '5px 18px 5px 36px',
}}>
<div key={token.key} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '5px 18px 5px 36px' }}>
{token.type === 'color' && (
<div style={{ width: 14, height: 14, borderRadius: 3, background: token.rawValue, flexShrink: 0, border: '1px solid rgba(255,255,255,0.15)' }} />
)}
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#3385FF', flex: 1, letterSpacing: '-0.01em' }}>
{token.key}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.4)', letterSpacing: '-0.01em' }}>
{token.rawValue}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#3385FF', flex: 1, letterSpacing: '-0.01em' }}>{token.key}</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.4)', letterSpacing: '-0.01em' }}>{token.rawValue}</span>
</div>
))}
</div>
@@ -400,7 +585,8 @@ function TokenPreview({
<div style={{ padding: '14px 18px', display: 'flex', gap: 10, alignItems: 'center' }}>
<button
onClick={onActivate}
disabled={activateState === 'activating' || activateState === 'done'}
disabled={activateState === 'activating' || activateState === 'done' || !workspaceId}
title={!workspaceId ? 'Open a workspace in the canvas first' : undefined}
style={{
padding: '9px 20px',
background: activateState === 'done' ? 'rgba(125,211,168,0.15)' : '#3385FF',
@@ -410,19 +596,18 @@ function TokenPreview({
fontFamily: "'Inter', sans-serif",
fontWeight: 600,
fontSize: '0.75rem',
cursor: activateState === 'activating' || activateState === 'done' ? 'not-allowed' : 'pointer',
opacity: activateState === 'activating' ? 0.6 : 1,
cursor: (activateState === 'activating' || activateState === 'done' || !workspaceId) ? 'not-allowed' : 'pointer',
opacity: (activateState === 'activating' || !workspaceId) ? 0.6 : 1,
transition: 'background 0.15s, opacity 0.15s',
}}
>
{activateState === 'activating' ? 'Activating…' :
activateState === 'done' ? '✓ Tokens activated' :
{activateState === 'activating' ? 'Saving…' :
activateState === 'done' ? '✓ Tokens saved & activated' :
`Activate ${tokens.length} tokens`}
</button>
{activateState === 'error' && (
<span style={{ fontSize: '0.625rem', color: '#FF8080', fontFamily: "'Inter', sans-serif" }}>
Activation failed try again
Save failed check console for details
</span>
)}
</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() {
return (
<details style={{ marginTop: 8 }}>
@@ -445,44 +754,18 @@ function FormatReference() {
}}>
Supported formats
</summary>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
{[
{
label: 'W3C DTCG',
desc: '$value / $type fields',
example: `{\n "color": {\n "primary": { "$value": "#0066FF", "$type": "color" }\n }\n}`,
},
{
label: 'Style Dictionary',
desc: 'Nested with value field',
example: `{\n "color": {\n "primary": { "value": "#0066FF" }\n }\n}`,
},
{
label: 'Flat CSS Variables',
desc: 'All keys start with --',
example: `{\n "--color-primary": "#0066FF",\n "--spacing-md": "16px"\n}`,
},
{ label: 'W3C DTCG', desc: '$value / $type fields', example: '{\n "color": {\n "primary": { "$value": "#0066FF", "$type": "color" }\n }\n}' },
{ label: 'Style Dictionary', desc: 'Nested with value field', example: '{\n "color": {\n "primary": { "value": "#0066FF" }\n }\n}' },
{ label: 'Flat CSS Variables', desc: 'All keys start with --', example: '{\n "--color-primary": "#0066FF",\n "--spacing-md": "16px"\n}' },
].map(({ label, desc, example }) => (
<div key={label} style={{
background: 'rgba(255,255,255,0.03)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 8,
overflow: 'hidden',
}}>
<div key={label} style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '10px 14px 6px', display: 'flex', gap: 10, alignItems: 'baseline' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', fontWeight: 600 }}>{label}</span>
<span style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: 'rgba(255,255,255,0.3)' }}>{desc}</span>
</div>
<pre style={{
margin: 0, padding: '8px 14px 12px',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: '#7EB8FF',
background: 'rgba(0,0,0,0.2)',
overflow: 'auto',
lineHeight: 1.65,
}}>
<pre style={{ margin: 0, padding: '8px 14px 12px', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#7EB8FF', background: 'rgba(0,0,0,0.2)', overflow: 'auto', lineHeight: 1.65 }}>
{example}
</pre>
</div>
@@ -4,9 +4,132 @@
// Small, reusable input components for the Design Panel sections.
// All accept an `onPatch(property, value)` callback that flows up to
// useCanvas().patchStyleEdit → PATCH_ELEMENT_STYLE → fiber hook.
//
// When the `tokenAware` prop is set to true, NumInput and ColorInput render a
// token-match badge (Phase 6) alongside the input. Clicking the badge opens a
// TokenPicker so the designer can swap to a nearby design token value.
import { useState, useRef } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useCanvas } from '@/store/canvas';
import type { DesignToken, TokenMatch } from '@/store/canvas.types';
// ── Token badge helper ────────────────────────────────────────────────────────
// Lazy-resolves the closest design token for a CSS value and renders a small
// clickable badge. Clicking opens a TokenPicker dropdown for the section input.
// This is extracted so both NumInput and ColorInput can embed it.
function useTokenMatch(
value: string,
propKey: string,
enabled: boolean,
): { match: TokenMatch | null; tokens: DesignToken[] | null; rootFontSizePx: number } {
const { designLanguageTokens, artboardRootFontSize, selectedArtboardId } = useCanvas();
const rootFontSizePx = selectedArtboardId ? (artboardRootFontSize[selectedArtboardId] ?? 16) : 16;
const [match, setMatch] = useState<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 ────────────────────────────────────────────────
@@ -126,6 +249,7 @@ export function NumInput({
inputWidth = 60,
readOnly,
title,
tokenAware = false,
}: {
value: string;
propKey: string;
@@ -133,6 +257,8 @@ export function NumInput({
inputWidth?: number;
readOnly?: boolean;
title?: string;
/** When true, resolves the value against loaded design tokens and shows a badge. */
tokenAware?: boolean;
}) {
const T = useCanvasTheme();
const unit = parseCssUnit(value);
@@ -144,12 +270,14 @@ export function NumInput({
setDraft(parseCssNum(value));
}
const commit = (v: string) => {
const commit = useCallback((v: string) => {
const n = parseFloat(v);
if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
};
}, [onPatch, propKey, unit]);
return (
const { match, tokens, rootFontSizePx } = useTokenMatch(value, propKey, tokenAware && !readOnly);
const input = (
<input
readOnly={readOnly}
title={title}
@@ -173,8 +301,8 @@ export function NumInput({
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: readOnly ? T.bgDeep : T.bgDeep,
border: `1px solid ${T.border}`,
background: T.bgDeep,
border: `1px solid ${match?.exact ? T.accent + '55' : T.border}`,
borderRadius: 4,
color: readOnly ? T.dim : T.fg,
padding: '3px 6px',
@@ -183,9 +311,26 @@ export function NumInput({
textAlign: 'right',
boxSizing: 'border-box',
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 ──────────────────────────────────────────────────────────
@@ -241,10 +386,13 @@ export function ColorInput({
value,
propKey,
onPatch,
tokenAware = false,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
/** When true, resolves the color against loaded design tokens and shows a badge. */
tokenAware?: boolean;
}) {
const T = useCanvasTheme();
const colorRef = useRef<HTMLInputElement>(null);
@@ -256,13 +404,16 @@ export function ColorInput({
setHexDraft((value.startsWith('rgb') ? rgbToHex(value) : value).replace('#', ''));
}
const commitHex = (v: string) => {
const commitHex = useCallback((v: string) => {
const cleaned = v.startsWith('#') ? v : `#${v}`;
onPatch(propKey, cleaned);
};
}, [onPatch, propKey]);
const { match, tokens, rootFontSizePx } = useTokenMatch(value, propKey, tokenAware);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, flex: 1 }}>
{/* Color swatch / native picker */}
<div
title="Pick color"
style={{
@@ -280,6 +431,8 @@ export function ColorInput({
style={{ position: 'absolute', inset: 0, opacity: 0, cursor: 'pointer', width: '100%', height: '100%' }}
/>
</div>
{/* Hex text input */}
<input
value={hexDraft.toUpperCase()}
maxLength={6}
@@ -294,14 +447,93 @@ export function ColorInput({
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: T.bgDeep,
border: `1px solid ${T.border}`,
border: `1px solid ${match?.exact ? T.accent + '55' : T.border}`,
borderRadius: 4,
color: T.fg,
padding: '3px 6px',
width: 60,
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>
);
}
@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { Badge } from '@fluentui/react-components';
import { useCanvas } from '@/store/canvas';
import { useHistory } from '@/store/history';
@@ -171,377 +171,6 @@ export function Inspector() {
);
}
/* ── Design tab ─────────────────────────────────────────────── */
/** Strip the numeric portion from a CSS value: "320px" → "320", "1.5" → "1.5" */
function parseCssNum(val: string | undefined): string {
if (!val) return '';
const m = val.match(/^(-?[\d.]+)/);
return m?.[1] ?? '';
}
/** Extract the unit suffix: "320px" → "px", "1.5" → "", "14pt" → "pt" */
function parseCssUnit(val: string | undefined): string {
if (!val) return 'px';
const m = val.match(/^-?[\d.]+(.*)$/);
return m?.[1]?.trim() ?? '';
}
/** rgb()/rgba() → #RRGGBB */
function rgbToHex(rgb: string): string {
const m = rgb.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return '#000000';
return '#' + [m[1], m[2], m[3]]
.map(n => parseInt(n ?? '0').toString(16).padStart(2, '0'))
.join('');
}
/** rgba(..., 0.8) → "80" (percent string, no %) */
function rgbaAlpha(val: string): string {
const m = val.match(/rgba?\(\d+,\s*\d+,\s*\d+(?:,\s*([\d.]+))?\)/);
if (!m) return '100';
const a = m[1] !== undefined ? parseFloat(m[1]) : 1;
return String(Math.round(a * 100));
}
/** Is the CSS color transparent / none? */
function isTransparent(val: string | undefined): boolean {
if (!val) return true;
return val === 'transparent' || val === 'rgba(0, 0, 0, 0)';
}
// ── Compact numeric stepper input ────────────────────────────────
function NumInput({
value,
propKey,
onPatch,
inputWidth = 60,
}: {
value: string;
propKey: string;
onPatch: (prop: string, val: string) => void;
inputWidth?: number;
}) {
const T = useCanvasTheme();
const unit = parseCssUnit(value);
const numStr = parseCssNum(value);
const [draft, setDraft] = useState(numStr);
const prevRef = useRef(value);
if (prevRef.current !== value) {
prevRef.current = value;
setDraft(parseCssNum(value));
}
const commit = (v: string) => {
const n = parseFloat(v);
if (!isNaN(n)) onPatch(propKey, `${n}${unit}`);
};
return (
<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 ───────────────────────────────────────────────
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';
// ── Fill Section — Background Color & Opacity ─────────────────────────────────
// Shows fill color, opacity, and a "no fill" empty state.
// ── Fill Section — Full Implementation (spec §5.4) ────────────────────────────
// Multiple fill layers, per-layer type selector (None / Solid / Gradient / Image),
// gradient bar with draggable color stops, linear/radial toggle, angle input,
// and per-layer opacity.
import { useState } from 'react';
import { isTransparent, ColorInput, NumInput, FieldLabel, SectionHeader, HSep } from '../DesignInputs';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useCanvasTheme } from '@/store/canvasTheme';
import { isTransparent, HSep } from '../DesignInputs';
// ── Types ──────────────────────────────────────────────────────────────────────
type FillType = 'none' | 'solid' | 'gradient' | 'image';
type GradientKind = 'linear' | 'radial';
interface GradientStop {
id: string;
color: string; // hex e.g. "#3385ff"
position: number; // 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 {
styles: Record<string, string>;
styles: Record<string, string>;
onPatch: (prop: string, val: string) => void;
}
export function FillSection({ styles, onPatch }: FillSectionProps) {
const [open, setOpen] = useState(true);
const bg = styles['background-color'] ?? '';
const T = useCanvasTheme();
const [open, setOpen] = useState(true);
const [layers, setLayers] = useState<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 (
<>
<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 && (
<div style={{ padding: '0 14px 10px' }}>
{isTransparent(bg) ? (
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ padding: '0 0 6px' }}>
{layers.length === 0 ? (
<div style={{ padding: '0 14px 4px' }}>
<button
onClick={() => onPatch('background-color', '#ffffff')}
onClick={addLayer}
style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
padding: '4px 8px',
background: 'rgba(255,255,255,0.05)',
border: '1px dashed rgba(255,255,255,0.15)',
borderRadius: 4,
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
letterSpacing: '0.06em',
borderRadius: 4, color: 'rgba(255,255,255,0.3)',
cursor: 'pointer', letterSpacing: '0.06em',
}}
>
+ Add fill
</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<ColorInput value={bg} propKey="background-color" onPatch={onPatch} />
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, flexShrink: 0 }}>
<FieldLabel>Opacity</FieldLabel>
<NumInput
value={styles['opacity'] !== undefined ? `${Math.round(parseFloat(styles['opacity'] ?? '1') * 100)}%` : '100%'}
propKey="_opacity"
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>
layers.map(layer => (
<FillLayerRow
key={layer.id}
layer={layer}
onAddStop={(pos) => addStop(layer.id, pos)}
onChange={patch => updateLayer(layer.id, patch)}
onRemove={() => removeLayer(layer.id)}
/>
))
)}
</div>
)}
<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: 'flex', flexDirection: 'column', gap: 2 }}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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>
@@ -106,7 +106,7 @@ export function FrameSection({ styles, onPatch }: FrameSectionProps) {
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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>
@@ -123,7 +123,7 @@ export function LayoutSection({ styles, onPatch }: LayoutSectionProps) {
{/* Gap */}
<div style={{ display: 'flex', gap: 8, marginBottom: 6, alignItems: 'center' }}>
<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>
</>
)}
@@ -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: 'flex', flexDirection: 'column', gap: 2 }}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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>
@@ -45,7 +45,7 @@ export function StrokeSection({ styles, onPatch }: StrokeSectionProps) {
) : (
<>
<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 style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 8px', marginBottom: 6 }}>
<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: 'flex', flexDirection: 'column', gap: 2 }}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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 style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<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>
@@ -83,7 +83,7 @@ export function TypographySection({
{/* Color */}
<div style={{ marginBottom: 6 }}>
<FieldLabel>Color</FieldLabel>
<ColorInput value={styles['color'] ?? '#000000'} propKey="color" onPatch={onPatch} />
<ColorInput value={styles['color'] ?? '#000000'} propKey="color" onPatch={onPatch} tokenAware />
</div>
{/* Decoration + Transform */}
+114 -13
View File
@@ -9,6 +9,11 @@
* prop JSX prop changes in the component call-site .tsx file
* 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"
*/
@@ -34,6 +39,13 @@ export interface GeneratedFileDiff {
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 ─────────────────────────────────────────────────────────────
// Generates a CSS custom-property block showing what changed.
// Virtual filename: derived from the component's call-site or "<component>.css".
@@ -78,15 +90,52 @@ function buildCssDiff(
// ── Strategy: prop ────────────────────────────────────────────────────────────
// Generates a JSX snippet showing style prop changes on the component element.
// This is a simplified representation; Phase 4+ CLI integration will replace
// these with real AST-rewritten patches at the actual call-site.
// When `realSource` is provided (fetched from the CLI indexer), the diff is
// produced against the actual call-site line in the real file. Otherwise falls
// back to a synthetic snippet that approximates the change.
function buildPropDiff(
componentName: string,
callSiteFile: string | undefined,
callSiteLine: number | undefined,
patches: StylePatch[],
realSource?: string,
): GeneratedFileDiff | null {
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
.filter((p) => p.previousValue)
.map((p) => ` ${camelCase(p.property)}: '${p.previousValue}'`)
@@ -115,26 +164,59 @@ function buildPropDiff(
// ── Strategy: tailwind ────────────────────────────────────────────────────────
// Converts CSS property patches into approximate Tailwind utility additions.
// The mapping is heuristic — a real implementation would require a Tailwind
// config lookup via the CLI indexer (Phase 3 integration).
// When `realSource` is provided, injects utility classes at the real call-site
// line. Otherwise uses a synthetic snippet with placeholder base classes.
function buildTailwindDiff(
componentName: string,
callSiteFile: string | undefined,
callSiteLine: number | undefined,
patches: StylePatch[],
realSource?: string,
): GeneratedFileDiff | null {
const virtualName = callSiteFile ?? `${componentName}.tsx`;
const newClasses = patches
.map((p) => cssToTailwindApprox(p.property, p.value))
.filter(Boolean)
.join(' ');
const oldClasses = patches
.filter((p) => p.previousValue)
.map((p) => cssToTailwindApprox(p.property, p.previousValue ?? ''))
.filter(Boolean)
.join(' ');
const newClasses = patches
.map((p) => cssToTailwindApprox(p.property, p.value))
.filter(Boolean)
.join(' ');
// ── Real-source path ───────────────────────────────────────────────────────
if (realSource && callSiteLine !== undefined) {
const lines = realSource.split('\n');
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 oldContents = `<${componentName} className="${[baseClasses, oldClasses].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.
* 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[],
strategy: DiffStrategy,
componentData: FiberNode | null,
): GeneratedFileDiff | null {
fetchFile?: FileFetcher,
): Promise<GeneratedFileDiff | null> {
if (patches.length === 0) return null;
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) {
case 'css':
return buildCssDiff(componentName, callSiteFile, patches);
case 'prop':
return buildPropDiff(componentName, callSiteFile, patches);
return buildPropDiff(componentName, callSiteFile, callSiteLine, patches, realSource);
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 { 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).
// Both forms must be listed so esbuild leaves them as-is whether the
@@ -23,12 +26,31 @@ const NODE_BUILTINS = [
'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 = {
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22',
external: NODE_BUILTINS,
plugins: [html2canvasTextPlugin],
logLevel: 'info',
};
+1
View File
@@ -30,6 +30,7 @@
"@originmain/renderer": "workspace:*",
"@types/node": "^22.0.0",
"esbuild": "^0.28.0",
"html2canvas": "^1.4.1",
"typescript": "^5.5.0"
},
"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')).
// Add __om_isolation__/ to your .gitignore to prevent accidental commits.
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() {
// 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(() => {
if (typeof window === 'undefined') return;
window.__OM_ISO_RENDER__ = function() {
// Force a re-render by dispatching a custom event — the component
// reads window.__OM_ISO_PROPS__ in its own render cycle.
// Make sure the global is initialised before the first render.
window.__OM_ISO_PROPS__ = window.__OM_ISO_PROPS__ ?? {};
// 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
const props = (typeof window !== 'undefined' && window.__OM_ISO_PROPS__) ? window.__OM_ISO_PROPS__ : {};
return <${componentName} {...(props as Record<string, unknown>)} />;
return <${componentName} {...isoProps} />;
}
export default IsolationPage;
+19
View File
@@ -16,6 +16,7 @@ import type { IncomingMessage, ServerResponse,
import type { Socket } from 'node:net';
import { injectFiberHook } from './inject.js';
import { handleIsolationRequest } from './isolation-server.js';
import html2canvasSource from 'html2canvas/dist/html2canvas.min.js';
export interface ProxyOptions {
/** 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.
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) => {
// ── 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 ────────────────────────────
if (clientReq.url?.startsWith('/__om_isolation__')) {
handleIsolationRequest(clientReq, clientRes);
+5 -4
View File
@@ -777,8 +777,9 @@ export function buildProxyFiberHookScript(): string {
}
// ── html2canvas lazy loader ───────────────────────────────────────────────
// html2canvas is not bundled in the fiber hook — inject from CDN on first
// need, caching the Promise so the script tag is added only once.
// The Originmain proxy serves html2canvas at /__om_h2c__.js (embedded at
// 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() {
if (typeof window.html2canvas === 'function') {
return Promise.resolve(window.html2canvas);
@@ -786,9 +787,9 @@ export function buildProxyFiberHookScript(): string {
if (_html2canvasLoading) return _html2canvasLoading;
_html2canvasLoading = new Promise(function(resolve, reject) {
var s = document.createElement('script');
s.src = 'https://unpkg.com/html2canvas@1.4.1/dist/html2canvas.min.js';
s.src = '/__om_h2c__.js';
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);
});
return _html2canvasLoading;