improved a lot of things

This commit is contained in:
SinachPat
2026-04-29 04:07:16 +01:00
parent 960646fee3
commit 050c4ce7fe
36 changed files with 856 additions and 226 deletions
+2 -1
View File
@@ -137,7 +137,8 @@
"Bash(pnpm --filter @originmain/origin-graph test)",
"Bash(grep -E \"\\\\.d\\\\.ts$\")",
"Bash(pnpm -w ls @anthropic-ai/sdk)",
"Bash(grep -v \"\\\\.map$\")"
"Bash(grep -v \"\\\\.map$\")",
"Bash(perl -pi -e 's/background: '\\\\''#FAFAFA'\\\\''/background: '\\\\''var\\(--page-bg\\)'\\\\''/g' /Users/USER/Desktop/originmain/packages/app/src/app/onboarding/page.tsx /Users/USER/Desktop/originmain/packages/app/src/app/error.tsx)"
]
}
}
+18 -6
View File
@@ -299,14 +299,26 @@ The following is a second-pass audit of all real product gaps, independent of la
- **Vitest: diff-engine**: stray test moved to correct location; all 49 tests pass, 88% coverage
- **Vitest: origin-graph**: 45 new Zod schema tests in `__tests__/types.test.ts`; 100% `types.ts` coverage
### ✅ Completed — Session 5 (2026-04-28)
- **Completion zone payload fixed**: `POST /api/ai/completion-zone` now adapts canvas UI shape `{artboard_id, bounds, prompt}``CompletionZoneInput {componentTreeJson, intent}`. Auto-loads active DLF from workspace. Returns `description` + backwards-compat `result`/`completion` keys.
- **Cross-artboard query fixed**: `POST /api/ai/query` now adapts navigator UI shape `{workspace_id, question}``ArtboardQueryInput {query, artboardsJson}`. Fetches live artboard list from DB. Returns `{results, reasoning, answer}`.
- **DELETE /api/workspace/:id**: Owner-only workspace deletion. `deleteWorkspace()` added to `origin-graph/queries.ts`. WorkspaceSettingsForm danger zone now calls the endpoint and redirects to `/workspaces` on success.
- **Diff summary wired**: `POST /api/ai/diff-summary` route created. DiffTab export now calls it before `POST /api/diffs` — summary included in the diff payload. Export button shows "Summarising…" → "Exporting…" states.
- **Artboard fork**: Fork button (branch icon) added to navigator artboard rows. Creates child artboard with `parent_artboard_id` set, offset 40px right of source, same `renderUrl` + `origin_id` inherited.
- **Migration split fixed**: Migrations 002005 copied to `supabase/migrations/`. `get_diffs_by_status` RPC appended to `origin-graph/migrations/001_initial_schema.sql`.
- **Dead code removed**: `packages/app/src/hooks/useDiff.ts` and `packages/app/src/data/artboard-snapshots.ts` deleted (no imports, confirmed safe).
- **Dark mode**: `packages/app/src/store/theme.ts` (Zustand persist, key `originmain:theme`). `providers.tsx` reads `useTheme` to pick `originmainLightTheme`/`originmainDarkTheme`. Sun/moon toggle button in AppChrome breadcrumb bar.
- **Plugin stub page**: `/workspace/[wid]/plugins` — full page with permissions preview, Phase 4 roadmap note. Linked from workspace page header alongside Settings.
- **Agent status badges**: `Artboard.tsx` calls `useDiffs(id)` and renders compact colored chips (draft/reviewed/applied/blocked) bottom-right of each artboard frame when diffs exist.
- **TypeScript**: `tsc --noEmit` exits 0 on both `packages/app` and `packages/origin-graph`.
### 🔧 Remaining — Lower Priority
- [ ] **Multiplayer**: Wire `MultiplayerAdapter` into app (install `@liveblocks/client` + `@liveblocks/react`; create `packages/app/src/lib/liveblocks.ts`)
- [ ] **Plugin system stub page**: UI route at `/workspace/[wid]/plugins` listing installed plugins
- [ ] **`packages/e2e`**: Playwright E2E tests not yet created
- [ ] **Redis rate limiter**: Replace in-process rate limiter in agent-bridge for multi-instance support
- [ ] **Redis rate limiter**: Replace in-process rate limiter in `agent-bridge/src/rate-limiter.ts` for multi-instance MCP support
- [ ] **Product analytics**: Instrument PostHog for activation/engagement metrics (GTM requirement)
- [ ] **Onboarding wizard**: Step-by-step "connect your app" flow from signup to first live artboard
**Webhook project_id:**
`/api/webhooks/[provider]/route.ts` currently sets `project_id: null`. Read the `?project=` query param from the request URL (`new URL(req.url).searchParams.get('project')`) and pass it to the ingestion result.
*Last updated: 2026-04-26 — Session 3 complete*
*Last updated: 2026-04-28 — Session 5 complete*
+3 -1
View File
@@ -2,7 +2,9 @@ import Anthropic from '@anthropic-ai/sdk';
// ── Model constants ───────────────────────────────────────────────────────────
export const MODEL = 'claude-opus-4-7' as const;
// claude-opus-4-5 is the current stable Opus 4 release.
// Extended thinking is enabled in gateway.ts with budget_tokens.
export const MODEL = 'claude-opus-4-5' as const;
// ── Singleton client ──────────────────────────────────────────────────────────
// The client is created once and shared. API key is injected from the server
+11 -4
View File
@@ -73,8 +73,10 @@ export interface GatewayRequest {
messages: Anthropic.Messages.MessageParam[];
system?: Anthropic.Messages.TextBlockParam[];
maxTokens?: number;
// NOTE: temperature is intentionally omitted — Opus 4.7 with adaptive thinking
// rejects temperature, top_p, and top_k with a 400 error.
// NOTE: temperature is intentionally omitted — extended thinking rejects
// temperature, top_p, and top_k with a 400 error.
// NOTE: when thinking is enabled, max_tokens must be >= 16_000 and
// budget_tokens must be < max_tokens. We enforce this in complete().
}
export interface GatewayResponse {
@@ -101,10 +103,15 @@ export class AIGateway {
let lastError: Error | null = null;
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try {
// Extended thinking requires max_tokens >= 16_000.
// budget_tokens must be strictly less than max_tokens.
const maxTokens = Math.max(req.maxTokens ?? 4096, 16_000);
const budgetTokens = Math.floor(maxTokens * 0.8);
const response = await this.client.messages.create({
model: MODEL,
max_tokens: req.maxTokens ?? 4096,
thinking: { type: 'adaptive' },
max_tokens: maxTokens,
thinking: { type: 'enabled', budget_tokens: budgetTokens },
...(req.system !== undefined ? { system: req.system } : {}),
messages: req.messages,
});
@@ -1,21 +1,83 @@
// POST /api/ai/completion-zone
// Fills a design completion zone given a component tree and user intent.
//
// Accepts two shapes:
// Canvas UI payload: { artboard_id, bounds, prompt, componentTreeJson? }
// Canonical payload: { componentTreeJson, intent, dlfJson?, screenshotBase64? }
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, fillCompletionZone } from '@originmain/ai-layer';
import type { CompletionZoneInput } from '@originmain/ai-layer';
import { serverClient } from '@/lib/supabase';
import { getActiveDesignLanguageFile } from '@originmain/origin-graph';
// ── Canvas UI payload shape ───────────────────────────────────────────────────
interface CanvasPayload {
artboard_id?: string;
bounds?: { x: number; y: number; width: number; height: number };
prompt?: string;
componentTreeJson?: string;
}
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as CompletionZoneInput;
const raw = (await req.json()) as CanvasPayload & Partial<CompletionZoneInput>;
// ── Adapt canvas UI payload → CompletionZoneInput ────────────────────────
let input: CompletionZoneInput;
if (raw.intent !== undefined) {
// Already canonical shape — pass through
input = raw as CompletionZoneInput;
} else {
// Canvas UI shape: {artboard_id, bounds, prompt, componentTreeJson?}
if (!raw.prompt?.trim()) {
return NextResponse.json({ error: 'prompt is required' }, { status: 400 });
}
// Build a context string from bounds + artboard metadata if available
const boundsLabel = raw.bounds
? `Canvas region: x=${raw.bounds.x}, y=${raw.bounds.y}, width=${raw.bounds.width}, height=${raw.bounds.height}`
: '';
// Try to load the active DLF for this workspace (best-effort — skip on error)
let dlfJson: string | undefined;
if (raw.artboard_id) {
try {
const db = serverClient();
// Resolve workspace_id from artboard
const { data: ab } = await db
.from('artboards')
.select('workspace_id')
.eq('id', raw.artboard_id)
.single();
if (ab) {
const dlf = await getActiveDesignLanguageFile(db, (ab as { workspace_id: string }).workspace_id);
if (dlf) dlfJson = JSON.stringify(dlf.schema_jsonb);
}
} catch { /* non-fatal */ }
}
input = {
componentTreeJson: raw.componentTreeJson ?? boundsLabel ?? '{}',
intent: raw.prompt.trim(),
...(dlfJson !== undefined ? { dlfJson } : {}),
};
}
try {
const gateway = new AIGateway();
const result = await fillCompletionZone(gateway, body);
return NextResponse.json({ proposedTree: result.proposedTree, description: result.description });
const result = await fillCompletionZone(gateway, input);
return NextResponse.json({
proposedTree: result.proposedTree,
description: result.description,
// Also include result/completion for backwards-compat with any old clients
result: result.description,
completion: result.description,
});
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
@@ -0,0 +1,29 @@
// POST /api/ai/diff-summary
// Generates a one-sentence natural-language summary of a set of component changes.
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, generateDiffSummary } from '@originmain/ai-layer';
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as { changesJson?: string; componentName?: string };
if (!body.changesJson) {
return NextResponse.json({ error: 'changesJson is required' }, { status: 400 });
}
try {
const gateway = new AIGateway();
const result = await generateDiffSummary(gateway, {
changesJson: body.changesJson,
componentName: body.componentName ?? 'Component',
});
return NextResponse.json({ summary: result.summary });
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+54 -3
View File
@@ -1,21 +1,72 @@
// POST /api/ai/query
// Cross-artboard search: finds artboards matching a natural language query.
//
// Accepts two shapes:
// Navigator UI payload: { workspace_id, question }
// Canonical payload: { query, artboardsJson }
import { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, queryCrossArtboard } from '@originmain/ai-layer';
import type { ArtboardQueryInput } from '@originmain/ai-layer';
import { serverClient } from '@/lib/supabase';
import { getArtboards } from '@originmain/origin-graph';
interface NavigatorPayload {
workspace_id?: string;
question?: string;
}
export async function POST(req: NextRequest) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as ArtboardQueryInput;
const raw = (await req.json()) as NavigatorPayload & Partial<ArtboardQueryInput>;
// ── Adapt navigator UI payload → ArtboardQueryInput ──────────────────────
let input: ArtboardQueryInput;
if (raw.query !== undefined && raw.artboardsJson !== undefined) {
// Already canonical
input = raw as ArtboardQueryInput;
} else {
// Navigator UI shape: { workspace_id, question }
const queryText = (raw.question ?? raw.query ?? '').trim();
if (!queryText) return NextResponse.json({ error: 'question is required' }, { status: 400 });
const workspaceId = raw.workspace_id;
if (!workspaceId) return NextResponse.json({ error: 'workspace_id is required' }, { status: 400 });
// Fetch artboard metadata to give the AI context
let artboardsJson = '[]';
try {
const db = serverClient();
const artboards = await getArtboards(db, workspaceId);
artboardsJson = JSON.stringify(
artboards.map(ab => ({
id: ab.id,
name: ab.name,
metadata: ab.metadata_jsonb,
created_at: ab.created_at,
}))
);
} catch { /* non-fatal: AI will work with empty list */ }
input = { query: queryText, artboardsJson };
}
try {
const gateway = new AIGateway();
const result = await queryCrossArtboard(gateway, body);
return NextResponse.json(result);
const result = await queryCrossArtboard(gateway, input);
// Return both the canonical shape AND a human-readable answer string
// so the navigator UI's `data.answer ?? data.result` check gets something useful.
const answerText = result.reasoning ||
(result.results.length > 0
? result.results.map(r => `${r.artboardId}: ${r.reason}`).join('\n')
: 'No matching artboards found.');
return NextResponse.json({ ...result, answer: answerText, result: answerText });
} catch (err) {
const message = err instanceof Error ? err.message : 'AI error';
return NextResponse.json({ error: message }, { status: 500 });
@@ -1,9 +1,11 @@
// PATCH /api/workspace/:id → rename workspace (owner only)
// GET /api/workspace/:id → fetch workspace (any member)
// GET /api/workspace/:id → fetch workspace (any member)
// 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 { NextRequest, NextResponse } from 'next/server';
import { serverClient } from '@/lib/supabase';
import { deleteWorkspace } from '@originmain/origin-graph';
import type { Workspace } from '@originmain/origin-graph';
type RouteContext = { params: Promise<{ id: string }> };
@@ -69,3 +71,22 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
return NextResponse.json(data as Workspace);
}
export async function DELETE(_req: NextRequest, { params }: RouteContext) {
const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const { id } = await params;
const db = serverClient();
const isOwner = await assertOwner(db, id, userId);
if (!isOwner) return NextResponse.json({ error: 'Only workspace owners can delete' }, { status: 403 });
try {
await deleteWorkspace(db, id);
return new NextResponse(null, { status: 204 });
} catch (err) {
const message = err instanceof Error ? err.message : 'Delete failed';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ export default function GlobalError({
margin: 0, minHeight: '100vh',
display: 'flex', flexDirection: 'column',
alignItems: 'center', justifyContent: 'center',
background: '#FAFAFA',
background: 'var(--page-bg)',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
padding: 24,
}}>
+20 -1
View File
@@ -12,9 +12,28 @@ html, body {
for the canvas route only. Setting it globally here would break
scrolling on settings, workspace, and other non-canvas pages. */
/* ── Theme CSS variables ─────────────────────────────────────────────────────
--page-bg : background of workspace / settings / plugin shell pages
--page-text: primary text colour on those pages
The canvas editor (AppChrome) uses its own inline dark styles and does not
consume these variables. Toggling data-theme on <html> via Providers is
enough for all shell pages to respond. */
:root {
--page-bg: #FAFAFA;
--page-text: #0A0A0A;
}
[data-theme="dark"] {
--page-bg: #0C0C10;
--page-text: #FAFAFA;
}
body {
font-family: 'Segoe UI Variable', 'Segoe UI', system-ui, -apple-system, sans-serif;
background: #f5f5f5;
background: var(--page-bg);
color: var(--page-text);
transition: background 0.2s, color 0.2s;
}
/* ── Accessible focus indicators ────────────────────────────────────────────
+1 -1
View File
@@ -49,7 +49,7 @@ export default function OnboardingPage() {
<main style={{
minHeight: '100dvh',
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
background: '#FAFAFA',
background: 'var(--page-bg)',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
padding: '24px',
}}>
+16 -8
View File
@@ -1,31 +1,39 @@
'use client';
import { useState, type ReactNode } from 'react';
import { useState, useEffect, type ReactNode } from 'react';
import { FluentProvider } from '@fluentui/react-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { originmainLightTheme } from '@originmain/ui';
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
import { useTheme } from '@/store/theme';
export function Providers({ children }: { children: ReactNode }) {
// TanStack Query v5: create QueryClient inside useState so it's stable across
// re-renders and each client-side navigation gets the same instance. Creating
// it outside the component would cause it to be shared across requests on the
// server (SSR memory leak / cross-request data pollution).
// re-renders and each client-side navigation gets the same instance.
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // treat data as fresh for 30 s
retry: 1, // one automatic retry on transient errors
staleTime: 30_000,
retry: 1,
refetchOnWindowFocus: false,
},
},
}),
);
const mode = useTheme((s) => s.mode);
const theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme;
// Sync data-theme on <html> so CSS variables and server-rendered page
// backgrounds can respond to the user's preference without prop drilling.
useEffect(() => {
document.documentElement.setAttribute('data-theme', mode);
}, [mode]);
return (
<QueryClientProvider client={queryClient}>
<FluentProvider theme={originmainLightTheme} style={{ height: '100%' }}>
<FluentProvider theme={theme} style={{ height: '100%' }}>
{children}
</FluentProvider>
</QueryClientProvider>
@@ -16,7 +16,7 @@ export default function WorkspaceError({
}, [error]);
return (
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<div style={{ minHeight: '100vh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }]} />
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', marginBottom: 16 }}></div>
+13 -1
View File
@@ -46,7 +46,7 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
const projects = (projectsData ?? []) as Project[];
return (
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader breadcrumbs={[
{ label: 'Workspaces', href: '/workspaces' },
{ label: workspace.name },
@@ -67,6 +67,18 @@ export default async function WorkspacePage({ params }: { params: Promise<{ wid:
</p>
</div>
<div style={{ display: 'flex', gap: 10 }}>
<Link href={`/workspace/${wid}/plugins`} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'transparent', color: '#71717A',
fontSize: '0.875rem', fontWeight: 500,
padding: '9px 16px', borderRadius: 9,
textDecoration: 'none', border: '1px solid rgba(0,0,0,0.1)',
}}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
<path d="M12 2a2 2 0 0 1 2 2v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v1a2 2 0 0 1-4 0v-1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V6a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2z" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
Plugins
</Link>
<Link href={`/workspace/${wid}/settings`} style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'transparent', color: '#71717A',
@@ -0,0 +1,113 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { serverClient } from '@/lib/supabase';
import { AppHeader } from '@/components/shell/AppHeader';
import type { Workspace } from '@originmain/origin-graph';
export async function generateMetadata({ params }: { params: Promise<{ wid: string }> }) {
const { wid } = await params;
const db = serverClient();
const result = (await db.from('workspaces').select('name').eq('id', wid).single()) as unknown as { data: { name: string } | null };
return { title: `Plugins — ${result.data?.name ?? 'Workspace'} — Originmain` };
}
export default async function WorkspacePluginsPage({ params }: { params: Promise<{ wid: string }> }) {
const { wid } = await params;
const { userId } = await auth();
if (!userId) redirect('/sign-in');
const db = serverClient();
// Verify membership
const { data: member } = await db
.from('team_members')
.select('id')
.eq('workspace_id', wid)
.eq('user_id', userId)
.limit(1)
.single();
if (!member) redirect('/workspaces');
const { data: ws } = await db.from('workspaces').select('*').eq('id', wid).single();
const workspace = ws as Workspace | null;
return (
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif" }}>
<AppHeader workspaceName={workspace?.name ?? 'Workspace'} workspaceId={wid} />
<div style={{ maxWidth: 760, margin: '0 auto', padding: '48px 24px' }}>
{/* Breadcrumb */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 32, fontSize: '0.8125rem', color: '#71717A' }}>
<Link href="/workspaces" style={{ color: '#71717A', textDecoration: 'none' }}>Workspaces</Link>
<span>/</span>
<Link href={`/workspace/${wid}`} style={{ color: '#71717A', textDecoration: 'none' }}>{workspace?.name ?? 'Workspace'}</Link>
<span>/</span>
<span style={{ color: '#09090B' }}>Plugins</span>
</div>
<h1 style={{ fontSize: '1.5rem', fontWeight: 700, letterSpacing: '-0.025em', color: '#09090B', margin: '0 0 8px' }}>
Plugins
</h1>
<p style={{ fontSize: '0.9375rem', color: '#52525B', margin: '0 0 40px', lineHeight: 1.6 }}>
Extend Originmain with custom completion zones, ingestion connectors, and design tools.
</p>
{/* Coming soon card */}
<div style={{
background: '#FFFFFF', border: '1px solid rgba(0,0,0,0.07)', borderRadius: 14,
padding: '40px 36px', textAlign: 'center',
}}>
<div style={{
width: 52, height: 52, borderRadius: 14,
background: 'rgba(51,133,255,0.08)', border: '1px solid rgba(51,133,255,0.15)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
margin: '0 auto 20px',
}}>
{/* Puzzle icon */}
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
<path d="M12 2a2 2 0 0 1 2 2v1h3a1 1 0 0 1 1 1v3h1a2 2 0 0 1 0 4h-1v3a1 1 0 0 1-1 1h-3v1a2 2 0 0 1-4 0v-1H7a1 1 0 0 1-1-1v-3H5a2 2 0 0 1 0-4h1V6a1 1 0 0 1 1-1h3V4a2 2 0 0 1 2-2z" stroke="#3385FF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
<h2 style={{ fontSize: '1.125rem', fontWeight: 600, color: '#09090B', margin: '0 0 10px', letterSpacing: '-0.015em' }}>
Plugin marketplace coming in Phase 4
</h2>
<p style={{ fontSize: '0.875rem', color: '#71717A', maxWidth: 480, margin: '0 auto 28px', lineHeight: 1.65 }}>
The plugin API lets teams register custom AI completion zones, custom webhook ingesters, and design-language extensions all sandboxed and permission-scoped.
</p>
{/* Permissions preview */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, justifyContent: 'center', marginBottom: 28 }}>
{[
'artboards:read', 'artboards:write', 'diffs:read', 'diffs:export',
'completion-zones:register', 'ingesters:register', 'design-language:read',
].map(scope => (
<span key={scope} style={{
fontSize: '0.6875rem', fontFamily: "'JetBrains Mono', ui-monospace, monospace",
background: 'rgba(51,133,255,0.07)', color: '#3385FF',
border: '1px solid rgba(51,133,255,0.18)',
borderRadius: 6, padding: '3px 8px',
letterSpacing: '-0.01em',
}}>
{scope}
</span>
))}
</div>
<Link
href={`/workspace/${wid}/settings`}
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: '0.875rem', fontWeight: 500, color: '#3385FF',
textDecoration: 'none',
}}
>
Back to settings
</Link>
</div>
</div>
</div>
);
}
@@ -61,7 +61,7 @@ export default function NewProjectPage({ params }: { params: Promise<{ wid: stri
};
return (
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader breadcrumbs={[
{ label: 'Workspaces', href: '/workspaces' },
{ label: 'Workspace', href: `/workspace/${wid}` },
@@ -37,7 +37,7 @@ export default async function WorkspaceSettingsPage({ params }: { params: Promis
const memberRole = (member as { role: string }).role;
return (
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader breadcrumbs={[
{ label: 'Workspaces', href: '/workspaces' },
{ label: workspace.name, href: `/workspace/${wid}` },
+1 -1
View File
@@ -15,7 +15,7 @@ export default function WorkspacesError({
}, [error]);
return (
<div style={{ minHeight: '100vh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<div style={{ minHeight: '100vh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader />
<main style={{ maxWidth: 480, margin: '80px auto', padding: '0 24px', textAlign: 'center' }}>
<div style={{ fontSize: '2rem', marginBottom: 16 }}></div>
+1 -1
View File
@@ -35,7 +35,7 @@ export default function NewWorkspacePage() {
}
return (
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader breadcrumbs={[{ label: 'Workspaces', href: '/workspaces' }, { label: 'New workspace' }]} />
<main style={{ maxWidth: 480, margin: '64px auto', padding: '0 24px' }}>
+1 -1
View File
@@ -37,7 +37,7 @@ export default async function WorkspacesPage() {
if (workspaces.length === 0) redirect('/onboarding');
return (
<div style={{ minHeight: '100dvh', background: '#FAFAFA', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<div style={{ minHeight: '100dvh', background: 'var(--page-bg)', fontFamily: "'Inter', -apple-system, sans-serif" }}>
<AppHeader />
<main style={{ maxWidth: 960, margin: '0 auto', padding: '48px 24px' }}>
@@ -4,6 +4,7 @@ import { useState, useCallback, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useCanvas } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import { useDiffs } from '@/hooks/useDiffs';
import { LiveArtboard } from './LiveArtboard';
import { SelectionOverlay } from './SelectionOverlay';
import type { FiberNode } from '@originmain/renderer';
@@ -18,11 +19,22 @@ interface ArtboardProps {
renderUrl?: string;
}
// Status color map matching the inspector
const DIFF_STATUS_BADGE: Record<string, { color: string; bg: string; label: string }> = {
DRAFT: { color: '#FFBA7B', bg: 'rgba(255,186,123,0.15)', label: 'draft' },
REVIEWED: { color: '#7EB8FF', bg: 'rgba(126,184,255,0.15)', label: 'reviewed' },
APPLIED: { color: '#10B981', bg: 'rgba(16,185,129,0.15)', label: 'applied' },
REJECTED: { color: '#FF8080', bg: 'rgba(255,128,128,0.15)', label: 'blocked' },
};
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
const selected = selectedArtboardId === id;
const queryClient = useQueryClient();
// Diff status badges — fetch is cached by TanStack Query across all artboards
const { diffs } = useDiffs(id);
// ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
const [localFiberRoot, setLocalFiberRoot] = useState<FiberNode | undefined>(undefined);
@@ -246,6 +258,45 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
</>
)}
{/* Diff status badge strip — bottom-right overlay, only visible when diffs exist */}
{diffs.length > 0 && (() => {
// Group by status and show compact chips
const counts: Record<string, number> = {};
for (const d of diffs) counts[d.status] = (counts[d.status] ?? 0) + 1;
const entries = Object.entries(counts).filter(([s]) => s in DIFF_STATUS_BADGE);
if (entries.length === 0) return null;
return (
<div
style={{
position: 'absolute', bottom: 6, right: 6, zIndex: 10,
display: 'flex', gap: 4, pointerEvents: 'none',
}}
>
{entries.map(([status, count]) => {
const b = DIFF_STATUS_BADGE[status]!;
return (
<span
key={status}
title={`${count} ${b.label} diff${count !== 1 ? 's' : ''}`}
style={{
display: 'inline-flex', alignItems: 'center', gap: 3,
padding: '2px 5px', borderRadius: 4,
background: b.bg, border: `1px solid ${b.color}33`,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5rem', fontWeight: 600,
color: b.color, letterSpacing: '0.03em',
backdropFilter: 'blur(4px)',
}}
>
<span style={{ width: 4, height: 4, borderRadius: '50%', background: b.color, display: 'inline-block' }} />
{count}
</span>
);
})}
</div>
);
})()}
{/* Content */}
{renderUrl ? (
<>
@@ -10,6 +10,7 @@ import { Inspector } from '../inspector/Inspector';
import { useHistory } from '@/store/history';
import { useCanvas, type Tool } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
import { useTheme } from '@/store/theme';
interface AppChromeProps {
workspaceId?: string;
@@ -22,6 +23,7 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
const selectedArtboardId = useCanvas((s) => s.selectedArtboardId);
const setContext = useCanvas((s) => s.setContext);
const setActiveTool = useCanvas((s) => s.setActiveTool);
const { mode: themeMode, toggle: toggleTheme } = useTheme();
// Push workspace/project IDs into the store so Canvas and Navigator can read them.
useEffect(() => {
@@ -162,6 +164,34 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
<div style={{ flex: 1 }} />
{/* Theme toggle */}
<button
onClick={toggleTheme}
title={`Switch to ${themeMode === 'dark' ? 'light' : 'dark'} mode`}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'rgba(255,255,255,0.35)', padding: '4px 6px',
display: 'flex', alignItems: 'center',
transition: 'color 0.12s',
fontSize: 13,
}}
onMouseEnter={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.75)')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.35)')}
>
{themeMode === 'dark' ? (
/* Sun icon */
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
) : (
/* Moon icon */
<svg width="13" height="13" viewBox="0 0 14 14" fill="none">
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
<UserButton />
</div>
@@ -542,19 +542,47 @@ function HSep() {
function DiffTab({ artboardId }: { artboardId: string | null }) {
const { stacks } = useHistory();
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle');
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
const hasChanges = pendingChanges.length > 0;
const exportDiff = useCallback(() => {
const exportDiff = useCallback(async () => {
if (!artboardId || !hasChanges) return;
createDiff.mutate({
artboard_id: artboardId,
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
summary: '',
status: 'DRAFT',
});
// 1. Generate AI summary (best-effort — fall back to empty string on failure)
let summary = '';
const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged');
if (meaningfulChanges.length > 0) {
setSummaryStatus('summarising');
try {
const res = await fetch('/api/ai/diff-summary', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
changesJson: JSON.stringify(meaningfulChanges),
componentName: meaningfulChanges[0]?.key ?? 'Component',
}),
});
if (res.ok) {
const data = await res.json() as { summary?: string };
summary = data.summary ?? '';
}
} catch { /* non-fatal — proceed without summary */ }
}
// 2. Export diff with AI-generated summary included
setSummaryStatus('exporting');
createDiff.mutate(
{
artboard_id: artboardId,
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
summary,
status: 'DRAFT',
},
{ onSettled: () => setSummaryStatus('idle') },
);
}, [artboardId, pendingChanges, hasChanges, createDiff]);
if (!artboardId) {
@@ -579,18 +607,22 @@ function DiffTab({ artboardId }: { artboardId: string | null }) {
<DiffChangeRow key={i} change={change} />
))}
<button
onClick={exportDiff}
disabled={createDiff.isPending}
onClick={() => void exportDiff()}
disabled={summaryStatus !== 'idle' || createDiff.isPending}
style={{
marginTop: 10, width: '100%',
fontFamily: "'JetBrains Mono', monospace", fontSize: '0.5875rem',
background: T.accent, border: 'none', borderRadius: 6,
color: '#fff', padding: '7px 0', cursor: createDiff.isPending ? 'wait' : 'pointer',
letterSpacing: '0.04em', opacity: createDiff.isPending ? 0.6 : 1,
color: '#fff', padding: '7px 0',
cursor: (summaryStatus !== 'idle' || createDiff.isPending) ? 'wait' : 'pointer',
letterSpacing: '0.04em',
opacity: (summaryStatus !== 'idle' || createDiff.isPending) ? 0.6 : 1,
transition: 'opacity 0.15s',
}}
>
{createDiff.isPending ? 'Exporting' : 'Export diff →'}
{summaryStatus === 'summarising' ? 'Summarising…' :
summaryStatus === 'exporting' || createDiff.isPending ? 'Exporting…' :
'Export diff →'}
</button>
{createDiff.isError && (
<span style={{ fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace', display: 'block', marginTop: 4 }}>
@@ -5,7 +5,7 @@ import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import { SquareRegular } from '@fluentui/react-icons';
import { useCanvas } from '@/store/canvas';
import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
import { useArtboards, patchArtboard, createArtboardMutation } from '@/hooks/useArtboards';
import { useQueryClient } from '@tanstack/react-query';
const T = {
@@ -61,6 +61,30 @@ export function ArtboardNavigator() {
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
}, [workspaceId, projectId, queryClient]);
const forkArtboard = useCallback(async (id: string, label: string) => {
if (!workspaceId) return;
const source = rawArtboards.find(ab => ab.id === id);
if (!source) return;
const meta = { ...(source.metadata_jsonb as Record<string, unknown>) };
// Offset fork to the right of the original so it doesn't overlap
const srcWidth = typeof meta['width'] === 'number' ? (meta['width'] as number) : 360;
meta['x'] = typeof meta['x'] === 'number' ? (meta['x'] as number) + srcWidth + 40 : 40;
try {
await createArtboardMutation({
workspace_id: workspaceId,
project_id: projectId ?? null,
name: `Fork of ${label}`,
origin_id: source.origin_id,
parent_artboard_id: id,
metadata_jsonb: meta,
});
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
} catch (err) {
console.error('[Navigator] forkArtboard failed:', err);
window.alert('Could not fork artboard — please try again.');
}
}, [workspaceId, projectId, rawArtboards, queryClient]);
// Real graph stats derived from live fiber trees
const totalComponents = Object.values(artboardFiberRoots).reduce(
(acc, root) => acc + countFiberNodes(root), 0,
@@ -112,6 +136,7 @@ export function ArtboardNavigator() {
}
label={ab.label}
onRename={() => void renameArtboard(ab.id, ab.label)}
onFork={() => void forkArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/>
);
@@ -160,6 +185,7 @@ function NavRow({
icon,
label,
onRename,
onFork,
onDelete,
}: {
selected?: boolean;
@@ -168,6 +194,7 @@ function NavRow({
icon: React.ReactNode;
label: string;
onRename?: () => void;
onFork?: () => void;
onDelete?: () => void;
}) {
const [hov, setHov] = useState(false);
@@ -206,18 +233,31 @@ function NavRow({
}} />
)}
{/* Action buttons: rename + delete — shown on hover */}
{/* Action buttons: rename + fork + delete — shown on hover */}
{(hov || selected) && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{onRename && (
<IconBtn title="Rename" onClick={onRename}>
{/* Pencil */}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M1 7.5L7 1.5l1.5 1.5-6 6H1V7.5z" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</IconBtn>
)}
{onFork && (
<IconBtn title="Fork" onClick={onFork}>
{/* Branch / fork icon */}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<circle cx="2" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
<circle cx="8" cy="2" r="1.2" stroke="currentColor" strokeWidth="1"/>
<circle cx="2" cy="8" r="1.2" stroke="currentColor" strokeWidth="1"/>
<path d="M2 3.2v1.3C2 5.4 2.6 6 3.5 6H5M8 3.2V5a1 1 0 0 1-1 1H5m0 0v2" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</IconBtn>
)}
{onDelete && (
<IconBtn title="Delete" onClick={onDelete} danger>
{/* Trash */}
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M2 2.5h6M4 2.5V1.5h2V2.5M3 2.5v6h4v-6" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
+64 -10
View File
@@ -2,49 +2,69 @@
import Link from 'next/link';
import { UserButton } from '@clerk/nextjs';
import { useTheme } from '@/store/theme';
interface Crumb { label: string; href?: string }
interface AppHeaderProps {
breadcrumbs?: Crumb[];
/** @deprecated Pass breadcrumbs instead */
workspaceName?: string;
/** @deprecated Pass breadcrumbs instead */
workspaceId?: string;
}
export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
export function AppHeader({ breadcrumbs = [], workspaceName, workspaceId }: AppHeaderProps) {
const { mode, toggle } = useTheme();
// Back-compat: if old props are passed without breadcrumbs, synthesise them
const crumbs: Crumb[] = breadcrumbs.length > 0
? breadcrumbs
: workspaceName
? [
{ label: 'Workspaces', href: '/workspaces' as string },
...(workspaceId
? [{ label: workspaceName, href: `/workspace/${workspaceId}` as string }]
: [{ label: workspaceName }]),
]
: [];
return (
<header style={{
position: 'sticky', top: 0, zIndex: 100,
height: 56,
background: 'rgba(255,255,255,0.92)',
background: mode === 'dark' ? 'rgba(12,12,16,0.92)' : 'rgba(255,255,255,0.92)',
backdropFilter: 'blur(12px)',
borderBottom: '1px solid rgba(0,0,0,0.07)',
borderBottom: mode === 'dark' ? '1px solid rgba(255,255,255,0.07)' : '1px solid rgba(0,0,0,0.07)',
display: 'flex', alignItems: 'center',
padding: '0 24px',
gap: 0,
}}>
{/* Logo */}
<Link href="/workspaces" style={{ textDecoration: 'none', flexShrink: 0 }}>
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: '#0A0A0A' }}>
<span style={{ fontSize: '1rem', fontWeight: 700, letterSpacing: '-0.02em', color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
Origin<span style={{ color: '#0066FF' }}>main</span>
</span>
</Link>
{/* Breadcrumbs */}
{breadcrumbs.map((crumb, i) => (
{crumbs.map((crumb, i) => (
<span key={i} style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ margin: '0 8px', color: '#D4D4D8', fontSize: '0.875rem' }}>/</span>
<span style={{ margin: '0 8px', color: mode === 'dark' ? 'rgba(255,255,255,0.2)' : '#D4D4D8', fontSize: '0.875rem' }}>/</span>
{crumb.href ? (
<Link href={crumb.href} style={{
fontSize: '0.875rem', fontWeight: 500,
color: '#71717A', textDecoration: 'none',
color: mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A',
textDecoration: 'none',
transition: 'color 0.1s',
}}
onMouseEnter={e => (e.currentTarget.style.color = '#0A0A0A')}
onMouseLeave={e => (e.currentTarget.style.color = '#71717A')}
onMouseEnter={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A')}
onMouseLeave={e => (e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.45)' : '#71717A')}
>
{crumb.label}
</Link>
) : (
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0A0A0A' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: mode === 'dark' ? '#FAFAFA' : '#0A0A0A' }}>
{crumb.label}
</span>
)}
@@ -53,6 +73,40 @@ export function AppHeader({ breadcrumbs = [] }: AppHeaderProps) {
<div style={{ flex: 1 }} />
{/* Theme toggle */}
<button
onClick={toggle}
title={`Switch to ${mode === 'dark' ? 'light' : 'dark'} mode`}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA',
padding: '6px 8px', marginRight: 8,
display: 'flex', alignItems: 'center', borderRadius: 6,
transition: 'color 0.12s, background 0.12s',
}}
onMouseEnter={e => {
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.85)' : '#0A0A0A';
e.currentTarget.style.background = mode === 'dark' ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.05)';
}}
onMouseLeave={e => {
e.currentTarget.style.color = mode === 'dark' ? 'rgba(255,255,255,0.4)' : '#A1A1AA';
e.currentTarget.style.background = 'none';
}}
>
{mode === 'dark' ? (
/* Sun */
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="2.5" stroke="currentColor" strokeWidth="1.2"/>
<path d="M7 1v1.5M7 11.5V13M1 7h1.5M11.5 7H13M2.93 2.93l1.06 1.06M10.01 10.01l1.06 1.06M2.93 11.07l1.06-1.06M10.01 3.99l1.06-1.06" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round"/>
</svg>
) : (
/* Moon */
<svg width="15" height="15" viewBox="0 0 14 14" fill="none">
<path d="M12 8.5A5.5 5.5 0 0 1 5.5 2a5.5 5.5 0 1 0 6.5 6.5z" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
)}
</button>
<UserButton />
</header>
);
@@ -377,10 +377,18 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
color: '#EF4444', cursor: 'pointer',
}}
onClick={() => {
if (window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone.`)) {
// TODO: call DELETE /api/workspace/:id when implemented
window.alert('Delete endpoint not yet implemented — coming soon.');
onClick={async () => {
if (!window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone. All projects and artboards will be permanently lost.`)) return;
try {
const res = await fetch(`/api/workspace/${workspaceId}`, { method: 'DELETE' });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `Server error ${res.status}`);
}
router.push('/workspaces');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
window.alert(`Could not delete workspace: ${msg}`);
}
}}
>
-147
View File
@@ -1,147 +0,0 @@
import type { ComponentSnapshot } from '@originmain/diff-engine';
interface ArtboardSnapshots {
before: ComponentSnapshot;
after: ComponentSnapshot;
}
const dashboardCard: ArtboardSnapshots = {
before: {
id: 'dashboard-card',
name: 'DashboardCard',
filePath: 'src/components/DashboardCard.tsx',
props: {
title: 'Revenue Overview',
value: '$12,450',
delta: 2.4,
period: 'monthly',
loading: false,
},
styles: {
borderRadius: '8px',
accentColor: '#2A6CD4',
padding: '16',
},
},
after: {
id: 'dashboard-card',
name: 'DashboardCard',
filePath: 'src/components/DashboardCard.tsx',
props: {
title: 'Revenue Overview',
value: '$12,450',
delta: 2.4,
period: 'monthly',
loading: false,
},
styles: {
borderRadius: '12px',
accentColor: '#0066FF',
padding: '20',
},
},
};
const userProfile: ArtboardSnapshots = {
before: {
id: 'user-profile',
name: 'UserProfile',
filePath: 'src/components/UserProfile.tsx',
props: {
name: 'Sarah Chen',
role: 'Designer',
plan: 'Pro',
avatarSize: 40,
},
styles: {
avatarGradient: 'linear-gradient(135deg, #6D28D9, #2563EB)',
buttonVariant: 'outline',
},
},
after: {
id: 'user-profile',
name: 'UserProfile',
filePath: 'src/components/UserProfile.tsx',
props: {
name: 'Sarah Chen',
role: 'Design Engineer',
plan: 'Team',
avatarSize: 52,
},
styles: {
avatarGradient: 'linear-gradient(135deg, #7C3AED, #0066FF)',
buttonVariant: 'ghost',
},
},
};
const navSidebar: ArtboardSnapshots = {
before: {
id: 'nav-sidebar',
name: 'NavSidebar',
filePath: 'src/components/NavSidebar.tsx',
props: {
brand: 'Origin',
collapsed: false,
activeItem: 'Dashboard',
},
styles: {
background: '#F5F5F5',
itemPadding: '6px 10px',
borderRadius: '4px',
},
},
after: {
id: 'nav-sidebar',
name: 'NavSidebar',
filePath: 'src/components/NavSidebar.tsx',
props: {
brand: 'Originmain',
collapsed: false,
activeItem: 'Dashboard',
},
styles: {
background: '#FAFAFA',
itemPadding: '7px 12px',
borderRadius: '5px',
},
},
};
const dataTable: ArtboardSnapshots = {
before: {
id: 'data-table',
name: 'DataTable',
filePath: 'src/components/DataTable.tsx',
props: {
title: 'Components',
striped: false,
pageSize: 10,
},
styles: {
headerColor: '#71717A',
rowHeight: '32px',
},
},
after: {
id: 'data-table',
name: 'DataTable',
filePath: 'src/components/DataTable.tsx',
props: {
title: 'Component Inventory',
striped: true,
pageSize: 10,
},
styles: {
headerColor: '#A1A1AA',
rowHeight: '36px',
},
},
};
export const ARTBOARD_SNAPSHOTS: Record<string, ArtboardSnapshots> = {
'dashboard-card': dashboardCard,
'user-profile': userProfile,
'nav-sidebar': navSidebar,
'data-table': dataTable,
};
-12
View File
@@ -1,12 +0,0 @@
import { useMemo } from 'react';
import { diffComponents, type DiffResult } from '@originmain/diff-engine';
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
export function useDiff(artboardId: string | null): DiffResult | null {
return useMemo(() => {
if (!artboardId) return null;
const snapshots = ARTBOARD_SNAPSHOTS[artboardId];
if (!snapshots) return null;
return diffComponents(snapshots.before, snapshots.after);
}, [artboardId]);
}
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
type ThemeMode = 'light' | 'dark';
interface ThemeStore {
mode: ThemeMode;
toggle: () => void;
setMode: (mode: ThemeMode) => void;
}
export const useTheme = create<ThemeStore>()(
persist(
(set, get) => ({
mode: 'dark',
toggle: () => set({ mode: get().mode === 'dark' ? 'light' : 'dark' }),
setMode: (mode) => set({ mode }),
}),
{
name: 'originmain:theme',
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({ mode: state.mode }),
},
),
);
File diff suppressed because one or more lines are too long
@@ -172,3 +172,18 @@ BEGIN
END LOOP;
END;
$$;
-- ── RPC: get_diffs_by_status ──────────────────────────────────────────────────
-- Used by getDiffsByStatus() in queries.ts.
-- Joins intent_diffs → artboards to scope results by workspace_id.
create or replace function get_diffs_by_status(p_workspace_id uuid, p_status text)
returns setof intent_diffs
language sql stable as $$
select d.*
from intent_diffs d
join artboards a on a.id = d.artboard_id
where a.workspace_id = p_workspace_id
and d.status = p_status
order by d.created_at desc;
$$;
+8
View File
@@ -267,6 +267,14 @@ export async function removeTeamMember(
if (error) throw new Error(error.message);
}
export async function deleteWorkspace(db: DbClient, id: string): Promise<void> {
const { error } = await (db
.from('workspaces')
.delete()
.eq('id', id) as unknown as Promise<{ data: unknown; error: DbError | null }>);
if (error) throw new Error(error.message);
}
export async function updateWorkspace(
db: DbClient,
id: string,
@@ -0,0 +1,46 @@
-- Origin Graph — Artboard Ancestry Materialized View
-- Migration: 002
-- Pre-computes all ancestor/descendant relationships so the app never needs
-- recursive CTEs at query time. Updated automatically on artboards INSERT.
CREATE MATERIALIZED VIEW artboard_ancestry AS
WITH RECURSIVE ancestry(artboard_id, ancestor_id, depth) AS (
-- Base: each artboard is at depth 0 relative to itself
SELECT id AS artboard_id, id AS ancestor_id, 0 AS depth
FROM artboards
UNION ALL
-- Recurse: walk up the parent chain
SELECT a.id AS artboard_id, anc.ancestor_id, anc.depth + 1
FROM artboards a
JOIN ancestry anc ON a.parent_artboard_id = anc.artboard_id
)
SELECT artboard_id, ancestor_id, depth
FROM ancestry
WHERE artboard_id <> ancestor_id -- exclude self-reference
ORDER BY artboard_id, depth;
CREATE UNIQUE INDEX artboard_ancestry_pk
ON artboard_ancestry(artboard_id, ancestor_id);
CREATE INDEX artboard_ancestry_ancestor_idx
ON artboard_ancestry(ancestor_id);
-- ── Refresh trigger ───────────────────────────────────────────────────────────
-- Refreshes the materialized view concurrently whenever an artboard is
-- inserted. CONCURRENTLY requires the unique index above — it allows reads
-- to continue during refresh.
CREATE OR REPLACE FUNCTION refresh_artboard_ancestry()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY artboard_ancestry;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_artboard_ancestry_refresh
AFTER INSERT OR UPDATE OF parent_artboard_id ON artboards
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_artboard_ancestry();
+111
View File
@@ -0,0 +1,111 @@
-- Origin Graph — Row-Level Security Policies
-- Migration: 003
-- All tables are workspace-scoped. A user may only read or write rows in
-- workspaces where they have a team_members record. The Clerk JWT is verified
-- server-side; auth.uid() maps to the Clerk user_id column.
-- Enable RLS on every table
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE artboards ENABLE ROW LEVEL SECURITY;
ALTER TABLE origins ENABLE ROW LEVEL SECURITY;
ALTER TABLE intent_diffs ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE design_language_files ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
-- ── Helper: is the current user a member of the given workspace? ──────────────
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 user_id = auth.uid()::TEXT
);
$$;
-- ── 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_id = auth.uid()::TEXT);
CREATE POLICY workspaces_update ON workspaces
FOR UPDATE USING (owner_id = auth.uid()::TEXT);
-- ── 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));
-- ── intent_diffs ──────────────────────────────────────────────────────────────
-- Derived from artboard's workspace membership
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_select ON design_language_files
FOR SELECT USING (is_workspace_member(workspace_id));
CREATE POLICY dlf_insert ON design_language_files
FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
CREATE POLICY dlf_update ON design_language_files
FOR UPDATE USING (is_workspace_member(workspace_id));
-- ── team_members ──────────────────────────────────────────────────────────────
CREATE POLICY team_members_select ON team_members
FOR SELECT USING (is_workspace_member(workspace_id));
-- Only workspace owners can add/remove members
CREATE POLICY team_members_insert ON team_members
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = auth.uid()::TEXT)
);
CREATE POLICY team_members_delete ON team_members
FOR DELETE USING (
EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = auth.uid()::TEXT)
);
@@ -0,0 +1,6 @@
-- ── Migration 004: Add notes column to intent_diffs ──────────────────────────
-- Allows the coding agent to record why a diff was blocked or any implementation
-- notes when updating status via the Agent Bridge MCP tool.
ALTER TABLE intent_diffs
ADD COLUMN IF NOT EXISTS notes TEXT;
+26
View File
@@ -0,0 +1,26 @@
-- Migration: 005 — projects
-- A Project groups artboards for a specific application inside a workspace.
-- Users connect their running app to a project (via app_url) and work
-- on its artboards in the canvas.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
app_url TEXT, -- e.g. https://localhost:3000 or https://staging.myapp.com
framework TEXT, -- e.g. 'react', 'next', 'vue', 'svelte'
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX projects_workspace_idx ON projects(workspace_id);
CREATE TRIGGER trg_projects_updated_at
BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- Add optional project_id to artboards so artboards can be scoped to a project.
-- Nullable: existing artboards without a project remain workspace-level.
ALTER TABLE artboards ADD COLUMN IF NOT EXISTS project_id UUID REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS artboards_project_idx ON artboards(project_id);