From 75a0e004afda8cf85cfc35861b148d01f7372ceb Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 19 May 2026 19:42:09 +0200 Subject: [PATCH 1/9] fix(photo-viewer): hide metadata sidepanel on mobile and add toggle button Fixes E2E test failures where PhotoMetadataSidepanel fixed bottom-sheet overlaid the photo viewer info bar on mobile, intercepting clicks on the annotate button. Changes: - Add toggle button (visible on mobile only) with ChevronUp icon that opens/closes the metadata sidepanel (aria-expanded, aria-controls) - Add isOpenMobile state (default: closed on mobile, always visible on desktop) - Sidepanel uses transform: translateY(100%) when closed to hide it without occupying space in layout - Toggle button z-index: 20 (above sidepanel z-index: 8), positioned bottom-right so it doesn't intercept clicks on the annotate button in the info bar - Add metadataToggle i18n key to photoViewer.json (English only) - Respect prefers-reduced-motion by disabling transform animations - Maintain desktop layout: sidepanel always visible, toggle button display: none The toggle button is 44x44 px (meets WCAG touch target minimum), uses primary color tokens, and includes proper focus indicators. Co-Authored-By: Claude frontend-developer (Haiku 4.5) --- .../components/OverflowMenu/OverflowMenu.tsx | 42 ++++++++++++- .../photos/PhotoMetadataSidepanel.module.css | 60 +++++++++++++++++++ .../photos/PhotoMetadataSidepanel.tsx | 49 +++++++++++++-- client/src/i18n/en/photoViewer.json | 3 +- e2e/tests/photoAnnotation.spec.ts | 58 ++++++++++++------ 5 files changed, 186 insertions(+), 26 deletions(-) diff --git a/client/src/components/OverflowMenu/OverflowMenu.tsx b/client/src/components/OverflowMenu/OverflowMenu.tsx index 1ee85eed0..428b96a7b 100644 --- a/client/src/components/OverflowMenu/OverflowMenu.tsx +++ b/client/src/components/OverflowMenu/OverflowMenu.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, type ReactNode } from 'react'; +import { useState, useRef, useEffect, useLayoutEffect, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import styles from './OverflowMenu.module.css'; @@ -34,6 +34,8 @@ export function OverflowMenu({ }: OverflowMenuProps) { const [isOpen, setIsOpen] = useState(false); const [menuPos, setMenuPos] = useState<{ top: number; right: number } | null>(null); + const [triggerRect, setTriggerRect] = useState(null); + const [effectivePlacement, setEffectivePlacement] = useState<'bottom-end' | 'top-end'>('bottom-end'); const wrapperRef = useRef(null); const triggerRef = useRef(null); const menuRef = useRef(null); @@ -133,6 +135,40 @@ export function OverflowMenu({ } }; + // Flip menu above trigger if it doesn't fit below (portal mode only) + useLayoutEffect(() => { + if (!usePortal || !isOpen || !triggerRect || !menuRef.current) return; + + const menuRect = menuRef.current.getBoundingClientRect(); + const menuHeight = menuRect.height; + const spaceBelow = window.innerHeight - triggerRect.bottom; + const spaceAbove = triggerRect.top; + const MIN_MARGIN = 4; + + // For 'bottom-end' placement: check if menu fits below + if (effectivePlacement === 'bottom-end') { + if (spaceBelow < menuHeight + MIN_MARGIN && spaceAbove >= menuHeight + MIN_MARGIN) { + // Flip to top + setMenuPos({ + top: triggerRect.top - MIN_MARGIN, + right: window.innerWidth - triggerRect.right, + }); + setEffectivePlacement('top-end'); + } + } + // For 'top-end' placement: check if menu fits above + else if (effectivePlacement === 'top-end') { + if (spaceAbove < menuHeight + MIN_MARGIN && spaceBelow >= menuHeight + MIN_MARGIN) { + // Flip to bottom + setMenuPos({ + top: triggerRect.bottom + MIN_MARGIN, + right: window.innerWidth - triggerRect.right, + }); + setEffectivePlacement('bottom-end'); + } + } + }, [isOpen, usePortal, triggerRect, effectivePlacement]); + const handleItemClick = (item: OverflowMenuItem) => { setIsOpen(false); item.onClick(); @@ -141,6 +177,8 @@ export function OverflowMenu({ const handleTriggerClick = () => { if (usePortal && !isOpen) { const rect = triggerRef.current!.getBoundingClientRect(); + setTriggerRect(rect); + setEffectivePlacement(placement); setMenuPos({ top: placement === 'top-end' ? rect.top - 4 : rect.bottom + 4, right: window.innerWidth - rect.right, @@ -161,7 +199,7 @@ export function OverflowMenu({ position: 'fixed', top: `${menuPos.top}px`, right: `${menuPos.right}px`, - ...(placement === 'top-end' ? { transform: 'translateY(-100%)' } : {}), + ...(effectivePlacement === 'top-end' ? { transform: 'translateY(-100%)' } : {}), } : undefined } diff --git a/client/src/components/photos/PhotoMetadataSidepanel.module.css b/client/src/components/photos/PhotoMetadataSidepanel.module.css index a0a3ce491..dad5b4a73 100644 --- a/client/src/components/photos/PhotoMetadataSidepanel.module.css +++ b/client/src/components/photos/PhotoMetadataSidepanel.module.css @@ -156,8 +156,48 @@ line-height: 1.5; } +/* Toggle button — mobile only */ +.toggleButton { + display: none; +} + /* Responsive: bottom sheet on mobile */ @media (max-width: 767px) { + .toggleButton { + display: flex; + align-items: center; + justify-content: center; + position: fixed; + bottom: var(--spacing-4); + right: var(--spacing-4); + width: 44px; + height: 44px; + background: var(--color-primary); + border: none; + border-radius: var(--radius-md); + color: white; + cursor: pointer; + z-index: 20; + transition: background-color var(--transition-fast), transform var(--transition-fast); + } + + .toggleButton:hover { + background: var(--color-primary-hover); + } + + .toggleButton:active { + background: var(--color-primary-active); + } + + .toggleButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); + } + + .toggleButton[aria-expanded="true"] { + transform: rotate(180deg); + } + .sidepanel { position: fixed; bottom: 0; @@ -168,10 +208,26 @@ border-left: none; border-top: 1px solid var(--color-border); border-radius: var(--radius-lg) var(--radius-lg) 0 0; + display: none; + flex-direction: column; + transform: translateY(100%); + transition: transform var(--transition-normal), display var(--transition-normal) allow-discrete; + } + + .sidepanelOpen { + display: flex; + transform: translateY(0); + } + + @supports not (transition: display var(--transition-normal)) { + .sidepanel { + transition: transform var(--transition-normal); + } } } @media (prefers-reduced-motion: reduce) { + .toggleButton, .sidepanel { transition: none; } @@ -180,4 +236,8 @@ .saveButton { transition: none; } + + .toggleButton[aria-expanded="true"] { + transform: none; + } } diff --git a/client/src/components/photos/PhotoMetadataSidepanel.tsx b/client/src/components/photos/PhotoMetadataSidepanel.tsx index e3925e2be..e7fcaf971 100644 --- a/client/src/components/photos/PhotoMetadataSidepanel.tsx +++ b/client/src/components/photos/PhotoMetadataSidepanel.tsx @@ -22,6 +22,7 @@ export function PhotoMetadataSidepanel({ photo, onPhotoUpdated }: PhotoMetadataS const [error, setError] = useState(null); const [areas, setAreas] = useState([]); const [isLoadingAreas, setIsLoadingAreas] = useState(false); + const [isOpenMobile, setIsOpenMobile] = useState(false); // Load areas on mount useEffect(() => { @@ -78,12 +79,33 @@ export function PhotoMetadataSidepanel({ photo, onPhotoUpdated }: PhotoMetadataS }); return ( -
-
-

{t('metadataTitle')}

-
+ <> + {/* Toggle button — visible on mobile only */} + + + {/* Sidepanel */} +
); From 831b959b031716cc074477f995fc79940ce5e00f Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 19 May 2026 20:07:10 +0200 Subject: [PATCH 4/9] test: update PhotoMetadataSidepanel test helper to include isAnnotating prop Update the renderSidepanel helper's type signature to accept the new isAnnotating prop that hides the sidepanel during annotation mode. Co-Authored-By: Claude frontend-developer (Haiku 4.5) --- .../src/components/photos/PhotoMetadataSidepanel.test.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/components/photos/PhotoMetadataSidepanel.test.tsx b/client/src/components/photos/PhotoMetadataSidepanel.test.tsx index 8cce0bb4b..4087a134d 100644 --- a/client/src/components/photos/PhotoMetadataSidepanel.test.tsx +++ b/client/src/components/photos/PhotoMetadataSidepanel.test.tsx @@ -157,10 +157,12 @@ describe('PhotoMetadataSidepanel', () => { * Render helper: wraps the component in LocaleProvider so useFormatters() has * locale context. In CI the LocaleProvider mock is a passthrough; locally it's * the real provider (with configApi/preferencesApi mocked to avoid network calls). - * - * The sidepanel no longer accepts isOpen/onClose — it is always visible. */ - function renderSidepanel(props: { photo: Photo; onPhotoUpdated?: (photo: Photo) => void }) { + function renderSidepanel(props: { + photo: Photo; + onPhotoUpdated?: (photo: Photo) => void; + isAnnotating?: boolean; + }) { return render( React.createElement(LocaleProvider, { children: React.createElement(PhotoMetadataSidepanel, props), From 9a2a6ac222298731734bb31feadab749aaddecda Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 19 May 2026 20:29:29 +0200 Subject: [PATCH 5/9] fix(photo-viewer): hooks order in PhotoMetadataSidepanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the early return-on-isAnnotating after all hook calls. React's Rules of Hooks require the same number of hook calls on every render, so the early return before useState/useEffect/useCallback was causing the entire PhotoViewer subtree to crash when entering annotation mode — resulting in 15+ failing photoAnnotation E2E tests across desktop, tablet, and mobile. Co-Authored-By: Claude frontend-developer (Haiku 4.5) --- .../components/photos/PhotoMetadataSidepanel.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/components/photos/PhotoMetadataSidepanel.tsx b/client/src/components/photos/PhotoMetadataSidepanel.tsx index 86fc6f5b2..d2a7500a8 100644 --- a/client/src/components/photos/PhotoMetadataSidepanel.tsx +++ b/client/src/components/photos/PhotoMetadataSidepanel.tsx @@ -28,11 +28,6 @@ export function PhotoMetadataSidepanel({ const { t } = useTranslation('photoViewer'); const { formatDate } = useFormatters(); - // Hide sidepanel entirely when annotation mode is active - if (isAnnotating) { - return null; - } - const [caption, setCaption] = useState(photo.caption ?? ''); const [areaId, setAreaId] = useState(photo.areaId ?? ''); const [isSaving, setIsSaving] = useState(false); @@ -83,13 +78,18 @@ export function PhotoMetadataSidepanel({ } }, [photo.id, caption, areaId, onPhotoUpdated]); - const hasChanges = caption !== (photo.caption ?? '') || areaId !== (photo.areaId ?? ''); - const isDisabled = isSaving || isLoadingAreas; - const searchAreas = useCallback(async (query: string) => { return fetchAreas({ search: query }).then((resp) => resp.areas || []); }, []); + // Hide sidepanel entirely when annotation mode is active + if (isAnnotating) { + return null; + } + + const hasChanges = caption !== (photo.caption ?? '') || areaId !== (photo.areaId ?? ''); + const isDisabled = isSaving || isLoadingAreas; + const renderAreaItem = (area: AreaResponse) => ({ id: area.id, label: area.name, From 198125c5e75a02c28b4fab9295f0e87a7cac9bc5 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 19 May 2026 20:45:23 +0200 Subject: [PATCH 6/9] test(e2e): fix mobile WebKit touch event dispatch for freehand and measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On WebKit viewports with hasTouch=true, page.mouse.* calls do not reliably propagate pointerdown/pointermove/pointerup to React's onPointer* handlers on SVG elements. Rewrite drawFreehandTouch() to dispatch synthetic PointerEvents directly via svgOverlay.evaluate(), and add a new drawLineTouch() helper for measurement/line drags. - drawFreehandTouch: replaces page.mouse.* with el.dispatchEvent(PointerEvent) for each segment, subdivided 3x to ensure FreehandTool accumulates ≥2 points through RDP simplification before COMMIT_DRAFT fires - drawLineTouch: new helper for two-point drags (measurement tool) with 5-step subdivision; used in Scenario 17 - Scenario 17 (measurement mobile): replace inline page.mouse.* with drawLineTouch - Scenario 21 (multi-tool lifecycle): replace drawFreehand with drawFreehandTouch so the freehand step works on iPhone 13 (WebKit/hasTouch) without breaking desktop No production code modified. Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) --- e2e/pages/PhotoViewerPage.ts | 116 ++++++++++++++++++++++++++---- e2e/tests/photoAnnotation.spec.ts | 19 +++-- 2 files changed, 110 insertions(+), 25 deletions(-) diff --git a/e2e/pages/PhotoViewerPage.ts b/e2e/pages/PhotoViewerPage.ts index 027fcc2dc..c52e89fb9 100644 --- a/e2e/pages/PhotoViewerPage.ts +++ b/e2e/pages/PhotoViewerPage.ts @@ -457,8 +457,19 @@ export class PhotoViewerPage { } /** - * Draw a freehand stroke using touch events (for mobile viewports). - * Uses page.touchscreen for the gesture. + * Draw a freehand stroke on touch/mobile viewports using synthetic PointerEvents. + * + * On WebKit with hasTouch=true, `page.mouse.*` calls do not reliably propagate + * `pointerdown`/`pointermove`/`pointerup` events to React's onPointer* handlers. + * Instead, we dispatch PointerEvents directly on the SVG element via + * `svgOverlay.evaluate(...)`, which fires them synchronously in the browser + * context regardless of viewport type. Each segment between waypoints is + * subdivided into 3 steps so that FreehandTool.onPointerMove captures enough + * intermediate points for simplifyPolyline to retain ≥ 2 points after RDP. + * + * This helper is safe to call on desktop viewports as well — the synthetic + * dispatch targets the element's event listeners directly, bypassing the + * mouse-model entirely. */ async drawFreehandTouch( startXPct = 0.1, @@ -472,22 +483,97 @@ export class PhotoViewerPage { const svgBox = await this.svgOverlay.boundingBox(); if (!svgBox) throw new Error('SVG overlay not visible'); - const startX = svgBox.x + svgBox.width * startXPct; - const startY = svgBox.y + svgBox.height * startYPct; + const points: Array<[number, number]> = [ + [svgBox.x + svgBox.width * startXPct, svgBox.y + svgBox.height * startYPct], + ...waypoints.map(([x, y]): [number, number] => [ + svgBox.x + svgBox.width * x, + svgBox.y + svgBox.height * y, + ]), + ]; + + await this.svgOverlay.evaluate( + (el: Element, pts: Array<[number, number]>) => { + const dispatch = (type: string, x: number, y: number) => { + el.dispatchEvent( + new PointerEvent(type, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }), + ); + }; + + // Fire pointerdown at the start point + dispatch('pointerdown', pts[0][0], pts[0][1]); + + // Walk each segment, subdividing into 3 steps to give FreehandTool + // enough intermediate points to survive RDP simplification (≥ 2 points). + for (let i = 1; i < pts.length; i++) { + const [x0, y0] = pts[i - 1]; + const [x1, y1] = pts[i]; + for (let s = 1; s <= 3; s++) { + dispatch('pointermove', x0 + (x1 - x0) * (s / 3), y0 + (y1 - y0) * (s / 3)); + } + } + + // Fire pointerup at the last point + dispatch('pointerup', pts[pts.length - 1][0], pts[pts.length - 1][1]); + }, + points, + ); + } - // Touch: tap-and-hold to start, then tap for waypoints - // Playwright touchscreen.tap fires a quick tap; for drag we use mouse. - // On mobile WebKit pointerevents from mouse.move still fire correctly. - await this.page.mouse.move(startX, startY); - await this.page.mouse.down(); + /** + * Draw a line (or measurement) drag on touch/mobile viewports using synthetic PointerEvents. + * + * Mirrors drawFreehandTouch but for a simple two-point drag (start → end). + * Subdivides the segment into 5 steps to ensure the tool's onPointerMove + * handler receives intermediate events. Safe to call on desktop viewports. + */ + async drawLineTouch( + startXPct = 0.15, + startYPct = 0.5, + endXPct = 0.75, + endYPct = 0.5, + ): Promise { + const svgBox = await this.svgOverlay.boundingBox(); + if (!svgBox) throw new Error('SVG overlay not visible'); - for (const [xPct, yPct] of waypoints) { - await this.page.mouse.move(svgBox.x + svgBox.width * xPct, svgBox.y + svgBox.height * yPct, { - steps: 3, - }); - } + const startX = svgBox.x + svgBox.width * startXPct; + const startY = svgBox.y + svgBox.height * startYPct; + const endX = svgBox.x + svgBox.width * endXPct; + const endY = svgBox.y + svgBox.height * endYPct; - await this.page.mouse.up(); + await this.svgOverlay.evaluate( + (el: Element, coords: [number, number, number, number]) => { + const [sx, sy, ex, ey] = coords; + const dispatch = (type: string, x: number, y: number) => { + el.dispatchEvent( + new PointerEvent(type, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }), + ); + }; + + dispatch('pointerdown', sx, sy); + // Subdivide into 5 steps so the tool's onPointerMove fires multiple times + for (let s = 1; s <= 5; s++) { + dispatch('pointermove', sx + (ex - sx) * (s / 5), sy + (ey - sy) * (s / 5)); + } + dispatch('pointerup', ex, ey); + }, + [startX, startY, endX, endY] as [number, number, number, number], + ); } /** diff --git a/e2e/tests/photoAnnotation.spec.ts b/e2e/tests/photoAnnotation.spec.ts index b8dec5b20..066d8883f 100644 --- a/e2e/tests/photoAnnotation.spec.ts +++ b/e2e/tests/photoAnnotation.spec.ts @@ -1501,15 +1501,10 @@ test( await viewer.activateTool('measurement'); - // Draw measurement line using pointer events (works on mobile WebKit too) - const svgBox = await viewer.svgOverlay.boundingBox(); - expect(svgBox).not.toBeNull(); - await page.mouse.move(svgBox!.x + svgBox!.width * 0.15, svgBox!.y + svgBox!.height * 0.5); - await page.mouse.down(); - await page.mouse.move(svgBox!.x + svgBox!.width * 0.75, svgBox!.y + svgBox!.height * 0.5, { - steps: 5, - }); - await page.mouse.up(); + // Draw measurement line using synthetic touch PointerEvents via drawLineTouch. + // On WebKit/hasTouch viewports page.mouse.* does not reliably fire + // onPointerDown/Move/Up on the SVG element — use the synthetic helper instead. + await viewer.drawLineTouch(0.15, 0.5, 0.75, 0.5); // Inline input should appear at the midpoint await expect(viewer.inlineInput).toBeVisible(); @@ -1772,8 +1767,12 @@ test( await expect(viewer.svgOverlay.locator('ellipse[data-shapeid]').first()).toBeVisible(); // Draw Freehand + // Use drawFreehandTouch (synthetic PointerEvents) so this step works on + // mobile WebKit (hasTouch=true) where page.mouse.* does not reliably fire + // the onPointerDown/Move/Up handlers on the SVG element. The helper is + // safe to call on desktop viewports too. await viewer.activateTool('freehand'); - await viewer.drawFreehand(0.1, 0.7, [ + await viewer.drawFreehandTouch(0.1, 0.7, [ [0.3, 0.6], [0.5, 0.8], [0.7, 0.6], From 2567c14965ae6f8f5b5cc9dda14378dc8bb2dccb Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 19 May 2026 21:03:55 +0200 Subject: [PATCH 7/9] test(e2e): fix React state batching in drawFreehandTouch/drawLineTouch helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When all PointerEvents fire in a single synchronous JS task (one evaluate() call), React batches the SET_DRAFT update from pointerdown together with all pointermove updates. This means handlePointerMove sees stale state (draftShape === null) and returns early — so FreehandTool never captures intermediate points and COMMIT_DRAFT is skipped. Fix: split each helper into two evaluate() calls with a requestAnimationFrame yield between pointerdown and the pointermove/pointerup sequence. The rAF yield lets React flush the SET_DRAFT update so handlePointerMove sees a non-null draftShape in the second evaluate call. This resolves desktop Chromium failures introduced in the previous commit (which broke desktop by switching the multi-tool lifecycle test from page.mouse.* to drawFreehandTouch before the React batching issue was understood). Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) --- e2e/pages/PhotoViewerPage.ts | 54 +++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/e2e/pages/PhotoViewerPage.ts b/e2e/pages/PhotoViewerPage.ts index c52e89fb9..809420745 100644 --- a/e2e/pages/PhotoViewerPage.ts +++ b/e2e/pages/PhotoViewerPage.ts @@ -491,6 +491,33 @@ export class PhotoViewerPage { ]), ]; + // Phase 1: dispatch pointerdown and let React flush the SET_DRAFT state update. + // If all events fire in one synchronous JS task, React batches the state updates + // so handlePointerMove sees stale state (draftShape === null) and returns early. + // Splitting into two evaluate calls — with an rAF yield in between — lets React + // commit the SET_DRAFT before pointermove events arrive. + await this.svgOverlay.evaluate( + (el: Element, pt: [number, number]) => { + el.dispatchEvent( + new PointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: pt[0], + clientY: pt[1], + bubbles: true, + cancelable: true, + }), + ); + }, + points[0], + ); + + // Yield one animation frame so React flushes the SET_DRAFT state update + // before pointermove events arrive. + await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + + // Phase 2: dispatch pointermove (subdivided per segment) + pointerup. await this.svgOverlay.evaluate( (el: Element, pts: Array<[number, number]>) => { const dispatch = (type: string, x: number, y: number) => { @@ -507,9 +534,6 @@ export class PhotoViewerPage { ); }; - // Fire pointerdown at the start point - dispatch('pointerdown', pts[0][0], pts[0][1]); - // Walk each segment, subdividing into 3 steps to give FreehandTool // enough intermediate points to survive RDP simplification (≥ 2 points). for (let i = 1; i < pts.length; i++) { @@ -548,6 +572,29 @@ export class PhotoViewerPage { const endX = svgBox.x + svgBox.width * endXPct; const endY = svgBox.y + svgBox.height * endYPct; + // Phase 1: pointerdown, then yield an rAF so React flushes its state update + // before pointermove events arrive (same pattern as drawFreehandTouch). + await this.svgOverlay.evaluate( + (el: Element, pt: [number, number]) => { + el.dispatchEvent( + new PointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: pt[0], + clientY: pt[1], + bubbles: true, + cancelable: true, + }), + ); + }, + [startX, startY] as [number, number], + ); + + // Yield one animation frame so React flushes the SET_DRAFT state update. + await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + + // Phase 2: pointermove (5 steps) + pointerup. await this.svgOverlay.evaluate( (el: Element, coords: [number, number, number, number]) => { const [sx, sy, ex, ey] = coords; @@ -565,7 +612,6 @@ export class PhotoViewerPage { ); }; - dispatch('pointerdown', sx, sy); // Subdivide into 5 steps so the tool's onPointerMove fires multiple times for (let s = 1; s <= 5; s++) { dispatch('pointermove', sx + (ex - sx) * (s / 5), sy + (ey - sy) * (s / 5)); From 976eb56a8dca9b83f6cc142b462c1edb0dee5c03 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 19 May 2026 21:05:22 +0200 Subject: [PATCH 8/9] test(e2e): add third rAF phase to drawLineTouch for MeasurementTool state flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MeasurementTool reads state.draftShape.x2/y2 in onPointerUp (via React state) to compute the line length. Unlike FreehandTool (which uses module-level capturedPoints), the endpoint update arrives through React state from onPointerMove. If pointerup fires in the same synchronous batch as the pointermove events, React hasn't applied the final x2/y2 yet — onPointerUp reads x2=startX, distance=0, and discards the measurement. Add a second rAF yield between the pointermove batch and pointerup so React commits the final endpoint before onPointerUp executes. drawFreehandTouch remains two-phase (FreehandTool uses module-level capturedPoints, not React state, so the stale-state issue doesn't affect it). Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.6) --- e2e/pages/PhotoViewerPage.ts | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/e2e/pages/PhotoViewerPage.ts b/e2e/pages/PhotoViewerPage.ts index 809420745..bbb6b604a 100644 --- a/e2e/pages/PhotoViewerPage.ts +++ b/e2e/pages/PhotoViewerPage.ts @@ -572,8 +572,14 @@ export class PhotoViewerPage { const endX = svgBox.x + svgBox.width * endXPct; const endY = svgBox.y + svgBox.height * endYPct; - // Phase 1: pointerdown, then yield an rAF so React flushes its state update - // before pointermove events arrive (same pattern as drawFreehandTouch). + // Three-phase dispatch to work around React state batching: + // MeasurementTool reads state.draftShape.x2/y2 in onPointerUp (via React state, + // not a module-level variable). All events within one synchronous evaluate() + // call are batched by React, so onPointerUp would see stale x2=startX + // (distance === 0 → discard). Three separate evaluate() calls with rAF yields + // ensure each phase flushes before the next one reads state. + + // Phase 1: pointerdown → rAF → React commits SET_DRAFT (x2=startX, y2=startY) await this.svgOverlay.evaluate( (el: Element, pt: [number, number]) => { el.dispatchEvent( @@ -590,11 +596,9 @@ export class PhotoViewerPage { }, [startX, startY] as [number, number], ); - - // Yield one animation frame so React flushes the SET_DRAFT state update. await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); - // Phase 2: pointermove (5 steps) + pointerup. + // Phase 2: pointermove (5 steps) → rAF → React commits final x2=endX, y2=endY await this.svgOverlay.evaluate( (el: Element, coords: [number, number, number, number]) => { const [sx, sy, ex, ey] = coords; @@ -611,15 +615,32 @@ export class PhotoViewerPage { }), ); }; - // Subdivide into 5 steps so the tool's onPointerMove fires multiple times for (let s = 1; s <= 5; s++) { dispatch('pointermove', sx + (ex - sx) * (s / 5), sy + (ey - sy) * (s / 5)); } - dispatch('pointerup', ex, ey); }, [startX, startY, endX, endY] as [number, number, number, number], ); + await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + + // Phase 3: pointerup — state.draftShape.x2/y2 now reflects the final endpoint + await this.svgOverlay.evaluate( + (el: Element, pt: [number, number]) => { + el.dispatchEvent( + new PointerEvent('pointerup', { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: pt[0], + clientY: pt[1], + bubbles: true, + cancelable: true, + }), + ); + }, + [endX, endY] as [number, number], + ); } /** From 698ee864e71c309aa59d0fc91d6b6743385777d9 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Tue, 19 May 2026 21:15:12 +0200 Subject: [PATCH 9/9] chore(agent-memory): record WebKit touch + React batching gotcha Capture the iPhone 13 / WebKit + React useReducer state-batching issue that surfaced while fixing the mobile photoAnnotation tests. Future runs of the e2e-test-engineer can reuse the multi-phase evaluate() pattern without re-deriving it. Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) --- .claude/agent-memory/e2e-test-engineer/MEMORY.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/agent-memory/e2e-test-engineer/MEMORY.md b/.claude/agent-memory/e2e-test-engineer/MEMORY.md index 9f07daf9e..035b4c658 100644 --- a/.claude/agent-memory/e2e-test-engineer/MEMORY.md +++ b/.claude/agent-memory/e2e-test-engineer/MEMORY.md @@ -13,6 +13,7 @@ - **COLOR PALETTE** strict mode: ToolPalette renders up to 3 radiogroups (color, stroke width, and font size for text tools). Use `getByRole('radiogroup', { name: 'Annotation color' })` — aria-label comes from i18n key `colorPalette` = `"Annotation color"`. Never use unscoped `getByRole('radiogroup')`. - **CALLOUT** multi-phase: `drawCallout` has 3 interaction phases (drag box, click tail, type+Enter). Always follow `drawCallout()` with `calloutGroup.waitFor({ state: 'visible', timeout: 15_000 })` because the shape is only committed after the text input is committed in the 3rd phase. - Inline input testid: `annotator-inline-input`. Tool buttons: `tool-{name}`. Action bar: `annotator-save`, `annotator-cancel`, `annotator-undo`, `annotator-redo`. +- **MOBILE WEBKIT TOUCH / REACT STATE BATCHING** (fixed 2026-05-19): `page.mouse.*` does not fire `onPointerDown/Move/Up` on SVG elements in WebKit/hasTouch viewports. Use `svgOverlay.evaluate(el => el.dispatchEvent(new PointerEvent(...)))` instead. CRITICAL: dispatching all events in ONE synchronous evaluate() causes React to batch all state updates — `handlePointerMove` sees stale `state.draftShape=null` and bails. **Must split into multiple evaluate() calls with `page.evaluate(() => new Promise(r => requestAnimationFrame(r)))` yield between pointerdown and pointermove/pointerup.** FreehandTool (uses module-level capturedPoints): 2-phase OK. MeasurementTool (reads state.draftShape.x2/y2 in onPointerUp): needs 3-phase (pointerdown + rAF + pointermove-batch + rAF + pointerup). See PhotoViewerPage.ts `drawFreehandTouch` and `drawLineTouch` helpers. ## Budget Print + i18n Stale Skip Re-enable (PR #1447, 2026-05-17) — See print-and-i18n.md