fix: voice room UI overlap, sizing, and scroll button issues (#371)

Co-authored-by: Aditya Chaudhary <30+useradityaa@noreply.coracle.social>
This commit is contained in:
Aditya Chaudhary 2026-08-27 23:06:10 +00:00 committed by hodlbod
parent fe9b0fe87a
commit 2974faa2e9
6 changed files with 305 additions and 190 deletions

View file

@ -286,6 +286,15 @@ export const callState = writable<CallState>(CallState.Disconnected)
export const callTargetRoom = writable<Room | undefined>(undefined)
export const deriveIsCallActiveElsewhere = (url: string | undefined, h: string | undefined) =>
derived(
[callState, callTargetRoom],
([$state, $targetRoom]) =>
($state === CallState.Joining || $state === CallState.Connected) &&
$targetRoom !== undefined &&
!($targetRoom.url === url && $targetRoom.h === h),
)
export const speakingParticipants = writable<CallParticipant[]>([])
export const participantMediaState = writable(new Map<string, ParticipantMediaState>())

View file

@ -18,6 +18,7 @@
callTargetRoom,
callMicMuted,
cancelJoinVoiceRoom,
deriveIsCallActiveElsewhere,
leaveVoiceRoom,
toggleMute,
} from "@app/call"
@ -27,19 +28,10 @@
// The call's own room page already shows full controls (CallControlBar), so
// the banner would just be redundant clutter there.
const isViewingCurrentVoiceRoom = $derived(
$callTargetRoom !== undefined &&
routeUrl !== undefined &&
typeof h === "string" &&
$callTargetRoom.url === routeUrl &&
$callTargetRoom.h === h,
)
const visible = $derived(
($callState === CallState.Joining || $callState === CallState.Connected) &&
$callTargetRoom !== undefined &&
!isViewingCurrentVoiceRoom,
const isCallActiveElsewhere = $derived(
deriveIsCallActiveElsewhere(routeUrl, typeof h === "string" ? h : undefined),
)
const visible = $derived($isCallActiveElsewhere)
const roomName = $derived(
$callTargetRoom ? displayRoom($callTargetRoom.url, $callTargetRoom.h) : "",

View file

@ -67,6 +67,16 @@
const chatUnread = $derived($notifications.has(roomPath))
const isChatPanelActive = $derived($videoCallLayout === VideoCallLayout.Split)
// With chat open, the floating bar shares the video pane with the sidebar rather
// than spanning the full width — a smaller bar reads as sized for that narrower
// space instead of just looking unchanged. Sized below mobile's max-md:h-9 (36px)
// since desktop is mouse-driven, not a touch target. max-md sizing itself is
// unaffected: mobile chat is a full-screen overlay, not a space-sharing sidebar.
const compactButtonClass = $derived(
cx("max-md:h-9 max-md:w-9", isChatPanelActive && "md:h-8 md:w-8"),
)
const iconSize = $derived(isChatPanelActive ? 3.5 : 4.5)
const onChatToggle = () => {
videoCallLayout.update(p =>
p === VideoCallLayout.Split ? VideoCallLayout.Video : VideoCallLayout.Split,
@ -93,9 +103,9 @@
<Button
data-tip="Cancel"
aria-label="Cancel joining voice room"
class="button button-circle button-xs button-neutral"
class="button button-circle button-sm button-neutral"
onclick={cancelJoinVoiceRoom}>
<Icon icon={CloseCircle} size={3} />
<Icon icon={CloseCircle} size={4} />
</Button>
</div>
{/if}
@ -105,6 +115,7 @@
out:fade={{duration: 120}}
class={cx(
"pointer-events-auto col-start-1 row-start-1 flex items-center gap-1.5 rounded-full border border-line bg-surface/95 p-1.5 shadow-xl backdrop-blur-md md:gap-2 md:p-2",
isChatPanelActive && "md:gap-1 md:p-1",
hideConnectedOnDesktop && "md:hidden",
)}>
<Button
@ -112,11 +123,12 @@
aria-label={$callMicMuted ? "Unmute microphone" : "Mute microphone"}
aria-pressed={!$callMicMuted}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
"button button-circle",
compactButtonClass,
$callMicMuted ? "button-neutral" : "button-primary",
)}
onclick={toggleMute}>
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={4.5} />
<Icon icon={$callMicMuted ? MicrophoneOff : Microphone} size={iconSize} />
</Button>
{#if $currentCallSession}
<Button
@ -124,13 +136,14 @@
aria-label={$currentCallSession.cameraOn ? "Turn off camera" : "Turn on camera"}
aria-pressed={$currentCallSession.cameraOn}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
"button button-circle",
compactButtonClass,
$currentCallSession.cameraOn ? "button-primary" : "button-neutral",
)}
onclick={toggleCamera}>
<Icon
icon={$currentCallSession.cameraOn ? VideocameraRecord : VideocameraOff}
size={4.5} />
size={iconSize} />
</Button>
{#if !Capacitor.isNativePlatform()}
<Button
@ -140,31 +153,33 @@
: "Share screen"}
aria-pressed={$currentCallSession.screenShareOn}
class={cx(
"button button-circle max-md:h-9 max-md:w-9",
"button button-circle",
compactButtonClass,
$currentCallSession.screenShareOn ? "button-primary" : "button-neutral",
)}
onclick={toggleScreenShare}>
<Icon icon={Monitor} size={4.5} />
<Icon icon={Monitor} size={iconSize} />
</Button>
{/if}
{/if}
<Button
data-tip="Call settings"
aria-label="Call settings"
class="button button-circle button-neutral max-md:h-9 max-md:w-9"
class={cx("button button-circle button-neutral", compactButtonClass)}
onclick={openCallSettings}>
<Icon icon={Settings} size={4.5} />
<Icon icon={Settings} size={iconSize} />
</Button>
<Button
data-tip="Toggle chat"
aria-label="Toggle chat panel"
aria-pressed={isChatPanelActive}
class={cx(
"button button-circle relative max-md:h-9 max-md:w-9",
"button button-circle relative",
compactButtonClass,
isChatPanelActive ? "button-primary" : "button-neutral",
)}
onclick={onChatToggle}>
<Icon icon={ChatRound} size={4.5} />
<Icon icon={ChatRound} size={iconSize} />
{#if chatUnread}
<span
class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full bg-primary ring-2 ring-surface"
@ -175,9 +190,9 @@
<Button
data-tip="Leave call"
aria-label="Leave voice room"
class="button button-circle button-error max-md:h-9 max-md:w-9"
class={cx("button button-circle button-error", compactButtonClass)}
onclick={leaveVoiceRoom}>
<Icon icon={EndCall} size={5} />
<Icon icon={EndCall} size={isChatPanelActive ? 4 : 5} />
</Button>
</div>
{/if}

View file

@ -32,7 +32,14 @@
import CallControlBar from "@app/components/CallControlBar.svelte"
import {deletes, relays, rooms, thunks, user} from "@app/core"
import {joinRoom, leaveRoom} from "@app/access"
import {CallState, callTargetRoom, callState, VideoCallLayout, videoCallLayout} from "@app/call"
import {
CallState,
callTargetRoom,
callState,
VideoCallLayout,
videoCallLayout,
deriveIsCallActiveElsewhere,
} from "@app/call"
import {
PROTECTED,
RoomType,
@ -64,6 +71,9 @@
isVoiceRoom && $callState === CallState.Connected && isCallTargetingThisRoom,
)
const isCallActiveElsewhere = $derived(deriveIsCallActiveElsewhere(url, h))
const callBannerVisible = $derived($isCallActiveElsewhere)
// Reuses voiceConnectedHere (rather than re-deriving isVoiceRoom/callState) so it
// can't diverge and stay true for a different voice room the call isn't targeting
// — that previously hid this room's messages/compose row while connected elsewhere.
@ -487,16 +497,22 @@
})
</script>
<div class={cx("flex min-h-0 flex-1 flex-col", isOverlayChat && "relative")}>
<div
class={cx(
"flex min-h-0 flex-1 flex-col",
isOverlayChat && "max-md:relative",
// Desktop: chat is a real flex sibling beside the video pane rather than an
// absolute overlay on top of it, so opening/closing it actually resizes the
// video pane instead of just floating a card over it — see the sidebar's
// width transition below for why this fixes controls jumping on toggle.
voiceConnectedHere && "md:flex-row",
)}>
{#if h && voiceConnectedHere}
<VideoCallContent
layout={$videoCallLayout}
{url}
{h}
class={cx(
"hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex",
isOverlayChat && "md:absolute md:inset-0",
)} />
class="hidden min-h-0 w-full min-w-0 flex-1 flex-col md:flex" />
{/if}
{#if h && voiceConnectedHere}
@ -512,8 +528,21 @@
class={cx(
"room flex min-h-0 min-w-0 flex-1 flex-col",
videoCallChatHidden && "hidden",
// Desktop: a real sidebar column (fixed width, animated) instead of an absolute
// overlay card — its width transition is what makes the video pane (and the
// controls centered inside it, see VideoCallContent) resize and re-center in
// step with the chat opening/closing, instead of jumping.
voiceConnectedHere &&
"md:flex md:min-w-0 md:flex-none md:border-0 md:border-l md:border-line md:bg-surface md:shadow-lg md:transition-all md:duration-300 md:ease-in-out",
voiceConnectedHere &&
(isOverlayChat
? "md:w-72 md:opacity-100"
: "md:w-0 md:border-l-0 md:opacity-0 md:shadow-none md:pointer-events-none"),
isOverlayChat &&
"absolute inset-x-2 top-4 bottom-[calc(3.5rem+var(--saib))] z-popover overflow-hidden rounded-2xl border border-line bg-surface/95 shadow-lg md:left-auto md:right-4 md:bottom-4 md:w-96 md:max-w-[26rem] md:bg-surface/90",
// Narrower width (w-72) plus generous insets keep the mobile card's edges from
// landing flush against a video tile's edge. No shadow on mobile — the card
// already reads as distinct from the video behind it via its own background.
"max-md:absolute max-md:inset-x-2 max-md:top-4 max-md:bottom-[calc(3.5rem+var(--saib))] max-md:z-popover max-md:overflow-hidden max-md:rounded-2xl max-md:border max-md:border-line max-md:bg-surface/95",
)}>
{#if isOverlayChat}
<Button
@ -524,153 +553,184 @@
</Button>
{/if}
{#if h}
<RoomPinnedMessages {url} {h} />
{/if}
<!-- Separate from the panel's own overflow: the panel itself can't clip its own
overflow (that would also clip its desktop shadow), so this inner wrapper is
what keeps content from spilling out while the panel's width is animating
toward 0 on close. -->
<div
bind:this={element}
onscroll={onScroll}
class={cx(
"room__content scroll-container",
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
"flex min-h-0 min-w-0 flex-1 flex-col",
voiceConnectedHere && "md:overflow-hidden",
)}>
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<div class="py-20">
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
<p class="opacity-75">You aren't currently a member of this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
{#if joining}
<Spinner size="sm" />
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
{/if}
</div>
</div>
{:else}
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
{id}
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if}
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{#if h}
<RoomPinnedMessages {url} {h} />
{/if}
<div class="h-screen"></div>
</div>
<div
class={cx(
"room__compose flex flex-row items-center gap-1 px-2",
showMobileVideoPanel && "max-md:hidden",
// the connected bar (mic/camera/screenshare/settings/chat/leave) is too wide
// to sit beside the compose input on any screen size — give it its own row.
// gap-2 only applies here (the inline row stays flush with no gap).
voiceConnectedHere && "flex-col items-stretch gap-2",
)}>
<div class="room__compose-inner min-w-0 flex-1">
<div
bind:this={element}
onscroll={onScroll}
class={cx(
"room__content scroll-container",
showMobileVideoPanel ? "hidden md:flex md:flex-col-reverse" : "flex",
)}>
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted}
<div class="card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
{#if joining}
<Spinner size="sm" />
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
<div class="py-20">
<div class="card flex flex-col gap-8 m-auto max-w-md items-center text-center">
<p class="opacity-75">You aren't currently a member of this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
{#if joining}
<Spinner size="sm" />
{:else}
<Icon icon={Login2} />
{/if}
Join Room
</Button>
{/if}
</div>
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
{#if loadingForward && elements.length > 0}
<p class="py-20 flex justify-center">
<Spinner loading={loadingForward}>Looking for messages...</Spinner>
</p>
{/if}
{#each elements as { type, id, value, showPubkey } (id)}
{#if type === "new-messages"}
<div
{id}
class={cx("flex items-center py-2 text-xs transition-colors", {
"opacity-0": showFixedNewMessages,
})}>
<div class="h-px grow bg-primary text-primary-content"></div>
<p
class="rounded-full bg-primary text-primary-content px-2 py-1"
style="color: var(--primary-content)">
New Messages
</p>
<div class="h-px grow bg-primary text-primary-content"></div>
</div>
{:else if type === "date"}
<Divider>{value}</Divider>
{:else}
{@const event = value as TrustedEvent}
{#if event.kind === addMemberKind}
<RoomItemAddMember {url} {event} />
{:else}
<RoomItem
{url}
{event}
{replyTo}
{showPubkey}
canEdit={canEditEvent}
onEdit={onEditEvent} />
{/if}
{/if}
{#if sharedEvent}
<RoomComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/each}
<p class="flex h-10 items-center justify-center py-20">
{#if loadingBackward}
<Spinner loading={loadingBackward}>Looking for messages...</Spinner>
{:else}
<Spinner>End of message history</Spinner>
{/if}
</p>
{/if}
<div class="h-screen"></div>
</div>
<div
class={cx(
// no explicit flex-row: it's flex's default direction, and Tailwind emits
// .flex-col before .flex-row in the compiled sheet, so having both classes
// present at once (base flex-row + conditional flex-col below) let flex-row
// always win — the compose row and call controls never actually stacked.
"room__compose flex items-center gap-1 px-2",
showMobileVideoPanel && "max-md:hidden",
// the connected bar (mic/camera/screenshare/settings/chat/leave) is too wide
// to sit beside the compose input on any screen size — give it its own row.
// gap-2 only applies here (the inline row stays flush with no gap).
voiceConnectedHere && "flex-col items-stretch gap-2",
)}>
<div class="room__compose-inner min-w-0 flex-1">
{#if $room?.meta?.isPrivate() && $membershipStatus !== MembershipStatus.Granted}
<!-- pass -->
{:else if $room?.meta?.isRestricted() && $membershipStatus !== MembershipStatus.Granted}
<div class="card m-4 flex flex-row items-center justify-between px-4 py-3">
<p class="opacity-75">Only members are allowed to post to this room.</p>
{#if $membershipStatus === MembershipStatus.Pending}
<Button class="button button-neutral button-sm" disabled={leaving} onclick={leave}>
<Icon icon={ClockCircle} />
Access Pending
</Button>
{:else}
<Button class="button button-neutral button-sm" disabled={joining} onclick={join}>
{#if joining}
<Spinner size="sm" />
{:else}
<Icon icon={Login2} />
{/if}
Ask to Join
</Button>
{/if}
</div>
{:else}
<div>
{#if parent}
<RoomComposeParent {url} event={parent} clear={clearParent} verb="Replying to" />
{/if}
{#if sharedEvent}
<RoomComposeParent {url} event={sharedEvent} clear={clearShare} verb="Sharing" />
{/if}
{#if eventToEdit}
<RoomComposeEdit clear={clearEventToEdit} />
{/if}
</div>
{#key initialValues}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
bind:this={compose} />
{/key}
{/if}
</div>
{#if h}
<div
class={cx(
"hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2",
// CallControlBar's connected pill is itself md:hidden here (a wider copy
// floats over the video instead — see VideoCallContent.svelte), so once
// this row actually stacks (flex-col, above) this wrapper has nothing to
// show on desktop and was otherwise left as an empty padded bar under
// the compose box. voiceConnectedHere is exactly the state where the
// pill is connected, so hiding it here is safe for the join/joining
// states, which don't hit this flex-col branch at all.
voiceConnectedHere && "md:hidden",
)}>
<CallControlBar {url} {h} hideConnectedOnDesktop />
</div>
{#key initialValues}
<RoomCompose
{url}
{h}
{onSubmit}
{onEscape}
{onEditPrevious}
{initialValues}
bind:this={compose} />
{/key}
{/if}
</div>
{#if h}
<div class="hide-on-keyboard flex flex-shrink-0 items-center justify-center py-2">
<CallControlBar {url} {h} hideConnectedOnDesktop />
</div>
{/if}
</div>
</div>
</div>
{#if showScrollButton}
<div in:fade class="chat__scroll-down">
<div
in:fade
class={cx(
"chat__scroll-down",
callBannerVisible && "chat__scroll-down--banner",
isOverlayChat && "chat__scroll-down--overlay-chat",
)}>
<Button class="button button-neutral button-circle" onclick={scrollToBottom}>
<Icon icon={AltArrowDown} />
</Button>

View file

@ -190,14 +190,22 @@
}
const panelChrome = $derived(
cx(
mobile &&
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-surface px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
!mobile &&
"flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-surface px-2 pb-2 pt-4",
className,
),
mobile
? cx(
"flex min-h-0 w-full flex-1 flex-col gap-2 overflow-y-auto overflow-x-hidden bg-surface px-2 pt-4 md:hidden pb-[calc(3.5rem+var(--saib))]",
className,
)
: "flex min-h-0 w-full min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-surface px-2 pb-2 pt-4",
)
// Desktop: `className` (the instance's own visibility/sizing, e.g. "hidden ...
// md:flex") lives on this wrapper rather than on panelChrome, so the floating
// control bar below shares its box — a `relative` box that's already sized to
// the real (chat-sidebar-aware) video pane width, since RoomChat now lays the
// chat sidebar out as a flex sibling instead of an absolute overlay. Centering
// the controls against this box (instead of the viewport) is what keeps them
// centered in the actual remaining space as that width animates open/closed.
const desktopWrapperClass = $derived(cx("relative flex min-h-0 flex-1 flex-col", className))
</script>
{#snippet videoTile(tile: VideoTileData, layout: TileLayoutVariant)}
@ -305,25 +313,35 @@
{/snippet}
{#if showVideoContent}
<div class={panelChrome}>
{@render videoPanelBody()}
</div>
<!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's
flex-computed height isn't guaranteed to reach the real viewport bottom, so
an absolute child positioned against its box can end up overlapping the
fixed bottom nav bar. Matches the same bottom-nav-clearing offset the old
chat FAB used.
{#if mobile}
<div class={panelChrome}>
{@render videoPanelBody()}
</div>
<!-- fixed (viewport-relative), not absolute within panelChrome — panelChrome's
flex-computed height isn't guaranteed to reach the real viewport bottom, so
an absolute child positioned against its box can end up overlapping the
fixed bottom nav bar. Matches the same bottom-nav-clearing offset the old
chat FAB used.
RoomChat mounts both a desktop and a mobile VideoCallContent instance at
once while connected (each gated by its own `class` prop, e.g. "md:hidden"
vs "hidden ... md:flex"), so this fixed bar needs the same mobile/desktop
split — otherwise both instances render their own copy at the identical
screen position simultaneously. -->
<div
class={cx(
"left-content pointer-events-none fixed right-sai z-popover justify-center bottom-[calc(4.5rem+var(--saib))] md:bottom-4",
mobile ? "flex md:hidden" : "hidden md:flex",
)}>
<CallControlBar {url} {h} />
</div>
RoomChat mounts both a desktop and a mobile VideoCallContent instance at
once while connected, each gated by its own `class` prop — this instance
is the mobile one, so it's the only copy visible below md. -->
<div
class="left-content pointer-events-none fixed right-sai z-popover flex justify-center bottom-[calc(4.5rem+var(--saib))] md:hidden">
<CallControlBar {url} {h} />
</div>
{:else}
<!-- desktopWrapperClass carries this instance's own visibility ("hidden ...
md:flex") and is `relative`, so the control bar below is `absolute` against
the real (chat-sidebar-aware) video pane box rather than the viewport —
it re-centers automatically as that box's width animates. -->
<div class={desktopWrapperClass}>
<div class={panelChrome}>
{@render videoPanelBody()}
</div>
<div class="pointer-events-none absolute inset-x-0 bottom-4 z-popover flex justify-center">
<CallControlBar {url} {h} />
</div>
</div>
{/if}
{/if}

View file

@ -1,9 +1,30 @@
@layer components {
/* z-popover (not z-feature) so it stacks above the overlay chat pane (also
z-popover) instead of landing behind it — same tier + later-in-DOM trick
RoomChat's "New Messages" chip already relies on. */
.chat__scroll-down {
@apply z-feature fixed right-4 bottom-28 md:bottom-16;
@apply z-popover fixed right-4 bottom-28 md:bottom-16;
padding-bottom: var(--saib);
}
/* .chat__scroll-down is `fixed` (viewport-relative), so it ignores CallBanner's real
in-flow layout space entirely even though CallBanner shrinks the chat area above
it. Nudge the button up by CallBanner's own approximate rendered height (~3.5rem:
border-top + py-2 padding + its tallest child, the button-sm leave/mute controls)
so it clears the banner instead of landing behind it. */
.chat__scroll-down--banner {
@apply bottom-[calc(7rem+3.5rem)] md:bottom-[calc(4rem+3.5rem)];
}
/* On desktop, isOverlayChat (Split layout) floats the chat pane at md:bottom-24
in this same corner — .chat__scroll-down's default md:bottom-16 puts the
button's top edge past that pane's bottom edge, clipping into its rounded
corner. Drop below it instead. Mobile is untouched: there the pane fills
nearly the whole screen, so the button is meant to float over it. */
.chat__scroll-down--overlay-chat {
@apply md:bottom-10;
}
.chat-bubble {
@apply bg-surface-less text-content mx-1 mb-2 flex min-w-[100px] cursor-auto flex-col gap-1 rounded-2xl p-2 text-left lg:max-w-2xl;
}