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 diff --git a/client/src/components/OverflowMenu/OverflowMenu.tsx b/client/src/components/OverflowMenu/OverflowMenu.tsx index 1ee85eed0..b88a26bb4 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); @@ -80,6 +82,27 @@ export function OverflowMenu({ }; }, [isOpen, usePortal]); + // Close menu on Escape key at document level + useEffect(() => { + if (!isOpen) return; + + const handleEscape = (e: KeyboardEvent) => { + // Skip if the menu element itself already handled this event + if (menuRef.current?.contains(e.target as Node)) { + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + setIsOpen(false); + triggerRef.current?.focus(); + } + }; + + document.addEventListener('keydown', handleEscape); + return () => document.removeEventListener('keydown', handleEscape); + }, [isOpen]); + // Keyboard navigation const handleTriggerKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown' && !isOpen) { @@ -133,6 +156,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 +198,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 +220,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.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), diff --git a/client/src/components/photos/PhotoMetadataSidepanel.tsx b/client/src/components/photos/PhotoMetadataSidepanel.tsx index e3925e2be..d2a7500a8 100644 --- a/client/src/components/photos/PhotoMetadataSidepanel.tsx +++ b/client/src/components/photos/PhotoMetadataSidepanel.tsx @@ -7,12 +7,24 @@ import { useFormatters } from '../../lib/formatters.js'; import { SearchPicker } from '../SearchPicker/index.js'; import styles from './PhotoMetadataSidepanel.module.css'; +/** + * PhotoMetadataSidepanel — displays and edits photo metadata (caption, area). + * On mobile, renders as a bottom sheet with a toggle button. + * When annotation mode is active, the sidepanel and toggle button are hidden + * to prevent interaction interference with the annotation canvas. + */ export interface PhotoMetadataSidepanelProps { photo: Photo; onPhotoUpdated?: (photo: Photo) => void; + /** If true, hides the sidepanel and toggle button to avoid pointer event interference during annotation. */ + isAnnotating?: boolean; } -export function PhotoMetadataSidepanel({ photo, onPhotoUpdated }: PhotoMetadataSidepanelProps) { +export function PhotoMetadataSidepanel({ + photo, + onPhotoUpdated, + isAnnotating = false, +}: PhotoMetadataSidepanelProps) { const { t } = useTranslation('photoViewer'); const { formatDate } = useFormatters(); @@ -22,6 +34,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(() => { @@ -65,25 +78,51 @@ export function PhotoMetadataSidepanel({ photo, onPhotoUpdated }: PhotoMetadataS } }, [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, }); return ( -
-
-

{t('metadataTitle')}

-
+ <> + {/* Toggle button — visible on mobile only */} + + + {/* Sidepanel */} +
); diff --git a/client/src/i18n/en/photoViewer.json b/client/src/i18n/en/photoViewer.json index 28a04ca07..250c1f6d2 100644 --- a/client/src/i18n/en/photoViewer.json +++ b/client/src/i18n/en/photoViewer.json @@ -21,5 +21,6 @@ "delete": "Delete photo", "deleteConfirmTitle": "Delete this photo?", "deleteConfirmBody": "This photo will be permanently removed. This action cannot be undone.", - "deleteConfirmAction": "Delete" + "deleteConfirmAction": "Delete", + "metadataToggle": "Toggle photo metadata" } diff --git a/e2e/pages/PhotoViewerPage.ts b/e2e/pages/PhotoViewerPage.ts index 027fcc2dc..bbb6b604a 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,164 @@ 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, + ]), + ]; + + // 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) => { + el.dispatchEvent( + new PointerEvent(type, { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: x, + clientY: y, + bubbles: true, + cancelable: true, + }), + ); + }; + + // 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(); + // 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( + new PointerEvent('pointerdown', { + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + clientX: pt[0], + clientY: pt[1], + bubbles: true, + cancelable: true, + }), + ); + }, + [startX, startY] as [number, number], + ); + await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); + + // 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; + 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, + }), + ); + }; + // 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)); + } + }, + [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], + ); } /** diff --git a/e2e/tests/photoAnnotation.spec.ts b/e2e/tests/photoAnnotation.spec.ts index 73af96606..066d8883f 100644 --- a/e2e/tests/photoAnnotation.spec.ts +++ b/e2e/tests/photoAnnotation.spec.ts @@ -662,11 +662,23 @@ test('Arrow tool — draw arrow and save', async ({ await viewer.drawLine(0.2, 0.5, 0.7, 0.3); - // A with marker-end=url(#arrowhead) should appear - const arrowLine = viewer.svgOverlay.locator('line[data-shapeid]').first(); - await expect(arrowLine).toBeVisible(); - const markerEnd = await arrowLine.getAttribute('marker-end'); - expect(markerEnd).toContain('arrowhead'); + // Arrow rendering contract: a group containing a + // shaft and a arrowhead (no marker-end — the arrowhead is an + // explicit polygon child, not an SVG marker). + const arrowGroup = viewer.svgOverlay.locator('g[data-shapeid]').first(); + await expect(arrowGroup).toBeAttached({ timeout: 15_000 }); + + // The group must contain exactly one line (shaft) and one polygon (arrowhead) + await expect(arrowGroup.locator('line')).toHaveCount(1); + await expect(arrowGroup.locator('polygon')).toHaveCount(1); + + // The polygon's points attribute should encode a triangle (three coordinate pairs) + const arrowPolygon = arrowGroup.locator('polygon'); + const points = await arrowPolygon.getAttribute('points'); + expect(points).not.toBeNull(); + // A triangle arrowhead has exactly 3 coordinate pairs (6 numbers) + const coordPairs = (points ?? '').trim().split(/\s+/); + expect(coordPairs).toHaveLength(3); // Save and verify const [putResponse] = await Promise.all([ @@ -724,19 +736,26 @@ test('Line tool — draw line and save', async ({ await viewer.drawLine(0.2, 0.5, 0.7, 0.5); // A should appear (no marker-end for plain line). - // Use waitFor with explicit timeout: actionTimeout (5 s) is too tight on a - // 2-vCPU CI shard running testcontainers; expect.timeout (7 s) is the floor, - // but 15 s gives the shard comfortable headroom. + // Use state:'attached' rather than state:'visible': an SVG with + // y1 === y2 has a zero-height bounding box, so Playwright's visibility + // check fails even though the stroke renders correctly to the user. const lineEl = viewer.svgOverlay.locator('line[data-shapeid]').first(); try { - await lineEl.waitFor({ state: 'visible', timeout: 15_000 }); + await lineEl.waitFor({ state: 'attached', timeout: 15_000 }); } catch (e) { const svgHtml = await page .evaluate(() => document.querySelector('[role="application"]')?.innerHTML ?? '(not found)') .catch(() => '(eval failed)'); - console.error('[DEBUG] Line shape not visible after drawLine. SVG innerHTML:', svgHtml); + console.error('[DEBUG] Line shape not attached after drawLine. SVG innerHTML:', svgHtml); throw e; } + + // Verify rendered geometry: horizontal line (y1 ≈ y2), expected stroke color and width + await expect(lineEl).toHaveAttribute('x1', /\d/); + await expect(lineEl).toHaveAttribute('x2', /\d/); + await expect(lineEl).toHaveAttribute('y1', /\d/); + await expect(lineEl).toHaveAttribute('y2', /\d/); + await expect(lineEl).toHaveAttribute('stroke-width', '1'); // Arrow has marker-end; plain line has marker-end="none" or absent const markerEnd = await lineEl.getAttribute('marker-end'); expect(markerEnd === null || markerEnd === 'none').toBe(true); @@ -812,18 +831,20 @@ test('Line tool — Shift-snap constrains angle to 45° increments', async ({ await page.keyboard.up('Shift'); // The committed line should have y1 ≈ y2 (horizontal snap). - // Use waitFor with explicit 15 s timeout: actionTimeout (5 s) is too tight on - // a 2-vCPU CI shard; the shape commit goes through two async React state - // updates (useReducer → undoStack useState). + // Use state:'attached' rather than state:'visible': a horizontal SVG + // (y1 === y2) has a zero-height bounding box, which causes Playwright's + // visibility check to fail even though the stroke is visually correct. + // The 15 s explicit timeout gives CI shards comfortable headroom beyond the + // default actionTimeout (5 s) for two async React state updates. const lineEl = viewer.svgOverlay.locator('line[data-shapeid]').first(); try { - await lineEl.waitFor({ state: 'visible', timeout: 15_000 }); + await lineEl.waitFor({ state: 'attached', timeout: 15_000 }); } catch (e) { const svgHtml = await page .evaluate(() => document.querySelector('[role="application"]')?.innerHTML ?? '(not found)') .catch(() => '(eval failed)'); console.error( - '[DEBUG] Shift-snap: line shape not visible after Shift+drag. SVG innerHTML:', + '[DEBUG] Shift-snap: line shape not attached after Shift+drag. SVG innerHTML:', svgHtml, ); throw e; @@ -1480,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(); @@ -1751,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], @@ -1949,22 +1969,27 @@ test('Color palette — selecting a swatch marks it aria-checked and new shapes const colorGroup = page.getByRole('radiogroup', { name: 'Annotation color' }); await expect(colorGroup).toBeVisible(); - // The default color is red — find the red swatch (aria-checked="true") - const defaultChecked = colorGroup.locator('[aria-checked="true"]').first(); - await expect(defaultChecked).toBeVisible(); - const defaultBgColor = await defaultChecked.evaluate( + // The default color is red — red is the first swatch (index 0). + // We pin the red swatch by position so the reference stays stable after + // clicking a different color (a live '[aria-checked="true"]' locator would + // follow the newly-checked swatch instead of staying on red). + const swatches = colorGroup.locator('[role="radio"]'); + // Colors order: red, yellow, green, blue, black, white + const redSwatch = swatches.nth(0); + await expect(redSwatch).toBeVisible(); + await expect(redSwatch).toHaveAttribute('aria-checked', 'true'); + const defaultBgColor = await redSwatch.evaluate( (el) => (el as HTMLElement).style.backgroundColor, ); // Default color is #dc2626 (red) — browser may normalize to rgb format expect(defaultBgColor).toBeTruthy(); // Click the blue swatch (index 3 = blue = #3b82f6) - const swatches = colorGroup.locator('[role="radio"]'); - // Colors order: red, yellow, green, blue, black, white const blueSwatch = swatches.nth(3); await blueSwatch.click(); await expect(blueSwatch).toHaveAttribute('aria-checked', 'true'); - await expect(defaultChecked).toHaveAttribute('aria-checked', 'false'); + // The red swatch (pinned by index) should now be unchecked + await expect(redSwatch).toHaveAttribute('aria-checked', 'false'); // Draw a rectangle — it should have stroke matching the blue color await viewer.activateTool('rectangle');