made tiny updates

This commit is contained in:
SinachPat
2026-05-05 22:06:31 +01:00
parent 19b8f29523
commit fdf64ee72c
39 changed files with 5499 additions and 381 deletions
@@ -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,
});
}
+8 -32
View File
@@ -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 });
}
+118
View File
@@ -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 });
}
+78
View File
@@ -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>
);
}
+193 -42
View File
@@ -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} />
+92 -3
View File
@@ -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;
}
}
+122 -4
View File
@@ -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>();
+232
View File
@@ -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 '';
}
}
+91 -5
View File
@@ -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 ActiveFar.
* 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 } }),
}));
+43
View File
@@ -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;
}