DSH / Atlas
2026-08-04implementedfeature

Web composer shared width axis and control-row polish

Web 输入区共享宽度轴与控制行打磨

The web conversation column sized each surface independently: the transcript column, the input card, the todo/goal/queue dock cards, and the ask-question/approval/plan-review takeover cards each carried their own hardcoded max-width (736/752/776/800px variants) and their own side paddings. The surfaces drifted a few pixels apart at full width and diverged further on narrow viewports, where some panels kept clearance

English

Problem

The web conversation column sized each surface independently: the transcript column, the input card, the todo/goal/queue dock cards, and the ask-question/approval/plan-review takeover cards each carried their own hardcoded max-width (736/752/776/800px variants) and their own side paddings. The surfaces drifted a few pixels apart at full width and diverged further on narrow viewports, where some panels kept clearance from the screen edge and others went flush. Separately, the composer's control row had no adaptive behavior — on a narrow card the permission trigger's label squeezed the row — and the overlay menus anchored to the card could render wider than the card itself, painting past its right edge.

Decision

One content width variable owns the whole column. --dsh-chat-content-width (748px) is declared on ConversationRoot's .root — the transcript and the composer seat are sibling subtrees, so the declaration must sit on their common ancestor for CSS custom-property inheritance to reach both. Every other geometry derives from it: the input card caps at content + 32px (--dsh-composer-card-max-width), the dock cards subtract four dock insets (4 × 8px) from the card and land back on the content width, and the takeover cards use the content width directly. The narrow-viewport invariant is expressed structurally, not numerically: content-width surfaces pad calc(var(--dsh-composer-side-clearance) + 16px) per side while the input card clears the bare clearance (16px), so "input card = content + 32px" holds at every viewport width, not just at the cap.

The control row inside the card is a container-type: inline-size container, and the permission trigger drops its text label (keeping glyph + chevron) under a 460px container query. The query is anonymous on purpose: CSS modules hash container-name per module, so a name declared in InputBar's sheet can never match a query written in PermissionSelect's sheet — the two hashed names silently differ and the query never fires. Only triggers that carry a mode glyph collapse (:has(.triggerIcon)); a host-configured mode without one keeps its text as its sole identifier.

Overlay menus anchored to the card (slash menu, command popupSelect) clamp to the anchor's width (max-width: min(<design cap>, 100%)), truncating long rows with ellipses instead of overflowing the card. Tooltip bubbles keep a 12px viewport-edge safety margin in the clamp (ui-primitives Tooltip).

Alternatives considered

Keep per-surface widths and align the numbers by hand. Rejected: the drift this change removes was exactly the residue of hand-aligned constants; any future width change would need five coordinated edits with nothing enforcing the relation.

Declare the variables on .composerStack. Rejected after trying it: the takeover panels are siblings of the stack in the composer seat and the transcript is a different subtree entirely, so the variables never reached them; the common ancestor (.root) is the only correct home.

A named container query for the label collapse. Rejected by measurement: CSS modules scope container-name per module, so the cross-module name never matched and the query was dead. The anonymous query resolves against the nearest ancestor container, which is unambiguous here (the row is the only container).

JS ResizeObserver for the label collapse. Rejected: a container query is declarative, needs no listener lifecycle, and the 460px threshold is a design choice either way.

Consequences

Changing the column width is now a one-line edit with the ratio relations preserved by construction, which the 736 → 748 retune already exercised. The cost is indirection: the widths of five surfaces are no longer readable off their own stylesheets and require following the variable chain to ConversationRoot. The container-query collapse adds the constraint that InputBar's row stays a size container; removing that declaration silently disables the permission trigger's adaptive behavior. The anonymous query also means any future second container between the row and the trigger would capture it — if that happens, the query must move or the intermediate container must be avoided.

中文

问题

Web 会话列的各个区域各自独立设定尺寸:transcript(文本记录)列、输入卡片、todo/goal/queue 停靠卡片、ask-question/approval/plan-review 接管卡片各自硬编码 max-width(736/752/776/800px 等变体)与各自的侧边内边距。这些区域在全宽下彼此漂移几个像素,在窄视口下偏差更大——有的面板保留了到屏幕边缘的间隙,有的却贴边。另外,输入卡片的控制行没有自适应行为——窄卡片下权限触发器的文字标签会挤压整行;锚定在卡片上的浮层菜单也可能渲染得比卡片更宽,越过其右边缘。

决策

一个内容宽度变量控制整列。--dsh-chat-content-width(748px)声明在 ConversationRoot 的 .root 上——transcript 与 composer 座位是兄弟子树,声明必须放在共同祖先上,CSS 自定义属性才能通过继承同时到达两者。其他几何全部由它推导:输入卡片上限为 content + 32px--dsh-composer-card-max-width),停靠卡片从卡片宽度中减去四个停靠 inset(4 × 8px)正好回到内容宽度,接管卡片直接使用内容宽度。窄视口不变式以结构而非数值表达:内容宽度的区域每侧 pad calc(var(--dsh-composer-side-clearance) + 16px),而输入卡片只留裸 clearance(16px),因此「输入卡片 = 内容 + 32px」在任意视口宽度下都成立,而不只是在上限处。

卡片内的控制行是一个 container-type: inline-size 容器,权限触发器在 460px 容器查询下收起文字标签(保留图标 + 下拉箭头)。查询刻意匿名:CSS modules 按模块哈希 container-name,InputBar 样式表里声明的名字永远无法匹配 PermissionSelect 样式表里写的查询——两个哈希后的名字悄然不同,查询永不触发。只有带模式图标的触发器才收起(:has(.triggerIcon));没有图标的宿主自定义模式保留文字作为其唯一标识。

锚定在卡片上的浮层菜单(slash 菜单、command popupSelect)钳制到锚点宽度(max-width: min(<design cap>, 100%)),过长的行以省略号截断而不是溢出卡片。Tooltip 气泡在钳制中保留 12px 的视口边缘安全距离(ui-primitives Tooltip)。

考虑过的替代方案

保留各区域独立宽度,手工对齐数值。 否决:本次改动消除的漂移正是手工对齐常量的残留;未来任何宽度调整都需要五处协同编辑,且没有任何机制强制这组关系。

把变量声明在 .composerStack 上。 尝试后否决:接管面板在 composer 座位中是 stack 的兄弟节点,transcript 更是完全不同的子树,变量根本到不了它们;共同祖先(.root)是唯一正确的家。

用命名容器查询实现标签收起。 经实测否决:CSS modules 按模块作用域化 container-name,跨模块名字永不匹配,查询是死的。匿名查询解析到最近的祖先容器,在这里没有歧义(该行是唯一的容器)。

用 JS ResizeObserver 实现标签收起。 否决:容器查询是声明式的,无需监听器生命周期,而 460px 阈值无论哪种方案都是设计选择。

后果

修改列宽现在是一行编辑,比例关系由构造保证——736 → 748 的重调已经验证了这一点。代价是间接性:五个区域的宽度不再能从各自的样式表直接读出,需要沿变量链追到 ConversationRoot。容器查询收起增加了一个约束:InputBar 的行必须保持为尺寸容器;删掉那条声明会静默禁用权限触发器的自适应行为。匿名查询也意味着未来若在行与触发器之间出现第二个容器,它会截获该查询——届时查询必须迁移,或避免中间容器。