improved a lot of things
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)(.*)',
|
||||
],
|
||||
};
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './schema.js';
|
||||
export * from './validator.js';
|
||||
export * from './tokens.js';
|
||||
@@ -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>;
|
||||
@@ -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(', ');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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); };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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';
|
||||
@@ -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()),
|
||||
});
|
||||
@@ -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;
|
||||
@@ -1 +1,2 @@
|
||||
export {};
|
||||
export * from './types.js';
|
||||
export * from './queries.js';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'>;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 10–160 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)}`;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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' },
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user