improved a lot of things

This commit is contained in:
SinachPat
2026-04-26 09:09:55 +01:00
parent 2911f22df8
commit 97315846be
70 changed files with 4967 additions and 10 deletions
+13 -1
View File
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { ClerkProvider, Show, SignInButton, SignUpButton, UserButton } from '@clerk/nextjs';
import { Providers } from './providers';
import './globals.css';
@@ -11,7 +12,18 @@ export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
<ClerkProvider>
<header style={{ position: 'fixed', top: 0, right: 0, zIndex: 9999, padding: '8px 16px', display: 'flex', gap: 8, alignItems: 'center' }}>
<Show when="signed-out">
<SignInButton />
<SignUpButton />
</Show>
<Show when="signed-in">
<UserButton />
</Show>
</header>
<Providers>{children}</Providers>
</ClerkProvider>
</body>
</html>
);
@@ -0,0 +1,117 @@
'use client';
import { useEffect, useRef, useCallback } from 'react';
import {
buildFiberHookScript,
createHostEnvelope,
isRendererEnvelope,
} from '@originmain/renderer';
import type { FiberNode, RendererMessage } from '@originmain/renderer';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface LiveArtboardProps {
id: string;
/** URL of the connected application route to render */
src: string;
width?: number;
height?: number;
designTokens?: Record<string, string>;
onReady?: () => void;
onFiberTreeUpdate?: (root: FiberNode) => void;
onComponentSelected?: (nodeId: string) => void;
style?: React.CSSProperties;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function LiveArtboard({
id,
src,
width = 1280,
height = 720,
designTokens,
onReady,
onFiberTreeUpdate,
onComponentSelected,
style,
}: LiveArtboardProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
// Send a message to the iframe via the typed protocol
const sendMessage = useCallback(
(type: Parameters<typeof createHostEnvelope>[1]['type'], payload?: Record<string, unknown>) => {
const iframe = iframeRef.current;
if (!iframe?.contentWindow) return;
const envelope = createHostEnvelope(id, { type, ...(payload ?? {}) } as Parameters<typeof createHostEnvelope>[1]);
iframe.contentWindow.postMessage(envelope, '*');
},
[id]
);
// Handle messages from the renderer iframe
useEffect(() => {
function handleMessage(event: MessageEvent) {
if (!isRendererEnvelope(event.data)) return;
if (event.data.artboardId !== id) return;
const msg: RendererMessage = event.data.message;
switch (msg.type) {
case 'READY':
// Inject fiber hook after the renderer signals it's ready
injectFiberHook(iframeRef.current, id);
if (designTokens) sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
onReady?.();
break;
case 'FIBER_TREE_UPDATE':
onFiberTreeUpdate?.(msg.root);
break;
case 'COMPONENT_SELECTED':
onComponentSelected?.(msg.nodeId);
break;
}
}
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [id, designTokens, sendMessage, onReady, onFiberTreeUpdate, onComponentSelected]);
// Push updated design tokens whenever they change
useEffect(() => {
if (!designTokens) return;
sendMessage('SET_DESIGN_TOKENS', { tokens: designTokens });
}, [designTokens, sendMessage]);
return (
<iframe
ref={iframeRef}
src={src}
title={`artboard-${id}`}
// Security: allow-scripts required to run React; allow-same-origin required
// for postMessage with targeted origin validation. Do NOT combine these with
// untrusted third-party content.
sandbox="allow-scripts allow-same-origin allow-forms"
style={{
width,
height,
border: 'none',
display: 'block',
...style,
}}
/>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function injectFiberHook(iframe: HTMLIFrameElement | null, artboardId: string) {
if (!iframe?.contentDocument) return;
try {
const script = iframe.contentDocument.createElement('script');
script.textContent = buildFiberHookScript(artboardId);
iframe.contentDocument.head.appendChild(script);
} catch {
// Cross-origin or sandboxing prevents injection — renderer must include the
// hook script itself in that case.
}
}
@@ -0,0 +1,337 @@
'use client';
import { useState, useRef, useCallback, useEffect } from 'react';
import { useHistory } from '@/store/history';
import type { FiberNode, DOMRectLike } from '@originmain/renderer';
import type { PropChange } from '@originmain/diff-engine';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface SelectionState {
nodeId: string;
nodeName: string;
rect: DOMRectLike;
}
export interface SelectionOverlayProps {
artboardId: string;
/** Fiber tree from the live renderer (if connected) */
fiberRoot?: FiberNode;
/** Width and height must match the artboard frame exactly */
width: number;
height: number;
onSelectionChange?: (selection: SelectionState | null) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function SelectionOverlay({
artboardId,
fiberRoot,
width,
height,
onSelectionChange,
}: SelectionOverlayProps) {
const [selected, setSelected] = useState<SelectionState | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
const pushEdit = useHistory(s => s.pushEdit);
const handleClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
if (!fiberRoot) {
setSelected(null);
onSelectionChange?.(null);
return;
}
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const clickX = e.clientX - overlayRect.left;
const clickY = e.clientY - overlayRect.top;
const hit = hitTestFiber(fiberRoot, clickX, clickY);
if (hit) {
const sel: SelectionState = { nodeId: hit.id, nodeName: hit.name, rect: hit.domRect! };
setSelected(sel);
onSelectionChange?.(sel);
} else {
setSelected(null);
onSelectionChange?.(null);
}
},
[fiberRoot, onSelectionChange]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!fiberRoot) return;
const overlayRect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const x = e.clientX - overlayRect.left;
const y = e.clientY - overlayRect.top;
const hit = hitTestFiber(fiberRoot, x, y);
setHoveredId(hit?.id ?? null);
},
[fiberRoot]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setSelected(null);
onSelectionChange?.(null);
}
},
[onSelectionChange]
);
return (
<div
role="presentation"
tabIndex={-1}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseLeave={() => setHoveredId(null)}
onKeyDown={handleKeyDown}
style={{
position: 'absolute',
inset: 0,
width,
height,
zIndex: 5,
cursor: 'crosshair',
}}
>
{hoveredId && fiberRoot && (
<HoverHighlight fiberRoot={fiberRoot} nodeId={hoveredId} />
)}
{selected && (
<SelectionHandles
artboardId={artboardId}
selection={selected}
onResizeCommit={(changes) => {
pushEdit(artboardId, {
componentId: selected.nodeId,
componentName: selected.nodeName,
changes,
timestamp: Date.now(),
});
}}
/>
)}
</div>
);
}
// ── Hover highlight ───────────────────────────────────────────────────────────
function HoverHighlight({ fiberRoot, nodeId }: { fiberRoot: FiberNode; nodeId: string }) {
const node = findNode(fiberRoot, nodeId);
if (!node?.domRect) return null;
const { x, y, width, height } = node.domRect;
return (
<div
style={{
position: 'absolute',
left: x,
top: y,
width,
height,
border: '1px solid rgba(51,133,255,0.4)',
borderRadius: 2,
pointerEvents: 'none',
}}
/>
);
}
// ── Selection handles ─────────────────────────────────────────────────────────
interface SelectionHandlesProps {
artboardId: string;
selection: SelectionState;
onResizeCommit: (changes: PropChange[]) => void;
}
function SelectionHandles({ selection, onResizeCommit }: SelectionHandlesProps) {
const { rect, nodeName } = selection;
const startRect = useRef<DOMRectLike | null>(null);
// liveRectRef tracks the running rect without stale-closure issues.
// liveRect is the React state for rendering only.
const liveRectRef = useRef<DOMRectLike>(rect);
const [liveRect, setLiveRect] = useState(rect);
// onResizeCommitRef ensures onMouseUp always calls the latest callback even if
// the parent re-renders between mousedown and mouseup.
const onResizeCommitRef = useRef(onResizeCommit);
useEffect(() => { onResizeCommitRef.current = onResizeCommit; });
// Track registered drag listeners so we can clean them up on unmount.
const dragListenersRef = useRef<{
move: (e: MouseEvent) => void;
up: (e: MouseEvent) => void;
} | null>(null);
useEffect(() => {
return () => {
if (dragListenersRef.current) {
window.removeEventListener('mousemove', dragListenersRef.current.move);
window.removeEventListener('mouseup', dragListenersRef.current.up);
dragListenersRef.current = null;
}
};
}, []);
const makeResizeHandle = useCallback(
(corner: 'tl' | 'tr' | 'bl' | 'br') =>
(e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
// Capture the rect at drag-start from the ref (always current).
startRect.current = { ...liveRectRef.current };
const startX = e.clientX;
const startY = e.clientY;
const onMouseMove = (ev: MouseEvent) => {
if (!startRect.current) return;
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
// Always apply delta from the START rect, not the previous frame's rect.
// Applying to prev causes exponential drift over the course of a drag.
const next = adjustRect(startRect.current, corner, dx, dy);
liveRectRef.current = next;
setLiveRect(next);
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
dragListenersRef.current = null;
if (!startRect.current) return;
// Read final dimensions from the ref — not from the stale liveRect closure.
const widthChange = liveRectRef.current.width - startRect.current.width;
const heightChange = liveRectRef.current.height - startRect.current.height;
const changes: PropChange[] = [];
if (Math.abs(widthChange) > 0.5) {
changes.push({
key: 'width',
before: startRect.current.width,
after: liveRectRef.current.width,
changeType: 'modified',
});
}
if (Math.abs(heightChange) > 0.5) {
changes.push({
key: 'height',
before: startRect.current.height,
after: liveRectRef.current.height,
changeType: 'modified',
});
}
if (changes.length > 0) onResizeCommitRef.current(changes);
startRect.current = null;
};
dragListenersRef.current = { move: onMouseMove, up: onMouseUp };
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
},
[] // No reactive deps — all values read from refs
);
const { x, y, width, height } = liveRect;
const HANDLE = 8;
return (
<>
{/* Selection frame */}
<div
style={{
position: 'absolute',
left: x,
top: y,
width,
height,
border: '2px solid #3385FF',
borderRadius: 2,
pointerEvents: 'none',
}}
/>
{/* Label */}
<div
style={{
position: 'absolute',
left: x,
top: y - 20,
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: 10,
color: '#3385FF',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
{nodeName}
</div>
{/* Corner handles */}
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
const cx = corner.includes('l') ? x - HANDLE / 2 : x + width - HANDLE / 2;
const cy = corner.includes('t') ? y - HANDLE / 2 : y + height - HANDLE / 2;
return (
<div
key={corner}
onMouseDown={makeResizeHandle(corner)}
style={{
position: 'absolute',
left: cx,
top: cy,
width: HANDLE,
height: HANDLE,
background: '#fff',
border: '2px solid #3385FF',
borderRadius: 2,
cursor: corner === 'tl' || corner === 'br' ? 'nwse-resize' : 'nesw-resize',
zIndex: 10,
}}
/>
);
})}
</>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function hitTestFiber(node: FiberNode, x: number, y: number): FiberNode | null {
for (const child of [...node.children].reverse()) {
const hit = hitTestFiber(child, x, y);
if (hit) return hit;
}
if (!node.domRect) return null;
const { x: nx, y: ny, width, height } = node.domRect;
if (x >= nx && x <= nx + width && y >= ny && y <= ny + height) return node;
return null;
}
function findNode(root: FiberNode, id: string): FiberNode | null {
if (root.id === id) return root;
for (const child of root.children) {
const found = findNode(child, id);
if (found) return found;
}
return null;
}
function adjustRect(
rect: DOMRectLike,
corner: 'tl' | 'tr' | 'bl' | 'br',
dx: number,
dy: number
): DOMRectLike {
let { x, y, width, height } = rect;
if (corner.includes('l')) { x += dx; width = Math.max(8, width - dx); }
else { width = Math.max(8, width + dx); }
if (corner.includes('t')) { y += dy; height = Math.max(8, height - dy); }
else { height = Math.max(8, height + dy); }
return { x, y, width, height };
}
@@ -0,0 +1,80 @@
'use client';
import { useFileTree, FileTree } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import type { GitStatusEntry } from '@pierre/trees';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface CodebaseFileTreeProps {
/** Flat list of file paths, relative to repo root */
paths: string[];
/** Array of git status entries (matches @pierre/trees FileTreeOptions.gitStatus) */
gitStatus?: readonly GitStatusEntry[];
/** Initial directory expansion depth (default: 1) */
initialExpansion?: number;
/** Collapse single-child directory chains (default: true) */
flattenEmptyDirectories?: boolean;
className?: string;
style?: React.CSSProperties;
}
// Dark panel tokens — match ArtboardNavigator palette
const treeThemeStyles = themeToTreeStyles({
type: 'dark',
bg: '#111115',
fg: 'rgba(255,255,255,0.42)',
colors: {
'editor.selectionBackground': 'rgba(51,133,255,0.14)',
'list.activeSelectionBackground': 'rgba(51,133,255,0.14)',
'list.inactiveSelectionBackground': 'rgba(51,133,255,0.08)',
'list.hoverBackground': 'rgba(255,255,255,0.04)',
'list.activeSelectionForeground': 'rgba(255,255,255,0.88)',
'editorIndentGuide.background': 'rgba(255,255,255,0.04)',
},
});
// ── Component ─────────────────────────────────────────────────────────────────
export function CodebaseFileTree({
paths,
gitStatus,
initialExpansion = 1,
flattenEmptyDirectories = true,
className,
style,
}: CodebaseFileTreeProps) {
const { model } = useFileTree({
paths,
...(gitStatus !== undefined ? { gitStatus } : {}),
initialExpansion,
flattenEmptyDirectories,
density: 'compact',
icons: 'minimal',
fileTreeSearchMode: 'expand-matches',
});
return (
<div
className={className}
style={{
flex: 1,
overflow: 'hidden',
minHeight: 0,
background: '#111115',
...style,
}}
>
<FileTree
model={model}
style={{
...treeThemeStyles,
height: '100%',
width: '100%',
'--trees-item-height': '26px',
'--trees-indent-width': '14px',
} as React.CSSProperties}
/>
</div>
);
}
@@ -0,0 +1,81 @@
'use client';
import { PatchDiff } from '@pierre/diffs/react';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface CodeDiffPanelProps {
/** Unified diff string produced by the diff-engine's generatePatch() */
patch: string;
/** Render mode: 'split' shows before/after columns, 'unified' stacks them */
layout?: 'split' | 'unified';
className?: string;
style?: React.CSSProperties;
}
// PatchDiff options — dark theme, Shiki syntax highlighting
// Note: typed 'as const' to avoid widening to BaseDiffOptions (which includes
// 'custom' hunkSeparator that FileDiffOptions excludes).
const DARK_OPTIONS = {
theme: 'github-dark-dimmed',
diffIndicators: 'bars',
disableBackground: false,
expandUnchanged: false,
collapsedContextThreshold: 3,
} as const;
// ── Component ─────────────────────────────────────────────────────────────────
export function CodeDiffPanel({
patch,
layout = 'split',
className,
style,
}: CodeDiffPanelProps) {
if (!patch) {
return (
<div
className={className}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '24px 16px',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
color: 'rgba(255,255,255,0.22)',
letterSpacing: '0.06em',
background: '#111115',
...style,
}}
>
No diff available
</div>
);
}
const options = {
...DARK_OPTIONS,
diffStyle: layout,
} as const;
return (
<div
className={className}
style={{
overflow: 'auto',
background: '#111115',
// Map Fluent 2 neutral surface tokens into @pierre/diffs CSS custom properties
'--diffs-background': '#111115',
'--diffs-gutter-background': '#0D0D11',
'--diffs-addition-background': 'rgba(70,220,120,0.06)',
'--diffs-deletion-background': 'rgba(255,70,70,0.06)',
'--diffs-addition-gutter': 'rgba(70,220,120,0.15)',
'--diffs-deletion-gutter': 'rgba(255,70,70,0.15)',
...style,
} as React.CSSProperties}
>
<PatchDiff patch={patch} options={options} />
</div>
);
}
@@ -0,0 +1,123 @@
'use client';
import { useState } from 'react';
import {
Tree,
TreeItem,
TreeItemLayout,
type TreeOpenChangeData,
type TreeOpenChangeEvent,
} from '@fluentui/react-components';
import { SquareRegular, FolderRegular, FolderOpenRegular } from '@fluentui/react-icons';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface ArtboardNode {
id: string;
name: string;
children?: ArtboardNode[];
}
export interface ArtboardTreeProps {
nodes: ArtboardNode[];
selectedId?: string | null;
onSelect?: (id: string) => void;
}
// ── Component ─────────────────────────────────────────────────────────────────
export function ArtboardTree({ nodes, selectedId, onSelect }: ArtboardTreeProps) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
function handleOpenChange(_e: TreeOpenChangeEvent, data: TreeOpenChangeData) {
setOpenItems(prev => {
const next = new Set(prev);
if (data.open) next.add(String(data.value));
else next.delete(String(data.value));
return next;
});
}
return (
<Tree
aria-label="Artboard hierarchy"
size="small"
openItems={openItems}
onOpenChange={handleOpenChange}
style={{ background: 'transparent', padding: '2px 0' }}
>
{nodes.map(node => (
<ArtboardTreeNode
key={node.id}
node={node}
selectedId={selectedId}
openItems={openItems}
onSelect={onSelect}
/>
))}
</Tree>
);
}
// ── Recursive node ─────────────────────────────────────────────────────────────
function ArtboardTreeNode({
node,
selectedId,
openItems,
onSelect,
}: {
node: ArtboardNode;
selectedId: string | null | undefined;
openItems: Set<string>;
onSelect: ((id: string) => void) | undefined;
}) {
const hasChildren = node.children && node.children.length > 0;
const isOpen = openItems.has(node.id);
const isSelected = selectedId === node.id;
const icon = hasChildren ? (
isOpen ? (
<FolderOpenRegular style={{ fontSize: 13 }} />
) : (
<FolderRegular style={{ fontSize: 13 }} />
)
) : (
<SquareRegular style={{ fontSize: 11 }} />
);
return (
<TreeItem
value={node.id}
itemType={hasChildren ? 'branch' : 'leaf'}
style={{
background: isSelected ? 'rgba(51,133,255,0.12)' : undefined,
borderRadius: 4,
}}
>
<TreeItemLayout
iconBefore={icon}
onClick={() => !hasChildren && onSelect?.(node.id)}
style={{
fontSize: '0.75rem',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
color: isSelected ? 'rgba(255,255,255,0.88)' : 'rgba(255,255,255,0.55)',
fontWeight: isSelected ? 500 : 400,
cursor: hasChildren ? 'default' : 'pointer',
}}
>
{node.name}
</TreeItemLayout>
{hasChildren &&
node.children!.map(child => (
<ArtboardTreeNode
key={child.id}
node={child}
selectedId={selectedId}
openItems={openItems}
onSelect={onSelect}
/>
))}
</TreeItem>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { clerkMiddleware } from '@clerk/nextjs/server';
export default clerkMiddleware();
export const config = {
matcher: [
// Skip Next.js internals and all static files
'/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
// Always run for API routes
'/(api|trpc)(.*)',
],
};
+122
View File
@@ -0,0 +1,122 @@
import { create } from 'zustand';
import type { PropChange } from '@originmain/diff-engine';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface EditEntry {
/** Which component was changed */
componentId: string;
componentName: string;
/** The prop/style changes in this edit */
changes: PropChange[];
timestamp: number;
}
interface ArtboardHistory {
past: EditEntry[];
future: EditEntry[];
}
interface HistoryStore {
/** Per-artboard history stacks */
stacks: Record<string, ArtboardHistory>;
/** Push a new edit onto the artboard's history (clears the future stack) */
pushEdit: (artboardId: string, entry: EditEntry) => void;
/** Undo the most recent edit for an artboard; returns the undone entry */
undo: (artboardId: string) => EditEntry | undefined;
/** Redo the next edit for an artboard; returns the redone entry */
redo: (artboardId: string) => EditEntry | undefined;
canUndo: (artboardId: string) => boolean;
canRedo: (artboardId: string) => boolean;
/** Clear history for a specific artboard */
clearHistory: (artboardId: string) => void;
}
// ── Store ─────────────────────────────────────────────────────────────────────
const MAX_HISTORY = 100;
function emptyStack(): ArtboardHistory {
return { past: [], future: [] };
}
export const useHistory = create<HistoryStore>((set, get) => ({
stacks: {},
pushEdit(artboardId, entry) {
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
const past = [...current.past, entry].slice(-MAX_HISTORY);
return {
stacks: {
...state.stacks,
[artboardId]: { past, future: [] },
},
};
});
},
undo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const last = stack.past[stack.past.length - 1];
if (!last) return undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
return {
stacks: {
...state.stacks,
[artboardId]: {
past: current.past.slice(0, -1),
future: [last, ...current.future],
},
},
};
});
return last;
},
redo(artboardId) {
const stack = get().stacks[artboardId] ?? emptyStack();
const next = stack.future[0];
if (!next) return undefined;
set(state => {
const current = state.stacks[artboardId] ?? emptyStack();
return {
stacks: {
...state.stacks,
[artboardId]: {
past: [...current.past, next],
future: current.future.slice(1),
},
},
};
});
return next;
},
canUndo(artboardId) {
return (get().stacks[artboardId]?.past.length ?? 0) > 0;
},
canRedo(artboardId) {
return (get().stacks[artboardId]?.future.length ?? 0) > 0;
},
clearHistory(artboardId) {
set(state => ({
stacks: {
...state.stacks,
[artboardId]: emptyStack(),
},
}));
},
}));