reviewer 在本机跑 Chromium 逐条实测(同一批宿主规则 + 插件产物),结论:修复方向与形状正确
(`.mermaidModal` 预修复计算 `no-drag`、修复后 `none`;级联探针确实能把未修复产物判红),
但有几处措辞与守卫不到位。逐条处理:
1. **「app-region 会被继承」的说法在该引擎里不成立**:`[data-dsh-panel-host]` 不是 body
子元素(DOM 是 `body > [data-dsh-better-sidebar] > [data-dsh-panel-host]`),lane 用的
Chromium 不传播该属性(实测:`drag` 父元素里子元素读 `none`)。改写成「宿主是 body
子元素;在 Electron 里该值会传到它内部的面板层(#772 的 CDP 实测),lane 的引擎不传播,
所以面板层自己也声明同一条规则」——CSS 注释、指南 §12.1、探针注释三处同步。
2. **`regions.layer` 那条断言是真空断言**(修复前后都读 `none`):保留为回归钉子,但在
注释里明确标注它不具判别力、面板层的规则由 `panel-host-css.spec.ts` 钉住。
3. **给修复本身补行为级守护**:放大视图已经在同一 lane 里被真实打开过,于是在那里直接对
`[data-mermaid-modal]`(以及它的按钮)读计算值——断言用**页面里宿主自己的** darwin
规则(不再是探针复制的副本)。实测判别力:npm 0.22.0 上 `Received: "no-drag"`、修复版
`none`。
4. **「交互层不要加 initial」与放大视图的矛盾**:放大视图是**遮罩层例外**——模态语义下
只有其中控件必须保持 `no-drag`;残余代价(背景落在顶部拖拽带上时点背景会变拖窗)已写进
指南,避免后来者按字面判据「修」掉其中一边。
5. **`.selectionPopup` 的负向守卫可被选择器列表绕过**(reviewer 实测:`.selectionPopup,.x
{ initial !important }` 能通过):改成按**声明块**扫描——凡带 `-webkit-app-region:
initial` 的块,其选择器文本都不得出现 `selectionPopup`。
6. **补上 `none` 关键字的坑**:字面量 `none` 计算成 `no-drag`(是扣减值),只有 `initial`
/`unset`/`revert` 才是中性值——CSS 注释、指南与单元用例注释都已写明。
7. **未覆盖层给出结论**:`FileTree` 的 `.uploadDropChatHint` 是 body 直挂的装饰层,但只在
OS 文件拖拽悬停期间存在且 `pointer-events:none`,收益极小而**有意不处理**(写进指南,
不再让「覆盖清单」看起来是绝对集合)。
8. 顺带:`§12.2` 的同步集里 `tests/theme.spec.ts` 只守颜色(本轮无颜色改动),形状守护落在
已解析该样式表的 `tests/panel-host-css.spec.ts` 上——指南那句已按此表述。
验证:typecheck / lint 通过;panel-host-css 4 例通过;本地挂载 lane 7 passed(含新的放大
视图计算值断言);对 npm 0.22.0 的判别力实测:宿主探针红(`host` = `no-drag`)、放大视图
断言红(`modal` = `no-drag`),修复版全绿。
85 lines
4.6 KiB
TypeScript
85 lines
4.6 KiB
TypeScript
/**
|
|
* 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 block comments (`/*` to `*`-`/`) 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*:/)
|
|
})
|
|
|
|
it('keeps the app-region opt-out that un-breaks macOS window drag (issue #772)', () => {
|
|
// The shell's `html[data-platform=darwin] body > :not(#root) { no-drag }`
|
|
// carries an id in its specificity, so only `initial !important` wins;
|
|
// `initial` computes to the NEUTRAL `none` (does not subtract from the
|
|
// drag region) while `no-drag` is the subtracting value — and the literal
|
|
// keyword `none` is NOT neutral (it computes to `no-drag`), which is
|
|
// exactly why this guard pins `initial`. Hence the split: decorative
|
|
// viewport-sized layers opt out, their panels stay no-drag so controls
|
|
// keep receiving clicks. Dropping the `!important` silently re-breaks
|
|
// window drag / double-click-title zoom on macOS (there is no macOS
|
|
// runner to catch it), so pin the shape here where Linux `pnpm test`
|
|
// fails instead.
|
|
const text = stripComments(css)
|
|
expect(text, 'the panel host must opt out of app-region computation').toMatch(
|
|
/:global\(\[data-dsh-panel-host\]\)\s*\{[^}]*-webkit-app-region:\s*initial\s*!important/,
|
|
)
|
|
expect(text, 'panels must stay no-drag').toMatch(
|
|
/:global\(\[data-dsh-panel-host\]\)\s*>\s*\*\s*\{[^}]*-webkit-app-region:\s*no-drag/,
|
|
)
|
|
expect(text, 'the mermaid zoom modal is the other persistent viewport-sized body child').toMatch(
|
|
/\.mermaidModal\s*\{[^}]*-webkit-app-region:\s*initial\s*!important/,
|
|
)
|
|
// The interactive popups must NOT get `initial`: they are buttons/handles
|
|
// the shell already keeps no-drag, and outranking that rule makes a press
|
|
// start a window drag (the #103/#111 click-swallow class). Checked per
|
|
// DECLARATION BLOCK, not per single-selector rule: a selector list
|
|
// (`.selectionPopup, .other { … initial !important }`) would slip past a
|
|
// simple "class immediately followed by {" pattern.
|
|
const resets = [...text.matchAll(/([^{}]+)\{([^{}]*)\}/g)]
|
|
.filter(([, , body]) => /-webkit-app-region:\s*initial/.test(body ?? ''))
|
|
expect(resets.length, 'the file must declare the app-region opt-out').toBeGreaterThan(0)
|
|
const resetSelectors = resets.map(([, selector]) => selector ?? '').join(',')
|
|
for (const excluded of ['selectionPopup']) {
|
|
expect(resetSelectors, `${excluded} must not opt out of app-region`).not.toContain(excluded)
|
|
}
|
|
})
|
|
})
|