Files
ChatApp/docs/superpowers/specs/2026-05-12-discord-call-tile-handling-design.md
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

7.9 KiB
Raw Permalink Blame History

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.
// 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 === nullsetCallMode('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:

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 focusedVideoStub.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).