From 375caa33e6b467299cb1174cce4b5a598527a1f3 Mon Sep 17 00:00:00 2001 From: Dylan Trotter Date: Sat, 26 Sep 2026 23:45:28 +0000 Subject: [PATCH] Stop the brief panel running off the right edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sizing the panel at 100vw assumed it started at the left edge. It is anchored to a trigger near the right, so a viewport-wide panel hung off the screen: text wrapped at a width that was mostly off-screen, which read as no wrapping at all. Cap the width by --radix-popover-content-available-width, the room Radix measured from where it actually placed the panel, and let long unbroken strings break rather than widen it. Move every layout-critical property to inline styles. The plugin's Tailwind output is scoped to its own subtree and this content is portalled, so sizing should not depend on those classes reaching it. Cosmetics stay in className, where a miss is only cosmetic. The tests now assert the applied inline style. The previous ones checked that className contained w-[calc(100vw-1rem)] and passed while the panel rendered wrong — a class name being present says nothing about a rule reaching the element. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/thread-briefs/app.test.tsx | 59 +++++++++++++++++++----------- plugins/thread-briefs/app.tsx | 28 +++++++++----- 2 files changed, 56 insertions(+), 31 deletions(-) diff --git a/plugins/thread-briefs/app.test.tsx b/plugins/thread-briefs/app.test.tsx index 64fb8f5..739cd2c 100644 --- a/plugins/thread-briefs/app.test.tsx +++ b/plugins/thread-briefs/app.test.tsx @@ -147,39 +147,56 @@ describe("the header popover", () => { slot.lifecycle.unmount(); }); - it("caps its height and scrolls, so a long brief is reachable on a phone", async () => { - const slot = await render({ isCompactViewport: true }); + /** + * These assert the *inline style*, not class names. An earlier version + * checked `className` contained `w-[calc(100vw-1rem)]` and passed happily + * while the panel rendered unconstrained: the class name being present says + * nothing about whether a rule reached the element, and this content is + * portalled out of the plugin's scoped stylesheet. + */ + const openPanel = async (isCompactViewport: boolean) => { + const slot = await render({ isCompactViewport }); fireEvent.click(await slot.findByRole("button", { name: "Thread brief" })); + const panel = ( + await slot.findByText("Ship the thread-briefs plugin") + ).closest("[style*='max-height']") as HTMLElement | null; + return { slot, panel }; + }; - const goal = await slot.findByText("Ship the thread-briefs plugin"); - const panel = goal.closest("[style*='max-height']") as HTMLElement | null; + it("caps its height and scrolls, so a long brief is reachable on a phone", async () => { + const { slot, panel } = await openPanel(true); expect(panel).not.toBeNull(); - // Without both of these the panel grows past the viewport with no way to - // reach the fields below the fold. expect(panel?.style.maxHeight).toContain( "--radix-popover-content-available-height", ); - expect(panel?.className).toContain("overflow-y-auto"); + expect(panel?.style.overflowY).toBe("auto"); + slot.lifecycle.unmount(); + }); + + it("never exceeds the width Radix measured, so it cannot run off-screen", async () => { + const { slot, panel } = await openPanel(true); + // The bug this replaces: a 100vw-wide panel anchored near the right edge + // hangs off the screen and its text wraps out of sight. + expect(panel?.style.maxWidth).toContain( + "--radix-popover-content-available-width", + ); + slot.lifecycle.unmount(); + }); + it("wraps long unbroken strings rather than widening", async () => { + const { slot, panel } = await openPanel(true); + expect(panel?.style.overflowWrap).toBe("anywhere"); slot.lifecycle.unmount(); }); it("goes near-full-width on a compact viewport and a fixed column otherwise", async () => { - const compact = await render({ isCompactViewport: true }); - fireEvent.click(await compact.findByRole("button", { name: "Thread brief" })); - const compactPanel = ( - await compact.findByText("Ship the thread-briefs plugin") - ).closest("[style*='max-height']") as HTMLElement | null; - expect(compactPanel?.className).toContain("w-[calc(100vw-1rem)]"); - compact.lifecycle.unmount(); + const compact = await openPanel(true); + expect(compact.panel?.style.width).toBe("calc(100vw - 1rem)"); + compact.slot.lifecycle.unmount(); - const wide = await render({ isCompactViewport: false }); - fireEvent.click(await wide.findByRole("button", { name: "Thread brief" })); - const widePanel = ( - await wide.findByText("Ship the thread-briefs plugin") - ).closest("[style*='max-height']") as HTMLElement | null; - expect(widePanel?.className).toContain("w-80"); - wide.lifecycle.unmount(); + const wide = await openPanel(false); + expect(wide.panel?.style.width).toBe("20rem"); + wide.slot.lifecycle.unmount(); }); it("sets a manual stage", async () => { diff --git a/plugins/thread-briefs/app.tsx b/plugins/thread-briefs/app.tsx index 9632f89..dd6732e 100644 --- a/plugins/thread-briefs/app.tsx +++ b/plugins/thread-briefs/app.tsx @@ -308,19 +308,27 @@ function BriefHeaderAction({ align="end" sideOffset={6} collisionPadding={8} - // A five-field brief is easily taller than a phone viewport, so the - // panel has to cap its height and scroll inside. Radix measures the - // room it actually has and publishes it as this variable; the vh - // fallback covers the case where collision detection is skipped. - // Inline rather than a Tailwind arbitrary value so it cannot depend - // on what the plugin's Tailwind pass chose to emit. + // Every layout-critical property is an inline style. The plugin's + // Tailwind output is scoped to its own subtree, and this content is + // portalled, so leaning on those classes for sizing is a bet this + // panel does not need to take. Cosmetics stay in className, where a + // miss is only cosmetic. style={{ + // A five-field brief is easily taller than a phone viewport. maxHeight: "var(--radix-popover-content-available-height, 70vh)", + // ...and `100vw` is not the room this panel has: it is anchored to + // a trigger near the right edge, so a viewport-wide panel hangs off + // the screen and its text wraps out of sight. Radix measures the + // width actually available from where it was placed; cap by that. + maxWidth: "var(--radix-popover-content-available-width, calc(100vw - 1rem))", + width: isCompactViewport ? "calc(100vw - 1rem)" : "20rem", + overflowY: "auto", + overscrollBehavior: "contain", + // Long unbroken strings (URLs, branch names) must not force the + // panel wider than its cap. + overflowWrap: "anywhere", }} - className={`z-50 overflow-y-auto overscroll-contain rounded-md border border-border bg-card p-3 shadow-md ${ - // Near-full width on a phone; a fixed column on a wide screen. - isCompactViewport ? "w-[calc(100vw-1rem)]" : "w-80" - }`} + className="z-50 rounded-md border border-border bg-card p-3 shadow-md" >