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
+1
View File
@@ -16,6 +16,7 @@
"zod": "^3.0.0"
},
"devDependencies": {
"@types/node": "^22.0.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",
"compilerOptions": {
"noEmit": true
"noEmit": true,
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules"]
+2
View File
@@ -10,6 +10,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
"@originmain/design-language": "workspace:*",
"@originmain/diff-engine": "workspace:*",
"zod": "^3.0.0"
},
+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"
},
"dependencies": {
"@clerk/nextjs": "^7.2.7",
"@fluentui/react-components": "^9.54.0",
"@fluentui/react-icons": "^2.0.0",
"@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-actions { display: flex; align-items: center; gap: 8px; }
/* ── Hamburger ── */
.nav-burger {
display: none;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 36px;
height: 36px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: var(--r-2);
transition: background 0.12s;
flex-shrink: 0;
}
.nav-burger:hover { background: var(--bg-muted); }
.nav-burger span {
display: block;
width: 20px;
height: 2px;
background: var(--fg);
border-radius: 2px;
transition: transform 0.22s cubic-bezier(0.16,1,0.3,1), opacity 0.18s;
transform-origin: center;
}
.nav-burger.open span:nth-child(1) { transform: translateY(7px) rotate(45deg); }
.nav-burger.open span:nth-child(2) { opacity: 0; transform: scaleX(0); }
.nav-burger.open span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); }
/* ── Mobile drawer ── */
.nav-mobile {
display: none;
position: absolute;
top: 60px; left: 0; right: 0;
background: rgba(255,255,255,0.97);
backdrop-filter: blur(20px) saturate(1.6);
-webkit-backdrop-filter: blur(20px) saturate(1.6);
border-bottom: 1px solid var(--border);
box-shadow: 0 8px 32px rgba(0,0,0,0.07);
padding: 12px 24px 24px;
flex-direction: column;
gap: 2px;
}
.nav-mobile.open { display: flex; }
.nav-mobile ul { list-style: none; display: flex; flex-direction: column; }
.nav-mobile ul li a {
display: block;
font-size: 1rem;
font-weight: 500;
color: var(--fg-2);
padding: 11px 4px;
border-bottom: 1px solid var(--border);
letter-spacing: -0.01em;
transition: color 0.1s;
}
.nav-mobile ul li:last-child a { border-bottom: none; }
.nav-mobile ul li a:hover { color: var(--fg); }
.nav-mobile-ctas {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
}
.nav-mobile-ctas .btn { text-align: center; padding: 11px 0; font-size: .9375rem; }
/* ════════════════════════════════════════════════════════════
HERO — Figma Sites: product-forward, massive type
════════════════════════════════════════════════════════════ */
@@ -1257,7 +1324,15 @@ footer {
@media (max-width: 768px) {
.wrap, .wrap-wide { padding: 0 24px; }
#nav { padding: 0 24px; }
.nav-inner {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
.nav-links { display: none; }
.nav-actions .btn { display: none; }
.nav-burger { display: flex; }
.section { padding: 80px 0; }
.feat-block { padding: 80px 24px; }
#hero { padding: 140px 24px 0; }
@@ -1295,6 +1370,23 @@ footer {
<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-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>
</nav>
@@ -1931,6 +2023,38 @@ window.addEventListener('scroll', () => {
nav.classList.toggle('scrolled', window.scrollY > 20);
}, { passive: true });
// ── Mobile hamburger ──────────────────────────────────────
const burger = document.getElementById('nav-burger');
const mobileMenu = document.getElementById('nav-mobile');
function toggleMenu(force) {
const open = force !== undefined ? force : !burger.classList.contains('open');
burger.classList.toggle('open', open);
burger.setAttribute('aria-expanded', String(open));
mobileMenu.classList.toggle('open', open);
mobileMenu.setAttribute('aria-hidden', String(!open));
document.body.style.overflow = open ? 'hidden' : '';
}
burger.addEventListener('click', () => toggleMenu());
// Close on any link click inside the drawer
mobileMenu.querySelectorAll('a').forEach(a =>
a.addEventListener('click', () => toggleMenu(false))
);
// Close on outside tap
document.addEventListener('click', e => {
if (mobileMenu.classList.contains('open') && !nav.contains(e.target)) {
toggleMenu(false);
}
});
// Close on Escape key
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && mobileMenu.classList.contains('open')) toggleMenu(false);
});
// ── Scroll reveal ─────────────────────────────────────────
const revealEls = document.querySelectorAll('.reveal, .stagger');
const revealAll = () => revealEls.forEach(el => el.classList.add('in'));
+13 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs';
import { Providers } from './providers';
import './globals.css';
@@ -11,7 +12,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<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>
</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 };
}