Skip to content

Commit 6f561f8

Browse files
authored
✨ Add region sync for local TDD auto-approval (#191)
## Summary Enables local TDD mode to use user-defined regions from the cloud dashboard for auto-approving visual diffs. This brings feature parity with the cloud product's region-based filtering. **Phase 1 - CLI & API:** - Add `POST /api/sdk/screenshots/regions` endpoint in cloud API - Add `vizzly regions:sync` command with `--include-candidates` flag - Auto-sync regions during baseline download when API token is configured - Store region data in `.vizzly/regions.json` **Phase 2 - TDD Server & UI:** - Add `POST /api/regions/sync` endpoint for UI-triggered sync - Add 2D bounding box intersection calculation for region coverage - Auto-approve comparisons when 80%+ of diffs fall within confirmed regions - Add "Sync Regions" button in Settings view - Add region overlay visualization in fullscreen viewer (press `G` to toggle) - Extract `HotSpotOverlay` component to Observatory for sharing with cloud ## How it works 1. Users confirm dynamic regions (timestamps, avatars, etc.) in the cloud dashboard 2. CLI syncs these regions via `vizzly regions:sync` or Settings UI 3. During local comparisons, if 80%+ of diff clusters intersect confirmed regions → auto-pass as "region-filtered" 4. Same behavior as cloud product, same 80% threshold ## Test plan - [ ] Run `vizzly regions:sync` with valid API token - regions saved to `.vizzly/regions.json` - [ ] Start TDD server, open Settings, click "Sync Regions" - success toast - [ ] Create a diff that falls within a confirmed region - auto-passes as "region-filtered" - [ ] Open fullscreen viewer, press `G` - green region boxes appear on screenshot - [ ] Run `npm test` - all tests pass
1 parent 60f7ade commit 6f561f8

17 files changed

Lines changed: 833 additions & 70 deletions

File tree

.github/workflows/sdk-e2e.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ jobs:
269269
id: playwright-cache
270270
with:
271271
path: ~/.cache/ms-playwright
272-
key: playwright-${{ steps.playwright-version.outputs.version }}-chromium
272+
key: playwright-${{ steps.playwright-version.outputs.version }}-ember-chromium-v2
273273

274274
- name: Install Playwright browsers
275275
if: steps.playwright-cache.outputs.cache-hit != 'true'

package-lock.json

Lines changed: 19 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@
119119
"@tanstack/react-query": "^5.90.11",
120120
"@types/node": "^25.0.2",
121121
"@vitejs/plugin-react": "^5.0.3",
122-
"@vizzly-testing/observatory": "^0.3.0",
122+
"@vizzly-testing/observatory": "^0.3.3",
123123
"autoprefixer": "^10.4.21",
124124
"babel-plugin-transform-remove-console": "^6.9.4",
125125
"postcss": "^8.5.6",

src/reporter/src/components/comparison/fullscreen-viewer.jsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
DocumentMagnifyingGlassIcon,
2020
InformationCircleIcon,
2121
ListBulletIcon,
22+
MapPinIcon,
2223
} from '@heroicons/react/24/outline';
2324
import {
2425
ApprovalButtonGroup,
@@ -101,6 +102,7 @@ function FullscreenViewerInner({
101102
let [showInspector, setShowInspector] = useState(false);
102103
let [queueFilter, setQueueFilter] = useState('needs-review');
103104
let [_showBaseline, setShowBaseline] = useState(true);
105+
let [showRegions, setShowRegions] = useState(false);
104106

105107
let { zoom, setZoom } = useZoom('fit');
106108
let { isActive: isReviewMode } = useReviewMode();
@@ -111,7 +113,7 @@ function FullscreenViewerInner({
111113
// Toggle inspector (closes other panels)
112114
let toggleInspector = useCallback(() => {
113115
setShowInspector(prev => !prev);
114-
}, []);
116+
}, [setShowInspector]);
115117

116118
// Transform comparisons for queue display
117119
// Map CLI status to Observatory result format
@@ -291,7 +293,7 @@ function FullscreenViewerInner({
291293
setViewMode(current =>
292294
current === VIEW_MODES.OVERLAY ? VIEW_MODES.TOGGLE : VIEW_MODES.OVERLAY
293295
);
294-
}, []);
296+
}, [setViewMode]);
295297

296298
// Toggle handler for 'd' - toggles diff overlay or baseline/current
297299
let handleDiffToggle = useCallback(() => {
@@ -300,7 +302,7 @@ function FullscreenViewerInner({
300302
} else {
301303
setShowDiffOverlay(prev => !prev);
302304
}
303-
}, [viewMode]);
305+
}, [viewMode, setShowBaseline, setShowDiffOverlay]);
304306

305307
// Review mode shortcuts
306308
let reviewModeShortcuts = useMemo(
@@ -320,6 +322,7 @@ function FullscreenViewerInner({
320322
onReject,
321323
cycleViewMode,
322324
handleDiffToggle,
325+
setViewMode,
323326
]
324327
);
325328

@@ -352,12 +355,25 @@ function FullscreenViewerInner({
352355
toggleInspector();
353356
}
354357
break;
358+
case 'g':
359+
if (!e.metaKey && !e.ctrlKey) {
360+
e.preventDefault();
361+
setShowRegions(prev => !prev);
362+
}
363+
break;
355364
}
356365
};
357366

358367
window.addEventListener('keydown', handleKeyDown);
359368
return () => window.removeEventListener('keydown', handleKeyDown);
360-
}, [canNavigate, handlePrevious, handleNext, onClose, toggleInspector]);
369+
}, [
370+
canNavigate,
371+
handlePrevious,
372+
handleNext,
373+
onClose,
374+
toggleInspector,
375+
setShowRegions,
376+
]);
361377

362378
// Scroll active queue item into view
363379
useEffect(() => {
@@ -381,7 +397,7 @@ function FullscreenViewerInner({
381397
behavior: 'smooth',
382398
});
383399
}
384-
}, []);
400+
}, [activeQueueItemRef.current]);
385401

386402
if (!comparison) {
387403
return (
@@ -523,6 +539,20 @@ function FullscreenViewerInner({
523539
<ListBulletIcon className="w-5 h-5 pointer-events-none" />
524540
</button>
525541

542+
{/* Regions toggle - only show if comparison has regions */}
543+
{comparison?.confirmedRegions?.length > 0 && (
544+
<button
545+
type="button"
546+
onClick={() => setShowRegions(!showRegions)}
547+
className={`p-2 rounded-md transition-colors ${showRegions ? 'bg-emerald-500/15 text-emerald-400' : 'text-slate-400 hover:text-white hover:bg-slate-800/60'}`}
548+
title="Show Regions (G)"
549+
aria-label="Toggle regions"
550+
data-testid="toggle-regions-btn"
551+
>
552+
<MapPinIcon className="w-5 h-5 pointer-events-none" />
553+
</button>
554+
)}
555+
526556
<button
527557
type="button"
528558
onClick={toggleInspector}
@@ -661,6 +691,7 @@ function FullscreenViewerInner({
661691
onOnionSkinChange={setOnionSkinPosition}
662692
zoom={zoom}
663693
disableLoadingOverlay={true}
694+
showRegions={showRegions}
664695
className="w-full h-full"
665696
/>
666697
</main>
@@ -793,6 +824,19 @@ function FullscreenViewerInner({
793824
<InformationCircleIcon className="w-5 h-5 pointer-events-none" />
794825
</button>
795826

827+
{/* Regions toggle - mobile */}
828+
{comparison?.confirmedRegions?.length > 0 && (
829+
<button
830+
type="button"
831+
onClick={() => setShowRegions(!showRegions)}
832+
className={`flex items-center justify-center p-2.5 rounded-lg transition-colors ${showRegions ? 'bg-emerald-500/15 text-emerald-400' : 'text-slate-400 hover:text-white hover:bg-slate-800/60 active:bg-slate-700/60'}`}
833+
aria-label="Toggle regions"
834+
data-testid="mobile-toggle-regions-btn"
835+
>
836+
<MapPinIcon className="w-5 h-5 pointer-events-none" />
837+
</button>
838+
)}
839+
796840
{canDelete && onDelete && (
797841
<button
798842
type="button"

src/reporter/src/components/comparison/screenshot-display.jsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
22
import {
3+
HotSpotOverlay,
34
OnionSkinMode,
45
OverlayMode,
56
ToggleView,
@@ -23,6 +24,8 @@ export function ScreenshotDisplay({
2324
// Zoom support
2425
zoom = 'fit',
2526
className = '',
27+
// Region overlay
28+
showRegions = false,
2629
}) {
2730
const [imageErrors, setImageErrors] = useState(new Set());
2831
const [imageLoadStates, setImageLoadStates] = useState(new Map());
@@ -305,6 +308,19 @@ export function ScreenshotDisplay({
305308
onDiffToggle={onDiffToggle}
306309
/>
307310
)}
311+
312+
{/* Region overlay - shows confirmed regions (green boxes) */}
313+
{showRegions && comparison?.confirmedRegions?.length > 0 && (
314+
<HotSpotOverlay
315+
confirmed={comparison.confirmedRegions}
316+
candidates={[]}
317+
imageWidth={naturalImageSize.width}
318+
imageHeight={naturalImageSize.height}
319+
showConfirmed={true}
320+
showCandidates={false}
321+
disabled={true}
322+
/>
323+
)}
308324
</div>
309325
</div>
310326

src/reporter/src/components/dashboard/dashboard-filters.jsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,15 +55,18 @@ function IconDropdown({
5555
onChange(val);
5656
setIsOpen(false);
5757
},
58-
[onChange]
58+
[onChange, setIsOpen]
5959
);
6060

6161
// Close on outside click
62-
let handleBlur = useCallback(e => {
63-
if (!dropdownRef.current?.contains(e.relatedTarget)) {
64-
setIsOpen(false);
65-
}
66-
}, []);
62+
let handleBlur = useCallback(
63+
e => {
64+
if (!dropdownRef.current?.contains(e.relatedTarget)) {
65+
setIsOpen(false);
66+
}
67+
},
68+
[dropdownRef.current?.contains, setIsOpen]
69+
);
6770

6871
return (
6972
// biome-ignore lint/a11y/noStaticElementInteractions: dropdown container needs blur handler

src/reporter/src/components/views/comparisons-view.jsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ export default function ComparisonsView() {
162162
},
163163
});
164164
},
165-
[acceptMutation, addToast]
165+
[acceptMutation, addToast, setLoadingStates]
166166
);
167167

168168
// Reject a single comparison
@@ -179,7 +179,7 @@ export default function ComparisonsView() {
179179
},
180180
});
181181
},
182-
[rejectMutation, addToast]
182+
[rejectMutation, addToast, setLoadingStates]
183183
);
184184

185185
let handleAcceptAll = useCallback(async () => {

0 commit comments

Comments
 (0)