import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type React from 'react'; import { NARROW_BREAKPOINT, applyMinimumSize, clampRect, defaultRect, moveRect, resizeRect, type Rect, type ResizeEdge, type Viewport, type WindowMode, } from './floatingGeometry'; import { readWindowState, writeWindowState } from './floatingWindowStorage'; export interface FloatingWindowApi { mode: WindowMode; rect: Rect; narrow: boolean; /** * The width of whatever box is actually PAINTED right now ( sub-task 5b) - `rect.width` while * windowed, but the live viewport width whenever the effective mode is fullscreen (an explicit toggle * OR the narrow-viewport override), because fullscreen renders at `inset: 0` and never consults `rect` * at all (see the `style` branch in `FloatingWindow.tsx`). Consuming `rect.width` there would report the * stale, stored windowed size. Backing state is the SAME `resize` listener already used for `narrow` - * see the comment above `viewportWidth` - so this is not a new measurement. */ displayWidth: number; setMode: (mode: WindowMode) => void; toggleFullscreen: () => void; minimise: () => void; restore: () => void; beginDrag: (event: React.PointerEvent) => void; beginResize: (edge: ResizeEdge, event: React.PointerEvent) => void; } const viewportNow = (): Viewport => ({ width: window.innerWidth, height: window.innerHeight }); export function useFloatingGeometry( id: string, opts?: { defaultRect?: Rect; /** * - OPEN IN THIS MODE, WHATEVER WAS STORED, and this is not "a default". * * Spencer, 2026-08-14: *"the meeting window should open up in fullscreen and only collapse down if * the user wants to, that goes for all roles... this meeting needs everyone's full attention so you * shouldn't have to take an action to make it full screen, collapsing should be the action if needed."* * * THE WHOLE JOB IS THAT THIS IS NOT A DEFAULT. A default is read only when nothing is stored, and * this window persists its mode - so after the first visit the "default" never runs again and whatever * the user left last time greets them next time. **Including `minimised`**, which would open the * next visit collapsed, against the same day's ruling that the way into a visit *"can't be missed"*. * * MODE RESETS; THE REMEMBERED SIZE DOES NOT. `rect` is only consulted AFTER a deliberate collapse, * so someone who sized the window to sit beside their chart still gets that size back when they choose * to collapse. Clearing the whole stored record would pass the "opens fullscreen" test and quietly * cost the preference - which is the reading this option exists to rule out. * `restoreTo` is forced to `windowed` alongside it, so the collapse control has somewhere sane to * go. Never `minimised`: one tap would vanish a live visit. */ openAs?: WindowMode; }, ): FloatingWindowApi { const [narrow, setNarrow] = useState(() => window.innerWidth < NARROW_BREAKPOINT); // sub-task 5b: kept alongside `narrow` rather than only deriving a boolean from it - a // consumer publishing content width (`displayWidth` below) needs the actual pixel number for fullscreen, // not just "is it below the breakpoint". Read by the SAME `resize` listener that already updates // `narrow`, so this adds no new listener and measures nothing that wasn't already being read. const [viewportWidth, setViewportWidth] = useState(() => window.innerWidth); const [{ mode, rect, restoreTo }, setState] = useState(() => { // -- the viewport is passed so a rect computed for a DIFFERENT screen is dropped rather // than replayed. A position anchored at 1032px lands on the patient queue rail at 2560px, and it is // fully on screen there, so the clamp below cannot see anything wrong with it. const stored = readWindowState(id, viewportNow()); // A stored rect is UNTRUSTED GEOMETRY. The storage validator proves the numbers are finite; it does // NOT prove they are usable. A hand-edited or stale `width: 5` would pass validation and render a 5px // window, and a rect saved on a 3440px monitor is off-screen on a laptop. Both are unrecoverable for // the user, so the minimums and the clamp are applied on the way IN, not only during a drag. const startRect = clampRect( applyMinimumSize(stored?.rect ?? opts?.defaultRect ?? defaultRect(viewportNow())), viewportNow(), ); // Finding 2: the narrow override is a DISPLAY concern, derived below - never baked into the stored // mode. Forcing it here would persist a narrow-viewport artifact as if it were a deliberate user choice, // and it would never revert on its own once the viewport widens again. // - `openAs` OVERRIDES the stored mode; it does not fall back to it. See the option's doc for // why that distinction is the entire fix. `rect` is untouched here on purpose: the size is a // preference, the mode is the ruling. if (opts?.openAs) { return { mode: opts.openAs, rect: startRect, // Never `minimised` - the collapse control must land somewhere that keeps the visit visible. restoreTo: opts.openAs === 'fullscreen' ? 'windowed' as const : 'fullscreen' as const, }; } return { mode: stored?.mode ?? 'windowed' as const, rect: startRect, restoreTo: stored?.restoreTo ?? 'windowed' as const, }; }); // Persist on every change to mode/rect/restoreTo - `rect` is in the dep list, so this actually fires on // every pointermove while dragging or resizing, not only once a gesture "settles". That is acceptable // (nothing in src/ listens for `storage` events) but it is NOT what the old comment here claimed. // // `id` is also a dep, and that is a mount-stable assumption, not just a persistence key: the state // initializer above reads storage for `id` exactly ONCE, on mount. Swap `id` on a live instance (e.g. // parameterising it by encounter while switching encounters without unmounting) and this effect keeps // writing the FIRST id's in-memory state under the NEW id's storage key - silently overwriting whatever // that other surface had saved. See the doc on `FloatingWindowProps.id` in FloatingWindow.tsx - `id` // must be a static surface name and must never change on a mounted instance. useEffect(() => { writeWindowState(id, { mode, rect, restoreTo, viewport: viewportNow() }); }, [id, mode, rect, restoreTo]); // Re-clamp on viewport resize: a window parked bottom-right must not vanish when the browser shrinks. useEffect(() => { const onResize = () => { setNarrow(window.innerWidth < NARROW_BREAKPOINT); setViewportWidth(window.innerWidth); setState((s) => ({ ...s, rect: clampRect(s.rect, viewportNow()) })); }; window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); const setMode = useCallback((next: WindowMode) => { setState((s) => ({ ...s, mode: next, // Remember where to come back to, so restore() returns to fullscreen or windowed correctly. // Guarded on `s.mode !== 'minimised'`: a re-entrant minimise() (e.g. a doubled click) must leave an // already-recorded restoreTo alone, not re-derive it from 'minimised' and fall through to 'windowed'. restoreTo: next === 'minimised' && s.mode !== 'minimised' ? (s.mode === 'fullscreen' ? 'fullscreen' : 'windowed') : s.restoreTo, })); }, []); const toggleFullscreen = useCallback(() => { setState((s) => ({ ...s, mode: s.mode === 'fullscreen' ? 'windowed' : 'fullscreen' })); }, []); const minimise = useCallback(() => setMode('minimised'), [setMode]); const restore = useCallback(() => setState((s) => ({ ...s, mode: s.restoreTo })), []); // Finding 2: the reported mode is DERIVED, never stored. `mode` above stays the user's real // preference - that is what gets persisted - while narrow viewports display as fullscreen without ever // touching it, so widening the viewport again reveals the real mode underneath with no extra plumbing. // // I2 (review fix): EXCEPT 'minimised' - that one must survive the narrow override. FloatingWindow // renders its minimise button unconditionally, so if narrow forced 'fullscreen' over top of a real // 'minimised' mode, the button would be permanently reachable and permanently inert on a phone, and the // tap that hits it still persists. restore() already reads `restoreTo` off the stored mode, so this // carve-out is the only change minimised needs. const effectiveMode: WindowMode = narrow && mode !== 'minimised' ? 'fullscreen' : mode; // See the JSDoc on `displayWidth` in `FloatingWindowApi` above. Keyed on `effectiveMode`, not `mode`, // so the narrow-forced-fullscreen case is covered too - that is the phone case, and it is the one that // matters most: `rect.width` there could be an arbitrary desktop-sized windowed rect the user never sees. const displayWidth = effectiveMode === 'fullscreen' ? viewportWidth : rect.width; // Pointer plumbing follows QuestionnairePreview/ColumnResizeHandle.tsx, this repo's existing idiom. // Two lessons are inherited from it deliberately: // 1. remove listeners and reset state BEFORE releasing capture, so a throw cannot abort cleanup; // 2. wrap releasePointerCapture - it throws NotFoundError on pointercancel, when the pointer is gone. const dragging = useRef(false); const startGesture = useCallback(( event: React.PointerEvent, onDelta: (dx: number, dy: number) => void, ) => { if (narrow) return; // forced fullscreen: nothing to drag or resize if (dragging.current) return; // ignore a second pointer mid-gesture dragging.current = true; const target = event.currentTarget as unknown as HTMLElement; const startX = event.clientX; const startY = event.clientY; const onMove = (e: PointerEvent) => onDelta(e.clientX - startX, e.clientY - startY); const onUp = () => { target.removeEventListener('pointermove', onMove as EventListener); target.removeEventListener('pointerup', onUp as EventListener); target.removeEventListener('pointercancel', onUp as EventListener); dragging.current = false; try { target.releasePointerCapture(event.pointerId); } catch { // Already released — happens on pointercancel. Must not abort the cleanup above. } }; target.setPointerCapture(event.pointerId); target.addEventListener('pointermove', onMove as EventListener); target.addEventListener('pointerup', onUp as EventListener); target.addEventListener('pointercancel', onUp as EventListener); }, [narrow]); const beginDrag = useCallback((event: React.PointerEvent) => { const origin = rect; startGesture(event, (dx, dy) => { setState((s) => ({ ...s, rect: moveRect(origin, dx, dy, viewportNow()) })); }); }, [rect, startGesture]); const beginResize = useCallback((edge: ResizeEdge, event: React.PointerEvent) => { const origin = rect; startGesture(event, (dx, dy) => { setState((s) => ({ ...s, rect: clampRect(resizeRect(origin, edge, dx, dy), viewportNow()) })); }); }, [rect, startGesture]); return useMemo( () => ({ mode: effectiveMode, rect, narrow, displayWidth, setMode, toggleFullscreen, minimise, restore, beginDrag, beginResize, }), [effectiveMode, rect, narrow, displayWidth, setMode, toggleFullscreen, minimise, restore, beginDrag, beginResize], ); }