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,17 +263,44 @@ export function ArtboardNavigator() {
{/* ── Artboards tab ── */}
{navTab === 'artboards' && (
<>
{/* 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 }}
@@ -175,6 +314,31 @@ export function ArtboardNavigator() {
);
})}
</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
+7 -7
View File
@@ -40,7 +40,7 @@ function colorDistance(a: string, b: string): number {
// ── Numeric resolution ────────────────────────────────────────────────────────
function parseNumericPx(value: string): number | null {
function parseNumericPx(value: string, rootFontSizePx = 16): number | null {
const v = value.trim();
if (v.endsWith('px')) {
const n = parseFloat(v);
@@ -48,16 +48,16 @@ function parseNumericPx(value: string): number | null {
}
if (v.endsWith('rem')) {
const n = parseFloat(v);
return isNaN(n) ? null : n * 16; // normalise with standard 16px base
return isNaN(n) ? null : n * rootFontSizePx;
}
const n = parseFloat(v);
if (!isNaN(n) && v === String(n)) return n;
return null;
}
function numericDistance(a: string, b: string): number {
const na = parseNumericPx(a);
const nb = parseNumericPx(b);
function numericDistance(a: string, b: string, rootFontSizePx = 16): number {
const na = parseNumericPx(a, rootFontSizePx);
const nb = parseNumericPx(b, rootFontSizePx);
if (na === null || nb === null) return Infinity;
return Math.abs(na - nb);
}
@@ -96,7 +96,7 @@ export function resolveValueToToken(
distance = colorDistance(normalised, tokenNorm);
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
} else {
distance = numericDistance(normalised, tokenNorm);
distance = numericDistance(normalised, tokenNorm, rootFontSizePx);
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
}
@@ -134,7 +134,7 @@ export function resolveValueToTokens(
distance = colorDistance(normalised, tokenNorm);
if (distance > COLOR_DISTANCE_THRESHOLD) continue;
} else {
distance = numericDistance(normalised, tokenNorm);
distance = numericDistance(normalised, tokenNorm, rootFontSizePx);
if (distance > NUMERIC_DISTANCE_THRESHOLD) continue;
}
+67 -2
View File
@@ -10,6 +10,9 @@ import type {
Origin,
IntentDiff,
DesignLanguageFile,
DesignLanguage,
DesignLanguageVersion,
InsertDesignLanguage,
AgentSession,
TeamMember,
Project,
@@ -31,6 +34,7 @@ export interface DbClient {
select(cols?: string): DbQuery;
insert(row: unknown): DbMutation;
update(row: unknown): DbMutation;
upsert(row: unknown, opts?: { onConflict?: string }): DbMutation;
delete(): DbMutation;
};
rpc(fn: string, args?: unknown): Promise<{ data: unknown; error: DbError | null }>;
@@ -236,6 +240,66 @@ export async function getActiveDesignLanguageFile(
return data;
}
// ── Phase 6: design_languages queries (migration 014) ────────────────────────
/**
* Return the single active design language row for a workspace, or null if none
* has been uploaded yet. Uses the UNIQUE(workspace_id) constraint one row per
* workspace, no is_active flag needed.
*/
export async function getDesignLanguage(
db: DbClient,
workspaceId: string,
): Promise<DesignLanguage | null> {
const { data, error } = await (db
.from('design_languages')
.select('*')
.eq('workspace_id', workspaceId)
.limit(1)
.single() as Promise<{ data: DesignLanguage | null; error: DbError | null }>);
if (error?.code === 'PGRST116') return null; // No rows — no token file uploaded yet
if (error) throw new Error(error.message);
return data;
}
/**
* Upsert a design language row (INSERT ON CONFLICT DO UPDATE).
* The DB enforces UNIQUE(workspace_id) so this is safe for concurrent callers.
* Also inserts a history row into design_language_versions (handled by trigger).
*/
export async function upsertDesignLanguage(
db: DbClient,
row: InsertDesignLanguage,
): Promise<DesignLanguage> {
const { data, error } = await (db
.from('design_languages')
.upsert(row, { onConflict: 'workspace_id' })
.select()
.single() as Promise<{ data: DesignLanguage; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
/**
* Return the version history for a design language, newest first.
* The prune trigger keeps at most 10 rows per design_language_id.
*/
export async function getDesignLanguageVersions(
db: DbClient,
designLanguageId: string,
): Promise<DesignLanguageVersion[]> {
const { data, error } = await (db
.from('design_language_versions')
.select('*')
.eq('design_language_id', designLanguageId)
.order('version', { ascending: false }) as unknown as Promise<{
data: DesignLanguageVersion[];
error: DbError | null;
}>);
if (error) throw new Error(error.message);
return data ?? [];
}
// ── Origin queries ────────────────────────────────────────────────────────────
export async function getOrigin(db: DbClient, id: string): Promise<Origin> {
@@ -342,13 +406,14 @@ export async function addTeamMember(db: DbClient, row: InsertTeamMember): Promis
export async function removeTeamMember(
db: DbClient,
workspaceId: string,
userId: string,
/** The email address of the member to remove. */
email: string,
): Promise<void> {
const { error } = await (db
.from('team_members')
.delete()
.eq('workspace_id', workspaceId)
.eq('user_id', userId) as unknown as Promise<{ data: unknown; error: DbError | null }>);
.eq('email', email) as unknown as Promise<{ data: unknown; error: DbError | null }>);
if (error) throw new Error(error.message);
}
+46 -3
View File
@@ -41,7 +41,7 @@ export type WorkspacePlan = z.infer<typeof WorkspacePlanSchema>;
export const WorkspaceSchema = z.object({
id: z.string().uuid(),
name: z.string(),
owner_id: z.string(),
owner_email: z.string().email(),
plan: WorkspacePlanSchema,
settings_jsonb: z.record(z.unknown()),
created_at: z.string().datetime(),
@@ -111,7 +111,7 @@ export type Origin = z.infer<typeof OriginSchema>;
export const IntentDiffSchema = z.object({
id: z.string().uuid(),
artboard_id: z.string().uuid(),
author_id: z.string(),
author_email: z.string().email(),
/** Spec column name: changes (renamed from changes_jsonb in migration 008) */
changes: z.record(z.unknown()),
/** Spec column name: aggregate_summary (renamed from summary in migration 008) */
@@ -157,7 +157,7 @@ export type DesignLanguageFile = z.infer<typeof DesignLanguageFileSchema>;
export const TeamMemberSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
user_id: z.string(),
email: z.string().email(),
role: TeamRoleSchema,
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
@@ -199,3 +199,46 @@ export type InsertAgentSession = Omit<AgentSession, 'id' | 'created
export type InsertDesignLanguageFile = Omit<DesignLanguageFile, 'id' | 'created_at' | 'updated_at'>;
export type InsertTeamMember = Omit<TeamMember, 'id' | 'created_at' | 'updated_at'>;
export type InsertProject = Omit<Project, 'id' | 'created_at' | 'updated_at'>;
// ── Phase 6: design_languages (new token-file schema, migration 014) ──────────
/** Allowed source format discriminants (matches migration 014 CHECK constraint). */
export const SourceFormatSchema = z.enum(['dtcg', 'style-dictionary', 'flat-css-vars']);
export type SourceFormat = z.infer<typeof SourceFormatSchema>;
/**
* Mirrors the `design_languages` table (migration 014).
* One row per workspace UPSERT on every token file upload.
*/
export const DesignLanguageSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
name: z.string(),
/** Raw token file JSON as uploaded (before normalisation). */
raw_json: z.record(z.unknown()),
/** Normalised DesignToken[] flat array (from Phase 6 parser). */
normalized: z.array(z.record(z.unknown())),
source_format: SourceFormatSchema,
token_count: z.number().int(),
version: z.number().int(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type DesignLanguage = z.infer<typeof DesignLanguageSchema>;
/**
* Mirrors the `design_language_versions` table (migration 014).
* Append-only version history; pruned to last 10 per design_language.
*/
export const DesignLanguageVersionSchema = z.object({
id: z.string().uuid(),
design_language_id: z.string().uuid(),
version: z.number().int(),
raw_json: z.record(z.unknown()),
normalized: z.array(z.record(z.unknown())),
source_format: SourceFormatSchema,
created_at: z.string().datetime(),
});
export type DesignLanguageVersion = z.infer<typeof DesignLanguageVersionSchema>;
export type InsertDesignLanguage = Omit<DesignLanguage, 'id' | 'created_at' | 'updated_at'>;
+20
View File
@@ -124,6 +124,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.62.0
version: 5.99.2(react@19.2.5)
'@tanstack/react-virtual':
specifier: ^3.13.24
version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@trpc/client':
specifier: ^11.17.0
version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
@@ -1795,6 +1798,15 @@ packages:
peerDependencies:
react: ^18 || ^19
'@tanstack/react-virtual@3.13.24':
resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/virtual-core@3.14.0':
resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==}
'@trpc/client@11.17.0':
resolution: {integrity: sha512-KpJBFrbKTDeVCFv/3ckL1XBBH5Yssn8hethI/rUy7GIpTj+VzjtPjykDqJpzobuVOz+d26cXCSu1t4I6MYI5Zg==}
hasBin: true
@@ -4721,6 +4733,14 @@ snapshots:
'@tanstack/query-core': 5.99.2
react: 19.2.5
'@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/virtual-core': 3.14.0
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@tanstack/virtual-core@3.14.0': {}
'@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@trpc/server': 11.17.0(typescript@5.9.3)
@@ -0,0 +1,223 @@
-- ── Migration 014: design_languages & design_language_versions ────────────────
-- spec: SOURCE-AWARE-CANVAS.md Phase 6 §9.10 "DB Schema"
--
-- Stores the active Design Language (token file) for each workspace and
-- maintains a versioned history so designers can roll back to any prior upload.
--
-- Tables:
-- design_languages — one row per workspace (current active version)
-- design_language_versions — append-only version history (last 10 kept by trigger)
--
-- Identity note:
-- team_members.email is the authoritative user identifier (migration 015
-- renames user_id → email). RLS policies here use auth.jwt() ->> 'email'
-- to match the Clerk JWT template that injects the user's primary email as
-- the `email` claim.
--
-- Security:
-- RLS enabled on both tables; users can only access their own workspace rows.
-- The prune trigger runs as the table owner (SECURITY DEFINER) so it can
-- delete old version rows regardless of the invoking user's RLS policies.
-- ── design_languages ─────────────────────────────────────────────────────────
-- One row per workspace — UPSERT on every upload to keep this table small.
CREATE TABLE IF NOT EXISTS design_languages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL UNIQUE,
name text NOT NULL DEFAULT 'Design Language',
-- Full raw token file JSON as uploaded (before normalisation).
raw_json jsonb NOT NULL,
-- Normalised DesignToken[] flat array (produced by the Phase 6 parser).
normalized jsonb NOT NULL,
-- Detected source format: 'dtcg' | 'style-dictionary' | 'flat-css-vars'
source_format text NOT NULL,
-- Number of tokens in the normalised array (for quick display).
token_count integer NOT NULL DEFAULT 0,
-- Monotonically increasing version counter — starts at 1.
version integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- Enforce allowed source_format values.
ALTER TABLE design_languages
ADD CONSTRAINT design_languages_source_format_check
CHECK (source_format IN ('dtcg', 'style-dictionary', 'flat-css-vars'));
-- Index for workspace lookups (already implicit from UNIQUE constraint, but
-- explicit for clarity in query plans).
CREATE INDEX IF NOT EXISTS design_languages_workspace_id_idx
ON design_languages (workspace_id);
-- ── design_language_versions ──────────────────────────────────────────────────
-- Append-only history; pruned to last 10 entries per design_language by trigger.
CREATE TABLE IF NOT EXISTS design_language_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
design_language_id uuid NOT NULL REFERENCES design_languages(id) ON DELETE CASCADE,
version integer NOT NULL,
raw_json jsonb NOT NULL,
normalized jsonb NOT NULL,
source_format text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (design_language_id, version)
);
ALTER TABLE design_language_versions
ADD CONSTRAINT design_language_versions_source_format_check
CHECK (source_format IN ('dtcg', 'style-dictionary', 'flat-css-vars'));
CREATE INDEX IF NOT EXISTS design_language_versions_dl_id_idx
ON design_language_versions (design_language_id);
-- ── Prune trigger ─────────────────────────────────────────────────────────────
-- After every INSERT into design_language_versions, delete all rows for the
-- same design_language_id except the 10 most-recent (by version DESC).
-- Keeps storage bounded without requiring a separate cron job.
-- Runs as SECURITY DEFINER so it can bypass RLS on the versions table.
CREATE OR REPLACE FUNCTION prune_design_language_versions()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
DELETE FROM design_language_versions
WHERE design_language_id = NEW.design_language_id
AND id NOT IN (
SELECT id
FROM design_language_versions
WHERE design_language_id = NEW.design_language_id
ORDER BY version DESC
LIMIT 10
);
RETURN NULL; -- AFTER trigger; return value is ignored
END;
$$;
CREATE TRIGGER prune_design_language_versions_trigger
AFTER INSERT ON design_language_versions
FOR EACH ROW
EXECUTE FUNCTION prune_design_language_versions();
-- ── updated_at trigger ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_trigger
WHERE tgname = 'design_languages_set_updated_at'
AND tgrelid = 'design_languages'::regclass
) THEN
CREATE TRIGGER design_languages_set_updated_at
BEFORE UPDATE ON design_languages
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
END IF;
END;
$$;
-- ── Row Level Security ────────────────────────────────────────────────────────
-- API routes use the service-role key (bypasses RLS entirely).
-- These policies guard direct browser / anon-key access.
--
-- User identity: auth.jwt() ->> 'email' — set by the Clerk Supabase JWT
-- template which injects the user's verified primary email as the `email` claim.
-- This matches team_members.email (the authoritative identity column after
-- migration 015 renames user_id → email).
--
-- NOTE: these policies reference team_members.email. Migration 015 performs
-- that rename; if run before 015, team_members.user_id must be substituted
-- below. In practice migrations run in order so 014 always precedes 015.
-- The policies are dropped and recreated by migration 015 once the column
-- exists under its final name.
ALTER TABLE design_languages ENABLE ROW LEVEL SECURITY;
ALTER TABLE design_language_versions ENABLE ROW LEVEL SECURITY;
-- Deny all anon access (belt-and-suspenders with the service-role default).
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_languages' AND policyname = 'dl_no_anon'
) THEN
CREATE POLICY dl_no_anon ON design_languages FOR ALL TO anon USING (false);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_language_versions' AND policyname = 'dlv_no_anon'
) THEN
CREATE POLICY dlv_no_anon ON design_language_versions FOR ALL TO anon USING (false);
END IF;
END $$;
-- design_languages: workspace members may read / write their workspace row.
-- (Migration 015 drops and recreates these after renaming user_id → email.)
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_languages'
AND policyname = 'dl_workspace_member_select'
) THEN
CREATE POLICY dl_workspace_member_select ON design_languages
FOR SELECT
USING (
workspace_id IN (
SELECT workspace_id FROM team_members
WHERE user_id = auth.jwt() ->> 'email'
)
);
END IF;
END $$;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_languages'
AND policyname = 'dl_workspace_member_write'
) THEN
CREATE POLICY dl_workspace_member_write ON design_languages
FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM team_members
WHERE user_id = auth.jwt() ->> 'email'
)
);
END IF;
END $$;
-- design_language_versions: workspace members may read; inserts go via the
-- application layer (service role), deletes via the prune trigger.
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_policies
WHERE tablename = 'design_language_versions'
AND policyname = 'dlv_workspace_member_select'
) THEN
CREATE POLICY dlv_workspace_member_select ON design_language_versions
FOR SELECT
USING (
design_language_id IN (
SELECT dl.id FROM design_languages dl
JOIN team_members tm ON tm.workspace_id = dl.workspace_id
WHERE tm.user_id = auth.jwt() ->> 'email'
)
);
END IF;
END $$;
+337
View File
@@ -0,0 +1,337 @@
-- ── Migration 015: Email as universal user identity ───────────────────────────
--
-- Background
-- ----------
-- Prior migrations stored Clerk opaque user IDs (e.g. `user_2abc…`) in every
-- `user_id` / `owner_id` / `author_id` column. Those IDs are internal to Clerk
-- and cannot be resolved back to humans without an extra Clerk API call. The
-- only durable, human-readable identity available in this stack is the user's
-- primary email address.
--
-- What this migration does
-- ------------------------
-- 1. Renames identity columns in all tables:
-- team_members.user_id → email
-- workspaces.owner_id → owner_email
-- intent_diffs.author_id → author_email
--
-- 2. Drops all stale RLS policies that referenced the old column names or
-- used `auth.uid()::text` (which returns a UUID-shaped sub claim, not an
-- email) and recreates every policy to compare against
-- `auth.jwt() ->> 'email'` — the email claim injected by the Clerk
-- Supabase JWT template.
--
-- 3. Updates the `is_workspace_member` helper function from migration 003
-- to use the renamed column.
--
-- Application changes (applied in tandem)
-- ----------------------------------------
-- • API routes use currentUser().primaryEmailAddress.emailAddress instead of
-- auth().userId for all DB identity lookups.
-- • The invite UI accepts an email address instead of a Clerk user ID.
--
-- Idempotency
-- -----------
-- Column renames are guarded by DO $$ blocks that skip if the target column
-- already exists. Policy drops use DROP … IF EXISTS. Safe to re-run.
-- ══════════════════════════════════════════════════════════════════════════════
-- 1. Column renames
-- ══════════════════════════════════════════════════════════════════════════════
-- ── team_members: user_id → email ────────────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'team_members' AND column_name = 'user_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'team_members' AND column_name = 'email'
) THEN
ALTER TABLE team_members RENAME COLUMN user_id TO email;
END IF;
END $$;
-- Replace the unique constraint (workspace_id, user_id) with (workspace_id, email).
ALTER TABLE team_members
DROP CONSTRAINT IF EXISTS team_members_workspace_id_user_id_key;
DO $$ BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'team_members_workspace_id_email_key'
) THEN
ALTER TABLE team_members
ADD CONSTRAINT team_members_workspace_id_email_key UNIQUE (workspace_id, email);
END IF;
END $$;
-- Replace the index.
DROP INDEX IF EXISTS team_members_user_id_idx;
CREATE INDEX IF NOT EXISTS team_members_email_idx ON team_members (email);
-- ── workspaces: owner_id → owner_email ───────────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'workspaces' AND column_name = 'owner_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'workspaces' AND column_name = 'owner_email'
) THEN
ALTER TABLE workspaces RENAME COLUMN owner_id TO owner_email;
END IF;
END $$;
DROP INDEX IF EXISTS workspaces_owner_id_idx;
CREATE INDEX IF NOT EXISTS workspaces_owner_email_idx ON workspaces (owner_email);
-- ── intent_diffs: author_id → author_email ───────────────────────────────────
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'intent_diffs' AND column_name = 'author_id'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'intent_diffs' AND column_name = 'author_email'
) THEN
ALTER TABLE intent_diffs RENAME COLUMN author_id TO author_email;
END IF;
END $$;
-- ══════════════════════════════════════════════════════════════════════════════
-- 2. Drop all stale RLS policies
-- ══════════════════════════════════════════════════════════════════════════════
-- workspaces
DROP POLICY IF EXISTS workspaces_select ON workspaces;
DROP POLICY IF EXISTS workspaces_insert ON workspaces;
DROP POLICY IF EXISTS workspaces_update ON workspaces;
DROP POLICY IF EXISTS "workspace owner access" ON workspaces;
-- artboards
DROP POLICY IF EXISTS artboards_select ON artboards;
DROP POLICY IF EXISTS artboards_insert ON artboards;
DROP POLICY IF EXISTS artboards_update ON artboards;
DROP POLICY IF EXISTS artboards_delete ON artboards;
DROP POLICY IF EXISTS "workspace member artboard access" ON artboards;
-- origins
DROP POLICY IF EXISTS "workspace member origin access" ON origins;
-- intent_diffs
DROP POLICY IF EXISTS intent_diffs_select ON intent_diffs;
DROP POLICY IF EXISTS intent_diffs_insert ON intent_diffs;
DROP POLICY IF EXISTS intent_diffs_update ON intent_diffs;
DROP POLICY IF EXISTS "workspace member diff access" ON intent_diffs;
-- agent_sessions
DROP POLICY IF EXISTS agent_sessions_select ON agent_sessions;
DROP POLICY IF EXISTS agent_sessions_insert ON agent_sessions;
DROP POLICY IF EXISTS agent_sessions_update ON agent_sessions;
DROP POLICY IF EXISTS "workspace member session access" ON agent_sessions;
-- design_language_files
DROP POLICY IF EXISTS dlf_select ON design_language_files;
DROP POLICY IF EXISTS dlf_insert ON design_language_files;
DROP POLICY IF EXISTS dlf_update ON design_language_files;
DROP POLICY IF EXISTS "workspace member dlf access" ON design_language_files;
DROP POLICY IF EXISTS dlf_workspace_select ON design_language_files;
DROP POLICY IF EXISTS dlf_workspace_write ON design_language_files;
-- team_members
DROP POLICY IF EXISTS team_members_select ON team_members;
DROP POLICY IF EXISTS team_members_insert ON team_members;
DROP POLICY IF EXISTS team_members_delete ON team_members;
-- design_languages (migration 014)
DROP POLICY IF EXISTS dl_workspace_member_select ON design_languages;
DROP POLICY IF EXISTS dl_workspace_member_write ON design_languages;
DROP POLICY IF EXISTS "design_languages_workspace_member_select" ON design_languages;
DROP POLICY IF EXISTS "design_languages_workspace_member_insert" ON design_languages;
DROP POLICY IF EXISTS "design_languages_workspace_member_update" ON design_languages;
-- design_language_versions (migration 014)
DROP POLICY IF EXISTS dlv_workspace_member_select ON design_language_versions;
DROP POLICY IF EXISTS "design_language_versions_workspace_member_select" ON design_language_versions;
DROP POLICY IF EXISTS "design_language_versions_workspace_member_insert" ON design_language_versions;
-- ══════════════════════════════════════════════════════════════════════════════
-- 3. Update the is_workspace_member helper (migration 003)
-- ══════════════════════════════════════════════════════════════════════════════
-- Recreate with team_members.email and auth.jwt() ->> 'email'.
CREATE OR REPLACE FUNCTION is_workspace_member(ws_id UUID)
RETURNS BOOLEAN LANGUAGE sql SECURITY DEFINER AS $$
SELECT EXISTS (
SELECT 1 FROM team_members
WHERE workspace_id = ws_id
AND email = auth.jwt() ->> 'email'
);
$$;
-- ══════════════════════════════════════════════════════════════════════════════
-- 4. Recreate all RLS policies with email-based identity
-- ══════════════════════════════════════════════════════════════════════════════
--
-- Identity predicate throughout: auth.jwt() ->> 'email'
-- This reads the `email` claim from the Clerk Supabase JWT template.
--
-- For workspace owners the check is: workspaces.owner_email = auth.jwt() ->> 'email'
-- For general membership: team_members.email = auth.jwt() ->> 'email'
-- ── workspaces ────────────────────────────────────────────────────────────────
CREATE POLICY workspaces_select ON workspaces
FOR SELECT
USING (is_workspace_member(id));
CREATE POLICY workspaces_insert ON workspaces
FOR INSERT
WITH CHECK (owner_email = auth.jwt() ->> 'email');
CREATE POLICY workspaces_update ON workspaces
FOR UPDATE
USING (owner_email = auth.jwt() ->> 'email');
-- ── artboards ─────────────────────────────────────────────────────────────────
CREATE POLICY artboards_select ON artboards
FOR SELECT USING (is_workspace_member(workspace_id));
CREATE POLICY artboards_insert ON artboards
FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
CREATE POLICY artboards_update ON artboards
FOR UPDATE USING (is_workspace_member(workspace_id));
CREATE POLICY artboards_delete ON artboards
FOR DELETE USING (is_workspace_member(workspace_id));
-- ── origins ───────────────────────────────────────────────────────────────────
-- Accepts both FK directions (spec: origins.artboard_id; legacy: artboards.origin_id).
CREATE POLICY "workspace member origin access" ON origins
FOR ALL
USING (
-- spec-compliant: origins.artboard_id set to the artboard's uuid
artboard_id IN (
SELECT id FROM artboards
WHERE is_workspace_member(workspace_id)
)
OR
-- legacy: artboards.origin_id points at this origin row
id IN (
SELECT origin_id FROM artboards
WHERE is_workspace_member(workspace_id)
AND origin_id IS NOT NULL
)
);
-- ── intent_diffs ──────────────────────────────────────────────────────────────
CREATE POLICY intent_diffs_select ON intent_diffs
FOR SELECT
USING (is_workspace_member(
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
));
CREATE POLICY intent_diffs_insert ON intent_diffs
FOR INSERT
WITH CHECK (is_workspace_member(
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
));
CREATE POLICY intent_diffs_update ON intent_diffs
FOR UPDATE
USING (is_workspace_member(
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
));
-- ── agent_sessions ────────────────────────────────────────────────────────────
CREATE POLICY agent_sessions_select ON agent_sessions
FOR SELECT
USING (is_workspace_member(
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
));
CREATE POLICY agent_sessions_insert ON agent_sessions
FOR INSERT
WITH CHECK (is_workspace_member(
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
));
CREATE POLICY agent_sessions_update ON agent_sessions
FOR UPDATE
USING (is_workspace_member(
(SELECT workspace_id FROM artboards WHERE id = artboard_id)
));
-- ── design_language_files ─────────────────────────────────────────────────────
CREATE POLICY dlf_workspace_select ON design_language_files
FOR SELECT
USING (is_workspace_member(workspace_id));
CREATE POLICY dlf_workspace_write ON design_language_files
FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM team_members
WHERE email = auth.jwt() ->> 'email'
AND role IN ('OWNER', 'DESIGNER')
)
);
-- ── team_members ──────────────────────────────────────────────────────────────
CREATE POLICY team_members_select ON team_members
FOR SELECT USING (is_workspace_member(workspace_id));
-- Only workspace owners can add members.
CREATE POLICY team_members_insert ON team_members
FOR INSERT
WITH CHECK (
EXISTS (
SELECT 1 FROM workspaces
WHERE id = workspace_id
AND owner_email = auth.jwt() ->> 'email'
)
);
-- Owners can remove anyone; members can remove themselves.
CREATE POLICY team_members_delete ON team_members
FOR DELETE
USING (
EXISTS (
SELECT 1 FROM workspaces
WHERE id = workspace_id
AND owner_email = auth.jwt() ->> 'email'
)
OR email = auth.jwt() ->> 'email'
);
-- ── design_languages ──────────────────────────────────────────────────────────
CREATE POLICY dl_workspace_member_select ON design_languages
FOR SELECT
USING (is_workspace_member(workspace_id));
CREATE POLICY dl_workspace_member_write ON design_languages
FOR ALL
USING (is_workspace_member(workspace_id));
-- ── design_language_versions ──────────────────────────────────────────────────
CREATE POLICY dlv_workspace_member_select ON design_language_versions
FOR SELECT
USING (
design_language_id IN (
SELECT dl.id FROM design_languages dl
WHERE is_workspace_member(dl.workspace_id)
)
);