From 8c6f22c4ae746d9177b7cd4a34e953661930cd17 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Mon, 31 Aug 2026 12:28:52 +0200 Subject: [PATCH 1/7] fix(call): align one participant stripe to the end. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Dorra Jaouad --- src/components/CallView/Grid/VideosGrid.vue | 11 ++++- .../CallView/Grid/gridLayout.spec.ts | 22 ++++++++++ src/components/CallView/Grid/gridLayout.ts | 40 +++++++++++++++++++ 3 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/components/CallView/Grid/VideosGrid.vue b/src/components/CallView/Grid/VideosGrid.vue index 2046e4d993c..f8e7c7eee96 100644 --- a/src/components/CallView/Grid/VideosGrid.vue +++ b/src/components/CallView/Grid/VideosGrid.vue @@ -173,6 +173,7 @@ import { getHalfColumnCount, getHalfColumnMaxWidth, getHalfColumnMinWidth, + getJustifyContent, GRID_GAP, TARGET_ASPECT_RATIO, TILE_COLUMN_SPAN, @@ -465,8 +466,14 @@ export default { gridTemplateColumns: `repeat(${getHalfColumnCount(columns)}, ${halfColumnWidth})`, gridTemplateRows: `repeat(${rows}, minmax(${this.dpiAwareMinHeight}px, 1fr))`, // The columns no longer take the whole width once they are - // capped, so the grid itself has to center them - justifyContent: 'center', + // capped, so the grid itself has to place them + justifyContent: getJustifyContent({ + isStripe: this.isStripe, + totalTiles: this.totalTiles, + // The local video is laid out after the remote tiles, so it + // is the only tile of the grid when no remote one is shown + isLocalVideoAlone: !this.noLocalVideoReserve && this.displayedVideos.length === 0, + }), } }, diff --git a/src/components/CallView/Grid/gridLayout.spec.ts b/src/components/CallView/Grid/gridLayout.spec.ts index 34e3d943198..5e059ca351c 100644 --- a/src/components/CallView/Grid/gridLayout.spec.ts +++ b/src/components/CallView/Grid/gridLayout.spec.ts @@ -11,6 +11,7 @@ import { getHalfColumnCount, getHalfColumnMaxWidth, getHalfColumnMinWidth, + getJustifyContent, getMinTileHeight, getMinTileWidth, GRID_GAP, @@ -342,4 +343,25 @@ describe('gridLayout', () => { } }) }) + + describe('getJustifyContent', () => { + test('centers the tiles of the grid', () => { + expect(getJustifyContent({ isStripe: false, totalTiles: 1, isLocalVideoAlone: true })).toBe('center') + expect(getJustifyContent({ isStripe: false, totalTiles: 5, isLocalVideoAlone: false })).toBe('center') + }) + + test('centers the tiles of a stripe holding several of them', () => { + expect(getJustifyContent({ isStripe: true, totalTiles: 2, isLocalVideoAlone: false })).toBe('center') + }) + + test('keeps a lone local video at the end of the stripe', () => { + expect(getJustifyContent({ isStripe: true, totalTiles: 1, isLocalVideoAlone: true })).toBe('end') + }) + + test('keeps a lone remote tile at the start of the stripe', () => { + // The local video is not shown as a tile, so the only tile left is a + // remote one and it belongs to the start of the row + expect(getJustifyContent({ isStripe: true, totalTiles: 1, isLocalVideoAlone: false })).toBe('start') + }) + }) }) diff --git a/src/components/CallView/Grid/gridLayout.ts b/src/components/CallView/Grid/gridLayout.ts index 7070d901026..5ebe4f05a60 100644 --- a/src/components/CallView/Grid/gridLayout.ts +++ b/src/components/CallView/Grid/gridLayout.ts @@ -375,3 +375,43 @@ export function computeTilePlacements({ return placements } + +/** Alignment of the tile columns within the grid */ +export type GridJustifyContent = 'center' | 'start' | 'end' + +type JustifyContentOptions = { + /** Whether the grid is shown as a stripe */ + isStripe: boolean + /** Number of tiles laid out on the page, including the local video tile */ + totalTiles: number + /** Whether the local video is the only tile of the grid */ + isLocalVideoAlone: boolean +} + +/** + * Alignment of the tile columns within the grid. + * + * The columns no longer take the whole width once they are capped, so the grid + * itself has to place them. They are centered, except in a stripe holding a + * single tile: that tile keeps the side it would sit on in a fuller stripe + * rather than moving to the middle. The local video is laid out after the + * remote ones, so it stays at the inline end, while a lone remote tile keeps + * the inline start the tiles are laid out from - which is where it belongs once + * the local video is not shown as a tile. + * + * @param options - the layout inputs + * @param options.isStripe - whether the grid is shown as a stripe + * @param options.totalTiles - number of tiles on the page, including the local video tile + * @param options.isLocalVideoAlone - whether the local video is the only tile + */ +export function getJustifyContent({ + isStripe, + totalTiles, + isLocalVideoAlone, +}: JustifyContentOptions): GridJustifyContent { + if (!isStripe || totalTiles !== 1) { + return 'center' + } + + return isLocalVideoAlone ? 'end' : 'start' +} From b2c138c64d67e020eb529c43b29bec78b29254a0 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Mon, 31 Aug 2026 12:29:02 +0200 Subject: [PATCH 2/7] fix(call): paint the tiles of a call on a solid background The background of a tile was a translucent tint, so whatever the tile sat over - the promoted video, in a stripe - showed through it in full detail. Paint the shade that tint used to composite to instead, and drop the `background-image` reset left over with it: no rule sets an image here. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Dorra Jaouad --- src/components/CallView/shared/VideoBackground.vue | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/CallView/shared/VideoBackground.vue b/src/components/CallView/shared/VideoBackground.vue index a3105f63158..0e3cee8c32e 100644 --- a/src/components/CallView/shared/VideoBackground.vue +++ b/src/components/CallView/shared/VideoBackground.vue @@ -21,12 +21,14 @@ const isDarkTheme = useIsDarkTheme() top: 0; height: 100%; width: 100%; - background-color: rgba(var(--overlay-color), 0.3); - background-image: none; + // A solid surface: nothing of the call view behind the tile shows through + // it. The two colours are what the translucent tint they replace used to + // composite to over the background of the call, so a tile keeps the shade + // it had in either theme. + background-color: #363636; - --overlay-color: 0, 0, 0; &.dark-theme { - --overlay-color: 255, 255, 255; + background-color: #515151; } } From 1e3c9b711bbfb2b830c46c29846c329dc417fdb5 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Mon, 31 Aug 2026 12:29:15 +0200 Subject: [PATCH 3/7] feat(call): lay the stripe out as a card The stripe took no surface of its own, so its tiles floated over the call background whether or not there was anything behind them. Give it a rounded card to sit on, which slides up out of the bottom of the call view as the stripe expands and back down as it collapses. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Dorra Jaouad --- src/components/CallView/Grid/StripeControls.vue | 1 + src/components/CallView/Grid/VideosGrid.vue | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/src/components/CallView/Grid/StripeControls.vue b/src/components/CallView/Grid/StripeControls.vue index bedb241903b..3a3c52c2b3b 100644 --- a/src/components/CallView/Grid/StripeControls.vue +++ b/src/components/CallView/Grid/StripeControls.vue @@ -123,6 +123,7 @@ const toggleLabel = computed(() => props.isOpen gap: var(--default-grid-baseline); width: fit-content; padding-block-start: var(--default-grid-baseline); + padding-inline-end: var(--default-grid-baseline); // Barely there while the call is not being looked at, as they sit over the // tiles, and in their own color as soon as it is opacity: .4; diff --git a/src/components/CallView/Grid/VideosGrid.vue b/src/components/CallView/Grid/VideosGrid.vue index f8e7c7eee96..5d20c376cab 100644 --- a/src/components/CallView/Grid/VideosGrid.vue +++ b/src/components/CallView/Grid/VideosGrid.vue @@ -676,6 +676,22 @@ export default { // Kept out of the grid itself, whose measured height is the height of its // tiles padding-block: var(--grid-gap); + // Keeps the tiles off the rounded corners of the card + padding-inline: var(--grid-gap); + + // The card the tiles sit on + .grid-main-wrapper:not(.overlap) & { + background-color: #262626; + border-radius: var(--border-radius-container); + } + + // A stripe overlapping the promoted video takes no card of its own, which + // would band that video across: its tiles - the self camera, the only one + // left in a one to one call - are lifted off the call by a shadow instead + .overlap & :deep(.localVideoContainer), + .overlap & :deep(.video-container-grid) { + box-shadow: 0 0px 10px rgba(0, 0, 0, 0.5); + } } .dev-mode-video { From 94ca6f235fc7dc5efa9e8521d1a1dcac136b5a75 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Mon, 31 Aug 2026 13:11:39 +0200 Subject: [PATCH 4/7] fix(call): float the stripe while nobody else is in the call The promoted video covers the whole call view, the stripe floating over it, while a single participant fills the promoted area. A call nobody else has joined yet was left out, so the stripe sat on a card of its own for as long as it was the only thing on screen and dropped it the moment somebody joined. Lay an empty promoted area out the same way. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Dorra Jaouad --- src/components/CallView/CallView.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/CallView/CallView.vue b/src/components/CallView/CallView.vue index a72784c9020..577db392787 100644 --- a/src/components/CallView/CallView.vue +++ b/src/components/CallView/CallView.vue @@ -455,8 +455,13 @@ export default { return this.callParticipantModels.length === 1 }, + // Whether the promoted video covers the whole call view, with the stripe + // floating over it rather than taking room of its own. A promoted area + // held by a single participant is laid out that way, and so is one with + // nobody in it yet. showFullPage() { - return this.isOneToOne && !(this.showLocalScreen || this.showRemoteScreen || this.showSelectedScreen) + return this.callParticipantModels.length <= 1 + && !(this.showLocalScreen || this.showRemoteScreen || this.showSelectedScreen) }, hasLocalVideo() { From 95d39666aeacbc92a904b58e238ca4849c11ba47 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Mon, 31 Aug 2026 13:11:57 +0200 Subject: [PATCH 5/7] refactor(call): name the promoted video prop after the layout it drives `isOneToOne` counted the participants of the call to inset the promoted video by the padding of the wrapper, which is what the full page layout asks for. It names the call rather than the layout, and the two have since parted ways: a call nobody else has joined is laid out full page and is not one to one. Pass the layout itself. SpeakersGrid handed the flag to tiles that are never big, where the class it sets matches no rule, so it is dropped there. Assisted-by: ClaudeCode:claude-opus-5 Signed-off-by: Dorra Jaouad --- src/components/CallView/CallView.vue | 9 ++------- src/components/CallView/Grid/SpeakersGrid.vue | 3 --- src/components/CallView/shared/VideoVue.vue | 9 +++++---- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/components/CallView/CallView.vue b/src/components/CallView/CallView.vue index 577db392787..3608ed5f9dc 100644 --- a/src/components/CallView/CallView.vue +++ b/src/components/CallView/CallView.vue @@ -34,7 +34,7 @@ :model="selectedCallParticipantModel" :sharedData="sharedDatas[selectedVideoPeerId]" :showTalkingHighlight="false" - :isOneToOne="isOneToOne" + :isFullPage="showFullPage" isGrid isBig fitVideo /> @@ -77,7 +77,6 @@ :models="promotedSpeakerModels" :sharedDatas="sharedDatas" :showVideoOverlay="showVideoOverlay" - :isOneToOne="isOneToOne" @selectVideo="handleSelectVideo" /> @@ -451,10 +450,6 @@ export default { return this.selectedVideoPeerId !== null && !this.screens.includes(this.selectedVideoPeerId) }, - isOneToOne() { - return this.callParticipantModels.length === 1 - }, - // Whether the promoted video covers the whole call view, with the stripe // floating over it rather than taking room of its own. A promoted area // held by a single participant is laid out that way, and so is one with diff --git a/src/components/CallView/Grid/SpeakersGrid.vue b/src/components/CallView/Grid/SpeakersGrid.vue index d9d595c804e..63c8fbdd750 100644 --- a/src/components/CallView/Grid/SpeakersGrid.vue +++ b/src/components/CallView/Grid/SpeakersGrid.vue @@ -23,8 +23,6 @@ const props = defineProps<{ sharedDatas: Record /** Whether the video overlay is currently shown */ showVideoOverlay?: boolean - /** Whether the call is a one to one conversation */ - isOneToOne?: boolean }>() const emit = defineEmits<{ @@ -101,7 +99,6 @@ function tileStyle(index: number) { :model="model" :sharedData="sharedDatas[model.attributes.peerId]" :showVideoOverlay="showVideoOverlay" - :isOneToOne="isOneToOne" isGrid fitVideo @clickVideo="emit('selectVideo', model.attributes.peerId)" /> diff --git a/src/components/CallView/shared/VideoVue.vue b/src/components/CallView/shared/VideoVue.vue index 75d18ca2006..0ae1711138b 100644 --- a/src/components/CallView/shared/VideoVue.vue +++ b/src/components/CallView/shared/VideoVue.vue @@ -189,8 +189,9 @@ export default { default: false, }, - // True when this video component is used in one to one conversations - isOneToOne: { + // True when this video fills the whole call view, with the stripe + // floating over it + isFullPage: { type: Boolean, default: false, }, @@ -323,7 +324,7 @@ export default { presenter: this.isPresenterOverlay && this.mouseover, 'video-container-grid': this.isGrid, 'video-container-big': this.isBig, - 'one-to-one': this.isOneToOne, + 'full-page': this.isFullPage, 'presenter-overlay': this.isPresenterOverlay, } }, @@ -654,7 +655,7 @@ export default { .video-container-big { position: absolute; - &.one-to-one { + &.full-page { width: calc(100% - var(--wrapper-padding) * 2); } From bc6279c05af7da373b1490ead92c49aa50ea7e93 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Mon, 31 Aug 2026 13:07:32 +0200 Subject: [PATCH 6/7] fix(Stripe): adjust positioning of stripe against promoted area Signed-off-by: Dorra Jaouad --- src/components/CallView/Grid/StripeControls.vue | 3 +-- src/components/CallView/Grid/VideosGrid.vue | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/CallView/Grid/StripeControls.vue b/src/components/CallView/Grid/StripeControls.vue index 3a3c52c2b3b..9ccabb76ebd 100644 --- a/src/components/CallView/Grid/StripeControls.vue +++ b/src/components/CallView/Grid/StripeControls.vue @@ -122,8 +122,7 @@ const toggleLabel = computed(() => props.isOpen align-items: center; gap: var(--default-grid-baseline); width: fit-content; - padding-block-start: var(--default-grid-baseline); - padding-inline-end: var(--default-grid-baseline); + padding-block-start: var(--grid-gap); // Barely there while the call is not being looked at, as they sit over the // tiles, and in their own color as soon as it is opacity: .4; diff --git a/src/components/CallView/Grid/VideosGrid.vue b/src/components/CallView/Grid/VideosGrid.vue index 5d20c376cab..cd8717b15fe 100644 --- a/src/components/CallView/Grid/VideosGrid.vue +++ b/src/components/CallView/Grid/VideosGrid.vue @@ -621,6 +621,7 @@ export default { &--stripe { height: var(--stripe-height); + margin-block-start: var(--grid-gap); } } @@ -767,15 +768,15 @@ export default { .stripe-controls-position { position: absolute; - top: var(--grid-gap); - inset-inline-end: var(--grid-gap); + top: calc(var(--default-grid-baseline) * 3); + inset-inline-end: calc(var(--default-grid-baseline) * 3); z-index: 2; transition: top var(--animation-slow) ease-in-out; // A collapsed stripe holds no tile to sit over, and no room of its own to // sit in, so the controls take the room above it &--collapsed { - top: calc(-1 * (var(--clickable-area-small) + var(--default-grid-baseline) + var(--grid-gap))); + top: calc(-1 * (var(--clickable-area-small) + var(--grid-gap) + var(--grid-gap))); } } From 98167fbe441a3c0e990396742cbb039bd7c7b7d1 Mon Sep 17 00:00:00 2001 From: Dorra Jaouad Date: Thu, 3 Sep 2026 13:37:46 +0200 Subject: [PATCH 7/7] fix(grid): fix glitched grid on full view. It is because the animation delays the right dimensions the computation is done on pre-animated object Signed-off-by: Dorra Jaouad --- .../CallView/Grid/useGridDimensions.ts | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/src/components/CallView/Grid/useGridDimensions.ts b/src/components/CallView/Grid/useGridDimensions.ts index 953a92d2ec9..8af81439331 100644 --- a/src/components/CallView/Grid/useGridDimensions.ts +++ b/src/components/CallView/Grid/useGridDimensions.ts @@ -90,8 +90,21 @@ export function useGridDimensions({ return } - gridWidth.value = element.clientWidth - gridHeight.value = element.clientHeight + const width = element.clientWidth + const height = element.clientHeight + + // The grid is hidden or mid-transition (the stripe collapses and expands + // by its height): measuring it now would shrink the layout down to a + // single row and it would be kept until the next resize, as the element + // is given back its size by a transition rather than by a layout change + // of its own. Keep the current layout instead and wait for the + // `ResizeObserver` to report the final size. + if (width <= 0 || height <= 0) { + return + } + + gridWidth.value = width + gridHeight.value = height const dimensions = computeGridDimensions({ gridWidth: gridWidth.value, @@ -115,10 +128,26 @@ export function useGridDimensions({ let resizeObserver: ResizeObserver | null = null onMounted(() => { + resizeObserver = new ResizeObserver(debouncedRecompute) + if (wrapper.value) { - resizeObserver = new ResizeObserver(debouncedRecompute) resizeObserver.observe(wrapper.value) } + + // The grid element is observed as well: it is the one being measured and + // its size does not always follow the size of the wrapper, which keeps + // the whole height of the call view while the grid is collapsed and + // expanded by a transition. The element is unmounted along with the + // collapsed stripe, so it is observed again whenever it comes back. + watch(grid, (element, previousElement) => { + if (previousElement) { + resizeObserver?.unobserve(previousElement) + } + if (element) { + resizeObserver?.observe(element) + } + }, { immediate: true }) + recompute() })