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>
This commit is contained in:
@@ -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).
|
||||
Reference in New Issue
Block a user