DSH / Atlas
2026-07-27implementedbug-fixarchived 2026-08-07

Question-composer option rows are scroll content, not the slack absorber

提问 composer 的选项行是滚动内容,而非空间不足时的吸收方

The question composer card is capped against the viewport (`max-height: min(60vh, 520px)`) and scrolls its option list, so the header and the footer actions stay reachable on long question batches. When the composer seat got short — a small window, or a short viewport with the details panel open — the option rows rendered on top of each other and on top of the question title. The cap was not the defect; the distribut

English

Problem

The question composer card is capped against the viewport (max-height: min(60vh, 520px)) and scrolls its option list, so the header and the footer actions stay reachable on long question batches. When the composer seat got short — a small window, or a short viewport with the details panel open — the option rows rendered on top of each other and on top of the question title.

The cap was not the defect; the distribution of the shortfall was. .options is a flex-direction: column box whose children default to flex-shrink: 1, so under-allocation shrank the rows first instead of overflowing the scroll container. A row shrank to its min-height: 42px while .optionCopy kept the taller intrinsic height its wrapped copy needs (two lines for an option with a description). With align-items: center, the copy is then centered on a box shorter than itself and paints outside the row's border box in both directions — over the title above and the next row below. Measured on the shipped client at 900x440: 6.5px of copy outside the row box, growing to 10px at 380px tall, while .options reported scrollHeight === clientHeight and therefore never offered a scrollbar.

Only rows whose copy wraps can reproduce it. A row whose copy fits on one line has slack between its content and its 42px minimum, so shrinking it stays invisible — which is why the pre-existing e2e fixture (options Blue/Green, no descriptions) rendered correctly at every size.

Decision

.option and .custom declare flex-shrink: 0.

The rows are the scroll content of a capped card; the card's overflow belongs to .options, which already owns overflow-y: auto and min-height: 0. Pinning the children makes the shortfall reach that scroll container instead of being absorbed by the rows, which is the behavior the cap was designed for. The alternative — letting rows shrink but keeping the copy inside them — would require clipping or ellipsizing option descriptions at exactly the sizes where the user most needs to read them.

.header and .footer already carried flex-shrink: 0 for the same reason at the card level; the option list's children were the missing half of that rule.

Alternatives considered

Clip or ellipsize the copy inside a shrunk row (overflow: hidden on .option). This removes the overlap with one declaration and no layout rethink. Rejected because it trades a visible defect for a silent one: the row keeps its 42px, and the second line of an option description simply disappears at exactly the sizes where the card is tightest. The description is decision-relevant content, not decoration.

Drop align-items: center for align-items: flex-start. The copy would grow downward only, so it would no longer paint over the title above. It does not fix anything: a shrunk row still overflows onto the row below, and the fix would silently change the vertical alignment of every option row at every size, including the common one.

Remove the card's max-height cap so nothing is ever squeezed. No shortfall means no distribution problem. Rejected because the cap is what keeps the header and the footer actions on screen for a long question batch; removing it reintroduces the failure the cap exists to prevent (the composer seat is a fixed-height conversation column with overflow: hidden, so an uncapped card loses its own submit button instead).

Cap the wrapped copy at one line (white-space: nowrap plus ellipsis on .description). Rows would never wrap, so they could never overflow when shrunk. Rejected for the same reason as clipping, plus it degrades the wide-viewport rendering — where there is ample room — to fix a narrow-viewport defect.

Consequences

  • A squeezed composer scrolls its option list instead of overlapping it: at 900x380 the list reports scrollHeight 200 against clientHeight 114 and offers a scrollbar, where before it reported them equal and offered none.
  • Option rows keep their full wrapped copy at every viewport size. Nothing is clipped or ellipsized, and the wide-viewport rendering is unchanged (the rule only takes effect when the flex box is under-allocated).
  • The card now reaches its scroll state sooner, since the shortfall is no longer partly absorbed by the rows. That is the intended behavior of the cap, and it means a short seat shows a scrollbar in cases that previously showed a silently mis-painted list.
  • The scenario's recorded question is longer than it needs to be for the round trip it primarily tests. That cost is deliberate: the layout invariant is unfalsifiable without wrapping copy, and a second fixture for one CSS rule would be worse.

Verification

The web e2e composer scenario asserts the invariant on the live composer at three squeezed seat heights (900x520 / 440 / 380): every option row's children stay inside the row's border box. Two guards keep the assertion from holding vacuously — at least one row must be wrapped (the only shape that overflows) and .options must actually be scrolling (proof the seat is genuinely capped). The scenario's recorded question now carries long option descriptions for exactly that reason; without wrapping copy the assertion cannot fail.

Confirmed both directions against the built client: with flex-shrink: 0 reverted the scenario fails (scrolls: false, 6.5px spill), and with it restored it passes. A standalone geometry sweep over 340 viewport sizes (420-1600 x 320-960) went from 86 sizes with copy outside a row box to zero.

The assertion is replay-only: record mode must reach the fixture write rather than aborting on layout. Note that the composer ships as a client-module bundle, so pnpm run build:web alone does not pick up a change to QuestionComposer.module.css — the package build must run for the browser lane to see it.

Reproducing the shortfall requires a short viewport, not a short container. The cap is min(60vh, 520px), so shrinking the conversation column below the card's own height clips the card without under-allocating it — the rows keep their full height and nothing spills. Anything demonstrating or measuring this defect outside the e2e scenario has to change the viewport.

A stale lib/ makes the browser lane assert against an older client than the tree, and a pnpm run build that fails part-way leaves exactly that: the packages built before the failure are current, the rest are not. Refreshing a golden in that state records the older client's surface. Confirm the build exited zero before capturing, and note that untracked directories under packages/ are compiled too — a leftover from another branch can fail the build for reasons the diff does not explain.

中文

问题

提问 composer 的卡片会按视口设上限(max-height: min(60vh, 520px)),并让选项列表自行滚动,这样在成批提问时头部和底部操作按钮始终可达。但当 composer 的可用区域变矮时(窗口较小,或视口偏矮且详情面板处于展开状态),选项行会互相叠在一起,也会叠到问题标题上。

缺陷不在这个高度上限,而在高度不足时由谁来吸收。.options 是一个 flex-direction: column 的盒子,其子元素默认取 flex-shrink: 1,因此空间不足时首先被压缩的是各个选项行,而不是让滚动容器产生溢出。一行会被压到它的 min-height: 42px,而 .optionCopy 仍保持文案折行后所需的更大固有高度(带描述的选项会占两行)。由于 align-items: center,文案于是以一个比自身更矮的盒子为基准居中,并向上下两个方向画到该行边框盒之外——向上盖住标题,向下盖住下一行。在实际发布的客户端上于 900x440 处实测:文案有 6.5px 落在行盒之外,视口高度降到 380px 时增至 10px,而 .options 报告的 scrollHeight 等于 clientHeight,因此始终不会给出滚动条。

只有文案会折行的选项行才能复现该问题。文案单行即可容纳的行,其内容与 42px 最小高度之间尚有余量,被压缩也看不出来——这正是既有 e2e fixture(测试前置数据)(选项为 BlueGreen,无描述)在任何尺寸下都渲染正常的原因。

决策

.option.custom 声明 flex-shrink: 0

在设有高度上限的卡片中,这些行是滚动内容;卡片的溢出归 .options 承担,它本就持有 overflow-y: automin-height: 0。把子元素固定住之后,高度不足会传导到那个滚动容器,而不再被行本身吸收,这正是该高度上限设计时想要的行为。另一种做法是允许行被压缩,但把文案约束在行内,那就必须在用户最需要阅读选项描述的尺寸上对其做裁剪或省略号处理。

.header.footer 出于同样的原因,已在卡片层级带有 flex-shrink: 0;选项列表的子元素正是这条规则缺失的另一半。

曾考虑的替代方案

在被压缩的行内裁剪文案或加省略号(对 .optionoverflow: hidden)。 这样一条声明就能消除重叠,且不必重新考虑布局。之所以否决:它把一个可见缺陷换成了一个无声缺陷——行仍保持 42px,而选项描述的第二行会在卡片最紧张的那些尺寸上直接消失。描述属于影响决策的内容,不是装饰。

align-items: center 改为 align-items: flex-start 文案就只会向下生长,因此不再向上盖住标题。但这什么也没修好:被压缩的行依然会溢出到下一行上,而且这一改动会在所有尺寸下(包括常见尺寸)无声改变每个选项行的垂直对齐。

移除卡片的 max-height 上限,使其永远不会被压缩。 没有高度不足,就没有分配问题。之所以否决:正是这个上限保证成批提问时头部和底部操作按钮留在屏幕内;移除它会重新引入该上限本就为之存在的失败(composer 所处的容器是一个固定高度、overflow: hidden 的会话列,因此不设上限的卡片会连自己的提交按钮一起丢掉)。

把折行文案限制为单行(对 .descriptionwhite-space: nowrap 加省略号)。 行永远不会折行,因此被压缩时也永远不会溢出。否决理由与裁剪相同,此外它还为了修一个窄视口缺陷,而牺牲了空间充裕的宽视口渲染效果。

后果

  • 被压缩的 composer 会滚动其选项列表,而不是让选项行互相重叠:在 900x380 处,该列表报告 scrollHeight 为 200、clientHeight 为 114,并给出滚动条;此前两者相等,不给滚动条。
  • 选项行在任何视口尺寸下都保留完整的折行文案。不裁剪、不加省略号,宽视口下的渲染保持不变(该规则仅在 flex 盒子空间不足时才生效)。
  • 由于高度不足不再被行部分吸收,卡片现在更早进入滚动状态。这正是该高度上限想要的行为,也意味着在此前只会无声画错列表的情形下,较矮的可用区域现在会显示滚动条。
  • 该场景录制的问题,比它主要测试的那次往返所需的长度更长。这个代价是有意付出的:没有折行文案,该布局不变式无法被证伪,而为一条 CSS 规则再加一份 fixture 会更糟。

验证

Web e2e 的 composer 场景会在三个受挤压的可用区域高度下(900x520/440/380),在实际运行的 composer 上断言该不变式:每个选项行的子元素都留在该行的边框盒之内。两道守卫防止该断言空洞地成立——必须至少有一行处于折行状态(这是唯一会溢出的形态),且 .options 必须确实处在滚动状态(证明可用区域确实受到了高度上限约束)。该场景录制的问题现在带有较长的选项描述,正是为此;没有折行文案,这条断言不可能失败。

在构建产物客户端上双向确认过:撤销 flex-shrink: 0 后该场景失败(scrolls: false,6.5px 溢出),恢复后通过。一次覆盖 340 种视口尺寸(420-1600 x 320-960)的独立几何遍历,从 86 种尺寸存在文案落在行盒之外,降到 0 种。

该断言仅在回放模式下执行:录制模式必须走到写入 fixture 那一步,而不是在布局检查处中断。另需注意,composer 以客户端模块包的形式发布,因此单跑 pnpm run build:web 不会带上对 QuestionComposer.module.css 的改动——必须执行包构建,浏览器测试通道才能看到它。

要复现这种空间不足,需要的是矮视口,而不是矮容器。高度上限为 min(60vh, 520px),因此把会话列压到比卡片自身高度更矮,只会裁剪卡片,而不会让它空间不足——各行仍保持完整高度,也不会有任何溢出。凡是在 e2e 场景之外演示或测量该缺陷的手段,都必须改变视口。

lib/ 陈旧会让浏览器测试通道对着一个比工作树更旧的客户端做断言,而中途失败的 pnpm run build 留下的正是这种状态:失败之前构建的那些包是新的,其余不是。在这种状态下刷新预期输出,记录下来的是旧客户端的界面。抓取之前先确认构建以 0 退出;另需注意 packages/ 下的未跟踪目录同样会被编译——来自另一个分支的遗留物可能以 diff 无法解释的原因让构建失败。