From a030397aca72429f5c773271f857ff0c685fb9d4 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Tue, 4 Aug 2026 15:05:32 +0800 Subject: [PATCH] feat(web): draw the sidebar's scrollbar only under the pointer The session list overflows after a handful of sessions, and its scrollbar was drawn permanently in a column that is at rest most of the time. SidebarRoot now tracks the pointer over the whole column and rebinds ui-theme's scrollbar indirection pair to `transparent` while it is outside, keeping the thumb for 2s after the pointer leaves so it does not blink out on the way past. Rebinding colour leaves the list's `scrollbar-gutter: stable` reservation in force, so revealing the bar moves no row. ui-theme's gate now states the widened contract: a rebind targets an -l2 token pair or `transparent`, and nothing else. --- ...d-scrollbars-and-reserved-gutter.i18n.yaml | 4 +- ...8-themed-scrollbars-and-reserved-gutter.md | 4 +- ...hemed-scrollbars-and-reserved-gutter.zh.md | 4 +- ...nter-revealed-sidebar-scrollbars.i18n.yaml | 6 + ...-04-pointer-revealed-sidebar-scrollbars.md | 54 +++++++++ ...-pointer-revealed-sidebar-scrollbars.zh.md | 54 +++++++++ apps/web/tests/sidebar-scrollbar.e2e.ts | 110 +++++++++++++++++- .../sidebar-scrollbar/geometry.expected.md | 10 +- packages/client/ui-sidebar/README.i18n.yaml | 4 +- packages/client/ui-sidebar/README.md | 2 + packages/client/ui-sidebar/README.zh.md | 2 + .../src/client/SidebarRoot.module.css | 13 +++ .../ui-sidebar/src/client/SidebarRoot.tsx | 34 +++++- .../sidebar-snapshot.spec.tsx.snap | 6 +- .../tests/pointer-scrollbars.spec.tsx | 96 +++++++++++++++ .../tests/scrollbar-quiet-styles.spec.ts | 33 ++++++ packages/client/ui-theme/README.i18n.yaml | 4 +- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/tests/scrollbar-styles.spec.ts | 14 ++- 20 files changed, 430 insertions(+), 28 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md create mode 100644 .agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md create mode 100644 packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx create mode 100644 packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts 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 45e957b824..7950dc88f3 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: b45f70b126d083916c756afb88a8b646a4e9bb85 -2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 8afa36429ce7e6e061b014d63dcb20e5a642a84c +2026-07-28-themed-scrollbars-and-reserved-gutter.md: ba3d9d3c94cf9a775c43c14e292187b3a43935fe +2026-07-28-themed-scrollbars-and-reserved-gutter.zh.md: 51d668f584ee254183d5d1cf709140df2556e8d0 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 b45f70b126..ba3d9d3c94 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,7 +20,7 @@ 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. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit 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. `transparent` is the pair's other legal target, added when the sidebar's bars [started following the pointer](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md); the gate below admits those two and nothing else. The mechanically discoverable subset is owned by `packages/client/ui-theme/tests/scrollbar-styles.spec.ts`: any sheet that both scrolls and paints an elevated surface must rebind, so this note no longer maintains a complete surface inventory. Most declare the pair on the elevated card rather than on the scrolling descendant, because elevation belongs to the surface and custom properties inherit to whichever child actually scrolls. Four surfaces — `Menu`, `InputBar`, `QuestionComposer`, and `TodoPanel` — were missed in the first implementation and found in review, which is why the per-sheet rebinding contract is checked mechanically rather than by inspection. @@ -54,7 +54,7 @@ The gutter and the sheet's `::-webkit-scrollbar` width are jointly necessary aga ## Consequences -- Every scroll container in the client draws the themed thumb: `rgb(229, 229, 229)` on a light base surface, `rgb(60, 60, 61)` on a dark one, and `rgb(84, 85, 87)` for a dark elevated surface that rebinds to the l2 pair. +- Every scroll container in the client draws the themed thumb: `rgb(229, 229, 229)` on a light base surface, `rgb(60, 60, 61)` on a dark one, and `rgb(84, 85, 87)` for a dark elevated surface that rebinds to the l2 pair. The sidebar's regions draw theirs only under the pointer, through the same indirection. - The two renderings are separately specified, so a change to the thumb's geometry or hover behavior has to be made twice — once in `scrollbar-width`/`scrollbar-color`, once in the pseudo-elements. Routing both through the indirection pair confines that duplication to the properties Firefox and WebKit do not share. - The hover tokens (`--dsw-alias-scrollbar-hover-l1`/`-l2`) render only on the pseudo-element path. Firefox states one thumb color through `scrollbar-color` and derives its own hover treatment, so a design change to the hover colors is visible in Chromium and Safari and not in Firefox. This is a limit of `scrollbar-color`, not of the sheet. - `body *` matches every element, for two properties whose effect the user agent already limits to elements that actually scroll. The cost is a broad selector; the alternative was a rebinding contract that does not work. 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 8afa36429c..51d668f584 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,7 +20,7 @@ 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 上。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 +两条路径都读取同一组间接变量 `--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 上。这组变量另一个合法的目标是 `transparent`,它随侧边栏滚动条[改为跟随指针](../feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md)一并引入;下文的门禁只接受这两种目标。可由机械检查发现的子集归 `packages/client/ui-theme/tests/scrollbar-styles.spec.ts` 所有:任何既滚动又绘制抬升表面的样式表都必须重新绑定,因此本 note 不再维护完整的表面清单。多数把这组变量声明在抬升卡片上而非滚动的后代元素上,因为抬升层级属于这个表面,而自定义属性会继承到真正滚动的那个子元素。 `Menu`、`InputBar`、`QuestionComposer` 与 `TodoPanel` 这四个表面在最初的实现里被漏掉、由评审发现,因此逐样式表的重新绑定契约由机械检查而非人工审阅把关。 @@ -54,7 +54,7 @@ Status: implemented ## 后果 -- 客户端的每个滚动容器都绘制带主题的滑块:亮色基础表面为 `rgb(229, 229, 229)`,暗色基础表面为 `rgb(60, 60, 61)`,重新绑定到 l2 的暗色抬升表面为 `rgb(84, 85, 87)`。 +- 客户端的每个滚动容器都绘制带主题的滑块:亮色基础表面为 `rgb(229, 229, 229)`,暗色基础表面为 `rgb(60, 60, 61)`,重新绑定到 l2 的暗色抬升表面为 `rgb(84, 85, 87)`。侧边栏内的滚动区域经由同一组间接变量,只在指针到达时才绘制滑块。 - 两种渲染分别指定,因此改动滑块的几何或 hover 行为需要改两处:一处在 `scrollbar-width`/`scrollbar-color`,一处在伪元素。让两者都经由这组间接变量,把这份重复限制在 Firefox 与 WebKit 不共用的那些属性上。 - hover token(`--dsw-alias-scrollbar-hover-l1`/`-l2`)只在伪元素路径上渲染。Firefox 通过 `scrollbar-color` 只表述一个滑块颜色,其 hover 表现由引擎自行推导,因此对 hover 颜色的设计改动在 Chromium 与 Safari 上可见,在 Firefox 上不可见。这是 `scrollbar-color` 本身的限制,不是这张样式表的限制。 - `body *` 匹配所有元素,涉及的两个属性其效果本就被浏览器限制在实际会滚动的元素上。代价是一个覆盖面很宽的选择器;另一种选择是一个不生效的重新绑定契约。 diff --git a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.i18n.yaml new file mode 100644 index 0000000000..3e23ba8c4a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.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/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md +2026-08-04-pointer-revealed-sidebar-scrollbars.md: fdc68fc0eda0aa68b3ec09d93df4a313f39f6d35 +2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md: 8b11aec961a713fe5e07046dee7473fa34471689 diff --git a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md new file mode 100644 index 0000000000..fdc68fc0ed --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.md @@ -0,0 +1,54 @@ +# Agent Note: The sidebar's scrollbars follow the pointer + +Status: implemented + +English | [中文](2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md) + +## Problem + +The sidebar's session list overflows after a handful of sessions, and from that point its scrollbar is drawn permanently — in a column that is at rest most of the time, next to rows whose own chrome only appears on hover. It is the one piece of always-on furniture in the sidebar, and nothing about it is actionable until someone reaches for it. The product ask (2026-08-04) is to draw it only while the pointer is over the sidebar, with a short tail so it does not blink out on the way past. + +## Decision + +`SidebarRoot` tracks the pointer over the whole column and carries a `quietBars` class whenever it is outside. The rule that class selects rebinds ui-theme's indirection pair — `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` — to `transparent`, so every scroll region nested under the column draws no thumb. The session list is the only one today; a future one inherits the behavior rather than opting into it. + +The tail is `SCROLLBAR_LINGER_MS = 2000`: `pointerleave` starts a timer, `pointerenter` cancels a pending one, and only the timer firing puts the class back. A pointer that crosses the column's edge and returns — travelling around a portalled menu, or overshooting on the way to a row — never sees the thumb blink. + +The pointer surface is the column, not the list. A pointer heading for the bar crosses the logo row, the New Session capsule, and the search field first, so revealing on the list alone would surface the bar only once the pointer was already among the rows. + +`transparent` is what makes the reveal free of layout. `scrollbar-gutter: stable` on the list exists so rows never move ([the gutter note](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)); rebinding a colour leaves that reservation in force, so the thumb appears in space the list was already holding for it. + +The indirection pair rather than a rule on the list, because that pair is ui-theme's documented rebinding seam: one declaration reaches both rendering paths (the WebKit pseudo-elements and Firefox's `scrollbar-color`), and custom properties inherit, which is what makes the column — rather than each scroll region in it — the thing that owns the state. + +That widens the rebinding contract, so its gate states the new shape rather than accepting it by silence: `ui-theme/tests/scrollbar-styles.spec.ts` now admits exactly two rebind targets, an `-l2` token pair or `transparent`, and rejects everything else — an l1 rebind, which only restates the base-surface default under a name that reads as an elevation, and a literal colour, which leaves the palette. + +## Alternatives considered + +**CSS `:hover` on the column, with no JavaScript state.** The whole mechanism in one rule, and it cannot express the tail: the bar would vanish on the frame the pointer crossed the edge, which is exactly when a pointer is travelling to the conversation or around a portalled menu. The ask names the tail, and a hover-only version reads as flicker. + +**Keep it in CSS and get the delay from a transition,** by registering `--dsh-scrollbar-thumb` through `@property` so the custom property becomes animatable and a `transition-delay` could hold the colour. Rejected on cost and on reach: the registration is global to every surface that reads the pair, for one column's timing, and the WebKit scrollbar pseudo-elements this palette actually renders through do not reliably transition — the delay would be specified where it cannot be observed. + +**Hide the bar itself** — `scrollbar-width: none`, or `display: none` on `::-webkit-scrollbar`. Rejected because it takes the reserved band with it: the bar would reappear by re-taking 8px and shift every row sideways under the pointer that revealed it, which is the regression the gutter reservation was added to fix. + +**Draw an overlay thumb in the app** and hide the native bar entirely, which is what a fully custom fade would need. It buys arbitrary styling and costs hit-testing, drag, wheel, momentum, and both palettes' hover states — a large owned surface for a cosmetic gain, in a client whose scrollbars are already themed through tokens. + +**Scope the reveal to the scrolling list rather than the column.** Fewer elements involved, and it puts the reveal at the wrong boundary: the pointer reaches the rows last, so the bar would appear after the user is already reading them, and every other scroll region added to the sidebar later would have to opt in by hand. + +**Reveal on scroll events too,** so a keyboard- or touch-driven scroll shows the bar. Rejected as drawing an affordance the input that triggered it cannot use; the rows themselves already show that the list moved. + +## Consequences + +- A list scrolled by keyboard or by a touch drag shows no thumb once the linger passes, since neither leaves a pointer over the column. +- The column starts quiet on a cold load and stays so until the pointer first moves over it. A pointer already parked there when the page loads fires nothing until it moves, which is the browser's rule rather than this shell's. +- An elevated surface nested in the column that rebinds the pair to l2 for its own elevation overrides the quiet state and keeps its bar drawn. Nothing in the sidebar does this today; the settings panel portals out of the column entirely. +- The shell's DOM now carries a state class, so ui-sidebar's shell snapshots pin `quietBars` and a regression in the default state is a snapshot diff rather than something someone has to notice in a screenshot. + +## Testing + +`packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx` drives the class through the transitions with fake timers: revealed on entry, still revealed one millisecond before the linger closes, quiet one millisecond after, and cancelled by a return within the window. It also unmounts mid-linger and asserts no timer survives — a pending hide firing into a dead component is the failure this shape is prone to. The events are `pointerover`/`pointerout` carrying a `relatedTarget`, because React synthesizes enter and leave from those and ignores the raw ones. + +`packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts` reads the sheet: the rule states both halves of the pair — rebinding the resting thumb alone would leave the hover colour painting the moment the pointer reached the bar — and states no `scrollbar-gutter`, which belongs to the scrolling region. + +`apps/web/tests/sidebar-scrollbar.e2e.ts` is where the two halves meet a real engine. It parks the pointer over the list before every colour reading, since a scenario that never moves the mouse would measure the quiet state throughout and read as vacuous green. Its own test then moves the pointer away, asserts the thumb is still drawn on the leave itself, polls until it resolves to `rgba(0, 0, 0, 0)`, and re-measures the geometry there to prove the reservation held while the bar was hidden. The committed golden records the thumb at both pointer positions in both palettes. + +The e2e's control is a mutation, and it needs the plugin's own bundle: dropping `quietBars` from the shell, rebuilding `@deepseek-ai/dsh-client-ui-sidebar` and only then `build:web`, turns that test red on the thumb resolving to `rgb(229, 229, 229)` where it expects `rgba(0, 0, 0, 0)`. Rerunning `build:web` alone exercises a stale bundle and passes with the change removed, which is the trap [the gutter note](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) documented. diff --git a/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md new file mode 100644 index 0000000000..8b11aec961 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-04-pointer-revealed-sidebar-scrollbars.zh.md @@ -0,0 +1,54 @@ +# Agent Note:侧边栏的滚动条跟随指针 + +Status: implemented + +[English](2026-08-04-pointer-revealed-sidebar-scrollbars.md) | 中文 + +## Problem + +侧边栏的会话列表只要有十来个会话就会溢出,从那一刻起它的滚动条就一直画在那里——所处的这一列大部分时间都是静止的,而列表行自己的操作按钮只在悬停时才出现。它是侧边栏里唯一始终常驻的构件,而在有人真的伸手去拖它之前,它不提供任何可操作性。产品诉求(2026-08-04)是只在指针位于侧边栏内时才绘制它,并留一小段拖尾,避免指针路过时它一闪而灭。 + +## Decision + +`SidebarRoot` 跟踪整列上的指针,只要指针不在列内就给根元素挂上 `quietBars` 类。该类选中的规则把 ui-theme 的那组间接变量——`--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover`——重新绑定为 `transparent`,于是嵌套在这一列下的每个滚动区域都不绘制滑块。今天这样的区域只有会话列表;将来新增的区域会直接继承这一行为,而不需要逐个接入。 + +拖尾是 `SCROLLBAR_LINGER_MS = 2000`:`pointerleave` 启动一个定时器,`pointerenter` 取消尚未触发的定时器,只有定时器真正触发才会把类加回去。指针越过列边界又折返时——绕过一个 portal 菜单,或是奔向某一行时冲过了头——不会看到滑块闪动。 + +承载指针的是整列,而不是列表。奔向滚动条的指针会先经过 logo 行、New Session 胶囊和搜索框,所以只在列表上显示,会让滚动条等到指针已经落在行中间时才出现。 + +`transparent` 正是让这次显示不触发任何布局的原因。列表上的 `scrollbar-gutter: stable` 存在的意义就是让行永不移动(见[空槽 Agent Note(agent 决策记录)](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md));重新绑定的只是颜色,那份预留始终有效,所以滑块出现在列表本就为它留出的空间里。 + +选择这组间接变量而不是给列表加规则,是因为这组变量正是 ui-theme 写明的重新绑定接缝:一次声明同时作用于两条渲染路径(WebKit 伪元素与 Firefox 的 `scrollbar-color`),而自定义属性会继承——这正是让整列、而不是列内每个滚动区域,成为该状态所有者的原因。 + +这拓宽了重新绑定契约,因此它的门禁把新的形态明写出来,而不是默许通过:`ui-theme/tests/scrollbar-styles.spec.ts` 现在只接受两种重新绑定目标,即一组 `-l2` token 或 `transparent`,其余一律拒绝——绑回 l1 只是用一个听起来像抬升层级的名字重述基础表面的默认值,而字面颜色则脱离了调色板。 + +## Alternatives considered + +**只用列上的 CSS `:hover`,不引入 JavaScript 状态。** 整套机制只需一条规则,但它表达不出拖尾:指针越过边界的那一帧滑块就会消失,而那恰好是指针正奔向对话区或绕行 portal 菜单的时刻。诉求本身点名了拖尾,只有 hover 的版本读起来就是闪烁。 + +**留在 CSS 里、用过渡拿到这段延迟**,即通过 `@property` 注册 `--dsh-scrollbar-thumb` 让该自定义属性可动画,再用 `transition-delay` 把颜色按住。因代价与作用范围被否决:这项注册对每个读取这组变量的表面都是全局的,却只为一列的时序服务;而且这套调色板实际渲染所走的 WebKit 滚动条伪元素并不可靠地支持过渡——延迟会被声明在观察不到它的地方。 + +**直接把滚动条藏掉**——`scrollbar-width: none`,或对 `::-webkit-scrollbar` 用 `display: none`。被否决,因为这会连带取消那段预留:滚动条重新出现时要重新占走 8px,会把每一行都在显示它的那个指针底下横向推移,而这正是当初加入空槽预留所修掉的回归。 + +**在应用内自绘一个覆盖式滑块**,并彻底隐藏原生滚动条,这是完全自定义淡入淡出所需要的做法。它换来任意样式,代价是命中测试、拖拽、滚轮、惯性以及两套调色板下的 hover 状态——在一个滚动条已由 token 统一主题化的客户端里,为观感付出的是一大片自持表面。 + +**把显示范围收敛到滚动的列表而不是整列。** 涉及的元素更少,却把显示的边界放错了位置:指针最后才到达行,滚动条会等到用户已经在读这些行时才出现;而且日后加入侧边栏的其他滚动区域都得手工接入。 + +**滚动事件也触发显示**,让键盘或触摸驱动的滚动同样显示滚动条。被否决,因为那是在为触发它的输入方式画一个它用不上的可供性;行本身已经说明列表移动过了。 + +## Consequences + +- 用键盘或触摸拖动滚动的列表,在拖尾结束后不显示滑块,因为这两种方式都不会把指针留在列上。 +- 冷启动时该列处于静默状态,直到指针第一次移到它上面为止。页面加载时就停在那里的指针在移动之前不会触发任何事件,这是浏览器的规则,而非这个外壳的。 +- 嵌套在列内、为自身抬升层级把这组变量重新绑定到 l2 的抬升表面,会覆盖静默状态并继续绘制自己的滚动条。今天侧边栏内没有这样的表面;设置面板整个 portal 到了列之外。 +- 外壳的 DOM 现在带有一个状态类,因此 ui-sidebar 的外壳快照会钉住 `quietBars`,默认状态出现回归时表现为快照 diff,而不是需要有人从截图里看出来的东西。 + +## Testing + +`packages/client/ui-sidebar/tests/pointer-scrollbars.spec.tsx` 用假定时器把这个类走过各次跃迁:进入时显示,拖尾结束前 1 毫秒仍然显示,结束后 1 毫秒转为静默,以及窗口内折返会取消隐藏。它还在拖尾进行中卸载组件并断言没有定时器存活——待触发的隐藏落到已销毁的组件上,正是这种写法容易犯的错。事件用的是带 `relatedTarget` 的 `pointerover`/`pointerout`,因为 React 由它们合成 enter 与 leave,而会忽略原生的那两个事件。 + +`packages/client/ui-sidebar/tests/scrollbar-quiet-styles.spec.ts` 直接读样式表:该规则必须写出这组变量的两半——只重新绑定静止态滑块,会让指针一碰到滚动条就露出 hover 颜色——并且不得出现 `scrollbar-gutter`,那属于滚动区域自己。 + +`apps/web/tests/sidebar-scrollbar.e2e.ts` 是两半在真实引擎里汇合的地方。它在每次读取颜色前先把指针停在列表上,因为一个从不移动鼠标的场景全程测到的都是静默状态,会变成空洞的绿。随后它自己的用例把指针移开,断言在 leave 当下滑块仍在绘制,轮询直到它解析为 `rgba(0, 0, 0, 0)`,并在该状态下重新测量几何,以证明滚动条隐藏期间那份预留依然生效。提交的 golden 记录了两套调色板下、两个指针位置上的滑块颜色。 + +这条 e2e 的对照是一次 mutation,而它需要插件自己的产物:把 `quietBars` 从外壳中去掉,先重新构建 `@deepseek-ai/dsh-client-ui-sidebar`、之后再跑 `build:web`,该用例会因为滑块解析为 `rgb(229, 229, 229)`、而期望 `rgba(0, 0, 0, 0)` 而变红。只重跑 `build:web` 用的是过期产物,即使改动已被删除也照样通过,这正是[空槽 Agent Note(agent 决策记录)](../bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)记录过的陷阱。 diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index 12b9588a97..370f9ca6af 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -44,6 +44,12 @@ // overlap, and `timeCoveredBy` measures it at 7. Each was mutation-checked with // the other assertions in its test silenced. // +// The thumb is a pointer affordance (ui-sidebar rebinds the indirection pair +// to `transparent` while the pointer is outside the column), so every +// measurement below states which pointer position it was taken at: the +// scenario parks the pointer over the sidebar before asserting a colour, and +// the quiet state and its linger get their own test. +// // Chromium also takes the `::-webkit-scrollbar*` path, not the standard // properties: scrollbar.css gates `scrollbar-width`/`scrollbar-color` behind // `@supports not selector(::-webkit-scrollbar)`, which is false here. The @@ -193,6 +199,30 @@ function measureList(page: Page): Promise { }) } +/** One palette's readings, taken at both pointer positions. */ +interface PaletteMetrics { + /** Everything measured with the pointer over the list, which is when a thumb exists. */ + hovered: ListMetrics + /** `--dsh-scrollbar-thumb` with the pointer parked outside the column. */ + quietThumb: string +} + +/** + * Read one palette at both pointer positions, ending with the pointer back + * over the list so a caller measuring further leaves it revealed. + * @param page - the page under test. + * @returns the palette's quiet thumb and its hovered metrics. + */ +async function measurePalette(page: Page): Promise { + await pointAt(page, 'away') + // Poll rather than sleep the linger out: the wait is the column's, and a + // fixed sleep would either race it or pad every palette. + await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB) + const quietThumb = await resolveThumb(page) + await pointAt(page, 'list') + return { hovered: await measureList(page), quietThumb } +} + /** * Render the golden body: the resolved scrollbar style of the list in each * palette, plus the geometric relations the fix establishes. @@ -208,18 +238,19 @@ function measureList(page: Page): Promise { * @param dark - metrics measured under the dark palette. * @returns the golden body, without a trailing newline. */ -function renderGeometry(light: ListMetrics, dark: ListMetrics): string { - const palette = (name: string, metrics: ListMetrics): string[] => [ +function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string { + const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [ `## ${name}`, '', + `- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`, `- scrollbar-gutter: ${metrics.gutter}`, `- ::-webkit-scrollbar width: ${metrics.width}`, `- ::-webkit-scrollbar-track background: ${metrics.track}`, `- scrollbar-width: ${metrics.standardWidth}`, `- scrollbar-color: ${metrics.standardColor}`, `- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`, - `- --dsh-scrollbar-thumb: ${metrics.token}`, - `- --dsh-scrollbar-thumb-hover: ${metrics.hoverToken}`, + `- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`, + `- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`, `- list overflows: ${String(metrics.overflows)}`, `- reserved band: ${String(metrics.band)}px`, `- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`, @@ -235,6 +266,48 @@ function renderGeometry(light: ListMetrics, dark: ListMetrics): string { ].join('\n').trimEnd() } +/** + * Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the + * geometry. Own probe element for the same reason {@link measureList} uses + * one: `getComputedStyle` returns a live declaration. + * @param page - the page under test. + * @returns the resolved thumb colour, serialized as `rgb`/`rgba`. + */ +function resolveThumb(page: Page): Promise { + return page.evaluate(() => { + const list = document.querySelector('[role="tree"][aria-label="Sessions"]') + if (list === null) throw new Error('sidebar session list not in the DOM') + const probe = document.createElement('span') + probe.style.color = 'var(--dsh-scrollbar-thumb)' + list.append(probe) + const value = getComputedStyle(probe).color + probe.remove() + return value + }) +} + +/** Fully transparent, which is how the quiet column spells "no thumb". */ +const NO_THUMB = 'rgba(0, 0, 0, 0)' + +/** + * Park the pointer over the session list or outside the sidebar entirely. The + * column reveals its scrollbars from real pointer movement, so a scenario that + * never moves the mouse measures the quiet state whatever it intended to. + * @param page - the page under test. + * @param where - `list` to point at the session list, `away` for the far side + * of the viewport (the conversation column). + */ +async function pointAt(page: Page, where: 'list' | 'away'): Promise { + const box = await page.locator('[role="tree"][aria-label="Sessions"]').boundingBox() + if (box === null) throw new Error('sidebar session list has no layout box') + const viewport = page.viewportSize() + if (viewport === null) throw new Error('page has no viewport') + const target = where === 'list' + ? { x: box.x + box.width / 2, y: box.y + box.height / 2 } + : { x: viewport.width - 5, y: box.y + box.height / 2 } + await page.mouse.move(target.x, target.y) +} + /** * Reveal the seeded rows: every seeded session is unattached, so they all sit * in the collapsed Ungrouped bucket. Converges on expanded rather than @@ -280,6 +353,10 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) await expandSeededSessions(page) + // Every assertion about a thumb colour needs a drawn thumb, and the column + // only draws one under the pointer; the quiet state is asserted where it is + // the subject rather than left as an ambient condition of the whole file. + await pointAt(page, 'list') }, 180_000) afterAll(async () => { @@ -317,6 +394,27 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer')) + const revealed = await resolveThumb(page) + expect(revealed).not.toBe(NO_THUMB) + await pointAt(page, 'away') + // The linger, measured as a state rather than a duration: the thumb is + // still drawn on the leave itself, and gone once the window has passed. A + // tighter timing assertion would pin the wall clock of a CI machine. + expect(await resolveThumb(page)).toBe(revealed) + await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB) + // The reservation is unconditional, so nothing moved while the bar was + // hidden — this is what buys `transparent` over hiding the bar itself. + const quiet = await measureList(page) + expect(quiet.gutter).toBe('stable') + expect(quiet.band).toBeGreaterThan(0) + expect(quiet.timeCoveredBy).toBe(0) + await pointAt(page, 'list') + await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it('renders the themed thumb through the WebKit path in both palettes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme')) const light = await measureList(page) @@ -354,9 +452,9 @@ describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thum it('matches the committed scrollbar geometry golden in both palettes', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden')) - const light = await measureList(page) + const light = await measurePalette(page) await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') }) - const dark = await measureList(page) + const dark = await measurePalette(page) await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') }) await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE) expect(tripwire.pageErrors).toEqual([]) diff --git a/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md b/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md index 4349532ef8..527193ebcb 100644 --- a/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md +++ b/apps/web/tests/snapshots/sidebar-scrollbar/geometry.expected.md @@ -2,14 +2,15 @@ ## Light palette +- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0) - scrollbar-gutter: stable - ::-webkit-scrollbar width: 8px - ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0) - scrollbar-width: auto - scrollbar-color: auto - ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover) -- --dsh-scrollbar-thumb: rgb(229, 229, 229) -- --dsh-scrollbar-thumb-hover: rgb(212, 212, 212) +- --dsh-scrollbar-thumb, pointer over the list: rgb(229, 229, 229) +- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(212, 212, 212) - list overflows: true - reserved band: 8px - relative time covered by the bar: 0px @@ -18,14 +19,15 @@ ## Dark palette +- --dsh-scrollbar-thumb, pointer outside the sidebar: rgba(0, 0, 0, 0) - scrollbar-gutter: stable - ::-webkit-scrollbar width: 8px - ::-webkit-scrollbar-track background: rgba(0, 0, 0, 0) - scrollbar-width: auto - scrollbar-color: auto - ::-webkit-scrollbar-thumb:hover declarations: var(--dsh-scrollbar-thumb-hover) -- --dsh-scrollbar-thumb: rgb(60, 60, 61) -- --dsh-scrollbar-thumb-hover: rgb(84, 85, 87) +- --dsh-scrollbar-thumb, pointer over the list: rgb(60, 60, 61) +- --dsh-scrollbar-thumb-hover, pointer over the list: rgb(84, 85, 87) - list overflows: true - reserved band: 8px - relative time covered by the bar: 0px diff --git a/packages/client/ui-sidebar/README.i18n.yaml b/packages/client/ui-sidebar/README.i18n.yaml index c1f5d5df03..1bd10f89a6 100644 --- a/packages/client/ui-sidebar/README.i18n.yaml +++ b/packages/client/ui-sidebar/README.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 packages/client/ui-sidebar/README.md -README.md: 19c2d1033de4475816249aa8429f4a589eeb6481 -README.zh.md: b8c154586570cf1b9fd4bf776bc09b36ab5ee7d2 +README.md: 5bb697b3d2f9b5eaea9c382765d2510fa24806ce +README.zh.md: 302f66c540774b1f209fc797201e41c56b849310 diff --git a/packages/client/ui-sidebar/README.md b/packages/client/ui-sidebar/README.md index 19c2d1033d..5bb697b3d2 100644 --- a/packages/client/ui-sidebar/README.md +++ b/packages/client/ui-sidebar/README.md @@ -8,6 +8,8 @@ New Session starts the runtime's page-local frontend Session Intent; a real Work `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state. +Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's [scrollbar indirection](../ui-theme/README.md) to `transparent` whenever the pointer is outside it, and keeps the thumb drawn for 2s after the pointer leaves, so a list nobody is pointing at carries no bar. The reservation that keeps rows from moving belongs to the scrolling region ([ui-workspace](../ui-workspace/README.md)), so revealing a thumb never reflows. + The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there. The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly). diff --git a/packages/client/ui-sidebar/README.zh.md b/packages/client/ui-sidebar/README.zh.md index b8c1545865..302f66c540 100644 --- a/packages/client/ui-sidebar/README.zh.md +++ b/packages/client/ui-sidebar/README.zh.md @@ -8,6 +8,8 @@ New Session 会启动运行时的页面局部前端 Session Intent;真实 Work `SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` 钩子、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。 +栏内的滚动条是一种指针可供性:只要指针不在栏内,外壳就把 ui-theme 的[滚动条间接层](../ui-theme/README.md)重新绑定为 `transparent`;指针离开后滑块再保留 2 秒,因此没人指向的列表不会带着滚动条。避免行位移的空间预留属于滚动区域本身([ui-workspace](../ui-workspace/README.md)),所以显示滑块不会引起重排。 + 页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。 `/client` 导出表层只包含插件主体(`apply`/`inject`)及契约类型:SidebarRoot、行组件和树派生均属于内部实现(slot 注册通过闭包引用它们;测试直接导入 src 路径)。 diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index ebb47467af..478c35ffbb 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -24,6 +24,19 @@ padding: 18px 10px 6px; } +/* Scrollbars in the column are a pointer affordance: the shell adds this + class whenever the pointer is not inside (SidebarRoot.tsx owns the linger), + and rebinding ui-theme's indirection pair to `transparent` takes the thumb + out of every scroll region nested under it — the workspace browser's + session list today. `transparent` rather than `display: none` on the bar: + the reservation (`scrollbar-gutter: stable` on the list) stays in force, so + revealing the thumb never reflows a row. Rebinding contract and the two + rendering paths it reaches: ui-theme's README. */ +.root.quietBars { + --dsh-scrollbar-thumb: transparent; + --dsh-scrollbar-thumb-hover: transparent; +} + /* Collapse phase 1: the whole frozen-width content fades out in place over 150ms; at settle the children unmount/snap to the rail layout. */ .fading > * { diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx index f7b83c29b6..a9924434fc 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.tsx +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.tsx @@ -8,6 +8,11 @@ * button and the foot is the `sidebar.workspaces` registrant's, and the foot * is the `sidebar.settings` registrant's; the shell hands them the wide flag * (plus an expand request callback for the browser). + * + * The column also owns whether the scroll regions nested in it draw a + * scrollbar at all: the shell tracks the pointer and rebinds ui-theme's + * scrollbar indirection away while it is elsewhere, so a list the user is not + * pointing at carries no bar. */ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -22,6 +27,14 @@ import css from './SidebarRoot.module.css' /** Wide-content unmount delay; matches the 150ms wide-content fade-out. */ const COLLAPSE_SETTLE_MS = 150 +/** + * How long the column's scrollbars stay drawn after the pointer leaves it. + * The bar is a pointer affordance here, and hiding it on the leave event + * itself makes it blink out while the pointer is only crossing the column's + * edge — on the way to the conversation, or around a portalled menu. + */ +const SCROLLBAR_LINGER_MS = 2000 + /** * Render the sidebar column shell. * @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts). @@ -56,10 +69,29 @@ export function SidebarRoot({ const everWide = useRef(!collapsed) if (!collapsed) everWide.current = true + // Scrollbars in the column follow the pointer (.quietBars rebinds them + // away): drawn while it is inside, and for SCROLLBAR_LINGER_MS after it + // leaves. A pointer that returns within that window cancels the pending + // hide rather than restarting from a hidden bar. + const [pointerInside, setPointerInside] = useState(false) + const lingerTimer = useRef(undefined) + useEffect(() => () => { window.clearTimeout(lingerTimer.current) }, []) + return (
{ + window.clearTimeout(lingerTimer.current) + setPointerInside(true) + }} + onPointerLeave={() => { + window.clearTimeout(lingerTimer.current) + lingerTimer.current = window.setTimeout(() => { setPointerInside(false) }, SCROLLBAR_LINGER_MS) + }} >
{/* Expanded, the wordmark doubles as a New Session shortcut; the diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap index 0506519bb2..043e63d242 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.spec.tsx.snap @@ -5,7 +5,7 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad data-slot="sidebar" >