From 97315846be0605186149aaa3cdf2c8fadfc170e3 Mon Sep 17 00:00:00 2001 From: SinachPat Date: Sun, 26 Apr 2026 09:09:55 +0100 Subject: [PATCH] improved a lot of things --- .claude/settings.local.json | 19 +- HANDOFF.md | 251 +++++++++++++ marketing/index.html | 124 +++++++ packages/agent-bridge/package.json | 1 + .../agent-bridge/src/adapters/claude-code.ts | 70 ++++ packages/agent-bridge/src/adapters/cursor.ts | 70 ++++ packages/agent-bridge/src/auth.ts | 68 ++++ packages/agent-bridge/src/index.ts | 18 +- packages/agent-bridge/src/protocol.ts | 69 ++++ packages/agent-bridge/src/rate-limiter.ts | 52 +++ packages/agent-bridge/src/tools.ts | 216 +++++++++++ packages/agent-bridge/tsconfig.json | 3 +- packages/ai-layer/package.json | 2 + packages/ai-layer/src/client.ts | 20 ++ packages/ai-layer/src/features/agent-qa.ts | 61 ++++ .../ai-layer/src/features/artboard-query.ts | 53 +++ .../ai-layer/src/features/completion-zone.ts | 76 ++++ .../ai-layer/src/features/diff-summary.ts | 34 ++ .../ai-layer/src/features/drift-report.ts | 73 ++++ packages/ai-layer/src/gateway.ts | 143 ++++++++ packages/ai-layer/src/index.ts | 19 +- packages/ai-layer/src/prompts/system.ts | 31 ++ packages/app/package.json | 1 + packages/app/public/marketing.html | 124 +++++++ packages/app/src/app/layout.tsx | 14 +- .../src/components/canvas/LiveArtboard.tsx | 117 ++++++ .../components/canvas/SelectionOverlay.tsx | 337 ++++++++++++++++++ .../components/codebase/CodebaseFileTree.tsx | 80 +++++ .../app/src/components/diff/CodeDiffPanel.tsx | 81 +++++ .../src/components/navigator/ArtboardTree.tsx | 123 +++++++ packages/app/src/middleware.ts | 12 + packages/app/src/store/history.ts | 122 +++++++ packages/app/tsconfig.tsbuildinfo | 2 +- packages/design-language/package.json | 21 ++ packages/design-language/src/index.ts | 3 + packages/design-language/src/schema.ts | 111 ++++++ packages/design-language/src/tokens.ts | 54 +++ packages/design-language/src/validator.ts | 89 +++++ packages/design-language/tsconfig.json | 8 + .../integrations/src/connectors/github.ts | 84 +++++ .../integrations/src/connectors/intercom.ts | 109 ++++++ .../integrations/src/connectors/linear.ts | 65 ++++ packages/integrations/src/connectors/slack.ts | 74 ++++ packages/integrations/src/index.ts | 15 +- packages/integrations/src/types.ts | 28 ++ packages/multiplayer/package.json | 27 ++ packages/multiplayer/src/adapter.ts | 89 +++++ packages/multiplayer/src/cursor-colors.ts | 30 ++ packages/multiplayer/src/index.ts | 14 + packages/multiplayer/src/room-schema.ts | 93 +++++ packages/multiplayer/tsconfig.json | 8 + .../migrations/001_initial_schema.sql | 174 +++++++++ .../migrations/002_artboard_ancestry.sql | 46 +++ .../migrations/003_rls_policies.sql | 111 ++++++ .../migrations/004_add_notes_to_diffs.sql | 6 + packages/origin-graph/src/index.ts | 3 +- packages/origin-graph/src/queries.ts | 200 +++++++++++ packages/origin-graph/src/types.ts | 135 +++++++ packages/platform/package.json | 20 ++ packages/platform/src/enterprise.ts | 123 +++++++ packages/platform/src/index.ts | 38 ++ packages/platform/src/plugin-api.ts | 121 +++++++ packages/platform/src/theming.ts | 89 +++++ packages/platform/tsconfig.json | 8 + packages/renderer/src/fiber-hook.ts | 114 ++++++ packages/renderer/src/index.ts | 27 +- packages/renderer/src/module-federation.ts | 62 ++++ packages/renderer/src/protocol.ts | 87 +++++ pnpm-lock.yaml | 200 +++++++++++ tsconfig.base.json | 5 +- 70 files changed, 4967 insertions(+), 10 deletions(-) create mode 100644 HANDOFF.md create mode 100644 packages/agent-bridge/src/adapters/claude-code.ts create mode 100644 packages/agent-bridge/src/adapters/cursor.ts create mode 100644 packages/agent-bridge/src/auth.ts create mode 100644 packages/agent-bridge/src/protocol.ts create mode 100644 packages/agent-bridge/src/rate-limiter.ts create mode 100644 packages/agent-bridge/src/tools.ts create mode 100644 packages/ai-layer/src/client.ts create mode 100644 packages/ai-layer/src/features/agent-qa.ts create mode 100644 packages/ai-layer/src/features/artboard-query.ts create mode 100644 packages/ai-layer/src/features/completion-zone.ts create mode 100644 packages/ai-layer/src/features/diff-summary.ts create mode 100644 packages/ai-layer/src/features/drift-report.ts create mode 100644 packages/ai-layer/src/gateway.ts create mode 100644 packages/ai-layer/src/prompts/system.ts create mode 100644 packages/app/src/components/canvas/LiveArtboard.tsx create mode 100644 packages/app/src/components/canvas/SelectionOverlay.tsx create mode 100644 packages/app/src/components/codebase/CodebaseFileTree.tsx create mode 100644 packages/app/src/components/diff/CodeDiffPanel.tsx create mode 100644 packages/app/src/components/navigator/ArtboardTree.tsx create mode 100644 packages/app/src/middleware.ts create mode 100644 packages/app/src/store/history.ts create mode 100644 packages/design-language/package.json create mode 100644 packages/design-language/src/index.ts create mode 100644 packages/design-language/src/schema.ts create mode 100644 packages/design-language/src/tokens.ts create mode 100644 packages/design-language/src/validator.ts create mode 100644 packages/design-language/tsconfig.json create mode 100644 packages/integrations/src/connectors/github.ts create mode 100644 packages/integrations/src/connectors/intercom.ts create mode 100644 packages/integrations/src/connectors/linear.ts create mode 100644 packages/integrations/src/connectors/slack.ts create mode 100644 packages/integrations/src/types.ts create mode 100644 packages/multiplayer/package.json create mode 100644 packages/multiplayer/src/adapter.ts create mode 100644 packages/multiplayer/src/cursor-colors.ts create mode 100644 packages/multiplayer/src/index.ts create mode 100644 packages/multiplayer/src/room-schema.ts create mode 100644 packages/multiplayer/tsconfig.json create mode 100644 packages/origin-graph/migrations/001_initial_schema.sql create mode 100644 packages/origin-graph/migrations/002_artboard_ancestry.sql create mode 100644 packages/origin-graph/migrations/003_rls_policies.sql create mode 100644 packages/origin-graph/migrations/004_add_notes_to_diffs.sql create mode 100644 packages/origin-graph/src/queries.ts create mode 100644 packages/origin-graph/src/types.ts create mode 100644 packages/platform/package.json create mode 100644 packages/platform/src/enterprise.ts create mode 100644 packages/platform/src/index.ts create mode 100644 packages/platform/src/plugin-api.ts create mode 100644 packages/platform/src/theming.ts create mode 100644 packages/platform/tsconfig.json create mode 100644 packages/renderer/src/fiber-hook.ts create mode 100644 packages/renderer/src/module-federation.ts create mode 100644 packages/renderer/src/protocol.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d3135af..6054104 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -26,7 +26,24 @@ "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({k:d.get\\(k\\) for k in ['description','main','types','exports','peerDependencies','keywords']}, indent=2\\)\\)\")", "Bash(pnpm --filter @originmain/diff-engine test)", "Bash(npx tsc *)", - "Bash(npm test *)" + "Bash(npm test *)", + "Skill(claude-api)", + "Skill(claude-api:*)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\({**d.get\\('dependencies',{}\\), **d.get\\('devDependencies',{}\\)}, indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('exports', d.get\\('main', ''\\)\\), indent=2\\)\\)\")", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(d['version']\\)\")", + "Bash(node_modules/.bin/tsc --noEmit)", + "Bash(echo \"exit: $?\")", + "Bash(echo \"agent-bridge: $?\")", + "Bash(echo \"integrations: $?\")", + "Bash(echo \"agent-bridge exit: $?\")", + "Bash(echo \"integrations exit: $?\")", + "Bash(echo \"platform exit: $?\")", + "Bash(node -e \"const c = require\\('./node_modules/.pnpm/@clerk+nextjs@7.2.7_next@15.3.1_react-dom@19.2.5_react@19.2.5__react@19.2.5__react@19.2.5_react-dom@19.2.5_react@19.2.5__react@19.2.5/node_modules/@clerk/nextjs/dist/cjs/index.js'\\); console.log\\(Object.keys\\(c\\).filter\\(k => /show|signed/i.test\\(k\\)\\)\\)\")", + "Bash(echo \"port 3000 status: $?\")", + "Bash(curl -s http://localhost:3002)", + "Bash(xargs ps *)", + "Bash(curl -s http://localhost:3000 -L)" ] } } diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..820df9c --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,251 @@ +# Originmain — Implementation Handoff + +> Living document. Updated after each layer is completed or substantially advanced. +> Reference: `Originmain-Implementation-Guide-v1_0.md` (v1.0, April 2026) + +--- + +## Layer Status Summary + +| Layer | Name | Status | Notes | +|-------|-----------------------------|--------------|------------------------------------------------------------------| +| 0 | Infrastructure & DevOps | ⏳ Deferred | Skipped for now per plan | +| 1 | Canvas UI Shell | ✅ Complete | ArtboardTree, CodebaseFileTree, CodeDiffPanel | +| 2 | Live Rendering Engine | ✅ Complete | postMessage protocol, fiber hook, MF config, LiveArtboard iframe | +| 3 | Visual Editing & Diff Engine| ✅ Complete | Diff engine (49 tests), SelectionOverlay, history store | +| 4 | Origin Graph & Data Store | ✅ Complete | 3 migrations, RLS, Zod types, query helpers | +| 5 | Design Language Runtime | ✅ Complete | New package, JSON Schema, Zod validator, token pipeline | +| 6 | AI Completion Layer | ✅ Complete | Gateway, 5 features, prompt caching, Claude Opus 4.7 | +| 7 | Agent Bridge (MCP) | ✅ Complete | 5 tools, HMAC auth, rate limiter, Cursor + Claude Code adapters | +| 8 | Multi-Origin Ingestion | ✅ Complete | OriginIngester interface + 4 connectors | +| 9 | Multiplayer & Presence | ✅ Foundation | Room schema, adapter interface, cursor palette — no app integration yet | +| 10 | Platform & Extensions | ✅ Foundation | Plugin API, enterprise types (SSO/SCIM/audit), white-label theming | + +--- + +## Layer 1 — Canvas UI Shell ✅ + +**Completed:** 2026-04-25 + +### What was built + +#### Diff Engine integration (pre-Layer 1) +- **`packages/diff-engine/src/schemas.ts`** — Zod schemas for `ComponentSnapshot` and `ComponentDiff` +- **`packages/diff-engine/src/engine.ts`** — Core diff computation, Myers LCS, recursive child diffing +- **`packages/diff-engine/src/myers.ts`** — Myers longest-common-subsequence algorithm +- **`packages/diff-engine/src/patch.ts`** — Unified patch generation +- **`packages/diff-engine/src/serialize.ts`** — Snapshot serialization to text +- **49 tests, ~88% coverage, all passing** + +#### Canvas UI Shell components (NEW — additive only) +- **`packages/app/src/components/navigator/ArtboardTree.tsx`** — Fluent 2 `Tree`/`TreeItem`/`TreeItemLayout` for workspace artboard hierarchy +- **`packages/app/src/components/codebase/CodebaseFileTree.tsx`** — `@pierre/trees` standalone codebase file browser (separate from ArtboardNavigator.tsx) +- **`packages/app/src/components/diff/CodeDiffPanel.tsx`** — `@pierre/diffs` `PatchDiff` renderer with unified diff patch string input + +### Critical invariants +- `ArtboardNavigator.tsx` must **never** be reversed or have its `@pierre/trees` FileTree removed +- `ArtboardTree.tsx` and `CodebaseFileTree.tsx` are **separate** components +- All new components are **additive** + +--- + +## Layer 2 — Live Rendering Engine ✅ + +**Completed:** 2026-04-25 + +### Files created +- **`packages/renderer/src/protocol.ts`** — `HostMessage`/`RendererMessage` unions, `isHostEnvelope()`, `isRendererEnvelope()`, `createHostEnvelope()`, `createRendererEnvelope()`; source discriminants `'originmain-host'` / `'originmain-renderer'` +- **`packages/renderer/src/fiber-hook.ts`** — `buildFiberHookScript(artboardId)` generates injectable script that patches `__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot`, walks Fiber tree, posts `FIBER_TREE_UPDATE` envelopes to `window.parent` +- **`packages/renderer/src/module-federation.ts`** — `createRendererHostConfig()` and `createRemoteConfig()` MF host config helpers with shared React singleton +- **`packages/renderer/src/index.ts`** — exports all of the above +- **`packages/app/src/components/canvas/LiveArtboard.tsx`** — React iframe component; `sandbox="allow-scripts allow-same-origin allow-forms"`; injects fiber hook on `READY`; sends `SET_DESIGN_TOKENS` on token change + +--- + +## Layer 3 — Visual Editing & Diff Engine ✅ + +**Completed:** 2026-04-25 + +### Files created +- **`packages/app/src/store/history.ts`** — Zustand store: `stacks: Record`, `pushEdit()`, `undo()`, `redo()`, `canUndo()`, `canRedo()`, `clearHistory()`. Max 100 entries per artboard. +- **`packages/app/src/components/canvas/SelectionOverlay.tsx`** — Transparent `cursor: crosshair` layer; `hitTestFiber()` depth-first Fiber tree search by mouse position + `domRect`; `SelectionHandles` corner drag-to-resize; commits `PropChange[]` via `pushEdit()` + +--- + +## Layer 4 — Origin Graph & Data Store ✅ + +**Completed:** 2026-04-25 + +### Files created +- **`packages/origin-graph/migrations/001_initial_schema.sql`** — 5 enum types, 7 tables (workspaces, artboards, origins, intent_diffs, agent_sessions, design_language_files, team_members), FK indexes, `set_updated_at()` trigger +- **`packages/origin-graph/migrations/002_artboard_ancestry.sql`** — Recursive CTE materialized view `artboard_ancestry(artboard_id, ancestor_id, depth)`, unique index for `CONCURRENTLY` refresh, trigger on artboard insert/update +- **`packages/origin-graph/migrations/003_rls_policies.sql`** — `is_workspace_member(ws_id UUID)` SECURITY DEFINER function, SELECT/INSERT/UPDATE/DELETE policies on all 7 tables +- **`packages/origin-graph/src/types.ts`** — Zod schemas for all 7 tables + insert types +- **`packages/origin-graph/src/queries.ts`** — `DbClient` generic interface + 13 query helpers (no hard Supabase dependency) + +--- + +## Layer 5 — Design Language Runtime ✅ + +**Completed:** 2026-04-25 + +### New package: `packages/design-language` + +- **`src/schema.ts`** — `DesignLanguageFileBodySchema` with `tokens`, `components`, `screens`, `voice`, `accessibility` subsections +- **`src/validator.ts`** — `validateDesignLanguageFile()` returns `ValidationSuccess | ValidationFailure`; `checkComponentConstraints()` for real-time per-edit validation +- **`src/tokens.ts`** — `extractColorTokens()`, `tokensToCssVars()`, `dlfToCssVars()`, `dlfSummary()` +- **`tsconfig.base.json`** — path alias added: `"@originmain/design-language": ["./packages/design-language/src/index.ts"]` + +--- + +## Layer 6 — AI Completion Layer ✅ + +**Completed:** 2026-04-25 + +### New package: `packages/ai-layer` + +- **`src/client.ts`** — `getClient(): Anthropic`, `MODEL = 'claude-opus-4-7'` +- **`src/gateway.ts`** — `AIGateway` class: 60 RPM rate limiter, 3 retries on 429/529/5xx, cost tracking (`computeCost()`). Note: `thinking: {type: 'adaptive'}` pending SDK ≥0.58 upgrade +- **`src/prompts/system.ts`** — `buildSystemPrompt({role, dlfJson?})` returns `TextBlockParam[]`; DLF block has `cache_control: {type: 'ephemeral'}` as stable system prompt prefix +- **`src/completion-zone.ts`** — `fillCompletionZone()`: 3-retry JSON validation loop, temp 0.3 +- **`src/diff-summary.ts`** — `generateDiffSummary()`: temp 0.3, 128 tokens max +- **`src/artboard-query.ts`** — `queryCrossArtboard()`: temp 0.3 +- **`src/drift-report.ts`** — `generateDriftReport()`: base64 image, temp 0.3 +- **`src/agent-qa.ts`** — `answerAgentQuestion()`: temp 0.7 + +### 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. + +--- + +## Layer 7 — Agent Bridge (MCP) ✅ + +**Completed:** 2026-04-26 + +### New package: `packages/agent-bridge` + +- **`src/protocol.ts`** — JSON-RPC 2.0 types: `JsonRpcRequest`, `JsonRpcSuccess`, `JsonRpcError`, `MCP_ERROR` constants (`UNAUTHORIZED: -32001`, `RATE_LIMITED: -32002`), `AuthRequest`/`AuthAck` handshake, `textResult()`/`jsonResult()` helpers +- **`src/auth.ts`** — `issueWorkspaceToken(workspaceId, agentType)` HMAC-SHA256 base64url token; `verifyWorkspaceToken(token)` with timing-safe comparison, 30-day TTL; uses Node `crypto` built-in +- **`src/rate-limiter.ts`** — Sliding window: 100 requests/60-min/workspace; in-process Map of timestamp arrays (replace with Redis for multi-instance) +- **`src/tools.ts`** — `ToolContext` interface (db + ai adapters), `McpTool` interface, 5 tools: `get_pending_diffs` (rate-guarded), `get_artboard_context`, `ask_design_agent`, `update_diff_status`, `get_design_language`; `TOOLS` array, `TOOL_MAP`, `getToolList()` +- **`src/adapters/cursor.ts`** — `generateCursorConfig()`: generates `.cursorrules` markdown + `cursor_settings.json` MCP server entry with Bearer auth +- **`src/adapters/claude-code.ts`** — `generateClaudeCodeConfig()`: generates `CLAUDE.md` section + `.claude/settings.json` MCP server declaration with SSE transport + +### Notes +- Added `@types/node` devDep + `"types": ["node"]` in tsconfig for `crypto`/`Buffer`/`process` +- `AgentType`: `'CURSOR' | 'CLAUDE_CODE' | 'GENERIC'` + +--- + +## Layer 8 — Multi-Origin Ingestion ✅ + +**Completed:** 2026-04-26 + +### Package: `packages/integrations` + +- **`src/types.ts`** — `OriginIngester` interface: `parsePayload(raw): TPayload` + `ingest(payload): IngestionResult`; `IngestionResult` carries `origin: InsertOrigin`, `artboardTitle`, optional `renderUrl` +- **`src/connectors/linear.ts`** — `linearIngester`: Zod-validates Linear Issue webhook payload; maps to `LINEAR_ISSUE` origin; prefers attachment URL for `renderUrl` +- **`src/connectors/slack.ts`** — `slackIngester`: Zod-validates Slack `message_posted` event; extracts first image attachment; maps to `SLACK_MESSAGE` origin +- **`src/connectors/github.ts`** — `githubIngester`: Zod-validates GitHub `pull_request` webhook (opened/synchronize/reopened/closed); maps to `GIT_COMMIT` origin with PR metadata +- **`src/connectors/intercom.ts`** — `intercomIngester`: Zod-validates Intercom `conversation.user.created` webhook; extracts annotated screenshot attachment; maps to `URL` origin with user context + +### Design note +Each connector validates untrusted webhook JSON at the boundary with Zod (`parsePayload` throws `ZodError` on invalid input). The `origin` output maps directly to `InsertOrigin` from `@originmain/origin-graph` — no `workspace_id` on origins (that scoping lives on the `artboards` table via `origin_id`). + +--- + +## Package Inventory + +| Package | Status | Notes | +|----------------------------|-------------|-------------------------------------------------------------| +| `packages/app` | Active | Next.js 15 app; Layers 1–3 complete | +| `packages/diff-engine` | Complete | 49 tests, ~88% coverage | +| `packages/ui` | Active | Theme + FluentProvider | +| `packages/renderer` | Complete | Layer 2: postMessage protocol, fiber hook, MF config | +| `packages/origin-graph` | Complete | Layer 4: migrations, RLS, Zod types, query helpers | +| `packages/design-language` | Complete | Layer 5: DLF schema, validator, token pipeline | +| `packages/ai-layer` | Complete | Layer 6: gateway, 5 features, prompt library | +| `packages/agent-bridge` | Complete | Layer 7: MCP tools, auth, rate limiter, adapters | +| `packages/integrations` | Complete | Layer 8: OriginIngester + 4 connectors | +| `packages/multiplayer` | Foundation | Layer 9: room schema, MultiplayerAdapter, cursor palette | +| `packages/platform` | Foundation | Layer 10: Plugin API, SSO/SCIM/audit types, theming | +| `packages/e2e` | Not started | Playwright E2E; Phase 1 completion target | + +--- + +## Key Technical Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Artboard tree | Fluent 2 `Tree`/`TreeItem` | ARIA, Fluent token integration, <100s of nodes | +| Codebase file tree | `@pierre/trees` | Virtualization for 5k+ files, git status, search | +| Diff renderer | `@pierre/diffs` `PatchDiff` | Shiki syntax highlight, split/unified modes | +| Canvas state | Zustand | Lightweight, supports undo/redo middleware | +| TS imports in Next.js | `extensionAlias` `.js → .ts` | ESM-compatible workspace packages | +| AI model | Claude Opus 4.7 | Max intelligence for design engineering tasks | +| Prompt caching | DLF as stable system prompt prefix | ~60% input token cost saving per session | +| MCP auth | HMAC-SHA256 workspace token | Stateless, 30-day TTL, timing-safe verify | +| Rate limit | In-process sliding window | Simple; swap for Redis in multi-instance deploy | +| Webhook validation | Zod at connector boundary | `ZodError` → HTTP 400 before any DB write | +| `exactOptionalPropertyTypes` | Conditional spread `...(v !== undefined ? {k: v} : {})` | Required by strict TS config throughout monorepo | + +--- + +## Layer 9 — Multiplayer & Presence (Foundation) ✅ + +**Completed:** 2026-04-26 (Phase 3 foundation only — no Liveblocks app integration per guide) + +### New package: `packages/multiplayer` + +- **`src/room-schema.ts`** — Liveblocks room schema types: + - `UserPresence` — cursor `{x,y}`, `activeArtboardId`, `selectedComponentIds`, `cursorColor`; updated ≤50ms throttle + - `ArtboardStorageObject` — CRDT-backed artboard state (viewport, locked, lastModifiedAt) + - `WorkspaceStorage` — `artboards: Record` + `artboardOrder` + - `LiveblocksRoomTypes` — generic type params for `createRoomContext` + - `RoomEvent` — broadcast events: `ARTBOARD_LOCKED`, `DIFF_EXPORTED`, `COMPLETION_ZONE_ACCEPTED` + - `workspaceRoomId(workspaceId)` — room ID convention: `"workspace:"` +- **`src/adapter.ts`** — `MultiplayerAdapter` interface matching Zustand store API surface (presence reads/writes, artboard storage, subscribe); `createLocalAdapter()` stub for Phase 1/2 +- **`src/cursor-colors.ts`** — `cursorColorForUser(userId)` deterministic 12-colour palette assignment + +### Phase 3 integration note +Actual `createClient` / `createRoomContext` calls go in `packages/app/src/lib/liveblocks.ts` (Phase 3). `@liveblocks/client` and `@liveblocks/react` are declared as optional peer deps — not installed yet. + +--- + +## Layer 10 — Platform & Extensions (Foundation) ✅ + +**Completed:** 2026-04-26 (Phase 4 foundation — types and contracts only) + +### New package: `packages/platform` + +- **`src/plugin-api.ts`** — Plugin API: + - `PluginManifest` — reverse-DNS `id`, `version`, `entryUrl`, `bundleHash`, `permissions[]` + - `PluginPermission` — 7 scopes: `artboards:read`, `artboards:write`, `diffs:read`, `diffs:export`, `completion-zones:register`, `ingesters:register`, `design-language:read` + - `PluginReadAPI` / `PluginWriteAPI` — async APIs exposed to sandboxed plugin via postMessage + - `PluginContext` — `read`, `write` (null unless `artboards:write` granted), `notify()` + - `CustomCompletionZoneDefinition` — custom zone type with `systemPromptAddendum` + `outputSchema` + - `CustomIngesterDefinition` — custom connector `handleWebhook()` interface + - `PluginRegistry` — `list / install / uninstall / setEnabled` per workspace +- **`src/enterprise.ts`** — Enterprise features: + - `SsoConfig` — SAML 2.0: `idpEntityId`, `idpSsoUrl`, `idpCertificate`, `enforced`, `emailDomains`; providers: okta/azure/google-workspace/onelogin/custom + - `ScimUser` / `ScimGroup` — SCIM 2.0 normalized objects with `role` mapped to `TeamRole` + - `AuditLogEntry` — 30 audit actions across auth/workspace/artboard/diff/AI/plugin/DLF/agent-bridge + - `buildAuditEntry()` — convenience constructor with auto-timestamp +- **`src/theming.ts`** — White-label theming: + - `BrandTokens` — `primaryColor`, `secondaryColor`, `logoUrl`, `productName`, `faviconUrl` + - `brandVariantsFromHex(hex)` — derives 16-shade Fluent 2 `BrandVariants` from single hex colour via HSL interpolation (shade10=lightest → shade160=darkest) + +--- + +## 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) +2. **Redis-backed rate limiter** — `packages/agent-bridge/src/rate-limiter.ts` is in-process only; needs Redis for multi-instance MCP server deployment +3. **Layer 0** — Infrastructure (Vercel, Supabase, Render, GitHub Actions) deferred per plan +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` +5. **Layer 10 Phase 4** — Plugin sandbox runtime (iframe + postMessage bridge); SCIM webhook endpoint; SSO provider registration UI; audit log Supabase table + extension +6. **`packages/e2e`** — Playwright E2E tests not yet created + +--- + +*Last updated: 2026-04-26 after Layers 9–10 foundation completion* diff --git a/marketing/index.html b/marketing/index.html index f795c6e..0aa767f 100644 --- a/marketing/index.html +++ b/marketing/index.html @@ -277,6 +277,73 @@ button { font-family: inherit; cursor: pointer; border: none; background: none; .nav-links a:hover { color: var(--fg); } .nav-actions { display: flex; align-items: center; gap: 8px; } +/* ── Hamburger ── */ +.nav-burger { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 5px; + width: 36px; + height: 36px; + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: var(--r-2); + transition: background 0.12s; + flex-shrink: 0; +} +.nav-burger:hover { background: var(--bg-muted); } +.nav-burger span { + display: block; + width: 20px; + height: 2px; + background: var(--fg); + border-radius: 2px; + transition: transform 0.22s cubic-bezier(0.16,1,0.3,1), opacity 0.18s; + transform-origin: center; +} +.nav-burger.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); } +.nav-burger.open span:nth-child(2) { opacity: 0; transform: scaleX(0); } +.nav-burger.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } + +/* ── Mobile drawer ── */ +.nav-mobile { + display: none; + position: absolute; + top: 60px; left: 0; right: 0; + background: rgba(255,255,255,0.97); + backdrop-filter: blur(20px) saturate(1.6); + -webkit-backdrop-filter: blur(20px) saturate(1.6); + border-bottom: 1px solid var(--border); + box-shadow: 0 8px 32px rgba(0,0,0,0.07); + padding: 12px 24px 24px; + flex-direction: column; + gap: 2px; +} +.nav-mobile.open { display: flex; } +.nav-mobile ul { list-style: none; display: flex; flex-direction: column; } +.nav-mobile ul li a { + display: block; + font-size: 1rem; + font-weight: 500; + color: var(--fg-2); + padding: 11px 4px; + border-bottom: 1px solid var(--border); + letter-spacing: -0.01em; + transition: color 0.1s; +} +.nav-mobile ul li:last-child a { border-bottom: none; } +.nav-mobile ul li a:hover { color: var(--fg); } +.nav-mobile-ctas { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; +} +.nav-mobile-ctas .btn { text-align: center; padding: 11px 0; font-size: .9375rem; } + /* ════════════════════════════════════════════════════════════ HERO — Figma Sites: product-forward, massive type ════════════════════════════════════════════════════════════ */ @@ -1257,7 +1324,15 @@ footer { @media (max-width: 768px) { .wrap, .wrap-wide { padding: 0 24px; } #nav { padding: 0 24px; } + .nav-inner { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } .nav-links { display: none; } + .nav-actions .btn { display: none; } + .nav-burger { display: flex; } .section { padding: 80px 0; } .feat-block { padding: 80px 24px; } #hero { padding: 140px 24px 0; } @@ -1295,6 +1370,23 @@ footer { + + + + @@ -1931,6 +2023,38 @@ window.addEventListener('scroll', () => { nav.classList.toggle('scrolled', window.scrollY > 20); }, { passive: true }); +// ── Mobile hamburger ────────────────────────────────────── +const burger = document.getElementById('nav-burger'); +const mobileMenu = document.getElementById('nav-mobile'); + +function toggleMenu(force) { + const open = force !== undefined ? force : !burger.classList.contains('open'); + burger.classList.toggle('open', open); + burger.setAttribute('aria-expanded', String(open)); + mobileMenu.classList.toggle('open', open); + mobileMenu.setAttribute('aria-hidden', String(!open)); + document.body.style.overflow = open ? 'hidden' : ''; +} + +burger.addEventListener('click', () => toggleMenu()); + +// Close on any link click inside the drawer +mobileMenu.querySelectorAll('a').forEach(a => + a.addEventListener('click', () => toggleMenu(false)) +); + +// Close on outside tap +document.addEventListener('click', e => { + if (mobileMenu.classList.contains('open') && !nav.contains(e.target)) { + toggleMenu(false); + } +}); + +// Close on Escape key +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && mobileMenu.classList.contains('open')) toggleMenu(false); +}); + // ── Scroll reveal ───────────────────────────────────────── const revealEls = document.querySelectorAll('.reveal, .stagger'); const revealAll = () => revealEls.forEach(el => el.classList.add('in')); diff --git a/packages/agent-bridge/package.json b/packages/agent-bridge/package.json index 90141a5..5b99788 100644 --- a/packages/agent-bridge/package.json +++ b/packages/agent-bridge/package.json @@ -16,6 +16,7 @@ "zod": "^3.0.0" }, "devDependencies": { + "@types/node": "^22.0.0", "typescript": "^5.5.0" } } diff --git a/packages/agent-bridge/src/adapters/claude-code.ts b/packages/agent-bridge/src/adapters/claude-code.ts new file mode 100644 index 0000000..3b62679 --- /dev/null +++ b/packages/agent-bridge/src/adapters/claude-code.ts @@ -0,0 +1,70 @@ +import { TOOLS } from '../tools.js'; + +// ── Claude Code adapter ─────────────────────────────────────────────────────── +// Generates two artifacts for Claude Code MCP integration: +// 1. CLAUDE.md section — tool reference injected into the project memory +// 2. MCP server declaration — JSON block for .claude/settings.json +// +// Claude Code discovers MCP servers from .claude/settings.json and injects +// CLAUDE.md into every session's system context automatically. + +export interface ClaudeCodeAdapterOptions { + /** MCP WebSocket endpoint URL */ + mcpServerUrl: string; + /** Signed workspace token (from issueWorkspaceToken) */ + workspaceToken: string; + workspaceName: string; + /** Optional project name shown in CLAUDE.md header */ + projectName?: string; +} + +export interface ClaudeCodeAdapterOutput { + claudeMdSection: string; + mcpServerDeclaration: ClaudeCodeMcpServer; +} + +export interface ClaudeCodeMcpServer { + name: string; + type: 'sse' | 'stdio'; + url: string; + headers: Record; +} + +export function generateClaudeCodeConfig(opts: ClaudeCodeAdapterOptions): ClaudeCodeAdapterOutput { + const projectLabel = opts.projectName ?? opts.workspaceName; + const toolLines = TOOLS.map(t => `- \`${t.name}\`: ${t.description}`).join('\n'); + + const claudeMdSection = `## Origin Design Agent Bridge — ${projectLabel} + +This project is connected to an Origin MCP server that exposes design-to-code tools. +The server is pre-configured in \`.claude/settings.json\`. + +### Available MCP Tools +${toolLines} + +### Implementation Workflow +1. \`get_pending_diffs\` → list EXPORTED IntentDiffs that need code changes +2. \`get_artboard_context\` → load component tree, screenshots, and design language +3. Implement the required changes in the codebase +4. \`ask_design_agent\` → clarify design intent when the diff is ambiguous +5. \`update_diff_status\` → mark IMPLEMENTED or BLOCKED with an explanation + +### Design Language Validation +Run \`get_design_language\` at session start to cache the team's active Design Language File. +All token references (colors, typography, spacing) must match the DLF. + +### Rate Limit +100 diff exports/hour per workspace. If you hit the limit, wait before retrying. +`; + + const mcpServerDeclaration: ClaudeCodeMcpServer = { + name: 'origin', + type: 'sse', + url: opts.mcpServerUrl, + headers: { + Authorization: `Bearer ${opts.workspaceToken}`, + }, + }; + + return { claudeMdSection, mcpServerDeclaration }; +} diff --git a/packages/agent-bridge/src/adapters/cursor.ts b/packages/agent-bridge/src/adapters/cursor.ts new file mode 100644 index 0000000..a9c1af0 --- /dev/null +++ b/packages/agent-bridge/src/adapters/cursor.ts @@ -0,0 +1,70 @@ +import { TOOLS } from '../tools.js'; + +// ── Cursor adapter ──────────────────────────────────────────────────────────── +// Generates the two files Cursor reads to discover MCP tools: +// 1. .cursorrules — natural-language context injected into every prompt +// 2. cursor_settings.json — MCP server registration (added to .cursor/settings) +// +// Both are returned as strings; the caller writes them to the workspace root. + +export interface CursorAdapterOptions { + /** MCP WebSocket endpoint URL */ + mcpServerUrl: string; + /** Signed workspace token (from issueWorkspaceToken) */ + workspaceToken: string; + workspaceName: string; +} + +export interface CursorAdapterOutput { + cursorrules: string; + cursorSettings: CursorSettings; +} + +interface McpServerEntry { + url: string; + headers: Record; +} + +interface CursorSettings { + mcpServers: Record; +} + +export function generateCursorConfig(opts: CursorAdapterOptions): CursorAdapterOutput { + const toolDescriptions = TOOLS.map(t => `- **${t.name}**: ${t.description}`).join('\n'); + + const cursorrules = `# Origin — Design-to-Code Agent Bridge +# Workspace: ${opts.workspaceName} +# +# You have access to the following Origin MCP tools via the connected MCP server. +# Use them to fetch design diffs, query artboard context, and report implementation status. +# +## Available Tools +${toolDescriptions} + +## Workflow +1. Call \`get_pending_diffs\` to retrieve EXPORTED IntentDiffs awaiting implementation. +2. Call \`get_artboard_context\` to load component tree, design language, and screenshots. +3. Implement the diff. Use \`ask_design_agent\` if design intent is unclear. +4. Call \`update_diff_status\` with IMPLEMENTED or BLOCKED when done. + +## Design Language +Always validate tokens/colors/spacing against the workspace Design Language File. +Call \`get_design_language\` once per session to cache the active DLF locally. + +## Rate Limits +100 diff exports per hour per workspace. The server returns HTTP 429 when exceeded. +`; + + const cursorSettings: CursorSettings = { + mcpServers: { + origin: { + url: opts.mcpServerUrl, + headers: { + Authorization: `Bearer ${opts.workspaceToken}`, + }, + }, + }, + }; + + return { cursorrules, cursorSettings }; +} diff --git a/packages/agent-bridge/src/auth.ts b/packages/agent-bridge/src/auth.ts new file mode 100644 index 0000000..32b7c9d --- /dev/null +++ b/packages/agent-bridge/src/auth.ts @@ -0,0 +1,68 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +// ── Workspace token ─────────────────────────────────────────────────────────── +// Format: base64(workspaceId:agentType:timestamp:hmac) +// HMAC-SHA256 signed with AGENT_BRIDGE_SECRET (server-side env var). +// Tokens expire after 30 days. + +const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +export type AgentType = 'CURSOR' | 'CLAUDE_CODE' | 'GENERIC'; + +export interface WorkspaceToken { + workspaceId: string; + agentType: AgentType; + issuedAt: number; +} + +function getSecret(): string { + const secret = process.env.AGENT_BRIDGE_SECRET; + if (!secret) throw new Error('AGENT_BRIDGE_SECRET is not set'); + return secret; +} + +function sign(payload: string, secret: string): string { + return createHmac('sha256', secret).update(payload).digest('hex'); +} + +export function issueWorkspaceToken(workspaceId: string, agentType: AgentType): string { + const issuedAt = Date.now(); + const payload = `${workspaceId}:${agentType}:${issuedAt}`; + const hmac = sign(payload, getSecret()); + return Buffer.from(`${payload}:${hmac}`).toString('base64url'); +} + +export function verifyWorkspaceToken(token: string): WorkspaceToken | null { + // Decode base64url — this is the only step that can throw for reasons outside + // our control (malformed input). All other failures are deterministic logic. + let decoded: string; + try { + decoded = Buffer.from(token, 'base64url').toString('utf-8'); + } catch { + return null; + } + + const parts = decoded.split(':'); + if (parts.length !== 4) return null; + + const [workspaceId, agentType, issuedAtStr, providedHmac] = parts; + if (!workspaceId || !agentType || !issuedAtStr || !providedHmac) return null; + + const issuedAt = parseInt(issuedAtStr, 10); + if (isNaN(issuedAt) || Date.now() - issuedAt > TOKEN_TTL_MS) return null; + + const payload = `${workspaceId}:${agentType}:${issuedAtStr}`; + // getSecret() intentionally throws if AGENT_BRIDGE_SECRET is unset — + // that's a server misconfiguration, not an invalid token. + const expectedHmac = sign(payload, getSecret()); + + const expected = Buffer.from(expectedHmac, 'hex'); + const provided = Buffer.from(providedHmac, 'hex'); + if (expected.length !== provided.length) return null; + if (!timingSafeEqual(expected, provided)) return null; + + const validAgentTypes: AgentType[] = ['CURSOR', 'CLAUDE_CODE', 'GENERIC']; + if (!validAgentTypes.includes(agentType as AgentType)) return null; + + return { workspaceId, agentType: agentType as AgentType, issuedAt }; +} diff --git a/packages/agent-bridge/src/index.ts b/packages/agent-bridge/src/index.ts index cb0ff5c..044e677 100644 --- a/packages/agent-bridge/src/index.ts +++ b/packages/agent-bridge/src/index.ts @@ -1 +1,17 @@ -export {}; +export type { JsonRpcRequest, JsonRpcSuccess, JsonRpcError, JsonRpcResponse, AuthRequest, AuthAck, ToolResult } from './protocol.js'; +export { MCP_ERROR, textResult, jsonResult } from './protocol.js'; + +export type { WorkspaceToken, AgentType } from './auth.js'; +export { issueWorkspaceToken, verifyWorkspaceToken } from './auth.js'; + +export type { RateLimitResult } from './rate-limiter.js'; +export { checkRateLimit, getRateLimitStatus } from './rate-limiter.js'; + +export type { McpTool, ToolContext } from './tools.js'; +export { TOOLS, TOOL_MAP, getToolList } from './tools.js'; + +export type { CursorAdapterOptions, CursorAdapterOutput } from './adapters/cursor.js'; +export { generateCursorConfig } from './adapters/cursor.js'; + +export type { ClaudeCodeAdapterOptions, ClaudeCodeAdapterOutput, ClaudeCodeMcpServer } from './adapters/claude-code.js'; +export { generateClaudeCodeConfig } from './adapters/claude-code.js'; diff --git a/packages/agent-bridge/src/protocol.ts b/packages/agent-bridge/src/protocol.ts new file mode 100644 index 0000000..a6654a2 --- /dev/null +++ b/packages/agent-bridge/src/protocol.ts @@ -0,0 +1,69 @@ +// ── MCP / JSON-RPC 2.0 protocol types ──────────────────────────────────────── +// The Agent Bridge implements MCP over JSON-RPC 2.0, transported via WebSocket +// (long-running) or HTTP POST (polling-based agents). + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: string | number; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: string | number; + result: T; +} + +export interface JsonRpcError { + jsonrpc: '2.0'; + id: string | number | null; + error: { + code: number; + message: string; + data?: unknown; + }; +} + +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; + +// ── MCP error codes ─────────────────────────────────────────────────────────── +export const MCP_ERROR = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, + // Application-level codes + UNAUTHORIZED: -32001, + RATE_LIMITED: -32002, + NOT_FOUND: -32003, +} as const; + +// ── Origmain-specific envelope ──────────────────────────────────────────────── +// Every connection must present a signed workspace token in the first message. + +export interface AuthRequest { + type: 'auth'; + workspaceToken: string; +} + +export interface AuthAck { + type: 'auth_ack'; + workspaceId: string; + agentType: 'CURSOR' | 'CLAUDE_CODE' | 'GENERIC'; +} + +// ── Tool result types ───────────────────────────────────────────────────────── + +export interface ToolResult { + content: { type: 'text'; text: string } | { type: 'json'; json: T }; +} + +export function textResult(text: string): ToolResult { + return { content: { type: 'text', text } }; +} + +export function jsonResult(json: T): ToolResult { + return { content: { type: 'json', json } }; +} diff --git a/packages/agent-bridge/src/rate-limiter.ts b/packages/agent-bridge/src/rate-limiter.ts new file mode 100644 index 0000000..c5661d6 --- /dev/null +++ b/packages/agent-bridge/src/rate-limiter.ts @@ -0,0 +1,52 @@ +// ── Per-workspace rate limiter ──────────────────────────────────────────────── +// Enforces: 100 diff exports per hour per workspace. +// Uses a sliding window implemented with an in-process timestamp array. +// For multi-instance deployments, replace with a Redis-backed implementation. + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + resetAt: number; +} + +const WINDOW_MS = 60 * 60 * 1000; // 1 hour +const MAX_REQUESTS = 100; + +const windows = new Map(); + +export function checkRateLimit(workspaceId: string): RateLimitResult { + const now = Date.now(); + const cutoff = now - WINDOW_MS; + + const prev = (windows.get(workspaceId) ?? []).filter(t => t > cutoff); + const allowed = prev.length < MAX_REQUESTS; + + // Only persist the new timestamp when the request is allowed. + // Read the allowed decision from `prev.length` (before push) so concurrent + // synchronous callers in the same event-loop tick all see the same baseline. + if (allowed) { + windows.set(workspaceId, [...prev, now]); + } + + const timestamps = allowed ? [...prev, now] : prev; + const oldest = timestamps[0]; + const resetAt = oldest !== undefined ? oldest + WINDOW_MS : now + WINDOW_MS; + + return { + allowed, + remaining: Math.max(0, MAX_REQUESTS - timestamps.length), + resetAt, + }; +} + +export function getRateLimitStatus(workspaceId: string): RateLimitResult { + const now = Date.now(); + const cutoff = now - WINDOW_MS; + const timestamps = (windows.get(workspaceId) ?? []).filter(t => t > cutoff); + const oldest = timestamps[0]; + return { + allowed: timestamps.length < MAX_REQUESTS, + remaining: Math.max(0, MAX_REQUESTS - timestamps.length), + resetAt: oldest !== undefined ? oldest + WINDOW_MS : now + WINDOW_MS, + }; +} diff --git a/packages/agent-bridge/src/tools.ts b/packages/agent-bridge/src/tools.ts new file mode 100644 index 0000000..2e09bbd --- /dev/null +++ b/packages/agent-bridge/src/tools.ts @@ -0,0 +1,216 @@ +import { z } from 'zod'; +import { checkRateLimit } from './rate-limiter.js'; +import { jsonResult, textResult, MCP_ERROR } from './protocol.js'; +import type { ToolResult, JsonRpcError } from './protocol.js'; +import type { DiffStatus } from '@originmain/origin-graph'; + +// ── Tool context (injected per-connection) ──────────────────────────────────── + +export interface ToolContext { + workspaceId: string; + /** Adapter to the Origin Graph data layer */ + db: { + getDiffsByStatus(workspaceId: string, status: DiffStatus): Promise; + getDiff(id: string): Promise; + getArtboard(id: string): Promise; + getDesignLanguageFile(workspaceId: string): Promise; + updateDiffStatus(id: string, status: DiffStatus, notes?: string): Promise; + }; + /** Adapter to the AI layer (for ask_design_agent) */ + ai: { + answerAgentQuestion(diffId: string, question: string, artboardContext: unknown): Promise; + }; +} + +// ── Tool definition ─────────────────────────────────────────────────────────── + +export interface McpTool { + name: string; + description: string; + inputSchema: z.ZodTypeAny; + execute(params: unknown, ctx: ToolContext): Promise | JsonRpcError>; +} + +// ── Rate-limited wrapper ────────────────────────────────────────────────────── + +function rateGuard(workspaceId: string): JsonRpcError | null { + const { allowed } = checkRateLimit(workspaceId); + if (!allowed) { + return { + jsonrpc: '2.0', + id: null, + error: { code: MCP_ERROR.RATE_LIMITED, message: 'Rate limit exceeded: 100 diff exports per hour per workspace' }, + }; + } + return null; +} + +function notFound(id: string, type: string): JsonRpcError { + return { + jsonrpc: '2.0', + id: null, + error: { code: MCP_ERROR.NOT_FOUND, message: `${type} ${id} not found` }, + }; +} + +// ── Tool: get_pending_diffs ─────────────────────────────────────────────────── + +const GetPendingDiffsInput = z.object({ + workspace_id: z.string().uuid(), + artboard_id: z.string().uuid().optional(), +}); + +const getPendingDiffs: McpTool = { + name: 'get_pending_diffs', + description: 'Returns all IntentDiff objects with EXPORTED status for the workspace.', + inputSchema: GetPendingDiffsInput, + async execute(params, ctx) { + const guard = rateGuard(ctx.workspaceId); + if (guard) return guard; + + const { artboard_id } = GetPendingDiffsInput.parse(params); + const diffs = await ctx.db.getDiffsByStatus(ctx.workspaceId, 'EXPORTED'); + + const filtered = artboard_id + ? (diffs as Array<{ artboard_id: string }>).filter(d => d.artboard_id === artboard_id) + : diffs; + + return jsonResult(filtered); + }, +}; + +// ── Tool: get_artboard_context ──────────────────────────────────────────────── + +const GetArtboardContextInput = z.object({ + artboard_id: z.string().uuid(), +}); + +const getArtboardContext: McpTool = { + name: 'get_artboard_context', + description: 'Returns full artboard metadata, component tree, design language file, and before/after screenshots.', + inputSchema: GetArtboardContextInput, + async execute(params, ctx) { + const { artboard_id } = GetArtboardContextInput.parse(params); + const artboard = await ctx.db.getArtboard(artboard_id); + if (artboard == null) return notFound(artboard_id, 'Artboard'); + const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId); + return jsonResult({ artboard, designLanguageFile: dlf }); + }, +}; + +// ── Tool: ask_design_agent ──────────────────────────────────────────────────── + +const AskDesignAgentInput = z.object({ + diff_id: z.string().uuid(), + question: z.string().min(1).max(2000), +}); + +const askDesignAgent: McpTool = { + name: 'ask_design_agent', + description: 'Ask the design AI agent a question about a specific diff. Returns a Claude-generated answer with visual reference.', + inputSchema: AskDesignAgentInput, + async execute(params, ctx) { + const { diff_id, question } = AskDesignAgentInput.parse(params); + + // Fetch the diff first to resolve its artboard_id, then fetch the artboard. + const diff = await ctx.db.getDiff(diff_id); + if (diff == null) return notFound(diff_id, 'IntentDiff'); + const artboardId = (diff as { artboard_id: string }).artboard_id; + const artboard = await ctx.db.getArtboard(artboardId); + if (artboard == null) return notFound(artboardId, 'Artboard'); + + const answer = await ctx.ai.answerAgentQuestion(diff_id, question, artboard); + return textResult(answer); + }, +}; + +// ── Tool: update_diff_status ────────────────────────────────────────────────── + +const UpdateDiffStatusInput = z.object({ + diff_id: z.string().uuid(), + status: z.enum(['IMPLEMENTED', 'BLOCKED']), + notes: z.string().max(1000).optional(), +}); + +const updateDiffStatus: McpTool = { + name: 'update_diff_status', + description: 'Acknowledges implementation or reports a block. Updates the diff status in the Origin Graph.', + inputSchema: UpdateDiffStatusInput, + async execute(params, ctx) { + const { diff_id, status, notes } = UpdateDiffStatusInput.parse(params); + await ctx.db.updateDiffStatus(diff_id, status, notes); + return textResult(`Diff ${diff_id} status updated to ${status}`); + }, +}; + +// ── Tool: get_design_language ───────────────────────────────────────────────── + +const GetDesignLanguageInput = z.object({ + workspace_id: z.string().uuid(), +}); + +const getDesignLanguage: McpTool = { + name: 'get_design_language', + description: 'Returns the team\'s active Design Language File for local validation by the coding agent.', + inputSchema: GetDesignLanguageInput, + async execute(params, ctx) { + // Validate the input even though we use the connection-scoped workspaceId. + // This ensures MCP clients send well-formed requests and the schema is enforced. + GetDesignLanguageInput.parse(params); + const dlf = await ctx.db.getDesignLanguageFile(ctx.workspaceId); + if (!dlf) return textResult('No design language file configured for this workspace.'); + return jsonResult(dlf); + }, +}; + +// ── Tool registry ───────────────────────────────────────────────────────────── + +export const TOOLS: McpTool[] = [ + getPendingDiffs, + getArtboardContext, + askDesignAgent, + updateDiffStatus, + getDesignLanguage, +]; + +export const TOOL_MAP = new Map(TOOLS.map(t => [t.name, t])); + +/** Returns the MCP-spec JSON Schema listing for all tools. */ +export function getToolList() { + return TOOLS.map(t => ({ + name: t.name, + description: t.description, + inputSchema: { type: 'object', ...zodToJsonSchema(t.inputSchema) }, + })); +} + +// ── Minimal Zod → JSON Schema ───────────────────────────────────────────────── +// Handles the subset of Zod types used by the 5 tools above. +// Throws at startup if an unsupported type is encountered — fail loud, not silent. + +function zodToJsonSchema(schema: z.ZodTypeAny): { properties?: Record; required?: string[] } { + if (schema instanceof z.ZodObject) { + const shape = schema.shape as Record; + const properties: Record = {}; + const required: string[] = []; + + for (const [key, field] of Object.entries(shape)) { + properties[key] = zodFieldToSchema(field); + if (!(field instanceof z.ZodOptional)) { + required.push(key); + } + } + + return { properties, ...(required.length > 0 ? { required } : {}) }; + } + throw new Error(`zodToJsonSchema: unsupported top-level type ${schema.constructor.name}`); +} + +function zodFieldToSchema(field: z.ZodTypeAny): unknown { + if (field instanceof z.ZodOptional) return zodFieldToSchema(field.unwrap()); + if (field instanceof z.ZodString) return { type: 'string' }; + if (field instanceof z.ZodEnum) return { type: 'string', enum: field.options as string[] }; + if (field instanceof z.ZodNumber) return { type: 'number' }; + if (field instanceof z.ZodBoolean) return { type: 'boolean' }; + throw new Error(`zodToJsonSchema: unsupported field type ${field.constructor.name}`); +} diff --git a/packages/agent-bridge/tsconfig.json b/packages/agent-bridge/tsconfig.json index 1ad8c28..1b10195 100644 --- a/packages/agent-bridge/tsconfig.json +++ b/packages/agent-bridge/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "noEmit": true + "noEmit": true, + "types": ["node"] }, "include": ["src"], "exclude": ["node_modules"] diff --git a/packages/ai-layer/package.json b/packages/ai-layer/package.json index c3b51d3..0b23954 100644 --- a/packages/ai-layer/package.json +++ b/packages/ai-layer/package.json @@ -10,6 +10,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@anthropic-ai/sdk": "^0.56.0", + "@originmain/design-language": "workspace:*", "@originmain/diff-engine": "workspace:*", "zod": "^3.0.0" }, diff --git a/packages/ai-layer/src/client.ts b/packages/ai-layer/src/client.ts new file mode 100644 index 0000000..d1dec41 --- /dev/null +++ b/packages/ai-layer/src/client.ts @@ -0,0 +1,20 @@ +import Anthropic from '@anthropic-ai/sdk'; + +// ── Model constants ─────────────────────────────────────────────────────────── + +export const MODEL = 'claude-opus-4-7' as const; + +// ── Singleton client ────────────────────────────────────────────────────────── +// The client is created once and shared. API key is injected from the server +// environment — never exposed to the client bundle. + +let _client: Anthropic | null = null; + +export function getClient(): Anthropic { + if (!_client) { + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set'); + _client = new Anthropic({ apiKey }); + } + return _client; +} diff --git a/packages/ai-layer/src/features/agent-qa.ts b/packages/ai-layer/src/features/agent-qa.ts new file mode 100644 index 0000000..73cb0b0 --- /dev/null +++ b/packages/ai-layer/src/features/agent-qa.ts @@ -0,0 +1,61 @@ +import type { AIGateway } from '../gateway.js'; +import { buildSystemPrompt } from '../prompts/system.js'; + +export interface AgentQAInput { + /** Question from the coding agent (e.g. Cursor or Claude Code) */ + question: string; + /** The diff ID this question is about */ + diffId: string; + /** Full artboard context as JSON */ + artboardContextJson: string; + /** Active DLF */ + dlfJson?: string; +} + +export interface AgentQAOutput { + answer: string; + /** Optional visual reference description (e.g. "see padding token in section 3") */ + visualReference?: string; +} + +export async function answerAgentQuestion( + gateway: AIGateway, + input: AgentQAInput +): Promise { + const system = buildSystemPrompt({ + role: 'a design agent answering questions from a coding agent implementing a design diff', + ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), + }); + + const response = await gateway.complete({ + system, + messages: [ + { + role: 'user', + content: `\n${input.artboardContextJson}\n\n\n${input.diffId}\n\n\n${input.question}\n\n\nAnswer the coding agent's question concisely and precisely. If the answer references a specific design token, component, or visual region, include a "visualReference" field. Return JSON:\n{\n "answer": "...",\n "visualReference": "..." (optional)\n}\n\nRespond ONLY with valid JSON.`, + }, + ], + maxTokens: 1024, + temperature: 0.7, + }); + + let parsed: unknown; + try { + parsed = JSON.parse(response.text); + } catch (err) { + throw new Error( + `Design agent returned unparseable JSON: ${String(err)}. ` + + `Raw response (first 300 chars): ${response.text.slice(0, 300)}` + ); + } + + const output = parsed as AgentQAOutput; + if (!output.answer || typeof output.answer !== 'string') { + throw new Error( + `Design agent response missing required "answer" field. ` + + `Raw response (first 300 chars): ${response.text.slice(0, 300)}` + ); + } + + return output; +} diff --git a/packages/ai-layer/src/features/artboard-query.ts b/packages/ai-layer/src/features/artboard-query.ts new file mode 100644 index 0000000..3f656ce --- /dev/null +++ b/packages/ai-layer/src/features/artboard-query.ts @@ -0,0 +1,53 @@ +import type { AIGateway } from '../gateway.js'; +import { buildSystemPrompt } from '../prompts/system.js'; + +export interface ArtboardQueryInput { + /** Natural language query from the user */ + query: string; + /** Array of artboard metadata objects as JSON */ + artboardsJson: string; +} + +export interface ArtboardQueryResult { + artboardId: string; + relevanceScore: number; + reason: string; +} + +export interface ArtboardQueryOutput { + results: ArtboardQueryResult[]; + reasoning: string; +} + +export async function queryCrossArtboard( + gateway: AIGateway, + input: ArtboardQueryInput +): Promise { + const system = buildSystemPrompt({ role: 'a search agent filtering artboards by design intent' }); + + const response = await gateway.complete({ + system, + messages: [ + { + role: 'user', + content: `\n${input.artboardsJson}\n\n\n\n${input.query}\n\n\nReturn a JSON object:\n{\n "results": [{"artboardId": "...", "relevanceScore": 0-1, "reason": "..."}],\n "reasoning": "brief explanation"\n}\n\nOnly include artboards with relevanceScore > 0.3. Respond ONLY with valid JSON.`, + }, + ], + maxTokens: 1024, + temperature: 0.3, + }); + + // Returning empty results on parse failure is indistinguishable from "no match". + // Throw so the UI can show an error rather than a misleading empty state. + let parsed: unknown; + try { + parsed = JSON.parse(response.text); + } catch (err) { + throw new Error( + `Artboard query returned unparseable JSON: ${String(err)}. ` + + `Raw response (first 300 chars): ${response.text.slice(0, 300)}` + ); + } + + return parsed as ArtboardQueryOutput; +} diff --git a/packages/ai-layer/src/features/completion-zone.ts b/packages/ai-layer/src/features/completion-zone.ts new file mode 100644 index 0000000..53d6f5f --- /dev/null +++ b/packages/ai-layer/src/features/completion-zone.ts @@ -0,0 +1,76 @@ +import type Anthropic from '@anthropic-ai/sdk'; +import { AIGateway } from '../gateway.js'; +import { buildSystemPrompt } from '../prompts/system.js'; +import type { GatewayResponse } from '../gateway.js'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface CompletionZoneInput { + /** JSON of the surrounding component tree */ + componentTreeJson: string; + /** User intent / what the zone should be filled with */ + intent: string; + /** Active Design Language File as JSON string */ + dlfJson?: string; + /** Before screenshot as base64 data URL (optional) */ + screenshotBase64?: string; +} + +export interface CompletionZoneOutput { + /** Proposed component tree changes as structured JSON */ + proposedTree: unknown; + /** Natural language description of the proposed change */ + description: string; + raw: GatewayResponse; +} + +// ── Feature ─────────────────────────────────────────────────────────────────── + +export async function fillCompletionZone( + gateway: AIGateway, + input: CompletionZoneInput, + maxRetries = 3 +): Promise { + const system = buildSystemPrompt({ + role: 'a Completion Zone design agent', + ...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}), + }); + + const userContent: Anthropic.Messages.ContentBlockParam[] = [ + { + type: 'text', + text: `\n${input.componentTreeJson}\n\n\n\n${input.intent}\n\n\nReturn a JSON object with two fields:\n- "proposedTree": the updated component tree matching the intent\n- "description": a one-sentence description of the change\n\nRespond ONLY with valid JSON.`, + }, + ]; + + if (input.screenshotBase64) { + userContent.unshift({ + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: input.screenshotBase64.replace(/^data:image\/\w+;base64,/, '') }, + } as Anthropic.Messages.ImageBlockParam); + } + + // Retry up to maxRetries times on invalid JSON output + let lastError: Error | null = null; + for (let attempt = 0; attempt < maxRetries; attempt++) { + const response = await gateway.complete({ + system, + messages: [{ role: 'user', content: userContent }], + maxTokens: 4096, + temperature: 0.3, + }); + + try { + const parsed = JSON.parse(response.text); + return { proposedTree: parsed.proposedTree as unknown, description: String(parsed.description ?? ''), raw: response }; + } catch (parseErr) { + lastError = new Error( + `AI returned invalid JSON on attempt ${attempt + 1}: ${String(parseErr)}. ` + + `Raw response (first 200 chars): ${response.text.slice(0, 200)}` + ); + } + } + + throw lastError ?? new Error('Completion zone fill failed after retries'); +} + diff --git a/packages/ai-layer/src/features/diff-summary.ts b/packages/ai-layer/src/features/diff-summary.ts new file mode 100644 index 0000000..7f10cfb --- /dev/null +++ b/packages/ai-layer/src/features/diff-summary.ts @@ -0,0 +1,34 @@ +import type { AIGateway } from '../gateway.js'; +import { buildSystemPrompt } from '../prompts/system.js'; + +export interface DiffSummaryInput { + /** Serialized component-level changes (JSON) */ + changesJson: string; + /** Component name */ + componentName: string; +} + +export interface DiffSummaryOutput { + summary: string; +} + +export async function generateDiffSummary( + gateway: AIGateway, + input: DiffSummaryInput +): Promise { + const system = buildSystemPrompt({ role: 'a technical writer summarizing UI component changes' }); + + const response = await gateway.complete({ + system, + messages: [ + { + role: 'user', + content: `Summarize the following component changes for "${input.componentName}" in one concise sentence (max 20 words) suitable for a developer reviewing a pull request. Focus on what changed and its purpose.\n\n\n${input.changesJson}\n\n\nRespond with only the summary sentence.`, + }, + ], + maxTokens: 128, + temperature: 0.3, + }); + + return { summary: response.text.trim() }; +} diff --git a/packages/ai-layer/src/features/drift-report.ts b/packages/ai-layer/src/features/drift-report.ts new file mode 100644 index 0000000..302ae1e --- /dev/null +++ b/packages/ai-layer/src/features/drift-report.ts @@ -0,0 +1,73 @@ +import type { AIGateway } from '../gateway.js'; +import { buildSystemPrompt } from '../prompts/system.js'; + +export interface DriftReportInput { + /** Screenshot of the live app as base64 data URL */ + screenshotBase64: string; + /** Active Design Language File as JSON string */ + dlfJson: string; +} + +export interface DriftViolation { + component: string; + property: string; + currentValue: string; + expectedValue: string; + severity: 'critical' | 'warning'; + description: string; +} + +export interface DriftReportOutput { + violations: DriftViolation[]; + summary: string; + violationCount: number; +} + +export async function generateDriftReport( + gateway: AIGateway, + input: DriftReportInput +): Promise { + const system = buildSystemPrompt({ + role: 'a design system compliance auditor', + dlfJson: input.dlfJson, + }); + + const response = await gateway.complete({ + system, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: input.screenshotBase64.replace(/^data:image\/\w+;base64,/, ''), + }, + }, + { + 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.', + }, + ], + }, + ], + maxTokens: 4096, + temperature: 0.3, + }); + + // Returning empty violations on parse failure would be a false-negative in a + // compliance feature. Throw so the caller can surface the error clearly. + let parsed: unknown; + try { + parsed = JSON.parse(response.text); + } catch (err) { + throw new Error( + `Drift report returned unparseable JSON: ${String(err)}. ` + + `Raw response (first 300 chars): ${response.text.slice(0, 300)}` + ); + } + + return parsed as DriftReportOutput; +} diff --git a/packages/ai-layer/src/gateway.ts b/packages/ai-layer/src/gateway.ts new file mode 100644 index 0000000..86602b3 --- /dev/null +++ b/packages/ai-layer/src/gateway.ts @@ -0,0 +1,143 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { getClient, MODEL } from './client.js'; + +// ── Gateway config ──────────────────────────────────────────────────────────── + +interface GatewayConfig { + /** Max requests per minute (default: 60) */ + rpmLimit?: number; + /** Max retries on transient errors (default: 3) */ + maxRetries?: number; +} + +// ── Cost tracking ───────────────────────────────────────────────────────────── + +export interface RequestCost { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + /** Estimated cost in USD cents */ + estimatedCentsCost: number; +} + +const OPUS_4_INPUT_COST_PER_M = 500; // $5.00 / 1M +const OPUS_4_OUTPUT_COST_PER_M = 2500; // $25.00 / 1M +const OPUS_4_CACHE_READ_PER_M = 50; // $0.50 / 1M (estimated) + +function computeCost(usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): RequestCost { + const inputTokens = usage.input_tokens; + const outputTokens = usage.output_tokens; + const cacheReadTokens = usage.cache_read_input_tokens ?? 0; + const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0; + + const billableInput = inputTokens - cacheReadTokens; + const estimatedCentsCost = Math.round( + (billableInput / 1_000_000) * OPUS_4_INPUT_COST_PER_M + + (outputTokens / 1_000_000) * OPUS_4_OUTPUT_COST_PER_M + + (cacheReadTokens / 1_000_000) * OPUS_4_CACHE_READ_PER_M + ); + + return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, estimatedCentsCost }; +} + +// ── Rate limiter ────────────────────────────────────────────────────────────── + +class RateLimiter { + private readonly rpm: number; + private timestamps: number[] = []; + + constructor(rpm: number) { this.rpm = rpm; } + + async acquire(): Promise { + const now = Date.now(); + this.timestamps = this.timestamps.filter(t => now - t < 60_000); + if (this.timestamps.length >= this.rpm) { + const oldest = this.timestamps[0]; + if (oldest !== undefined) { + const wait = 60_000 - (now - oldest); + if (wait > 0) await sleep(wait); + } + } + this.timestamps.push(Date.now()); + } +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ── Gateway ─────────────────────────────────────────────────────────────────── + +export interface GatewayRequest { + messages: Anthropic.Messages.MessageParam[]; + system?: Anthropic.Messages.TextBlockParam[]; + maxTokens?: number; + // NOTE: adaptive thinking (`thinking: { type: 'adaptive' }`) requires SDK >=0.58. + // Upgrade @anthropic-ai/sdk and uncomment when available. + /** Temperature: 0.3 for deterministic, 0.7 for generative */ + temperature?: number; +} + +export interface GatewayResponse { + content: Anthropic.Messages.ContentBlock[]; + text: string; + cost: RequestCost; +} + +export class AIGateway { + private readonly client: Anthropic; + private readonly rateLimiter: RateLimiter; + private readonly maxRetries: number; + private totalCost = 0; // cumulative cents + + constructor(config: GatewayConfig = {}) { + this.client = getClient(); + this.rateLimiter = new RateLimiter(config.rpmLimit ?? 60); + this.maxRetries = config.maxRetries ?? 3; + } + + async complete(req: GatewayRequest): Promise { + await this.rateLimiter.acquire(); + + let lastError: Error | null = null; + for (let attempt = 0; attempt < this.maxRetries; attempt++) { + try { + const response = await this.client.messages.create({ + model: MODEL, + max_tokens: req.maxTokens ?? 4096, + ...(req.temperature !== undefined ? { temperature: req.temperature } : {}), + ...(req.system !== undefined ? { system: req.system } : {}), + messages: req.messages, + }); + + const text = response.content + .filter((b): b is Anthropic.Messages.TextBlock => b.type === 'text') + .map(b => b.text) + .join(''); + + const cost = computeCost(response.usage); + this.totalCost += cost.estimatedCentsCost; + + return { content: response.content, text, cost }; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + // Retry on 429, 529, 5xx, and network errors (no HTTP status). + // Break immediately on client errors (4xx that aren't rate-limits). + if (err instanceof Anthropic.APIError) { + const { status } = err; + if (status !== 429 && status !== 529 && status < 500) break; + } + // Non-APIError (network timeout, DNS failure, etc.) → always retry + await sleep(2 ** attempt * 1000); + } + } + + throw lastError ?? new Error('AI gateway request failed'); + } + + /** Cumulative estimated cost in cents since this gateway was instantiated. */ + getCumulativeCost(): number { + return this.totalCost; + } +} diff --git a/packages/ai-layer/src/index.ts b/packages/ai-layer/src/index.ts index cb0ff5c..715b700 100644 --- a/packages/ai-layer/src/index.ts +++ b/packages/ai-layer/src/index.ts @@ -1 +1,18 @@ -export {}; +export { getClient, MODEL } from './client.js'; +export { AIGateway } from './gateway.js'; +export type { GatewayRequest, GatewayResponse, RequestCost } from './gateway.js'; + +export { fillCompletionZone } from './features/completion-zone.js'; +export type { CompletionZoneInput, CompletionZoneOutput } from './features/completion-zone.js'; + +export { generateDiffSummary } from './features/diff-summary.js'; +export type { DiffSummaryInput, DiffSummaryOutput } from './features/diff-summary.js'; + +export { queryCrossArtboard } from './features/artboard-query.js'; +export type { ArtboardQueryInput, ArtboardQueryOutput, ArtboardQueryResult } from './features/artboard-query.js'; + +export { generateDriftReport } from './features/drift-report.js'; +export type { DriftReportInput, DriftReportOutput, DriftViolation } from './features/drift-report.js'; + +export { answerAgentQuestion } from './features/agent-qa.js'; +export type { AgentQAInput, AgentQAOutput } from './features/agent-qa.js'; diff --git a/packages/ai-layer/src/prompts/system.ts b/packages/ai-layer/src/prompts/system.ts new file mode 100644 index 0000000..ee5dd51 --- /dev/null +++ b/packages/ai-layer/src/prompts/system.ts @@ -0,0 +1,31 @@ +import type Anthropic from '@anthropic-ai/sdk'; + +// ── System prompt builder ───────────────────────────────────────────────────── +// The DLF is placed as the FIRST cache breakpoint — it's stable across all +// requests in a workspace session, so cache hit rate is very high. +// Variable content (artboard context, user question) comes AFTER the breakpoints. + +export function buildSystemPrompt(opts: { + role: string; + dlfJson?: string; +}): Anthropic.Messages.TextBlockParam[] { + const blocks: Anthropic.Messages.TextBlockParam[] = []; + + // First block: stable role description — cached + blocks.push({ + type: 'text', + text: `You are ${opts.role}. You work within Originmain, an AI-native design engineering platform. Your outputs are used directly by designers and engineers to implement product changes. Be precise, structured, and always respect the design system constraints provided.`, + cache_control: { type: 'ephemeral' }, + }); + + // Second block: DLF if provided — cached (stable per workspace) + if (opts.dlfJson) { + blocks.push({ + type: 'text', + text: `\n${opts.dlfJson}\n`, + cache_control: { type: 'ephemeral' }, + }); + } + + return blocks; +} diff --git a/packages/app/package.json b/packages/app/package.json index a56c673..17acb5d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@clerk/nextjs": "^7.2.7", "@fluentui/react-components": "^9.54.0", "@fluentui/react-icons": "^2.0.0", "@originmain/diff-engine": "workspace:*", diff --git a/packages/app/public/marketing.html b/packages/app/public/marketing.html index f795c6e..0aa767f 100644 --- a/packages/app/public/marketing.html +++ b/packages/app/public/marketing.html @@ -277,6 +277,73 @@ button { font-family: inherit; cursor: pointer; border: none; background: none; .nav-links a:hover { color: var(--fg); } .nav-actions { display: flex; align-items: center; gap: 8px; } +/* ── Hamburger ── */ +.nav-burger { + display: none; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 5px; + width: 36px; + height: 36px; + background: none; + border: none; + cursor: pointer; + padding: 4px; + border-radius: var(--r-2); + transition: background 0.12s; + flex-shrink: 0; +} +.nav-burger:hover { background: var(--bg-muted); } +.nav-burger span { + display: block; + width: 20px; + height: 2px; + background: var(--fg); + border-radius: 2px; + transition: transform 0.22s cubic-bezier(0.16,1,0.3,1), opacity 0.18s; + transform-origin: center; +} +.nav-burger.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); } +.nav-burger.open span:nth-child(2) { opacity: 0; transform: scaleX(0); } +.nav-burger.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); } + +/* ── Mobile drawer ── */ +.nav-mobile { + display: none; + position: absolute; + top: 60px; left: 0; right: 0; + background: rgba(255,255,255,0.97); + backdrop-filter: blur(20px) saturate(1.6); + -webkit-backdrop-filter: blur(20px) saturate(1.6); + border-bottom: 1px solid var(--border); + box-shadow: 0 8px 32px rgba(0,0,0,0.07); + padding: 12px 24px 24px; + flex-direction: column; + gap: 2px; +} +.nav-mobile.open { display: flex; } +.nav-mobile ul { list-style: none; display: flex; flex-direction: column; } +.nav-mobile ul li a { + display: block; + font-size: 1rem; + font-weight: 500; + color: var(--fg-2); + padding: 11px 4px; + border-bottom: 1px solid var(--border); + letter-spacing: -0.01em; + transition: color 0.1s; +} +.nav-mobile ul li:last-child a { border-bottom: none; } +.nav-mobile ul li a:hover { color: var(--fg); } +.nav-mobile-ctas { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 16px; +} +.nav-mobile-ctas .btn { text-align: center; padding: 11px 0; font-size: .9375rem; } + /* ════════════════════════════════════════════════════════════ HERO — Figma Sites: product-forward, massive type ════════════════════════════════════════════════════════════ */ @@ -1257,7 +1324,15 @@ footer { @media (max-width: 768px) { .wrap, .wrap-wide { padding: 0 24px; } #nav { padding: 0 24px; } + .nav-inner { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + } .nav-links { display: none; } + .nav-actions .btn { display: none; } + .nav-burger { display: flex; } .section { padding: 80px 0; } .feat-block { padding: 80px 24px; } #hero { padding: 140px 24px 0; } @@ -1295,6 +1370,23 @@ footer { + + + + @@ -1931,6 +2023,38 @@ window.addEventListener('scroll', () => { nav.classList.toggle('scrolled', window.scrollY > 20); }, { passive: true }); +// ── Mobile hamburger ────────────────────────────────────── +const burger = document.getElementById('nav-burger'); +const mobileMenu = document.getElementById('nav-mobile'); + +function toggleMenu(force) { + const open = force !== undefined ? force : !burger.classList.contains('open'); + burger.classList.toggle('open', open); + burger.setAttribute('aria-expanded', String(open)); + mobileMenu.classList.toggle('open', open); + mobileMenu.setAttribute('aria-hidden', String(!open)); + document.body.style.overflow = open ? 'hidden' : ''; +} + +burger.addEventListener('click', () => toggleMenu()); + +// Close on any link click inside the drawer +mobileMenu.querySelectorAll('a').forEach(a => + a.addEventListener('click', () => toggleMenu(false)) +); + +// Close on outside tap +document.addEventListener('click', e => { + if (mobileMenu.classList.contains('open') && !nav.contains(e.target)) { + toggleMenu(false); + } +}); + +// Close on Escape key +document.addEventListener('keydown', e => { + if (e.key === 'Escape' && mobileMenu.classList.contains('open')) toggleMenu(false); +}); + // ── Scroll reveal ───────────────────────────────────────── const revealEls = document.querySelectorAll('.reveal, .stagger'); const revealAll = () => revealEls.forEach(el => el.classList.add('in')); diff --git a/packages/app/src/app/layout.tsx b/packages/app/src/app/layout.tsx index 81ab149..90f6eec 100644 --- a/packages/app/src/app/layout.tsx +++ b/packages/app/src/app/layout.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from 'react'; +import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs'; import { Providers } from './providers'; import './globals.css'; @@ -11,7 +12,18 @@ export default function RootLayout({ children }: { children: ReactNode }) { return ( - {children} + +
+ + + + + + + +
+ {children} +
); diff --git a/packages/app/src/components/canvas/LiveArtboard.tsx b/packages/app/src/components/canvas/LiveArtboard.tsx new file mode 100644 index 0000000..c0b6dcf --- /dev/null +++ b/packages/app/src/components/canvas/LiveArtboard.tsx @@ -0,0 +1,117 @@ +'use client'; + +import { useEffect, useRef, useCallback } from 'react'; +import { + buildFiberHookScript, + createHostEnvelope, + isRendererEnvelope, +} from '@originmain/renderer'; +import type { FiberNode, RendererMessage } from '@originmain/renderer'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface LiveArtboardProps { + id: string; + /** URL of the connected application route to render */ + src: string; + width?: number; + height?: number; + designTokens?: Record; + onReady?: () => void; + onFiberTreeUpdate?: (root: FiberNode) => void; + onComponentSelected?: (nodeId: string) => void; + style?: React.CSSProperties; +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export function LiveArtboard({ + id, + src, + width = 1280, + height = 720, + designTokens, + onReady, + onFiberTreeUpdate, + onComponentSelected, + style, +}: LiveArtboardProps) { + const iframeRef = useRef(null); + + // Send a message to the iframe via the typed protocol + const sendMessage = useCallback( + (type: Parameters[1]['type'], payload?: Record) => { + const iframe = iframeRef.current; + if (!iframe?.contentWindow) return; + const envelope = createHostEnvelope(id, { type, ...(payload ?? {}) } as Parameters[1]); + iframe.contentWindow.postMessage(envelope, '*'); + }, + [id] + ); + + // Handle messages from the renderer iframe + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (!isRendererEnvelope(event.data)) return; + if (event.data.artboardId !== id) return; + + const msg: RendererMessage = event.data.message; + switch (msg.type) { + case 'READY': + // Inject fiber hook after the renderer signals it's ready + injectFiberHook(iframeRef.current, id); + if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens }); + onReady?.(); + break; + case 'FIBER_TREE_UPDATE': + onFiberTreeUpdate?.(msg.root); + break; + case 'COMPONENT_SELECTED': + onComponentSelected?.(msg.nodeId); + break; + } + } + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [id, designTokens, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]); + + // Push updated design tokens whenever they change + useEffect(() => { + if (!designTokens) return; + sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens }); + }, [designTokens, sendMessage]); + + return ( +