made tiny updates

This commit is contained in:
SinachPat
2026-05-04 12:30:56 +01:00
parent 3f029e15c2
commit 5b2d918c13
47 changed files with 2597 additions and 521 deletions
+4
View File
@@ -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 };
+20 -6
View File
@@ -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>
);
}
+90 -147
View File
@@ -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>
+59
View File
@@ -0,0 +1,59 @@
// ── useDlf hook ───────────────────────────────────────────────────────────────
// Fetches the active Design Language File for the current workspace and returns
// its parsed body as a typed DesignLanguageFileBody (tokens, component rules,
// screen rules, voice, accessibility). The raw DB row's schema_jsonb field is
// validated through the Zod schema at cache-write time so every consumer gets
// a fully typed result without re-parsing on each render.
//
// Stale time is 5 minutes — design systems change infrequently (deploy-time
// events) so we avoid redundant round-trips during normal editing sessions.
import { useQuery } from '@tanstack/react-query';
import type { DesignLanguageFile } from '@originmain/origin-graph';
import { DesignLanguageFileBodySchema, type DesignLanguageFileBody } from '@originmain/design-language';
// ── Fetch + parse ─────────────────────────────────────────────────────────────
async function fetchDlf(workspaceId: string): Promise<DesignLanguageFileBody | null> {
const url = `/api/design-language?workspaceId=${encodeURIComponent(workspaceId)}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`DLF fetch failed: ${res.status}`);
// API returns the raw DB row or null when no DLF is uploaded yet.
const file = (await res.json()) as DesignLanguageFile | null;
if (!file) return null;
// Validate schema_jsonb through the typed Zod schema.
// We throw on failure so TanStack Query surfaces it via query.error —
// callers can distinguish "no DLF" (null) from "malformed DLF" (error).
const parsed = DesignLanguageFileBodySchema.safeParse(file.schema_jsonb);
if (!parsed.success) {
throw new Error(
`Design language file schema is invalid: ${parsed.error.errors.map(e => e.message).join('; ')}`,
);
}
return parsed.data;
}
// ── Hook ──────────────────────────────────────────────────────────────────────
export function useDlf(workspaceId: string | null | undefined) {
const query = useQuery({
queryKey: ['dlf', workspaceId] as const,
queryFn: () => fetchDlf(workspaceId!),
enabled: Boolean(workspaceId),
// Design language files change at deploy-time, not interactively.
// 5-minute staleness keeps the inspector snappy without burning requests.
staleTime: 5 * 60_000,
// Retry once on transient network errors, then surface the error.
retry: 1,
});
return {
/** Parsed DLF body, or null if no file is uploaded for this workspace. */
dlf: query.data ?? null,
isLoading: query.isLoading,
/** Set when the DLF fetch succeeded but the schema failed Zod validation. */
error: query.error,
};
}
+27
View File
@@ -0,0 +1,27 @@
'use client';
// ── tRPC client (Next.js App Router, client components) ───────────────────────
// Creates a typed tRPC client backed by the TanStack Query context already
// provided by the app's QueryClientProvider.
//
// Usage (in client components):
// const summarize = trpc.ai.summarizeDiff.useMutation();
// await summarize.mutateAsync({ artboardId, workspaceId, changes });
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import type { AppRouter } from '@/server/routers/index';
// Typed tRPC hooks — import `trpc` in client components for .useQuery / .useMutation
export const trpc = createTRPCReact<AppRouter>();
// Raw client for server-side usage (e.g., in Server Actions)
export function makeTrpcClient() {
return trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
});
}
+151
View File
@@ -0,0 +1,151 @@
// ── AI tRPC router ────────────────────────────────────────────────────────────
// Spec Layer 6: server-side AI feature routes. All mutations are authenticated
// (protectedProcedure) and include the caller's workspace ID for attribution.
//
// Procedures:
// ai.generateDiffSummary — generate an aggregate_summary for a pending diff
// ai.fillCompletionZone — fill an AI completion zone with a proposed tree
// ai.answerAgentQuestion — answer a coding-agent design question
// ai.queryArtboards — cross-artboard semantic search (spec Layer 6)
import { z } from 'zod';
import { router, protectedProcedure } from '../trpc.js';
import { AIGateway } from '@originmain/ai-layer';
import { getArtboard, getArtboardsByWorkspace } from '@originmain/origin-graph';
// ── Singleton gateway (lazily created) ───────────────────────────────────────
let _gateway: AIGateway | null = null;
function getGateway(): AIGateway {
if (!_gateway) _gateway = new AIGateway();
return _gateway;
}
// ── Router ────────────────────────────────────────────────────────────────────
export const aiRouter = router({
// ── generateDiffSummary ────────────────────────────────────────────────────
// Generates an AI aggregate_summary for a set of pending prop changes.
// Input: artboardId + workspaceId + changes[]
// Returns { summary: string }
generateDiffSummary: protectedProcedure
.input(z.object({
artboardId: z.string().uuid(),
workspaceId: z.string().uuid(),
changesJson: z.string(), // JSON-serialised PropChange[]
componentName: z.string(), // name of the changed component
dlfJson: z.string().optional(),
}))
.mutation(async ({ input }) => {
const gateway = getGateway();
const result = await gateway.generateDiffSummary({
changesJson: input.changesJson,
componentName: input.componentName,
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
return { summary: result.summary };
}),
// ── fillCompletionZone ────────────────────────────────────────────────────
// Fills an AI completion zone with a proposed component tree.
// Returns { completion: string, proposedTree: unknown }.
fillCompletionZone: protectedProcedure
.input(z.object({
artboardId: z.string().uuid(),
workspaceId: z.string().uuid(),
intent: z.string().min(1),
bounds: z.object({ x: z.number(), y: z.number(), width: z.number(), height: z.number() }),
dlfJson: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const gateway = getGateway();
let componentTreeJson = '';
try {
const artboard = await getArtboard(ctx.db, input.artboardId);
componentTreeJson = JSON.stringify({ artboard });
} catch { /* non-fatal — artboard context is optional */ }
const result = await gateway.fillCompletionZone({
componentTreeJson,
intent: input.intent,
workspaceId: input.workspaceId,
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
});
return {
completion: result.description,
proposedTree: result.proposedTree,
};
}),
// ── answerAgentQuestion ───────────────────────────────────────────────────
// Answers a coding-agent question about a design diff (spec Layer 6.3-R3).
// Screenshots (before/after) are passed as base64 data URLs.
answerAgentQuestion: protectedProcedure
.input(z.object({
question: z.string().min(1),
diffId: z.string().uuid(),
artboardId: z.string().uuid(),
workspaceId: z.string().uuid(),
dlfJson: z.string().optional(),
beforeScreenshotBase64: z.string().optional(),
afterScreenshotBase64: z.string().optional(),
}))
.mutation(async ({ input, ctx }) => {
const gateway = getGateway();
let artboardContextJson = '';
try {
const artboard = await getArtboard(ctx.db, input.artboardId);
artboardContextJson = JSON.stringify(artboard);
} catch { /* non-fatal */ }
const result = await gateway.answerAgentQuery({
question: input.question,
diffId: input.diffId,
artboardContextJson,
...(input.dlfJson !== undefined ? { dlfJson: input.dlfJson } : {}),
...(input.beforeScreenshotBase64 !== undefined ? { beforeScreenshotBase64: input.beforeScreenshotBase64 } : {}),
...(input.afterScreenshotBase64 !== undefined ? { afterScreenshotBase64: input.afterScreenshotBase64 } : {}),
});
return result;
}),
// ── queryArtboards ────────────────────────────────────────────────────────
// Cross-artboard semantic search — finds artboards relevant to a natural
// language query (spec Layer 6: "cross-artboard queries via AI").
// Returns ranked results with relevance scores and reasoning.
queryArtboards: protectedProcedure
.input(z.object({
query: z.string().min(1),
workspaceId: z.string().uuid(),
}))
.mutation(async ({ input, ctx }) => {
const gateway = getGateway();
// Fetch all artboards in the workspace as context for the AI
let artboardsJson = '[]';
try {
const artboards = await getArtboardsByWorkspace(ctx.db, input.workspaceId);
artboardsJson = JSON.stringify(artboards);
} catch { /* non-fatal — AI returns empty results with no context */ }
const result = await gateway.queryArtboards({
query: input.query,
artboardsJson,
});
return result;
}),
});
export type AIRouter = typeof aiRouter;
+9
View File
@@ -0,0 +1,9 @@
// ── App router — root tRPC router ─────────────────────────────────────────────
import { router } from '../trpc.js';
import { aiRouter } from './ai.js';
export const appRouter = router({
ai: aiRouter,
});
export type AppRouter = typeof appRouter;
+43
View File
@@ -0,0 +1,43 @@
// ── tRPC server initialisation ────────────────────────────────────────────────
// Creates the tRPC context (Clerk auth + Supabase server client), the router
// factory, and the protected-procedure middleware.
//
// Spec Layer 6: all AI feature calls MUST go through tRPC server-side routes
// so they are authenticated, rate-limited, and attributed to a workspace.
import { initTRPC, TRPCError } from '@trpc/server';
import { auth } from '@clerk/nextjs/server';
import { serverClient } from '@/lib/supabase';
import type { DbClient } from '@originmain/origin-graph';
// ── Context ───────────────────────────────────────────────────────────────────
export interface TRPCContext {
userId: string | null;
db: DbClient;
}
export async function createTRPCContext(): Promise<TRPCContext> {
const { userId } = await auth();
const db = serverClient();
return { userId, db };
}
// ── Initialisation ────────────────────────────────────────────────────────────
const t = initTRPC.context<TRPCContext>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// ── Auth middleware ───────────────────────────────────────────────────────────
// Throws UNAUTHORIZED if the caller has no Clerk session.
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, userId: ctx.userId } });
});
export const protectedProcedure = t.procedure.use(isAuthed);
+21
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import 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