+```
+
+The fixed `w-[240px]` is replaced by `aspect-video` so the thumb's width is driven by the strip's `h-[180px]` height. This keeps webcam thumbs at 16:9 (320×180) instead of an arbitrary 240×180 which crops faces.
+
+- [ ] **Step 4: Type-check**
+
+```bash
+pnpm --filter @chatapp/desktop typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Manual verification**
+
+Start the dev server, join a call with 2–4 webcams. Check:
+
+ - All grid tiles are equal-size 16:9 boxes; no tile is taller or wider than its neighbors.
+ - With 3 participants → single row of 3; with 4 → 2×2; with 5–6 → 3×2 (last cell may be empty/centered).
+ - Faces are framed naturally (`object-cover`); no obvious squish or stretch.
+
+If layout looks wrong, screenshot, do not commit, and iterate on the wrapping classes.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/desktop/src/components/InCallPanel.tsx
+git commit -m "feat(call): uniform 16:9 grid cells, drop grid-rows constraint"
+```
+
+---
+
+## Task 4: Introduce `StageLayout` discriminated union
+
+**Files:**
+- Modify: `apps/desktop/src/components/InCallPanel.tsx:295-345` (the area where `effectiveFocusedId` and `speaker` are computed inside `InCallPanel`)
+
+- [ ] **Step 1: Add the `StageLayout` type and selector**
+
+In `InCallPanel.tsx`, locate the block:
+
+```tsx
+// Screen shares no longer auto-promote — the user opts in by clicking the
+// "Bildschirm anschauen" overlay, which also toggles whether the audio
+// plays. Focus falls back to the first tile so focus-mode always has
+// something to show when no tile was explicitly picked.
+const effectiveFocusedId = focusedId ?? tiles[0]?.id ?? null;
+const speaker = tiles.find((p) => p.id === effectiveFocusedId) ?? tiles[0];
+```
+
+Replace it with:
+
+```tsx
+// Discord-style precedence:
+// 1. focusedId set → 'focus', that tile is the stage.
+// 2. ≥2 shares, no pin → 'bento', shares fill the stage, webcams strip.
+// 3. exactly 1 share, no pin → 'focus' (auto-promote share).
+// 4. no shares, no pin → 'equal-grid'.
+type StageLayout =
+ | { kind: 'equal-grid' }
+ | { kind: 'focus'; bigTileId: string }
+ | { kind: 'bento'; shareIds: string[] };
+
+const shareIds = tiles.filter((t) => t.kind === 'screen').map((t) => t.id);
+const stageLayout: StageLayout = (() => {
+ if (focusedId !== null && tiles.some((t) => t.id === focusedId)) {
+ return { kind: 'focus', bigTileId: focusedId };
+ }
+ if (shareIds.length >= 2) return { kind: 'bento', shareIds };
+ if (shareIds.length === 1 && shareIds[0]) {
+ return { kind: 'focus', bigTileId: shareIds[0] };
+ }
+ return { kind: 'equal-grid' };
+})();
+
+// Tile that owns the big stage when layout is 'focus'. Resolved lazily by
+// callers below — kept here just so the speaker prop on CallStage/Fullscreen
+// stays consistent with the layout decision.
+const bigTile =
+ stageLayout.kind === 'focus'
+ ? tiles.find((t) => t.id === stageLayout.bigTileId)
+ : undefined;
+```
+
+- [ ] **Step 2: Replace existing usages of `speaker` and `effectiveFocusedId`**
+
+Search `InCallPanel.tsx` for every remaining reference to `effectiveFocusedId` and `speaker` inside the `InCallPanel` function and replace as follows:
+
+- `speaker` (used as prop on `CallStage`, `FullscreenCall`, focused-tile detection) → `bigTile`.
+- `effectiveFocusedId` (used in `pinnedTileId` prop for context menu) → `focusedId` (we no longer override pin for menu purposes; the auto-promoted share isn't user-pinned).
+
+Concretely, the line `pinnedTileId: focusedId,` is already correct (uses `focusedId`, not the effective). The `effectiveFocusedId` declaration and `speaker` are removed by Step 1. Remaining usages:
+
+ - **In the fullscreen branch:** replace `speaker={effectiveSpeaker}` and the `effectiveSpeaker = hasFocus ? speaker : undefined` derivation with `speaker={bigTile}` (and drop the now-redundant `hasFocus` / `effectiveSpeaker` lines, since `bigTile` is undefined exactly when there's no focus).
+ - **In the focus branch:** replace `speaker={speaker}` with `speaker={bigTile}`.
+
+After this step, `InCallPanel`'s render path no longer uses the old `effectiveFocusedId` or `speaker` locals — only `stageLayout`, `bigTile`, `focusedId`.
+
+- [ ] **Step 3: Type-check**
+
+```bash
+pnpm --filter @chatapp/desktop typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Manual verification**
+
+Start the dev server, join a call (no shares yet, no pin). Check:
+
+ - Equal-grid renders as in Task 3 (no behavior regression).
+ - Right-click → "Anpinnen" still works: pins the tile, the call panel collapses to focus-mode showing that tile big.
+ - Right-click → "Anpinnen aufheben" returns to equal-grid.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add apps/desktop/src/components/InCallPanel.tsx
+git commit -m "feat(call): introduce StageLayout discriminated union"
+```
+
+---
+
+## Task 5: Wire CallStage to render `focus` and `equal-grid` from StageLayout
+
+**Files:**
+- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `InCallPanel`'s docked-call render branch (the area that calls `
`) and `CallStage` itself
+
+- [ ] **Step 1: Map `stageLayout` → `mode` for `CallStage`**
+
+In `InCallPanel.tsx`, locate the docked-call render that mounts ``. Replace the `mode={callMode}` prop with a derived value:
+
+```tsx
+ {
+ // Discord-style toggle: clicking the already-focused tile drops the
+ // pin; clicking another tile swaps. callMode auto-syncs.
+ setFocusedId(focusedId === id ? null : id);
+ }}
+ ...
+/>
+```
+
+Note: `setCallMode` calls on click are removed — `callMode` no longer tracks pin state. `callMode` is now only `'grid'` (docked) or `'fullscreen'` (cinema). The third state (`'focus'`) is implicit when `focusedId !== null` and isn't a separate top-level mode anymore.
+
+- [ ] **Step 2: Drop the click-toggle that swapped callMode**
+
+Find the `onClick` callbacks in `InCallPanel` that did `setCallMode('focus')` or `setCallMode('grid')`. Replace each with a single `setFocusedId(focusedId === id ? null : id)` call (or remove the redundant ones that are now handled by `onFocusTile`).
+
+- [ ] **Step 3: Adjust the `Mode` button bar**
+
+The ` onChange('grid')} label="Grid">` row currently switches between `grid`, `focus`, `fullscreen`. Drop the `focus` button entirely — there's no manual focus mode anymore. Keep `grid` and `fullscreen`.
+
+Locate the ModeButtonRow (search for `ModeButton`):
+
+```tsx
+ onChange('grid')} label="Grid">
+
+
+ onChange('focus')} label="Sprecher">
+
+
+ onChange('fullscreen')} label="Vollbild">
+
+
+```
+
+Delete the middle (`focus`) ModeButton block. The remaining two cover all user-driven modes.
+
+- [ ] **Step 4: Type-check**
+
+```bash
+pnpm --filter @chatapp/desktop typecheck
+```
+
+Expected: PASS. If `'focus'` is referenced in `CallMode` type and unused now, leave the type alone — `'focus'` is still a valid value, just not user-selectable. Don't refactor the type.
+
+- [ ] **Step 5: Manual verification**
+
+In a dev call (2–4 participants, no shares):
+
+ - **Click a webcam tile** → it becomes big, others to strip (`focus`-style stage). No mode bar change.
+ - **Click it again** → equal grid restores.
+ - **Click another tile while one is pinned** → swap to that tile.
+ - Mode bar shows only `Grid` and `Vollbild` (the middle `Sprecher` button is gone).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/desktop/src/components/InCallPanel.tsx
+git commit -m "feat(call): left-click toggles pin, drop manual focus mode"
+```
+
+---
+
+## Task 6: Auto-promote single share (precedence rule 3)
+
+**Files:**
+- No code change beyond what's already in Task 4 + Task 5. This task is the **manual verification** that the auto-promote path works end-to-end.
+
+- [ ] **Step 1: Manual verification — share auto-promote**
+
+In a dev call (2 participants):
+
+ - User A starts a screen share. Expected: share auto-promotes to the big stage on User B's side; User A's webcam moves to the strip.
+ - User A stops the share. Expected: equal grid restores.
+ - User A shares again; User B clicks User A's webcam thumb. Expected: webcam pins big, share moves to strip.
+ - User B double-clicks the pinned webcam (Task 7 adds this; if not yet implemented, right-click → unpin works too). Expected: share auto-promotes again.
+
+If any step fails, return to Task 4 (`stageLayout` selector) and verify the bigTile derivation is reading `tiles.find((t) => t.id === stageLayout.bigTileId)` correctly.
+
+- [ ] **Step 2: No commit**
+
+This task only verifies behavior introduced in earlier tasks.
+
+---
+
+## Task 7: Doubleclick on the focused tile clears pin
+
+**Files:**
+- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:120-130` (root `` of the tile)
+- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:54-87` (ParticipantTileProps)
+- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `TileRender` props and the focused-tile rendering paths
+
+- [ ] **Step 1: Add `onDoubleClick` prop**
+
+In `CallParticipantTile.tsx`, extend `ParticipantTileProps`:
+
+```tsx
+onDoubleClick?: () => void;
+```
+
+In the `CallParticipantTile` body, destructure it:
+
+```tsx
+const {
+ // ... existing
+ onDoubleClick,
+} = props;
+```
+
+And add it to the root `
`:
+
+```tsx
+
+```
+
+- [ ] **Step 2: Plumb `onDoubleClick` through `TileRender`**
+
+In `InCallPanel.tsx`, extend the `TileRender` component props (the inline interface) with `onDoubleClick?: () => void;`. Pass it through to `CallParticipantTile` the same way `onClick` is passed:
+
+```tsx
+{...(onDoubleClick ? { onDoubleClick } : {})}
+```
+
+And on the `
` wrapping the screen-tile branch, add `onDoubleClick={onDoubleClick}` next to `onClick`.
+
+- [ ] **Step 3: Hook doubleclick on focused tiles to clear pin**
+
+In `FocusedTile`, accept and forward `onDoubleClick`:
+
+```tsx
+function FocusedTile({
+ tile,
+ e2ee,
+ activeSpeakers,
+ remoteScreenShares,
+ conversationMembers,
+ onContextMenu,
+ onDoubleClick,
+}: {
+ // ... existing
+ onDoubleClick?: () => void;
+}) {
+ return (
+
+
+
+ );
+}
+```
+
+In `CallStage`'s focus branch, pass an `onDoubleClick` that clears the pin:
+
+```tsx
+
onFocusTile(speaker.id)}
+ {...(onTileContextMenu
+ ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker, e) }
+ : {})}
+/>
+```
+
+(`onFocusTile(speaker.id)` toggles — clicking the already-pinned id clears the pin per Task 5's setter.)
+
+In `FullscreenCall`'s big-tile branch, pass the same `onDoubleClick` to the big tile wrapper:
+
+```tsx
+ onFocusTile(speaker.id)}
+>
+```
+
+- [ ] **Step 4: Type-check**
+
+```bash
+pnpm --filter @chatapp/desktop typecheck
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Manual verification**
+
+In a dev call with a pinned tile:
+
+ - **Doubleclick the pinned big tile** → unpins, layout falls back through StageLayout precedence (equal-grid if no shares, or share auto-promote if shares are active).
+ - Single-click still toggles (no regression).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/desktop/src/components/CallParticipantTile.tsx apps/desktop/src/components/InCallPanel.tsx
+git commit -m "feat(call): doubleclick on focused tile clears pin"
+```
+
+---
+
+## Task 8: Multi-share bento stage
+
+**Files:**
+- Modify: `apps/desktop/src/components/InCallPanel.tsx` — `CallStage` (add a bento branch) and the docked-call render to pass the `stageLayout` directly to `CallStage`
+
+- [ ] **Step 1: Pass `stageLayout` to `CallStage`**
+
+In `InCallPanel.tsx`, extend `StageProps`:
+
+```tsx
+interface StageProps {
+ tiles: Tile[];
+ speaker: Tile | undefined;
+ /** Discriminated layout decision driven by InCallPanel's StageLayout
+ * selector. Drives the bento-vs-grid-vs-focus render branch. */
+ stageLayout:
+ | { kind: 'equal-grid' }
+ | { kind: 'focus'; bigTileId: string }
+ | { kind: 'bento'; shareIds: string[] };
+ // mode dropped — it duplicated stageLayout. callMode is still in the
+ // parent for fullscreen-mode entry, just not threaded here anymore.
+ activeSpeakers: Set
;
+ e2ee: boolean;
+ remoteScreenShares: {
+ track: import('livekit-client').RemoteTrack;
+ participantId: string;
+ participantName: string;
+ }[];
+ conversationMembers: ConversationSummary['members'];
+ onFocusTile: (id: string) => void;
+ onTileContextMenu?: (tile: Tile, e: React.MouseEvent) => void;
+ compact?: boolean;
+}
+```
+
+(Remove the `mode: CallMode;` field; replace with `stageLayout`.)
+
+Update the `` site in `InCallPanel` to pass `stageLayout={stageLayout}` instead of `mode={...}`.
+
+- [ ] **Step 2: Rewrite `CallStage` branch dispatch**
+
+In `CallStage`, replace the body (currently `if (mode === 'focus' && speaker) { ... } // Grid ...`) with:
+
+```tsx
+function CallStage({
+ tiles,
+ speaker,
+ stageLayout,
+ activeSpeakers,
+ e2ee,
+ remoteScreenShares,
+ conversationMembers,
+ onFocusTile,
+ onTileContextMenu,
+ compact = false,
+}: StageProps) {
+ if (stageLayout.kind === 'focus' && speaker) {
+ // ... existing focus branch unchanged
+ }
+
+ if (stageLayout.kind === 'bento') {
+ const shares = tiles.filter((t) => stageLayout.shareIds.includes(t.id));
+ const webcams = tiles.filter((t) => !stageLayout.shareIds.includes(t.id));
+ const bentoCols = gridColsFor(shares.length);
+ return (
+
+
+
+ {shares.map((s) => (
+
+ onFocusTile(s.id)}
+ {...(onTileContextMenu
+ ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
+ : {})}
+ />
+
+ ))}
+
+
+ {webcams.length > 0 && (
+
+ {webcams.map((w) => (
+
+ onFocusTile(w.id)}
+ {...(onTileContextMenu
+ ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
+ : {})}
+ />
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // equal-grid
+ const gridClass = gridColsFor(tiles.length);
+ return (
+
+
+ {tiles.map((p) => (
+
+ onFocusTile(p.id)}
+ {...(onTileContextMenu
+ ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
+ : {})}
+ />
+
+ ))}
+
+
+ );
+}
+```
+
+- [ ] **Step 3: Apply the same layout choice inside `FullscreenCall`**
+
+In `FullscreenCall`, the existing branch is `if (hasFocus) { big-stage } else { grid }`. Update the else-branch to also handle bento. The grid render path inside `FullscreenCall` currently uses `sortedGridTiles` + `gridColsFor`. Extend it:
+
+After the existing `hasFocus` check and before the grid render, add:
+
+```tsx
+const fsShareIds = tiles
+ .filter((t) => t.kind === 'screen')
+ .map((t) => t.id);
+const bentoMode = !hasFocus && fsShareIds.length >= 2;
+const bentoShares = bentoMode
+ ? tiles.filter((t) => fsShareIds.includes(t.id))
+ : [];
+const bentoWebcams = bentoMode
+ ? tiles.filter((t) => !fsShareIds.includes(t.id))
+ : [];
+```
+
+Then wrap the existing grid-only render in:
+
+```tsx
+{bentoMode ? (
+
+
+
+ {bentoShares.map((s) => (
+
+ onFocusTile(s.id)}
+ {...(onTileContextMenu
+ ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
+ : {})}
+ />
+
+ ))}
+
+
+ {bentoWebcams.length > 0 && (
+
+ {bentoWebcams.map((w) => (
+
+ onFocusTile(w.id)}
+ {...(onTileContextMenu
+ ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
+ : {})}
+ />
+
+ ))}
+
+ )}
+
+) : (
+ // ... existing grid render unchanged
+)}
+```
+
+- [ ] **Step 4: Type-check**
+
+```bash
+pnpm --filter @chatapp/desktop typecheck
+```
+
+Expected: PASS. If `mode` is still referenced anywhere in `CallStage`, remove the stray reference (it's been replaced by `stageLayout.kind`).
+
+- [ ] **Step 5: Manual verification — multi-share**
+
+Set up a 2-share scenario (two clients sharing simultaneously):
+
+ - In docked mode: stage shows both shares side-by-side at equal size, webcams in the strip below.
+ - In fullscreen-cinema mode: same bento, fills the screen.
+ - Clicking one of the bento shares pins it → layout drops to single-stage focus on that share.
+ - Stopping one share → falls back through StageLayout → rule 3 (single-share auto-promote).
+ - Stopping both → equal grid.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add apps/desktop/src/components/InCallPanel.tsx
+git commit -m "feat(call): multi-share bento layout in stage + fullscreen"
+```
+
+---
+
+## Task 9: End-to-end verification pass
+
+**Files:** None.
+
+- [ ] **Step 1: Run all manual checks from the spec, end to end**
+
+Spec section "Testing" lists six scenarios. Run all six:
+
+1. Webcam-only equal grid: 4 webcams, no pin, no share → 2×2 uniform, faces cropped via cover.
+2. Pinning toggle: click webcam → big with contain (no head crop), strip below; click again → grid.
+3. Share auto-promote: start share → share is big, webcams strip, share aspect respected.
+4. Pin override during share: while share is big, click webcam → webcam pins big, share to strip; click webcam again → back to share auto-promote.
+5. Multi-share: two users share → bento stage; click one → pin that share.
+6. Active speaker: someone talks → emerald border, no reorder.
+
+- [ ] **Step 2: Check no console errors**
+
+Open DevTools console during the call. Expected: no warnings about React keys, missing props, or unhandled promise rejections related to the touched files.
+
+- [ ] **Step 3: Final commit (if any cleanup)**
+
+If you made trailing cleanup commits during the verification, push the branch. Otherwise nothing more to commit.
+
+```bash
+git log --oneline -10
+```
+
+Expected: 5–6 commits with prefixes `feat(call): ...`.
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** Each section of the spec maps to a task:
+ - Spec §1 (Tile aspect ratio) → Task 3
+ - Spec §2 (Object-fit per mode) → Task 1
+ - Spec §3 (Click-to-pin) → Task 5 + Task 7 (doubleclick)
+ - Spec §4 (Layout selection precedence) → Task 4 + Task 5 + Task 6 + Task 8
+ - Spec §5 (Active-speaker preserved) → no work needed; verified in Task 9 step 1.6
+- **No placeholders:** Every step has concrete code or commands.
+- **Type consistency:** `StageLayout` is named the same in spec and plan. `bigTileId`/`bigTile` naming is consistent across Tasks 4–8. `onFocusTile` signature `(id: string) => void` matches between InCallPanel callsite and CallStage prop.
+- **Reading-order safety:** Each task block re-states the file paths and the exact code being replaced — Task N doesn't assume the reader memorized Task N-1.
diff --git a/docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md b/docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md
new file mode 100644
index 0000000..e7dce69
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md
@@ -0,0 +1,139 @@
+# 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).