made tiny updates

This commit is contained in:
SinachPat
2026-05-04 12:30:56 +01:00
parent 3f029e15c2
commit 5b2d918c13
47 changed files with 2597 additions and 521 deletions
+13 -1
View File
@@ -151,7 +151,19 @@
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('version:', d.get\\('version'\\)\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('version:', d.get\\('version'\\)\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('type:', d.get\\('type'\\)\\); print\\('error:', d.get\\('error',{}\\).get\\('message','none'\\)[:200]\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\('type:', d.get\\('type'\\)\\); print\\('error:', d.get\\('error',{}\\).get\\('message','none'\\)[:200]\\)\")",
"Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); text=next\\(\\(b['text'] for b in d.get\\('content',[]\\) if b['type']=='text'\\),''\\); print\\('STATUS:', d.get\\('type','?'\\)\\); print\\('ERROR:', d.get\\('error',{}\\).get\\('message','none'\\)[:300]\\); print\\('TEXT:', text[:300]\\)\")", "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); text=next\\(\\(b['text'] for b in d.get\\('content',[]\\) if b['type']=='text'\\),''\\); print\\('STATUS:', d.get\\('type','?'\\)\\); print\\('ERROR:', d.get\\('error',{}\\).get\\('message','none'\\)[:300]\\); print\\('TEXT:', text[:300]\\)\")",
"Bash(perl -pi -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.22\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.18\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.28\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.32\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.45\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.5\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.62\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.85\\\\\\)'\\\\''/color: T.fg/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.88\\\\\\)'\\\\''/color: T.fg/g;' /Users/USER/Desktop/originmain/packages/app/src/components/inspector/Inspector.tsx)" "Bash(perl -pi -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.22\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.18\\\\\\)'\\\\''/color: T.dim/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.28\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.32\\\\\\)'\\\\''/color: T.key/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.45\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.5\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.62\\\\\\)'\\\\''/color: T.fgMuted/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.85\\\\\\)'\\\\''/color: T.fg/g;' -e 's/color: '\\\\''rgba\\\\\\(255,255,255,0\\\\.88\\\\\\)'\\\\''/color: T.fg/g;' /Users/USER/Desktop/originmain/packages/app/src/components/inspector/Inspector.tsx)",
"Bash(pnpm --filter @originmain/renderer exec tsc --noEmit)",
"Bash(pnpm --filter \"@originmain/origin-graph\" exec tsc --noEmit)",
"Bash(pnpm --filter \"@originmain/ai-layer\" exec tsc --noEmit)",
"Bash(pnpm --filter \"@originmain/diff-engine\" exec tsc --noEmit)",
"Bash(pnpm --filter \"@originmain/renderer\" exec tsc --noEmit)",
"Bash(pnpm --filter \"@originmain/app\" exec tsc --noEmit)",
"Bash(pnpm --filter \"@originmain/origin-graph\" run test)",
"Bash(pnpm update *)",
"Bash(pnpm --filter \"@originmain/diff-engine\" run test)",
"Bash(pnpm --filter @originmain/app typecheck)",
"Bash(pnpm --filter @originmain/ai-layer typecheck)",
"Bash(pnpm --filter @originmain/design-language typecheck)"
] ]
} }
} }
+2 -2
View File
@@ -16,10 +16,10 @@
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/parser": "^8.0.0",
"@vitest/coverage-v8": "^2.0.0", "@vitest/coverage-v8": "^2.1.9",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^2.0.0" "vitest": "^2.1.9"
}, },
"engines": { "engines": {
"node": ">=22", "node": ">=22",
+4 -3
View File
@@ -2,9 +2,10 @@ import Anthropic from '@anthropic-ai/sdk';
// ── Model constants ─────────────────────────────────────────────────────────── // ── Model constants ───────────────────────────────────────────────────────────
// claude-opus-4-7 uses adaptive thinking (thinking.type = 'adaptive'). // Spec Layer 6: "Claude Sonnet 4 API is the only AI provider."
// It does NOT accept temperature, top_p, or top_k — those are omitted in gateway.ts. // claude-sonnet-4-6 supports per-call temperature (0.1 / 0.2 / 0.3 per prompt)
export const MODEL = 'claude-opus-4-7' as const; // which the spec requires for diff-summary, completion-zone, and agent-query.
export const MODEL = 'claude-sonnet-4-6' as const;
// ── Singleton client ────────────────────────────────────────────────────────── // ── Singleton client ──────────────────────────────────────────────────────────
// The client is created once and shared. API key is injected from the server // The client is created once and shared. API key is injected from the server
+10 -12
View File
@@ -1,5 +1,5 @@
import type { AIGateway } from '../gateway.js'; import type { AIGateway } from '../gateway.js';
import { buildSystemPrompt } from '../prompts/system.js'; import { buildAgentQueryMessages } from '../prompts/agent-query.prompt.js';
export interface AgentQAInput { export interface AgentQAInput {
/** Question from the coding agent (e.g. Cursor or Claude Code) */ /** Question from the coding agent (e.g. Cursor or Claude Code) */
@@ -10,6 +10,10 @@ export interface AgentQAInput {
artboardContextJson: string; artboardContextJson: string;
/** Active DLF */ /** Active DLF */
dlfJson?: string; dlfJson?: string;
/** Before-state screenshot as base64 data URL (spec Layer 6.3-R3) */
beforeScreenshotBase64?: string;
/** After-state screenshot as base64 data URL (spec Layer 6.3-R3) */
afterScreenshotBase64?: string;
} }
export interface AgentQAOutput { export interface AgentQAOutput {
@@ -22,20 +26,14 @@ export async function answerAgentQuestion(
gateway: AIGateway, gateway: AIGateway,
input: AgentQAInput input: AgentQAInput
): Promise<AgentQAOutput> { ): Promise<AgentQAOutput> {
const system = buildSystemPrompt({ const { system, userContent, maxTokens } = buildAgentQueryMessages(input);
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({ const response = await gateway.complete({
system, system,
messages: [ // userContent is ContentBlockParam[] — may include image blocks for screenshots
{ messages: [{ role: 'user', content: userContent }],
role: 'user', maxTokens,
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.`, temperature: 0.1, // spec Layer 6.3: 0.1 for agent-query (factual, authoritative)
},
],
maxTokens: 1024,
}); });
let parsed: unknown; let parsed: unknown;
@@ -1,5 +1,5 @@
import type { AIGateway } from '../gateway.js'; import type { AIGateway } from '../gateway.js';
import { buildSystemPrompt } from '../prompts/system.js'; import { buildArtboardQueryMessages } from '../prompts/artboard-query.prompt.js';
export interface ArtboardQueryInput { export interface ArtboardQueryInput {
/** Natural language query from the user */ /** Natural language query from the user */
@@ -23,17 +23,13 @@ export async function queryCrossArtboard(
gateway: AIGateway, gateway: AIGateway,
input: ArtboardQueryInput input: ArtboardQueryInput
): Promise<ArtboardQueryOutput> { ): Promise<ArtboardQueryOutput> {
const system = buildSystemPrompt({ role: 'a search agent filtering artboards by design intent' }); const { system, userContent, maxTokens } = buildArtboardQueryMessages(input);
const response = await gateway.complete({ const response = await gateway.complete({
system, system,
messages: [ messages: [{ role: 'user', content: userContent }],
{ maxTokens,
role: 'user', temperature: 0.1, // spec Layer 10.2: 0.1 for workspace queries (factual)
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,
}); });
// Returning empty results on parse failure is indistinguishable from "no match". // Returning empty results on parse failure is indistinguishable from "no match".
@@ -1,8 +1,17 @@
import type Anthropic from '@anthropic-ai/sdk'; import { z } from 'zod';
import { AIGateway } from '../gateway.js'; import { AIGateway } from '../gateway.js';
import { buildSystemPrompt } from '../prompts/system.js'; import { buildCompletionZoneMessages } from '../prompts/completion-zone.prompt.js';
import type { GatewayResponse } from '../gateway.js'; import type { GatewayResponse } from '../gateway.js';
// ── Output schema (spec Layer 6.3-R2: validated with Zod before accepting) ────
// The model MUST return exactly these two fields. We validate the shape before
// returning to callers so a malformed AI response never reaches the canvas store.
const CompletionZoneResultSchema = z.object({
proposedTree: z.unknown(), // arbitrary component tree — shape validated downstream
description: z.string().min(1), // non-empty natural-language summary
});
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
export interface CompletionZoneInput { export interface CompletionZoneInput {
@@ -14,10 +23,12 @@ export interface CompletionZoneInput {
dlfJson?: string; dlfJson?: string;
/** Before screenshot as base64 data URL (optional) */ /** Before screenshot as base64 data URL (optional) */
screenshotBase64?: string; screenshotBase64?: string;
/** Workspace ID for per-workspace cost attribution (spec Layer 6) */
workspaceId?: string;
} }
export interface CompletionZoneOutput { export interface CompletionZoneOutput {
/** Proposed component tree changes as structured JSON */ /** Proposed component tree changes as structured JSON — Zod-validated shape */
proposedTree: unknown; proposedTree: unknown;
/** Natural language description of the proposed change */ /** Natural language description of the proposed change */
description: string; description: string;
@@ -31,45 +42,50 @@ export async function fillCompletionZone(
input: CompletionZoneInput, input: CompletionZoneInput,
maxRetries = 3 maxRetries = 3
): Promise<CompletionZoneOutput> { ): Promise<CompletionZoneOutput> {
const system = buildSystemPrompt({ const { system, userContent, maxTokens } = buildCompletionZoneMessages(input);
role: 'a Completion Zone design agent',
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
const userContent: Anthropic.Messages.ContentBlockParam[] = [ // Retry up to maxRetries times on invalid JSON or Zod-invalid output
{
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; let lastError: Error | null = null;
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
const response = await gateway.complete({ const response = await gateway.complete({
system, system,
messages: [{ role: 'user', content: userContent }], messages: [{ role: 'user', content: userContent }],
maxTokens: 4096, maxTokens,
temperature: 0.3, // spec Layer 6.3: 0.3 for completion-zone (creative latitude)
...(input.workspaceId !== undefined ? { workspaceId: input.workspaceId } : {}),
}); });
// Strip markdown fences the model occasionally adds despite instructions
const raw = response.text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '').trim();
let parsed: unknown;
try { try {
const parsed = JSON.parse(response.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim()); parsed = JSON.parse(raw);
return { proposedTree: parsed.proposedTree as unknown, description: String(parsed.description ?? ''), raw: response };
} catch (parseErr) { } catch (parseErr) {
lastError = new Error( lastError = new Error(
`AI returned invalid JSON on attempt ${attempt + 1}: ${String(parseErr)}. ` + `AI returned invalid JSON on attempt ${attempt + 1}: ${String(parseErr)}. ` +
`Raw response (first 200 chars): ${response.text.slice(0, 200)}` `Raw response (first 200 chars): ${response.text.slice(0, 200)}`
); );
continue;
} }
// Validate shape with Zod before accepting (spec Layer 6.3-R2)
const validated = CompletionZoneResultSchema.safeParse(parsed);
if (!validated.success) {
lastError = new Error(
`AI response failed schema validation on attempt ${attempt + 1}: ` +
validated.error.errors.map(e => `${e.path.join('.')}: ${e.message}`).join(', ') +
`. Raw response (first 200 chars): ${response.text.slice(0, 200)}`
);
continue;
}
return {
proposedTree: validated.data.proposedTree,
description: validated.data.description,
raw: response,
};
} }
throw lastError ?? new Error('Completion zone fill failed after retries'); throw lastError ?? new Error('Completion zone fill failed after retries');
} }
+48 -9
View File
@@ -1,11 +1,13 @@
import type { AIGateway } from '../gateway.js'; import type { AIGateway } from '../gateway.js';
import { buildSystemPrompt } from '../prompts/system.js'; import { buildDiffSummaryMessages, buildAggregateSummaryMessages } from '../prompts/diff-summary.prompt.js';
export interface DiffSummaryInput { export interface DiffSummaryInput {
/** Serialized component-level changes (JSON) */ /** Serialized component-level changes (JSON) */
changesJson: string; changesJson: string;
/** Component name */ /** Component name */
componentName: string; componentName: string;
/** Active DLF as JSON string — passed through for rule-violation annotation */
dlfJson?: string;
} }
export interface DiffSummaryOutput { export interface DiffSummaryOutput {
@@ -16,17 +18,54 @@ export async function generateDiffSummary(
gateway: AIGateway, gateway: AIGateway,
input: DiffSummaryInput input: DiffSummaryInput
): Promise<DiffSummaryOutput> { ): Promise<DiffSummaryOutput> {
const system = buildSystemPrompt({ role: 'a technical writer summarizing UI component changes' }); const { system, userContent, maxTokens } = buildDiffSummaryMessages(input);
const response = await gateway.complete({ const response = await gateway.complete({
system, system,
messages: [ messages: [{ role: 'user', content: userContent }],
{ maxTokens,
role: 'user', temperature: 0.2, // spec Layer 6.3: 0.2 for diff-summary (consistent output)
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.`, });
},
], return { summary: response.text.trim() };
maxTokens: 128, }
// ── Aggregate summary ─────────────────────────────────────────────────────────
export interface AggregateSummaryInput {
/** All changes in the diff as serialized JSON */
changesJson: string;
/** Human-readable artboard name, e.g. "Homepage Hero" */
artboardName: string;
/** Active DLF as JSON string — used to flag rule violations in the summary */
dlfJson?: string;
}
export interface AggregateSummaryOutput {
summary: string;
}
/**
* Generates a 2-3 sentence summary of ALL changes across a single diff
* (artboard-level granularity, suitable for the diff card header and PR body).
* Uses buildAggregateSummaryMessages — the session-level counterpart of
* buildDiffSummaryMessages (component-level).
*/
export async function generateAggregateSummary(
gateway: AIGateway,
input: AggregateSummaryInput,
): Promise<AggregateSummaryOutput> {
const { system, userContent, maxTokens } = buildAggregateSummaryMessages({
changesJson: input.changesJson,
artboardName: input.artboardName,
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
const response = await gateway.complete({
system,
messages: [{ role: 'user', content: userContent }],
maxTokens,
temperature: 0.2, // same as per-component summary — deterministic aggregate
}); });
return { summary: response.text.trim() }; return { summary: response.text.trim() };
+103 -21
View File
@@ -1,5 +1,13 @@
import Anthropic from '@anthropic-ai/sdk'; import Anthropic from '@anthropic-ai/sdk';
import { getClient, MODEL } from './client.js'; import { getClient, MODEL } from './client.js';
import { generateDiffSummary, generateAggregateSummary } from './features/diff-summary.js';
import { fillCompletionZone } from './features/completion-zone.js';
import { queryCrossArtboard } from './features/artboard-query.js';
import { answerAgentQuestion } from './features/agent-qa.js';
import type { DiffSummaryInput, DiffSummaryOutput, AggregateSummaryInput, AggregateSummaryOutput } from './features/diff-summary.js';
import type { CompletionZoneInput, CompletionZoneOutput } from './features/completion-zone.js';
import type { ArtboardQueryInput, ArtboardQueryOutput } from './features/artboard-query.js';
import type { AgentQAInput, AgentQAOutput } from './features/agent-qa.js';
// ── Gateway config ──────────────────────────────────────────────────────────── // ── Gateway config ────────────────────────────────────────────────────────────
@@ -21,21 +29,22 @@ export interface RequestCost {
estimatedCentsCost: number; estimatedCentsCost: number;
} }
const OPUS_4_INPUT_COST_PER_M = 500; // $5.00 / 1M // Spec Layer 6: claude-sonnet-4-6 pricing (used for cost attribution logging)
const OPUS_4_OUTPUT_COST_PER_M = 2500; // $25.00 / 1M const SONNET_4_INPUT_COST_PER_M = 300; // $3.00 / 1M input tokens
const OPUS_4_CACHE_READ_PER_M = 50; // $0.50 / 1M (estimated) const SONNET_4_OUTPUT_COST_PER_M = 1500; // $15.00 / 1M output tokens
const SONNET_4_CACHE_READ_PER_M = 30; // $0.30 / 1M cache-read tokens
function computeCost(usage: { input_tokens: number; output_tokens: number; cache_read_input_tokens?: number | null; cache_creation_input_tokens?: number | null }): RequestCost { 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 inputTokens = usage.input_tokens;
const outputTokens = usage.output_tokens; const outputTokens = usage.output_tokens;
const cacheReadTokens = usage.cache_read_input_tokens ?? 0; const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0; const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
const billableInput = inputTokens - cacheReadTokens; const billableInput = inputTokens - cacheReadTokens;
const estimatedCentsCost = Math.round( const estimatedCentsCost = Math.round(
(billableInput / 1_000_000) * OPUS_4_INPUT_COST_PER_M + (billableInput / 1_000_000) * SONNET_4_INPUT_COST_PER_M +
(outputTokens / 1_000_000) * OPUS_4_OUTPUT_COST_PER_M + (outputTokens / 1_000_000) * SONNET_4_OUTPUT_COST_PER_M +
(cacheReadTokens / 1_000_000) * OPUS_4_CACHE_READ_PER_M (cacheReadTokens / 1_000_000) * SONNET_4_CACHE_READ_PER_M
); );
return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, estimatedCentsCost }; return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, estimatedCentsCost };
@@ -70,11 +79,22 @@ function sleep(ms: number): Promise<void> {
// ── Gateway ─────────────────────────────────────────────────────────────────── // ── Gateway ───────────────────────────────────────────────────────────────────
export interface GatewayRequest { export interface GatewayRequest {
messages: Anthropic.Messages.MessageParam[]; messages: Anthropic.Messages.MessageParam[];
system?: Anthropic.Messages.TextBlockParam[]; system?: Anthropic.Messages.TextBlockParam[];
maxTokens?: number; maxTokens?: number;
// NOTE: temperature is intentionally omitted — claude-opus-4-7 with /**
// adaptive thinking rejects temperature, top_p, and top_k with a 400 error. * Sampling temperature — spec Layer 6.3 per-prompt values:
* diff-summary: 0.2 (consistent, low-creativity summaries)
* completion-zone: 0.3 (slight creative latitude for UI generation)
* agent-query: 0.1 (factual/authoritative answers)
* Defaults to 0.2 if omitted. Range [0, 1].
*/
temperature?: number;
/**
* Workspace ID for per-workspace cost attribution (spec Layer 6.2-R2).
* Logged on every request so cost can be aggregated per workspace.
*/
workspaceId?: string;
} }
export interface GatewayResponse { export interface GatewayResponse {
@@ -98,15 +118,18 @@ export class AIGateway {
async complete(req: GatewayRequest): Promise<GatewayResponse> { async complete(req: GatewayRequest): Promise<GatewayResponse> {
await this.rateLimiter.acquire(); await this.rateLimiter.acquire();
const requestId = `req_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
const startMs = Date.now();
let lastError: Error | null = null; let lastError: Error | null = null;
for (let attempt = 0; attempt < this.maxRetries; attempt++) { for (let attempt = 0; attempt < this.maxRetries; attempt++) {
try { try {
const response = await this.client.messages.create({ const response = await this.client.messages.create({
model: MODEL, model: MODEL,
max_tokens: req.maxTokens ?? 4096, max_tokens: req.maxTokens ?? 4096,
thinking: { type: 'adaptive' }, temperature: req.temperature ?? 0.2,
...(req.system !== undefined ? { system: req.system } : {}), ...(req.system !== undefined ? { system: req.system } : {}),
messages: req.messages, messages: req.messages,
}); });
const text = response.content const text = response.content
@@ -114,20 +137,55 @@ export class AIGateway {
.map(b => b.text) .map(b => b.text)
.join(''); .join('');
const cost = computeCost(response.usage); const cost = computeCost(response.usage);
const latencyMs = Date.now() - startMs;
this.totalCost += cost.estimatedCentsCost; this.totalCost += cost.estimatedCentsCost;
// ── Structured request log (spec Layer 6.2-R4) ──────────────────────
// Every AI call is logged with: requestId, model, token counts, cost
// estimate, latency, and workspace ID for per-workspace attribution.
console.log(JSON.stringify({
level: 'info',
event: 'ai_request',
requestId,
model: MODEL,
workspaceId: req.workspaceId ?? null,
inputTokens: cost.inputTokens,
outputTokens: cost.outputTokens,
cacheReadTokens: cost.cacheReadTokens,
cacheWriteTokens: cost.cacheWriteTokens,
estimatedCents: cost.estimatedCentsCost,
latencyMs,
attempt,
}));
return { content: response.content, text, cost }; return { content: response.content, text, cost };
} catch (err) { } catch (err) {
lastError = err instanceof Error ? err : new Error(String(err)); lastError = err instanceof Error ? err : new Error(String(err));
const latencyMs = Date.now() - startMs;
// Log every failed attempt for observability
console.error(JSON.stringify({
level: 'error',
event: 'ai_request_error',
requestId,
model: MODEL,
workspaceId: req.workspaceId ?? null,
attempt,
latencyMs,
error: lastError.message,
}));
// Retry on 429, 529, 5xx, and network errors (no HTTP status). // Retry on 429, 529, 5xx, and network errors (no HTTP status).
// Break immediately on client errors (4xx that aren't rate-limits). // Break immediately on client errors (4xx that aren't rate-limits).
if (err instanceof Anthropic.APIError) { if (err instanceof Anthropic.APIError) {
const { status } = err; const { status } = err;
if (status !== 429 && status !== 529 && status < 500) break; if (status !== 429 && status !== 529 && status < 500) break;
} }
// Non-APIError (network timeout, DNS failure, etc.) → always retry // Non-APIError (network timeout, DNS failure, etc.) → always retry.
await sleep(2 ** attempt * 1000); // Cap at 30s so a long retry sequence doesn't wedge the server forever.
const delayMs = Math.min(2 ** attempt * 1000, 30_000);
await sleep(delayMs);
} }
} }
@@ -138,4 +196,28 @@ export class AIGateway {
getCumulativeCost(): number { getCumulativeCost(): number {
return this.totalCost; return this.totalCost;
} }
// ── Spec Layer 5 convenience methods ─────────────────────────────────────────
// Thin wrappers that delegate to the standalone feature functions, making the
// gateway usable as a single dependency injection point across the app layer.
generateDiffSummary(input: DiffSummaryInput): Promise<DiffSummaryOutput> {
return generateDiffSummary(this, input);
}
generateAggregateSummary(input: AggregateSummaryInput): Promise<AggregateSummaryOutput> {
return generateAggregateSummary(this, input);
}
fillCompletionZone(input: CompletionZoneInput): Promise<CompletionZoneOutput> {
return fillCompletionZone(this, input);
}
queryArtboards(input: ArtboardQueryInput): Promise<ArtboardQueryOutput> {
return queryCrossArtboard(this, input);
}
answerAgentQuery(input: AgentQAInput): Promise<AgentQAOutput> {
return answerAgentQuestion(this, input);
}
} }
+15 -2
View File
@@ -2,11 +2,24 @@ export { getClient, MODEL } from './client.js';
export { AIGateway } from './gateway.js'; export { AIGateway } from './gateway.js';
export type { GatewayRequest, GatewayResponse, RequestCost } from './gateway.js'; export type { GatewayRequest, GatewayResponse, RequestCost } from './gateway.js';
// ── Versioned prompt builders ─────────────────────────────────────────────────
export { DIFF_SUMMARY_PROMPT_VERSION, buildDiffSummaryMessages, buildAggregateSummaryMessages } from './prompts/diff-summary.prompt.js';
export type { DiffSummaryPromptInput, AggregateSummaryPromptInput } from './prompts/diff-summary.prompt.js';
export { COMPLETION_ZONE_PROMPT_VERSION, buildCompletionZoneMessages } from './prompts/completion-zone.prompt.js';
export type { CompletionZonePromptInput } from './prompts/completion-zone.prompt.js';
export { ARTBOARD_QUERY_PROMPT_VERSION, buildArtboardQueryMessages } from './prompts/artboard-query.prompt.js';
export type { ArtboardQueryPromptInput } from './prompts/artboard-query.prompt.js';
export { AGENT_QUERY_PROMPT_VERSION, buildAgentQueryMessages } from './prompts/agent-query.prompt.js';
export type { AgentQueryPromptInput } from './prompts/agent-query.prompt.js';
export { fillCompletionZone } from './features/completion-zone.js'; export { fillCompletionZone } from './features/completion-zone.js';
export type { CompletionZoneInput, CompletionZoneOutput } from './features/completion-zone.js'; export type { CompletionZoneInput, CompletionZoneOutput } from './features/completion-zone.js';
export { generateDiffSummary } from './features/diff-summary.js'; export { generateDiffSummary, generateAggregateSummary } from './features/diff-summary.js';
export type { DiffSummaryInput, DiffSummaryOutput } from './features/diff-summary.js'; export type { DiffSummaryInput, DiffSummaryOutput, AggregateSummaryInput, AggregateSummaryOutput } from './features/diff-summary.js';
export { queryCrossArtboard } from './features/artboard-query.js'; export { queryCrossArtboard } from './features/artboard-query.js';
export type { ArtboardQueryInput, ArtboardQueryOutput, ArtboardQueryResult } from './features/artboard-query.js'; export type { ArtboardQueryInput, ArtboardQueryOutput, ArtboardQueryResult } from './features/artboard-query.js';
@@ -0,0 +1,99 @@
// ── Agent Query Prompt ────────────────────────────────────────────────────────
// Versioned prompt for answering coding-agent questions about design diffs.
//
// Version: 1.0
// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider")
// Tokens: max 1024 — JSON with answer + optional visual reference
//
// Structure:
// System: stable role (cached) + optional DLF constraint block (cached)
// User: [before screenshot] + [after screenshot] + artboard context +
// diff ID + agent question (spec Layer 6.3-R3: screenshots required)
import type Anthropic from '@anthropic-ai/sdk';
import { buildSystemPrompt } from './system.js';
export const AGENT_QUERY_PROMPT_VERSION = '1.0';
export interface AgentQueryPromptInput {
/** 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 (IntentDiff + artboard metadata) as JSON */
artboardContextJson: string;
/** Active DLF as JSON string (optional — cached in system when present) */
dlfJson?: string;
/**
* Before-state screenshot as base64 data URL (spec Layer 6.3-R3).
* Gives the model visual ground truth of what the artboard looked like
* before the diff was applied.
*/
beforeScreenshotBase64?: string;
/**
* After-state screenshot as base64 data URL (spec Layer 6.3-R3).
* Gives the model the proposed post-diff visual state.
*/
afterScreenshotBase64?: string;
}
function stripDataUrl(dataUrl: string): string {
return dataUrl.replace(/^data:image\/\w+;base64,/, '');
}
/**
* Builds the system + user content blocks for a coding-agent design question.
* When screenshots are provided they precede the text context so the model can
* visually ground its answer before reading the structured JSON.
* Max 1024 tokens — the answer must be precise and actionable.
*/
export function buildAgentQueryMessages(input: AgentQueryPromptInput): {
system: Anthropic.Messages.TextBlockParam[];
userContent: Anthropic.Messages.ContentBlockParam[];
maxTokens: number;
} {
const system = buildSystemPrompt({
role: 'a design agent answering questions from a coding agent implementing a design diff',
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
const userContent: Anthropic.Messages.ContentBlockParam[] = [];
// Screenshots first — visual context before text, per multimodal best practice.
if (input.beforeScreenshotBase64) {
userContent.push({
type: 'text',
text: 'Before state (artboard before this diff was applied):',
});
userContent.push({
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: stripDataUrl(input.beforeScreenshotBase64) },
} as Anthropic.Messages.ImageBlockParam);
}
if (input.afterScreenshotBase64) {
userContent.push({
type: 'text',
text: 'After state (proposed artboard after this diff is applied):',
});
userContent.push({
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: stripDataUrl(input.afterScreenshotBase64) },
} as Anthropic.Messages.ImageBlockParam);
}
userContent.push({
type: 'text',
text:
`<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\n` +
`Answer 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\n` +
`Respond ONLY with valid JSON.`,
});
return { system, userContent, maxTokens: 1024 };
}
@@ -0,0 +1,46 @@
// ── Artboard Query Prompt ─────────────────────────────────────────────────────
// Versioned prompt for cross-artboard design intent search.
//
// Version: 1.0
// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider")
// Tokens: max 1024 — output is a JSON results array ranked by relevance
//
// Structure:
// System: stable role (cached) — no DLF needed for cross-artboard search
// User: artboard metadata array + query string (not cached — varies per call)
import type Anthropic from '@anthropic-ai/sdk';
import { buildSystemPrompt } from './system.js';
export const ARTBOARD_QUERY_PROMPT_VERSION = '1.0';
export interface ArtboardQueryPromptInput {
/** Natural language query from the user */
query: string;
/** Array of artboard metadata objects serialized as JSON */
artboardsJson: string;
}
/**
* Builds the system + user message pair for a cross-artboard relevance search.
* Only artboards with relevanceScore > 0.3 should be returned by the model.
*/
export function buildArtboardQueryMessages(input: ArtboardQueryPromptInput): {
system: Anthropic.Messages.TextBlockParam[];
userContent: string;
maxTokens: number;
} {
const system = buildSystemPrompt({ role: 'a search agent filtering artboards by design intent' });
const userContent =
`<artboards>\n${input.artboardsJson}\n</artboards>\n\n` +
`<query>\n${input.query}\n</query>\n\n` +
`Return a JSON object:\n` +
`{\n` +
` "results": [{"artboardId": "...", "relevanceScore": 0-1, "reason": "..."}],\n` +
` "reasoning": "brief explanation"\n` +
`}\n\n` +
`Only include artboards with relevanceScore > 0.3. Respond ONLY with valid JSON.`;
return { system, userContent, maxTokens: 1024 };
}
@@ -0,0 +1,71 @@
// ── Completion Zone Prompt ────────────────────────────────────────────────────
// Versioned prompt for filling AI Completion Zones.
//
// Version: 1.0
// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider")
// Tokens: max 4096 — output is a structured JSON tree
//
// Structure:
// System: stable role (cached) + DLF as a hard constraint block (cached)
// User: component tree context + optional screenshot + zone intent string
//
// The DLF is placed first (before the component tree) so it is cached across
// all completion zone calls in the same workspace session. The model must treat
// the DLF as a hard constraint: any proposed component must exist in the DLF's
// component list, and all props must pass the DLF's allowedProps rules.
import type Anthropic from '@anthropic-ai/sdk';
import { buildSystemPrompt } from './system.js';
export const COMPLETION_ZONE_PROMPT_VERSION = '1.0';
export interface CompletionZonePromptInput {
componentTreeJson: string;
intent: string;
dlfJson?: string;
screenshotBase64?: string;
}
/**
* Builds the full message array for a completion zone generation request.
* Returns a multi-part user message that includes an optional image block.
*/
export function buildCompletionZoneMessages(input: CompletionZonePromptInput): {
system: Anthropic.Messages.TextBlockParam[];
userContent: Anthropic.Messages.ContentBlockParam[];
maxTokens: number;
} {
const system = buildSystemPrompt({
role: 'a Completion Zone design agent that generates UI component trees to fill empty design regions',
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
const textBlock: Anthropic.Messages.TextBlockParam = {
type: 'text',
text:
`<component_tree>\n${input.componentTreeJson}\n</component_tree>\n\n` +
`<intent>\n${input.intent}\n</intent>\n\n` +
(input.dlfJson
? `The DLF in the system prompt is a HARD CONSTRAINT. Only use components and prop values defined there.\n\n`
: '') +
`Return a JSON object with exactly two fields:\n` +
`- "proposedTree": the updated component tree matching the intent (same shape as the input tree)\n` +
`- "description": one sentence describing what was generated and why\n\n` +
`Respond ONLY with valid JSON. No markdown, no prose outside the JSON.`,
};
const userContent: Anthropic.Messages.ContentBlockParam[] = [];
// Screenshot goes first if provided (visual context before textual context).
if (input.screenshotBase64) {
const base64Data = input.screenshotBase64.replace(/^data:image\/\w+;base64,/, '');
userContent.push({
type: 'image',
source: { type: 'base64', media_type: 'image/png', data: base64Data },
} as Anthropic.Messages.ImageBlockParam);
}
userContent.push(textBlock);
return { system, userContent, maxTokens: 4096 };
}
@@ -0,0 +1,72 @@
// ── Diff Summary Prompt ───────────────────────────────────────────────────────
// Versioned prompt for generating human-readable summaries of ComponentChange
// records and aggregate IntentDiff records.
//
// Version: 1.0
// Model: claude-sonnet-4-6 (spec Layer 6: "Claude Sonnet 4 API is the only AI provider")
// Tokens: max 300 per change summary, 500 for aggregate summary
//
// Structure:
// System: stable role (cached) + optional DLF constraint block (cached)
// User: component changes + output instruction (not cached — varies per call)
import type Anthropic from '@anthropic-ai/sdk';
import { buildSystemPrompt } from './system.js';
export const DIFF_SUMMARY_PROMPT_VERSION = '1.0';
export interface DiffSummaryPromptInput {
changesJson: string;
componentName: string;
dlfJson?: string;
}
export interface AggregateSummaryPromptInput {
changesJson: string;
artboardName: string;
dlfJson?: string;
}
/**
* Builds the system + user message pair for a single-component change summary.
* Max 300 tokens — one tight sentence for PR review readability.
*/
export function buildDiffSummaryMessages(
input: DiffSummaryPromptInput,
): { system: Anthropic.Messages.TextBlockParam[]; userContent: string; maxTokens: number } {
const system = buildSystemPrompt({
role: 'a technical writer summarizing UI component changes for pull request reviewers',
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
const userContent =
`Summarize the following changes to "${input.componentName}" in one concise sentence ` +
`(max 20 words). Focus on the user-visible impact, not technical prop names. ` +
`If the change violates a design system rule, note it.\n\n` +
`<changes>\n${input.changesJson}\n</changes>\n\n` +
`Respond with ONLY the summary sentence.`;
return { system, userContent, maxTokens: 300 };
}
/**
* Builds the system + user message pair for an aggregate IntentDiff summary.
* Max 500 tokens — a short paragraph describing all changes together.
*/
export function buildAggregateSummaryMessages(
input: AggregateSummaryPromptInput,
): { system: Anthropic.Messages.TextBlockParam[]; userContent: string; maxTokens: number } {
const system = buildSystemPrompt({
role: 'a technical writer summarizing a design session for engineering handoff',
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
const userContent =
`Write a 2-3 sentence summary of the following design changes made to the "${input.artboardName}" artboard. ` +
`Describe the combined intent — what changed and why — in terms a developer reading a PR can understand. ` +
`Do not list individual prop changes; synthesize them.\n\n` +
`<changes>\n${input.changesJson}\n</changes>\n\n` +
`Respond with ONLY the summary paragraph.`;
return { system, userContent, maxTokens: 500 };
}
+4
View File
@@ -27,9 +27,13 @@
"@pierre/trees": "1.0.0-beta.3", "@pierre/trees": "1.0.0-beta.3",
"@supabase/supabase-js": "^2.0.0", "@supabase/supabase-js": "^2.0.0",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
"@trpc/client": "^11.17.0",
"@trpc/react-query": "^11.17.0",
"@trpc/server": "^11.17.0",
"next": "^15.1.0", "next": "^15.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"zod": "^3.25.76",
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
@@ -0,0 +1,21 @@
// ── tRPC HTTP handler (Next.js App Router) ────────────────────────────────────
// Mounts the tRPC appRouter at /api/trpc/* using the fetch adapter.
// Both GET (queries) and POST (mutations) are needed.
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/index';
import { createTRPCContext } from '@/server/trpc';
import type { NextRequest } from 'next/server';
const handler = (req: NextRequest) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
onError: ({ path, error }) => {
console.error(`tRPC error on /${path}:`, error.message);
},
});
export { handler as GET, handler as POST };
+20 -6
View File
@@ -3,7 +3,9 @@
import { useState, useEffect, type ReactNode } from 'react'; import { useState, useEffect, type ReactNode } from 'react';
import { FluentProvider } from '@fluentui/react-components'; import { FluentProvider } from '@fluentui/react-components';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui'; import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
import { trpc } from '@/lib/trpc';
import { useTheme } from '@/store/theme'; import { useTheme } from '@/store/theme';
import { TourOverlay } from '@/components/walkthrough/TourOverlay'; import { TourOverlay } from '@/components/walkthrough/TourOverlay';
@@ -23,6 +25,16 @@ export function Providers({ children }: { children: ReactNode }) {
}), }),
); );
// tRPC client — shares the QueryClient so tRPC queries/mutations go into the
// same cache as all other TanStack Query calls in the app.
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({ url: '/api/trpc' }),
],
}),
);
const mode = useTheme((s) => s.mode); const mode = useTheme((s) => s.mode);
const theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme; const theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme;
@@ -33,11 +45,13 @@ export function Providers({ children }: { children: ReactNode }) {
}, [mode]); }, [mode]);
return ( return (
<QueryClientProvider client={queryClient}> <trpc.Provider client={trpcClient} queryClient={queryClient}>
<FluentProvider theme={theme} style={{ height: '100%' }}> <QueryClientProvider client={queryClient}>
{children} <FluentProvider theme={theme} style={{ height: '100%' }}>
<TourOverlay /> {children}
</FluentProvider> <TourOverlay />
</QueryClientProvider> </FluentProvider>
</QueryClientProvider>
</trpc.Provider>
); );
} }
+90 -147
View File
@@ -7,6 +7,7 @@ import { useCanvas } from '@/store/canvas';
import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards'; import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards';
import { useCanvasTheme } from '@/store/canvasTheme'; import { useCanvasTheme } from '@/store/canvasTheme';
import { Artboard } from './Artboard'; import { Artboard } from './Artboard';
import { CompletionZone } from './CompletionZone';
export function Canvas() { export function Canvas() {
const T = useCanvasTheme(); const T = useCanvasTheme();
@@ -77,8 +78,13 @@ export function Canvas() {
// Zone tool: drag to draw a completion zone // Zone tool: drag to draw a completion zone
const zoneStart = useRef<{ x: number; y: number } | null>(null); const zoneStart = useRef<{ x: number; y: number } | null>(null);
const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [zonePreview, setZonePreview] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null); const [zoneDone, setZoneDone] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
// Completion preview: AI result overlaid on the artboard at zone coords (spec Layer 6)
const [completionPreview, setCompletionPreview] = useState<{
result: string;
bounds: { x: number; y: number; w: number; h: number };
} | null>(null);
// Wheel: pan or pinch-zoom // Wheel: pan or pinch-zoom
useEffect(() => { useEffect(() => {
@@ -274,15 +280,93 @@ export function Canvas() {
</span> </span>
</div> </div>
)} )}
{/* AI completion preview — rendered in artboard space at zone bounds.
Spec Layer 6: "A preview overlay showing the AI-generated completion
on the artboard." Positioned inside the transform layer so it tracks
pan/zoom automatically with no coordinate conversion needed. */}
{completionPreview && (
<div
style={{
position: 'absolute',
left: completionPreview.bounds.x,
top: completionPreview.bounds.y,
width: completionPreview.bounds.w,
height: completionPreview.bounds.h,
background: 'rgba(20,22,30,0.82)',
border: '1.5px solid rgba(51,133,255,0.55)',
borderRadius: 6,
backdropFilter: 'blur(6px)',
padding: '10px 12px',
boxSizing: 'border-box',
pointerEvents: 'none',
overflow: 'hidden',
}}
>
{/* "AI" badge */}
<div style={{
display: 'flex',
alignItems: 'center',
gap: 5,
marginBottom: 6,
}}>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.45rem',
fontWeight: 700,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'rgba(51,133,255,0.9)',
background: 'rgba(51,133,255,0.12)',
border: '1px solid rgba(51,133,255,0.3)',
borderRadius: 3,
padding: '1px 4px',
}}>
AI Preview
</span>
{/* Dismiss button */}
<button
onMouseDown={e => { e.stopPropagation(); setCompletionPreview(null); }}
style={{
marginLeft: 'auto',
background: 'none',
border: 'none',
color: 'rgba(255,255,255,0.3)',
cursor: 'pointer',
fontSize: 10,
padding: 0,
lineHeight: 1,
pointerEvents: 'auto',
}}
>
</button>
</div>
<p style={{
fontFamily: 'sans-serif',
fontSize: '0.625rem',
color: 'rgba(255,255,255,0.75)',
lineHeight: 1.55,
margin: 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}>
{completionPreview.result}
</p>
</div>
)}
</div> </div>
{/* Zone prompt overlay — shown after a zone drag completes */} {/* Completion zone popup — shown after a zone drag completes.
Lives in screen space (outside the transform layer) so the input
isn't scaled by zoom. */}
{zoneDone && ( {zoneDone && (
<ZonePromptOverlay <CompletionZone
bounds={zoneDone} bounds={zoneDone}
artboardId={selectedArtboardId} artboardId={selectedArtboardId}
panX={panX} panY={panY} zoom={zoom} panX={panX} panY={panY} zoom={zoom}
onClose={() => setZoneDone(null)} onClose={() => { setZoneDone(null); }}
onResult={(result, bounds) => setCompletionPreview({ result, bounds })}
/> />
)} )}
@@ -443,145 +527,4 @@ function UrlOnboardingOverlay({
); );
} }
/* ── Zone prompt overlay ──────────────────────────────────── */ // ZonePromptOverlay extracted to ./CompletionZone.tsx (spec Layer 6.4)
function ZonePromptOverlay({
bounds, artboardId, panX, panY, zoom, onClose,
}: {
bounds: { x: number; y: number; w: number; h: number };
artboardId: string | null;
panX: number; panY: number; zoom: number;
onClose: () => void;
}) {
const [prompt, setPrompt] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [result, setResult] = useState('');
// Convert canvas → screen coordinates (relative to canvas container)
const screenX = bounds.x * zoom + panX;
const screenY = (bounds.y + bounds.h) * zoom + panY + 10; // 10px below zone
const submit = useCallback(async () => {
if (!prompt.trim() || !artboardId) return;
setStatus('loading');
try {
const res = await fetch('/api/ai/completion-zone', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artboard_id: artboardId,
bounds: { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h },
prompt: prompt.trim(),
}),
});
if (!res.ok) throw new Error(`${res.status}`);
const data = await res.json() as { completion?: string; result?: string };
setResult(data.completion ?? data.result ?? 'Done');
setStatus('done');
} catch (e) {
console.error('[ZonePrompt]', e);
setStatus('error');
}
}, [prompt, artboardId, bounds]);
return (
<div
style={{
position: 'absolute',
left: Math.max(8, screenX),
top: Math.max(8, screenY),
zIndex: 50,
width: 280,
background: '#1A1A20',
border: '1px solid rgba(51,133,255,0.35)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
padding: '12px 14px',
fontFamily: "'Inter', -apple-system, sans-serif",
}}
onMouseDown={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'rgba(51,133,255,0.9)', letterSpacing: '-0.01em' }}>
Completion zone · {bounds.w}×{bounds.h}
</span>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'rgba(255,255,255,0.3)', cursor: 'pointer', fontSize: 13, padding: 0, lineHeight: 1 }}></button>
</div>
{status === 'done' ? (
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.75)', lineHeight: 1.6, marginBottom: 10 }}>
{result}
</div>
) : (
<>
<textarea
autoFocus
value={prompt}
onChange={e => setPrompt(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void submit();
if (e.key === 'Escape') onClose();
e.stopPropagation();
}}
placeholder="Describe what to generate in this zone…"
rows={3}
style={{
width: '100%', boxSizing: 'border-box',
background: 'rgba(255,255,255,0.05)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 6, padding: '8px 10px',
fontSize: '0.75rem', color: 'rgba(255,255,255,0.85)',
fontFamily: 'inherit', resize: 'none', outline: 'none',
marginBottom: 8,
}}
onFocus={e => (e.currentTarget.style.borderColor = '#3385FF')}
onBlur={e => (e.currentTarget.style.borderColor = 'rgba(255,255,255,0.1)')}
/>
{status === 'error' && (
<p style={{ fontSize: '0.625rem', color: '#FF8080', margin: '0 0 6px' }}>Request failed try again</p>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !prompt.trim() || !artboardId}
style={{
flex: 1, padding: '7px 0', borderRadius: 6,
background: !prompt.trim() || !artboardId ? 'rgba(51,133,255,0.3)' : '#3385FF',
border: 'none', color: '#fff',
fontSize: '0.75rem', fontWeight: 600, cursor: 'pointer',
fontFamily: 'inherit', opacity: status === 'loading' ? 0.7 : 1,
}}
>
{status === 'loading' ? 'Generating…' : 'Generate ⌘↵'}
</button>
<button
onClick={onClose}
style={{
padding: '7px 12px', borderRadius: 6,
background: 'transparent', border: '1px solid rgba(255,255,255,0.12)',
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Cancel
</button>
</div>
</>
)}
{status === 'done' && (
<button
onClick={onClose}
style={{
width: '100%', padding: '7px 0', borderRadius: 6,
background: 'rgba(255,255,255,0.07)', border: 'none',
color: 'rgba(255,255,255,0.5)', fontSize: '0.75rem',
cursor: 'pointer', fontFamily: 'inherit',
}}
>
Close
</button>
)}
</div>
);
}
@@ -0,0 +1,236 @@
'use client';
// ── CompletionZone ────────────────────────────────────────────────────────────
// Floating prompt overlay shown after a user draws an AI completion zone on the
// canvas. Handles the full AI → result → IntentDiff flow.
//
// Spec Layer 6: AI calls go through the tRPC server-side router (ai.fillCompletionZone)
// so they are authenticated and workspace-attributed before reaching the AI layer.
// Spec Layer 6.4: completion zone fills are recorded as DRAFT IntentDiffs with
// changeType 'insertion' so the diff engine can classify them correctly.
import { useState, useCallback } from 'react';
import { Button, Textarea } from '@fluentui/react-components';
import { trpc } from '@/lib/trpc';
import { useCanvas } from '@/store/canvas';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface ZoneBounds {
x: number;
y: number;
w: number;
h: number;
}
export interface CompletionZoneProps {
bounds: ZoneBounds;
artboardId: string | null;
/** Canvas pan/zoom — used to convert artboard coords to screen position */
panX: number;
panY: number;
zoom: number;
onClose: () => void;
/**
* Fires when the AI returns a result — Canvas uses this to render a
* preview overlay at the zone bounds in artboard coordinate space.
* (spec Layer 6: "A preview overlay showing the AI-generated completion")
*/
onResult?: (result: string, bounds: ZoneBounds) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function CompletionZone({
bounds, artboardId, panX, panY, zoom, onClose, onResult,
}: CompletionZoneProps) {
const [prompt, setPrompt] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'error'>('idle');
const [result, setResult] = useState('');
const [diffStatus, setDiffStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const workspaceId = useCanvas(s => s.workspaceId);
const activeAgentSessionId = useCanvas(s => s.activeAgentSessionId);
const fillZone = trpc.ai.fillCompletionZone.useMutation();
// Convert artboard → screen coordinates (relative to canvas container).
// Position the popover 10px below the drawn zone.
const screenX = bounds.x * zoom + panX;
const screenY = (bounds.y + bounds.h) * zoom + panY + 10;
// ── Submit: call completion-zone via tRPC (spec Layer 6) ─────────────────
const submit = useCallback(async () => {
if (!prompt.trim() || !artboardId || !workspaceId) return;
setStatus('loading');
setDiffStatus('idle');
try {
const data = await fillZone.mutateAsync({
artboardId,
workspaceId,
intent: prompt.trim(),
bounds: { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h },
});
setResult(data.completion);
setStatus('done');
// Notify canvas to render the preview overlay at the zone bounds
onResult?.(data.completion, bounds);
} catch (e) {
console.error('[CompletionZone]', e);
setStatus('error');
}
}, [prompt, artboardId, workspaceId, bounds, fillZone, onResult]);
// ── Accept: save as DRAFT IntentDiff (spec Layer 6.4) ────────────────────
// Uses ComponentChange with changeType 'insertion' so validateChange() and
// the diff overlay can classify this as a new element being added.
const acceptCompletion = useCallback(async () => {
if (!artboardId || !result || diffStatus !== 'idle') return;
setDiffStatus('saving');
try {
const zoneContext = { x: bounds.x, y: bounds.y, width: bounds.w, height: bounds.h };
// Synthetic componentId derived from zone position — ensures multiple fills
// on the same artboard produce distinct diff records.
const componentChange = {
componentId: `zone-${bounds.x}-${bounds.y}-${bounds.w}x${bounds.h}`,
displayName: 'AICompletionZone',
changeType: 'insertion' as const,
before: {},
after: { description: result, zoneContext },
humanSummary: result,
};
const res = await fetch('/api/diffs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
artboard_id: artboardId,
aggregate_summary: result,
changes: { changes: [componentChange] },
status: 'draft',
// Links diff to active agent session (if any) for Agent Bridge sync.
// Empty string when no session is running — matches DB DEFAULT ''.
session_id: activeAgentSessionId ?? '',
}),
});
if (!res.ok) throw new Error(`${res.status}`);
setDiffStatus('saved');
} catch (e) {
console.error('[CompletionZone] accept diff failed', e);
setDiffStatus('error');
}
}, [artboardId, result, diffStatus, bounds, activeAgentSessionId]);
// ── Render ────────────────────────────────────────────────────────────────
// ── Render ─────────────────────────────────────────────────────────────────
// Positioned in screen space (outside the canvas transform layer) so the
// input controls render at normal scale regardless of canvas zoom.
// Spec Layer 6.4: uses Fluent 2 Textarea + Button per spec requirement.
return (
<div
style={{
position: 'absolute',
left: Math.max(8, screenX),
top: Math.max(8, screenY),
zIndex: 50,
width: 300,
background: '#1A1A20',
border: '1px solid rgba(51,133,255,0.35)',
borderRadius: 10,
boxShadow: '0 8px 32px rgba(0,0,0,0.6)',
padding: '12px 14px',
fontFamily: "'Inter', -apple-system, sans-serif",
}}
onMouseDown={e => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 10 }}>
<span style={{ fontSize: '0.6875rem', fontWeight: 600, color: 'rgba(51,133,255,0.9)', letterSpacing: '-0.01em' }}>
Completion zone · {bounds.w}×{bounds.h}
</span>
<Button
appearance="subtle"
size="small"
onClick={onClose}
style={{ minWidth: 0, padding: '0 4px', color: 'rgba(255,255,255,0.35)' }}
>
</Button>
</div>
{status === 'done' ? (
<>
{/* AI result preview */}
<div style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.75)', lineHeight: 1.6, marginBottom: 10 }}>
{result}
</div>
{/* Accept (spec: "Accept" commits to IntentDiff) / Reject (spec) */}
<div style={{ display: 'flex', gap: 6 }}>
<Button
appearance="primary"
size="small"
disabled={diffStatus !== 'idle'}
onClick={() => void acceptCompletion()}
title="Save this completion as a draft intent diff"
style={{ flex: 1 }}
>
{diffStatus === 'saving' ? 'Saving…' : diffStatus === 'saved' ? '✓ Saved' : diffStatus === 'error' ? 'Save failed' : '✓ Accept'}
</Button>
<Button
appearance="subtle"
size="small"
onClick={onClose}
>
Reject
</Button>
</div>
</>
) : (
<>
{/* Fluent 2 Textarea for intent input (spec Layer 6.4) */}
<Textarea
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={prompt}
onChange={(_, d) => setPrompt(d.value)}
onKeyDown={e => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) void submit();
if (e.key === 'Escape') onClose();
e.stopPropagation();
}}
placeholder="Describe what to generate in this zone…"
rows={3}
resize="none"
style={{ width: '100%', marginBottom: 8 }}
/>
{status === 'error' && (
<p style={{ fontSize: '0.625rem', color: 'var(--colorPaletteRedForeground1)', margin: '0 0 6px' }}>
Request failed try again
</p>
)}
<div style={{ display: 'flex', gap: 6 }}>
<Button
appearance="primary"
size="small"
disabled={status === 'loading' || !prompt.trim() || !artboardId}
onClick={() => void submit()}
style={{ flex: 1 }}
>
{status === 'loading' ? 'Generating…' : 'Generate ⌘↵'}
</Button>
<Button
appearance="subtle"
size="small"
onClick={onClose}
>
Cancel
</Button>
</div>
</>
)}
</div>
);
}
@@ -1,11 +1,13 @@
'use client'; 'use client';
import { useState, useRef, useCallback, useEffect } from 'react'; import { useState, useRef, useCallback, useEffect } from 'react';
import { Badge } from '@fluentui/react-components';
import { useHistory } from '@/store/history'; import { useHistory } from '@/store/history';
import { useCanvas } from '@/store/canvas'; import { useCanvas } from '@/store/canvas';
import { useViewport } from '@/store/viewport'; import { useViewport } from '@/store/viewport';
import type { FiberNode, DOMRectLike } from '@originmain/renderer'; import type { FiberNode, DOMRectLike } from '@originmain/renderer';
import type { PropChange } from '@originmain/diff-engine'; import type { PropChange } from '@originmain/diff-engine';
import type { Violation } from '@originmain/design-language';
// ── Types ───────────────────────────────────────────────────────────────────── // ── Types ─────────────────────────────────────────────────────────────────────
@@ -179,7 +181,7 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
const onResizeCommitRef = useRef(onResizeCommit); const onResizeCommitRef = useRef(onResizeCommit);
useEffect(() => { onResizeCommitRef.current = onResizeCommit; }); useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
const { patchStyleEdit, dispatchRemoveElement } = useCanvas(); const { patchStyleEdit, dispatchRemoveElement, activeViolations } = useCanvas();
// Sync rect when selection changes to a different element // Sync rect when selection changes to a different element
useEffect(() => { useEffect(() => {
@@ -309,6 +311,11 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
{Math.round(width)} × {Math.round(height)} {Math.round(width)} × {Math.round(height)}
</span> </span>
{/* DLF violation badge — shown when the Inspector has detected violations */}
{activeViolations.length > 0 && (
<ViolationBadge violations={activeViolations} />
)}
{/* Delete button */} {/* Delete button */}
<button <button
title="Delete element (⌫)" title="Delete element (⌫)"
@@ -380,6 +387,31 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
); );
} }
// ── Violation badge (Fluent 2 Badge — spec Layer 5.2-R3) ─────────────────────
// Shown in the SelectionHandles label row when the Inspector has found DLF
// violations for the currently-selected component.
// Uses Fluent 2 Badge with appearance="filled" and color="warning"|"danger"
// exactly as required by the spec.
function ViolationBadge({ violations }: { violations: Violation[] }) {
const hasError = violations.some(v => v.severity === 'error');
const tooltip = violations
.map(v => `[${v.severity}] ${v.prop}: ${v.message}`)
.join('\n');
return (
<span title={tooltip} style={{ cursor: 'default' }}>
<Badge
appearance="filled"
color={hasError ? 'danger' : 'warning'}
size="small"
>
{violations.length} {violations.length === 1 ? 'violation' : 'violations'}
</Badge>
</span>
);
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function hitTestFiber(node: FiberNode, x: number, y: number): FiberNode | null { function hitTestFiber(node: FiberNode, x: number, y: number): FiberNode | null {
@@ -1,12 +1,17 @@
'use client'; 'use client';
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Badge } from '@fluentui/react-components';
import { useCanvas } from '@/store/canvas'; import { useCanvas } from '@/store/canvas';
import { useHistory } from '@/store/history'; import { useHistory } from '@/store/history';
import { useArtboards, patchArtboard } from '@/hooks/useArtboards'; import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
import { useDiffs } from '@/hooks/useDiffs'; import { useDiffs } from '@/hooks/useDiffs';
import { useDlf } from '@/hooks/useDlf';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { trpc } from '@/lib/trpc';
import { useCanvasTheme } from '@/store/canvasTheme'; import { useCanvasTheme } from '@/store/canvasTheme';
import { checkComponentConstraints } from '@originmain/design-language';
import type { Violation } from '@originmain/design-language';
import type { PropChange } from '@originmain/diff-engine'; import type { PropChange } from '@originmain/diff-engine';
import type { FiberNode } from '@originmain/renderer'; import type { FiberNode } from '@originmain/renderer';
import type { Artboard, IntentDiff } from '@originmain/origin-graph'; import type { Artboard, IntentDiff } from '@originmain/origin-graph';
@@ -109,6 +114,7 @@ export function Inspector() {
componentId={selectedComponentId} componentId={selectedComponentId}
componentData={selectedComponentData} componentData={selectedComponentData}
styles={selectedComponentStyles} styles={selectedComponentStyles}
workspaceId={workspaceId}
/> />
) : tab === 'props' ? ( ) : tab === 'props' ? (
<PropsTab <PropsTab
@@ -534,14 +540,36 @@ function DesignTab({
componentId, componentId,
componentData, componentData,
styles, styles,
workspaceId,
}: { }: {
artboardId: string | null; artboardId: string | null;
componentId: string | null; componentId: string | null;
componentData: FiberNode | null; componentData: FiberNode | null;
styles: Record<string, string> | null; styles: Record<string, string> | null;
workspaceId: string | null | undefined;
}) { }) {
const T = useCanvasTheme(); const T = useCanvasTheme();
const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren } = useCanvas(); const { patchStyleEdit, patchChildrenStyleEdit, indexerStatus, selectedComponentHasDirectText, selectedComponentHasParagraphChildren, setActiveViolations } = useCanvas();
const { dlf } = useDlf(workspaceId);
// Re-run constraint checks whenever the selected component or active DLF changes.
// We pass component.props (React props) — not CSS styles — to the validator since
// DLF component rules govern variant/size/etc., not raw CSS properties.
const dlfViolations = useMemo<Violation[]>(() => {
if (!dlf || !componentData?.name) return [];
return checkComponentConstraints({
componentName: componentData.name,
props: (componentData.props ?? {}) as Record<string, unknown>,
dlf,
});
}, [dlf, componentData?.name, componentData?.props]);
// Sync violations to the canvas store so SelectionOverlay can render inline badges.
// Runs after every render where dlfViolations changes; clears on component deselect.
useEffect(() => {
setActiveViolations(dlfViolations);
return () => { setActiveViolations([]); };
}, [dlfViolations, setActiveViolations]);
if (!artboardId) { if (!artboardId) {
return ( return (
@@ -659,6 +687,11 @@ function DesignTab({
)} )}
</div> </div>
{/* ── DLF violation banner ─────────────────────────────────── */}
{dlfViolations.length > 0 && (
<DlfViolationBanner violations={dlfViolations} />
)}
{/* ── Section components ───────────────────────────────────── */} {/* ── Section components ───────────────────────────────────── */}
<FrameSection styles={styles} onPatch={patch} /> <FrameSection styles={styles} onPatch={patch} />
<ConstraintsSection styles={styles} onPatch={patch} /> <ConstraintsSection styles={styles} onPatch={patch} />
@@ -1148,13 +1181,74 @@ function HSep() {
return <div style={{ height: 1, background: T.sep, margin: '2px 0' }} />; return <div style={{ height: 1, background: T.sep, margin: '2px 0' }} />;
} }
/* ── DLF violation banner ─────────────────────────────────── */
function DlfViolationBanner({ violations }: { violations: Violation[] }) {
const T = useCanvasTheme();
const hasError = violations.some(v => v.severity === 'error');
return (
<div
style={{
margin: '4px 10px 2px',
padding: '8px 10px',
background: hasError ? 'rgba(255,80,80,0.07)' : 'rgba(255,186,123,0.07)',
border: `1px solid ${hasError ? 'rgba(255,80,80,0.4)' : 'rgba(255,186,123,0.4)'}`,
borderRadius: 6,
}}
>
{/* Section header */}
<div style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5rem',
fontWeight: 600,
letterSpacing: '0.08em',
textTransform: 'uppercase',
color: hasError ? '#FF8080' : '#FFBA7B',
marginBottom: 6,
}}>
{hasError ? 'Design system violations' : 'Design system warnings'}
</div>
{/* Per-violation Fluent 2 badges (spec Layer 5.2-R3) */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{violations.map((v, i) => (
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 5 }}>
<Badge
appearance="filled"
color={v.severity === 'error' ? 'danger' : 'warning'}
size="small"
style={{ flexShrink: 0, marginTop: 1 }}
>
{v.severity}
</Badge>
<span style={{
fontFamily: "'JetBrains Mono', monospace",
fontSize: '0.5625rem',
color: T.fgMuted,
lineHeight: 1.5,
}}>
{v.prop && <strong style={{ color: T.fg }}>{v.prop}: </strong>}
{v.message}
</span>
</div>
))}
</div>
</div>
);
}
/* ── Diff tab ─────────────────────────────────────────────── */ /* ── Diff tab ─────────────────────────────────────────────── */
function DiffTab({ artboardId }: { artboardId: string | null }) { function DiffTab({ artboardId }: { artboardId: string | null }) {
const T = useCanvasTheme(); const T = useCanvasTheme();
const { stacks } = useHistory(); const { stacks } = useHistory();
const { diffs, createDiff, isLoading } = useDiffs(artboardId); const { diffs, createDiff, isLoading } = useDiffs(artboardId);
const { workspaceId, activeAgentSessionId } = useCanvas();
const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle'); const [summaryStatus, setSummaryStatus] = useState<'idle' | 'summarising' | 'exporting'>('idle');
// tRPC mutation for AI diff summary (spec Layer 6 — server-side, authenticated)
const summarizeDiff = trpc.ai.generateDiffSummary.useMutation();
const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] }; const artboardHistory = artboardId ? (stacks[artboardId] ?? { past: [], future: [] }) : { past: [], future: [] };
const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes); const pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
const hasChanges = pendingChanges.length > 0; const hasChanges = pendingChanges.length > 0;
@@ -1162,39 +1256,37 @@ function DiffTab({ artboardId }: { artboardId: string | null }) {
const exportDiff = useCallback(async () => { const exportDiff = useCallback(async () => {
if (!artboardId || !hasChanges) return; if (!artboardId || !hasChanges) return;
// 1. Generate AI summary (best-effort — fall back to empty string on failure) // 1. Generate AI summary via tRPC (best-effort — fall back to empty string)
let summary = ''; let summary = '';
const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged'); const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged');
if (meaningfulChanges.length > 0) { if (meaningfulChanges.length > 0 && workspaceId) {
setSummaryStatus('summarising'); setSummaryStatus('summarising');
try { try {
const res = await fetch('/api/ai/diff-summary', { const data = await summarizeDiff.mutateAsync({
method: 'POST', artboardId,
headers: { 'Content-Type': 'application/json' }, workspaceId,
body: JSON.stringify({ changesJson: JSON.stringify(meaningfulChanges),
changesJson: JSON.stringify(meaningfulChanges), componentName: meaningfulChanges[0]?.key ?? 'Component',
componentName: meaningfulChanges[0]?.key ?? 'Component',
}),
}); });
if (res.ok) { summary = data.summary;
const data = await res.json() as { summary?: string };
summary = data.summary ?? '';
}
} catch { /* non-fatal — proceed without summary */ } } catch { /* non-fatal — proceed without summary */ }
} }
// 2. Export diff with AI-generated summary included // 2. Export diff with AI-generated summary included.
// session_id links this diff to the active agent session (if any) so the
// Agent Bridge can query diffs-by-session. Empty string = no active session.
setSummaryStatus('exporting'); setSummaryStatus('exporting');
createDiff.mutate( createDiff.mutate(
{ {
artboard_id: artboardId, artboard_id: artboardId,
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] }, changes: { propChanges: pendingChanges, styleChanges: [] },
summary, aggregate_summary: summary,
status: 'DRAFT', status: 'draft',
session_id: activeAgentSessionId ?? '',
}, },
{ onSettled: () => setSummaryStatus('idle') }, { onSettled: () => setSummaryStatus('idle') },
); );
}, [artboardId, pendingChanges, hasChanges, createDiff]); }, [artboardId, pendingChanges, hasChanges, createDiff, activeAgentSessionId]);
if (!artboardId) { if (!artboardId) {
return ( return (
@@ -1277,7 +1369,7 @@ const STATUS_COLOR: Record<string, string> = {
function SavedDiffRow({ diff }: { diff: IntentDiff }) { function SavedDiffRow({ diff }: { diff: IntentDiff }) {
const T = useCanvasTheme(); const T = useCanvasTheme();
const changes = diff.changes_jsonb as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null; const changes = diff.changes as { propChanges?: PropChange[]; styleChanges?: PropChange[] } | null;
const count = (changes?.propChanges?.length ?? 0) + (changes?.styleChanges?.length ?? 0); const count = (changes?.propChanges?.length ?? 0) + (changes?.styleChanges?.length ?? 0);
const color = STATUS_COLOR[diff.status] ?? T.dim; const color = STATUS_COLOR[diff.status] ?? T.dim;
return ( return (
@@ -1290,9 +1382,9 @@ function SavedDiffRow({ diff }: { diff: IntentDiff }) {
{count} change{count !== 1 ? 's' : ''} {count} change{count !== 1 ? 's' : ''}
</span> </span>
</div> </div>
{diff.summary && ( {diff.aggregate_summary && (
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: T.fgMuted, lineHeight: 1.4, display: 'block' }}> <span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: T.fgMuted, lineHeight: 1.4, display: 'block' }}>
{diff.summary} {diff.aggregate_summary}
</span> </span>
)} )}
</div> </div>
+59
View File
@@ -0,0 +1,59 @@
// ── useDlf hook ───────────────────────────────────────────────────────────────
// Fetches the active Design Language File for the current workspace and returns
// its parsed body as a typed DesignLanguageFileBody (tokens, component rules,
// screen rules, voice, accessibility). The raw DB row's schema_jsonb field is
// validated through the Zod schema at cache-write time so every consumer gets
// a fully typed result without re-parsing on each render.
//
// Stale time is 5 minutes — design systems change infrequently (deploy-time
// events) so we avoid redundant round-trips during normal editing sessions.
import { useQuery } from '@tanstack/react-query';
import type { DesignLanguageFile } from '@originmain/origin-graph';
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from '@originmain/design-language';
// ── Fetch + parse ─────────────────────────────────────────────────────────────
async function fetchDlf(workspaceId: string): Promise<DesignLanguageFileBody | null> {
const url = `/api/design-language?workspaceId=${encodeURIComponent(workspaceId)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`DLF fetch failed: ${res.status}`);
// API returns the raw DB row or null when no DLF is uploaded yet.
const file = (await res.json()) as DesignLanguageFile | null;
if (!file) return null;
// Validate schema_jsonb through the typed Zod schema.
// We throw on failure so TanStack Query surfaces it via query.error —
// callers can distinguish "no DLF" (null) from "malformed DLF" (error).
const parsed = DesignLanguageFileBodySchema.safeParse(file.schema_jsonb);
if (!parsed.success) {
throw new Error(
`Design language file schema is invalid: ${parsed.error.errors.map(e => e.message).join('; ')}`,
);
}
return parsed.data;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useDlf(workspaceId: string | null | undefined) {
const query = useQuery({
queryKey: ['dlf', workspaceId] as const,
queryFn: () => fetchDlf(workspaceId!),
enabled: Boolean(workspaceId),
// Design language files change at deploy-time, not interactively.
// 5-minute staleness keeps the inspector snappy without burning requests.
staleTime: 5 * 60_000,
// Retry once on transient network errors, then surface the error.
retry: 1,
});
return {
/** Parsed DLF body, or null if no file is uploaded for this workspace. */
dlf: query.data ?? null,
isLoading: query.isLoading,
/** Set when the DLF fetch succeeded but the schema failed Zod validation. */
error: query.error,
};
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
// ── tRPC client (Next.js App Router, client components) ───────────────────────
// Creates a typed tRPC client backed by the TanStack Query context already
// provided by the app's QueryClientProvider.
//
// Usage (in client components):
// const summarize = trpc.ai.summarizeDiff.useMutation();
// await summarize.mutateAsync({ artboardId, workspaceId, changes });
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/index';
// Typed tRPC hooks — import `trpc` in client components for .useQuery / .useMutation
export const trpc = createTRPCReact<AppRouter>();
// Raw client for server-side usage (e.g., in Server Actions)
export function makeTrpcClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
});
}
+151
View File
@@ -0,0 +1,151 @@
// ── AI tRPC router ────────────────────────────────────────────────────────────
// Spec Layer 6: server-side AI feature routes. All mutations are authenticated
// (protectedProcedure) and include the caller's workspace ID for attribution.
//
// Procedures:
// ai.generateDiffSummary — generate an aggregate_summary for a pending diff
// ai.fillCompletionZone — fill an AI completion zone with a proposed tree
// ai.answerAgentQuestion — answer a coding-agent design question
// ai.queryArtboards — cross-artboard semantic search (spec Layer 6)
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc.js';
import { AIGateway } from '@originmain/ai-layer';
import { getArtboard, getArtboardsByWorkspace } from '@originmain/origin-graph';
// ── Singleton gateway (lazily created) ───────────────────────────────────────
let _gateway: AIGateway | null = null;
function getGateway(): AIGateway {
if (!_gateway) _gateway = new AIGateway();
return _gateway;
}
// ── Router ────────────────────────────────────────────────────────────────────
export const aiRouter = router({
// ── generateDiffSummary ────────────────────────────────────────────────────
// Generates an AI aggregate_summary for a set of pending prop changes.
// Input: artboardId + workspaceId + changes[]
// Returns { summary: string }
generateDiffSummary: protectedProcedure
.input(z.object({
artboardId: z.string().uuid(),
workspaceId: z.string().uuid(),
changesJson: z.string(), // JSON-serialised PropChange[]
componentName: z.string(), // name of the changed component
dlfJson: z.string().optional(),
}))
.mutation(async ({ input }) => {
const gateway = getGateway();
const result = await gateway.generateDiffSummary({
changesJson: input.changesJson,
componentName: input.componentName,
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
return { summary: result.summary };
}),
// ── fillCompletionZone ────────────────────────────────────────────────────
// Fills an AI completion zone with a proposed component tree.
// Returns { completion: string, proposedTree: unknown }.
fillCompletionZone: protectedProcedure
.input(z.object({
artboardId: z.string().uuid(),
workspaceId: z.string().uuid(),
intent: z.string().min(1),
bounds: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }),
dlfJson: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const gateway = getGateway();
let componentTreeJson = '';
try {
const artboard = await getArtboard(ctx.db, input.artboardId);
componentTreeJson = JSON.stringify({ artboard });
} catch { /* non-fatal — artboard context is optional */ }
const result = await gateway.fillCompletionZone({
componentTreeJson,
intent: input.intent,
workspaceId: input.workspaceId,
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
return {
completion: result.description,
proposedTree: result.proposedTree,
};
}),
// ── answerAgentQuestion ───────────────────────────────────────────────────
// Answers a coding-agent question about a design diff (spec Layer 6.3-R3).
// Screenshots (before/after) are passed as base64 data URLs.
answerAgentQuestion: protectedProcedure
.input(z.object({
question: z.string().min(1),
diffId: z.string().uuid(),
artboardId: z.string().uuid(),
workspaceId: z.string().uuid(),
dlfJson: z.string().optional(),
beforeScreenshotBase64: z.string().optional(),
afterScreenshotBase64: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const gateway = getGateway();
let artboardContextJson = '';
try {
const artboard = await getArtboard(ctx.db, input.artboardId);
artboardContextJson = JSON.stringify(artboard);
} catch { /* non-fatal */ }
const result = await gateway.answerAgentQuery({
question: input.question,
diffId: input.diffId,
artboardContextJson,
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
...(input.beforeScreenshotBase64 !== undefined ? { beforeScreenshotBase64: input.beforeScreenshotBase64 } : {}),
...(input.afterScreenshotBase64 !== undefined ? { afterScreenshotBase64: input.afterScreenshotBase64 } : {}),
});
return result;
}),
// ── queryArtboards ────────────────────────────────────────────────────────
// Cross-artboard semantic search — finds artboards relevant to a natural
// language query (spec Layer 6: "cross-artboard queries via AI").
// Returns ranked results with relevance scores and reasoning.
queryArtboards: protectedProcedure
.input(z.object({
query: z.string().min(1),
workspaceId: z.string().uuid(),
}))
.mutation(async ({ input, ctx }) => {
const gateway = getGateway();
// Fetch all artboards in the workspace as context for the AI
let artboardsJson = '[]';
try {
const artboards = await getArtboardsByWorkspace(ctx.db, input.workspaceId);
artboardsJson = JSON.stringify(artboards);
} catch { /* non-fatal — AI returns empty results with no context */ }
const result = await gateway.queryArtboards({
query: input.query,
artboardsJson,
});
return result;
}),
});
export type AIRouter = typeof aiRouter;
+9
View File
@@ -0,0 +1,9 @@
// ── App router — root tRPC router ─────────────────────────────────────────────
import { router } from '../trpc.js';
import { aiRouter } from './ai.js';
export const appRouter = router({
ai: aiRouter,
});
export type AppRouter = typeof appRouter;
+43
View File
@@ -0,0 +1,43 @@
// ── tRPC server initialisation ────────────────────────────────────────────────
// Creates the tRPC context (Clerk auth + Supabase server client), the router
// factory, and the protected-procedure middleware.
//
// Spec Layer 6: all AI feature calls MUST go through tRPC server-side routes
// so they are authenticated, rate-limited, and attributed to a workspace.
import { initTRPC, TRPCError } from '@trpc/server';
import { auth } from '@clerk/nextjs/server';
import { serverClient } from '@/lib/supabase';
import type { DbClient } from '@originmain/origin-graph';
// ── Context ───────────────────────────────────────────────────────────────────
export interface TRPCContext {
userId: string | null;
db: DbClient;
}
export async function createTRPCContext(): Promise<TRPCContext> {
const { userId } = await auth();
const db = serverClient();
return { userId, db };
}
// ── Initialisation ────────────────────────────────────────────────────────────
const t = initTRPC.context<TRPCContext>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// ── Auth middleware ───────────────────────────────────────────────────────────
// Throws UNAUTHORIZED if the caller has no Clerk session.
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
+21
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import type { FiberNode } from '@originmain/renderer'; import type { FiberNode } from '@originmain/renderer';
import type { Violation } from '@originmain/design-language';
/** Framework and CSS strategy detected by the CLI AST indexer at startup. */ /** Framework and CSS strategy detected by the CLI AST indexer at startup. */
export interface ProjectMeta { export interface ProjectMeta {
@@ -83,6 +84,20 @@ interface CanvasStore {
/** Project metadata fetched from GET /health on CLI connection */ /** Project metadata fetched from GET /health on CLI connection */
projectMeta: ProjectMeta | null; projectMeta: ProjectMeta | null;
setProjectMeta: (meta: ProjectMeta | null) => void; setProjectMeta: (meta: ProjectMeta | null) => void;
// ── DLF violations (spec Layer 5.2) ──────────────────────────────────────────
// Written by Inspector's DesignTab when it evaluates the selected component
// against the active DLF; read by SelectionOverlay to render inline badges.
activeViolations: Violation[];
setActiveViolations: (violations: Violation[]) => void;
// ── Active agent session (spec Layer 6 — diff attribution) ──────────────────
// Set by the Agent Bridge when a session starts/ends. Inspector and
// CompletionZone read this to populate session_id on intent_diffs so the
// Agent Bridge can later query diffs by session (getDiffsByStatus etc.).
// null = no agent session active; user-created diffs get session_id ''.
activeAgentSessionId: string | null;
setActiveAgentSessionId: (id: string | null) => void;
} }
export const useCanvas = create<CanvasStore>((set) => ({ export const useCanvas = create<CanvasStore>((set) => ({
@@ -155,4 +170,10 @@ export const useCanvas = create<CanvasStore>((set) => ({
projectMeta: null, projectMeta: null,
setProjectMeta: (meta) => set({ projectMeta: meta }), setProjectMeta: (meta) => set({ projectMeta: meta }),
activeViolations: [],
setActiveViolations: (violations) => set({ activeViolations: violations }),
activeAgentSessionId: null,
setActiveAgentSessionId: (id) => set({ activeAgentSessionId: id }),
})); }));
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -14,8 +14,8 @@
"zod": "^3.0.0" "zod": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^2.1.9",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^2.0.0", "vitest": "^2.1.9"
"@vitest/coverage-v8": "^2.0.0"
} }
} }
+44 -96
View File
@@ -1,111 +1,59 @@
import { z } from 'zod'; import { z } from 'zod';
// ── Token schemas ───────────────────────────────────────────────────────────── // ── Design Language File Schema ───────────────────────────────────────────────
// Matches the spec exactly (Layer 5.1). This schema validates DLF files uploaded
export const ColorTokenSchema = z.object({ // by teams. All fields must align with the spec — deviations break real uploads.
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({ export const DesignLanguageFileBodySchema = z.object({
/** Semantic version, e.g. "1.0.0" */ /** Spec requires exactly '1.0' — reject any other version string. */
version: z.string().optional(), version: z.literal('1.0'),
/** Human-readable name, e.g. "Acme Design System" */ /** Human-readable name, e.g. "Acme Design System" */
name: z.string().optional(), name: z.string().optional(),
tokens: TokensSchema.optional(), tokens: z.object({
/** name → hex or token reference, e.g. { "brand": "#0F52BA" } */
colors: z.record(z.string()),
typography: z.record(z.string()),
spacing: z.record(z.string()),
motion: z.record(z.string()).optional(),
}),
/** Per-component rules, keyed by component display name */ /**
components: z.record(ComponentRuleSchema).optional(), * Per-component rules, keyed by component display name.
* allowedProps: prop → array of allowed string values
* forbiddenVariants: Fluent 2 variant strings that are prohibited
* requiredAria: ARIA attribute names that must be present
*/
components: z.record(z.object({
allowedProps: z.record(z.array(z.string())).optional(),
forbiddenVariants: z.array(z.string()).optional(),
requiredAria: z.array(z.string()).optional(),
})),
/** Per-screen rules, keyed by screen name or route pattern */ /** Per-screen rules, keyed by screen name or route pattern */
screens: z.record(ScreenRuleSchema).optional(), screens: z.record(z.object({
allowedComponents: z.array(z.string()).optional(),
forbiddenComponents: z.array(z.string()).optional(),
requiredSections: z.array(z.string()).optional(),
})).optional(),
voice: VoiceRuleSchema.optional(), voice: z.object({
/** Array of tone descriptors, e.g. ["friendly", "concise"] */
tone: z.array(z.string()),
avoidWords: z.array(z.string()).optional(),
}).optional(),
accessibility: AccessibilitySchema.optional(), accessibility: z.object({
minContrastRatio: z.number().default(4.5),
minTouchTargetPx: z.number().default(44),
requireAltText: z.boolean().default(true),
}).optional(),
}); });
export type DesignLanguageFileBody = z.infer<typeof DesignLanguageFileBodySchema>; export type DesignLanguageFileBody = z.infer<typeof DesignLanguageFileBodySchema>;
export type Tokens = z.infer<typeof TokensSchema>;
export type ComponentRule = z.infer<typeof ComponentRuleSchema>; // Convenience re-exports for downstream consumers
export type ScreenRule = z.infer<typeof ScreenRuleSchema>; export type ComponentRule = NonNullable<DesignLanguageFileBody['components'][string]>;
export type ScreenRule = NonNullable<NonNullable<DesignLanguageFileBody['screens']>[string]>;
export type Tokens = DesignLanguageFileBody['tokens'];
+7 -6
View File
@@ -6,15 +6,16 @@ import type { DesignLanguageFileBody } from './schema.js';
export type FluentTokenMap = Record<string, string>; export type FluentTokenMap = Record<string, string>;
/** Extract color token overrides from a DLF as a Fluent 2 token map. */ /**
* Extract color tokens from a DLF as a Fluent 2 token map.
* Tokens are flat strings (spec Layer 5.1: tokens.colors is Record<string,string>),
* so each key becomes a CSS custom property name and the value is the CSS value.
*/
export function extractColorTokens(dlf: DesignLanguageFileBody): FluentTokenMap { export function extractColorTokens(dlf: DesignLanguageFileBody): FluentTokenMap {
const out: FluentTokenMap = {}; const out: FluentTokenMap = {};
const colors = dlf.tokens?.colors ?? {}; const colors = dlf.tokens?.colors ?? {};
for (const [, token] of Object.entries(colors)) { for (const [name, value] of Object.entries(colors)) {
if (!token) continue; if (value) out[name] = value;
if (token.fluentToken) {
out[token.fluentToken] = token.value;
}
} }
return out; return out;
} }
+294 -18
View File
@@ -37,16 +37,16 @@ function formatZodErrors(error: ZodError): ValidationError[] {
})); }));
} }
// ── Runtime constraint checks ───────────────────────────────────────────────── // ── Runtime constraint checks (per-component) ─────────────────────────────────
// These run during visual edits and AI completions to catch violations // Used by the Inspector panel to validate the selected component's live React
// against the active DLF before they're shown to the user. // props against the active DLF in real time.
//
// Uses the spec schema: `allowedProps` is `Record<string, string[]>` — each
// entry maps a prop name to the list of allowed string values.
export interface ViolationCheck { export interface ViolationCheck {
/** Name of the component being checked */
componentName: string; componentName: string;
/** Props being applied */
props: Record<string, unknown>; props: Record<string, unknown>;
/** The active DLF */
dlf: DesignLanguageFileBody; dlf: DesignLanguageFileBody;
} }
@@ -64,26 +64,302 @@ export function checkComponentConstraints(check: ViolationCheck): Violation[] {
const componentRule = dlf.components?.[componentName]; const componentRule = dlf.components?.[componentName];
if (!componentRule) return violations; if (!componentRule) return violations;
const { props: propRules } = componentRule; const { allowedProps, forbiddenVariants } = componentRule;
if (!propRules) return violations;
for (const [propKey, rule] of Object.entries(propRules)) { // allowedProps: prop → string[] of allowed values
const value = props[propKey]; if (allowedProps) {
for (const [propKey, allowedValues] of Object.entries(allowedProps)) {
const value = props[propKey];
if (value !== undefined && !allowedValues.includes(String(value))) {
violations.push({
prop: propKey,
value,
message: `"${propKey}=${String(value)}" is not in the allowed values list`,
severity: 'warning',
});
}
}
}
// Use hasOwnProperty to distinguish "key absent" from "key set to undefined". // Component-level forbidden variant guard
// Under exactOptionalPropertyTypes these are semantically different. if (forbiddenVariants) {
if (rule.required && !Object.prototype.hasOwnProperty.call(props, propKey)) { const variant = props['variant'];
violations.push({ prop: propKey, value, message: `"${propKey}" is required by design system rules`, severity: 'error' }); if (typeof variant === 'string' && forbiddenVariants.includes(variant)) {
violations.push({
prop: 'variant',
value: variant,
message: `variant="${variant}" is forbidden for ${componentName} by design system rules`,
severity: 'error',
});
}
}
// requiredAria: ARIA attributes that must be present (spec Layer 5.2)
// Each entry is an aria attribute name (e.g. "aria-label", "role").
// Absence of any required ARIA attribute is a DLF error — accessibility
// violations are always errors, never warnings.
const { requiredAria } = componentRule;
if (requiredAria) {
for (const ariaAttr of requiredAria) {
if (props[ariaAttr] === undefined || props[ariaAttr] === null || props[ariaAttr] === '') {
violations.push({
prop: ariaAttr,
value: undefined,
message: `"${ariaAttr}" is required for ${componentName} by accessibility rules`,
severity: 'error',
});
}
}
}
// ── DLF accessibility block checks (spec Layer 5.2) ──────────────────────
const a11y = dlf.accessibility;
if (a11y) {
// requireAltText: image-like components must have a non-empty alt prop.
// Heuristic: component has a `src` prop (e.g. <img>, <Image>, <Avatar>).
if (a11y.requireAltText) {
const hasSrc = typeof props['src'] === 'string' && props['src'] !== '';
const altVal = props['alt'];
if (hasSrc && (altVal === undefined || altVal === null || altVal === '')) {
violations.push({
prop: 'alt',
value: altVal,
message: `"alt" text is required for image components (DLF accessibility.requireAltText)`,
severity: 'error',
});
}
} }
if (rule.forbidden && value !== undefined && rule.forbidden.includes(value)) { // minTouchTargetPx: interactive components must be at least N×N pixels.
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is forbidden by design system rules`, severity: 'error' }); // Checked against numeric width/height props when both are present.
const { minTouchTargetPx } = a11y;
if (minTouchTargetPx > 0) {
const w = typeof props['width'] === 'number' ? props['width'] : undefined;
const h = typeof props['height'] === 'number' ? props['height'] : undefined;
if (w !== undefined && w < minTouchTargetPx) {
violations.push({
prop: 'width', value: w,
message: `width ${w}px is below the minimum touch target of ${minTouchTargetPx}px`,
severity: 'warning',
});
}
if (h !== undefined && h < minTouchTargetPx) {
violations.push({
prop: 'height', value: h,
message: `height ${h}px is below the minimum touch target of ${minTouchTargetPx}px`,
severity: 'warning',
});
}
} }
if (rule.allowed && value !== undefined && !rule.allowed.includes(value)) { // minContrastRatio: check foreground/background color props.
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is not in the allowed values list`, severity: 'warning' }); // Only checked when both `color` and `backgroundColor` are hex strings,
// since contrast requires both colors. Uses WCAG 2.x relative luminance.
const { minContrastRatio } = a11y;
if (minContrastRatio > 0) {
const fg = props['color'];
const bg = props['backgroundColor'];
if (typeof fg === 'string' && typeof bg === 'string') {
const ratio = wcagContrastRatio(fg, bg);
if (ratio !== null && ratio < minContrastRatio) {
violations.push({
prop: 'color', value: fg,
message: `Color contrast ratio ${ratio.toFixed(2)}:1 is below DLF minimum of ${minContrastRatio}:1`,
severity: 'error',
});
}
}
} }
} }
return violations; return violations;
} }
// ── WCAG 2.x contrast ratio helper ───────────────────────────────────────────
// Returns the contrast ratio [1, 21] for two hex colors, or null if either
// string is not a recognisable hex color. Handles #RGB and #RRGGBB formats.
function hexToLinearRgb(hex: string): [number, number, number] | null {
const clean = hex.startsWith('#') ? hex.slice(1) : hex;
const expanded = clean.length === 3
? clean.split('').map(c => c + c).join('')
: clean;
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) return null;
const r = parseInt(expanded.slice(0, 2), 16) / 255;
const g = parseInt(expanded.slice(2, 4), 16) / 255;
const b = parseInt(expanded.slice(4, 6), 16) / 255;
const linearise = (v: number) =>
v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
return [linearise(r), linearise(g), linearise(b)];
}
function relativeLuminance(rgb: [number, number, number]): number {
return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2];
}
function wcagContrastRatio(hex1: string, hex2: string): number | null {
const rgb1 = hexToLinearRgb(hex1);
const rgb2 = hexToLinearRgb(hex2);
if (!rgb1 || !rgb2) return null;
const L1 = relativeLuminance(rgb1);
const L2 = relativeLuminance(rgb2);
const lighter = Math.max(L1, L2);
const darker = Math.min(L1, L2);
return (lighter + 0.05) / (darker + 0.05);
}
// ── ComponentChange validation (spec Layer 5.2) ───────────────────────────────
export interface ComponentChange {
componentId: string;
displayName: string;
filePath?: string;
changeType:
| 'prop_change'
| 'component_swap'
| 'layout_change'
| 'token_change'
| 'removal'
| 'insertion';
before: Record<string, unknown>;
after: Record<string, unknown>;
humanSummary: string;
}
export interface DesignViolation {
componentId: string;
/** DLF path of the violated rule, e.g. "components.Button.allowedProps.variant" */
rule: string;
severity: 'error' | 'warning';
message: string;
}
export interface ChangeValidationResult {
valid: boolean;
violations: DesignViolation[];
}
export function validateChange(
change: ComponentChange,
dlf: DesignLanguageFileBody,
): ChangeValidationResult {
const violations: DesignViolation[] = [];
// Removals have no after-state to validate.
if (change.changeType === 'removal') {
return { valid: true, violations: [] };
}
// prop_change, component_swap, insertion → check after-state props.
if (
change.changeType === 'prop_change' ||
change.changeType === 'component_swap' ||
change.changeType === 'insertion'
) {
const propViolations = checkComponentConstraints({
componentName: change.displayName,
props: change.after,
dlf,
});
for (const v of propViolations) {
violations.push({
componentId: change.componentId,
rule: `components.${change.displayName}.allowedProps.${v.prop}`,
severity: v.severity,
message: v.message,
});
}
// Additional forbidden-variant check for component_swap.
if (change.changeType === 'component_swap') {
const rule = dlf.components?.[change.displayName];
if (rule?.forbiddenVariants) {
const newVariant = change.after['variant'];
if (typeof newVariant === 'string' && rule.forbiddenVariants.includes(newVariant)) {
violations.push({
componentId: change.componentId,
rule: `components.${change.displayName}.forbiddenVariants`,
severity: 'error',
message: `Variant "${newVariant}" is forbidden for ${change.displayName} by design system rules`,
});
}
}
}
// prop_change: warn if a prop value is a hardcoded hex color that doesn't
// match any value in dlf.tokens.colors. Design systems expect color props to
// reference token names (e.g. "brand") not raw hex values ("#0F52BA").
// Only fires when dlf.tokens.colors is populated (opt-in per DLF).
if (change.changeType === 'prop_change' && dlf.tokens.colors) {
const tokenColorValues = new Set(
Object.values(dlf.tokens.colors).map(v => v.toLowerCase()),
);
const HEX_RE = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
for (const [propKey, propValue] of Object.entries(change.after)) {
if (typeof propValue === 'string' && HEX_RE.test(propValue)) {
if (!tokenColorValues.has(propValue.toLowerCase())) {
violations.push({
componentId: change.componentId,
rule: `tokens.colors`,
severity: 'warning',
message: `${change.displayName}.${propKey}="${propValue}" is a hardcoded color — use a DLF color token instead`,
});
}
}
}
}
}
// layout_change → check spacing values against the DLF spacing scale.
if (change.changeType === 'layout_change') {
const spacingScale = dlf.tokens?.spacing;
if (spacingScale) {
const allowed = new Set(Object.values(spacingScale));
const spacingProps = [
'padding', 'margin', 'gap',
'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft',
'marginTop', 'marginRight', 'marginBottom', 'marginLeft',
'top', 'right', 'bottom', 'left',
];
for (const key of spacingProps) {
const val = change.after[key];
if (val !== undefined && typeof val === 'string' && !allowed.has(val)) {
violations.push({
componentId: change.componentId,
rule: `tokens.spacing`,
severity: 'warning',
message: `Spacing "${key}=${val}" is not in the design system spacing scale`,
});
}
}
}
return { valid: violations.length === 0, violations };
}
// token_change → verify the new token name exists in dlf.tokens[category].
if (change.changeType === 'token_change') {
const newTokenName = change.after['tokenName'];
const tokenCategory = change.after['tokenCategory'] as string | undefined;
if (typeof newTokenName === 'string' && tokenCategory) {
const categoryMap: Record<string, Record<string, string> | undefined> = {
colors: dlf.tokens?.colors,
typography: dlf.tokens?.typography,
spacing: dlf.tokens?.spacing,
motion: dlf.tokens?.motion,
};
const bucket = categoryMap[tokenCategory];
if (bucket !== undefined && !Object.prototype.hasOwnProperty.call(bucket, newTokenName)) {
violations.push({
componentId: change.componentId,
rule: `tokens.${tokenCategory}.${newTokenName}`,
severity: 'warning',
message: `Token "${newTokenName}" is not defined in the design language file under "${tokenCategory}"`,
});
}
}
}
return { valid: violations.length === 0, violations };
}
+2 -2
View File
@@ -15,8 +15,8 @@
"zod": "^3.0.0" "zod": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^2.0.0", "@vitest/coverage-v8": "^2.1.9",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^2.0.0" "vitest": "^2.1.9"
} }
} }
@@ -160,16 +160,16 @@ describe('IntentDiffSchema', () => {
id: UUID, id: UUID,
artboard_id: UUID, artboard_id: UUID,
author_id: 'user_xyz', author_id: 'user_xyz',
changes_jsonb: { propChanges: [], styleChanges: [] }, changes: { propChanges: [], styleChanges: [] },
summary: 'Updated button variant', aggregate_summary: 'Updated button variant',
status: 'DRAFT', status: 'draft',
created_at: NOW, created_at: NOW,
updated_at: NOW, updated_at: NOW,
}; };
it('parses a valid diff', () => { it('parses a valid diff', () => {
const d = IntentDiffSchema.parse(valid); const d = IntentDiffSchema.parse(valid);
expect(d.status).toBe('DRAFT'); expect(d.status).toBe('draft');
expect(d.notes).toBeUndefined(); expect(d.notes).toBeUndefined();
}); });
+3 -2
View File
@@ -12,11 +12,12 @@
}, },
"dependencies": { "dependencies": {
"@originmain/diff-engine": "workspace:*", "@originmain/diff-engine": "workspace:*",
"@supabase/supabase-js": "^2.104.1",
"zod": "^3.0.0" "zod": "^3.0.0"
}, },
"devDependencies": { "devDependencies": {
"@vitest/coverage-v8": "^2.1.9",
"typescript": "^5.5.0", "typescript": "^5.5.0",
"vitest": "^2.0.0", "vitest": "^2.1.9"
"@vitest/coverage-v8": "^2.0.0"
} }
} }
+27
View File
@@ -0,0 +1,27 @@
// ── Origin Graph client (spec Layer 4.2) ─────────────────────────────────────
// Thin typed wrapper around @supabase/supabase-js for the Origin Graph schema.
// The spec requires this file as the primary entry point for Supabase access
// in the origin-graph package.
//
// Usage in the app layer:
// import { createOriginGraphClient } from '@originmain/origin-graph';
// const db = createOriginGraphClient(supabaseUrl, supabaseKey);
//
// The returned client satisfies the `DbClient` interface used by all
// query functions in queries.ts — no type casting needed.
import { createClient } from '@supabase/supabase-js';
import type { DbClient } from './queries.js';
/**
* Creates a typed Supabase client configured for the Origin Graph schema.
* Pass `supabaseUrl` + `anonKey` for client-side usage (RLS enforced),
* or `supabaseUrl` + `serviceRoleKey` for server-side usage (bypasses RLS).
*/
export function createOriginGraphClient(
supabaseUrl: string,
supabaseKey: string,
options?: { auth?: { autoRefreshToken?: boolean; persistSession?: boolean } },
): DbClient {
return createClient(supabaseUrl, supabaseKey, options) as unknown as DbClient;
}
+1
View File
@@ -1,2 +1,3 @@
export * from './types.js'; export * from './types.js';
export * from './queries.js'; export * from './queries.js';
export * from './client.js';
+85
View File
@@ -19,6 +19,7 @@ import type {
InsertAgentSession, InsertAgentSession,
InsertTeamMember, InsertTeamMember,
InsertProject, InsertProject,
InsertWorkspace,
DiffStatus, DiffStatus,
ArtboardAncestry, ArtboardAncestry,
} from './types.js'; } from './types.js';
@@ -73,6 +74,17 @@ export async function getArtboards(
return data; return data;
} }
/**
* Spec Layer 4.2 canonical function name — delegates to getArtboards.
* Callers that use the spec-mandated name get the same result.
*/
export async function getArtboardsByWorkspace(
db: DbClient,
workspaceId: string,
): Promise<Artboard[]> {
return getArtboards(db, workspaceId);
}
export async function getArtboard(db: DbClient, id: string): Promise<Artboard> { export async function getArtboard(db: DbClient, id: string): Promise<Artboard> {
const { data, error } = await (db const { data, error } = await (db
.from('artboards') .from('artboards')
@@ -126,6 +138,31 @@ export async function getArtboardAncestors(db: DbClient, artboardId: string): Pr
return data; return data;
} }
// ── Full-text artboard search (migration 006) ─────────────────────────────────
// Calls the search_artboards RPC which ranks artboards by relevance across name,
// metadata_jsonb, linked intent diff summaries, and origin source_ref.
// Returns up to `limit` results ordered by rank DESC.
export interface ArtboardSearchResult extends Artboard {
rank: number;
}
export async function searchArtboards(
db: DbClient,
workspaceId: string,
query: string,
limit = 20,
): Promise<ArtboardSearchResult[]> {
if (!query.trim()) return [];
const { data, error } = await (db.rpc('search_artboards', {
p_workspace_id: workspaceId,
p_query: query.trim(),
p_limit: limit,
}) as Promise<{ data: ArtboardSearchResult[]; error: DbError | null }>);
if (error) throw new Error(error.message);
return data ?? [];
}
// ── Intent diff queries ─────────────────────────────────────────────────────── // ── Intent diff queries ───────────────────────────────────────────────────────
export async function getDiffs(db: DbClient, artboardId: string): Promise<IntentDiff[]> { export async function getDiffs(db: DbClient, artboardId: string): Promise<IntentDiff[]> {
@@ -201,6 +238,16 @@ export async function getActiveDesignLanguageFile(
// ── Origin queries ──────────────────────────────────────────────────────────── // ── Origin queries ────────────────────────────────────────────────────────────
export async function getOrigin(db: DbClient, id: string): Promise<Origin> {
const { data, error } = await (db
.from('origins')
.select('*')
.eq('id', id)
.single() as Promise<{ data: Origin; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function createOrigin(db: DbClient, row: InsertOrigin): Promise<Origin> { export async function createOrigin(db: DbClient, row: InsertOrigin): Promise<Origin> {
const { data, error } = await (db const { data, error } = await (db
.from('origins') .from('origins')
@@ -211,6 +258,34 @@ export async function createOrigin(db: DbClient, row: InsertOrigin): Promise<Ori
return data; return data;
} }
// ── Origin Graph aggregate query ─────────────────────────────────────────────
// Returns the complete provenance picture for a single artboard: the artboard
// row, its linked origin (if any), and all intent diffs ever recorded against
// it. This is the primary read path described in Layer 4.2 of the spec.
export async function getOriginGraph(
db: DbClient,
artboardId: string,
): Promise<{ artboard: Artboard; origin: Origin | null; diffs: IntentDiff[] }> {
// Fetch artboard and diffs in parallel for minimum latency.
const [artboard, diffs] = await Promise.all([
getArtboard(db, artboardId),
getDiffs(db, artboardId),
]);
let origin: Origin | null = null;
if (artboard.origin_id) {
try {
origin = await getOrigin(db, artboard.origin_id);
} catch {
// Origin may have been deleted (ON DELETE SET NULL on the FK) — treat as
// missing rather than throwing, since the artboard itself is valid.
}
}
return { artboard, origin, diffs };
}
// ── Agent session queries ───────────────────────────────────────────────────── // ── Agent session queries ─────────────────────────────────────────────────────
export async function createAgentSession(db: DbClient, row: InsertAgentSession): Promise<AgentSession> { export async function createAgentSession(db: DbClient, row: InsertAgentSession): Promise<AgentSession> {
@@ -225,6 +300,16 @@ export async function createAgentSession(db: DbClient, row: InsertAgentSession):
// ── Workspace queries ───────────────────────────────────────────────────────── // ── Workspace queries ─────────────────────────────────────────────────────────
export async function createWorkspace(db: DbClient, row: InsertWorkspace): Promise<Workspace> {
const { data, error } = await (db
.from('workspaces')
.insert(row)
.select()
.single() as Promise<{ data: Workspace; error: DbError | null }>);
if (error) throw new Error(error.message);
return data;
}
export async function getWorkspace(db: DbClient, id: string): Promise<Workspace> { export async function getWorkspace(db: DbClient, id: string): Promise<Workspace> {
const { data, error } = await (db const { data, error } = await (db
.from('workspaces') .from('workspaces')
+102 -76
View File
@@ -3,122 +3,143 @@ import { z } from 'zod';
// ── Enums ───────────────────────────────────────────────────────────────────── // ── Enums ─────────────────────────────────────────────────────────────────────
export const OriginTypeSchema = z.enum([ export const OriginTypeSchema = z.enum([
'GIT_COMMIT', // Spec Layer 4 lowercase set (canonical)
'LINEAR_ISSUE', 'route', 'linear', 'git', 'slack', 'feedback', 'fork', 'manual',
'SLACK_MESSAGE', // Legacy uppercase set (migration 001) — kept for backward compat
'URL', 'GIT_COMMIT', 'LINEAR_ISSUE', 'SLACK_MESSAGE', 'URL', 'FORK',
'FORK', // Canonical uppercase aliases (migration 007)
'ROUTE', 'FEEDBACK', 'MANUAL',
]); ]);
export type OriginType = z.infer<typeof OriginTypeSchema>; export type OriginType = z.infer<typeof OriginTypeSchema>;
export const DiffStatusSchema = z.enum([ export const DiffStatusSchema = z.enum([
'DRAFT', // Spec Layer 4 lowercase set (canonical)
'EXPORTED', 'draft', 'exported', 'acknowledged', 'implemented', 'rejected',
'IMPLEMENTED', // Legacy uppercase set — kept for backward compat
'BLOCKED', 'DRAFT', 'EXPORTED', 'IMPLEMENTED', 'BLOCKED',
// UI-driven additions
'ACKNOWLEDGED', 'REJECTED', 'REVIEWED', 'APPLIED',
]); ]);
export type DiffStatus = z.infer<typeof DiffStatusSchema>; export type DiffStatus = z.infer<typeof DiffStatusSchema>;
export const TeamRoleSchema = z.enum([ export const TeamRoleSchema = z.enum(['OWNER', 'DESIGNER', 'ENGINEER', 'PM', 'VIEWER']);
'OWNER',
'DESIGNER',
'ENGINEER',
'PM',
'VIEWER',
]);
export type TeamRole = z.infer<typeof TeamRoleSchema>; export type TeamRole = z.infer<typeof TeamRoleSchema>;
export const AgentTypeSchema = z.enum(['CURSOR', 'CLAUDE_CODE', 'GENERIC']); export const AgentTypeSchema = z.enum([
// Spec lowercase (canonical)
'cursor', 'claude-code', 'generic',
// Legacy uppercase
'CURSOR', 'CLAUDE_CODE', 'GENERIC',
]);
export type AgentType = z.infer<typeof AgentTypeSchema>; export type AgentType = z.infer<typeof AgentTypeSchema>;
export const WorkspacePlanSchema = z.enum(['FREE', 'TEAM', 'ENTERPRISE']); export const WorkspacePlanSchema = z.enum(['FREE', 'TEAM', 'ENTERPRISE']);
export type WorkspacePlan = z.infer<typeof WorkspacePlanSchema>; export type WorkspacePlan = z.infer<typeof WorkspacePlanSchema>;
// ── Row types (match PostgreSQL columns 1:1) ───────────────────────────────── // ── Row types (match PostgreSQL columns 1:1) ─────────────────────────────────
export const WorkspaceSchema = z.object({ export const WorkspaceSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
name: z.string(), name: z.string(),
owner_id: z.string(), owner_id: z.string(),
plan: WorkspacePlanSchema, plan: WorkspacePlanSchema,
settings_jsonb: z.record(z.unknown()), settings_jsonb: z.record(z.unknown()),
created_at: z.string().datetime(), created_at: z.string().datetime(),
updated_at: z.string().datetime(), updated_at: z.string().datetime(),
}); });
export type Workspace = z.infer<typeof WorkspaceSchema>; export type Workspace = z.infer<typeof WorkspaceSchema>;
export const ArtboardSchema = z.object({ export const ArtboardSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
workspace_id: z.string().uuid(), workspace_id: z.string().uuid(),
project_id: z.string().uuid().nullable(), project_id: z.string().uuid().nullable(),
name: z.string(), name: z.string(),
origin_id: z.string().uuid().nullable(), origin_id: z.string().uuid().nullable(),
parent_artboard_id: z.string().uuid().nullable(), parent_artboard_id: z.string().uuid().nullable(),
metadata_jsonb: z.record(z.unknown()), /** Spec alias added migration 008 — mirrors parent_artboard_id */
created_at: z.string().datetime(), parent_id: z.string().uuid().nullable().optional(),
updated_at: z.string().datetime(), metadata_jsonb: z.record(z.unknown()),
route: z.string().nullable().optional(),
remote_url: z.string().nullable().optional(),
/** Spec: NOT NULL DEFAULT 1440 (migration 008) */
width: z.number().int().default(1440),
/** Spec: NOT NULL DEFAULT 900 (migration 008) */
height: z.number().int().default(900),
created_by: z.string().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
}); });
export type Artboard = z.infer<typeof ArtboardSchema>; export type Artboard = z.infer<typeof ArtboardSchema>;
export const OriginSchema = z.object({ export const OriginSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
type: OriginTypeSchema, type: OriginTypeSchema,
source_ref: z.string(), source_ref: z.string(),
source_metadata_jsonb: z.record(z.unknown()), source_metadata_jsonb: z.record(z.unknown()),
created_at: z.string().datetime(), /** Spec Layer 4 FK: origins.artboard_id (added migration 008) */
updated_at: z.string().datetime(), artboard_id: z.string().uuid().nullable().optional(),
source_id: z.string().nullable().optional(),
source_url: z.string().nullable().optional(),
screenshot_url: z.string().nullable().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
}); });
export type Origin = z.infer<typeof OriginSchema>; export type Origin = z.infer<typeof OriginSchema>;
export const IntentDiffSchema = z.object({ export const IntentDiffSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
artboard_id: z.string().uuid(), artboard_id: z.string().uuid(),
author_id: z.string(), author_id: z.string(),
changes_jsonb: z.record(z.unknown()), /** Spec column name: changes (renamed from changes_jsonb in migration 008) */
summary: z.string(), changes: z.record(z.unknown()),
status: DiffStatusSchema, /** Spec column name: aggregate_summary (renamed from summary in migration 008) */
/** Free-text notes from the coding agent (e.g. why it was blocked) */ aggregate_summary: z.string(),
notes: z.string().optional(), status: DiffStatusSchema,
created_at: z.string().datetime(), notes: z.string().optional(),
updated_at: z.string().datetime(), session_id: z.string().nullable().optional(),
before_screenshot: z.string().nullable().optional(),
after_screenshot: z.string().nullable().optional(),
exported_code: z.string().nullable().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
}); });
export type IntentDiff = z.infer<typeof IntentDiffSchema>; export type IntentDiff = z.infer<typeof IntentDiffSchema>;
export const AgentSessionSchema = z.object({ export const AgentSessionSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
artboard_id: z.string().uuid(), artboard_id: z.string().uuid(),
diff_id: z.string().uuid().nullable(), diff_id: z.string().uuid().nullable(),
agent_type: AgentTypeSchema, agent_type: AgentTypeSchema,
messages_jsonb: z.array(z.record(z.unknown())), messages_jsonb: z.array(z.record(z.unknown())),
status: z.enum(['ACTIVE', 'COMPLETED', 'FAILED']), status: z.enum(['ACTIVE', 'COMPLETED', 'FAILED']),
created_at: z.string().datetime(), created_at: z.string().datetime(),
updated_at: z.string().datetime(), updated_at: z.string().datetime(),
}); });
export type AgentSession = z.infer<typeof AgentSessionSchema>; export type AgentSession = z.infer<typeof AgentSessionSchema>;
export const DesignLanguageFileSchema = z.object({ export const DesignLanguageFileSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
workspace_id: z.string().uuid(), workspace_id: z.string().uuid(),
name: z.string(), name: z.string(),
schema_jsonb: z.record(z.unknown()), schema_jsonb: z.record(z.unknown()),
version: z.number().int(), version: z.number().int(),
created_at: z.string().datetime(), is_active: z.boolean().optional(),
updated_at: z.string().datetime(), created_by: z.string().nullable().optional(),
created_at: z.string().datetime(),
updated_at: z.string().datetime(),
}); });
export type DesignLanguageFile = z.infer<typeof DesignLanguageFileSchema>; export type DesignLanguageFile = z.infer<typeof DesignLanguageFileSchema>;
export const TeamMemberSchema = z.object({ export const TeamMemberSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
workspace_id: z.string().uuid(), workspace_id: z.string().uuid(),
user_id: z.string(), user_id: z.string(),
role: TeamRoleSchema, role: TeamRoleSchema,
created_at: z.string().datetime(), created_at: z.string().datetime(),
updated_at: z.string().datetime(), updated_at: z.string().datetime(),
}); });
export type TeamMember = z.infer<typeof TeamMemberSchema>; export type TeamMember = z.infer<typeof TeamMemberSchema>;
// ── Project ───────────────────────────────────────────────────────────────────
export const ProjectSchema = z.object({ export const ProjectSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
workspace_id: z.string().uuid(), workspace_id: z.string().uuid(),
@@ -131,7 +152,7 @@ export const ProjectSchema = z.object({
}); });
export type Project = z.infer<typeof ProjectSchema>; export type Project = z.infer<typeof ProjectSchema>;
// ── Ancestry (from materialized view) ──────────────────────────────────────── // ── Ancestry (from closure table) ────────────────────────────────────────────
export interface ArtboardAncestry { export interface ArtboardAncestry {
artboard_id: string; artboard_id: string;
@@ -139,13 +160,18 @@ export interface ArtboardAncestry {
depth: number; depth: number;
} }
// ── Insert types (omit server-set fields) ──────────────────────────────────── // ── Insert types (omit server-set fields) ────────────────────────────────────
export type InsertWorkspace = Omit<Workspace, 'id' | 'created_at' | 'updated_at'>; 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'>; * width/height are optional on insert because the DB column defaults are
export type InsertIntentDiff = Omit<IntentDiff, 'id' | 'created_at' | 'updated_at'>; * 1440 and 900 respectively (migration 008). Callers may omit them.
export type InsertAgentSession = Omit<AgentSession, 'id' | 'created_at' | 'updated_at'>; */
export type InsertDesignLanguageFile = Omit<DesignLanguageFile, 'id' | 'created_at' | 'updated_at'>; export type InsertArtboard = Omit<Artboard, 'id' | 'created_at' | 'updated_at' | 'width' | 'height'>
export type InsertTeamMember = Omit<TeamMember, 'id' | 'created_at' | 'updated_at'>; & { width?: number; height?: number };
export type InsertProject = Omit<Project, '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'>;
export type InsertProject = Omit<Project, 'id' | 'created_at' | 'updated_at'>;
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
coverage: { coverage: {
provider: 'v8', provider: 'v8',
include: ['src/**/*.ts'], include: ['src/**/*.ts'],
exclude: ['src/index.ts', 'src/queries.ts'], exclude: ['src/index.ts', 'src/queries.ts', 'src/client.ts'],
thresholds: { lines: 80, functions: 80, branches: 75 }, thresholds: { lines: 80, functions: 80, branches: 75 },
}, },
}, },
+62 -8
View File
@@ -15,7 +15,7 @@ importers:
specifier: ^8.0.0 specifier: ^8.0.0
version: 8.59.0(eslint@9.39.4)(typescript@5.9.3) version: 8.59.0(eslint@9.39.4)(typescript@5.9.3)
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17)) version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
eslint: eslint:
specifier: ^9.0.0 specifier: ^9.0.0
@@ -24,7 +24,7 @@ importers:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(@types/node@22.19.17) version: 2.1.9(@types/node@22.19.17)
packages/agent-bridge: packages/agent-bridge:
@@ -124,6 +124,15 @@ importers:
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.62.0 specifier: ^5.62.0
version: 5.99.2(react@19.2.5) version: 5.99.2(react@19.2.5)
'@trpc/client':
specifier: ^11.17.0
version: 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
'@trpc/react-query':
specifier: ^11.17.0
version: 11.17.0(@tanstack/react-query@5.99.2(react@19.2.5))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.5)(typescript@5.9.3)
'@trpc/server':
specifier: ^11.17.0
version: 11.17.0(typescript@5.9.3)
next: next:
specifier: ^15.1.0 specifier: ^15.1.0
version: 15.5.15(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) version: 15.5.15(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -133,6 +142,9 @@ importers:
react-dom: react-dom:
specifier: ^19.0.0 specifier: ^19.0.0
version: 19.2.5(react@19.2.5) version: 19.2.5(react@19.2.5)
zod:
specifier: ^3.25.76
version: 3.25.76
zustand: zustand:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5)) version: 5.0.12(@types/react@19.2.14)(react@19.2.5)(use-sync-external-store@1.6.0(react@19.2.5))
@@ -179,13 +191,13 @@ importers:
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17)) version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
typescript: typescript:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(@types/node@22.19.17) version: 2.1.9(@types/node@22.19.17)
packages/diff-engine: packages/diff-engine:
@@ -198,13 +210,13 @@ importers:
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17)) version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
typescript: typescript:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(@types/node@22.19.17) version: 2.1.9(@types/node@22.19.17)
packages/e2e: packages/e2e:
@@ -259,18 +271,21 @@ importers:
'@originmain/diff-engine': '@originmain/diff-engine':
specifier: workspace:* specifier: workspace:*
version: link:../diff-engine version: link:../diff-engine
'@supabase/supabase-js':
specifier: ^2.104.1
version: 2.104.1
zod: zod:
specifier: ^3.0.0 specifier: ^3.0.0
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17)) version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
typescript: typescript:
specifier: ^5.5.0 specifier: ^5.5.0
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^2.0.0 specifier: ^2.1.9
version: 2.1.9(@types/node@22.19.17) version: 2.1.9(@types/node@22.19.17)
packages/platform: packages/platform:
@@ -1771,6 +1786,28 @@ packages:
peerDependencies: peerDependencies:
react: ^18 || ^19 react: ^18 || ^19
'@trpc/client@11.17.0':
resolution: {integrity: sha512-KpJBFrbKTDeVCFv/3ckL1XBBH5Yssn8hethI/rUy7GIpTj+VzjtPjykDqJpzobuVOz+d26cXCSu1t4I6MYI5Zg==}
hasBin: true
peerDependencies:
'@trpc/server': 11.17.0
typescript: '>=5.7.2'
'@trpc/react-query@11.17.0':
resolution: {integrity: sha512-AGcl5YAF8NnhBmyJ6PqJqKb1M5VTGSoNRNqJ3orct4o4epdcg0GWhW+qT9q6gPzs/2ImIwYCdfFpgNGdZ9yLHA==}
peerDependencies:
'@tanstack/react-query': ^5.80.3
'@trpc/client': 11.17.0
'@trpc/server': 11.17.0
react: '>=18.2.0'
typescript: '>=5.7.2'
'@trpc/server@11.17.0':
resolution: {integrity: sha512-jbAOUe0PpUTCYqziyu+8vYXZdDXPudZgnEhWCQ2NjKnVEjfE93RqHTt1oycZJv/HNf51YlRXfEEwSIAbb161rw==}
hasBin: true
peerDependencies:
typescript: '>=5.7.2'
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -4651,6 +4688,23 @@ snapshots:
'@tanstack/query-core': 5.99.2 '@tanstack/query-core': 5.99.2
react: 19.2.5 react: 19.2.5
'@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@trpc/server': 11.17.0(typescript@5.9.3)
typescript: 5.9.3
'@trpc/react-query@11.17.0(@tanstack/react-query@5.99.2(react@19.2.5))(@trpc/client@11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.17.0(typescript@5.9.3))(react@19.2.5)(typescript@5.9.3)':
dependencies:
'@tanstack/react-query': 5.99.2(react@19.2.5)
'@trpc/client': 11.17.0(@trpc/server@11.17.0(typescript@5.9.3))(typescript@5.9.3)
'@trpc/server': 11.17.0(typescript@5.9.3)
react: 19.2.5
typescript: 5.9.3
'@trpc/server@11.17.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/hast@3.0.4': '@types/hast@3.0.4':
+52 -39
View File
@@ -1,46 +1,59 @@
-- Origin Graph — Artboard Ancestry Materialized View -- ── Migration 002: Artboard Ancestry Guard ───────────────────────────────────
-- Migration: 002 -- The artboard_ancestry closure table and its maintenance trigger are already
-- Pre-computes all ancestor/descendant relationships so the app never needs -- created in migration 001 (initial schema). This migration is a safe, idempotent
-- recursive CTEs at query time. Updated automatically on artboards INSERT. -- guard that ensures the trigger function and trigger exist as expected.
--
-- IMPORTANT: An earlier draft of this file attempted to create a MATERIALIZED
-- VIEW named artboard_ancestry, which would conflict with the table created in
-- 001. That approach has been superseded: the trigger-based closure table from
-- 001 is maintained incrementally (O(depth) per INSERT) which is far cheaper
-- than a full REFRESH MATERIALIZED VIEW CONCURRENTLY on every artboard insert.
-- Do NOT re-introduce the materialized view approach.
CREATE MATERIALIZED VIEW artboard_ancestry AS -- ── Ensure the ancestry maintenance trigger function exists ───────────────────
WITH RECURSIVE ancestry(artboard_id, ancestor_id, depth) AS ( -- Uses CREATE OR REPLACE so the migration is idempotent; safe to re-run.
-- 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 CREATE OR REPLACE FUNCTION maintain_artboard_ancestry()
-- 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 $$ RETURNS TRIGGER LANGUAGE plpgsql AS $$
BEGIN BEGIN
REFRESH MATERIALIZED VIEW CONCURRENTLY artboard_ancestry; -- Self-row: every artboard is its own ancestor at depth 0.
RETURN NULL; INSERT INTO artboard_ancestry (artboard_id, ancestor_id, depth)
VALUES (NEW.id, NEW.id, 0)
ON CONFLICT DO NOTHING;
-- Inherit all ancestors of the parent at depth + 1.
IF NEW.parent_artboard_id IS NOT NULL THEN
INSERT INTO artboard_ancestry (artboard_id, ancestor_id, depth)
SELECT NEW.id, ancestor_id, depth + 1
FROM artboard_ancestry
WHERE artboard_id = NEW.parent_artboard_id
ON CONFLICT DO NOTHING;
END IF;
RETURN NEW;
END; END;
$$; $$;
CREATE TRIGGER trg_artboard_ancestry_refresh -- ── Ensure the trigger is attached ───────────────────────────────────────────
AFTER INSERT OR UPDATE OF parent_artboard_id ON artboards -- DROP + recreate is the idempotent way to ensure the trigger is attached
FOR EACH STATEMENT -- exactly once; IF NOT EXISTS for triggers requires PG 17+ so use this pattern.
EXECUTE FUNCTION refresh_artboard_ancestry();
DROP TRIGGER IF EXISTS artboards_ancestry_insert ON artboards;
CREATE TRIGGER artboards_ancestry_insert
AFTER INSERT ON artboards
FOR EACH ROW EXECUTE FUNCTION maintain_artboard_ancestry();
-- ── RPC helper: get all descendants of an artboard ───────────────────────────
-- Complements getArtboardAncestors() in origin-graph/queries.ts.
-- Returns direct + transitive descendants, ordered nearest-first.
CREATE OR REPLACE FUNCTION get_artboard_descendants(p_artboard_id UUID)
RETURNS TABLE (artboard_id UUID, depth INTEGER)
LANGUAGE SQL STABLE AS $$
SELECT artboard_id, depth
FROM artboard_ancestry
WHERE ancestor_id = p_artboard_id
AND artboard_id <> p_artboard_id -- exclude self
ORDER BY depth, artboard_id;
$$;
+127
View File
@@ -0,0 +1,127 @@
-- ── Migration 006: Full-Text Search ──────────────────────────────────────────
-- Adds tsvector generated columns and GIN indexes to artboards and origins so
-- the natural-language cross-artboard query feature (Layer 10 / Phase 3) can do
-- weighted full-text search without re-scanning jsonb at query time.
--
-- search_artboards(query, workspace_id) is the primary search entry point.
-- It returns artboard rows ranked by text relevance using ts_rank_cd.
-- ── 1. Generated tsvector column on artboards ─────────────────────────────────
-- Covers: artboard name (weight A), route / renderUrl from metadata (weight B),
-- and the raw metadata_jsonb text (weight C).
ALTER TABLE artboards
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(metadata_jsonb::text, '')), 'C')
) STORED;
CREATE INDEX IF NOT EXISTS artboards_search_vector_idx
ON artboards USING GIN (search_vector);
-- ── 2. Generated tsvector column on origins ───────────────────────────────────
-- Covers: source_ref (weight A), source_metadata_jsonb text (weight B).
ALTER TABLE origins
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(source_ref, '')), 'A') ||
setweight(to_tsvector('english', coalesce(source_metadata_jsonb::text, '')), 'B')
) STORED;
CREATE INDEX IF NOT EXISTS origins_search_vector_idx
ON origins USING GIN (search_vector);
-- ── 3. Generated tsvector on intent_diffs ────────────────────────────────────
-- Covers: summary (weight A), changes_jsonb text (weight C).
-- Allows searching "show me every diff about the navigation bar" style queries.
ALTER TABLE intent_diffs
ADD COLUMN IF NOT EXISTS search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(summary, '')), 'A') ||
setweight(to_tsvector('english', coalesce(changes_jsonb::text, '')), 'C')
) STORED;
CREATE INDEX IF NOT EXISTS intent_diffs_search_vector_idx
ON intent_diffs USING GIN (search_vector);
-- ── 4. search_artboards RPC ───────────────────────────────────────────────────
-- Parameters:
-- p_workspace_id — scope results to a single workspace
-- p_query — plain-text search string (converted to tsquery internally)
-- p_limit — max rows to return (default 20)
--
-- Returns artboards ranked by text relevance. Joins to origins and intent_diffs
-- to boost matches found in provenance or diff history.
--
-- Plain-to-tsquery converts arbitrary user text into a safe tsquery, allowing
-- partial words and multi-word phrases without syntax errors.
CREATE OR REPLACE FUNCTION search_artboards(
p_workspace_id UUID,
p_query TEXT,
p_limit INTEGER DEFAULT 20
)
RETURNS TABLE (
id UUID,
workspace_id UUID,
project_id UUID,
name TEXT,
origin_id UUID,
parent_artboard_id UUID,
metadata_jsonb JSONB,
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
rank FLOAT4
)
LANGUAGE SQL STABLE AS $$
WITH q AS (
SELECT plainto_tsquery('english', p_query) AS tsq
)
SELECT
a.id,
a.workspace_id,
a.project_id,
a.name,
a.origin_id,
a.parent_artboard_id,
a.metadata_jsonb,
a.created_at,
a.updated_at,
-- Combine artboard rank with a bonus for matching origins or diffs
(
ts_rank_cd(a.search_vector, q.tsq, 32) * 1.5 +
coalesce((
SELECT max(ts_rank_cd(d.search_vector, q.tsq, 32))
FROM intent_diffs d
WHERE d.artboard_id = a.id
AND d.search_vector @@ q.tsq
), 0) +
coalesce((
SELECT ts_rank_cd(o.search_vector, q.tsq, 32)
FROM origins o
WHERE o.id = a.origin_id
AND o.search_vector @@ q.tsq
), 0)
) AS rank
FROM artboards a, q
WHERE
a.workspace_id = p_workspace_id
AND (
a.search_vector @@ q.tsq
OR EXISTS (
SELECT 1 FROM intent_diffs d
WHERE d.artboard_id = a.id
AND d.search_vector @@ q.tsq
)
OR EXISTS (
SELECT 1 FROM origins o
WHERE o.id = a.origin_id
AND o.search_vector @@ q.tsq
)
)
ORDER BY rank DESC, a.updated_at DESC
LIMIT p_limit;
$$;
+111
View File
@@ -0,0 +1,111 @@
-- ── Migration 007: Spec Compliance ───────────────────────────────────────────
-- Adds the columns and constraint values that were specified in the Layer 4-5
-- design but omitted from migration 001. All additions are additive (nullable
-- columns, expanded CHECK constraint value sets) so existing rows remain valid.
--
-- Tables touched:
-- origins — source_id, source_url, screenshot_url
-- intent_diffs — session_id, before_screenshot, after_screenshot,
-- exported_code + expanded status CHECK
-- design_language_files — is_active, created_by
-- artboards — route, remote_url, width, height, created_by
-- + expanded origin type CHECK via origins table
-- ── 1. origins — provenance fields ───────────────────────────────────────────
-- The spec required source_id (e.g. Linear issue ID, Git SHA, Slack message TS),
-- a canonical source_url, and a screenshot_url for the origin artifact.
-- All nullable — not every origin type has all three fields.
ALTER TABLE origins
ADD COLUMN IF NOT EXISTS source_id text,
ADD COLUMN IF NOT EXISTS source_url text,
ADD COLUMN IF NOT EXISTS screenshot_url text;
-- Expand the origin type CHECK to include the spec's lowercase values alongside
-- the existing uppercase ones. PostgreSQL CHECK is re-evaluated on INSERT/UPDATE
-- only, so existing rows are unaffected.
ALTER TABLE origins
DROP CONSTRAINT IF EXISTS origins_type_check;
ALTER TABLE origins
ADD CONSTRAINT origins_type_check
CHECK (type IN (
-- Original uppercase set
'GIT_COMMIT', 'LINEAR_ISSUE', 'SLACK_MESSAGE', 'URL', 'FORK',
-- Spec Layer 4 lowercase set
'route', 'linear', 'git', 'slack', 'feedback', 'fork', 'manual',
-- Canonical aliases
'ROUTE', 'FEEDBACK', 'MANUAL'
));
-- ── 2. intent_diffs — lifecycle fields ───────────────────────────────────────
-- session_id links a diff back to the agent session that produced it.
-- before/after screenshots enable the visual diff view in the export panel.
-- exported_code stores the generated TypeScript/JSX expressing the changes.
ALTER TABLE intent_diffs
ADD COLUMN IF NOT EXISTS session_id text,
ADD COLUMN IF NOT EXISTS before_screenshot text,
ADD COLUMN IF NOT EXISTS after_screenshot text,
ADD COLUMN IF NOT EXISTS exported_code text;
-- Expand the status CHECK to include all values used by the spec and the UI.
-- Original: DRAFT, EXPORTED, IMPLEMENTED, BLOCKED
-- Spec adds: acknowledged, rejected (lowercase in spec, uppercase in app)
-- App uses: REVIEWED, APPLIED (from Inspector STATUS_COLOR map)
ALTER TABLE intent_diffs
DROP CONSTRAINT IF EXISTS intent_diffs_status_check;
ALTER TABLE intent_diffs
ADD CONSTRAINT intent_diffs_status_check
CHECK (status IN (
'DRAFT', 'EXPORTED', 'IMPLEMENTED', 'BLOCKED',
'ACKNOWLEDGED', 'REJECTED',
'REVIEWED', 'APPLIED'
));
-- Update the get_diffs_by_status RPC to accept the full value set.
-- The function body is unchanged; only the type comment is refreshed.
CREATE OR REPLACE FUNCTION get_diffs_by_status(p_workspace_id uuid, p_status text)
RETURNS SETOF intent_diffs
LANGUAGE SQL STABLE AS $$
SELECT d.*
FROM intent_diffs d
JOIN artboards a ON a.id = d.artboard_id
WHERE a.workspace_id = p_workspace_id
AND d.status = p_status
ORDER BY d.created_at DESC;
$$;
-- ── 3. design_language_files — activation + ownership ────────────────────────
-- is_active provides the spec's boolean gate for "is this DLF in effect?"
-- The existing schema uses version + max(version) query for the active file;
-- is_active supplements that pattern and also enables instant deactivation
-- without incrementing the version.
-- created_by stores the Clerk user ID of the uploader (audit trail).
ALTER TABLE design_language_files
ADD COLUMN IF NOT EXISTS is_active boolean NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS created_by text;
-- Index for the "get active DLF for workspace" query pattern.
CREATE INDEX IF NOT EXISTS design_language_files_is_active_idx
ON design_language_files (workspace_id, is_active, version DESC);
-- ── 4. artboards — explicit dimensional + provenance columns ─────────────────
-- The spec modelled these as first-class columns. The existing schema stores
-- them in metadata_jsonb instead. Adding as nullable explicit columns lets both
-- patterns coexist: old rows keep metadata_jsonb, new rows can set either/both.
-- No NOT NULL constraints so zero migration cost for existing rows.
ALTER TABLE artboards
ADD COLUMN IF NOT EXISTS route text,
ADD COLUMN IF NOT EXISTS remote_url text,
ADD COLUMN IF NOT EXISTS width integer,
ADD COLUMN IF NOT EXISTS height integer,
ADD COLUMN IF NOT EXISTS created_by text;
-- Partial index: quickly find all artboards with an explicit route set.
CREATE INDEX IF NOT EXISTS artboards_route_idx
ON artboards (workspace_id, route)
WHERE route IS NOT NULL;
@@ -0,0 +1,197 @@
-- ── Migration 008: Spec schema alignment ──────────────────────────────────────
-- Aligns the database schema with the Layer 4 spec requirements.
-- All changes are additive or rename-only; no data is destroyed.
--
-- Changes:
-- 1. artboards.width / height — add NOT NULL DEFAULT per spec (1440 / 900)
-- 2. artboards.parent_id — spec names this column parent_id (not parent_artboard_id);
-- add parent_id as a generated column alias
-- 3. origins.artboard_id — spec requires the FK to live on origins (not artboards)
-- 4. intent_diffs: rename summary → aggregate_summary + rename changes_jsonb → changes
-- 5. intent_diffs.session_id — make NOT NULL per spec (was nullable in migration 007)
-- 6. agent_sessions.agent_type — add lowercase check values per spec
-- 7. RLS: add permissive workspace-membership allow policies
-- ── 1. artboards width/height defaults ───────────────────────────────────────
-- Backfill nulls introduced by migration 007, then add NOT NULL + DEFAULT.
update artboards set width = 1440 where width is null;
update artboards set height = 900 where height is null;
alter table artboards
alter column width set not null,
alter column width set default 1440,
alter column height set not null,
alter column height set default 900;
-- created_by: spec says NOT NULL; backfill with '' then constrain
update artboards set created_by = '' where created_by is null;
alter table artboards alter column created_by set not null;
alter table artboards alter column created_by set default '';
-- ── 2. artboards.parent_id alias ─────────────────────────────────────────────
-- Spec uses parent_id; existing code uses parent_artboard_id. Add parent_id as
-- a nullable FK that mirrors parent_artboard_id for spec-compliant consumers.
-- Both columns will co-exist during the transition.
do $$ begin
if not exists (
select 1 from information_schema.columns
where table_name = 'artboards' and column_name = 'parent_id'
) then
alter table artboards add column parent_id uuid references artboards(id);
-- Backfill from the existing column
update artboards set parent_id = parent_artboard_id where parent_artboard_id is not null;
end if;
end $$;
-- ── 3. origins.artboard_id (spec FK direction) ───────────────────────────────
-- Spec: origins.artboard_id uuid not null references artboards(id) on delete cascade
-- Existing: artboards.origin_id (reverse FK).
-- We add the spec-required column as nullable (existing origin rows have no artboard_id
-- until they are re-linked). New origins created via spec-compliant code must set it.
do $$ begin
if not exists (
select 1 from information_schema.columns
where table_name = 'origins' and column_name = 'artboard_id'
) then
alter table origins add column artboard_id uuid references artboards(id) on delete cascade;
-- Best-effort backfill: find artboards pointing to each origin
update origins o
set artboard_id = a.id
from artboards a
where a.origin_id = o.id;
end if;
end $$;
-- ── 4. intent_diffs: rename columns to spec names ────────────────────────────
-- Rename changes_jsonb → changes (spec column name)
do $$ begin
if exists (
select 1 from information_schema.columns
where table_name = 'intent_diffs' and column_name = 'changes_jsonb'
) and not exists (
select 1 from information_schema.columns
where table_name = 'intent_diffs' and column_name = 'changes'
) then
alter table intent_diffs rename column changes_jsonb to changes;
end if;
end $$;
-- Rename summary → aggregate_summary (spec column name)
do $$ begin
if exists (
select 1 from information_schema.columns
where table_name = 'intent_diffs' and column_name = 'summary'
) and not exists (
select 1 from information_schema.columns
where table_name = 'intent_diffs' and column_name = 'aggregate_summary'
) then
alter table intent_diffs rename column summary to aggregate_summary;
end if;
end $$;
-- ── 5. intent_diffs.session_id NOT NULL ──────────────────────────────────────
-- Spec: session_id text not null. Backfill existing nulls, then constrain.
update intent_diffs set session_id = '' where session_id is null;
alter table intent_diffs alter column session_id set not null;
alter table intent_diffs alter column session_id set default '';
-- Also: author_id was NOT NULL in the spec from migration 001 — ensure created_by
-- exists on design_language_files as NOT NULL (backfill then constrain)
update design_language_files set created_by = '' where created_by is null;
alter table design_language_files alter column created_by set not null;
alter table design_language_files alter column created_by set default '';
-- ── 6. agent_sessions: expand agent_type to include spec lowercase values ─────
-- Spec: 'cursor', 'claude-code', 'generic' (lowercase, hyphenated)
-- Existing: uppercase 'CURSOR', 'CLAUDE_CODE', 'GENERIC' + original set
-- Drop and re-create the check constraint to include both sets.
alter table agent_sessions drop constraint if exists agent_sessions_agent_type_check;
alter table agent_sessions add constraint agent_sessions_agent_type_check
check (agent_type in (
'cursor', 'claude-code', 'generic', -- spec lowercase
'CURSOR', 'CLAUDE_CODE', 'GENERIC' -- existing uppercase
));
-- ── 7. RLS workspace-membership allow policies ────────────────────────────────
-- Spec: "workspace members can access" via auth.uid()::text = owner_id
-- These are the permissive policies required by Layer 4. The deny-all-anon
-- policies from migration 003 remain and complement these.
-- workspaces: owner can access their own workspace
drop policy if exists "workspace owner access" on workspaces;
create policy "workspace owner access" on workspaces
for all
using (owner_id = auth.uid()::text);
-- artboards: members of the workspace can access
drop policy if exists "workspace member artboard access" on artboards;
create policy "workspace member artboard access" on artboards
for all
using (
workspace_id in (
select id from workspaces where owner_id = auth.uid()::text
)
or workspace_id in (
select workspace_id from team_members where user_id = auth.uid()::text
)
);
-- origins: accessible when the linked artboard is accessible
drop policy if exists "workspace member origin access" on origins;
create policy "workspace member origin access" on origins
for all
using (
id in (
select origin_id from artboards
where workspace_id in (
select id from workspaces where owner_id = auth.uid()::text
union
select workspace_id from team_members where user_id = auth.uid()::text
)
and origin_id is not null
)
);
-- intent_diffs: accessible when the linked artboard is accessible
drop policy if exists "workspace member diff access" on intent_diffs;
create policy "workspace member diff access" on intent_diffs
for all
using (
artboard_id in (
select id from artboards
where workspace_id in (
select id from workspaces where owner_id = auth.uid()::text
union
select workspace_id from team_members where user_id = auth.uid()::text
)
)
);
-- design_language_files: accessible within the workspace
drop policy if exists "workspace member dlf access" on design_language_files;
create policy "workspace member dlf access" on design_language_files
for all
using (
workspace_id in (
select id from workspaces where owner_id = auth.uid()::text
union
select workspace_id from team_members where user_id = auth.uid()::text
)
);
-- agent_sessions: accessible when the linked artboard is accessible
drop policy if exists "workspace member session access" on agent_sessions;
create policy "workspace member session access" on agent_sessions
for all
using (
artboard_id in (
select id from artboards
where workspace_id in (
select id from workspaces where owner_id = auth.uid()::text
union
select workspace_id from team_members where user_id = auth.uid()::text
)
)
);
@@ -0,0 +1,22 @@
-- migration 009 — Add spec-canonical lowercase status values to intent_diffs CHECK
-- Spec Layer 4: canonical status values are lowercase:
-- 'draft', 'exported', 'acknowledged', 'implemented', 'rejected'
-- The current CHECK (from migration 007) only allows uppercase values.
-- New code (Inspector.tsx, CompletionZone.tsx) inserts lowercase 'draft'.
-- This migration expands the CHECK to accept both cases so both old and new
-- code work without a breaking schema change.
ALTER TABLE intent_diffs
DROP CONSTRAINT IF EXISTS intent_diffs_status_check;
ALTER TABLE intent_diffs
ADD CONSTRAINT intent_diffs_status_check
CHECK (status IN (
-- Spec-canonical lowercase (Layer 4)
'draft', 'exported', 'acknowledged', 'implemented', 'rejected',
-- Legacy uppercase (migration 001, migration 007) — kept for compat
'DRAFT', 'EXPORTED', 'IMPLEMENTED', 'BLOCKED',
'ACKNOWLEDGED', 'REJECTED',
-- UI-driven additions (Inspector STATUS_COLOR map)
'REVIEWED', 'APPLIED'
));
@@ -0,0 +1,53 @@
-- ── Migration 010: Fix origins RLS policy ────────────────────────────────────
--
-- Problem (introduced in migration 008):
-- The "workspace member origin access" policy only checks the OLD FK direction
-- (artboards.origin_id → origins.id). Migration 008 added the SPEC-required FK
-- column origins.artboard_id, but the policy was not updated. Any origin created
-- via spec-compliant code (setting origins.artboard_id) is therefore invisible
-- to authenticated users — the SELECT returns zero rows even though the insert
-- succeeds.
--
-- Fix:
-- Rewrite the policy to accept either FK direction:
-- 1. NEW (spec): origins.artboard_id is in the user's accessible artboard set.
-- 2. OLD (legacy): origins.id appears in artboards.origin_id for accessible boards.
-- The OR allows both legacy data and spec-compliant data to be readable without
-- requiring a destructive data migration.
-- ─────────────────────────────────────────────────────────────────────────────
-- Helper CTE: IDs of all artboards accessible to the calling user.
-- Referenced by both branches of the OR below.
drop policy if exists "workspace member origin access" on origins;
create policy "workspace member origin access" on origins
for all
using (
-- ── Branch 1: spec-compliant origins (origins.artboard_id FK) ───────────
-- New origins set origins.artboard_id = <artboard uuid>; the origin is
-- accessible iff that artboard is in the user's workspace.
artboard_id in (
select id from artboards
where workspace_id in (
select id from workspaces where owner_id = auth.uid()::text
union
select workspace_id from team_members where user_id = auth.uid()::text
)
)
or
-- ── Branch 2: legacy origins (artboards.origin_id FK) ───────────────────
-- Old origins are referenced from artboards via artboards.origin_id.
-- Still need to work so existing data stays accessible while backfill
-- populates origins.artboard_id (migration 008 best-effort backfill).
id in (
select origin_id from artboards
where workspace_id in (
select id from workspaces where owner_id = auth.uid()::text
union
select workspace_id from team_members where user_id = auth.uid()::text
)
and origin_id is not null
)
);
@@ -0,0 +1,37 @@
-- ── Migration 011: Add route column to artboards FTS tsvector ────────────────
--
-- Problem:
-- Migration 006 created artboards.search_vector with only:
-- name (weight A) || metadata_jsonb::text (weight C)
-- The comment in 006 says route should be at weight B, but route didn't exist
-- until migration 007. Generated column expressions cannot be altered in-place
-- (pre-PG17) — the column must be dropped and re-added.
--
-- Fix:
-- Drop and recreate artboards.search_vector to include:
-- name (weight A) — exact component/screen name matches rank highest
-- route (weight B) — URL route matches rank highly (added by migration 007)
-- metadata_jsonb (weight C) — catch-all metadata text
--
-- Drop/re-add regenerates values for all existing rows automatically since
-- the column is GENERATED ALWAYS AS (stored computed column).
-- ─────────────────────────────────────────────────────────────────────────────
-- Drop the existing generated column (and its GIN index, which depends on it)
DROP INDEX IF EXISTS artboards_search_vector_idx;
ALTER TABLE artboards
DROP COLUMN IF EXISTS search_vector;
-- Re-add with route at weight B
ALTER TABLE artboards
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
setweight(to_tsvector('english', coalesce(route, '')), 'B') ||
setweight(to_tsvector('english', coalesce(metadata_jsonb::text, '')), 'C')
) STORED;
-- Rebuild the GIN index on the updated column
CREATE INDEX artboards_search_vector_idx
ON artboards USING GIN (search_vector);