import React, { createContext, useCallback, useContext, useLayoutEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Minus, Maximize2, Minimize2, X } from 'lucide-react'; import { cn } from '../../../lib/utils'; import { getAkashicPortalContainer, hasAkashicPortalHost } from '../../../lib/getAkashicPortalContainer'; import { Z_FLOATING_WINDOW, Z_MODAL } from '../../../lib/stackingLayers'; import { useFloatingGeometry } from './useFloatingGeometry'; import { RESIZE_EDGES, type Rect, type ResizeEdge, type WindowMode } from './floatingGeometry'; /** * The stacking floor every floating window starts above ( sub-task 5a / Task 10). * * NO LONGER A NUMBER WRITTEN HERE, and that is the fix rather than a tidy-up. This value used to be a * literal `1000`, justified by "z-50 is the highest anything else in the app uses" - true of the app * CHROME, and it cleared it correctly. But the INVERSE question was never asked: a Radix modal opened from * INSIDE a floating window (Delete on a note `AlertDialog`) also sat at `z-50`, so it mounted BEHIND the * window while still blocking pointer events everywhere outside itself - an invisible dialog, a frozen * meeting window and a dead Leave button in a live, billed call. * * The relationship `popovers > modals > floating windows > app chrome` now lives in ONE file, * `lib/stackingLayers.ts`, with the reasoning for each layer. Re-exported under the original name so this * primitive's own contract ("windows start at or above this floor") is still readable from here. */ export const FLOATING_WINDOW_BASE_Z = Z_FLOATING_WINDOW; // Module-scoped and monotonically increasing, on purpose: every live `FloatingWindow` instance shares // ONE stacking order, so the meeting window and the floating notes pane (both mountable at once - see // `notesLocation === 'floating'`) can be raised against each other regardless of which file rendered // them. Deliberately NOT React state and NOT persisted - see `floatingWindowStorage.ts`: the localStorage // payload is geometry only. Z-order is who-was-clicked-last, which is session state and stops mattering // the moment the tab closes; writing it to storage would just widen what that key holds for no benefit. // // LIFETIME FIX (-08-06): this counter used to grow WITHOUT BOUND - every pointerdown // anywhere in a window's root called it (see `bringToFront` below), so ~100 raises in a single page // session (a 30-minute video visit easily qualifies) walked it past `Z_MODAL` and reproduced the exact // defect `stackingLayers.ts` exists to prevent: a Radix modal opened from inside the window (the // delete-confirm in `PatientNotes`) mounts BEHIND it, frozen and invisible, Leave button dead mid-call. // Two changes close that off: // 1. `bringToFront` (below) is a no-op when the window is already frontmost - clicking the one window // already on top can never consume the range, which removes the common case outright. // 2. This counter is CLAMPED at `FLOATING_WINDOW_CEILING_Z`, one below `Z_MODAL`, derived from that // single source rather than a second literal so the two files cannot drift apart again. // Clamping alone would still let two windows raised in genuine alternation land on the SAME z-index once // saturated - a tie, whose winner is arbitrary DOM order rather than "who was clicked last". So whoever // currently holds the top slot is demoted one notch (to `FLOATING_WINDOW_CEILING_Z - 1`) the instant // someone else takes it, keeping the front-to-back relationship between the two most-recently-touched // windows correct even while pinned at the ceiling. `demoteCurrentFront` is how each `FloatingWindow` // instance can be reached back into to do that - see the ref-backed `demote` callback in the component. const FLOATING_WINDOW_CEILING_Z = Z_MODAL - 1; let topZIndex = FLOATING_WINDOW_BASE_Z - 1; let demoteCurrentFront: ((z: number) => void) | null = null; const takeTopZIndex = (demote: (z: number) => void): number => { if (topZIndex < FLOATING_WINDOW_CEILING_Z) { topZIndex += 1; } else { // Saturated: there is no unused integer left below the modal layer. Push whoever currently holds the // ceiling down to its second slot so the newly-promoted window is unambiguously above it, instead of // letting the counter climb onto `Z_MODAL` (the original defect, arrived at ~100 raises later). demoteCurrentFront?.(FLOATING_WINDOW_CEILING_Z - 1); topZIndex = FLOATING_WINDOW_CEILING_Z; } demoteCurrentFront = demote; return topZIndex; }; /** * 1px border Γ— 2 sides on the outer chrome (`border` in the className the windowed/fullscreen markup * shares - see the return JSX below). The only horizontal inset between the outer box and the CONTENT * box: the content wrapper (`min-h-0 flex-1 overflow-auto`) carries no horizontal padding of its own, and * the title bar sits in its own row (`flex-col`), so it costs no horizontal space either. Named rather * than inlined so a future change to that border is a one-line update here instead of a silently stale * published width - see `useFloatingWindowWidth` below. */ const CONTENT_HORIZONTAL_CHROME = 2; /** * Publishes the CONTENT width of the nearest enclosing `FloatingWindow` ( sub-task 5b) - the * outer box minus `CONTENT_HORIZONTAL_CHROME` - so a child can key its own layout off the space it * actually has. `undefined` outside a `FloatingWindow`, so a consumer can tell "not inside a window" from * "zero width" rather than silently getting `0`. * * Deliberately keyed on the WINDOW's own width, never `window.innerWidth`/a viewport media query. The * two agree almost everywhere - `useFloatingGeometry` forces the effective mode to `fullscreen` below * `NARROW_BREAKPOINT`, so on a phone the window IS the viewport - but they diverge exactly where it * matters: a window resized narrower than the viewport around it (dragged to 500px on a wide monitor). A * viewport anchor would report "plenty of room" there and the wrong layout would render in a box that has * none. * * Backed by `useFloatingGeometry`'s `displayWidth` (see its JSDoc), which is already-tracked geometry, not * a new measurement - no `ResizeObserver` here. */ const FloatingWindowWidthContext = createContext(undefined); /** A spot in the title bar, before the window buttons, that window content may portal into. */ const FloatingWindowHeaderSlotContext = createContext(null); export function useFloatingWindowHeaderSlot(): HTMLElement | null { return useContext(FloatingWindowHeaderSlotContext); } export function useFloatingWindowWidth(): number | undefined { return useContext(FloatingWindowWidthContext); } export interface FloatingWindowProps { /** * A STATIC surface name (e.g. `'meeting'`, `'encounter-notes'`) - never a per-record identifier * (patient id, encounter id, etc.) - and it must never change on a mounted instance. * * `id` doubles as the localStorage key suffix (see `windowStorageKey` in `floatingWindowStorage.ts`) * AND a mount-stable assumption inside `useFloatingGeometry`: the hook's state initializer reads * stored state for `id` exactly ONCE, on mount, but the persist effect re-runs whenever `id` changes. * Swap `id` on a live instance (e.g. `` id={`notes-${encounterId}`} `` while switching encounters * without unmounting) and the hook never reads the new id's stored state, and immediately overwrites * that other window's saved geometry with the current one. * * It is also a value baked into a localStorage key that survives logout on a shared clinic * workstation. "Geometry is not PHI" (see `floatingWindowStorage.ts`) is true of the persisted value - * it is not a license to make the KEY itself an identifier. * * There is no runtime registry or guard enforcing any of this (deliberately out of scope) - it is a * usage contract only. Two live instances sharing an id would also fight over the same geometry. */ id: string; title: React.ReactNode; /** Muted context after the title; a second line on a narrow screen. */ subtitle?: React.ReactNode; children: React.ReactNode; onClose?: () => void; defaultRect?: Rect; /** * - open in this mode WHATEVER was stored last time, rather than restoring it. * * Not a default. This window persists its mode, so a value read only when nothing is stored stops * being a default after the very first use. Pass this when the activity's opening state is a product * decision rather than a user preference - a video visit needs the screen, so `minimised` must never be * what greets the next one. The remembered SIZE is still restored; only the mode is overridden. */ openAs?: WindowMode; /** * What stays on screen while minimised. Omitted, the window collapses to one reopen button. Given, it * becomes a small tile the user can drag, with a restore control and no close, so a live call keeps * playing and keeps its own Leave. Spencer, 2026-08-26: "Let's replicate zoom on minimize." */ minimisedContent?: React.ReactNode; /** What the one reopen button says when there is no minimised content. Defaults to the title. */ minimisedLabel?: React.ReactNode; /** Added to the window root, so a feature can scope its own tokens onto the chrome. */ className?: string; /** "dark" draws the title bar on the dark token set, for a window over a dark surface such as a call. */ tone?: "default" | "dark"; } const EDGE_CLASS: Record = { n: 'top-0 left-2 right-2 h-1.5 cursor-ns-resize', s: 'bottom-0 left-2 right-2 h-1.5 cursor-ns-resize', e: 'right-0 top-2 bottom-2 w-1.5 cursor-ew-resize', w: 'left-0 top-2 bottom-2 w-1.5 cursor-ew-resize', ne: 'top-0 right-0 h-3 w-3 cursor-nesw-resize', nw: 'top-0 left-0 h-3 w-3 cursor-nwse-resize', se: 'bottom-0 right-0 h-3 w-3 cursor-nwse-resize', sw: 'bottom-0 left-0 h-3 w-3 cursor-nesw-resize', }; /** * The shared floating window ( sub-task 2). Both the telehealth meeting window and the encounter * notes pane are instances of this - build it once, so two implementations can never disagree. * * Knows nothing about notes, messages, video, patients or encounters. It answers one question: where am I * and how big am I. * * Portals to #akashic-overlay-root, NEVER document.body (the CSS-bleed trap). * * Usage rule (not enforced in code - there is no registry): mount at most ONE live instance per `id`. * Two instances sharing an id would both read/write the same localStorage key and fight over the same * geometry. */ export const FloatingWindow: React.FC = ({ id, title, subtitle, children, onClose, defaultRect, openAs, minimisedContent, minimisedLabel, className, tone = "default", }) => { const barTone = tone === "dark" ? "dark bg-surface text-ink" : ""; const geo = useFloatingGeometry(id, { defaultRect, openAs }); // w-96, not narrower: a call's controls row (mic, camera, Leave, End for everyone) measured ~325px on the // mount, and a narrower tile clipped the mic button off its left edge where nothing could press it. // The minimised tile's place, as offsets from the bottom-right corner. Not persisted: a tile only exists // for the life of one minimise, and restoring goes back to the window's own stored rect. const [tileOffset, setTileOffset] = useState({ right: 16, bottom: 16 }); const [headerSlot, setHeaderSlot] = useState(null); const tileDrag = useRef<{ x: number; y: number; right: number; bottom: number } | null>(null); // Click-to-front (Task 10). Taken once on mount - NOT re-derived every render, which would make an // unrelated re-render (a drag's pointermove, a message arriving) silently jump the window to the top - // and again whenever `bringToFront` fires, which every pointerdown anywhere in the window's own root // does, via the handler below. Two sibling windows (the meeting window + the floating notes pane) share // `topZIndex`, so whichever was interacted with last wins regardless of mount order. // // `setZIndexRef` is how `takeTopZIndex` can demote THIS instance from outside itself once the counter // saturates (see the module comment above) - it always forwards to this instance's own, stable `setZIndex`, // never a stale one, because it is repointed every render rather than captured once. The indirection only // exists because the very first `takeTopZIndex` call happens inside `useState`'s lazy initializer, before // `setZIndex` itself exists to hand over. const setZIndexRef = useRef<(z: number) => void>(() => {}); const demote = useCallback((z: number) => setZIndexRef.current(z), []); // Parked finding fixed here: `takeTopZIndex` can call `demoteCurrentFront?.(...)`, which // is a setState reaching into a DIFFERENT, already-rendered sibling instance. That is the normal, legal // way to do it from an EFFECT (React commits first, THEN runs effects - updating a sibling there is fine) // but this used to be called from the `useState` lazy initializer above - i.e. mid-RENDER, which React // requires to be pure. React 18 StrictMode double-invokes lazy initializers specifically to surface // exactly this, so under saturation (see `FLOATING_WINDOW_CEILING_Z` above) plus a StrictMode remount // this could double-demote a sibling or log "Cannot update a component while rendering a different // component". The initializer is now a plain, pure placeholder; the real slot is claimed in the // `useLayoutEffect` below instead, which is the legal place to do it. const [zIndex, setZIndex] = useState(FLOATING_WINDOW_BASE_Z); setZIndexRef.current = setZIndex; // `useLayoutEffect`, not `useEffect`: fires synchronously after the DOM commits but before the browser // paints, so the placeholder above is never actually visible - matching the old initializer's "resolved // before first paint" behaviour. Runs once per mount (`demote` is a stable ref-forwarding callback, so // this dependency never itself changes); StrictMode's dev-only mountcleanupmount replay just claims // (and immediately re-claims) a slot, which burns one extra integer from the shared, clamped range - // harmless, and exactly the kind of thing `FLOATING_WINDOW_CEILING_Z` already exists to make a non-issue. useLayoutEffect(() => { setZIndex(takeTopZIndex(demote)); }, [demote]); // No-op when this window is already frontmost: comparing against the shared module counter (not just // "some higher number") is what makes clicking the one open window unable to ever consume the range - // the common case behind the ~100-interaction drift, since a live video visit is mostly clicks inside // the ONE window that's already on top. const bringToFront = useCallback(() => { setZIndex((current) => (current === topZIndex ? current : takeTopZIndex(demote))); }, [demote]); // Fail closed rather than silently leaking into document.body. // // THE CHECK MOVED, AND THE MOVE IS THE POINT ( follow-up). This used to read // `if (!container) return null` - which worked only because `getAkashicPortalContainer()` could return // null. made it get-or-create so a first-paint dialog could not escape the scoped root, and // that turned this guard into DEAD CODE: the helper now always returns an element, so a floating window // with no app root present would have rendered into `` - the exact bleed this line refuses. // Guard on the thing that actually matters (is there a scoped root to be inside?), not on a null the // helper no longer produces. Caught by this component's own test, not by. if (!hasAkashicPortalHost()) return null; const container = getAkashicPortalContainer(); const minimised = geo.mode === 'minimised'; const fullscreen = geo.mode === 'fullscreen'; // Published to children via `useFloatingWindowWidth` below. `geo.displayWidth` is already the width of // whatever box is actually rendered right now (windowed rect, or live viewport in fullscreen - see its // JSDoc in `useFloatingGeometry.ts`); `Math.max(0,...)` is only a defensive floor against a pathologically // tiny real browser window, never expected to bind in practice. const contentWidth = Math.max(0, geo.displayWidth - CONTENT_HORIZONTAL_CHROME); const style: React.CSSProperties = { zIndex, ...(minimised ? { position: 'fixed', bottom: 16, right: 16 } : fullscreen ? { position: 'fixed', inset: 0 } : { position: 'fixed', left: geo.rect.x, top: geo.rect.y, width: geo.rect.width, height: geo.rect.height }), }; // Derived from `id`, never a new prop: `id` is already required and static (see the JSDoc above), so // every instance gets a stable, discoverable root testid for free. This is also what closes the gap a // review of `MeetingWindow.tsx` flagged - that component's own `data-testid="meeting-window"` marks its // CONTENT, which renders nothing while minimised, so a live-but-minimised call previously had no testid // anywhere on screen. The minimised button below carries this same testid, so it does now. const rootTestId = `floating-window-${id}`; if (minimised && minimisedContent) { const stopDrag = () => { tileDrag.current = null; }; return createPortal(
{ if ((e.target as HTMLElement).closest('button')) return; tileDrag.current = { x: e.clientX, y: e.clientY, ...tileOffset }; e.currentTarget.setPointerCapture?.(e.pointerId); }} onPointerMove={(e) => { const start = tileDrag.current; if (!start) return; setTileOffset({ right: Math.min(Math.max(0, start.right - (e.clientX - start.x)), window.innerWidth - 120), bottom: Math.min(Math.max(0, start.bottom - (e.clientY - start.y)), window.innerHeight - 60), }); }} onPointerUp={stopDrag} onPointerCancel={stopDrag} className={cn("flex cursor-move touch-none select-none items-center gap-2 border-b border-line px-3 py-1.5", barTone)} > {title}
{minimisedContent}
, container, ); } if (minimised) { return createPortal( , container, ); } return createPortal(
{ if ((e.target as HTMLElement).closest('button')) return; e.preventDefault(); geo.beginDrag(e); }} className={cn("flex items-center gap-2.5 select-none", tone !== "dark" && "border-b border-line", geo.narrow ? "px-3 py-2.5" : "px-4 py-[13px]", !(fullscreen || geo.narrow) && "cursor-move", barTone)} > {title} {subtitle && {geo.narrow ? subtitle : <>· {subtitle}}} {/* πŸ‡ΊπŸ‡Έ "Minimize" β€” en-US, matching the rest of the product. It read "Minimise" and was the only en-GB string on the surface. ⚠️ The internal API keeps `geo.minimise`: renaming that too would touch the hook, its storage shape and every call site for a user-visible copy change. */} {/* I3: the label reads the EFFECTIVE mode but toggleFullscreen writes the STORED mode. On narrow there is nothing on screen to toggle (narrow already forces fullscreen display), so rendering it at all invites a tap that silently writes mode:'fullscreen' to storage β€” the exact door the Task 4 state-initializer fix closed, reopened one control over. */} {!geo.narrow && ( )} {onClose && ( )}
{/* Mode-gated by construction: this whole branch (and therefore the Provider) does not render at all while minimised β€” see the early `if (minimised)` return above, which shows only the reopen tab and never mounts `children`. So there is no "stale content width while minimised" case to special-case here: a consumer either isn't mounted (minimised) or reads the current mode's real `contentWidth` (windowed/fullscreen). */}
{children}
{/* Resize handles only while windowed β€” fullscreen has nothing to resize, and a narrow viewport is forced fullscreen so it never reaches here. */} {!fullscreen && !geo.narrow && RESIZE_EDGES.map((edge) => (
{ e.preventDefault(); geo.beginResize(edge, e); }} className={`absolute ${EDGE_CLASS[edge]}`} /> ))}
, container, ); };