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