From a41c205d4fcb1c8ca1cfcb39be8d5016c79c9555 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 16:52:55 -0700 Subject: [PATCH 1/2] fix(web): keep the preview mini-player clear of the inline thread details card --- apps/web/src/components/ChatView.tsx | 1 + .../preview/ThreadPreviewMiniPlayer.tsx | 75 ++++++++-- .../preview/previewMiniPlayerLayout.test.ts | 91 +++++++++---- .../preview/previewMiniPlayerLayout.ts | 128 +++++++++++------- 4 files changed, 211 insertions(+), 84 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 35e73730c6fb..fdb027a00c66 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -10199,6 +10199,7 @@ export default function ChatView(props: ChatViewProps) { threadRef={activeThreadRef} miniPlayer={activePreviewMiniPlayer} composerOverlayElement={isDraftHeroState ? null : composerOverlayElement} + detailsPanelOpen={inlineThreadPanelOpen} /> ) : null} diff --git a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx index 5249fe37401a..8566d06074aa 100644 --- a/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx +++ b/apps/web/src/components/preview/ThreadPreviewMiniPlayer.tsx @@ -64,6 +64,8 @@ interface Props { readonly miniPlayer: PreviewMiniPlayerState; /** The docked composer overlay; null while the composer floats mid-screen. */ readonly composerOverlayElement: HTMLElement | null; + /** Whether the inline thread-details card is open in the chat column. */ + readonly detailsPanelOpen: boolean; } interface Layout { @@ -71,27 +73,47 @@ interface Layout { readonly obstacles: PreviewMiniPlayerObstacles; } +const sameSpan = ( + a: T | null, + b: T | null, + extent: keyof T, +) => + a === b || + (a !== null && b !== null && a.left === b.left && a.right === b.right && a[extent] === b[extent]); + const sameLayout = (a: Layout, b: Layout) => a.container.width === b.container.width && a.container.height === b.container.height && - (a.obstacles.composer === b.obstacles.composer || - (a.obstacles.composer !== null && - b.obstacles.composer !== null && - a.obstacles.composer.left === b.obstacles.composer.left && - a.obstacles.composer.right === b.obstacles.composer.right && - a.obstacles.composer.height === b.obstacles.composer.height)); + sameSpan(a.obstacles.composer, b.obstacles.composer, "height") && + sameSpan(a.obstacles.detailsCard, b.obstacles.detailsCard, "bottom"); + +/** + * The inline thread-details card lives in the aside beside the chat column, + * so it is found from the column's nearest ancestor that reserves room for it. + */ +function findDetailsCard(container: HTMLElement): HTMLElement | null { + const card = container + .closest('[data-thread-details-inline-reserved="true"]') + ?.querySelector('[data-thread-details-panel="inline"] [data-thread-details-card]'); + return card instanceof HTMLElement ? card : null; +} /** - * Measures the chat column and the composer in the column's coordinates. The - * composer's columns come from its centered stack, not the full-width overlay, - * so the margins beside it stay open to the player. + * Measures the chat column, the composer, and the details card in the column's + * coordinates. The composer's columns come from its centered stack, not the + * full-width overlay, so the margins beside it stay open to the player. */ -function measureLayout(container: HTMLElement, composerOverlay: HTMLElement | null): Layout { +function measureLayout( + container: HTMLElement, + composerOverlay: HTMLElement | null, + detailsCard: HTMLElement | null, +): Layout { const containerRect = container.getBoundingClientRect(); const stackRect = composerOverlay ?.querySelector('[data-chat-composer-stack="true"]') ?.getBoundingClientRect(); const overlayRect = composerOverlay?.getBoundingClientRect(); + const cardRect = detailsCard?.getBoundingClientRect(); return { container: { width: container.clientWidth, height: container.clientHeight }, obstacles: { @@ -103,6 +125,14 @@ function measureLayout(container: HTMLElement, composerOverlay: HTMLElement | nu height: Math.ceil(overlayRect.height), } : null, + detailsCard: + cardRect && cardRect.height > 0 + ? { + left: Math.floor(cardRect.left - containerRect.left), + right: Math.ceil(cardRect.right - containerRect.left), + bottom: Math.ceil(cardRect.bottom - containerRect.top), + } + : null, }, }; } @@ -125,7 +155,12 @@ const RESIZE_HANDLES: ReadonlyArray<{ ]; /** Floats the thread's browser tab or device stream over chat. */ -export function ThreadPreviewMiniPlayer({ threadRef, miniPlayer, composerOverlayElement }: Props) { +export function ThreadPreviewMiniPlayer({ + threadRef, + miniPlayer, + composerOverlayElement, + detailsPanelOpen, +}: Props) { const { source } = miniPlayer; return source.kind === "browser" ? ( ) : ( ); } @@ -151,6 +188,7 @@ function BrowserMiniPlayer({ tabId, miniPlayer, composerOverlayElement, + detailsPanelOpen, }: Props & { readonly tabId: string }) { const previewState = useThreadPreviewState(threadRef); const snapshot = previewState.sessions[tabId] ?? null; @@ -196,6 +234,7 @@ function BrowserMiniPlayer({ miniPlayer={miniPlayer} sourceSize={sourceSize} composerOverlayElement={composerOverlayElement} + detailsPanelOpen={detailsPanelOpen} label="Floating browser preview" recording={recording} onOpenInPanel={openInPanel} @@ -254,6 +293,7 @@ function DeviceMiniPlayer({ source, miniPlayer, composerOverlayElement, + detailsPanelOpen, }: Props & { readonly source: Extract }) { const { state: deviceState } = useDeviceState(threadRef.environmentId); const [screen, setScreen] = useState(null); @@ -284,6 +324,7 @@ function DeviceMiniPlayer({ miniPlayer={miniPlayer} sourceSize={sourceSize} composerOverlayElement={composerOverlayElement} + detailsPanelOpen={detailsPanelOpen} label="Floating device preview" onOpenInPanel={openInPanel} cornerRadius={cornerRadius} @@ -320,6 +361,7 @@ function MiniPlayerShell({ miniPlayer, sourceSize, composerOverlayElement, + detailsPanelOpen, label, onOpenInPanel, pillActions, @@ -331,6 +373,7 @@ function MiniPlayerShell({ readonly miniPlayer: PreviewMiniPlayerState; readonly sourceSize: PreviewMiniPlayerSize; readonly composerOverlayElement: HTMLElement | null; + readonly detailsPanelOpen: boolean; readonly label: string; readonly onOpenInPanel: () => void; readonly pillActions?: ReactNode; @@ -363,12 +406,15 @@ function MiniPlayerShell({ usePreviewMiniPlayerStore.getState().close(threadRef); }; - // The composer grows on its own (drafts, banners), so it is observed alongside the column. + // The composer and the details card grow on their own (drafts, banners, + // workspace rows), so both are observed alongside the column. The card is + // looked up when the panel opens; the flag re-runs this on toggle. useLayoutEffect(() => { const element = containerRef.current; if (!element) return; + const detailsCard = detailsPanelOpen ? findDetailsCard(element) : null; const measure = () => { - const next = measureLayout(element, composerOverlayElement); + const next = measureLayout(element, composerOverlayElement, detailsCard); setLayout((current) => (current && sameLayout(current, next) ? current : next)); }; measure(); @@ -376,8 +422,9 @@ function MiniPlayerShell({ const observer = new ResizeObserver(measure); observer.observe(element); if (composerOverlayElement) observer.observe(composerOverlayElement); + if (detailsCard) observer.observe(detailsCard); return () => observer.disconnect(); - }, [composerOverlayElement]); + }, [composerOverlayElement, detailsPanelOpen]); const beginGesture = ( event: ReactPointerEvent, diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index 3575c0984fc4..031feee45398 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { clampPreviewMiniPlayerPosition, - defaultPreviewMiniPlayerPosition, PREVIEW_MINI_PLAYER_EDGE_GAP, type PreviewMiniPlayerObstacles, resizePreviewMiniPlayer, @@ -18,8 +17,14 @@ const source = { width: 1_600, height: 1_000 }; const gap = PREVIEW_MINI_PLAYER_EDGE_GAP; // A centered composer stack with margins on each side. const composer = { left: 100, right: 900, height: 150 }; -const obstacles: PreviewMiniPlayerObstacles = { composer }; -const tallComposer: PreviewMiniPlayerObstacles = { composer: { ...composer, height: 300 } }; +const obstacles: PreviewMiniPlayerObstacles = { composer, detailsCard: null }; +const tallComposer: PreviewMiniPlayerObstacles = { + composer: { ...composer, height: 300 }, + detailsCard: null, +}; +// The inline details card docked to the top-right corner. +const detailsCard = { left: 680, right: 988, bottom: 260 }; +const withCard: PreviewMiniPlayerObstacles = { composer, detailsCard }; describe("resolvePreviewMiniPlayerSourceSize", () => { it("uses the device viewport scaled by zoom", () => { @@ -122,7 +127,7 @@ describe("resolvePreviewMiniPlayerFrame", () => { // The frame an edge resize produced in the left margin, resolved again from // the stored width and position on the next render. const phone = { width: 390, height: 844 }; - const beside = { composer: { left: 300, right: 900, height: 300 } }; + const beside = { composer: { left: 300, right: 900, height: 300 }, detailsCard: null }; const resized = resizePreviewMiniPlayer({ start: { x: 12, y: 100, width: 240, height: 519 }, direction: "east", @@ -234,7 +239,7 @@ describe("resizePreviewMiniPlayer", () => { // taller than the rows above the composer, nudged from its right edge. const phone = { width: 390, height: 844 }; const start = { x: 12, y: 100, width: 240, height: 519 }; - const beside = { composer: { left: 300, right: 900, height: 300 } }; + const beside = { composer: { left: 300, right: 900, height: 300 }, detailsCard: null }; expect( resizePreviewMiniPlayer({ start, @@ -284,30 +289,31 @@ describe("resizePreviewMiniPlayer", () => { }); }); -describe("defaultPreviewMiniPlayerPosition", () => { - it("keeps the top-right fallback when the inline details panel is closed", () => { +describe("resolvePreviewMiniPlayerFrame with the details card", () => { + it("opens under the inline details card with right edges aligned", () => { expect( - defaultPreviewMiniPlayerPosition({ - fallback: { x: 664, y: 16 }, - parentRect: { left: 300, top: 60 }, - playerWidth: 320, - detailsCardRect: null, + resolvePreviewMiniPlayerFrame({ + width: null, + position: null, + source, + container, + obstacles: withCard, }), - ).toEqual({ x: 664, y: 16 }); + ).toEqual({ x: 988 - 320, y: 260 + gap, width: 320, height: 200 }); }); - it("slides under the inline details card with right edges aligned", () => { + it("opens left of the card when it cannot fit beneath it", () => { + // A tall card leaves too few rows above the composer for a full player. + const tallCard = { ...withCard, detailsCard: { ...detailsCard, bottom: 480 } }; expect( - defaultPreviewMiniPlayerPosition({ - fallback: { x: 664, y: 16 }, - parentRect: { left: 300, top: 60 }, - playerWidth: 320, - detailsCardRect: { right: 1280, bottom: 320 }, + resolvePreviewMiniPlayerFrame({ + width: null, + position: null, + source, + container, + obstacles: tallCard, }), - ).toEqual({ - x: 1280 - 300 - 320, - y: 320 - 60 + PREVIEW_MINI_PLAYER_EDGE_GAP, - }); + ).toEqual({ x: 680 - gap - 320, y: 700 - 150 - gap - 200, width: 320, height: 200 }); }); }); @@ -325,6 +331,7 @@ describe("clampPreviewMiniPlayerPosition", () => { expect( clampPreviewMiniPlayerPosition({ x: 500, y: 448 }, container, player, { composer: { ...composer, height: 160 }, + detailsCard: null, }), ).toEqual({ x: 500, y: 288 }); }); @@ -351,6 +358,44 @@ describe("clampPreviewMiniPlayerPosition", () => { ).toEqual({ x: composer.right + gap, y: 500 }); }); + it("keeps the player out from under the details card", () => { + // Dragged up into the card's columns: pushed down to its bottom edge. + expect(clampPreviewMiniPlayerPosition({ x: 700, y: 40 }, container, player, withCard)).toEqual({ + x: 628, + y: 260 + gap, + }); + }); + + it("slides sideways out from under the card when that is the shorter move", () => { + expect( + clampPreviewMiniPlayerPosition( + { x: 660, y: 40 }, + container, + { width: 60, height: 150 }, + withCard, + ), + ).toEqual({ x: 680 - gap - 60, y: 40 }); + }); + + it("clears the card and the composer together in a tight column", () => { + // Its own columns have too few rows between the card and the composer, + // so it slides left of the card, where the rows above the composer suffice. + const tight = { composer: { left: 0, right: 1_000, height: 300 }, detailsCard }; + expect(clampPreviewMiniPlayerPosition({ x: 700, y: 600 }, container, player, tight)).toEqual({ + x: 680 - gap - 360, + y: 700 - 300 - gap - 240, + }); + // Too tall for any column: the card wins and the player sits below it. + expect( + clampPreviewMiniPlayerPosition( + { x: 700, y: 600 }, + container, + { width: 360, height: 400 }, + tight, + ), + ).toEqual({ x: 628, y: 260 + gap }); + }); + it("sits above the composer when it is too wide for either margin", () => { expect( clampPreviewMiniPlayerPosition( diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 38d69c154aab..2022e7edbe98 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -83,15 +83,21 @@ interface HorizontalSpan { } /** - * The composer stack docked to the bottom edge, in container coordinates. It - * only reserves the columns it covers, so the margins beside it stay open all - * the way down. + * What the player keeps clear of, in container coordinates. Both are docked + * to an edge, so each only reserves the columns it covers: the margins beside + * the composer and the column left of the details card stay open all the way. */ export interface PreviewMiniPlayerObstacles { + /** The composer stack docked to the bottom edge. */ readonly composer: (HorizontalSpan & { readonly height: number }) | null; + /** The inline thread-details card docked to the top-right corner. */ + readonly detailsCard: (HorizontalSpan & { readonly bottom: number }) | null; } -export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { composer: null }; +export const NO_PREVIEW_MINI_PLAYER_OBSTACLES: PreviewMiniPlayerObstacles = { + composer: null, + detailsCard: null, +}; const spanOf = (x: number, width: number): HorizontalSpan => ({ left: x, right: x + width }); @@ -109,6 +115,12 @@ function floorFor( : container.height; } +/** The highest row (before the edge gap) open to a player covering these columns. */ +function ceilingFor(span: HorizontalSpan, obstacles: PreviewMiniPlayerObstacles): number { + const { detailsCard } = obstacles; + return detailsCard && spansOverlap(span, detailsCard) ? Math.max(0, detailsCard.bottom) : 0; +} + /** * The box a stored size is fitted into. A player with a position keeps the * rows its own columns have, so a tall frame parked beside the composer @@ -123,7 +135,7 @@ const availableArea = ( width: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, height: (span - ? floorFor(span, container, obstacles) + ? floorFor(span, container, obstacles) - ceilingFor(span, obstacles) : container.height - Math.max(0, obstacles.composer?.height ?? 0)) - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, }); @@ -160,20 +172,6 @@ function defaultPreviewMiniPlayerWidth(source: PreviewMiniPlayerSize): number { ); } -/** Place a new player below the inline details card when it owns the top-right corner. */ -export function defaultPreviewMiniPlayerPosition(input: { - readonly fallback: PreviewMiniPlayerPosition; - readonly parentRect: { readonly left: number; readonly top: number }; - readonly playerWidth: number; - readonly detailsCardRect: { readonly right: number; readonly bottom: number } | null; -}): PreviewMiniPlayerPosition { - if (input.detailsCardRect === null) return input.fallback; - return { - x: input.detailsCardRect.right - input.parentRect.left - input.playerWidth, - y: input.detailsCardRect.bottom - input.parentRect.top + PREVIEW_MINI_PLAYER_EDGE_GAP, - }; -} - const clampToContainer = ( position: PreviewMiniPlayerPosition, container: PreviewMiniPlayerSize, @@ -198,14 +196,22 @@ const overlapsObstacle = ( player: PreviewMiniPlayerSize, container: PreviewMiniPlayerSize, obstacles: PreviewMiniPlayerObstacles, -): boolean => - position.y + player.height > floorFor(spanOf(position.x, player.width), container, obstacles); +): boolean => { + const span = spanOf(position.x, player.width); + return ( + position.y + player.height > floorFor(span, container, obstacles) || + position.y < ceilingFor(span, obstacles) + ); +}; /** - * Keeps the player inside the container and off the composer. An overlapping - * player is pushed out along whichever side needs the smaller move, so a drag - * slides along the composer into the margin beside it instead of stopping at - * its top edge; when no side leaves it fully clear it sits above the composer. + * Keeps the player inside the container and off the composer and details + * card. An overlapping player is moved the shortest distance that leaves it + * clear: vertically into the rows its own columns have open, or sideways to + * the space beside an obstacle, so a drag slides along the composer into a + * margin instead of stopping at its edge. When nothing leaves it clear it + * keeps its columns and sits below the card; the composer is where the user + * is typing, but a player under the card cannot be reached at all. */ export function clampPreviewMiniPlayerPosition( position: PreviewMiniPlayerPosition, @@ -214,22 +220,42 @@ export function clampPreviewMiniPlayerPosition( obstacles: PreviewMiniPlayerObstacles = NO_PREVIEW_MINI_PLAYER_OBSTACLES, ): PreviewMiniPlayerPosition { const inside = clampToContainer(position, container, player); - const { composer } = obstacles; - if (!composer || !overlapsObstacle(inside, player, container, obstacles)) return inside; + if (!overlapsObstacle(inside, player, container, obstacles)) return inside; const gap = PREVIEW_MINI_PLAYER_EDGE_GAP; - const above = { x: inside.x, y: container.height - composer.height - gap - player.height }; - const beside = [ - { x: composer.left - gap - player.width, y: inside.y }, - { x: composer.right + gap, y: inside.y }, - ]; - let best = clampToContainer(above, container, player); - let bestDistance = Math.abs(best.y - inside.y); - for (const candidate of beside) { - const clamped = clampToContainer(candidate, container, player); - if (clamped.x !== candidate.x || overlapsObstacle(candidate, player, container, obstacles)) { + // The rows open to a player at this x, holding y as close to the drag as they allow. + const fitRows = (x: number): PreviewMiniPlayerPosition => { + const span = spanOf(x, player.width); + return { + x, + y: Math.max( + Math.min(inside.y, floorFor(span, container, obstacles) - gap - player.height), + ceilingFor(span, obstacles) + gap, + gap, + ), + }; + }; + const fallback = fitRows(inside.x); + const { composer, detailsCard } = obstacles; + const span = spanOf(inside.x, player.width); + const beside = [composer, detailsCard] + .filter( + (obstacle): obstacle is NonNullable => + obstacle !== null && spansOverlap(span, obstacle), + ) + .flatMap((obstacle) => [obstacle.left - gap - player.width, obstacle.right + gap]); + let best = fallback; + let bestDistance = overlapsObstacle(fallback, player, container, obstacles) + ? Number.POSITIVE_INFINITY + : Math.abs(fallback.y - inside.y); + for (const x of beside) { + const candidate = fitRows(x); + if ( + clampToContainer(candidate, container, player).x !== x || + overlapsObstacle(candidate, player, container, obstacles) + ) { continue; } - const distance = Math.abs(candidate.x - inside.x); + const distance = Math.abs(x - inside.x) + Math.abs(candidate.y - inside.y); if (distance < bestDistance) { best = candidate; bestDistance = distance; @@ -242,7 +268,8 @@ export function clampPreviewMiniPlayerPosition( * Resolves the on-screen frame from the stored width and position. Clamping * happens here on every layout pass instead of being written back to the * store, so a temporarily narrow container never destroys the user's chosen - * width. A player without a position sits in the top-right corner. + * width. A player without a position sits in the top-right corner, or tucks + * under the details card with right edges aligned when the card owns it. */ export function resolvePreviewMiniPlayerFrame(input: { readonly width: number | null; @@ -263,10 +290,15 @@ export function resolvePreviewMiniPlayerFrame(input: { source, availableArea(container, obstacles, position && width ? spanOf(position.x, width) : null), ); - const anchored = position ?? { - x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, - y: PREVIEW_MINI_PLAYER_EDGE_GAP, - }; + const { detailsCard } = obstacles; + const anchored = + position ?? + (detailsCard + ? { x: detailsCard.right - size.width, y: detailsCard.bottom + PREVIEW_MINI_PLAYER_EDGE_GAP } + : { + x: container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - size.width, + y: PREVIEW_MINI_PLAYER_EDGE_GAP, + }); return { ...clampPreviewMiniPlayerPosition(anchored, container, size, obstacles), ...size }; } @@ -299,10 +331,12 @@ export function resizePreviewMiniPlayer(input: { const south = direction.includes("south"); const right = start.x + start.width; const bottom = start.y + start.height; - // Growth stops where the player's current columns meet the composer, and a + // Growth stops where the player's current columns meet an obstacle, and a // plain edge drag lets the free axis use everything those columns have. A // wider player may reach new columns; the clamp below slides it clear. - const floor = floorFor(spanOf(start.x, start.width), container, obstacles); + const span = spanOf(start.x, start.width); + const floor = floorFor(span, container, obstacles); + const ceiling = ceilingFor(span, obstacles); const max = { width: west ? right - PREVIEW_MINI_PLAYER_EDGE_GAP @@ -310,10 +344,10 @@ export function resizePreviewMiniPlayer(input: { ? container.width - PREVIEW_MINI_PLAYER_EDGE_GAP - start.x : container.width - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, height: north - ? bottom - PREVIEW_MINI_PLAYER_EDGE_GAP + ? bottom - ceiling - PREVIEW_MINI_PLAYER_EDGE_GAP : south ? floor - PREVIEW_MINI_PLAYER_EDGE_GAP - start.y - : floor - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, + : floor - ceiling - PREVIEW_MINI_PLAYER_EDGE_GAP * 2, }; const desiredWidth = start.width + (east ? delta.x : west ? -delta.x : 0); const desiredHeight = start.height + (south ? delta.y : north ? -delta.y : 0); From 19f464a52ac7cabf68244961681e62c7c847f5b9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 14 Sep 2026 17:12:30 -0700 Subject: [PATCH 2/2] fix(web): keep stored player size and on-screen clamp under the details card --- .../preview/previewMiniPlayerLayout.test.ts | 32 +++++++++++++++++++ .../preview/previewMiniPlayerLayout.ts | 20 +++++++++--- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts index 031feee45398..5c8e3ab2bd08 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.test.ts @@ -315,6 +315,25 @@ describe("resolvePreviewMiniPlayerFrame with the details card", () => { }), ).toEqual({ x: 680 - gap - 320, y: 700 - 150 - gap - 200, width: 320, height: 200 }); }); + + it("keeps a stored player's size when the card covers its columns", () => { + // The card leaves ~290 rows beneath it; fitting into them would collapse + // a 480-wide player to ~1px tall. The frame keeps its size and the clamp + // relocates it to the open columns left of the card. + const frame = resolvePreviewMiniPlayerFrame({ + width: 480, + position: { x: 700, y: 280 }, + source, + container, + obstacles: withCard, + }); + expect(frame).toEqual({ + x: 680 - gap - 480, + y: 700 - 150 - gap - 300, + width: 480, + height: 300, + }); + }); }); describe("clampPreviewMiniPlayerPosition", () => { @@ -396,6 +415,19 @@ describe("clampPreviewMiniPlayerPosition", () => { ).toEqual({ x: 628, y: 260 + gap }); }); + it("stays inside the container when a full-width card leaves no open rows", () => { + // A card reaching past the player's last row would push the fallback + // below the container; the clamp keeps the bottom edge visible instead. + const wall = { + composer, + detailsCard: { left: 0, right: 1_000, bottom: 690 }, + }; + expect(clampPreviewMiniPlayerPosition({ x: 300, y: 500 }, container, player, wall)).toEqual({ + x: 300, + y: 700 - gap - player.height, + }); + }); + it("sits above the composer when it is too wide for either margin", () => { expect( clampPreviewMiniPlayerPosition( diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 2022e7edbe98..cd8afd17cd44 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -227,10 +227,13 @@ export function clampPreviewMiniPlayerPosition( const span = spanOf(x, player.width); return { x, - y: Math.max( - Math.min(inside.y, floorFor(span, container, obstacles) - gap - player.height), - ceilingFor(span, obstacles) + gap, - gap, + y: Math.min( + container.height - gap - player.height, + Math.max( + Math.min(inside.y, floorFor(span, container, obstacles) - gap - player.height), + ceilingFor(span, obstacles) + gap, + gap, + ), ), }; }; @@ -288,7 +291,14 @@ export function resolvePreviewMiniPlayerFrame(input: { const size = fitPreviewMiniPlayerWidth( width ?? defaultPreviewMiniPlayerWidth(source), source, - availableArea(container, obstacles, position && width ? spanOf(position.x, width) : null), + availableArea( + container, + // A stored player keeps its size under the details card — the card's + // ceiling is a placement concern, not a sizing one, and the clamp pass + // relocates the frame into columns the card leaves open. + position && width ? { composer: obstacles.composer, detailsCard: null } : obstacles, + position && width ? spanOf(position.x, width) : null, + ), ); const { detailsCard } = obstacles; const anchored =