# Discord-Style Call Tile Handling — Design Spec **Date:** 2026-05-12 **Scope:** `apps/desktop/src/components/InCallPanel.tsx`, `CallParticipantTile.tsx`, `ScreenShareViewer.tsx` **Goal:** Make tile rendering, click handling, and mixed share/webcam layouts behave like Discord. --- ## Problem Today's in-call rendering has four user-visible defects: 1. **Webcam tiles look stretched/cropped wrong.** `VideoStub` uses `object-cover` in every size, so when the tile aspect ratio diverges from the webcam stream, faces get cropped aggressively or distorted. 2. **Screen-share tiles get the wrong aspect.** `ScreenShareViewer` uses `object-contain` (correct), but the grid cell that wraps it has no aspect-ratio constraint. Cells stretch tall/wide based on the grid template, leaving the share floating with large black bars on the sides. 3. **Click-to-pin doesn't feel like Discord.** Left-click in docked grid swaps `callMode` from grid → focus, but fullscreen-grid doesn't react; pinning is right-click-only; toggling off requires another right-click. 4. **Mixed layouts (share + webcams) treat every tile equally.** A screen share competes for space with 1:1 webcam tiles instead of dominating the stage with webcams beside it. ## Goals - Grid renders uniform tile sizes without distorting content. - Single left-click on any tile pins it big; click again or click another tile swaps. - When at least one screen share is live and nothing is manually pinned, the share auto-promotes to the big stage spot. - Multiple parallel shares share the stage in a bento layout; webcams sit as a strip. - Active-speaker reorder stays disabled (preserves the earlier "no constant switching" fix). ## Non-Goals - No new transitions/animations beyond what's already in place. - No changes to context menu, volume control, screen-share picker. - No mobile/responsive rework — desktop only. - No migration of stored prefs (focused tile is session-only already). --- ## Design ### 1. Tile aspect ratio Every tile (webcam **and** screen) in the **grid** renders inside an `aspect-video` (16:9) box. - Grid container drops `grid-rows-*` and instead lets `aspect-video` on each cell drive height. - `gridColsFor(n)` keeps the column count logic, just drops the row count constraint. - Effect: uniform tile sizes, no stretching, content sizing is per-tile not per-row. ```tsx // Today: grid h-full gap-2 grid-cols-3 grid-rows-2 // Tomorrow: grid h-full gap-2 grid-cols-3 (each child has aspect-video) ``` In fullscreen-cinema **focus** layouts (single big tile + thumbnail strip) the strip thumbs use `aspect-video` as well so they line up evenly. ### 2. Object-fit per mode | Tile type | Grid (thumb) | Pinned/Focus (big) | Fullscreen strip | |--------------|--------------|--------------------|------------------| | Webcam | `cover` | `contain` | `cover` | | Screen share | `contain` | `contain` | `contain` | - `VideoStub` accepts a new `fit?: 'cover' | 'contain'` prop (default `cover`). `CallParticipantTile` passes `contain` when its `focused` prop is true. - `ScreenShareViewer` already uses `object-contain` — no change there beyond removing the hardcoded `aspectRatio: '16/9'` on the unwatched preview button (the parent grid cell will own the ratio). ### 3. Click-to-pin Single source of truth: `focusedId` in `CallContext`. - **Left-click** on any tile: `setFocusedId(tile.id === focusedId ? null : tile.id)`. - In `callMode === 'grid'` and `focusedId !== null` → also `setCallMode('focus')`. - In `callMode === 'focus'` and `focusedId === null` → `setCallMode('grid')`. - In `callMode === 'fullscreen'`: only `focusedId` flips; layout reacts inside `FullscreenCall`. - **Doubleclick on the big/pinned tile**: clears the pin (`focusedId = null`). - **Right-click**: unchanged — opens existing context menu (volume / pin toggle / profile). - **Esc in fullscreen**: unchanged — exits fullscreen back to grid. The current Stage `onClick` (`InCallPanel.tsx` around lines 578–586) already does this for docked mode; we extend the same handler to `FullscreenCall`'s tile click path (`onFocusTile`). ### 4. Layout selection (precedence) `InCallPanel` picks one of four layouts every render. Precedence top-down — first matching rule wins: | # | Condition | Layout | |---|-----------|--------| | 1 | `focusedId !== null` | Single-stage focus: the pinned tile is big, all others strip. | | 2 | `shareCount >= 2` and `focusedId === null` | Multi-share bento: all shares in sub-grid stage, webcams strip below. | | 3 | `shareCount === 1` and `focusedId === null` | Single-stage focus auto-promote: the share is big, webcams strip. | | 4 | `shareCount === 0` and `focusedId === null` | Equal grid (preserves the 2026-05-12 "no constant switching" fix). | Where `shareCount = tiles.filter((t) => t.kind === 'screen').length`. This is implemented via a derived `stageLayout` discriminated union, not a single `effectiveFocusedId`: ```ts type StageLayout = | { kind: 'equal-grid' } | { kind: 'focus'; bigTileId: string } | { kind: 'bento'; shareIds: string[] }; ``` - Pin clearance returns control to rules 2/3/4 — Discord-style auto-fall-back. - Clicking a share inside the bento sets `focusedId = share.id` → drops into rule 1 (single-stage focus on that share). - Share ends → falls naturally from rule 3 → rule 4, or rule 2 → rule 3. ### 5. Active-speaker behavior (preserved) No reorder. The emerald speaking border on `CallParticipantTile` stays. `prioritizeTiles` only runs when paginating (>12 tiles), as fixed in the 2026-05-12 patch. --- ## File-level changes | File | Change | |------|--------| | `InCallPanel.tsx` | Compute `stageLayout` (equal-grid / focus / bento per the precedence table). Adjust grid CSS (drop `grid-rows-*`, add `aspect-video` per cell). Plumb `onClick` to `FullscreenCall` tiles. Render bento stage for layout `bento`. | | `CallParticipantTile.tsx` | Forward `focused` → `VideoStub.fit`. Update wrapper class to expect `aspect-video` from parent grid cell. Add `onDoubleClick` to clear pin. | | `ScreenShareViewer.tsx` | Remove hardcoded `aspectRatio: '16/9'` on the unwatched preview button (parent owns aspect now). | No changes to `CallContext`, no changes to data model (`Tile`, `focusedId`). --- ## Risks & Mitigations - **Aspect-video might shrink tiles when there are many participants.** Mitigation: existing `GRID_PAGE_SIZE = 12` pagination keeps cells from collapsing to thumbnail-sized; we accept that 12 tiles at 16:9 will produce small rows just like Discord does at the same density. - **Auto-promote could be unexpected if a user explicitly cleared their pin.** Mitigation: pin clearance sets `focusedId = null`, then `firstShareId` takes over only if shares exist — exactly Discord's behavior. The user can stop watching shares to escape. - **`aspect-video` + flex children in the existing focus-mode (`FocusCall`)**: Focus mode has its own big-tile layout (`Stage` lines ~520–610). It already sets `flex h-full`; introducing `aspect-video` only on the strip thumbs is additive and won't reflow the big tile. --- ## Testing Manual verification with two-user dev call: 1. **Webcam-only equal grid:** 4 webcams, none pinned, no shares → all tiles 16:9 equal, faces visible cropped (cover), no stretching. 2. **Pinning toggle:** click a webcam → that tile becomes big with `contain` fit (no face crop), rest strip; click again → back to grid. 3. **Auto-promote:** start a screen share → share is big, webcams strip, share aspect respected (no stretch). 4. **Manual override during share:** while share is big, click a webcam → webcam pins big, share moves to strip. Click webcam again → falls back to share-auto-promote. 5. **Multi-share:** two users share → bento stage, both shares visible at equal size, webcams below. 6. **Active speaker:** someone talks → emerald border, no tile reorder/swap. No automated tests (Storybook setup not in place for in-call layouts).