# Discord-Style Call Tile Handling Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Make in-call tile rendering, click-to-pin, and mixed share/webcam layouts mirror Discord — uniform 16:9 grid, correct object-fit per mode, left-click pins, auto-promote shares, multi-share bento.

**Architecture:** All changes are renderer-only inside `apps/desktop/src/components/`. A new discriminated-union `StageLayout` lives in `InCallPanel.tsx` and replaces the implicit `effectiveFocusedId` logic. `CallParticipantTile` gains a `fit` prop forwarded to the underlying `<video>` element. `ScreenShareViewer` drops its hardcoded 16:9 button-aspect because the parent grid cell owns the ratio now. No changes to `CallContext`, no data-model changes.

**Tech Stack:** React 18, TypeScript, Tailwind (`aspect-video`, `grid-cols-*`, `object-cover`/`object-contain`), LiveKit JS SDK 2.x.

**Spec:** [`docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md`](../specs/2026-05-12-discord-call-tile-handling-design.md)

**Testing note:** No Vitest/Jest harness exists for in-call layouts (Storybook not wired up). Every task ends with a **manual verification checklist** run against `pnpm --filter @chatapp/desktop dev` plus a peer (or a second window joined to the same room). Tasks are committed only after the manual checks pass.

---

## File Structure

| File | Responsibility | Change |
|---|---|---|
| `apps/desktop/src/components/CallParticipantTile.tsx` | Single participant tile (webcam or audio-only) | Add `fit` prop on `VideoStub`, forward `focused` → `fit='contain'`, add `onDoubleClick` |
| `apps/desktop/src/components/ScreenShareViewer.tsx` | Renders a remote screen-share with watch/fullscreen chrome | Drop hardcoded `aspectRatio: '16/9'` on the unwatched preview button |
| `apps/desktop/src/components/InCallPanel.tsx` | Top-level in-call orchestration: stage layouts, fullscreen, controls, pin state plumbing | Add `StageLayout` selector; rewrite `CallStage` + `FullscreenCall` rendering; drop `grid-rows-*` from `gridColsFor`; wrap every tile cell in `aspect-video` |

No new files. Three modified files, each with a clear local responsibility.

---

## Task 1: VideoStub gains `fit` prop, default cover, contain when focused

**Files:**
- Modify: `apps/desktop/src/components/CallParticipantTile.tsx:299-339` (VideoStub component) + `CallParticipantTile.tsx:89-135` (CallParticipantTile wiring)

- [ ] **Step 1: Add `fit` prop to VideoStub**

In `CallParticipantTile.tsx`, replace the `VideoStub` signature (currently `function VideoStub({ userId, displayName, avatarUrl, videoTrack, me, small }: ...)`):

```tsx
function VideoStub({
  userId,
  displayName,
  avatarUrl,
  videoTrack,
  me,
  small,
  fit,
}: ParticipantTileProps & { small: boolean; fit: 'cover' | 'contain' }) {
```

And replace the className on the `<video>` element (currently `'h-full w-full object-cover ' + (me ? 'scale-x-[-1]' : '')`) with:

```tsx
className={
  'h-full w-full ' +
  (fit === 'contain' ? 'object-contain ' : 'object-cover ') +
  (me ? 'scale-x-[-1]' : '') /* mirror local preview */
}
```

- [ ] **Step 2: Forward `fit` from CallParticipantTile**

In `CallParticipantTile.tsx`, inside `CallParticipantTile`, replace:

```tsx
{video ? (
  <VideoStub {...props} small={small} />
) : (
  <AudioContent {...props} small={small} />
)}
```

with:

```tsx
{video ? (
  <VideoStub {...props} small={small} fit={focused ? 'contain' : 'cover'} />
) : (
  <AudioContent {...props} small={small} />
)}
```

- [ ] **Step 3: Type-check**

Run: `pnpm --filter @chatapp/desktop typecheck`
Expected: PASS (no errors in `CallParticipantTile.tsx`).

- [ ] **Step 4: Commit**

```bash
git add apps/desktop/src/components/CallParticipantTile.tsx
git commit -m "feat(call): VideoStub accepts fit prop, contain when focused"
```

---

## Task 2: ScreenShareViewer drops the hardcoded preview aspect

**Files:**
- Modify: `apps/desktop/src/components/ScreenShareViewer.tsx:108-128` (unwatched preview button)

- [ ] **Step 1: Remove `style={{ aspectRatio: '16 / 9' }}`**

In `ScreenShareViewer.tsx`, locate the `<button type="button" onClick={() => watchShare(...)}>` (the "Bildschirm anschauen" overlay). Replace:

```tsx
<button
  type="button"
  onClick={() => watchShare(share.participantId)}
  aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
  className="group relative block w-full cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
  style={{ aspectRatio: '16 / 9' }}
>
```

with:

```tsx
<button
  type="button"
  onClick={() => watchShare(share.participantId)}
  aria-label={t('app:call.watch_screen', { defaultValue: 'Bildschirm anschauen' })}
  className="group relative block h-full w-full flex-1 cursor-pointer overflow-hidden bg-ink-900 focus:outline-none"
>
```

`flex-1 h-full` makes the button fill whatever vertical space the parent grid cell (now `aspect-video`) gives it, instead of forcing its own 16:9 inside an arbitrary cell.

- [ ] **Step 2: Type-check + commit**

```bash
pnpm --filter @chatapp/desktop typecheck
git add apps/desktop/src/components/ScreenShareViewer.tsx
git commit -m "feat(call): drop hardcoded 16:9 on screen-share preview button"
```

---

## Task 3: Grid cells become aspect-video, drop grid-rows-*

**Files:**
- Modify: `apps/desktop/src/components/InCallPanel.tsx:1085-1099` (`gridColsFor`), `:1030-1052` (`CallStage` grid branch), `:1003-1025` (`CallStage` focus strip)

- [ ] **Step 1: Rewrite `gridColsFor` to drop row constraints**

In `InCallPanel.tsx`, replace the existing `gridColsFor`:

```tsx
function gridColsFor(n: number): string {
  // Explicit `grid-rows-*` so cells get a defined height (1fr of available
  // space). Without this, implicit rows default to auto → they size to
  // content, and a video element's intrinsic size blows the tile past the
  // container bounds (overlapping the toolbar below).
  if (n <= 1) return 'grid-cols-1 grid-rows-1';
  if (n === 2) return 'grid-cols-2 grid-rows-1';
  if (n === 3) return 'grid-cols-3 grid-rows-1';
  if (n === 4) return 'grid-cols-2 grid-rows-2';
  if (n <= 6) return 'grid-cols-3 grid-rows-2';
  if (n <= 9) return 'grid-cols-3 grid-rows-3';
  return 'grid-cols-4 grid-rows-3';
}
```

with:

```tsx
function gridColsFor(n: number): string {
  // Discord-style: column count only. Cells are `aspect-video` so their
  // height follows from their width, and the container centers them
  // vertically when the row stack is shorter than the available area.
  if (n <= 1) return 'grid-cols-1';
  if (n === 2) return 'grid-cols-2';
  if (n === 3) return 'grid-cols-3';
  if (n === 4) return 'grid-cols-2';
  if (n <= 6) return 'grid-cols-3';
  if (n <= 9) return 'grid-cols-3';
  return 'grid-cols-4';
}
```

- [ ] **Step 2: Wrap CallStage grid cells in aspect-video**

In `CallStage`, replace the grid-branch return (currently the block starting with `// Grid` then `const gridClass = gridColsFor(tiles.length);` …):

```tsx
// Grid
const gridClass = gridColsFor(tiles.length);
return (
  <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
    <div className={'grid h-full gap-2 ' + gridClass}>
      {tiles.map((p) => (
        <div key={p.id} className="[&>div]:h-full [&>div]:w-full">
          <TileRender ... />
        </div>
      ))}
    </div>
  </div>
);
```

with:

```tsx
// Grid
const gridClass = gridColsFor(tiles.length);
return (
  <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
    <div
      className={
        'grid h-full max-h-full gap-2 place-content-center ' + gridClass
      }
    >
      {tiles.map((p) => (
        <div
          key={p.id}
          className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
        >
          <TileRender
            tile={p}
            activeSpeakers={activeSpeakers}
            e2ee={e2ee}
            remoteScreenShares={remoteScreenShares}
            conversationMembers={conversationMembers}
            onClick={() => onFocusTile(p.id)}
            {...(onTileContextMenu
              ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
              : {})}
          />
        </div>
      ))}
    </div>
  </div>
);
```

`place-content-center` centers the row stack vertically; each cell is `aspect-video` so 16:9 wins over arbitrary row stretching.

- [ ] **Step 3: Wrap focus-strip thumbs in aspect-video**

In the `if (mode === 'focus' && speaker)` branch of `CallStage`, replace the strip cell wrapper (currently `<div key={p.id} className="h-full w-[240px] shrink-0 [&>div]:h-full">`):

```tsx
<div
  key={p.id}
  className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
>
```

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 `<CallStage mode={callMode} ...>`) and `CallStage` itself

- [ ] **Step 1: Map `stageLayout` → `mode` for `CallStage`**

In `InCallPanel.tsx`, locate the docked-call render that mounts `<CallStage mode={callMode} ...>`. Replace the `mode={callMode}` prop with a derived value:

```tsx
<CallStage
  tiles={tiles}
  speaker={bigTile}
  // Discord-style: layout decision is driven by StageLayout (see top of
  // InCallPanel), not by the user-visible callMode toggle. callMode still
  // gates the cinema/fullscreen entry — for the docked stage we collapse
  // 'focus' and 'bento' to whatever CallStage knows how to render.
  mode={stageLayout.kind === 'equal-grid' ? 'grid' : 'focus'}
  activeSpeakers={activeSpeakers}
  e2ee={e2ee}
  remoteScreenShares={remoteScreenShares}
  conversationMembers={conversation.members}
  onFocusTile={(id) => {
    // 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 `<ModeButton active={mode === 'grid'} onClick={() => 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
<ModeButton active={mode === 'grid'} onClick={() => onChange('grid')} label="Grid">
  <GridIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'focus'} onClick={() => onChange('focus')} label="Sprecher">
  <FocusIcon className="h-4 w-4" />
</ModeButton>
<ModeButton active={mode === 'fullscreen'} onClick={() => onChange('fullscreen')} label="Vollbild">
  <MaximizeIcon className="h-4 w-4" />
</ModeButton>
```

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 `<div>` 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 `<div>`:

```tsx
<div
  onClick={onClick}
  onDoubleClick={onDoubleClick}
  onContextMenu={onContextMenu}
  ...
>
```

- [ ] **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 `<div>` 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 (
    <div className="h-full [&>div]:h-full">
      <TileRender
        tile={tile}
        activeSpeakers={activeSpeakers}
        e2ee={e2ee}
        remoteScreenShares={remoteScreenShares}
        conversationMembers={conversationMembers}
        focused
        {...(onContextMenu ? { onContextMenu } : {})}
        {...(onDoubleClick ? { onDoubleClick } : {})}
      />
    </div>
  );
}
```

In `CallStage`'s focus branch, pass an `onDoubleClick` that clears the pin:

```tsx
<FocusedTile
  tile={speaker}
  e2ee={e2ee}
  activeSpeakers={activeSpeakers}
  remoteScreenShares={remoteScreenShares}
  conversationMembers={conversationMembers}
  onDoubleClick={() => 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
<div
  className="relative min-h-0 flex-1 cursor-pointer p-4 pb-2 [&>div]:h-full [&>div]:w-full"
  onDoubleClick={() => 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<string>;
  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 `<CallStage ...>` 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 (
      <div className="flex min-h-0 flex-1 flex-col gap-2.5 p-3">
        <div className="min-h-0 flex-1">
          <div
            className={
              'grid h-full max-h-full gap-2 place-content-center ' + bentoCols
            }
          >
            {shares.map((s) => (
              <div
                key={s.id}
                className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
              >
                <TileRender
                  tile={s}
                  activeSpeakers={activeSpeakers}
                  e2ee={e2ee}
                  remoteScreenShares={remoteScreenShares}
                  conversationMembers={conversationMembers}
                  onClick={() => onFocusTile(s.id)}
                  {...(onTileContextMenu
                    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
                    : {})}
                />
              </div>
            ))}
          </div>
        </div>
        {webcams.length > 0 && (
          <div className="flex h-[180px] gap-2.5 overflow-x-auto">
            {webcams.map((w) => (
              <div
                key={w.id}
                className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
              >
                <TileRender
                  tile={w}
                  activeSpeakers={activeSpeakers}
                  e2ee={e2ee}
                  remoteScreenShares={remoteScreenShares}
                  conversationMembers={conversationMembers}
                  size="small"
                  onClick={() => onFocusTile(w.id)}
                  {...(onTileContextMenu
                    ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
                    : {})}
                />
              </div>
            ))}
          </div>
        )}
      </div>
    );
  }

  // equal-grid
  const gridClass = gridColsFor(tiles.length);
  return (
    <div className={'min-h-0 flex-1 p-3 ' + (compact ? '' : 'p-4')}>
      <div
        className={
          'grid h-full max-h-full gap-2 place-content-center ' + gridClass
        }
      >
        {tiles.map((p) => (
          <div
            key={p.id}
            className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full"
          >
            <TileRender
              tile={p}
              activeSpeakers={activeSpeakers}
              e2ee={e2ee}
              remoteScreenShares={remoteScreenShares}
              conversationMembers={conversationMembers}
              onClick={() => onFocusTile(p.id)}
              {...(onTileContextMenu
                ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(p, e) }
                : {})}
            />
          </div>
        ))}
      </div>
    </div>
  );
}
```

- [ ] **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 ? (
  <div className="flex min-h-0 flex-1 flex-col gap-2 p-4">
    <div className="min-h-0 flex-1">
      <div className={'grid h-full max-h-full gap-2 place-content-center ' + gridColsFor(bentoShares.length)}>
        {bentoShares.map((s) => (
          <div key={s.id} className="aspect-video min-h-0 w-full [&>div]:h-full [&>div]:w-full">
            <TileRender
              tile={s}
              activeSpeakers={activeSpeakers}
              e2ee={e2ee}
              remoteScreenShares={remoteScreenShares}
              conversationMembers={conversationMembers}
              onClick={() => onFocusTile(s.id)}
              {...(onTileContextMenu
                ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(s, e) }
                : {})}
            />
          </div>
        ))}
      </div>
    </div>
    {bentoWebcams.length > 0 && (
      <div className="flex h-[180px] gap-2 overflow-x-auto">
        {bentoWebcams.map((w) => (
          <div key={w.id} className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full">
            <TileRender
              tile={w}
              activeSpeakers={activeSpeakers}
              e2ee={e2ee}
              remoteScreenShares={remoteScreenShares}
              conversationMembers={conversationMembers}
              size="small"
              onClick={() => onFocusTile(w.id)}
              {...(onTileContextMenu
                ? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(w, e) }
                : {})}
            />
          </div>
        ))}
      </div>
    )}
  </div>
) : (
  // ... 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.
