reopt designreopt design
DocsExploreToolsPricingBuilder
Start
Overview
Start
Next.js 설치
Private install
Core Concepts
아키텍처
Composition Patterns
Accessibility
Keyboard Patterns
Styling
Theme System
Advanced Patterns
Build & Operate
Skills
AI Integration
CLI (opt surface add)
Dependency Graph
Tools
Canvas Catalog
Theme Builder
Form Builder
Templates
Templates
Releases
Release Notes
Oopt-ui
reopt designreopt design

A design system for the AI era

  • Docs
  • Pricing
  • Releases
  • GitHub
  • Terms of Service
  • Privacy Policy

© 2026 reopt-ai. All rights reserved.

VisualDiffManager

surface

스크린샷 비주얼 리그레션 관리 + 승인 워크플로우. 3-column 레이아웃 — 스냅샷 리스트 + 이미지 diff 뷰어.

컴포넌트 의존 관계

깊이
▼ USES (3)VisualDiffManagersummary-rowfilter-barloading-overlay
100%

기본 사용

Visual Regression

Total5→
Match1↑
Diff2↓
New1→
Select all

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[]—스냅샷 목록
selectedSnapshotIdstring—선택된 스냅샷 ID
onSnapshotSelect(id: string) => void—스냅샷 선택 핸들러
selectedIdsstring[]—일괄 선택된 스냅샷 ID
viewModeDiffViewMode—비교 뷰 모드 (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 뷰어 렌더러
loadingboolean—로딩 상태
headerReactNode—커스텀 헤더 슬롯
actionsReactNode—액션 버튼 슬롯
labelsVisualDiffManagerLabels—커스텀 레이블
classNamestring—최외곽 CSS 클래스

Surface 설치

CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.

bash
npx @reopt-ai/opt-cli surface add visual-diff-manager

Consumer 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.tsx→visual-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";