From 2c8d3ae9c2bbf69e4555cef36cc28afeae215e64 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Tue, 14 Apr 2026 17:06:23 -0500 Subject: [PATCH 1/2] fix(ui): wrap long URLs inside message bubbles (#212) ## Problem Posting a long link that a text formatter cannot break up (e.g. a URL with a very long unbroken segment) stretched the message bubble wider than other bubbles. PR #209 added `break-words` (overflow-wrap: break-word) on the prose body, but `break-word` only inserts soft breaks when a word would otherwise overflow, and crucially does NOT lower the element's min-content size. So a sibling flex/grid parent can still be forced wider by the intrinsic min-content of a long token. ## Approach Replace `break-words` with `[overflow-wrap:anywhere]`. `anywhere` is a strict superset of `break-word`: it keeps the soft-break behavior but also lowers the element's min-content to a single character, allowing the bubble's flex ancestors to shrink around the wrapped content. ## Testing - Added example-data entry with a long unbreakable URL so the regression is visually exercised by the dev build and by Playwright. - Added a Playwright regression test (#212) that asserts a bubble containing the long URL stays within the viewport at narrow widths. - Full `message-layout.spec.ts` suite (7 tests) passes on chromium. Closes #212 [AI-assisted - Claude] --- ui/src/components/conversation.rs | 12 ++++++----- ui/src/example_data.rs | 19 +++++++++++++++++ ui/tests/message-layout.spec.ts | 35 +++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/ui/src/components/conversation.rs b/ui/src/components/conversation.rs index bdae7bb1..d54ffe34 100644 --- a/ui/src/components/conversation.rs +++ b/ui/src/components/conversation.rs @@ -1808,14 +1808,16 @@ fn MessageGroupComponent( // Message body, wrapped in a padding container so the // "(edited)" indicator can sit inline at the trailing // edge of the body text rather than as a separate - // flex-column row. `break-words` ensures long URLs and - // unbreakable tokens wrap instead of clipping (the - // bubble has `overflow-hidden` to keep the reply strip - // contained). + // flex-column row. `[overflow-wrap:anywhere]` ensures + // long URLs and unbreakable tokens wrap instead of + // forcing the bubble past `max-w-prose`. `anywhere` is + // stricter than `break-word`: it also lowers the + // element's min-content so flex/grid parents can shrink + // the bubble to fit. div { class: "px-3 py-2 min-w-0", div { - class: "prose prose-sm dark:prose-invert max-w-none break-words", + class: "prose prose-sm dark:prose-invert max-w-none [overflow-wrap:anywhere]", dangerous_inner_html: "{msg.content_html}" } if msg.edited { diff --git a/ui/src/example_data.rs b/ui/src/example_data.rs index 41fb5a01..e8552e98 100644 --- a/ui/src/example_data.rs +++ b/ui/src/example_data.rs @@ -355,6 +355,25 @@ fn add_example_messages( messages.messages.push(reply_msg); } + // Add a message containing an unbreakable long URL so the bubble width + // regression for #212 is exercised by Playwright. + { + let (long_url_author_id, long_url_signing_key) = authors[0]; + current_time_ms += 30_000; + let long_url_msg = AuthorizedMessageV1::new( + MessageV1 { + room_owner: *owner_id, + author: long_url_author_id, + time: get_time_from_millis(current_time_ms), + content: RoomMessageBody::public( + "https://example.com/longlongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpathlonglongurlpath".to_string(), + ), + }, + long_url_signing_key, + ); + messages.messages.push(long_url_msg); + } + // Add reactions to messages from OTHER members (not owner) // Rule: One reaction per user per message // In "Your Private Room" the owner IS self, so this shows self reacting to others diff --git a/ui/tests/message-layout.spec.ts b/ui/tests/message-layout.spec.ts index 716fa99d..450a2ea7 100644 --- a/ui/tests/message-layout.spec.ts +++ b/ui/tests/message-layout.spec.ts @@ -298,3 +298,38 @@ test.describe("Reply strip keyboard accessibility (#210)", () => { .toBeGreaterThan(0); }); }); + +// #212: a message containing an unbreakable long string (e.g. a long URL) +// must wrap inside the bubble. Before the fix, `break-words` alone +// (overflow-wrap: break-word) did not affect min-content sizing, so the +// long token forced the bubble's intrinsic min-content past `max-w-prose` +// and stretched the bubble. Adding `overflow-wrap: anywhere` makes the +// browser size min-content per-character, letting `max-w-prose` cap the +// bubble width. +test.describe("Long unbreakable content (#212)", () => { + test.use({ viewport: { width: 480, height: 900 } }); + + test("bubble with long URL does not exceed max-w-prose", async ({ + page, + }) => { + await page.goto("/"); + await waitForApp(page); + await selectRoom(page, "Your Private Room"); + + const longTokenBubble = page + .locator(".max-w-prose") + .filter({ hasText: "longlongurlpath" }) + .first(); + await expect(longTokenBubble).toBeVisible({ timeout: 10_000 }); + + const bubbleWidth = await longTokenBubble.evaluate( + (el) => el.getBoundingClientRect().width + ); + + // Viewport is 480px; chat panel is narrower than that. The bubble + // must be visibly bounded — the regression made it overflow the + // viewport entirely. Allow generous headroom; we only care that the + // unbreakable token did NOT push the bubble past the viewport. + expect(bubbleWidth).toBeLessThanOrEqual(480); + }); +}); From 3258ed4c6471667e29ca3eb6ea27a0a280712bf8 Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Tue, 14 Apr 2026 17:12:43 -0500 Subject: [PATCH 2/2] test(ui): tighten #212 regression test assertions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses testing-reviewer feedback on PR #213. The original assertion (`bubbleWidth <= 480`) was too loose to distinguish the fixed and buggy states. Replace with three stricter checks: 1. Inner prose `.scrollWidth <= .clientWidth` — verifies the long URL actually wraps inside its container rather than overflowing. 2. Rendered `` element width fits within the prose box. 3. `document.documentElement.scrollWidth <= .clientWidth` — defense in depth against the long-URL bubble pushing the chat column wider than siblings. Tests run at 1024x900 where `max-w-prose` (~65ch ≈ 520px) is the binding constraint rather than the viewport. Verified full `message-layout.spec.ts` suite (7 tests) passes on chromium and the new test passes on webkit as well. [AI-assisted - Claude] --- ui/tests/message-layout.spec.ts | 61 ++++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/ui/tests/message-layout.spec.ts b/ui/tests/message-layout.spec.ts index 450a2ea7..8bf73cf9 100644 --- a/ui/tests/message-layout.spec.ts +++ b/ui/tests/message-layout.spec.ts @@ -300,16 +300,16 @@ test.describe("Reply strip keyboard accessibility (#210)", () => { }); // #212: a message containing an unbreakable long string (e.g. a long URL) -// must wrap inside the bubble. Before the fix, `break-words` alone -// (overflow-wrap: break-word) did not affect min-content sizing, so the -// long token forced the bubble's intrinsic min-content past `max-w-prose` -// and stretched the bubble. Adding `overflow-wrap: anywhere` makes the -// browser size min-content per-character, letting `max-w-prose` cap the -// bubble width. +// must wrap inside the bubble without overflowing. `overflow-wrap: break-word` +// (the `break-words` utility) inserts soft breaks when content would +// otherwise overflow, but does NOT lower min-content sizing — so an ancestor +// `min-w-0` flex parent still gets stretched by the long token. Switching to +// `overflow-wrap: anywhere` also lowers min-content, which is what lets the +// bubble actually shrink to fit. test.describe("Long unbreakable content (#212)", () => { - test.use({ viewport: { width: 480, height: 900 } }); + test.use({ viewport: { width: 1024, height: 900 } }); - test("bubble with long URL does not exceed max-w-prose", async ({ + test("bubble with long URL wraps and does not cause horizontal overflow", async ({ page, }) => { await page.goto("/"); @@ -321,15 +321,44 @@ test.describe("Long unbreakable content (#212)", () => { .filter({ hasText: "longlongurlpath" }) .first(); await expect(longTokenBubble).toBeVisible({ timeout: 10_000 }); - - const bubbleWidth = await longTokenBubble.evaluate( - (el) => el.getBoundingClientRect().width + await longTokenBubble.scrollIntoViewIfNeeded(); + + // Core assertion: the inner prose body must not overflow its own box — + // i.e. the long URL must actually wrap. If `overflow-wrap` fails to + // apply, the element's min-content exceeds the parent width and + // scrollWidth > clientWidth. + const proseOverflow = await longTokenBubble.evaluate((el) => { + const prose = el.querySelector(".max-w-none") as HTMLElement | null; + if (!prose) return { ok: false, reason: "no prose div" }; + const a = prose.querySelector("a") as HTMLElement | null; + const aRect = a?.getBoundingClientRect(); + const pRect = prose.getBoundingClientRect(); + return { + ok: true, + proseScroll: prose.scrollWidth, + proseClient: prose.clientWidth, + aWidth: aRect?.width ?? 0, + proseWidth: pRect.width, + }; + }); + expect(proseOverflow.ok).toBe(true); + // The prose content must not overflow its own container. + expect(proseOverflow.proseScroll).toBeLessThanOrEqual( + proseOverflow.proseClient + 1 + ); + // And the rendered must fit inside the prose box (i.e. the URL + // wrapped rather than forcing the link to be wider than its parent). + expect(proseOverflow.aWidth).toBeLessThanOrEqual( + proseOverflow.proseWidth + 1 ); - // Viewport is 480px; chat panel is narrower than that. The bubble - // must be visibly bounded — the regression made it overflow the - // viewport entirely. Allow generous headroom; we only care that the - // unbreakable token did NOT push the bubble past the viewport. - expect(bubbleWidth).toBeLessThanOrEqual(480); + // Defense in depth: no horizontal overflow on the document. The + // original regression screenshot showed the long-URL bubble pushing + // the chat column wider than its sibling bubbles. + const docOverflow = await page.evaluate(() => ({ + scroll: document.documentElement.scrollWidth, + client: document.documentElement.clientWidth, + })); + expect(docOverflow.scroll).toBeLessThanOrEqual(docOverflow.client + 1); }); });