From 965ec49c163ebb29df3cecb885f1fa0081d63ae7 Mon Sep 17 00:00:00 2001 From: Zzy Date: Fri, 21 Aug 2026 23:41:33 +0800 Subject: [PATCH] fix: harden layout push bounds --- src/client/layout-push.ts | 15 ++++++++++----- src/client/layout.css | 5 ++--- tests/layout-css.spec.ts | 9 ++++++++- tests/layout-push.spec.ts | 24 ++++++++++++++++++++++++ 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/client/layout-push.ts b/src/client/layout-push.ts index cf235d6..8b53cb2 100644 --- a/src/client/layout-push.ts +++ b/src/client/layout-push.ts @@ -3,7 +3,7 @@ * The conversation column (output + composer) must keep at least * {@link PANEL_MIN} of the viewport after the bottom panel claims height. */ -import { BOTTOM_MIN, PANEL_MIN } from './state.ts' +import { PANEL_MIN } from './state.ts' export interface LayoutPushInput { narrow: boolean @@ -20,13 +20,18 @@ export interface LayoutPushSize { height: number } +function finiteNonNegative(value: number): number { + return Number.isFinite(value) ? Math.max(0, value) : 0 +} + /** Compute the live layout-push size. Narrow drawers float and push 0. */ export function layoutPushSize(input: LayoutPushInput): LayoutPushSize { if (input.narrow) return { width: 0, height: 0 } - const maxWidth = Math.max(PANEL_MIN, input.viewportWidth) - const maxHeight = Math.max(BOTTOM_MIN, input.viewportHeight - PANEL_MIN) + const viewportWidth = finiteNonNegative(input.viewportWidth) + const viewportHeight = finiteNonNegative(input.viewportHeight) + const maxHeight = Math.max(0, viewportHeight - Math.min(PANEL_MIN, viewportHeight)) return { - width: input.panelOpen ? Math.min(Math.max(0, input.width), maxWidth) : 0, - height: input.bottomOpen ? Math.min(Math.max(0, input.bottomHeight), maxHeight) : 0, + width: input.panelOpen ? Math.min(finiteNonNegative(input.width), viewportWidth) : 0, + height: input.bottomOpen ? Math.min(finiteNonNegative(input.bottomHeight), maxHeight) : 0, } } diff --git a/src/client/layout.css b/src/client/layout.css index e634d79..17075c6 100644 --- a/src/client/layout.css +++ b/src/client/layout.css @@ -47,10 +47,9 @@ /* Grid/flex items default to min-height:auto. A long unbreakable token (OAuth URL) then grows this column past the viewport and clips the composer + left-rail Settings row. min-height:0 lets the cell shrink; - overflow:hidden keeps paint inside the cell; overflow-wrap wraps the - token so it cannot force the cell's intrinsic size. */ + overflow-wrap lets the token wrap instead of forcing its intrinsic + size. The host's own descendants remain responsible for scrolling. */ min-height: 0; - overflow: hidden; overflow-wrap: anywhere; transition: margin-bottom var(--ds-transition-duration-slow) var(--ds-ease-in-out); } diff --git a/tests/layout-css.spec.ts b/tests/layout-css.spec.ts index 3b42b9a..c90d889 100644 --- a/tests/layout-css.spec.ts +++ b/tests/layout-css.spec.ts @@ -16,7 +16,14 @@ describe('layout.css conversation column', () => { it('allows the center column to shrink and wrap long tokens', () => { expect(css).toMatch(/min-height:\s*0/) - expect(css).toMatch(/overflow:\s*hidden/) expect(css).toMatch(/overflow-wrap:\s*anywhere/) }) + + it('leaves overflow ownership to the host conversation descendants', () => { + const conversationRule = css.match( + /#root \[data-dsh-frame\][\s\S]*?\{([\s\S]*?)\n\}/, + )?.[1] + expect(conversationRule).toBeDefined() + expect(conversationRule).not.toMatch(/(?:^|[;\s])overflow\s*:/) + }) }) diff --git a/tests/layout-push.spec.ts b/tests/layout-push.spec.ts index 35b3fa9..b9052d2 100644 --- a/tests/layout-push.spec.ts +++ b/tests/layout-push.spec.ts @@ -72,4 +72,28 @@ describe('layoutPushSize', () => { viewportHeight: 800, })).toEqual({ width: 400, height: 220 }) }) + + it('never pushes beyond a viewport smaller than the normal panel minima', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: 400, + bottomHeight: 220, + viewportWidth: 200, + viewportHeight: 200, + })).toEqual({ width: 200, height: 0 }) + }) + + it('turns non-finite geometry into a safe zero push', () => { + expect(layoutPushSize({ + narrow: false, + panelOpen: true, + bottomOpen: true, + width: Number.NaN, + bottomHeight: Number.POSITIVE_INFINITY, + viewportWidth: Number.NaN, + viewportHeight: Number.POSITIVE_INFINITY, + })).toEqual({ width: 0, height: 0 }) + }) })