updated design

This commit is contained in:
SinachPat
2026-04-25 22:52:30 +01:00
parent df79ae0bca
commit e836ac6d2b
22 changed files with 3480 additions and 781 deletions
+7 -5
View File
@@ -9,17 +9,19 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@originmain/ui": "workspace:*",
"@fluentui/react-components": "^9.54.0",
"@fluentui/react-icons": "^2.0.0",
"@originmain/diff-engine": "workspace:*",
"@originmain/origin-graph": "workspace:*",
"@originmain/renderer": "workspace:*",
"@fluentui/react-components": "^9.54.0",
"@fluentui/react-icons": "^2.0.0",
"@originmain/ui": "workspace:*",
"@pierre/diffs": "^1.1.19",
"@pierre/trees": "1.0.0-beta.3",
"@tanstack/react-query": "^5.62.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zustand": "^5.0.0",
"@tanstack/react-query": "^5.62.0"
"zustand": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,9 @@
import { useState } from 'react';
import { useCanvas } from '@/store/canvas';
import { useDiff } from '@/hooks/useDiff';
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
import type { DiffResult, PropChange } from '@originmain/diff-engine';
const PROPS = [
{ key: 'title', val: '"Revenue Overview"', type: 's' },
@@ -11,15 +14,6 @@ const PROPS = [
{ key: 'loading', val: 'false', type: 'b' },
];
const DIFF = [
{ op: 'del', text: ' borderRadius: 8px' },
{ op: 'add', text: '+ borderRadius: 12px' },
{ op: 'del', text: ' accentColor: #2A6CD4' },
{ op: 'add', text: '+ accentColor: #0066FF' },
{ op: 'del', text: ' padding: 16' },
{ op: 'add', text: '+ padding: 20' },
];
const TYPE_COLORS: Record<string, string> = {
s: '#7DD3A8',
n: '#7EB8FF',
@@ -44,6 +38,7 @@ const T = {
export function Inspector() {
const { selectedArtboardId } = useCanvas();
const [tab, setTab] = useState<TabId>('props');
const diffResult = useDiff(selectedArtboardId);
return (
<div
@@ -132,37 +127,7 @@ export function Inspector() {
</Section>
</>
) : tab === 'diff' ? (
<Section label="Intent Diff">
<div
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5875rem',
color: 'rgba(255,255,255,0.28)',
marginBottom: 10,
letterSpacing: '-0.01em',
}}
>
DashboardCard.tsx · 3 hunks
</div>
{DIFF.map((d, i) => (
<div
key={i}
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
padding: '4px 8px',
borderRadius: 4,
marginBottom: 3,
lineHeight: 1.55,
background: d.op === 'del' ? 'rgba(255,70,70,0.08)' : 'rgba(70,220,120,0.08)',
color: d.op === 'del' ? '#FF8080' : '#7DDBA0',
borderLeft: `2px solid ${d.op === 'del' ? 'rgba(255,80,80,0.3)' : 'rgba(70,220,120,0.3)'}`,
}}
>
{d.text}
</div>
))}
</Section>
<DiffTab artboardId={selectedArtboardId} diffResult={diffResult} />
) : (
<Section label="Origin Graph">
<GraphNode label="DashboardCard" depth={0} isRoot />
@@ -296,3 +261,92 @@ function GraphNode({ label, depth, isRoot }: { label: string; depth: number; isR
function HSep() {
return <div style={{ height: 1, background: 'rgba(255,255,255,0.04)', margin: '2px 0' }} />;
}
/* ── Diff tab ─────────────────────────────────────────────── */
function DiffTab({
artboardId,
diffResult,
}: {
artboardId: string | null;
diffResult: DiffResult | null;
}) {
if (!artboardId || !diffResult) {
return (
<Section label="Intent Diff">
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No diff available
</span>
</Section>
);
}
const { diff } = diffResult;
const snapshots = ARTBOARD_SNAPSHOTS[artboardId];
const filename = snapshots?.after.filePath ?? `${diff.name}.tsx`;
const allChanges = [...diff.propChanges, ...diff.styleChanges];
const hunkCount = allChanges.filter(c => c.changeType !== 'unchanged').length;
return (
<Section label="Intent Diff">
<div
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5875rem',
color: 'rgba(255,255,255,0.28)',
marginBottom: 10,
letterSpacing: '-0.01em',
}}
>
{filename} · {hunkCount} change{hunkCount !== 1 ? 's' : ''}
</div>
{allChanges.map((change, i) => (
<DiffChangeRow key={i} change={change} />
))}
{allChanges.length === 0 && (
<span style={{ fontFamily: "'JetBrains Mono', ui-monospace, monospace", fontSize: '0.625rem', color: 'rgba(255,255,255,0.22)' }}>
No changes detected
</span>
)}
</Section>
);
}
function DiffChangeRow({ change }: { change: PropChange }) {
const isRemoved = change.changeType === 'removed';
const isAdded = change.changeType === 'added';
const isModified = change.changeType === 'modified';
const rows: Array<{ op: 'del' | 'add'; text: string }> = [];
if (isModified) {
rows.push({ op: 'del', text: ` ${change.key}: ${change.before}` });
rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` });
} else if (isRemoved) {
rows.push({ op: 'del', text: ` ${change.key}: ${change.before}` });
} else if (isAdded) {
rows.push({ op: 'add', text: `+ ${change.key}: ${change.after}` });
}
return (
<>
{rows.map((r, i) => (
<div
key={i}
style={{
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.625rem',
padding: '4px 8px',
borderRadius: 4,
marginBottom: 3,
lineHeight: 1.55,
background: r.op === 'del' ? 'rgba(255,70,70,0.08)' : 'rgba(70,220,120,0.08)',
color: r.op === 'del' ? '#FF8080' : '#7DDBA0',
borderLeft: `2px solid ${r.op === 'del' ? 'rgba(255,80,80,0.3)' : 'rgba(70,220,120,0.3)'}`,
}}
>
{r.text}
</div>
))}
</>
);
}
@@ -1,16 +1,9 @@
'use client';
import {
Tree,
TreeItem,
TreeItemLayout,
} from '@fluentui/react-components';
import {
SquareRegular,
DocumentRegular,
FolderRegular,
FolderOpenRegular,
} from '@fluentui/react-icons';
import { useState } from 'react';
import { useFileTree, FileTree, useFileTreeSelection } from '@pierre/trees/react';
import { themeToTreeStyles } from '@pierre/trees';
import { SquareRegular } from '@fluentui/react-icons';
import { useCanvas } from '@/store/canvas';
const ARTBOARDS = [
@@ -20,22 +13,55 @@ const ARTBOARDS = [
{ id: 'data-table', label: 'DataTable' },
];
// Dark panel tokens
const FILE_PATHS = [
'src/components/DashboardCard.tsx',
'src/components/UserProfile.tsx',
'src/components/NavSidebar.tsx',
'src/components/DataTable.tsx',
'src/components/StatsCard.tsx',
'src/app/canvas/page.tsx',
'src/app/layout.tsx',
'src/store/canvas.ts',
'src/store/viewport.ts',
];
const T = {
bg: '#111115',
border: 'rgba(255,255,255,0.055)',
label: 'rgba(255,255,255,0.22)',
item: 'rgba(255,255,255,0.42)',
itemHov: 'rgba(255,255,255,0.72)',
selBg: 'rgba(51,133,255,0.12)',
selFg: 'rgba(255,255,255,0.88)',
accent: '#3385FF',
sep: 'rgba(255,255,255,0.04)',
bg: '#111115',
border: 'rgba(255,255,255,0.055)',
item: 'rgba(255,255,255,0.42)',
itemHov: 'rgba(255,255,255,0.72)',
selBg: 'rgba(51,133,255,0.12)',
selFg: 'rgba(255,255,255,0.88)',
accent: '#3385FF',
dim: 'rgba(255,255,255,0.22)',
sep: 'rgba(255,255,255,0.04)',
};
// Map the panel's dark theme into Trees' CSS custom properties
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)',
},
});
export function ArtboardNavigator() {
const { selectedArtboardId, selectArtboard } = useCanvas();
const { model } = useFileTree({
paths: FILE_PATHS,
initialExpansion: 2,
density: 'compact',
icons: 'minimal',
});
return (
<div
style={{
@@ -49,171 +75,138 @@ export function ArtboardNavigator() {
fontSize: 12,
}}
>
{/* Artboards section */}
<SectionLabel icon="⬜">Artboards</SectionLabel>
{/* ── Artboards ── */}
<SectionLabel>Artboards</SectionLabel>
<div style={{ padding: '2px 6px 0' }}>
{ARTBOARDS.map((ab) => (
<div
key={ab.id}
onClick={() => selectArtboard(ab.id)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '6px 10px',
borderRadius: 5,
cursor: 'pointer',
background: selectedArtboardId === ab.id ? T.selBg : 'transparent',
color: selectedArtboardId === ab.id ? T.selFg : T.item,
fontSize: '0.75rem',
letterSpacing: '-0.01em',
fontWeight: selectedArtboardId === ab.id ? 500 : 400,
transition: 'background 0.1s, color 0.1s',
userSelect: 'none',
marginBottom: 1,
}}
onMouseEnter={e => {
if (selectedArtboardId !== ab.id) {
(e.currentTarget as HTMLDivElement).style.background = 'rgba(255,255,255,0.04)';
(e.currentTarget as HTMLDivElement).style.color = T.itemHov;
{ARTBOARDS.map((ab) => {
const sel = selectedArtboardId === ab.id;
return (
<NavRow
key={ab.id}
selected={sel}
onClick={() => selectArtboard(ab.id)}
icon={
<SquareRegular
style={{ fontSize: 11, color: sel ? T.accent : 'rgba(255,255,255,0.2)', flexShrink: 0 }}
/>
}
}}
onMouseLeave={e => {
if (selectedArtboardId !== ab.id) {
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
(e.currentTarget as HTMLDivElement).style.color = T.item;
}
}}
>
<SquareRegular
style={{
fontSize: 11,
color: selectedArtboardId === ab.id ? T.accent : 'rgba(255,255,255,0.25)',
flexShrink: 0,
}}
label={ab.label}
after={sel && <ActiveDot />}
/>
{ab.label}
{selectedArtboardId === ab.id && (
<span
style={{
marginLeft: 'auto',
width: 5,
height: 5,
borderRadius: '50%',
background: T.accent,
flexShrink: 0,
}}
/>
)}
</div>
))}
);
})}
</div>
<HSep />
{/* Files section */}
<SectionLabel icon="📁">Files</SectionLabel>
<Tree
aria-label="Codebase"
size="small"
style={{ padding: '2px 6px' }}
>
<TreeItem
itemType="branch"
value="src"
style={{ color: T.item }}
>
<TreeItemLayout
iconBefore={<FolderOpenRegular style={{ fontSize: 11, color: 'rgba(255,255,255,0.3)' }} />}
style={{ fontSize: '0.75rem', color: T.item, padding: '4px 4px' }}
>
src
</TreeItemLayout>
<Tree>
<TreeItem itemType="branch" value="components">
<TreeItemLayout
iconBefore={<FolderRegular style={{ fontSize: 11, color: 'rgba(255,255,255,0.25)' }} />}
style={{ fontSize: '0.75rem', color: 'rgba(255,255,255,0.35)', padding: '4px 4px' }}
>
components
</TreeItemLayout>
<Tree>
{ARTBOARDS.map((ab) => (
<TreeItem key={ab.id} itemType="leaf" value={`file-${ab.id}`}>
<TreeItemLayout
iconBefore={<DocumentRegular style={{ fontSize: 10, color: 'rgba(255,255,255,0.2)' }} />}
style={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.3)', padding: '3px 4px' }}
>
{ab.label}.tsx
</TreeItemLayout>
</TreeItem>
))}
</Tree>
</TreeItem>
<TreeItem itemType="leaf" value="app-page">
<TreeItemLayout
iconBefore={<DocumentRegular style={{ fontSize: 10, color: 'rgba(255,255,255,0.2)' }} />}
style={{ fontSize: '0.6875rem', color: 'rgba(255,255,255,0.3)', padding: '3px 4px' }}
>
page.tsx
</TreeItemLayout>
</TreeItem>
</Tree>
</TreeItem>
</Tree>
{/* ── Files — @pierre/trees ── */}
<SectionLabel>Files</SectionLabel>
<div style={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
<FileTree
model={model}
style={{
...treeThemeStyles,
height: '100%',
width: '100%',
// Fine-tune item sizing to match our panel density
'--trees-item-height': '26px',
'--trees-indent-width': '14px',
} as React.CSSProperties}
/>
</div>
<HSep />
{/* Graph nodes indicator */}
<SectionLabel icon="◉">Graph</SectionLabel>
<div
style={{
padding: '4px 16px 12px',
display: 'flex',
flexDirection: 'column',
gap: 5,
}}
>
<GraphStat label="nodes" value="284" color="#3385FF" />
<GraphStat label="components" value="47" color="rgba(255,255,255,0.45)" />
<GraphStat label="tokens" value="112" color="rgba(255,255,255,0.45)" />
{/* ── Graph stats ── */}
<SectionLabel>Graph</SectionLabel>
<div style={{ padding: '4px 14px 14px', display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<GraphStat label="nodes" value="284" color={T.accent} />
<GraphStat label="components" value="47" color="rgba(255,255,255,0.45)" />
<GraphStat label="tokens" value="112" color="rgba(255,255,255,0.45)" />
</div>
</div>
);
}
function SectionLabel({ children, icon }: { children: string; icon: string }) {
/* ── Artboard row ─────────────────────────────────────────── */
function NavRow({
selected = false,
onClick,
icon,
label,
after,
}: {
selected?: boolean;
onClick?: () => void;
icon: React.ReactNode;
label: string;
after?: React.ReactNode;
}) {
const [hov, setHov] = useState(false);
return (
<div
onClick={onClick}
onMouseEnter={() => setHov(true)}
onMouseLeave={() => setHov(false)}
style={{
padding: '12px 12px 5px',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5625rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'rgba(255,255,255,0.22)',
userSelect: 'none',
display: 'flex',
alignItems: 'center',
gap: 6,
gap: 7,
padding: '6px 10px',
borderRadius: 5,
cursor: 'pointer',
background: selected ? T.selBg : hov ? 'rgba(255,255,255,0.04)' : 'transparent',
color: selected ? T.selFg : hov ? T.itemHov : T.item,
fontSize: '0.75rem',
letterSpacing: '-0.01em',
fontWeight: selected ? 500 : 400,
userSelect: 'none',
marginBottom: 1,
transition: 'background 0.1s, color 0.1s',
}}
>
{icon}
<span style={{ flex: 1 }}>{label}</span>
{after}
</div>
);
}
/* ── Helpers ──────────────────────────────────────────────── */
function SectionLabel({ children }: { children: string }) {
return (
<div style={{
padding: '12px 12px 5px',
fontFamily: "'JetBrains Mono', ui-monospace, monospace",
fontSize: '0.5625rem',
fontWeight: 500,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: T.dim,
userSelect: 'none',
flexShrink: 0,
}}>
{children}
</div>
);
}
function HSep() {
return <div style={{ height: 1, background: T.sep, margin: '6px 0', flexShrink: 0 }} />;
}
function ActiveDot() {
return (
<div
style={{
height: 1,
background: 'rgba(255,255,255,0.04)',
margin: '8px 0',
flexShrink: 0,
}}
/>
<span style={{
marginLeft: 'auto',
width: 5,
height: 5,
borderRadius: '50%',
background: T.accent,
flexShrink: 0,
display: 'block',
}} />
);
}
+147
View File
@@ -0,0 +1,147 @@
import type { ComponentSnapshot } from '@originmain/diff-engine';
interface ArtboardSnapshots {
before: ComponentSnapshot;
after: ComponentSnapshot;
}
const dashboardCard: ArtboardSnapshots = {
before: {
id: 'dashboard-card',
name: 'DashboardCard',
filePath: 'src/components/DashboardCard.tsx',
props: {
title: 'Revenue Overview',
value: '$12,450',
delta: 2.4,
period: 'monthly',
loading: false,
},
styles: {
borderRadius: '8px',
accentColor: '#2A6CD4',
padding: '16',
},
},
after: {
id: 'dashboard-card',
name: 'DashboardCard',
filePath: 'src/components/DashboardCard.tsx',
props: {
title: 'Revenue Overview',
value: '$12,450',
delta: 2.4,
period: 'monthly',
loading: false,
},
styles: {
borderRadius: '12px',
accentColor: '#0066FF',
padding: '20',
},
},
};
const userProfile: ArtboardSnapshots = {
before: {
id: 'user-profile',
name: 'UserProfile',
filePath: 'src/components/UserProfile.tsx',
props: {
name: 'Sarah Chen',
role: 'Designer',
plan: 'Pro',
avatarSize: 40,
},
styles: {
avatarGradient: 'linear-gradient(135deg, #6D28D9, #2563EB)',
buttonVariant: 'outline',
},
},
after: {
id: 'user-profile',
name: 'UserProfile',
filePath: 'src/components/UserProfile.tsx',
props: {
name: 'Sarah Chen',
role: 'Design Engineer',
plan: 'Team',
avatarSize: 52,
},
styles: {
avatarGradient: 'linear-gradient(135deg, #7C3AED, #0066FF)',
buttonVariant: 'ghost',
},
},
};
const navSidebar: ArtboardSnapshots = {
before: {
id: 'nav-sidebar',
name: 'NavSidebar',
filePath: 'src/components/NavSidebar.tsx',
props: {
brand: 'Origin',
collapsed: false,
activeItem: 'Dashboard',
},
styles: {
background: '#F5F5F5',
itemPadding: '6px 10px',
borderRadius: '4px',
},
},
after: {
id: 'nav-sidebar',
name: 'NavSidebar',
filePath: 'src/components/NavSidebar.tsx',
props: {
brand: 'Originmain',
collapsed: false,
activeItem: 'Dashboard',
},
styles: {
background: '#FAFAFA',
itemPadding: '7px 12px',
borderRadius: '5px',
},
},
};
const dataTable: ArtboardSnapshots = {
before: {
id: 'data-table',
name: 'DataTable',
filePath: 'src/components/DataTable.tsx',
props: {
title: 'Components',
striped: false,
pageSize: 10,
},
styles: {
headerColor: '#71717A',
rowHeight: '32px',
},
},
after: {
id: 'data-table',
name: 'DataTable',
filePath: 'src/components/DataTable.tsx',
props: {
title: 'Component Inventory',
striped: true,
pageSize: 10,
},
styles: {
headerColor: '#A1A1AA',
rowHeight: '36px',
},
},
};
export const ARTBOARD_SNAPSHOTS: Record<string, ArtboardSnapshots> = {
'dashboard-card': dashboardCard,
'user-profile': userProfile,
'nav-sidebar': navSidebar,
'data-table': dataTable,
};
+12
View File
@@ -0,0 +1,12 @@
import { useMemo } from 'react';
import { diffComponents, type DiffResult } from '@originmain/diff-engine';
import { ARTBOARD_SNAPSHOTS } from '@/data/artboard-snapshots';
export function useDiff(artboardId: string | null): DiffResult | null {
return useMemo(() => {
if (!artboardId) return null;
const snapshots = ARTBOARD_SNAPSHOTS[artboardId];
if (!snapshots) return null;
return diffComponents(snapshots.before, snapshots.after);
}, [artboardId]);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest';
import { computeDiff, diffComponents } from '../src/engine.js';
import type { ComponentSnapshot } from '../src/schemas.js';
const btn: ComponentSnapshot = {
id: 'btn-1',
name: 'Button',
filePath: 'src/components/Button.tsx',
props: { variant: 'primary', size: 'md', disabled: false },
styles: { background: '#0066FF', borderRadius: '8px' },
};
describe('computeDiff', () => {
it('marks unchanged when nothing changed', () => {
const d = computeDiff(btn, btn);
expect(d.changeType).toBe('unchanged');
expect(d.propChanges).toHaveLength(0);
expect(d.styleChanges).toHaveLength(0);
expect(d.patch).toBe('');
});
it('detects modified prop', () => {
const after = { ...btn, props: { ...btn.props, size: 'lg' } };
const d = computeDiff(btn, after);
expect(d.changeType).toBe('modified');
const change = d.propChanges.find(c => c.key === 'size');
expect(change?.before).toBe('md');
expect(change?.after).toBe('lg');
expect(change?.changeType).toBe('modified');
});
it('detects added prop', () => {
const after = { ...btn, props: { ...btn.props, loading: true } };
const d = computeDiff(btn, after);
const change = d.propChanges.find(c => c.key === 'loading');
expect(change?.changeType).toBe('added');
expect(change?.before).toBeUndefined();
expect(change?.after).toBe(true);
});
it('detects removed prop', () => {
const { disabled: _, ...remaining } = btn.props;
const after = { ...btn, props: remaining };
const d = computeDiff(btn, after);
const change = d.propChanges.find(c => c.key === 'disabled');
expect(change?.changeType).toBe('removed');
expect(change?.before).toBe(false);
expect(change?.after).toBeUndefined();
});
it('detects style change', () => {
const after = { ...btn, styles: { ...btn.styles, borderRadius: '12px' } };
const d = computeDiff(btn, after);
const change = d.styleChanges.find(c => c.key === 'borderRadius');
expect(change?.before).toBe('8px');
expect(change?.after).toBe('12px');
});
it('generates a non-empty patch when changed', () => {
const after = { ...btn, props: { ...btn.props, size: 'xl' } };
const d = computeDiff(btn, after);
expect(d.patch).toBeTruthy();
expect(d.patch).toContain('--- a/');
expect(d.patch).toContain('+++ b/');
});
it('propagates id and name from after snapshot', () => {
const d = computeDiff(btn, { ...btn, name: 'IconButton' });
expect(d.name).toBe('IconButton');
expect(d.id).toBe(btn.id);
});
it('deep-equal objects are unchanged', () => {
const snap: ComponentSnapshot = {
id: 'x',
name: 'X',
props: { config: { a: 1, b: [2, 3] } },
};
const d = computeDiff(snap, { ...snap, props: { config: { a: 1, b: [2, 3] } } });
expect(d.changeType).toBe('unchanged');
});
it('deep-equal objects with different value are modified', () => {
const snap: ComponentSnapshot = {
id: 'x',
name: 'X',
props: { config: { a: 1, b: [2, 3] } },
};
const d = computeDiff(snap, { ...snap, props: { config: { a: 1, b: [2, 99] } } });
expect(d.changeType).toBe('modified');
});
it('handles added child', () => {
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
const before: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [] };
const after: ComponentSnapshot = { ...before, children: [child] };
const d = computeDiff(before, after);
expect(d.childDiffs).toBeDefined();
expect(d.childDiffs![0]?.changeType).toBe('added');
});
it('handles removed child', () => {
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
const before: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [child] };
const after: ComponentSnapshot = { ...before, children: [] };
const d = computeDiff(before, after);
expect(d.childDiffs).toBeDefined();
expect(d.childDiffs![0]?.changeType).toBe('removed');
});
it('omits childDiffs when no child changes', () => {
const child: ComponentSnapshot = { id: 'c1', name: 'Icon', props: { name: 'check' } };
const snap: ComponentSnapshot = { id: 'x', name: 'X', props: {}, children: [child] };
const d = computeDiff(snap, snap);
expect(d.childDiffs).toBeUndefined();
});
});
describe('diffComponents', () => {
it('returns diff and patch at top level', () => {
const after = { ...btn, props: { ...btn.props, size: 'xl' } };
const result = diffComponents(btn, after);
expect(result.diff.changeType).toBe('modified');
expect(result.patch).toBe(result.diff.patch);
expect(result.patch).toBeTruthy();
});
it('patch and diff.patch are identical', () => {
const result = diffComponents(btn, btn);
expect(result.patch).toBe('');
expect(result.diff.patch).toBe('');
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect } from 'vitest';
import { computeEditScript } from '../src/myers.js';
describe('computeEditScript', () => {
it('returns empty for two empty arrays', () => {
expect(computeEditScript([], [])).toEqual([]);
});
it('returns all-insert for empty before', () => {
const ops = computeEditScript([], ['a', 'b']);
expect(ops).toEqual([{ type: 'insert', lines: ['a', 'b'] }]);
});
it('returns all-delete for empty after', () => {
const ops = computeEditScript(['a', 'b'], []);
expect(ops).toEqual([{ type: 'delete', lines: ['a', 'b'] }]);
});
it('returns all-equal for identical arrays', () => {
const ops = computeEditScript(['a', 'b', 'c'], ['a', 'b', 'c']);
expect(ops).toEqual([{ type: 'equal', lines: ['a', 'b', 'c'] }]);
});
it('detects a single changed line', () => {
const ops = computeEditScript(['a', 'b', 'c'], ['a', 'X', 'c']);
const types = ops.map(o => o.type);
expect(types).toContain('delete');
expect(types).toContain('insert');
const deleted = ops.filter(o => o.type === 'delete').flatMap(o => o.lines);
const inserted = ops.filter(o => o.type === 'insert').flatMap(o => o.lines);
expect(deleted).toContain('b');
expect(inserted).toContain('X');
});
it('detects inserted lines at end', () => {
const ops = computeEditScript(['a'], ['a', 'b', 'c']);
const inserted = ops.filter(o => o.type === 'insert').flatMap(o => o.lines);
expect(inserted).toEqual(['b', 'c']);
});
it('detects deleted lines at start', () => {
const ops = computeEditScript(['x', 'y', 'a'], ['a']);
const deleted = ops.filter(o => o.type === 'delete').flatMap(o => o.lines);
expect(deleted).toEqual(['x', 'y']);
});
it('merges consecutive ops of same type', () => {
const ops = computeEditScript(['a', 'b'], ['c', 'd']);
// Should have merged deletes and inserts, not one-op-per-line
const delOp = ops.find(o => o.type === 'delete');
const insOp = ops.find(o => o.type === 'insert');
expect(delOp?.lines.length).toBeGreaterThanOrEqual(1);
expect(insOp?.lines.length).toBeGreaterThanOrEqual(1);
});
it('round-trip: applying ops to before yields after', () => {
const before = ['prop: 1', 'style: red', 'size: md'];
const after = ['prop: 1', 'style: blue', 'size: lg', 'weight: 600'];
const ops = computeEditScript(before, after);
const result: string[] = [];
for (const op of ops) {
if (op.type === 'equal' || op.type === 'insert') result.push(...op.lines);
}
expect(result).toEqual(after);
});
it('handles single-element change', () => {
const ops = computeEditScript(['x'], ['y']);
expect(ops.some(o => o.type === 'delete' && o.lines.includes('x'))).toBe(true);
expect(ops.some(o => o.type === 'insert' && o.lines.includes('y'))).toBe(true);
});
});
@@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { generatePatch } from '../src/patch.js';
const A = `Component: Button
Props:
size: "md"
variant: "primary"
Styles:
background: #0066FF
borderRadius: 8px
`;
const B = `Component: Button
Props:
size: "lg"
variant: "primary"
Styles:
background: #0066FF
borderRadius: 12px
`;
describe('generatePatch', () => {
it('returns empty string for identical inputs', () => {
expect(generatePatch(A, A)).toBe('');
});
it('returns empty string for two empty strings', () => {
expect(generatePatch('', '')).toBe('');
});
it('includes --- and +++ headers', () => {
const patch = generatePatch(A, B);
expect(patch).toMatch(/^--- a\//m);
expect(patch).toMatch(/^\+\+\+ b\//m);
});
it('includes @@ hunk header', () => {
const patch = generatePatch(A, B);
expect(patch).toMatch(/^@@ /m);
});
it('marks changed lines correctly', () => {
const patch = generatePatch(A, B);
expect(patch).toContain('- size: "md"');
expect(patch).toContain('+ size: "lg"');
expect(patch).toContain('- borderRadius: 8px');
expect(patch).toContain('+ borderRadius: 12px');
});
it('preserves unchanged lines as context', () => {
const patch = generatePatch(A, B);
expect(patch).toContain(' variant: "primary"');
});
it('uses custom filename in headers', () => {
const patch = generatePatch(A, B, { filename: 'Button.tsx' });
expect(patch).toContain('--- a/Button.tsx');
expect(patch).toContain('+++ b/Button.tsx');
});
it('handles all-insert (empty before)', () => {
const patch = generatePatch('', 'line1\nline2\n');
expect(patch).toContain('+line1');
expect(patch).toContain('+line2');
});
it('handles all-delete (empty after)', () => {
const patch = generatePatch('line1\nline2\n', '');
expect(patch).toContain('-line1');
expect(patch).toContain('-line2');
});
it('produces multiple hunks when changes are far apart', () => {
const before = Array.from({ length: 20 }, (_, i) => `line${i}`).join('\n') + '\n';
const after = before
.replace('line0', 'CHANGED0')
.replace('line19', 'CHANGED19');
const patch = generatePatch(before, after);
const hunkCount = (patch.match(/^@@/gm) ?? []).length;
expect(hunkCount).toBe(2);
});
it('merges nearby hunks into one', () => {
const before = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n') + '\n';
const after = before.replace('line0', 'X').replace('line1', 'Y');
const patch = generatePatch(before, after);
const hunkCount = (patch.match(/^@@/gm) ?? []).length;
expect(hunkCount).toBe(1);
});
});
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { serializeSnapshot, serializeValue } from '../src/serialize.js';
import type { ComponentSnapshot } from '../src/schemas.js';
const snap: ComponentSnapshot = {
id: 'btn-1',
name: 'Button',
filePath: 'src/components/Button.tsx',
props: { variant: 'primary', size: 'md', disabled: false },
styles: { borderRadius: '8px', background: '#0066FF' },
};
describe('serializeSnapshot', () => {
it('includes component name and filePath', () => {
const out = serializeSnapshot(snap);
expect(out).toContain('Component: Button [src/components/Button.tsx]');
});
it('sorts props alphabetically for stable diffs', () => {
const out = serializeSnapshot(snap);
const propLines = out.split('\n').filter(l => l.includes(':') && !l.includes('Component'));
const propNames = propLines
.map(l => l.trim().split(':')[0]!.trim())
.filter(k => ['disabled', 'size', 'variant', 'background', 'borderRadius'].includes(k));
// disabled < size < variant (alphabetical)
const dIdx = propNames.indexOf('disabled');
const sIdx = propNames.indexOf('size');
const vIdx = propNames.indexOf('variant');
expect(dIdx).toBeLessThan(sIdx);
expect(sIdx).toBeLessThan(vIdx);
});
it('sorts styles alphabetically', () => {
const out = serializeSnapshot(snap);
const bgIdx = out.indexOf('background:');
const brIdx = out.indexOf('borderRadius:');
expect(bgIdx).toBeLessThan(brIdx);
});
it('produces identical output for same input (deterministic)', () => {
expect(serializeSnapshot(snap)).toBe(serializeSnapshot(snap));
});
it('handles component without filePath', () => {
const out = serializeSnapshot({ id: 'x', name: 'Foo', props: {} });
expect(out).toBe('Component: Foo');
});
it('handles empty props and styles', () => {
const out = serializeSnapshot({ id: 'x', name: 'Foo', props: {}, styles: {} });
expect(out).not.toContain('Props:');
expect(out).not.toContain('Styles:');
});
it('serializes nested children', () => {
const withChild: ComponentSnapshot = {
...snap,
children: [{ id: 'icon', name: 'Icon', props: { name: 'check' } }],
};
const out = serializeSnapshot(withChild);
expect(out).toContain('Children:');
expect(out).toContain('[0]');
expect(out).toContain('Component: Icon');
});
});
describe('serializeValue', () => {
it('wraps strings in quotes', () => expect(serializeValue('hello')).toBe('"hello"'));
it('renders numbers as-is', () => expect(serializeValue(42)).toBe('42'));
it('renders booleans', () => {
expect(serializeValue(true)).toBe('true');
expect(serializeValue(false)).toBe('false');
});
it('renders null', () => expect(serializeValue(null)).toBe('null'));
it('renders undefined', () => expect(serializeValue(undefined)).toBe('undefined'));
it('renders arrays', () => expect(serializeValue([1, 'a'])).toBe('[1, "a"]'));
it('renders objects with sorted keys', () => {
expect(serializeValue({ z: 1, a: 2 })).toBe('{ a: 2, z: 1 }');
});
});
+3 -2
View File
@@ -11,11 +11,12 @@
"test": "vitest run --coverage"
},
"dependencies": {
"@pierre/diffs": "^1.1.19",
"zod": "^3.0.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"@vitest/coverage-v8": "^2.0.0"
"vitest": "^2.0.0"
}
}
+185
View File
@@ -0,0 +1,185 @@
import type { ComponentSnapshot, ComponentDiff, PropChange, ChangeType } from './schemas.js';
import { serializeSnapshot } from './serialize.js';
import { generatePatch } from './patch.js';
// ── Core diff result ──────────────────────────────────────────────────────────
export interface DiffResult {
diff: ComponentDiff;
patch: string;
}
// ── Public API ────────────────────────────────────────────────────────────────
export function diffComponents(
before: ComponentSnapshot,
after: ComponentSnapshot
): DiffResult {
const diff = computeDiff(before, after);
return { diff, patch: diff.patch };
}
export function computeDiff(
before: ComponentSnapshot,
after: ComponentSnapshot
): ComponentDiff {
const propChanges = diffRecord(before.props, after.props);
const styleChanges = diffRecord(
before.styles ?? {},
after.styles ?? {}
) as PropChange[];
const changeType = determineChangeType(propChanges, styleChanges);
const childDiffs = diffChildren(before.children ?? [], after.children ?? []);
const beforeText = serializeSnapshot(before);
const afterText = serializeSnapshot(after);
const filename = after.filePath ?? `${after.name}.tsx`;
const patch = generatePatch(beforeText, afterText, { filename });
const result: ComponentDiff = {
id: after.id,
name: after.name,
changeType,
propChanges,
styleChanges,
patch,
};
if (childDiffs.length > 0) result.childDiffs = childDiffs;
return result;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function diffRecord(
before: Record<string, unknown>,
after: Record<string, unknown>
): PropChange[] {
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
const changes: PropChange[] = [];
for (const key of [...keys].sort()) {
const b = before[key];
const a = after[key];
const hadKey = Object.prototype.hasOwnProperty.call(before, key);
const hasKey = Object.prototype.hasOwnProperty.call(after, key);
if (!hadKey) {
changes.push({ key, before: undefined, after: a, changeType: 'added' });
} else if (!hasKey) {
changes.push({ key, before: b, after: undefined, changeType: 'removed' });
} else if (!deepEqual(b, a)) {
changes.push({ key, before: b, after: a, changeType: 'modified' });
}
}
return changes;
}
function diffChildren(
before: ComponentSnapshot[],
after: ComponentSnapshot[]
): ComponentDiff[] {
const diffs: ComponentDiff[] = [];
// Match by id first; fall back to positional match
const beforeById = new Map(before.map(c => [c.id, c]));
const afterById = new Map(after.map(c => [c.id, c]));
// Modified or unchanged children that exist in both
for (const afterChild of after) {
const beforeChild = beforeById.get(afterChild.id);
if (beforeChild) {
const d = computeDiff(beforeChild, afterChild);
if (d.changeType !== 'unchanged') diffs.push(d);
} else {
// Added child
diffs.push(addedDiff(afterChild));
}
}
// Removed children
for (const beforeChild of before) {
if (!afterById.has(beforeChild.id)) {
diffs.push(removedDiff(beforeChild));
}
}
return diffs;
}
function addedDiff(snap: ComponentSnapshot): ComponentDiff {
const props = Object.keys(snap.props).sort().map(key => ({
key,
before: undefined as unknown,
after: snap.props[key],
changeType: 'added' as ChangeType,
}));
const styles = Object.keys(snap.styles ?? {}).sort().map(key => ({
key,
before: undefined as unknown,
after: snap.styles![key],
changeType: 'added' as ChangeType,
}));
return {
id: snap.id,
name: snap.name,
changeType: 'added',
propChanges: props,
styleChanges: styles,
patch: generatePatch('', serializeSnapshot(snap), { filename: snap.name }),
};
}
function removedDiff(snap: ComponentSnapshot): ComponentDiff {
const props = Object.keys(snap.props).sort().map(key => ({
key,
before: snap.props[key],
after: undefined as unknown,
changeType: 'removed' as ChangeType,
}));
const styles = Object.keys(snap.styles ?? {}).sort().map(key => ({
key,
before: snap.styles![key] as unknown,
after: undefined as unknown,
changeType: 'removed' as ChangeType,
}));
return {
id: snap.id,
name: snap.name,
changeType: 'removed',
propChanges: props,
styleChanges: styles,
patch: generatePatch(serializeSnapshot(snap), '', { filename: snap.name }),
};
}
function determineChangeType(
propChanges: PropChange[],
styleChanges: PropChange[]
): ChangeType {
const hasChanges = propChanges.length > 0 || styleChanges.length > 0;
return hasChanges ? 'modified' : 'unchanged';
}
function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return false;
if (typeof a !== typeof b) return false;
if (typeof a !== 'object') return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((v, i) => deepEqual(v, b[i]));
}
const ao = a as Record<string, unknown>;
const bo = b as Record<string, unknown>;
const aKeys = Object.keys(ao).sort();
const bKeys = Object.keys(bo).sort();
if (aKeys.length !== bKeys.length) return false;
if (aKeys.join(',') !== bKeys.join(',')) return false;
return aKeys.every(k => deepEqual(ao[k], bo[k]));
}
+25 -1
View File
@@ -1 +1,25 @@
export {};
// @originmain/diff-engine — public API
export type {
ComponentSnapshot,
ComponentDiff,
PropChange,
ChangeType,
DiffLayout,
} from './schemas.js';
export {
ComponentSnapshotSchema,
ComponentDiffSchema,
PropChangeSchema,
ChangeTypeSchema,
DiffLayoutSchema,
} from './schemas.js';
export { computeEditScript, type EditOp, type EditType } from './myers.js';
export { serializeSnapshot, serializeValue } from './serialize.js';
export { generatePatch, type PatchOptions } from './patch.js';
export { diffComponents, computeDiff, type DiffResult } from './engine.js';
+62
View File
@@ -0,0 +1,62 @@
// LCS-based O(nm) diff algorithm — correct and simple for component-sized inputs.
// Produces a minimal edit script as a sequence of equal/insert/delete operations.
export type EditType = 'equal' | 'insert' | 'delete';
export interface EditOp {
type: EditType;
lines: string[];
}
export function computeEditScript(before: string[], after: string[]): EditOp[] {
const n = before.length;
const m = after.length;
if (n === 0 && m === 0) return [];
if (n === 0) return [{ type: 'insert', lines: [...after] }];
if (m === 0) return [{ type: 'delete', lines: [...before] }];
// Build LCS DP table
const dp: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
dp[i]![j] =
before[i - 1] === after[j - 1]
? (dp[i - 1]![j - 1] ?? 0) + 1
: Math.max(dp[i - 1]![j] ?? 0, dp[i]![j - 1] ?? 0);
}
}
// Backtrack from (n, m) to (0, 0)
type RawOp = { type: EditType; line: string };
const raw: RawOp[] = [];
let i = n, j = m;
while (i > 0 || j > 0) {
if (i > 0 && j > 0 && before[i - 1] === after[j - 1]) {
raw.push({ type: 'equal', line: before[i - 1]! });
i--; j--;
} else if (j > 0 && (i === 0 || (dp[i]![j - 1] ?? 0) >= (dp[i - 1]![j] ?? 0))) {
raw.push({ type: 'insert', line: after[j - 1]! });
j--;
} else {
raw.push({ type: 'delete', line: before[i - 1]! });
i--;
}
}
raw.reverse();
// Merge consecutive ops of the same type
const ops: EditOp[] = [];
for (const op of raw) {
const last = ops[ops.length - 1];
if (last && last.type === op.type) {
last.lines.push(op.line);
} else {
ops.push({ type: op.type, lines: [op.line] });
}
}
return ops;
}
+114
View File
@@ -0,0 +1,114 @@
import { computeEditScript, type EditOp } from './myers.js';
// ── Flat line representation ──────────────────────────────────────────────────
interface FlatLine {
type: 'equal' | 'insert' | 'delete';
content: string;
beforeIdx: number; // 1-based; -1 for pure insertions
afterIdx: number; // 1-based; -1 for pure deletions
}
function flattenOps(ops: EditOp[]): FlatLine[] {
const lines: FlatLine[] = [];
let b = 1, a = 1;
for (const op of ops) {
for (const content of op.lines) {
if (op.type === 'equal') {
lines.push({ type: 'equal', content, beforeIdx: b++, afterIdx: a++ });
} else if (op.type === 'delete') {
lines.push({ type: 'delete', content, beforeIdx: b++, afterIdx: -1 });
} else {
lines.push({ type: 'insert', content, beforeIdx: -1, afterIdx: a++ });
}
}
}
return lines;
}
// ── Hunk grouping ─────────────────────────────────────────────────────────────
interface Hunk {
flatStart: number;
flatEnd: number;
}
function buildHunks(flat: FlatLine[], context: number): Hunk[] {
const changed = flat.reduce<number[]>((acc, l, i) => {
if (l.type !== 'equal') acc.push(i);
return acc;
}, []);
if (changed.length === 0) return [];
const hunks: Hunk[] = [];
let start = Math.max(0, changed[0]! - context);
let end = Math.min(flat.length - 1, changed[0]! + context);
for (let i = 1; i < changed.length; i++) {
const ns = Math.max(0, changed[i]! - context);
if (ns <= end + 1) {
end = Math.min(flat.length - 1, changed[i]! + context);
} else {
hunks.push({ flatStart: start, flatEnd: end });
start = ns;
end = Math.min(flat.length - 1, changed[i]! + context);
}
}
hunks.push({ flatStart: start, flatEnd: end });
return hunks;
}
// ── Patch generation ──────────────────────────────────────────────────────────
export interface PatchOptions {
filename?: string;
contextLines?: number;
}
export function generatePatch(
beforeText: string,
afterText: string,
opts: PatchOptions = {}
): string {
const { filename = 'component', contextLines = 3 } = opts;
const before = splitLines(beforeText);
const after = splitLines(afterText);
if (before.join('\n') === after.join('\n')) return '';
const ops = computeEditScript(before, after);
const flat = flattenOps(ops);
const hunks = buildHunks(flat, contextLines);
if (hunks.length === 0) return '';
const out: string[] = [`--- a/${filename}`, `+++ b/${filename}`];
for (const hunk of hunks) {
const hunkLines = flat.slice(hunk.flatStart, hunk.flatEnd + 1);
const beforeStart = hunkLines.find(l => l.beforeIdx !== -1)?.beforeIdx ?? 1;
const afterStart = hunkLines.find(l => l.afterIdx !== -1)?.afterIdx ?? 1;
const beforeCount = hunkLines.filter(l => l.type !== 'insert').length;
const afterCount = hunkLines.filter(l => l.type !== 'delete').length;
out.push(`@@ -${beforeStart},${beforeCount} +${afterStart},${afterCount} @@`);
for (const line of hunkLines) {
if (line.type === 'equal') out.push(` ${line.content}`);
else if (line.type === 'delete') out.push(`-${line.content}`);
else out.push(`+${line.content}`);
}
}
return out.join('\n') + '\n';
}
function splitLines(text: string): string[] {
return text === '' ? [] : text.split('\n').filter((_, i, arr) => i < arr.length - 1 || arr[arr.length - 1] !== '');
}
+65
View File
@@ -0,0 +1,65 @@
import { z } from 'zod';
// ── Component snapshot ────────────────────────────────────────────────────────
export interface ComponentSnapshot {
id: string;
name: string;
filePath?: string;
props: Record<string, unknown>;
styles?: Record<string, string>;
children?: ComponentSnapshot[];
}
export const ComponentSnapshotSchema: z.ZodType<ComponentSnapshot> = z.lazy(() =>
z.object({
id: z.string(),
name: z.string(),
filePath: z.string().optional(),
props: z.record(z.unknown()),
styles: z.record(z.string()).optional(),
children: z.array(ComponentSnapshotSchema).optional(),
})
) as z.ZodType<ComponentSnapshot>;
// ── Change types ──────────────────────────────────────────────────────────────
export const ChangeTypeSchema = z.enum(['added', 'removed', 'modified', 'unchanged']);
export type ChangeType = z.infer<typeof ChangeTypeSchema>;
export const PropChangeSchema = z.object({
key: z.string(),
before: z.unknown(),
after: z.unknown(),
changeType: ChangeTypeSchema,
});
export type PropChange = z.infer<typeof PropChangeSchema>;
// ── Component diff ────────────────────────────────────────────────────────────
export interface ComponentDiff {
id: string;
name: string;
changeType: ChangeType;
propChanges: PropChange[];
styleChanges: PropChange[];
patch: string;
childDiffs?: ComponentDiff[];
}
export const ComponentDiffSchema: z.ZodType<ComponentDiff> = z.lazy(() =>
z.object({
id: z.string(),
name: z.string(),
changeType: ChangeTypeSchema,
propChanges: z.array(PropChangeSchema),
styleChanges: z.array(PropChangeSchema),
patch: z.string(),
childDiffs: z.array(ComponentDiffSchema).optional(),
})
) as z.ZodType<ComponentDiff>;
// ── Layout ────────────────────────────────────────────────────────────────────
export const DiffLayoutSchema = z.enum(['split', 'unified']);
export type DiffLayout = z.infer<typeof DiffLayoutSchema>;
+58
View File
@@ -0,0 +1,58 @@
import type { ComponentSnapshot } from './schemas.js';
// Stable text serialization of a component snapshot.
// Props are sorted alphabetically so diffs are determined by value changes, not key ordering.
export function serializeSnapshot(snapshot: ComponentSnapshot, depth = 0): string {
const indent = ' '.repeat(depth);
const lines: string[] = [];
const header = snapshot.filePath
? `Component: ${snapshot.name} [${snapshot.filePath}]`
: `Component: ${snapshot.name}`;
lines.push(`${indent}${header}`);
// Props section
const propKeys = Object.keys(snapshot.props).sort();
if (propKeys.length > 0) {
lines.push(`${indent}Props:`);
for (const key of propKeys) {
lines.push(`${indent} ${key}: ${serializeValue(snapshot.props[key])}`);
}
}
// Styles section
const styleKeys = snapshot.styles ? Object.keys(snapshot.styles).sort() : [];
if (styleKeys.length > 0) {
lines.push(`${indent}Styles:`);
for (const key of styleKeys) {
lines.push(`${indent} ${key}: ${snapshot.styles![key]}`);
}
}
// Children section
if (snapshot.children && snapshot.children.length > 0) {
lines.push(`${indent}Children:`);
for (let i = 0; i < snapshot.children.length; i++) {
lines.push(`${indent} [${i}]`);
lines.push(...serializeSnapshot(snapshot.children[i]!, depth + 2).split('\n'));
}
}
return lines.join('\n');
}
export function serializeValue(val: unknown): string {
if (val === null) return 'null';
if (val === undefined) return 'undefined';
if (typeof val === 'string') return `"${val}"`;
if (typeof val === 'number' || typeof val === 'boolean') return String(val);
if (Array.isArray(val)) return `[${val.map(serializeValue).join(', ')}]`;
if (typeof val === 'object') {
const entries = Object.entries(val as Record<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([k, v]) => `${k}: ${serializeValue(v)}`);
return `{ ${entries.join(', ')} }`;
}
return String(val);
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['__tests__/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
exclude: ['src/index.ts'],
thresholds: { lines: 85, functions: 85, branches: 80 },
},
},
});