From d7a162fc6629d4b0d6272d10dd28cd17e5e260de Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 15:02:17 +0800 Subject: [PATCH 1/5] fix(web): stop the conversation column from scrolling sideways MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero's backdrop ellipse is sized 1051/776 of the hero box so its blur scales with the input card, which means it reaches past the column whenever the column is narrower than the glow. `[data-conversation-scroll]` declared only `overflow-y: auto`, and a box that scrolls in one axis computes the other axis's initial `visible` to `auto` — so that bleed came back as a real horizontal scrollbar, 24–95px of range across ordinary laptop widths. Declare `overflow-x: hidden` on the column instead of leaving the second axis to be derived. Clipping is unchanged (the box already clipped both axes); the declaration withdraws only the bar and the user gesture. --- ...versation-column-one-axis-scroll.i18n.yaml | 6 + ...-04-conversation-column-one-axis-scroll.md | 37 +++ ...-conversation-column-one-axis-scroll.zh.md | 37 +++ .../tests/conversation-column-overflow.e2e.ts | 285 ++++++++++++++++++ .../geometry.expected.md | 9 + apps/web/tsconfig.json | 1 + .../skeleton/ConversationRoot.module.css | 8 + tsconfig.host.json | 1 + 8 files changed, 384 insertions(+) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md create mode 100644 apps/web/tests/conversation-column-overflow.e2e.ts create mode 100644 apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml new file mode 100644 index 0000000000..47a432a7dd --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +2026-08-04-conversation-column-one-axis-scroll.md: fa2347e5e1b8d41da020db69840e1dcf32cfc4c3 +2026-08-04-conversation-column-one-axis-scroll.zh.md: aba34e304b8e6a349bd1e295ceb00d5ad809f6dd diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md new file mode 100644 index 0000000000..fa2347e5e1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -0,0 +1,37 @@ +# Agent Note: The conversation column scrolls on one axis + +Status: implemented + +English | [中文](2026-08-04-conversation-column-one-axis-scroll.zh.md) + +## Problem + +Narrowing the center column — by the window or by the sidebar drag — put a horizontal scrollbar under the whole conversation column on the hero. The bleeding element is the hero's decorative backdrop ellipse: `.heroGlow` is sized `1051/776` of the hero box so its blur scales in userSpace with the input card, which means it reaches past the column whenever the column is narrower than the glow. + +That bleed is by construction and stays. What made it user-visible is the scroll container it sits in. `[data-conversation-scroll]` declared `overflow-y: auto` and left the other axis at its initial `visible`, and a box that scrolls in one axis computes `visible` to `auto` in the other. Every column narrower than the glow therefore offered a real horizontal scroll range — measured at 24–95px across the widths a laptop actually produces. + +## Decision + +`.scrollBody` declares `overflow-x: hidden`. The column states that it is a one-axis scroller instead of leaving the second axis to be derived. + +Clipping does not change. `overflow-y: auto` had already made the box a scroll container that clips both axes, so the declaration withdraws only the scrollbar and the user gesture; the glow keeps its bleed, its blur radius, and the same painted extent, and the column keeps its vertical scroll. Nothing in the composer chain moves. + +## Alternatives considered + +**Size the glow to fit the column.** Rejected. The glow's width is what scales its `stdDeviation="50"` blur with the input card (figma 313:14109); constraining it would make the blur tighten as the column narrows, which is a visual regression to fix a scrollbar. + +**Wrap the glow in a clipping box.** Rejected. It adds a box whose only job is to undo an overflow the column already clips, and it leaves the derived `overflow-x: auto` in place for the next element that bleeds — the transcript is full of candidates. + +**Rely on the frame's `.centerCol { overflow: hidden }`.** It cannot help. That clip is outside the scroll container, so it hides the glow's overhang at the column border while the container inside it still scrolls to reach it. The reported bar was that container's. + +**Assert `scrollWidth === clientWidth` in the test.** Rejected as the signal, because it does not distinguish the states: `hidden` clips the bleed rather than reflowing it away, so the scroll range reads the same on both sides of the fix. Only refusing a user gesture differs, which is what the scenario measures. + +## Testing + +[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all. + +Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to the full bleed — without it a `scrollLeft` of 0 could equally mean the wheel never arrived. + +## Consequences + +The conversation column no longer offers a horizontal scrollbar at any width, and decorative bleed in the composer chain is now clipped rather than exposed as scroll range. The cost is that genuinely wide content under this column is clipped instead of reachable by scrolling: any such surface owns its own scroller, as the markdown code block and the trajectory table already do. diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md new file mode 100644 index 0000000000..aba34e304b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -0,0 +1,37 @@ +# Agent Note:会话列只在一个轴上滚动 + +状态:已实现 + +[English](2026-08-04-conversation-column-one-axis-scroll.md) | 中文 + +## 问题 + +当中间列被拉窄——无论是拖窗口还是拖侧边栏——hero 态的整条会话列下方就会出现一条横向滚动条。溢出的元素是 hero 的装饰性背景椭圆:`.heroGlow` 的宽度取 hero 盒子的 `1051/776`,好让它的模糊在 userSpace 中随输入卡片一同缩放;这也意味着只要列比它窄,它就会伸出列外。 + +这处外溢是设计使然,保持不变。真正让它对用户可见的是它所处的滚动容器。`[data-conversation-scroll]` 只声明了 `overflow-y: auto`,另一个轴留在初始值 `visible`;而一个在某一轴上滚动的盒子,会把另一轴的 `visible` 计算为 `auto`。于是每一条比该椭圆窄的列都真的给出了一段横向滚动范围——在笔记本实际会产生的几档宽度上,实测为 24–95px。 + +## 决定 + +`.scrollBody` 声明 `overflow-x: hidden`。这条列明确声明自己是单轴滚动容器,而不是把第二个轴交给推导。 + +裁剪行为不变。`overflow-y: auto` 早已使该盒子成为在两个轴上都裁剪的滚动容器,因此这条声明收回的只是滚动条和用户手势;椭圆保留它的外溢、模糊半径和同样的绘制范围,列也保留纵向滚动。输入区那条链路上没有任何东西移动。 + +## 曾考虑的替代方案 + +**把椭圆缩到列内。** 否决。椭圆的宽度正是让它 `stdDeviation="50"` 的模糊随输入卡片缩放的依据(figma 313:14109);约束宽度会使列越窄模糊越紧,等于为修一条滚动条而制造一处视觉回归。 + +**给椭圆套一层裁剪盒。** 否决。这层盒子唯一的职责是抵消列本就会裁剪的溢出,而推导出的 `overflow-x: auto` 仍然留在原处,等着下一个外溢的元素——会话流里这样的候选者不少。 + +**依赖外框的 `.centerCol { overflow: hidden }`。** 它帮不上忙。那处裁剪在滚动容器之外,只能在列边界处遮住椭圆探出的部分,而里面的容器照样可以滚过去够到它。用户报告的那条滚动条属于内层容器。 + +**在测试里断言 `scrollWidth === clientWidth`。** 作为判据被否决,因为它区分不出两种状态:`hidden` 裁剪外溢,而不是把它重排掉,所以修复前后读到的滚动范围一样。唯一有差别的是拒绝用户手势,这正是该场景所测量的。 + +## 测试 + +[apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上向列横向滚轮并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。 + +两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到完整的外溢量——没有它,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 + +## 后果 + +会话列在任何宽度下都不再给出横向滚动条,输入区链路上的装饰性外溢从暴露为滚动范围改为被裁剪。代价是这条列下真正过宽的内容会被裁掉而非可滚动够到:这类界面各自拥有自己的滚动容器,markdown 代码块和轨迹表格已经如此。 diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts new file mode 100644 index 0000000000..e676a7df94 --- /dev/null +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -0,0 +1,285 @@ +// Web e2e scenario: the conversation column scrolls on one axis only, as the +// browser actually lays it out. The reported symptom was a horizontal +// scrollbar under the whole center column once the window (or the sidebar +// drag) narrowed it — the hero's decorative backdrop ellipse bleeding past the +// column and becoming user-scrollable. +// +// The bleed is by construction and stays: `.heroGlow` is sized 1051/776 of the +// hero box (ConversationRoot.module.css) so the blur scales with the input +// card. What changed is the scroll container: `[data-conversation-scroll]` +// scrolls vertically, and a box that scrolls in one axis computes the other +// axis's initial `visible` to `auto`, so the bleed came back as a bar. The +// fix states `overflow-x: hidden` there. +// +// Only a real engine reports that pair — the bleed and the resulting scroll +// range — so the scenario sweeps viewport widths that bracket the glow's +// width and asserts both at each stop. Asserting no horizontal scroll alone +// would go vacuous the moment the glow stopped bleeding for an unrelated +// reason, which is why each stop also records whether it bleeds; the wide stop +// is the control where it does not. +// +// Zero model calls: the hero is the boot state, so nothing is seeded and no +// replay row mounts. A stray stream would fail loud with NO_ADAPTER. +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-overflow', import.meta.url)) +/** + * Committed golden of the one-axis relation at every stop. It records + * relations and booleans, never absolute coordinates: the column width follows + * the viewport and the sidebar, and a golden carrying pixels would document the + * platform instead of the change. + */ +const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') +const MODE = webSnapshotMode() +/** + * Viewport widths bracketing the glow. The hero box is `min(776, column - 48)` + * and the glow is 1051/776 of it, so every stop under a ~1051px column bleeds + * and the widest one does not — the sweep therefore covers both sides of the + * relation rather than sampling one comfortable width. + */ +const WIDTHS = [1680, 1200, 1000, 800, 600] +/** Element id of the mutation control's injected sheet, so the test can take it back out. */ +const CONTROL_STYLE_ID = 'dsh-column-overflow-control' + +/** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */ +interface ColumnMetrics { + /** Viewport width the stop was measured at. */ + width: number + /** The column's content width. Not committed to the golden — it is what settles after a resize, and what the sweep waits on. */ + columnWidth: number + /** Resolved `overflow-x` on the conversation scroll container. */ + overflowX: string + /** True when the glow's box reaches past the column's content edge — the condition the fix has to survive. */ + glowBleeds: boolean + /** + * `scrollWidth - clientWidth`. Deliberately NOT the assertion: `hidden` and + * `auto` both report the same value, because `hidden` clips the bleed rather + * than reflowing it away. Recorded because it is the vacuity guard in + * numbers — it must stay positive at the narrow stops, or the scenario has + * stopped reproducing the situation the fix is for. + */ + bleedRange: number + /** True when the column still scrolls vertically — the axis the fix must not take away. */ + scrollsVertically: boolean +} + +/** + * Measure the conversation column at the page's current viewport. + * @param page - the page under test. + * @param width - the viewport width already applied, recorded with the reading. + * @returns the stop's overflow relations. + */ +function measureColumn(page: Page, width: number): Promise { + return page.evaluate((viewportWidth) => { + const scroller = document.querySelector('[data-conversation-scroll]') + if (scroller === null) throw new Error('conversation scroll container not in the DOM') + const glow = scroller.querySelector('[class*="heroGlow"]') + if (glow === null) throw new Error('hero glow not in the DOM — the boot state is not the hero') + const box = scroller.getBoundingClientRect() + const glowBox = glow.getBoundingClientRect() + return { + width: viewportWidth, + columnWidth: scroller.clientWidth, + overflowX: getComputedStyle(scroller).overflowX, + // `clientWidth` is the content edge, which is what the scrollable + // overflow region is measured against; either side counts as a bleed, + // though only the right one can produce a bar in this writing mode. + glowBleeds: glowBox.right > box.left + scroller.clientWidth + 0.5 || glowBox.left < box.left - 0.5, + bleedRange: scroller.scrollWidth - scroller.clientWidth, + scrollsVertically: getComputedStyle(scroller).overflowY === 'auto', + } + }, width) +} + +/** + * Scroll the column sideways the way a user would and report where it landed. + * + * This is the one signal that separates the two states, and it is why the + * scenario needs a real engine: `overflow-x: hidden` leaves the box + * programmatically scrollable and leaves `scrollWidth` untouched, so every + * property reading agrees across the fix. Only refusing an actual input event + * differs — measured at the 1200px stop, the shipped column stays at 0 while + * the same page with `overflow-x: auto` forced on lands at the full 66px bleed. + * @param page - the page under test. + * @returns `scrollLeft` after one horizontal wheel over the column. + */ +async function wheelHorizontally(page: Page): Promise { + const origin = await page.evaluate(() => { + const scroller = document.querySelector('[data-conversation-scroll]') + if (scroller === null) throw new Error('conversation scroll container not in the DOM') + // Start from the origin so the reading is this gesture's own effect. + scroller.scrollLeft = 0 + const box = scroller.getBoundingClientRect() + // Near the top of the column, clear of the centered hero card: the wheel + // must reach the column, not a nested scroller the composer owns. + return { x: box.left + box.width / 2, y: box.top + 60 } + }) + await page.mouse.move(origin.x, origin.y) + await page.mouse.wheel(300, 0) + // Two frames: the scroll applies during the frame the wheel is dispatched + // into, and is readable in the next. Polling for a settled value cannot be + // used here — the value under test is 0, which a poll starting at 0 accepts + // before the gesture has had any chance to move it. The timing is the same + // on both sides of the mutation control below, which is what makes a 0 + // reading evidence rather than a race won. + return page.evaluate(() => new Promise((resolve) => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + resolve(document.querySelector('[data-conversation-scroll]')?.scrollLeft ?? -1) + }) + }) + })) +} + +/** A stop's readings plus where a horizontal wheel over it landed. */ +type ColumnStop = ColumnMetrics & { + /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */ + scrollLeftAfterWheel: number +} + +/** + * Render the golden body: one line per stop, relations only. + * + * Absolute pixels are deliberately absent apart from `scrollLeftAfterWheel`, + * which the fix pins to 0 by construction. The bleed is recorded as a boolean + * rather than its width, so the golden survives any platform whose column + * lands a pixel off — a fixture that has to be re-recorded per platform + * documents the platform, not the change. + * @param stops - the measured stops, in sweep order. + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(stops: ColumnStop[]): string { + return [ + '# Conversation column horizontal overflow', + '', + '| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically |', + '| --- | --- | --- | --- | --- |', + ...stops.map(stop => `| ${String(stop.width)}px | ${stop.overflowX} | ${String(stop.glowBleeds)} ` + + `| ${String(stop.scrollLeftAfterWheel)}px | ${String(stop.scrollsVertically)} |`), + ].join('\n') +} + +describe('web e2e: the conversation column scrolls on one axis', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + browser = await chromium.launch() + page = await newEnglishPage(browser, 900) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[data-conversation-scroll] [class*="heroGlow"]', { timeout: 30_000 }) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Sweep the stops once and hand the readings to every assertion below, so + * the golden and the assertions describe the same measurement rather than + * two runs that could disagree. + * @returns the stops in {@link WIDTHS} order. + */ + const sweep = async (): Promise => { + const stops: ColumnStop[] = [] + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 900 }) + // The glow rides the hero box, which rides the column, and the column's + // track animates: settle on a column width that stops moving, or a stop + // gets read mid-transition and reports the previous viewport's relation. + let previous = -1 + await expect.poll(async () => { + const current = (await measureColumn(page, width)).columnWidth + const settled = current === previous + previous = current + return settled + }, { timeout: 10_000 }).toBe(true) + stops.push({ ...await measureColumn(page, width), scrollLeftAfterWheel: await wheelHorizontally(page) }) + } + return stops + } + + it('never scrolls horizontally, at any width the glow bleeds past', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow')) + const stops = await sweep() + // The vacuity guard, in two halves: the glow has to reach past the column + // at the narrow stops, and that reach has to still register as scrollable + // overflow. Without both, the claim below holds for free. + expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([1200, 1000, 800, 600]) + for (const stop of stops.filter(stop => stop.glowBleeds)) { + expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0) + } + for (const stop of stops) { + expect(stop.overflowX, `viewport ${String(stop.width)}`).toBe('hidden') + // The reported symptom, stated directly: a horizontal wheel over the + // column moves nothing, at every stop. + expect(stop.scrollLeftAfterWheel, `viewport ${String(stop.width)}`).toBe(0) + // The axis the column is a scroller for must survive the fix. + expect(stop.scrollsVertically, `viewport ${String(stop.width)}`).toBe(true) + } + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('reports the pre-fix state when the axis is opened back up', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-control')) + // The mutation control, run in the page rather than against a second + // build: it restores exactly what the fix changed — the initial `visible` + // that a one-axis scroller computes to `auto` — and shows the same gesture, + // at the same timing, carrying the column to the full bleed. Without it a + // `scrollLeft` of 0 could equally mean the wheel never arrived. + await page.setViewportSize({ width: 1200, height: 900 }) + // Injected with an id rather than through `addStyleTag`, so the teardown + // below can take the sheet out again by selector: it must not outlive this + // test, or the golden ends up reading the control. + await page.evaluate((id: string) => { + const sheet = document.createElement('style') + sheet.id = id + sheet.textContent = '[data-conversation-scroll] { overflow-x: auto !important; }' + document.head.append(sheet) + }, CONTROL_STYLE_ID) + try { + const before = await measureColumn(page, 1200) + expect(before.overflowX).toBe('auto') + expect(await wheelHorizontally(page)).toBe(before.bleedRange) + expect(before.bleedRange).toBeGreaterThan(0) + } finally { + await page.evaluate((id: string) => { + document.getElementById(id)?.remove() + }, CONTROL_STYLE_ID) + } + // The override is gone and the shipped state is back: the later goldens + // read the product, not the control. + expect((await measureColumn(page, 1200)).overflowX).toBe('hidden') + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('matches the committed column-overflow golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-column-overflow-golden')) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(await sweep()), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('commits exactly the fixtures it reads', async () => { + // No model calls, so no replay log: the golden is the whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md b/apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md new file mode 100644 index 0000000000..f9c807b43e --- /dev/null +++ b/apps/web/tests/snapshots/conversation-column-overflow/geometry.expected.md @@ -0,0 +1,9 @@ +# Conversation column horizontal overflow + +| viewport | overflow-x | glow bleeds past the column | scrollLeft after a horizontal wheel | scrolls vertically | +| --- | --- | --- | --- | --- | +| 1680px | hidden | false | 0px | true | +| 1200px | hidden | true | 0px | true | +| 1000px | hidden | true | 0px | true | +| 800px | hidden | true | 0px | true | +| 600px | hidden | true | 0px | true | diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 112731204b..c4e5869251 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -42,6 +42,7 @@ "tests/hmr-live.e2e.ts", "tests/seeded-history.e2e.ts", "tests/sidebar-scrollbar.e2e.ts", + "tests/conversation-column-overflow.e2e.ts", "tests/code-mode-round.e2e.ts", "tests/composer-draft-scroll.e2e.ts", "tests/cordis-tool-round.e2e.ts", diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index b1c5f51451..0be5a9fcc0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -191,6 +191,14 @@ flex-direction: column; min-height: 0; overflow-y: auto; + /* The column scrolls on ONE axis. Stating `hidden` rather than leaving the + initial `visible` is what removes the horizontal bar: a box that scrolls in + one axis computes `visible` to `auto` in the other, so any bleed becomes + user-scrollable. `.heroGlow` bleeds by construction (1051/776 of the hero + box), which put a horizontal scrollbar under every center column narrower + than the glow. Clipping is unchanged — `overflow-y: auto` already made this + a scroll container that clips both axes, so this only takes away the bar. */ + overflow-x: hidden; /* Reserved unconditionally: the composer seat rides this box's content box in Chat and its padding box under a view's composer overlay, so an `auto` gutter moves the input card sideways by the bar's width whenever the two diff --git a/tsconfig.host.json b/tsconfig.host.json index 77c06dc9fb..37fe3aad2b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -29,6 +29,7 @@ "apps/web/tests/hmr-live.e2e.ts", "apps/web/tests/seeded-history.e2e.ts", "apps/web/tests/sidebar-scrollbar.e2e.ts", + "apps/web/tests/conversation-column-overflow.e2e.ts", "apps/web/tests/code-mode-round.e2e.ts", "apps/web/tests/composer-draft-scroll.e2e.ts", "apps/web/tests/cordis-tool-round.e2e.ts", From 21148232f52f1db82784acf5409b7838f1e57035 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 15:08:36 +0800 Subject: [PATCH 2/5] test(web): give the horizontal-wheel reading a smooth-scroll settle The 0 the shipped column reports cannot be reached by polling for a settled value, so the read is a fixed wait; make that wait cover a smooth-scroll animation on any engine the lane runs on. Identical on both sides of the mutation control, which is what keeps the 0 evidence rather than a race won. --- apps/web/tests/conversation-column-overflow.e2e.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index e676a7df94..5744986598 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -125,12 +125,13 @@ async function wheelHorizontally(page: Page): Promise { }) await page.mouse.move(origin.x, origin.y) await page.mouse.wheel(300, 0) - // Two frames: the scroll applies during the frame the wheel is dispatched - // into, and is readable in the next. Polling for a settled value cannot be + // A fixed settle, then two frames. Polling for a settled value cannot be // used here — the value under test is 0, which a poll starting at 0 accepts - // before the gesture has had any chance to move it. The timing is the same - // on both sides of the mutation control below, which is what makes a 0 - // reading evidence rather than a race won. + // before the gesture has had any chance to move it — so the wait is + // generous enough to cover a smooth-scroll animation on any engine the lane + // runs on. The timing is identical on both sides of the mutation control + // below, which is what makes a 0 reading evidence rather than a race won. + await page.waitForTimeout(400) return page.evaluate(() => new Promise((resolve) => { requestAnimationFrame(() => { requestAnimationFrame(() => { From 94e2c8129df3c8a508f71077adc325193eedefd1 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 15:14:06 +0800 Subject: [PATCH 3/5] test(web): share one sweep and settle the control's resize Review follow-ups on the column-overflow scenario: - Memoize the sweep so the golden and the assertions consume the same readings, which is what its contract already claimed; two runs could disagree if a resize settled differently between them. - Settle the column width before the mutation control measures. The test arrives from 1680 alone and from the sweep's 600 in a full run, and the frame eases its column tracks, so an immediate read can report the previous viewport's bleed. - Name the wheel delta, assert the bleed stays inside it, and compare the travelled distance rounded: a clamp or a sub-pixel would otherwise read as a broken fix. --- .../tests/conversation-column-overflow.e2e.ts | 76 +++++++++++++------ 1 file changed, 53 insertions(+), 23 deletions(-) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index 5744986598..03ba4f9572 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -49,6 +49,8 @@ const MODE = webSnapshotMode() const WIDTHS = [1680, 1200, 1000, 800, 600] /** Element id of the mutation control's injected sheet, so the test can take it back out. */ const CONTROL_STYLE_ID = 'dsh-column-overflow-control' +/** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */ +const WHEEL_DELTA = 300 /** One viewport stop: whether the glow bleeds past the column, and whether that bleed scrolls. */ interface ColumnMetrics { @@ -124,7 +126,7 @@ async function wheelHorizontally(page: Page): Promise { return { x: box.left + box.width / 2, y: box.top + 60 } }) await page.mouse.move(origin.x, origin.y) - await page.mouse.wheel(300, 0) + await page.mouse.wheel(WHEEL_DELTA, 0) // A fixed settle, then two frames. Polling for a settled value cannot be // used here — the value under test is 0, which a poll starting at 0 accepts // before the gesture has had any chance to move it — so the wait is @@ -190,28 +192,45 @@ describe('web e2e: the conversation column scrolls on one axis', () => { }) /** - * Sweep the stops once and hand the readings to every assertion below, so - * the golden and the assertions describe the same measurement rather than - * two runs that could disagree. + * Resize to a viewport and read the column once its width stops moving. + * + * The glow rides the hero box, which rides the column, and the frame eases + * its column tracks over `--ds-transition-duration-slow`: reading straight + * after a resize can report the previous viewport's relation, or a width + * caught mid-transition. + * @param width - viewport width to settle at. + * @returns the column's readings at that width. + */ + const settleAt = async (width: number): Promise => { + await page.setViewportSize({ width, height: 900 }) + let previous = -1 + await expect.poll(async () => { + const current = (await measureColumn(page, width)).columnWidth + const settled = current === previous + previous = current + return settled + }, { timeout: 10_000 }).toBe(true) + return measureColumn(page, width) + } + + /** + * Sweep the stops once per run and hand the SAME readings to every assertion + * below, so the golden and the assertions describe one measurement instead of + * two runs that could disagree. Memoized rather than re-run per test: the + * gestures below move the viewport, and a second sweep would be a second + * chance for a resize to settle differently. * @returns the stops in {@link WIDTHS} order. */ - const sweep = async (): Promise => { - const stops: ColumnStop[] = [] - for (const width of WIDTHS) { - await page.setViewportSize({ width, height: 900 }) - // The glow rides the hero box, which rides the column, and the column's - // track animates: settle on a column width that stops moving, or a stop - // gets read mid-transition and reports the previous viewport's relation. - let previous = -1 - await expect.poll(async () => { - const current = (await measureColumn(page, width)).columnWidth - const settled = current === previous - previous = current - return settled - }, { timeout: 10_000 }).toBe(true) - stops.push({ ...await measureColumn(page, width), scrollLeftAfterWheel: await wheelHorizontally(page) }) - } - return stops + let swept: Promise | undefined + const sweep = (): Promise => { + swept ??= (async () => { + const stops: ColumnStop[] = [] + for (const width of WIDTHS) { + stops.push({ ...await settleAt(width), scrollLeftAfterWheel: await wheelHorizontally(page) }) + } + return stops + })() + return swept } it('never scrolls horizontally, at any width the glow bleeds past', async () => { @@ -242,7 +261,10 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // that a one-axis scroller computes to `auto` — and shows the same gesture, // at the same timing, carrying the column to the full bleed. Without it a // `scrollLeft` of 0 could equally mean the wheel never arrived. - await page.setViewportSize({ width: 1200, height: 900 }) + // Settle the resize first: this test runs at 1680 on its own and after the + // sweep's 600 in a full run, and an unsettled column reports the previous + // viewport's bleed. + await settleAt(1200) // Injected with an id rather than through `addStyleTag`, so the teardown // below can take the sheet out again by selector: it must not outlive this // test, or the golden ends up reading the control. @@ -255,8 +277,16 @@ describe('web e2e: the conversation column scrolls on one axis', () => { try { const before = await measureColumn(page, 1200) expect(before.overflowX).toBe('auto') - expect(await wheelHorizontally(page)).toBe(before.bleedRange) expect(before.bleedRange).toBeGreaterThan(0) + // The gesture has to be able to reach the far edge, or the equality below + // would fail on the clamp and read as a broken fix. Stated as its own + // assertion so that failure names itself. + expect(before.bleedRange).toBeLessThan(WHEEL_DELTA) + // Rounded: `scrollLeft` is fractional under a fractional layout while + // `scrollWidth - clientWidth` is integral, and the claim is that the + // column travelled the whole bleed — not that two engines agree on a + // sub-pixel. + expect(Math.round(await wheelHorizontally(page))).toBe(before.bleedRange) } finally { await page.evaluate((id: string) => { document.getElementById(id)?.remove() From 438b769fbc7f5f2ef127da85b7f2c474de76bb08 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 18:38:58 +0800 Subject: [PATCH 4/5] test(web): reconcile one-axis scroll with stable gutter --- ...versation-column-one-axis-scroll.i18n.yaml | 4 +- ...-04-conversation-column-one-axis-scroll.md | 2 +- ...-conversation-column-one-axis-scroll.zh.md | 2 +- .../tests/conversation-column-overflow.e2e.ts | 44 ++++++++++++++----- .../geometry.expected.md | 6 +-- 5 files changed, 40 insertions(+), 18 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml index 47a432a7dd..754ca8bbd0 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md -2026-08-04-conversation-column-one-axis-scroll.md: fa2347e5e1b8d41da020db69840e1dcf32cfc4c3 -2026-08-04-conversation-column-one-axis-scroll.zh.md: aba34e304b8e6a349bd1e295ceb00d5ad809f6dd +2026-08-04-conversation-column-one-axis-scroll.md: 9a487c506a75033d0854f08e95da24704309003d +2026-08-04-conversation-column-one-axis-scroll.zh.md: 23441a7c8655d1f19d3c0fe0f661f81f69b55dba diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md index fa2347e5e1..9a487c506a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.md @@ -30,7 +30,7 @@ Clipping does not change. `overflow-y: auto` had already made the box a scroll c [apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) sweeps viewport widths bracketing the glow and, at each stop, wheels horizontally over the column and reads `scrollLeft`. The committed golden records the relation per stop; the widest stop is the control where the glow does not bleed at all. -Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to the full bleed — without it a `scrollLeft` of 0 could equally mean the wheel never arrived. +Two guards keep the scenario honest. The vacuity guard asserts the glow still reaches past the column at the narrow stops, so the claim cannot pass by the symptom having disappeared for an unrelated reason. The mutation control forces `overflow-x: auto` back on in the page and shows the same gesture, at the same timing, carrying the column to its positive scroll boundary; the test measures that boundary directly because a stable scrollbar gutter can leave some overflow on the negative side of the scroll origin. Without the control, a `scrollLeft` of 0 could equally mean the wheel never arrived. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md index aba34e304b..23441a7c86 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-04-conversation-column-one-axis-scroll.zh.md @@ -30,7 +30,7 @@ [apps/web/tests/conversation-column-overflow.e2e.ts](../../../../apps/web/tests/conversation-column-overflow.e2e.ts) 扫过一组把椭圆宽度夹在中间的视口宽度,在每一档上向列横向滚轮并读取 `scrollLeft`。提交的 golden 逐档记录该关系;最宽的一档是椭圆根本不外溢的对照。 -两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到完整的外溢量——没有它,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 +两道防线保证该场景不流于形式。空断言防线断言窄档上椭圆确实仍伸出列外,使这项主张不可能因为症状出于无关原因消失而通过。变异对照则在页面内把 `overflow-x: auto` 强制改回,证明同一手势在同一时序下能把列带到正向滚动边界。测试直接测量该边界,因为稳定的滚动条槽可能让部分外溢处于滚动原点的负向。没有这项对照,`scrollLeft` 读到 0 同样可以解释为滚轮根本没送达。 ## 后果 diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index 03ba4f9572..ae140bfd66 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -110,7 +110,7 @@ function measureColumn(page: Page, width: number): Promise { * programmatically scrollable and leaves `scrollWidth` untouched, so every * property reading agrees across the fix. Only refusing an actual input event * differs — measured at the 1200px stop, the shipped column stays at 0 while - * the same page with `overflow-x: auto` forced on lands at the full 66px bleed. + * the same page with `overflow-x: auto` forced on lands at its scroll boundary. * @param page - the page under test. * @returns `scrollLeft` after one horizontal wheel over the column. */ @@ -143,6 +143,28 @@ async function wheelHorizontally(page: Page): Promise { })) } +/** + * Measure the positive horizontal scroll boundary without changing the + * shipped overflow mode. This is distinct from `scrollWidth - clientWidth` + * when a stable scrollbar gutter leaves part of the overflow on the negative + * side of the scroll origin. + * @param page - the page under test. + * @returns the greatest positive `scrollLeft` reachable by the control gesture. + */ +async function horizontalScrollLimit(page: Page): Promise { + return page.evaluate((delta) => { + const scroller = document.querySelector('[data-conversation-scroll]') + if (scroller === null) throw new Error('conversation scroll container not in the DOM') + const previousScrollBehavior = scroller.style.scrollBehavior + scroller.style.scrollBehavior = 'auto' + scroller.scrollLeft = delta + const limit = scroller.scrollLeft + scroller.scrollLeft = 0 + scroller.style.scrollBehavior = previousScrollBehavior + return limit + }, WHEEL_DELTA) +} + /** A stop's readings plus where a horizontal wheel over it landed. */ type ColumnStop = ColumnMetrics & { /** `scrollLeft` after one horizontal wheel: the user-facing claim, 0 at every stop. */ @@ -259,8 +281,8 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // The mutation control, run in the page rather than against a second // build: it restores exactly what the fix changed — the initial `visible` // that a one-axis scroller computes to `auto` — and shows the same gesture, - // at the same timing, carrying the column to the full bleed. Without it a - // `scrollLeft` of 0 could equally mean the wheel never arrived. + // at the same timing, carrying the column to its positive scroll boundary. + // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived. // Settle the resize first: this test runs at 1680 on its own and after the // sweep's 600 in a full run, and an unsettled column reports the previous // viewport's bleed. @@ -278,15 +300,15 @@ describe('web e2e: the conversation column scrolls on one axis', () => { const before = await measureColumn(page, 1200) expect(before.overflowX).toBe('auto') expect(before.bleedRange).toBeGreaterThan(0) - // The gesture has to be able to reach the far edge, or the equality below - // would fail on the clamp and read as a broken fix. Stated as its own - // assertion so that failure names itself. - expect(before.bleedRange).toBeLessThan(WHEEL_DELTA) + const scrollLimit = await horizontalScrollLimit(page) + // The control has a reachable horizontal range, and the gesture exceeds + // it so the equality below proves that the wheel reached the far edge. + expect(scrollLimit).toBeGreaterThan(0) + expect(scrollLimit).toBeLessThan(WHEEL_DELTA) // Rounded: `scrollLeft` is fractional under a fractional layout while - // `scrollWidth - clientWidth` is integral, and the claim is that the - // column travelled the whole bleed — not that two engines agree on a - // sub-pixel. - expect(Math.round(await wheelHorizontally(page))).toBe(before.bleedRange) + // the claim is that the column reached the positive boundary, not that + // two engines agree on a sub-pixel. + expect(Math.round(await wheelHorizontally(page))).toBe(Math.round(scrollLimit)) } finally { await page.evaluate((id: string) => { document.getElementById(id)?.remove() diff --git a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md index b226cb845d..9735019508 100644 --- a/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md +++ b/apps/web/tests/snapshots/composer-tab-geometry/geometry.expected.md @@ -2,7 +2,7 @@ ## Wide viewport (1680px, card at its cap) -- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter stable, overflow hidden/auto @@ -14,7 +14,7 @@ ## Narrow viewport (800px, card shrinking with the column) -- Chat: scrollbar-gutter stable, overflow auto/auto +- Chat: scrollbar-gutter stable, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter stable, overflow hidden/auto @@ -26,7 +26,7 @@ ## Wide viewport, reservation removed in the page (control) -- Chat: scrollbar-gutter auto, overflow auto/auto +- Chat: scrollbar-gutter auto, overflow hidden/auto - Chat scroller scrolls: true - Chat reserved band: 8px - Trajectory: scrollbar-gutter auto, overflow hidden/hidden From cfc2783b878b817326c88700f81683ada73556a7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 5 Aug 2026 19:09:26 +0800 Subject: [PATCH 5/5] test(web): stabilize the overflow mutation control --- .../tests/conversation-column-overflow.e2e.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/web/tests/conversation-column-overflow.e2e.ts b/apps/web/tests/conversation-column-overflow.e2e.ts index ae140bfd66..97e1fd1fa2 100644 --- a/apps/web/tests/conversation-column-overflow.e2e.ts +++ b/apps/web/tests/conversation-column-overflow.e2e.ts @@ -40,13 +40,13 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/conversation-column-over */ const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md') const MODE = webSnapshotMode() +/** Narrow sweep stop where the mutation control retains overflow across scrollbar implementations. */ +const CONTROL_VIEWPORT = 600 /** - * Viewport widths bracketing the glow. The hero box is `min(776, column - 48)` - * and the glow is 1051/776 of it, so every stop under a ~1051px column bleeds - * and the widest one does not — the sweep therefore covers both sides of the - * relation rather than sampling one comfortable width. + * Viewport widths bracketing the glow: the narrow stops retain the reported + * bleed while the widest stop proves the relation can also be false. */ -const WIDTHS = [1680, 1200, 1000, 800, 600] +const WIDTHS = [1680, 1200, 1000, 800, CONTROL_VIEWPORT] /** Element id of the mutation control's injected sheet, so the test can take it back out. */ const CONTROL_STYLE_ID = 'dsh-column-overflow-control' /** Horizontal wheel delta per gesture; must exceed the widest bleed the sweep can produce. */ @@ -261,7 +261,9 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // The vacuity guard, in two halves: the glow has to reach past the column // at the narrow stops, and that reach has to still register as scrollable // overflow. Without both, the claim below holds for free. - expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([1200, 1000, 800, 600]) + expect(stops.filter(stop => stop.glowBleeds).map(stop => stop.width)).toEqual([ + 1200, 1000, 800, CONTROL_VIEWPORT, + ]) for (const stop of stops.filter(stop => stop.glowBleeds)) { expect(stop.bleedRange, `viewport ${String(stop.width)}`).toBeGreaterThan(0) } @@ -283,10 +285,6 @@ describe('web e2e: the conversation column scrolls on one axis', () => { // that a one-axis scroller computes to `auto` — and shows the same gesture, // at the same timing, carrying the column to its positive scroll boundary. // Without it a `scrollLeft` of 0 could equally mean the wheel never arrived. - // Settle the resize first: this test runs at 1680 on its own and after the - // sweep's 600 in a full run, and an unsettled column reports the previous - // viewport's bleed. - await settleAt(1200) // Injected with an id rather than through `addStyleTag`, so the teardown // below can take the sheet out again by selector: it must not outlive this // test, or the golden ends up reading the control. @@ -297,7 +295,10 @@ describe('web e2e: the conversation column scrolls on one axis', () => { document.head.append(sheet) }, CONTROL_STYLE_ID) try { - const before = await measureColumn(page, 1200) + // Resolve the mutated layout at the narrowest sweep stop. At wider stops, + // a classic scrollbar can change the available box enough to remove the + // overflow that the control is meant to expose. + const before = await settleAt(CONTROL_VIEWPORT) expect(before.overflowX).toBe('auto') expect(before.bleedRange).toBeGreaterThan(0) const scrollLimit = await horizontalScrollLimit(page) @@ -316,7 +317,7 @@ describe('web e2e: the conversation column scrolls on one axis', () => { } // The override is gone and the shipped state is back: the later goldens // read the product, not the control. - expect((await measureColumn(page, 1200)).overflowX).toBe('hidden') + expect((await settleAt(CONTROL_VIEWPORT)).overflowX).toBe('hidden') expect(tripwire.pageErrors).toEqual([]) }, 120_000)