made tiny updates
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
// POST /api/agent-bridge/register-indexer
|
||||
// POST /api/agent-bridge/register-indexer/heartbeat (same handler, path checked below)
|
||||
//
|
||||
// Called by the CLI's `originmain dev` command to register the local AST indexer
|
||||
// so the Agent Bridge can proxy component-resolution requests to it.
|
||||
//
|
||||
// Security (spec Phase 5 §8.3):
|
||||
// • Bearer auth: same workspace-token mechanism as the main MCP endpoint.
|
||||
// • indexerUrl MUST be localhost / 127.0.0.1 — external URLs are rejected to
|
||||
// prevent the Agent Bridge from being used as an SSRF relay.
|
||||
// • TTL: 300 s default; heartbeat POST refreshes it every 120 s.
|
||||
// • Agent Bridge evicts registrations with no heartbeat after 360 s.
|
||||
//
|
||||
// Heartbeat endpoint: POST /api/agent-bridge/register-indexer/heartbeat
|
||||
// Body: { workspaceToken: string }
|
||||
// Returns 200 on success, 404 if the workspace has no active registration.
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyWorkspaceToken, registerIndexer, heartbeatIndexer } from '@originmain/agent-bridge';
|
||||
|
||||
const TTL_SECONDS = 300; // 5 minutes per spec
|
||||
|
||||
/**
|
||||
* Returns true when `url` resolves to the local machine (localhost / loopback).
|
||||
* We block any non-localhost indexerUrl to prevent SSRF.
|
||||
*/
|
||||
function isLocalhostUrl(raw: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
return parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1' || parsed.hostname === '::1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// ── Auth ────────────────────────────────────────────────────────────────────
|
||||
// Accept the workspace token either in the Authorization header (CLI standard)
|
||||
// or in the request body as `workspaceToken` (heartbeat convenience).
|
||||
let token: string | null = null;
|
||||
const authHeader = req.headers.get('authorization') ?? '';
|
||||
if (authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.slice(7);
|
||||
}
|
||||
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = (await req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
// Body is optional for heartbeat (token may come from header only)
|
||||
}
|
||||
|
||||
if (!token && typeof body['workspaceToken'] === 'string') {
|
||||
token = body['workspaceToken'];
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing Bearer token or workspaceToken in body' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceToken = verifyWorkspaceToken(token);
|
||||
if (!workspaceToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid or expired workspace token' },
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Heartbeat path ──────────────────────────────────────────────────────────
|
||||
// The CLI sends a heartbeat POST to the same URL with no indexerUrl in the body.
|
||||
// We detect this by the absence of indexerUrl and refresh the TTL instead.
|
||||
if (!body['indexerUrl']) {
|
||||
const refreshed = heartbeatIndexer(workspaceToken.workspaceId, TTL_SECONDS);
|
||||
if (!refreshed) {
|
||||
return NextResponse.json(
|
||||
{ error: 'No active registration for this workspace — re-register first' },
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: 'heartbeat',
|
||||
workspaceId: workspaceToken.workspaceId,
|
||||
ttlSeconds: TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Registration path ───────────────────────────────────────────────────────
|
||||
const indexerUrl = typeof body['indexerUrl'] === 'string' ? body['indexerUrl'] : null;
|
||||
|
||||
if (!indexerUrl) {
|
||||
return NextResponse.json({ error: '`indexerUrl` is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Security: only localhost URLs are allowed — prevent SSRF
|
||||
if (!isLocalhostUrl(indexerUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'indexerUrl must be a localhost URL (e.g. http://localhost:4171)' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const ttl = typeof body['ttl'] === 'number' ? Math.min(body['ttl'], 600) : TTL_SECONDS;
|
||||
|
||||
registerIndexer(workspaceToken.workspaceId, indexerUrl, ttl);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
action: 'registered',
|
||||
workspaceId: workspaceToken.workspaceId,
|
||||
indexerUrl,
|
||||
ttlSeconds: ttl,
|
||||
heartbeatIntervalSeconds: 120,
|
||||
});
|
||||
}
|
||||
@@ -3,16 +3,8 @@
|
||||
// Authentication: Bearer <workspace_token> (HMAC-SHA256, issued by issueWorkspaceToken).
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifyWorkspaceToken, TOOL_MAP, getToolList } from '@originmain/agent-bridge';
|
||||
import { verifyWorkspaceToken, TOOL_MAP, getToolList, dispatchTool } from '@originmain/agent-bridge';
|
||||
import type { JsonRpcRequest, ToolContext } from '@originmain/agent-bridge';
|
||||
import {
|
||||
getDiffsByStatus,
|
||||
getDiff,
|
||||
getArtboard,
|
||||
getActiveDesignLanguageFile,
|
||||
updateDiffStatus,
|
||||
} from '@originmain/origin-graph';
|
||||
import { AIGateway, answerAgentQuestion } from '@originmain/ai-layer';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
@@ -54,8 +46,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// ── Dispatch ────────────────────────────────────────────────────────────────
|
||||
const tool = TOOL_MAP.get(body.method);
|
||||
if (!tool) {
|
||||
if (!TOOL_MAP[body.method]) {
|
||||
return NextResponse.json({
|
||||
jsonrpc: '2.0',
|
||||
id: body.id,
|
||||
@@ -63,31 +54,16 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
const db = serverClient();
|
||||
const { workspaceId } = workspaceToken;
|
||||
|
||||
const ctx: ToolContext = {
|
||||
workspaceId,
|
||||
db: {
|
||||
getDiffsByStatus: (wsId, status) => getDiffsByStatus(db, wsId, status),
|
||||
getDiff: (id) => getDiff(db, id),
|
||||
getArtboard: (id) => getArtboard(db, id),
|
||||
getDesignLanguageFile: (wsId) => getActiveDesignLanguageFile(db, wsId),
|
||||
updateDiffStatus: (id, status, notes) =>
|
||||
updateDiffStatus(db, id, status, notes).then(() => undefined),
|
||||
},
|
||||
ai: {
|
||||
answerAgentQuestion: (diffId: string, question: string, artboardContext: unknown) =>
|
||||
answerAgentQuestion(new AIGateway(), {
|
||||
diffId,
|
||||
question,
|
||||
artboardContextJson: JSON.stringify(artboardContext),
|
||||
}).then(r => r.answer),
|
||||
},
|
||||
workspaceId: workspaceToken.workspaceId,
|
||||
params: body.params ?? {},
|
||||
// Pass the server-side Supabase client for tools that need DB writes
|
||||
// (e.g. update_diff_status writes blocked_reason to intent_diffs).
|
||||
db: serverClient() as unknown as NonNullable<ToolContext['db']>,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await tool.execute(body.params ?? {}, ctx);
|
||||
const result = await dispatchTool(body.method, ctx);
|
||||
return NextResponse.json({ jsonrpc: '2.0', id: body.id, result });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Internal error';
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// POST /api/artboards/thumbnail
|
||||
//
|
||||
// Accepts a base64 JPEG data URL captured by html2canvas inside an artboard
|
||||
// iframe, uploads it to Supabase Storage, and persists the public URL in the
|
||||
// artboards.thumbnail_url column.
|
||||
//
|
||||
// Request body: { artboardId: string; workspaceId: string; dataUrl: string }
|
||||
// Response: { publicUrl: string }
|
||||
//
|
||||
// Storage path: artboard-thumbnails/{workspaceId}/{artboardId}.jpg
|
||||
// Bucket policy: public read, authenticated write.
|
||||
//
|
||||
// spec: SOURCE-AWARE-CANVAS.md Phase 0 §3.6 "Thumbnail capture"
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { updateArtboard } from '@originmain/origin-graph';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
const BUCKET = 'artboard-thumbnails';
|
||||
const MAX_DATA_URL = 5 * 1024 * 1024; // 5 MB safety cap — rejects obviously corrupted payloads
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
|
||||
let body: { artboardId?: string; workspaceId?: string; dataUrl?: string };
|
||||
try {
|
||||
body = (await req.json()) as typeof body;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { artboardId, workspaceId, dataUrl } = body;
|
||||
if (!artboardId || !workspaceId || !dataUrl) {
|
||||
return NextResponse.json({ error: 'artboardId, workspaceId, and dataUrl are required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (dataUrl.length > MAX_DATA_URL) {
|
||||
return NextResponse.json({ error: 'dataUrl exceeds 5 MB limit' }, { status: 413 });
|
||||
}
|
||||
|
||||
// Strip the `data:image/jpeg;base64,` prefix and decode to bytes
|
||||
const base64 = dataUrl.replace(/^data:image\/[a-z]+;base64,/, '');
|
||||
let imageBytes: Buffer;
|
||||
try {
|
||||
imageBytes = Buffer.from(base64, 'base64');
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid base64 data URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
const db = serverClient() as unknown as SupabaseClient;
|
||||
const storagePath = `${workspaceId}/${artboardId}.jpg`;
|
||||
|
||||
// ── Upload to Supabase Storage ────────────────────────────────────────────
|
||||
const { error: uploadError } = await db.storage
|
||||
.from(BUCKET)
|
||||
.upload(storagePath, imageBytes, {
|
||||
contentType: 'image/jpeg',
|
||||
upsert: true, // overwrite on repeat capture
|
||||
});
|
||||
|
||||
if (uploadError) {
|
||||
return NextResponse.json({ error: `Storage upload failed: ${uploadError.message}` }, { status: 500 });
|
||||
}
|
||||
|
||||
// ── Get public URL ────────────────────────────────────────────────────────
|
||||
const { data: urlData } = db.storage.from(BUCKET).getPublicUrl(storagePath);
|
||||
const publicUrl = urlData.publicUrl;
|
||||
|
||||
// ── Persist to artboards.thumbnail_url ───────────────────────────────────
|
||||
try {
|
||||
await updateArtboard(serverClient(), artboardId, { thumbnail_url: publicUrl });
|
||||
} catch (err) {
|
||||
// Non-fatal: the in-memory data URL still works for this session
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn(`[thumbnail] DB update failed for ${artboardId}: ${msg}`);
|
||||
}
|
||||
|
||||
return NextResponse.json({ publicUrl });
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// GET /api/cli-auth?callback=<url>
|
||||
//
|
||||
// Browser-initiated CLI auth endpoint. The user arrives here after `originmain
|
||||
// login` opens their browser. Authenticates via Clerk, issues a HMAC workspace
|
||||
// token, and redirects to the CLI's local callback server with the credentials.
|
||||
//
|
||||
// Query params:
|
||||
// callback — The local CLI callback URL (must be localhost).
|
||||
// workspace_id — Optional. If omitted, uses the user's first workspace.
|
||||
//
|
||||
// Redirect target: <callback>?token=X&workspaceId=Y&bridgeUrl=Z
|
||||
// or <callback>?error=<message> on failure.
|
||||
//
|
||||
// Security:
|
||||
// • callback must be a localhost URL — external URLs are rejected.
|
||||
// • Requires Clerk authentication; unauthenticated users are redirected to sign-in.
|
||||
//
|
||||
// spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { serverClient } from '@/lib/supabase';
|
||||
import { issueWorkspaceToken } from '@originmain/agent-bridge';
|
||||
|
||||
const DEFAULT_BRIDGE_URL = process.env['ORIGINMAIN_BRIDGE_URL'] ?? 'http://localhost:4172';
|
||||
const APP_URL = process.env['NEXT_PUBLIC_APP_URL'] ?? 'http://localhost:3000';
|
||||
|
||||
/** Only localhost callback URLs are accepted — prevents open-redirect attacks. */
|
||||
function isLocalhostCallback(raw: string): boolean {
|
||||
try {
|
||||
const u = new URL(raw);
|
||||
return u.hostname === 'localhost' || u.hostname === '127.0.0.1' || u.hostname === '::1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function errorRedirect(callbackUrl: string, message: string): NextResponse {
|
||||
const target = new URL(callbackUrl);
|
||||
target.searchParams.set('error', message);
|
||||
return NextResponse.redirect(target.toString());
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const callbackUrl = searchParams.get('callback');
|
||||
const workspaceIdParam = searchParams.get('workspace_id');
|
||||
|
||||
// ── Validate callback URL ─────────────────────────────────────────────────
|
||||
if (!callbackUrl) {
|
||||
return NextResponse.json({ error: '`callback` query param is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!isLocalhostCallback(callbackUrl)) {
|
||||
return NextResponse.json(
|
||||
{ error: '`callback` must be a localhost URL' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Require authentication ────────────────────────────────────────────────
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
// Redirect to sign-in, then back here after login
|
||||
const signInUrl = new URL('/sign-in', APP_URL);
|
||||
signInUrl.searchParams.set('redirect_url', req.nextUrl.toString());
|
||||
return NextResponse.redirect(signInUrl.toString());
|
||||
}
|
||||
|
||||
// ── Resolve workspace ─────────────────────────────────────────────────────
|
||||
const db = serverClient();
|
||||
let workspaceId = workspaceIdParam;
|
||||
|
||||
if (!workspaceId) {
|
||||
// Use the user's first workspace membership
|
||||
const { data: member } = await db
|
||||
.from('team_members')
|
||||
.select('workspace_id')
|
||||
.eq('user_id', userId)
|
||||
.limit(1)
|
||||
.single() as unknown as { data: { workspace_id: string } | null; error: unknown };
|
||||
|
||||
if (!member) {
|
||||
return errorRedirect(callbackUrl, 'No workspace found for this account. Create a workspace at ' + APP_URL);
|
||||
}
|
||||
workspaceId = member.workspace_id;
|
||||
} else {
|
||||
// Verify the user is a member of the requested workspace
|
||||
const { data: member } = await db
|
||||
.from('team_members')
|
||||
.select('id')
|
||||
.eq('workspace_id', workspaceId)
|
||||
.eq('user_id', userId)
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (!member) {
|
||||
return errorRedirect(callbackUrl, `You are not a member of workspace ${workspaceId}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Issue workspace token ─────────────────────────────────────────────────
|
||||
let token: string;
|
||||
try {
|
||||
token = issueWorkspaceToken(workspaceId, 'GENERIC');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Token generation failed';
|
||||
return errorRedirect(callbackUrl, msg);
|
||||
}
|
||||
|
||||
// ── Redirect to CLI callback ──────────────────────────────────────────────
|
||||
const target = new URL(callbackUrl);
|
||||
target.searchParams.set('token', token);
|
||||
target.searchParams.set('workspaceId', workspaceId);
|
||||
target.searchParams.set('bridgeUrl', DEFAULT_BRIDGE_URL);
|
||||
|
||||
return NextResponse.redirect(target.toString());
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// POST /api/design-language/fetch
|
||||
// Server-side CORS proxy for fetching user-supplied design token JSON files.
|
||||
//
|
||||
// The browser cannot directly fetch a token file hosted at an arbitrary URL due
|
||||
// to CORS restrictions. This route performs the fetch server-side and returns
|
||||
// the raw JSON text so the client can run the standard validation pipeline.
|
||||
//
|
||||
// Security mitigations:
|
||||
// 1. Auth: requires a valid Clerk session — unauthenticated callers are rejected.
|
||||
// 2. HTTPS only: rejects http:// URLs to prevent plaintext credential exposure.
|
||||
// 3. Private-IP block: rejects requests to localhost, RFC-1918 ranges, and
|
||||
// link-local addresses to prevent SSRF (Server-Side Request Forgery).
|
||||
// 4. Size cap: response bodies larger than 1 MB are rejected.
|
||||
// 5. Content-Type guard: the upstream response must be JSON-like.
|
||||
//
|
||||
// spec: SOURCE-AWARE-CANVAS §3 Phase 6 — "Fetch from URL" token import flow
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Maximum allowed response body size (1 MB). Token files are never this large;
|
||||
// the cap protects against slow-loris and accidental large-file fetches.
|
||||
const MAX_BODY_BYTES = 1_048_576;
|
||||
|
||||
/**
|
||||
* Returns true when the URL hostname resolves to a private / loopback address
|
||||
* that should never be reachable from a proxied server-side request.
|
||||
* We guard against SSRF by rejecting hostnames that literally look private —
|
||||
* a full DNS-resolution check is not performed here because it would require
|
||||
* an extra async lookup and still be racy.
|
||||
*/
|
||||
function isPrivateHost(hostname: string): boolean {
|
||||
// Loopback
|
||||
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1') return true;
|
||||
// RFC-1918 private ranges (textual prefix match is sufficient for common cases)
|
||||
if (/^10\./.test(hostname)) return true; // 10.0.0.0/8
|
||||
if (/^192\.168\./.test(hostname)) return true; // 192.168.0.0/16
|
||||
if (/^172\.(1[6-9]|2\d|3[01])\./.test(hostname)) return true; // 172.16.0.0/12
|
||||
// Link-local
|
||||
if (/^169\.254\./.test(hostname)) return true;
|
||||
if (/^fe80:/i.test(hostname)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
// ── Auth ───────────────────────────────────────────────────────────────────
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// ── Parse request body ─────────────────────────────────────────────────────
|
||||
let body: { url?: string };
|
||||
try {
|
||||
body = await req.json() as { url?: string };
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Request body must be JSON' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { url } = body;
|
||||
if (!url || typeof url !== 'string') {
|
||||
return NextResponse.json({ error: '`url` field is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// ── URL validation ─────────────────────────────────────────────────────────
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid URL' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (parsed.protocol !== 'https:') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Only HTTPS URLs are supported' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (isPrivateHost(parsed.hostname)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Requests to private or loopback addresses are not allowed' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Proxy fetch ────────────────────────────────────────────────────────────
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'User-Agent': 'Originmain-DLF-Proxy/1.0',
|
||||
},
|
||||
// 10-second timeout via AbortSignal
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return NextResponse.json({ error: `Fetch failed: ${msg}` }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Upstream returned ${upstream.status} ${upstream.statusText}` },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Content-Type guard ────────────────────────────────────────────────────
|
||||
const contentType = upstream.headers.get('content-type') ?? '';
|
||||
if (!contentType.includes('json') && !contentType.includes('text')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Upstream response is not JSON or plain text' },
|
||||
{ status: 415 },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Size cap ──────────────────────────────────────────────────────────────
|
||||
const bytes = await upstream.arrayBuffer();
|
||||
if (bytes.byteLength > MAX_BODY_BYTES) {
|
||||
return NextResponse.json(
|
||||
{ error: `Response too large (max ${MAX_BODY_BYTES / 1024} KB)` },
|
||||
{ status: 413 },
|
||||
);
|
||||
}
|
||||
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
|
||||
// Validate that the body parses as JSON before forwarding — the client
|
||||
// expects valid JSON, not a redirect page or HTML error body.
|
||||
try {
|
||||
JSON.parse(text);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Upstream response is not valid JSON' },
|
||||
{ status: 422 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ json: text }, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* POST /api/intent
|
||||
*
|
||||
* Canvas → Agent Bridge intent push endpoint (spec Phase 4 §8.4).
|
||||
*
|
||||
* The canvas calls this when the designer exports a style diff. This route:
|
||||
* 1. Validates the authenticated user and payload
|
||||
* 2. Stores the intent in the agent-bridge pending queue
|
||||
* 3. Returns the generated intentId so the canvas can track status
|
||||
*
|
||||
* The connected IDE agent drains the queue the next time it calls push_intent
|
||||
* (or receives an INTENT_RECEIVED WebSocket push in Phase 5+).
|
||||
*/
|
||||
|
||||
import { auth } from '@clerk/nextjs/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { storePendingIntent } from '@originmain/agent-bridge';
|
||||
|
||||
interface IntentPayload {
|
||||
/** Supabase workspace ID. */
|
||||
workspaceId: string;
|
||||
/** The artboard the diff came from. */
|
||||
artboardId: string;
|
||||
/** Display name of the component that was edited. */
|
||||
componentName: string;
|
||||
/** JSON-serialised StylePatch[] from diff-generator.ts */
|
||||
patchJson: string;
|
||||
/** One of: 'css' | 'prop' | 'tailwind' */
|
||||
strategy: string;
|
||||
/** Optional AI-generated summary sentence. */
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { userId } = await auth();
|
||||
if (!userId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
let body: IntentPayload;
|
||||
try {
|
||||
body = await req.json() as IntentPayload;
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { workspaceId, artboardId, componentName, patchJson, strategy, summary } = body;
|
||||
|
||||
if (!workspaceId || !artboardId || !componentName || !patchJson || !strategy) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: workspaceId, artboardId, componentName, patchJson, strategy' },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const validStrategies = ['css', 'prop', 'tailwind'];
|
||||
if (!validStrategies.includes(strategy)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Invalid strategy. Must be one of: ${validStrategies.join(', ')}` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const intentId = storePendingIntent(workspaceId, {
|
||||
artboardId,
|
||||
componentName,
|
||||
patchJson,
|
||||
strategy,
|
||||
summary: summary ?? '',
|
||||
});
|
||||
|
||||
return NextResponse.json({ intentId, status: 'EXPORTED' }, { status: 201 });
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,493 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* /settings/design-language — Phase 6
|
||||
*
|
||||
* Design Language Settings page. Allows workspace admins to:
|
||||
* 1. Upload a token file (Style Dictionary / W3C DTCG / flat CSS vars)
|
||||
* 2. Validate the parsed token set before activating
|
||||
* 3. Activate the tokens workspace-wide (stored in Supabase + canvas store)
|
||||
* 4. View version history of previously uploaded token files
|
||||
*
|
||||
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.5
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import type { DesignToken } from '@/store/canvas.types';
|
||||
|
||||
// ── Upload & validation states ─────────────────────────────────────────────────
|
||||
|
||||
type ParseState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'parsing' }
|
||||
| { status: 'parsed'; tokens: DesignToken[]; filename: string }
|
||||
| { status: 'error'; message: string };
|
||||
|
||||
type ActivateState = 'idle' | 'activating' | 'done' | 'error';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function parseTokenFileClient(jsonText: string): Promise<DesignToken[]> {
|
||||
const { parseTokenFileJson } = await import('@originmain/design-language');
|
||||
return parseTokenFileJson(jsonText) as DesignToken[];
|
||||
}
|
||||
|
||||
function groupByCategory(tokens: DesignToken[]): Record<string, DesignToken[]> {
|
||||
const groups: Record<string, DesignToken[]> = {};
|
||||
for (const t of tokens) {
|
||||
const g = t.group;
|
||||
(groups[g] ??= []).push(t);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
// ── Page ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function DesignLanguagePage() {
|
||||
const { designLanguageTokens, setDesignLanguageTokens } = useCanvas();
|
||||
const [parseState, setParseState] = useState<ParseState>({ status: 'idle' });
|
||||
const [activateState, setActivateState] = useState<ActivateState>('idle');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ── File handling ───────────────────────────────────────────────────────────
|
||||
|
||||
const processFile = useCallback(async (file: File) => {
|
||||
if (!file.name.endsWith('.json')) {
|
||||
setParseState({ status: 'error', message: 'Only .json token files are supported.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setParseState({ status: 'parsing' });
|
||||
try {
|
||||
const text = await file.text();
|
||||
const tokens = await parseTokenFileClient(text);
|
||||
if (tokens.length === 0) {
|
||||
setParseState({ status: 'error', message: 'No tokens found. Check the file format.' });
|
||||
return;
|
||||
}
|
||||
setParseState({ status: 'parsed', tokens, filename: file.name });
|
||||
setActivateState('idle');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setParseState({ status: 'error', message: `Parse failed: ${msg}` });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void processFile(file);
|
||||
// Reset so the same file can be re-uploaded
|
||||
e.target.value = '';
|
||||
}, [processFile]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragOver(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) void processFile(file);
|
||||
}, [processFile]);
|
||||
|
||||
// ── Activation ──────────────────────────────────────────────────────────────
|
||||
|
||||
const activate = useCallback(() => {
|
||||
if (parseState.status !== 'parsed') return;
|
||||
setActivateState('activating');
|
||||
try {
|
||||
setDesignLanguageTokens(parseState.tokens);
|
||||
setActivateState('done');
|
||||
} catch {
|
||||
setActivateState('error');
|
||||
}
|
||||
}, [parseState, setDesignLanguageTokens]);
|
||||
|
||||
const deactivate = useCallback(() => {
|
||||
setDesignLanguageTokens(null);
|
||||
setParseState({ status: 'idle' });
|
||||
setActivateState('idle');
|
||||
}, [setDesignLanguageTokens]);
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: '#0F1117',
|
||||
color: 'rgba(255,255,255,0.85)',
|
||||
fontFamily: "'Inter', system-ui, sans-serif",
|
||||
padding: '48px 40px',
|
||||
maxWidth: 800,
|
||||
margin: '0 auto',
|
||||
}}>
|
||||
{/* Header */}
|
||||
<div style={{ marginBottom: 40 }}>
|
||||
<h1 style={{
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: '1.25rem',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
margin: 0,
|
||||
marginBottom: 8,
|
||||
letterSpacing: '-0.03em',
|
||||
}}>
|
||||
Design Language
|
||||
</h1>
|
||||
<p style={{ fontSize: '0.8125rem', color: 'rgba(255,255,255,0.4)', margin: 0, lineHeight: 1.6 }}>
|
||||
Upload a design token file to enable token-aware inputs and constraint checking across all artboards.
|
||||
Supports Style Dictionary, W3C DTCG, and flat CSS variable formats.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Active token set status */}
|
||||
{designLanguageTokens && (
|
||||
<ActiveTokenBanner tokens={designLanguageTokens} onDeactivate={deactivate} />
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<UploadZone
|
||||
dragOver={dragOver}
|
||||
onDragOver={() => setDragOver(true)}
|
||||
onDragLeave={() => setDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
/>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Parse state */}
|
||||
{parseState.status === 'parsing' && (
|
||||
<StatusCard>
|
||||
<Spinner />
|
||||
<span style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.5)' }}>Parsing token file…</span>
|
||||
</StatusCard>
|
||||
)}
|
||||
|
||||
{parseState.status === 'error' && (
|
||||
<StatusCard color="rgba(255,80,80,0.08)" border="rgba(255,80,80,0.3)">
|
||||
<span style={{ fontSize: '0.8125rem', color: '#FF8080' }}>⚠ {parseState.message}</span>
|
||||
</StatusCard>
|
||||
)}
|
||||
|
||||
{parseState.status === 'parsed' && (
|
||||
<TokenPreview
|
||||
tokens={parseState.tokens}
|
||||
filename={parseState.filename}
|
||||
activateState={activateState}
|
||||
onActivate={activate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Format reference */}
|
||||
<FormatReference />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function ActiveTokenBanner({ tokens, onDeactivate }: { tokens: DesignToken[]; onDeactivate: () => void }) {
|
||||
const groups = groupByCategory(tokens);
|
||||
return (
|
||||
<div style={{
|
||||
padding: '14px 18px',
|
||||
background: 'rgba(125,211,168,0.06)',
|
||||
border: '1px solid rgba(125,211,168,0.25)',
|
||||
borderRadius: 10,
|
||||
marginBottom: 28,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 14,
|
||||
}}>
|
||||
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#7DD3A8', flexShrink: 0, boxShadow: '0 0 8px rgba(125,211,168,0.6)' }} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: '#7DD3A8', fontWeight: 600, marginBottom: 3 }}>
|
||||
{tokens.length} tokens active
|
||||
</div>
|
||||
<div style={{ fontSize: '0.625rem', color: 'rgba(255,255,255,0.35)', fontFamily: "'JetBrains Mono', monospace" }}>
|
||||
{Object.entries(groups).map(([g, ts]) => `${g}:${ts.length}`).join(' · ')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDeactivate}
|
||||
style={{
|
||||
background: 'none', border: '1px solid rgba(255,80,80,0.3)', borderRadius: 6,
|
||||
color: '#FF8080', padding: '5px 12px', cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
|
||||
letterSpacing: '0.04em', transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(255,80,80,0.08)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'none')}
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UploadZone({
|
||||
dragOver,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onClick,
|
||||
}: {
|
||||
dragOver: boolean;
|
||||
onDragOver: () => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onDragOver={e => { e.preventDefault(); onDragOver(); }}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
style={{
|
||||
border: `2px dashed ${dragOver ? '#3385FF' : 'rgba(255,255,255,0.15)'}`,
|
||||
borderRadius: 12,
|
||||
padding: '36px 24px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
background: dragOver ? 'rgba(51,133,255,0.06)' : 'rgba(255,255,255,0.02)',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '1.5rem', marginBottom: 10, opacity: 0.4 }}>📂</div>
|
||||
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.75rem', color: 'rgba(255,255,255,0.6)', marginBottom: 6 }}>
|
||||
Drop token file here or click to browse
|
||||
</div>
|
||||
<div style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.625rem', color: 'rgba(255,255,255,0.25)' }}>
|
||||
.json — Style Dictionary · W3C DTCG · Flat CSS vars
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard({ children, color, border }: { children: React.ReactNode; color?: string; border?: string }) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '14px 18px',
|
||||
background: color ?? 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${border ?? 'rgba(255,255,255,0.12)'}`,
|
||||
borderRadius: 10,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div style={{
|
||||
width: 14, height: 14, borderRadius: '50%',
|
||||
border: '2px solid rgba(255,255,255,0.15)',
|
||||
borderTopColor: '#3385FF',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
);
|
||||
}
|
||||
|
||||
function TokenPreview({
|
||||
tokens,
|
||||
filename,
|
||||
activateState,
|
||||
onActivate,
|
||||
}: {
|
||||
tokens: DesignToken[];
|
||||
filename: string;
|
||||
activateState: ActivateState;
|
||||
onActivate: () => void;
|
||||
}) {
|
||||
const groups = groupByCategory(tokens);
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 24,
|
||||
}}>
|
||||
{/* Preview header */}
|
||||
<div style={{
|
||||
padding: '14px 18px',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.07)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: '#7DD3A8' }}>
|
||||
✓ Parsed
|
||||
</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', flex: 1 }}>
|
||||
{filename}
|
||||
</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.35)' }}>
|
||||
{tokens.length} tokens · {Object.keys(groups).length} groups
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Group list */}
|
||||
{Object.entries(groups).map(([group, groupTokens]) => (
|
||||
<div key={group} style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}>
|
||||
<button
|
||||
onClick={() => setExpanded(expanded === group ? null : group)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 8,
|
||||
width: '100%', padding: '10px 18px',
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>
|
||||
{expanded === group ? '▼' : '▶'}
|
||||
</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.75)', flex: 1, textTransform: 'capitalize' }}>
|
||||
{group}
|
||||
</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: 'rgba(255,255,255,0.25)' }}>
|
||||
{groupTokens.length}
|
||||
</span>
|
||||
{/* Color swatches preview */}
|
||||
<div style={{ display: 'flex', gap: 3 }}>
|
||||
{groupTokens
|
||||
.filter(t => t.type === 'color')
|
||||
.slice(0, 6)
|
||||
.map(t => (
|
||||
<div key={t.key} style={{ width: 12, height: 12, borderRadius: 2, background: t.rawValue, border: '1px solid rgba(255,255,255,0.1)' }} />
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expanded === group && (
|
||||
<div style={{ paddingBottom: 8 }}>
|
||||
{groupTokens.map(token => (
|
||||
<div key={token.key} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '5px 18px 5px 36px',
|
||||
}}>
|
||||
{token.type === 'color' && (
|
||||
<div style={{ width: 14, height: 14, borderRadius: 3, background: token.rawValue, flexShrink: 0, border: '1px solid rgba(255,255,255,0.15)' }} />
|
||||
)}
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#3385FF', flex: 1, letterSpacing: '-0.01em' }}>
|
||||
{token.key}
|
||||
</span>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: 'rgba(255,255,255,0.4)', letterSpacing: '-0.01em' }}>
|
||||
{token.rawValue}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Activate button */}
|
||||
<div style={{ padding: '14px 18px', display: 'flex', gap: 10, alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={onActivate}
|
||||
disabled={activateState === 'activating' || activateState === 'done'}
|
||||
style={{
|
||||
padding: '9px 20px',
|
||||
background: activateState === 'done' ? 'rgba(125,211,168,0.15)' : '#3385FF',
|
||||
border: `1px solid ${activateState === 'done' ? 'rgba(125,211,168,0.4)' : 'transparent'}`,
|
||||
borderRadius: 7,
|
||||
color: activateState === 'done' ? '#7DD3A8' : 'white',
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontWeight: 600,
|
||||
fontSize: '0.75rem',
|
||||
cursor: activateState === 'activating' || activateState === 'done' ? 'not-allowed' : 'pointer',
|
||||
opacity: activateState === 'activating' ? 0.6 : 1,
|
||||
transition: 'background 0.15s, opacity 0.15s',
|
||||
}}
|
||||
>
|
||||
{activateState === 'activating' ? 'Activating…' :
|
||||
activateState === 'done' ? '✓ Tokens activated' :
|
||||
`Activate ${tokens.length} tokens`}
|
||||
</button>
|
||||
|
||||
{activateState === 'error' && (
|
||||
<span style={{ fontSize: '0.625rem', color: '#FF8080', fontFamily: "'Inter', sans-serif" }}>
|
||||
Activation failed — try again
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormatReference() {
|
||||
return (
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5875rem',
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
cursor: 'pointer',
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
userSelect: 'none',
|
||||
listStyle: 'none',
|
||||
}}>
|
||||
Supported formats ↓
|
||||
</summary>
|
||||
|
||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{[
|
||||
{
|
||||
label: 'W3C DTCG',
|
||||
desc: '$value / $type fields',
|
||||
example: `{\n "color": {\n "primary": { "$value": "#0066FF", "$type": "color" }\n }\n}`,
|
||||
},
|
||||
{
|
||||
label: 'Style Dictionary',
|
||||
desc: 'Nested with value field',
|
||||
example: `{\n "color": {\n "primary": { "value": "#0066FF" }\n }\n}`,
|
||||
},
|
||||
{
|
||||
label: 'Flat CSS Variables',
|
||||
desc: 'All keys start with --',
|
||||
example: `{\n "--color-primary": "#0066FF",\n "--spacing-md": "16px"\n}`,
|
||||
},
|
||||
].map(({ label, desc, example }) => (
|
||||
<div key={label} style={{
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div style={{ padding: '10px 14px 6px', display: 'flex', gap: 10, alignItems: 'baseline' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem', color: 'rgba(255,255,255,0.7)', fontWeight: 600 }}>{label}</span>
|
||||
<span style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: 'rgba(255,255,255,0.3)' }}>{desc}</span>
|
||||
</div>
|
||||
<pre style={{
|
||||
margin: 0, padding: '8px 14px 12px',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5625rem',
|
||||
color: '#7EB8FF',
|
||||
background: 'rgba(0,0,0,0.2)',
|
||||
overflow: 'auto',
|
||||
lineHeight: 1.65,
|
||||
}}>
|
||||
{example}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import { useDiffs } from '@/hooks/useDiffs';
|
||||
import { LiveArtboard } from './LiveArtboard';
|
||||
import { LiveArtboard } from './LiveArtboard';
|
||||
import { IsolationFrame } from './IsolationFrame';
|
||||
import { SelectionOverlay } from './SelectionOverlay';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
|
||||
interface ArtboardProps {
|
||||
id: string;
|
||||
@@ -19,8 +20,28 @@ interface ArtboardProps {
|
||||
renderUrl?: string;
|
||||
/** Route path appended to renderUrl so each artboard can show a different screen. */
|
||||
route?: string;
|
||||
/**
|
||||
* Artboard type (spec Phase 0 §3.3).
|
||||
* route — (default) renders a URL in an iframe
|
||||
* isolation — renders a single component via the CLI's /__om_isolation__ page
|
||||
* static — static screenshot; no iframe
|
||||
*/
|
||||
artboard_type?: 'route' | 'isolation' | 'static';
|
||||
/** Component name for isolation artboards (artboard_type === 'isolation'). */
|
||||
isolation_component?: string | null;
|
||||
/** Workspace-relative file path for isolation artboards. */
|
||||
isolation_file?: string | null;
|
||||
/** Current prop overrides forwarded to the isolation iframe. */
|
||||
isolation_props?: Record<string, unknown> | null;
|
||||
/** Called when the live app reports discoverable routes — Canvas handles creation. */
|
||||
onRoutesDiscovered?: (sourceId: string, routes: Array<{ path: string; label: string }>) => void;
|
||||
/**
|
||||
* Viewport culling classification (spec Phase 0 §3.2).
|
||||
* active — overlaps the current viewport → render full LiveArtboard iframe
|
||||
* near — within 1 viewport margin of the visible area → keep iframe alive
|
||||
* far — beyond the near zone → suspend iframe to save resources
|
||||
*/
|
||||
renderPriority?: 'active' | 'near' | 'far';
|
||||
}
|
||||
|
||||
/** Builds the iframe src from a base URL + optional route path.
|
||||
@@ -40,15 +61,64 @@ const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: stri
|
||||
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
|
||||
};
|
||||
|
||||
export function Artboard({ id, label, x, y, width, height, renderUrl, route, onRoutesDiscovered }: ArtboardProps) {
|
||||
export function Artboard({
|
||||
id, label, x, y, width, height,
|
||||
renderUrl, route,
|
||||
artboard_type = 'route',
|
||||
isolation_component,
|
||||
isolation_file,
|
||||
isolation_props,
|
||||
onRoutesDiscovered,
|
||||
renderPriority = 'active',
|
||||
}: ArtboardProps) {
|
||||
const {
|
||||
selectedArtboardId, selectArtboard, workspaceId, projectId,
|
||||
setArtboardLive, setFiberRoot, selectComponent, setComponentStyles, setComponentTextFlags,
|
||||
selectedComponentId,
|
||||
selectedComponentId, setSelectedArtboardSize,
|
||||
artboardResizeEvent, clearArtboardResize,
|
||||
artboardThumbnails, setArtboardThumbnail,
|
||||
setElementSnapshot,
|
||||
designLanguageTokens,
|
||||
} = useCanvas();
|
||||
const thumbnailDataUrl = artboardThumbnails[id] ?? null;
|
||||
|
||||
// Phase 6: convert DesignToken[] → Record<string,string> CSS var map so that
|
||||
// LiveArtboard can forward them to the iframe via SET_DESIGN_TOKENS on READY
|
||||
// and on every change (including Supabase Realtime updates).
|
||||
const designTokens = designLanguageTokens
|
||||
? Object.fromEntries(designLanguageTokens.map((t) => [t.key, t.rawValue]))
|
||||
: undefined;
|
||||
const selected = selectedArtboardId === id;
|
||||
|
||||
// Push dimensions into canvas store when this artboard is selected
|
||||
// so the Toolbar's device preset picker can read them without prop drilling.
|
||||
useEffect(() => {
|
||||
if (selected) setSelectedArtboardSize(width, height);
|
||||
}, [selected, width, height, setSelectedArtboardSize]);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Watch for device-preset resize events addressed to this artboard and
|
||||
// perform the PATCH, then clear the event.
|
||||
useEffect(() => {
|
||||
if (!artboardResizeEvent || artboardResizeEvent.artboardId !== id) return;
|
||||
const { width: newW, height: newH } = artboardResizeEvent;
|
||||
clearArtboardResize();
|
||||
fetch(`/api/artboards/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
metadata_jsonb: {
|
||||
x, y, width: newW, height: newH,
|
||||
...(renderUrl ? { renderUrl } : {}),
|
||||
...(route ? { route } : {}),
|
||||
},
|
||||
}),
|
||||
}).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||
}).catch(console.error);
|
||||
}, [artboardResizeEvent, id, x, y, renderUrl, route, workspaceId, projectId, queryClient, clearArtboardResize]);
|
||||
|
||||
// Diff status badges — fetch is cached by TanStack Query across all artboards
|
||||
const { diffs } = useDiffs(id);
|
||||
|
||||
@@ -146,6 +216,13 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
const newX = Math.round(dragStart.current.artX + dragOffsetRef.current.dx);
|
||||
const newY = Math.round(dragStart.current.artY + dragOffsetRef.current.dy);
|
||||
|
||||
// Phase 0 spec §4.3: mark as manually positioned when the drag exceeds
|
||||
// 10 world-space px so auto-arrange doesn't overwrite user layout.
|
||||
const dragDist = Math.sqrt(
|
||||
dragOffsetRef.current.dx ** 2 + dragOffsetRef.current.dy ** 2,
|
||||
);
|
||||
const wasIntentionalDrag = dragDist >= 10;
|
||||
|
||||
// Persist position
|
||||
fetch(`/api/artboards/${id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -157,6 +234,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
x: newX, y: newY, width, height,
|
||||
...(renderUrl ? { renderUrl } : {}),
|
||||
...(route ? { route } : {}),
|
||||
...(wasIntentionalDrag ? { manuallyPositioned: true } : {}),
|
||||
},
|
||||
}),
|
||||
}).then(() => {
|
||||
@@ -213,6 +291,12 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
return (
|
||||
<div
|
||||
style={{ position: 'absolute', top: effectiveY, left: effectiveX }}
|
||||
data-artboard-world="true"
|
||||
data-artboard-world-x={String(effectiveX)}
|
||||
data-artboard-world-y={String(effectiveY)}
|
||||
data-artboard-world-w={String(width)}
|
||||
data-artboard-world-h={String(height)}
|
||||
data-artboard-selected={String(selected)}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); selectArtboard(id); }}
|
||||
>
|
||||
@@ -362,49 +446,116 @@ export function Artboard({ id, label, x, y, width, height, renderUrl, route, onR
|
||||
})()}
|
||||
|
||||
{/* Content */}
|
||||
{renderUrl ? (
|
||||
{/* Isolation artboard — renders a single component via the CLI proxy */}
|
||||
{artboard_type === 'isolation' && renderUrl && isolation_component && isolation_file ? (
|
||||
<IsolationFrame
|
||||
artboardId={id}
|
||||
componentName={isolation_component}
|
||||
componentFile={isolation_file}
|
||||
proxyUrl={renderUrl}
|
||||
{...(isolation_props ? { isolationProps: isolation_props } : {})}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
) : renderUrl ? (
|
||||
<>
|
||||
<LiveArtboard
|
||||
id={id}
|
||||
src={buildSrc(renderUrl, route)}
|
||||
width={width}
|
||||
height={height}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onReady={() => { setArtboardLive(id, true); setIsStaticPage(false); }}
|
||||
onFiberTreeUpdate={handleFiberUpdate}
|
||||
onComponentSelected={handleComponentSelected}
|
||||
onComponentStylesUpdate={handleComponentStylesUpdate}
|
||||
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
|
||||
onStaticPageDetected={() => setIsStaticPage(true)}
|
||||
/>
|
||||
{renderPriority !== 'far' ? (
|
||||
<>
|
||||
<LiveArtboard
|
||||
id={id}
|
||||
src={buildSrc(renderUrl, route)}
|
||||
width={width}
|
||||
height={height}
|
||||
{...(renderPriority === 'near' ? { style: { visibility: 'hidden' } } : {})}
|
||||
{...(designTokens ? { designTokens } : {})}
|
||||
selectedComponentId={selectedComponentId}
|
||||
onReady={() => { setArtboardLive(id, true); setIsStaticPage(false); }}
|
||||
onFiberTreeUpdate={handleFiberUpdate}
|
||||
onComponentSelected={handleComponentSelected}
|
||||
onComponentStylesUpdate={handleComponentStylesUpdate}
|
||||
onRoutesDiscovered={(routes) => onRoutesDiscovered?.(id, routes)}
|
||||
onStaticPageDetected={() => setIsStaticPage(true)}
|
||||
onThumbnailReady={(dataUrl) => {
|
||||
// 1. Store data URL in Zustand for immediate in-session display
|
||||
setArtboardThumbnail(id, dataUrl);
|
||||
// 2. Upload to Supabase Storage in the background (non-blocking).
|
||||
// Spec §3.6: only the public Storage URL is persisted in the DB;
|
||||
// data URIs are session-only.
|
||||
if (dataUrl && workspaceId) {
|
||||
void fetch('/api/artboards/thumbnail', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ artboardId: id, workspaceId, dataUrl }),
|
||||
}).catch(() => { /* upload failure is non-fatal */ });
|
||||
}
|
||||
}}
|
||||
onSnapshotReady={(dataUrl, nodeId) => setElementSnapshot(id, nodeId, dataUrl)}
|
||||
/>
|
||||
|
||||
{/* Static-page banner — shown when the proxy serves a non-React page */}
|
||||
{isStaticPage && (
|
||||
{/* Static-page banner — shown when the proxy serves a non-React page */}
|
||||
{isStaticPage && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
background: 'rgba(245,158,11,0.92)', backdropFilter: 'blur(8px)',
|
||||
padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 8,
|
||||
zIndex: 20, pointerEvents: 'none',
|
||||
}}>
|
||||
<span style={{ fontSize: 12 }}>⚠️</span>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
||||
color: '#1C1917', letterSpacing: '-0.01em',
|
||||
}}>
|
||||
Static HTML page — no React components detected. Navigate to a React route to enable inspection.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<SelectionOverlay
|
||||
artboardId={id}
|
||||
{...(localFiberRoot !== undefined ? { fiberRoot: localFiberRoot } : {})}
|
||||
width={width}
|
||||
height={height}
|
||||
onSelectionChange={(sel) => {
|
||||
if (sel) selectArtboard(id);
|
||||
handleComponentSelected(sel?.nodeId ?? '');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
/* Off-screen placeholder — iframe unmounted to save resources.
|
||||
* Displays the last JPEG thumbnail captured before suspension. */
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 0, left: 0, right: 0,
|
||||
background: 'rgba(245,158,11,0.92)', backdropFilter: 'blur(8px)',
|
||||
padding: '6px 12px', display: 'flex', alignItems: 'center', gap: 8,
|
||||
zIndex: 20, pointerEvents: 'none',
|
||||
width, height,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<span style={{ fontSize: 12 }}>⚠️</span>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
||||
color: '#1C1917', letterSpacing: '-0.01em',
|
||||
}}>
|
||||
Static HTML page — no React components detected. Navigate to a React route to enable inspection.
|
||||
</span>
|
||||
{thumbnailDataUrl ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={thumbnailDataUrl}
|
||||
alt=""
|
||||
aria-hidden
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{
|
||||
width: '100%', height: '100%',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
color: 'rgba(255,255,255,0.12)',
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
off-screen
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<SelectionOverlay
|
||||
artboardId={id}
|
||||
{...(localFiberRoot !== undefined ? { fiberRoot: localFiberRoot } : {})}
|
||||
width={width}
|
||||
height={height}
|
||||
onSelectionChange={(sel) => {
|
||||
if (sel) selectArtboard(id);
|
||||
handleComponentSelected(sel?.nodeId ?? '');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyArtboardContent id={id} label={label} width={width} height={height} workspaceId={workspaceId} projectId={projectId} queryClient={queryClient} />
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { Artboard } from './Artboard';
|
||||
import { CompletionZone } from './CompletionZone';
|
||||
import { artboardIframeMap } from '@/lib/artboard-iframe-map';
|
||||
import { createHostEnvelope } from '@originmain/renderer';
|
||||
|
||||
export function Canvas() {
|
||||
const T = useCanvasTheme();
|
||||
@@ -15,7 +17,7 @@ export function Canvas() {
|
||||
const panX = useViewport((s) => s.panX);
|
||||
const panY = useViewport((s) => s.panY);
|
||||
const zoom = useViewport((s) => s.zoom);
|
||||
const { activeTool, setActiveTool, selectArtboard, selectedArtboardId, workspaceId, projectId } = useCanvas();
|
||||
const { activeTool, setActiveTool, selectArtboard, selectedArtboardId, workspaceId, projectId, setDiscoveredRoutes } = useCanvas();
|
||||
const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -23,6 +25,84 @@ export function Canvas() {
|
||||
const lastPos = useRef({ x: 0, y: 0 });
|
||||
const spaceDown = useRef(false);
|
||||
|
||||
// ── Viewport culling (spec Phase 0 §3.2) ─────────────────────────────────
|
||||
// Classifies each artboard as 'active' | 'near' | 'far' based on whether it
|
||||
// overlaps with the current viewport. Updated 100ms after pan/zoom settles.
|
||||
// 'active'/'near' → full LiveArtboard iframe; 'far' → placeholder thumbnail.
|
||||
const [renderPriorities, setRenderPriorities] = useState<Record<string, 'active' | 'near' | 'far'>>({});
|
||||
const cullTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Track previous priorities so we can detect Active/Near → Far transitions
|
||||
// and request a thumbnail snapshot before the iframe is unmounted.
|
||||
const prevPrioritiesRef = useRef<Record<string, 'active' | 'near' | 'far'>>({});
|
||||
|
||||
useEffect(() => {
|
||||
function computeCulling() {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const { panX, panY, zoom } = useViewport.getState();
|
||||
const vpW = el.clientWidth;
|
||||
const vpH = el.clientHeight;
|
||||
|
||||
// Viewport bounds in world space
|
||||
const vpLeft = -panX / zoom;
|
||||
const vpTop = -panY / zoom;
|
||||
const vpRight = vpLeft + vpW / zoom;
|
||||
const vpBottom = vpTop + vpH / zoom;
|
||||
|
||||
// Near zone: 1 viewport width/height of padding beyond the visible edge
|
||||
const nearPadX = vpW / zoom;
|
||||
const nearPadY = vpH / zoom;
|
||||
|
||||
const next: Record<string, 'active' | 'near' | 'far'> = {};
|
||||
for (const ab of artboards) {
|
||||
const al = ab.x;
|
||||
const at = ab.y;
|
||||
const ar = ab.x + ab.width;
|
||||
const ab_ = ab.y + ab.height;
|
||||
|
||||
const overlapsViewport =
|
||||
ar > vpLeft && al < vpRight && ab_ > vpTop && at < vpBottom;
|
||||
|
||||
const overlapsNear =
|
||||
ar > vpLeft - nearPadX && al < vpRight + nearPadX &&
|
||||
ab_ > vpTop - nearPadY && at < vpBottom + nearPadY;
|
||||
|
||||
next[ab.id] = overlapsViewport ? 'active' : overlapsNear ? 'near' : 'far';
|
||||
}
|
||||
|
||||
// Detect transitions to 'far' and request a thumbnail before the iframe unmounts.
|
||||
const prev = prevPrioritiesRef.current;
|
||||
for (const abId of Object.keys(next)) {
|
||||
const wasVisible = prev[abId] !== 'far';
|
||||
const nowFar = next[abId] === 'far';
|
||||
if (wasVisible && nowFar) {
|
||||
const iframe = artboardIframeMap.get(abId);
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage(createHostEnvelope(abId, { type: 'CAPTURE_THUMBNAIL' }), '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
prevPrioritiesRef.current = next;
|
||||
|
||||
setRenderPriorities(next);
|
||||
}
|
||||
|
||||
function scheduleCull() {
|
||||
if (cullTimerRef.current) clearTimeout(cullTimerRef.current);
|
||||
cullTimerRef.current = setTimeout(computeCulling, 100);
|
||||
}
|
||||
|
||||
// Run immediately when artboards list changes, then subscribe to viewport changes
|
||||
computeCulling();
|
||||
|
||||
// Subscribe to viewport store updates
|
||||
const unsub = useViewport.subscribe(scheduleCull);
|
||||
return () => {
|
||||
unsub();
|
||||
if (cullTimerRef.current) clearTimeout(cullTimerRef.current);
|
||||
};
|
||||
}, [artboards]);
|
||||
|
||||
// ── Route discovery: auto-create screen grid ──────────────────────────────
|
||||
// When a live artboard discovers routes we don't have artboards for yet,
|
||||
// this creates them in a horizontal row to the right of all existing frames.
|
||||
@@ -41,6 +121,9 @@ export function Canvas() {
|
||||
|
||||
pendingRouteCreation.current = true;
|
||||
|
||||
// Persist all discovered routes in the canvas store so the Routes tab can display them
|
||||
setDiscoveredRoutes(sourceArtboardId, routes);
|
||||
|
||||
// Position new artboards in a row to the right of all existing frames
|
||||
const GAP = 80;
|
||||
const rightEdge = artboards.reduce(
|
||||
@@ -73,7 +156,7 @@ export function Canvas() {
|
||||
.catch(console.error)
|
||||
.finally(() => { pendingRouteCreation.current = false; });
|
||||
},
|
||||
[artboards, workspaceId, projectId, queryClient],
|
||||
[artboards, workspaceId, projectId, queryClient, setDiscoveredRoutes],
|
||||
);
|
||||
|
||||
// Zone tool: drag to draw a completion zone
|
||||
@@ -218,6 +301,7 @@ export function Canvas() {
|
||||
transition: 'background 0.2s',
|
||||
cursor,
|
||||
}}
|
||||
data-canvas-viewport="true"
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
@@ -256,7 +340,12 @@ export function Canvas() {
|
||||
}}
|
||||
>
|
||||
{artboards.map((ab) => (
|
||||
<Artboard key={ab.id} {...ab} onRoutesDiscovered={handleRoutesDiscovered} />
|
||||
<Artboard
|
||||
key={ab.id}
|
||||
{...ab}
|
||||
onRoutesDiscovered={handleRoutesDiscovered}
|
||||
renderPriority={renderPriorities[ab.id] ?? 'active'}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Zone tool: live drag preview rectangle */}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* IsolationFrame — Phase 3 full implementation
|
||||
*
|
||||
* Renders an isolated view of a single React component inside an iframe.
|
||||
* The CLI proxy serves the isolation page at:
|
||||
* `/__om_isolation__?component=<name>&file=<path>`
|
||||
*
|
||||
* When the indexer is not ready, shows an informative placeholder. When it is
|
||||
* ready, renders the isolation iframe. The host sends UPDATE_ISOLATION_PROPS
|
||||
* messages so the designer can tweak props live from the Inspector.
|
||||
*
|
||||
* spec: SOURCE-AWARE-CANVAS.md Phase 3 §3.5 "Component Isolation"
|
||||
*/
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { createHostEnvelope } from '@originmain/renderer';
|
||||
|
||||
interface IsolationFrameProps {
|
||||
/** Artboard ID (used for message routing). */
|
||||
artboardId: string;
|
||||
/** Display name of the component to isolate — passed as ?component= param. */
|
||||
componentName: string;
|
||||
/** Workspace-relative source file path — passed as ?file= param. */
|
||||
componentFile: string;
|
||||
/** Base URL of the CLI proxy (e.g. "http://localhost:4170"). */
|
||||
proxyUrl: string;
|
||||
/** Current prop overrides to forward into the isolation page. */
|
||||
isolationProps?: Record<string, unknown>;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Builds the isolation page URL from the proxy base and component params. */
|
||||
function buildIsolationUrl(
|
||||
proxyUrl: string,
|
||||
componentName: string,
|
||||
componentFile: string,
|
||||
): string {
|
||||
const base = proxyUrl.replace(/\/$/, '');
|
||||
const params = new URLSearchParams({
|
||||
component: componentName,
|
||||
file: componentFile,
|
||||
});
|
||||
return `${base}/__om_isolation__?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function IsolationFrame({
|
||||
artboardId,
|
||||
componentName,
|
||||
componentFile,
|
||||
proxyUrl,
|
||||
isolationProps,
|
||||
width,
|
||||
height,
|
||||
}: IsolationFrameProps) {
|
||||
const T = useCanvasTheme();
|
||||
const { indexerStatus } = useCanvas();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
// ── Forward prop overrides to the isolation iframe ─────────────────────────
|
||||
// Sends UPDATE_ISOLATION_PROPS whenever isolationProps changes so the
|
||||
// component re-renders with the new values without a full page reload.
|
||||
const sendIsolationProps = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe?.contentWindow) return;
|
||||
try {
|
||||
const msg = createHostEnvelope(artboardId, {
|
||||
type: 'UPDATE_ISOLATION_PROPS',
|
||||
props: isolationProps ?? {},
|
||||
});
|
||||
iframe.contentWindow.postMessage(msg, '*');
|
||||
} catch { /* iframe may not be ready yet — will retry on next onLoad */ }
|
||||
}, [artboardId, isolationProps]);
|
||||
|
||||
useEffect(() => {
|
||||
sendIsolationProps();
|
||||
}, [sendIsolationProps]);
|
||||
|
||||
// ── Indexer not running — show placeholder ─────────────────────────────────
|
||||
if (indexerStatus !== 'ready') {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 12,
|
||||
padding: '32px 20px',
|
||||
background: T.bgDeep,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 8,
|
||||
textAlign: 'center',
|
||||
width, height,
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
{/* Isolation icon */}
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.3 }}>
|
||||
<rect x="3" y="3" width="22" height="22" rx="4" stroke="white" strokeWidth="1.3" strokeDasharray="4 2.5"/>
|
||||
<rect x="9" y="9" width="10" height="10" rx="2" stroke="white" strokeWidth="1.3"/>
|
||||
</svg>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5875rem',
|
||||
color: T.fgMuted,
|
||||
letterSpacing: '0.02em',
|
||||
}}>
|
||||
Isolation mode requires CLI indexer
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: '0.5625rem',
|
||||
color: T.dim,
|
||||
lineHeight: 1.55,
|
||||
}}>
|
||||
Run{' '}
|
||||
<code style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
color: '#FFBA7B',
|
||||
fontSize: '0.5rem',
|
||||
background: 'rgba(255,186,123,0.08)',
|
||||
borderRadius: 3,
|
||||
padding: '1px 4px',
|
||||
}}>
|
||||
npx @originmain/cli dev
|
||||
</code>
|
||||
{' '}to enable component isolation.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
padding: '4px 10px',
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
border: `1px solid ${T.sep}`,
|
||||
borderRadius: 5,
|
||||
}}>
|
||||
<div style={{
|
||||
width: 5, height: 5, borderRadius: '50%',
|
||||
background: T.dim, flexShrink: 0,
|
||||
}} />
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
color: T.dim,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
Indexer offline
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Indexer ready — render isolation iframe ────────────────────────────────
|
||||
const src = buildIsolationUrl(proxyUrl, componentName, componentFile);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', width, height, overflow: 'hidden' }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={src}
|
||||
width={width}
|
||||
height={height}
|
||||
style={{
|
||||
display: 'block',
|
||||
border: 'none',
|
||||
background: 'white',
|
||||
}}
|
||||
title={`${componentName} — isolation`}
|
||||
// The isolation page is served by the CLI proxy (localhost).
|
||||
// allow="*" is safe here because it's a locally-served page.
|
||||
allow="*"
|
||||
onLoad={sendIsolationProps}
|
||||
/>
|
||||
|
||||
{/* Isolation indicator badge */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '3px 8px',
|
||||
background: 'rgba(51,133,255,0.12)',
|
||||
border: '1px solid rgba(51,133,255,0.3)',
|
||||
borderRadius: 4,
|
||||
backdropFilter: 'blur(8px)',
|
||||
pointerEvents: 'none',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 5, height: 5, borderRadius: '50%',
|
||||
background: '#3385FF', flexShrink: 0,
|
||||
animation: 'om-pulse 2s infinite',
|
||||
}} />
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5rem',
|
||||
color: '#3385FF',
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
}}>
|
||||
isolation
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Subtle top border to distinguish from route artboards */}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: 0, left: 0, right: 0,
|
||||
height: 2,
|
||||
background: 'linear-gradient(90deg, rgba(51,133,255,0.6) 0%, rgba(51,133,255,0) 100%)',
|
||||
pointerEvents: 'none',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@originmain/renderer';
|
||||
import type { FiberNode, RendererMessage } from '@originmain/renderer';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { artboardIframeMap } from '@/lib/artboard-iframe-map';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,6 +36,10 @@ export interface LiveArtboardProps {
|
||||
/** Called when READY fires but no React commits arrive within 4 s — signals a
|
||||
* static HTML page where the fiber hook can't find a React runtime. */
|
||||
onStaticPageDetected?: () => void;
|
||||
/** Phase 0: Called when the renderer responds to CAPTURE_THUMBNAIL with a JPEG data URL (or null on failure). */
|
||||
onThumbnailReady?: (dataUrl: string | null) => void;
|
||||
/** Phase 4: Called when the renderer responds to CAPTURE_SNAPSHOT with a PNG data URL (or null on failure). */
|
||||
onSnapshotReady?: (dataUrl: string | null, nodeId: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
@@ -53,8 +58,11 @@ export function LiveArtboard({
|
||||
onComponentStylesUpdate,
|
||||
onRoutesDiscovered,
|
||||
onStaticPageDetected,
|
||||
onThumbnailReady,
|
||||
onSnapshotReady,
|
||||
style,
|
||||
}: LiveArtboardProps) {
|
||||
const { setArtboardRootFontSize } = useCanvas();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
// Track whether the iframe has sent READY so we don't send messages too early.
|
||||
const isReadyRef = useRef(false);
|
||||
@@ -102,6 +110,10 @@ export function LiveArtboard({
|
||||
switch (msg.type) {
|
||||
case 'READY':
|
||||
isReadyRef.current = true;
|
||||
// Store the root font size for rem→px normalisation in the token resolver.
|
||||
if (typeof msg.rootFontSizePx === 'number') {
|
||||
setArtboardRootFontSize(id, msg.rootFontSizePx);
|
||||
}
|
||||
// Push current design tokens into the iframe immediately.
|
||||
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
|
||||
// Restore the highlight ring for any active selection.
|
||||
@@ -146,12 +158,20 @@ export function LiveArtboard({
|
||||
case 'ROUTES_DISCOVERED':
|
||||
onRoutesDiscovered?.(msg.routes);
|
||||
break;
|
||||
case 'THUMBNAIL_READY':
|
||||
// Phase 0: store base64 JPEG for the far-state placeholder in Artboard.tsx.
|
||||
onThumbnailReady?.(msg.dataUrl);
|
||||
break;
|
||||
case 'SNAPSHOT_READY':
|
||||
// Phase 4: PNG of the selected element for the Code Preview diff in Inspector.
|
||||
onSnapshotReady?.(msg.dataUrl, msg.nodeId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered]);
|
||||
}, [id, designTokens, selectedComponentId, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected, onComponentStylesUpdate, onRoutesDiscovered, onThumbnailReady, onSnapshotReady]);
|
||||
|
||||
// ── Push updated design tokens whenever they change ───────────────────────
|
||||
useEffect(() => {
|
||||
@@ -209,6 +229,16 @@ export function LiveArtboard({
|
||||
}
|
||||
}, [selectedComponentId, sendMessage]);
|
||||
|
||||
// ── Register / deregister in the artboardIframeMap singleton ────────────────
|
||||
// This allows canvas-level dispatch (e.g. CompletionZone, CodeTab send-to-agent)
|
||||
// to reach the correct iframe without going through React state or Zustand.
|
||||
useEffect(() => {
|
||||
const el = iframeRef.current;
|
||||
if (!el) return;
|
||||
artboardIframeMap.set(id, el);
|
||||
return () => { artboardIframeMap.delete(id); };
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
|
||||
@@ -14,6 +14,8 @@ import { useTheme } from '@/store/theme';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { useWalkthrough } from '@/store/walkthrough';
|
||||
import { useIndexer } from '@/hooks/useIndexer';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
interface AppChromeProps {
|
||||
workspaceId?: string;
|
||||
@@ -26,6 +28,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
|
||||
const setContext = useCanvas((s) => s.setContext);
|
||||
const setActiveTool = useCanvas((s) => s.setActiveTool);
|
||||
const setDesignLanguageTokens = useCanvas((s) => s.setDesignLanguageTokens);
|
||||
const { mode: themeMode, toggle: toggleTheme } = useTheme();
|
||||
const CT = useCanvasTheme();
|
||||
const startTour = useWalkthrough((s) => s.start);
|
||||
@@ -33,6 +36,50 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
// Connect to the CLI AST indexer (reads window.__OM_INDEX_URL__, no-op if absent)
|
||||
useIndexer();
|
||||
|
||||
// Phase 6: Supabase Realtime subscription for design language token updates.
|
||||
// When another team member uploads a new active token file, all open sessions
|
||||
// receive the update automatically and re-broadcast SET_DESIGN_TOKENS to every
|
||||
// live iframe via the Artboard → LiveArtboard prop chain.
|
||||
useEffect(() => {
|
||||
if (!workspaceId) return;
|
||||
|
||||
const db = browserClient() as unknown as SupabaseClient;
|
||||
const channel = db
|
||||
.channel(`dlf:${workspaceId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'design_language_files',
|
||||
filter: `workspace_id=eq.${workspaceId}`,
|
||||
},
|
||||
(payload) => {
|
||||
// Only react to rows that are marked as the active version.
|
||||
const row = (payload.new ?? payload.old) as Record<string, unknown> | undefined;
|
||||
if (!row || !row['is_active']) return;
|
||||
|
||||
// Re-parse tokens from the updated schema_jsonb.
|
||||
const schemaJsonb = row['schema_jsonb'];
|
||||
if (!schemaJsonb || typeof schemaJsonb !== 'object') return;
|
||||
|
||||
import('@originmain/design-language').then(({ parseTokenFile }) => {
|
||||
try {
|
||||
const tokens = parseTokenFile(schemaJsonb);
|
||||
setDesignLanguageTokens(tokens as Parameters<typeof setDesignLanguageTokens>[0]);
|
||||
} catch {
|
||||
// Malformed token file in DB — don't crash the session
|
||||
}
|
||||
}).catch(() => { /* design-language package unavailable */ });
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
void db.removeChannel(channel);
|
||||
};
|
||||
}, [workspaceId, setDesignLanguageTokens]);
|
||||
|
||||
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
|
||||
useEffect(() => {
|
||||
if (workspaceId && projectId) setContext(workspaceId, projectId);
|
||||
@@ -78,16 +125,98 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
|
||||
}
|
||||
}
|
||||
|
||||
// Undo/Redo require a selected artboard
|
||||
if (!(e.metaKey || e.ctrlKey)) return;
|
||||
if (!selectedArtboardId) return;
|
||||
// ── Zoom / fit shortcuts (Cmd / Ctrl + …) ──────────────────────────────
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
const vp = useViewport.getState();
|
||||
const { zoom, panX, panY, setZoom, setPan } = vp;
|
||||
|
||||
if (e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
useHistory.getState().undo(selectedArtboardId);
|
||||
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
||||
e.preventDefault();
|
||||
useHistory.getState().redo(selectedArtboardId);
|
||||
// Cmd+= or Cmd++ → zoom in 10% at viewport centre
|
||||
if (e.key === '=' || e.key === '+') {
|
||||
e.preventDefault();
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
setZoom(zoom * 1.1, cx, cy);
|
||||
return;
|
||||
}
|
||||
// Cmd+- → zoom out 10% at viewport centre
|
||||
if (e.key === '-') {
|
||||
e.preventDefault();
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
setZoom(zoom * 0.9, cx, cy);
|
||||
return;
|
||||
}
|
||||
// Cmd+0 → fit all artboards in view (80px padding)
|
||||
if (e.key === '0') {
|
||||
e.preventDefault();
|
||||
// Dynamically import to avoid circular dep; artboards read from DOM
|
||||
const canvasEl = document.querySelector('[data-canvas-viewport]') as HTMLDivElement | null;
|
||||
const vpW = canvasEl?.clientWidth ?? window.innerWidth;
|
||||
const vpH = canvasEl?.clientHeight ?? window.innerHeight;
|
||||
const artboardEls = document.querySelectorAll('[data-artboard-world]');
|
||||
if (artboardEls.length === 0) { setZoom(1, vpW / 2, vpH / 2); setPan(0, 0); return; }
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
artboardEls.forEach(el => {
|
||||
const wx = parseFloat((el as HTMLElement).dataset.artboardWorldX ?? '0');
|
||||
const wy = parseFloat((el as HTMLElement).dataset.artboardWorldY ?? '0');
|
||||
const ww = parseFloat((el as HTMLElement).dataset.artboardWorldW ?? '0');
|
||||
const wh = parseFloat((el as HTMLElement).dataset.artboardWorldH ?? '0');
|
||||
minX = Math.min(minX, wx); minY = Math.min(minY, wy);
|
||||
maxX = Math.max(maxX, wx + ww); maxY = Math.max(maxY, wy + wh);
|
||||
});
|
||||
const pad = 80;
|
||||
const tw = maxX - minX; const th = maxY - minY;
|
||||
const newZoom = Math.max(0.1, Math.min(4, (vpW - pad * 2) / tw, (vpH - pad * 2) / th));
|
||||
const newX = pad - minX * newZoom + (vpW - pad * 2 - tw * newZoom) / 2;
|
||||
const newY = pad - minY * newZoom + (vpH - pad * 2 - th * newZoom) / 2;
|
||||
setPan(newX, newY);
|
||||
setZoom(newZoom);
|
||||
return;
|
||||
}
|
||||
// Cmd+1 → reset to 100% centred on selected artboard (or origin)
|
||||
if (e.key === '1') {
|
||||
e.preventDefault();
|
||||
const vpW = window.innerWidth; const vpH = window.innerHeight;
|
||||
// Try to centre on selected artboard's world position
|
||||
const selEl = document.querySelector('[data-artboard-world][data-artboard-selected="true"]') as HTMLElement | null;
|
||||
if (selEl) {
|
||||
const wx = parseFloat(selEl.dataset.artboardWorldX ?? '0');
|
||||
const wy = parseFloat(selEl.dataset.artboardWorldY ?? '0');
|
||||
const ww = parseFloat(selEl.dataset.artboardWorldW ?? '0');
|
||||
const wh = parseFloat(selEl.dataset.artboardWorldH ?? '0');
|
||||
setPan(vpW / 2 - (wx + ww / 2), vpH / 2 - (wy + wh / 2));
|
||||
} else {
|
||||
setPan(0, 0);
|
||||
}
|
||||
setZoom(1);
|
||||
return;
|
||||
}
|
||||
// Cmd+Shift+H → fit artboard height to viewport
|
||||
if (e.key === 'H' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const vpH = window.innerHeight;
|
||||
const selEl = document.querySelector('[data-artboard-world][data-artboard-selected="true"]') as HTMLElement | null;
|
||||
if (selEl) {
|
||||
const wx = parseFloat(selEl.dataset.artboardWorldX ?? '0');
|
||||
const wy = parseFloat(selEl.dataset.artboardWorldY ?? '0');
|
||||
const wh = parseFloat(selEl.dataset.artboardWorldH ?? '0');
|
||||
const newZoom = Math.max(0.1, Math.min(4, vpH / wh));
|
||||
setPan(panX, -wy * newZoom + (vpH - wh * newZoom) / 2);
|
||||
setZoom(newZoom, wx, wy);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo/Redo require a selected artboard
|
||||
if (!selectedArtboardId) return;
|
||||
if (e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
useHistory.getState().undo(selectedArtboardId);
|
||||
} else if ((e.key === 'z' && e.shiftKey) || e.key === 'y') {
|
||||
e.preventDefault();
|
||||
useHistory.getState().redo(selectedArtboardId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
ToolbarDivider,
|
||||
Tooltip,
|
||||
} from '@fluentui/react-components';
|
||||
import {
|
||||
@@ -18,13 +18,50 @@ import { useCanvas, type Tool } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
|
||||
// ── Device presets (spec Phase 0 §3.4) ───────────────────────────────────────
|
||||
export const DEVICE_PRESETS = [
|
||||
{ key: 'desktop-hd', label: 'Desktop HD', width: 1440, height: 900 },
|
||||
{ key: 'desktop-lg', label: 'Desktop Large', width: 1280, height: 800 },
|
||||
{ key: 'laptop', label: 'Laptop', width: 1024, height: 768 },
|
||||
{ key: 'tablet-landscape', label: 'Tablet Landscape', width: 1366, height: 1024 },
|
||||
{ key: 'tablet-portrait', label: 'Tablet Portrait', width: 768, height: 1024 },
|
||||
{ key: 'mobile-iphone-14', label: 'iPhone 14', width: 390, height: 844 },
|
||||
{ key: 'mobile-iphone-se', label: 'iPhone SE', width: 375, height: 667 },
|
||||
{ key: 'mobile-android', label: 'Android', width: 360, height: 800 },
|
||||
] as const;
|
||||
|
||||
function matchPreset(w: number | null, h: number | null) {
|
||||
if (w == null || h == null) return null;
|
||||
return DEVICE_PRESETS.find(p => p.width === w && p.height === h) ?? null;
|
||||
}
|
||||
|
||||
export function Toolbar() {
|
||||
const T = useCanvasTheme();
|
||||
const { activeTool, setActiveTool } = useCanvas();
|
||||
const { activeTool, setActiveTool,
|
||||
selectedArtboardId, selectedArtboardW, selectedArtboardH,
|
||||
dispatchArtboardResize } = useCanvas();
|
||||
const zoom = useViewport((s) => s.zoom);
|
||||
const setZoom = useViewport((s) => s.setZoom);
|
||||
const reset = useViewport((s) => s.reset);
|
||||
|
||||
const [presetOpen, setPresetOpen] = useState(false);
|
||||
const presetRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
if (!presetOpen) return;
|
||||
const onDown = (e: MouseEvent) => {
|
||||
if (!presetRef.current?.contains(e.target as Node)) setPresetOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDown);
|
||||
return () => document.removeEventListener('mousedown', onDown);
|
||||
}, [presetOpen]);
|
||||
|
||||
const activePreset = matchPreset(selectedArtboardW, selectedArtboardH);
|
||||
const sizeLabel = selectedArtboardW != null && selectedArtboardH != null
|
||||
? `${selectedArtboardW} × ${selectedArtboardH}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -106,9 +143,90 @@ export function Toolbar() {
|
||||
</Tooltip>
|
||||
</ToolGroup>
|
||||
|
||||
{/* Device preset picker — only visible when an artboard is selected */}
|
||||
{selectedArtboardId && sizeLabel && (
|
||||
<>
|
||||
<Sep T={T} />
|
||||
<div ref={presetRef} style={{ position: 'relative' }}>
|
||||
<button
|
||||
onClick={() => setPresetOpen(o => !o)}
|
||||
title="Device preset"
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 5,
|
||||
background: presetOpen ? T.accentBg : T.activeBg,
|
||||
border: `1px solid ${presetOpen ? T.accent + '66' : T.sep}`,
|
||||
borderRadius: 5, padding: '3px 8px',
|
||||
fontSize: '0.625rem',
|
||||
fontFamily: "'JetBrains Mono', 'SF Mono', ui-monospace, monospace",
|
||||
color: presetOpen ? T.accent : T.fgMuted,
|
||||
cursor: 'pointer', letterSpacing: '-0.01em',
|
||||
transition: 'background 0.1s, color 0.1s, border-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Device icon */}
|
||||
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" style={{ flexShrink: 0 }}>
|
||||
{selectedArtboardH != null && selectedArtboardW != null && selectedArtboardH > selectedArtboardW ? (
|
||||
/* Portrait — phone */
|
||||
<rect x="3" y="0.5" width="6" height="11" rx="1.5" stroke="currentColor" strokeWidth="1"/>
|
||||
) : (
|
||||
/* Landscape — desktop/tablet */
|
||||
<rect x="0.5" y="2" width="11" height="8" rx="1.5" stroke="currentColor" strokeWidth="1"/>
|
||||
)}
|
||||
</svg>
|
||||
<span>{activePreset ? activePreset.label : sizeLabel}</span>
|
||||
<svg width="7" height="7" viewBox="0 0 8 8" fill="none">
|
||||
<path d="M2 3l2 2 2-2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{presetOpen && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, marginTop: 4,
|
||||
background: T.bg, border: `1px solid ${T.border}`,
|
||||
borderRadius: 7, boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
||||
minWidth: 200, zIndex: 100, overflow: 'hidden',
|
||||
}}>
|
||||
{DEVICE_PRESETS.map(preset => {
|
||||
const isActive = activePreset?.key === preset.key;
|
||||
return (
|
||||
<button
|
||||
key={preset.key}
|
||||
onClick={() => {
|
||||
if (selectedArtboardId) {
|
||||
dispatchArtboardResize(selectedArtboardId, preset.width, preset.height);
|
||||
}
|
||||
setPresetOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||
width: '100%', padding: '7px 12px',
|
||||
background: isActive ? T.accentBg : 'transparent',
|
||||
border: 'none', cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.6rem', letterSpacing: '-0.01em',
|
||||
color: isActive ? T.accent : T.item,
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isActive) (e.currentTarget).style.background = T.hoverBg; }}
|
||||
onMouseLeave={e => { if (!isActive) (e.currentTarget).style.background = 'transparent'; }}
|
||||
>
|
||||
<span>{preset.label}</span>
|
||||
<span style={{ color: T.dim, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{preset.width} × {preset.height}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Right side */}
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Tooltip content="Zoom out" relationship="label">
|
||||
<Tooltip content="Zoom out Cmd−" relationship="label">
|
||||
<TBtn T={T} active={false} onClick={() => setZoom(zoom * 0.8)}>
|
||||
<ZoomOutRegular />
|
||||
</TBtn>
|
||||
@@ -142,7 +260,7 @@ export function Toolbar() {
|
||||
{Math.round(zoom * 100)}%
|
||||
</button>
|
||||
|
||||
<Tooltip content="Zoom in" relationship="label">
|
||||
<Tooltip content="Zoom in Cmd+" relationship="label">
|
||||
<TBtn T={T} active={false} onClick={() => setZoom(zoom * 1.25)}>
|
||||
<ZoomInRegular />
|
||||
</TBtn>
|
||||
|
||||
@@ -12,9 +12,16 @@ import { trpc } from '@/lib/trpc';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { checkComponentConstraints } from '@originmain/design-language';
|
||||
import type { Violation } from '@originmain/design-language';
|
||||
import { generatePatch } from '@originmain/diff-engine';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
|
||||
import { FileDiff as PierreDiff } from '@pierre/diffs/react';
|
||||
import { processFile, diffAcceptRejectHunk } from '@pierre/diffs';
|
||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||
import { useIndexer } from '@/hooks/useIndexer';
|
||||
import { browserClient } from '@/lib/supabase';
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
import { FrameSection } from './sections/FrameSection';
|
||||
import { LayoutSection } from './sections/LayoutSection';
|
||||
import { FillSection } from './sections/FillSection';
|
||||
@@ -30,7 +37,7 @@ const TYPE_COLORS: Record<string, string> = {
|
||||
b: '#FFBA7B',
|
||||
};
|
||||
|
||||
type TabId = 'design' | 'props' | 'diff' | 'graph';
|
||||
type TabId = 'design' | 'props' | 'code' | 'diff' | 'graph';
|
||||
|
||||
export function Inspector() {
|
||||
const T = useCanvasTheme();
|
||||
@@ -57,7 +64,7 @@ export function Inspector() {
|
||||
>
|
||||
{/* Tab bar */}
|
||||
<div style={{ display: 'flex', borderBottom: `1px solid ${T.border}`, flexShrink: 0 }}>
|
||||
{(['design', 'props', 'diff', 'graph'] as TabId[]).map((t) => (
|
||||
{(['design', 'props', 'code', 'diff', 'graph'] as TabId[]).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
@@ -123,6 +130,8 @@ export function Inspector() {
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
) : tab === 'code' ? (
|
||||
<CodeTab componentId={selectedComponentId} componentData={selectedComponentData} artboardId={selectedArtboardId} />
|
||||
) : tab === 'diff' ? (
|
||||
<DiffTab artboardId={selectedArtboardId} />
|
||||
) : (
|
||||
@@ -1238,6 +1247,386 @@ function DlfViolationBanner({ violations }: { violations: Violation[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Source diff helpers ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Best-effort application of PropChange values to a source file string.
|
||||
* Searches for `propKey: oldValue` patterns and replaces with new values.
|
||||
* Works for inline style objects and most JSX prop assignments.
|
||||
*/
|
||||
function applyChangesToSource(source: string, changes: PropChange[]): string {
|
||||
let result = source;
|
||||
for (const change of changes) {
|
||||
if (change.before === undefined || change.before === change.after) continue;
|
||||
// Escape the old value for use in regex
|
||||
const escapedBefore = String(change.before).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Match `key: value` (handles optional whitespace and trailing comma)
|
||||
const re = new RegExp(`(${change.key}\\s*:\\s*)${escapedBefore}`, 'g');
|
||||
result = result.replace(re, `$1${String(change.after)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ── Code tab (Phase 4 full implementation) ──────────────────────────────── */
|
||||
function CodeTab({
|
||||
componentId,
|
||||
componentData,
|
||||
artboardId,
|
||||
}: {
|
||||
componentId: string | null;
|
||||
componentData: FiberNode | null;
|
||||
artboardId: string | null;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
const { indexerStatus, undoStyleEdit, patchStyleEdit } = useCanvas();
|
||||
const { stacks } = useHistory();
|
||||
const { fetchFile } = useIndexer();
|
||||
const { createDiff } = useDiffs(artboardId);
|
||||
|
||||
const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split');
|
||||
const [fileDiff, setFileDiff] = useState<FileDiffMetadata | null>(null);
|
||||
const [patchStr, setPatchStr] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [diffError, setDiffError] = useState<string | null>(null);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [exportedId, setExportedId] = useState<string | null>(null);
|
||||
const [intentRtStatus, setIntentRtStatus] = useState<string | null>(null);
|
||||
|
||||
// Pending prop changes for this artboard from the edit history
|
||||
const artboardHistory = artboardId
|
||||
? (stacks[artboardId] ?? { past: [], future: [] })
|
||||
: { past: [], future: [] };
|
||||
const pendingChanges = artboardHistory.past
|
||||
.flatMap(e => e.changes)
|
||||
.filter(c => c.changeType !== 'unchanged');
|
||||
|
||||
// ── Generate diff whenever callSite / pending changes / indexer status change ─
|
||||
useEffect(() => {
|
||||
if (!componentData?.callSite || indexerStatus !== 'ready' || pendingChanges.length === 0) {
|
||||
setFileDiff(null);
|
||||
setPatchStr('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoading(true);
|
||||
setDiffError(null);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const filePath = componentData.callSite!.fileName.replace(/\\/g, '/');
|
||||
|
||||
// Fetch source — best-effort; null if indexer can't serve it
|
||||
const sourceContent = await fetchFile(filePath).catch(() => null);
|
||||
|
||||
let patch: string;
|
||||
if (sourceContent) {
|
||||
// Real diff anchored in the actual source file
|
||||
const afterContent = applyChangesToSource(sourceContent, pendingChanges);
|
||||
patch = generatePatch(sourceContent, afterContent, { filename: filePath });
|
||||
} else {
|
||||
// Fallback: synthetic diff from prop key/value pairs alone
|
||||
const beforeText = pendingChanges.map(c => ` ${c.key}: ${String(c.before)},`).join('\n');
|
||||
const afterText = pendingChanges.map(c => ` ${c.key}: ${String(c.after)},`).join('\n');
|
||||
patch = generatePatch(beforeText, afterText, { filename: filePath });
|
||||
}
|
||||
|
||||
if (cancelled || !patch) return;
|
||||
|
||||
const parsed = processFile(patch);
|
||||
if (!cancelled) {
|
||||
setFileDiff(parsed ?? null);
|
||||
setPatchStr(patch);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) setDiffError(err instanceof Error ? err.message : 'Diff generation failed');
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
// Re-run when the file path or change count shifts; intentionally not
|
||||
// exhaustive — pendingChanges reference changes every render.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [componentData?.callSite?.fileName, pendingChanges.length, indexerStatus, fetchFile]);
|
||||
|
||||
// ── Supabase Realtime — watch intent_diffs row for agent status updates ───────
|
||||
// browserClient() is typed as DbClient (minimal) — cast to SupabaseClient to
|
||||
// access the Realtime channel API which DbClient intentionally omits.
|
||||
useEffect(() => {
|
||||
if (!exportedId) return;
|
||||
const db = browserClient() as unknown as SupabaseClient;
|
||||
const channel = db
|
||||
.channel(`code_tab_intent_${exportedId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{ event: 'UPDATE', schema: 'public', table: 'intent_diffs', filter: `id=eq.${exportedId}` },
|
||||
(payload: { new: Record<string, unknown> }) => {
|
||||
const status = payload.new['status'];
|
||||
if (typeof status === 'string') setIntentRtStatus(status);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
return () => { void db.removeChannel(channel); };
|
||||
}, [exportedId]);
|
||||
|
||||
// ── Cmd+Z — undo the last DOM style preview ───────────────────────────────────
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
||||
const undone = undoStyleEdit();
|
||||
if (undone) {
|
||||
e.preventDefault();
|
||||
patchStyleEdit(undone.artboardId, undone.nodeId, undone.property, undone.previousValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [undoStyleEdit, patchStyleEdit]);
|
||||
|
||||
// ── Empty state — no component selected ──────────────────────────────────────
|
||||
if (!componentId) {
|
||||
return (
|
||||
<div style={{ padding: '32px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center' }}>
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.22 }}>
|
||||
<polyline points="7,9 2,14 7,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<polyline points="21,9 26,14 21,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
|
||||
<line x1="16" y1="6" x2="12" y2="22" stroke="white" strokeWidth="1.4" strokeLinecap="round"/>
|
||||
</svg>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
|
||||
Select a component to<br/>view its source
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filePath = componentData?.callSite?.fileName?.replace(/\\/g, '/') ?? null;
|
||||
const shortPath = filePath ? filePath.split('/').slice(-2).join('/') : null;
|
||||
const hunkCount = fileDiff?.hunks?.length ?? 0;
|
||||
|
||||
async function handleSendToAgent() {
|
||||
if (!artboardId || !fileDiff || pendingChanges.length === 0 || isSending) return;
|
||||
setIsSending(true);
|
||||
try {
|
||||
const result = await createDiff.mutateAsync({
|
||||
artboard_id: artboardId,
|
||||
changes: { propChanges: pendingChanges, styleChanges: [] },
|
||||
aggregate_summary: `Code diff — ${componentData?.name ?? componentId} (${pendingChanges.length} change${pendingChanges.length !== 1 ? 's' : ''})`,
|
||||
status: 'EXPORTED',
|
||||
session_id: '',
|
||||
exported_code: patchStr || null,
|
||||
});
|
||||
setExportedId(result.id);
|
||||
setIntentRtStatus('EXPORTED');
|
||||
} catch {
|
||||
/* surface nothing — mutation error shown via createDiff.isError */
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge colour helpers
|
||||
const rtColour =
|
||||
intentRtStatus === 'IMPLEMENTED' ? '#7DD3A8' :
|
||||
intentRtStatus === 'BLOCKED' ? '#FF6B6B' : '#FFBA7B';
|
||||
const rtBg =
|
||||
intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.10)' :
|
||||
intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.10)' : 'rgba(255,186,123,0.10)';
|
||||
const rtBorder =
|
||||
intentRtStatus === 'IMPLEMENTED' ? 'rgba(125,211,168,0.30)' :
|
||||
intentRtStatus === 'BLOCKED' ? 'rgba(255,107,107,0.30)' : 'rgba(255,186,123,0.30)';
|
||||
const rtLabel =
|
||||
intentRtStatus === 'IMPLEMENTED' ? '✓ Implemented by agent' :
|
||||
intentRtStatus === 'BLOCKED' ? '✗ Blocked — check agent output' :
|
||||
intentRtStatus ?? '';
|
||||
|
||||
const canSend = !!fileDiff && !isSending && pendingChanges.length > 0;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
|
||||
{/* ── Header: breadcrumb + indexer dot + toggle ──────────────────── */}
|
||||
<div style={{
|
||||
padding: '9px 12px',
|
||||
borderBottom: `1px solid ${T.border}`,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 7,
|
||||
}}>
|
||||
{/* File path + indexer status dot */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.fgMuted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{shortPath ?? '—'}
|
||||
{componentData?.callSite?.lineNumber != null && (
|
||||
<span style={{ color: T.dim }}>:{componentData.callSite.lineNumber}</span>
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
title={indexerStatus === 'ready' ? 'CLI indexer ready' : indexerStatus === 'indexing' ? 'Indexing…' : 'CLI indexer offline'}
|
||||
style={{
|
||||
display: 'inline-block', width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
|
||||
background: indexerStatus === 'ready' ? '#7DD3A8' : indexerStatus === 'indexing' ? '#FFBA7B' : T.dim,
|
||||
boxShadow: indexerStatus === 'ready' ? '0 0 5px rgba(125,211,168,0.7)' : 'none',
|
||||
transition: 'background 0.25s',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Component badge + split / unified toggle */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
|
||||
color: T.accent, background: T.accentBg,
|
||||
border: `1px solid ${T.accent}33`, borderRadius: 4, padding: '1px 6px',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 100,
|
||||
}}>
|
||||
{componentData?.name ?? componentId}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
{(['split', 'unified'] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setDiffStyle(s)}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||
letterSpacing: '0.04em', textTransform: 'uppercase',
|
||||
color: diffStyle === s ? T.accent : T.dim,
|
||||
background: diffStyle === s ? T.accentBg : 'transparent',
|
||||
border: `1px solid ${diffStyle === s ? T.accent + '44' : 'transparent'}`,
|
||||
borderRadius: 3, padding: '2px 6px', cursor: 'pointer',
|
||||
transition: 'color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Diff viewer ──────────────────────────────────────────────────── */}
|
||||
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
{indexerStatus !== 'ready' ? (
|
||||
<div style={{ padding: '20px 14px' }}>
|
||||
<div style={{ padding: '10px 12px', background: 'rgba(255,186,123,0.06)', border: '1px solid rgba(255,186,123,0.2)', borderRadius: 7 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
|
||||
<div style={{ width: 5, height: 5, borderRadius: '50%', background: '#FFBA7B', flexShrink: 0 }} />
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FFBA7B', letterSpacing: '0.04em' }}>CLI indexer offline</span>
|
||||
</div>
|
||||
<p style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: T.fgDim, lineHeight: 1.6, margin: 0 }}>
|
||||
Source diffs require the CLI indexer. Run{' '}
|
||||
<code style={{ fontFamily: "'JetBrains Mono', monospace", color: '#FFBA7B', fontSize: '0.5rem' }}>
|
||||
npx @originmain/cli dev
|
||||
</code>{' '}to enable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : pendingChanges.length === 0 ? (
|
||||
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em' }}>
|
||||
No pending changes
|
||||
</span>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim }}>Generating diff…</span>
|
||||
</div>
|
||||
) : diffError ? (
|
||||
<div style={{ padding: '14px', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FF6B6B' }}>
|
||||
{diffError}
|
||||
</div>
|
||||
) : fileDiff ? (
|
||||
<div>
|
||||
{/* @pierre/diffs React diff viewer */}
|
||||
<PierreDiff
|
||||
fileDiff={fileDiff}
|
||||
options={{ diffStyle, lineDiffType: 'char' }}
|
||||
style={{ fontSize: '0.5625rem' }}
|
||||
/>
|
||||
|
||||
{/* Per-hunk accept / reject controls */}
|
||||
{hunkCount > 0 && (
|
||||
<div style={{ padding: '8px 12px', borderTop: `1px solid ${T.border}`, display: 'flex', flexDirection: 'column', gap: 5 }}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||
color: T.dim, letterSpacing: '0.07em', textTransform: 'uppercase', marginBottom: 2,
|
||||
}}>
|
||||
{hunkCount} hunk{hunkCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{fileDiff.hunks.map((_, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: T.fgMuted, flex: 1 }}>
|
||||
Hunk {i + 1}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setFileDiff(diffAcceptRejectHunk(fileDiff, i, 'accept'))}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||
color: '#7DD3A8', background: 'rgba(125,211,168,0.08)',
|
||||
border: '1px solid rgba(125,211,168,0.28)', borderRadius: 3,
|
||||
padding: '2px 7px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
accept
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFileDiff(diffAcceptRejectHunk(fileDiff, i, 'reject'))}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
|
||||
color: '#FF6B6B', background: 'rgba(255,107,107,0.08)',
|
||||
border: '1px solid rgba(255,107,107,0.28)', borderRadius: 3,
|
||||
padding: '2px 7px', cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
reject
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Footer: Realtime status badge + Send to Agent ─────────────── */}
|
||||
<div style={{ padding: '9px 12px', borderTop: `1px solid ${T.border}`, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
|
||||
{/* Realtime intent status badge */}
|
||||
{intentRtStatus && (
|
||||
<div style={{
|
||||
padding: '4px 9px',
|
||||
background: rtBg,
|
||||
border: `1px solid ${rtBorder}`,
|
||||
borderRadius: 5,
|
||||
}}>
|
||||
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: rtColour, letterSpacing: '0.02em' }}>
|
||||
{rtLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Send to Agent button */}
|
||||
<button
|
||||
onClick={() => void handleSendToAgent()}
|
||||
disabled={!canSend}
|
||||
style={{
|
||||
width: '100%',
|
||||
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
|
||||
background: canSend ? T.accent : T.bgDeep,
|
||||
color: canSend ? '#fff' : T.dim,
|
||||
border: 'none', borderRadius: 6, padding: '7px 0',
|
||||
cursor: canSend ? 'pointer' : 'not-allowed',
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
>
|
||||
{isSending ? 'Sending…' : 'Send to Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Diff tab ─────────────────────────────────────────────── */
|
||||
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
const T = useCanvasTheme();
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TokenAwareInput — Phase 6
|
||||
*
|
||||
* A numeric/text CSS input that watches the loaded design tokens and shows a
|
||||
* token-match badge when the current value maps to a known token. Clicking the
|
||||
* badge opens a TokenPicker dropdown to swap to a different token value.
|
||||
*
|
||||
* Used in all Design tab section inputs (FrameSection, FillSection, etc.).
|
||||
*
|
||||
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.4 "Token-aware inputs"
|
||||
*/
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { TokenPicker } from './TokenPicker';
|
||||
import type { DesignToken, TokenMatch } from '@/store/canvas.types';
|
||||
|
||||
// Lazily import the resolver from the design-language package at runtime.
|
||||
// This avoids a hard dependency at module load time while still getting full
|
||||
// type safety via the import type pattern.
|
||||
async function resolveToken(
|
||||
cssValue: string,
|
||||
tokens: DesignToken[],
|
||||
rootFontSizePx: number,
|
||||
): Promise<TokenMatch | null> {
|
||||
try {
|
||||
const { resolveValueToToken } = await import('@originmain/design-language');
|
||||
return resolveValueToToken(cssValue, tokens, rootFontSizePx) as TokenMatch | null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface TokenAwareInputProps {
|
||||
/** Current CSS value string (e.g. "16px", "rgb(0,102,255)"). */
|
||||
value: string;
|
||||
/** CSS property name — used to filter token candidates by type. */
|
||||
propKey: string;
|
||||
/** Called when the user commits a new value (keyboard Enter / blur / token pick). */
|
||||
onPatch: (prop: string, val: string) => void;
|
||||
/** Width of the input in px. Default: 60. */
|
||||
inputWidth?: number;
|
||||
/** If true, renders a full-width input. Overrides inputWidth. */
|
||||
fullWidth?: boolean;
|
||||
/** If true, renders a color picker swatch alongside the input. */
|
||||
isColor?: boolean;
|
||||
}
|
||||
|
||||
export function TokenAwareInput({
|
||||
value,
|
||||
propKey,
|
||||
onPatch,
|
||||
inputWidth = 60,
|
||||
fullWidth = false,
|
||||
isColor = false,
|
||||
}: TokenAwareInputProps) {
|
||||
const T = useCanvasTheme();
|
||||
const { designLanguageTokens, artboardRootFontSize, selectedArtboardId } = useCanvas();
|
||||
const rootFontSizePx = selectedArtboardId ? (artboardRootFontSize[selectedArtboardId] ?? 16) : 16;
|
||||
|
||||
const [draft, setDraft] = useState(value);
|
||||
const [match, setMatch] = useState<TokenMatch | null>(null);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const prevValueRef = useRef(value);
|
||||
|
||||
// Sync draft when external value changes
|
||||
if (prevValueRef.current !== value) {
|
||||
prevValueRef.current = value;
|
||||
setDraft(value);
|
||||
}
|
||||
|
||||
// Resolve token whenever value or token library changes
|
||||
useEffect(() => {
|
||||
if (!designLanguageTokens || designLanguageTokens.length === 0) {
|
||||
setMatch(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void resolveToken(value, designLanguageTokens, rootFontSizePx).then((m) => {
|
||||
if (!cancelled) setMatch(m);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [value, designLanguageTokens, rootFontSizePx]);
|
||||
|
||||
const commit = useCallback((v: string) => {
|
||||
onPatch(propKey, v);
|
||||
}, [onPatch, propKey]);
|
||||
|
||||
const handleTokenSelect = useCallback((token: DesignToken) => {
|
||||
setDraft(token.rawValue);
|
||||
commit(token.rawValue);
|
||||
setPickerOpen(false);
|
||||
}, [commit]);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{/* Main input */}
|
||||
<input
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onBlur={() => commit(draft)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') { commit(draft); e.currentTarget.blur(); }
|
||||
if (e.key === 'Escape') setDraft(value);
|
||||
e.stopPropagation();
|
||||
}}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5875rem',
|
||||
background: T.bgDeep,
|
||||
border: `1px solid ${match?.exact ? T.accent + '55' : T.border}`,
|
||||
borderRadius: 4,
|
||||
color: T.fg,
|
||||
padding: '3px 6px',
|
||||
width: fullWidth ? '100%' : inputWidth,
|
||||
outline: 'none',
|
||||
textAlign: 'right',
|
||||
boxSizing: 'border-box',
|
||||
transition: 'border-color 0.15s',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Token match badge */}
|
||||
{match && designLanguageTokens && (
|
||||
<button
|
||||
title={`Token: ${match.token.name}\n${match.token.key}\n${match.exact ? 'Exact match' : `Distance: ${match.distance.toFixed(1)}`}`}
|
||||
onClick={() => setPickerOpen((o) => !o)}
|
||||
style={{
|
||||
background: match.exact ? T.accentBg : 'rgba(255,186,123,0.12)',
|
||||
border: `1px solid ${match.exact ? T.accent + '44' : 'rgba(255,186,123,0.3)'}`,
|
||||
borderRadius: 3,
|
||||
padding: '2px 4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 3,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Colour swatch for colour tokens */}
|
||||
{match.token.type === 'color' && (
|
||||
<div style={{
|
||||
width: 8, height: 8, borderRadius: '50%', flexShrink: 0,
|
||||
background: match.token.rawValue,
|
||||
border: '1px solid rgba(255,255,255,0.2)',
|
||||
}} />
|
||||
)}
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.4rem',
|
||||
color: match.exact ? T.accent : '#FFBA7B',
|
||||
letterSpacing: '-0.01em',
|
||||
maxWidth: 48,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{match.token.key.replace(/^--/, '')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* TokenPicker dropdown */}
|
||||
{pickerOpen && designLanguageTokens && (
|
||||
<TokenPicker
|
||||
cssValue={value}
|
||||
propKey={propKey}
|
||||
tokens={designLanguageTokens}
|
||||
rootFontSizePx={rootFontSizePx}
|
||||
onSelect={handleTokenSelect}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* TokenPicker — Phase 6
|
||||
*
|
||||
* Dropdown panel listing the closest token matches for the current CSS value.
|
||||
* Opened by TokenAwareInput when the user clicks the token badge.
|
||||
* Clicking a row patches the value and closes the picker.
|
||||
*
|
||||
* spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.4
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import type { DesignToken, TokenMatch } from '@/store/canvas.types';
|
||||
|
||||
async function resolveTokens(
|
||||
cssValue: string,
|
||||
tokens: DesignToken[],
|
||||
rootFontSizePx: number,
|
||||
): Promise<TokenMatch[]> {
|
||||
try {
|
||||
const { resolveValueToTokens } = await import('@originmain/design-language');
|
||||
return (resolveValueToTokens(cssValue, tokens, rootFontSizePx, 8) as TokenMatch[]);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
interface TokenPickerProps {
|
||||
cssValue: string;
|
||||
propKey: string;
|
||||
tokens: DesignToken[];
|
||||
rootFontSizePx: number;
|
||||
onSelect: (token: DesignToken) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function TokenPicker({
|
||||
cssValue,
|
||||
propKey,
|
||||
tokens,
|
||||
rootFontSizePx,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: TokenPickerProps) {
|
||||
const T = useCanvasTheme();
|
||||
const [candidates, setCandidates] = useState<TokenMatch[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load candidates
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
void resolveTokens(cssValue, tokens, rootFontSizePx).then((matches) => {
|
||||
setCandidates(matches);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [cssValue, tokens, rootFontSizePx]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) onClose();
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [onClose]);
|
||||
|
||||
// Filter by search term
|
||||
const filtered = search
|
||||
? candidates.filter(
|
||||
(m) =>
|
||||
m.token.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
m.token.key.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
: candidates;
|
||||
|
||||
// Also show browseable tokens filtered by prop category when no close matches found
|
||||
const browseable: TokenMatch[] = filtered.length === 0 && search
|
||||
? tokens
|
||||
.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
t.key.toLowerCase().includes(search.toLowerCase()),
|
||||
)
|
||||
.slice(0, 8)
|
||||
.map((token) => ({ token, exact: false, distance: Infinity }))
|
||||
: [];
|
||||
|
||||
const rows = [...filtered, ...browseable];
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
right: 0,
|
||||
marginTop: 4,
|
||||
width: 220,
|
||||
background: T.bg,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 7,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.45)',
|
||||
zIndex: 200,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{
|
||||
padding: '7px 10px 5px',
|
||||
borderBottom: `1px solid ${T.sep}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 5,
|
||||
}}>
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.08em',
|
||||
textTransform: 'uppercase',
|
||||
color: T.dim,
|
||||
}}>
|
||||
Token Picker · {propKey.replace(/^--/, '')}
|
||||
</span>
|
||||
|
||||
{/* Search */}
|
||||
<input
|
||||
autoFocus
|
||||
placeholder="Search tokens…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Escape') onClose(); e.stopPropagation(); }}
|
||||
style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5875rem',
|
||||
background: T.bgDeep,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 4,
|
||||
color: T.fg,
|
||||
padding: '3px 7px',
|
||||
outline: 'none',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Candidates list */}
|
||||
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
|
||||
{loading ? (
|
||||
<div style={{
|
||||
padding: '12px 10px',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5625rem',
|
||||
color: T.dim,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
Resolving…
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div style={{
|
||||
padding: '12px 10px',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.5625rem',
|
||||
color: T.dim,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
No matching tokens
|
||||
</div>
|
||||
) : (
|
||||
rows.map((m) => (
|
||||
<TokenRow
|
||||
key={m.token.key}
|
||||
match={m}
|
||||
onSelect={() => onSelect(m.token)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TokenRow({
|
||||
match,
|
||||
onSelect,
|
||||
}: {
|
||||
match: TokenMatch;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const T = useCanvasTheme();
|
||||
const [hov, setHov] = useState(false);
|
||||
const { token, exact, distance } = match;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onSelect}
|
||||
onMouseEnter={() => setHov(true)}
|
||||
onMouseLeave={() => setHov(false)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 7,
|
||||
width: '100%',
|
||||
padding: '6px 10px',
|
||||
background: hov ? T.hoverBg : 'transparent',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Swatch for colors, or a type icon for others */}
|
||||
{token.type === 'color' ? (
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 3, flexShrink: 0,
|
||||
background: token.rawValue,
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
boxShadow: exact ? `0 0 0 2px ${T.accent}55` : 'none',
|
||||
}} />
|
||||
) : (
|
||||
<div style={{
|
||||
width: 16, height: 16, borderRadius: 3, flexShrink: 0,
|
||||
background: T.bgDeep,
|
||||
border: `1px solid ${T.sep}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.45rem', color: T.dim,
|
||||
}}>
|
||||
{token.type === 'spacing' ? 'sp' :
|
||||
token.type === 'fontSize' ? 'f' :
|
||||
token.type === 'fontWeight' ? 'fw' : '·'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token name + key */}
|
||||
<div style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5625rem',
|
||||
color: exact ? T.accent : T.fg,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{token.name}
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.475rem',
|
||||
color: T.dim,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{token.rawValue}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Match indicator */}
|
||||
<div style={{ flexShrink: 0, textAlign: 'right' }}>
|
||||
{exact ? (
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.45rem',
|
||||
color: T.accent,
|
||||
background: T.accentBg,
|
||||
border: `1px solid ${T.accent}33`,
|
||||
borderRadius: 3,
|
||||
padding: '1px 3px',
|
||||
}}>
|
||||
exact
|
||||
</span>
|
||||
) : distance !== Infinity ? (
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', monospace",
|
||||
fontSize: '0.45rem',
|
||||
color: T.dim,
|
||||
}}>
|
||||
Δ{distance.toFixed(1)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, type ReactNode } from 'react';
|
||||
import { useState, useCallback, useMemo, type ReactNode } from 'react';
|
||||
import { useFileTree, FileTree } from '@pierre/trees/react';
|
||||
import { themeToTreeStyles } from '@pierre/trees';
|
||||
import { SquareRegular } from '@fluentui/react-icons';
|
||||
@@ -10,13 +10,37 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { useTheme } from '@/store/theme';
|
||||
|
||||
type NavTab = 'artboards' | 'routes';
|
||||
|
||||
export function ArtboardNavigator() {
|
||||
const T = useCanvasTheme();
|
||||
const mode = useTheme((s) => s.mode);
|
||||
const { selectedArtboardId, selectArtboard, workspaceId, projectId, liveArtboardIds, artboardFiberRoots } = useCanvas();
|
||||
const [navTab, setNavTab] = useState<NavTab>('artboards');
|
||||
const { selectedArtboardId, selectArtboard, workspaceId, projectId, liveArtboardIds, artboardFiberRoots, discoveredRoutes } = useCanvas();
|
||||
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Aggregate unique routes across all artboards, deduplicated by path
|
||||
const allRoutes = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const result: Array<{ path: string; label: string; artboardId: string }> = [];
|
||||
for (const [artboardId, routes] of Object.entries(discoveredRoutes)) {
|
||||
for (const r of routes) {
|
||||
if (!seen.has(r.path)) {
|
||||
seen.add(r.path);
|
||||
result.push({ ...r, artboardId });
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}, [discoveredRoutes]);
|
||||
|
||||
// Build a set of routes already covered by an artboard (for the "+" button logic)
|
||||
const coveredRoutes = useMemo(
|
||||
() => new Set(artboards.map((ab) => ab.route ?? '/')),
|
||||
[artboards],
|
||||
);
|
||||
|
||||
// Map the panel theme into Trees' CSS custom properties — recomputed when mode changes
|
||||
const treeThemeStyles = themeToTreeStyles(mode === 'dark' ? {
|
||||
type: 'dark',
|
||||
@@ -122,32 +146,50 @@ export function ArtboardNavigator() {
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{/* ── Artboards ── */}
|
||||
<SectionLabel>Artboards</SectionLabel>
|
||||
<div style={{ padding: '2px 6px 0' }}>
|
||||
{artboards.map((ab) => {
|
||||
const sel = selectedArtboardId === ab.id;
|
||||
const live = liveArtboardIds.has(ab.id);
|
||||
return (
|
||||
<NavRow
|
||||
key={ab.id}
|
||||
T={T}
|
||||
selected={sel}
|
||||
live={live}
|
||||
onClick={() => selectArtboard(ab.id)}
|
||||
icon={
|
||||
<SquareRegular
|
||||
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
|
||||
/>
|
||||
}
|
||||
label={ab.label}
|
||||
onRename={() => void renameArtboard(ab.id, ab.label)}
|
||||
onFork={() => void forkArtboard(ab.id, ab.label)}
|
||||
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* ── Tab switcher: Artboards | Routes ── */}
|
||||
<TabSwitcher T={T} active={navTab} onChange={setNavTab} routeCount={allRoutes.length} />
|
||||
|
||||
{/* ── Artboards tab ── */}
|
||||
{navTab === 'artboards' && (
|
||||
<div style={{ padding: '2px 6px 0' }}>
|
||||
{artboards.map((ab) => {
|
||||
const sel = selectedArtboardId === ab.id;
|
||||
const live = liveArtboardIds.has(ab.id);
|
||||
return (
|
||||
<NavRow
|
||||
key={ab.id}
|
||||
T={T}
|
||||
selected={sel}
|
||||
live={live}
|
||||
onClick={() => selectArtboard(ab.id)}
|
||||
icon={
|
||||
<SquareRegular
|
||||
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
|
||||
/>
|
||||
}
|
||||
label={ab.label}
|
||||
onRename={() => void renameArtboard(ab.id, ab.label)}
|
||||
onFork={() => void forkArtboard(ab.id, ab.label)}
|
||||
onDelete={() => void deleteArtboard(ab.id, ab.label)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Routes tab ── */}
|
||||
{navTab === 'routes' && (
|
||||
<RoutesTab
|
||||
T={T}
|
||||
routes={allRoutes}
|
||||
coveredRoutes={coveredRoutes}
|
||||
artboards={artboards}
|
||||
workspaceId={workspaceId}
|
||||
projectId={projectId}
|
||||
queryClient={queryClient}
|
||||
selectArtboard={selectArtboard}
|
||||
/>
|
||||
)}
|
||||
|
||||
<HSep />
|
||||
|
||||
@@ -183,9 +225,277 @@ export function ArtboardNavigator() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Artboard row ─────────────────────────────────────────── */
|
||||
/* ── Tab switcher ─────────────────────────────────────────── */
|
||||
import type { CanvasTokens } from '@/store/canvasTheme';
|
||||
|
||||
function TabSwitcher({
|
||||
T,
|
||||
active,
|
||||
onChange,
|
||||
routeCount,
|
||||
}: {
|
||||
T: CanvasTokens;
|
||||
active: NavTab;
|
||||
onChange: (tab: NavTab) => void;
|
||||
routeCount: number;
|
||||
}) {
|
||||
const tabs: { key: NavTab; label: string }[] = [
|
||||
{ key: 'artboards', label: 'Artboards' },
|
||||
{ key: 'routes', label: 'Routes' },
|
||||
];
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
padding: '10px 10px 4px',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{tabs.map(({ key, label }) => {
|
||||
const isActive = active === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onChange(key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '4px 0',
|
||||
background: isActive ? T.accentBg : 'transparent',
|
||||
border: `1px solid ${isActive ? T.accent + '55' : T.sep}`,
|
||||
borderRadius: 5,
|
||||
cursor: 'pointer',
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5625rem',
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
letterSpacing: '0.05em',
|
||||
textTransform: 'uppercase',
|
||||
color: isActive ? T.accent : T.dim,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 4,
|
||||
transition: 'background 0.1s, color 0.1s, border-color 0.1s',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{key === 'routes' && routeCount > 0 && (
|
||||
<span style={{
|
||||
background: isActive ? T.accent + '33' : T.sep,
|
||||
color: isActive ? T.accent : T.fgDim,
|
||||
borderRadius: 3,
|
||||
padding: '0 3px',
|
||||
fontSize: '0.5rem',
|
||||
fontWeight: 700,
|
||||
lineHeight: '14px',
|
||||
minWidth: 14,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
{routeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Routes tab ───────────────────────────────────────────── */
|
||||
function RoutesTab({
|
||||
T,
|
||||
routes,
|
||||
coveredRoutes,
|
||||
artboards,
|
||||
workspaceId,
|
||||
projectId,
|
||||
queryClient,
|
||||
selectArtboard,
|
||||
}: {
|
||||
T: CanvasTokens;
|
||||
routes: Array<{ path: string; label: string; artboardId: string }>;
|
||||
coveredRoutes: Set<string>;
|
||||
artboards: Array<{ id: string; label: string; route?: string; x: number; y: number; width: number; height: number; renderUrl?: string }>;
|
||||
workspaceId: string | null;
|
||||
projectId: string | null;
|
||||
queryClient: ReturnType<typeof useQueryClient>;
|
||||
selectArtboard: (id: string | null) => void;
|
||||
}) {
|
||||
const handleCreateArtboard = useCallback(async (route: { path: string; label: string; artboardId: string }) => {
|
||||
if (!workspaceId) return;
|
||||
const sourceAb = artboards.find((ab) => ab.id === route.artboardId);
|
||||
if (!sourceAb) return;
|
||||
const GAP = 80;
|
||||
const rightEdge = artboards.reduce(
|
||||
(max, ab) => Math.max(max, ab.x + ab.width),
|
||||
sourceAb.x + sourceAb.width,
|
||||
);
|
||||
try {
|
||||
await createArtboardMutation({
|
||||
workspace_id: workspaceId,
|
||||
project_id: projectId ?? null,
|
||||
name: route.label,
|
||||
origin_id: null,
|
||||
parent_artboard_id: null,
|
||||
metadata_jsonb: {
|
||||
x: rightEdge + GAP,
|
||||
y: sourceAb.y,
|
||||
width: sourceAb.width,
|
||||
height: sourceAb.height,
|
||||
renderUrl: sourceAb.renderUrl,
|
||||
route: route.path,
|
||||
},
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
|
||||
} catch (err) {
|
||||
console.error('[Navigator] Failed to create artboard for route', err);
|
||||
}
|
||||
}, [artboards, workspaceId, projectId, queryClient]);
|
||||
|
||||
if (routes.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
padding: '16px 14px',
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: '0.625rem',
|
||||
color: T.fgDim,
|
||||
lineHeight: 1.6,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<div style={{ marginBottom: 6, fontSize: '1rem', opacity: 0.4 }}>🔌</div>
|
||||
No routes discovered yet.
|
||||
<br />
|
||||
Connect a live artboard to auto-discover pages.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: '4px 6px 0', overflow: 'auto', flex: 1, minHeight: 0 }}>
|
||||
{routes.map((route) => {
|
||||
const isCovered = coveredRoutes.has(route.path);
|
||||
const existingAb = artboards.find((ab) => (ab.route ?? '/') === route.path);
|
||||
return (
|
||||
<RouteRow
|
||||
key={route.path}
|
||||
T={T}
|
||||
path={route.path}
|
||||
label={route.label}
|
||||
isCovered={isCovered}
|
||||
{...(existingAb ? { onOpen: () => selectArtboard(existingAb.id) } : {})}
|
||||
{...(!isCovered ? { onAdd: () => void handleCreateArtboard(route) } : {})}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RouteRow({
|
||||
T,
|
||||
path,
|
||||
label,
|
||||
isCovered,
|
||||
onOpen,
|
||||
onAdd,
|
||||
}: {
|
||||
T: CanvasTokens;
|
||||
path: string;
|
||||
label: string;
|
||||
isCovered: boolean;
|
||||
onOpen?: () => void;
|
||||
onAdd?: () => void;
|
||||
}) {
|
||||
const [hov, setHov] = useState(false);
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => setHov(true)}
|
||||
onMouseLeave={() => setHov(false)}
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '4px 6px 4px 10px',
|
||||
borderRadius: 5,
|
||||
cursor: onOpen ? 'pointer' : 'default',
|
||||
background: hov && onOpen ? T.hoverBg : 'transparent',
|
||||
marginBottom: 1,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
>
|
||||
{/* Route path icon */}
|
||||
<svg width="9" height="9" viewBox="0 0 9 9" fill="none" style={{ flexShrink: 0 }}>
|
||||
<path d="M1 4.5h7M4.5 1.5l3 3-3 3" stroke={isCovered ? T.accent : T.dim} strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
|
||||
<div style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.625rem',
|
||||
color: isCovered ? T.fg : T.fgMuted,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
letterSpacing: '-0.01em',
|
||||
}}>
|
||||
{path}
|
||||
</div>
|
||||
{label !== path && (
|
||||
<div style={{
|
||||
fontFamily: "'Inter', sans-serif",
|
||||
fontSize: '0.5625rem',
|
||||
color: T.dim,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{label}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status badge or add button */}
|
||||
{isCovered ? (
|
||||
<span style={{
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5rem',
|
||||
color: T.accent,
|
||||
background: T.accentBg,
|
||||
border: `1px solid ${T.accent}33`,
|
||||
borderRadius: 3,
|
||||
padding: '1px 4px',
|
||||
flexShrink: 0,
|
||||
letterSpacing: '0.05em',
|
||||
}}>
|
||||
✓
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onAdd?.(); }}
|
||||
title="Create artboard for this route"
|
||||
style={{
|
||||
background: hov ? T.accentBg : 'transparent',
|
||||
border: `1px solid ${hov ? T.accent + '55' : T.sep}`,
|
||||
borderRadius: 3,
|
||||
padding: '2px 5px',
|
||||
cursor: 'pointer',
|
||||
color: hov ? T.accent : T.dim,
|
||||
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
|
||||
fontSize: '0.5625rem',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
lineHeight: 1,
|
||||
transition: 'background 0.1s, color 0.1s, border-color 0.1s',
|
||||
}}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Artboard row ─────────────────────────────────────────── */
|
||||
|
||||
function NavRow({
|
||||
T,
|
||||
selected = false,
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
// ── Artboard iframe map ───────────────────────────────────────────────────────
|
||||
// Module-level singleton: maps artboard ID → live HTMLIFrameElement.
|
||||
//
|
||||
// DOM references must never be stored in Zustand — they are not serialisable,
|
||||
// prevent garbage collection, and break React DevTools. Each <LiveArtboard>
|
||||
// registers its iframeRef.current on mount and removes it on unmount.
|
||||
//
|
||||
// The canvas dispatches postMessage via:
|
||||
// artboardIframeMap.get(selectedArtboardId)?.contentWindow?.postMessage(…)
|
||||
|
||||
export const artboardIframeMap = new Map<string, HTMLIFrameElement>();
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* diff-generator.ts — Phase 4
|
||||
*
|
||||
* Converts style-edit patches (from the canvas store's styleEditQueue) into
|
||||
* FileDiffMetadata objects that @pierre/diffs can render as interactive hunks.
|
||||
*
|
||||
* Three strategies are supported (spec §8.3):
|
||||
* css — inline CSS custom property overrides in a virtual .css file
|
||||
* prop — JSX prop changes in the component call-site .tsx file
|
||||
* tailwind — Tailwind class string replacement in the component call-site
|
||||
*
|
||||
* spec: SOURCE-AWARE-CANVAS.md Phase 4 §8 "Intent Diff"
|
||||
*/
|
||||
|
||||
import { processFile } from '@pierre/diffs';
|
||||
import type { FileDiffMetadata } from '@pierre/diffs';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
|
||||
export type DiffStrategy = 'css' | 'prop' | 'tailwind';
|
||||
|
||||
export interface StylePatch {
|
||||
property: string;
|
||||
value: string;
|
||||
/** Previous value — populated from the styleUndoStack or live styles. */
|
||||
previousValue?: string;
|
||||
}
|
||||
|
||||
export interface GeneratedFileDiff {
|
||||
/** The @pierre/diffs metadata ready to hand to <FileDiff> */
|
||||
fileDiff: FileDiffMetadata;
|
||||
/** Virtual filename used (e.g. "src/Button.module.css") */
|
||||
filename: string;
|
||||
/** Strategy used to produce this diff */
|
||||
strategy: DiffStrategy;
|
||||
}
|
||||
|
||||
// ── Strategy: css ─────────────────────────────────────────────────────────────
|
||||
// Generates a CSS custom-property block showing what changed.
|
||||
// Virtual filename: derived from the component's call-site or "<component>.css".
|
||||
function buildCssDiff(
|
||||
componentName: string,
|
||||
callSiteFile: string | undefined,
|
||||
patches: StylePatch[],
|
||||
): GeneratedFileDiff | null {
|
||||
const virtualName = callSiteFile
|
||||
? callSiteFile.replace(/\.(tsx|jsx|ts|js)$/, '.module.css')
|
||||
: `${componentName}.module.css`;
|
||||
|
||||
// Old: previous values (or empty if unknown)
|
||||
const oldLines = [
|
||||
`.${componentName} {`,
|
||||
...patches.map((p) =>
|
||||
p.previousValue
|
||||
? ` ${cssPropertyName(p.property)}: ${p.previousValue};`
|
||||
: ` /* ${cssPropertyName(p.property)}: <previous value not captured> */`,
|
||||
),
|
||||
'}',
|
||||
];
|
||||
|
||||
// New: updated values
|
||||
const newLines = [
|
||||
`.${componentName} {`,
|
||||
...patches.map((p) => ` ${cssPropertyName(p.property)}: ${p.value};`),
|
||||
'}',
|
||||
];
|
||||
|
||||
const oldContents = oldLines.join('\n');
|
||||
const newContents = newLines.join('\n');
|
||||
|
||||
const fileDiff = processFile('', {
|
||||
oldFile: { name: virtualName, contents: oldContents },
|
||||
newFile: { name: virtualName, contents: newContents },
|
||||
});
|
||||
|
||||
if (!fileDiff) return null;
|
||||
return { fileDiff, filename: virtualName, strategy: 'css' };
|
||||
}
|
||||
|
||||
// ── Strategy: prop ────────────────────────────────────────────────────────────
|
||||
// Generates a JSX snippet showing style prop changes on the component element.
|
||||
// This is a simplified representation; Phase 4+ CLI integration will replace
|
||||
// these with real AST-rewritten patches at the actual call-site.
|
||||
function buildPropDiff(
|
||||
componentName: string,
|
||||
callSiteFile: string | undefined,
|
||||
patches: StylePatch[],
|
||||
): GeneratedFileDiff | null {
|
||||
const virtualName = callSiteFile ?? `${componentName}.tsx`;
|
||||
|
||||
const styleOld = patches
|
||||
.filter((p) => p.previousValue)
|
||||
.map((p) => ` ${camelCase(p.property)}: '${p.previousValue}'`)
|
||||
.join(',\n');
|
||||
|
||||
const styleNew = patches
|
||||
.map((p) => ` ${camelCase(p.property)}: '${p.value}'`)
|
||||
.join(',\n');
|
||||
|
||||
const oldContents = styleOld
|
||||
? `<${componentName}\n style={{\n${styleOld},\n }}\n/>`
|
||||
: `<${componentName} />`;
|
||||
|
||||
const newContents = styleNew
|
||||
? `<${componentName}\n style={{\n${styleNew},\n }}\n/>`
|
||||
: `<${componentName} />`;
|
||||
|
||||
const fileDiff = processFile('', {
|
||||
oldFile: { name: virtualName, contents: oldContents },
|
||||
newFile: { name: virtualName, contents: newContents },
|
||||
});
|
||||
|
||||
if (!fileDiff) return null;
|
||||
return { fileDiff, filename: virtualName, strategy: 'prop' };
|
||||
}
|
||||
|
||||
// ── Strategy: tailwind ────────────────────────────────────────────────────────
|
||||
// Converts CSS property patches into approximate Tailwind utility additions.
|
||||
// The mapping is heuristic — a real implementation would require a Tailwind
|
||||
// config lookup via the CLI indexer (Phase 3 integration).
|
||||
function buildTailwindDiff(
|
||||
componentName: string,
|
||||
callSiteFile: string | undefined,
|
||||
patches: StylePatch[],
|
||||
): GeneratedFileDiff | null {
|
||||
const virtualName = callSiteFile ?? `${componentName}.tsx`;
|
||||
|
||||
const oldClasses = patches
|
||||
.filter((p) => p.previousValue)
|
||||
.map((p) => cssToTailwindApprox(p.property, p.previousValue ?? ''))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const newClasses = patches
|
||||
.map((p) => cssToTailwindApprox(p.property, p.value))
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const baseClasses = 'flex items-center'; // placeholder existing classes
|
||||
const oldContents = `<${componentName} className="${[baseClasses, oldClasses].filter(Boolean).join(' ')}" />`;
|
||||
const newContents = `<${componentName} className="${[baseClasses, newClasses].filter(Boolean).join(' ')}" />`;
|
||||
|
||||
const fileDiff = processFile('', {
|
||||
oldFile: { name: virtualName, contents: oldContents },
|
||||
newFile: { name: virtualName, contents: newContents },
|
||||
});
|
||||
|
||||
if (!fileDiff) return null;
|
||||
return { fileDiff, filename: virtualName, strategy: 'tailwind' };
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Generates a FileDiffMetadata for the given patches using the chosen strategy.
|
||||
* Returns null if the diff is empty (no actual changes).
|
||||
*/
|
||||
export function buildFileDiffMetadata(
|
||||
patches: StylePatch[],
|
||||
strategy: DiffStrategy,
|
||||
componentData: FiberNode | null,
|
||||
): GeneratedFileDiff | null {
|
||||
if (patches.length === 0) return null;
|
||||
|
||||
const componentName = componentData?.name ?? 'Component';
|
||||
const callSiteFile = componentData?.callSite?.fileName;
|
||||
|
||||
switch (strategy) {
|
||||
case 'css':
|
||||
return buildCssDiff(componentName, callSiteFile, patches);
|
||||
case 'prop':
|
||||
return buildPropDiff(componentName, callSiteFile, patches);
|
||||
case 'tailwind':
|
||||
return buildTailwindDiff(componentName, callSiteFile, patches);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** camelCase CSS property name: "background-color" → "backgroundColor" */
|
||||
function camelCase(prop: string): string {
|
||||
return prop.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
|
||||
}
|
||||
|
||||
/** Keep CSS property name as-is (kebab-case). */
|
||||
function cssPropertyName(prop: string): string {
|
||||
return prop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Very rough CSS → Tailwind approximation for the tailwind diff strategy.
|
||||
* In Phase 3+ this should be replaced with a proper lookup via the CLI indexer.
|
||||
*/
|
||||
function cssToTailwindApprox(property: string, value: string): string {
|
||||
// Strip units for numeric comparisons
|
||||
const num = parseFloat(value);
|
||||
const px = value.endsWith('px') ? num : null;
|
||||
|
||||
switch (property) {
|
||||
case 'color': return `text-[${value}]`;
|
||||
case 'background-color':
|
||||
case 'background': return `bg-[${value}]`;
|
||||
case 'font-size': return px !== null ? `text-[${px}px]` : `text-[${value}]`;
|
||||
case 'font-weight': return `font-[${value}]`;
|
||||
case 'padding': return px !== null ? `p-[${px}px]` : `p-[${value}]`;
|
||||
case 'padding-top': return px !== null ? `pt-[${px}px]` : '';
|
||||
case 'padding-right': return px !== null ? `pr-[${px}px]` : '';
|
||||
case 'padding-bottom': return px !== null ? `pb-[${px}px]` : '';
|
||||
case 'padding-left': return px !== null ? `pl-[${px}px]` : '';
|
||||
case 'margin': return px !== null ? `m-[${px}px]` : `m-[${value}]`;
|
||||
case 'margin-top': return px !== null ? `mt-[${px}px]` : '';
|
||||
case 'margin-right': return px !== null ? `mr-[${px}px]` : '';
|
||||
case 'margin-bottom': return px !== null ? `mb-[${px}px]` : '';
|
||||
case 'margin-left': return px !== null ? `ml-[${px}px]` : '';
|
||||
case 'width': return px !== null ? `w-[${px}px]` : `w-[${value}]`;
|
||||
case 'height': return px !== null ? `h-[${px}px]` : `h-[${value}]`;
|
||||
case 'border-radius': return px !== null ? `rounded-[${px}px]` : `rounded-[${value}]`;
|
||||
case 'gap': return px !== null ? `gap-[${px}px]` : `gap-[${value}]`;
|
||||
case 'opacity': return `opacity-[${value}]`;
|
||||
case 'display':
|
||||
if (value === 'flex') return 'flex';
|
||||
if (value === 'grid') return 'grid';
|
||||
if (value === 'none') return 'hidden';
|
||||
return '';
|
||||
case 'flex-direction':
|
||||
if (value === 'column') return 'flex-col';
|
||||
if (value === 'row-reverse') return 'flex-row-reverse';
|
||||
if (value === 'column-reverse') return 'flex-col-reverse';
|
||||
return 'flex-row';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FiberNode } from '@originmain/renderer';
|
||||
import type { Violation } from '@originmain/design-language';
|
||||
import type { DesignToken } from './canvas.types';
|
||||
|
||||
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */
|
||||
export interface ProjectMeta {
|
||||
@@ -92,15 +93,58 @@ interface CanvasStore {
|
||||
setActiveViolations: (violations: Violation[]) => void;
|
||||
|
||||
// ── Active agent session (spec Layer 6 — diff attribution) ──────────────────
|
||||
// Set by the Agent Bridge when a session starts/ends. Inspector and
|
||||
// CompletionZone read this to populate session_id on intent_diffs so the
|
||||
// Agent Bridge can later query diffs by session (getDiffsByStatus etc.).
|
||||
// null = no agent session active; user-created diffs get session_id ''.
|
||||
activeAgentSessionId: string | null;
|
||||
setActiveAgentSessionId: (id: string | null) => void;
|
||||
|
||||
// ── Phase 0: Discovered routes (aggregated from ROUTES_DISCOVERED messages) ───
|
||||
/** Map of artboardId → routes discovered by that artboard's live app. */
|
||||
discoveredRoutes: Record<string, Array<{ path: string; label: string }>>;
|
||||
setDiscoveredRoutes: (artboardId: string, routes: Array<{ path: string; label: string }>) => void;
|
||||
|
||||
// ── Selected artboard dimensions (Phase 0 device preset picker) ──────────────
|
||||
// Set by Artboard.tsx whenever the selected artboard renders, so that Toolbar
|
||||
// can read current width/height without needing workspaceId/projectId.
|
||||
selectedArtboardW: number | null;
|
||||
selectedArtboardH: number | null;
|
||||
setSelectedArtboardSize: (w: number, h: number) => void;
|
||||
/** One-shot resize event: Artboard.tsx watches this and fires a PATCH, then clears. */
|
||||
artboardResizeEvent: { artboardId: string; width: number; height: number } | null;
|
||||
dispatchArtboardResize: (artboardId: string, width: number, height: number) => void;
|
||||
clearArtboardResize: () => void;
|
||||
|
||||
// ── Phase 4 — Intent diff tracking ───────────────────────────────────────────
|
||||
/** Map of intentId → status: 'EXPORTED' | 'IMPLEMENTED' | 'BLOCKED' */
|
||||
intentStatus: Record<string, string>;
|
||||
setIntentStatus: (intentId: string, status: string) => void;
|
||||
|
||||
/** Undo queue for DOM style previews (Cmd+Z support). */
|
||||
styleUndoStack: Array<{ artboardId: string; nodeId: string; property: string; previousValue: string }>;
|
||||
pushStyleUndo: (artboardId: string, nodeId: string, property: string, previousValue: string) => void;
|
||||
undoStyleEdit: () => { artboardId: string; nodeId: string; property: string; previousValue: string } | null;
|
||||
|
||||
// ── Phase 6 — Design Language (DesignToken[] from parser/resolver) ────────────
|
||||
/** Loaded design tokens after user uploads a token file — null until uploaded. */
|
||||
designLanguageTokens: DesignToken[] | null;
|
||||
setDesignLanguageTokens: (tokens: DesignToken[] | null) => void;
|
||||
|
||||
/** Root font size in px per artboard — read from rootFontSizePx in READY message.
|
||||
* Used by the token resolver to normalise rem → px. */
|
||||
artboardRootFontSize: Record<string, number>;
|
||||
setArtboardRootFontSize: (artboardId: string, px: number) => void;
|
||||
|
||||
// ── Phase 0 — Artboard thumbnails ────────────────────────────────────────────
|
||||
/** Base64 JPEG data-URL snapshots per artboard — captured when transitioning Active→Far.
|
||||
* Displayed as a static image placeholder while the iframe is unmounted (far state). */
|
||||
artboardThumbnails: Record<string, string | null>;
|
||||
setArtboardThumbnail: (artboardId: string, dataUrl: string | null) => void;
|
||||
|
||||
// ── Phase 4 — Element snapshot (for Code Preview diff) ───────────────────────
|
||||
/** Most-recent PNG snapshot response from SNAPSHOT_READY — consumed by CodeTab. */
|
||||
elementSnapshot: { artboardId: string; nodeId: string; dataUrl: string | null } | null;
|
||||
setElementSnapshot: (artboardId: string, nodeId: string, dataUrl: string | null) => void;
|
||||
}
|
||||
|
||||
export const useCanvas = create<CanvasStore>((set) => ({
|
||||
export const useCanvas = create<CanvasStore>((set, get) => ({
|
||||
activeTool: 'select',
|
||||
setActiveTool: (tool) => set({ activeTool: tool }),
|
||||
|
||||
@@ -176,4 +220,46 @@ export const useCanvas = create<CanvasStore>((set) => ({
|
||||
|
||||
activeAgentSessionId: null,
|
||||
setActiveAgentSessionId: (id) => set({ activeAgentSessionId: id }),
|
||||
|
||||
discoveredRoutes: {},
|
||||
setDiscoveredRoutes: (artboardId, routes) =>
|
||||
set((s) => ({ discoveredRoutes: { ...s.discoveredRoutes, [artboardId]: routes } })),
|
||||
|
||||
selectedArtboardW: null,
|
||||
selectedArtboardH: null,
|
||||
setSelectedArtboardSize: (w, h) => set({ selectedArtboardW: w, selectedArtboardH: h }),
|
||||
|
||||
artboardResizeEvent: null,
|
||||
dispatchArtboardResize: (artboardId, width, height) =>
|
||||
set({ artboardResizeEvent: { artboardId, width, height } }),
|
||||
clearArtboardResize: () => set({ artboardResizeEvent: null }),
|
||||
|
||||
intentStatus: {},
|
||||
setIntentStatus: (intentId, status) =>
|
||||
set((s) => ({ intentStatus: { ...s.intentStatus, [intentId]: status } })),
|
||||
|
||||
styleUndoStack: [],
|
||||
pushStyleUndo: (artboardId, nodeId, property, previousValue) =>
|
||||
set((s) => ({ styleUndoStack: [...s.styleUndoStack, { artboardId, nodeId, property, previousValue }] })),
|
||||
undoStyleEdit: () => {
|
||||
const stack = get().styleUndoStack;
|
||||
if (stack.length === 0) return null;
|
||||
const item = stack[stack.length - 1]!;
|
||||
set({ styleUndoStack: stack.slice(0, -1) });
|
||||
return item;
|
||||
},
|
||||
|
||||
designLanguageTokens: null,
|
||||
setDesignLanguageTokens: (tokens) => set({ designLanguageTokens: tokens }),
|
||||
|
||||
artboardRootFontSize: {},
|
||||
setArtboardRootFontSize: (artboardId, px) =>
|
||||
set((s) => ({ artboardRootFontSize: { ...s.artboardRootFontSize, [artboardId]: px } })),
|
||||
|
||||
artboardThumbnails: {},
|
||||
setArtboardThumbnail: (artboardId, dataUrl) =>
|
||||
set((s) => ({ artboardThumbnails: { ...s.artboardThumbnails, [artboardId]: dataUrl } })),
|
||||
|
||||
elementSnapshot: null,
|
||||
setElementSnapshot: (artboardId, nodeId, dataUrl) => set({ elementSnapshot: { artboardId, nodeId, dataUrl } }),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// ── Canvas store shared types ─────────────────────────────────────────────────
|
||||
// Kept in a separate file so canvas.ts can import them in a type-only position
|
||||
// without circular dependency issues.
|
||||
|
||||
export type TokenType =
|
||||
| 'color'
|
||||
| 'spacing'
|
||||
| 'sizing'
|
||||
| 'borderRadius'
|
||||
| 'borderWidth'
|
||||
| 'fontFamily'
|
||||
| 'fontSize'
|
||||
| 'fontWeight'
|
||||
| 'lineHeight'
|
||||
| 'letterSpacing'
|
||||
| 'shadow'
|
||||
| 'opacity'
|
||||
| 'other';
|
||||
|
||||
/** A single normalised design token from any of the three supported input formats
|
||||
* (Style Dictionary, W3C DTCG, flat CSS variable map — see Phase 6 spec §9.2). */
|
||||
export interface DesignToken {
|
||||
/** CSS custom property name: "--color-primary" */
|
||||
key: string;
|
||||
/** Human label: "Color / Primary" */
|
||||
name: string;
|
||||
/** Top-level group: "color", "spacing", etc. */
|
||||
group: string;
|
||||
/** Resolved CSS value: "#0066FF" */
|
||||
rawValue: string;
|
||||
type: TokenType;
|
||||
description?: string;
|
||||
/** If resolved from an alias chain, lists each intermediate key. */
|
||||
aliasChain?: string[];
|
||||
}
|
||||
|
||||
export interface TokenMatch {
|
||||
token: DesignToken;
|
||||
/** Value matches token exactly (distance === 0). */
|
||||
exact: boolean;
|
||||
/** 0 = exact; higher = further from a match. */
|
||||
distance: number;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user