review: address ds-review-bot findings on composer geometry
- Rewrite the 08-04 gutter-reservation note in place: the reservation is Chat's alone and the overlay branch points to the seat-width compensation note; the Testing section now describes the uncompensated control. - Cross-reference the earlier rejection of the seat inset in the 08-12 note and record the engine-resolved-vs-fixed-8px drift as accepted residual cost instead of asserting Firefox resolves 8px. - Pair --dsh-scrollbar-width with the mirrored ::-webkit-scrollbar rule and the compensation consumer in the scrollbar-styles gate; document the variable in the ui-theme README. - Rename the e2e control to compareTabsWithoutCompensation and correct the vacuity-guard comment; rewrap the WorkspaceBrowser doc comment.
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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-composer-tab-gutter-reservation.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md
|
||||||
2026-08-04-composer-tab-gutter-reservation.md: 2c2813371af073eee4bd80ce7813e77af3c0c319
|
2026-08-04-composer-tab-gutter-reservation.md: 8bd9fb2d86982d82b44a82c55b7303fcd9a5bf4d
|
||||||
2026-08-04-composer-tab-gutter-reservation.zh.md: e450def2482218028875a48fce8842c88b5ba82e
|
2026-08-04-composer-tab-gutter-reservation.zh.md: 4b70aeb1d3777384907c345968971fcf75b3e74d
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
Status: implemented
|
Status: implemented
|
||||||
|
|
||||||
> Superseded for overlay views by [the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md): the overlay branch no longer reserves a gutter, and the seat compensates for the bar instead. Chat keeps the reservation described here.
|
|
||||||
|
|
||||||
English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md)
|
English | [中文](2026-08-04-composer-tab-gutter-reservation.zh.md)
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
@@ -16,13 +14,11 @@ So for as long as the transcript overflowed — the ordinary state of any sessio
|
|||||||
|
|
||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
`.scrollBody` declares `scrollbar-gutter: stable` unconditionally, and the overlay branch declares the same box a scroll container on both axes — `overflow-x: hidden; overflow-y: auto` — instead of `overflow: hidden`.
|
`.scrollBody` declares `scrollbar-gutter: stable` for the Chat state, and the overlay branch overrides it with `scrollbar-gutter: auto` while staying a scroll container on both axes — `overflow-x: hidden; overflow-y: auto`. The reservation is Chat's alone: it holds the seat's content box at the same width whether or not the transcript overflows, so the card never jumps as a growing transcript starts to scroll, nor between the hero phase and the first scrolling turn. The overlay branch reserves nothing — the view owns its own scrollers, so a gutter there would only narrow the view's content — and its seat compensates for the bar instead ([the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md)).
|
||||||
|
|
||||||
The two halves are one change. The reservation is what makes both states measure against the same width; declaring the overlay branch a scroll container is what makes the reservation reach it. `stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between the two tabs — an `auto` gutter would state the bug rather than fix it.
|
`stable` rather than `auto` because `auto` reserves only while the box actually overflows, and the difference between overflowing and not is precisely the difference between Chat's two phases — an `auto` gutter would state the bug rather than fix it.
|
||||||
|
|
||||||
The overlay state is a scroll container that nothing scrolls: the view fills it (`flex: 1 1 0` with its own clip) and the seat is out of flow, so no gesture and no clipping behavior changes. What changes is which declarations the engine honours. WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation left on a hidden box would hold in Chromium and silently not in Safari.
|
The reservation lives on an `overflow-y: auto` box, and that form is load-bearing: WebKit applies `scrollbar-gutter` to an `overflow-y: auto` box and ignores it on a hidden one — measured on this app's own composer layers and recorded in [the composer scrollport note](2026-07-31-composer-text-layers-share-one-scrollport.md) — so a reservation on a hidden box would hold in Chromium and silently not in Safari. The overlay branch keeps its `overflow-y: auto` form too, as a clipping box nothing scrolls out of: a single-axis scroller computes the other axis to `auto`, so the horizontal axis is declared `hidden` rather than left to compute, and would otherwise grow a horizontal scrollbar of its own the first time a view's content reached past the column.
|
||||||
|
|
||||||
The horizontal axis is declared rather than left to compute: a box that scrolls on one axis computes `visible` on the other to `auto`, and would grow a horizontal scrollbar of its own the first time a view's content reached past the column.
|
|
||||||
|
|
||||||
The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
|
The reservation is worth what it costs only because the bar takes layout space here at all, which is not the browser's default behavior but this client's: `::-webkit-scrollbar` carries a width in ui-theme's sheet ([themed scrollbars](2026-07-28-themed-scrollbars-and-reserved-gutter.md)), and the sidebar's session list already reserves its own gutter for the same reason.
|
||||||
|
|
||||||
@@ -39,7 +35,7 @@ The reservation is worth what it costs only because the bar takes layout space h
|
|||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
|
- Chat's content column is permanently 8px narrower — in the hero phase and while the transcript is short as well, where no bar is drawn. That is the trade: one card position at every content height, instead of the widest possible column.
|
||||||
- The fix covers three transitions with one declaration, because all three are the same difference: Chat ↔ Trajectory, short ↔ scrolling transcript within Chat, and hero ↔ first scrolling turn.
|
- The card holds one position across three transitions, by two mechanisms: the reservation keeps Chat's seat at one width across its own phases (short ↔ scrolling transcript, hero ↔ first scrolling turn), and the overlay seat's compensation matches it on the Chat ↔ Trajectory transition ([the seat-width compensation](2026-08-12-composer-overlay-seat-width-compensation.md)).
|
||||||
- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
|
- The overlay state is now a scroll container. Nothing in it can overflow today; a future view that let its content exceed the column would scroll this box instead of clipping, and would need its own clip the way the Trajectory view already has one.
|
||||||
- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.
|
- The committed golden records the reserved band, so a change to the sheet's `::-webkit-scrollbar` width — the value that decides how wide the reservation is — arrives as a reviewable diff in this scenario as well as in the sidebar's.
|
||||||
|
|
||||||
@@ -47,6 +43,6 @@ The reservation is worth what it costs only because the bar takes layout space h
|
|||||||
|
|
||||||
`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.
|
`apps/web/tests/composer-tab-geometry.e2e.ts` measures the input card's rectangle in both tabs, at a viewport where the card sits at its width cap and one where it shrinks with the column, and asserts the two rectangles are the same rectangle. Only a real engine reports this: jsdom gives every element a zero-sized box and no scrollbar, so a unit spec could assert the declarations exist but not that the two states land in the same place. For the same reason no CSS-text spec accompanies it — it would restate the declarations without adding a fact the browser lane does not already establish.
|
||||||
|
|
||||||
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, both tabs agree before this change as much as after it, and every comparison in the file holds vacuously. Measured, the pre-fix cascade leaves both bands at 0 under the argument, and at 8 and 0 with it dropped.
|
The scenario launches chromium without Playwright's default `--hide-scrollbars`, which is load-bearing: under that argument a bar consumes no layout width, so the tabs agree with and without the compensation and every comparison in the file holds vacuously. Measured, both bands sit at 0 under the argument and at 8 and 0 with it dropped.
|
||||||
|
|
||||||
The pre-fix cascade is then applied in the page — `scrollbar-gutter: auto` on the scroller, `overflow: hidden` on the overlay branch — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.
|
The uncompensated cascade is then applied in the page — the overlay seat's `right` compensation dropped to 0 via `!important`, Chat's reservation untouched — and the same two tabs measured through it, which is what separates a card that does not move from a tab switch that never reached the layout. It reproduces the reported symptom as a number: 4px on each edge, half the 8px band. The golden records that control beside the fixed state, so the fixture carries the difference the change removes rather than only its absence.
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
Status: implemented
|
Status: implemented
|
||||||
|
|
||||||
> 对覆盖视图已被 [座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.md) 取代:覆盖分支不再预留滚动条槽,座位改为补偿滚动条宽度。Chat 仍保留本文所述的预留。
|
|
||||||
|
|
||||||
[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文
|
[English](2026-08-04-composer-tab-gutter-reservation.md) | 中文
|
||||||
|
|
||||||
## 问题
|
## 问题
|
||||||
@@ -16,13 +14,11 @@ composer 座位在组件树中只有一个节点、一个位置,但它究竟
|
|||||||
|
|
||||||
## 决策
|
## 决策
|
||||||
|
|
||||||
`.scrollBody` 无条件声明 `scrollbar-gutter: stable`,overlay 分支则把同一个盒子在两个轴向上都声明为滚动容器——`overflow-x: hidden; overflow-y: auto`——而不再是 `overflow: hidden`。
|
`.scrollBody` 为 Chat 状态声明 `scrollbar-gutter: stable`,覆盖分支则将其覆盖为 `scrollbar-gutter: auto`,同时保持为双轴滚动容器——`overflow-x: hidden; overflow-y: auto`。这条预留只属于 Chat:它让座位的内容盒在 transcript 是否溢出时都保持同一宽度,因此卡片不会在 transcript 增长到开始滚动的那一刻跳动,也不会在 hero 态与第一个可滚动轮次之间跳动。覆盖分支不预留任何槽位——视图自己滚动,槽位只会白白收窄视图内容——它的座位改为补偿滚动条宽度([座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.md))。
|
||||||
|
|
||||||
这两半是同一处改动。预留使两种状态依附于同一个宽度;把 overlay 分支声明为滚动容器,才使这条预留真正抵达它。选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是两个标签页之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
|
选 `stable` 而非 `auto`,是因为 `auto` 只在盒子确实溢出时才预留,而「溢出与否」恰恰就是 Chat 两种相位之间的那点差别——`auto` 的写法只是把缺陷重述一遍,并不能修掉它。
|
||||||
|
|
||||||
overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图把它填满(`flex: 1 1 0`,且自带裁剪),座位不在常规流中,因此没有任何手势与裁剪行为发生变化。变化的是引擎会认哪些声明。WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留留在一个 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。
|
这条预留位于 `overflow-y: auto` 的盒子上,而这个形式是承重的:WebKit 对 `overflow-y: auto` 的盒子应用 `scrollbar-gutter`,对 hidden 的盒子则忽略它——这是在本应用 composer 自身的图层上实测所得,并记录于 [composer 滚动视口记录](2026-07-31-composer-text-layers-share-one-scrollport.md)——所以把预留放在 hidden 盒子上,会在 Chromium 上成立,在 Safari 上悄无声息地不成立。覆盖分支同样保留 `overflow-y: auto` 的形式,作为没有任何内容会滚出去的裁剪盒:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,因此横向轴显式声明为 `hidden` 而不是交给推导,否则某个视图的内容第一次伸出列外时,它就会长出自己的横向滚动条。
|
||||||
|
|
||||||
横向轴是显式声明的,而不是交给推导:单轴滚动的盒子会把另一轴的 `visible` 计算为 `auto`,于是只要某个视图的内容第一次伸出列外,它就会长出自己的横向滚动条。
|
|
||||||
|
|
||||||
这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
|
这条预留之所以值回它的代价,前提是滚动条在这里确实占布局空间——这并非浏览器的默认行为,而是本客户端的选择:ui-theme 的样式表给 `::-webkit-scrollbar` 声明了宽度([滚动条主题化](2026-07-28-themed-scrollbars-and-reserved-gutter.md)),侧边栏的会话列表也正是出于同一原因预留了自己的滚动条槽。
|
||||||
|
|
||||||
@@ -39,7 +35,7 @@ overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图
|
|||||||
## 后果
|
## 后果
|
||||||
|
|
||||||
- Chat 的内容列永久变窄 8px——hero 态与 transcript 尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
|
- Chat 的内容列永久变窄 8px——hero 态与 transcript 尚短、根本不绘制滚动条时同样如此。这就是这笔交易:以最宽的列换取卡片在任何内容高度下都只有一个位置。
|
||||||
- 一条声明覆盖三种切换,因为这三者本就是同一个差异:Chat ↔ Trajectory、Chat 内部 transcript 较短 ↔ transcript 可滚动,以及 hero ↔ 第一个可滚动轮次。
|
- 卡片在三种切换下保持同一位置,由两种机制达成:预留让 Chat 的座位在自身各相位间保持同一宽度(transcript 较短 ↔ 可滚动、hero ↔ 第一个可滚动轮次),Chat ↔ Trajectory 的切换则由覆盖座位的补偿来对齐([座位宽度补偿](2026-08-12-composer-overlay-seat-width-compensation.md))。
|
||||||
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
|
- overlay 状态现在是一个滚动容器。今天其中没有任何内容会溢出;将来若有视图允许自身内容超出会话列,这个盒子会滚动而不是裁剪,那个视图就需要像 Trajectory 视图那样自带裁剪。
|
||||||
- 提交的 golden 记录了预留条带,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
|
- 提交的 golden 记录了预留条带,因此样式表中 `::-webkit-scrollbar` 宽度的变化——决定这条预留有多宽的那个值——会在本场景中与在侧边栏场景中一样,以可评审的 diff 形式出现。
|
||||||
|
|
||||||
@@ -47,6 +43,6 @@ overlay 状态是一个没有任何东西会去滚动它的滚动容器:视图
|
|||||||
|
|
||||||
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
|
`apps/web/tests/composer-tab-geometry.e2e.ts` 在两个标签页下测量输入卡片的矩形,分别取卡片处于宽度上限的视口与卡片随列收缩的视口,并断言这两个矩形是同一个矩形。只有真实引擎能报告这件事:jsdom 给每个元素的盒子尺寸都是零,也没有滚动条,因此单元测试只能断言那些声明存在,无法断言两种状态落在同一位置。出于同一原因,本次没有附带读取 CSS 文本的单元测试——它只会把声明复述一遍,并不会补上浏览器车道尚未确立的事实。
|
||||||
|
|
||||||
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,两个标签页在改动前后同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时,改动前的层叠让两条预留带的宽度都是 0;去掉它则分别是 8 与 0。
|
该场景启动 chromium 时去掉了 Playwright 默认的 `--hide-scrollbars`,这一点是承重的:带上该参数时滚动条不占任何布局宽度,因此两个标签页在有补偿与无补偿时同样一致,文件中的每一处比较都会空洞地通过。实测:带上该参数时两条预留带的宽度都是 0;去掉它则分别是 8 与 0。
|
||||||
|
|
||||||
随后,改动前的层叠会被注入页面——滚动容器上 `scrollbar-gutter: auto`,overlay 分支上 `overflow: hidden`——并在其下测量同样的两个标签页,这正是把「卡片确实没动」与「标签页切换根本没到达布局」区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture(测试前置数据)承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。
|
随后,未补偿的级联会被注入页面——通过 `!important` 把覆盖座位的 `right` 补偿降为 0,Chat 的预留保持不变——并在其下测量同样的两个标签页,这正是把「卡片确实没动」与「标签页切换根本没到达布局」区分开的那一步。它把上报的症状复现为一个数字:每条边 4px,恰是 8px 带宽的一半。golden 把这份对照与修复后的状态并排记录,因此 fixture(测试前置数据)承载的是这次改动所消除的那个差值,而不仅仅是它的缺席。
|
||||||
+2
-2
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# 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-12-composer-overlay-seat-width-compensation.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md
|
||||||
2026-08-12-composer-overlay-seat-width-compensation.md: 85281ed4301046672ff4c02320b196a397e6d163
|
2026-08-12-composer-overlay-seat-width-compensation.md: 0ec4d1272ac1adab5b724dccf44f567e15cc3368
|
||||||
2026-08-12-composer-overlay-seat-width-compensation.zh.md: 7bcf060e651a828068ef207caaebd01fa2ffd7ae
|
2026-08-12-composer-overlay-seat-width-compensation.zh.md: 2f66771240fe62e15c481342d55a82dd09cfb864
|
||||||
+5
-2
@@ -14,7 +14,7 @@ The trajectory table made that cost visible: its full-width row divider lines st
|
|||||||
|
|
||||||
The reservation now belongs to Chat alone. The overlay branch declares `scrollbar-gutter: auto`, so the view's content spans the full column; the overlay composer seat (absolutely positioned against the padding box) gives back the bar's width with `right: var(--dsh-scrollbar-width)`, so the input card still measures the same width as Chat's seat and does not move between tabs.
|
The reservation now belongs to Chat alone. The overlay branch declares `scrollbar-gutter: auto`, so the view's content spans the full column; the overlay composer seat (absolutely positioned against the padding box) gives back the bar's width with `right: var(--dsh-scrollbar-width)`, so the input card still measures the same width as Chat's seat and does not move between tabs.
|
||||||
|
|
||||||
The compensation value is not a literal: ui-theme's scrollbar.css defines `--dsh-scrollbar-width` (8px on the WebKit path) beside the `::-webkit-scrollbar` rule it mirrors, and the seat reads that variable. A change to the sheet's bar width reaches the compensation in the same reviewable diff as the bar itself.
|
The compensation value is not a literal: ui-theme's scrollbar.css defines `--dsh-scrollbar-width` (8px on the WebKit path) beside the `::-webkit-scrollbar` rule it mirrors, and the seat reads that variable. The scrollbar-styles spec pairs the variable with the mirrored rule and with the compensation consumer, so a change to the sheet's bar width without the variable — or to the variable without the consumer — fails the gate, not just review.
|
||||||
|
|
||||||
## Alternatives considered
|
## Alternatives considered
|
||||||
|
|
||||||
@@ -24,12 +24,15 @@ The compensation value is not a literal: ui-theme's scrollbar.css defines `--dsh
|
|||||||
|
|
||||||
**Accept the 4px card shift.** Dropping the reservation without compensating the seat would move the input card on every tab switch, which is exactly the symptom the earlier note fixed. Rejected: the card position is a deliberate cross-tab invariant.
|
**Accept the 4px card shift.** Dropping the reservation without compensating the seat would move the input card on every tab switch, which is exactly the symptom the earlier note fixed. Rejected: the card position is a deliberate cross-tab invariant.
|
||||||
|
|
||||||
|
**Inset the overlay seat by the bar's width.** The [gutter-reservation note](2026-08-04-composer-tab-gutter-reservation.md) rejected exactly this, and this note adopts it; what changed is the rejection's premise. The number was the engine's, not ours — the WebKit path draws the sheet's 8px bar while the Firefox path draws whatever `scrollbar-width: thin` resolves to — so a hardcoded inset would line the two states up in Chromium and drift elsewhere. The overlay branch reserved an engine-resolved gutter of its own back then, so an inset had to match that width exactly. Today the overlay branch reserves nothing, so the compensation is the overlay side's only mechanism, and the literal half of the rejection is answered by making the 8px a variable that mirrors the `::-webkit-scrollbar` rule in the same diff. The Firefox half remains: Chat reserves the engine-resolved width while the compensation stays fixed, and the residual drift where the two differ is recorded as an accepted cost in Consequences.
|
||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
- Chat keeps its reserved gutter and its stable card position; nothing changes on that tab.
|
- Chat keeps its reserved gutter and its stable card position; nothing changes on that tab.
|
||||||
- Overlay views (trajectory) span the full column; the trajectory ledger's divider lines reach the pane edge.
|
- Overlay views (trajectory) span the full column; the trajectory ledger's divider lines reach the pane edge.
|
||||||
- The input card still holds one horizontal position across the Chat and Trajectory tabs, now by two mechanisms instead of one: Chat reserves, the overlay seat compensates.
|
- The input card still holds one horizontal position across the Chat and Trajectory tabs, now by two mechanisms instead of one: Chat reserves, the overlay seat compensates.
|
||||||
- `--dsh-scrollbar-width` becomes a public ui-theme variable read outside ui-theme; the scrollbar-styles spec's indirection checks only scan `--dsh-scrollbar-thumb{,-hover}` rebinds, so the width variable is not covered by the pair gate.
|
- Chat reserves the engine-resolved width while the overlay seat compensates a fixed 8px. Where the two differ — the Firefox path resolves `scrollbar-width: thin` per platform, and the e2e runs only on Chromium — the card drifts by half the difference on tab switch. Accepted residual cost, recorded here rather than asserted away: no measurement of the Firefox thin width on the target platforms exists in this change.
|
||||||
|
- `--dsh-scrollbar-width` becomes a public ui-theme variable read outside ui-theme; the scrollbar-styles spec pairs it with the mirrored `::-webkit-scrollbar` width rule and with the compensation consumer, closing the indirection-gate gap the variable would otherwise leave.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
+5
-2
@@ -14,7 +14,7 @@ trajectory 表格让这个代价显形:整行分隔线在面板右边缘前 8p
|
|||||||
|
|
||||||
预留现在只属于 Chat。覆盖分支声明 `scrollbar-gutter: auto`,视图内容占满整列;覆盖分支的 composer 座位(相对 padding box 绝对定位)用 `right: var(--dsh-scrollbar-width)` 让出滚动条宽度,使输入卡仍与 Chat 座位测得相同宽度,切换标签页时不移动。
|
预留现在只属于 Chat。覆盖分支声明 `scrollbar-gutter: auto`,视图内容占满整列;覆盖分支的 composer 座位(相对 padding box 绝对定位)用 `right: var(--dsh-scrollbar-width)` 让出滚动条宽度,使输入卡仍与 Chat 座位测得相同宽度,切换标签页时不移动。
|
||||||
|
|
||||||
补偿值不是字面量:ui-theme 的 scrollbar.css 在它镜像的 `::-webkit-scrollbar` 规则旁定义 `--dsh-scrollbar-width`(WebKit 路径 8px),座位读取该变量。样式表滚动条宽度一变,补偿就会与滚动条本身出现在同一次可审阅的 diff 中。
|
补偿值不是字面量:ui-theme 的 scrollbar.css 在它镜像的 `::-webkit-scrollbar` 规则旁定义 `--dsh-scrollbar-width`(WebKit 路径 8px),座位读取该变量。scrollbar-styles 规格把该变量与其镜像规则、以及补偿消费者配对检查,因此样式表滚动条宽度一变却不同步变量——或变量一变却不同步消费者——都会让门禁失败,而不只是评审时发现。
|
||||||
|
|
||||||
## 备选方案
|
## 备选方案
|
||||||
|
|
||||||
@@ -24,12 +24,15 @@ trajectory 表格让这个代价显形:整行分隔线在面板右边缘前 8p
|
|||||||
|
|
||||||
**接受 4px 卡片位移。** 去掉预留却不补偿座位,会在每次切换标签页时移动输入卡——正是前一份 note 修复的症状。已拒绝:卡片位置是刻意保持的跨标签页不变量。
|
**接受 4px 卡片位移。** 去掉预留却不补偿座位,会在每次切换标签页时移动输入卡——正是前一份 note 修复的症状。已拒绝:卡片位置是刻意保持的跨标签页不变量。
|
||||||
|
|
||||||
|
**把 overlay 座位按滚动条宽度内缩。** [滚动条槽预留 note](2026-08-04-composer-tab-gutter-reservation.md) 当初否决的正是这个方案,本 note 采纳了它;变的是否决的前提。这个数字属于引擎而不属于我们——WebKit 路径绘制样式表里的 8px 滚动条,Firefox 路径绘制 `scrollbar-width: thin` 解析出的宽度——因此硬编码的内缩会让两种状态在 Chromium 上对齐、在别处继续漂移。当初 overlay 分支自己预留的是引擎解析出的槽宽,内缩必须精确匹配那个宽度。如今 overlay 分支不预留任何槽位,补偿成为覆盖侧唯一的机制;否决的字面量那一半,通过把 8px 变成与 `::-webkit-scrollbar` 规则同处一个 diff 的变量来回应。Firefox 那一半仍然存在:Chat 预留引擎解析宽度,补偿保持固定 8px,两者不等之处的残余漂移作为接受的代价记录在后果中。
|
||||||
|
|
||||||
## 后果
|
## 后果
|
||||||
|
|
||||||
- Chat 保留滚动条槽与稳定的卡片位置;该标签页无任何变化。
|
- Chat 保留滚动条槽与稳定的卡片位置;该标签页无任何变化。
|
||||||
- 覆盖视图(trajectory)占满整列;trajectory 台账的分隔线到达面板右边缘。
|
- 覆盖视图(trajectory)占满整列;trajectory 台账的分隔线到达面板右边缘。
|
||||||
- 输入卡在 Chat 与 Trajectory 标签页间仍保持同一水平位置,现在由两种机制而非一种达成:Chat 预留,覆盖座位补偿。
|
- 输入卡在 Chat 与 Trajectory 标签页间仍保持同一水平位置,现在由两种机制而非一种达成:Chat 预留,覆盖座位补偿。
|
||||||
- `--dsh-scrollbar-width` 成为 ui-theme 对外、且被 ui-theme 之外读取的变量;scrollbar-styles 规格的间接层检查只扫描 `--dsh-scrollbar-thumb{,-hover}` 重绑,宽度变量不受成对门禁覆盖。
|
- Chat 预留引擎解析宽度,覆盖座位补偿固定的 8px。两者不等之处——Firefox 路径按平台解析 `scrollbar-width: thin`,而 e2e 只在 Chromium 上运行——卡片在切换标签页时会漂移半个差值。这是接受的残余代价,如实记录于此而不断言消除:本次改动并未提供目标平台 Firefox thin 宽度的实测。
|
||||||
|
- `--dsh-scrollbar-width` 成为 ui-theme 对外、且被 ui-theme 之外读取的变量;scrollbar-styles 规格把它与镜像的 `::-webkit-scrollbar` 宽度规则、以及补偿消费者配对检查,补上了该变量本会留下的间接层门禁缺口。
|
||||||
|
|
||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
|
|||||||
@@ -28,13 +28,13 @@
|
|||||||
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
|
// The browser is launched WITHOUT Playwright's default `--hide-scrollbars`,
|
||||||
// which is load-bearing rather than incidental. Under that argument a scroll
|
// which is load-bearing rather than incidental. Under that argument a scroll
|
||||||
// container's bar consumes no layout width at all, so the two tabs agree with
|
// container's bar consumes no layout width at all, so the two tabs agree with
|
||||||
// and without the reservation and every comparison below holds vacuously —
|
// and without the compensation and every comparison below holds vacuously —
|
||||||
// measured: the unreserved cascade leaves both tabs' bands at 0 there, against
|
// measured: the uncompensated cascade leaves both tabs' bands at 0 there,
|
||||||
// 8 and 0 with the argument dropped. Dropping it is also the faithful
|
// against 8 and 0 with the argument dropped. Dropping it is also the faithful
|
||||||
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
|
// configuration: ui-theme's scrollbar.css gives `::-webkit-scrollbar` a width,
|
||||||
// and a bar that occupies layout space is what the product actually draws.
|
// and a bar that occupies layout space is what the product actually draws.
|
||||||
//
|
//
|
||||||
// The scenario runs that unreserved cascade in the page — the overlay seat's
|
// The scenario runs that uncompensated cascade in the page — the overlay seat's
|
||||||
// `right` compensation dropped to 0 — and measures the same two tabs through
|
// `right` compensation dropped to 0 — and measures the same two tabs through
|
||||||
// it, which is what keeps the equal rectangles above from being explained by a
|
// it, which is what keeps the equal rectangles above from being explained by a
|
||||||
// tab switch that never reached the layout. It is the reported symptom as a
|
// tab switch that never reached the layout. It is the reported symptom as a
|
||||||
@@ -227,7 +227,7 @@ async function compareTabs(page: Page): Promise<TabComparison> {
|
|||||||
* @param page - the page under test.
|
* @param page - the page under test.
|
||||||
* @returns the comparison as the column lays out without the compensation.
|
* @returns the comparison as the column lays out without the compensation.
|
||||||
*/
|
*/
|
||||||
async function compareTabsWithoutReservation(page: Page): Promise<TabComparison> {
|
async function compareTabsWithoutCompensation(page: Page): Promise<TabComparison> {
|
||||||
await page.evaluate(({ id, css }) => {
|
await page.evaluate(({ id, css }) => {
|
||||||
const style = document.createElement('style')
|
const style = document.createElement('style')
|
||||||
style.id = id
|
style.id = id
|
||||||
@@ -327,9 +327,13 @@ describe('web e2e: input card position across view tabs', () => {
|
|||||||
it('reserves the gutter in Chat and lets Trajectory own its width', async () => {
|
it('reserves the gutter in Chat and lets Trajectory own its width', async () => {
|
||||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-band'))
|
||||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||||
// Vacuity guard, in two parts. A transcript that does not overflow gives
|
// Vacuity guard. The scenario must be able to fail: on an engine that
|
||||||
// Chat no scrollbar, and a hidden or overlaid bar gives it no width; either
|
// does not implement `scrollbar-gutter`, Chat reserves nothing and the
|
||||||
// would make the tabs agree without the compensation doing anything.
|
// overlay seat's fixed compensation stands alone, manufacturing an 8px
|
||||||
|
// deviation the equal-rectangle assertions would catch. `stable` reserves
|
||||||
|
// even without overflow, so a short transcript is not a vacuous case; the
|
||||||
|
// poll still pins the measurement to the overflowing state the product
|
||||||
|
// ships.
|
||||||
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
|
await expect.poll(async () => (await measureTab(page)).scrolls, { timeout: 10_000 }).toBe(true)
|
||||||
const comparison = await compareTabs(page)
|
const comparison = await compareTabs(page)
|
||||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||||
@@ -356,7 +360,7 @@ describe('web e2e: input card position across view tabs', () => {
|
|||||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||||
const comparison = await compareTabs(page)
|
const comparison = await compareTabs(page)
|
||||||
// The reported symptom as a number. At this viewport the card sits at its
|
// The reported symptom as a number. At this viewport the card sits at its
|
||||||
// width cap, so the unreserved cascade's shift shows up as a centring
|
// width cap, so the uncompensated cascade's shift shows up as a centring
|
||||||
// difference — half the band on each edge — rather than as a width change.
|
// difference — half the band on each edge — rather than as a width change.
|
||||||
expect(comparison.leftShift).toBe(0)
|
expect(comparison.leftShift).toBe(0)
|
||||||
expect(comparison.rightShift).toBe(0)
|
expect(comparison.rightShift).toBe(0)
|
||||||
@@ -387,11 +391,11 @@ describe('web e2e: input card position across view tabs', () => {
|
|||||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-composer-tab-geometry-control'))
|
||||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||||
// The control: without it, equal rectangles could also mean the tab switch
|
// The control: without it, equal rectangles could also mean the tab switch
|
||||||
// never reached the layout. Under the unreserved cascade the overlay seat
|
// never reached the layout. Under the uncompensated cascade the overlay seat
|
||||||
// loses its `right` compensation and measures the full padding box, so the
|
// loses its `right` compensation and measures the full padding box, so the
|
||||||
// card moves by half the band on each edge. Chat's own reservation is
|
// card moves by half the band on each edge. Chat's own reservation is
|
||||||
// untouched — that is the side that must not change.
|
// untouched — that is the side that must not change.
|
||||||
const comparison = await compareTabsWithoutReservation(page)
|
const comparison = await compareTabsWithoutCompensation(page)
|
||||||
expect(comparison.chat.gutter).toBe('stable')
|
expect(comparison.chat.gutter).toBe('stable')
|
||||||
expect(comparison.chat.band).toBeGreaterThan(0)
|
expect(comparison.chat.band).toBeGreaterThan(0)
|
||||||
expect(comparison.trajectory.band).toBe(0)
|
expect(comparison.trajectory.band).toBe(0)
|
||||||
@@ -411,7 +415,7 @@ describe('web e2e: input card position across view tabs', () => {
|
|||||||
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
await setMeasuredViewport(page, NARROW_VIEWPORT, true)
|
||||||
const narrow = await compareTabs(page)
|
const narrow = await compareTabs(page)
|
||||||
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
await setMeasuredViewport(page, WIDE_VIEWPORT, false)
|
||||||
const control = await compareTabsWithoutReservation(page)
|
const control = await compareTabsWithoutCompensation(page)
|
||||||
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
|
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(wide, narrow, control), MODE)
|
||||||
expect(tripwire.pageErrors).toEqual([])
|
expect(tripwire.pageErrors).toEqual([])
|
||||||
}, 60_000)
|
}, 60_000)
|
||||||
|
|||||||
@@ -297,8 +297,10 @@
|
|||||||
by the bar's width; the overlay seat is against the padding box, so it
|
by the bar's width; the overlay seat is against the padding box, so it
|
||||||
must give back the same width to keep the input card in place across
|
must give back the same width to keep the input card in place across
|
||||||
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
|
tabs. Reads ui-theme's --dsh-scrollbar-width, which mirrors the WebKit
|
||||||
bar's layout width; Firefox's thin bar resolves to the same 8px on the
|
bar's layout width. Chat reserves the engine-resolved width, so on an
|
||||||
platforms this app targets, so the compensation holds there too. */
|
engine where the resolved width differs from this fixed 8px the card
|
||||||
|
drifts by half the difference — accepted residual risk, recorded in
|
||||||
|
([decision](../../../../../../.agents/notes/implemented/bug-fix/2026-08-12-composer-overlay-seat-width-compensation.md)). */
|
||||||
right: var(--dsh-scrollbar-width);
|
right: var(--dsh-scrollbar-width);
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# 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:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||||
README.md: df4d5e0370962bf6f2a8ac0a7b88d669225dc5c5
|
README.md: c24f2ae5692fdd05134ce780d21569b87570d1c7
|
||||||
README.zh.md: fb8e937979a5168803be78a49e1bc1e7eacd7a47
|
README.zh.md: 518b8930027c88bd85139a8a7325cea1fd40b40c
|
||||||
@@ -8,7 +8,7 @@ When the host composition includes an HTTP server, the host half injects a synch
|
|||||||
|
|
||||||
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
`src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them.
|
||||||
|
|
||||||
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default.
|
Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. `--dsh-scrollbar-width` mirrors the WebKit bar's layout width for surfaces that align themselves beside a space-consuming bar — [ui-conversation](../ui-conversation/README.md) reads it for the overlay composer seat's `right` offset — and the scrollbar-styles spec pairs it with the mirrored rule and the consumer.
|
||||||
|
|
||||||
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
|
The two paths are mutually exclusive by construction. `scrollbar-width`/`scrollbar-color` sit inside `@supports not selector(::-webkit-scrollbar)` because a non-`auto` value of either makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included — declaring both unconditionally leaves `--dsh-scrollbar-thumb-hover` with no rendering anywhere. Firefox therefore takes the standard properties and WebKit-based engines take the pseudo-elements, so the hover token only ever renders through the pseudo-element path. Reasoning and the measured computed values: [the scrollbar Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md).
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
`src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。
|
||||||
|
|
||||||
滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。
|
滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。`--dsh-scrollbar-width` 镜像 WebKit 滚动条的布局宽度,供需要与占布局宽度的滚动条对齐的表面使用——[ui-conversation](../ui-conversation/README.md) 用它作为覆盖 composer 座位 `right` 偏移——scrollbar-styles 规格把它与镜像规则及消费者配对检查。
|
||||||
|
|
||||||
两条路径在构造上互斥。`scrollbar-width`/`scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。相关原理与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
|
两条路径在构造上互斥。`scrollbar-width`/`scrollbar-color` 写在 `@supports not selector(::-webkit-scrollbar)` 之内,因为这两个属性中的任一个只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中——若无条件地同时声明,`--dsh-scrollbar-thumb-hover` 在任何引擎上都不会被渲染。因此 Firefox 走标准属性,WebKit 系引擎走伪元素,hover token 只经由伪元素这条路径渲染。相关原理与实测计算值见[滚动条 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)。
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every
|
* Scrollbar stylesheet contract, asserted against the CSS text on disk: every
|
||||||
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
|
* --dsw-alias-scrollbar-* token design-platform.css defines has a consumer,
|
||||||
* scrollbar.css binds the base-surface pair through the rebindable
|
* scrollbar.css binds the base-surface pair through the rebindable
|
||||||
* indirection, and elevated surfaces rebind that indirection in complete
|
* indirection, the width variable mirrors the ::-webkit-scrollbar rule for
|
||||||
* pairs. The expected token set is scanned out of design-platform.css, so
|
* consumers that align beside the bar, and elevated surfaces rebind that
|
||||||
* adding, renaming, or dropping a scrollbar token moves these assertions with
|
* indirection in complete pairs. The expected token set is scanned out of
|
||||||
* it.
|
* design-platform.css, so adding, renaming, or dropping a scrollbar token
|
||||||
|
* moves these assertions with it.
|
||||||
*/
|
*/
|
||||||
import { readdirSync, readFileSync } from 'node:fs'
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
@@ -341,6 +342,67 @@ describe('scrollbar.css base-surface binding', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('scrollbar.css width variable', () => {
|
||||||
|
const WIDTH_VARIABLE = `${INDIRECTION_PREFIX}width`
|
||||||
|
|
||||||
|
it('defines the width variable on body as a static length', () => {
|
||||||
|
// The overlay seat compensation reads a fixed number, not a second
|
||||||
|
// indirection: the mirror check below compares the WebKit rule against
|
||||||
|
// this value, so a var()-to-var() chain would compare one indirection to
|
||||||
|
// another instead of pinning the number.
|
||||||
|
const value = scrollbarRules
|
||||||
|
.filter(rule => rule.selectors.includes('body'))
|
||||||
|
.flatMap(rule => rule.declarations)
|
||||||
|
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
|
||||||
|
expect(value, WIDTH_VARIABLE).toBeDefined()
|
||||||
|
expect(value, WIDTH_VARIABLE).toMatch(/^\d+(?:\.\d+)?px$/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mirrors the ::-webkit-scrollbar width rule with the variable value', () => {
|
||||||
|
// The compensation stays aligned with the WebKit bar only while both read
|
||||||
|
// the same number. A change to one side without the other puts the overlay
|
||||||
|
// seat a band off from Chat on WebKit engines.
|
||||||
|
const variableValue = scrollbarRules
|
||||||
|
.filter(rule => rule.selectors.includes('body'))
|
||||||
|
.flatMap(rule => rule.declarations)
|
||||||
|
.findLast(([property]) => property === WIDTH_VARIABLE)?.[1]
|
||||||
|
const webkitWidth = scrollbarRules
|
||||||
|
.filter(rule => rule.selectors.includes('::-webkit-scrollbar'))
|
||||||
|
.flatMap(rule => rule.declarations)
|
||||||
|
.findLast(([property]) => property === 'width')?.[1]
|
||||||
|
expect(webkitWidth, '::-webkit-scrollbar width').toBeDefined()
|
||||||
|
expect(webkitWidth).toBe(variableValue)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('every reader of the width variable outside ui-theme references a defined variable', () => {
|
||||||
|
// The consumer is ConversationRoot's overlay composer seat
|
||||||
|
// (`right: var(--dsh-scrollbar-width)`); a rename in scrollbar.css without
|
||||||
|
// the consumer, or a typo in the consumer, leaves the value
|
||||||
|
// guaranteed-invalid and the seat loses the band. The equal-rectangle e2e
|
||||||
|
// would catch it only on an engine that draws the bar, so the sheet
|
||||||
|
// contract states it here.
|
||||||
|
const defined = new Set(
|
||||||
|
scrollbarRules
|
||||||
|
.flatMap(rule => rule.declarations)
|
||||||
|
.filter(([property]) => property.startsWith(INDIRECTION_PREFIX))
|
||||||
|
.map(([property]) => property),
|
||||||
|
)
|
||||||
|
expect(defined).toContain(WIDTH_VARIABLE)
|
||||||
|
const readers: string[] = []
|
||||||
|
for (const file of packageStylesheets()) {
|
||||||
|
if (file === fileURLToPath(new URL('scrollbar.css', STYLES))) continue
|
||||||
|
for (const rule of parseRules(readFileSync(file, 'utf8'))) {
|
||||||
|
for (const [property, value] of rule.declarations) {
|
||||||
|
for (const name of varReferences(value)) {
|
||||||
|
if (name === WIDTH_VARIABLE) readers.push(`${file} ${rule.selectors.join(', ')}: ${property}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(readers.length, 'compensation consumer').toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('scrollbar.css selectors', () => {
|
describe('scrollbar.css selectors', () => {
|
||||||
const scrollbarColorSelectors = scrollbarRules
|
const scrollbarColorSelectors = scrollbarRules
|
||||||
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))
|
.filter(rule => rule.declarations.some(([property]) => property === 'scrollbar-color'))
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
* workspace), search, the grouped tree or flat list, and the workspace
|
* workspace), search, the grouped tree or flat list, and the workspace
|
||||||
* dialogs. Wide state renders the full browser; rail state renders the two
|
* dialogs. Wide state renders the full browser; rail state renders the two
|
||||||
* region icons (search / add workspace) as 36px controls on the shell's shared
|
* region icons (search / add workspace) as 36px controls on the shell's shared
|
||||||
* rail entry path, each requesting expansion through the owner share. Adding is the header
|
* rail entry path, each requesting expansion through the owner share. Adding
|
||||||
* button's one action, so it raises the directory flow with no menu in
|
* is the header button's one action, so it raises the directory flow with no
|
||||||
* between; the flow and its error dialog live in WorkspacePicker (same
|
* menu in between; the flow and its error dialog live in WorkspacePicker
|
||||||
* package — direct composition, no slot between them).
|
* (same package — direct composition, no slot between them).
|
||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
|
|||||||
Reference in New Issue
Block a user