export type LadderRung = 'wide' | 'medium' | 'narrow'; export type VideoLayout = 'gallery' | 'speaker'; export type PanelLayout = 'side-by-side' | 'tabs' | 'icons' | 'none'; // The px thresholds are a JUDGEMENT, not a derivation - parameters to tune in a browser against real // panel content. The RUNGS ('wide' | 'medium' | 'narrow') are the contract; these numbers are not. Tests // reference these constants, never the literals, so re-tuning them later does not require rewriting the // tests. export const LADDER_WIDE_MIN = 900; export const LADDER_MEDIUM_MIN = 600; /** * THERE IS NO `minimised` RUNG, AND THAT IS DELIBERATE (Spencer, 2026-08-07: *"amend the spec, delete * the dead branch"*). * * This resolver originally carried a fourth rung for the minimised window. It was **unreachable in * production and always had been**: `MeetingWindow` passed `minimised: false` unconditionally, and * `FloatingWindow` renders NO CHILDREN AT ALL while minimised - it shows only the reopen tab, so nothing * downstream of this function exists to lay out. A minimised window has no layout question to answer. * * Worth keeping because it cost a review to catch: the ledger for this sub-task recorded that * "minimised sidesteps the stale-width problem by construction", which reads as a design property. It * was not one - it was the rung not existing. **Tests guarding an unreachable branch read as coverage * and are not**, and two such tests were removed with it. * * If a minimised window ever gains a body, this is a NEW rung with a real design question, not the * restoration of this one. */ export interface MeetingLayoutInputs { contentWidth: number; openPanelCount: number; /** * (E11) - HOW MANY PEOPLE ARE IN THE CALL, self included. * * Spencer's ruling: *"a collaboration visit is a DIFFERENT layout, not a bigger one."* A cross-org * visit can put a patient, an outside specialist and a family member on one call, and **four equal * tiles inside a note-dominant surface gives four unusable faces**. Width alone cannot see that: a * wide window with four people is exactly the case the gallery handles worst. * * Optional, defaulting to a 1:1. An absent count must read as the two-party visit, which is the * overwhelmingly common case and the one the gallery is right for - never as a crowd. */ participantCount?: number; } export interface MeetingLayout { rung: LadderRung; video: VideoLayout; panels: PanelLayout; selfView: boolean; } /** * The collapse ladder. Spencer, 2026-08-05: the rung depends on "how they resize the window and if they * have the messages and notes visible or not". * * PURE ON PURPOSE. jsdom reports ZERO for every bounding box, so a render-based layout assertion agrees * with any implementation, including a broken one. This function is the only part of the ladder that can * be genuinely tested; everything downstream of it (the actual render) is browser-owed. So all the * decision-making lives here, exhaustively testable, and the component becomes a dumb renderer of its * verdict. * * The px thresholds are a JUDGEMENT, not a derivation - parameters to tune in a browser against real * panel content. The RUNGS are the contract. */ export function resolveMeetingLayout(i: MeetingLayoutInputs): MeetingLayout { // Degenerate widths (0, negative) fall through the last comparison and land on 'narrow' - never // undefined. A layout verdict must always be complete. const rung: LadderRung = i.contentWidth >= LADDER_WIDE_MIN ? 'wide' : i.contentWidth >= LADDER_MEDIUM_MIN ? 'medium' : 'narrow'; // A panel the user closed STAYS closed - the ladder governs only what happens to panels that are // OPEN as space runs out. It never re-opens one. const panels: PanelLayout = i.openPanelCount === 0 ? 'none' : rung === 'wide' ? 'side-by-side' : rung === 'medium' ? 'tabs' : 'icons'; // (E11) - A CROWD IS A DIFFERENT LAYOUT, NOT A SMALLER GRID. // // Two independent reasons to feature one speaker, and they must BOTH hold the verdict or the layout // flips back the moment the other stops applying: // - **narrow** - there is not room for a grid at all (the original rule, unchanged); // - **three or more people** - Spencer: four equal tiles inside a note-dominant surface gives four // unusable faces. Width cannot see this: a WIDE window with four people is precisely the case // the gallery handles worst, so the old width-only rule was wrong exactly where it looked safest. // // A 1:1 features the other person too: practitioner#s-video-visit draws the patient on the stage with // your own inset at every width. Only someone alone, waiting, is a gallery of one. const crowded = (i.participantCount ?? 2) >= 3; const oneToOne = (i.participantCount ?? 2) === 2; return { rung, video: rung === 'narrow' || crowded || oneToOne ? 'speaker' : 'gallery', // Spencer's ruling, 2026-08-07: the self-view SURVIVES the narrow rungs. In the gallery it is // simply one of the tiles; at narrow it is an inset beside the active speaker. Either way it is // present. Two of the six defects the first real browser calls found were the self-view tile // never reaching the client, and camera-off reading as "waiting" - without a self-view a // practitioner cannot distinguish a dead camera from a quiet room, mid-visit. selfView: true, panels, }; } /** The deck's windowed meeting width: past `LADDER_WIDE_MIN`, so leaving full screen keeps the side column. */ export const MEETING_WINDOW_WIDTH = 980; const MEETING_WINDOW_MARGIN = 24; export function meetingWindowRect(viewport: { width: number; height: number }) { const width = Math.min(MEETING_WINDOW_WIDTH, viewport.width - 2 * MEETING_WINDOW_MARGIN); const height = Math.min(viewport.height - 2 * MEETING_WINDOW_MARGIN, Math.round(viewport.height * 0.82)); return { x: Math.round((viewport.width - width) / 2), y: Math.round((viewport.height - height) / 2), width, height, }; }