improved a lot of things
This commit is contained in:
@@ -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
@@ -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*
|
|
||||||
|
|||||||
@@ -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() };
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
|
|||||||
@@ -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)' }}>
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Generated
+31
-2
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user