improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 09:09:55 +01:00
parent 2911f22df8
commit 97315846be
70 changed files with 4967 additions and 10 deletions
+18 -1
View File
@@ -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(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(pnpm --filter @originmain/diff-engine test)",
"Bash(npx tsc *)", "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)"
] ]
} }
} }
+251
View File
@@ -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<string, {past, future}>`, `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<T>`, `JsonRpcError`, `MCP_ERROR` constants (`UNAUTHORIZED: -32001`, `RATE_LIMITED: -32002`), `AuthRequest`/`AuthAck` handshake, `textResult()`/`jsonResult<T>()` 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<TPayload>` 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 13 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<id, ArtboardStorageObject>` + `artboardOrder`
- `LiveblocksRoomTypes` — generic type params for `createRoomContext<Presence, Storage, UserMeta, RoomEvent>`
- `RoomEvent` — broadcast events: `ARTBOARD_LOCKED`, `DIFF_EXPORTED`, `COMPLETION_ZONE_ACCEPTED`
- `workspaceRoomId(workspaceId)` — room ID convention: `"workspace:<uuid>"`
- **`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 910 foundation completion*
+124
View File
@@ -277,6 +277,73 @@ button { font-family: inherit; cursor: pointer; border: none; background: none;
.nav-links a:hover { color: var(--fg); } .nav-links a:hover { color: var(--fg); }
.nav-actions { display: flex; align-items: center; gap: 8px; } .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 HERO — Figma Sites: product-forward, massive type
════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════ */
@@ -1257,7 +1324,15 @@ footer {
@media (max-width: 768px) { @media (max-width: 768px) {
.wrap, .wrap-wide { padding: 0 24px; } .wrap, .wrap-wide { padding: 0 24px; }
#nav { 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-links { display: none; }
.nav-actions .btn { display: none; }
.nav-burger { display: flex; }
.section { padding: 80px 0; } .section { padding: 80px 0; }
.feat-block { padding: 80px 24px; } .feat-block { padding: 80px 24px; }
#hero { padding: 140px 24px 0; } #hero { padding: 140px 24px 0; }
@@ -1295,6 +1370,23 @@ footer {
<div class="nav-actions"> <div class="nav-actions">
<a href="#" class="btn btn-secondary" style="padding:7px 16px;font-size:.875rem">Log in</a> <a href="#" class="btn btn-secondary" style="padding:7px 16px;font-size:.875rem">Log in</a>
<a href="#" class="btn btn-primary" style="padding:8px 18px;font-size:.875rem">Request Access</a> <a href="#" class="btn btn-primary" style="padding:8px 18px;font-size:.875rem">Request Access</a>
<button class="nav-burger" id="nav-burger" aria-label="Toggle navigation" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
</div>
<!-- Mobile drawer (hidden on desktop) -->
<div class="nav-mobile" id="nav-mobile" aria-hidden="true">
<ul>
<li><a href="#features">Product</a></li>
<li><a href="#integrations">Integrations</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a href="#">Docs</a></li>
</ul>
<div class="nav-mobile-ctas">
<a href="#" class="btn btn-secondary">Log in</a>
<a href="#" class="btn btn-primary">Request Access</a>
</div> </div>
</div> </div>
</nav> </nav>
@@ -1931,6 +2023,38 @@ window.addEventListener('scroll', () => {
nav.classList.toggle('scrolled', window.scrollY > 20); nav.classList.toggle('scrolled', window.scrollY > 20);
}, { passive: true }); }, { 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 ───────────────────────────────────────── // ── Scroll reveal ─────────────────────────────────────────
const revealEls = document.querySelectorAll('.reveal, .stagger'); const revealEls = document.querySelectorAll('.reveal, .stagger');
const revealAll = () => revealEls.forEach(el => el.classList.add('in')); const revealAll = () => revealEls.forEach(el => el.classList.add('in'));
+1
View File
@@ -16,6 +16,7 @@
"zod": "^3.0.0" "zod": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.5.0" "typescript": "^5.5.0"
} }
} }
@@ -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<string, string>;
}
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 };
}
@@ -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<string, string>;
}
interface CursorSettings {
mcpServers: Record<string, McpServerEntry>;
}
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 };
}
+68
View File
@@ -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 };
}
+17 -1
View File
@@ -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';
+69
View File
@@ -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<T = unknown> {
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<T = unknown> = JsonRpcSuccess<T> | 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<T> {
content: { type: 'text'; text: string } | { type: 'json'; json: T };
}
export function textResult(text: string): ToolResult<never> {
return { content: { type: 'text', text } };
}
export function jsonResult<T>(json: T): ToolResult<T> {
return { content: { type: 'json', json } };
}
+52
View File
@@ -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<string, number[]>();
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,
};
}
+216
View File
@@ -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<unknown[]>;
getDiff(id: string): Promise<unknown>;
getArtboard(id: string): Promise<unknown>;
getDesignLanguageFile(workspaceId: string): Promise<unknown | null>;
updateDiffStatus(id: string, status: DiffStatus, notes?: string): Promise<void>;
};
/** Adapter to the AI layer (for ask_design_agent) */
ai: {
answerAgentQuestion(diffId: string, question: string, artboardContext: unknown): Promise<string>;
};
}
// ── Tool definition ───────────────────────────────────────────────────────────
export interface McpTool {
name: string;
description: string;
inputSchema: z.ZodTypeAny;
execute(params: unknown, ctx: ToolContext): Promise<ToolResult<unknown> | 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<string, unknown>; required?: string[] } {
if (schema instanceof z.ZodObject) {
const shape = schema.shape as Record<string, z.ZodTypeAny>;
const properties: Record<string, unknown> = {};
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}`);
}
+2 -1
View File
@@ -1,7 +1,8 @@
{ {
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"noEmit": true "noEmit": true,
"types": ["node"]
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules"] "exclude": ["node_modules"]
+2
View File
@@ -10,6 +10,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
"@originmain/design-language": "workspace:*",
"@originmain/diff-engine": "workspace:*", "@originmain/diff-engine": "workspace:*",
"zod": "^3.0.0" "zod": "^3.0.0"
}, },
+20
View File
@@ -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;
}
@@ -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<AgentQAOutput> {
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: `<artboard_context>\n${input.artboardContextJson}\n</artboard_context>\n\n<diff_id>${input.diffId}</diff_id>\n\n<question>\n${input.question}\n</question>\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;
}
@@ -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<ArtboardQueryOutput> {
const system = buildSystemPrompt({ role: 'a search agent filtering artboards by design intent' });
const response = await gateway.complete({
system,
messages: [
{
role: 'user',
content: `<artboards>\n${input.artboardsJson}\n</artboards>\n\n<query>\n${input.query}\n</query>\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;
}
@@ -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<CompletionZoneOutput> {
const system = buildSystemPrompt({
role: 'a Completion Zone design agent',
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
const userContent: Anthropic.Messages.ContentBlockParam[] = [
{
type: 'text',
text: `<component_tree>\n${input.componentTreeJson}\n</component_tree>\n\n<intent>\n${input.intent}\n</intent>\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');
}
@@ -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<DiffSummaryOutput> {
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<changes>\n${input.changesJson}\n</changes>\n\nRespond with only the summary sentence.`,
},
],
maxTokens: 128,
temperature: 0.3,
});
return { summary: response.text.trim() };
}
@@ -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<DriftReportOutput> {
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;
}
+143
View File
@@ -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<void> {
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<void> {
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<GatewayResponse> {
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;
}
}
+18 -1
View File
@@ -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';
+31
View File
@@ -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: `<design_language_file>\n${opts.dlfJson}\n</design_language_file>`,
cache_control: { type: 'ephemeral' },
});
}
return blocks;
}
+1
View File
@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@clerk/nextjs": "^7.2.7",
"@fluentui/react-components": "^9.54.0", "@fluentui/react-components": "^9.54.0",
"@fluentui/react-icons": "^2.0.0", "@fluentui/react-icons": "^2.0.0",
"@originmain/diff-engine": "workspace:*", "@originmain/diff-engine": "workspace:*",
+124
View File
@@ -277,6 +277,73 @@ button { font-family: inherit; cursor: pointer; border: none; background: none;
.nav-links a:hover { color: var(--fg); } .nav-links a:hover { color: var(--fg); }
.nav-actions { display: flex; align-items: center; gap: 8px; } .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 HERO — Figma Sites: product-forward, massive type
════════════════════════════════════════════════════════════ */ ════════════════════════════════════════════════════════════ */
@@ -1257,7 +1324,15 @@ footer {
@media (max-width: 768px) { @media (max-width: 768px) {
.wrap, .wrap-wide { padding: 0 24px; } .wrap, .wrap-wide { padding: 0 24px; }
#nav { 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-links { display: none; }
.nav-actions .btn { display: none; }
.nav-burger { display: flex; }
.section { padding: 80px 0; } .section { padding: 80px 0; }
.feat-block { padding: 80px 24px; } .feat-block { padding: 80px 24px; }
#hero { padding: 140px 24px 0; } #hero { padding: 140px 24px 0; }
@@ -1295,6 +1370,23 @@ footer {
<div class="nav-actions"> <div class="nav-actions">
<a href="#" class="btn btn-secondary" style="padding:7px 16px;font-size:.875rem">Log in</a> <a href="#" class="btn btn-secondary" style="padding:7px 16px;font-size:.875rem">Log in</a>
<a href="#" class="btn btn-primary" style="padding:8px 18px;font-size:.875rem">Request Access</a> <a href="#" class="btn btn-primary" style="padding:8px 18px;font-size:.875rem">Request Access</a>
<button class="nav-burger" id="nav-burger" aria-label="Toggle navigation" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
</div>
<!-- Mobile drawer (hidden on desktop) -->
<div class="nav-mobile" id="nav-mobile" aria-hidden="true">
<ul>
<li><a href="#features">Product</a></li>
<li><a href="#integrations">Integrations</a></li>
<li><a href="#pricing">Pricing</a></li>
<li><a href="#">Docs</a></li>
</ul>
<div class="nav-mobile-ctas">
<a href="#" class="btn btn-secondary">Log in</a>
<a href="#" class="btn btn-primary">Request Access</a>
</div> </div>
</div> </div>
</nav> </nav>
@@ -1931,6 +2023,38 @@ window.addEventListener('scroll', () => {
nav.classList.toggle('scrolled', window.scrollY > 20); nav.classList.toggle('scrolled', window.scrollY > 20);
}, { passive: true }); }, { 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 ───────────────────────────────────────── // ── Scroll reveal ─────────────────────────────────────────
const revealEls = document.querySelectorAll('.reveal, .stagger'); const revealEls = document.querySelectorAll('.reveal, .stagger');
const revealAll = () => revealEls.forEach(el => el.classList.add('in')); const revealAll = () => revealEls.forEach(el => el.classList.add('in'));
+13 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs';
import { Providers } from './providers'; import { Providers } from './providers';
import './globals.css'; import './globals.css';
@@ -11,7 +12,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
<Providers>{children}</Providers> <ClerkProvider>
<header style={{ position: 'fixed', top: 0, right: 0, zIndex: 9999, padding: '8px 16px', display: 'flex', gap: 8, alignItems: 'center' }}>
<Show when="signed-out">
<SignInButton />
<SignUpButton />
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</header>
<Providers>{children}</Providers>
</ClerkProvider>
</body> </body>
</html> </html>
); );
@@ -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<string, string>;
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<HTMLIFrameElement>(null);
// Send a message to the iframe via the typed protocol
const sendMessage = useCallback(
(type: Parameters<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
const iframe = iframeRef.current;
if (!iframe?.contentWindow) return;
const envelope = createHostEnvelope(id, { type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[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 (
<iframe
ref={iframeRef}
src={src}
title={`artboard-${id}`}
// Security: allow-scripts required to run React; allow-same-origin required
// for postMessage with targeted origin validation. Do NOT combine these with
// untrusted third-party content.
sandbox="allow-scripts allow-same-origin allow-forms"
style={{
width,
height,
border: 'none',
display: 'block',
...style,
}}
/>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function injectFiberHook(iframe: HTMLIFrameElement | null, artboardId: string) {
if (!iframe?.contentDocument) return;
try {
const script = iframe.contentDocument.createElement('script');
script.textContent = buildFiberHookScript(artboardId);
iframe.contentDocument.head.appendChild(script);
} catch {
// Cross-origin or sandboxing prevents injection — renderer must include the
// hook script itself in that case.
}
}
@@ -0,0 +1,337 @@
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useHistory } from '@/store/history';
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
import type { PropChange } from '@originmain/diff-engine';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface SelectionState {
nodeId: string;
nodeName: string;
rect: DOMRectLike;
}
export interface SelectionOverlayProps {
artboardId: string;
/** Fiber tree from the live renderer (if connected) */
fiberRoot?: FiberNode;
/** Width and height must match the artboard frame exactly */
width: number;
height: number;
onSelectionChange?: (selection: SelectionState | null) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function SelectionOverlay({
artboardId,
fiberRoot,
width,
height,
onSelectionChange,
}: SelectionOverlayProps) {
const [selected, setSelected] = useState<SelectionState | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const pushEdit = useHistory(s => s.pushEdit);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!fiberRoot) {
setSelected(null);
onSelectionChange?.(null);
return;
}
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const clickX = e.clientX - overlayRect.left;
const clickY = e.clientY - overlayRect.top;
const hit = hitTestFiber(fiberRoot, clickX, clickY);
if (hit) {
const sel: SelectionState = { nodeId: hit.id, nodeName: hit.name, rect: hit.domRect! };
setSelected(sel);
onSelectionChange?.(sel);
} else {
setSelected(null);
onSelectionChange?.(null);
}
},
[fiberRoot, onSelectionChange]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!fiberRoot) return;
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - overlayRect.left;
const y = e.clientY - overlayRect.top;
const hit = hitTestFiber(fiberRoot, x, y);
setHoveredId(hit?.id ?? null);
},
[fiberRoot]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setSelected(null);
onSelectionChange?.(null);
}
},
[onSelectionChange]
);
return (
<div
role="presentation"
tabIndex={-1}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoveredId(null)}
onKeyDown={handleKeyDown}
style={{
position: 'absolute',
inset: 0,
width,
height,
zIndex: 5,
cursor: 'crosshair',
}}
>
{hoveredId && fiberRoot && (
<HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} />
)}
{selected && (
<SelectionHandles
artboardId={artboardId}
selection={selected}
onResizeCommit={(changes) => {
pushEdit(artboardId, {
componentId: selected.nodeId,
componentName: selected.nodeName,
changes,
timestamp: Date.now(),
});
}}
/>
)}
</div>
);
}
// ── Hover highlight ───────────────────────────────────────────────────────────
function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: string }) {
const node = findNode(fiberRoot, nodeId);
if (!node?.domRect) return null;
const { x, y, width, height } = node.domRect;
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width,
height,
border: '1px solid rgba(51,133,255,0.4)',
borderRadius: 2,
pointerEvents: 'none',
}}
/>
);
}
// ── Selection handles ─────────────────────────────────────────────────────────
interface SelectionHandlesProps {
artboardId: string;
selection: SelectionState;
onResizeCommit: (changes: PropChange[]) => void;
}
function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps) {
const { rect, nodeName } = selection;
const startRect = useRef<DOMRectLike | null>(null);
// liveRectRef tracks the running rect without stale-closure issues.
// liveRect is the React state for rendering only.
const liveRectRef = useRef<DOMRectLike>(rect);
const [liveRect, setLiveRect] = useState(rect);
// onResizeCommitRef ensures onMouseUp always calls the latest callback even if
// the parent re-renders between mousedown and mouseup.
const onResizeCommitRef = useRef(onResizeCommit);
useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
// Track registered drag listeners so we can clean them up on unmount.
const dragListenersRef = useRef<{
move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void;
} | null>(null);
useEffect(() => {
return () => {
if (dragListenersRef.current) {
window.removeEventListener('mousemove', dragListenersRef.current.move);
window.removeEventListener('mouseup', dragListenersRef.current.up);
dragListenersRef.current = null;
}
};
}, []);
const makeResizeHandle = useCallback(
(corner: 'tl' | 'tr' | 'bl' | 'br') =>
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
// Capture the rect at drag-start from the ref (always current).
startRect.current = { ...liveRectRef.current };
const startX = e.clientX;
const startY = e.clientY;
const onMouseMove = (ev: MouseEvent) => {
if (!startRect.current) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
// Always apply delta from the START rect, not the previous frame's rect.
// Applying to prev causes exponential drift over the course of a drag.
const next = adjustRect(startRect.current, corner, dx, dy);
liveRectRef.current = next;
setLiveRect(next);
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
dragListenersRef.current = null;
if (!startRect.current) return;
// Read final dimensions from the ref — not from the stale liveRect closure.
const widthChange = liveRectRef.current.width - startRect.current.width;
const heightChange = liveRectRef.current.height - startRect.current.height;
const changes: PropChange[] = [];
if (Math.abs(widthChange) > 0.5) {
changes.push({
key: 'width',
before: startRect.current.width,
after: liveRectRef.current.width,
changeType: 'modified',
});
}
if (Math.abs(heightChange) > 0.5) {
changes.push({
key: 'height',
before: startRect.current.height,
after: liveRectRef.current.height,
changeType: 'modified',
});
}
if (changes.length > 0) onResizeCommitRef.current(changes);
startRect.current = null;
};
dragListenersRef.current = { move: onMouseMove, up: onMouseUp };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
},
[] // No reactive deps — all values read from refs
);
const { x, y, width, height } = liveRect;
const HANDLE = 8;
return (
<>
{/* Selection frame */}
<div
style={{
position: 'absolute',
left: x,
top: y,
width,
height,
border: '2px solid #3385FF',
borderRadius: 2,
pointerEvents: 'none',
}}
/>
{/* Label */}
<div
style={{
position: 'absolute',
left: x,
top: y - 20,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 10,
color: '#3385FF',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
{nodeName}
</div>
{/* Corner handles */}
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
const cx = corner.includes('l') ? x - HANDLE / 2 : x + width - HANDLE / 2;
const cy = corner.includes('t') ? y - HANDLE / 2 : y + height - HANDLE / 2;
return (
<div
key={corner}
onMouseDown={makeResizeHandle(corner)}
style={{
position: 'absolute',
left: cx,
top: cy,
width: HANDLE,
height: HANDLE,
background: '#fff',
border: '2px solid #3385FF',
borderRadius: 2,
cursor: corner === 'tl' || corner === 'br' ? 'nwse-resize' : 'nesw-resize',
zIndex: 10,
}}
/>
);
})}
</>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function hitTestFiber(node: FiberNode, x: number, y: number): FiberNode | null {
for (const child of [...node.children].reverse()) {
const hit = hitTestFiber(child, x, y);
if (hit) return hit;
}
if (!node.domRect) return null;
const { x: nx, y: ny, width, height } = node.domRect;
if (x >= nx && x <= nx + width && y >= ny && y <= ny + height) return node;
return null;
}
function findNode(root: FiberNode, id: string): FiberNode | null {
if (root.id === id) return root;
for (const child of root.children) {
const found = findNode(child, id);
if (found) return found;
}
return null;
}
function adjustRect(
rect: DOMRectLike,
corner: 'tl' | 'tr' | 'bl' | 'br',
dx: number,
dy: number
): DOMRectLike {
let { x, y, width, height } = rect;
if (corner.includes('l')) { x += dx; width = Math.max(8, width - dx); }
else { width = Math.max(8, width + dx); }
if (corner.includes('t')) { y += dy; height = Math.max(8, height - dy); }
else { height = Math.max(8, height + dy); }
return { x, y, width, height };
}
@@ -0,0 +1,80 @@
'use client';
import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import type { GitStatusEntry } from '@pierre/trees';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface CodebaseFileTreeProps {
/** Flat list of file paths, relative to repo root */
paths: string[];
/** Array of git status entries (matches @pierre/trees FileTreeOptions.gitStatus) */
gitStatus?: readonly GitStatusEntry[];
/** Initial directory expansion depth (default: 1) */
initialExpansion?: number;
/** Collapse single-child directory chains (default: true) */
flattenEmptyDirectories?: boolean;
className?: string;
style?: React.CSSProperties;
}
// Dark panel tokens — match ArtboardNavigator palette
const treeThemeStyles = themeToTreeStyles({
type: 'dark',
bg: '#111115',
fg: 'rgba(255,255,255,0.42)',
colors: {
'editor.selectionBackground': 'rgba(51,133,255,0.14)',
'list.activeSelectionBackground': 'rgba(51,133,255,0.14)',
'list.inactiveSelectionBackground': 'rgba(51,133,255,0.08)',
'list.hoverBackground': 'rgba(255,255,255,0.04)',
'list.activeSelectionForeground': 'rgba(255,255,255,0.88)',
'editorIndentGuide.background': 'rgba(255,255,255,0.04)',
},
});
// ── Component ─────────────────────────────────────────────────────────────────
export function CodebaseFileTree({
paths,
gitStatus,
initialExpansion = 1,
flattenEmptyDirectories = true,
className,
style,
}: CodebaseFileTreeProps) {
const { model } = useFileTree({
paths,
...(gitStatus !== undefined ? { gitStatus } : {}),
initialExpansion,
flattenEmptyDirectories,
density: 'compact',
icons: 'minimal',
fileTreeSearchMode: 'expand-matches',
});
return (
<div
className={className}
style={{
flex: 1,
overflow: 'hidden',
minHeight: 0,
background: '#111115',
...style,
}}
>
<FileTree
model={model}
style={{
...treeThemeStyles,
height: '100%',
width: '100%',
'--trees-item-height': '26px',
'--trees-indent-width': '14px',
} as React.CSSProperties}
/>
</div>
);
}
@@ -0,0 +1,81 @@
'use client';
import { PatchDiff } from '@pierre/diffs/react';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface CodeDiffPanelProps {
/** Unified diff string produced by the diff-engine's generatePatch() */
patch: string;
/** Render mode: 'split' shows before/after columns, 'unified' stacks them */
layout?: 'split' | 'unified';
className?: string;
style?: React.CSSProperties;
}
// PatchDiff options — dark theme, Shiki syntax highlighting
// Note: typed 'as const' to avoid widening to BaseDiffOptions (which includes
// 'custom' hunkSeparator that FileDiffOptions excludes).
const DARK_OPTIONS = {
theme: 'github-dark-dimmed',
diffIndicators: 'bars',
disableBackground: false,
expandUnchanged: false,
collapsedContextThreshold: 3,
} as const;
// ── Component ─────────────────────────────────────────────────────────────────
export function CodeDiffPanel({
patch,
layout = 'split',
className,
style,
}: CodeDiffPanelProps) {
if (!patch) {
return (
<div
className={className}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px 16px',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
color: 'rgba(255,255,255,0.22)',
letterSpacing: '0.06em',
background: '#111115',
...style,
}}
>
No diff available
</div>
);
}
const options = {
...DARK_OPTIONS,
diffStyle: layout,
} as const;
return (
<div
className={className}
style={{
overflow: 'auto',
background: '#111115',
// Map Fluent 2 neutral surface tokens into @pierre/diffs CSS custom properties
'--diffs-background': '#111115',
'--diffs-gutter-background': '#0D0D11',
'--diffs-addition-background': 'rgba(70,220,120,0.06)',
'--diffs-deletion-background': 'rgba(255,70,70,0.06)',
'--diffs-addition-gutter': 'rgba(70,220,120,0.15)',
'--diffs-deletion-gutter': 'rgba(255,70,70,0.15)',
...style,
} as React.CSSProperties}
>
<PatchDiff patch={patch} options={options} />
</div>
);
}
@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import {
Tree,
TreeItem,
TreeItemLayout,
type TreeOpenChangeData,
type TreeOpenChangeEvent,
} from '@fluentui/react-components';
import { SquareRegular, FolderRegular, FolderOpenRegular } from '@fluentui/react-icons';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface ArtboardNode {
id: string;
name: string;
children?: ArtboardNode[];
}
export interface ArtboardTreeProps {
nodes: ArtboardNode[];
selectedId?: string | null;
onSelect?: (id: string) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ArtboardTree({ nodes, selectedId, onSelect }: ArtboardTreeProps) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
function handleOpenChange(_e: TreeOpenChangeEvent, data: TreeOpenChangeData) {
setOpenItems(prev => {
const next = new Set(prev);
if (data.open) next.add(String(data.value));
else next.delete(String(data.value));
return next;
});
}
return (
<Tree
aria-label="Artboard hierarchy"
size="small"
openItems={openItems}
onOpenChange={handleOpenChange}
style={{ background: 'transparent', padding: '2px 0' }}
>
{nodes.map(node => (
<ArtboardTreeNode
key={node.id}
node={node}
selectedId={selectedId}
openItems={openItems}
onSelect={onSelect}
/>
))}
</Tree>
);
}
// ── Recursive node ─────────────────────────────────────────────────────────────
function ArtboardTreeNode({
node,
selectedId,
openItems,
onSelect,
}: {
node: ArtboardNode;
selectedId: string | null | undefined;
openItems: Set<string>;
onSelect: ((id: string) => void) | undefined;
}) {
const hasChildren = node.children && node.children.length > 0;
const isOpen = openItems.has(node.id);
const isSelected = selectedId === node.id;
const icon = hasChildren ? (
isOpen ? (
<FolderOpenRegular style={{ fontSize: 13 }} />
) : (
<FolderRegular style={{ fontSize: 13 }} />
)
) : (
<SquareRegular style={{ fontSize: 11 }} />
);
return (
<TreeItem
value={node.id}
itemType={hasChildren ? 'branch' : 'leaf'}
style={{
background: isSelected ? 'rgba(51,133,255,0.12)' : undefined,
borderRadius: 4,
}}
>
<TreeItemLayout
iconBefore={icon}
onClick={() => !hasChildren && onSelect?.(node.id)}
style={{
fontSize: '0.75rem',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
color: isSelected ? 'rgba(255,255,255,0.88)' : 'rgba(255,255,255,0.55)',
fontWeight: isSelected ? 500 : 400,
cursor: hasChildren ? 'default' : 'pointer',
}}
>
{node.name}
</TreeItemLayout>
{hasChildren &&
node.children!.map(child => (
<ArtboardTreeNode
key={child.id}
node={child}
selectedId={selectedId}
openItems={openItems}
onSelect={onSelect}
/>
))}
</TreeItem>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();
export const config = {
matcher: [
// Skip Next.js internals and all static files
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
+122
View File
@@ -0,0 +1,122 @@
import { create } from 'zustand';
import type { PropChange } from '@originmain/diff-engine';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface EditEntry {
/** Which component was changed */
componentId: string;
componentName: string;
/** The prop/style changes in this edit */
changes: PropChange[];
timestamp: number;
}
interface ArtboardHistory {
past: EditEntry[];
future: EditEntry[];
}
interface HistoryStore {
/** Per-artboard history stacks */
stacks: Record<string, ArtboardHistory>;
/** Push a new edit onto the artboard's history (clears the future stack) */
pushEdit: (artboardId: string, entry: EditEntry) => void;
/** Undo the most recent edit for an artboard; returns the undone entry */
undo: (artboardId: string) => EditEntry | undefined;
/** Redo the next edit for an artboard; returns the redone entry */
redo: (artboardId: string) => EditEntry | undefined;
canUndo: (artboardId: string) => boolean;
canRedo: (artboardId: string) => boolean;
/** Clear history for a specific artboard */
clearHistory: (artboardId: string) => void;
}
// ── Store ─────────────────────────────────────────────────────────────────────
const MAX_HISTORY = 100;
function emptyStack(): ArtboardHistory {
return { past: [], future: [] };
}
export const useHistory = create<HistoryStore>((set, get) => ({
stacks: {},
pushEdit(artboardId, entry) {
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
const past = [...current.past, entry].slice(-MAX_HISTORY);
return {
stacks: {
...state.stacks,
[artboardId]: { past, future: [] },
},
};
});
},
undo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const last = stack.past[stack.past.length - 1];
if (!last) return undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
return {
stacks: {
...state.stacks,
[artboardId]: {
past: current.past.slice(0, -1),
future: [last, ...current.future],
},
},
};
});
return last;
},
redo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const next = stack.future[0];
if (!next) return undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
return {
stacks: {
...state.stacks,
[artboardId]: {
past: [...current.past, next],
future: current.future.slice(1),
},
},
};
});
return next;
},
canUndo(artboardId) {
return (get().stacks[artboardId]?.past.length ?? 0) > 0;
},
canRedo(artboardId) {
return (get().stacks[artboardId]?.future.length ?? 0) > 0;
},
clearHistory(artboardId) {
set(state => ({
stacks: {
...state.stacks,
[artboardId]: emptyStack(),
},
}));
},
}));
File diff suppressed because one or more lines are too long
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@originmain/design-language",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --coverage"
},
"dependencies": {
"zod": "^3.0.0"
},
"devDependencies": {
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"@vitest/coverage-v8": "^2.0.0"
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from './schema.js';
export * from './validator.js';
export * from './tokens.js';
+111
View File
@@ -0,0 +1,111 @@
import { z } from 'zod';
// ── Token schemas ─────────────────────────────────────────────────────────────
export const ColorTokenSchema = z.object({
value: z.string().regex(/^#[0-9A-Fa-f]{3,8}$|^rgba?\(|^hsl/, 'Must be a valid CSS color'),
/** Fluent 2 token name this maps to, e.g. "colorBrandBackground" */
fluentToken: z.string().optional(),
description: z.string().optional(),
});
export const TypographyTokenSchema = z.object({
fontFamily: z.string().optional(),
fontSize: z.union([z.string(), z.number()]).optional(),
fontWeight: z.union([z.string(), z.number()]).optional(),
lineHeight: z.union([z.string(), z.number()]).optional(),
letterSpacing: z.union([z.string(), z.number()]).optional(),
fluentToken: z.string().optional(),
});
export const SpacingTokenSchema = z.object({
value: z.union([z.string(), z.number()]),
fluentToken: z.string().optional(),
});
export const MotionTokenSchema = z.object({
duration: z.string().optional(),
easing: z.string().optional(),
fluentToken: z.string().optional(),
});
export const TokensSchema = z.object({
colors: z.record(ColorTokenSchema).optional(),
typography: z.record(TypographyTokenSchema).optional(),
spacing: z.record(SpacingTokenSchema).optional(),
motion: z.record(MotionTokenSchema).optional(),
});
// ── Component rules ───────────────────────────────────────────────────────────
export const PropRuleSchema = z.object({
allowed: z.array(z.unknown()).optional(),
forbidden: z.array(z.unknown()).optional(),
required: z.boolean().optional(),
type: z.string().optional(),
});
export const ComponentRuleSchema = z.object({
/** Allowed prop values, keyed by prop name */
props: z.record(PropRuleSchema).optional(),
/** Fluent 2 variants that are explicitly forbidden */
forbiddenVariants: z.array(z.string()).optional(),
/** ARIA attributes that are required on this component */
requiredAria: z.array(z.string()).optional(),
notes: z.string().optional(),
});
// ── Screen rules ──────────────────────────────────────────────────────────────
export const ScreenRuleSchema = z.object({
/** Components that are allowed to appear on this screen */
allowedComponents: z.array(z.string()).optional(),
/** Components that must be present on this screen */
requiredSections: z.array(z.string()).optional(),
layout: z.string().optional(),
notes: z.string().optional(),
});
// ── Voice / tone ──────────────────────────────────────────────────────────────
export const VoiceRuleSchema = z.object({
tone: z.string().optional(),
maxSentenceLength: z.number().optional(),
avoidWords: z.array(z.string()).optional(),
preferWords: z.array(z.string()).optional(),
examples: z.array(z.string()).optional(),
});
// ── Accessibility ─────────────────────────────────────────────────────────────
export const AccessibilitySchema = z.object({
wcagLevel: z.enum(['A', 'AA', 'AAA']).optional(),
contrastRatio: z.number().optional(),
customRules: z.array(z.string()).optional(),
});
// ── Design Language File ──────────────────────────────────────────────────────
export const DesignLanguageFileBodySchema = z.object({
/** Semantic version, e.g. "1.0.0" */
version: z.string().optional(),
/** Human-readable name, e.g. "Acme Design System" */
name: z.string().optional(),
tokens: TokensSchema.optional(),
/** Per-component rules, keyed by component display name */
components: z.record(ComponentRuleSchema).optional(),
/** Per-screen rules, keyed by screen name or route pattern */
screens: z.record(ScreenRuleSchema).optional(),
voice: VoiceRuleSchema.optional(),
accessibility: AccessibilitySchema.optional(),
});
export type DesignLanguageFileBody = z.infer<typeof DesignLanguageFileBodySchema>;
export type Tokens = z.infer<typeof TokensSchema>;
export type ComponentRule = z.infer<typeof ComponentRuleSchema>;
export type ScreenRule = z.infer<typeof ScreenRuleSchema>;
+54
View File
@@ -0,0 +1,54 @@
import type { DesignLanguageFileBody } from './schema.js';
// ── Fluent 2 token mapping ────────────────────────────────────────────────────
// Maps DLF token names to Fluent 2 (Griffel) CSS custom property names.
// A token entry with a `fluentToken` field overrides the default Fluent 2 value.
export type FluentTokenMap = Record<string, string>;
/** Extract color token overrides from a DLF as a Fluent 2 token map. */
export function extractColorTokens(dlf: DesignLanguageFileBody): FluentTokenMap {
const out: FluentTokenMap = {};
const colors = dlf.tokens?.colors ?? {};
for (const [, token] of Object.entries(colors)) {
if (!token) continue;
if (token.fluentToken) {
out[token.fluentToken] = token.value;
}
}
return out;
}
/** Convert a Fluent 2 token map to CSS custom properties for injection. */
export function tokensToCssVars(tokens: FluentTokenMap): string {
const entries = Object.entries(tokens)
.map(([name, value]) => ` --${camelToKebab(name)}: ${value};`)
.join('\n');
return `:root {\n${entries}\n}`;
}
/** Convert a DLF to a CSS var block suitable for injection into the renderer iframe. */
export function dlfToCssVars(dlf: DesignLanguageFileBody): string {
return tokensToCssVars(extractColorTokens(dlf));
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function camelToKebab(str: string): string {
return str.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`);
}
/** Build a short human-readable summary of the DLF's token count for logging. */
export function dlfSummary(dlf: DesignLanguageFileBody): string {
const tokenCounts = {
colors: Object.keys(dlf.tokens?.colors ?? {}).length,
typography: Object.keys(dlf.tokens?.typography ?? {}).length,
spacing: Object.keys(dlf.tokens?.spacing ?? {}).length,
components: Object.keys(dlf.components ?? {}).length,
screens: Object.keys(dlf.screens ?? {}).length,
};
return Object.entries(tokenCounts)
.filter(([, v]) => v > 0)
.map(([k, v]) => `${v} ${k}`)
.join(', ');
}
+89
View File
@@ -0,0 +1,89 @@
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from './schema.js';
import type { ZodError } from 'zod';
// ── Validation result ─────────────────────────────────────────────────────────
export interface ValidationSuccess {
valid: true;
dlf: DesignLanguageFileBody;
}
export interface ValidationFailure {
valid: false;
errors: ValidationError[];
}
export interface ValidationError {
path: string;
message: string;
}
export type ValidationResult = ValidationSuccess | ValidationFailure;
// ── Parse & validate ──────────────────────────────────────────────────────────
export function validateDesignLanguageFile(input: unknown): ValidationResult {
const result = DesignLanguageFileBodySchema.safeParse(input);
if (result.success) {
return { valid: true, dlf: result.data };
}
return { valid: false, errors: formatZodErrors(result.error) };
}
function formatZodErrors(error: ZodError): ValidationError[] {
return error.errors.map(e => ({
path: e.path.join('.') || '(root)',
message: e.message,
}));
}
// ── Runtime constraint checks ─────────────────────────────────────────────────
// These run during visual edits and AI completions to catch violations
// against the active DLF before they're shown to the user.
export interface ViolationCheck {
/** Name of the component being checked */
componentName: string;
/** Props being applied */
props: Record<string, unknown>;
/** The active DLF */
dlf: DesignLanguageFileBody;
}
export interface Violation {
prop: string;
value: unknown;
message: string;
severity: 'error' | 'warning';
}
export function checkComponentConstraints(check: ViolationCheck): Violation[] {
const { componentName, props, dlf } = check;
const violations: Violation[] = [];
const componentRule = dlf.components?.[componentName];
if (!componentRule) return violations;
const { props: propRules } = componentRule;
if (!propRules) return violations;
for (const [propKey, rule] of Object.entries(propRules)) {
const value = props[propKey];
// Use hasOwnProperty to distinguish "key absent" from "key set to undefined".
// Under exactOptionalPropertyTypes these are semantically different.
if (rule.required && !Object.prototype.hasOwnProperty.call(props, propKey)) {
violations.push({ prop: propKey, value, message: `"${propKey}" is required by design system rules`, severity: 'error' });
}
if (rule.forbidden && value !== undefined && rule.forbidden.includes(value)) {
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is forbidden by design system rules`, severity: 'error' });
}
if (rule.allowed && value !== undefined && !rule.allowed.includes(value)) {
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is not in the allowed values list`, severity: 'warning' });
}
}
return violations;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,84 @@
import { z } from 'zod';
import type { OriginIngester, IngestionResult } from '../types.js';
// ── GitHub Webhook payload ────────────────────────────────────────────────────
// Handles pull_request events (opened, synchronize, reopened).
// Ref: https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request
const GitHubUserSchema = z.object({
login: z.string(),
html_url: z.string().url(),
});
const GitHubRepositorySchema = z.object({
id: z.number(),
full_name: z.string(),
html_url: z.string().url(),
default_branch: z.string(),
});
const GitHubPullRequestPayloadSchema = z.object({
action: z.enum(['opened', 'synchronize', 'reopened', 'closed']),
number: z.number().int(),
pull_request: z.object({
id: z.number(),
number: z.number().int(),
title: z.string(),
html_url: z.string().url(),
state: z.enum(['open', 'closed']),
head: z.object({
sha: z.string().length(40),
ref: z.string(),
label: z.string(),
}),
base: z.object({
sha: z.string().length(40),
ref: z.string(),
}),
user: GitHubUserSchema,
body: z.string().nullable().optional(),
draft: z.boolean().optional(),
}),
repository: GitHubRepositorySchema,
sender: GitHubUserSchema,
});
export type GitHubPullRequestPayload = z.infer<typeof GitHubPullRequestPayloadSchema>;
// ── Connector ─────────────────────────────────────────────────────────────────
export const githubIngester: OriginIngester<GitHubPullRequestPayload> = {
parsePayload(raw) {
return GitHubPullRequestPayloadSchema.parse(raw);
},
ingest(payload): IngestionResult {
const { pull_request: pr, repository } = payload;
// The render URL points to the head commit's deployed preview if available.
// Conventionally: https://<pr-number>.<preview-domain> — caller overrides as needed.
const renderUrl = pr.html_url;
return {
origin: {
type: 'GIT_COMMIT',
source_ref: pr.head.sha,
source_metadata_jsonb: {
pr_number: pr.number,
pr_title: pr.title,
pr_url: pr.html_url,
head_sha: pr.head.sha,
head_ref: pr.head.ref,
base_sha: pr.base.sha,
base_ref: pr.base.ref,
repo: repository.full_name,
author: pr.user.login,
...(pr.body !== undefined && pr.body !== null ? { body: pr.body } : {}),
...(pr.draft !== undefined ? { draft: pr.draft } : {}),
},
},
artboardTitle: `PR #${pr.number}: ${pr.title}`,
renderUrl,
};
},
};
@@ -0,0 +1,109 @@
import { z } from 'zod';
import type { OriginIngester, IngestionResult } from '../types.js';
// ── Intercom Webhook payload ──────────────────────────────────────────────────
// Handles conversation.user.created events where users report UI issues.
// Intercom sends annotated screenshots as file_url attachments.
// Ref: https://developers.intercom.com/docs/references/webhooks/conversation/
const IntercomAttachmentSchema = z.object({
type: z.literal('upload'),
name: z.string(),
url: z.string().url(),
content_type: z.string().optional(),
filesize: z.number().optional(),
width: z.number().optional(),
height: z.number().optional(),
});
const IntercomUserSchema = z.object({
type: z.enum(['user', 'lead']),
id: z.string(),
email: z.string().email().optional(),
name: z.string().optional(),
});
const IntercomConversationPartSchema = z.object({
type: z.literal('conversation_part'),
body: z.string().nullable().optional(),
attachments: z.array(IntercomAttachmentSchema).optional(),
author: z.object({
type: z.string(),
id: z.string(),
email: z.string().optional(),
name: z.string().optional(),
}).optional(),
});
const IntercomWebhookPayloadSchema = z.object({
type: z.literal('notification_event'),
topic: z.string(),
data: z.object({
type: z.literal('notification_event_data'),
item: z.object({
type: z.literal('conversation'),
id: z.string(),
created_at: z.number(),
source: z.object({
type: z.string(),
subject: z.string().optional(),
body: z.string().nullable().optional(),
attachments: z.array(IntercomAttachmentSchema).optional(),
author: IntercomUserSchema.optional(),
}),
conversation_parts: z.object({
conversation_parts: z.array(IntercomConversationPartSchema).optional(),
}).optional(),
}),
}),
});
export type IntercomWebhookPayload = z.infer<typeof IntercomWebhookPayloadSchema>;
// ── Connector ─────────────────────────────────────────────────────────────────
export const intercomIngester: OriginIngester<IntercomWebhookPayload> = {
parsePayload(raw) {
return IntercomWebhookPayloadSchema.parse(raw);
},
ingest(payload): IngestionResult {
const { item } = payload.data;
const { source } = item;
// Prefer annotated screenshot from the source message
const imageAttachment = source.attachments?.find(a =>
a.content_type?.startsWith('image/')
);
const authorName = source.author?.name ?? source.author?.email ?? 'Unknown user';
const subject = source.subject ?? `User report from ${authorName}`;
return {
origin: {
type: 'URL',
source_ref: item.id,
source_metadata_jsonb: {
conversation_id: item.id,
topic: payload.topic,
subject,
body: source.body ?? '',
created_at: item.created_at,
author: {
...(source.author?.id !== undefined ? { id: source.author.id } : {}),
...(source.author?.email !== undefined ? { email: source.author.email } : {}),
...(source.author?.name !== undefined ? { name: source.author.name } : {}),
},
...(imageAttachment !== undefined ? {
screenshot_url: imageAttachment.url,
screenshot_name: imageAttachment.name,
...(imageAttachment.width !== undefined ? { width: imageAttachment.width } : {}),
...(imageAttachment.height !== undefined ? { height: imageAttachment.height } : {}),
} : {}),
},
},
artboardTitle: `Intercom: ${subject}`,
...(imageAttachment !== undefined ? { renderUrl: imageAttachment.url } : {}),
};
},
};
@@ -0,0 +1,65 @@
import { z } from 'zod';
import type { OriginIngester, IngestionResult } from '../types.js';
// ── Linear webhook payload ────────────────────────────────────────────────────
// Fired on Issue create/update events.
const LinearAttachmentSchema = z.object({
url: z.string().url().optional(),
title: z.string().optional(),
});
const LinearIssuePayloadSchema = z.object({
action: z.enum(['create', 'update', 'remove']),
type: z.literal('Issue'),
data: z.object({
id: z.string(),
identifier: z.string(),
title: z.string(),
description: z.string().optional(),
url: z.string().url(),
priority: z.number().int().min(0).max(4).optional(),
state: z.object({ name: z.string() }).optional(),
assignee: z.object({ name: z.string(), email: z.string() }).optional(),
attachments: z.array(LinearAttachmentSchema).optional(),
team: z.object({ id: z.string(), name: z.string() }),
}),
updatedFrom: z.record(z.unknown()).optional(),
});
export type LinearIssuePayload = z.infer<typeof LinearIssuePayloadSchema>;
// ── Connector ─────────────────────────────────────────────────────────────────
export const linearIngester: OriginIngester<LinearIssuePayload> = {
parsePayload(raw) {
return LinearIssuePayloadSchema.parse(raw);
},
ingest(payload): IngestionResult {
const { data } = payload;
const attachment = data.attachments?.find(a => a.url !== undefined);
const renderUrl = attachment?.url ?? data.url;
return {
origin: {
type: 'LINEAR_ISSUE',
source_ref: data.identifier,
source_metadata_jsonb: {
id: data.id,
identifier: data.identifier,
title: data.title,
url: data.url,
...(data.description !== undefined ? { description: data.description } : {}),
...(data.priority !== undefined ? { priority: data.priority } : {}),
...(data.state !== undefined ? { state: data.state.name } : {}),
...(data.assignee !== undefined ? { assignee: data.assignee.name } : {}),
team: data.team.name,
},
},
artboardTitle: `${data.identifier}: ${data.title}`,
renderUrl,
};
},
};
@@ -0,0 +1,74 @@
import { z } from 'zod';
import type { OriginIngester, IngestionResult } from '../types.js';
// ── Slack Event API payload ───────────────────────────────────────────────────
// Sent when a message is posted to a channel the app is subscribed to.
// Ref: https://api.slack.com/events/message
const SlackFileSchema = z.object({
id: z.string(),
name: z.string().optional(),
mimetype: z.string().optional(),
url_private: z.string().optional(),
permalink: z.string().optional(),
});
const SlackMessageEventSchema = z.object({
type: z.literal('event_callback'),
event_id: z.string(),
team_id: z.string(),
event: z.object({
type: z.literal('message'),
channel: z.string(),
channel_name: z.string().optional(),
user: z.string(),
text: z.string(),
ts: z.string(),
thread_ts: z.string().optional(),
files: z.array(SlackFileSchema).optional(),
}),
authorizations: z.array(z.object({ user_id: z.string() })).optional(),
});
export type SlackMessagePayload = z.infer<typeof SlackMessageEventSchema>;
// ── Connector ─────────────────────────────────────────────────────────────────
export const slackIngester: OriginIngester<SlackMessagePayload> = {
parsePayload(raw) {
return SlackMessageEventSchema.parse(raw);
},
ingest(payload): IngestionResult {
const { event, team_id } = payload;
// Extract first image attachment as the render target
const imageFile = event.files?.find(f =>
f.mimetype?.startsWith('image/') && f.url_private !== undefined
);
const renderUrl = imageFile?.url_private ?? imageFile?.permalink;
const channelLabel = event.channel_name ?? event.channel;
const unixTs = parseFloat(event.ts);
const date = new Date(unixTs * 1000).toISOString().slice(0, 10);
return {
origin: {
type: 'SLACK_MESSAGE',
source_ref: `${event.channel}:${event.ts}`,
source_metadata_jsonb: {
team_id,
channel: event.channel,
channel_name: channelLabel,
user: event.user,
text: event.text,
ts: event.ts,
...(event.thread_ts !== undefined ? { thread_ts: event.thread_ts } : {}),
...(imageFile !== undefined ? { image_file_id: imageFile.id } : {}),
},
},
artboardTitle: `Slack: #${channelLabel} (${date})`,
...(renderUrl !== undefined ? { renderUrl } : {}),
};
},
};
+14 -1
View File
@@ -1 +1,14 @@
export {}; export type { IngestionResult, OriginIngester, WebhookEnvelope } from './types.js';
export { WebhookEnvelopeSchema } from './types.js';
export type { LinearIssuePayload } from './connectors/linear.js';
export { linearIngester } from './connectors/linear.js';
export type { SlackMessagePayload } from './connectors/slack.js';
export { slackIngester } from './connectors/slack.js';
export type { GitHubPullRequestPayload } from './connectors/github.js';
export { githubIngester } from './connectors/github.js';
export type { IntercomWebhookPayload } from './connectors/intercom.js';
export { intercomIngester } from './connectors/intercom.js';
+28
View File
@@ -0,0 +1,28 @@
import { z } from 'zod';
import type { InsertOrigin } from '@originmain/origin-graph';
// ── Core ingester interface ───────────────────────────────────────────────────
export interface IngestionResult {
origin: InsertOrigin;
/** Human-readable label used as the artboard title */
artboardTitle: string;
/** URL that the Live Artboard renderer should load, if applicable */
renderUrl?: string;
}
export interface OriginIngester<TPayload> {
/** Validates and parses the raw webhook payload. Throws ZodError on invalid input. */
parsePayload(raw: unknown): TPayload;
/** Converts a validated payload into an IngestionResult. */
ingest(payload: TPayload): IngestionResult;
}
// ── Shared helpers ────────────────────────────────────────────────────────────
export const WebhookEnvelopeSchema = z.object({
timestamp: z.string().optional(),
signature: z.string().optional(),
});
export type WebhookEnvelope = z.infer<typeof WebhookEnvelopeSchema>;
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@originmain/multiplayer",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@originmain/origin-graph": "workspace:*",
"zod": "^3.0.0"
},
"peerDependencies": {
"@liveblocks/client": "^2.0.0",
"@liveblocks/react": "^2.0.0"
},
"peerDependenciesMeta": {
"@liveblocks/client": { "optional": true },
"@liveblocks/react": { "optional": true }
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
+89
View File
@@ -0,0 +1,89 @@
import type { ArtboardStorageObject, UserPresence } from './room-schema.js';
// ── MultiplayerAdapter interface ──────────────────────────────────────────────
// The single contract that both the Zustand (Phase 1) and Liveblocks (Phase 3)
// implementations must satisfy. Components import this interface, never a
// concrete store, so the swap is a one-line dependency injection change.
export interface MultiplayerAdapter {
// ── Presence ────────────────────────────────────────────────────────────────
/** Returns the calling user's own presence object. */
getSelfPresence(): UserPresence | null;
/** Returns presence objects for all other users in the room. */
getOthersPresence(): readonly UserPresence[];
/**
* Update fields of the calling user's own presence.
* Each field is optional (omit to leave unchanged). Under exactOptionalPropertyTypes,
* Partial<> is not used here because it conflates "key absent" with "key = undefined"
* for non-nullable fields like cursor. Use null to explicitly clear cursor position.
*/
updatePresence(patch: {
cursor?: { x: number; y: number } | null;
activeArtboardId?: string | null;
selectedComponentIds?: readonly string[];
}): void;
// ── Artboard storage ─────────────────────────────────────────────────────────
/** Returns the current storage object for an artboard, or null if not found. */
getArtboard(artboardId: string): ArtboardStorageObject | null;
/** Returns all artboard storage objects in the workspace. */
getAllArtboards(): readonly ArtboardStorageObject[];
/** Returns the artboard display order (array of IDs). */
getArtboardOrder(): readonly string[];
/** Upserts an artboard storage object. Creates it if it doesn't exist. */
upsertArtboard(artboard: ArtboardStorageObject): void;
/** Reorders artboards by providing the new full ordering. */
setArtboardOrder(order: readonly string[]): void;
/** Locks an artboard so other users cannot make edits. */
lockArtboard(artboardId: string): void;
/** Unlocks an artboard. */
unlockArtboard(artboardId: string): void;
// ── Connection state ─────────────────────────────────────────────────────────
/** Whether the adapter is connected to a live room or operating locally. */
isConnected(): boolean;
/**
* Subscribe to state changes. Returns an unsubscribe function.
* Implementation must call the callback on every presence or storage mutation.
*/
subscribe(callback: () => void): () => void;
}
// ── Phase 1 stub implementation ───────────────────────────────────────────────
// Used during Phase 1 and 2 development (no Liveblocks dependency).
// All reads return empty/null; writes are no-ops.
// Phase 3 replaces this with LiveblocksAdapter from packages/app/src/lib/liveblocks.ts.
export function createLocalAdapter(): MultiplayerAdapter {
const listeners = new Set<() => void>();
return {
getSelfPresence: () => null,
getOthersPresence: () => [],
updatePresence: () => undefined,
getArtboard: () => null,
getAllArtboards: () => [],
getArtboardOrder: () => [],
upsertArtboard: () => undefined,
setArtboardOrder: () => undefined,
lockArtboard: () => undefined,
unlockArtboard: () => undefined,
isConnected: () => false,
subscribe(cb) {
listeners.add(cb);
return () => { listeners.delete(cb); };
},
};
}
+30
View File
@@ -0,0 +1,30 @@
// ── Cursor colour palette ─────────────────────────────────────────────────────
// 12-colour WCAG AA-passing palette for presence cursors.
// Colours are assigned deterministically by user ID so they're stable across reconnects.
const PALETTE = [
'#E03D3D', // red
'#E07B3D', // orange
'#D4A017', // amber
'#4CAF50', // green
'#2196F3', // blue
'#9C27B0', // purple
'#00BCD4', // cyan
'#FF4081', // pink
'#8BC34A', // lime
'#FF5722', // deep-orange
'#3F51B5', // indigo
'#009688', // teal
] as const;
/** Assigns a cursor colour to a user ID deterministically. */
export function cursorColorForUser(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = (hash * 31 + userId.charCodeAt(i)) >>> 0;
}
const color = PALETTE[hash % PALETTE.length];
return color ?? PALETTE[0];
}
export { PALETTE as CURSOR_PALETTE };
+14
View File
@@ -0,0 +1,14 @@
export type {
UserPresence,
InitialPresence,
ArtboardStorageObject,
WorkspaceStorage,
LiveblocksRoomTypes,
RoomEvent,
} from './room-schema.js';
export { UserPresenceSchema, workspaceRoomId } from './room-schema.js';
export type { MultiplayerAdapter } from './adapter.js';
export { createLocalAdapter } from './adapter.js';
export { cursorColorForUser, CURSOR_PALETTE } from './cursor-colors.js';
+93
View File
@@ -0,0 +1,93 @@
// ── Liveblocks Room Schema ────────────────────────────────────────────────────
// Defines the TypeScript shapes for a Liveblocks room.
// One room = one Workspace. Each artboard is a LiveObject in Storage.
//
// IMPORTANT: Liveblocks is a Phase 3 dependency. This file defines types only.
// Do NOT import @liveblocks/client here — the peer dep is optional until Phase 3.
import { z } from 'zod';
// ── Presence ──────────────────────────────────────────────────────────────────
// Updated at ≤50ms throttle as the user moves the cursor or changes selection.
export interface UserPresence {
/** Liveblocks user ID (from Clerk JWT sub) */
userId: string;
displayName: string;
/** Hex colour assigned to this user's cursor */
cursorColor: string;
/** Canvas-space coordinates of the user's cursor, or null if off-canvas */
cursor: { x: number; y: number } | null;
/** Currently focused artboard ID */
activeArtboardId: string | null;
/** IDs of currently selected components (in the active artboard) */
selectedComponentIds: readonly string[];
}
export type InitialPresence = Omit<UserPresence, 'userId' | 'displayName' | 'cursorColor'>;
// ── Storage ───────────────────────────────────────────────────────────────────
// CRDT-backed shared state. Mutations are conflict-free via Liveblocks LiveObject/LiveMap.
/** Serializable artboard state stored as a Liveblocks LiveObject. */
export interface ArtboardStorageObject {
id: string;
name: string;
/** Viewport transform: scale + translate */
viewport: { scale: number; translateX: number; translateY: number };
/** Locked artboards cannot be edited by collaborators */
locked: boolean;
/** ISO timestamp of the last write to this artboard's storage object */
lastModifiedAt: string;
lastModifiedByUserId: string;
}
/** Top-level Liveblocks Storage shape for a Workspace room. */
export interface WorkspaceStorage {
/** LiveMap<artboardId, ArtboardStorageObject> — one entry per artboard */
artboards: Record<string, ArtboardStorageObject>;
/** Ordering of artboard IDs in the navigator panel (drag-to-reorder) */
artboardOrder: readonly string[];
}
// ── Liveblocks room config types ──────────────────────────────────────────────
// These shapes mirror the generic parameters expected by createRoomContext.
// Used as TypeScript contracts; actual createClient / createRoomContext calls
// live in the Phase 3 integration (packages/app/src/lib/liveblocks.ts).
export interface LiveblocksRoomTypes {
Presence: UserPresence;
Storage: WorkspaceStorage;
UserMeta: {
id: string;
info: { name: string; avatar?: string };
};
RoomEvent: RoomEvent;
}
// ── Room events ───────────────────────────────────────────────────────────────
// Broadcast events sent between users without CRDT persistence.
export type RoomEvent =
| { type: 'ARTBOARD_LOCKED'; artboardId: string; byUserId: string }
| { type: 'ARTBOARD_UNLOCKED'; artboardId: string; byUserId: string }
| { type: 'DIFF_EXPORTED'; diffId: string; byUserId: string }
| { type: 'COMPLETION_ZONE_ACCEPTED'; zoneId: string; artboardId: string };
// ── Room ID convention ────────────────────────────────────────────────────────
/** Derives the Liveblocks room ID from a workspace UUID. */
export function workspaceRoomId(workspaceId: string): string {
return `workspace:${workspaceId}`;
}
// ── Zod schema for presence validation ───────────────────────────────────────
export const UserPresenceSchema = z.object({
userId: z.string(),
displayName: z.string(),
cursorColor: z.string().regex(/^#[0-9a-f]{6}$/i),
cursor: z.object({ x: z.number(), y: z.number() }).nullable(),
activeArtboardId: z.string().nullable(),
selectedComponentIds: z.array(z.string()),
});
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -0,0 +1,174 @@
-- Origin Graph — Initial Schema
-- Migration: 001
-- All tables use UUIDs as primary keys and include created_at / updated_at.
-- Enable pgcrypto for gen_random_uuid() if not already enabled.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ── Enum types ────────────────────────────────────────────────────────────────
CREATE TYPE origin_type AS ENUM (
'GIT_COMMIT',
'LINEAR_ISSUE',
'SLACK_MESSAGE',
'URL',
'FORK'
);
CREATE TYPE diff_status AS ENUM (
'DRAFT',
'EXPORTED',
'IMPLEMENTED',
'BLOCKED'
);
CREATE TYPE team_role AS ENUM (
'OWNER',
'DESIGNER',
'ENGINEER',
'PM',
'VIEWER'
);
CREATE TYPE agent_type AS ENUM (
'CURSOR',
'CLAUDE_CODE',
'GENERIC'
);
CREATE TYPE workspace_plan AS ENUM (
'FREE',
'TEAM',
'ENTERPRISE'
);
CREATE TYPE agent_session_status AS ENUM (
'ACTIVE',
'COMPLETED',
'FAILED'
);
-- ── workspaces ────────────────────────────────────────────────────────────────
CREATE TABLE workspaces (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id TEXT NOT NULL, -- Clerk user ID
plan workspace_plan NOT NULL DEFAULT 'FREE',
settings_jsonb JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ── origins ───────────────────────────────────────────────────────────────────
CREATE TABLE origins (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
type origin_type NOT NULL,
source_ref TEXT NOT NULL,
source_metadata_jsonb JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ── artboards ─────────────────────────────────────────────────────────────────
CREATE TABLE artboards (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
origin_id UUID REFERENCES origins(id) ON DELETE SET NULL,
parent_artboard_id UUID REFERENCES artboards(id) ON DELETE SET NULL,
metadata_jsonb JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX artboards_workspace_idx ON artboards(workspace_id);
CREATE INDEX artboards_parent_idx ON artboards(parent_artboard_id);
-- ── intent_diffs ──────────────────────────────────────────────────────────────
CREATE TABLE intent_diffs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
artboard_id UUID NOT NULL REFERENCES artboards(id) ON DELETE CASCADE,
author_id TEXT NOT NULL, -- Clerk user ID
changes_jsonb JSONB NOT NULL DEFAULT '{}',
summary TEXT NOT NULL DEFAULT '',
status diff_status NOT NULL DEFAULT 'DRAFT',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX intent_diffs_artboard_idx ON intent_diffs(artboard_id);
CREATE INDEX intent_diffs_status_idx ON intent_diffs(status);
-- ── agent_sessions ────────────────────────────────────────────────────────────
CREATE TABLE agent_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
artboard_id UUID NOT NULL REFERENCES artboards(id) ON DELETE CASCADE,
diff_id UUID REFERENCES intent_diffs(id) ON DELETE SET NULL,
agent_type agent_type NOT NULL,
messages_jsonb JSONB NOT NULL DEFAULT '[]',
status agent_session_status NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX agent_sessions_artboard_idx ON agent_sessions(artboard_id);
CREATE INDEX agent_sessions_diff_idx ON agent_sessions(diff_id);
-- ── design_language_files ─────────────────────────────────────────────────────
CREATE TABLE design_language_files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
schema_jsonb JSONB NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX dlf_workspace_idx ON design_language_files(workspace_id);
-- ── team_members ──────────────────────────────────────────────────────────────
CREATE TABLE team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL, -- Clerk user ID
role team_role NOT NULL DEFAULT 'VIEWER',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, user_id)
);
CREATE INDEX team_members_workspace_idx ON team_members(workspace_id);
CREATE INDEX team_members_user_idx ON team_members(user_id);
-- ── updated_at trigger ────────────────────────────────────────────────────────
CREATE OR REPLACE FUNCTION set_updated_at()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$;
DO $$
DECLARE t TEXT;
BEGIN
FOREACH t IN ARRAY ARRAY['workspaces','origins','artboards','intent_diffs',
'agent_sessions','design_language_files','team_members']
LOOP
EXECUTE format(
'CREATE TRIGGER trg_%I_updated_at
BEFORE UPDATE ON %I
FOR EACH ROW EXECUTE FUNCTION set_updated_at()',
t, t
);
END LOOP;
END;
$$;
@@ -0,0 +1,46 @@
-- Origin Graph — Artboard Ancestry Materialized View
-- Migration: 002
-- Pre-computes all ancestor/descendant relationships so the app never needs
-- recursive CTEs at query time. Updated automatically on artboards INSERT.
CREATE MATERIALIZED VIEW artboard_ancestry AS
WITH RECURSIVE ancestry(artboard_id, ancestor_id, depth) AS (
-- Base: each artboard is at depth 0 relative to itself
SELECT id AS artboard_id, id AS ancestor_id, 0 AS depth
FROM artboards
UNION ALL
-- Recurse: walk up the parent chain
SELECT a.id AS artboard_id, anc.ancestor_id, anc.depth + 1
FROM artboards a
JOIN ancestry anc ON a.parent_artboard_id = anc.artboard_id
)
SELECT artboard_id, ancestor_id, depth
FROM ancestry
WHERE artboard_id <> ancestor_id -- exclude self-reference
ORDER BY artboard_id, depth;
CREATE UNIQUE INDEX artboard_ancestry_pk
ON artboard_ancestry(artboard_id, ancestor_id);
CREATE INDEX artboard_ancestry_ancestor_idx
ON artboard_ancestry(ancestor_id);
-- ── Refresh trigger ───────────────────────────────────────────────────────────
-- Refreshes the materialized view concurrently whenever an artboard is
-- inserted. CONCURRENTLY requires the unique index above — it allows reads
-- to continue during refresh.
CREATE OR REPLACE FUNCTION refresh_artboard_ancestry()
RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY artboard_ancestry;
RETURN NULL;
END;
$$;
CREATE TRIGGER trg_artboard_ancestry_refresh
AFTER INSERT OR UPDATE OF parent_artboard_id ON artboards
FOR EACH STATEMENT
EXECUTE FUNCTION refresh_artboard_ancestry();
@@ -0,0 +1,111 @@
-- Origin Graph — Row-Level Security Policies
-- Migration: 003
-- All tables are workspace-scoped. A user may only read or write rows in
-- workspaces where they have a team_members record. The Clerk JWT is verified
-- server-side; auth.uid() maps to the Clerk user_id column.
-- Enable RLS on every table
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE artboards ENABLE ROW LEVEL SECURITY;
ALTER TABLE origins ENABLE ROW LEVEL SECURITY;
ALTER TABLE intent_diffs ENABLE ROW LEVEL SECURITY;
ALTER TABLE agent_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE design_language_files ENABLE ROW LEVEL SECURITY;
ALTER TABLE team_members ENABLE ROW LEVEL SECURITY;
-- ── Helper: is the current user a member of the given workspace? ──────────────
CREATE OR REPLACE FUNCTION is_workspace_member(ws_id UUID)
RETURNS BOOLEAN LANGUAGE sql SECURITY DEFINER AS $$
SELECT EXISTS (
SELECT 1 FROM team_members
WHERE workspace_id = ws_id
AND user_id = auth.uid()::TEXT
);
$$;
-- ── workspaces ────────────────────────────────────────────────────────────────
CREATE POLICY workspaces_select ON workspaces
FOR SELECT USING (is_workspace_member(id));
CREATE POLICY workspaces_insert ON workspaces
FOR INSERT WITH CHECK (owner_id = auth.uid()::TEXT);
CREATE POLICY workspaces_update ON workspaces
FOR UPDATE USING (owner_id = auth.uid()::TEXT);
-- ── artboards ─────────────────────────────────────────────────────────────────
CREATE POLICY artboards_select ON artboards
FOR SELECT USING (is_workspace_member(workspace_id));
CREATE POLICY artboards_insert ON artboards
FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
CREATE POLICY artboards_update ON artboards
FOR UPDATE USING (is_workspace_member(workspace_id));
CREATE POLICY artboards_delete ON artboards
FOR DELETE USING (is_workspace_member(workspace_id));
-- ── intent_diffs ──────────────────────────────────────────────────────────────
-- Derived from artboard's workspace membership
CREATE POLICY intent_diffs_select ON intent_diffs
FOR SELECT USING (
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
);
CREATE POLICY intent_diffs_insert ON intent_diffs
FOR INSERT WITH CHECK (
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
);
CREATE POLICY intent_diffs_update ON intent_diffs
FOR UPDATE USING (
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
);
-- ── agent_sessions ────────────────────────────────────────────────────────────
CREATE POLICY agent_sessions_select ON agent_sessions
FOR SELECT USING (
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
);
CREATE POLICY agent_sessions_insert ON agent_sessions
FOR INSERT WITH CHECK (
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
);
CREATE POLICY agent_sessions_update ON agent_sessions
FOR UPDATE USING (
is_workspace_member((SELECT workspace_id FROM artboards WHERE id = artboard_id))
);
-- ── design_language_files ─────────────────────────────────────────────────────
CREATE POLICY dlf_select ON design_language_files
FOR SELECT USING (is_workspace_member(workspace_id));
CREATE POLICY dlf_insert ON design_language_files
FOR INSERT WITH CHECK (is_workspace_member(workspace_id));
CREATE POLICY dlf_update ON design_language_files
FOR UPDATE USING (is_workspace_member(workspace_id));
-- ── team_members ──────────────────────────────────────────────────────────────
CREATE POLICY team_members_select ON team_members
FOR SELECT USING (is_workspace_member(workspace_id));
-- Only workspace owners can add/remove members
CREATE POLICY team_members_insert ON team_members
FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = auth.uid()::TEXT)
);
CREATE POLICY team_members_delete ON team_members
FOR DELETE USING (
EXISTS (SELECT 1 FROM workspaces WHERE id = workspace_id AND owner_id = auth.uid()::TEXT)
);
@@ -0,0 +1,6 @@
-- ── Migration 004: Add notes column to intent_diffs ──────────────────────────
-- Allows the coding agent to record why a diff was blocked or any implementation
-- notes when updating status via the Agent Bridge MCP tool.
ALTER TABLE intent_diffs
ADD COLUMN IF NOT EXISTS notes TEXT;
+2 -1
View File
@@ -1 +1,2 @@
export {}; export * from './types.js';
export * from './queries.js';
+200
View File
@@ -0,0 +1,200 @@
// ── Query interface ───────────────────────────────────────────────────────────
// These functions describe the Origin Graph query surface. They accept a
// generic `db` client (matching Supabase's SupabaseClient shape) so the
// package doesn't take a hard dependency on @supabase/supabase-js. Wire them
// to TanStack Query's queryFn in the app layer.
import type {
Artboard,
Workspace,
IntentDiff,
DesignLanguageFile,
AgentSession,
TeamMember,
InsertArtboard,
InsertIntentDiff,
InsertAgentSession,
DiffStatus,
ArtboardAncestry,
} from './types.js';
// ── Minimal Supabase client interface ────────────────────────────────────────
export interface DbClient {
from(table: string): {
select(cols?: string): DbQuery;
insert(row: unknown): DbMutation;
update(row: unknown): DbMutation;
delete(): DbMutation;
};
rpc(fn: string, args?: unknown): Promise<{ data: unknown; error: DbError | null }>;
}
export interface DbQuery {
eq(col: string, val: unknown): DbQuery;
in(col: string, vals: unknown[]): DbQuery;
order(col: string, opts?: { ascending?: boolean }): DbQuery;
limit(n: number): DbQuery;
single(): Promise<{ data: unknown; error: DbError | null }>;
then(resolve: (result: { data: unknown[]; error: DbError | null }) => void): void;
}
export interface DbMutation {
eq(col: string, val: unknown): DbMutation;
select(cols?: string): DbMutation;
single(): Promise<{ data: unknown; error: DbError | null }>;
then(resolve: (result: { data: unknown; error: DbError | null }) => void): void;
}
export interface DbError {
message: string;
code?: string;
}
// ── Artboard queries ──────────────────────────────────────────────────────────
export async function getArtboards(db: DbClient, workspaceId: string): Promise<Artboard[]> {
const { data, error } = await (db
.from('artboards')
.select('*')
.eq('workspace_id', workspaceId)
.order('created_at', { ascending: false }) as unknown as Promise<{ data: Artboard[]; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function getArtboard(db: DbClient, id: string): Promise<Artboard> {
const { data, error } = await (db
.from('artboards')
.select('*')
.eq('id', id)
.single() as Promise<{ data: Artboard; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function createArtboard(db: DbClient, row: InsertArtboard): Promise<Artboard> {
const { data, error } = await (db
.from('artboards')
.insert(row)
.select()
.single() as Promise<{ data: Artboard; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function getArtboardAncestors(db: DbClient, artboardId: string): Promise<ArtboardAncestry[]> {
const { data, error } = await (db
.from('artboard_ancestry')
.select('*')
.eq('artboard_id', artboardId)
.order('depth', { ascending: true }) as unknown as Promise<{ data: ArtboardAncestry[]; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
// ── Intent diff queries ───────────────────────────────────────────────────────
export async function getDiffs(db: DbClient, artboardId: string): Promise<IntentDiff[]> {
const { data, error } = await (db
.from('intent_diffs')
.select('*')
.eq('artboard_id', artboardId)
.order('created_at', { ascending: false }) as unknown as Promise<{ data: IntentDiff[]; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function getDiffsByStatus(db: DbClient, workspaceId: string, status: DiffStatus): Promise<IntentDiff[]> {
// Join through artboards for workspace scoping
const { data, error } = await (db.rpc('get_diffs_by_status', { p_workspace_id: workspaceId, p_status: status }) as Promise<{ data: IntentDiff[]; error: DbError | null }>);
if (error) throw new Error(error.message);
return data as IntentDiff[];
}
export async function createDiff(db: DbClient, row: InsertIntentDiff): Promise<IntentDiff> {
const { data, error } = await (db
.from('intent_diffs')
.insert(row)
.select()
.single() as Promise<{ data: IntentDiff; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function getDiff(db: DbClient, id: string): Promise<IntentDiff> {
const { data, error } = await (db
.from('intent_diffs')
.select('*')
.eq('id', id)
.single() as Promise<{ data: IntentDiff; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function updateDiffStatus(
db: DbClient,
id: string,
status: DiffStatus,
notes?: string
): Promise<IntentDiff> {
const { data, error } = await (db
.from('intent_diffs')
.update({ status, ...(notes !== undefined ? { notes } : {}) })
.eq('id', id)
.select()
.single() as Promise<{ data: IntentDiff; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
// ── Design language file queries ──────────────────────────────────────────────
export async function getActiveDesignLanguageFile(
db: DbClient,
workspaceId: string
): Promise<DesignLanguageFile | null> {
const { data, error } = await (db
.from('design_language_files')
.select('*')
.eq('workspace_id', workspaceId)
.order('version', { ascending: false })
.limit(1)
.single() as Promise<{ data: DesignLanguageFile | null; error: DbError | null }>);
if (error?.code === 'PGRST116') return null; // No rows found
if (error) throw new Error(error.message);
return data;
}
// ── Agent session queries ─────────────────────────────────────────────────────
export async function createAgentSession(db: DbClient, row: InsertAgentSession): Promise<AgentSession> {
const { data, error } = await (db
.from('agent_sessions')
.insert(row)
.select()
.single() as Promise<{ data: AgentSession; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
// ── Workspace queries ─────────────────────────────────────────────────────────
export async function getWorkspace(db: DbClient, id: string): Promise<Workspace> {
const { data, error } = await (db
.from('workspaces')
.select('*')
.eq('id', id)
.single() as Promise<{ data: Workspace; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function getTeamMembers(db: DbClient, workspaceId: string): Promise<TeamMember[]> {
const { data, error } = await (db
.from('team_members')
.select('*')
.eq('workspace_id', workspaceId) as unknown as Promise<{ data: TeamMember[]; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
+135
View File
@@ -0,0 +1,135 @@
import { z } from 'zod';
// ── Enums ─────────────────────────────────────────────────────────────────────
export const OriginTypeSchema = z.enum([
'GIT_COMMIT',
'LINEAR_ISSUE',
'SLACK_MESSAGE',
'URL',
'FORK',
]);
export type OriginType = z.infer<typeof OriginTypeSchema>;
export const DiffStatusSchema = z.enum([
'DRAFT',
'EXPORTED',
'IMPLEMENTED',
'BLOCKED',
]);
export type DiffStatus = z.infer<typeof DiffStatusSchema>;
export const TeamRoleSchema = z.enum([
'OWNER',
'DESIGNER',
'ENGINEER',
'PM',
'VIEWER',
]);
export type TeamRole = z.infer<typeof TeamRoleSchema>;
export const AgentTypeSchema = z.enum(['CURSOR', 'CLAUDE_CODE', 'GENERIC']);
export type AgentType = z.infer<typeof AgentTypeSchema>;
export const WorkspacePlanSchema = z.enum(['FREE', 'TEAM', 'ENTERPRISE']);
export type WorkspacePlan = z.infer<typeof WorkspacePlanSchema>;
// ── Row types (match PostgreSQL columns 1:1) ──────────────────────────────────
export const WorkspaceSchema = z.object({
id: z.string().uuid(),
name: z.string(),
owner_id: z.string(),
plan: WorkspacePlanSchema,
settings_jsonb: z.record(z.unknown()),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type Workspace = z.infer<typeof WorkspaceSchema>;
export const ArtboardSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
name: z.string(),
origin_id: z.string().uuid().nullable(),
parent_artboard_id: z.string().uuid().nullable(),
metadata_jsonb: z.record(z.unknown()),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type Artboard = z.infer<typeof ArtboardSchema>;
export const OriginSchema = z.object({
id: z.string().uuid(),
type: OriginTypeSchema,
source_ref: z.string(),
source_metadata_jsonb: z.record(z.unknown()),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type Origin = z.infer<typeof OriginSchema>;
export const IntentDiffSchema = z.object({
id: z.string().uuid(),
artboard_id: z.string().uuid(),
author_id: z.string(),
changes_jsonb: z.record(z.unknown()),
summary: z.string(),
status: DiffStatusSchema,
/** Free-text notes from the coding agent (e.g. why it was blocked) */
notes: z.string().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type IntentDiff = z.infer<typeof IntentDiffSchema>;
export const AgentSessionSchema = z.object({
id: z.string().uuid(),
artboard_id: z.string().uuid(),
diff_id: z.string().uuid().nullable(),
agent_type: AgentTypeSchema,
messages_jsonb: z.array(z.record(z.unknown())),
status: z.enum(['ACTIVE', 'COMPLETED', 'FAILED']),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type AgentSession = z.infer<typeof AgentSessionSchema>;
export const DesignLanguageFileSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
name: z.string(),
schema_jsonb: z.record(z.unknown()),
version: z.number().int(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type DesignLanguageFile = z.infer<typeof DesignLanguageFileSchema>;
export const TeamMemberSchema = z.object({
id: z.string().uuid(),
workspace_id: z.string().uuid(),
user_id: z.string(),
role: TeamRoleSchema,
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
});
export type TeamMember = z.infer<typeof TeamMemberSchema>;
// ── Ancestry (from materialized view) ────────────────────────────────────────
export interface ArtboardAncestry {
artboard_id: string;
ancestor_id: string;
depth: number;
}
// ── Insert types (omit server-set fields) ────────────────────────────────────
export type InsertWorkspace = Omit<Workspace, 'id' | 'created_at' | 'updated_at'>;
export type InsertArtboard = Omit<Artboard, 'id' | 'created_at' | 'updated_at'>;
export type InsertOrigin = Omit<Origin, 'id' | 'created_at' | 'updated_at'>;
export type InsertIntentDiff = Omit<IntentDiff, 'id' | 'created_at' | 'updated_at'>;
export type InsertAgentSession = Omit<AgentSession, 'id' | 'created_at' | 'updated_at'>;
export type InsertDesignLanguageFile = Omit<DesignLanguageFile, 'id' | 'created_at' | 'updated_at'>;
export type InsertTeamMember = Omit<TeamMember, 'id' | 'created_at' | 'updated_at'>;
+20
View File
@@ -0,0 +1,20 @@
{
"name": "@originmain/platform",
"version": "0.0.1",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@originmain/origin-graph": "workspace:*",
"@originmain/design-language": "workspace:*",
"zod": "^3.0.0"
},
"devDependencies": {
"typescript": "^5.5.0"
}
}
+123
View File
@@ -0,0 +1,123 @@
import { z } from 'zod';
// ── SSO — SAML 2.0 (via Clerk Enterprise) ────────────────────────────────────
export const SamlProviderSchema = z.enum(['okta', 'azure', 'google-workspace', 'onelogin', 'custom']);
export type SamlProvider = z.infer<typeof SamlProviderSchema>;
export const SsoConfigSchema = z.object({
workspaceId: z.string().uuid(),
provider: SamlProviderSchema,
/** Entity ID of the Identity Provider */
idpEntityId: z.string().url(),
/** SSO URL (Single Sign-On service endpoint) */
idpSsoUrl: z.string().url(),
/** PEM-encoded X.509 certificate from the IdP */
idpCertificate: z.string().startsWith('-----BEGIN CERTIFICATE-----'),
/** Whether SSO is required for all workspace members */
enforced: z.boolean().default(false),
/** Domains that trigger SSO redirect (e.g. "acme.com") */
emailDomains: z.array(z.string().min(3)),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export type SsoConfig = z.infer<typeof SsoConfigSchema>;
export type InsertSsoConfig = Omit<SsoConfig, 'createdAt' | 'updatedAt'>;
// ── SCIM — User Provisioning ──────────────────────────────────────────────────
// SCIM 2.0 endpoint handled by Clerk Enterprise.
// These types represent the normalized user/group objects we store after sync.
export const ScimUserSchema = z.object({
scimId: z.string(),
workspaceId: z.string().uuid(),
externalId: z.string(),
email: z.string().email(),
displayName: z.string(),
active: z.boolean(),
/** Maps to TeamRole in origin-graph */
role: z.enum(['OWNER', 'DESIGNER', 'ENGINEER', 'PM', 'VIEWER']).default('VIEWER'),
groups: z.array(z.string()),
syncedAt: z.string().datetime(),
});
export type ScimUser = z.infer<typeof ScimUserSchema>;
export const ScimGroupSchema = z.object({
scimId: z.string(),
workspaceId: z.string().uuid(),
displayName: z.string(),
memberScimIds: z.array(z.string()),
syncedAt: z.string().datetime(),
});
export type ScimGroup = z.infer<typeof ScimGroupSchema>;
// ── Audit Log ─────────────────────────────────────────────────────────────────
// All workspace-level actions are logged for enterprise compliance.
// Backed by Supabase audit log extension (pg_audit) or a dedicated audit table.
export const AuditActionSchema = z.enum([
// Auth
'auth.login',
'auth.logout',
'auth.sso_login',
'auth.token_issued',
// Workspace
'workspace.created',
'workspace.settings_updated',
'workspace.member_invited',
'workspace.member_removed',
'workspace.member_role_changed',
// Artboard
'artboard.created',
'artboard.deleted',
'artboard.locked',
'artboard.unlocked',
// Diff
'diff.exported',
'diff.status_updated',
// AI
'ai.completion_zone_submitted',
'ai.completion_zone_accepted',
'ai.completion_zone_rejected',
// Plugin
'plugin.installed',
'plugin.uninstalled',
'plugin.enabled',
'plugin.disabled',
// DLF
'dlf.uploaded',
'dlf.activated',
// Agent Bridge
'agent_bridge.token_issued',
'agent_bridge.connected',
'agent_bridge.rate_limit_hit',
]);
export type AuditAction = z.infer<typeof AuditActionSchema>;
export const AuditLogEntrySchema = z.object({
id: z.string().uuid(),
workspaceId: z.string().uuid(),
actorId: z.string(),
actorEmail: z.string().email().optional(),
action: AuditActionSchema,
resourceType: z.string().optional(),
resourceId: z.string().optional(),
metadata: z.record(z.unknown()).optional(),
ipAddress: z.string().optional(),
userAgent: z.string().optional(),
occurredAt: z.string().datetime(),
});
export type AuditLogEntry = z.infer<typeof AuditLogEntrySchema>;
export type InsertAuditLogEntry = Omit<AuditLogEntry, 'id'>;
/** Convenience function for constructing a well-typed audit entry. */
export function buildAuditEntry(
params: Omit<InsertAuditLogEntry, 'occurredAt'>
): InsertAuditLogEntry {
return { ...params, occurredAt: new Date().toISOString() };
}
+38
View File
@@ -0,0 +1,38 @@
// Plugin API
export type {
PluginPermission,
PluginManifest,
PluginReadAPI,
PluginWriteAPI,
PluginContext,
CustomCompletionZoneDefinition,
CustomIngesterDefinition,
InstalledPlugin,
PluginRegistry,
} from './plugin-api.js';
export { PluginPermissionSchema, PluginManifestSchema } from './plugin-api.js';
// Enterprise
export type {
SamlProvider,
SsoConfig,
InsertSsoConfig,
ScimUser,
ScimGroup,
AuditAction,
AuditLogEntry,
InsertAuditLogEntry,
} from './enterprise.js';
export {
SamlProviderSchema,
SsoConfigSchema,
ScimUserSchema,
ScimGroupSchema,
AuditActionSchema,
AuditLogEntrySchema,
buildAuditEntry,
} from './enterprise.js';
// White-label theming
export type { BrandTokens } from './theming.js';
export { BrandTokensSchema, brandVariantsFromHex } from './theming.js';
+121
View File
@@ -0,0 +1,121 @@
import { z } from 'zod';
import type { Artboard, InsertOrigin, IntentDiff } from '@originmain/origin-graph';
// ── Plugin Manifest ───────────────────────────────────────────────────────────
// Plugins declare their identity, permissions, and extension points in a
// manifest. The host validates this on install and on every load.
export const PluginPermissionSchema = z.enum([
'artboards:read', // read artboard metadata and component trees
'artboards:write', // create artboards and origins (requires approval)
'diffs:read', // read IntentDiff objects
'diffs:export', // export diffs to external tools
'completion-zones:register', // register custom Completion Zone types
'ingesters:register', // register custom ingestion connectors
'design-language:read', // read the workspace Design Language File
]);
export type PluginPermission = z.infer<typeof PluginPermissionSchema>;
export const PluginManifestSchema = z.object({
/** Unique reverse-DNS identifier: e.g. "com.acme.my-plugin" */
id: z.string().regex(/^[a-z0-9]+(\.[a-z0-9-]+)+$/, 'Must be reverse-DNS format'),
name: z.string().min(1).max(80),
version: z.string().regex(/^\d+\.\d+\.\d+$/, 'Must be semver'),
description: z.string().max(300),
/** URL where the plugin's sandboxed JS bundle is hosted */
entryUrl: z.string().url(),
permissions: z.array(PluginPermissionSchema),
/** SHA-256 hash of the entryUrl bundle — required; verified before execution */
bundleHash: z.string().regex(/^[0-9a-f]{64}$/),
});
export type PluginManifest = z.infer<typeof PluginManifestSchema>;
// ── Plugin Context ────────────────────────────────────────────────────────────
// The API surface exposed to a sandboxed plugin via postMessage.
// Split into read and write sides so permission checks are explicit.
export interface PluginReadAPI {
getArtboard(id: string): Promise<Artboard | null>;
listArtboards(): Promise<readonly Artboard[]>;
getDiff(id: string): Promise<IntentDiff | null>;
listPendingDiffs(): Promise<readonly IntentDiff[]>;
getDesignLanguageFile(): Promise<unknown | null>;
}
export interface PluginWriteAPI {
/**
* Creates a new artboard linked to the given origin.
* The host injects workspaceId from PluginContext.workspaceId — plugins
* do not set workspace scoping; it is enforced server-side.
*/
createArtboard(params: {
name: string;
origin: InsertOrigin;
}): Promise<{ artboardId: string }>;
exportDiff(diffId: string, format: 'json' | 'markdown'): Promise<string>;
}
export interface PluginContext {
/** Plugin's declared manifest (read-only inside sandbox) */
readonly manifest: PluginManifest;
/** Workspace ID the plugin is operating within */
readonly workspaceId: string;
/** Read APIs — available if 'artboards:read' or 'diffs:read' is granted */
readonly read: PluginReadAPI;
/** Write APIs — available only if 'artboards:write' is granted */
readonly write: PluginWriteAPI | null;
/** Emits a UI notification to the host application */
notify(message: string, level?: 'info' | 'warning' | 'error'): void;
}
// ── Custom Completion Zone ────────────────────────────────────────────────────
// Plugins can register custom Completion Zone types with their own AI prompts
// and rendering logic.
export interface CustomCompletionZoneDefinition {
/** Unique type identifier: e.g. "com.acme.marketing-copy" */
type: string;
label: string;
description: string;
/** System prompt appended to the standard Completion Zone system prompt */
systemPromptAddendum: string;
/** JSON Schema describing the expected output format */
outputSchema: Record<string, unknown>;
}
// ── Custom Ingester ───────────────────────────────────────────────────────────
// Plugins can register additional ingestion connectors beyond the 4 first-party set.
export interface CustomIngesterDefinition {
/** Matches the 'type' field in the plugin manifest */
sourceType: string;
label: string;
/** Webhook URL path registered with the host's Edge Function router */
webhookPath: string;
/** The plugin's JS bundle handles validatePayload + ingest via sandboxed eval */
handleWebhook(rawBody: string, headers: Record<string, string>): Promise<{
artboardTitle: string;
renderUrl?: string;
sourceRef: string;
metadata: Record<string, unknown>;
}>;
}
// ── Plugin Registry ───────────────────────────────────────────────────────────
export interface InstalledPlugin {
manifest: PluginManifest;
installedAt: string;
installedByUserId: string;
enabled: boolean;
}
export interface PluginRegistry {
list(workspaceId: string): Promise<readonly InstalledPlugin[]>;
install(workspaceId: string, manifest: PluginManifest, byUserId: string): Promise<void>;
uninstall(workspaceId: string, pluginId: string): Promise<void>;
setEnabled(workspaceId: string, pluginId: string, enabled: boolean): Promise<void>;
}
+89
View File
@@ -0,0 +1,89 @@
import { z } from 'zod';
// ── White-label Theming ───────────────────────────────────────────────────────
// Enterprise workspaces can override the Originmain UI with their own brand.
// The theming pipeline:
// 1. WorkspaceBrand (stored in workspace.metadata_jsonb) → BrandTokens
// 2. BrandTokens → Fluent 2 BrandVariants (via createLightTheme / createDarkTheme)
// 3. BrandVariants → FluentProvider theme prop
//
// Note: Actual Fluent 2 createLightTheme / createDarkTheme calls happen in
// packages/app/src/lib/workspace-theme.ts to avoid a @fluentui dep here.
export const BrandTokensSchema = z.object({
/** Primary brand colour in 6-digit hex */
primaryColor: z.string().regex(/^#[0-9a-f]{6}$/i),
/** Optional secondary accent */
secondaryColor: z.string().regex(/^#[0-9a-f]{6}$/i).optional(),
/** Brand logo URL (SVG or PNG, ≤512KB) */
logoUrl: z.string().url().optional(),
/** Tab title prefix: e.g. "Acme Design" → "Acme Design — Originmain" */
productName: z.string().max(40).optional(),
/** Favicon URL (ICO, PNG, or SVG) */
faviconUrl: z.string().url().optional(),
});
export type BrandTokens = z.infer<typeof BrandTokensSchema>;
/**
* Derives the 10-shade Fluent 2 BrandVariants record from a single hex colour.
* Shades are generated by interpolating HSL lightness across the standard scale.
* The returned object is passed directly to createLightTheme / createDarkTheme.
*/
export function brandVariantsFromHex(hex: string): Record<`shade${10 | 20 | 30 | 40 | 50 | 60 | 70 | 80 | 90 | 100 | 110 | 120 | 130 | 140 | 150 | 160}`, string> {
const [r, g, b] = hexToRgb(hex);
const [h, s] = rgbToHsl(r, g, b);
// Fluent 2 uses shades 10160 in 10-step increments (16 shades).
// shade10 = lightest, shade160 = darkest.
const shadeNumbers = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160] as const;
const result = {} as Record<string, string>;
for (const shade of shadeNumbers) {
// Map shade index to lightness: shade10 ≈ 95%, shade160 ≈ 10%
const lightness = 95 - ((shade - 10) / 150) * 85;
result[`shade${shade}`] = hslToHex(h, s, lightness);
}
return result as ReturnType<typeof brandVariantsFromHex>;
}
// ── HSL ↔ RGB ↔ Hex helpers ──────────────────────────────────────────────────
function hexToRgb(hex: string): [number, number, number] {
const clean = hex.replace('#', '');
const r = parseInt(clean.slice(0, 2), 16);
const g = parseInt(clean.slice(2, 4), 16);
const b = parseInt(clean.slice(4, 6), 16);
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) {
throw new Error(`Invalid hex color: "${hex}"`);
}
return [r, g, b];
}
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
const rn = r / 255, gn = g / 255, bn = b / 255;
const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
const l = (max + min) / 2;
if (max === min) return [0, 0, l * 100];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h = 0;
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
else h = ((rn - gn) / d + 4) / 6;
return [h * 360, s * 100, l * 100];
}
function hslToHex(h: number, s: number, l: number): string {
const sn = s / 100, ln = l / 100;
const a = sn * Math.min(ln, 1 - ln);
const f = (n: number): string => {
const k = (n + h / 30) % 12;
const color = ln - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color).toString(16).padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"noEmit": true
},
"include": ["src"],
"exclude": ["node_modules"]
}
+114
View File
@@ -0,0 +1,114 @@
import { RENDERER_SOURCE } from './protocol.js';
import type { FiberNode, DOMRectLike } from './protocol.js';
// ── Fiber hook script ─────────────────────────────────────────────────────────
// This script is injected into the sandboxed iframe before the remote app
// initialises. It installs a React DevTools global hook so React reports every
// commit. On each commit, we walk the Fiber tree, serialize it to FiberNode[],
// and postMessage the result to the host.
//
// The script must be self-contained (no imports) because it runs in the iframe.
// We generate it as a string via buildFiberHookScript() so it can be injected
// via a <script> tag or a blob URL.
export function buildFiberHookScript(artboardId: string): string {
// Inline the constants and logic — the iframe has no access to this module.
return `(function(artboardId) {
var SOURCE = ${JSON.stringify(RENDERER_SOURCE)};
// Install the React DevTools global hook BEFORE React loads.
// React checks for this object at module evaluation time and registers itself.
var hook = window.__REACT_DEVTOOLS_GLOBAL_HOOK__;
if (!hook) {
hook = { renderers: new Map(), _isDisabled: false };
window.__REACT_DEVTOOLS_GLOBAL_HOOK__ = hook;
}
var originalOnCommitFiberRoot = hook.onCommitFiberRoot;
hook.onCommitFiberRoot = function(rendererId, root, priorityLevel, didError) {
if (typeof originalOnCommitFiberRoot === 'function') {
originalOnCommitFiberRoot.call(this, rendererId, root, priorityLevel, didError);
}
try {
var fiberRoot = root.current;
var tree = serializeFiber(fiberRoot);
window.parent.postMessage(
{ source: SOURCE, artboardId: artboardId, message: { type: 'FIBER_TREE_UPDATE', root: tree } },
'*'
);
} catch (err) {
window.parent.postMessage(
{ source: SOURCE, artboardId: artboardId, message: { type: 'ERROR', message: String(err) } },
'*'
);
}
};
function serializeFiber(fiber) {
if (!fiber) return null;
var name = getDisplayName(fiber);
if (!name) return serializeFiber(fiber.child) || null;
var rect = getDomRect(fiber);
var node = {
id: String(fiber.index || Math.random()),
name: name,
props: serializeProps(fiber.memoizedProps),
children: [],
domRect: rect || undefined,
};
var child = fiber.child;
while (child) {
var serialized = serializeFiber(child);
if (serialized) node.children.push(serialized);
child = child.sibling;
}
return node;
}
function getDisplayName(fiber) {
var type = fiber.type;
if (!type) return null;
if (typeof type === 'string') return type;
if (typeof type === 'function') return type.displayName || type.name || null;
if (type.$$typeof) return type.displayName || type.name || null;
return null;
}
function getDomRect(fiber) {
try {
var dom = fiber.stateNode;
if (dom && dom.getBoundingClientRect) {
var r = dom.getBoundingClientRect();
return { x: r.x, y: r.y, width: r.width, height: r.height };
}
} catch (_) {}
return null;
}
function serializeProps(props) {
if (!props || typeof props !== 'object') return {};
var out = {};
for (var key in props) {
if (key === 'children') continue;
var val = props[key];
var type = typeof val;
if (type === 'string' || type === 'number' || type === 'boolean' || val === null) {
out[key] = val;
}
}
return out;
}
// Signal that the renderer iframe is ready.
window.parent.postMessage(
{ source: SOURCE, artboardId: artboardId, message: { type: 'READY' } },
'*'
);
})(${JSON.stringify(artboardId)});`;
}
// Re-export types for convenience
export type { FiberNode, DOMRectLike };
+26 -1
View File
@@ -1 +1,26 @@
export {}; export {
HOST_SOURCE,
RENDERER_SOURCE,
isHostEnvelope,
isRendererEnvelope,
createHostEnvelope,
createRendererEnvelope,
} from './protocol.js';
export type {
FiberNode,
DOMRectLike,
HostMessage,
HostEnvelope,
RendererMessage,
RendererEnvelope,
} from './protocol.js';
export { buildFiberHookScript } from './fiber-hook.js';
export {
createRendererHostConfig,
createRemoteConfig,
} from './module-federation.js';
export type { RemoteConfig, RendererHostOptions } from './module-federation.js';
@@ -0,0 +1,62 @@
// ── Module Federation host configuration helper ───────────────────────────────
// The Originmain renderer acts as the MF *host*. The connected application acts
// as the *remote*, exposing its component routes via ModuleFederationPlugin.
//
// Usage in packages/app/next.config.ts (or a custom webpack.config.js):
// import { createRendererHostConfig } from '@originmain/renderer';
// const { ModuleFederationPlugin } = require('webpack').container;
// new ModuleFederationPlugin(createRendererHostConfig({ remoteUrl }))
export interface RemoteConfig {
/** Unique name for this remote (used as the JS namespace, e.g. "connected_app") */
name: string;
/** Public URL of the remote's remoteEntry.js, e.g. "http://localhost:3001/remoteEntry.js" */
url: string;
/** Component paths exposed by the remote, e.g. ["./Button", "./Card"] */
exposes?: string[];
}
export interface RendererHostOptions {
/** All remote applications to wire up */
remotes?: RemoteConfig[];
}
/** Webpack ModuleFederationPlugin config for the Originmain renderer host. */
export function createRendererHostConfig(opts: RendererHostOptions = {}) {
const { remotes = [] } = opts;
const remotesMap: Record<string, string> = {};
for (const r of remotes) {
// Webpack MF syntax: "namespace@url"
remotesMap[r.name] = `${r.name}@${r.url}`;
}
return {
name: 'originmain_host',
remotes: remotesMap,
// React and ReactDOM must be singletons — a duplicate React instance causes
// hooks to fail silently across the host/remote boundary.
shared: {
react: { singleton: true, requiredVersion: '>=19', eager: false },
'react-dom': { singleton: true, requiredVersion: '>=19', eager: false },
},
};
}
/** Webpack ModuleFederationPlugin config scaffold for the *remote* (connected app). */
export function createRemoteConfig(opts: {
name: string;
exposes: Record<string, string>;
publicPath?: string;
}) {
return {
name: opts.name,
filename: 'remoteEntry.js',
exposes: opts.exposes,
publicPath: opts.publicPath ?? 'auto',
shared: {
react: { singleton: true, requiredVersion: '>=19' },
'react-dom': { singleton: true, requiredVersion: '>=19' },
},
};
}
+87
View File
@@ -0,0 +1,87 @@
// ── Source discriminants ──────────────────────────────────────────────────────
// All postMessage envelopes carry a `source` field so the host and renderer can
// ignore messages from unrelated parties (browser extensions, devtools, etc.).
export const HOST_SOURCE = 'originmain-host' as const;
export const RENDERER_SOURCE = 'originmain-renderer' as const;
// ── Fiber node ────────────────────────────────────────────────────────────────
export interface FiberNode {
id: string;
name: string;
props: Record<string, unknown>;
children: FiberNode[];
domRect?: DOMRectLike;
}
export interface DOMRectLike {
x: number;
y: number;
width: number;
height: number;
}
// ── Host → Renderer messages ──────────────────────────────────────────────────
export type HostMessage =
| { type: 'SET_DESIGN_TOKENS'; tokens: Record<string, string> }
| { type: 'NAVIGATE'; path: string }
| { type: 'SELECT_COMPONENT'; nodeId: string }
| { type: 'DESELECT' }
| { type: 'INJECT_FIBER_HOOK' };
export interface HostEnvelope {
source: typeof HOST_SOURCE;
artboardId: string;
message: HostMessage;
}
// ── Renderer → Host messages ──────────────────────────────────────────────────
export type RendererMessage =
| { type: 'READY' }
| { type: 'FIBER_TREE_UPDATE'; root: FiberNode }
| { type: 'COMPONENT_SELECTED'; nodeId: string; rect: DOMRectLike }
| { type: 'COMPONENT_DESELECTED' }
| { type: 'ERROR'; message: string };
export interface RendererEnvelope {
source: typeof RENDERER_SOURCE;
artboardId: string;
message: RendererMessage;
}
// ── Type guards ───────────────────────────────────────────────────────────────
export function isHostEnvelope(data: unknown): data is HostEnvelope {
return (
typeof data === 'object' &&
data !== null &&
(data as HostEnvelope).source === HOST_SOURCE
);
}
export function isRendererEnvelope(data: unknown): data is RendererEnvelope {
return (
typeof data === 'object' &&
data !== null &&
(data as RendererEnvelope).source === RENDERER_SOURCE
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
export function createHostEnvelope(
artboardId: string,
message: HostMessage
): HostEnvelope {
return { source: HOST_SOURCE, artboardId, message };
}
export function createRendererEnvelope(
artboardId: string,
message: RendererMessage
): RendererEnvelope {
return { source: RENDERER_SOURCE, artboardId, message };
}
+200
View File
@@ -42,12 +42,21 @@ importers:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.17
typescript: typescript:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
packages/ai-layer: packages/ai-layer:
dependencies: dependencies:
'@anthropic-ai/sdk':
specifier: ^0.56.0
version: 0.56.0
'@originmain/design-language':
specifier: workspace:*
version: link:../design-language
'@originmain/diff-engine': '@originmain/diff-engine':
specifier: workspace:* specifier: workspace:*
version: link:../diff-engine version: link:../diff-engine
@@ -61,6 +70,9 @@ importers:
packages/app: packages/app:
dependencies: dependencies:
'@clerk/nextjs':
specifier: ^7.2.7
version: 7.2.7(next@15.5.15(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@fluentui/react-components': '@fluentui/react-components':
specifier: ^9.54.0 specifier: ^9.54.0
version: 9.73.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(scheduler@0.27.0) version: 9.73.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(scheduler@0.27.0)
@@ -114,6 +126,22 @@ importers:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
packages/design-language:
dependencies:
zod:
specifier: ^3.0.0
version: 3.25.76
devDependencies:
'@vitest/coverage-v8':
specifier: ^2.0.0
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
typescript:
specifier: ^5.5.0
version: 5.9.3
vitest:
specifier: ^2.0.0
version: 2.1.9(@types/node@22.19.17)
packages/diff-engine: packages/diff-engine:
dependencies: dependencies:
'@pierre/diffs': '@pierre/diffs':
@@ -155,6 +183,25 @@ importers:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
packages/multiplayer:
dependencies:
'@liveblocks/client':
specifier: ^2.0.0
version: 2.24.4
'@liveblocks/react':
specifier: ^2.0.0
version: 2.24.4(react@19.2.5)
'@originmain/origin-graph':
specifier: workspace:*
version: link:../origin-graph
zod:
specifier: ^3.0.0
version: 3.25.76
devDependencies:
typescript:
specifier: ^5.5.0
version: 5.9.3
packages/origin-graph: packages/origin-graph:
dependencies: dependencies:
'@originmain/diff-engine': '@originmain/diff-engine':
@@ -174,6 +221,22 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.1.9(@types/node@22.19.17) version: 2.1.9(@types/node@22.19.17)
packages/platform:
dependencies:
'@originmain/design-language':
specifier: workspace:*
version: link:../design-language
'@originmain/origin-graph':
specifier: workspace:*
version: link:../origin-graph
zod:
specifier: ^3.0.0
version: 3.25.76
devDependencies:
typescript:
specifier: ^5.5.0
version: 5.9.3
packages/renderer: packages/renderer:
devDependencies: devDependencies:
typescript: typescript:
@@ -205,6 +268,10 @@ packages:
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
'@anthropic-ai/sdk@0.56.0':
resolution: {integrity: sha512-SLCB8M8+VMg1cpCucnA1XWHGWqVSZtIWzmOdDOEu3eTFZMB+A0sGZ1ESO5MHDnqrNTXz3safMrWx9x4rMZSOqA==}
hasBin: 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'}
@@ -229,6 +296,37 @@ packages:
'@bcoe/v8-coverage@0.2.3': '@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
'@clerk/backend@3.4.1':
resolution: {integrity: sha512-+Tgo1uPEFpBRvyFW3JtPbrTMRgiP+pWBo9gi2tTB0AxEqR2I/kSYy5l3+KqWciUpbVZtVvLXm1j+NEE2WEG+jg==}
engines: {node: '>=20.9.0'}
'@clerk/nextjs@7.2.7':
resolution: {integrity: sha512-n0t/r033IT1rfK+b046xTt7VadZA0H8J+DXYBUdaNveQ2pV+MrtxHOSXfCWhjqghonKVImCchXNalX+mZGDaDQ==}
engines: {node: '>=20.9.0'}
peerDependencies:
next: ^15.2.8 || ^15.3.8 || ^15.4.10 || ^15.5.9 || ^15.6.0-0 || ^16.0.10 || ^16.1.0-0
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
'@clerk/react@6.4.5':
resolution: {integrity: sha512-RxRfBwZF8aMxMZFtceAnjmQC4xLn7vyHOre9/toBXIfDlVU3bH35EGdKu8Js5OPa3GMKdQHDtNJ40UXPPLw38Q==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
'@clerk/shared@4.8.5':
resolution: {integrity: sha512-YxgUWHoKEXEbRPWPEcB2Q0o+NJkDc0/zQRp4QCsnGIM5e32hlBUwxcYpyDjDlZ2lYB+GUXHuEc3KETnxWGp26g==}
engines: {node: '>=20.9.0'}
peerDependencies:
react: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
react-dom: ^18.0.0 || ~19.0.3 || ~19.1.4 || ~19.2.3 || ~19.3.0-0
peerDependenciesMeta:
react:
optional: true
react-dom:
optional: true
'@ctrl/tinycolor@3.6.1': '@ctrl/tinycolor@3.6.1':
resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==} resolution: {integrity: sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1150,6 +1248,17 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@liveblocks/client@2.24.4':
resolution: {integrity: sha512-jT6kpapuSWuQlg8m/r3N5R/i7XlQAG2UkeaQi1DkyCc2+U4PWgKNVVXVWUUKM+OcbG3yKoJJ93Pw4AZE0hHWDg==}
'@liveblocks/core@2.24.4':
resolution: {integrity: sha512-Os2Fs/RgggyrISpqHIefWo864auKIkpw48BG5fvPubcXkLU9i8wUcK5oczWEqJ0JvtyTfqREl/tkEhGf124DCw==}
'@liveblocks/react@2.24.4':
resolution: {integrity: sha512-p6t7HVGkQ34MLtiDp2L1qXmae4MH8MyOTaJfP2USfimOrMp1vIKendxuIoaR71XvtShvhihkwvdHa3ogCmm+2A==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
'@next/env@15.5.15': '@next/env@15.5.15':
resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==} resolution: {integrity: sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==}
@@ -1398,12 +1507,18 @@ packages:
'@shikijs/vscode-textmate@10.0.2': '@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@stablelib/base64@1.0.1':
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
'@swc/helpers@0.5.21': '@swc/helpers@0.5.21':
resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==}
'@tanstack/query-core@5.90.16':
resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==}
'@tanstack/query-core@5.99.2': '@tanstack/query-core@5.99.2':
resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==} resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==}
@@ -1773,6 +1888,9 @@ packages:
fast-levenshtein@2.0.6: fast-levenshtein@2.0.6:
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
fast-sha256@1.3.0:
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
fdir@6.5.0: fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
@@ -1815,6 +1933,9 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'} engines: {node: '>=10.13.0'}
glob-to-regexp@0.4.1:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
glob@10.5.0: glob@10.5.0:
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
@@ -1890,6 +2011,10 @@ packages:
jackspeak@3.4.3: jackspeak@3.4.3:
resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
js-yaml@4.1.1: js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true hasBin: true
@@ -2130,6 +2255,9 @@ packages:
engines: {node: '>=10'} engines: {node: '>=10'}
hasBin: true hasBin: true
server-only@0.0.1:
resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
sharp@0.34.5: sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
@@ -2162,6 +2290,9 @@ packages:
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
standardwebhooks@1.0.0:
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
std-env@3.10.0: std-env@3.10.0:
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
@@ -2408,6 +2539,8 @@ snapshots:
'@jridgewell/gen-mapping': 0.3.13 '@jridgewell/gen-mapping': 0.3.13
'@jridgewell/trace-mapping': 0.3.31 '@jridgewell/trace-mapping': 0.3.31
'@anthropic-ai/sdk@0.56.0': {}
'@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': {}
@@ -2425,6 +2558,44 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {} '@bcoe/v8-coverage@0.2.3': {}
'@clerk/backend@3.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@clerk/shared': 4.8.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
standardwebhooks: 1.0.0
tslib: 2.8.1
transitivePeerDependencies:
- react
- react-dom
'@clerk/nextjs@7.2.7(next@15.5.15(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@clerk/backend': 3.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@clerk/react': 6.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@clerk/shared': 4.8.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
next: 15.5.15(@playwright/test@1.59.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)
server-only: 0.0.1
tslib: 2.8.1
'@clerk/react@6.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@clerk/shared': 4.8.5(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)
tslib: 2.8.1
'@clerk/shared@4.8.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@tanstack/query-core': 5.90.16
dequal: 2.0.3
glob-to-regexp: 0.4.1
js-cookie: 3.0.5
std-env: 3.10.0
optionalDependencies:
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
'@ctrl/tinycolor@3.6.1': {} '@ctrl/tinycolor@3.6.1': {}
'@emnapi/runtime@1.10.0': '@emnapi/runtime@1.10.0':
@@ -3873,6 +4044,18 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@liveblocks/client@2.24.4':
dependencies:
'@liveblocks/core': 2.24.4
'@liveblocks/core@2.24.4': {}
'@liveblocks/react@2.24.4(react@19.2.5)':
dependencies:
'@liveblocks/client': 2.24.4
'@liveblocks/core': 2.24.4
react: 19.2.5
'@next/env@15.5.15': {} '@next/env@15.5.15': {}
'@next/swc-darwin-arm64@15.5.15': '@next/swc-darwin-arm64@15.5.15':
@@ -4042,6 +4225,8 @@ snapshots:
'@shikijs/vscode-textmate@10.0.2': {} '@shikijs/vscode-textmate@10.0.2': {}
'@stablelib/base64@1.0.1': {}
'@swc/helpers@0.5.15': '@swc/helpers@0.5.15':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
@@ -4050,6 +4235,8 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@tanstack/query-core@5.90.16': {}
'@tanstack/query-core@5.99.2': {} '@tanstack/query-core@5.99.2': {}
'@tanstack/react-query@5.99.2(react@19.2.5)': '@tanstack/react-query@5.99.2(react@19.2.5)':
@@ -4470,6 +4657,8 @@ snapshots:
fast-levenshtein@2.0.6: {} fast-levenshtein@2.0.6: {}
fast-sha256@1.3.0: {}
fdir@6.5.0(picomatch@4.0.4): fdir@6.5.0(picomatch@4.0.4):
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
@@ -4505,6 +4694,8 @@ snapshots:
dependencies: dependencies:
is-glob: 4.0.3 is-glob: 4.0.3
glob-to-regexp@0.4.1: {}
glob@10.5.0: glob@10.5.0:
dependencies: dependencies:
foreground-child: 3.3.1 foreground-child: 3.3.1
@@ -4588,6 +4779,8 @@ snapshots:
optionalDependencies: optionalDependencies:
'@pkgjs/parseargs': 0.11.0 '@pkgjs/parseargs': 0.11.0
js-cookie@3.0.5: {}
js-yaml@4.1.1: js-yaml@4.1.1:
dependencies: dependencies:
argparse: 2.0.1 argparse: 2.0.1
@@ -4846,6 +5039,8 @@ snapshots:
semver@7.7.4: {} semver@7.7.4: {}
server-only@0.0.1: {}
sharp@0.34.5: sharp@0.34.5:
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
@@ -4905,6 +5100,11 @@ snapshots:
stackback@0.0.2: {} stackback@0.0.2: {}
standardwebhooks@1.0.0:
dependencies:
'@stablelib/base64': 1.0.1
fast-sha256: 1.3.0
std-env@3.10.0: {} std-env@3.10.0: {}
string-width@4.2.3: string-width@4.2.3:
+4 -1
View File
@@ -17,7 +17,10 @@
"@originmain/agent-bridge": ["./packages/agent-bridge/src/index.ts"], "@originmain/agent-bridge": ["./packages/agent-bridge/src/index.ts"],
"@originmain/ai-layer": ["./packages/ai-layer/src/index.ts"], "@originmain/ai-layer": ["./packages/ai-layer/src/index.ts"],
"@originmain/renderer": ["./packages/renderer/src/index.ts"], "@originmain/renderer": ["./packages/renderer/src/index.ts"],
"@originmain/integrations": ["./packages/integrations/src/index.ts"] "@originmain/integrations": ["./packages/integrations/src/index.ts"],
"@originmain/design-language": ["./packages/design-language/src/index.ts"],
"@originmain/multiplayer": ["./packages/multiplayer/src/index.ts"],
"@originmain/platform": ["./packages/platform/src/index.ts"]
} }
} }
} }