diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml index 1b2380340f..a205f48f54 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.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-07-28-themed-scrollbars-and-reserved-gutter.md -2026-07-28-themed-scrollbars-and-reserved-gutter.md: 53f125c5cbe891608e909ac412f6c44d9aae5c3d -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: a3b595c8ddbadb8aacdebc2e5e2dc27ac5972383 +2026-07-28-themed-scrollbars-and-reserved-gutter.md: 38228c868bb00210118e8110feb722fb81d0d56c +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 9fafe1faa9303b5e2e23a1b3064904f71494026d diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md index 53f125c5cb..38228c868b 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md @@ -20,9 +20,13 @@ The rules sit on `body`, not `html`. `design-platform.css` declares the `--dsw-a The two renderings are mutually exclusive, and the exclusion is enforced rather than assumed. A non-`auto` `scrollbar-width` or `scrollbar-color` makes Chromium and Safari discard every `::-webkit-scrollbar*` rule for that element, `::-webkit-scrollbar-thumb:hover` included. Declaring both unconditionally therefore leaves the hover token rendering nowhere at all: the engines that implement the hover pseudo-element are exactly the ones the standard properties silence, and Firefox has no hover pseudo-element to fall back on. The standard properties consequently sit inside `@supports not selector(::-webkit-scrollbar)`, which is true only where the pseudo-element is unimplemented, so Firefox takes the standard path and WebKit-based engines take the pseudo-element path. The WebKit rules are not gated in turn: an engine without those pseudo-elements drops them as unknown selectors, so a gate would only restate what selector matching already does. An engine too old for the `selector()` function makes the condition invalid, which evaluates false and selects the pseudo-element path — the correct side for the pre-16.4 Safari that is the realistic case for that reading. -Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface 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, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. Seven surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, and the question composer card. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. +Both paths read one indirection pair, `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover`, bound on `body` to the l1 (base-surface) tokens. **This is the rebinding contract, and it is the part the CSS alone does not state**: an elevated surface 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, and that one rebind reaches the standard properties and the WebKit pseudo-elements together. The pair is rebound as a pair; rebinding the resting thumb alone leaves the hover state on the base-surface token. Eight surfaces rebind today: the command popup, the slash menu, the model-select panel, the settings panel, the shared `ui-primitives` menu card, the composer input card, the question composer card, and the todo panel. Most declare it on the elevated card rather than on the scrolling descendant, because the elevation is a property of the surface and custom properties inherit down to whichever child actually scrolls. -The last three were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints a known elevated surface somewhere must rebind. The elevated set is derived from the sheets that already rebind — a rebinding rule paints the surface whose elevation it declares — so it is self-maintaining rather than a list to update. The check is surface-level rather than element-level because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which. +The last four were missed in the first implementation and found in review, which is why the rebinding contract is now checked mechanically rather than by inspection: a sheet that scrolls somewhere and paints an elevated surface somewhere must rebind. + +The elevated set is resolved from the palette's own dark elevation ladder — the surface tokens whose dark value lands on `bg-layer-2` or `bg-layer-3`, which is the step the l1/l2 split encodes. Deriving it instead from the sheets that already rebind was the first attempt and is unsound: such a set can only confirm what someone already remembered, and a surface nobody has rebound yet — exactly the case the check exists for — defines itself as unelevated. `--dsw-specific-tip` proved it, resolving to the menu surface's rung while the todo panel scrolled on it unrebound and the derived check stayed green. + +Scope is by token family, not by geometry: only `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface. `--dsw-alias-button-*`, `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same rungs while naming a control or an inline span that no scroll container renders its bar against. Shape cannot make that call, since a floating button legitimately carries a radius, a shadow, and a fixed size. The check is per sheet rather than per rule because the card and the descendant that scrolls are separate rules, and CSS text does not express which contains which. The track and the corner stay transparent, so the thumb reads against whatever surface scrolls under it; only the thumb and its hover state carry a token color. diff --git a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md index a3b595c8dd..9fafe1faa9 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md @@ -20,9 +20,13 @@ Status: implemented 两种渲染互斥,而这种互斥是被强制的,不是假定的。`scrollbar-width` 或 `scrollbar-color` 只要取非 `auto` 值,Chromium 与 Safari 就会丢弃该元素上的全部 `::-webkit-scrollbar*` 规则,`::-webkit-scrollbar-thumb:hover` 也在其中。因此无条件地同时声明会让 hover token 在任何地方都得不到渲染:实现了 hover 伪元素的引擎,恰恰就是被标准属性静音的那些,而 Firefox 没有 hover 伪元素可作退路。于是标准属性写在 `@supports not selector(::-webkit-scrollbar)` 之内,该条件只在伪元素未被实现处为真,因此 Firefox 走标准属性路径,WebKit 系引擎走伪元素路径。WebKit 规则不再反向加门禁:不实现这些伪元素的引擎会把它们当作未知选择器丢弃,因此加门禁只是重述选择器匹配本身已经做的事。对于旧到不支持 `selector()` 函数的引擎,该条件无效,从而求值为假并选中伪元素路径——对于这条判断下现实存在的 16.4 之前的 Safari,这正是正确的一侧。 -两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。目前有七处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板、设置面板、`ui-primitives` 共用菜单卡片、输入条卡片与提问组件卡片。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`,它们在 `body` 上绑定到 l1(基础表面)token。**这就是重新绑定契约,也是单看 CSS 无法得知的部分**:抬升表面在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`,这一次重新绑定同时作用于标准属性和 WebKit 伪元素。这组变量必须成对重新绑定;只改静止态滑块会让 hover 状态仍留在基础表面的 token 上。目前有八处抬升表面做了重新绑定:命令浮层、斜杠菜单、模型选择面板、设置面板、`ui-primitives` 共用菜单卡片、输入条卡片、提问组件卡片与待办面板。多数把声明写在抬升卡片上而非滚动的后代元素上,因为抬升层级是这个表面的属性,而自定义属性会继承到真正滚动的那个子元素。 -后三处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制已知的抬升表面,就必须重新绑定。抬升表面集合从已经做了重新绑定的样式表推导得出——重新绑定的那条规则正好绘制着它所声明抬升层级的那个表面——因此该集合自我维护,不是一份需要人去更新的清单。这项检查以表面为粒度而非以元素为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。 +后四处在最初的实现里被漏掉、由评审发现,因此重新绑定契约现在由机械检查把关,而不再依赖人工审阅:一张样式表只要在某处滚动、又在某处绘制抬升表面,就必须重新绑定。 + +抬升表面集合是从调色板自身的暗色抬升阶梯解析出来的——暗色取值落在 `bg-layer-2` 或 `bg-layer-3` 上的那些表面 token,而这一档正是 l1/l2 之分所编码的层级差。最初的做法是从已经做了重新绑定的样式表反向推导,那是不成立的:这样得到的集合只能确认别人已经记得的部分,而尚无人重新绑定的表面——恰恰就是这项检查存在的理由——会把自己定义成「非抬升」。`--dsw-specific-tip` 证明了这一点:它解析到与菜单表面相同的那一档,待办面板在它上面滚动却没有重新绑定,而推导式的检查依然是绿的。 + +判定范围依据 token 家族而非几何形状:只有 `--dsw-alias-bg-*` 与 `--dsw-specific-*` 表述的是表面。`--dsw-alias-button-*`、`--dsw-alias-interactive-*` 与 `--dsw-alias-markdown-*` 会落到相同档位,但它们表述的是控件或行内片段,没有任何滚动容器会把滚动条画在它们之上。形状无法做这个判断,因为悬浮按钮本来就会带圆角、阴影和固定尺寸。这项检查以样式表为粒度而非以规则为粒度,因为卡片与真正滚动的后代元素是两条不同的规则,而 CSS 文本无法表达谁包含谁。 轨道与两条滚动条相交的角落保持透明,因此滑块是以其下滚动的任何表面为背景被看到;只有滑块及其 hover 状态带 token 颜色。 diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index c6b50a2c77..7b506b5553 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -11,6 +11,13 @@ border: 1px solid var(--dsw-alias-border-l1); border-radius: 14px; background: var(--dsw-specific-tip); + /* Elevated surface: `--dsw-specific-tip` is the same dark rung as the menu + surface, and `.list` scrolls inside this card, so the thumb takes the l2 + elevation tokens. Declared here because the elevation belongs to the + surface, and the custom properties inherit down to `.list` (see ui-theme + styles/scrollbar.css for the rebinding contract). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } .body { diff --git a/packages/client/ui-theme/tests/scrollbar-styles.spec.ts b/packages/client/ui-theme/tests/scrollbar-styles.spec.ts index 544c8a1bcb..6eab29451b 100644 --- a/packages/client/ui-theme/tests/scrollbar-styles.spec.ts +++ b/packages/client/ui-theme/tests/scrollbar-styles.spec.ts @@ -169,16 +169,14 @@ const referencedTokens = new Map() const rebindRules: { file: string; rule: CssRule }[] = [] /** * What one stylesheet contributes to the elevated-surface question: which - * surface tokens its rules paint, whether any rule scrolls, and whether it + * elevated surfaces it paints, whether any rule scrolls, and whether it * rebinds. Kept per file rather than per rule because the elevated card and the * descendant that actually scrolls are separate rules in the same sheet, and - * CSS text does not express which element contains which. + * CSS text does not express which contains which. */ interface SheetSurfaces { - /** Surface tokens named by `background`/`background-color` on a rebinding rule. */ - rebound: Set - /** Surface tokens named by `background`/`background-color` anywhere in the sheet. */ - painted: Set + /** Elevated surface tokens this sheet paints anywhere. */ + elevated: Set /** True when some rule declares `overflow*: auto|scroll`. */ scrolls: boolean /** True when some rule rebinds the indirection. */ @@ -190,10 +188,57 @@ const sheetSurfaces = new Map() const OVERFLOW_PROPERTIES = ['overflow', 'overflow-x', 'overflow-y'] /** Properties that paint a surface, and so identify the elevation a rule sits on. */ const SURFACE_PROPERTIES = ['background', 'background-color'] +/** + * Token families that name a SURFACE — a background an element is drawn on, and + * so something a scrollbar can sit against. `--dsw-alias-button-*`, + * `--dsw-alias-interactive-*`, and `--dsw-alias-markdown-*` reach the same dark + * elevation rungs while naming a control or an inline span, which no scroll + * container renders its bar against (ChatView's floating `.toBottom` pill, + * CodeBlock's banner). Family, not geometry: a floating button legitimately + * carries a radius, a shadow, and a fixed size, so shape cannot separate them. + */ +const SURFACE_TOKEN_PATTERN = /^--dsw-(?:alias-bg-|specific-)/ + +/** + * The palette's own dark elevation ladder, resolved from `design-platform.css`: + * `bg-layer-2` and `bg-layer-3` are the rungs above the base surfaces, and the + * l1/l2 scrollbar split encodes exactly that step. Reading it from the palette + * rather than from the sheets that happen to rebind is what lets the check flag + * a surface NOBODY has rebound yet. + * @returns surface tokens whose dark value sits on an elevated rung. + */ +function elevatedRungs(): Set { + const definitions = new Map() + for (const rule of platformRules) { + // Dark declarations come later in the sheet and overwrite the light ones, + // which is the palette this distinction exists in. + for (const [property, value] of rule.declarations) definitions.set(property, value) + } + const resolve = (name: string): string => { + const seen = new Set() + let current = name + while (definitions.has(current) && !seen.has(current)) { + seen.add(current) + const value = definitions.get(current)! + const [reference] = varReferences(value) + if (reference === undefined) return value + current = reference + } + return current + } + const rungs = new Set([resolve('--dsw-alias-bg-layer-2'), resolve('--dsw-alias-bg-layer-3')]) + const tokens = new Set() + for (const name of definitions.keys()) { + if (SURFACE_TOKEN_PATTERN.test(name) && rungs.has(resolve(name))) tokens.add(name) + } + return tokens +} + +const elevatedSurfaces = elevatedRungs() for (const file of packageStylesheets()) { const rules = parseRules(readFileSync(file, 'utf8')) - const surfaces: SheetSurfaces = { rebound: new Set(), painted: new Set(), scrolls: false, rebinds: false } + const surfaces: SheetSurfaces = { elevated: new Set(), scrolls: false, rebinds: false } for (const rule of rules) { let rebinds = false const ruleSurfaces: string[] = [] @@ -206,25 +251,17 @@ for (const file of packageStylesheets()) { referencedTokens.set(token, [...referencedTokens.get(token) ?? [], file]) } } - for (const token of ruleSurfaces) surfaces.painted.add(token) + for (const token of ruleSurfaces) { + if (elevatedSurfaces.has(token)) surfaces.elevated.add(token) + } if (rebinds) { rebindRules.push({ file, rule }) surfaces.rebinds = true - for (const token of ruleSurfaces) surfaces.rebound.add(token) } } sheetSurfaces.set(file, surfaces) } -/** - * Surface tokens known to be elevated, derived from the sheets that already - * rebind rather than listed here: a rebinding rule paints the surface whose - * elevation it is declaring. Deriving it means a new elevated surface joins the - * set by rebinding, and cannot be added to the palette without either rebinding - * or failing the check below. - */ -const elevatedSurfaces = new Set([...sheetSurfaces.values()].flatMap(surfaces => [...surfaces.rebound])) - describe('design-platform.css scrollbar tokens', () => { it('defines the same scrollbar token set in the light and the dark block', () => { // A token present only in the light block silently keeps its light value @@ -426,24 +463,44 @@ describe('elevated surface rebinds', () => { } }) - it('every sheet that scrolls on a known elevated surface rebinds', () => { + it('resolves the elevated surface set from the palette ladder', () => { + // The set has to come from the palette, not from the sheets that happen to + // rebind: derived from rebinds it can only confirm what someone already + // remembered, and a surface nobody has rebound yet — the case the check + // exists for — would define itself as unelevated. Anchoring it here means a + // new palette token on an elevated rung is in scope the moment it is + // defined. `--dsw-specific-tip` is the regression that proved the point: it + // resolves to the same dark rung as the menu surface, and the Todo panel + // scrolled on it unrebound while a rebind-derived set stayed green. + expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-2') + expect(elevatedSurfaces).toContain('--dsw-alias-bg-layer-3') + expect(elevatedSurfaces).toContain('--dsw-specific-menu') + expect(elevatedSurfaces).toContain('--dsw-specific-input-major') + expect(elevatedSurfaces).toContain('--dsw-specific-tip') + // Base surfaces stay out, or every scroll container would be in scope and + // the check would say nothing. + expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-base') + expect(elevatedSurfaces).not.toContain('--dsw-alias-bg-layer-1') + }) + + it('every sheet that scrolls on an elevated surface rebinds', () => { // The failure this closes: a scroll container on an elevated surface that // nobody remembered to rebind renders the l1 thumb, which differs from l2 - // only in the dark palette and only for that one surface — invisible in - // review and in a light-palette screenshot. Three sheets shipped that way - // (ui-primitives Menu, InputBar, QuestionComposer) and review caught them - // by hand, which is what this replaces. + // only in the dark palette and only for that one surface — invisible both in + // review and in a light-palette screenshot. Four sheets shipped that way + // (ui-primitives Menu, InputBar, QuestionComposer, TodoPanel) and review + // caught them by hand, which is what this replaces. // // Surface-level, not element-level: the elevated card and the descendant // that scrolls are separate rules, and CSS text does not say which contains - // which. A sheet that both scrolls somewhere and paints a known elevated - // surface somewhere must rebind; the elevation would otherwise be a - // coincidence of two unrelated rules, which no sheet under test does. - expect(elevatedSurfaces.size).toBeGreaterThan(0) + // which. What keeps that from over-reporting is the token FAMILY: only + // `--dsw-alias-bg-*` and `--dsw-specific-*` name a surface, so a floating + // button or an inline code span reaching the same rung is out of scope + // (ChatView's `.toBottom`, CodeBlock's banner). Geometry cannot make that + // call — a floating button carries a radius, a shadow, and a fixed size. for (const [file, surfaces] of sheetSurfaces) { if (!surfaces.scrolls || surfaces.rebinds) continue - const elevated = [...surfaces.painted].filter(token => elevatedSurfaces.has(token)) - expect(elevated, `${file} scrolls on ${elevated.join(', ')} without rebinding`).toEqual([]) + expect([...surfaces.elevated], `${file} scrolls on an elevated surface without rebinding`).toEqual([]) } }) })