diff --git a/packages/app/package.json b/packages/app/package.json index 19f18be..b49fdac 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -27,6 +27,7 @@ "@pierre/trees": "1.0.0-beta.3", "@supabase/supabase-js": "^2.0.0", "@tanstack/react-query": "^5.62.0", + "@tanstack/react-virtual": "^3.13.24", "@trpc/client": "^11.17.0", "@trpc/react-query": "^11.17.0", "@trpc/server": "^11.17.0", diff --git a/packages/app/src/app/api/ai/drift-report/route.ts b/packages/app/src/app/api/ai/drift-report/route.ts index 84f7701..bdf761f 100644 --- a/packages/app/src/app/api/ai/drift-report/route.ts +++ b/packages/app/src/app/api/ai/drift-report/route.ts @@ -3,14 +3,17 @@ // then calls generateDriftReport. Screenshots are optional; text-only analysis runs // when the artboard has no renderUrl or the screenshot is not provided by the client. -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { AIGateway, generateDriftReport } from '@originmain/ai-layer'; import { serverClient } from '@/lib/supabase'; export async function POST(req: NextRequest) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const callerEmail = user.primaryEmailAddress?.emailAddress; + if (!callerEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const body = (await req.json().catch(() => ({}))) as { artboard_id?: string; @@ -48,7 +51,7 @@ export async function POST(req: NextRequest) { .from('team_members') .select('id') .eq('workspace_id', artboard.workspace_id) - .eq('user_id', userId) + .eq('email', callerEmail) .limit(1) .single(); @@ -56,14 +59,15 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); } - // Fetch the most recently updated Design Language File for this workspace (optional) - const { data: dlf } = await db - .from('design_language_files') - .select('schema_jsonb, name') + // Fetch the active Design Language for this workspace (Phase 6 table). + // raw_json holds the original uploaded token file; pass it to generateDriftReport + // for context. Absence of a design language is non-fatal — analysis runs without it. + const { data: dl } = await db + .from('design_languages') + .select('raw_json, name') .eq('workspace_id', artboard.workspace_id) - .order('updated_at', { ascending: false }) .limit(1) - .single() as unknown as { data: { schema_jsonb: unknown; name: string } | null }; + .single() as unknown as { data: { raw_json: unknown; name: string } | null }; const meta = artboard.metadata_jsonb; const artboardContext = [ @@ -77,7 +81,7 @@ export async function POST(req: NextRequest) { const gateway = new AIGateway(); const result = await generateDriftReport(gateway, { artboardContext, - ...(dlf ? { dlfJson: JSON.stringify(dlf.schema_jsonb) } : {}), + ...(dl ? { dlfJson: JSON.stringify(dl.raw_json) } : {}), ...(body.screenshot_base64 ? { screenshotBase64: body.screenshot_base64 } : {}), }); return NextResponse.json(result); diff --git a/packages/app/src/app/api/artboards/[id]/route.ts b/packages/app/src/app/api/artboards/[id]/route.ts index dcc1976..b396749 100644 --- a/packages/app/src/app/api/artboards/[id]/route.ts +++ b/packages/app/src/app/api/artboards/[id]/route.ts @@ -36,6 +36,9 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) { const patch: Partial = {}; if (typeof body.name === 'string' && body.name.trim()) patch.name = body.name.trim(); if (body.metadata_jsonb !== undefined) patch.metadata_jsonb = body.metadata_jsonb; + // isolation_props are stored as a direct DB column and patched independently + // of metadata_jsonb so IsolationFrame can receive live prop updates. + if (body.isolation_props !== undefined) patch.isolation_props = body.isolation_props; if (Object.keys(patch).length === 0) return NextResponse.json({ error: 'No valid fields to update' }, { status: 400 }); diff --git a/packages/app/src/app/api/cli-auth/route.ts b/packages/app/src/app/api/cli-auth/route.ts index cef2f93..1922745 100644 --- a/packages/app/src/app/api/cli-auth/route.ts +++ b/packages/app/src/app/api/cli-auth/route.ts @@ -17,7 +17,7 @@ // // spec: SOURCE-AWARE-CANVAS.md Phase 5 §8.6 -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import { issueWorkspaceToken } from '@originmain/agent-bridge'; @@ -59,38 +59,45 @@ export async function GET(req: NextRequest): Promise { } // ── Require authentication ──────────────────────────────────────────────── - const { userId } = await auth(); - if (!userId) { - // Redirect to sign-in, then back here after login + const user = await currentUser(); + if (!user) { const signInUrl = new URL('/sign-in', APP_URL); signInUrl.searchParams.set('redirect_url', req.nextUrl.toString()); return NextResponse.redirect(signInUrl.toString()); } + const email = user.primaryEmailAddress?.emailAddress; + if (!email) { + return errorRedirect(callbackUrl, 'No verified email address on this account.'); + } + // ── Resolve workspace ───────────────────────────────────────────────────── const db = serverClient(); let workspaceId = workspaceIdParam; if (!workspaceId) { - // Use the user's first workspace membership + // Use the user's first workspace membership. const { data: member } = await db .from('team_members') .select('workspace_id') - .eq('user_id', userId) + .eq('email', email) .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); + 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 + // 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) + .eq('email', email) .limit(1) .single(); diff --git a/packages/app/src/app/api/design-language/route.ts b/packages/app/src/app/api/design-language/route.ts index b9b0e10..c38267a 100644 --- a/packages/app/src/app/api/design-language/route.ts +++ b/packages/app/src/app/api/design-language/route.ts @@ -1,125 +1,129 @@ -// GET /api/design-language?workspaceId= → active design language file -// GET /api/design-language?workspaceId=&all=1 → all versions (history) -// POST /api/design-language → upload new version (deactivates prior) +// GET /api/design-language?workspaceId= → active DesignLanguage row +// GET /api/design-language?workspaceId=&all=1 → version history (newest first) +// POST /api/design-language → upload / replace token file -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; -import { getActiveDesignLanguageFile } from '@originmain/origin-graph'; -import type { InsertDesignLanguageFile, DesignLanguageFile } from '@originmain/origin-graph'; +import { + getDesignLanguage, + upsertDesignLanguage, + getDesignLanguageVersions, +} from '@originmain/origin-graph'; +import type { InsertDesignLanguage } from '@originmain/origin-graph'; -export async function GET(req: NextRequest) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - const workspaceId = req.nextUrl.searchParams.get('workspaceId'); - if (!workspaceId) return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }); +// ── Auth + workspace-member guard (shared) ──────────────────────────────────── +async function assertMember(email: string, workspaceId: string) { const db = serverClient(); - - // Guard: caller must be a member of the target workspace. - const { data: member } = await db + const { data } = await db .from('team_members') .select('id') .eq('workspace_id', workspaceId) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); - if (!member) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + return Boolean(data); +} - // ?all=1 returns the full version history, newest first. - if (req.nextUrl.searchParams.get('all') === '1') { - const { data, error } = await (db - .from('design_language_files') - .select('*') - .eq('workspace_id', workspaceId) - .order('version', { ascending: false }) as unknown as Promise<{ - data: DesignLanguageFile[]; - error: { message: string } | null; - }>); - if (error) return NextResponse.json({ error: error.message }, { status: 500 }); - return NextResponse.json(data ?? []); +// ── GET ─────────────────────────────────────────────────────────────────────── + +export async function GET(req: NextRequest) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); + + const workspaceId = req.nextUrl.searchParams.get('workspaceId'); + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }); } - // Default: return the single active file. + if (!(await assertMember(email, workspaceId))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const db = serverClient(); + + // ?all=1 — full version history for the workspace's design language. + if (req.nextUrl.searchParams.get('all') === '1') { + const dl = await getDesignLanguage(db, workspaceId).catch(() => null); + if (!dl) return NextResponse.json([]); + const versions = await getDesignLanguageVersions(db, dl.id).catch(() => []); + return NextResponse.json(versions); + } + + // Default — active token file (one row per workspace). + const dl = await getDesignLanguage(db, workspaceId).catch((err: Error) => { + throw new Response(JSON.stringify({ error: err.message }), { status: 500 }); + }); + return NextResponse.json(dl ?? null); +} + +// ── POST ────────────────────────────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); + + let body: Partial; try { - const file = await getActiveDesignLanguageFile(db, workspaceId); - if (!file) return NextResponse.json(null); - return NextResponse.json(file); + body = (await req.json()) as Partial; + } catch { + return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); + } + + const { workspace_id, name, raw_json, normalized, source_format, token_count } = body; + + if (!workspace_id || !raw_json || !normalized || !source_format) { + return NextResponse.json( + { error: 'Missing required fields: workspace_id, raw_json, normalized, source_format' }, + { status: 400 }, + ); + } + + if (!(await assertMember(email, workspace_id))) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); + } + + const db = serverClient(); + + // Determine next version number: current + 1, or 1 for first upload. + const existing = await getDesignLanguage(db, workspace_id).catch(() => null); + const version = existing ? existing.version + 1 : 1; + + const row: InsertDesignLanguage = { + workspace_id, + name: name ?? 'Design Language', + raw_json, + normalized, + source_format, + token_count: token_count ?? (Array.isArray(normalized) ? normalized.length : 0), + version, + }; + + try { + const dl = await upsertDesignLanguage(db, row); + + // Insert a version snapshot — the prune trigger fires after this insert + // and deletes any rows beyond the 10-most-recent. + await db + .from('design_language_versions') + .insert({ + design_language_id: dl.id, + version: dl.version, + raw_json: dl.raw_json, + normalized: dl.normalized, + source_format: dl.source_format, + }); + + return NextResponse.json(dl, { status: 201 }); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; return NextResponse.json({ error: message }, { status: 500 }); } } - -export async function POST(req: NextRequest) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - - let body: InsertDesignLanguageFile & { is_active?: boolean }; - try { - body = (await req.json()) as InsertDesignLanguageFile & { is_active?: boolean }; - } catch { - return NextResponse.json({ error: 'Invalid JSON body' }, { status: 400 }); - } - - if (!body.workspace_id || !body.name || !body.schema_jsonb) { - return NextResponse.json( - { error: 'Missing required fields: workspace_id, name, schema_jsonb' }, - { status: 400 }, - ); - } - - const db = serverClient(); - - // Guard: caller must be a member of the target workspace. - const { data: postMember } = await db - .from('team_members') - .select('id') - .eq('workspace_id', body.workspace_id) - .eq('user_id', userId) - .limit(1) - .single(); - if (!postMember) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - - // ── Compute next version number ──────────────────────────────────────────── - const existing = await getActiveDesignLanguageFile(db, body.workspace_id); - const version = existing ? existing.version + 1 : 1; - - // ── Deactivate all prior versions for this workspace ─────────────────────── - // This must happen before the insert so the partial unique index - // (only one is_active = true per workspace) is not violated. - if (existing) { - const { error: deactivateError } = await (db - .from('design_language_files') - .update({ is_active: false }) - .eq('workspace_id', body.workspace_id) as unknown as Promise<{ - data: unknown; - error: { message: string } | null; - }>); - if (deactivateError) { - return NextResponse.json( - { error: `Failed to deactivate previous version: ${deactivateError.message}` }, - { status: 500 }, - ); - } - } - - // ── Insert new version as the active one ─────────────────────────────────── - const { data, error } = await (db - .from('design_language_files') - .insert({ - ...body, - version, - is_active: true, - created_by: userId, - }) - .select() - .single() as unknown as Promise<{ - data: DesignLanguageFile; - error: { message: string } | null; - }>); - - if (error) return NextResponse.json({ error: error.message }, { status: 500 }); - return NextResponse.json(data, { status: 201 }); -} diff --git a/packages/app/src/app/api/diffs/route.ts b/packages/app/src/app/api/diffs/route.ts index 76eb58e..6f039cd 100644 --- a/packages/app/src/app/api/diffs/route.ts +++ b/packages/app/src/app/api/diffs/route.ts @@ -1,15 +1,15 @@ // GET /api/diffs?artboardId= → list diffs for an artboard // POST /api/diffs → create draft diff -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import { getDiffs, createDiff } from '@originmain/origin-graph'; import type { InsertIntentDiff } from '@originmain/origin-graph'; export async function GET(req: NextRequest) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const artboardId = req.nextUrl.searchParams.get('artboardId'); if (!artboardId) return NextResponse.json({ error: 'artboardId is required' }, { status: 400 }); @@ -25,11 +25,14 @@ export async function GET(req: NextRequest) { } export async function POST(req: NextRequest) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const body = (await req.json()) as Omit; - const insert: InsertIntentDiff = { ...body, author_id: userId }; + const authorEmail = user.primaryEmailAddress?.emailAddress; + if (!authorEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); + + const body = (await req.json()) as Omit; + const insert: InsertIntentDiff = { ...body, author_email: authorEmail }; try { const db = serverClient(); diff --git a/packages/app/src/app/api/intent/route.ts b/packages/app/src/app/api/intent/route.ts index 8857471..9023431 100644 --- a/packages/app/src/app/api/intent/route.ts +++ b/packages/app/src/app/api/intent/route.ts @@ -15,7 +15,7 @@ * (or receives an INTENT_RECEIVED WebSocket push in Phase 5+). */ -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { storePendingIntent } from '@originmain/agent-bridge'; import { createDiff } from '@originmain/origin-graph'; @@ -37,10 +37,11 @@ interface IntentPayload { } export async function POST(req: NextRequest) { - const { userId } = await auth(); - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const authorEmail = user.primaryEmailAddress?.emailAddress; + if (!authorEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); let body: IntentPayload; try { @@ -87,7 +88,7 @@ export async function POST(req: NextRequest) { const row = await createDiff(db, { artboard_id: artboardId, - author_id: userId, + author_email: authorEmail, changes: changesRecord, aggregate_summary: summary ?? `${componentName} style changes`, status: 'EXPORTED', diff --git a/packages/app/src/app/api/workspace/[id]/invite/route.ts b/packages/app/src/app/api/workspace/[id]/invite/route.ts index 80f9cff..c09b8f4 100644 --- a/packages/app/src/app/api/workspace/[id]/invite/route.ts +++ b/packages/app/src/app/api/workspace/[id]/invite/route.ts @@ -1,7 +1,7 @@ -// POST /api/workspace/:id/invite → add a member by Clerk userId -// DELETE /api/workspace/:id/invite → remove a member by Clerk userId (owners only) +// POST /api/workspace/:id/invite → add a member by email address +// DELETE /api/workspace/:id/invite → remove a member by email address (owners only) -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import { addTeamMember, removeTeamMember } from '@originmain/origin-graph'; @@ -9,33 +9,43 @@ import type { TeamRole } from '@originmain/origin-graph'; type Ctx = { params: Promise<{ id: string }> }; +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + export async function POST(req: NextRequest, { params }: Ctx) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const caller = await currentUser(); + if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const callerEmail = caller.primaryEmailAddress?.emailAddress; + if (!callerEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const { id: workspaceId } = await params; const db = serverClient(); - // Only workspace owners can invite - const { data: owner } = await db + // Only workspace owners can invite. + const { data: ownerRow } = await db .from('workspaces') .select('id') .eq('id', workspaceId) - .eq('owner_id', userId) + .eq('owner_email', callerEmail) .single(); - if (!owner) return NextResponse.json({ error: 'Forbidden — only owners can invite members' }, { status: 403 }); + if (!ownerRow) + return NextResponse.json({ error: 'Forbidden — only owners can invite members' }, { status: 403 }); - const body = (await req.json().catch(() => ({}))) as { userId?: string; role?: TeamRole }; - if (!body.userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + const body = (await req.json().catch(() => ({}))) as { email?: string; role?: TeamRole }; + + if (!body.email || !EMAIL_RE.test(body.email)) + return NextResponse.json({ error: 'A valid email address is required' }, { status: 400 }); + + const inviteEmail = body.email.toLowerCase().trim(); const role: TeamRole = body.role ?? 'DESIGNER'; - // Check if already a member + // Check if already a member. const { data: existing } = await db .from('team_members') .select('id') .eq('workspace_id', workspaceId) - .eq('user_id', body.userId) + .eq('email', inviteEmail) .limit(1) .single(); @@ -44,7 +54,7 @@ export async function POST(req: NextRequest, { params }: Ctx) { try { const member = await addTeamMember(db, { workspace_id: workspaceId, - user_id: body.userId, + email: inviteEmail, role, }); return NextResponse.json(member, { status: 201 }); @@ -55,28 +65,33 @@ export async function POST(req: NextRequest, { params }: Ctx) { } export async function DELETE(req: NextRequest, { params }: Ctx) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const caller = await currentUser(); + if (!caller) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const callerEmail = caller.primaryEmailAddress?.emailAddress; + if (!callerEmail) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const { id: workspaceId } = await params; const db = serverClient(); - const body = (await req.json().catch(() => ({}))) as { userId?: string }; - if (!body.userId) return NextResponse.json({ error: 'userId is required' }, { status: 400 }); + const body = (await req.json().catch(() => ({}))) as { email?: string }; + if (!body.email) return NextResponse.json({ error: 'email is required' }, { status: 400 }); - // Only owners can remove; a member can remove themselves - const { data: owner } = await db + const targetEmail = body.email.toLowerCase().trim(); + + // Owners can remove anyone; members can remove themselves. + const { data: ownerRow } = await db .from('workspaces') .select('id') .eq('id', workspaceId) - .eq('owner_id', userId) + .eq('owner_email', callerEmail) .single(); - if (!owner && body.userId !== userId) + if (!ownerRow && targetEmail !== callerEmail) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); try { - await removeTeamMember(db, workspaceId, body.userId); + await removeTeamMember(db, workspaceId, targetEmail); return new NextResponse(null, { status: 204 }); } catch (err) { const message = err instanceof Error ? err.message : 'Unknown error'; diff --git a/packages/app/src/app/api/workspace/[id]/projects/[pid]/route.ts b/packages/app/src/app/api/workspace/[id]/projects/[pid]/route.ts index 112cdf2..41d331c 100644 --- a/packages/app/src/app/api/workspace/[id]/projects/[pid]/route.ts +++ b/packages/app/src/app/api/workspace/[id]/projects/[pid]/route.ts @@ -2,7 +2,7 @@ // PATCH /api/workspace/:id/projects/:pid → update name / app_url / framework / description // DELETE /api/workspace/:id/projects/:pid → permanently delete project -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import { updateProject, deleteProject } from '@originmain/origin-graph'; @@ -10,23 +10,27 @@ import type { InsertProject, Project } from '@originmain/origin-graph'; type Ctx = { params: Promise<{ id: string; pid: string }> }; -async function assertMember(workspaceId: string, userId: string): Promise { +async function assertMember(workspaceId: string, email: string): Promise { const db = serverClient(); const { data } = await db .from('team_members') .select('id') .eq('workspace_id', workspaceId) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); return !!data; } export async function GET(_req: NextRequest, { params }: Ctx) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); + const { id: workspaceId, pid } = await params; - if (!(await assertMember(workspaceId, userId))) + if (!(await assertMember(workspaceId, email))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); const { data, error } = await serverClient() @@ -42,10 +46,14 @@ export async function GET(_req: NextRequest, { params }: Ctx) { } export async function PATCH(req: NextRequest, { params }: Ctx) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); + const { id: workspaceId, pid } = await params; - if (!(await assertMember(workspaceId, userId))) + if (!(await assertMember(workspaceId, email))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); const body = (await req.json().catch(() => ({}))) as Partial; @@ -68,10 +76,14 @@ export async function PATCH(req: NextRequest, { params }: Ctx) { } export async function DELETE(_req: NextRequest, { params }: Ctx) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); + const { id: workspaceId, pid } = await params; - if (!(await assertMember(workspaceId, userId))) + if (!(await assertMember(workspaceId, email))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); try { diff --git a/packages/app/src/app/api/workspace/[id]/projects/route.ts b/packages/app/src/app/api/workspace/[id]/projects/route.ts index 494f446..75c14ee 100644 --- a/packages/app/src/app/api/workspace/[id]/projects/route.ts +++ b/packages/app/src/app/api/workspace/[id]/projects/route.ts @@ -1,17 +1,21 @@ // GET /api/workspace/[id]/projects — list projects in a workspace // POST /api/workspace/[id]/projects — create a new project -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import type { Project, InsertProject } from '@originmain/origin-graph'; -async function assertMember(db: ReturnType, workspaceId: string, userId: string) { +async function assertMember( + db: ReturnType, + workspaceId: string, + email: string, +) { const { data } = await db .from('team_members') .select('id') .eq('workspace_id', workspaceId) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); return !!data; @@ -21,13 +25,16 @@ export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const { id: workspaceId } = await params; const db = serverClient(); - if (!(await assertMember(db, workspaceId, userId))) + if (!(await assertMember(db, workspaceId, email))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); const { data, error } = await db @@ -45,13 +52,16 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const { id: workspaceId } = await params; const db = serverClient(); - if (!(await assertMember(db, workspaceId, userId))) + if (!(await assertMember(db, workspaceId, email))) return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); const body = await req.json().catch(() => ({})); diff --git a/packages/app/src/app/api/workspace/[id]/route.ts b/packages/app/src/app/api/workspace/[id]/route.ts index 02e3732..8359620 100644 --- a/packages/app/src/app/api/workspace/[id]/route.ts +++ b/packages/app/src/app/api/workspace/[id]/route.ts @@ -2,7 +2,7 @@ // PATCH /api/workspace/:id → rename workspace (owner only) // DELETE /api/workspace/:id → delete workspace and all its data (owner only) -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import { deleteWorkspace } from '@originmain/origin-graph'; @@ -10,20 +10,29 @@ import type { Workspace } from '@originmain/origin-graph'; type RouteContext = { params: Promise<{ id: string }> }; -async function assertOwner(db: ReturnType, workspaceId: string, userId: string) { +async function getCallerEmail(): Promise { + const user = await currentUser(); + return user?.primaryEmailAddress?.emailAddress ?? null; +} + +async function assertOwner( + db: ReturnType, + workspaceId: string, + email: string, +) { const { data } = await db .from('team_members') .select('role') .eq('workspace_id', workspaceId) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); return (data as { role: string } | null)?.role === 'OWNER'; } export async function GET(_req: NextRequest, { params }: RouteContext) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const email = await getCallerEmail(); + if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { id } = await params; const db = serverClient(); @@ -33,7 +42,7 @@ export async function GET(_req: NextRequest, { params }: RouteContext) { .from('team_members') .select('id') .eq('workspace_id', id) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); @@ -46,13 +55,13 @@ export async function GET(_req: NextRequest, { params }: RouteContext) { } export async function PATCH(req: NextRequest, { params }: RouteContext) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const email = await getCallerEmail(); + if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { id } = await params; const db = serverClient(); - const isOwner = await assertOwner(db, id, userId); + const isOwner = await assertOwner(db, id, email); if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can rename' }, { status: 403 }); const body = (await req.json().catch(() => ({}))) as { name?: string }; @@ -73,13 +82,13 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) { } export async function DELETE(_req: NextRequest, { params }: RouteContext) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const email = await getCallerEmail(); + if (!email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const { id } = await params; const db = serverClient(); - const isOwner = await assertOwner(db, id, userId); + const isOwner = await assertOwner(db, id, email); if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can delete' }, { status: 403 }); try { diff --git a/packages/app/src/app/api/workspace/[id]/tokens/route.ts b/packages/app/src/app/api/workspace/[id]/tokens/route.ts index e4f38bd..cad1bb7 100644 --- a/packages/app/src/app/api/workspace/[id]/tokens/route.ts +++ b/packages/app/src/app/api/workspace/[id]/tokens/route.ts @@ -2,7 +2,7 @@ // Issues a signed HMAC workspace token for IDE integrations (Cursor, Claude Code). // Returns the token plus ready-to-paste config snippets for each IDE. -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import { @@ -16,18 +16,21 @@ export async function POST( req: NextRequest, { params }: { params: Promise<{ id: string }> }, ) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const { id: workspaceId } = await params; const db = serverClient(); - // Verify membership + // Verify membership. const { data: member } = await db .from('team_members') .select('id') .eq('workspace_id', workspaceId) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); diff --git a/packages/app/src/app/api/workspace/route.ts b/packages/app/src/app/api/workspace/route.ts index 60477b3..3ccef53 100644 --- a/packages/app/src/app/api/workspace/route.ts +++ b/packages/app/src/app/api/workspace/route.ts @@ -2,44 +2,42 @@ // Returns the authenticated user's workspace. Auto-creates one on first visit // (FREE plan, name derived from Clerk user display name or email). -import { auth, currentUser } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import type { Workspace, InsertWorkspace } from '@originmain/origin-graph'; export async function GET() { - const { userId } = await auth(); - if (!userId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const db = serverClient(); - // Look up an existing workspace owned by this Clerk user. - const { data: existing, error: findError } = await db - .from('workspaces') - .select('*') - .eq('owner_id', userId) + // Look up an existing workspace where this user is a member. + const { data: existing } = await db + .from('team_members') + .select('workspace_id') + .eq('email', email) .limit(1) - .single(); - - if (findError && findError.code !== 'PGRST116') { - return NextResponse.json({ error: findError.message }, { status: 500 }); - } + .single() as unknown as { data: { workspace_id: string } | null }; if (existing) { - return NextResponse.json(existing as Workspace); + const { data: ws, error } = await db + .from('workspaces') + .select('*') + .eq('id', existing.workspace_id) + .single(); + if (!error && ws) return NextResponse.json(ws as Workspace); } - // No workspace yet — auto-create one. Fetch the Clerk user for a friendly name. - const user = await currentUser(); - const name = - user?.fullName ?? - user?.emailAddresses[0]?.emailAddress ?? - 'My Workspace'; + // No workspace yet — auto-create one with a friendly name. + const name = user.fullName ?? email; const insert: InsertWorkspace = { - owner_id: userId, + owner_email: email, name, plan: 'FREE', settings_jsonb: {}, @@ -55,10 +53,10 @@ export async function GET() { return NextResponse.json({ error: insertError.message }, { status: 500 }); } - // Also add the owner as a team member. + // Add the owner as a team member so GET /api/workspaces can find it. await db.from('team_members').insert({ workspace_id: (created as Workspace).id, - user_id: userId, + email, role: 'OWNER', }); diff --git a/packages/app/src/app/api/workspaces/route.ts b/packages/app/src/app/api/workspaces/route.ts index 1856b85..c896834 100644 --- a/packages/app/src/app/api/workspaces/route.ts +++ b/packages/app/src/app/api/workspaces/route.ts @@ -1,14 +1,17 @@ // GET /api/workspaces — list every workspace the signed-in user belongs to // POST /api/workspaces — create a new workspace + add creator as OWNER -import { auth, currentUser } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; import { serverClient } from '@/lib/supabase'; import type { Workspace, InsertWorkspace } from '@originmain/origin-graph'; export async function GET() { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const db = serverClient(); @@ -16,7 +19,7 @@ export async function GET() { const { data: memberships, error: mErr } = await db .from('team_members') .select('workspace_id') - .eq('user_id', userId); + .eq('email', email); if (mErr) return NextResponse.json({ error: mErr.message }, { status: 500 }); @@ -35,8 +38,11 @@ export async function GET() { } export async function POST(req: NextRequest) { - const { userId } = await auth(); - if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const user = await currentUser(); + if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + + const email = user.primaryEmailAddress?.emailAddress; + if (!email) return NextResponse.json({ error: 'No verified email on account' }, { status: 401 }); const body = await req.json().catch(() => ({})); const name = typeof body.name === 'string' && body.name.trim() @@ -48,7 +54,7 @@ export async function POST(req: NextRequest) { const db = serverClient(); const insert: InsertWorkspace = { - owner_id: userId, + owner_email: email, name, plan: 'FREE', settings_jsonb: {}, @@ -67,7 +73,7 @@ export async function POST(req: NextRequest) { // Add creator as OWNER team member so GET /api/workspaces can find it. await db.from('team_members').insert({ workspace_id: workspace.id, - user_id: userId, + email, role: 'OWNER', }); diff --git a/packages/app/src/app/workspace/[wid]/page.tsx b/packages/app/src/app/workspace/[wid]/page.tsx index 6b00409..6df59e5 100644 --- a/packages/app/src/app/workspace/[wid]/page.tsx +++ b/packages/app/src/app/workspace/[wid]/page.tsx @@ -1,4 +1,4 @@ -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import Link from 'next/link'; import { serverClient } from '@/lib/supabase'; @@ -18,9 +18,10 @@ export async function generateMetadata({ params }: { params: Promise<{ wid: stri export default async function WorkspacePage({ params }: { params: Promise<{ wid: string }> }) { const { wid } = await params; - const { userId } = await auth(); - if (!userId) redirect('/sign-in'); + const user = await currentUser(); + if (!user) redirect('/sign-in'); + const email = user.primaryEmailAddress?.emailAddress ?? ''; const db = serverClient(); // Verify membership @@ -28,7 +29,7 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid: .from('team_members') .select('id') .eq('workspace_id', wid) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); diff --git a/packages/app/src/app/workspace/[wid]/plugins/page.tsx b/packages/app/src/app/workspace/[wid]/plugins/page.tsx index ddeca06..fa27d99 100644 --- a/packages/app/src/app/workspace/[wid]/plugins/page.tsx +++ b/packages/app/src/app/workspace/[wid]/plugins/page.tsx @@ -1,4 +1,4 @@ -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import Link from 'next/link'; import { serverClient } from '@/lib/supabase'; @@ -14,9 +14,10 @@ export async function generateMetadata({ params }: { params: Promise<{ wid: stri export default async function WorkspacePluginsPage({ params }: { params: Promise<{ wid: string }> }) { const { wid } = await params; - const { userId } = await auth(); - if (!userId) redirect('/sign-in'); + const user = await currentUser(); + if (!user) redirect('/sign-in'); + const email = user.primaryEmailAddress?.emailAddress ?? ''; const db = serverClient(); // Verify membership @@ -24,7 +25,7 @@ export default async function WorkspacePluginsPage({ params }: { params: Promise .from('team_members') .select('id') .eq('workspace_id', wid) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); diff --git a/packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx b/packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx index 1ebded1..f29dcdf 100644 --- a/packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx +++ b/packages/app/src/app/workspace/[wid]/project/[pid]/page.tsx @@ -1,4 +1,4 @@ -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import { serverClient } from '@/lib/supabase'; import { AppChrome } from '@/components/chrome/AppChrome'; @@ -16,9 +16,10 @@ export default async function ProjectCanvasPage({ params: Promise<{ wid: string; pid: string }>; }) { const { wid, pid } = await params; - const { userId } = await auth(); - if (!userId) redirect('/sign-in'); + const user = await currentUser(); + if (!user) redirect('/sign-in'); + const email = user.primaryEmailAddress?.emailAddress ?? ''; const db = serverClient(); // Verify membership @@ -26,7 +27,7 @@ export default async function ProjectCanvasPage({ .from('team_members') .select('id') .eq('workspace_id', wid) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); diff --git a/packages/app/src/app/workspace/[wid]/project/[pid]/settings/page.tsx b/packages/app/src/app/workspace/[wid]/project/[pid]/settings/page.tsx index e0e08fb..2822eeb 100644 --- a/packages/app/src/app/workspace/[wid]/project/[pid]/settings/page.tsx +++ b/packages/app/src/app/workspace/[wid]/project/[pid]/settings/page.tsx @@ -1,4 +1,4 @@ -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import { serverClient } from '@/lib/supabase'; import { AppHeader } from '@/components/shell/AppHeader'; @@ -19,9 +19,10 @@ export default async function ProjectSettingsPage({ params: Promise<{ wid: string; pid: string }>; }) { const { wid, pid } = await params; - const { userId } = await auth(); - if (!userId) redirect('/sign-in'); + const user = await currentUser(); + if (!user) redirect('/sign-in'); + const email = user.primaryEmailAddress?.emailAddress ?? ''; const db = serverClient(); // Verify membership and role @@ -29,7 +30,7 @@ export default async function ProjectSettingsPage({ .from('team_members') .select('role') .eq('workspace_id', wid) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single() as unknown as { data: { role: string } | null }; diff --git a/packages/app/src/app/workspace/[wid]/settings/page.tsx b/packages/app/src/app/workspace/[wid]/settings/page.tsx index 71eb6fe..0227b53 100644 --- a/packages/app/src/app/workspace/[wid]/settings/page.tsx +++ b/packages/app/src/app/workspace/[wid]/settings/page.tsx @@ -1,4 +1,4 @@ -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import { serverClient } from '@/lib/supabase'; import { AppHeader } from '@/components/shell/AppHeader'; @@ -14,9 +14,10 @@ export async function generateMetadata({ params }: { params: Promise<{ wid: stri export default async function WorkspaceSettingsPage({ params }: { params: Promise<{ wid: string }> }) { const { wid } = await params; - const { userId } = await auth(); - if (!userId) redirect('/sign-in'); + const user = await currentUser(); + if (!user) redirect('/sign-in'); + const email = user.primaryEmailAddress?.emailAddress ?? ''; const db = serverClient(); // Verify membership @@ -24,7 +25,7 @@ export default async function WorkspaceSettingsPage({ params }: { params: Promis .from('team_members') .select('id, role') .eq('workspace_id', wid) - .eq('user_id', userId) + .eq('email', email) .limit(1) .single(); diff --git a/packages/app/src/app/workspaces/page.tsx b/packages/app/src/app/workspaces/page.tsx index 9ecf1f6..f54a60a 100644 --- a/packages/app/src/app/workspaces/page.tsx +++ b/packages/app/src/app/workspaces/page.tsx @@ -1,4 +1,4 @@ -import { auth } from '@clerk/nextjs/server'; +import { currentUser } from '@clerk/nextjs/server'; import { redirect } from 'next/navigation'; import Link from 'next/link'; import { serverClient } from '@/lib/supabase'; @@ -8,12 +8,12 @@ import type { Workspace } from '@originmain/origin-graph'; export const metadata = { title: 'Workspaces — Originmain' }; -async function getWorkspaces(userId: string): Promise { +async function getWorkspaces(email: string): Promise { const db = serverClient(); const { data: memberships } = await db .from('team_members') .select('workspace_id') - .eq('user_id', userId); + .eq('email', email); const ids = (memberships ?? []).map((m) => (m as { workspace_id: string }).workspace_id); if (ids.length === 0) return []; @@ -28,10 +28,11 @@ async function getWorkspaces(userId: string): Promise { } export default async function WorkspacesPage() { - const { userId } = await auth(); - if (!userId) redirect('/sign-in'); + const user = await currentUser(); + if (!user) redirect('/sign-in'); - const workspaces = await getWorkspaces(userId); + const email = user.primaryEmailAddress?.emailAddress ?? ''; + const workspaces = await getWorkspaces(email); // New user with no workspaces yet → send to onboarding if (workspaces.length === 0) redirect('/onboarding'); diff --git a/packages/app/src/components/chrome/AppChrome.tsx b/packages/app/src/components/chrome/AppChrome.tsx index 811134a..b0cd216 100644 --- a/packages/app/src/components/chrome/AppChrome.tsx +++ b/packages/app/src/components/chrome/AppChrome.tsx @@ -45,32 +45,25 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName } const db = browserClient() as unknown as SupabaseClient; const channel = db - .channel(`dlf:${workspaceId}`) + .channel(`dl:${workspaceId}`) .on( 'postgres_changes', { event: '*', schema: 'public', - table: 'design_language_files', + table: 'design_languages', filter: `workspace_id=eq.${workspaceId}`, }, (payload) => { - // Only react to rows that are marked as the active version. + // One row per workspace — any INSERT/UPDATE means new active tokens. const row = (payload.new ?? payload.old) as Record | undefined; - if (!row || !row['is_active']) return; + if (!row) return; - // Re-parse tokens from the updated schema_jsonb. - const schemaJsonb = row['schema_jsonb']; - if (!schemaJsonb || typeof schemaJsonb !== 'object') return; + // `normalized` is already a DesignToken[] stored as JSONB — use directly. + const normalized = row['normalized']; + if (!Array.isArray(normalized)) return; - import('@originmain/design-language').then(({ parseTokenFile }) => { - try { - const tokens = parseTokenFile(schemaJsonb); - setDesignLanguageTokens(tokens as Parameters[0]); - } catch { - // Malformed token file in DB — don't crash the session - } - }).catch(() => { /* design-language package unavailable */ }); + setDesignLanguageTokens(normalized as Parameters[0]); }, ) .subscribe(); diff --git a/packages/app/src/components/inspector/CodeTab.tsx b/packages/app/src/components/inspector/CodeTab.tsx new file mode 100644 index 0000000..eb1b692 --- /dev/null +++ b/packages/app/src/components/inspector/CodeTab.tsx @@ -0,0 +1,645 @@ +'use client'; + +// ── Code Tab (Phase 4) ──────────────────────────────────────────────────────── +// Shows a live source diff for the selected component based on pending style +// edits, with hunk-level accept/reject, Send-to-Agent, and Realtime status. +// Extracted from Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2. +// +// Phase 4 additions: +// • diffIndicators: 'bars' — gutter bar change indicators +// • lineAnnotations — token match badges on addition lines +// • renderAnnotation — renders the token key pill +// • onTokenEnter/Leave — hover tooltip showing matched design token +// • rejectedHunks Set — tracks explicitly rejected hunks +// • allRejected guard — disables Send-to-Agent when all hunks rejected + +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { useCanvas } from '@/store/canvas'; +import { useHistory } from '@/store/history'; +import { useDiffs } from '@/hooks/useDiffs'; +import { useIndexer } from '@/hooks/useIndexer'; +import { useCanvasTheme } from '@/store/canvasTheme'; +import { generatePatch } from '@originmain/diff-engine'; +import type { PropChange } from '@originmain/diff-engine'; +import type { FiberNode } from '@originmain/renderer'; +import type { IntentDiff } from '@originmain/origin-graph'; +import { FileDiff as PierreDiff } from '@pierre/diffs/react'; +import type { DiffLineAnnotation } from '@pierre/diffs/react'; +import { processFile, diffAcceptRejectHunk } from '@pierre/diffs'; +import type { FileDiffMetadata } from '@pierre/diffs'; +import { resolveValueToToken } from '@originmain/design-language'; +import { browserClient } from '@/lib/supabase'; +import type { SupabaseClient } from '@supabase/supabase-js'; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +/** + * Best-effort application of PropChange values to a source file string. + * Searches for `propKey: oldValue` patterns and replaces with new values. + */ +function applyChangesToSource(source: string, changes: PropChange[]): string { + let result = source; + for (const change of changes) { + if (change.before === undefined || change.before === change.after) continue; + const escapedBefore = String(change.before).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const re = new RegExp(`(${change.key}\\s*:\\s*)${escapedBefore}`, 'g'); + result = result.replace(re, `$1${String(change.after)}`); + } + return result; +} + +// ── Sub-components ───────────────────────────────────────────────────────────── + +const STATUS_COLOR: Record = { + DRAFT: '#FFBA7B', + REVIEWED: '#7EB8FF', + APPLIED: '#7DD3A8', + REJECTED: '#FF8080', +}; + +export function SavedDiffRow({ diff }: { diff: IntentDiff }) { + const T = useCanvasTheme(); + const changes = diff.changes as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null; + const count = (changes?.propChanges?.length ?? 0) + (changes?.styleChanges?.length ?? 0); + const color = STATUS_COLOR[diff.status] ?? T.dim; + return ( +
+
+ + {diff.status} + + + {count} change{count !== 1 ? 's' : ''} + +
+ {diff.aggregate_summary && ( + + {diff.aggregate_summary} + + )} +
+ ); +} + +export function DiffChangeRow({ change }: { change: PropChange }) { + const isRemoved = change.changeType === 'removed'; + const isAdded = change.changeType === 'added'; + const isModified = change.changeType === 'modified'; + + const rows: Array<{ op: 'del' | 'add'; text: string }> = []; + if (isModified) { + rows.push({ op: 'del', text: `− ${change.key}: ${change.before}` }); + rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` }); + } else if (isRemoved) { + rows.push({ op: 'del', text: `− ${change.key}: ${change.before}` }); + } else if (isAdded) { + rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` }); + } + + return ( + <> + {rows.map((r, i) => ( +
+ {r.text} +
+ ))} + + ); +} + +// ── HunkList — virtualised list of hunk accept/reject controls ──────────────── +// Uses @tanstack/react-virtual so even a 1 000-hunk diff won't freeze the panel. +// Each row is ~26px high; overscan=3 keeps the list feeling instant on scroll. + +interface HunkListProps { + hunks: FileDiffMetadata['hunks']; + allRejected: boolean; + fileDiff: FileDiffMetadata; + handleRejectHunk: (renderedIdx: number) => void; + setFileDiff: (d: FileDiffMetadata) => void; +} + +function HunkList({ hunks, allRejected, fileDiff, handleRejectHunk, setFileDiff }: HunkListProps) { + const T = useCanvasTheme(); + const scrollRef = useRef(null); + + const virtualizer = useVirtualizer({ + count: hunks.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 26, + overscan: 3, + }); + + return ( +
+ + {hunks.length} hunk{hunks.length !== 1 ? 's' : ''} + {allRejected && — all rejected} + + + {/* Scrollable virtual container — capped at 160px so it doesn't crowd the diff */} +
+
+ {virtualizer.getVirtualItems().map((vItem) => ( +
+ + Hunk {vItem.index + 1} + + + +
+ ))} +
+
+
+ ); +} + +// ── Main CodeTab component ───────────────────────────────────────────────────── + +interface CodeTabProps { + componentId: string | null; + componentData: FiberNode | null; + artboardId: string | null; +} + +// ── Token annotation metadata shape ─────────────────────────────────────────── +interface TokenAnnotationMeta { + tokenKey: string; + tokenName: string; +} + +export function CodeTab({ componentId, componentData, artboardId }: CodeTabProps) { + const T = useCanvasTheme(); + const { + indexerStatus, undoStyleEdit, patchStyleEdit, + designLanguageTokens, artboardRootFontSize, + } = useCanvas(); + const { stacks } = useHistory(); + const { fetchFile } = useIndexer(); + const { createDiff } = useDiffs(artboardId); + + const [diffStyle, setDiffStyle] = useState<'split' | 'unified'>('split'); + const [fileDiff, setFileDiff] = useState(null); + const [patchStr, setPatchStr] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [diffError, setDiffError] = useState(null); + const [isSending, setIsSending] = useState(false); + const [exportedId, setExportedId] = useState(null); + const [intentRtStatus, setIntentRtStatus] = useState(null); + + // ── Phase 4: rejection tracking ─────────────────────────────────────────── + // rejectedHunks tracks each explicit hunk rejection; size compared against + // initialHunkCount to determine when all hunks have been dismissed. + const [rejectedHunks, setRejectedHunks] = useState>(new Set()); + const [initialHunkCount, setInitialHunkCount] = useState(0); + // Monotonically incrementing ID so each rejection inserts a unique entry. + const rejectionIdRef = useRef(0); + + // ── Phase 4: token hover tooltip ────────────────────────────────────────── + const [tokenTooltip, setTokenTooltip] = useState<{ + tokenKey: string; tokenName: string; x: number; y: number; + } | null>(null); + + const tokens = designLanguageTokens ?? []; + const rootFontSizePx = artboardId ? (artboardRootFontSize[artboardId] ?? 16) : 16; + + const artboardHistory = artboardId + ? (stacks[artboardId] ?? { past: [], future: [] }) + : { past: [], future: [] }; + const pendingChanges = artboardHistory.past + .flatMap(e => e.changes) + .filter(c => c.changeType !== 'unchanged'); + + // ── Generate diff ────────────────────────────────────────────────────────── + 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, '/'); + const sourceContent = await fetchFile(filePath).catch(() => null); + + let patch: string; + if (sourceContent) { + const afterContent = applyChangesToSource(sourceContent, pendingChanges); + patch = generatePatch(sourceContent, afterContent, { filename: filePath }); + } else { + 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); + // Reset rejection tracking when a fresh diff arrives. + setRejectedHunks(new Set()); + rejectionIdRef.current = 0; + setInitialHunkCount(parsed?.hunks?.length ?? 0); + } + } catch (err) { + if (!cancelled) setDiffError(err instanceof Error ? err.message : 'Diff generation failed'); + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [componentData?.callSite?.fileName, pendingChanges.length, indexerStatus, fetchFile]); + + // ── Realtime — watch intent_diffs for agent status ───────────────────────── + 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 }) => { + const status = payload.new['status']; + if (typeof status === 'string') setIntentRtStatus(status); + }, + ) + .subscribe(); + return () => { void db.removeChannel(channel); }; + }, [exportedId]); + + // ── Cmd+Z undo ───────────────────────────────────────────────────────────── + 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 ──────────────────────────────────────────────────────────── + if (!componentId) { + return ( +
+ + + + + + + Select a component to
view its source +
+
+ ); + } + + const filePath = componentData?.callSite?.fileName?.replace(/\\/g, '/') ?? null; + const shortPath = filePath ? filePath.split('/').slice(-2).join('/') : null; + const hunkCount = fileDiff?.hunks?.length ?? 0; + + // ── Phase 4: allRejected guard ──────────────────────────────────────────── + // True when the user has explicitly dismissed every hunk via "reject". + // Prevents sending an empty diff to the agent. + const allRejected = initialHunkCount > 0 && rejectedHunks.size >= initialHunkCount; + + // ── Phase 4: per-hunk reject handler ───────────────────────────────────── + const handleRejectHunk = useCallback((renderedIdx: number) => { + const id = rejectionIdRef.current++; + setRejectedHunks(prev => new Set([...prev, id])); + setFileDiff(prev => prev ? diffAcceptRejectHunk(prev, renderedIdx, 'reject') : null); + }, []); + + // ── Phase 4: line annotations (token match badges on addition lines) ────── + // For each hunk that is not yet rejected, find the first pending change + // whose new value resolves to a known design token and annotate the first + // addition line of that hunk with the token key/name. + const lineAnnotations = useMemo[]>(() => { + if (!fileDiff || !tokens.length) return []; + + const annotations: DiffLineAnnotation[] = []; + + fileDiff.hunks.forEach((hunk) => { + for (const change of pendingChanges) { + const cssValue = String(change.after ?? '').trim(); + if (!cssValue) continue; + const match = resolveValueToToken(cssValue, tokens, rootFontSizePx); + if (match) { + annotations.push({ + side: 'additions', + lineNumber: hunk.additionStart, + metadata: { tokenKey: match.token.key, tokenName: match.token.name }, + }); + break; // one annotation per hunk + } + } + }); + + return annotations; + }, [fileDiff, tokens, pendingChanges, rootFontSizePx]); + + // ── Phase 4: annotation renderer ───────────────────────────────────────── + // Renders the token key as a small pill badge in the diff gutter annotation slot. + const renderAnnotation = useCallback( + (annotation: DiffLineAnnotation): React.ReactNode => { + if (!annotation.metadata) return null; + return ( + + {annotation.metadata.tokenKey} + + ); + }, + [], + ); + + 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 { /* mutation error shown via createDiff.isError */ } + finally { setIsSending(false); } + } + + 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 && !allRejected; + + return ( +
+ + {/* ── Header ────────────────────────────────────────────────────────── */} +
+
+ + {shortPath ?? '—'} + {componentData?.callSite?.lineNumber != null && ( + :{componentData.callSite.lineNumber} + )} + + +
+ +
+ + {componentData?.name ?? componentId} + + + {(['split', 'unified'] as const).map(s => ( + + ))} +
+
+ + {/* ── Diff viewer ───────────────────────────────────────────────────── */} +
+ {indexerStatus !== 'ready' ? ( +
+
+
+
+ CLI indexer offline +
+

+ Source diffs require the CLI indexer. Run{' '} + + npx @originmain/cli dev + {' '}to enable. +

+
+
+ ) : pendingChanges.length === 0 ? ( +
+ + No pending changes + +
+ ) : isLoading ? ( +
+ Generating diff… +
+ ) : diffError ? ( +
+ {diffError} +
+ ) : fileDiff ? ( +
+ {/* Token hover tooltip (Phase 4) */} + {tokenTooltip && ( +
+
+ {tokenTooltip.tokenKey} +
+
+ {tokenTooltip.tokenName} +
+
+ )} + + + fileDiff={fileDiff} + lineAnnotations={lineAnnotations} + renderAnnotation={renderAnnotation} + options={{ + diffStyle, + lineDiffType: 'char', + diffIndicators: 'bars', + onTokenEnter: (props, event) => { + void event; + const match = resolveValueToToken(props.tokenText, tokens, rootFontSizePx); + if (match) { + const rect = props.tokenElement.getBoundingClientRect(); + setTokenTooltip({ + tokenKey: match.token.key, + tokenName: match.token.name, + x: rect.left, + y: rect.bottom, + }); + } + }, + onTokenLeave: () => setTokenTooltip(null), + onTokenClick: (props, event) => { + void event; + // Copy token key to clipboard on click. + const match = resolveValueToToken(props.tokenText, tokens, rootFontSizePx); + if (match) { + void navigator.clipboard.writeText(match.token.key).catch(() => { /* non-fatal */ }); + } + }, + }} + style={{ fontSize: '0.5625rem' }} + /> + {hunkCount > 0 && ( + + )} +
+ ) : null} +
+ + {/* ── Footer: Realtime status + Send to Agent ───────────────────────── */} +
+ {intentRtStatus && ( +
+ + {rtLabel} + +
+ )} + +
+
+ ); +} diff --git a/packages/app/src/components/inspector/DesignTab.tsx b/packages/app/src/components/inspector/DesignTab.tsx new file mode 100644 index 0000000..f8e50e0 --- /dev/null +++ b/packages/app/src/components/inspector/DesignTab.tsx @@ -0,0 +1,240 @@ +'use client'; + +// ── Design Tab (Phase 2) ────────────────────────────────────────────────────── +// The main inspector Design panel: component identity header, DLF violation +// banner, and all design-property section components. Extracted from +// Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2. + +import { useState, useMemo, useEffect } from 'react'; +import { Badge } from '@fluentui/react-components'; +import { useCanvas } from '@/store/canvas'; +import { useCanvasTheme } from '@/store/canvasTheme'; +import { useDlf } from '@/hooks/useDlf'; +import { checkComponentConstraints } from '@originmain/design-language'; +import type { Violation } from '@originmain/design-language'; +import type { FiberNode } from '@originmain/renderer'; +import { FrameSection } from './sections/FrameSection'; +import { LayoutSection } from './sections/LayoutSection'; +import { FillSection } from './sections/FillSection'; +import { StrokeSection } from './sections/StrokeSection'; +import { EffectsSection } from './sections/EffectsSection'; +import { TypographySection } from './sections/TypographySection'; +import { BoxModelSection } from './sections/BoxModelSection'; +import { ConstraintsSection } from './sections/ConstraintsSection'; + +// ── DLF violation banner ─────────────────────────────────────────────────────── + +function DlfViolationBanner({ violations }: { violations: Violation[] }) { + const T = useCanvasTheme(); + const hasError = violations.some(v => v.severity === 'error'); + + return ( +
+
+ {hasError ? 'Design system violations' : 'Design system warnings'} +
+ +
+ {violations.map((v, i) => ( +
+ + {v.severity} + + + {v.prop && {v.prop}: } + {v.message} + +
+ ))} +
+
+ ); +} + +// ── Main DesignTab component ─────────────────────────────────────────────────── + +interface DesignTabProps { + artboardId: string | null; + componentId: string | null; + componentData: FiberNode | null; + styles: Record | null; + workspaceId: string | null | undefined; +} + +export function DesignTab({ + artboardId, + componentId, + componentData, + styles, + workspaceId, +}: DesignTabProps) { + const T = useCanvasTheme(); + const { + patchStyleEdit, + patchChildrenStyleEdit, + indexerStatus, + selectedComponentHasDirectText, + selectedComponentHasParagraphChildren, + setActiveViolations, + } = useCanvas(); + const { dlf } = useDlf(workspaceId); + + // Re-run constraint checks whenever selected component or active DLF changes. + const dlfViolations = useMemo(() => { + if (!dlf || !componentData?.name) return []; + return checkComponentConstraints({ + componentName: componentData.name, + props: (componentData.props ?? {}) as Record, + dlf, + }); + }, [dlf, componentData?.name, componentData?.props]); + + // Sync violations to canvas store so SelectionOverlay can render inline badges. + useEffect(() => { + setActiveViolations(dlfViolations); + return () => { setActiveViolations([]); }; + }, [dlfViolations, setActiveViolations]); + + if (!artboardId) { + return ( +
+ + Select an artboard + +
+ ); + } + + if (!componentId) { + return ( +
+ + + + + + Click a component in the
artboard to inspect & edit +
+
+ ); + } + + if (!styles) { + return ( +
+ + Fetching styles… + +
+ ); + } + + const patch = (prop: string, val: string) => { + if (!artboardId || !componentId) return; + patchStyleEdit(artboardId, componentId, prop, val); + }; + + const patchChildren = (selector: string, prop: string, val: string) => { + if (!artboardId || !componentId) return; + patchChildrenStyleEdit(artboardId, componentId, selector, prop, val); + }; + + // ── Call-site display ────────────────────────────────────────────────────── + const callSite = componentData?.callSite; + const callSiteLabel = callSite + ? (() => { + const parts = callSite.fileName.replace(/\\/g, '/').split('/'); + return `${parts.slice(-2).join('/')}:${callSite.lineNumber}`; + })() + : null; + + // ── Indexer dot ──────────────────────────────────────────────────────────── + const indexerDot = { + offline: { color: T.dim, title: 'CLI indexer offline' }, + indexing: { color: '#FFBA7B', title: 'Indexing…' }, + ready: { color: '#7DD3A8', title: 'Indexer ready' }, + }[indexerStatus]; + + return ( + <> + {/* ── Component identity header ───────────────────────────────────── */} +
+
+ + {componentData?.name ?? componentId} + +
+
+ {callSiteLabel && ( + + ↳ {callSiteLabel} + + )} +
+ + {/* ── DLF violation banner ────────────────────────────────────────── */} + {dlfViolations.length > 0 && } + + {/* ── Section components ──────────────────────────────────────────── */} + + + + + + + + + + ); +} + +// Re-export so Inspector can still do a single-line import +export { DlfViolationBanner }; diff --git a/packages/app/src/components/inspector/Inspector.tsx b/packages/app/src/components/inspector/Inspector.tsx index 0562516..46f7d02 100644 --- a/packages/app/src/components/inspector/Inspector.tsx +++ b/packages/app/src/components/inspector/Inspector.tsx @@ -1,47 +1,39 @@ 'use client'; -import { useState, useCallback, useMemo, useEffect } from 'react'; -import { Badge } from '@fluentui/react-components'; +// ── Inspector panel ─────────────────────────────────────────────────────────── +// Tab bar + routing to DesignTab / PropsTab / CodeTab / DiffTab / GraphTab. +// Heavy per-tab logic lives in the extracted tab files. + +import { useState, useCallback } from 'react'; import { useCanvas } from '@/store/canvas'; import { useHistory } from '@/store/history'; -import { useArtboards, patchArtboard } from '@/hooks/useArtboards'; +import { useArtboards } from '@/hooks/useArtboards'; import { useDiffs } from '@/hooks/useDiffs'; -import { useDlf } from '@/hooks/useDlf'; -import { useQueryClient } from '@tanstack/react-query'; -import { trpc } from '@/lib/trpc'; import { useCanvasTheme } from '@/store/canvasTheme'; -import { checkComponentConstraints } from '@originmain/design-language'; -import type { Violation } from '@originmain/design-language'; +import { trpc } from '@/lib/trpc'; 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'; -import { StrokeSection } from './sections/StrokeSection'; -import { EffectsSection } from './sections/EffectsSection'; -import { TypographySection } from './sections/TypographySection'; -import { BoxModelSection } from './sections/BoxModelSection'; -import { ConstraintsSection } from './sections/ConstraintsSection'; - -const TYPE_COLORS: Record = { - s: '#7DD3A8', - n: '#7EB8FF', - b: '#FFBA7B', -}; +import { Section, PropRow, HSep } from './DesignInputs'; +import { DesignTab } from './DesignTab'; +import { PropsTab } from './PropsTab'; +import { CodeTab, DiffChangeRow, SavedDiffRow } from './CodeTab'; type TabId = 'design' | 'props' | 'code' | 'diff' | 'graph'; export function Inspector() { const T = useCanvasTheme(); - const { selectedArtboardId, liveArtboardIds, artboardFiberRoots, selectedComponentId, selectedComponentData, selectedComponentStyles, workspaceId, projectId } = useCanvas(); + const { + selectedArtboardId, + liveArtboardIds, + artboardFiberRoots, + selectedComponentId, + selectedComponentData, + selectedComponentStyles, + workspaceId, + projectId, + } = useCanvas(); const [tab, setTab] = useState('design'); const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null; @@ -52,14 +44,9 @@ export function Inspector() { data-tour="inspector-panel" className="dark-panel" style={{ - gridColumn: 3, - gridRow: 2, - background: T.bg, - borderLeft: `1px solid ${T.border}`, - display: 'flex', - flexDirection: 'column', - overflow: 'hidden', - fontSize: 12, + gridColumn: 3, gridRow: 2, + background: T.bg, borderLeft: `1px solid ${T.border}`, + display: 'flex', flexDirection: 'column', overflow: 'hidden', fontSize: 12, }} > {/* Tab bar */} @@ -69,20 +56,14 @@ export function Inspector() { key={t} onClick={() => setTab(t)} style={{ - flex: 1, - padding: '11px 0', + flex: 1, padding: '11px 0', fontFamily: "'JetBrains Mono', ui-monospace, monospace", - fontSize: '0.5875rem', - fontWeight: 500, - letterSpacing: '0.08em', - textTransform: 'uppercase', - color: tab === t ? T.tabOn : T.tabFg, - background: 'transparent', - border: 'none', + fontSize: '0.5875rem', fontWeight: 500, + letterSpacing: '0.08em', textTransform: 'uppercase', + color: tab === t ? T.tabOn : T.tabFg, + background: 'transparent', border: 'none', borderBottom: tab === t ? `2px solid ${T.accent}` : '2px solid transparent', - cursor: 'pointer', - transition: 'color 0.12s', - marginBottom: -1, + cursor: 'pointer', transition: 'color 0.12s', marginBottom: -1, }} > {t} @@ -90,28 +71,12 @@ export function Inspector() { ))}
- {/* Content — minHeight:0 is required so this flex child can actually shrink and scroll */} + {/* Content — minHeight:0 required so this flex child can shrink and scroll */}
{!selectedArtboardId ? ( -
+
- + Select an artboard
@@ -131,7 +96,11 @@ export function Inspector() { projectId={projectId} /> ) : tab === 'code' ? ( - + ) : tab === 'diff' ? ( ) : ( @@ -140,18 +109,10 @@ export function Inspector() {
{/* Status bar */} -
+
| null; - workspaceId: string | null | undefined; -}) { - const T = useCanvasTheme(); - const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren, setActiveViolations } = useCanvas(); - const { dlf } = useDlf(workspaceId); - - // Re-run constraint checks whenever the selected component or active DLF changes. - // We pass component.props (React props) — not CSS styles — to the validator since - // DLF component rules govern variant/size/etc., not raw CSS properties. - const dlfViolations = useMemo(() => { - if (!dlf || !componentData?.name) return []; - return checkComponentConstraints({ - componentName: componentData.name, - props: (componentData.props ?? {}) as Record, - dlf, - }); - }, [dlf, componentData?.name, componentData?.props]); - - // Sync violations to the canvas store so SelectionOverlay can render inline badges. - // Runs after every render where dlfViolations changes; clears on component deselect. - useEffect(() => { - setActiveViolations(dlfViolations); - return () => { setActiveViolations([]); }; - }, [dlfViolations, setActiveViolations]); - - if (!artboardId) { - return ( -
- - Select an artboard - -
- ); - } - - if (!componentId) { - return ( -
- - - - - - Click a component in the
artboard to inspect & edit -
-
- ); - } - - if (!styles) { - return ( -
- - Fetching styles… - -
- ); - } - - const patch = (prop: string, val: string) => { - if (!artboardId || !componentId) return; - patchStyleEdit(artboardId, componentId, prop, val); - }; - - const patchChildren = (selector: string, prop: string, val: string) => { - if (!artboardId || !componentId) return; - patchChildrenStyleEdit(artboardId, componentId, selector, prop, val); - }; - - // ── Derive call-site display ────────────────────────────────────── - const callSite = componentData?.callSite; - const callSiteLabel = callSite - ? (() => { - // Show the last two path segments for readability: "app/page.tsx:34" - const parts = callSite.fileName.replace(/\\/g, '/').split('/'); - const short = parts.slice(-2).join('/'); - return `${short}:${callSite.lineNumber}`; - })() - : null; - - // ── Indexer status dot ──────────────────────────────────────────── - const indexerDot = { - offline: { color: T.dim, title: 'CLI indexer offline' }, - indexing: { color: '#FFBA7B', title: 'Indexing…' }, - ready: { color: '#7DD3A8', title: 'Indexer ready' }, - }[indexerStatus]; - - return ( - <> - {/* ── Component identity header ────────────────────────────── */} -
-
- {/* Component name */} - - {componentData?.name ?? componentId} - - {/* Indexer dot */} -
-
- {/* Call-site breadcrumb — "used in app/page.tsx:34" */} - {callSiteLabel && ( - - ↳ {callSiteLabel} - - )} -
- - {/* ── DLF violation banner ─────────────────────────────────── */} - {dlfViolations.length > 0 && ( - - )} - - {/* ── Section components ───────────────────────────────────── */} - - - - - - - - - - ); -} - -/* ── Props tab ────────────────────────────────────────────── */ -function PropsTab({ - artboard, - selectedComponentData, - workspaceId, - projectId, -}: { - artboard: Artboard | null; - selectedComponentData: FiberNode | null; - workspaceId: string | null; - projectId: string | null; -}) { - const T = useCanvasTheme(); - const queryClient = useQueryClient(); - const [editingUrl, setEditingUrl] = useState(false); - const [urlDraft, setUrlDraft] = useState(''); - const [editingRoute, setEditingRoute] = useState(false); - const [routeDraft, setRouteDraft] = useState(''); - - // Drift report state - const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle'); - const [driftReport, setDriftReport] = useState(''); - - const generateDriftReport = useCallback(async () => { - if (!artboard) return; - setDriftStatus('loading'); - setDriftReport(''); - try { - const res = await fetch('/api/ai/drift-report', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ artboard_id: artboard.id }), - }); - if (!res.ok) throw new Error(`${res.status}`); - const data = await res.json() as { report?: string; result?: string }; - setDriftReport(data.report ?? data.result ?? '— No report returned'); - setDriftStatus('done'); - } catch { - setDriftStatus('error'); - } - }, [artboard]); - - const saveRenderUrl = useCallback(async () => { - if (!artboard) return; - const { renderUrl: _removed, ...rest } = artboard.metadata_jsonb; - const meta: Record = urlDraft.trim() - ? { ...rest, renderUrl: urlDraft.trim() } - : { ...rest }; - try { - await patchArtboard(artboard.id, { metadata_jsonb: meta }); - queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); - } catch (e) { - console.error('[Inspector] patch renderUrl failed', e); - } - setEditingUrl(false); - }, [artboard, urlDraft, workspaceId, projectId, queryClient]); - - if (!artboard) return null; - - const meta = artboard.metadata_jsonb; - const N = TYPE_COLORS['n']!; - const B = TYPE_COLORS['b']!; - const S = TYPE_COLORS['s']!; - - const canvasProps: Array<{ key: string; val: string; color: string }> = [ - { key: 'x', val: String(meta['x'] ?? 0), color: N }, - { key: 'y', val: String(meta['y'] ?? 0), color: N }, - { key: 'width', val: String(meta['width'] ?? 0), color: N }, - { key: 'height', val: String(meta['height'] ?? 0), color: N }, - ]; - - const reservedKeys = new Set(['x', 'y', 'width', 'height', 'renderUrl', 'route']); - const extraProps = Object.entries(meta) - .filter(([k]) => !reservedKeys.has(k)) - .map(([k, v]) => { - const t = typeof v; - const color = t === 'number' ? N : t === 'boolean' ? B : S; - const val = t === 'string' ? `"${v}"` : String(v); - return { key: k, val, color }; - }); - - const renderUrl = typeof meta['renderUrl'] === 'string' ? meta['renderUrl'] as string : ''; - const currentRoute = typeof meta['route'] === 'string' ? meta['route'] as string : '/'; - - const saveRoute = useCallback(async () => { - if (!artboard) return; - const cleaned = routeDraft.trim() || '/'; - const { route: _r, ...rest } = artboard.metadata_jsonb; - const meta2: Record = - cleaned === '/' ? { ...rest } : { ...rest, route: cleaned }; - try { - await patchArtboard(artboard.id, { metadata_jsonb: meta2 }); - queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] }); - } catch (e) { - console.error('[Inspector] patch route failed', e); - } - setEditingRoute(false); - }, [artboard, routeDraft, workspaceId, projectId, queryClient]); - - return ( - <> - {/* Selected fiber component props — shown when a component is clicked in canvas */} - {selectedComponentData && ( - <> -
- {Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => { - const t = typeof v; - const color = t === 'number' ? N : t === 'boolean' ? B : S; - const display = t === 'string' ? `"${v as string}"` : String(v); - return ; - })} - {Object.keys(selectedComponentData.props ?? {}).length === 0 && ( - - No props - - )} -
- - - )} - - {extraProps.length > 0 && ( - <> -
- {extraProps.map(({ key, val, color }) => ( - - ))} -
- - - )} - -
- {canvasProps.map(({ key, val, color }) => ( - - ))} -
- - -
- - - - {/* renderUrl — inline editable */} - -
-
- - url - - -
- - {editingUrl ? ( -
- setUrlDraft(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') void saveRenderUrl(); - if (e.key === 'Escape') setEditingUrl(false); - }} - placeholder="http://localhost:3000" - style={{ - flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace", - background: T.bgDeep, border: `1px solid ${T.border}`, - borderRadius: 5, padding: '4px 8px', color: T.fg, - outline: 'none', - }} - /> - -
- ) : renderUrl ? ( - - {renderUrl} - - ) : ( - - not connected - - )} -
- - {/* route — which screen/path this artboard renders */} -
-
- - route - - -
- - {editingRoute ? ( -
- setRouteDraft(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') void saveRoute(); - if (e.key === 'Escape') setEditingRoute(false); - }} - placeholder="/dashboard" - style={{ - flex: 1, fontSize: '0.5875rem', fontFamily: "'JetBrains Mono', monospace", - background: T.bgDeep, border: `1px solid ${T.border}`, - borderRadius: 5, padding: '4px 8px', color: T.fg, - outline: 'none', - }} - /> - -
- ) : ( - - {currentRoute} - - )} -
-
- - - - {/* ── Drift Report ───────────────────────────────────── */} -
- - - {driftStatus === 'error' && ( -
- Report failed — try again -
- )} - - {driftStatus === 'done' && driftReport && ( -
- {driftReport} -
- )} -
- - ); -} - -function Section({ label, children }: { label: string; children: React.ReactNode }) { - const T = useCanvasTheme(); - return ( -
-
- {label} -
- {children} -
- ); -} - -function PropRow({ label, value, color }: { label: string; value: string; color: string }) { - const T = useCanvasTheme(); - return ( -
- - {label} - - - {value} - -
- ); -} - -function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isRoot?: boolean }) { - const T = useCanvasTheme(); - return ( -
-
- - {label} - -
- ); -} - -/* ── Graph tab ────────────────────────────────────────────── */ function GraphTab({ fiberRoot }: { fiberRoot: FiberNode | undefined }) { const T = useCanvasTheme(); if (!fiberRoot) { @@ -750,16 +146,13 @@ function GraphTab({ fiberRoot }: { fiberRoot: FiberNode | undefined }) { ); } - const nodeCount = countFiberNodes(fiberRoot); - const treeDepth = measureFiberDepth(fiberRoot); - return (
- - + +
); @@ -773,28 +166,16 @@ function FiberTreeView({ node, depth }: { node: FiberNode; depth: number }) { return (
hasChildren && setCollapsed(c => !c)} > -
+
{hasChildren && ( {collapsed ? '▶' : '▼'} )} - + {node.name}
@@ -814,467 +195,23 @@ function measureFiberDepth(node: FiberNode, d = 0): number { return Math.max(...node.children.map(c => measureFiberDepth(c, d + 1))); } -function HSep() { - const T = useCanvasTheme(); - return
; -} +// ── Diff tab ────────────────────────────────────────────────────────────────── -/* ── DLF violation banner ─────────────────────────────────── */ - -function DlfViolationBanner({ violations }: { violations: Violation[] }) { - const T = useCanvasTheme(); - const hasError = violations.some(v => v.severity === 'error'); - - return ( -
- {/* Section header */} -
- {hasError ? 'Design system violations' : 'Design system warnings'} -
- - {/* Per-violation Fluent 2 badges (spec Layer 5.2-R3) */} -
- {violations.map((v, i) => ( -
- - {v.severity} - - - {v.prop && {v.prop}: } - {v.message} - -
- ))} -
-
- ); -} - -// ── 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(null); - const [patchStr, setPatchStr] = useState(''); - const [isLoading, setIsLoading] = useState(false); - const [diffError, setDiffError] = useState(null); - const [isSending, setIsSending] = useState(false); - const [exportedId, setExportedId] = useState(null); - const [intentRtStatus, setIntentRtStatus] = useState(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 }) => { - 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 ( -
- - - - - - - Select a component to
view its source -
-
- ); - } - - 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 ( -
- - {/* ── Header: breadcrumb + indexer dot + toggle ──────────────────── */} -
- {/* File path + indexer status dot */} -
- - {shortPath ?? '—'} - {componentData?.callSite?.lineNumber != null && ( - :{componentData.callSite.lineNumber} - )} - - -
- - {/* Component badge + split / unified toggle */} -
- - {componentData?.name ?? componentId} - - - {(['split', 'unified'] as const).map(s => ( - - ))} -
-
- - {/* ── Diff viewer ──────────────────────────────────────────────────── */} -
- {indexerStatus !== 'ready' ? ( -
-
-
-
- CLI indexer offline -
-

- Source diffs require the CLI indexer. Run{' '} - - npx @originmain/cli dev - {' '}to enable. -

-
-
- ) : pendingChanges.length === 0 ? ( -
- - No pending changes - -
- ) : isLoading ? ( -
- Generating diff… -
- ) : diffError ? ( -
- {diffError} -
- ) : fileDiff ? ( -
- {/* @pierre/diffs React diff viewer */} - - - {/* Per-hunk accept / reject controls */} - {hunkCount > 0 && ( -
- - {hunkCount} hunk{hunkCount !== 1 ? 's' : ''} - - {fileDiff.hunks.map((_, i) => ( -
- - Hunk {i + 1} - - - -
- ))} -
- )} -
- ) : null} -
- - {/* ── Footer: Realtime status badge + Send to Agent ─────────────── */} -
- {/* Realtime intent status badge */} - {intentRtStatus && ( -
- - {rtLabel} - -
- )} - - {/* Send to Agent button */} - -
-
- ); -} - -/* ── Diff tab ─────────────────────────────────────────────── */ function DiffTab({ artboardId }: { artboardId: string | null }) { - const T = useCanvasTheme(); - const { stacks } = useHistory(); + const T = useCanvasTheme(); + const { stacks } = useHistory(); const { diffs, createDiff, isLoading } = useDiffs(artboardId); const { workspaceId, activeAgentSessionId } = useCanvas(); const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle'); - // tRPC mutation for AI diff summary (spec Layer 6 — server-side, authenticated) const summarizeDiff = trpc.ai.generateDiffSummary.useMutation(); - const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] }; + const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] }; const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes); const hasChanges = pendingChanges.length > 0; const exportDiff = useCallback(async () => { if (!artboardId || !hasChanges) return; - - // 1. Generate AI summary via tRPC (best-effort — fall back to empty string) let summary = ''; const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged'); if (meaningfulChanges.length > 0 && workspaceId) { @@ -1287,12 +224,9 @@ function DiffTab({ artboardId }: { artboardId: string | null }) { componentName: meaningfulChanges[0]?.key ?? 'Component', }); summary = data.summary; - } catch { /* non-fatal — proceed without summary */ } + } catch { /* non-fatal */ } } - // 2. Export diff with AI-generated summary included. - // session_id links this diff to the active agent session (if any) so the - // Agent Bridge can query diffs-by-session. Empty string = no active session. setSummaryStatus('exporting'); createDiff.mutate( { @@ -1304,6 +238,7 @@ function DiffTab({ artboardId }: { artboardId: string | null }) { }, { onSettled: () => setSummaryStatus('idle') }, ); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [artboardId, pendingChanges, hasChanges, createDiff, activeAgentSessionId]); if (!artboardId) { @@ -1318,15 +253,12 @@ function DiffTab({ artboardId }: { artboardId: string | null }) { return ( <> - {/* Pending local changes */}
c.changeType !== 'unchanged').length : 0} changes`}> {hasChanges ? ( <> {pendingChanges .filter(c => c.changeType !== 'unchanged') - .map((change, i) => ( - - ))} + .map((change, i) => )} +
+ )} +
+ {artboards.map((ab) => { + const sel = selectedArtboardId === ab.id; + const live = liveArtboardIds.has(ab.id); + const isCover = !!(rawArtboards.find(r => r.id === ab.id)?.metadata_jsonb as Record | undefined)?.['isCover']; + return ( + selectArtboard(ab.id)} + onContextMenu={(e) => { + e.preventDefault(); + setContextMenu({ artboardId: ab.id, label: ab.label, x: e.clientX, y: e.clientY }); + }} + icon={ + + } + label={ab.label} + onRename={() => void renameArtboard(ab.id, ab.label)} + onFork={() => void forkArtboard(ab.id, ab.label)} + onDelete={() => void deleteArtboard(ab.id, ab.label)} + /> + ); + })} +
+ + )} + + {/* ── Right-click context menu (spec §3.5) ── */} + {contextMenu && ( + { + setContextMenu(null); + void renameArtboard(contextMenu.artboardId, contextMenu.label); + }} + onDuplicate={() => { + setContextMenu(null); + void forkArtboard(contextMenu.artboardId, contextMenu.label); + }} + onSetAsCover={() => void setAsCover(contextMenu.artboardId)} + onDelete={() => { + setContextMenu(null); + void deleteArtboard(contextMenu.artboardId, contextMenu.label); + }} + onClose={() => setContextMenu(null)} + /> )} {/* ── Routes tab ── */} @@ -494,13 +658,90 @@ function RouteRow({ ); } +/* ── Context menu component (spec §3.5) ──────────────────── */ + +import React from 'react'; + +const ArtboardContextMenu = React.forwardRef< + HTMLDivElement, + { + T: CanvasTokens; + x: number; y: number; + onRename: () => void; + onDuplicate: () => void; + onSetAsCover: () => void; + onDelete: () => void; + onClose: () => void; + } +>(({ T, x, y, onRename, onDuplicate, onSetAsCover, onDelete, onClose }, ref) => { + const items: Array<{ label: string; action: () => void; danger?: boolean }> = [ + { label: 'Rename', action: onRename }, + { label: 'Duplicate', action: onDuplicate }, + { label: 'Set as Cover', action: onSetAsCover }, + { label: 'Delete', action: onDelete, danger: true }, + ]; + + return ( +
+ {items.map((item) => ( + + {item.label} + + ))} +
+ ); +}); +ArtboardContextMenu.displayName = 'ArtboardContextMenu'; + +function ContextMenuItem({ + T, children, danger, onAction, onClose, +}: { + T: CanvasTokens; + children: string; + danger?: boolean | undefined; + onAction: () => void; + onClose: () => void; +}) { + const [hov, setHov] = useState(false); + return ( +
setHov(true)} + onMouseLeave={() => setHov(false)} + onClick={() => { onAction(); onClose(); }} + style={{ + padding: '5px 10px', borderRadius: 4, cursor: 'pointer', + fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem', + color: danger ? (hov ? '#FF6060' : '#FF8080') : (hov ? T.fg : T.fgMuted), + background: hov ? (danger ? 'rgba(255,80,80,0.10)' : T.hoverBg) : 'transparent', + transition: 'background 0.08s, color 0.08s', + }} + > + {children} +
+ ); +} + /* ── Artboard row ─────────────────────────────────────────── */ function NavRow({ T, selected = false, live = false, + isCover = false, onClick, + onContextMenu, icon, label, onRename, @@ -510,7 +751,9 @@ function NavRow({ T: CanvasTokens; selected?: boolean; live?: boolean; + isCover?: boolean; onClick?: () => void; + onContextMenu?: (e: React.MouseEvent) => void; icon: React.ReactNode; label: string; onRename?: () => void; @@ -522,6 +765,7 @@ function NavRow({ return (
setHov(true)} onMouseLeave={() => setHov(false)} style={{ @@ -544,6 +788,18 @@ function NavRow({ {icon} {label} + {/* Cover badge */} + {isCover && !hov && !live && ( + + cover + + )} + {/* Live render indicator — pulsing green dot */} {live && !hov && ( ('DESIGNER'); const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'conflict' | 'error'>('idle'); const [errorMsg, setErrorMsg] = useState(''); @@ -60,7 +60,7 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) { useEffect(() => { return () => { clearTimeout(resetTimer.current); }; }, []); const submit = useCallback(async () => { - const trimmed = userId.trim(); + const trimmed = email.trim().toLowerCase(); if (!trimmed) return; clearTimeout(resetTimer.current); setStatus('loading'); @@ -69,7 +69,7 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) { const res = await fetch(`/api/workspace/${workspaceId}/invite`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ userId: trimmed, role }), + body: JSON.stringify({ email: trimmed, role }), }); if (res.status === 409) { setStatus('conflict'); return; } if (!res.ok) { @@ -77,14 +77,14 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) { throw new Error(data.error ?? `HTTP ${res.status}`); } setStatus('done'); - setUserId(''); + setEmail(''); resetTimer.current = setTimeout(() => setStatus('idle'), 3000); } catch (e) { setErrorMsg(e instanceof Error ? e.message : 'Invite failed'); setStatus('error'); resetTimer.current = setTimeout(() => setStatus('idle'), 4000); } - }, [userId, role, workspaceId]); + }, [email, role, workspaceId]); const roles: TeamRole[] = ['DESIGNER', 'ENGINEER', 'PM', 'VIEWER', 'OWNER']; @@ -110,21 +110,22 @@ function TeamInviteForm({ workspaceId }: { workspaceId: string }) { ))}
- {/* User ID input + submit */} - + {/* Email input + submit */} +
setUserId(e.target.value)} + placeholder="colleague@company.com" + value={email} + onChange={e => setEmail(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') void submit(); }} disabled={status === 'loading'} />