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>
7.9 KiB
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:
- Webcam tiles look stretched/cropped wrong.
VideoStubusesobject-coverin every size, so when the tile aspect ratio diverges from the webcam stream, faces get cropped aggressively or distorted. - Screen-share tiles get the wrong aspect.
ScreenShareViewerusesobject-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. - Click-to-pin doesn't feel like Discord. Left-click in docked grid swaps
callModefrom grid → focus, but fullscreen-grid doesn't react; pinning is right-click-only; toggling off requires another right-click. - 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 letsaspect-videoon 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.
// 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 |
VideoStubaccepts a newfit?: 'cover' | 'contain'prop (defaultcover).CallParticipantTilepassescontainwhen itsfocusedprop is true.ScreenShareVieweralready usesobject-contain— no change there beyond removing the hardcodedaspectRatio: '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'andfocusedId !== null→ alsosetCallMode('focus'). - In
callMode === 'focus'andfocusedId === null→setCallMode('grid'). - In
callMode === 'fullscreen': onlyfocusedIdflips; layout reacts insideFullscreenCall.
- In
- 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:
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 = 12pagination 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, thenfirstShareIdtakes 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 (Stagelines ~520–610). It already setsflex h-full; introducingaspect-videoonly on the strip thumbs is additive and won't reflow the big tile.
Testing
Manual verification with two-user dev call:
- Webcam-only equal grid: 4 webcams, none pinned, no shares → all tiles 16:9 equal, faces visible cropped (cover), no stretching.
- Pinning toggle: click a webcam → that tile becomes big with
containfit (no face crop), rest strip; click again → back to grid. - Auto-promote: start a screen share → share is big, webcams strip, share aspect respected (no stretch).
- 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.
- Multi-share: two users share → bento stage, both shares visible at equal size, webcams below.
- Active speaker: someone talks → emerald border, no tile reorder/swap.
No automated tests (Storybook setup not in place for in-call layouts).