VisualDiffManager
surface스크린샷 비주얼 리그레션 관리 + 승인 워크플로우. 3-column 레이아웃 — 스냅샷 리스트 + 이미지 diff 뷰어.
컴포넌트 의존 관계
깊이
100%
기본 사용
Visual Regression
Total5→
Match1↑
Diff2↓
New1→
No snapshot selected
Select a snapshot from the list to view the diff.
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
VisualDiffManager 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
VisualDiffManager Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
snapshots* | SnapshotItem[] | — | 스냅샷 목록 |
selectedSnapshotId | string | — | 선택된 스냅샷 ID |
onSnapshotSelect | (id: string) => void | — | 스냅샷 선택 핸들러 |
selectedIds | string[] | — | 일괄 선택된 스냅샷 ID |
viewMode | DiffViewMode | — | 비교 뷰 모드 (side-by-side/overlay/slider/diff-only) |
onApprove | (ids: string[]) => void | — | 승인 핸들러 |
onReject | (ids: string[]) => void | — | 거부 핸들러 |
onUpdateBaseline | (ids: string[]) => void | — | 베이스라인 업데이트 핸들러 |
renderDiffViewer | (snapshot: SnapshotItem, mode: DiffViewMode) => ReactNode | — | 커스텀 diff 뷰어 렌더러 |
loading | boolean | — | 로딩 상태 |
header | ReactNode | — | 커스텀 헤더 슬롯 |
actions | ReactNode | — | 액션 버튼 슬롯 |
labels | VisualDiffManagerLabels | — | 커스텀 레이블 |
className | string | — | 최외곽 CSS 클래스 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add visual-diff-managerConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { VisualDiffManager } from "@/components/surfaces/visual-diff-manager";Registry metadata
- 설명
- 스크린샷 비주얼 리그레션 관리 + 승인 워크플로우. 3-column 레이아웃 — 스냅샷 리스트 + 이미지 diff 뷰어.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- filter
- Install notes
- 없음
포함 파일
visual-diff-manager.tsxvisual-diff-manager.tsx
Surface 소스 보기
visual-diff-manager.tsx
"use client";
import * as React from "react";
import {
SurfaceLayout,
SummaryRow,
FilterBar,
cn,
type FilterGroupDef,
type StatCardType,
} from "@reopt-ai/opt-ui";
// ── Types ──
/** Type definition for `SnapshotStatus`. */
export type SnapshotStatus = "match" | "diff" | "new" | "missing";
/** View modes supported by `Diff`. */
export type DiffViewMode = "side-by-side" | "overlay" | "slider" | "diff-only";
/** Item shape for `Snapshot`. */
export interface SnapshotItem {
id: string;
name: string;
status: SnapshotStatus;
group?: string;
baselineUrl?: string;
actualUrl?: string;
diffUrl?: string;
diffPercentage?: number;
[key: string]: unknown;
}
/** Labels for `VisualDiffManager`. */
export interface VisualDiffManagerLabels {
title?: string;
searchPlaceholder?: string;
selectAllLabel?: string;
approveLabel?: string;
rejectLabel?: string;
updateBaselineLabel?: string;
sideBySideLabel?: string;
overlayLabel?: string;
sliderLabel?: string;
diffOnlyLabel?: string;
baselineLabel?: string;
actualLabel?: string;
noBaselineLabel?: string;
noActualLabel?: string;
emptyTitle?: string;
emptyDescription?: string;
noSelectionTitle?: string;
noSelectionDescription?: string;
matchLabel?: string;
diffLabel?: string;
newLabel?: string;
missingLabel?: string;
totalLabel?: string;
selectedCountLabel?: (count: number) => string;
settingsLabel?: string;
refreshLabel?: string;
}
const defaultLabels: Required<VisualDiffManagerLabels> = {
title: "Visual Regression",
searchPlaceholder: "Search snapshots…",
selectAllLabel: "Select all",
approveLabel: "Approve",
rejectLabel: "Reject",
updateBaselineLabel: "Update Baseline",
sideBySideLabel: "Side by Side",
overlayLabel: "Overlay",
sliderLabel: "Slider",
diffOnlyLabel: "Diff Only",
baselineLabel: "Baseline",
actualLabel: "Actual",
noBaselineLabel: "No baseline",
noActualLabel: "No actual",
emptyTitle: "No snapshots found",
emptyDescription: "Run visual regression tests to see results here.",
noSelectionTitle: "No snapshot selected",
noSelectionDescription: "Select a snapshot from the list to view the diff.",
matchLabel: "Match",
diffLabel: "Diff",
newLabel: "New",
missingLabel: "Missing",
totalLabel: "Total",
selectedCountLabel: (count: number) => `${count} selected`,
settingsLabel: "Settings",
refreshLabel: "Refresh",
};
// ── Props ──
/** Props for `VisualDiffManager`. */
export interface VisualDiffManagerProps {
/** Snapshot items */
snapshots: SnapshotItem[];
/** Currently selected snapshot id */
selectedSnapshotId?: string;
/** Called when a snapshot is selected */
onSnapshotSelect?: (snapshotId: string) => void;
/** Selected snapshot IDs for batch actions */
selectedIds?: string[];
/** Called when selection changes */
onSelectionChange?: (ids: string[]) => void;
/** Active diff view mode */
viewMode?: DiffViewMode;
/** Called when view mode changes */
onViewModeChange?: (mode: DiffViewMode) => void;
/** Approve selected snapshots */
onApprove?: (ids: string[]) => void;
/** Reject selected snapshots */
onReject?: (ids: string[]) => void;
/** Update baseline for selected snapshots */
onUpdateBaseline?: (ids: string[]) => void;
/** Search query */
searchQuery?: string;
/** Called when search changes */
onSearchChange?: (query: string) => void;
/** Sidebar filters */
filters?: FilterGroupDef[];
/** Called when filter changes */
onFilterChange?: (
filterId: string,
value: string | string[] | boolean,
) => void;
/** Custom diff viewer renderer */
renderDiffViewer?: (
snapshot: SnapshotItem,
viewMode: DiffViewMode,
) => React.ReactNode;
/** Sidebar list width */
sidebarWidth?: "sm" | "md" | "lg";
/** Settings button handler */
onSettings?: () => void;
/** Refresh handler */
onRefresh?: () => void;
loading?: boolean;
header?: React.ReactNode;
actions?: React.ReactNode;
labels?: VisualDiffManagerLabels;
className?: string;
}
// ── Defaults ──
const defaultFilters: FilterGroupDef[] = [
{
id: "status",
label: "Status",
type: "multi-select",
options: [
{ id: "match", value: "match", label: "Match" },
{ id: "diff", value: "diff", label: "Diff" },
{ id: "new", value: "new", label: "New" },
{ id: "missing", value: "missing", label: "Missing" },
],
},
];
const sidebarWidthClass = {
sm: "w-64",
md: "w-80",
lg: "w-96",
};
const statusColors: Record<SnapshotStatus, string> = {
match: "bg-success",
diff: "bg-warning",
new: "bg-accent",
missing: "bg-danger",
};
const statusTextColors: Record<SnapshotStatus, string> = {
match: "text-success",
diff: "text-warning",
new: "text-accent-fg",
missing: "text-danger",
};
// ── Component ──
/** Renders the `VisualDiffManager` component. */
export function VisualDiffManager({
snapshots,
selectedSnapshotId,
onSnapshotSelect,
selectedIds: controlledSelectedIds,
onSelectionChange,
viewMode: controlledViewMode,
onViewModeChange,
onApprove,
onReject,
onUpdateBaseline,
searchQuery: controlledSearchQuery,
onSearchChange,
filters = defaultFilters,
onFilterChange,
renderDiffViewer,
sidebarWidth = "md",
onSettings,
onRefresh,
loading = false,
header,
actions,
labels: customLabels,
className,
}: VisualDiffManagerProps) {
const labels = { ...defaultLabels, ...customLabels };
// Internal state
const [internalSelectedId, setInternalSelectedId] = React.useState<
string | undefined
>(undefined);
const [internalSelectedIds, setInternalSelectedIds] = React.useState<
string[]
>([]);
const [internalViewMode, setInternalViewMode] =
React.useState<DiffViewMode>("side-by-side");
const [internalSearch, setInternalSearch] = React.useState("");
const selectedId = selectedSnapshotId ?? internalSelectedId;
const selectedBatchIds = controlledSelectedIds ?? internalSelectedIds;
const viewMode = controlledViewMode ?? internalViewMode;
const searchQuery = controlledSearchQuery ?? internalSearch;
const selectedSnapshot = snapshots.find((s) => s.id === selectedId);
const isEmpty = snapshots.length === 0;
// Summary stats
const stats: StatCardType[] = React.useMemo(() => {
const counts = { match: 0, diff: 0, new: 0, missing: 0 };
for (const s of snapshots) {
counts[s.status]++;
}
return [
{
id: "total",
title: labels.totalLabel,
value: String(snapshots.length),
change: "",
trend: "neutral" as const,
},
{
id: "match",
title: labels.matchLabel,
value: String(counts.match),
change: "",
trend: "up" as const,
},
{
id: "diff",
title: labels.diffLabel,
value: String(counts.diff),
change: "",
trend: counts.diff > 0 ? ("down" as const) : ("neutral" as const),
},
{
id: "new",
title: labels.newLabel,
value: String(counts.new),
change: "",
trend: "neutral" as const,
},
];
}, [snapshots, labels]);
const handleSelect = React.useCallback(
(id: string) => {
setInternalSelectedId(id);
onSnapshotSelect?.(id);
},
[onSnapshotSelect],
);
const handleCheckbox = React.useCallback(
(id: string, checked: boolean) => {
const next = checked
? [...selectedBatchIds, id]
: selectedBatchIds.filter((x) => x !== id);
setInternalSelectedIds(next);
onSelectionChange?.(next);
},
[selectedBatchIds, onSelectionChange],
);
const handleSelectAll = React.useCallback(() => {
const allIds = snapshots.map((s) => s.id);
const next = selectedBatchIds.length === snapshots.length ? [] : allIds;
setInternalSelectedIds(next);
onSelectionChange?.(next);
}, [snapshots, selectedBatchIds, onSelectionChange]);
const handleViewModeChange = React.useCallback(
(mode: DiffViewMode) => {
setInternalViewMode(mode);
onViewModeChange?.(mode);
},
[onViewModeChange],
);
const handleSearchChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInternalSearch(e.target.value);
onSearchChange?.(e.target.value);
},
[onSearchChange],
);
// Filter by search
const filteredSnapshots = React.useMemo(() => {
if (!searchQuery) return snapshots;
const q = searchQuery.toLowerCase();
return snapshots.filter((s) => s.name.toLowerCase().includes(q));
}, [snapshots, searchQuery]);
const viewModes = React.useMemo(
() => [
{ id: "side-by-side" as const, label: labels.sideBySideLabel },
{ id: "overlay" as const, label: labels.overlayLabel },
{ id: "slider" as const, label: labels.sliderLabel },
{ id: "diff-only" as const, label: labels.diffOnlyLabel },
],
[
labels.sideBySideLabel,
labels.overlayLabel,
labels.sliderLabel,
labels.diffOnlyLabel,
],
);
const hasBatchSelection = selectedBatchIds.length > 0;
return (
<SurfaceLayout loading={loading} className={cn("gap-0", className)}>
{/* Header */}
<div className="border-border-subtle flex items-center justify-between border-b px-4 py-3">
{header ? (
<div>{header}</div>
) : (
<h2 className="text-text-primary text-lg font-semibold">
{labels.title}
</h2>
)}
<div className="gap-element flex items-center">
{onSettings && (
<button
type="button"
onClick={onSettings}
className="text-text-tertiary hover:text-text-primary rounded p-1.5 text-sm transition-colors"
aria-label={labels.settingsLabel}
>
⚙
</button>
)}
{onRefresh && (
<button
type="button"
onClick={onRefresh}
className="text-text-tertiary hover:text-text-primary rounded p-1.5 text-sm transition-colors"
aria-label={labels.refreshLabel}
>
↻
</button>
)}
{actions}
</div>
</div>
{/* Summary stats */}
<div className="border-border-subtle border-b px-4 py-3">
<SummaryRow stats={stats} columns={4} />
</div>
{isEmpty ? (
<div
role="status"
className="flex flex-col items-center justify-center py-16 text-center"
>
<h3 className="text-text-primary text-lg font-medium">
{labels.emptyTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.emptyDescription}
</p>
</div>
) : (
<>
{/* Batch action bar */}
{hasBatchSelection && (
<div className="border-border-subtle bg-bg-subtle gap-element flex items-center border-b px-4 py-2">
<span className="text-text-secondary text-sm">
{labels.selectedCountLabel(selectedBatchIds.length)}
</span>
<div className="gap-element ml-auto flex items-center">
{onApprove && (
<button
type="button"
onClick={() => onApprove(selectedBatchIds)}
className="bg-success/10 text-success hover:bg-success/20 rounded px-3 py-1 text-sm font-medium transition-colors"
>
{labels.approveLabel}
</button>
)}
{onReject && (
<button
type="button"
onClick={() => onReject(selectedBatchIds)}
className="bg-danger/10 text-danger hover:bg-danger/20 rounded px-3 py-1 text-sm font-medium transition-colors"
>
{labels.rejectLabel}
</button>
)}
{onUpdateBaseline && (
<button
type="button"
onClick={() => onUpdateBaseline(selectedBatchIds)}
className="bg-accent-subtle text-accent-fg hover:bg-accent-subtle/80 rounded px-3 py-1 text-sm font-medium transition-colors"
>
{labels.updateBaselineLabel}
</button>
)}
</div>
</div>
)}
{/* Main content: sidebar + viewer */}
<div className="flex flex-1">
{/* Snapshot list sidebar */}
<aside
className={cn(
"border-border-subtle shrink-0 overflow-y-auto border-r",
sidebarWidthClass[sidebarWidth],
)}
>
{/* Search */}
<div className="border-border-subtle border-b p-3">
<input
type="search"
value={searchQuery}
onChange={handleSearchChange}
placeholder={labels.searchPlaceholder}
className="bg-bg-subtle text-text-primary placeholder:text-text-tertiary focus-visible:ring-accent w-full rounded-md px-3 py-1.5 text-sm outline-none focus-visible:ring-1"
aria-label={labels.searchPlaceholder}
/>
</div>
{/* Filters */}
<div className="border-border-subtle border-b p-3">
<FilterBar filters={filters} onFilterChange={onFilterChange} />
</div>
{/* Select all */}
<div className="border-border-subtle gap-element flex items-center border-b px-3 py-2">
<input
type="checkbox"
checked={
selectedBatchIds.length === snapshots.length &&
snapshots.length > 0
}
onChange={handleSelectAll}
className="accent-accent h-3.5 w-3.5"
aria-label={labels.selectAllLabel}
/>
<span className="text-text-tertiary text-xs">
{labels.selectAllLabel}
</span>
</div>
{/* Snapshot items */}
<nav aria-label={labels.title}>
{filteredSnapshots.map((snapshot) => (
<div
key={snapshot.id}
className={cn(
"border-border-subtle gap-element flex items-center border-b px-3 py-2 transition-colors",
selectedId === snapshot.id
? "bg-accent-subtle"
: "hover:bg-bg-subtle",
)}
>
<input
type="checkbox"
checked={selectedBatchIds.includes(snapshot.id)}
onChange={(e) =>
handleCheckbox(snapshot.id, e.target.checked)
}
className="accent-accent h-3.5 w-3.5 shrink-0"
aria-label={`Select ${snapshot.name}`}
/>
<button
type="button"
onClick={() => handleSelect(snapshot.id)}
className="gap-element flex min-w-0 flex-1 items-center text-left"
aria-current={
selectedId === snapshot.id ? "true" : undefined
}
>
<span
className={cn(
"h-2 w-2 shrink-0 rounded-full",
statusColors[snapshot.status],
)}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate text-sm">
{snapshot.name}
</span>
<span
className={cn(
"shrink-0 text-xs font-medium",
statusTextColors[snapshot.status],
)}
>
{snapshot.diffPercentage != null
? `${snapshot.diffPercentage.toFixed(1)}%`
: snapshot.status}
</span>
</button>
</div>
))}
</nav>
</aside>
{/* Diff viewer */}
<div className="min-w-0 flex-1">
{selectedSnapshot ? (
<div className="flex h-full flex-col">
{/* View mode toggle */}
<div className="border-border-subtle gap-element flex items-center border-b px-4 py-2">
<div
className="border-border inline-flex rounded-md border"
role="group"
aria-label="View mode"
>
{viewModes.map((mode, i) => (
<button
key={mode.id}
type="button"
onClick={() => handleViewModeChange(mode.id)}
aria-pressed={viewMode === mode.id}
className={cn(
"px-3 py-1.5 text-xs font-medium transition-colors",
i === 0 && "rounded-l-md",
i === viewModes.length - 1 && "rounded-r-md",
viewMode === mode.id
? "bg-accent-subtle text-text-primary"
: "text-text-secondary hover:bg-bg-subtle",
)}
>
{mode.label}
</button>
))}
</div>
</div>
{/* Diff content */}
<div className="flex-1 overflow-auto p-4">
{renderDiffViewer ? (
renderDiffViewer(selectedSnapshot, viewMode)
) : (
<div className="gap-group flex">
{/* Default side-by-side view */}
<div className="flex-1">
<h4 className="text-text-tertiary mb-2 text-xs font-medium">
{labels.baselineLabel}
</h4>
<div className="bg-bg-subtle border-border-subtle flex aspect-video items-center justify-center rounded-lg border">
{selectedSnapshot.baselineUrl ? (
<img
src={selectedSnapshot.baselineUrl}
alt={`${selectedSnapshot.name} baseline`}
className="max-h-full max-w-full object-contain"
/>
) : (
<span className="text-text-tertiary text-sm">
{labels.noBaselineLabel}
</span>
)}
</div>
</div>
<div className="flex-1">
<h4 className="text-text-tertiary mb-2 text-xs font-medium">
{labels.actualLabel}
</h4>
<div className="bg-bg-subtle border-border-subtle flex aspect-video items-center justify-center rounded-lg border">
{selectedSnapshot.actualUrl ? (
<img
src={selectedSnapshot.actualUrl}
alt={`${selectedSnapshot.name} actual`}
className="max-h-full max-w-full object-contain"
/>
) : (
<span className="text-text-tertiary text-sm">
{labels.noActualLabel}
</span>
)}
</div>
</div>
</div>
)}
</div>
{/* Action buttons */}
<div className="border-border-subtle gap-element flex items-center border-t px-4 py-3">
{onApprove && (
<button
type="button"
onClick={() => onApprove([selectedSnapshot.id])}
className="bg-success/10 text-success hover:bg-success/20 rounded px-3 py-1.5 text-sm font-medium transition-colors"
>
{labels.approveLabel}
</button>
)}
{onReject && (
<button
type="button"
onClick={() => onReject([selectedSnapshot.id])}
className="bg-danger/10 text-danger hover:bg-danger/20 rounded px-3 py-1.5 text-sm font-medium transition-colors"
>
{labels.rejectLabel}
</button>
)}
{onUpdateBaseline && (
<button
type="button"
onClick={() => onUpdateBaseline([selectedSnapshot.id])}
className="bg-accent-subtle text-accent-fg hover:bg-accent-subtle/80 rounded px-3 py-1.5 text-sm font-medium transition-colors"
>
{labels.updateBaselineLabel}
</button>
)}
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center py-16 text-center">
<h3 className="text-text-primary text-lg font-medium">
{labels.noSelectionTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.noSelectionDescription}
</p>
</div>
)}
</div>
</div>
</>
)}
</SurfaceLayout>
);
}
VisualDiffManager.displayName = "VisualDiffManager";