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:
byGalax
2026-05-12 21:31:26 +02:00
parent 05870ef8fa
commit 8baac2fd1e
4 changed files with 1103 additions and 24 deletions
+65 -23
View File
@@ -921,6 +921,7 @@ function TileRender({
conversationMembers,
size,
focused,
cinema,
onClick,
onDoubleClick,
onContextMenu,
@@ -932,6 +933,10 @@ function TileRender({
conversationMembers: StageProps['conversationMembers'];
size?: 'default' | 'small';
focused?: boolean;
/** True when rendered inside FullscreenCall's big-tile slot. Drives
* chrome-suppression on the inner ScreenShareViewer so its toggle
* doesn't visually collide with the cinema-mode strip-hidden button. */
cinema?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
onContextMenu?: (e: React.MouseEvent) => void;
@@ -954,6 +959,7 @@ function TileRender({
share={share}
avatarUrl={member?.profile?.avatarUrl ?? tile.avatarUrl}
displayName={member?.profile?.displayName ?? tile.displayName}
hideFullscreenToggle={cinema === true}
/>
</div>
);
@@ -1017,7 +1023,11 @@ function CallStage({
{others.map((p) => (
<div
key={p.id}
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
// Docked strip: thumbs grow to share the row width evenly
// (flex-1) but stay bounded so 1-2 tiles don't stretch into
// 2:1 panoramas. Max-w cap keeps the visual rhythm aligned
// with the share above; min-w keeps them readable when many.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={p}
@@ -1076,7 +1086,8 @@ function CallStage({
{webcams.map((w) => (
<div
key={w.id}
className="aspect-video h-full shrink-0 [&>div]:h-full [&>div]:w-full"
// Same docked-strip sizing as the focus branch above.
className="h-full flex-1 min-w-[200px] max-w-[460px] [&>div]:h-full [&>div]:w-full"
>
<TileRender
tile={w}
@@ -1100,17 +1111,28 @@ function CallStage({
// Grid (equal-grid fallthrough)
const gridClass = gridColsFor(tiles.length);
// aspect-video on every cell pushed the row past the section height on
// wide chat panels — a single cell at full width forced height = width
// × 9/16 (~400px on a 700px panel), which clipped the controls bar
// below. Let cells fill grid tracks normally for n>=2, and only enforce
// a 16:9 silhouette (capped width, centred) for the solo-user case.
const isSolo = tiles.length === 1;
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
'grid h-full max-h-full gap-2 auto-rows-fr 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"
className={
isSolo
? 'aspect-video w-full max-w-[480px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>div]:h-full [&>div]:w-full'
}
>
<TileRender
tile={p}
@@ -1301,9 +1323,12 @@ function FullscreenCall({
return (
<div className="fixed inset-0 z-[60] flex flex-col overflow-hidden bg-surface">
{/* Content area. pb-24 reserves ~96px space at the bottom for the
floating controls bar so tiles never sit behind it. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-24">
{/* Content area. pb-28 reserves ~112px space at the bottom for the
floating controls bar plus extra clearance so the tiles' bottom
name-chip (positioned `bottom-2` inside each tile) doesn't sit
directly underneath the controls — pb-24 was tight enough that
on wide screens with audio-only avatars the chip got eclipsed. */}
<div className="relative flex min-h-0 flex-1 flex-col pb-28">
{hasFocus ? (
<>
<div
@@ -1319,6 +1344,7 @@ function FullscreenCall({
remoteScreenShares={remoteScreenShares}
conversationMembers={conversationMembers}
focused
cinema
{...(onTileContextMenu
? { onContextMenu: (e: React.MouseEvent) => onTileContextMenu(speaker!, e) }
: {})}
@@ -1394,24 +1420,40 @@ function FullscreenCall({
<div className="min-h-0 flex-1 p-4">
<div
className={
'grid h-full max-h-full gap-2 place-content-center ' + gridClass
'grid h-full max-h-full gap-2 auto-rows-fr place-content-center ' +
gridClass
}
>
{visibleTiles.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>
))}
{visibleTiles.map((p) => {
const isSolo = visibleTiles.length === 1;
return (
<div
key={p.id}
// Same fix as docked equal-grid: aspect-video w-full on
// wide screens pushed cell height to ~ width × 9/16,
// which dragged the tile's bottom name-chip down behind
// the floating controls bar. For n>=2 fill the grid
// tracks normally; for solo, cap width + centre.
className={
isSolo
? 'aspect-video w-full max-w-[720px] justify-self-center [&>div]:h-full [&>div]:w-full'
: 'min-h-0 min-w-0 [&>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>
{pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-xs text-fg-muted">
@@ -9,6 +9,11 @@ interface ScreenShareViewerProps {
share: RemoteScreenShare;
avatarUrl: string | null;
displayName: string;
/** Suppress the in-share fullscreen toggle button. Used inside cinema
* mode where (a) the window is already OS-fullscreen and (b) the
* toggle collides visually with FullscreenCall's strip-hidden button
* at the same top-right corner. */
hideFullscreenToggle?: boolean;
}
// Click-to-watch gate, live <video> attach/detach, native fullscreen toggle.
@@ -21,6 +26,7 @@ export function ScreenShareViewer({
share,
avatarUrl,
displayName,
hideFullscreenToggle = false,
}: ScreenShareViewerProps) {
const { t } = useTranslation(['app']);
const videoRef = useRef<HTMLVideoElement | null>(null);
@@ -76,7 +82,7 @@ export function ScreenShareViewer({
defaultValue: displayName + ' teilt den Bildschirm',
})}
</span>
{watching && (
{watching && !hideFullscreenToggle && (
<button
type="button"
onClick={toggleFullscreen}
@@ -0,0 +1,892 @@
# 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 24 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 56 → 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 (24 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: 56 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 48. `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.
@@ -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 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).