Files
ChatApp/docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md
T
byGalax 8baac2fd1e fix(call): tile sizing polish + cinema-mode chrome suppression + spec/plan docs
Equal-grid cells no longer set aspect-video — on wide chat panels this
forced cell height = width × 9/16 (~400px on a 700px panel) which pushed
the row past the section's max-h and ate the controls bar below. n>=2
cells now fill grid tracks normally via auto-rows-fr; the solo case
(n=1) keeps a 16:9 silhouette via aspect-video + max-w + justify-self-
center so a single-user-alone-calling view doesn't stretch into a
full-width slab. Same change applied to the fullscreen-grid path plus
+16px bottom-padding (pb-28) so audio-only avatars' name chip clears
the floating controls bar.

Docked stage strip thumbs (focus + bento) switch from aspect-video
shrink-0 to flex-1 min-w-[200px] max-w-[460px] so 2-3 thumbs share the
row width evenly under the share above, instead of clinging to the left
edge with dead space to the right. Fullscreen-cinema strip keeps the
small aspect-video thumbs the user explicitly approved.

ScreenShareViewer gains a hideFullscreenToggle prop; cinema mode passes
it via a new `cinema` prop on TileRender so the in-share fullscreen icon
doesn't visually collide with FullscreenCall's strip-hidden toggle at
the same top-right corner.

docs/superpowers/specs + plans for the Discord-style tile handling
workstream are committed alongside the implementation that completed it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 21:31:26 +02:00

140 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 578586) 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 ~520610). 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).