made tiny updates
This commit is contained in:
@@ -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\\('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(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
@@ -16,10 +16,10 @@
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^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",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
"vitest": "^2.1.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22",
|
||||
|
||||
@@ -2,9 +2,10 @@ import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
// ── Model constants ───────────────────────────────────────────────────────────
|
||||
|
||||
// claude-opus-4-7 uses adaptive thinking (thinking.type = 'adaptive').
|
||||
// It does NOT accept temperature, top_p, or top_k — those are omitted in gateway.ts.
|
||||
export const MODEL = 'claude-opus-4-7' as const;
|
||||
// Spec Layer 6: "Claude Sonnet 4 API is the only AI provider."
|
||||
// claude-sonnet-4-6 supports per-call temperature (0.1 / 0.2 / 0.3 per prompt)
|
||||
// which the spec requires for diff-summary, completion-zone, and agent-query.
|
||||
export const MODEL = 'claude-sonnet-4-6' as const;
|
||||
|
||||
// ── Singleton client ──────────────────────────────────────────────────────────
|
||||
// The client is created once and shared. API key is injected from the server
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
import { buildAgentQueryMessages } from '../prompts/agent-query.prompt.js';
|
||||
|
||||
export interface AgentQAInput {
|
||||
/** Question from the coding agent (e.g. Cursor or Claude Code) */
|
||||
@@ -10,6 +10,10 @@ export interface AgentQAInput {
|
||||
artboardContextJson: string;
|
||||
/** Active DLF */
|
||||
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 {
|
||||
@@ -22,20 +26,14 @@ export async function answerAgentQuestion(
|
||||
gateway: AIGateway,
|
||||
input: AgentQAInput
|
||||
): Promise<AgentQAOutput> {
|
||||
const system = buildSystemPrompt({
|
||||
role: 'a design agent answering questions from a coding agent implementing a design diff',
|
||||
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
|
||||
});
|
||||
const { system, userContent, maxTokens } = buildAgentQueryMessages(input);
|
||||
|
||||
const response = await gateway.complete({
|
||||
system,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<artboard_context>\n${input.artboardContextJson}\n</artboard_context>\n\n<diff_id>${input.diffId}</diff_id>\n\n<question>\n${input.question}\n</question>\n\nAnswer the coding agent's question concisely and precisely. If the answer references a specific design token, component, or visual region, include a "visualReference" field. Return JSON:\n{\n "answer": "...",\n "visualReference": "..." (optional)\n}\n\nRespond ONLY with valid JSON.`,
|
||||
},
|
||||
],
|
||||
maxTokens: 1024,
|
||||
// userContent is ContentBlockParam[] — may include image blocks for screenshots
|
||||
messages: [{ role: 'user', content: userContent }],
|
||||
maxTokens,
|
||||
temperature: 0.1, // spec Layer 6.3: 0.1 for agent-query (factual, authoritative)
|
||||
});
|
||||
|
||||
let parsed: unknown;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
import { buildArtboardQueryMessages } from '../prompts/artboard-query.prompt.js';
|
||||
|
||||
export interface ArtboardQueryInput {
|
||||
/** Natural language query from the user */
|
||||
@@ -23,17 +23,13 @@ export async function queryCrossArtboard(
|
||||
gateway: AIGateway,
|
||||
input: ArtboardQueryInput
|
||||
): 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({
|
||||
system,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `<artboards>\n${input.artboardsJson}\n</artboards>\n\n<query>\n${input.query}\n</query>\n\nReturn a JSON object:\n{\n "results": [{"artboardId": "...", "relevanceScore": 0-1, "reason": "..."}],\n "reasoning": "brief explanation"\n}\n\nOnly include artboards with relevanceScore > 0.3. Respond ONLY with valid JSON.`,
|
||||
},
|
||||
],
|
||||
maxTokens: 1024,
|
||||
messages: [{ role: 'user', content: userContent }],
|
||||
maxTokens,
|
||||
temperature: 0.1, // spec Layer 10.2: 0.1 for workspace queries (factual)
|
||||
});
|
||||
|
||||
// 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 { buildSystemPrompt } from '../prompts/system.js';
|
||||
import { buildCompletionZoneMessages } from '../prompts/completion-zone.prompt.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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface CompletionZoneInput {
|
||||
@@ -14,10 +23,12 @@ export interface CompletionZoneInput {
|
||||
dlfJson?: string;
|
||||
/** Before screenshot as base64 data URL (optional) */
|
||||
screenshotBase64?: string;
|
||||
/** Workspace ID for per-workspace cost attribution (spec Layer 6) */
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
export interface CompletionZoneOutput {
|
||||
/** Proposed component tree changes as structured JSON */
|
||||
/** Proposed component tree changes as structured JSON — Zod-validated shape */
|
||||
proposedTree: unknown;
|
||||
/** Natural language description of the proposed change */
|
||||
description: string;
|
||||
@@ -31,45 +42,50 @@ export async function fillCompletionZone(
|
||||
input: CompletionZoneInput,
|
||||
maxRetries = 3
|
||||
): Promise<CompletionZoneOutput> {
|
||||
const system = buildSystemPrompt({
|
||||
role: 'a Completion Zone design agent',
|
||||
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
|
||||
});
|
||||
const { system, userContent, maxTokens } = buildCompletionZoneMessages(input);
|
||||
|
||||
const userContent: Anthropic.Messages.ContentBlockParam[] = [
|
||||
{
|
||||
type: 'text',
|
||||
text: `<component_tree>\n${input.componentTreeJson}\n</component_tree>\n\n<intent>\n${input.intent}\n</intent>\n\nReturn a JSON object with two fields:\n- "proposedTree": the updated component tree matching the intent\n- "description": a one-sentence description of the change\n\nRespond ONLY with valid JSON.`,
|
||||
},
|
||||
];
|
||||
|
||||
if (input.screenshotBase64) {
|
||||
userContent.unshift({
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: input.screenshotBase64.replace(/^data:image\/\w+;base64,/, '') },
|
||||
} as Anthropic.Messages.ImageBlockParam);
|
||||
}
|
||||
|
||||
// Retry up to maxRetries times on invalid JSON output
|
||||
// Retry up to maxRetries times on invalid JSON or Zod-invalid output
|
||||
let lastError: Error | null = null;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
const response = await gateway.complete({
|
||||
system,
|
||||
messages: [{ role: 'user', content: userContent }],
|
||||
maxTokens: 4096,
|
||||
messages: [{ role: 'user', content: userContent }],
|
||||
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 {
|
||||
const parsed = JSON.parse(response.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim());
|
||||
return { proposedTree: parsed.proposedTree as unknown, description: String(parsed.description ?? ''), raw: response };
|
||||
parsed = JSON.parse(raw);
|
||||
} catch (parseErr) {
|
||||
lastError = new Error(
|
||||
`AI returned invalid JSON on attempt ${attempt + 1}: ${String(parseErr)}. ` +
|
||||
`Raw response (first 200 chars): ${response.text.slice(0, 200)}`
|
||||
);
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { AIGateway } from '../gateway.js';
|
||||
import { buildSystemPrompt } from '../prompts/system.js';
|
||||
import { buildDiffSummaryMessages, buildAggregateSummaryMessages } from '../prompts/diff-summary.prompt.js';
|
||||
|
||||
export interface DiffSummaryInput {
|
||||
/** Serialized component-level changes (JSON) */
|
||||
changesJson: string;
|
||||
/** Component name */
|
||||
componentName: string;
|
||||
/** Active DLF as JSON string — passed through for rule-violation annotation */
|
||||
dlfJson?: string;
|
||||
}
|
||||
|
||||
export interface DiffSummaryOutput {
|
||||
@@ -16,17 +18,54 @@ export async function generateDiffSummary(
|
||||
gateway: AIGateway,
|
||||
input: DiffSummaryInput
|
||||
): Promise<DiffSummaryOutput> {
|
||||
const system = buildSystemPrompt({ role: 'a technical writer summarizing UI component changes' });
|
||||
const { system, userContent, maxTokens } = buildDiffSummaryMessages(input);
|
||||
|
||||
const response = await gateway.complete({
|
||||
system,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: `Summarize the following component changes for "${input.componentName}" in one concise sentence (max 20 words) suitable for a developer reviewing a pull request. Focus on what changed and its purpose.\n\n<changes>\n${input.changesJson}\n</changes>\n\nRespond with only the summary sentence.`,
|
||||
},
|
||||
],
|
||||
maxTokens: 128,
|
||||
messages: [{ role: 'user', content: userContent }],
|
||||
maxTokens,
|
||||
temperature: 0.2, // spec Layer 6.3: 0.2 for diff-summary (consistent output)
|
||||
});
|
||||
|
||||
return { summary: response.text.trim() };
|
||||
}
|
||||
|
||||
// ── 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() };
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -21,21 +29,22 @@ export interface RequestCost {
|
||||
estimatedCentsCost: number;
|
||||
}
|
||||
|
||||
const OPUS_4_INPUT_COST_PER_M = 500; // $5.00 / 1M
|
||||
const OPUS_4_OUTPUT_COST_PER_M = 2500; // $25.00 / 1M
|
||||
const OPUS_4_CACHE_READ_PER_M = 50; // $0.50 / 1M (estimated)
|
||||
// Spec Layer 6: claude-sonnet-4-6 pricing (used for cost attribution logging)
|
||||
const SONNET_4_INPUT_COST_PER_M = 300; // $3.00 / 1M input tokens
|
||||
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 {
|
||||
const inputTokens = usage.input_tokens;
|
||||
const outputTokens = usage.output_tokens;
|
||||
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
||||
const inputTokens = usage.input_tokens;
|
||||
const outputTokens = usage.output_tokens;
|
||||
const cacheReadTokens = usage.cache_read_input_tokens ?? 0;
|
||||
const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0;
|
||||
|
||||
const billableInput = inputTokens - cacheReadTokens;
|
||||
const estimatedCentsCost = Math.round(
|
||||
(billableInput / 1_000_000) * OPUS_4_INPUT_COST_PER_M +
|
||||
(outputTokens / 1_000_000) * OPUS_4_OUTPUT_COST_PER_M +
|
||||
(cacheReadTokens / 1_000_000) * OPUS_4_CACHE_READ_PER_M
|
||||
(billableInput / 1_000_000) * SONNET_4_INPUT_COST_PER_M +
|
||||
(outputTokens / 1_000_000) * SONNET_4_OUTPUT_COST_PER_M +
|
||||
(cacheReadTokens / 1_000_000) * SONNET_4_CACHE_READ_PER_M
|
||||
);
|
||||
|
||||
return { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, estimatedCentsCost };
|
||||
@@ -70,11 +79,22 @@ function sleep(ms: number): Promise<void> {
|
||||
// ── Gateway ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GatewayRequest {
|
||||
messages: Anthropic.Messages.MessageParam[];
|
||||
system?: Anthropic.Messages.TextBlockParam[];
|
||||
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.
|
||||
messages: Anthropic.Messages.MessageParam[];
|
||||
system?: Anthropic.Messages.TextBlockParam[];
|
||||
maxTokens?: number;
|
||||
/**
|
||||
* 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 {
|
||||
@@ -98,15 +118,18 @@ export class AIGateway {
|
||||
async complete(req: GatewayRequest): Promise<GatewayResponse> {
|
||||
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;
|
||||
for (let attempt = 0; attempt < this.maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await this.client.messages.create({
|
||||
model: MODEL,
|
||||
max_tokens: req.maxTokens ?? 4096,
|
||||
thinking: { type: 'adaptive' },
|
||||
model: MODEL,
|
||||
max_tokens: req.maxTokens ?? 4096,
|
||||
temperature: req.temperature ?? 0.2,
|
||||
...(req.system !== undefined ? { system: req.system } : {}),
|
||||
messages: req.messages,
|
||||
messages: req.messages,
|
||||
});
|
||||
|
||||
const text = response.content
|
||||
@@ -114,20 +137,55 @@ export class AIGateway {
|
||||
.map(b => b.text)
|
||||
.join('');
|
||||
|
||||
const cost = computeCost(response.usage);
|
||||
const cost = computeCost(response.usage);
|
||||
const latencyMs = Date.now() - startMs;
|
||||
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 };
|
||||
} catch (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).
|
||||
// Break immediately on client errors (4xx that aren't rate-limits).
|
||||
if (err instanceof Anthropic.APIError) {
|
||||
const { status } = err;
|
||||
if (status !== 429 && status !== 529 && status < 500) break;
|
||||
}
|
||||
// Non-APIError (network timeout, DNS failure, etc.) → always retry
|
||||
await sleep(2 ** attempt * 1000);
|
||||
// Non-APIError (network timeout, DNS failure, etc.) → always retry.
|
||||
// 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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,24 @@ export { getClient, MODEL } from './client.js';
|
||||
export { AIGateway } 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 type { CompletionZoneInput, CompletionZoneOutput } from './features/completion-zone.js';
|
||||
|
||||
export { generateDiffSummary } from './features/diff-summary.js';
|
||||
export type { DiffSummaryInput, DiffSummaryOutput } from './features/diff-summary.js';
|
||||
export { generateDiffSummary, generateAggregateSummary } from './features/diff-summary.js';
|
||||
export type { DiffSummaryInput, DiffSummaryOutput, AggregateSummaryInput, AggregateSummaryOutput } from './features/diff-summary.js';
|
||||
|
||||
export { queryCrossArtboard } 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 };
|
||||
}
|
||||
@@ -27,9 +27,13 @@
|
||||
"@pierre/trees": "1.0.0-beta.3",
|
||||
"@supabase/supabase-js": "^2.0.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",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^3.25.76",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"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 };
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useState, useEffect, type ReactNode } from 'react';
|
||||
import { FluentProvider } from '@fluentui/react-components';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { httpBatchLink } from '@trpc/client';
|
||||
import { originmainLightTheme, originmainDarkTheme } from '@originmain/ui';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useTheme } from '@/store/theme';
|
||||
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 theme = mode === 'dark' ? originmainDarkTheme : originmainLightTheme;
|
||||
|
||||
@@ -33,11 +45,13 @@ export function Providers({ children }: { children: ReactNode }) {
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||
{children}
|
||||
<TourOverlay />
|
||||
</FluentProvider>
|
||||
</QueryClientProvider>
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<FluentProvider theme={theme} style={{ height: '100%' }}>
|
||||
{children}
|
||||
<TourOverlay />
|
||||
</FluentProvider>
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCanvas } from '@/store/canvas';
|
||||
import { useArtboards, createArtboardMutation } from '@/hooks/useArtboards';
|
||||
import { useCanvasTheme } from '@/store/canvasTheme';
|
||||
import { Artboard } from './Artboard';
|
||||
import { CompletionZone } from './CompletionZone';
|
||||
|
||||
export function Canvas() {
|
||||
const T = useCanvasTheme();
|
||||
@@ -77,8 +78,13 @@ export function Canvas() {
|
||||
|
||||
// Zone tool: drag to draw a completion zone
|
||||
const zoneStart = useRef<{ x: number; y: 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 [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);
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@@ -274,15 +280,93 @@ export function Canvas() {
|
||||
</span>
|
||||
</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>
|
||||
|
||||
{/* 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 && (
|
||||
<ZonePromptOverlay
|
||||
<CompletionZone
|
||||
bounds={zoneDone}
|
||||
artboardId={selectedArtboardId}
|
||||
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 ──────────────────────────────────── */
|
||||
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>
|
||||
);
|
||||
}
|
||||
// ZonePromptOverlay extracted to ./CompletionZone.tsx (spec Layer 6.4)
|
||||
|
||||
@@ -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';
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Badge } from '@fluentui/react-components';
|
||||
import { useHistory } from '@/store/history';
|
||||
import { useCanvas } from '@/store/canvas';
|
||||
import { useViewport } from '@/store/viewport';
|
||||
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
|
||||
import type { PropChange } from '@originmain/diff-engine';
|
||||
import type { Violation } from '@originmain/design-language';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -179,7 +181,7 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
|
||||
const onResizeCommitRef = useRef(onResizeCommit);
|
||||
useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
|
||||
|
||||
const { patchStyleEdit, dispatchRemoveElement } = useCanvas();
|
||||
const { patchStyleEdit, dispatchRemoveElement, activeViolations } = useCanvas();
|
||||
|
||||
// Sync rect when selection changes to a different element
|
||||
useEffect(() => {
|
||||
@@ -309,6 +311,11 @@ function SelectionHandles({ artboardId, selection, onSelectionChange, onResizeCo
|
||||
{Math.round(width)} × {Math.round(height)}
|
||||
</span>
|
||||
|
||||
{/* DLF violation badge — shown when the Inspector has detected violations */}
|
||||
{activeViolations.length > 0 && (
|
||||
<ViolationBadge violations={activeViolations} />
|
||||
)}
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function hitTestFiber(node: FiberNode, x: number, y: number): FiberNode | null {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
'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 { useHistory } from '@/store/history';
|
||||
import { useArtboards, patchArtboard } from '@/hooks/useArtboards';
|
||||
import { useDiffs } from '@/hooks/useDiffs';
|
||||
import { useDlf } from '@/hooks/useDlf';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
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 { FiberNode } from '@originmain/renderer';
|
||||
import type { Artboard, IntentDiff } from '@originmain/origin-graph';
|
||||
@@ -109,6 +114,7 @@ export function Inspector() {
|
||||
componentId={selectedComponentId}
|
||||
componentData={selectedComponentData}
|
||||
styles={selectedComponentStyles}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
) : tab === 'props' ? (
|
||||
<PropsTab
|
||||
@@ -534,14 +540,36 @@ function DesignTab({
|
||||
componentId,
|
||||
componentData,
|
||||
styles,
|
||||
workspaceId,
|
||||
}: {
|
||||
artboardId: string | null;
|
||||
componentId: string | null;
|
||||
componentData: FiberNode | null;
|
||||
styles: Record<string, string> | null;
|
||||
workspaceId: string | null | undefined;
|
||||
}) {
|
||||
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) {
|
||||
return (
|
||||
@@ -659,6 +687,11 @@ function DesignTab({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── DLF violation banner ─────────────────────────────────── */}
|
||||
{dlfViolations.length > 0 && (
|
||||
<DlfViolationBanner violations={dlfViolations} />
|
||||
)}
|
||||
|
||||
{/* ── Section components ───────────────────────────────────── */}
|
||||
<FrameSection 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' }} />;
|
||||
}
|
||||
|
||||
/* ── 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 ─────────────────────────────────────────────── */
|
||||
function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
const T = useCanvasTheme();
|
||||
const { stacks } = useHistory();
|
||||
const T = useCanvasTheme();
|
||||
const { stacks } = useHistory();
|
||||
const { diffs, createDiff, isLoading } = useDiffs(artboardId);
|
||||
const { workspaceId, activeAgentSessionId } = useCanvas();
|
||||
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 pendingChanges: PropChange[] = artboardHistory.past.flatMap(e => e.changes);
|
||||
const hasChanges = pendingChanges.length > 0;
|
||||
@@ -1162,39 +1256,37 @@ function DiffTab({ artboardId }: { artboardId: string | null }) {
|
||||
const exportDiff = useCallback(async () => {
|
||||
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 = '';
|
||||
const meaningfulChanges = pendingChanges.filter(c => c.changeType !== 'unchanged');
|
||||
if (meaningfulChanges.length > 0) {
|
||||
if (meaningfulChanges.length > 0 && workspaceId) {
|
||||
setSummaryStatus('summarising');
|
||||
try {
|
||||
const res = await fetch('/api/ai/diff-summary', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
changesJson: JSON.stringify(meaningfulChanges),
|
||||
componentName: meaningfulChanges[0]?.key ?? 'Component',
|
||||
}),
|
||||
const data = await summarizeDiff.mutateAsync({
|
||||
artboardId,
|
||||
workspaceId,
|
||||
changesJson: JSON.stringify(meaningfulChanges),
|
||||
componentName: meaningfulChanges[0]?.key ?? 'Component',
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { summary?: string };
|
||||
summary = data.summary ?? '';
|
||||
}
|
||||
summary = data.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');
|
||||
createDiff.mutate(
|
||||
{
|
||||
artboard_id: artboardId,
|
||||
changes_jsonb: { propChanges: pendingChanges, styleChanges: [] },
|
||||
summary,
|
||||
status: 'DRAFT',
|
||||
artboard_id: artboardId,
|
||||
changes: { propChanges: pendingChanges, styleChanges: [] },
|
||||
aggregate_summary: summary,
|
||||
status: 'draft',
|
||||
session_id: activeAgentSessionId ?? '',
|
||||
},
|
||||
{ onSettled: () => setSummaryStatus('idle') },
|
||||
);
|
||||
}, [artboardId, pendingChanges, hasChanges, createDiff]);
|
||||
}, [artboardId, pendingChanges, hasChanges, createDiff, activeAgentSessionId]);
|
||||
|
||||
if (!artboardId) {
|
||||
return (
|
||||
@@ -1277,7 +1369,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
|
||||
function SavedDiffRow({ diff }: { diff: IntentDiff }) {
|
||||
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 color = STATUS_COLOR[diff.status] ?? T.dim;
|
||||
return (
|
||||
@@ -1290,9 +1382,9 @@ function SavedDiffRow({ diff }: { diff: IntentDiff }) {
|
||||
{count} change{count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
{diff.summary && (
|
||||
{diff.aggregate_summary && (
|
||||
<span style={{ fontFamily: 'sans-serif', fontSize: '0.625rem', color: T.fgMuted, lineHeight: 1.4, display: 'block' }}>
|
||||
{diff.summary}
|
||||
{diff.aggregate_summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
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. */
|
||||
export interface ProjectMeta {
|
||||
@@ -83,6 +84,20 @@ interface CanvasStore {
|
||||
/** Project metadata fetched from GET /health on CLI connection */
|
||||
projectMeta: ProjectMeta | null;
|
||||
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) => ({
|
||||
@@ -155,4 +170,10 @@ export const useCanvas = create<CanvasStore>((set) => ({
|
||||
|
||||
projectMeta: null,
|
||||
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
@@ -14,8 +14,8 @@
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0"
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,111 +1,59 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// ── Token schemas ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const ColorTokenSchema = z.object({
|
||||
value: z.string().regex(/^#[0-9A-Fa-f]{3,8}$|^rgba?\(|^hsl/, 'Must be a valid CSS color'),
|
||||
/** Fluent 2 token name this maps to, e.g. "colorBrandBackground" */
|
||||
fluentToken: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TypographyTokenSchema = z.object({
|
||||
fontFamily: z.string().optional(),
|
||||
fontSize: z.union([z.string(), z.number()]).optional(),
|
||||
fontWeight: z.union([z.string(), z.number()]).optional(),
|
||||
lineHeight: z.union([z.string(), z.number()]).optional(),
|
||||
letterSpacing: z.union([z.string(), z.number()]).optional(),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const SpacingTokenSchema = z.object({
|
||||
value: z.union([z.string(), z.number()]),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MotionTokenSchema = z.object({
|
||||
duration: z.string().optional(),
|
||||
easing: z.string().optional(),
|
||||
fluentToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TokensSchema = z.object({
|
||||
colors: z.record(ColorTokenSchema).optional(),
|
||||
typography: z.record(TypographyTokenSchema).optional(),
|
||||
spacing: z.record(SpacingTokenSchema).optional(),
|
||||
motion: z.record(MotionTokenSchema).optional(),
|
||||
});
|
||||
|
||||
// ── Component rules ───────────────────────────────────────────────────────────
|
||||
|
||||
export const PropRuleSchema = z.object({
|
||||
allowed: z.array(z.unknown()).optional(),
|
||||
forbidden: z.array(z.unknown()).optional(),
|
||||
required: z.boolean().optional(),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ComponentRuleSchema = z.object({
|
||||
/** Allowed prop values, keyed by prop name */
|
||||
props: z.record(PropRuleSchema).optional(),
|
||||
/** Fluent 2 variants that are explicitly forbidden */
|
||||
forbiddenVariants: z.array(z.string()).optional(),
|
||||
/** ARIA attributes that are required on this component */
|
||||
requiredAria: z.array(z.string()).optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Screen rules ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ScreenRuleSchema = z.object({
|
||||
/** Components that are allowed to appear on this screen */
|
||||
allowedComponents: z.array(z.string()).optional(),
|
||||
/** Components that must be present on this screen */
|
||||
requiredSections: z.array(z.string()).optional(),
|
||||
layout: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
// ── Voice / tone ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const VoiceRuleSchema = z.object({
|
||||
tone: z.string().optional(),
|
||||
maxSentenceLength: z.number().optional(),
|
||||
avoidWords: z.array(z.string()).optional(),
|
||||
preferWords: z.array(z.string()).optional(),
|
||||
examples: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── Accessibility ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const AccessibilitySchema = z.object({
|
||||
wcagLevel: z.enum(['A', 'AA', 'AAA']).optional(),
|
||||
contrastRatio: z.number().optional(),
|
||||
customRules: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// ── Design Language File ──────────────────────────────────────────────────────
|
||||
// ── Design Language File Schema ───────────────────────────────────────────────
|
||||
// Matches the spec exactly (Layer 5.1). This schema validates DLF files uploaded
|
||||
// by teams. All fields must align with the spec — deviations break real uploads.
|
||||
|
||||
export const DesignLanguageFileBodySchema = z.object({
|
||||
/** Semantic version, e.g. "1.0.0" */
|
||||
version: z.string().optional(),
|
||||
/** Spec requires exactly '1.0' — reject any other version string. */
|
||||
version: z.literal('1.0'),
|
||||
|
||||
/** Human-readable name, e.g. "Acme Design System" */
|
||||
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 */
|
||||
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 Tokens = z.infer<typeof TokensSchema>;
|
||||
export type ComponentRule = z.infer<typeof ComponentRuleSchema>;
|
||||
export type ScreenRule = z.infer<typeof ScreenRuleSchema>;
|
||||
|
||||
// Convenience re-exports for downstream consumers
|
||||
export type ComponentRule = NonNullable<DesignLanguageFileBody['components'][string]>;
|
||||
export type ScreenRule = NonNullable<NonNullable<DesignLanguageFileBody['screens']>[string]>;
|
||||
export type Tokens = DesignLanguageFileBody['tokens'];
|
||||
|
||||
@@ -6,15 +6,16 @@ import type { DesignLanguageFileBody } from './schema.js';
|
||||
|
||||
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 {
|
||||
const out: FluentTokenMap = {};
|
||||
const colors = dlf.tokens?.colors ?? {};
|
||||
for (const [, token] of Object.entries(colors)) {
|
||||
if (!token) continue;
|
||||
if (token.fluentToken) {
|
||||
out[token.fluentToken] = token.value;
|
||||
}
|
||||
for (const [name, value] of Object.entries(colors)) {
|
||||
if (value) out[name] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -37,16 +37,16 @@ function formatZodErrors(error: ZodError): ValidationError[] {
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Runtime constraint checks ─────────────────────────────────────────────────
|
||||
// These run during visual edits and AI completions to catch violations
|
||||
// against the active DLF before they're shown to the user.
|
||||
// ── Runtime constraint checks (per-component) ─────────────────────────────────
|
||||
// Used by the Inspector panel to validate the selected component's live React
|
||||
// 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 {
|
||||
/** Name of the component being checked */
|
||||
componentName: string;
|
||||
/** Props being applied */
|
||||
props: Record<string, unknown>;
|
||||
/** The active DLF */
|
||||
dlf: DesignLanguageFileBody;
|
||||
}
|
||||
|
||||
@@ -64,26 +64,302 @@ export function checkComponentConstraints(check: ViolationCheck): Violation[] {
|
||||
const componentRule = dlf.components?.[componentName];
|
||||
if (!componentRule) return violations;
|
||||
|
||||
const { props: propRules } = componentRule;
|
||||
if (!propRules) return violations;
|
||||
const { allowedProps, forbiddenVariants } = componentRule;
|
||||
|
||||
for (const [propKey, rule] of Object.entries(propRules)) {
|
||||
const value = props[propKey];
|
||||
// allowedProps: prop → string[] of allowed values
|
||||
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".
|
||||
// Under exactOptionalPropertyTypes these are semantically different.
|
||||
if (rule.required && !Object.prototype.hasOwnProperty.call(props, propKey)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}" is required by design system rules`, severity: 'error' });
|
||||
// Component-level forbidden variant guard
|
||||
if (forbiddenVariants) {
|
||||
const variant = props['variant'];
|
||||
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)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is forbidden by design system rules`, severity: 'error' });
|
||||
// minTouchTargetPx: interactive components must be at least N×N pixels.
|
||||
// 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)) {
|
||||
violations.push({ prop: propKey, value, message: `"${propKey}=${String(value)}" is not in the allowed values list`, severity: 'warning' });
|
||||
// minContrastRatio: check foreground/background color props.
|
||||
// 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;
|
||||
}
|
||||
|
||||
// ── 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 };
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0"
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,16 +160,16 @@ describe('IntentDiffSchema', () => {
|
||||
id: UUID,
|
||||
artboard_id: UUID,
|
||||
author_id: 'user_xyz',
|
||||
changes_jsonb: { propChanges: [], styleChanges: [] },
|
||||
summary: 'Updated button variant',
|
||||
status: 'DRAFT',
|
||||
changes: { propChanges: [], styleChanges: [] },
|
||||
aggregate_summary: 'Updated button variant',
|
||||
status: 'draft',
|
||||
created_at: NOW,
|
||||
updated_at: NOW,
|
||||
};
|
||||
|
||||
it('parses a valid diff', () => {
|
||||
const d = IntentDiffSchema.parse(valid);
|
||||
expect(d.status).toBe('DRAFT');
|
||||
expect(d.status).toBe('draft');
|
||||
expect(d.notes).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -12,11 +12,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@originmain/diff-engine": "workspace:*",
|
||||
"@supabase/supabase-js": "^2.104.1",
|
||||
"zod": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^2.0.0",
|
||||
"@vitest/coverage-v8": "^2.0.0"
|
||||
"vitest": "^2.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,2 +1,3 @@
|
||||
export * from './types.js';
|
||||
export * from './queries.js';
|
||||
export * from './client.js';
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
InsertAgentSession,
|
||||
InsertTeamMember,
|
||||
InsertProject,
|
||||
InsertWorkspace,
|
||||
DiffStatus,
|
||||
ArtboardAncestry,
|
||||
} from './types.js';
|
||||
@@ -73,6 +74,17 @@ export async function getArtboards(
|
||||
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> {
|
||||
const { data, error } = await (db
|
||||
.from('artboards')
|
||||
@@ -126,6 +138,31 @@ export async function getArtboardAncestors(db: DbClient, artboardId: string): Pr
|
||||
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 ───────────────────────────────────────────────────────
|
||||
|
||||
export async function getDiffs(db: DbClient, artboardId: string): Promise<IntentDiff[]> {
|
||||
@@ -201,6 +238,16 @@ export async function getActiveDesignLanguageFile(
|
||||
|
||||
// ── 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> {
|
||||
const { data, error } = await (db
|
||||
.from('origins')
|
||||
@@ -211,6 +258,34 @@ export async function createOrigin(db: DbClient, row: InsertOrigin): Promise<Ori
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
export async function createAgentSession(db: DbClient, row: InsertAgentSession): Promise<AgentSession> {
|
||||
@@ -225,6 +300,16 @@ export async function createAgentSession(db: DbClient, row: InsertAgentSession):
|
||||
|
||||
// ── 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> {
|
||||
const { data, error } = await (db
|
||||
.from('workspaces')
|
||||
|
||||
@@ -3,122 +3,143 @@ import { z } from 'zod';
|
||||
// ── Enums ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const OriginTypeSchema = z.enum([
|
||||
'GIT_COMMIT',
|
||||
'LINEAR_ISSUE',
|
||||
'SLACK_MESSAGE',
|
||||
'URL',
|
||||
'FORK',
|
||||
// Spec Layer 4 lowercase set (canonical)
|
||||
'route', 'linear', 'git', 'slack', 'feedback', 'fork', 'manual',
|
||||
// Legacy uppercase set (migration 001) — kept for backward compat
|
||||
'GIT_COMMIT', 'LINEAR_ISSUE', 'SLACK_MESSAGE', 'URL', 'FORK',
|
||||
// Canonical uppercase aliases (migration 007)
|
||||
'ROUTE', 'FEEDBACK', 'MANUAL',
|
||||
]);
|
||||
export type OriginType = z.infer<typeof OriginTypeSchema>;
|
||||
|
||||
export const DiffStatusSchema = z.enum([
|
||||
'DRAFT',
|
||||
'EXPORTED',
|
||||
'IMPLEMENTED',
|
||||
'BLOCKED',
|
||||
// Spec Layer 4 lowercase set (canonical)
|
||||
'draft', 'exported', 'acknowledged', 'implemented', 'rejected',
|
||||
// Legacy uppercase set — kept for backward compat
|
||||
'DRAFT', 'EXPORTED', 'IMPLEMENTED', 'BLOCKED',
|
||||
// UI-driven additions
|
||||
'ACKNOWLEDGED', 'REJECTED', 'REVIEWED', 'APPLIED',
|
||||
]);
|
||||
export type DiffStatus = z.infer<typeof DiffStatusSchema>;
|
||||
|
||||
export const TeamRoleSchema = z.enum([
|
||||
'OWNER',
|
||||
'DESIGNER',
|
||||
'ENGINEER',
|
||||
'PM',
|
||||
'VIEWER',
|
||||
]);
|
||||
export const TeamRoleSchema = z.enum(['OWNER', 'DESIGNER', 'ENGINEER', 'PM', 'VIEWER']);
|
||||
export type TeamRole = z.infer<typeof TeamRoleSchema>;
|
||||
|
||||
export const AgentTypeSchema = z.enum(['CURSOR', 'CLAUDE_CODE', 'GENERIC']);
|
||||
export 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 const WorkspacePlanSchema = z.enum(['FREE', 'TEAM', 'ENTERPRISE']);
|
||||
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({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
owner_id: z.string(),
|
||||
plan: WorkspacePlanSchema,
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
owner_id: z.string(),
|
||||
plan: WorkspacePlanSchema,
|
||||
settings_jsonb: z.record(z.unknown()),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
export type Workspace = z.infer<typeof WorkspaceSchema>;
|
||||
|
||||
export const ArtboardSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
workspace_id: z.string().uuid(),
|
||||
project_id: z.string().uuid().nullable(),
|
||||
name: z.string(),
|
||||
origin_id: z.string().uuid().nullable(),
|
||||
id: z.string().uuid(),
|
||||
workspace_id: z.string().uuid(),
|
||||
project_id: z.string().uuid().nullable(),
|
||||
name: z.string(),
|
||||
origin_id: z.string().uuid().nullable(),
|
||||
parent_artboard_id: z.string().uuid().nullable(),
|
||||
metadata_jsonb: z.record(z.unknown()),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
/** Spec alias added migration 008 — mirrors parent_artboard_id */
|
||||
parent_id: z.string().uuid().nullable().optional(),
|
||||
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 const OriginSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
type: OriginTypeSchema,
|
||||
source_ref: z.string(),
|
||||
id: z.string().uuid(),
|
||||
type: OriginTypeSchema,
|
||||
source_ref: z.string(),
|
||||
source_metadata_jsonb: z.record(z.unknown()),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
/** Spec Layer 4 FK: origins.artboard_id (added migration 008) */
|
||||
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 const IntentDiffSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
artboard_id: z.string().uuid(),
|
||||
author_id: z.string(),
|
||||
changes_jsonb: z.record(z.unknown()),
|
||||
summary: z.string(),
|
||||
status: DiffStatusSchema,
|
||||
/** Free-text notes from the coding agent (e.g. why it was blocked) */
|
||||
notes: z.string().optional(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
id: z.string().uuid(),
|
||||
artboard_id: z.string().uuid(),
|
||||
author_id: z.string(),
|
||||
/** Spec column name: changes (renamed from changes_jsonb in migration 008) */
|
||||
changes: z.record(z.unknown()),
|
||||
/** Spec column name: aggregate_summary (renamed from summary in migration 008) */
|
||||
aggregate_summary: z.string(),
|
||||
status: DiffStatusSchema,
|
||||
notes: z.string().optional(),
|
||||
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 const AgentSessionSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
artboard_id: z.string().uuid(),
|
||||
diff_id: z.string().uuid().nullable(),
|
||||
agent_type: AgentTypeSchema,
|
||||
id: z.string().uuid(),
|
||||
artboard_id: z.string().uuid(),
|
||||
diff_id: z.string().uuid().nullable(),
|
||||
agent_type: AgentTypeSchema,
|
||||
messages_jsonb: z.array(z.record(z.unknown())),
|
||||
status: z.enum(['ACTIVE', 'COMPLETED', 'FAILED']),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
status: z.enum(['ACTIVE', 'COMPLETED', 'FAILED']),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
export type AgentSession = z.infer<typeof AgentSessionSchema>;
|
||||
|
||||
export const DesignLanguageFileSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.string().uuid(),
|
||||
workspace_id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
name: z.string(),
|
||||
schema_jsonb: z.record(z.unknown()),
|
||||
version: z.number().int(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
version: z.number().int(),
|
||||
is_active: z.boolean().optional(),
|
||||
created_by: z.string().nullable().optional(),
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
export type DesignLanguageFile = z.infer<typeof DesignLanguageFileSchema>;
|
||||
|
||||
export const TeamMemberSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
id: z.string().uuid(),
|
||||
workspace_id: z.string().uuid(),
|
||||
user_id: z.string(),
|
||||
role: TeamRoleSchema,
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
user_id: z.string(),
|
||||
role: TeamRoleSchema,
|
||||
created_at: z.string().datetime(),
|
||||
updated_at: z.string().datetime(),
|
||||
});
|
||||
export type TeamMember = z.infer<typeof TeamMemberSchema>;
|
||||
|
||||
// ── Project ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ProjectSchema = z.object({
|
||||
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>;
|
||||
|
||||
// ── Ancestry (from materialized view) ────────────────────────────────────────
|
||||
// ── Ancestry (from closure table) ────────────────────────────────────────────
|
||||
|
||||
export interface ArtboardAncestry {
|
||||
artboard_id: string;
|
||||
@@ -139,13 +160,18 @@ export interface ArtboardAncestry {
|
||||
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 InsertArtboard = Omit<Artboard, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertOrigin = Omit<Origin, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertIntentDiff = Omit<IntentDiff, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertAgentSession = Omit<AgentSession, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertDesignLanguageFile = Omit<DesignLanguageFile, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertTeamMember = Omit<TeamMember, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertProject = Omit<Project, 'id' | 'created_at' | 'updated_at'>;
|
||||
export type InsertWorkspace = Omit<Workspace, 'id' | 'created_at' | 'updated_at'>;
|
||||
/**
|
||||
* width/height are optional on insert because the DB column defaults are
|
||||
* 1440 and 900 respectively (migration 008). Callers may omit them.
|
||||
*/
|
||||
export type InsertArtboard = Omit<Artboard, 'id' | 'created_at' | 'updated_at' | 'width' | 'height'>
|
||||
& { width?: number; height?: number };
|
||||
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'>;
|
||||
|
||||
@@ -6,7 +6,7 @@ export default defineConfig({
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
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 },
|
||||
},
|
||||
},
|
||||
|
||||
Generated
+62
-8
@@ -15,7 +15,7 @@ importers:
|
||||
specifier: ^8.0.0
|
||||
version: 8.59.0(eslint@9.39.4)(typescript@5.9.3)
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
|
||||
eslint:
|
||||
specifier: ^9.0.0
|
||||
@@ -24,7 +24,7 @@ importers:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(@types/node@22.19.17)
|
||||
|
||||
packages/agent-bridge:
|
||||
@@ -124,6 +124,15 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.62.0
|
||||
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:
|
||||
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)
|
||||
@@ -133,6 +142,9 @@ importers:
|
||||
react-dom:
|
||||
specifier: ^19.0.0
|
||||
version: 19.2.5(react@19.2.5)
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
zustand:
|
||||
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))
|
||||
@@ -179,13 +191,13 @@ importers:
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(@types/node@22.19.17)
|
||||
|
||||
packages/diff-engine:
|
||||
@@ -198,13 +210,13 @@ importers:
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(@types/node@22.19.17)
|
||||
|
||||
packages/e2e:
|
||||
@@ -259,18 +271,21 @@ importers:
|
||||
'@originmain/diff-engine':
|
||||
specifier: workspace:*
|
||||
version: link:../diff-engine
|
||||
'@supabase/supabase-js':
|
||||
specifier: ^2.104.1
|
||||
version: 2.104.1
|
||||
zod:
|
||||
specifier: ^3.0.0
|
||||
version: 3.25.76
|
||||
devDependencies:
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(vitest@2.1.9(@types/node@22.19.17))
|
||||
typescript:
|
||||
specifier: ^5.5.0
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^2.0.0
|
||||
specifier: ^2.1.9
|
||||
version: 2.1.9(@types/node@22.19.17)
|
||||
|
||||
packages/platform:
|
||||
@@ -1771,6 +1786,28 @@ packages:
|
||||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
@@ -4651,6 +4688,23 @@ snapshots:
|
||||
'@tanstack/query-core': 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)':
|
||||
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/hast@3.0.4':
|
||||
|
||||
@@ -1,46 +1,59 @@
|
||||
-- Origin Graph — Artboard Ancestry Materialized View
|
||||
-- Migration: 002
|
||||
-- Pre-computes all ancestor/descendant relationships so the app never needs
|
||||
-- recursive CTEs at query time. Updated automatically on artboards INSERT.
|
||||
-- ── Migration 002: Artboard Ancestry Guard ───────────────────────────────────
|
||||
-- The artboard_ancestry closure table and its maintenance trigger are already
|
||||
-- created in migration 001 (initial schema). This migration is a safe, idempotent
|
||||
-- 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
|
||||
WITH RECURSIVE ancestry(artboard_id, ancestor_id, depth) AS (
|
||||
-- Base: each artboard is at depth 0 relative to itself
|
||||
SELECT id AS artboard_id, id AS ancestor_id, 0 AS depth
|
||||
FROM artboards
|
||||
-- ── Ensure the ancestry maintenance trigger function exists ───────────────────
|
||||
-- Uses CREATE OR REPLACE so the migration is idempotent; safe to re-run.
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Recurse: walk up the parent chain
|
||||
SELECT a.id AS artboard_id, anc.ancestor_id, anc.depth + 1
|
||||
FROM artboards a
|
||||
JOIN ancestry anc ON a.parent_artboard_id = anc.artboard_id
|
||||
)
|
||||
SELECT artboard_id, ancestor_id, depth
|
||||
FROM ancestry
|
||||
WHERE artboard_id <> ancestor_id -- exclude self-reference
|
||||
ORDER BY artboard_id, depth;
|
||||
|
||||
CREATE UNIQUE INDEX artboard_ancestry_pk
|
||||
ON artboard_ancestry(artboard_id, ancestor_id);
|
||||
|
||||
CREATE INDEX artboard_ancestry_ancestor_idx
|
||||
ON artboard_ancestry(ancestor_id);
|
||||
|
||||
-- ── Refresh trigger ───────────────────────────────────────────────────────────
|
||||
-- Refreshes the materialized view concurrently whenever an artboard is
|
||||
-- inserted. CONCURRENTLY requires the unique index above — it allows reads
|
||||
-- to continue during refresh.
|
||||
|
||||
CREATE OR REPLACE FUNCTION refresh_artboard_ancestry()
|
||||
CREATE OR REPLACE FUNCTION maintain_artboard_ancestry()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY artboard_ancestry;
|
||||
RETURN NULL;
|
||||
-- Self-row: every artboard is its own ancestor at depth 0.
|
||||
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;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_artboard_ancestry_refresh
|
||||
AFTER INSERT OR UPDATE OF parent_artboard_id ON artboards
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION refresh_artboard_ancestry();
|
||||
-- ── Ensure the trigger is attached ───────────────────────────────────────────
|
||||
-- DROP + recreate is the idempotent way to ensure the trigger is attached
|
||||
-- exactly once; IF NOT EXISTS for triggers requires PG 17+ so use this pattern.
|
||||
|
||||
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;
|
||||
$$;
|
||||
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user