From 96650fa189565e52c8ed6796a76cb4784e04c892 Mon Sep 17 00:00:00 2001 From: chendefine Date: Fri, 28 Aug 2026 18:38:18 +0800 Subject: [PATCH] fix(shell): keep an overflow:hidden fallback beneath clip + guard the cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shift fix) — but as a BARE declaration. Engines without clip support (Safari < 16, older Chromium) drop an unsupported declaration at parse time, which leaves the host `overflow: visible`: no clipping at all, and the historical "collapsed panels extend the document's scrollable area, whole page draggable" regression returns on those engines. Declare `overflow: hidden` BEFORE `overflow: clip`: clip-aware engines apply the later clip (identical behavior to #453), while old engines fall back to hidden and keep clipping (with its pre-clip scrollability — the same state they had before #453, strictly better than visible). The cascade order is the contract. Also extends the rule comment (the fix guards the whole scroll-into-view class — focus moving into off-viewport regions, nested iframes/workbenches claiming focus while loading — not just the reveal case) and adds tests/panel-host-css.spec.ts guarding the cascade (hidden first, clip last), the fixed viewport layer, and that the degraded rule never reintroduces overflow. Contract documented in the integration guide §12 (root anchor bullet). --- docs/external-plugin-guide.md | 2 +- src/client/sidebar.module.css | 13 +++++++++- tests/panel-host-css.spec.ts | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/panel-host-css.spec.ts diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md index c436d8d..29ef27d 100644 --- a/docs/external-plugin-guide.md +++ b/docs/external-plugin-guide.md @@ -752,7 +752,7 @@ ctx.effect(() => - **面板表面**:右/底面板背景 = `var(--dsw-alias-bg-layer-1)`。**绝不消费 `--dsw-specific-sidebar-fill`**(宿主左导航专属,皮肤按左导航语义覆盖它,面板消费会失去填充)。换面板表面 = 覆写 `--dsw-alias-bg-layer-1`。 - **终端/编辑器表面**:`effectiveTokenValue` 读 `--dsw-alias-bg-base`——`transparent` 与 alpha < 0.9 的半透明值回退不透明底色(文字不叠背景画,issue #90);≥ 0.9 放行。 -- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。 +- **根锚点**:宿主 div 带 `data-dsh-better-sidebar`(append 到 body);其内**面板宿主层** `[data-dsh-panel-host]`(`fixed; inset:0; z-25; pointer-events:none; overflow:hidden+clip`,v0.13.1+),面板/开关簇 absolute 定位,免疫中间层 transform 劫持;页面级 transform 触发 `data-dsh-panel-host-degraded` 降级。`overflow` 级联是**契约**(`hidden` 兜底 + `clip` 收尾,`tests/panel-host-css.spec.ts` 守护):`hidden` 盒子仍是滚动容器,脚本滚动或浏览器 scroll-into-view 修正(焦点移入视口外区域、嵌套 iframe/工作台加载时抢焦点、面板滑出动画中 focus() 落点)会沿最近可滚祖先滚走整层——面板与开关簇集体偏离视口角(computed left/right 仍"正确",偏移藏在盒子自身 scroll offset 里);`clip` 裁剪语义相同但不产生滚动盒,任何路径都滚不动这层。皮肤作用域覆盖限定在 `[data-dsh-better-sidebar]` 内。 - **布局变量**(`` 上,面板打开时有效):`--dsh-sidebar-width` / `--dsh-sidebar-height`。右面板宽度 = AppFrame 的 `padding-right` 预留(新版 `#root [data-dsh-frame]` / rc.8 `#root > [data-slot="root"] > div` 双锚点),AppFrame border box 保持完整桌面视口宽度(Harness 以此判定桌面/窄屏布局,避免插件面板展开误入窄屏);AppFrame 的 details 拖拽手柄按同一变量向左平移贴合列边缘。底部面板仍走 centerCol `margin-bottom`;centerCol 锚点 = **JS 标注**(禁止 `nth-child`):侧栏 shell 的定位器给测得的 centerCol 节点打 `[data-dsh-center-col]` 标签(`Sidebar.tsx` locate,节点更换/HMR 时随 ref 迁移),`layout.css` 用 `#root [data-dsh-center-col]` 选中(`drag-layout.e2e.ts` 断言恰一节点且为 `[data-slot="conversation"]` 的父级;frame 宽度与桌面 Session Log 由 `desktop-layout.e2e.ts` 断言)。 - **桌面信号与标题栏**(v0.14.1+ 四方案模型 `SidebarPrefs.titleBarScheme`,唯一决策点 `src/client/titlebar-strip.ts` 纯函数): - 壳信号(只读,不自动触发修改):URL `dsh-desktop-mode` / `dsh-desktop-platform` / 可选 `dsh-desktop-titlebar-inset`(0–120 clamp)。 diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 208ed60..85426dd 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -48,7 +48,18 @@ hidden box stays a PROGRAMMATICALLY scrollable container, and a scrollIntoView fired from panel content (the file tree's "Show in folder" reveal) scrolled the host itself — shifting the whole panel, - tab bar included, out of the viewport with no way to scroll back. */ + tab bar included, out of the viewport with no way to scroll back. + The same class covers any scroll-into-view fixup — focus moving into + an off-viewport region, a nested iframe/workbench claiming focus + while it loads — so the guard belongs on the layer itself, not per + call site. The `hidden` declaration BEFORE `clip` is an old-engine + fallback, not dead style: engines without `clip` support (Safari < 16) + drop the `clip` line, which would leave this box `overflow: visible` + — no clipping at all, and the page-draggable regression above + returns. With `hidden` first those engines keep the clipping (plus + its pre-clip scrollability) while every clip-aware engine applies + the later `clip`. */ + overflow: hidden; overflow: clip; } diff --git a/tests/panel-host-css.spec.ts b/tests/panel-host-css.spec.ts new file mode 100644 index 0000000..8b4e470 --- /dev/null +++ b/tests/panel-host-css.spec.ts @@ -0,0 +1,47 @@ +/** + * The unified panel host layer must clip WITHOUT being a scroll container — + * the guard for the "panels / toggle cluster jumped away from the viewport + * corner" bug class. + * + * `overflow: hidden` only clips: the box stays a scroll container, so any + * script scroll or the browser's native scroll-into-view fixup (focus moving + * into an off-viewport region, a nested workbench/iframe claiming focus + * while it loads, focus() landing during a panel's slide-out transition) + * walks up to the nearest scrollable ancestor — this layer — and scrolls it, + * dragging every panel plus the toggle cluster off the corner while the + * computed `left`/`right` still read correct (the offset hides in the box's + * own scroll offset). `overflow: clip` declared AFTER `hidden` keeps the + * `hidden` fallback for engines without clip support and removes the + * scrollability everywhere else; the cascade order is part of the contract. + */ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const css = readFileSync('src/client/sidebar.module.css', 'utf8') + +/** Body of the `:global([data-dsh-panel-host])` rule (up to the un-indented `}`). */ +const hostRule = css.match(/:global\(\[data-dsh-panel-host\]\) \{([\s\S]*?)\n\}/)?.[1] + +/** Strip /* ... *​/ comment blocks so prose mentions don't count as declarations. */ +const stripComments = (cssText: string): string => cssText.replace(/\/\*[\s\S]*?\*\//g, '') + +describe('panel host layer css', () => { + it('declares the clip cascade: hidden first, clip last', () => { + expect(hostRule, 'the [data-dsh-panel-host] rule must exist').toBeDefined() + const declarations = stripComments(hostRule!).match(/overflow\s*:\s*\w+/g) ?? [] + expect(declarations).toEqual(['overflow: hidden', 'overflow: clip']) + }) + + it('stays the fixed viewport layer (containing block + BFC guarantee)', () => { + expect(hostRule).toMatch(/position:\s*fixed/) + expect(hostRule).toMatch(/inset:\s*0/) + }) + + it('does not reintroduce overflow on the degraded (absolute) layer', () => { + const degraded = css.match( + /:global\(\[data-dsh-panel-host\]\[data-dsh-panel-host-degraded\]\) \{([\s\S]*?)\n\}/, + )?.[1] + expect(degraded, 'the degraded-mode rule must exist').toBeDefined() + expect(degraded).not.toMatch(/overflow\s*:/) + }) +})