# 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).
