Skip to content
Merged
1 change: 1 addition & 0 deletions .claude/agent-memory/e2e-test-engineer/MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
63 changes: 61 additions & 2 deletions client/src/components/OverflowMenu/OverflowMenu.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<DOMRect | null>(null);
const [effectivePlacement, setEffectivePlacement] = useState<'bottom-end' | 'top-end'>('bottom-end');
const wrapperRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
Expand All @@ -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,
Expand All @@ -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
}
Expand Down
60 changes: 60 additions & 0 deletions client/src/components/photos/PhotoMetadataSidepanel.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -180,4 +236,8 @@
.saveButton {
transition: none;
}

.toggleButton[aria-expanded="true"] {
transform: none;
}
}
8 changes: 5 additions & 3 deletions client/src/components/photos/PhotoMetadataSidepanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
74 changes: 64 additions & 10 deletions client/src/components/photos/PhotoMetadataSidepanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -22,6 +34,7 @@ export function PhotoMetadataSidepanel({ photo, onPhotoUpdated }: PhotoMetadataS
const [error, setError] = useState<string | null>(null);
const [areas, setAreas] = useState<AreaResponse[]>([]);
const [isLoadingAreas, setIsLoadingAreas] = useState(false);
const [isOpenMobile, setIsOpenMobile] = useState(false);

// Load areas on mount
useEffect(() => {
Expand Down Expand Up @@ -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 (
<div className={styles.sidepanel} aria-label={t('metadataTitle')} role="complementary">
<div className={styles.header}>
<h3 className={styles.title}>{t('metadataTitle')}</h3>
</div>
<>
{/* Toggle button — visible on mobile only */}
<button
type="button"
className={styles.toggleButton}
onClick={() => setIsOpenMobile((v) => !v)}
aria-expanded={isOpenMobile}
aria-controls="photo-metadata-sidepanel"
data-testid="photo-metadata-toggle"
title={t('metadataToggle')}
aria-label={t('metadataToggle')}
>
<ChevronUpIcon />
</button>

{/* Sidepanel */}
<div
className={`${styles.sidepanel} ${isOpenMobile ? styles.sidepanelOpen : ''}`}
aria-label={t('metadataTitle')}
role="complementary"
id="photo-metadata-sidepanel"
>
<div className={styles.header}>
<h3 className={styles.title}>{t('metadataTitle')}</h3>
</div>

<div className={styles.content}>
<div className={styles.content}>
{/* Upload date — read-only */}
<div className={styles.section}>
<label className={styles.label}>{t('uploadDate')}</label>
Expand Down Expand Up @@ -151,6 +190,21 @@ export function PhotoMetadataSidepanel({ photo, onPhotoUpdated }: PhotoMetadataS

{isSaving && <div className={styles.savingIndicator}>{t('saving')}</div>}
</div>
</div>
</div>
</>
);
}

function ChevronUpIcon() {
return (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M18 15l-6-6-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
6 changes: 5 additions & 1 deletion client/src/components/photos/PhotoViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,11 @@ export function PhotoViewer({
)}

{/* Metadata sidepanel */}
<PhotoMetadataSidepanel photo={currentPhoto} onPhotoUpdated={handlePhotoUpdated} />
<PhotoMetadataSidepanel
photo={currentPhoto}
onPhotoUpdated={handlePhotoUpdated}
isAnnotating={isAnnotating}
/>
</div>
</div>
);
Expand Down
3 changes: 2 additions & 1 deletion client/src/i18n/en/photoViewer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Loading