made tiny updates

This commit is contained in:
SinachPat
2026-05-06 22:11:40 +01:00
parent f21969f018
commit 1fc2702a4b
36 changed files with 2455 additions and 1558 deletions
+1
View File
@@ -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",
@@ -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);
@@ -36,6 +36,9 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
const patch: Partial<InsertArtboard> = {};
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 });
+16 -9
View File
@@ -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<NextResponse> {
}
// ── 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();
+109 -105
View File
@@ -1,125 +1,129 @@
// GET /api/design-language?workspaceId=<uuid> → active design language file
// GET /api/design-language?workspaceId=<uuid>&all=1 → all versions (history)
// POST /api/design-language → upload new version (deactivates prior)
// GET /api/design-language?workspaceId=<uuid> → active DesignLanguage row
// GET /api/design-language?workspaceId=<uuid>&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<InsertDesignLanguage>;
try {
const file = await getActiveDesignLanguageFile(db, workspaceId);
if (!file) return NextResponse.json(null);
return NextResponse.json(file);
body = (await req.json()) as Partial<InsertDesignLanguage>;
} 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 });
}
+10 -7
View File
@@ -1,15 +1,15 @@
// GET /api/diffs?artboardId=<uuid> → 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<InsertIntentDiff, 'author_id'>;
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<InsertIntentDiff, 'author_email'>;
const insert: InsertIntentDiff = { ...body, author_email: authorEmail };
try {
const db = serverClient();
+7 -6
View File
@@ -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',
@@ -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';
@@ -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<boolean> {
async function assertMember(workspaceId: string, email: string): Promise<boolean> {
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<InsertProject>;
@@ -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 {
@@ -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<typeof serverClient>, workspaceId: string, userId: string) {
async function assertMember(
db: ReturnType<typeof serverClient>,
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(() => ({}));
@@ -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<typeof import('@/lib/supabase').serverClient>, workspaceId: string, userId: string) {
async function getCallerEmail(): Promise<string | null> {
const user = await currentUser();
return user?.primaryEmailAddress?.emailAddress ?? null;
}
async function assertOwner(
db: ReturnType<typeof serverClient>,
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 {
@@ -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();
+23 -25
View File
@@ -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',
});
+14 -8
View File
@@ -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',
});
@@ -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();
@@ -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();
@@ -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();
@@ -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 };
@@ -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();
+7 -6
View File
@@ -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<Workspace[]> {
async function getWorkspaces(email: string): Promise<Workspace[]> {
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<Workspace[]> {
}
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');
@@ -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<string, unknown> | 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<typeof setDesignLanguageTokens>[0]);
} catch {
// Malformed token file in DB — don't crash the session
}
}).catch(() => { /* design-language package unavailable */ });
setDesignLanguageTokens(normalized as Parameters<typeof setDesignLanguageTokens>[0]);
},
)
.subscribe();
@@ -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<string, string> = {
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 (
<div style={{ marginBottom: 8, padding: '6px 8px', background: T.bgDeep, borderRadius: 6 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 2 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color }}>
{diff.status}
</span>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: T.dim }}>
{count} change{count !== 1 ? 's' : ''}
</span>
</div>
{diff.aggregate_summary && (
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: T.fgMuted, lineHeight: 1.4, display: 'block' }}>
{diff.aggregate_summary}
</span>
)}
</div>
);
}
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) => (
<div
key={i}
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem', padding: '4px 8px',
borderRadius: 4, marginBottom: 3, lineHeight: 1.55,
background: r.op === 'del' ? 'rgba(255,70,70,0.08)' : 'rgba(70,220,120,0.08)',
color: r.op === 'del' ? '#FF8080' : '#7DDBA0',
borderLeft: `2px solid ${r.op === 'del' ? 'rgba(255,80,80,0.3)' : 'rgba(70,220,120,0.3)'}`,
}}
>
{r.text}
</div>
))}
</>
);
}
// ── 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<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: hunks.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 26,
overscan: 3,
});
return (
<div
style={{
padding: '8px 12px', borderTop: `1px solid ${T.border}`,
display: 'flex', flexDirection: 'column', gap: 5,
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: T.dim, letterSpacing: '0.07em', textTransform: 'uppercase', marginBottom: 2,
}}>
{hunks.length} hunk{hunks.length !== 1 ? 's' : ''}
{allRejected && <span style={{ color: '#FF8080', marginLeft: 6 }}> all rejected</span>}
</span>
{/* Scrollable virtual container — capped at 160px so it doesn't crowd the diff */}
<div ref={scrollRef} style={{ maxHeight: 160, overflowY: 'auto' }}>
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
{virtualizer.getVirtualItems().map((vItem) => (
<div
key={vItem.index}
data-index={vItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute', top: 0, left: 0, width: '100%',
transform: `translateY(${vItem.start}px)`,
display: 'flex', alignItems: 'center', gap: 5, paddingBottom: 4,
}}
>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: T.fgMuted, flex: 1,
}}>
Hunk {vItem.index + 1}
</span>
<button
onClick={() => setFileDiff(diffAcceptRejectHunk(fileDiff, vItem.index, 'accept'))}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: '#7DD3A8', background: 'rgba(125,211,168,0.08)',
border: '1px solid rgba(125,211,168,0.28)', borderRadius: 3,
padding: '2px 7px', cursor: 'pointer',
}}
>
accept
</button>
<button
onClick={() => handleRejectHunk(vItem.index)}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: '#FF6B6B', background: 'rgba(255,107,107,0.08)',
border: '1px solid rgba(255,107,107,0.28)', borderRadius: 3,
padding: '2px 7px', cursor: 'pointer',
}}
>
reject
</button>
</div>
))}
</div>
</div>
</div>
);
}
// ── 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<FileDiffMetadata | null>(null);
const [patchStr, setPatchStr] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const [diffError, setDiffError] = useState<string | null>(null);
const [isSending, setIsSending] = useState(false);
const [exportedId, setExportedId] = useState<string | null>(null);
const [intentRtStatus, setIntentRtStatus] = useState<string | null>(null);
// ── 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<Set<number>>(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<string, unknown> }) => {
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 (
<div style={{ padding: '32px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center' }}>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.22 }}>
<polyline points="7,9 2,14 7,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<polyline points="21,9 26,14 21,19" stroke="white" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
<line x1="16" y1="6" x2="12" y2="22" stroke="white" strokeWidth="1.4" strokeLinecap="round"/>
</svg>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
Select a component to<br/>view its source
</span>
</div>
);
}
const filePath = componentData?.callSite?.fileName?.replace(/\\/g, '/') ?? null;
const shortPath = filePath ? filePath.split('/').slice(-2).join('/') : null;
const hunkCount = fileDiff?.hunks?.length ?? 0;
// ── 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<DiffLineAnnotation<TokenAnnotationMeta>[]>(() => {
if (!fileDiff || !tokens.length) return [];
const annotations: DiffLineAnnotation<TokenAnnotationMeta>[] = [];
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<TokenAnnotationMeta>): React.ReactNode => {
if (!annotation.metadata) return null;
return (
<span
title={annotation.metadata.tokenName}
style={{
display: 'inline-flex', alignItems: 'center',
padding: '1px 5px', borderRadius: 3, flexShrink: 0, whiteSpace: 'nowrap',
background: 'rgba(125,211,168,0.10)',
border: '1px solid rgba(125,211,168,0.28)',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.45rem', color: '#7DD3A8', letterSpacing: '0.04em', lineHeight: 1.2,
}}
>
{annotation.metadata.tokenKey}
</span>
);
},
[],
);
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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{/* ── Header ────────────────────────────────────────────────────────── */}
<div style={{
padding: '9px 12px', borderBottom: `1px solid ${T.border}`,
flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.fgMuted, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{shortPath ?? '—'}
{componentData?.callSite?.lineNumber != null && (
<span style={{ color: T.dim }}>:{componentData.callSite.lineNumber}</span>
)}
</span>
<span
title={indexerStatus === 'ready' ? 'CLI indexer ready' : indexerStatus === 'indexing' ? 'Indexing…' : 'CLI indexer offline'}
style={{
display: 'inline-block', width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
background: indexerStatus === 'ready' ? '#7DD3A8' : indexerStatus === 'indexing' ? '#FFBA7B' : T.dim,
boxShadow: indexerStatus === 'ready' ? '0 0 5px rgba(125,211,168,0.7)' : 'none',
transition: 'background 0.25s',
}}
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
color: T.accent, background: T.accentBg,
border: `1px solid ${T.accent}33`, borderRadius: 4, padding: '1px 6px',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', maxWidth: 100,
}}>
{componentData?.name ?? componentId}
</span>
<span style={{ flex: 1 }} />
{(['split', 'unified'] as const).map(s => (
<button
key={s}
onClick={() => setDiffStyle(s)}
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
letterSpacing: '0.04em', textTransform: 'uppercase',
color: diffStyle === s ? T.accent : T.dim,
background: diffStyle === s ? T.accentBg : 'transparent',
border: `1px solid ${diffStyle === s ? T.accent + '44' : 'transparent'}`,
borderRadius: 3, padding: '2px 6px', cursor: 'pointer',
transition: 'color 0.15s, background 0.15s',
}}
>
{s}
</button>
))}
</div>
</div>
{/* ── Diff viewer ───────────────────────────────────────────────────── */}
<div style={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
{indexerStatus !== 'ready' ? (
<div style={{ padding: '20px 14px' }}>
<div style={{ padding: '10px 12px', background: 'rgba(255,186,123,0.06)', border: '1px solid rgba(255,186,123,0.2)', borderRadius: 7 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 7, marginBottom: 6 }}>
<div style={{ width: 5, height: 5, borderRadius: '50%', background: '#FFBA7B', flexShrink: 0 }} />
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FFBA7B', letterSpacing: '0.04em' }}>CLI indexer offline</span>
</div>
<p style={{ fontFamily: "'Inter', sans-serif", fontSize: '0.5875rem', color: T.fgDim, lineHeight: 1.6, margin: 0 }}>
Source diffs require the CLI indexer. Run{' '}
<code style={{ fontFamily: "'JetBrains Mono', monospace", color: '#FFBA7B', fontSize: '0.5rem' }}>
npx @originmain/cli dev
</code>{' '}to enable.
</p>
</div>
</div>
) : pendingChanges.length === 0 ? (
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em' }}>
No pending changes
</span>
</div>
) : isLoading ? (
<div style={{ padding: '36px 14px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim }}>Generating diff</span>
</div>
) : diffError ? (
<div style={{ padding: '14px', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: '#FF6B6B' }}>
{diffError}
</div>
) : fileDiff ? (
<div style={{ position: 'relative' }}>
{/* Token hover tooltip (Phase 4) */}
{tokenTooltip && (
<div
style={{
position: 'fixed',
left: tokenTooltip.x, top: tokenTooltip.y + 4,
zIndex: 9999,
padding: '4px 8px', borderRadius: 5,
background: '#1A1A22', border: '1px solid rgba(125,211,168,0.3)',
pointerEvents: 'none',
}}
>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: '#7DD3A8' }}>
{tokenTooltip.tokenKey}
</div>
<div style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.45rem', color: 'rgba(255,255,255,0.4)', marginTop: 1 }}>
{tokenTooltip.tokenName}
</div>
</div>
)}
<PierreDiff<TokenAnnotationMeta>
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 && (
<HunkList
hunks={fileDiff.hunks}
allRejected={allRejected}
fileDiff={fileDiff}
handleRejectHunk={handleRejectHunk}
setFileDiff={setFileDiff}
/>
)}
</div>
) : null}
</div>
{/* ── Footer: Realtime status + Send to Agent ───────────────────────── */}
<div style={{ padding: '9px 12px', borderTop: `1px solid ${T.border}`, flexShrink: 0, display: 'flex', flexDirection: 'column', gap: 7 }}>
{intentRtStatus && (
<div style={{ padding: '4px 9px', background: rtBg, border: `1px solid ${rtBorder}`, borderRadius: 5 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', color: rtColour, letterSpacing: '0.02em' }}>
{rtLabel}
</span>
</div>
)}
<button
onClick={() => void handleSendToAgent()}
disabled={!canSend}
style={{
width: '100%', fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
background: canSend ? T.accent : T.bgDeep,
color: canSend ? '#fff' : T.dim,
border: 'none', borderRadius: 6, padding: '7px 0',
cursor: canSend ? 'pointer' : 'not-allowed',
transition: 'background 0.15s',
}}
>
{isSending ? 'Sending…' : 'Send to Agent'}
</button>
</div>
</div>
);
}
@@ -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 (
<div
style={{
margin: '4px 10px 2px',
padding: '8px 10px',
background: hasError ? 'rgba(255,80,80,0.07)' : 'rgba(255,186,123,0.07)',
border: `1px solid ${hasError ? 'rgba(255,80,80,0.4)' : 'rgba(255,186,123,0.4)'}`,
borderRadius: 6,
}}
>
<div style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem', fontWeight: 600,
letterSpacing: '0.08em', textTransform: 'uppercase',
color: hasError ? '#FF8080' : '#FFBA7B', marginBottom: 6,
}}>
{hasError ? 'Design system violations' : 'Design system warnings'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{violations.map((v, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 5 }}>
<Badge
appearance="filled"
color={v.severity === 'error' ? 'danger' : 'warning'}
size="small"
style={{ flexShrink: 0, marginTop: 1 }}
>
{v.severity}
</Badge>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
color: T.fgMuted, lineHeight: 1.5,
}}>
{v.prop && <strong style={{ color: T.fg }}>{v.prop}: </strong>}
{v.message}
</span>
</div>
))}
</div>
</div>
);
}
// ── Main DesignTab component ───────────────────────────────────────────────────
interface DesignTabProps {
artboardId: string | null;
componentId: string | null;
componentData: FiberNode | null;
styles: Record<string, string> | 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<Violation[]>(() => {
if (!dlf || !componentData?.name) return [];
return checkComponentConstraints({
componentName: componentData.name,
props: (componentData.props ?? {}) as Record<string, unknown>,
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 (
<div style={{ padding: '32px 16px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
Select an artboard
</span>
</div>
);
}
if (!componentId) {
return (
<div style={{ padding: '32px 20px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 10, textAlign: 'center' }}>
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" style={{ opacity: 0.22 }}>
<rect x="2" y="2" width="24" height="24" rx="4" stroke="white" strokeWidth="1.4" strokeDasharray="4 2"/>
<circle cx="14" cy="14" r="4" stroke="white" strokeWidth="1.4"/>
</svg>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem', color: T.dim, letterSpacing: '0.04em', lineHeight: 1.6 }}>
Click a component in the<br/>artboard to inspect &amp; edit
</span>
</div>
);
}
if (!styles) {
return (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
Fetching styles
</span>
</div>
);
}
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 ───────────────────────────────────── */}
<div style={{
padding: '10px 14px 8px', borderBottom: `1px solid ${T.sep}`,
display: 'flex', flexDirection: 'column', gap: 4,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.6875rem',
fontWeight: 600, color: T.fg, letterSpacing: '-0.01em',
flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{componentData?.name ?? componentId}
</span>
<div
title={indexerDot.title}
style={{
width: 6, height: 6, borderRadius: '50%', flexShrink: 0,
background: indexerDot.color,
boxShadow: indexerStatus === 'ready' ? `0 0 5px ${indexerDot.color}` : 'none',
transition: 'background 0.3s',
}}
/>
</div>
{callSiteLabel && (
<span
style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5rem',
color: T.dim, letterSpacing: '0.02em',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}
title={`${callSite?.fileName}:${callSite?.lineNumber}`}
>
{callSiteLabel}
</span>
)}
</div>
{/* ── DLF violation banner ────────────────────────────────────────── */}
{dlfViolations.length > 0 && <DlfViolationBanner violations={dlfViolations} />}
{/* ── Section components ──────────────────────────────────────────── */}
<FrameSection styles={styles} onPatch={patch} />
<ConstraintsSection styles={styles} onPatch={patch} />
<LayoutSection styles={styles} onPatch={patch} />
<FillSection styles={styles} onPatch={patch} />
<StrokeSection styles={styles} onPatch={patch} />
<TypographySection
styles={styles}
hasDirectText={selectedComponentHasDirectText}
hasParagraphChildren={selectedComponentHasParagraphChildren}
onPatch={patch}
onPatchChildren={patchChildren}
/>
<EffectsSection styles={styles} onPatch={patch} />
<BoxModelSection styles={styles} onPatch={patch} />
</>
);
}
// Re-export so Inspector can still do a single-line import
export { DlfViolationBanner };
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
'use client';
// ── Props Tab (Phase 2) ───────────────────────────────────────────────────────
// ── Props Tab (Phase 2 + §5.11) ───────────────────────────────────────────────
// Displays artboard metadata, editable render URL / route, selected component
// props, and the Drift Report action. Extracted from Inspector.tsx as per
// spec SOURCE-AWARE-CANVAS.md Phase 2.
// props with TypeScript type badges, isolation prop editor, and the Drift Report
// action. Extracted from Inspector.tsx as per spec SOURCE-AWARE-CANVAS.md Phase 2.
import { useState, useCallback } from 'react';
import { useQueryClient } from '@tanstack/react-query';
@@ -19,6 +19,95 @@ const TYPE_COLORS: Record<string, string> = {
b: '#FFBA7B',
};
// ── TypeScript type badge (spec §5.11) ────────────────────────────────────────
type TsBadgeKind = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'null' | 'unknown';
function inferTsKind(v: unknown): TsBadgeKind {
if (v === null || v === undefined) return 'null';
if (Array.isArray(v)) return 'array';
const t = typeof v;
if (t === 'string') return 'string';
if (t === 'number') return 'number';
if (t === 'boolean') return 'boolean';
if (t === 'object') return 'object';
return 'unknown';
}
const TS_BADGE_COLORS: Record<TsBadgeKind, { fg: string; bg: string }> = {
string: { fg: '#7DD3A8', bg: 'rgba(125,211,168,0.10)' },
number: { fg: '#7EB8FF', bg: 'rgba(126,184,255,0.10)' },
boolean: { fg: '#FFBA7B', bg: 'rgba(255,186,123,0.10)' },
object: { fg: '#C084FC', bg: 'rgba(192,132,252,0.10)' },
array: { fg: '#C084FC', bg: 'rgba(192,132,252,0.10)' },
null: { fg: '#6B7280', bg: 'rgba(107,114,128,0.10)' },
unknown: { fg: '#6B7280', bg: 'rgba(107,114,128,0.10)' },
};
function TypeBadge({ kind }: { kind: TsBadgeKind }) {
const c = TS_BADGE_COLORS[kind];
return (
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.45rem',
color: c.fg, background: c.bg,
border: `1px solid ${c.fg}33`,
borderRadius: 3, padding: '0 3px', flexShrink: 0,
letterSpacing: '0.04em', lineHeight: '14px',
}}>
{kind}
</span>
);
}
// ── Isolation prop editor row (spec §5.11) ────────────────────────────────────
// Editable input for a single isolation prop override. Displays the runtime
// value and TypeScript type; edits are committed on blur/Enter.
function IsolationPropRow({
propKey, runtimeValue, override, kind,
onChange,
}: {
propKey: string;
runtimeValue: unknown;
override: unknown;
kind: TsBadgeKind;
onChange: (key: string, rawValue: string) => void;
}) {
const T = useCanvasTheme();
const displayedValue = override !== undefined ? override : runtimeValue;
const [draft, setDraft] = useState(String(displayedValue ?? ''));
const commit = useCallback(() => {
onChange(propKey, draft);
}, [propKey, draft, onChange]);
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 6 }}>
<TypeBadge kind={kind} />
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5625rem',
color: T.key, flexShrink: 0, minWidth: 60,
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>
{propKey}
</span>
<input
value={draft}
onChange={e => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={e => { if (e.key === 'Enter') commit(); }}
style={{
flex: 1, minWidth: 0, fontSize: '0.5625rem',
fontFamily: "'JetBrains Mono', monospace",
background: T.bgDeep, border: `1px solid ${T.border}`,
borderRadius: 4, padding: '3px 6px',
color: override !== undefined ? T.accent : T.fg, outline: 'none',
}}
/>
</div>
);
}
interface PropsTabProps {
artboard: Artboard | null;
selectedComponentData: FiberNode | null;
@@ -39,6 +128,31 @@ export function PropsTab({
const [editingRoute, setEditingRoute] = useState(false);
const [routeDraft, setRouteDraft] = useState('');
// ── Isolation prop overrides (spec §5.11) ─────────────────────────────────
// Stored in artboard.isolation_props (a direct DB column).
const isolationProps = (artboard?.isolation_props ?? {}) as Record<string, unknown>;
const isIsolation = artboard?.artboard_type === 'isolation';
const handleIsolationPropChange = useCallback(async (propKey: string, rawValue: string) => {
if (!artboard) return;
// Coerce the string input to the original runtime type
const runtimeVal = selectedComponentData?.props?.[propKey];
let coerced: unknown = rawValue;
if (typeof runtimeVal === 'number') {
const n = Number(rawValue);
coerced = isNaN(n) ? rawValue : n;
} else if (typeof runtimeVal === 'boolean') {
coerced = rawValue === 'true';
}
const updated = { ...isolationProps, [propKey]: coerced };
try {
await patchArtboard(artboard.id, { isolation_props: updated });
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
} catch (err) {
console.error('[PropsTab] isolation_props patch failed', err);
}
}, [artboard, isolationProps, selectedComponentData?.props, workspaceId, projectId, queryClient]);
// Drift report
const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [driftReport, setDriftReport] = useState('');
@@ -121,15 +235,36 @@ export function PropsTab({
return (
<>
{/* ── Selected fiber component props ─────────────────────────────── */}
{/* ── Selected fiber component props (spec §5.11) ─────────────────── */}
{selectedComponentData && (
<>
<Section label={`${selectedComponentData.name}`}>
{Object.entries(selectedComponentData.props ?? {}).map(([k, v]) => {
const t = typeof v;
const color = t === 'number' ? N : t === 'boolean' ? B : S;
const disp = t === 'string' ? `"${v as string}"` : String(v);
return <PropRow key={k} label={k} value={disp} color={color} />;
const kind = inferTsKind(v);
const color = kind === 'number' ? N : kind === 'boolean' ? B : S;
const disp = typeof v === 'string' ? `"${v}"` : String(v);
if (isIsolation) {
// Isolation artboard: show editable override input
return (
<IsolationPropRow
key={k}
propKey={k}
runtimeValue={v}
override={isolationProps[k]}
kind={kind}
onChange={handleIsolationPropChange}
/>
);
}
// Standard artboard: read-only with type badge
return (
<div key={k} style={{ display: 'flex', alignItems: 'center', gap: 5, marginBottom: 5 }}>
<TypeBadge kind={kind} />
<PropRow label={k} value={disp} color={color} />
</div>
);
})}
{Object.keys(selectedComponentData.props ?? {}).length === 0 && (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.dim }}>
@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useMemo, type ReactNode } from 'react';
import { useState, useCallback, useMemo, useEffect, useRef, type ReactNode } from 'react';
import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import { SquareRegular } from '@fluentui/react-icons';
@@ -10,8 +10,71 @@ import { useQueryClient } from '@tanstack/react-query';
import { useCanvasTheme } from '@/store/canvasTheme';
import { useTheme } from '@/store/theme';
// ── Auto-arrange algorithm (spec §3.4) ────────────────────────────────────────
// Lays artboards out in a grid: max 3 per row, 200px gap between each.
// Rows are sized to the tallest artboard in the row.
const AUTO_ARRANGE_GAP = 200;
const AUTO_ARRANGE_PER_ROW = 3;
async function autoArrangeArtboards(
artboards: Array<{ id: string; metadata_jsonb: Record<string, unknown> }>,
) {
// Sort by current canvas position — top-to-bottom then left-to-right.
const sorted = [...artboards].sort((a, b) => {
const ay = Number(a.metadata_jsonb['y'] ?? 0);
const by = Number(b.metadata_jsonb['y'] ?? 0);
const ax = Number(a.metadata_jsonb['x'] ?? 0);
const bx = Number(b.metadata_jsonb['x'] ?? 0);
return ay !== by ? ay - by : ax - bx;
});
// Pre-compute row max heights for column-y offsets.
const rowMaxHeights: number[] = [];
for (let i = 0; i < sorted.length; i++) {
const row = Math.floor(i / AUTO_ARRANGE_PER_ROW);
const h = Number(sorted[i]!.metadata_jsonb['height'] ?? 900);
rowMaxHeights[row] = Math.max(rowMaxHeights[row] ?? 0, h);
}
// Compute row y offsets (cumulative sum of maxHeights + gaps).
const rowYOffset: number[] = [];
let cumY = 0;
for (let r = 0; r < rowMaxHeights.length; r++) {
rowYOffset[r] = cumY;
cumY += (rowMaxHeights[r] ?? 900) + AUTO_ARRANGE_GAP;
}
// Patch each artboard with its new grid position (fire in parallel).
await Promise.all(
sorted.map((ab, i) => {
const col = i % AUTO_ARRANGE_PER_ROW;
const row = Math.floor(i / AUTO_ARRANGE_PER_ROW);
// Column x: sum widths + gaps of artboards before this one in the same row.
let x = 0;
for (let j = 0; j < col; j++) {
const prev = sorted[row * AUTO_ARRANGE_PER_ROW + j];
x += Number(prev?.metadata_jsonb['width'] ?? 1440) + AUTO_ARRANGE_GAP;
}
const y = rowYOffset[row] ?? 0;
return patchArtboard(ab.id, {
metadata_jsonb: { ...ab.metadata_jsonb, x, y },
});
}),
);
}
type NavTab = 'artboards' | 'routes';
// ── Context menu state shape ──────────────────────────────────────────────────
interface ContextMenuState {
artboardId: string;
label: string;
x: number;
y: number;
}
export function ArtboardNavigator() {
const T = useCanvasTheme();
const mode = useTheme((s) => s.mode);
@@ -20,6 +83,55 @@ export function ArtboardNavigator() {
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const queryClient = useQueryClient();
// ── Right-click context menu (spec §3.5) ───────────────────────────────────
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const contextMenuRef = useRef<HTMLDivElement>(null);
// Close context menu on outside click or Escape.
useEffect(() => {
if (!contextMenu) return;
const onPointerDown = (e: PointerEvent) => {
if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) {
setContextMenu(null);
}
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setContextMenu(null);
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [contextMenu]);
// ── Auto-arrange handler (spec §3.4) ───────────────────────────────────────
const handleAutoArrange = useCallback(async () => {
if (!rawArtboards.length) return;
await autoArrangeArtboards(
rawArtboards.map((ab) => ({ id: ab.id, metadata_jsonb: ab.metadata_jsonb as Record<string, unknown> })),
);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
}, [rawArtboards, workspaceId, projectId, queryClient]);
// ── Context menu actions ────────────────────────────────────────────────────
const setAsCover = useCallback(async (artboardId: string) => {
setContextMenu(null);
// Clear isCover from all artboards, then set it on the target.
await Promise.all(
rawArtboards.map((ab) =>
patchArtboard(ab.id, {
metadata_jsonb: {
...(ab.metadata_jsonb as Record<string, unknown>),
isCover: ab.id === artboardId ? true : undefined,
},
}),
),
);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
}, [rawArtboards, workspaceId, projectId, queryClient]);
// Aggregate unique routes across all artboards, deduplicated by path
const allRoutes = useMemo(() => {
const seen = new Set<string>();
@@ -151,30 +263,82 @@ export function ArtboardNavigator() {
{/* ── Artboards tab ── */}
{navTab === 'artboards' && (
<div style={{ padding: '2px 6px 0' }}>
{artboards.map((ab) => {
const sel = selectedArtboardId === ab.id;
const live = liveArtboardIds.has(ab.id);
return (
<NavRow
key={ab.id}
T={T}
selected={sel}
live={live}
onClick={() => selectArtboard(ab.id)}
icon={
<SquareRegular
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
/>
}
label={ab.label}
onRename={() => void renameArtboard(ab.id, ab.label)}
onFork={() => void forkArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/>
);
})}
</div>
<>
{/* Re-arrange button (spec §3.4) */}
{rawArtboards.length > 1 && (
<div style={{ padding: '2px 8px 4px', flexShrink: 0 }}>
<button
onClick={() => void handleAutoArrange()}
title="Re-arrange all artboards in a grid (200px gap, 3 per row)"
style={{
width: '100%', fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem', letterSpacing: '0.06em', textTransform: 'uppercase',
background: 'transparent', border: `1px solid ${T.sep}`,
borderRadius: 4, padding: '3px 0', color: T.dim, cursor: 'pointer',
transition: 'color 0.12s, border-color 0.12s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.color = T.fg; (e.currentTarget as HTMLButtonElement).style.borderColor = T.border; }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.color = T.dim; (e.currentTarget as HTMLButtonElement).style.borderColor = T.sep; }}
>
Re-arrange all
</button>
</div>
)}
<div style={{ padding: '2px 6px 0' }}>
{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<string, unknown> | undefined)?.['isCover'];
return (
<NavRow
key={ab.id}
T={T}
selected={sel}
live={live}
isCover={isCover}
onClick={() => selectArtboard(ab.id)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ artboardId: ab.id, label: ab.label, x: e.clientX, y: e.clientY });
}}
icon={
<SquareRegular
style={{ fontSize: 11, color: sel ? T.accent : T.dim, flexShrink: 0 }}
/>
}
label={ab.label}
onRename={() => void renameArtboard(ab.id, ab.label)}
onFork={() => void forkArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/>
);
})}
</div>
</>
)}
{/* ── Right-click context menu (spec §3.5) ── */}
{contextMenu && (
<ArtboardContextMenu
ref={contextMenuRef}
T={T}
x={contextMenu.x}
y={contextMenu.y}
onRename={() => {
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 (
<div
ref={ref}
style={{
position: 'fixed', left: x, top: y, zIndex: 9999,
background: T.bgDeep,
border: `1px solid ${T.border}`,
borderRadius: 7,
boxShadow: '0 4px 20px rgba(0,0,0,0.45)',
padding: '4px',
minWidth: 140,
userSelect: 'none',
}}
>
{items.map((item) => (
<ContextMenuItem key={item.label} T={T} danger={item.danger} onAction={item.action} onClose={onClose}>
{item.label}
</ContextMenuItem>
))}
</div>
);
});
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 (
<div
onMouseEnter={() => 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}
</div>
);
}
/* ── 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 (
<div
onClick={onClick}
onContextMenu={onContextMenu}
onMouseEnter={() => setHov(true)}
onMouseLeave={() => setHov(false)}
style={{
@@ -544,6 +788,18 @@ function NavRow({
{icon}
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{/* Cover badge */}
{isCover && !hov && !live && (
<span style={{
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.45rem',
color: T.accent, background: T.accentBg,
border: `1px solid ${T.accent}33`,
borderRadius: 3, padding: '0 3px', flexShrink: 0, letterSpacing: '0.04em',
}}>
cover
</span>
)}
{/* Live render indicator — pulsing green dot */}
{live && !hov && (
<span style={{
@@ -50,7 +50,7 @@ const BTN_PRIMARY: React.CSSProperties = {
type TeamRole = 'OWNER' | 'DESIGNER' | 'ENGINEER' | 'PM' | 'VIEWER';
function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
const [userId, setUserId] = useState('');
const [email, setEmail] = useState('');
const [role, setRole] = useState<TeamRole>('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 }) {
))}
</div>
{/* User ID input + submit */}
<label style={LABEL} htmlFor="invite-uid">Clerk user ID</label>
{/* Email input + submit */}
<label style={LABEL} htmlFor="invite-email">Email address</label>
<div style={{ display: 'flex', gap: 10 }}>
<input
id="invite-uid"
id="invite-email"
type="email"
style={INPUT}
placeholder="user_2abc…"
value={userId}
onChange={e => 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'}
/>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !userId.trim()}
disabled={status === 'loading' || !email.trim()}
style={{
...BTN_PRIMARY,
flexShrink: 0,
@@ -356,7 +357,7 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
Team
</h2>
<p style={{ fontSize: '0.8125rem', color: 'var(--card-muted)', margin: '0 0 20px', lineHeight: 1.6 }}>
Add a team member using their Clerk user ID. You can find this in the Clerk dashboard under Users.
Add a team member using their email address. They must sign in with the same address.
</p>
<TeamInviteForm workspaceId={workspaceId} />
+2 -2
View File
@@ -42,10 +42,10 @@ async function fetchArtboards(workspaceId: string, projectId?: string): Promise<
return { rows, canvas: rows.map(toCanvasArtboard).filter((ab): ab is CanvasArtboard => ab !== null) };
}
/** PATCH an artboard (name and/or metadata_jsonb). */
/** PATCH an artboard (name, metadata_jsonb, and/or isolation_props). */
export async function patchArtboard(
id: string,
patch: { name?: string; metadata_jsonb?: Record<string, unknown> },
patch: { name?: string; metadata_jsonb?: Record<string, unknown>; isolation_props?: Record<string, unknown> },
): Promise<Artboard> {
const res = await fetch(`/api/artboards/${encodeURIComponent(id)}`, {
method: 'PATCH',
+24 -24
View File
@@ -1,15 +1,20 @@
// ── useDlf hook ───────────────────────────────────────────────────────────────
// Fetches the active Design Language File for the current workspace and returns
// its parsed body as a typed DesignLanguageFileBody (tokens, component rules,
// screen rules, voice, accessibility). The raw DB row's schema_jsonb field is
// validated through the Zod schema at cache-write time so every consumer gets
// a fully typed result without re-parsing on each render.
// Fetches the active Design Language for the current workspace and returns its
// parsed body as a typed DesignLanguageFileBody (tokens, component rules,
// screen rules, voice, accessibility) when the raw_json matches the old DLF
// spec format (version "1.0").
//
// Stale time is 5 minutes — design systems change infrequently (deploy-time
// events) so we avoid redundant round-trips during normal editing sessions.
// Phase 6 note: the route now returns a DesignLanguage row (migration 014)
// whose raw_json may be a W3C DTCG / Style Dictionary / flat-CSS-vars token
// file rather than the old DLF body. We attempt to validate raw_json through
// DesignLanguageFileBodySchema; on failure we return null so the inspector
// gracefully omits constraint checking rather than crashing.
//
// Stale time is 5 minutes — design systems change at deploy-time, not
// interactively, so we avoid redundant round-trips during normal editing.
import { useQuery } from '@tanstack/react-query';
import type { DesignLanguageFile } from '@originmain/origin-graph';
import type { DesignLanguage } from '@originmain/origin-graph';
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from '@originmain/design-language';
// ── Fetch + parse ─────────────────────────────────────────────────────────────
@@ -19,19 +24,16 @@ async function fetchDlf(workspaceId: string): Promise<DesignLanguageFileBody | n
const res = await fetch(url);
if (!res.ok) throw new Error(`DLF fetch failed: ${res.status}`);
// API returns the raw DB row or null when no DLF is uploaded yet.
const file = (await res.json()) as DesignLanguageFile | null;
if (!file) return null;
// API returns the active DesignLanguage row or null when nothing is uploaded.
const dl = (await res.json()) as DesignLanguage | null;
if (!dl) return null;
// Attempt to validate raw_json through the old DLF body schema.
// Phase 6 token files (W3C DTCG etc.) will not match — we return null
// rather than throwing so constraint checking simply goes quiet.
const parsed = DesignLanguageFileBodySchema.safeParse(dl.raw_json);
if (!parsed.success) return null;
// Validate schema_jsonb through the typed Zod schema.
// We throw on failure so TanStack Query surfaces it via query.error —
// callers can distinguish "no DLF" (null) from "malformed DLF" (error).
const parsed = DesignLanguageFileBodySchema.safeParse(file.schema_jsonb);
if (!parsed.success) {
throw new Error(
`Design language file schema is invalid: ${parsed.error.errors.map(e => e.message).join('; ')}`,
);
}
return parsed.data;
}
@@ -43,17 +45,15 @@ export function useDlf(workspaceId: string | null | undefined) {
queryFn: () => fetchDlf(workspaceId!),
enabled: Boolean(workspaceId),
// Design language files change at deploy-time, not interactively.
// 5-minute staleness keeps the inspector snappy without burning requests.
staleTime: 5 * 60_000,
// Retry once on transient network errors, then surface the error.
retry: 1,
});
return {
/** Parsed DLF body, or null if no file is uploaded for this workspace. */
/** Parsed DLF body, or null if no file is uploaded or it uses the new token format. */
dlf: query.data ?? null,
isLoading: query.isLoading,
/** Set when the DLF fetch succeeded but the schema failed Zod validation. */
/** Set when the DLF fetch itself failed (network / server error). */
error: query.error,
};
}
File diff suppressed because one or more lines are too long