From b4b20818b06688590f87d722247ddfccf0b2ab58 Mon Sep 17 00:00:00 2001 From: Gary Basin Date: Thu, 16 Jul 2026 17:09:48 -0400 Subject: [PATCH] fix(web): the fade follows measured overflow, so text that fits is not dimmed forever #545 made the Show more toggle depend on MEASURED overflow instead of the length heuristic, but left the fade on the collapsed state alone: clamped = canClamp && !expanded // fade rode on this showToggle = canClamp && overflows // toggle rode on this A message long enough to trip COLLAPSE_LINE_THRESHOLD / CHAR_THRESHOLD but short enough to fit inside max-h-80 therefore rendered with the bottom of its box faded to transparent and NO toggle to clear it. Before #545 the toggle was gated on the heuristic too, so the same message faded but could be expanded; the fade was escapable. #545's note that this case merely 'no longer offers a toggle that does nothing' understates it -- the toggle was doing something visible: it removed the fade. The constraint has to stay applied while collapsed, because overflow is measured THROUGH it (drop max-h and scrollHeight === clientHeight, so nothing ever reports overflowing and the block never clamps). So the fix splits the two: collapsedClassName keeps the constraint, and a new overflowingClassName carries the hint, applied only when the content actually overflows. Verified in a real browser (jsdom has no layout and cannot see a mask): a fitting message reports mask-image: none where it previously reported linear-gradient(...), while a genuinely overflowing message keeps both its fade and its toggle. --- .../web/src/components/ClampedBlock.test.tsx | 35 ++++++++++++++++++- surface/web/src/components/ClampedBlock.tsx | 22 +++++++++++- surface/web/src/components/MessageText.tsx | 7 +++- 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/surface/web/src/components/ClampedBlock.test.tsx b/surface/web/src/components/ClampedBlock.test.tsx index fa9f7e9b..09de5904 100644 --- a/surface/web/src/components/ClampedBlock.test.tsx +++ b/surface/web/src/components/ClampedBlock.test.tsx @@ -29,7 +29,12 @@ function setMetrics(element: Element, scrollHeight: number, clientHeight: number function renderClamp(children: React.ReactNode = 'Content') { return render( - + {children} , ); @@ -57,6 +62,34 @@ describe('ClampedBlock', () => { expect(screen.queryByRole('button')).toBeNull(); }); + // The fade advertises "there is more below". Content that fits gets no toggle, + // so a fade there would point at nothing and the user would have no control to + // clear it — the message would just render permanently dimmed. + it('withholds the overflow hint from content that fits, alongside the toggle', () => { + const { container } = renderClamp(); + const content = container.querySelector('.test-clamp'); + setMetrics(content!, 80, 80); + + act(() => observers[0]?.trigger()); + + expect(screen.queryByRole('button')).toBeNull(); + expect(container.querySelector('.test-fade')).toBeNull(); + }); + + it('applies the overflow hint while clamped content overflows', () => { + const { container } = renderClamp(); + const content = container.querySelector('.test-clamp'); + setMetrics(content!, 180, 80); + + act(() => observers[0]?.trigger()); + + expect(container.querySelector('.test-fade')).toBeTruthy(); + + // Expanding drops the hint with the constraint: nothing is hidden any more. + fireEvent.click(screen.getByRole('button', { name: 'Show more ↓' })); + expect(container.querySelector('.test-fade')).toBeNull(); + }); + it('toggles its caller-supplied label and expansion direction', () => { const { container } = renderClamp(); const content = container.querySelector('.test-clamp'); diff --git a/surface/web/src/components/ClampedBlock.tsx b/surface/web/src/components/ClampedBlock.tsx index 05cffa9a..ca606aa7 100644 --- a/surface/web/src/components/ClampedBlock.tsx +++ b/surface/web/src/components/ClampedBlock.tsx @@ -14,14 +14,27 @@ export function ClampedBlock({ contentClassName, enabled = true, expandLabel, + overflowingClassName, toggleClassName, }: { children: ReactNode; + /** + * The size constraint, applied whenever the block is collapsed. It has to be + * applied even when the content turns out to fit, because overflow is measured + * THROUGH it: without the constraint, scrollHeight === clientHeight and nothing + * would ever report as overflowing. + */ collapsedClassName: string; collapseLabel: ReactNode; contentClassName?: string; enabled?: boolean; expandLabel: ReactNode; + /** + * Styling that advertises there is more to see (a fade, say). Applied only when + * the content actually overflows, so it stays in step with the toggle: content + * that fits gets no toggle, and so must not get the hint either. + */ + overflowingClassName?: string; toggleClassName?: string; }) { const insideClamp = useContext(ClampContext); @@ -51,7 +64,14 @@ export function ClampedBlock({ return ( <> -
+
{children}
{showToggle ? ( diff --git a/surface/web/src/components/MessageText.tsx b/surface/web/src/components/MessageText.tsx index 823418cc..5e55ed02 100644 --- a/surface/web/src/components/MessageText.tsx +++ b/surface/web/src/components/MessageText.tsx @@ -798,7 +798,12 @@ export function MessageText({ // ancestor chain (GFM footnotes open with `

`) would // otherwise anchor above the clamp, escape the clip, and inflate the // scroll height into blank space (#544). - collapsedClassName="relative max-h-80 overflow-hidden [mask-image:linear-gradient(to_bottom,black_70%,transparent)]" + collapsedClassName="relative max-h-80 overflow-hidden" + // The fade rides on measured overflow, not on the length heuristic: a + // message long enough to trip the threshold but short enough to fit used + // to render faded with no toggle to clear it, because the toggle asks + // "does it overflow?" and the fade only asked "is it collapsed?". + overflowingClassName="[mask-image:linear-gradient(to_bottom,black_70%,transparent)]" expandLabel="Show more" collapseLabel="Show less" toggleClassName="mt-1 text-xs font-medium text-accent-text hover:underline"