improved a lot of things

This commit is contained in:
SinachPat
2026-04-27 04:52:15 +01:00
parent 197313c0ef
commit 9d9d7d7a37
25 changed files with 1598 additions and 288 deletions
+9 -1
View File
@@ -129,7 +129,15 @@
"Bash(git -C /Users/USER/Desktop/originmain status --short)", "Bash(git -C /Users/USER/Desktop/originmain status --short)",
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/tokens)", "Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/tokens)",
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/projects/\\\\[pid\\\\])", "Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/projects/\\\\[pid\\\\])",
"Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/invite)" "Bash(mkdir -p /Users/USER/Desktop/originmain/packages/app/src/app/api/workspace/\\\\[id\\\\]/invite)",
"Bash(grep anthropic *)",
"Bash(npx --yes npm-check-updates --packageFile /Users/USER/Desktop/originmain/packages/ai-layer/package.json --filter \"@anthropic-ai/sdk\")",
"Bash(pnpm --filter @originmain/app tsc --noEmit)",
"Bash(pnpm --filter @originmain/ai-layer add @anthropic-ai/sdk@latest)",
"Bash(pnpm --filter @originmain/origin-graph test)",
"Bash(grep -E \"\\\\.d\\\\.ts$\")",
"Bash(pnpm -w ls @anthropic-ai/sdk)",
"Bash(grep -v \"\\\\.map$\")"
] ]
} }
} }
+43 -62
View File
@@ -114,7 +114,7 @@
- **`src/agent-qa.ts`** — `answerAgentQuestion()`: temp 0.7 - **`src/agent-qa.ts`** — `answerAgentQuestion()`: temp 0.7
### SDK note ### SDK note
`@anthropic-ai/sdk` v0.56.0 is installed. Adaptive thinking (`thinking: {type: 'adaptive'}`) requires ≥0.58 — a TODO comment marks all 5 feature files for upgrade. All features remain fully functional at temperature level. `@anthropic-ai/sdk` upgraded to **v0.91.1** in Session 4. All 5 feature files now use `thinking: {type: 'adaptive'}` via the gateway. `temperature` parameter removed from `GatewayRequest` interface and all feature call sites (Opus 4.7 with adaptive thinking rejects temperature with HTTP 400).
--- ---
@@ -162,9 +162,9 @@ Each connector validates untrusted webhook JSON at the boundary with Zod (`parse
| `packages/diff-engine` | Complete | 49 tests, ~88% coverage | | `packages/diff-engine` | Complete | 49 tests, ~88% coverage |
| `packages/ui` | Active | Theme + FluentProvider | | `packages/ui` | Active | Theme + FluentProvider |
| `packages/renderer` | Complete | Layer 2: postMessage protocol, fiber hook, MF config | | `packages/renderer` | Complete | Layer 2: postMessage protocol, fiber hook, MF config |
| `packages/origin-graph` | Complete | Layer 4: migrations, RLS, Zod types, query helpers | | `packages/origin-graph` | Complete | Layer 4: migrations, RLS, Zod types, query helpers; 45 tests, 100% types.ts coverage |
| `packages/design-language` | Complete | Layer 5: DLF schema, validator, token pipeline | | `packages/design-language` | Complete | Layer 5: DLF schema, validator, token pipeline |
| `packages/ai-layer` | Complete | Layer 6: gateway, 5 features, prompt library | | `packages/ai-layer` | Complete | Layer 6: gateway, 5 features, prompt library; SDK v0.91.1, adaptive thinking |
| `packages/agent-bridge` | Complete | Layer 7: MCP tools, auth, rate limiter, adapters | | `packages/agent-bridge` | Complete | Layer 7: MCP tools, auth, rate limiter, adapters |
| `packages/integrations` | Complete | Layer 8: OriginIngester + 4 connectors | | `packages/integrations` | Complete | Layer 8: OriginIngester + 4 connectors |
| `packages/multiplayer` | Foundation | Layer 9: room schema, MultiplayerAdapter, cursor palette | | `packages/multiplayer` | Foundation | Layer 9: room schema, MultiplayerAdapter, cursor palette |
@@ -239,12 +239,11 @@ Actual `createClient` / `createRoomContext` calls go in `packages/app/src/lib/li
## Known Pending Items ## Known Pending Items
1. **`@anthropic-ai/sdk` upgrade to ≥0.58** — unlocks `thinking: {type: 'adaptive'}` in all 5 AI feature files (marked with TODO comments) 1. **Redis-backed rate limiter**`packages/agent-bridge/src/rate-limiter.ts` is in-process only; needs Redis for multi-instance MCP server deployment
2. **Redis-backed rate limiter**`packages/agent-bridge/src/rate-limiter.ts` is in-process only; needs Redis for multi-instance MCP server deployment 2. **Layer 0** — Infrastructure (Vercel, Supabase, Render, GitHub Actions) deferred per plan
3. **Layer 0** — Infrastructure (Vercel, Supabase, Render, GitHub Actions) deferred per plan 3. **Layer 9 Phase 3** — Wire `MultiplayerAdapter` into app components; add `createClient`/`createRoomContext` in `packages/app/src/lib/liveblocks.ts`; install `@liveblocks/client` + `@liveblocks/react`
4. **Layer 9 Phase 3**Wire `MultiplayerAdapter` into app components; add `createClient`/`createRoomContext` in `packages/app/src/lib/liveblocks.ts`; install `@liveblocks/client` + `@liveblocks/react` 4. **Layer 10 Phase 4**Plugin sandbox runtime (iframe + postMessage bridge); SCIM webhook endpoint; SSO provider registration UI; audit log Supabase table + extension
5. **Layer 10 Phase 4** — Plugin sandbox runtime (iframe + postMessage bridge); SCIM webhook endpoint; SSO provider registration UI; audit log Supabase table + extension 5. **`packages/e2e`** — Playwright E2E tests not yet created
6. **`packages/e2e`** — Playwright E2E tests not yet created
--- ---
@@ -270,62 +269,44 @@ The following is a second-pass audit of all real product gaps, independent of la
- `WorkspaceCard.tsx` + `ProjectCard.tsx` extracted as `'use client'` components → fixed server-side exception on breadcrumb click - `WorkspaceCard.tsx` + `ProjectCard.tsx` extracted as `'use client'` components → fixed server-side exception on breadcrumb click
### ✅ Completed — Session 2 (2026-04-26) ### ✅ Completed — Session 2 (2026-04-26)
- **Server-side crash fixed**: `workspaces/page.tsx` and `workspace/[wid]/page.tsx` confirmed using `WorkspaceCard`/`ProjectCard` client components (no `onMouseEnter` in server components) - **Server-side crash fixed**: `workspaces/page.tsx` and `workspace/[wid]/page.tsx` confirmed using `WorkspaceCard`/`ProjectCard` client components
- **Inspector fully rewired**: removed `useDiff`/`ARTBOARD_SNAPSHOTS` hardcoded data; now uses `useDiffs` (real DB diffs) + `useHistory` (pending local changes) - **Inspector fully rewired**: uses `useDiffs` (DB) + `useHistory` (pending); Export Diff button; `renderUrl` inline editor; live status bar reads `liveArtboardIds`
- **Export Diff button**: Inspector Diff tab shows pending `PropChange[]` from history store with "Export diff →" button that calls `POST /api/diffs` - **Empty canvas state**: DEMO_ARTBOARDS removed; Canvas shows hint when empty
- **`renderUrl` inline editor**: Inspector Props tab has inline input for `renderUrl`; saves via `PATCH /api/artboards/[id]`; `patchArtboard()` added to `useArtboards.ts` - **Zone tool drag preview**: live dashed rectangle + dimension label
- **Inspector status bar**: now reads `liveArtboardIds` from canvas store — shows pulsing green dot when live render connected, grey + hint when not - **Workspace settings page**: `GET+PATCH /api/workspace/[id]`, rename, IDE token issuance, danger zone
- **Empty canvas state**: removed `DEMO_ARTBOARDS` fallback from `useArtboards.ts`; Canvas shows dashed hint "Press A to create an artboard" when empty - **Settings link** added to workspace page
- **Zone tool drag preview**: Canvas draws live dashed rectangle + dimension label during zone drag; cleans up on mouse-up
- **Workspace settings page**: `GET+PATCH /api/workspace/[id]`, `/workspace/[wid]/settings` page + `WorkspaceSettingsForm` client component (rename, IDE token issuance with full config snippets, danger zone)
- **Settings link in workspace page**: gear icon button added next to "New project"
### 🔧 Remaining — In Order of Priority ### ✅ Completed — Session 3 (2026-04-26)
- **Canvas store `artboardFiberRoots`**: new `setFiberRoot(artboardId, root)` — Artboard calls it on every fiber update; Inspector Graph tab reads from it
- **Inspector Graph tab**: shows real collapsible `FiberTreeView` from live artboard; shows node count + depth; placeholder when no live render
- **Inspector Props tab — component selection**: when a component is clicked via `SelectionOverlay`, fiber props appear in a `↳ ComponentName` section above artboard metadata
- **Artboard delete button**: ✕ icon appears in label when artboard is selected; confirm dialog → `DELETE /api/artboards/[id]` → invalidate query
- **Artboard drag + rename**: already implemented (drag label to move, double-click to rename inline)
- **Navigator delete/rename**: hover on artboard row reveals pencil (rename) and trash (delete) icon buttons; calls `patchArtboard` / `DELETE` API
- **Zone prompt popup**: after zone drag ends, `ZonePromptOverlay` floats at zone position; textarea + "Generate ⌘↵" → `POST /api/ai/completion-zone`; shows result; Escape/close to dismiss
- **Dead demo code removed**: `ArtboardContent`, `DashboardCard`, `UserProfile`, `NavSidebar`, `DataTable` all deleted from `Artboard.tsx`
**CRITICAL** — all done ✅ ### ✅ Completed — Session 4 (2026-04-26)
- **Viewport per-workspace persistence**: `viewport.ts` `restore()` action + `AppChrome.tsx` useEffect saves/restores `panX/panY/zoom` per workspace in localStorage under `originmain:viewport:{workspaceId}`
- **Webhook `?project=` param**: `/api/webhooks/[provider]/route.ts` reads `project` query param → `project_id` on created artboard
- **Navigator live render badge**: artboard rows show pulsing green dot when artboard ID is in `liveArtboardIds`
- **Navigator real graph stats**: `artboardFiberRoots` traversal gives true component counts; live artboard count from `liveArtboardIds.size`
- **Cross-Artboard Query UI**: `CrossArtboardQuery` component in Navigator, queries `POST /api/ai/query`
- **Drift Report UI**: "↻ Generate drift report" button at bottom of Inspector Props tab; calls `POST /api/ai/drift-report`; shows scrollable pre-formatted result panel
- **`@anthropic-ai/sdk` upgraded to v0.91.1**: adaptive thinking wired in gateway; `temperature` removed from interface + all 5 feature files
- **Team invitation UI**: `TeamInviteForm` added to `WorkspaceSettingsForm` — Clerk userId + role picker → `POST /api/workspace/[id]/invite`; conflict/error/success feedback
- **Project settings page**: `/workspace/[wid]/project/[pid]/settings` — rename, description, app URL, framework selector, type-to-confirm delete; gear icon in AppChrome breadcrumb
- **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
**HIGH** ### 🔧 Remaining — Lower Priority
- [x] Replace `useDiff.ts` with real `useDiffs.ts`
- [x] `renderUrl` edit UI ✅
- [x] Export Diff button ✅
- [x] Zone tool canvas drag preview ✅
- [x] Empty canvas state ✅
- [x] Workspace settings page ✅
- [ ] Artboard content fallback — Artboard.tsx still shows UUID as a placeholder title when no fiber tree is connected
**MEDIUM** - [ ] **Multiplayer**: Wire `MultiplayerAdapter` into app (install `@liveblocks/client` + `@liveblocks/react`; create `packages/app/src/lib/liveblocks.ts`)
- [ ] `handleComponentSelected` → canvas `selectComponent()` → Inspector shows selected component's fiber props - [ ] **Plugin system stub page**: UI route at `/workspace/[wid]/plugins` listing installed plugins
- [ ] Graph tab wired to real fiber tree (currently shows hardcoded DashboardCard tree) - [ ] **`packages/e2e`**: Playwright E2E tests not yet created
- [ ] Navigator files tree selectable + artboard delete/rename actions - [ ] **Redis rate limiter**: Replace in-process rate limiter in agent-bridge for multi-instance support
- [ ] Artboard drag-to-move (label drag → `PATCH /api/artboards/[id]` with new x/y in metadata_jsonb)
- [ ] Artboard delete (trash icon → confirm → `DELETE /api/artboards/[id]`)
- [ ] Artboard rename (double-click label → inline edit → PATCH name)
- [ ] Viewport persistence to localStorage
- [ ] Webhook `project_id` from `?project=` query param
- [ ] `@anthropic-ai/sdk` upgrade to ≥0.58 for `thinking: {type: 'adaptive'}`
**LOW** **Webhook project_id:**
- [ ] Project settings page `/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.
- [ ] Team invitation UI (in workspace settings — `POST /api/workspace/[id]/invite` exists)
- [ ] Drift Report UI (in Inspector AI section)
- [ ] Cross-Artboard Query UI (in navigator)
- [ ] Vitest test files (diff-engine + origin-graph)
- [ ] Multiplayer basic Liveblocks wiring
- [ ] Plugin system stub page
- [ ] Zone tool: after drag, show prompt input → `POST /api/ai/completion-zone` with bounds
### Key Technical Notes for Next Agent *Last updated: 2026-04-26 — Session 3 complete*
**Artboard.tsx and component selection:**
The `handleComponentSelected` callback inside `Artboard.tsx` receives the clicked `FiberNode` (from `LiveArtboard`). It needs to call `useCanvas.getState().selectComponent(node.id, node)`. The Inspector's PropsTab should check `selectedComponentId`/`selectedComponentData` and show the fiber node's props when a component is selected (vs. showing artboard metadata when nothing is selected).
**Artboard drag-to-move:**
The `Artboard` component label area needs `onMouseDown` → tracks mouse delta → `PATCH /api/artboards/[id]` with `{ metadata_jsonb: { ...meta, x: newX, y: newY } }`. Use `patchArtboard(id, { metadata_jsonb: ... })` from `useArtboards.ts` then `queryClient.invalidateQueries`.
**Zone tool completion:**
After the zone drag ends (mouse-up), show a floating prompt input at the zone position. On submit → `POST /api/ai/completion-zone` with `{ artboard_id, bounds: {x,y,w,h}, prompt }`. Show result inline in the canvas.
**`@anthropic-ai/sdk` upgrade path:**
All 5 AI feature files in `packages/ai-layer/src/` have `// TODO: upgrade to adaptive thinking when SDK ≥0.58` comments. After `pnpm add @anthropic-ai/sdk@latest` in `packages/ai-layer`, replace the temperature-based calls with `thinking: {type: 'adaptive'}` on the `gateway.complete()` calls.
*Last updated: 2026-04-26 — Session 2 complete*
+1 -1
View File
@@ -10,7 +10,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.56.0", "@anthropic-ai/sdk": "^0.91.1",
"@originmain/design-language": "workspace:*", "@originmain/design-language": "workspace:*",
"@originmain/diff-engine": "workspace:*", "@originmain/diff-engine": "workspace:*",
"zod": "^3.0.0" "zod": "^3.0.0"
@@ -36,7 +36,6 @@ export async function answerAgentQuestion(
}, },
], ],
maxTokens: 1024, maxTokens: 1024,
temperature: 0.7,
}); });
let parsed: unknown; let parsed: unknown;
@@ -34,7 +34,6 @@ export async function queryCrossArtboard(
}, },
], ],
maxTokens: 1024, maxTokens: 1024,
temperature: 0.3,
}); });
// Returning empty results on parse failure is indistinguishable from "no match". // Returning empty results on parse failure is indistinguishable from "no match".
@@ -49,5 +48,19 @@ export async function queryCrossArtboard(
); );
} }
// Runtime guard: ensure the parsed value has the expected shape before casting.
// Without this, a malformed AI response (e.g. { results: null }) would let
// callers hit a TypeError on `.results.map()` instead of a clear error message.
if (
typeof parsed !== 'object' ||
parsed === null ||
!Array.isArray((parsed as Record<string, unknown>)['results'])
) {
throw new Error(
`Artboard query response missing "results" array. ` +
`Raw response (first 300 chars): ${response.text.slice(0, 300)}`
);
}
return parsed as ArtboardQueryOutput; return parsed as ArtboardQueryOutput;
} }
@@ -57,7 +57,6 @@ export async function fillCompletionZone(
system, system,
messages: [{ role: 'user', content: userContent }], messages: [{ role: 'user', content: userContent }],
maxTokens: 4096, maxTokens: 4096,
temperature: 0.3,
}); });
try { try {
@@ -27,7 +27,6 @@ export async function generateDiffSummary(
}, },
], ],
maxTokens: 128, maxTokens: 128,
temperature: 0.3,
}); });
return { summary: response.text.trim() }; return { summary: response.text.trim() };
+29 -20
View File
@@ -2,10 +2,12 @@ import type { AIGateway } from '../gateway.js';
import { buildSystemPrompt } from '../prompts/system.js'; import { buildSystemPrompt } from '../prompts/system.js';
export interface DriftReportInput { export interface DriftReportInput {
/** Screenshot of the live app as base64 data URL */ /** Screenshot of the live app as base64 data URL (optional — text-only analysis if absent) */
screenshotBase64: string; screenshotBase64?: string;
/** Active Design Language File as JSON string */ /** Active Design Language File as JSON string (optional — generic advice if absent) */
dlfJson: string; dlfJson?: string;
/** Human-readable artboard metadata to give the model context (name, size, origin, etc.) */
artboardContext?: string;
} }
export interface DriftViolation { export interface DriftViolation {
@@ -29,32 +31,39 @@ export async function generateDriftReport(
): Promise<DriftReportOutput> { ): Promise<DriftReportOutput> {
const system = buildSystemPrompt({ const system = buildSystemPrompt({
role: 'a design system compliance auditor', role: 'a design system compliance auditor',
dlfJson: input.dlfJson, ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
}); });
const response = await gateway.complete({ type ContentBlock =
system, | { type: 'image'; source: { type: 'base64'; media_type: 'image/png'; data: string } }
messages: [ | { type: 'text'; text: string };
{
role: 'user', const userContent: ContentBlock[] = [];
content: [
{ if (input.screenshotBase64) {
userContent.push({
type: 'image', type: 'image',
source: { source: {
type: 'base64', type: 'base64',
media_type: 'image/png', media_type: 'image/png',
data: input.screenshotBase64.replace(/^data:image\/\w+;base64,/, ''), data: input.screenshotBase64.replace(/^data:image\/\w+;base64,/, ''),
}, },
}, });
{ }
const analysisInstructions = input.screenshotBase64
? 'Compare this screenshot against the design language file provided in your system context.'
: `Analyse the following artboard for design system drift:\n\n${input.artboardContext ?? '(no artboard context provided)'}`;
userContent.push({
type: 'text', type: 'text',
text: 'Compare this screenshot against the design language file provided in your system context. Identify all design system drift violations.\n\nReturn a JSON object:\n{\n "violations": [{"component":"...", "property":"...", "currentValue":"...", "expectedValue":"...", "severity":"critical|warning", "description":"..."}],\n "summary": "...",\n "violationCount": N\n}\n\nRespond ONLY with valid JSON.', text: `${analysisInstructions} Identify all design system drift violations.\n\nReturn a JSON object:\n{\n "violations": [{"component":"...", "property":"...", "currentValue":"...", "expectedValue":"...", "severity":"critical|warning", "description":"..."}],\n "summary": "...",\n "violationCount": N\n}\n\nRespond ONLY with valid JSON.`,
}, });
],
}, const response = await gateway.complete({
], system,
messages: [{ role: 'user', content: userContent }],
maxTokens: 4096, maxTokens: 4096,
temperature: 0.3,
}); });
// Returning empty violations on parse failure would be a false-negative in a // Returning empty violations on parse failure would be a false-negative in a
+3 -5
View File
@@ -73,10 +73,8 @@ export interface GatewayRequest {
messages: Anthropic.Messages.MessageParam[]; messages: Anthropic.Messages.MessageParam[];
system?: Anthropic.Messages.TextBlockParam[]; system?: Anthropic.Messages.TextBlockParam[];
maxTokens?: number; maxTokens?: number;
// NOTE: adaptive thinking (`thinking: { type: 'adaptive' }`) requires SDK >=0.58. // NOTE: temperature is intentionally omitted — Opus 4.7 with adaptive thinking
// Upgrade @anthropic-ai/sdk and uncomment when available. // rejects temperature, top_p, and top_k with a 400 error.
/** Temperature: 0.3 for deterministic, 0.7 for generative */
temperature?: number;
} }
export interface GatewayResponse { export interface GatewayResponse {
@@ -106,7 +104,7 @@ export class AIGateway {
const response = await this.client.messages.create({ const response = await this.client.messages.create({
model: MODEL, model: MODEL,
max_tokens: req.maxTokens ?? 4096, max_tokens: req.maxTokens ?? 4096,
...(req.temperature !== undefined ? { temperature: req.temperature } : {}), thinking: { type: 'adaptive' },
...(req.system !== undefined ? { system: req.system } : {}), ...(req.system !== undefined ? { system: req.system } : {}),
messages: req.messages, messages: req.messages,
}); });
@@ -1,20 +1,85 @@
// POST /api/ai/drift-report // POST /api/ai/drift-report
// Analyzes a screenshot against the active Design Language File for drift violations. // Accepts { artboard_id } — fetches artboard metadata + workspace DLF from DB,
// 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 { auth } from '@clerk/nextjs/server';
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { AIGateway, generateDriftReport } from '@originmain/ai-layer'; import { AIGateway, generateDriftReport } from '@originmain/ai-layer';
import type { DriftReportInput } from '@originmain/ai-layer'; import { serverClient } from '@/lib/supabase';
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const { userId } = await auth(); const { userId } = await auth();
if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); if (!userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
const body = (await req.json()) as DriftReportInput; const body = (await req.json().catch(() => ({}))) as {
artboard_id?: string;
/** Optional — client may pass a base64 screenshot captured from the live iframe */
screenshot_base64?: string;
};
if (!body.artboard_id) {
return NextResponse.json({ error: 'artboard_id is required' }, { status: 400 });
}
const db = serverClient();
// Fetch artboard + verify the caller is a workspace member
const { data: artboard } = await db
.from('artboards')
.select('id, name, workspace_id, project_id, metadata_jsonb')
.eq('id', body.artboard_id)
.single() as unknown as {
data: {
id: string;
name: string;
workspace_id: string;
project_id: string | null;
metadata_jsonb: Record<string, unknown>;
} | null;
};
if (!artboard) {
return NextResponse.json({ error: 'Artboard not found' }, { status: 404 });
}
// Auth gate: require workspace membership
const { data: member } = await db
.from('team_members')
.select('id')
.eq('workspace_id', artboard.workspace_id)
.eq('user_id', userId)
.limit(1)
.single();
if (!member) {
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')
.eq('workspace_id', artboard.workspace_id)
.order('updated_at', { ascending: false })
.limit(1)
.single() as unknown as { data: { schema_jsonb: unknown; name: string } | null };
const meta = artboard.metadata_jsonb;
const artboardContext = [
`Artboard: ${artboard.name}`,
`Size: ${meta['width'] ?? '?'} × ${meta['height'] ?? '?'}`,
...(meta['renderUrl'] ? [`Render URL: ${String(meta['renderUrl'])}`] : []),
...(artboard.project_id ? [`Project ID: ${artboard.project_id}`] : []),
].join('\n');
try { try {
const gateway = new AIGateway(); const gateway = new AIGateway();
const result = await generateDriftReport(gateway, body); const result = await generateDriftReport(gateway, {
artboardContext,
...(dlf ? { dlfJson: JSON.stringify(dlf.schema_jsonb) } : {}),
...(body.screenshot_base64 ? { screenshotBase64: body.screenshot_base64 } : {}),
});
return NextResponse.json(result); return NextResponse.json(result);
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'AI error'; const message = err instanceof Error ? err.message : 'AI error';
@@ -98,6 +98,8 @@ export async function POST(
if (!workspaceId) { if (!workspaceId) {
return NextResponse.json({ error: 'workspace query param is required' }, { status: 400 }); return NextResponse.json({ error: 'workspace query param is required' }, { status: 400 });
} }
// Optional: ?project=<uuid> scopes the created artboard to a specific project
const projectId = req.nextUrl.searchParams.get('project');
const rawBody = Buffer.from(await req.arrayBuffer()); const rawBody = Buffer.from(await req.arrayBuffer());
@@ -147,7 +149,7 @@ export async function POST(
const artboard = await createArtboard(db, { const artboard = await createArtboard(db, {
workspace_id: workspaceId, workspace_id: workspaceId,
project_id: null, project_id: projectId,
name: result.artboardTitle, name: result.artboardTitle,
origin_id: origin.id, origin_id: origin.id,
parent_artboard_id: null, parent_artboard_id: null,
@@ -0,0 +1,103 @@
import { auth } from '@clerk/nextjs/server';
import { redirect } from 'next/navigation';
import Link from 'next/link';
import { serverClient } from '@/lib/supabase';
import { ProjectSettingsForm } from '@/components/shell/ProjectSettingsForm';
export async function generateMetadata({ params }: { params: Promise<{ wid: string; pid: string }> }) {
const { pid } = await params;
const db = serverClient();
const { data } = await db.from('projects').select('name').eq('id', pid).single() as unknown as {
data: { name: string } | null;
};
return { title: `${data?.name ?? 'Project'} Settings — Originmain` };
}
export default async function ProjectSettingsPage({
params,
}: {
params: Promise<{ wid: string; pid: string }>;
}) {
const { wid, pid } = await params;
const { userId } = await auth();
if (!userId) redirect('/sign-in');
const db = serverClient();
// Verify membership and role
const { data: member } = await db
.from('team_members')
.select('role')
.eq('workspace_id', wid)
.eq('user_id', userId)
.limit(1)
.single() as unknown as { data: { role: string } | null };
if (!member) redirect('/workspaces');
// Fetch workspace name + project
type WsRow = { data: { name: string } | null };
type ProjRow = { data: { id: string; name: string; description: string | null; app_url: string | null; framework: string | null } | null };
const [wsResult, projResult] = await Promise.all([
db.from('workspaces').select('name').eq('id', wid).single() as unknown as Promise<WsRow>,
db.from('projects').select('id, name, description, app_url, framework').eq('id', pid).eq('workspace_id', wid).single() as unknown as Promise<ProjRow>,
]);
if (!projResult.data) redirect(`/workspace/${wid}`);
const project = projResult.data;
return (
<div style={{
minHeight: '100dvh',
background: '#F5F5F7',
fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif",
}}>
{/* Header */}
<div style={{
background: '#FFFFFF',
borderBottom: '1px solid rgba(0,0,0,0.07)',
padding: '0 32px',
display: 'flex',
alignItems: 'center',
height: 56,
gap: 0,
}}>
<Link href="/workspaces" style={{ textDecoration: 'none', fontSize: '0.875rem', fontWeight: 700, color: '#0A0A0A', letterSpacing: '-0.01em' }}>
Origin<span style={{ color: '#3385FF' }}>main</span>
</Link>
<span style={{ color: 'rgba(0,0,0,0.2)', margin: '0 8px', fontSize: '0.875rem' }}>/</span>
<Link href={`/workspace/${wid}`} style={{ textDecoration: 'none', fontSize: '0.875rem', color: '#52525B', fontWeight: 500 }}>
{wsResult.data?.name ?? 'Workspace'}
</Link>
<span style={{ color: 'rgba(0,0,0,0.2)', margin: '0 8px', fontSize: '0.875rem' }}>/</span>
<Link href={`/workspace/${wid}/project/${pid}`} style={{ textDecoration: 'none', fontSize: '0.875rem', color: '#52525B', fontWeight: 500 }}>
{project.name}
</Link>
<span style={{ color: 'rgba(0,0,0,0.2)', margin: '0 8px', fontSize: '0.875rem' }}>/</span>
<span style={{ fontSize: '0.875rem', color: '#0A0A0A', fontWeight: 600 }}>Settings</span>
</div>
{/* Content */}
<div style={{ maxWidth: 640, margin: '0 auto', padding: '40px 24px' }}>
<h1 style={{ fontSize: '1.375rem', fontWeight: 700, color: '#0A0A0A', margin: '0 0 6px', letterSpacing: '-0.02em' }}>
Project settings
</h1>
<p style={{ fontSize: '0.875rem', color: '#71717A', margin: '0 0 32px', lineHeight: 1.6 }}>
Configure your project name, URL, and framework.
</p>
<ProjectSettingsForm
workspaceId={wid}
projectId={pid}
initialName={project.name}
initialDescription={project.description ?? ''}
initialAppUrl={project.app_url ?? ''}
initialFramework={project.framework ?? ''}
memberRole={member.role}
/>
</div>
</div>
);
}
+42 -133
View File
@@ -19,24 +19,24 @@ interface ArtboardProps {
} }
export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) { export function Artboard({ id, label, x, y, width, height, renderUrl }: ArtboardProps) {
const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, selectComponent } = useCanvas(); const { selectedArtboardId, selectArtboard, workspaceId, projectId, setArtboardLive, setFiberRoot, selectComponent } = useCanvas();
const selected = selectedArtboardId === id; const selected = selectedArtboardId === id;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// ── Fiber tree (from LiveArtboard) ───────────────────────────────────────── // ── Fiber tree (from LiveArtboard) ─────────────────────────────────────────
const [fiberRoot, setFiberRoot] = useState<FiberNode | undefined>(undefined); const [localFiberRoot, setLocalFiberRoot] = useState<FiberNode | undefined>(undefined);
const handleFiberUpdate = useCallback((root: FiberNode) => { const handleFiberUpdate = useCallback((root: FiberNode) => {
setFiberRoot(root); setFiberRoot(id, root);
setArtboardLive(id, true); setArtboardLive(id, true);
}, [id, setArtboardLive]); setLocalFiberRoot(root);
}, [id, setFiberRoot, setArtboardLive]);
const handleComponentSelected = useCallback((nodeId: string) => { const handleComponentSelected = useCallback((nodeId: string) => {
if (!fiberRoot) return; if (!localFiberRoot) return;
// Walk fiber tree to find the selected node const node = findFiberNode(localFiberRoot, nodeId);
const node = findFiberNode(fiberRoot, nodeId);
selectComponent(nodeId, node ?? null); selectComponent(nodeId, node ?? null);
}, [fiberRoot, selectComponent]); }, [localFiberRoot, selectComponent]);
// ── Drag to reposition ───────────────────────────────────────────────────── // ── Drag to reposition ─────────────────────────────────────────────────────
const isDragging = useRef(false); const isDragging = useRef(false);
@@ -121,6 +121,17 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
}).catch(console.error); }).catch(console.error);
}, [id, renameValue, label, workspaceId, projectId, queryClient]); }, [id, renameValue, label, workspaceId, projectId, queryClient]);
// ── Delete artboard ────────────────────────────────────────────────────────
const deleteArtboard = useCallback(() => {
if (!window.confirm(`Delete "${label}"? This cannot be undone.`)) return;
fetch(`/api/artboards/${id}`, { method: 'DELETE' })
.then(() => {
selectArtboard(null);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
})
.catch(console.error);
}, [id, label, selectArtboard, workspaceId, projectId, queryClient]);
const effectiveX = x + (isDragging.current ? dragOffset.dx : 0); const effectiveX = x + (isDragging.current ? dragOffset.dx : 0);
const effectiveY = y + (isDragging.current ? dragOffset.dy : 0); const effectiveY = y + (isDragging.current ? dragOffset.dy : 0);
@@ -171,6 +182,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
}} }}
/> />
) : ( ) : (
<>
<span <span
style={{ style={{
fontSize: 11, fontSize: 11,
@@ -184,6 +196,27 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
> >
{label} {label}
</span> </span>
{/* Delete button — only visible when selected */}
{selected && (
<button
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { e.stopPropagation(); deleteArtboard(); }}
title="Delete artboard"
style={{
marginLeft: 6,
background: 'none', border: 'none',
padding: '1px 3px', borderRadius: 3,
cursor: 'pointer', color: 'rgba(255,80,80,0.6)',
fontSize: 11, lineHeight: 1,
transition: 'color 0.12s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLButtonElement).style.color = '#FF5050'; }}
onMouseLeave={e => { (e.currentTarget as HTMLButtonElement).style.color = 'rgba(255,80,80,0.6)'; }}
>
</button>
)}
</>
)} )}
</div> </div>
@@ -226,7 +259,7 @@ export function Artboard({ id, label, x, y, width, height, renderUrl }: Artboard
/> />
<SelectionOverlay <SelectionOverlay
artboardId={id} artboardId={id}
{...(fiberRoot !== undefined ? { fiberRoot } : {})} {...(localFiberRoot !== undefined ? { fiberRoot: localFiberRoot } : {})}
width={width} width={width}
height={height} height={height}
/> />
@@ -350,18 +383,6 @@ function EmptyArtboardContent({
); );
} }
// ── Demo content (only for hardcoded demo IDs) ────────────────────────────────
function ArtboardContent({ id }: { id: string }) {
switch (id) {
case 'dashboard-card': return <DashboardCard />;
case 'user-profile': return <UserProfile />;
case 'nav-sidebar': return <NavSidebar />;
case 'data-table': return <DataTable />;
default: return null; // shouldn't reach here for real artboards
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function findFiberNode(root: FiberNode, nodeId: string): FiberNode | null { function findFiberNode(root: FiberNode, nodeId: string): FiberNode | null {
@@ -384,115 +405,3 @@ function Handle({ pos }: { pos: React.CSSProperties }) {
); );
} }
// ── Demo card components ──────────────────────────────────────────────────────
function DashboardCard() {
return (
<div style={{ padding: 20 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<span style={{ fontSize: 11, fontWeight: 600, color: '#111', letterSpacing: '-0.01em' }}>Revenue Overview</span>
<span style={{ fontSize: 9, background: '#ECFDF5', color: '#059669', padding: '2px 7px', borderRadius: 99, fontWeight: 600, fontFamily: 'monospace' }}>Live</span>
</div>
<div style={{ fontSize: 24, fontWeight: 800, color: '#0A0A0A', letterSpacing: '-0.045em', lineHeight: 1, marginBottom: 4 }}>$12,450</div>
<div style={{ fontSize: 10, color: '#059669', fontWeight: 500, marginBottom: 14, display: 'flex', alignItems: 'center', gap: 3 }}>
<span></span> +2.4% vs last month
</div>
<div style={{ height: 3, background: '#F0F0F0', borderRadius: 99, overflow: 'hidden', marginBottom: 14 }}>
<div style={{ height: '100%', width: '68%', background: 'linear-gradient(90deg, #0066FF, #3385FF)', borderRadius: 99 }} />
</div>
<div style={{ display: 'flex', gap: 4 }}>
{['Q4 2024', 'MRR', 'SaaS'].map(t => (
<span key={t} style={{ fontSize: 9, background: '#F4F4F5', color: '#71717A', padding: '3px 8px', borderRadius: 99, fontWeight: 500 }}>{t}</span>
))}
</div>
</div>
);
}
function UserProfile() {
return (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', alignItems: 'center', height: '100%' }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: 'linear-gradient(135deg, #7C3AED, #0066FF)', marginBottom: 12 }} />
<div style={{ fontSize: 13, fontWeight: 700, color: '#0A0A0A', letterSpacing: '-0.025em', marginBottom: 3 }}>Sarah Chen</div>
<div style={{ fontSize: 10, color: '#A1A1AA', marginBottom: 16 }}>Design Engineer</div>
<div style={{ width: '100%', display: 'flex', flexDirection: 'column', gap: 7 }}>
{[['Team', 'Acme Inc'], ['Role', 'Admin'], ['Plan', 'Team']].map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10 }}>
<span style={{ color: '#A1A1AA' }}>{k}</span>
<span style={{ fontWeight: 500, color: '#0A0A0A' }}>{v}</span>
</div>
))}
</div>
</div>
);
}
function NavSidebar() {
const items = [
{ icon: '⊞', label: 'Dashboard', active: true },
{ icon: '◉', label: 'Origin Graph', active: false },
{ icon: '⬜', label: 'Artboards', active: false },
{ icon: '△', label: 'Diffs', active: false },
{ icon: '🔗', label: 'Integrations',active: false },
];
return (
<div style={{ height: '100%', background: '#FAFAFA', display: 'flex', flexDirection: 'column', padding: '12px 0' }}>
<div style={{ padding: '0 12px 12px', fontSize: 11, fontWeight: 800, letterSpacing: '-0.04em', color: '#0A0A0A' }}>
Origin<span style={{ color: '#0066FF' }}>main</span>
</div>
<div style={{ height: 1, background: '#EBEBEB', margin: '0 0 8px' }} />
{items.map(({ icon, label, active }) => (
<div key={label} style={{
display: 'flex', alignItems: 'center', gap: 8,
padding: '7px 12px', margin: '1px 6px', borderRadius: 5,
background: active ? 'rgba(0,102,255,0.07)' : 'transparent',
fontSize: 10, fontWeight: active ? 600 : 400,
color: active ? '#0066FF' : '#52525B', cursor: 'default',
}}>
<span style={{ fontSize: 11 }}>{icon}</span>{label}
</div>
))}
</div>
);
}
function DataTable() {
const rows = [
{ name: 'DashboardCard', status: 'Live', nodes: 12, tokens: 8 },
{ name: 'UserProfile', status: 'Draft', nodes: 7, tokens: 3 },
{ name: 'NavSidebar', status: 'Live', nodes: 19, tokens: 11 },
{ name: 'DataTable', status: 'Review', nodes: 24, tokens: 14 },
];
return (
<div style={{ padding: '16px 0', height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '0 16px 12px', fontSize: 11, fontWeight: 700, color: '#0A0A0A', letterSpacing: '-0.02em' }}>
Component Inventory
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px', padding: '0 16px 6px', gap: 4 }}>
{['Name', 'Status', 'Nodes', 'Tokens'].map(h => (
<span key={h} style={{ fontFamily: 'monospace', fontSize: 8, color: '#A1A1AA', letterSpacing: '0.05em', textTransform: 'uppercase' }}>{h}</span>
))}
</div>
{rows.map((row, i) => (
<div key={row.name} style={{
display: 'grid', gridTemplateColumns: '1fr 60px 50px 50px',
padding: '8px 16px', gap: 4,
background: i % 2 === 0 ? 'transparent' : '#FAFAFA', alignItems: 'center',
}}>
<span style={{ fontSize: 10, fontWeight: 500, color: '#0A0A0A' }}>{row.name}</span>
<span style={{
fontSize: 8, fontWeight: 600, fontFamily: 'monospace',
color: row.status === 'Live' ? '#059669' : row.status === 'Draft' ? '#6B7280' : '#D97706',
background: row.status === 'Live' ? '#ECFDF5' : row.status === 'Draft' ? '#F9FAFB' : '#FFFBEB',
padding: '2px 6px', borderRadius: 99, width: 'fit-content',
}}>{row.status}</span>
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.nodes}</span>
<span style={{ fontSize: 10, color: '#52525B', fontFamily: 'monospace' }}>{row.tokens}</span>
</div>
))}
</div>
);
}
// Suppress unused warning — kept for demo IDs in ArtboardContent
void ArtboardContent;
+158 -4
View File
@@ -12,7 +12,7 @@ export function Canvas() {
const panX = useViewport((s) => s.panX); const panX = useViewport((s) => s.panX);
const panY = useViewport((s) => s.panY); const panY = useViewport((s) => s.panY);
const zoom = useViewport((s) => s.zoom); const zoom = useViewport((s) => s.zoom);
const { activeTool, setActiveTool, selectArtboard, workspaceId, projectId } = useCanvas(); const { activeTool, setActiveTool, selectArtboard, selectedArtboardId, workspaceId, projectId } = useCanvas();
const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const { artboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -23,6 +23,7 @@ export function Canvas() {
// Zone tool: drag to draw a completion zone // Zone tool: drag to draw a completion zone
const zoneStart = useRef<{ x: number; y: number } | null>(null); const zoneStart = useRef<{ x: number; y: number } | null>(null);
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
// Wheel: pan or pinch-zoom // Wheel: pan or pinch-zoom
useEffect(() => { useEffect(() => {
@@ -125,13 +126,15 @@ export function Canvas() {
const onMouseUp = useCallback(() => { const onMouseUp = useCallback(() => {
isPanning.current = false; isPanning.current = false;
// Zone tool: finalise zone on mouse-up (clear preview; zone result is handled elsewhere) // Zone tool: finalise zone — keep the bounds and show prompt popup
if (activeTool === 'zone' && zoneStart.current) { if (activeTool === 'zone' && zoneStart.current && zonePreview) {
const bounds = { ...zonePreview };
zoneStart.current = null; zoneStart.current = null;
setZonePreview(null); setZonePreview(null);
setActiveTool('select'); setActiveTool('select');
if (bounds.w > 8 && bounds.h > 8) setZoneDone(bounds);
} }
}, [activeTool, setActiveTool]); }, [activeTool, setActiveTool, zonePreview]);
// Dot grid that shifts with pan and scales with zoom // Dot grid that shifts with pan and scales with zoom
const gridSpacing = Math.max(6, 20 * zoom); const gridSpacing = Math.max(6, 20 * zoom);
@@ -215,6 +218,16 @@ export function Canvas() {
)} )}
</div> </div>
{/* Zone prompt overlay — shown after a zone drag completes */}
{zoneDone && (
<ZonePromptOverlay
bounds={zoneDone}
artboardId={selectedArtboardId}
panX={panX} panY={panY} zoom={zoom}
onClose={() => setZoneDone(null)}
/>
)}
{/* Empty canvas hint — shown only when workspace has no artboards yet */} {/* Empty canvas hint — shown only when workspace has no artboards yet */}
{artboards.length === 0 && ( {artboards.length === 0 && (
<div style={{ <div style={{
@@ -245,3 +258,144 @@ export function Canvas() {
</div> </div>
); );
} }
/* ── Zone prompt overlay ──────────────────────────────────── */
function ZonePromptOverlay({
bounds, artboardId, panX, panY, zoom, onClose,
}: {
bounds: { x: number; y: number; w: number; h: number };
artboardId: string | null;
panX: number; panY: number; zoom: number;
onClose: () => void;
}) {
const [prompt, setPrompt] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [result, setResult] = useState('');
// Convert canvas → screen coordinates (relative to canvas container)
const screenX = bounds.x * zoom + panX;
const screenY = (bounds.y + bounds.h) * zoom + panY + 10; // 10px below zone
const submit = useCallback(async () => {
if (!prompt.trim() || !artboardId) return;
setStatus('loading');
try {
const res = await fetch('/api/ai/completion-zone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artboard_id: artboardId,
bounds: { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h },
prompt: prompt.trim(),
}),
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json() as { completion?: string; result?: string };
setResult(data.completion ?? data.result ?? 'Done');
setStatus('done');
} catch (e) {
console.error('[ZonePrompt]', e);
setStatus('error');
}
}, [prompt, artboardId, bounds]);
return (
<div
style={{
position: 'absolute',
left: Math.max(8, screenX),
top: Math.max(8, screenY),
zIndex: 50,
width: 280,
background: '#1A1A20',
border: '1px solid rgba(51,133,255,0.35)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
padding: '12px 14px',
fontFamily: "'Inter', -apple-system, sans-serif",
}}
onMouseDown={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'rgba(51,133,255,0.9)', letterSpacing: '-0.01em' }}>
Completion zone · {bounds.w}×{bounds.h}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'rgba(255,255,255,0.3)', cursor: 'pointer', fontSize: 13, padding: 0, lineHeight: 1 }}></button>
</div>
{status === 'done' ? (
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.75)', lineHeight: 1.6, marginBottom: 10 }}>
{result}
</div>
) : (
<>
<textarea
autoFocus
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void submit();
if (e.key === 'Escape') onClose();
e.stopPropagation();
}}
placeholder="Describe what to generate in this zone…"
rows={3}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 6, padding: '8px 10px',
fontSize: '0.75rem', color: 'rgba(255,255,255,0.85)',
fontFamily: 'inherit', resize: 'none', outline: 'none',
marginBottom: 8,
}}
/>
{status === 'error' && (
<p style={{ fontSize: '0.625rem', color: '#FF8080', margin: '0 0 6px' }}>Request failed try again</p>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !prompt.trim() || !artboardId}
style={{
flex: 1, padding: '7px 0', borderRadius: 6,
background: !prompt.trim() || !artboardId ? 'rgba(51,133,255,0.3)' : '#3385FF',
border: 'none', color: '#fff',
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', opacity: status === 'loading' ? 0.7 : 1,
}}
>
{status === 'loading' ? 'Generating…' : 'Generate ⌘↵'}
</button>
<button
onClick={onClose}
style={{
padding: '7px 12px', borderRadius: 6,
background: 'transparent', border: '1px solid rgba(255,255,255,0.12)',
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Cancel
</button>
</div>
</>
)}
{status === 'done' && (
<button
onClick={onClose}
style={{
width: '100%', padding: '7px 0', borderRadius: 6,
background: 'rgba(255,255,255,0.07)', border: 'none',
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Close
</button>
)}
</div>
);
}
@@ -9,6 +9,7 @@ import { Canvas } from '../canvas/Canvas';
import { Inspector } from '../inspector/Inspector'; import { Inspector } from '../inspector/Inspector';
import { useHistory } from '@/store/history'; import { useHistory } from '@/store/history';
import { useCanvas, type Tool } from '@/store/canvas'; import { useCanvas, type Tool } from '@/store/canvas';
import { useViewport } from '@/store/viewport';
interface AppChromeProps { interface AppChromeProps {
workspaceId?: string; workspaceId?: string;
@@ -27,6 +28,25 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
if (workspaceId && projectId) setContext(workspaceId, projectId); if (workspaceId && projectId) setContext(workspaceId, projectId);
}, [workspaceId, projectId, setContext]); }, [workspaceId, projectId, setContext]);
// Per-workspace viewport persistence: restore saved pan/zoom on mount,
// save current state back to localStorage on unmount.
useEffect(() => {
if (!workspaceId) return;
const key = `originmain:viewport:${workspaceId}`;
try {
const raw = localStorage.getItem(key);
if (raw) {
const saved = JSON.parse(raw) as { panX: number; panY: number; zoom: number };
useViewport.getState().restore(saved);
}
} catch { /* ignore parse errors */ }
return () => {
const { panX, panY, zoom } = useViewport.getState();
localStorage.setItem(key, JSON.stringify({ panX, panY, zoom }));
};
}, [workspaceId]);
useEffect(() => { useEffect(() => {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
// Skip if user is typing in an input // Skip if user is typing in an input
@@ -116,6 +136,28 @@ export function AppChrome({ workspaceId, projectId, workspaceName, projectName }
{projectName ?? 'Canvas'} {projectName ?? 'Canvas'}
</span> </span>
{/* Project settings link */}
{workspaceId && projectId && (
<Link
href={`/workspace/${workspaceId}/project/${projectId}/settings`}
title="Project settings"
style={{
marginLeft: 8,
display: 'flex', alignItems: 'center',
textDecoration: 'none',
color: 'rgba(255,255,255,0.25)',
transition: 'color 0.12s',
}}
onMouseEnter={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.65)')}
onMouseLeave={e => (e.currentTarget.style.color = 'rgba(255,255,255,0.25)')}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M6 7.5A1.5 1.5 0 1 0 6 4.5 1.5 1.5 0 0 0 6 7.5Z" stroke="currentColor" strokeWidth="1" strokeLinecap="round"/>
<path d="M9.2 4.6l.4-1.4-1.2-.7-.9 1.1a3.5 3.5 0 0 0-3 0L3.6 2.5 2.4 3.2l.4 1.4A3.4 3.4 0 0 0 2 6c0 .5.1.9.3 1.4L1.9 8.7 3 9.5l1.1-1a3.5 3.5 0 0 0 3.8 0l1.1 1 1.2-.8-.4-1.3c.2-.5.3-1 .3-1.4a3.4 3.4 0 0 0-.9-2.4Z" stroke="currentColor" strokeWidth="1" strokeLinejoin="round"/>
</svg>
</Link>
)}
<div style={{ flex: 1 }} /> <div style={{ flex: 1 }} />
<div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}> <div style={{ transform: 'scale(0.8)', transformOrigin: 'right center' }}>
@@ -7,6 +7,7 @@ import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
import { useDiffs } from '@/hooks/useDiffs'; import { useDiffs } from '@/hooks/useDiffs';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import type { PropChange } from '@originmain/diff-engine'; import type { PropChange } from '@originmain/diff-engine';
import type { FiberNode } from '@originmain/renderer';
import type { Artboard, IntentDiff } from '@originmain/origin-graph'; import type { Artboard, IntentDiff } from '@originmain/origin-graph';
const TYPE_COLORS: Record<string, string> = { const TYPE_COLORS: Record<string, string> = {
@@ -31,7 +32,7 @@ const T = {
}; };
export function Inspector() { export function Inspector() {
const { selectedArtboardId, liveArtboardIds, workspaceId, projectId } = useCanvas(); const { selectedArtboardId, liveArtboardIds, artboardFiberRoots, selectedComponentId, selectedComponentData, workspaceId, projectId } = useCanvas();
const [tab, setTab] = useState<TabId>('props'); const [tab, setTab] = useState<TabId>('props');
const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const { rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null; const selectedArtboard = rawArtboards.find((ab) => ab.id === selectedArtboardId) ?? null;
@@ -104,24 +105,16 @@ export function Inspector() {
</span> </span>
</div> </div>
) : tab === 'props' ? ( ) : tab === 'props' ? (
<PropsTab artboard={selectedArtboard} workspaceId={workspaceId} projectId={projectId} /> <PropsTab
artboard={selectedArtboard}
selectedComponentData={selectedComponentId ? selectedComponentData : null}
workspaceId={workspaceId}
projectId={projectId}
/>
) : tab === 'diff' ? ( ) : tab === 'diff' ? (
<DiffTab artboardId={selectedArtboardId} /> <DiffTab artboardId={selectedArtboardId} />
) : ( ) : (
<Section label="Origin Graph"> <GraphTab fiberRoot={selectedArtboardId ? artboardFiberRoots[selectedArtboardId] : undefined} />
<GraphNode label="DashboardCard" depth={0} isRoot />
<GraphNode label="StatsCard" depth={1} />
<GraphNode label="ProgressBar" depth={2} />
<GraphNode label="ValueDisplay" depth={2} />
<GraphNode label="CardBase" depth={1} />
<GraphNode label="Elevation" depth={2} />
<HSep />
<div style={{ padding: '4px 0 8px' }}>
<PropRow label="nodes" value="284" color="#7EB8FF" />
<PropRow label="depth" value="4" color="#7EB8FF" />
<PropRow label="tokens used" value="12" color="#FFBA7B" />
</div>
</Section>
)} )}
</div> </div>
@@ -160,10 +153,12 @@ export function Inspector() {
/* ── Props tab ────────────────────────────────────────────── */ /* ── Props tab ────────────────────────────────────────────── */
function PropsTab({ function PropsTab({
artboard, artboard,
selectedComponentData,
workspaceId, workspaceId,
projectId, projectId,
}: { }: {
artboard: Artboard | null; artboard: Artboard | null;
selectedComponentData: FiberNode | null;
workspaceId: string | null; workspaceId: string | null;
projectId: string | null; projectId: string | null;
}) { }) {
@@ -171,6 +166,29 @@ function PropsTab({
const [editingUrl, setEditingUrl] = useState(false); const [editingUrl, setEditingUrl] = useState(false);
const [urlDraft, setUrlDraft] = useState(''); const [urlDraft, setUrlDraft] = useState('');
// Drift report state
const [driftStatus, setDriftStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [driftReport, setDriftReport] = useState('');
const generateDriftReport = useCallback(async () => {
if (!artboard) return;
setDriftStatus('loading');
setDriftReport('');
try {
const res = await fetch('/api/ai/drift-report', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artboard_id: artboard.id }),
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json() as { report?: string; result?: string };
setDriftReport(data.report ?? data.result ?? '— No report returned');
setDriftStatus('done');
} catch {
setDriftStatus('error');
}
}, [artboard]);
const saveRenderUrl = useCallback(async () => { const saveRenderUrl = useCallback(async () => {
if (!artboard) return; if (!artboard) return;
const { renderUrl: _removed, ...rest } = artboard.metadata_jsonb; const { renderUrl: _removed, ...rest } = artboard.metadata_jsonb;
@@ -214,6 +232,26 @@ function PropsTab({
return ( return (
<> <>
{/* Selected fiber component props — shown when a component is clicked in canvas */}
{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 display = t === 'string' ? `"${v as string}"` : String(v);
return <PropRow key={k} label={k} value={display} color={color} />;
})}
{Object.keys(selectedComponentData.props ?? {}).length === 0 && (
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No props
</span>
)}
</Section>
<HSep />
</>
)}
{extraProps.length > 0 && ( {extraProps.length > 0 && (
<> <>
<Section label="Component Props"> <Section label="Component Props">
@@ -237,6 +275,7 @@ function PropsTab({
<PropRow label="id" value={artboard.id.slice(0, 8) + '…'} color="rgba(255,255,255,0.28)" /> <PropRow label="id" value={artboard.id.slice(0, 8) + '…'} color="rgba(255,255,255,0.28)" />
{/* renderUrl — inline editable */} {/* renderUrl — inline editable */}
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: editingUrl ? 6 : 0 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: editingUrl ? 6 : 0 }}>
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.key }}> <span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: T.key }}>
@@ -299,6 +338,56 @@ function PropsTab({
)} )}
</div> </div>
</Section> </Section>
<HSep />
{/* ── Drift Report ───────────────────────────────────── */}
<Section label="Drift Report">
<button
onClick={() => void generateDriftReport()}
disabled={driftStatus === 'loading'}
style={{
width: '100%',
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5875rem',
background: driftStatus === 'loading' ? 'rgba(255,255,255,0.06)' : 'rgba(51,133,255,0.12)',
border: `1px solid ${driftStatus === 'loading' ? 'rgba(255,255,255,0.08)' : 'rgba(51,133,255,0.25)'}`,
borderRadius: 6, padding: '6px 0',
color: driftStatus === 'loading' ? 'rgba(255,255,255,0.35)' : T.accent,
cursor: driftStatus === 'loading' ? 'wait' : 'pointer',
letterSpacing: '0.04em',
transition: 'background 0.15s, border-color 0.15s, color 0.15s',
}}
>
{driftStatus === 'loading' ? 'Analysing…' : '↻ Generate drift report'}
</button>
{driftStatus === 'error' && (
<div style={{ marginTop: 6, fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace' }}>
Report failed try again
</div>
)}
{driftStatus === 'done' && driftReport && (
<div style={{
marginTop: 8,
padding: '8px 10px',
background: 'rgba(0,0,0,0.35)',
border: '1px solid rgba(255,255,255,0.06)',
borderRadius: 6,
maxHeight: 220,
overflow: 'auto',
fontSize: '0.5875rem',
fontFamily: "'Inter', sans-serif",
color: 'rgba(255,255,255,0.62)',
lineHeight: 1.65,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{driftReport}
</div>
)}
</Section>
</> </>
); );
} }
@@ -367,6 +456,81 @@ function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isR
); );
} }
/* ── Graph tab ────────────────────────────────────────────── */
function GraphTab({ fiberRoot }: { fiberRoot: FiberNode | undefined }) {
if (!fiberRoot) {
return (
<Section label="Origin Graph">
<span style={{ fontFamily: "'JetBrains Mono', monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No live render connect a URL in Props to see the fiber tree
</span>
</Section>
);
}
const nodeCount = countFiberNodes(fiberRoot);
const treeDepth = measureFiberDepth(fiberRoot);
return (
<Section label="Origin Graph">
<FiberTreeView node={fiberRoot} depth={0} />
<HSep />
<div style={{ padding: '4px 0 8px' }}>
<PropRow label="nodes" value={String(nodeCount)} color="#7EB8FF" />
<PropRow label="depth" value={String(treeDepth)} color="#7EB8FF" />
</div>
</Section>
);
}
function FiberTreeView({ node, depth }: { node: FiberNode; depth: number }) {
const [collapsed, setCollapsed] = useState(depth > 2);
const hasChildren = node.children && node.children.length > 0;
return (
<div>
<div
style={{
display: 'flex', alignItems: 'center', gap: 5,
paddingLeft: depth * 12, marginBottom: 4,
cursor: hasChildren ? 'pointer' : 'default',
}}
onClick={() => hasChildren && setCollapsed(c => !c)}
>
<div style={{
width: 5, height: 5, borderRadius: '50%', flexShrink: 0,
background: depth === 0 ? T.accent : 'rgba(255,255,255,0.2)',
}} />
{hasChildren && (
<span style={{ fontSize: '0.5rem', color: 'rgba(255,255,255,0.3)', marginRight: -2 }}>
{collapsed ? '▶' : '▼'}
</span>
)}
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.625rem',
color: depth === 0 ? T.accent : 'rgba(255,255,255,0.55)',
letterSpacing: '-0.01em',
}}>
{node.name}
</span>
</div>
{!collapsed && hasChildren && node.children!.map((child, i) => (
<FiberTreeView key={i} node={child} depth={depth + 1} />
))}
</div>
);
}
function countFiberNodes(node: FiberNode): number {
return 1 + (node.children ?? []).reduce((acc, c) => acc + countFiberNodes(c), 0);
}
function measureFiberDepth(node: FiberNode, d = 0): number {
if (!node.children?.length) return d;
return Math.max(...node.children.map(c => measureFiberDepth(c, d + 1)));
}
function HSep() { function HSep() {
return <div style={{ height: 1, background: 'rgba(255,255,255,0.04)', margin: '2px 0' }} />; return <div style={{ height: 1, background: 'rgba(255,255,255,0.04)', margin: '2px 0' }} />;
} }
@@ -1,11 +1,12 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useCallback, type ReactNode } from 'react';
import { useFileTree, FileTree } from '@pierre/trees/react'; import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees'; import { themeToTreeStyles } from '@pierre/trees';
import { SquareRegular } from '@fluentui/react-icons'; import { SquareRegular } from '@fluentui/react-icons';
import { useCanvas } from '@/store/canvas'; import { useCanvas } from '@/store/canvas';
import { useArtboards } from '@/hooks/useArtboards'; import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
import { useQueryClient } from '@tanstack/react-query';
const T = { const T = {
bg: '#111115', bg: '#111115',
@@ -35,8 +36,36 @@ const treeThemeStyles = themeToTreeStyles({
}); });
export function ArtboardNavigator() { export function ArtboardNavigator() {
const { selectedArtboardId, selectArtboard, workspaceId, projectId } = useCanvas(); const { selectedArtboardId, selectArtboard, workspaceId, projectId, liveArtboardIds, artboardFiberRoots } = useCanvas();
const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined); const { artboards, rawArtboards } = useArtboards(workspaceId ?? undefined, projectId ?? undefined);
const queryClient = useQueryClient();
const deleteArtboard = useCallback(async (id: string, name: string) => {
if (!window.confirm(`Delete "${name}"?`)) return;
try {
const res = await fetch(`/api/artboards/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Server returned ${res.status}`);
} catch (err) {
console.error('[Navigator] deleteArtboard failed:', err);
window.alert(`Could not delete "${name}" — please try again.`);
return;
}
if (selectedArtboardId === id) selectArtboard(null);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
}, [selectedArtboardId, selectArtboard, workspaceId, projectId, queryClient]);
const renameArtboard = useCallback(async (id: string, currentName: string) => {
const newName = window.prompt('Rename artboard:', currentName);
if (!newName || newName.trim() === currentName) return;
await patchArtboard(id, { name: newName.trim() }).catch(console.error);
queryClient.invalidateQueries({ queryKey: ['artboards', workspaceId, projectId ?? undefined] });
}, [workspaceId, projectId, queryClient]);
// Real graph stats derived from live fiber trees
const totalComponents = Object.values(artboardFiberRoots).reduce(
(acc, root) => acc + countFiberNodes(root), 0,
);
const liveCount = liveArtboardIds.size;
// Build file tree paths from artboard names (strip .tsx suffix if present, else use name as path) // Build file tree paths from artboard names (strip .tsx suffix if present, else use name as path)
const filePaths = rawArtboards.length > 0 const filePaths = rawArtboards.length > 0
@@ -68,10 +97,12 @@ export function ArtboardNavigator() {
<div style={{ padding: '2px 6px 0' }}> <div style={{ padding: '2px 6px 0' }}>
{artboards.map((ab) => { {artboards.map((ab) => {
const sel = selectedArtboardId === ab.id; const sel = selectedArtboardId === ab.id;
const live = liveArtboardIds.has(ab.id);
return ( return (
<NavRow <NavRow
key={ab.id} key={ab.id}
selected={sel} selected={sel}
live={live}
onClick={() => selectArtboard(ab.id)} onClick={() => selectArtboard(ab.id)}
icon={ icon={
<SquareRegular <SquareRegular
@@ -79,7 +110,8 @@ export function ArtboardNavigator() {
/> />
} }
label={ab.label} label={ab.label}
after={sel && <ActiveDot />} onRename={() => void renameArtboard(ab.id, ab.label)}
onDelete={() => void deleteArtboard(ab.id, ab.label)}
/> />
); );
})} })}
@@ -107,11 +139,14 @@ export function ArtboardNavigator() {
{/* ── Graph stats ── */} {/* ── Graph stats ── */}
<SectionLabel>Graph</SectionLabel> <SectionLabel>Graph</SectionLabel>
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}> <div style={{ padding: '4px 14px 8px', display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<GraphStat label="artboards" value={String(rawArtboards.length || artboards.length)} color={T.accent} /> <GraphStat label="artboards" value={String(rawArtboards.length)} color={T.accent} />
<GraphStat label="components" value="—" color="rgba(255,255,255,0.45)" /> <GraphStat label="live" value={liveCount > 0 ? String(liveCount) : '—'} color={liveCount > 0 ? '#10B981' : 'rgba(255,255,255,0.25)'} />
<GraphStat label="tokens" value="—" color="rgba(255,255,255,0.45)" /> <GraphStat label="components" value={totalComponents > 0 ? String(totalComponents) : '—'} color="rgba(255,255,255,0.45)" />
</div> </div>
{/* ── Cross-artboard query ── */}
<CrossArtboardQuery workspaceId={workspaceId} />
</div> </div>
); );
} }
@@ -119,16 +154,20 @@ export function ArtboardNavigator() {
/* ── Artboard row ─────────────────────────────────────────── */ /* ── Artboard row ─────────────────────────────────────────── */
function NavRow({ function NavRow({
selected = false, selected = false,
live = false,
onClick, onClick,
icon, icon,
label, label,
after, onRename,
onDelete,
}: { }: {
selected?: boolean; selected?: boolean;
live?: boolean;
onClick?: () => void; onClick?: () => void;
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
after?: React.ReactNode; onRename?: () => void;
onDelete?: () => void;
}) { }) {
const [hov, setHov] = useState(false); const [hov, setHov] = useState(false);
@@ -141,7 +180,7 @@ function NavRow({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 7, gap: 7,
padding: '6px 10px', padding: '4px 6px 4px 10px',
borderRadius: 5, borderRadius: 5,
cursor: 'pointer', cursor: 'pointer',
background: selected ? T.selBg : hov ? 'rgba(255,255,255,0.04)' : 'transparent', background: selected ? T.selBg : hov ? 'rgba(255,255,255,0.04)' : 'transparent',
@@ -155,9 +194,59 @@ function NavRow({
}} }}
> >
{icon} {icon}
<span style={{ flex: 1 }}>{label}</span> <span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{after}
{/* Live render indicator — pulsing green dot */}
{live && !hov && (
<span style={{
width: 5, height: 5, borderRadius: '50%',
background: '#10B981', flexShrink: 0, display: 'block',
boxShadow: '0 0 4px rgba(16,185,129,0.8)',
}} />
)}
{/* Action buttons: rename + delete — shown on hover */}
{(hov || selected) && (
<div style={{ display: 'flex', gap: 2, flexShrink: 0 }} onClick={e => e.stopPropagation()}>
{onRename && (
<IconBtn title="Rename" onClick={onRename}>
<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>
)}
{onDelete && (
<IconBtn title="Delete" onClick={onDelete} danger>
<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>
</IconBtn>
)}
</div> </div>
)}
</div>
);
}
function IconBtn({ children, title, onClick, danger }: { children: React.ReactNode; title: string; onClick: () => void; danger?: boolean }) {
const [hov, setHov] = useState(false);
return (
<button
title={title}
onClick={onClick}
onMouseEnter={() => setHov(true)}
onMouseLeave={() => setHov(false)}
style={{
background: hov ? (danger ? 'rgba(255,80,80,0.15)' : 'rgba(255,255,255,0.08)') : 'none',
border: 'none', borderRadius: 3, padding: '2px 3px',
cursor: 'pointer',
color: hov ? (danger ? '#FF6060' : 'rgba(255,255,255,0.8)') : 'rgba(255,255,255,0.3)',
transition: 'background 0.1s, color 0.1s',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
{children}
</button>
); );
} }
@@ -210,3 +299,95 @@ function GraphStat({ label, value, color }: { label: string; value: string; colo
</div> </div>
); );
} }
function countFiberNodes(node: { children?: unknown[] }): number {
return 1 + (node.children ?? []).reduce<number>(
(acc, c) => acc + countFiberNodes(c as { children?: unknown[] }),
0,
);
}
/* ── Cross-artboard query ─────────────────────────────────── */
function CrossArtboardQuery({ workspaceId }: { workspaceId: string | null }) {
const [query, setQuery] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [answer, setAnswer] = useState('');
const submit = useCallback(async () => {
if (!query.trim() || !workspaceId) return;
setStatus('loading');
setAnswer('');
try {
const res = await fetch('/api/ai/query', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workspace_id: workspaceId, question: query.trim() }),
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json() as { answer?: string; result?: string };
setAnswer(data.answer ?? data.result ?? '—');
setStatus('done');
} catch {
setStatus('error');
}
}, [query, workspaceId]);
return (
<div style={{ padding: '0 10px 14px', flexShrink: 0 }}>
<div style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5rem', fontWeight: 500, letterSpacing: '0.1em',
textTransform: 'uppercase', color: T.dim, padding: '8px 4px 6px',
}}>
Query
</div>
<div style={{ display: 'flex', gap: 4 }}>
<input
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') void submit();
e.stopPropagation();
}}
placeholder="Ask across artboards…"
style={{
flex: 1, background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.08)',
borderRadius: 5, padding: '5px 8px',
fontSize: '0.5875rem', fontFamily: 'inherit',
color: 'rgba(255,255,255,0.75)', outline: 'none',
}}
/>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !query.trim() || !workspaceId}
style={{
background: T.accent, border: 'none', borderRadius: 5,
padding: '5px 8px', cursor: 'pointer',
fontSize: '0.5875rem', color: '#fff', flexShrink: 0,
opacity: (status === 'loading' || !query.trim()) ? 0.5 : 1,
}}
>
{status === 'loading' ? '…' : '↵'}
</button>
</div>
{status === 'done' && answer && (
<div style={{
marginTop: 6, padding: '6px 8px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 5, fontSize: '0.5875rem',
color: 'rgba(255,255,255,0.6)', lineHeight: 1.55,
fontFamily: "'Inter', sans-serif",
maxHeight: 120, overflow: 'auto',
}}>
{answer}
</div>
)}
{status === 'error' && (
<div style={{ marginTop: 4, fontSize: '0.5rem', color: '#FF8080', fontFamily: 'monospace' }}>
Query failed try again
</div>
)}
</div>
);
}
@@ -0,0 +1,242 @@
'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
interface ProjectSettingsFormProps {
workspaceId: string;
projectId: string;
initialName: string;
initialDescription: string;
initialAppUrl: string;
initialFramework: string;
memberRole: string;
}
const SECTION: React.CSSProperties = {
background: '#FFFFFF',
border: '1px solid rgba(0,0,0,0.07)',
borderRadius: 14,
padding: '24px 28px',
marginBottom: 20,
};
const LABEL: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 600,
color: '#0A0A0A',
marginBottom: 6,
};
const INPUT: React.CSSProperties = {
width: '100%',
fontSize: '0.875rem',
padding: '9px 12px',
border: '1px solid rgba(0,0,0,0.12)',
borderRadius: 9,
outline: 'none',
fontFamily: "'Inter', -apple-system, sans-serif",
color: '#0A0A0A',
background: '#FAFAFA',
boxSizing: 'border-box',
};
const BTN_PRIMARY: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 6,
background: '#0A0A0A', color: '#FFFFFF',
fontSize: '0.875rem', fontWeight: 600,
padding: '9px 20px', borderRadius: 9,
border: 'none', cursor: 'pointer', letterSpacing: '-0.01em',
};
const FRAMEWORKS = ['', 'Next.js', 'Vite + React', 'Remix', 'SvelteKit', 'Nuxt', 'Other'] as const;
export function ProjectSettingsForm({
workspaceId,
projectId,
initialName,
initialDescription,
initialAppUrl,
initialFramework,
memberRole,
}: ProjectSettingsFormProps) {
const router = useRouter();
const isOwnerOrDev = memberRole === 'OWNER' || memberRole === 'DEVELOPER' || memberRole === 'DESIGNER';
/* ── Form state ── */
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [appUrl, setAppUrl] = useState(initialAppUrl);
const [framework, setFramework] = useState(initialFramework);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { return () => { clearTimeout(saveTimer.current); }; }, []);
const isDirty =
name.trim() !== initialName ||
description !== initialDescription ||
appUrl !== initialAppUrl ||
framework !== initialFramework;
const save = useCallback(async () => {
if (!isDirty || !name.trim()) return;
clearTimeout(saveTimer.current);
setSaveStatus('saving');
try {
const res = await fetch(`/api/workspace/${workspaceId}/projects/${projectId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description || null,
app_url: appUrl || null,
framework: framework || null,
}),
});
if (!res.ok) throw new Error('Save failed');
setSaveStatus('saved');
router.refresh();
saveTimer.current = setTimeout(() => setSaveStatus('idle'), 2500);
} catch {
setSaveStatus('error');
saveTimer.current = setTimeout(() => setSaveStatus('idle'), 3000);
}
}, [isDirty, name, description, appUrl, framework, workspaceId, projectId, router]);
/* ── Delete ── */
const [deleteConfirm, setDeleteConfirm] = useState('');
const [deleting, setDeleting] = useState(false);
// Guard against deleting an unsaved name: always compare against the persisted name.
const deleteProject = useCallback(async () => {
if (deleteConfirm.trim() !== initialName.trim()) return;
setDeleting(true);
try {
const res = await fetch(`/api/workspace/${workspaceId}/projects/${projectId}`, { method: 'DELETE' });
if (!res.ok) throw new Error('Delete failed');
router.push(`/workspace/${workspaceId}`);
} catch {
setDeleting(false);
window.alert('Delete failed — please try again.');
}
}, [deleteConfirm, initialName, workspaceId, projectId, router]);
return (
<>
{/* General */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 20px' }}>General</h2>
<label style={LABEL} htmlFor="proj-name">Project name</label>
<input
id="proj-name"
style={{ ...INPUT, marginBottom: 16 }}
value={name}
onChange={e => setName(e.target.value)}
disabled={!isOwnerOrDev}
/>
<label style={LABEL} htmlFor="proj-desc">Description</label>
<textarea
id="proj-desc"
rows={3}
style={{ ...INPUT, marginBottom: 16, resize: 'vertical' } as React.CSSProperties}
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="What does this project do?"
disabled={!isOwnerOrDev}
/>
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={LABEL} htmlFor="proj-url">App URL</label>
<input
id="proj-url"
style={INPUT}
value={appUrl}
onChange={e => setAppUrl(e.target.value)}
placeholder="https://your-app.vercel.app"
disabled={!isOwnerOrDev}
/>
</div>
<div style={{ flex: 1 }}>
<label style={LABEL} htmlFor="proj-framework">Framework</label>
<select
id="proj-framework"
style={{ ...INPUT }}
value={framework}
onChange={e => setFramework(e.target.value)}
disabled={!isOwnerOrDev}
>
{FRAMEWORKS.map(f => (
<option key={f} value={f}>{f || '— select —'}</option>
))}
</select>
</div>
</div>
{isOwnerOrDev && (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
onClick={() => void save()}
disabled={!isDirty || saveStatus === 'saving' || !name.trim()}
style={{
...BTN_PRIMARY,
opacity: (!isDirty || !name.trim() || saveStatus === 'saving') ? 0.4 : 1,
}}
>
{saveStatus === 'saving' ? 'Saving…' : saveStatus === 'saved' ? '✓ Saved' : 'Save changes'}
</button>
{saveStatus === 'error' && (
<span style={{ fontSize: '0.8125rem', color: '#EF4444' }}>Save failed try again</span>
)}
</div>
)}
{!isOwnerOrDev && (
<span style={{ fontSize: '0.75rem', color: '#71717A' }}>Only Designers, Developers, and Owners can edit project settings.</span>
)}
</div>
{/* Danger zone — owners only */}
{memberRole === 'OWNER' && (
<div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#EF4444', margin: '0 0 8px' }}>
Danger zone
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', margin: '0 0 16px', lineHeight: 1.6 }}>
Deleting this project is permanent. All artboards, diffs, and origins will be removed. To confirm,
type the project name below.
</p>
<label style={{ ...LABEL, color: '#EF4444' }} htmlFor="proj-delete-confirm">
Type <strong>{initialName}</strong> to confirm
</label>
<div style={{ display: 'flex', gap: 10 }}>
<input
id="proj-delete-confirm"
style={INPUT}
value={deleteConfirm}
onChange={e => setDeleteConfirm(e.target.value)}
placeholder={initialName}
/>
<button
onClick={() => void deleteProject()}
disabled={deleteConfirm.trim() !== initialName.trim() || deleting}
style={{
fontSize: '0.875rem', fontWeight: 600, padding: '9px 20px', borderRadius: 9,
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
color: '#EF4444', cursor: deleteConfirm.trim() === initialName.trim() ? 'pointer' : 'not-allowed',
flexShrink: 0,
opacity: deleteConfirm.trim() !== name.trim() || deleting ? 0.4 : 1,
}}
>
{deleting ? 'Deleting…' : 'Delete project'}
</button>
</div>
</div>
)}
</>
);
}
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
interface WorkspaceSettingsFormProps { interface WorkspaceSettingsFormProps {
@@ -46,6 +46,110 @@ const BTN_PRIMARY: React.CSSProperties = {
border: 'none', cursor: 'pointer', letterSpacing: '-0.01em', border: 'none', cursor: 'pointer', letterSpacing: '-0.01em',
}; };
// Matches TeamRoleSchema in @originmain/origin-graph — keep in sync.
type TeamRole = 'OWNER' | 'DESIGNER' | 'ENGINEER' | 'PM' | 'VIEWER';
function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
const [userId, setUserId] = useState('');
const [role, setRole] = useState<TeamRole>('DESIGNER');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'conflict' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
const resetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Clear any pending reset timers on unmount to avoid setState-after-unmount.
useEffect(() => { return () => { clearTimeout(resetTimer.current); }; }, []);
const submit = useCallback(async () => {
const trimmed = userId.trim();
if (!trimmed) return;
clearTimeout(resetTimer.current);
setStatus('loading');
setErrorMsg('');
try {
const res = await fetch(`/api/workspace/${workspaceId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: trimmed, role }),
});
if (res.status === 409) { setStatus('conflict'); return; }
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
setStatus('done');
setUserId('');
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]);
const roles: TeamRole[] = ['DESIGNER', 'ENGINEER', 'PM', 'VIEWER', 'OWNER'];
return (
<div>
{/* Role picker */}
<label style={LABEL}>Role</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{roles.map(r => (
<button
key={r}
onClick={() => setRole(r)}
style={{
fontSize: '0.75rem', fontWeight: 600, padding: '5px 13px', borderRadius: 8,
border: `1px solid ${role === r ? '#0066FF' : 'rgba(0,0,0,0.12)'}`,
background: role === r ? 'rgba(0,102,255,0.08)' : '#FFFFFF',
color: role === r ? '#0066FF' : '#52525B',
cursor: 'pointer',
}}
>
{r.charAt(0) + r.slice(1).toLowerCase()}
</button>
))}
</div>
{/* User ID input + submit */}
<label style={LABEL} htmlFor="invite-uid">Clerk user ID</label>
<div style={{ display: 'flex', gap: 10 }}>
<input
id="invite-uid"
style={INPUT}
placeholder="user_2abc…"
value={userId}
onChange={e => setUserId(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void submit(); }}
disabled={status === 'loading'}
/>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !userId.trim()}
style={{
...BTN_PRIMARY,
flexShrink: 0,
opacity: (status === 'loading' || !userId.trim()) ? 0.5 : 1,
transition: 'opacity 0.15s',
}}
>
{status === 'loading' ? 'Inviting…' : 'Invite'}
</button>
</div>
{/* Feedback */}
{status === 'done' && (
<p style={{ fontSize: '0.8125rem', color: '#10B981', marginTop: 8 }}> Member added successfully</p>
)}
{status === 'conflict' && (
<p style={{ fontSize: '0.8125rem', color: '#F59E0B', marginTop: 8 }}>User is already a member of this workspace</p>
)}
{status === 'error' && (
<p style={{ fontSize: '0.8125rem', color: '#EF4444', marginTop: 8 }}>{errorMsg || 'Invite failed — try again'}</p>
)}
</div>
);
}
export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }: WorkspaceSettingsFormProps) { export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }: WorkspaceSettingsFormProps) {
const router = useRouter(); const router = useRouter();
const isOwner = memberRole === 'OWNER'; const isOwner = memberRole === 'OWNER';
@@ -53,9 +157,13 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
/* ── Rename ── */ /* ── Rename ── */
const [name, setName] = useState(workspaceName); const [name, setName] = useState(workspaceName);
const [renameStatus, setRenameStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [renameStatus, setRenameStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const renameTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { return () => { clearTimeout(renameTimer.current); }; }, []);
async function saveName() { async function saveName() {
if (!name.trim() || name.trim() === workspaceName) return; if (!name.trim() || name.trim() === workspaceName) return;
clearTimeout(renameTimer.current);
setRenameStatus('saving'); setRenameStatus('saving');
try { try {
const res = await fetch(`/api/workspace/${workspaceId}`, { const res = await fetch(`/api/workspace/${workspaceId}`, {
@@ -66,10 +174,10 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
if (!res.ok) throw new Error('Rename failed'); if (!res.ok) throw new Error('Rename failed');
setRenameStatus('saved'); setRenameStatus('saved');
router.refresh(); router.refresh();
setTimeout(() => setRenameStatus('idle'), 2000); renameTimer.current = setTimeout(() => setRenameStatus('idle'), 2000);
} catch { } catch {
setRenameStatus('error'); setRenameStatus('error');
setTimeout(() => setRenameStatus('idle'), 3000); renameTimer.current = setTimeout(() => setRenameStatus('idle'), 3000);
} }
} }
@@ -241,6 +349,20 @@ export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }
)} )}
</div> </div>
{/* Team */}
{isOwner && (
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#0A0A0A', margin: '0 0 6px' }}>
Team
</h2>
<p style={{ fontSize: '0.8125rem', color: '#71717A', 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.
</p>
<TeamInviteForm workspaceId={workspaceId} />
</div>
)}
{/* Danger zone */} {/* Danger zone */}
{isOwner && ( {isOwner && (
<div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}> <div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}>
+8
View File
@@ -22,6 +22,10 @@ interface CanvasStore {
liveArtboardIds: Set<string>; liveArtboardIds: Set<string>;
setArtboardLive: (id: string, live: boolean) => void; setArtboardLive: (id: string, live: boolean) => void;
// ── Fiber tree cache (per artboard, updated on each FIBER_TREE_UPDATE) ─────
artboardFiberRoots: Record<string, FiberNode>;
setFiberRoot: (artboardId: string, root: FiberNode) => void;
// ── Component selection (from SelectionOverlay / fiber tree) ─────────────── // ── Component selection (from SelectionOverlay / fiber tree) ───────────────
selectedComponentId: string | null; selectedComponentId: string | null;
selectedComponentData: FiberNode | null; selectedComponentData: FiberNode | null;
@@ -48,6 +52,10 @@ export const useCanvas = create<CanvasStore>((set) => ({
return { liveArtboardIds: next }; return { liveArtboardIds: next };
}), }),
artboardFiberRoots: {},
setFiberRoot: (artboardId, root) =>
set((state) => ({ artboardFiberRoots: { ...state.artboardFiberRoots, [artboardId]: root } })),
selectedComponentId: null, selectedComponentId: null,
selectedComponentData: null, selectedComponentData: null,
selectComponent: (id, data) => set({ selectedComponentId: id, selectedComponentData: data }), selectComponent: (id, data) => set({ selectedComponentId: id, selectedComponentData: data }),
+3
View File
@@ -8,6 +8,8 @@ interface ViewportState {
setPan: (x: number, y: number) => void; setPan: (x: number, y: number) => void;
setZoom: (zoom: number, originX?: number, originY?: number) => void; setZoom: (zoom: number, originX?: number, originY?: number) => void;
reset: () => void; reset: () => void;
/** Restore from a saved snapshot (used for per-workspace persistence). */
restore: (data: { panX: number; panY: number; zoom: number }) => void;
} }
export const useViewport = create<ViewportState>()( export const useViewport = create<ViewportState>()(
@@ -31,6 +33,7 @@ export const useViewport = create<ViewportState>()(
}, },
reset: () => set({ panX: 0, panY: 0, zoom: 1 }), reset: () => set({ panX: 0, panY: 0, zoom: 1 }),
restore: (data) => set(data),
}), }),
{ {
name: 'originmain:viewport', name: 'originmain:viewport',
File diff suppressed because one or more lines are too long
@@ -0,0 +1,267 @@
import { describe, it, expect } from 'vitest';
import {
WorkspaceSchema,
ArtboardSchema,
OriginSchema,
IntentDiffSchema,
TeamMemberSchema,
ProjectSchema,
AgentSessionSchema,
OriginTypeSchema,
DiffStatusSchema,
TeamRoleSchema,
AgentTypeSchema,
WorkspacePlanSchema,
} from '../src/types.js';
// ── Timestamp helpers ──────────────────────────────────────────────────────────
const NOW = new Date().toISOString();
const UUID = '550e8400-e29b-41d4-a716-446655440000';
// ── Enum schemas ───────────────────────────────────────────────────────────────
describe('OriginTypeSchema', () => {
it.each(['GIT_COMMIT', 'LINEAR_ISSUE', 'SLACK_MESSAGE', 'URL', 'FORK'] as const)(
'accepts "%s"',
(v) => expect(OriginTypeSchema.parse(v)).toBe(v),
);
it('rejects unknown type', () => {
expect(() => OriginTypeSchema.parse('JIRA_ISSUE')).toThrow();
});
});
describe('DiffStatusSchema', () => {
it.each(['DRAFT', 'EXPORTED', 'IMPLEMENTED', 'BLOCKED'] as const)(
'accepts "%s"',
(v) => expect(DiffStatusSchema.parse(v)).toBe(v),
);
it('rejects unknown status', () => {
expect(() => DiffStatusSchema.parse('PENDING')).toThrow();
});
});
describe('TeamRoleSchema', () => {
it.each(['OWNER', 'DESIGNER', 'ENGINEER', 'PM', 'VIEWER'] as const)(
'accepts "%s"',
(v) => expect(TeamRoleSchema.parse(v)).toBe(v),
);
});
describe('AgentTypeSchema', () => {
it.each(['CURSOR', 'CLAUDE_CODE', 'GENERIC'] as const)(
'accepts "%s"',
(v) => expect(AgentTypeSchema.parse(v)).toBe(v),
);
});
describe('WorkspacePlanSchema', () => {
it.each(['FREE', 'TEAM', 'ENTERPRISE'] as const)(
'accepts "%s"',
(v) => expect(WorkspacePlanSchema.parse(v)).toBe(v),
);
});
// ── Row schemas ────────────────────────────────────────────────────────────────
describe('WorkspaceSchema', () => {
const valid = {
id: UUID,
name: 'Acme Corp',
owner_id: 'user_abc123',
plan: 'FREE',
settings_jsonb: {},
created_at: NOW,
updated_at: NOW,
};
it('parses a valid workspace', () => {
const ws = WorkspaceSchema.parse(valid);
expect(ws.name).toBe('Acme Corp');
expect(ws.plan).toBe('FREE');
});
it('rejects missing required field', () => {
const { name: _, ...noName } = valid;
expect(() => WorkspaceSchema.parse(noName)).toThrow();
});
it('rejects invalid UUID for id', () => {
expect(() => WorkspaceSchema.parse({ ...valid, id: 'not-a-uuid' })).toThrow();
});
it('rejects invalid plan', () => {
expect(() => WorkspaceSchema.parse({ ...valid, plan: 'STARTUP' })).toThrow();
});
it('rejects invalid datetime format', () => {
expect(() => WorkspaceSchema.parse({ ...valid, created_at: '2024/01/01' })).toThrow();
});
});
describe('ArtboardSchema', () => {
const valid = {
id: UUID,
workspace_id: UUID,
project_id: UUID,
name: 'Dashboard.tsx',
origin_id: UUID,
parent_artboard_id: null,
metadata_jsonb: { x: 100, y: 200, width: 360, height: 240 },
created_at: NOW,
updated_at: NOW,
};
it('parses a valid artboard', () => {
const ab = ArtboardSchema.parse(valid);
expect(ab.name).toBe('Dashboard.tsx');
expect(ab.parent_artboard_id).toBeNull();
});
it('accepts null project_id', () => {
const ab = ArtboardSchema.parse({ ...valid, project_id: null });
expect(ab.project_id).toBeNull();
});
it('accepts null origin_id', () => {
const ab = ArtboardSchema.parse({ ...valid, origin_id: null });
expect(ab.origin_id).toBeNull();
});
it('accepts nested metadata_jsonb values', () => {
const meta = { x: 0, y: 0, width: 800, height: 600, renderUrl: 'http://localhost:3000' };
const ab = ArtboardSchema.parse({ ...valid, metadata_jsonb: meta });
expect(ab.metadata_jsonb['renderUrl']).toBe('http://localhost:3000');
});
});
describe('OriginSchema', () => {
const valid = {
id: UUID,
type: 'GIT_COMMIT',
source_ref: 'abc123def456',
source_metadata_jsonb: { repo: 'originmain/app', branch: 'main' },
created_at: NOW,
updated_at: NOW,
};
it('parses a valid origin', () => {
const o = OriginSchema.parse(valid);
expect(o.type).toBe('GIT_COMMIT');
});
it('rejects unknown origin type', () => {
expect(() => OriginSchema.parse({ ...valid, type: 'NOTION_PAGE' })).toThrow();
});
});
describe('IntentDiffSchema', () => {
const valid = {
id: UUID,
artboard_id: UUID,
author_id: 'user_xyz',
changes_jsonb: { propChanges: [], styleChanges: [] },
summary: 'Updated button variant',
status: 'DRAFT',
created_at: NOW,
updated_at: NOW,
};
it('parses a valid diff', () => {
const d = IntentDiffSchema.parse(valid);
expect(d.status).toBe('DRAFT');
expect(d.notes).toBeUndefined();
});
it('accepts optional notes field', () => {
const d = IntentDiffSchema.parse({ ...valid, notes: 'Blocked by missing token' });
expect(d.notes).toBe('Blocked by missing token');
});
it('rejects invalid status', () => {
expect(() => IntentDiffSchema.parse({ ...valid, status: 'PENDING' })).toThrow();
});
});
describe('TeamMemberSchema', () => {
const valid = {
id: UUID,
workspace_id: UUID,
user_id: 'user_abc',
role: 'DESIGNER',
created_at: NOW,
updated_at: NOW,
};
it('parses a valid team member', () => {
const m = TeamMemberSchema.parse(valid);
expect(m.role).toBe('DESIGNER');
});
it('rejects invalid role', () => {
expect(() => TeamMemberSchema.parse({ ...valid, role: 'INTERN' })).toThrow();
});
});
describe('ProjectSchema', () => {
const valid = {
id: UUID,
workspace_id: UUID,
name: 'Design System',
description: 'Core component library',
app_url: 'https://ds.example.com',
framework: 'Next.js',
created_at: NOW,
updated_at: NOW,
};
it('parses a valid project', () => {
const p = ProjectSchema.parse(valid);
expect(p.name).toBe('Design System');
expect(p.framework).toBe('Next.js');
});
it('accepts null nullable fields', () => {
const p = ProjectSchema.parse({ ...valid, description: null, app_url: null, framework: null });
expect(p.description).toBeNull();
expect(p.app_url).toBeNull();
expect(p.framework).toBeNull();
});
it('rejects missing name', () => {
const { name: _, ...noName } = valid;
expect(() => ProjectSchema.parse(noName)).toThrow();
});
});
describe('AgentSessionSchema', () => {
const valid = {
id: UUID,
artboard_id: UUID,
diff_id: UUID,
agent_type: 'CLAUDE_CODE',
messages_jsonb: [{ role: 'user', content: 'hello' }],
status: 'ACTIVE',
created_at: NOW,
updated_at: NOW,
};
it('parses a valid session', () => {
const s = AgentSessionSchema.parse(valid);
expect(s.agent_type).toBe('CLAUDE_CODE');
expect(s.status).toBe('ACTIVE');
});
it('accepts null diff_id', () => {
const s = AgentSessionSchema.parse({ ...valid, diff_id: null });
expect(s.diff_id).toBeNull();
});
it('rejects invalid agent type', () => {
expect(() => AgentSessionSchema.parse({ ...valid, agent_type: 'COPILOT' })).toThrow();
});
it('rejects invalid status', () => {
expect(() => AgentSessionSchema.parse({ ...valid, status: 'PENDING' })).toThrow();
});
});
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['__tests__/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts', 'src/queries.ts'],
thresholds: { lines: 80, functions: 80, branches: 75 },
},
},
});
+31 -2
View File
@@ -52,8 +52,8 @@ importers:
packages/ai-layer: packages/ai-layer:
dependencies: dependencies:
'@anthropic-ai/sdk': '@anthropic-ai/sdk':
specifier: ^0.56.0 specifier: ^0.91.1
version: 0.56.0 version: 0.91.1(zod@3.25.76)
'@originmain/design-language': '@originmain/design-language':
specifier: workspace:* specifier: workspace:*
version: link:../design-language version: link:../design-language
@@ -296,6 +296,15 @@ packages:
resolution: {integrity: sha512-SLCB8M8+VMg1cpCucnA1XWHGWqVSZtIWzmOdDOEu3eTFZMB+A0sGZ1ESO5MHDnqrNTXz3safMrWx9x4rMZSOqA==} resolution: {integrity: sha512-SLCB8M8+VMg1cpCucnA1XWHGWqVSZtIWzmOdDOEu3eTFZMB+A0sGZ1ESO5MHDnqrNTXz3safMrWx9x4rMZSOqA==}
hasBin: true hasBin: true
'@anthropic-ai/sdk@0.91.1':
resolution: {integrity: sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@babel/helper-string-parser@7.27.1': '@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -2080,6 +2089,10 @@ packages:
json-buffer@3.0.1: json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-traverse@0.4.1: json-schema-traverse@0.4.1:
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
@@ -2429,6 +2442,9 @@ packages:
trim-lines@3.0.1: trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
ts-api-utils@2.5.0: ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
@@ -2611,6 +2627,12 @@ snapshots:
'@anthropic-ai/sdk@0.56.0': {} '@anthropic-ai/sdk@0.56.0': {}
'@anthropic-ai/sdk@0.91.1(zod@3.25.76)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 3.25.76
'@babel/helper-string-parser@7.27.1': {} '@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {} '@babel/helper-validator-identifier@7.28.5': {}
@@ -4903,6 +4925,11 @@ snapshots:
json-buffer@3.0.1: {} json-buffer@3.0.1: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.29.2
ts-algebra: 2.0.0
json-schema-traverse@0.4.1: {} json-schema-traverse@0.4.1: {}
json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify-without-jsonify@1.0.1: {}
@@ -5291,6 +5318,8 @@ snapshots:
trim-lines@3.0.1: {} trim-lines@3.0.1: {}
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@5.9.3): ts-api-utils@2.5.0(typescript@5.9.3):
dependencies: dependencies:
typescript: 5.9.3 typescript: 5.9.3