mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge pull request #4622 from deepseek-harness/feat/window-drag-coverage
refactor(client): 让每个 chrome 行自持窗口拖拽(单一声明 + 覆盖契约 + 统一重收集)
This commit is contained in:
commit
d9ff18ec2a
63 files changed
+2025
-373
No files matched your search
+6
@@ -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/architecture/2026-09-19-window-drag-coverage-contract.md
|
||||
2026-09-19-window-drag-coverage-contract.md: c73d9230a4194f74babbd41ef3a8fdedc07cdd81
|
||||
2026-09-19-window-drag-coverage-contract.zh.md: 81fb9bbcc299769ab5b5bde1c04deb7c691c3a34
|
||||
@@ -0,0 +1,64 @@
|
||||
# Agent Note: Window drag coverage as a contract
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-19-window-drag-coverage-contract.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The macOS desktop window's draggable area is authored twice. The layout decides where chrome rows are — their heights and what they contain — while hand-written app-region declarations decide what drags: one row declaration per chrome row (the sidebar's top strip and logo row, ui-dockkit's strip row, the Conversation header, the plugin manager's page heads, the Platform overlay's return bar) plus per-box subtractions spread across ui-web, ui-settings-general and ui-sidebar-right. The two descriptions are kept in step by hand, so they drift: a row whose box does not match the band leaves chrome outside the drag surface (the reported "the header drags, but some blank runs and controls do not") or puts content inside it (a press that misses a control by a few pixels drags the window, and a double click there runs the system's title-bar action).
|
||||
|
||||
## Decision
|
||||
|
||||
Coverage becomes a contract with four executed parts:
|
||||
|
||||
1. ui-web's `window-drag/regions.ts` publishes the composition rule as an executable model — a point is draggable when the last collected app-region box containing it declares `drag`, the geometry-in-DOM-order composition the native window applies — together with `INTERACTIVE_SELECTOR`, the single source for the interactive subtraction ui-web base.css declares. The base-styles spec asserts the sheet declares exactly that list, so the two cannot drift.
|
||||
2. The shell declares the darwin drag surface exactly once: `html[data-platform='darwin'] [data-window-drag]` in ui-web base.css. Every chrome row marks its own element in markup, so the row's box is the window's draggable geometry and no fixed band has to match any row's height. The ui-theme app-region gate holds a manifest that pairs each row's markup mark with its sheet, its class selector and its authored height, and refuses a mark on any element the manifest does not name — so a row that stops marking itself, an unmarked row added to the composition, and a mark on a content container all fail the same gate.
|
||||
3. The browser lane gains `window-drag-coverage`, which boots the shipped composition in Chromium with `data-platform='darwin'` and asserts, through the model, that no interactive box lies inside the drag surface, plus per-row probes for the chrome rows and a page whose head row marks itself while its scrollable body stays content.
|
||||
4. The shell owns the one recollection watcher — ui-web's `window-drag/recall.ts`, installed by the boot kernel. It measures every marked row once per frame while the surface can be moving, sets `data-window-drag-recall` on the body for each frame whose geometry changed, and clears it once the geometry holds still through a short grace window, so a report that arrives before a CSS transition moves anything cannot end the loop on its first unchanged sample. It reports every reason the surface can move: a DOM change touching a marked row or the container holding one, a marked row's box resizing, and a transition or animation starting on an element that holds a marked row. No chrome row carries a pulse of its own.
|
||||
|
||||
The native half — whether a press really drags or reaches the page — stays a checklist, because no keyless CI lane reaches the window server: the state matrix below is run for any change to this layer.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Publishing measured rectangles from JavaScript as synthetic drag boxes.** Geometry would have to be re-derived at pointer cadence, and one stale frame is exactly the swallow class this work removes; CSS-derived regions cannot be stale by construction.
|
||||
|
||||
**A global background drag (the `movableByWindowBackground` reading).** Dragging anywhere that is not a control would break text selection and blank-area clicks.
|
||||
|
||||
**One drag declaration per row in each row's own sheet.** That was this work's first shape, with a manifest pinning each row's height and the band over it. Every sheet then restates the window's geometry, and a row whose box moves in layout alone still has to be re-declared in CSS; one shell rule over a markup mark keeps the declaration count at one, and the manifest pairs the marks with the geometry that the gate can still check.
|
||||
|
||||
**Pinning current coverage as one golden snapshot.** That would freeze the two failure classes next to the correct behaviour; the manifest separates the invariant (no swallowed control) from the gaps (a budget that only shrinks).
|
||||
|
||||
## Consequences
|
||||
|
||||
- A new control needs no drag declaration: the interactive selector subtracts it. A new chrome row marks its element `data-window-drag` and adds a manifest entry pinning its height, so no band arithmetic and no other sheet changes.
|
||||
- A row's own box is the whole draggable geometry, and a content container is never one. The plugin manager's detail views mark the head row they share, not the detail container they are rendered in: marking the container would drag the window over its text and form labels, and the lane samples the body below that head to keep it so.
|
||||
- No coverage gap remains: every chrome row owns its run, and the gate asserts the whole package tree declares the drag surface in exactly two places — the shell's mark rule and the Windows caption row. The plugin manager's page head and its detail views' shared head row include the 48px clearance inside the row, so the entry-page gap closed with them.
|
||||
- The settings overlay portals beside `#root` (like the Modal primitive) and dropped its per-sheet `no-drag` rule: a covering surface inside the root precedes the columns' chrome, so a drag row declared later would override its subtraction, and ui-web base.css's `body > :not(#root)` rule subtracts a portalled one wherever it overlaps a drag row. The browser lane asserts that placement with the overlay open. Because a step's onboarding overlay marks only `#root` inert, the panel closes when an onboarding step mounts rather than staying focusable behind it.
|
||||
- The Platform overlay's return bar is a darwin drag row only. Its former per-sheet `app-region: drag` carried no platform scope, so Windows dragged the window from that 48px bar; under the one scoped rule the bar declares drag on macOS alone, where it is the window's top strip. Windows keeps the native caption row as its drag surface, and the shell ships no Linux desktop target. The manual state matrix gains a cell for this bar.
|
||||
- A row that slides across a hidden/visible edge cannot rely on the layout change alone: Electron recollects the window's drag rects only when a computed app-region value changes (electron#32341), and Blink skips hidden boxes when it collects them. The watcher closes that gap for every row at once — it re-arms on a DOM change that touches a marked row or on a marked row's box resizing, then pulses each frame in which a box moved — so the right panel carries no pulse of its own.
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/web/tests/window-drag-regions.client.spec.ts` — the composition rule, including order sensitivity and box edges.
|
||||
- `packages/client/web/tests/base-styles.client.spec.ts` — base.css declares the model's interactive selector, the one darwin drag rule, and the recall mark's subtraction.
|
||||
- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` — drag ownership, the row manifest, and each row's mark; a mark outside the manifest or a changed row height fails.
|
||||
- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` — the strip row's own subtraction; its drag is the markup mark's, so the sheet declares none.
|
||||
- `packages/client/web/tests/window-drag-recall.client.spec.ts` — the watcher through its two seams: what re-arms it, what it ignores, the settle loop, the shell defaults, and disposal.
|
||||
- `apps/web/tests/window-drag-coverage.e2e.ts` — real Chromium: no swallowed control, per-row probes, a detail page whose head row drags while its body does not, and the recall pulse the shell fires while the right panel slides.
|
||||
- Real-machine verification (2026-09-20, both platforms, HID-level pointer input plus window-frame readback): the macOS matrix above ran on a real `hiddenInset` window and the Windows cells ran on a Windows 11 ARM64 VM — chrome rows drag, content and the covering overlay do not, controls keep their clicks, and the right panel's strip row drags after its slide.
|
||||
|
||||
## Manual state matrix
|
||||
|
||||
Run on the packaged macOS app for any change to this layer. Each cell means both halves: drag the row's blank run (the window must move) and click the row's controls (they must act rather than drag).
|
||||
|
||||
| State | Rows to walk |
|
||||
| --- | --- |
|
||||
| Sidebar expanded, Conversation selected, view tabs shown | sidebar top strip, sidebar logo row, conversation title row, conversation tab-strip row, every control in those rows |
|
||||
| Sidebar expanded, Conversation selected, single view (no tabs) | same rows, plus the transcript's first rows below the header |
|
||||
| Sidebar collapsed | Conversation header over the centre, `shell.leading` seat controls, reopen and New session |
|
||||
| Right sidebar pushed | panel strip row and its controls, the pane body run below the strip, the pane divider's top run |
|
||||
| Right sidebar fullscreen | panel strip row at the traffic lights, first pane's strip inset, its controls |
|
||||
| Entry page (guide, plugin manager, settings) | page head with its 48px clearance, its first control row, and a detail page's head row over its body |
|
||||
| Window fullscreen on/off | every row above with the traffic lights hidden, in both sidebar states |
|
||||
| Double click in a chrome row's blank run | on Windows the caption row takes the system title-bar action (maximize/restore); on macOS the darwin rows are app-region areas rather than a title bar, so no system action is expected there — only the controls keep their own double click |
|
||||
@@ -0,0 +1,64 @@
|
||||
# Agent Note: 把窗口拖拽覆盖变成契约
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-19-window-drag-coverage-contract.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
macOS 桌面窗口的可拖区域被写了两遍。布局决定 chrome 行在哪——它们多高、装了什么;手写的 app-region 声明决定什么能拖:每个 chrome 行一条行声明(侧栏顶条与 logo 行、ui-dockkit 的 strip 行、会话 header、插件管理的页头、Platform 浮层的返回栏),加上散落在 ui-web、ui-settings-general、ui-sidebar-right 的逐盒减除。两份描述靠人肉保持一致,于是会漂:盒子与 band 不匹配的行,要么有 chrome 落在拖拽面之外(就是反馈里的"header 能拖,但有些空白与控制不能"),要么有内容落进拖拽面(点偏几个像素就变成拖窗口,双击那里还会触发系统标题栏行为)。
|
||||
|
||||
## Decision
|
||||
|
||||
覆盖变成由四部分组成的、可执行的契约:
|
||||
|
||||
1. ui-web 的 `window-drag/regions.ts` 把合成规则发布成可执行模型——当包含某点的最后一个 app-region 盒声明 `drag` 时该点可拖,这正是原生窗口施加的"按 DOM 顺序的几何合成"——并发布 `INTERACTIVE_SELECTOR`,作为 ui-web base.css 交互减除的唯一来源。base-styles spec 断言样式表声明的正是该列表,两者无法漂移。
|
||||
2. 外壳只声明一次 darwin 拖拽面:ui-web base.css 里的 `html[data-platform='darwin'] [data-window-drag]`。每个 chrome 行在 markup 里给自己的元素打标,于是该行自己的盒子就是窗口的可拖几何,不需要任何固定 band 去匹配行高。ui-theme 的 app-region 门禁持有一份清单,把每行的 markup 标记与其样式表、类选择器和写死的高度配成一条,并拒绝清单未点名的元素上的标记——行不再打标、新增未打标的行、把标记打在内容容器上,都会在同一条门禁上失败。
|
||||
3. 浏览器车道增加 `window-drag-coverage`:以 `data-platform='darwin'` 在 Chromium 里启动真实组合,用模型断言没有任何交互盒落在拖拽面内,并对各 chrome 行逐一探测,另加一个"页头行打标、其可滚动正文保持内容"的页面。
|
||||
4. 外壳持有唯一那个重收集 watcher——ui-web 的 `window-drag/recall.ts`,由引导内核安装。在表面可能移动期间,它每帧测量每个被打标的行,凡该帧几何发生变化就在 body 上打 `data-window-drag-recall`;几何在一段很短的宽限窗口内保持不动之后才清掉,这样在 CSS transition 真正移动任何东西之前到达的一次报告,不会在它第一帧未测到变化时就终止循环。它会报告表面可能移动的每一种原因:触及被打标行或承载它的容器的 DOM 变化、被打标行的盒子尺寸变化,以及持有被打标行的元素上开始的 transition 或 animation。任何 chrome 行都不再自带脉冲。
|
||||
|
||||
原生那一半——一次按下到底是拖窗口还是到达页面——仍是清单,因为没有无密钥 CI 车道能到达窗口服务器:对本层的任何改动都要走下面的状态矩阵。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**用 JavaScript 测量矩形并发布合成 drag 盒。** 几何得按指针节拍重新推导,而一帧陈旧正是这项工作要消除的吞点击类别;CSS 推导的区域在构造上不可能陈旧。
|
||||
|
||||
**全局背景拖拽(`movableByWindowBackground` 的读法)。** 只要不是控件就能拖,会破坏文本选择与空白处点击。
|
||||
|
||||
**每个行在自己的样式表里各声明一条 drag。** 这是本项工作最初的样子,并配一份清单钉住每行高度与覆盖它的 band。那样每个样式表都要复述窗口几何,而且只在布局里移动的盒子仍得在 CSS 里重新声明;改成"markup 打标 + 外壳一条规则"后声明只有一处,清单则继续把标记与门禁能查的几何配起来。
|
||||
|
||||
**把当前覆盖钉成一张金标准快照。** 那会把两类故障和正确行为一起冻住;清单把不变量(不得吞掉控件)与缺口(只许缩小的预算)分开。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 新控件不需要任何 drag 声明:交互选择器会减除它。新 chrome 行只需给元素打上 `data-window-drag` 并在清单里加一条钉住高度,无需 band 算术,也不牵动其它样式表。
|
||||
- 行自己的盒子就是完整的可拖几何,内容容器永远不是。插件管理的详情视图给它们共用的页头行打标,而不是给它所在的详情容器打标:标记容器会让窗口在它的正文与表单 label 上拖动;车道会采样该页头下方的正文来守住这一点。
|
||||
- 不再有任何缺口:每个 chrome 行自持其拖拽段,门禁断言整个包树只在两处声明拖拽面——外壳的标记规则与 Windows 标题栏行。插件管理页头与其详情视图共用的页头行都把 48px 避让量包含在行内,入口页那条缺口也随之关闭。
|
||||
- settings 浮层现在与 Modal primitive 一样 portal 到 `#root` 之外,并删掉了它自己那条 no-drag:挂在 root 内的覆盖层先于各列 chrome,后声明的 drag 行会盖掉它的减除;portal 之后由 ui-web base.css 的 `body > :not(#root)` 规则在它与 drag 行重叠处减除。车道在浮层打开时会断言这个归属。由于 onboarding 步骤的浮层只把 `#root` 置为 inert,onboarding 步骤挂载时面板会自行关闭,而不是留在它背后仍可聚焦。
|
||||
- Platform overlay 的返回栏只是 darwin 的 drag 行。它原先那条按表声明的 `app-region: drag` 没有任何平台限定,因此 Windows 上也能从这条 48px 栏拖动窗口;改到唯一那条带作用域的规则之后,该栏只在 macOS 上声明 drag,在那里它就是窗口顶条。Windows 保留原生标题栏行作为拖拽面,且外壳没有 Linux 桌面目标。人工状态矩阵会为这条栏增加一格。
|
||||
- 跨越隐藏/显示边界滑动的行不能只靠布局变化:只有当计算出的 app-region 值变化时,Electron 才会重新收集窗口的拖拽矩形(electron#32341),而 Blink 收集时会跳过隐藏的盒子。watcher 一次性把这个缺口对所有行补上——它在"触及被打标行的 DOM 变化"或"被打标行的盒子尺寸变化"时重新启动,随后在盒子仍在移动的每一帧发脉冲——因此右栏不再自带任何脉冲。
|
||||
|
||||
## Testing
|
||||
|
||||
- `packages/client/web/tests/window-drag-regions.client.spec.ts` —— 合成规则,含顺序敏感与盒子边界。
|
||||
- `packages/client/web/tests/base-styles.client.spec.ts` —— base.css 声明的正是模型的交互选择器、唯一那条 darwin drag 规则,以及 recall 标记的减除。
|
||||
- `packages/client/ui-theme/tests/app-region-styles.client.spec.ts` —— 拖拽所有权、行清单与每行的标记;清单之外的标记或行高变化即失败。
|
||||
- `packages/client/ui-dockkit/tests/app-region-styles.client.spec.ts` —— strip 行自己的减除;它的 drag 属于 markup 标记,因此样式表一条都不声明。
|
||||
- `packages/client/web/tests/window-drag-recall.client.spec.ts` —— 通过两个 seam 驱动 watcher:什么会重新启动它、什么被它忽略、收敛循环、外壳默认值,以及销毁。
|
||||
- `apps/web/tests/window-drag-coverage.e2e.ts` —— 真 Chromium:无控件被吞、逐行探测、一个页头行可拖而正文不可拖的详情页,以及右栏滑动期间外壳发出的 recall 脉冲。
|
||||
- 真机验收(2026-09-20,双平台,HID 级指针输入 + 窗口矩形读数):上面的 macOS 矩阵在真实 `hiddenInset` 窗口上跑过,Windows 各格在 Windows 11 ARM64 虚拟机上跑过——chrome 行可拖、正文与覆盖浮层不可拖、控件保留点击、右栏 strip 行在滑入后可拖。
|
||||
|
||||
## Manual state matrix
|
||||
|
||||
对本层的任何改动都要在打包 macOS 应用上跑。每个格子包含两半:拖该行的空白段(窗口必须移动),点该行的控件(必须生效而不是拖动)。
|
||||
|
||||
| 状态 | 要走的行 |
|
||||
| --- | --- |
|
||||
| 侧栏展开、选中会话、显示视图 tab | 侧栏顶条、侧栏 logo 行、会话标题行、会话 tab 条行,以及这些行里的每个控件 |
|
||||
| 侧栏展开、选中会话、单视图(无 tab) | 同上,外加 header 之下的转录区首行 |
|
||||
| 侧栏收起 | 覆盖中列的 会话 header、`shell.leading` 座的控件、重新打开与新会话 |
|
||||
| 右栏 push | 面板 strip 行及其控件、strip 下方的 pane body 段、分栏分隔条的顶部一段 |
|
||||
| 右栏 fullscreen | 红绿灯处的面板 strip 行、首格 strip 内边距、其控件 |
|
||||
| 入口页(开始页、插件管理、设置) | 含 48px 避让量的页头、它的首个控件行,以及详情页页头行与其正文 |
|
||||
| 窗口全屏开关 | 以上各行在红绿灯隐藏时、两种侧栏状态各一遍 |
|
||||
| 在 chrome 行空白段双击 | Windows 上 caption 行走系统标题栏行为(最大化/还原);macOS 上 darwin 行只是 app-region 区域而非标题栏,因此那里没有系统动作——只有控件保留自己的双击 |
|
||||
+2
-2
@@ -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/architecture/2026-09-20-sidebar-retained-tab-layout.md
|
||||
2026-09-20-sidebar-retained-tab-layout.md: 9579a0ba47c56f1d533f066fddd37e7ddf443346
|
||||
2026-09-20-sidebar-retained-tab-layout.zh.md: 365ad5682c8610cab5dc855984e58b4303bfae49
|
||||
2026-09-20-sidebar-retained-tab-layout.md: 3c1e23ad93ba3dc2f2a5134af7808ca992e0635f
|
||||
2026-09-20-sidebar-retained-tab-layout.zh.md: 000174a0c31dd3682f2c0224e7550419d2b7b39e
|
||||
@@ -17,7 +17,7 @@ Sidebar owns live-body retention, independently of Browser navigation and Worksp
|
||||
- `DockLayout` renders the Sidebar's one or two horizontal panes with stable tab siblings. The existing recursive `DockSurface` and separate `FloatLayer` remain available for general split-tree rendering; the layout engine and serialized format are unchanged.
|
||||
- Every tab has a stable Grid cell, frame, Header container and Body container. Explicit Grid columns determine docking placement; Flex assigns Header and Body height. Floating changes that same frame to `position: fixed` without changing its parent or replacing Body.
|
||||
- A floating frame's zero-sized Grid cell establishes a stacking context at the float layer. CSS `order` expresses float depth within that layer; DOM order stays sorted by tab identity. Raising many floats cannot increment their layer past menus. Selection and strip order never reorder the content-node list.
|
||||
- Common ancestors have no transform, clipping or enclosing stacking context. Docked cells own collapse transforms and visibility; each frame clips its content. Fullscreen changes the same root's width and docked layer. Existing window-drag region pulses remain, but no RAF copies content positions.
|
||||
- Common ancestors have no transform, clipping or enclosing stacking context. Docked cells own collapse transforms and visibility; each frame clips its content. Fullscreen changes the same root's width and docked layer. The right panel carries no window-drag pulse of its own: one shell-owned watcher measures the marked rows each frame while the surface can move and pulses the recall mark itself, so no layout code copies content positions.
|
||||
- Split and float gestures retain their existing measurements and operation state. These update user-selected split fractions and float rectangles, not a second Browser rectangle. Browser resize and content sizing use normal CSS layout.
|
||||
|
||||
### Session and tab lifetime
|
||||
|
||||
@@ -17,7 +17,7 @@ Sidebar 负责活 Body 的保留,不依赖 Browser 导航和 Workspace 存储
|
||||
- `DockLayout` 用稳定的 Tab 兄弟节点渲染 Sidebar 的一个或两个水平 Pane。现有递归 `DockSurface` 和独立 `FloatLayer` 仍可用于通用分割树渲染;布局引擎和序列化格式不变。
|
||||
- 每个 Tab 都有稳定的 Grid cell、frame、Header 容器和 Body 容器。显式 Grid 列决定停靠归属,Flex 分配 Header 与 Body 的高度。浮动只把同一个 frame 改为 `position: fixed`,不改父节点或替换 Body。
|
||||
- 浮动 frame 的零尺寸 Grid cell 在浮窗层建立层叠上下文。CSS `order` 表达该层内的浮窗深度,DOM 顺序始终按 Tab 身份排序。大量浮窗置顶不会把层级递增到菜单之上;选中项和 Tab 条排序均不重排内容节点列表。
|
||||
- 共同祖先不设置 transform、裁剪或包住全部内容的层叠上下文。停靠 cell 负责收起 transform 和显隐,各 frame 裁剪自身内容。Fullscreen 修改同一个根的宽度和停靠层级。窗口拖动区域的既有脉冲仍保留,但没有 RAF 复制内容位置。
|
||||
- 共同祖先不设置 transform、裁剪或包住全部内容的层叠上下文。停靠 cell 负责收起 transform 和显隐,各 frame 裁剪自身内容。Fullscreen 修改同一个根的宽度和停靠层级。右栏不再自带窗口拖拽脉冲:由外壳唯一那个 watcher 在表面可能移动时逐帧测量被打标行并自行脉冲 recall 标记,因此没有布局代码复制内容位置。
|
||||
- 分栏与浮窗手势沿用原有测量和操作状态,更新用户选择的分栏比例与浮窗矩形,而非维护第二份 Browser 矩形。窗口缩放和内容尺寸变化由普通 CSS 布局处理。
|
||||
|
||||
### 会话与 Tab 生命周期
|
||||
|
||||
+2
-2
@@ -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/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.md: 807c79d06c468e01b8b9df3f18a32e5fbaba1e90
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: a521aff7624b1f9dbab18e7d0f02f61af0bdc24a
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.md: 7e69fea3a40ace0761949a655cb302b7ca7f4e91
|
||||
2026-09-13-macos-hidden-titlebar-vibrancy.zh.md: fbba1857402d54e0da0b31198db0608d8146a34f
|
||||
@@ -18,11 +18,11 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
|
||||
|
||||
**Native theme sync.** The vibrancy material follows `nativeTheme.themeSource`, which otherwise tracks the OS appearance and diverges from the app's own theme preference. The ui-theme boot script and ui-layout `ThemePresenter` publish `html[data-ds-theme-source]` (`light`, `dark`, or `system`; a fixed preference, including registered theme ids, publishes its resolved scheme). The app preload observes the attribute and forwards it over `dsh-desktop:native-theme-set`; main validates the value and the sender (the main window's WebContents, including its local static Web document) and assigns `nativeTheme.themeSource`. Publishing the preference rather than the resolved scheme preserves OS-follow while the preference is `system`.
|
||||
|
||||
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the frame's `.leadingBand` drag band covers the same 52px geometry, so the strip declares no app-region of its own and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md).
|
||||
**Sidebar top strip and full hide.** On darwin the sidebar opens with a 52px top strip that clears the traffic lights and carries the collapse toggle; the strip marks itself `data-window-drag`, so its own 52px box is the window's drag region (ui-web base.css declares the one darwin drag rule) and the toggle subtracts itself through the base.css interactive rule. Collapsing the sidebar hides the column entirely — `computeColumns` takes an explicit `collapsedWidth` and the AppFrame passes 0 on darwin desktop — instead of the 56px rail the other platforms keep. The reopen affordances live in the frame's root-scoped `shell.leading` window-chrome seat, which the AppFrame mounts only while the column is fully hidden; ui-sidebar registers `HeaderLeadingControls` (open sidebar + New Session, reusing the shell's inject face and locale) into it. The seat placement, the `--dsh-frame-leading-clearance` variable panels pad by, and the removal of the earlier conversation-header seat are owned by the [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md).
|
||||
|
||||
**Window fullscreen marker.** macOS fullscreen hides the traffic lights, so layouts built around them must relax. The main process relays `isFullScreen()` over `dsh-desktop:window-fullscreen` on `enter-full-screen`, `leave-full-screen`, and each `did-finish-load`; the app preload's `syncWindowFullscreen()` mirrors it onto `html[data-fullscreen]`. Consumers: the AppFrame drops `--dsh-frame-leading-clearance` to 84px and moves the seat to `left: 12px`, the sidebar's `.topStrip` moves the toggle to the strip's left edge, and the right sidebar's fullscreen panel resets its first pane's dockkit strip inset to the ordinary 10px.
|
||||
|
||||
**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so `-webkit-app-region: drag` is declared only inside the two allowlisted stylesheets: ui-layout `AppFrame.module.css` (the darwin `.leadingBand`, a 52px full-width `pointer-events: none` strip rendered as the frame's first child, and the Windows caption `.frame::before`) and ui-sidebar `SidebarRoot.module.css` (`.logoRow`, which extends the band below 52px). Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. Because the drag bands mount before all content, any later no-drag box wins; content containers never declare drag, which would override earlier-mounted overlays.
|
||||
**Drag regions.** Electron computes drag regions from window geometry in DOM order, not stacking, so the shell declares `-webkit-app-region: drag` once, over the mark a chrome row puts on its own box, each row's box being its draggable geometry. The ui-theme app-region gate's `CHROME_ROWS` manifest is the list of rows that must carry the mark, so this note keeps only the mechanism. The frame declares none — the Windows caption row is that platform's own chrome — so no fixed band has to match any row's height. Everything interactive subtracts itself through two global ui-web base.css rules: one `:is(...)` selector opting out every interactive element (buttons, links, inputs, roles, `[tabindex]`), and one `body > :not(#root)` rule covering every overlay portaled beside `#root`. The ordering rule those two rest on: a surface that covers the window and must stay clickable portals beside `#root` (the settings overlay does), which puts it after every drag row in document order; content containers never declare drag, which would override earlier-mounted overlays. The [coverage contract note](../architecture/2026-09-19-window-drag-coverage-contract.md) owns the manifest, the browser lane, and the manual state matrix.
|
||||
|
||||
**Fullscreen traffic-light clearance.** ui-dockkit publishes the tab strip's start inset as `--dsh-dockkit-strip-inline-start` (fallback the design's own 10px). The right sidebar's fullscreen presentation on darwin sets 88px on the panel body and resets 10px for every non-first split cell's subtree, so exactly the pane touching the window's top-left corner clears the lights at any split depth.
|
||||
|
||||
@@ -43,9 +43,9 @@ Every macOS web-side adjustment keys off `html[data-platform='darwin']`, which o
|
||||
- The macOS window gains a translucent sidebar and hidden titlebar at zero cost to other platforms: every rule is scoped to `[data-platform='darwin']`, which only the Electron preload sets.
|
||||
- The vibrancy material follows the app theme, including third-party registered themes (their resolved scheme). Screenshots and screen recordings differ from the flat web rendering.
|
||||
- The reopen controls occupy the frame's `shell.leading` seat, a public slot in the client catalog; the earlier `conversation.session.header.leading` seat is removed ([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.md)).
|
||||
- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only inside the two allowlisted stylesheets (ui-layout `AppFrame.module.css`: darwin `.leadingBand` and Windows caption `.frame::before`; ui-sidebar `SidebarRoot.module.css`: `.logoRow`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region stylesheet gate enforces the allowlist over every package stylesheet.
|
||||
- Drag-region ownership is a window-global invariant: `-webkit-app-region: drag` appears only in ui-web `base.css` (the one darwin rule over `data-window-drag`) and in ui-layout `AppFrame.module.css` (the Windows caption `.frame::before`); other packages rely on the base.css opt-outs or add a scoped `no-drag`, never a new drag surface. The ui-theme app-region gate pairs each chrome row's markup mark with its sheet, selector, and authored height, and refuses a mark anywhere else.
|
||||
- The transparent window plus vibrancy is accepted to look different in screen sharing and may flash on startup; the transparent `backgroundColor` mitigates the flash.
|
||||
|
||||
## Testing
|
||||
|
||||
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region style gate pins drag-region ownership to the two allowlisted stylesheets across every package stylesheet.
|
||||
ui-theme boot and ui-layout presenter specs pin the `data-ds-theme-source` publication and disposal. The ui-sidebar apply spec pins the `shell.leading` registration (component, locale, shared inject face) and its removal on teardown; the ui-layout app-frame spec pins the seat's darwin mount. The desktop main-startup spec pins the fullscreen relay (transitions, reloads, destroyed-window guard, darwin-only registration) and the preload-platform spec pins the `html[data-fullscreen]` mirror. The ui-theme corner-shape and full-round style gates cover the new stylesheet, and its app-region gate pins the one darwin drag rule and every chrome row's markup mark.
|
||||
@@ -18,11 +18,11 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
|
||||
|
||||
**原生主题同步。** 毛玻璃材质跟随 `nativeTheme.themeSource`,后者默认跟踪系统外观,会与应用自身的主题偏好背离。ui-theme 引导脚本与 ui-layout 的 `ThemePresenter` 发布 `html[data-ds-theme-source]`(`light`、`dark` 或 `system`;固定偏好——包括注册主题 id——发布其解析后的配色)。应用 preload 观察该属性并经 `dsh-desktop:native-theme-set` 转发;主进程校验取值与发送者(主窗口的 WebContents,包括其本地静态 Web 文档)后赋给 `nativeTheme.themeSource`。发布偏好而非解析值,可在偏好为 `system` 时保留跟随系统。
|
||||
|
||||
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;框架的 `.leadingBand` 拖拽带覆盖同一 52px 几何,顶部条自身不声明 app-region,按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。
|
||||
**侧边栏顶部条与完全隐藏。** darwin 上侧边栏展开时有一条 52px 的顶部条,避开红绿灯并承载收起按钮;顶部条自己打上 `data-window-drag`,于是它自身那 52px 盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),按钮经 base.css 交互规则自行减除。收起侧边栏时整列隐藏——`computeColumns` 接受显式 `collapsedWidth`,AppFrame 在 darwin 桌面传 0——而非其他平台保留的 56px rail。重新打开的入口位于框架 root 作用域的 `shell.leading` 窗口 chrome 座,由 AppFrame 仅在整列隐藏时挂载;ui-sidebar 向其注册 `HeaderLeadingControls`(打开侧边栏 + 新会话,复用 shell 的 inject face 与 locale)。座的位置、面板用于避让的 `--dsh-frame-leading-clearance` 变量,以及早先会话头部座的移除,归 [shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md) 所有。
|
||||
|
||||
**窗口全屏标记。** macOS 全屏隐藏红绿灯,围绕它们构建的布局必须放松。主进程在 `enter-full-screen`、`leave-full-screen` 与每次 `did-finish-load` 时经 `dsh-desktop:window-fullscreen` 转发 `isFullScreen()`;应用 preload 的 `syncWindowFullscreen()` 把它镜像到 `html[data-fullscreen]`。消费者:AppFrame 把 `--dsh-frame-leading-clearance` 降到 84px 并把座移到 `left: 12px`,侧边栏 `.topStrip` 把收起按钮移到条带左端,右侧边栏全屏面板把首格 pane 的 dockkit strip 内边距重置回常规 10px。
|
||||
|
||||
**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此 `-webkit-app-region: drag` 只在两个允许清单样式表内声明:ui-layout `AppFrame.module.css`(darwin 的 `.leadingBand`——52px 全宽、`pointer-events: none`、作为框架第一个子元素渲染的条带——与 Windows 顶栏 `.frame::before`)和 ui-sidebar `SidebarRoot.module.css`(`.logoRow`,把拖拽带向 52px 以下延伸)。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。拖拽带先于全部内容挂载,任何靠后的 no-drag 盒子必然生效;内容容器永不声明 drag——那会覆盖更早挂载的浮层。
|
||||
**拖拽区。** Electron 按 DOM 顺序以窗口几何计算拖拽区,不看层叠,因此外壳只声明一次 `-webkit-app-region: drag`,作用于 chrome 行打在自身盒子上的标记,行自身的盒子就是它的可拖几何;哪些行必须带这个标记由 ui-theme app-region 门禁的 `CHROME_ROWS` 清单拥有,本 note 只保留机制。框架不再声明任何 drag——Windows 标题栏行是那个平台自己的 chrome——因此没有一条固定带子需要匹配某一行的高度。所有可交互内容通过 ui-web base.css 的两条全局规则自行减除:一条 `:is(...)` 选择器让每个交互元素(按钮、链接、输入框、role、`[tabindex]`)退出拖拽,一条 `body > :not(#root)` 规则覆盖所有 portal 到 `#root` 之外的浮层。这两条规则依赖的顺序约定:覆盖窗口且必须可点的表面 portal 到 `#root` 之外(settings 浮层即如此),从而在文档顺序上晚于所有 drag 行;内容容器永不声明 drag——那会覆盖更早挂载的浮层。清单、浏览器车道与人工状态矩阵归 [覆盖契约 note](../architecture/2026-09-19-window-drag-coverage-contract.zh.md)。
|
||||
|
||||
**全屏避开红绿灯。** ui-dockkit 把 tab 条起始内边距发布为 `--dsh-dockkit-strip-inline-start`(回退为设计自身的 10px)。右侧边栏全屏形态在 darwin 上对面板主体设 88px,并对每个非首格 split 单元的子树重置为 10px,使得任意分屏深度下恰好只有触及窗口左上角的 pane 避开红绿灯。
|
||||
|
||||
@@ -43,9 +43,9 @@ Electron 主进程在 darwin 上以 `titleBarStyle: 'hiddenInset'`、`trafficLig
|
||||
- macOS 窗口获得半透明侧边栏与隐藏标题栏,对其他平台零成本:所有规则限定在 `[data-platform='darwin']` 下,该属性仅由 Electron preload 设置。
|
||||
- 毛玻璃材质跟随应用主题,含第三方注册主题(取其解析配色)。截图与录屏与纯 Web 的平面渲染不同。
|
||||
- 重新打开控件占据框架的 `shell.leading` 座,是客户端 catalog 中的公开 slot;早先的 `conversation.session.header.leading` 座已移除([shell.leading Agent Note](../architecture/2026-09-17-frame-shell-leading-window-chrome-seat.zh.md))。
|
||||
- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在两个允许清单样式表内(ui-layout `AppFrame.module.css`:darwin `.leadingBand` 与 Windows 顶栏 `.frame::before`;ui-sidebar `SidebarRoot.module.css`:`.logoRow`);其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 样式门禁对全部包内样式表强制该允许清单。
|
||||
- 拖拽区归属是窗口级全局不变量:`-webkit-app-region: drag` 只出现在 ui-web `base.css`(作用于 `data-window-drag` 的唯一 darwin 规则)与 ui-layout `AppFrame.module.css`(Windows 顶栏 `.frame::before`)内;其他包依赖 base.css 的退出规则或添加限定范围的 `no-drag`,永不新增拖拽面。ui-theme 的 app-region 门禁把每个 chrome 行的 markup 标记与它的样式表、选择器和写死高度配成一条,并拒绝别处的标记。
|
||||
- 接受透明窗口 + 毛玻璃在屏幕共享中呈现不同、启动可能闪烁;透明 `backgroundColor` 缓解闪烁。
|
||||
|
||||
## Testing
|
||||
|
||||
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 样式门在全部包内样式表上把拖拽区归属钉在两个允许清单样式表内。
|
||||
ui-theme 引导与 ui-layout presenter 测试钉住 `data-ds-theme-source` 的发布与清除。ui-sidebar apply 测试钉住 `shell.leading` 注册(组件、locale、共享 inject face)及 teardown 移除;ui-layout 的 app-frame 测试钉住 darwin 下座的挂载。desktop main-startup 测试钉住全屏转发(状态切换、重载、已销毁窗口守卫、仅 darwin 注册),preload-platform 测试钉住 `html[data-fullscreen]` 镜像。ui-theme 的 corner-shape 与 full-round 样式门覆盖新样式表,其 app-region 门禁钉住唯一那条 darwin drag 规则与每个 chrome 行的 markup 标记。
|
||||
@@ -0,0 +1,497 @@
|
||||
// Browser coverage for the macOS window drag surface: which chrome rows drag,
|
||||
// which pixels stay content, and that no control is swallowed by the band.
|
||||
//
|
||||
// The lane boots the real composition in Chromium and marks the document root
|
||||
// darwin — the one platform where these rules exist (ui-web base.css and
|
||||
// ui-layout AppFrame scope every app-region rule to it). CDP-injected clicks
|
||||
// cannot decide this class: the swallow happens in Electron's native window, so
|
||||
// the claims here are geometry claims decided by the shared composition model
|
||||
// (ui-web window-drag/regions.ts).
|
||||
//
|
||||
// Probes are anchored to stable data hooks and derived from live rects, so a
|
||||
// layout change moves them rather than invalidating them. The ui-theme app-region
|
||||
// gate holds the other half of the claim: it pins each row's mark, its sheet, and
|
||||
// its authored height, and refuses a `data-window-drag` mark anywhere else. A
|
||||
// probe whose expectation is not yet met carries `gap` and fails loudly, so a
|
||||
// recorded gap shrinks here first and the manifest follows.
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INTERACTIVE_SELECTOR, RECALL_MARK, isDraggableAt, type RegionRect,
|
||||
} from '@deepseek-ai/dsh-client-web/src/window-drag/regions.ts'
|
||||
import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
/** One collected box: its app-region value, whether it is interactive, and where it is. */
|
||||
interface CollectedRegion extends RegionRect {
|
||||
/** Whether the element itself matches the interactive selector. */
|
||||
readonly interactive: boolean
|
||||
/** Sample points at which the browser's hit test reaches this element or its content. */
|
||||
readonly hits: readonly (readonly [number, number])[]
|
||||
}
|
||||
|
||||
/** A point the coverage table claims something about. */
|
||||
interface CoverageProbe {
|
||||
/** Row the probe speaks for, for the failure message. */
|
||||
readonly row: string
|
||||
/** What the point is, for the failure message. */
|
||||
readonly what: string
|
||||
/** Resolves the point from the live rects captured for the state under test. */
|
||||
readonly at: () => readonly [number, number]
|
||||
/** Whether the window would drag there. */
|
||||
readonly drag: boolean
|
||||
/** Set when the expectation is a recorded gap the refactor must close. */
|
||||
readonly gap?: string
|
||||
}
|
||||
|
||||
/** A viewport rect. */
|
||||
interface Rect {
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly width: number
|
||||
readonly height: number
|
||||
}
|
||||
|
||||
const EMPTY: Rect = { x: 0, y: 0, width: 0, height: 0 }
|
||||
|
||||
/** A browser-half bundle whose row registers configuration into the plugin page. */
|
||||
const FIXTURE_PLUGINS = fileURLToPath(new URL('./fixtures/plugins', import.meta.url))
|
||||
|
||||
/** Read one hook's viewport rect, or a zero rect while it is not rendered. */
|
||||
async function rectOf(page: Page, selector: string): Promise<Rect> {
|
||||
const locator = page.locator(selector).first()
|
||||
if (await locator.count() === 0) return EMPTY
|
||||
return locator.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every visible app-region box in DOM order.
|
||||
* @param page - the page under test.
|
||||
* @returns the boxes, in document order, with the interactive flag of each element.
|
||||
*/
|
||||
async function collectedRegions(page: Page, selector: string): Promise<CollectedRegion[]> {
|
||||
return page.evaluate((interactiveSelector) => {
|
||||
const collected: {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
draggable: boolean
|
||||
interactive: boolean
|
||||
hits: [number, number][]
|
||||
}[] = []
|
||||
for (const element of Array.from(document.querySelectorAll('*'))) {
|
||||
const style = getComputedStyle(element)
|
||||
const region = style.getPropertyValue('-webkit-app-region')
|
||||
if (region !== 'drag' && region !== 'no-drag') continue
|
||||
if (style.visibility === 'hidden' || style.display === 'none') continue
|
||||
const rect = element.getBoundingClientRect()
|
||||
if (rect.width === 0 || rect.height === 0) continue
|
||||
const interactive = element.matches(interactiveSelector)
|
||||
const insetX = Math.min(2, rect.width / 4)
|
||||
const insetY = Math.min(2, rect.height / 4)
|
||||
const samples: [number, number][] = [
|
||||
[rect.x + rect.width / 2, rect.y + rect.height / 2],
|
||||
[rect.x + insetX, rect.y + insetY],
|
||||
[rect.x + rect.width - insetX, rect.y + insetY],
|
||||
[rect.x + insetX, rect.y + rect.height - insetY],
|
||||
[rect.x + rect.width - insetX, rect.y + rect.height - insetY],
|
||||
]
|
||||
const hits = interactive
|
||||
? samples.filter(([x, y]) => {
|
||||
const top = document.elementFromPoint(x, y)
|
||||
return top !== null && (top === element || element.contains(top))
|
||||
})
|
||||
: []
|
||||
collected.push({
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
draggable: region === 'drag',
|
||||
interactive,
|
||||
hits,
|
||||
})
|
||||
}
|
||||
return collected
|
||||
}, selector)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive boxes whose press cannot reach the page, as printable descriptions.
|
||||
* A sample counts only where the browser's own hit test reaches the control: a
|
||||
* box clipped away by its column (the collapsed sidebar's hidden controls sit at
|
||||
* negative x) is not hittable anywhere it overlaps a drag row, and a press there
|
||||
* belongs to whatever is painted.
|
||||
* @param regions - collected boxes in DOM order.
|
||||
* @returns one description per interactive box with a hittable sample inside the drag surface.
|
||||
*/
|
||||
function swallowedBoxes(regions: readonly CollectedRegion[]): string[] {
|
||||
return regions
|
||||
.filter(region => region.interactive)
|
||||
.filter(region => region.hits.some(([x, y]) => isDraggableAt(regions, x, y)))
|
||||
.map(region => `${Math.round(region.x)},${Math.round(region.y)} ${Math.round(region.width)}x${Math.round(region.height)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settle when the shell first pulses the drag recall mark, or false when 15s pass
|
||||
* without one. Start it before the gesture under test and await it after.
|
||||
* @param page - the page under test.
|
||||
* @returns the pending observation of the first pulse.
|
||||
*/
|
||||
function firstRecallPulse(page: Page): Promise<boolean> {
|
||||
return page.evaluate((mark: string) => new Promise<boolean>((resolve) => {
|
||||
const observer = new MutationObserver((records) => {
|
||||
const pulsed = records.some(record => record.attributeName === mark
|
||||
&& (record.target as Element).hasAttribute(mark))
|
||||
if (!pulsed) return
|
||||
observer.disconnect()
|
||||
resolve(true)
|
||||
})
|
||||
observer.observe(document.body, { attributes: true, attributeFilter: [mark] })
|
||||
setTimeout(() => {
|
||||
observer.disconnect()
|
||||
resolve(false)
|
||||
}, 15_000)
|
||||
}), RECALL_MARK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until nothing under the page is animating, so a probe measures the settled
|
||||
* layout instead of a frame of a column slide.
|
||||
* @param page - the page under test.
|
||||
*/
|
||||
async function settled(page: Page): Promise<void> {
|
||||
await expect.poll(() => page.evaluate(() => document.getAnimations()
|
||||
.every(animation => animation.playState === 'finished' || animation.playState === 'idle')),
|
||||
{ timeout: 20_000 }).toBe(true)
|
||||
}
|
||||
|
||||
describe('web e2e: macOS window drag coverage', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
|
||||
beforeAll(async () => {
|
||||
// A manageable profile: the plugin page draws its card list — and so its
|
||||
// detail views — only when the deployment runs a profile it can manage.
|
||||
scaffold = await launchWebScaffold({
|
||||
profile: { packages: [{ dir: join(FIXTURE_PLUGINS, 'fixture-live-client') }] },
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
}, 180_000)
|
||||
|
||||
afterAll(async () => {
|
||||
try { await browser?.close() }
|
||||
finally { await scaffold?.close() }
|
||||
})
|
||||
|
||||
/**
|
||||
* Open a darwin-marked page in the shipped composition.
|
||||
* @returns the page and its console tripwire.
|
||||
*/
|
||||
async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType<typeof watchConsole> }> {
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.addInitScript((value: string) => {
|
||||
const mark = (): void => { document.documentElement.setAttribute('data-platform', value) }
|
||||
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
|
||||
else mark()
|
||||
}, 'darwin')
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
return { page, tripwire }
|
||||
}
|
||||
|
||||
it('leaves no interactive box inside the drag surface, and keeps the panel strip row draggable', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
try {
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const agent = scaffold.ctx.agents.list()[0]
|
||||
if (agent === undefined) throw new Error('connected workspace did not create an Agent')
|
||||
agent.session.append('turn/start', { turn: 1 })
|
||||
const pulsing = firstRecallPulse(page)
|
||||
await page.locator('[data-sidebar-right-expand]').click()
|
||||
await page.locator('[data-sidebar-right-panel][data-sidebar-right-open]').waitFor({ timeout: 15_000 })
|
||||
// The panel slides in from the frame's right edge; probing before the slide
|
||||
// settles measures the hidden position, where the band does not reach.
|
||||
await expect.poll(async () => page.locator('[data-sidebar-right-panel]').evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
return Math.round(innerWidth - rect.right) === 0
|
||||
&& element.getAnimations({ subtree: true })
|
||||
.every(animation => animation.playState === 'finished' || animation.playState === 'idle')
|
||||
}), { timeout: 15_000 }).toBe(true)
|
||||
|
||||
// The shell's watcher, not the panel, is what keeps the native drag rects in
|
||||
// step with the slide: it must have pulsed while the panel moved and must be
|
||||
// settled now — a mark left set would subtract the whole surface.
|
||||
expect(await pulsing, 'the shell pulses the drag recall mark while the panel slides').toBe(true)
|
||||
// The watcher clears the mark on the frame after the geometry settles.
|
||||
await expect.poll(
|
||||
() => page.evaluate((mark: string) => document.body.hasAttribute(mark), RECALL_MARK),
|
||||
{ timeout: 5_000 },
|
||||
).toBe(false)
|
||||
|
||||
const regions = await collectedRegions(page, INTERACTIVE_SELECTOR)
|
||||
const strip = await rectOf(page, '[data-dockkit-strip]')
|
||||
const panel = await rectOf(page, '[data-sidebar-right-panel]')
|
||||
const tabs = await rectOf(page, '[data-conversation-tabs]')
|
||||
const header = await rectOf(page, '[data-slot="conversation.header"] > header')
|
||||
const seat = await rectOf(page, '[data-shell-leading]')
|
||||
// The sidebar column drags through its own rows (ui-sidebar), so its
|
||||
// coverage is the rows' geometry rather than a frame band's height.
|
||||
const toggle = await rectOf(page, 'button[aria-label="Collapse sidebar"]')
|
||||
|
||||
const probes: CoverageProbe[] = [
|
||||
{
|
||||
row: 'right sidebar panel strip',
|
||||
what: 'the strip row top inset, above the controls',
|
||||
at: () => [strip.x + strip.width / 2, strip.y + 4],
|
||||
drag: true,
|
||||
},
|
||||
{
|
||||
row: 'right sidebar panel strip',
|
||||
what: 'the run below the strip row, over the pane body',
|
||||
at: () => [panel.x + panel.width / 2, strip.y + strip.height + 4],
|
||||
drag: false,
|
||||
},
|
||||
{
|
||||
row: 'conversation header',
|
||||
what: 'the blank run right of the view tabs, on the tab strip row',
|
||||
at: () => [tabs.x + tabs.width + 8, tabs.y + tabs.height / 2],
|
||||
drag: true,
|
||||
},
|
||||
{
|
||||
row: 'conversation header',
|
||||
what: 'the title row blank run',
|
||||
at: () => [header.x + header.width / 2, header.y + 4],
|
||||
drag: true,
|
||||
},
|
||||
{
|
||||
row: 'conversation header',
|
||||
what: 'the transcript run below the header block',
|
||||
at: () => [header.x + header.width / 2, header.y + header.height + 4],
|
||||
drag: false,
|
||||
},
|
||||
]
|
||||
if (toggle.width > 0) {
|
||||
probes.push(
|
||||
{
|
||||
row: 'sidebar top strip',
|
||||
what: 'the blank run left of the collapse toggle',
|
||||
at: () => [toggle.x - 40, toggle.y + toggle.height / 2],
|
||||
drag: true,
|
||||
},
|
||||
{
|
||||
row: 'sidebar column',
|
||||
what: 'the content run below the chrome rows',
|
||||
at: () => [toggle.x - 40, toggle.y + toggle.height + 70],
|
||||
drag: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
if (seat.width > 0) {
|
||||
probes.push({
|
||||
row: 'shell.leading seat',
|
||||
what: 'the row of window-chrome controls',
|
||||
at: () => [seat.x + 4, seat.y + seat.height / 2],
|
||||
drag: false,
|
||||
})
|
||||
}
|
||||
|
||||
for (const probe of probes) {
|
||||
const [x, y] = probe.at()
|
||||
const expected = `${probe.row}: ${probe.what} at ${Math.round(x)},${Math.round(y)}`
|
||||
+ (probe.gap === undefined ? '' : ` (recorded gap — ${probe.gap})`)
|
||||
expect(isDraggableAt(regions, x, y), expected).toBe(probe.drag)
|
||||
}
|
||||
|
||||
// The invariant that has no gap budget: a press inside any control box must
|
||||
// reach the page instead of dragging the window.
|
||||
expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([])
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, 'window-drag-coverage')
|
||||
throw error
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 180_000)
|
||||
|
||||
it('keeps the Conversation header draggable over the collapsed column, and the seat out of it', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
||||
await page.locator('[data-shell-leading]').waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await settled(page)
|
||||
const regions = await collectedRegions(page, INTERACTIVE_SELECTOR)
|
||||
const header = await rectOf(page, '[data-slot="conversation.header"] > header')
|
||||
const seat = await rectOf(page, '[data-shell-leading]')
|
||||
|
||||
// With the column hidden the centre starts at the frame's left edge, where
|
||||
// the Conversation header owns the run.
|
||||
expect(
|
||||
isDraggableAt(regions, header.x + 40, header.y + 4),
|
||||
'the Conversation header over the frame’s left edge',
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDraggableAt(regions, seat.x + seat.width / 2, seat.y + seat.height / 2),
|
||||
'the seat holding the reopen controls',
|
||||
).toBe(false)
|
||||
|
||||
expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, 'window-drag-coverage-collapsed')
|
||||
throw error
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('keeps an entry page’s chrome row draggable', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
const head = page.locator('[class*="pageHead"]').first()
|
||||
await head.waitFor({ timeout: 15_000 })
|
||||
await settled(page)
|
||||
const rect = await head.evaluate((element) => {
|
||||
const box = element.getBoundingClientRect()
|
||||
return { x: box.x, y: box.y, width: box.width, height: box.height }
|
||||
})
|
||||
|
||||
const regions = await collectedRegions(page, INTERACTIVE_SELECTOR)
|
||||
// The row starts at the window's top edge: a page inset above it would be a strip
|
||||
// no drag region covers, which is the dead band at the very top of this page.
|
||||
expect(rect.y, 'the page head starts at the window’s top edge').toBeLessThanOrEqual(0.5)
|
||||
// The page head owns its run, clearance included: that is the entry page's
|
||||
// window strip, with no frame band behind it.
|
||||
expect(
|
||||
isDraggableAt(regions, rect.x + 40, rect.y + 8),
|
||||
'the plugin manager page head',
|
||||
).toBe(true)
|
||||
expect(
|
||||
isDraggableAt(regions, rect.x + 40, rect.y + rect.height + 40),
|
||||
'the page body below the head',
|
||||
).toBe(false)
|
||||
|
||||
expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, 'window-drag-coverage-entry-page')
|
||||
throw error
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('keeps a detail page’s head row draggable and its body out of the drag surface', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
const cards = page.locator('[data-plugin-package], [data-plugin-item]')
|
||||
await cards.first().waitFor({ timeout: 30_000 })
|
||||
await settled(page)
|
||||
// The card's head carries the one control that opens its page.
|
||||
await cards.first().locator('button').first().click()
|
||||
// The mark sits on the head row the detail views share, not on the detail
|
||||
// container: marking the container would drag the window over its text and
|
||||
// form labels wherever no control covers them.
|
||||
const detail = page.locator('[data-plugin-item-detail], [data-plugin-detail], [data-plugin-row-detail]').first()
|
||||
await detail.waitFor({ timeout: 15_000 })
|
||||
const geometry = await detail.evaluate((element) => {
|
||||
const detailBox = element.getBoundingClientRect()
|
||||
const headBox = element.firstElementChild?.getBoundingClientRect()
|
||||
return {
|
||||
detail: { x: detailBox.x, y: detailBox.y, width: detailBox.width, height: detailBox.height },
|
||||
head: headBox === undefined
|
||||
? null
|
||||
: { x: headBox.x, y: headBox.y, width: headBox.width, height: headBox.height },
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
}
|
||||
})
|
||||
if (geometry.head === null) throw new Error('the detail view rendered no head row')
|
||||
const { detail: detailRect, head: headRect, viewport } = geometry
|
||||
const regions = await collectedRegions(page, INTERACTIVE_SELECTOR)
|
||||
|
||||
// The head's top inset is the window clearance above the crumb: blank, so
|
||||
// it drags like the entry page's head. The row also starts at the window's top
|
||||
// edge, or the strip above it belongs to no drag region.
|
||||
expect(headRect.y, 'the detail head row starts at the window’s top edge').toBeLessThanOrEqual(0.5)
|
||||
expect(
|
||||
isDraggableAt(regions, headRect.x + headRect.width / 2, headRect.y + 4),
|
||||
'the detail head row’s top inset',
|
||||
).toBe(true)
|
||||
|
||||
// Below the head, a point that no control covers is content: pressing the
|
||||
// description, a label, or a gap in the form must reach the page.
|
||||
const interactive = regions.filter(region => region.interactive)
|
||||
const bodyTop = headRect.y + headRect.height
|
||||
const bodyBottom = Math.min(detailRect.y + detailRect.height, viewport.height - 4)
|
||||
const dragging: string[] = []
|
||||
for (let row = 0; row < 8; row += 1) {
|
||||
for (let column = 0; column < 5; column += 1) {
|
||||
const x = detailRect.x + (detailRect.width * (column + 0.5)) / 5
|
||||
const y = bodyTop + ((bodyBottom - bodyTop) * (row + 0.5)) / 8
|
||||
if (y <= bodyTop || y > viewport.height - 4 || x > viewport.width - 4) continue
|
||||
if (interactive.some(region => x >= region.x && x < region.x + region.width
|
||||
&& y >= region.y && y < region.y + region.height)) continue
|
||||
if (isDraggableAt(regions, x, y)) dragging.push(`${Math.round(x)},${Math.round(y)}`)
|
||||
}
|
||||
}
|
||||
expect(dragging, 'content points inside the detail body that drag the window').toEqual([])
|
||||
|
||||
expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, 'window-drag-coverage-detail-page')
|
||||
throw error
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 120_000)
|
||||
|
||||
it('keeps a covering overlay out of the drag surface', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
try {
|
||||
await page.locator('button[aria-haspopup="dialog"]').first().click()
|
||||
const dialog = page.locator('[role="dialog"]').first()
|
||||
await dialog.waitFor({ timeout: 15_000 })
|
||||
await settled(page)
|
||||
const layer = await dialog.evaluate((element) => {
|
||||
const overlay = element.parentElement
|
||||
const rect = overlay?.getBoundingClientRect()
|
||||
return {
|
||||
parentIsBody: overlay?.parentElement === document.body,
|
||||
coversWindow: rect !== undefined && rect.top <= 0 && rect.bottom >= innerHeight,
|
||||
}
|
||||
})
|
||||
// A covering surface inside #root precedes the columns' chrome, so a drag
|
||||
// row declared later would override its subtraction; beside the root,
|
||||
// ui-web base.css's body rule subtracts it instead.
|
||||
expect(layer.parentIsBody, 'the covering overlay portals beside #root').toBe(true)
|
||||
expect(layer.coversWindow).toBe(true)
|
||||
|
||||
const regions = await collectedRegions(page, INTERACTIVE_SELECTOR)
|
||||
const header = await rectOf(page, '[data-slot="conversation.header"] > header')
|
||||
expect(
|
||||
isDraggableAt(regions, header.x + header.width / 2, 8),
|
||||
'the window top behind an open covering overlay',
|
||||
).toBe(false)
|
||||
expect(swallowedBoxes(regions), 'interactive boxes inside the drag surface').toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, 'window-drag-coverage-overlay')
|
||||
throw error
|
||||
} finally {
|
||||
await page.close()
|
||||
}
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -161,6 +161,7 @@
|
||||
"tests/chat-long-interactions.e2e.ts",
|
||||
"tests/chat-continuous-conversation.e2e.ts",
|
||||
"tests/composer-tab-geometry.e2e.ts",
|
||||
"tests/window-drag-coverage.e2e.ts",
|
||||
"tests/complex-history.perf.ts",
|
||||
"tests/pwsh-terminal.e2e.ts",
|
||||
"tests/workflow-run.e2e.ts"
|
||||
|
||||
@@ -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-conversation/README.md
|
||||
README.md: 153c505250f5654f0aeade23c221e19554294641
|
||||
README.zh.md: c849b1bdfd033e07cac382c4b62cd0df35333c8e
|
||||
README.md: 4283d902f5be2328d08e7bd0d00c64b0835411e9
|
||||
README.zh.md: 72493ef348413c4051509cff245bcb665187b93a
|
||||
@@ -58,7 +58,7 @@ Workspace selection uses `uiWorkspace.openWorkspace` to prepare the target and c
|
||||
|
||||
The package occupies the root-scoped `main` key `conversation`. Its `main.conversation` shell keeps the resident `conversation.header` outside the optional-Session `conversation.content` Component Factory. The header hosts root-scoped navigation without a selected Session; titles, actions, and View tabs remain in its strict Session child. The Factory owns the shared body and Composer, reads the current Session through its standard Hook, and exposes strict-Session `views` plus root-scoped `widthControls` local positions. Its default adapter renders the existing `conversation.session` entry, while the main occurrence selects the width handles; an embedded occurrence can replace `views` and omit those handles without rendering the main Header. The shared body and Composer register the queue and Todo docks. The Todo dock uses the shared panel elevation above the composer; its rows use the shared idle, ongoing, and done markers for pending, in-progress, and completed items. `ctx.uiSession.provide()` materializes the Conversation and input sources from the same Session binding and supplies `inputActions` as a stable standard prop.
|
||||
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident container reserves a 40px drag band on macOS desktop and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered.
|
||||
A blank Session retains the header's leading and corner controls, including the right-sidebar opener, while hiding its title, actions, utilities, and View tabs. Selecting a Workspace creates the Session needed by these controls; the first message is not required. Without a selected Session, the strict header is absent; the resident header keeps a 40px window strip on macOS desktop that it owns as its drag row, and no empty vertical space on Web, Windows, or Linux. Sidebar entries retain their own data and execution prerequisites. A started Session header uses one row when fewer than two Views are available; the tab row reserves space only while rendered.
|
||||
|
||||
View selection is deterministic: a registered persisted selection wins, otherwise registered `chat` wins, otherwise no View renders. It never chooses the first registered View. Shell phase combines Session lifecycle with the active-target set; no target-specific snapshot is read by the shell.
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ target package 通过 declaration merge 扩展 snapshot 与 Location data map,
|
||||
|
||||
本包占据 root 作用域 `main` 中的 `conversation` key。其 `main.conversation` 外壳将常驻的 `conversation.header` 放在可选 Session 的 `conversation.content` Component Factory 外。未选中 Session 时,头部仍承载根作用域导航;标题、操作和 View 标签保留在严格 Session 子组件中。Factory 拥有共享正文与 Composer,通过其标准 Hook 读取当前 Session,并公开 strict-Session `views` 与 root-scoped `widthControls` 两个局部位置。默认 adapter 渲染现有 `conversation.session` entry,主 occurrence 选择宽度拖拽条;嵌入式 occurrence 可以替换 `views`、省略拖拽条,且不渲染主 Header。共享正文与 Composer 注册 queue dock 和 Todo dock。Todo dock 在 composer 上方使用共享面板 elevation;其中的行分别以共享 idle、ongoing 与 done 标记表示待处理、进行中与已完成。`ctx.uiSession.provide()` 从同一个 Session binding 物化 Conversation 与 input source,并将 `inputActions` 作为稳定标准 prop 提供。
|
||||
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻容器在 macOS 桌面保留 40px 拖拽区域,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。
|
||||
blank Session 保留 header 的 leading 与 corner 控件,包括右侧栏展开入口,同时隐藏标题、actions、utilities 和 View tabs。选择 Workspace 会创建这些控件所需的 Session,无需先发送消息。没有选中 Session 时,strict header 不挂载;常驻 header 在 macOS 桌面保留 40px 的窗口顶条,由它自己作为拖拽行持有,在 Web、Windows 或 Linux 上不预留空白高度。侧栏各入口仍遵循自身的数据与执行环境要求。 已开始的 Session 在可用 View 少于两个时使用单行标题栏,仅在渲染标签行时保留其高度。
|
||||
|
||||
View 选择规则固定:有效且已注册的持久化选择优先,其次是已注册的 `chat`,否则不渲染 View;绝不选择第一个已注册 View。Shell phase 只组合 Session lifecycle 与 active-target set,不读取任何 target-specific 快照。
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ export function ConversationHeader({ sessionId, useSession, useConversation, ren
|
||||
const blank = session === undefined || conversation === undefined
|
||||
|| (session.blank && conversationPhase(session, conversation) === 'blank')
|
||||
return (
|
||||
<header className={clsx(css.header, blank && css.headerBlank, sessionId === undefined && css.headerSessionless)}>
|
||||
<header className={clsx(css.header, blank && css.headerBlank, sessionId === undefined && css.headerSessionless)} data-window-drag>
|
||||
<div className={css.headerLeading} data-conversation-header-leading="">
|
||||
{renderSlot('conversation.header.leading', {})}
|
||||
</div>
|
||||
|
||||
@@ -47,13 +47,13 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* macOS hiddenInset titlebar: window dragging comes from ui-layout's
|
||||
.leadingBand (the frame's first child), never from this header. Electron
|
||||
composes app-regions in document order ignoring stacking, so a drag rule
|
||||
here would keep eating clicks through overlays mounted earlier in the DOM
|
||||
(the settings modal lives in the sidebar column). The title-row clusters
|
||||
overlap the band and subtract their whole boxes; individual controls are
|
||||
covered by the base.css interactive-element rule. */
|
||||
/* macOS hiddenInset titlebar: the header block owns the window drag over its
|
||||
own box — title row plus the view tab strip, so the draggable geometry is the
|
||||
header's geometry and no frame band has to match its height. The header marks
|
||||
itself `data-window-drag` (ConversationHeader); below it the title-row clusters
|
||||
subtract their whole boxes, and individual controls are covered by the base.css
|
||||
interactive-element rule. A covering overlay lives beside #root
|
||||
(ui-settings-general), which puts it after this row in document order. */
|
||||
:global([data-platform='darwin']) .headerLeading,
|
||||
:global([data-platform='darwin']) .headerActions,
|
||||
:global([data-platform='darwin']) .headerUtilities,
|
||||
|
||||
@@ -75,7 +75,7 @@ export function ConversationSessionHeader({
|
||||
const last = index === ancestry.length - 1
|
||||
// The current crumb has no navigation, so it is plain text
|
||||
// rather than a disabled button: on darwin desktop a button
|
||||
// would subtract itself from the window drag band (ui-web
|
||||
// would subtract itself from the header's drag row (ui-web
|
||||
// base.css) and leave the title inert for dragging too.
|
||||
const title = last
|
||||
? (
|
||||
@@ -138,8 +138,8 @@ export function ConversationSessionHeader({
|
||||
</div>
|
||||
</div>
|
||||
{showTabs && (
|
||||
// data-conversation-tabs: ui-layout's window drag band matches this
|
||||
// marker (:has) to deepen only while the tab strip adds header height.
|
||||
// data-conversation-tabs: marks the tab strip, which the window-chrome
|
||||
// geometry and the browser coverage lane anchor on.
|
||||
<div className={css.tabs} role="tablist" data-conversation-tabs="">
|
||||
{tabs.map(viewTab => (
|
||||
<button
|
||||
|
||||
@@ -652,7 +652,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(b.view.queryByTestId('view-new-view')).toBeNull()
|
||||
expect(b.view.getByRole('tab', { name: 'Chat' }).getAttribute('aria-selected')).toBe('true')
|
||||
expect(b.view.getByRole('tab', { name: 'New view' }).getAttribute('aria-selected')).toBe('false')
|
||||
// ui-layout's window drag band deepens by matching this marker (:has).
|
||||
// The browser drag lane anchors its header tab-strip probe on this marker.
|
||||
expect(b.view.getByRole('tablist').hasAttribute('data-conversation-tabs')).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
@@ -238,7 +238,12 @@ export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.tabStrip} role="tablist" data-dockkit-strip={pane.id}>
|
||||
// The window drag claim is withdrawn while any pane floats: a floating pane
|
||||
// renders among this surface's cells in tab-id order, so one sorted before this
|
||||
// row would sit earlier in the document, where this row's drag box overrides the
|
||||
// float's own subtraction and swallows the panel's header and controls.
|
||||
<div className={css.tabStrip} role="tablist" data-dockkit-strip={pane.id}
|
||||
data-window-drag={state.floats.length === 0 ? true : undefined}>
|
||||
<div ref={stripTabs} className={css.stripTabs} role="presentation" data-dockkit-strip-tabs={pane.id}>
|
||||
{pane.tabs.map((tabId, index) => {
|
||||
const tab = getTab(state, tabId)
|
||||
|
||||
@@ -297,16 +297,17 @@
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
/* macOS desktop: an embedder may pin this surface under the window's top drag
|
||||
band (ui-layout .leadingBand — full-window geometry that Electron composes
|
||||
in DOM order, not by stacking). The chip run, the strip-end chrome, the
|
||||
pane body, and the split divider subtract their whole boxes so tabs,
|
||||
controls, content, and the resize grab stay usable even where they overlap
|
||||
the band; the strip's blank fill and top inset keep the band's drag, so the
|
||||
row moves the window like a native tab bar. The divider must be named here:
|
||||
it is a plain div, so ui-web base.css's interactive-element subtraction
|
||||
never matches it. Hidden surfaces contribute nothing: Blink skips
|
||||
non-visible boxes when it collects draggable regions. */
|
||||
/* macOS desktop: an embedder may pin this surface at the window's top edge (the
|
||||
right sidebar does, in both presentations). The strip row is marked
|
||||
`data-window-drag` in markup (TabPanel), so ui-web base.css's single darwin
|
||||
rule drags it over its own box; the chip run, the strip-end chrome, the pane
|
||||
body, and the split divider subtract their whole boxes so tabs, controls,
|
||||
content, and the resize grab stay usable wherever they overlap that row. The
|
||||
divider must be named here: it is a plain div, so ui-web base.css's
|
||||
interactive-element subtraction never matches it. A floating pane subtracts its
|
||||
own box the same way, so the strip row never drags the window over it. Hidden
|
||||
surfaces contribute nothing: Blink skips non-visible boxes when it collects
|
||||
draggable regions. */
|
||||
:global(html[data-platform='darwin']) .stripTabs,
|
||||
:global(html[data-platform='darwin']) .stripChrome,
|
||||
:global(html[data-platform='darwin']) .paneBody,
|
||||
|
||||
@@ -1,28 +1,48 @@
|
||||
/**
|
||||
* macOS drag-band subtraction, asserted against dockkit.module.css on disk:
|
||||
* an embedder may pin the surface under the window's top drag band, and
|
||||
* Electron composes app-regions from geometry in DOM order — every box that
|
||||
* must stay usable subtracts itself. The divider must be listed explicitly:
|
||||
* it is a plain div, so ui-web base.css's interactive-element subtraction
|
||||
* never matches it, and an unsubtracted vertical divider's top run would
|
||||
* drag the window instead of resizing the split.
|
||||
* macOS app-region subtraction, asserted against dockkit.module.css on disk: an
|
||||
* embedder may pin the surface at the window's top edge (the right sidebar does),
|
||||
* so the strip row marks itself `data-window-drag` in markup (TabPanel) and ui-web
|
||||
* base.css drags the row over its own box. Electron composes app-regions from
|
||||
* geometry in DOM order — every box that must stay usable subtracts itself from
|
||||
* that row. The divider must be listed explicitly: it is a plain div, so base.css's
|
||||
* interactive-element subtraction never matches it, and an unsubtracted vertical
|
||||
* divider's top run would drag the window instead of resizing the split.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
// Comments are stripped so a rule's captured selector is exactly its selector.
|
||||
const css = readFileSync(resolve(import.meta.dirname, '../src/components/dockkit.module.css'), 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
||||
|
||||
describe('macOS app-region subtraction', () => {
|
||||
it('subtracts the chip run, strip-end chrome, pane body, and divider; never declares drag', () => {
|
||||
const rules = [...css.matchAll(/(?<sel>[^{}]+)\{(?<body>[^{}]*)\}/g)]
|
||||
.map(match => [match.groups!['sel']!.trim(), match.groups!['body']!] as const)
|
||||
.filter(([, body]) => body.includes('-webkit-app-region'))
|
||||
expect(rules).toHaveLength(1)
|
||||
const [selector, body] = rules[0]!
|
||||
expect(/-webkit-app-region:\s*([^;]+);/.exec(body)?.[1]?.trim()).toBe('no-drag')
|
||||
/** The sheet's app-region rules, as `[selector, value]` in source order. */
|
||||
const regionRules = [...css.matchAll(/(?<sel>[^{}]+)\{(?<body>[^{}]*)\}/g)]
|
||||
.map(match => [match.groups!['sel']!.trim(), match.groups!['body']!] as const)
|
||||
.filter(([, body]) => body.includes('-webkit-app-region'))
|
||||
.map(([selector, body]) => [
|
||||
selector,
|
||||
/-webkit-app-region:\s*([^;]+);/.exec(body)?.[1]?.trim(),
|
||||
] as const)
|
||||
|
||||
describe('macOS app-region ownership', () => {
|
||||
it('subtracts the chip run, strip-end chrome, pane body, and divider, and declares no drag itself', () => {
|
||||
// The strip row's drag is the markup mark's job: a sheet that declares it
|
||||
// here would drag the same box twice and hide the row from the ownership gate.
|
||||
expect(regionRules).toHaveLength(1)
|
||||
|
||||
const [selector, value] = regionRules[0]!
|
||||
expect(value).toBe('no-drag')
|
||||
for (const part of ['.stripTabs', '.stripChrome', '.paneBody', '.divider']) {
|
||||
expect(selector, part).toContain(`:global(html[data-platform='darwin']) ${part}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('scopes the subtraction to the darwin root', () => {
|
||||
// A floating panel lives beside #root, where ui-web base.css subtracts it, so
|
||||
// the strip's drag must never reach a non-macOS host.
|
||||
for (const [selector] of regionRules) {
|
||||
expect(selector).toContain("html[data-platform='darwin']")
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -172,6 +172,28 @@ describe('DockSurface', () => {
|
||||
expect(screen.getByRole('tab', { name: /a\.txt/u }).getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('claims the window drag row while every pane is docked', () => {
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
controller.splitPane()
|
||||
renderSurface(controller, spyIntents())
|
||||
expect(document.querySelector('[data-dockkit-strip]')?.hasAttribute('data-window-drag')).toBe(true)
|
||||
})
|
||||
|
||||
it('withdraws the window drag claim while a pane floats', () => {
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
controller.splitPane()
|
||||
const [first] = dockPaneIds(controller.getSnapshot().state)
|
||||
if (first === undefined) throw new Error('expected a docked pane')
|
||||
controller.floatTab(getPane(controller.getSnapshot().state, first).tabs[0]!)
|
||||
|
||||
renderSurface(controller, spyIntents())
|
||||
// A float can sort before this row in the document, where the row's drag box would
|
||||
// override the floating pane's own subtraction.
|
||||
expect(document.querySelector('[data-dockkit-strip]')?.hasAttribute('data-window-drag')).toBe(false)
|
||||
})
|
||||
|
||||
it('shows the empty-pane label when a pane holds nothing', () => {
|
||||
const controller = new DockController()
|
||||
renderSurface(controller, spyIntents())
|
||||
|
||||
@@ -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-layout/README.md
|
||||
README.md: b26b182f5ea2c727411910cc5f85819b9dfbcb2c
|
||||
README.zh.md: 58ef066ab05389898dc912500cd6b37f1a07824d
|
||||
README.md: 092ce2c231718873b53c788b9133ef1aacc8ffe3
|
||||
README.zh.md: 3b6a7314b53698af97349522c47e780b2991c417
|
||||
@@ -31,7 +31,7 @@ Global panels occupy the root-scoped `main` keyed slot; `conversation` is the re
|
||||
|
||||
### Window-chrome seat
|
||||
|
||||
On macOS desktop (`html[data-platform='darwin']`, set only by the desktop preload) a collapsed sidebar hides its column entirely instead of keeping the rail, and the frame mounts the single root-scoped `shell.leading` seat at its top-left — beside the hiddenInset traffic lights, over every main panel; ui-sidebar occupies it with the reopen and New Session controls. While the seat is mounted the frame publishes `--dsh-frame-leading-clearance`, the inline band the window chrome occupies measured from the frame's left edge; a main panel whose content reaches the top-left corner pads by it so nothing lands under the lights or the controls. The frame also always publishes `--dsh-frame-top-clearance` (48px) on the root element, the constant step below the window's top strip; entry pages in the main panel (plugin manager and similar, not the conversation) pad their top by it, and overlay primitives (portal menus, bottom-anchored overlays, the settings panel) keep it as their top viewport margin — the root-element home lets overlays portalled to `document.body` read it too. The full-width window drag band is 52px; while the Conversation is selected and its header shows the view tab strip, the band deepens to the 76px header block (title row plus tab strip), so blank header space drags while header controls stay clickable.
|
||||
On macOS desktop (`html[data-platform='darwin']`, set only by the desktop preload) a collapsed sidebar hides its column entirely instead of keeping the rail, and the frame mounts the single root-scoped `shell.leading` seat at its top-left — beside the hiddenInset traffic lights, over every main panel; ui-sidebar occupies it with the reopen and New Session controls. While the seat is mounted the frame publishes `--dsh-frame-leading-clearance`, the inline band the window chrome occupies measured from the frame's left edge; a main panel whose content reaches the top-left corner pads by it so nothing lands under the lights or the controls. The frame also always publishes `--dsh-frame-top-clearance` (48px) on the root element, the constant step below the window's top strip; entry pages in the main panel (plugin manager and similar, not the conversation) pad their top by it, and overlay primitives (portal menus, bottom-anchored overlays, the settings panel) keep it as their top viewport margin — the root-element home lets overlays portalled to `document.body` read it too. The frame declares no darwin drag of its own: every chrome row marks itself `data-window-drag`, ui-web base.css turns that mark into the one darwin drag rule, and the row's own box is therefore the window's draggable geometry — a row's blank runs drag while its controls stay clickable. The frame's remaining drag rule is the Windows caption row, which belongs to that platform.
|
||||
|
||||
Windows Electron's `data-windows-titlebar` marker reserves the caption height above all columns and removes the collapsed sidebar rail. Only the content area's top-left corner has a 16px radius; the other corners and the internal divider remain square. The frame publishes `--dsh-windows-content-radius` and `--dsh-windows-sidebar-width` for ui-sidebar-right's fullscreen corner and sidebar clearance. Ordinary Web documents do not receive the marker; macOS retains its separate layout.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-reference"
|
||||
|
||||
### 窗口 chrome 座
|
||||
|
||||
在 macOS 桌面(`html[data-platform='darwin']`,仅由桌面 preload 设置)下,收起的侧边栏整列隐藏而非保留控制栏,框架在左上角挂载 root 作用域的单一 `shell.leading` 座——位于 hiddenInset 红绿灯旁,覆盖所有主面板;ui-sidebar 以重新打开与 New Session 控件占据该座。座挂载期间框架发布 `--dsh-frame-leading-clearance`:窗口 chrome 占据的行内带宽,自框架左边缘起量;内容抵达左上角的主面板以它做内边距,避免落在红绿灯或控件之下。框架还始终在根元素上发布 `--dsh-frame-top-clearance`(48px):窗口顶带之下的固定下沉量;主面板中的入口型页面(插件管理器等类似页面,非对话)以它做顶部内边距,浮层原语(portal 菜单、底部锚定浮层、设置面板)以它做视口顶部安全边距——放在根元素上让 portal 到 document.body 的浮层也能读到。全宽窗口拖拽带为 52px;选中会话界面且其 header 显示视图 tab 条时,加深至 76px 的 header 块(标题行加 tab 条),header 空白处可拖拽,控件保持可点。
|
||||
在 macOS 桌面(`html[data-platform='darwin']`,仅由桌面 preload 设置)下,收起的侧边栏整列隐藏而非保留控制栏,框架在左上角挂载 root 作用域的单一 `shell.leading` 座——位于 hiddenInset 红绿灯旁,覆盖所有主面板;ui-sidebar 以重新打开与 New Session 控件占据该座。座挂载期间框架发布 `--dsh-frame-leading-clearance`:窗口 chrome 占据的行内带宽,自框架左边缘起量;内容抵达左上角的主面板以它做内边距,避免落在红绿灯或控件之下。框架还始终在根元素上发布 `--dsh-frame-top-clearance`(48px):窗口顶带之下的固定下沉量;主面板中的入口型页面(插件管理器等类似页面,非对话)以它做顶部内边距,浮层原语(portal 菜单、底部锚定浮层、设置面板)以它做视口顶部安全边距——放在根元素上让 portal 到 document.body 的浮层也能读到。框架自己不声明任何 darwin drag:每个 chrome 行给自身打上 `data-window-drag`,ui-web base.css 把该标记变成唯一那条 darwin drag 规则,于是行自己的盒子就是窗口的可拖几何——行的空白段可拖,控件保持可点。框架仅剩的那条 drag 规则属于 Windows 标题栏行。
|
||||
|
||||
Windows Electron 的 `data-windows-titlebar` 标记在所有列上方预留顶栏高度,并移除收起后的侧栏轨道。内容区仅左上角保留 16px 圆角,其余角和内部交界处保持直角。框架发布 `--dsh-windows-content-radius` 和 `--dsh-windows-sidebar-width`,供 ui-sidebar-right 的全屏圆角及侧栏避让使用。普通 Web 文档不会获得该标记;macOS 保留其独立布局。
|
||||
|
||||
|
||||
@@ -179,42 +179,18 @@
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
/* The full-width window drag band (macOS desktop, always mounted): the
|
||||
window's top strip stays draggable over every panel in both sidebar states.
|
||||
Pointer events pass through (Electron app-regions are geometry, not hit
|
||||
testing), and it renders as the frame's first child — before all column
|
||||
content and overlays — so app-regions composing in document order let every
|
||||
later no-drag (base.css's interactive-element rule, overlay boxes) subtract
|
||||
from this band. That ordering is the drag mechanism's single invariant:
|
||||
drag regions live only on early chrome bands like this one, never on
|
||||
content containers, which would override earlier-mounted overlays. 52px
|
||||
matches the sidebar top strip's height. */
|
||||
.leadingBand {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
height: 52px;
|
||||
pointer-events: none;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* While the Conversation is the selected main panel AND its header shows the
|
||||
view tab strip (data-conversation-tabs, ui-conversation), the band deepens
|
||||
to the full 76px header block — title row plus tab strip, also the right
|
||||
panel's strip-plus-toolbar header — so blank header space drags while its
|
||||
controls stay clickable through the base.css no-drag rules. Without the
|
||||
strip the header compacts to the title row, which the 52px band already
|
||||
covers; entry pages keep the 52px strip too. */
|
||||
.frame[data-panel-conversation]:has([data-conversation-tabs]) .leadingBand {
|
||||
height: 76px;
|
||||
}
|
||||
/* The frame declares no window drag of its own: every chrome row owns the drag
|
||||
over its own box (ui-web base.css holds the one rule; the ui-theme app-region
|
||||
gate lists the rows). The ordering rule that replaces the old "band first"
|
||||
invariant: a surface that covers the window and must stay clickable portals
|
||||
beside #root, which puts it after every drag row in document order. The Windows
|
||||
caption row above is the other platform's chrome and stays. */
|
||||
|
||||
/* The seat itself: the two 28px controls centered on the conversation title
|
||||
row (10px header inset + 30px row → centre y 25) and level with the traffic
|
||||
lights (centre y ≈ 24). Sits above column content, below frame overlays
|
||||
(20) and the fullscreen right panel (40). The box subtracts itself from any
|
||||
window drag band beneath (Electron app-regions are geometry, not stacking). */
|
||||
window drag row beneath (Electron app-regions are geometry, not stacking). */
|
||||
.leadingSeat {
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
|
||||
@@ -42,24 +42,6 @@ function MainPanel({ usePanelInfo, renderSlot }: Pick<PropsRuntime<'root'>, 'use
|
||||
return renderSlot('main', {}, { entryKey: panelId ?? 'conversation' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the frame while the Conversation is selected — the deepened drag band
|
||||
* (AppFrame.module.css) keys off the attribute. A DOM write from a child keeps
|
||||
* the frame itself out of the panel subscription: selecting a panel must not
|
||||
* re-render the columns.
|
||||
*/
|
||||
function ConversationMarker({ usePanelInfo, frameRef }: Pick<PropsRuntime<'root'>, 'usePanelInfo'> & { frameRef: React.RefObject<HTMLDivElement | null> }) {
|
||||
const conversationActive = usePanelInfo(info => info.activePanelId === null)
|
||||
useLayoutEffect(() => {
|
||||
const frame = frameRef.current
|
||||
/* v8 ignore next -- the ref is attached by effect time: the marker renders inside the frame div. */
|
||||
if (frame === null) return
|
||||
if (conversationActive) frame.setAttribute('data-panel-conversation', '')
|
||||
else frame.removeAttribute('data-panel-conversation')
|
||||
}, [conversationActive, frameRef])
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Right column grid item. Zero-width unless the occupant asked for a track; the
|
||||
* occupant's panel is positioned against the column's right edge, which never
|
||||
@@ -290,11 +272,6 @@ export function AppFrame({
|
||||
data-dragging={dragging || undefined}
|
||||
data-animating={animating > 0 || undefined}
|
||||
>
|
||||
{/* First child: app-regions compose in document order, so everything
|
||||
mounted later (chrome controls, overlays) subtracts its no-drag
|
||||
from this band. */}
|
||||
{darwin && <div className={css.leadingBand} data-shell-leading-band />}
|
||||
<ConversationMarker usePanelInfo={usePanelInfo} frameRef={frameRef} />
|
||||
<DocumentTitle
|
||||
productTitle={productTitle}
|
||||
useSessions={useSessions}
|
||||
|
||||
@@ -256,9 +256,9 @@ describe('AppFrame', () => {
|
||||
it('mounts the shell.leading seat only while the darwin collapse hides the column', () => {
|
||||
document.documentElement.dataset.platform = 'darwin'
|
||||
const { frame, instance, sidebarOwner, queryByTestId } = mountFrame()
|
||||
// The window drag band composes app-regions in DOM order: it must render
|
||||
// before all column content so every later no-drag subtracts from it.
|
||||
expect(frame.firstElementChild?.hasAttribute('data-shell-leading-band')).toBe(true)
|
||||
// The frame declares no window drag of its own: every chrome row owns its
|
||||
// run (ui-sidebar, ui-dockkit, ui-conversation, ui-plugin-manager).
|
||||
expect(frame.querySelector('[data-shell-leading-band]')).toBeNull()
|
||||
expect(queryByTestId('shell.leading-content')).toBeNull()
|
||||
act(() => { instance.actions.toggleSidebar() })
|
||||
expect(tracks(frame)).toEqual([0, 0])
|
||||
@@ -280,8 +280,6 @@ describe('AppFrame', () => {
|
||||
expect(slotCalls).toEqual([{ key: 'main', props: {}, options: { entryKey: panelId ?? 'conversation' } }])
|
||||
expect(getByTestId('main-content').getAttribute('data-entry-key')).toBe(panelId ?? 'conversation')
|
||||
expect(instance.getSnapshot().panelInfo).toEqual({ activePanelId: panelId })
|
||||
// The deepened conversation drag band keys off this frame marker.
|
||||
expect(frame.hasAttribute('data-panel-conversation')).toBe(panelId === null)
|
||||
expect(instance.getSnapshot().layoutInfo).toBe(layoutInfo)
|
||||
expect(tracks(frame)).toEqual([280, 0])
|
||||
expect(selectedSession).toBe(sessionId)
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
padding: 28px clamp(24px, 4vw, 48px) 48px;
|
||||
/* Inline and bottom insets only: the 28px top inset belongs to the head row
|
||||
(`.pageHead`, `.detailTop`). macOS window dragging is owned by a row's own box,
|
||||
so an inset above that box leaves a top strip no drag region covers. */
|
||||
padding: 0 clamp(24px, 4vw, 48px) 48px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
@@ -23,12 +26,17 @@
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
box-sizing: border-box;
|
||||
/* The page's former top inset: the row starts at the window's top edge, so the whole
|
||||
strip above the page content drags the window. */
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
/* macOS hiddenInset titlebar: the frame's top clearance keeps the head below
|
||||
the window's top strip in both sidebar states so it never jumps. */
|
||||
the window's top strip in both sidebar states so it never jumps, and the head
|
||||
owns the window drag over its own box — clearance included — so this page's
|
||||
top strip drags without a frame band. */
|
||||
:global([data-platform='darwin']) .pageHead {
|
||||
padding-top: var(--dsh-frame-top-clearance, 0px);
|
||||
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -1047,10 +1055,22 @@ label.registryOption {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Same top clearance as .pageHead: the crumb is the detail views' leading
|
||||
content and otherwise lands under the traffic lights. */
|
||||
:global([data-platform='darwin']) .detail {
|
||||
padding-top: var(--dsh-frame-top-clearance, 0px);
|
||||
/* The head row every detail view shares: the crumb over the icon/actions row.
|
||||
It carries the same top clearance as .pageHead — the crumb is the detail
|
||||
views' leading content and otherwise lands under the traffic lights — and the
|
||||
same drag ownership: it marks itself `data-window-drag`, so this row's own box
|
||||
is the window's draggable geometry. The detail container below stays
|
||||
content: marking it would drag the window over its text and form labels. */
|
||||
.detailTop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* The same 28px top inset `.pageHead` carries, so the row starts at the window's
|
||||
top edge and the strip above the detail content drags the window. */
|
||||
padding-top: 28px;
|
||||
}
|
||||
|
||||
:global([data-platform='darwin']) .detailTop {
|
||||
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
|
||||
}
|
||||
|
||||
.crumb {
|
||||
|
||||
@@ -337,7 +337,7 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
|
||||
readonly actions?: ReactNode
|
||||
}): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<div className={css.detailTop} data-window-drag>
|
||||
<button type="button" className={css.crumb} aria-label={crumbLabel} onClick={onBack}>
|
||||
<IconChevronDownOutlineRegular className={css.crumbIcon} aria-hidden="true" />
|
||||
<span>{crumbText}</span>
|
||||
@@ -346,7 +346,7 @@ function DetailTop({ crumbLabel, crumbText, onBack, icon, actions }: {
|
||||
<span className={css.cardIcon} aria-hidden="true">{icon}</span>
|
||||
{actions}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1221,7 +1221,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
<section className={css.page} data-plugin-panel aria-busy={state.status === 'loading'}>
|
||||
{showsCards
|
||||
? (
|
||||
<header className={css.pageHead}>
|
||||
<header className={css.pageHead} data-window-drag>
|
||||
<div>
|
||||
<h1 className={css.pageTitle}>{t('title')}</h1>
|
||||
<p className={css.pageIntro}>{t('intro')}</p>
|
||||
|
||||
@@ -13,13 +13,16 @@
|
||||
padding-top: var(--dsh-windows-titlebar-height);
|
||||
}
|
||||
|
||||
/* The macOS shell's drag row: on darwin this bar IS the window's top strip, and
|
||||
ui-web base.css declares the one darwin rule over the `data-window-drag` mark.
|
||||
Windows keeps the native caption row as its drag surface instead — this bar
|
||||
sits below that row (see above) and declares no drag of its own. */
|
||||
.header {
|
||||
box-sizing: border-box;
|
||||
height: 48px;
|
||||
flex: none;
|
||||
padding: 6px 16px;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l1);
|
||||
app-region: drag;
|
||||
}
|
||||
|
||||
.controls {
|
||||
@@ -57,7 +60,6 @@
|
||||
line-height: 22px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
app-region: no-drag;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
|
||||
@@ -57,7 +57,7 @@ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failure
|
||||
}
|
||||
}, [bridge, page, attempt])
|
||||
return createPortal(<div ref={layer} className={css.overlay} role="dialog" aria-modal="true" aria-label={backLabel}>
|
||||
<header className={css.header}>
|
||||
<header className={css.header} data-window-drag>
|
||||
<div className={css.controls}>
|
||||
<span className={css.trafficLights} aria-hidden="true" />
|
||||
<button ref={back} className={css.back} onClick={onClose}>
|
||||
|
||||
@@ -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-settings-general/README.md
|
||||
README.md: 36d3fd7b260f95cffb67977e5a6f37852bf195aa
|
||||
README.zh.md: 9df8fe8bc3a39fceeccfc8b08a8e18cc5eeaf752
|
||||
README.md: 22fa9c364b83e1923bb5a23ab06ca0f76d9aaefa
|
||||
README.zh.md: 732b85a0f477b5c0891bdab5a0622f583ae8c1d9
|
||||
@@ -22,7 +22,7 @@ Use this package to give the dsh web client a Settings panel, connection-recover
|
||||
|
||||
-----
|
||||
|
||||
The Settings panel uses a shared 760 × 500 layout, bounded by the viewport. Longer sections scroll inside the content column; the Account entry uses the account icon.
|
||||
The Settings panel uses a shared 760 × 500 layout, bounded by the viewport. Longer sections scroll inside the content column; the Account entry uses the account icon. The panel portals beside `#root` rather than inside it, so a macOS window drag region a chrome row declares later in document order cannot swallow its controls.
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
@@ -22,7 +22,7 @@ kind: "package-reference"
|
||||
|
||||
-----
|
||||
|
||||
设置面板采用各页面共享的 760 × 500 布局,并受视口大小约束。较长的页面在内容栏内部滚动;账号入口使用账号图标。
|
||||
设置面板采用各页面共享的 760 × 500 布局,并受视口大小约束。较长的页面在内容栏内部滚动;账号入口使用账号图标。面板挂载在 `#root` 之外而不是其内部,这样在文档序上更靠后的 chrome 行所声明的 macOS 窗口拖拽区不会吞掉它的控件。
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
@@ -66,8 +66,10 @@
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:*",
|
||||
"@deepseek-ai/dsh-remote-mock": "workspace:*",
|
||||
"@types/react": "~18.3.1",
|
||||
"@types/react-dom": "~18.3.0",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"@deepseek-ai/dsh-settings": "workspace:*"
|
||||
},
|
||||
"files": [
|
||||
|
||||
@@ -238,8 +238,7 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* macOS: overlays covering the window drag bands must subtract themselves
|
||||
(Electron app-regions are geometry, not stacking). */
|
||||
:global([data-platform='darwin']) .overlay {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
/* No app-region rule here: the overlay portals beside #root (SettingsRoot), so
|
||||
ui-web base.css's `body > :not(#root)` subtraction covers it wherever it
|
||||
overlaps a chrome row's drag — including above every column, which an in-root
|
||||
overlay could not promise once a chrome row declares drag itself. */
|
||||
@@ -11,6 +11,7 @@
|
||||
* to the step, so a mounted-but-deciding step paints nothing here.
|
||||
*/
|
||||
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
ConnectionIndicator,
|
||||
@@ -68,7 +69,11 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
const closeButton = useRef<HTMLButtonElement | null>(null)
|
||||
useEffect(() => { closeButton.current?.focus() }, [])
|
||||
|
||||
return (
|
||||
// Portalled beside #root like the Modal primitive: a covering surface mounted
|
||||
// inside the root would precede the columns' chrome in document order, so a
|
||||
// chrome row that declares window drag after it would override its subtraction.
|
||||
// Beside the root, base.css's `body > :not(#root)` rule subtracts it instead.
|
||||
return createPortal((
|
||||
<div className={css.overlay} role="presentation">
|
||||
<div className={css.mask} aria-hidden="true" onClick={onClose} />
|
||||
<div className={css.panel} role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
||||
@@ -103,7 +108,7 @@ function SettingsPanel({ rows, renderSlot, activeId, onSelect, onClose }: PanelP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
), document.body)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -164,6 +169,16 @@ export function SettingsRoot(props: SettingsRootComponentProps) {
|
||||
setCompletedOnboarding(new Set())
|
||||
}, [onboardingActive])
|
||||
|
||||
const onboardingStepSeen = useRef(onboardingStep)
|
||||
// An onboarding step owns the viewport and marks `#root` inert. The panel portals
|
||||
// beside `#root`, outside that mark, so a step that appears while the panel is open
|
||||
// takes the panel down rather than leaving it focusable behind the onboarding mask.
|
||||
useEffect(() => {
|
||||
const appeared = onboardingStepSeen.current === undefined && onboardingStep !== undefined
|
||||
onboardingStepSeen.current = onboardingStep
|
||||
if (appeared && open) close()
|
||||
}, [onboardingStep, open, close])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previous = previousConnectionState.current
|
||||
previousConnectionState.current = connectionState
|
||||
|
||||
@@ -139,7 +139,14 @@ function mount({
|
||||
desktopUpdate = next
|
||||
view.rerender(<SettingsRoot {...props} />)
|
||||
}
|
||||
return { view, renderSlot, bump, listeners, reconnect, setConnectionState, setDesktopUpdate }
|
||||
/** Turn the mounted Session blank, which is what makes an onboarding step appear. */
|
||||
const setOnboardingActive = (next: boolean) => {
|
||||
const session = sessions.byId[activeId]
|
||||
if (session === undefined) throw new Error('expected the mounted Session')
|
||||
act(() => { sessions.byId[activeId] = { ...session, blank: next } })
|
||||
view.rerender(<SettingsRoot {...props} />)
|
||||
}
|
||||
return { view, renderSlot, bump, listeners, reconnect, setConnectionState, setDesktopUpdate, setOnboardingActive }
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
@@ -388,6 +395,16 @@ describe('SettingsPanel navigation', () => {
|
||||
expect(inactive).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('takes the panel down when an onboarding step appears beneath it', () => {
|
||||
const { setOnboardingActive } = mount({ onboardingActive: false })
|
||||
openPanel()
|
||||
expect(screen.getByRole('dialog')).toBeDefined()
|
||||
|
||||
// The step's overlay marks only #root inert, and the panel is portalled beside it.
|
||||
setOnboardingActive(true)
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps onboarding active before a main Session is retained', () => {
|
||||
const { renderSlot } = mount({ mainView: false })
|
||||
|
||||
|
||||
@@ -61,33 +61,15 @@
|
||||
transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
/* One-frame app-region pulse at each open/close edge (RightbarSeat): changing
|
||||
this computed value is what makes Electron recollect the window's drag
|
||||
rects, which the slide's transform/visibility flips alone never dirty
|
||||
(electron#32341). The panel's steady state sets no app-region in either
|
||||
presentation, so this write is a real change at both edges — including a
|
||||
hidden panel opening straight into fullscreen (a narrow window's first
|
||||
open), whose rects were skipped while hidden. While set, the whole panel is
|
||||
out of the drag band for that frame; the removal frame recollects the
|
||||
steady-state geometry below. */
|
||||
:global(html[data-platform='darwin']) .panel[data-sidebar-right-region-nudge] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.panel[data-sidebar-right-panel='fullscreen'] {
|
||||
--dsh-dockkit-dock-layer: 40;
|
||||
}
|
||||
|
||||
/* No whole-box subtraction here: Electron drag regions are window geometry,
|
||||
not stacking, so the frame's leading band does keep acting through this
|
||||
fullscreen content — but the kit already subtracts every box that must stay
|
||||
usable (tabs, strip-end chrome, pane bodies, dividers; ui-dockkit), and
|
||||
ui-web base.css subtracts interactive elements and portalled overlays.
|
||||
What remains of the band under the strips' blank runs is the window drag
|
||||
affordance, the same native-tab-bar reading as the push presentation. A
|
||||
panel-wide no-drag would trade it away and, worse, make no-drag the
|
||||
steady state, turning the open/close pulse above into a computed-value
|
||||
no-op exactly when a hidden panel opens straight into fullscreen. */
|
||||
not stacking, so a strip row inside this fullscreen content that marks itself
|
||||
`data-window-drag` (ui-dockkit) still owns the drag over its own box, and
|
||||
ui-web base.css subtracts interactive elements and portalled overlays. A
|
||||
panel-wide no-drag would trade that drag affordance away. */
|
||||
|
||||
/* Windows keeps caption controls above fullscreen file and terminal panels. */
|
||||
:global([data-windows-titlebar]) .panel[data-sidebar-right-panel='fullscreen'] {
|
||||
|
||||
@@ -358,36 +358,9 @@ export function RightbarSeat({
|
||||
if (shown && !fullscreen && !canShow) actions.setExpanded(sessionId, false)
|
||||
}, [actions, sessionId, shown, fullscreen, canShow])
|
||||
|
||||
// Electron rebuilds the window's -webkit-app-region rects only when a style
|
||||
// or layout pass dirties an app-region value (electron#32341), and Blink
|
||||
// skips hidden boxes when it collects them. The open/close slide flips only
|
||||
// transform and visibility — neither dirties the rects — so the strip's
|
||||
// no-drag boxes (ui-dockkit) are missing right after the first open, leaving
|
||||
// the tabs under the window drag band (ui-layout .leadingBand) swallowed by
|
||||
// it, and they would linger after a close. Pulsing an app-region rule
|
||||
// (SidebarRight.module.css) on the panel forces a recollection at both edges
|
||||
// of the gesture: once at the flip, once after the slide settles and the
|
||||
// stylesheet's delayed visibility flip (0.3s) has landed.
|
||||
useEffect(() => {
|
||||
if (!active || document.documentElement.dataset.platform !== 'darwin') return
|
||||
const panel = panelRef.current
|
||||
if (panel === null) return
|
||||
let raf: number | null = null
|
||||
const nudge = (): void => {
|
||||
panel.setAttribute('data-sidebar-right-region-nudge', '')
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = null
|
||||
panel.removeAttribute('data-sidebar-right-region-nudge')
|
||||
})
|
||||
}
|
||||
nudge()
|
||||
const settle = setTimeout(nudge, 400)
|
||||
return () => {
|
||||
clearTimeout(settle)
|
||||
if (raf !== null) cancelAnimationFrame(raf)
|
||||
panel.removeAttribute('data-sidebar-right-region-nudge')
|
||||
}
|
||||
}, [shown, active])
|
||||
// The open/close slide needs no pulse of its own: the shell's window drag
|
||||
// watcher (ui-web) measures the marked rows every frame the surface moves and
|
||||
// sets the recall mark itself (electron#32341).
|
||||
|
||||
// Fullscreen leaves the previous column report in force until its own slide
|
||||
// completes. Normal presentation and zero-duration transitions report before paint.
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* App-region nudge pulse, asserted against SidebarRight.module.css on disk:
|
||||
* Electron recollects the window's drag rects only when a style pass changes
|
||||
* a computed app-region value (electron#32341), so the panel's steady state
|
||||
* must declare NO app-region in either presentation — the open/close no-drag
|
||||
* pulse is then a real computed-value change at both edges, including a
|
||||
* hidden panel opening straight into fullscreen (a narrow window's first
|
||||
* open, whose rects were skipped while hidden). A steady-state value on the
|
||||
* panel (the old fullscreen whole-box no-drag) makes the pulse a silent
|
||||
* no-op and leaves the frame's drag band swallowing the strip's top-right
|
||||
* controls; fine-grained subtraction belongs to ui-dockkit (tabs, strip-end
|
||||
* chrome, pane bodies, dividers), leaving the strips' blank runs draggable.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(new URL('../src/client/shell/SidebarRight.module.css', import.meta.url), 'utf8')
|
||||
|
||||
/**
|
||||
* Every `<selector> -> value` pair of rules declaring `-webkit-app-region`.
|
||||
* @returns one entry per declaring rule, in source order.
|
||||
*/
|
||||
function appRegionRules(): [selector: string, value: string][] {
|
||||
const rules: [string, string][] = []
|
||||
for (const match of css.matchAll(/(?<sel>[^{}]+)\{(?<body>[^{}]*)\}/g)) {
|
||||
const value = /-webkit-app-region:\s*([^;]+);/.exec(match.groups!['body']!)?.[1]
|
||||
if (value !== undefined) rules.push([match.groups!['sel']!.trim(), value.trim()])
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
describe('app-region nudge pulse styles', () => {
|
||||
it('pulses the panel to no-drag', () => {
|
||||
const pulses = appRegionRules()
|
||||
.filter(([selector]) => selector.includes('[data-sidebar-right-region-nudge]'))
|
||||
expect(pulses).toHaveLength(1)
|
||||
expect(pulses[0]![0]).toMatch(/\.panel\[data-sidebar-right-region-nudge\]\s*$/)
|
||||
expect(pulses[0]![1]).toBe('no-drag')
|
||||
})
|
||||
|
||||
it('declares no steady-state app-region, so the pulse is a real change', () => {
|
||||
const steady = appRegionRules()
|
||||
.filter(([selector]) => !selector.includes('[data-sidebar-right-region-nudge]'))
|
||||
expect(steady).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -290,47 +290,6 @@ describe('RightbarSeat presentation', () => {
|
||||
expect(h.frame.closeRightbar).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the nudge while the darwin seat has no surface to render', async () => {
|
||||
// The seat's first render returns null (the open effect has not created
|
||||
// the surface yet), so the nudge effect fires with an unattached panel ref.
|
||||
document.documentElement.dataset.platform = 'darwin'
|
||||
try {
|
||||
const h = await mountSeat()
|
||||
const panel = element(h.view.container, '[data-sidebar-right-panel]')
|
||||
expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false)
|
||||
} finally {
|
||||
delete document.documentElement.dataset.platform
|
||||
}
|
||||
})
|
||||
|
||||
it('pulses the app-region nudge at each open and close edge on macOS only', async () => {
|
||||
const h = await mountSeat()
|
||||
const panel = element(h.view.container, '[data-sidebar-right-panel]')
|
||||
const marks = vi.spyOn(panel, 'setAttribute')
|
||||
const nudges = () => marks.mock.calls.filter(([name]) => name === 'data-sidebar-right-region-nudge').length
|
||||
// Web and Windows compose no app-regions: the slide needs no nudge.
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(nudges()).toBe(0)
|
||||
document.documentElement.dataset.platform = 'darwin'
|
||||
try {
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(nudges()).toBe(1)
|
||||
expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true)
|
||||
// A frame later the mark lifts (the pulse itself is the recollection
|
||||
// trigger), and the settle pulse re-marks after the 0.3s slide.
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 450)) })
|
||||
expect(nudges()).toBe(2)
|
||||
expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(false)
|
||||
// The close edge pulses again so the hidden panel's stale rects drop.
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(nudges()).toBe(3)
|
||||
expect(panel.hasAttribute('data-sidebar-right-region-nudge')).toBe(true)
|
||||
} finally {
|
||||
delete document.documentElement.dataset.platform
|
||||
}
|
||||
})
|
||||
|
||||
it('fills the viewport without replacing the content tree or releasing the wide track', async () => {
|
||||
const h = await mountSeat()
|
||||
const tab = h.open()
|
||||
|
||||
@@ -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: 94bb3a70ca8c06b5838f41436bb0f2ba2751501e
|
||||
README.zh.md: 86f54882ba1c96b9b50898993931757f7afef4e5
|
||||
README.md: 9ee8d9eb95d0991f1cdfcee3b5beee4d249d3f50
|
||||
README.zh.md: c858362b4b8a8c5e41b337253454d41001f926fa
|
||||
@@ -45,7 +45,7 @@ On Windows Electron, `html[data-windows-titlebar]` fixes the sidebar toggle in t
|
||||
|
||||
### macOS desktop
|
||||
|
||||
Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights and carries the collapse toggle; the frame's own drag band (ui-layout `.leadingBand`) covers that strip, and the logo row below extends the drag surface, so the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md).
|
||||
Under `html[data-platform='darwin']` (set only by the desktop preload) the expanded column opens with a 52px top strip that clears the hiddenInset traffic lights and carries the collapse toggle; the strip and the logo row below each mark themselves `data-window-drag`, so each row's own box is the window's drag region (ui-web base.css declares the one darwin drag rule), and the brand wordmark is not a New Session shortcut there — the dedicated New Session button keeps the action — and collapsing hides the column entirely instead of leaving the rail. The package registers `HeaderLeadingControls` into the frame's `shell.leading` window-chrome seat (ui-layout), which mounts it — the open-sidebar and New Session controls beside the traffic lights — only while the column is hidden, over every main panel. Rationale and the window-integration contract: the [macOS hidden-titlebar Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md).
|
||||
|
||||
### Scrollbars
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
|
||||
|
||||
### macOS 桌面
|
||||
|
||||
在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;框架自身的拖拽带(ui-layout `.leadingBand`)覆盖这条顶部条,其下的 logo 行延伸这块拖拽面,因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。
|
||||
在 `html[data-platform='darwin']`(仅由桌面 preload 设置)下,展开的侧边栏列顶部有一条 52px 的顶部条:避开 hiddenInset 红绿灯并承载收起按钮;顶部条与其下的 logo 行各自打上 `data-window-drag`,于是每行自己的盒子就是窗口的拖拽区(唯一的 darwin drag 规则由 ui-web base.css 声明),因此品牌 wordmark 在 macOS 上不再是 New Session 快捷入口——专用的 New Session 按钮保留该操作;收起时整列隐藏而非保留轨道。本包向框架的 `shell.leading` 窗口 chrome 座(ui-layout)注册 `HeaderLeadingControls`——打开侧边栏与 New Session 两个控件,由框架仅在列隐藏时挂载于红绿灯旁,覆盖所有主面板。设计依据与窗口集成约定见 [macOS 隐藏标题栏 Agent Note](../../../.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md)。
|
||||
|
||||
### 滚动条
|
||||
|
||||
|
||||
@@ -176,8 +176,9 @@
|
||||
|
||||
/* macOS hiddenInset titlebar: the strip spans the column's top edge (negative
|
||||
margins cancel the root padding) so it clears the traffic lights at
|
||||
(16, 18). Window dragging over this run comes from ui-layout's .leadingBand
|
||||
(same 52px geometry); the toggle subtracts via the base.css rule. */
|
||||
(16, 18). The strip marks itself `data-window-drag` in markup (SidebarRoot), so
|
||||
its own run is the column's draggable geometry and no frame band has to match
|
||||
the strip's height; the toggle inside subtracts via the base.css rule. */
|
||||
.topStrip {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -207,7 +208,15 @@
|
||||
|
||||
/* Logo row (figma pad (4,8,4,8)): expanded keeps the panel toggle at the
|
||||
right edge; the rail gives every control the same base left anchor before
|
||||
the shared entry translation is applied. */
|
||||
the shared entry translation is applied. macOS hiddenInset titlebar: the row
|
||||
is marked `data-window-drag` in markup (SidebarRoot), so ui-web base.css's
|
||||
single darwin rule carries the drag over its own box — below the top strip
|
||||
(40–100px: the strip's 52px foot with the row's -12px tuck). The brand rides
|
||||
that box (SidebarRoot renders the wordmark as a plain span there, so the
|
||||
global interactive no-drag rule never subtracts it); the dedicated New
|
||||
Session button below keeps the pointer and keyboard paths. Safe as a drag
|
||||
source: it precedes the sidebar's overlay slots in document order, so their
|
||||
no-drag still wins. */
|
||||
.logoRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -228,16 +237,6 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
/* macOS hiddenInset titlebar: the logo row extends the frame band's drag
|
||||
below 52px, and the brand rides it — SidebarRoot renders the wordmark as a
|
||||
plain span there, so the global interactive no-drag rule never subtracts
|
||||
it; the dedicated New Session button below keeps the pointer and keyboard
|
||||
paths. Safe as a drag source: it precedes the sidebar's overlay slots in
|
||||
document order, so their no-drag still wins. */
|
||||
:global([data-platform='darwin']) .logoRow {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* Brand group (figma I133:7632): the full wordmark rides the text ink
|
||||
(figma flows: the main-screen instance is black; blue is brand
|
||||
emphasis only). A button only in behavior (New Session shortcut): the
|
||||
|
||||
@@ -208,8 +208,8 @@ export function SidebarRoot({
|
||||
>
|
||||
{/* macOS hiddenInset titlebar: the strip shares the row with the
|
||||
traffic lights and keeps the toggle at the sidebar's top-right. */}
|
||||
{darwinDesktop && <div className={css.topStrip}>{toggle}</div>}
|
||||
<div className={css.logoRow}>
|
||||
{darwinDesktop && <div className={css.topStrip} data-window-drag>{toggle}</div>}
|
||||
<div className={css.logoRow} data-window-drag>
|
||||
{/* Expanded, the brand doubles as a New Session shortcut — except on
|
||||
macOS, where it stays part of the logo row's window-drag surface
|
||||
(a button would subtract itself through the global no-drag rule);
|
||||
|
||||
@@ -11,6 +11,7 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
|
||||
>
|
||||
<div
|
||||
class="logoRow"
|
||||
data-window-drag="true"
|
||||
>
|
||||
<button
|
||||
aria-label="Open sidebar"
|
||||
@@ -93,6 +94,7 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
|
||||
>
|
||||
<div
|
||||
class="logoRow"
|
||||
data-window-drag="true"
|
||||
>
|
||||
<button
|
||||
aria-label="New session"
|
||||
@@ -227,6 +229,7 @@ exports[`sidebar shell snapshots > renders the collapsed rail after the crossfad
|
||||
>
|
||||
<div
|
||||
class="logoRow"
|
||||
data-window-drag="true"
|
||||
>
|
||||
<button
|
||||
aria-label="Open sidebar"
|
||||
@@ -327,6 +330,7 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
|
||||
>
|
||||
<div
|
||||
class="logoRow"
|
||||
data-window-drag="true"
|
||||
>
|
||||
<button
|
||||
aria-label="New session"
|
||||
@@ -461,6 +465,7 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo
|
||||
>
|
||||
<div
|
||||
class="logoRow"
|
||||
data-window-drag="true"
|
||||
>
|
||||
<button
|
||||
aria-label="新建会话"
|
||||
|
||||
@@ -1,47 +1,221 @@
|
||||
/**
|
||||
* Window drag-region ownership, asserted against the CSS text on disk:
|
||||
* `-webkit-app-region: drag` belongs to window chrome: the frame band,
|
||||
* sidebar logo row, and the full-window Platform overlay's return bar.
|
||||
* Window drag-region ownership, asserted against the CSS and the markup on disk.
|
||||
* The shell declares `-webkit-app-region: drag` exactly once — ui-web base.css,
|
||||
* for any element that marks itself `data-window-drag` — so a chrome row's own box
|
||||
* is the window's draggable geometry and no fixed band has to match any row's
|
||||
* height. CHROME_ROWS pairs every row's mark with its sheet and its pinned
|
||||
* geometry, so a row that stops marking itself, an unmarked row added to the
|
||||
* composition, and a mark on a container that owns no chrome row all fail here.
|
||||
* Electron composes app-regions from window geometry in DOM order, ignoring
|
||||
* stacking: a drag rule on a content container overrides the no-drag of any
|
||||
* overlay mounted earlier, so its controls drag the window instead of
|
||||
* receiving clicks. Everything interactive opts out through ui-web base.css.
|
||||
* overlay mounted earlier, so its text and labels drag the window instead of
|
||||
* receiving clicks. Everything interactive opts out through ui-web base.css; a
|
||||
* box that selector cannot cover subtracts itself in its own sheet. Composed
|
||||
* geometry itself is the browser lane's claim — ui-theme cannot see layout.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { packageStylesheets, parseRules } from './stylesheet-scan.ts'
|
||||
import { packageFiles, packageStylesheets, parseRules, type CssRule } from './stylesheet-scan.ts'
|
||||
|
||||
/** The only stylesheets allowed to declare a window drag region, relative to packages/. */
|
||||
const DRAG_OWNERS = [
|
||||
'client/ui-layout/src/client/AppFrame.module.css',
|
||||
'client/ui-sidebar/src/client/SidebarRoot.module.css',
|
||||
'client/ui-settings-account/src/client/PlatformOverlay.module.css',
|
||||
/** The one stylesheet allowed to declare a darwin window drag region, relative to packages/. */
|
||||
const SHELL_SHEET = 'client/web/src/base.css'
|
||||
|
||||
/** The mark that stylesheet's rule selects, as authored. */
|
||||
const SHELL_DRAG_SELECTOR = "html[data-platform='darwin'] [data-window-drag]"
|
||||
|
||||
/** The frame's Windows caption row, relative to packages/; the other platform's chrome. */
|
||||
const WINDOWS_CAPTION = 'client/ui-layout/src/client/AppFrame.module.css'
|
||||
|
||||
/** That caption row's selector, as authored. */
|
||||
const WINDOWS_CAPTION_SELECTOR = ':global([data-windows-titlebar]) .frame::before'
|
||||
|
||||
/** The mark a chrome row puts on the element that owns its drag. */
|
||||
const DRAG_MARK = 'data-window-drag'
|
||||
|
||||
/** The mark attribute, without swallowing the recall mark that starts the same way. */
|
||||
const DRAG_MARK_PATTERN = /data-window-drag(?![\w-])/
|
||||
|
||||
/** Workspace root of the package tree, so the manifest can name files outside this package. */
|
||||
const PACKAGES_DIR = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
|
||||
const SIDEBAR = 'client/ui-sidebar/src/client/SidebarRoot.module.css'
|
||||
const CONVERSATION = 'client/ui-conversation/src/client/skeleton/ConversationRoot.module.css'
|
||||
const DOCKKIT = 'client/ui-dockkit/src/components/dockkit.module.css'
|
||||
const PLUGIN_MANAGER = 'client/ui-plugin-manager/src/client/PluginManagerPage.module.css'
|
||||
const PLATFORM_OVERLAY = 'client/ui-settings-account/src/client/PlatformOverlay.module.css'
|
||||
|
||||
/**
|
||||
* One chrome row: the sheet that lays it out, the markup that marks it, and the
|
||||
* geometry this manifest pins. A row whose pinned declaration changes fails on
|
||||
* the change; a row added to the composition without a mark or a manifest entry
|
||||
* fails in the markup scan below.
|
||||
*/
|
||||
interface ChromeRow {
|
||||
/** Stylesheet declaring the row's geometry, relative to packages/. */
|
||||
readonly file: string
|
||||
/** Class selector of the row's rule, in the sheet's source form. */
|
||||
readonly selector: string
|
||||
/** TSX file marking the row's element `data-window-drag`, relative to packages/. */
|
||||
readonly markup: string
|
||||
/** Height declaration to read, as `[property, value]` exactly as authored; omitted for content-sized rows. */
|
||||
readonly height?: readonly [property: string, value: string]
|
||||
/** Top inset that belongs to the row's own box, as `[property, value]`. */
|
||||
readonly inset?: readonly [property: string, value: string]
|
||||
}
|
||||
|
||||
const CHROME_ROWS: readonly ChromeRow[] = [
|
||||
{
|
||||
file: SIDEBAR,
|
||||
selector: '.topStrip',
|
||||
markup: 'client/ui-sidebar/src/client/SidebarRoot.tsx',
|
||||
height: ['height', '52px'],
|
||||
},
|
||||
{
|
||||
file: SIDEBAR,
|
||||
selector: '.logoRow',
|
||||
markup: 'client/ui-sidebar/src/client/SidebarRoot.tsx',
|
||||
height: ['height', '60px'],
|
||||
},
|
||||
{
|
||||
file: CONVERSATION,
|
||||
selector: '.header',
|
||||
markup: 'client/ui-conversation/src/client/skeleton/ConversationHeader.tsx',
|
||||
height: ['min-height', '76px'],
|
||||
},
|
||||
{
|
||||
file: DOCKKIT,
|
||||
selector: '.tabStrip',
|
||||
markup: 'client/ui-dockkit/src/components/TabPanel.tsx',
|
||||
height: ['height', '28px'],
|
||||
inset: ['padding', '10px 6px 0 var(--dsh-dockkit-strip-inline-start, 10px)'],
|
||||
},
|
||||
{
|
||||
file: PLUGIN_MANAGER,
|
||||
selector: '.pageHead',
|
||||
markup: 'client/ui-plugin-manager/src/client/PluginManagerPage.tsx',
|
||||
},
|
||||
{
|
||||
file: PLUGIN_MANAGER,
|
||||
selector: '.detailTop',
|
||||
markup: 'client/ui-plugin-manager/src/client/PluginManagerPage.tsx',
|
||||
},
|
||||
{
|
||||
file: PLATFORM_OVERLAY,
|
||||
selector: '.header',
|
||||
markup: 'client/ui-settings-account/src/client/PlatformOverlay.tsx',
|
||||
height: ['height', '48px'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Selectors of rules declaring a window drag region. Matches the prefixed and
|
||||
* unprefixed property and tolerates `!important`, so a creative spelling
|
||||
* cannot slip a drag surface past the allowlist.
|
||||
* Whether a rule declares a window drag region. Matches the prefixed and
|
||||
* unprefixed property and tolerates `!important`, so a creative spelling cannot
|
||||
* slip a drag surface past this check.
|
||||
* @param rule - one flattened rule.
|
||||
* @returns true when the rule declares a drag region.
|
||||
*/
|
||||
function declaresDrag(rule: CssRule): boolean {
|
||||
return rule.declarations
|
||||
.some(([property, value]) => /^(-webkit-)?app-region$/.test(property) && /^drag(\s|!|$)/.test(value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Selectors of a sheet's drag rules.
|
||||
* @param css - stylesheet text.
|
||||
* @returns the declaring selectors, in source order.
|
||||
*/
|
||||
function dragSelectors(css: string): string[] {
|
||||
return parseRules(css)
|
||||
.filter(rule => rule.declarations
|
||||
.some(([property, value]) => /^(-webkit-)?app-region$/.test(property) && /^drag(\s|!|$)/.test(value)))
|
||||
.filter(declaresDrag)
|
||||
.map(rule => rule.selectors.join(', '))
|
||||
}
|
||||
|
||||
/**
|
||||
* Allowlist violations across a stylesheet set.
|
||||
* @param files - stylesheet paths to scan.
|
||||
* @param read - returns one stylesheet's text.
|
||||
* @returns `path selectors` for each drag rule outside DRAG_OWNERS.
|
||||
* Every sheet in the package tree that declares a window drag region.
|
||||
* @returns one entry per declaring sheet, sorted by path, selectors in source order.
|
||||
*/
|
||||
function offenders(files: string[], read: (file: string) => string): string[] {
|
||||
return files
|
||||
.filter(file => !DRAG_OWNERS.some(owner => file.endsWith(`/${owner}`)))
|
||||
.flatMap(file => dragSelectors(read(file)).map(selectors => `${file} ${selectors}`))
|
||||
function dragOwners(): { file: string; selectors: string[] }[] {
|
||||
return packageStylesheets()
|
||||
.flatMap((file) => {
|
||||
const selectors = dragSelectors(readFileSync(file, 'utf8'))
|
||||
return selectors.length === 0 ? [] : [{ file: underPackages(file), selectors }]
|
||||
})
|
||||
.sort((left, right) => left.file.localeCompare(right.file))
|
||||
}
|
||||
|
||||
/**
|
||||
* Opening tags of one TSX file. Braces and quoted strings are kept whole, so a
|
||||
* `>` inside a prop expression or an attribute value does not end the tag.
|
||||
* @param source - TSX file text.
|
||||
* @returns each opening tag's text, in source order.
|
||||
*/
|
||||
function openingTags(source: string): string[] {
|
||||
const tags: string[] = []
|
||||
let index = 0
|
||||
while (index < source.length) {
|
||||
const open = source.indexOf('<', index)
|
||||
if (open === -1) break
|
||||
if (!/[A-Za-z]/.test(source[open + 1] ?? '')) {
|
||||
index = open + 1
|
||||
continue
|
||||
}
|
||||
let depth = 0
|
||||
let quote: string | undefined
|
||||
let end = open + 1
|
||||
for (; end < source.length; end += 1) {
|
||||
const character = source[end]!
|
||||
if (quote !== undefined) {
|
||||
if (character === quote) quote = undefined
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'" || character === '`') {
|
||||
quote = character
|
||||
continue
|
||||
}
|
||||
if (character === '{') depth += 1
|
||||
else if (character === '}') depth -= 1
|
||||
else if (character === '>' && depth === 0) break
|
||||
}
|
||||
tags.push(source.slice(open, end + 1))
|
||||
index = end + 1
|
||||
}
|
||||
return tags
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a JSX tag references a manifest row's class, as `css.<row>`.
|
||||
* @param tag - one opening tag's text.
|
||||
* @param row - the manifest row.
|
||||
* @returns true when the tag names the row's class through any local alias.
|
||||
*/
|
||||
function referencesRow(tag: string, row: ChromeRow): boolean {
|
||||
return new RegExp(`[\\w$]+\\.${row.selector.slice(1)}\\b`).test(tag)
|
||||
}
|
||||
|
||||
/**
|
||||
* A package-relative path for a failure message.
|
||||
* @param file - absolute path under packages/.
|
||||
* @returns the path below packages/.
|
||||
*/
|
||||
function underPackages(file: string): string {
|
||||
return file.slice(file.lastIndexOf('/packages/') + '/packages/'.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* One declaration's authored value.
|
||||
* @param file - stylesheet path relative to packages/.
|
||||
* @param selector - selector of the declaring rule.
|
||||
* @param property - declaration name.
|
||||
* @returns the value text, or undefined when the rule or declaration is absent.
|
||||
*/
|
||||
function declaredValue(file: string, selector: string, property: string): string | undefined {
|
||||
const path = packageStylesheets().find(candidate => candidate.endsWith(`/${file}`))
|
||||
/* v8 ignore next -- the manifest names shipped stylesheets; a missing one is reported by the height assertion below. */
|
||||
if (path === undefined) return undefined
|
||||
const rule = parseRules(readFileSync(path, 'utf8')).find(candidate => candidate.selectors.includes(selector))
|
||||
return rule?.declarations.find(([name]) => name === property)?.[1]
|
||||
}
|
||||
|
||||
describe('window drag-region ownership', () => {
|
||||
@@ -53,32 +227,50 @@ describe('window drag-region ownership', () => {
|
||||
expect(dragSelectors('.a { app-region: no-drag !important; }')).toEqual([])
|
||||
})
|
||||
|
||||
it('reports a drag rule outside the allowlist and exempts the owners', () => {
|
||||
const drag = '.evil { -webkit-app-region: drag; }'
|
||||
// Fixture path avoids the packages/ prefix: verify-package-paths checks such references against disk.
|
||||
expect(offenders(['/elsewhere/client/ui-evil/src/client/Evil.module.css'], () => drag))
|
||||
.toEqual(['/elsewhere/client/ui-evil/src/client/Evil.module.css .evil'])
|
||||
expect(offenders([`/packages/${DRAG_OWNERS[0]!}`], () => drag)).toEqual([])
|
||||
it('declares the window drag surface in exactly two places', () => {
|
||||
// Rows mark themselves instead of each sheet declaring its own drag, so the
|
||||
// tree holds one darwin rule — the mark rule in the shell sheet — plus the
|
||||
// Windows caption row, which is that platform's own chrome. A third
|
||||
// declaration, a per-sheet row rule, or a darwin band on the frame fails here.
|
||||
expect(dragOwners()).toEqual([
|
||||
{ file: WINDOWS_CAPTION, selectors: [WINDOWS_CAPTION_SELECTOR] },
|
||||
{ file: SHELL_SHEET, selectors: [SHELL_DRAG_SELECTOR] },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps -webkit-app-region: drag inside the owned chrome bands', () => {
|
||||
expect(offenders(packageStylesheets(), file => readFileSync(file, 'utf8'))).toEqual([])
|
||||
})
|
||||
|
||||
it('limits the Platform overlay drag region to its return bar and excludes the return button', () => {
|
||||
const file = packageStylesheets().find(candidate => candidate.endsWith('/client/ui-settings-account/src/client/PlatformOverlay.module.css'))!
|
||||
const css = readFileSync(file, 'utf8')
|
||||
expect(dragSelectors(css)).toEqual(['.header'])
|
||||
expect(parseRules(css).find(rule => rule.selectors.includes('.back'))?.declarations)
|
||||
.toContainEqual(['app-region', 'no-drag'])
|
||||
})
|
||||
|
||||
it('still finds a drag band in every allowlisted owner', () => {
|
||||
// An owner that stops declaring drag should leave the allowlist with it.
|
||||
for (const owner of DRAG_OWNERS) {
|
||||
const file = packageStylesheets().find(candidate => candidate.endsWith(`/${owner}`))
|
||||
expect(file, owner).toBeDefined()
|
||||
expect(dragSelectors(readFileSync(file!, 'utf8')), owner).not.toEqual([])
|
||||
it('declares every pinned chrome row at the height the manifest pins', () => {
|
||||
for (const row of CHROME_ROWS) {
|
||||
if (row.height === undefined) continue
|
||||
expect(declaredValue(row.file, row.selector, row.height[0]), `${row.selector} ${row.height[0]}`)
|
||||
.toBe(row.height[1])
|
||||
if (row.inset !== undefined) {
|
||||
expect(declaredValue(row.file, row.selector, row.inset[0]), `${row.selector} ${row.inset[0]}`)
|
||||
.toBe(row.inset[1])
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('marks its own window drag in every chrome row’s markup', () => {
|
||||
for (const row of CHROME_ROWS) {
|
||||
const marked = openingTags(readFileSync(join(PACKAGES_DIR, row.markup), 'utf8'))
|
||||
.filter(tag => DRAG_MARK_PATTERN.test(tag) && referencesRow(tag, row))
|
||||
expect(marked, `${row.markup} ${row.selector}`).toHaveLength(1)
|
||||
}
|
||||
})
|
||||
|
||||
it('finds the drag mark on exactly the chrome rows the manifest names', () => {
|
||||
// A mark on any other element — a content container, a wrapper, a row that
|
||||
// has no manifest entry — would drag the window over something that is not
|
||||
// window chrome, so the two lists must match exactly.
|
||||
const found: string[] = []
|
||||
for (const file of packageFiles(name => name.endsWith('.tsx'))) {
|
||||
if (!file.includes('/src/')) continue
|
||||
for (const tag of openingTags(readFileSync(file, 'utf8'))) {
|
||||
if (!DRAG_MARK_PATTERN.test(tag)) continue
|
||||
const row = CHROME_ROWS.find(candidate => file.endsWith(`/${candidate.markup}`) && referencesRow(tag, candidate))
|
||||
found.push(row === undefined ? `${underPackages(file)} <unmatched ${DRAG_MARK} tag>` : `${row.markup} ${row.selector}`)
|
||||
}
|
||||
}
|
||||
expect(found.sort()).toEqual(CHROME_ROWS.map(row => `${row.markup} ${row.selector}`).sort())
|
||||
})
|
||||
})
|
||||
@@ -72,21 +72,30 @@ export function varReferences(value: string): string[] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Every CSS file shipped as package source, excluding build output and
|
||||
* installed dependencies.
|
||||
* @returns absolute stylesheet paths under packages/, `/`-separated on every
|
||||
* platform so specs can match repo-relative suffixes verbatim.
|
||||
* Every file shipped as package source whose name the predicate accepts,
|
||||
* excluding build output and installed dependencies.
|
||||
* @param accepts - filename predicate.
|
||||
* @returns absolute paths under packages/, `/`-separated on every platform so
|
||||
* specs can match repo-relative suffixes verbatim.
|
||||
*/
|
||||
export function packageStylesheets(): string[] {
|
||||
export function packageFiles(accepts: (name: string) => boolean): string[] {
|
||||
const found: string[] = []
|
||||
const walk = (dir: string): void => {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name !== 'node_modules' && entry.name !== 'lib' && entry.name !== 'dist') walk(path)
|
||||
} else if (entry.name.endsWith('.css')) found.push(path.replaceAll('\\', '/'))
|
||||
} else if (accepts(entry.name)) found.push(path.replaceAll('\\', '/'))
|
||||
}
|
||||
}
|
||||
walk(PACKAGES_DIR)
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* Every CSS file shipped as package source.
|
||||
* @returns absolute stylesheet paths, `/`-separated on every platform.
|
||||
*/
|
||||
export function packageStylesheets(): string[] {
|
||||
return packageFiles(name => name.endsWith('.css'))
|
||||
}
|
||||
@@ -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/web/README.md
|
||||
README.md: 2cedd1748973e13ad1b9a3b0ef89c0101d4ffb09
|
||||
README.zh.md: 882c338339b725f82aae4d931ab85a6d14650b42
|
||||
README.md: cefb0f638b4a287d7fb92e2a4de624f2cba2481a
|
||||
README.zh.md: 534c4d9d7ab0103ed6fbb84ee2399dc37cf02d4c
|
||||
@@ -76,12 +76,14 @@ The boot kernel delegates manifest entry creation to Client Modules so live grap
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | Library entry: `AppWebEntry`, `getStaticModules`, platform tables |
|
||||
| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: module stage, boot page, immediate-tier prefetch, then `bootClient` + `mountClient` |
|
||||
| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`: module stage, boot page, immediate-tier prefetch, window drag-rect watcher install, then `bootClient` + `mountClient` |
|
||||
| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`: Loader mount, one entry per manifest row, activation audit |
|
||||
| [`src/mount.ts`](src/mount.ts) | `mountClient`: renderer handoff through a `uiRenderer` dependency fiber |
|
||||
| [`src/boot-page.ts`](src/boot-page.ts) | Framework-free boot page: spinner, per-entry status, failure rendering |
|
||||
| [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`: the implicit external baseline |
|
||||
| [`src/seed.ts`](src/seed.ts) | Static module table handed to the loader at boot |
|
||||
| [`src/window-drag/regions.ts`](src/window-drag/regions.ts) | The darwin app-region composition model, and the interactive selector `base.css` subtracts |
|
||||
| [`src/window-drag/recall.ts`](src/window-drag/recall.ts) | The shell's one window drag-rect watcher (electron/electron#32341): measure the marked rows per frame and pulse the recall mark while they move |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -76,12 +76,14 @@ kind: "package-library"
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| [`src/index.ts`](src/index.ts) | 库入口:`AppWebEntry`、`getStaticModules`、平台表 |
|
||||
| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:模块阶段、启动页、immediately 层级预取,随后调用 `bootClient` + `mountClient` |
|
||||
| [`src/boot.ts`](src/boot.ts) | `AppWebEntry`:模块阶段、启动页、immediately 层级预取、安装窗口拖拽矩形 watcher,随后调用 `bootClient` + `mountClient` |
|
||||
| [`src/boot-client.ts`](src/boot-client.ts) | `bootClient` / `assertEntriesActive`:挂载 Loader、每个 manifest 行一个 entry、激活审计 |
|
||||
| [`src/mount.ts`](src/mount.ts) | `mountClient`:经 `uiRenderer` 依赖 fiber 完成渲染器交接 |
|
||||
| [`src/boot-page.ts`](src/boot-page.ts) | 无框架启动页:spinner、逐 entry 状态、失败渲染 |
|
||||
| [`src/platform.ts`](src/platform.ts) | `PLATFORM_MODULES` / `PRELOADED_CLIENT_EXTERNALS`:隐式 external 基座 |
|
||||
| [`src/seed.ts`](src/seed.ts) | 启动时交给 loader 的静态模块表 |
|
||||
| [`src/window-drag/regions.ts`](src/window-drag/regions.ts) | darwin app-region 组合模型,以及 `base.css` 减除的交互元素选择器 |
|
||||
| [`src/window-drag/recall.ts`](src/window-drag/recall.ts) | 外壳唯一的窗口拖拽矩形 watcher(electron/electron#32341):逐帧测量被打标行,并在它们移动期间脉冲 recall 标记 |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -52,23 +52,41 @@ html[data-platform='darwin'] body[data-ds-dark-theme] {
|
||||
--dsw-specific-menu: rgba(48, 49, 54, 0.94);
|
||||
}
|
||||
|
||||
/* macOS desktop: the app frame publishes window drag bands, and Electron
|
||||
composes app-regions from geometry, not hit testing — a surface covering a
|
||||
band would otherwise drag the window instead of receiving clicks. Every
|
||||
overlay portals to document.body beside #root, so one rule subtracts them
|
||||
all; drag surfaces all live inside #root. */
|
||||
/* macOS desktop: the one drag declaration. A chrome row marks itself in markup
|
||||
(`data-window-drag`) and its own box becomes its draggable geometry, so no fixed
|
||||
band has to match any row's height; the ui-theme app-region gate's CHROME_ROWS
|
||||
manifest is the list of rows that must carry the mark. Electron composes
|
||||
app-regions from geometry, not hit testing, so a surface covering a row would
|
||||
otherwise drag the window instead of receiving clicks; every covering overlay
|
||||
portals to document.body beside #root, which also puts it after every drag row
|
||||
in document order, and one rule subtracts them all. */
|
||||
html[data-platform='darwin'] [data-window-drag] {
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
/* One-frame app-region pulse that forces Electron to recollect the window's drag
|
||||
rects (electron#32341): a change to a computed app-region value is what dirties
|
||||
them. The shell's window drag watcher (window-drag/recall.ts) sets this
|
||||
attribute on the body for each frame in which a marked row's box moved, and
|
||||
clears it on the frame that finds the geometry settled. The box it subtracts
|
||||
is the body's own: the row marks inside still declare drag and win over it in
|
||||
document order, so the pulse changes nothing about the composed surface — it
|
||||
only makes Electron collect it again. */
|
||||
html[data-platform='darwin'] [data-window-drag-recall] {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
html[data-platform='darwin'] body > :not(#root) {
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
/* Inside #root, interactive controls and raised surfaces (dialogs, menus)
|
||||
subtract themselves from any drag band they overlap, wherever they render.
|
||||
Electron composes app-regions from geometry in document order, so this works
|
||||
only because the bands are declared before all content (AppFrame's first
|
||||
child; the sidebar rows that precede the column's overlay slots) — a drag
|
||||
rule on a content container would override every overlay mounted earlier in
|
||||
the DOM. One selector on the native elements, ARIA roles, and tabindexed
|
||||
custom widgets replaces per-component no-drag opt-outs. */
|
||||
subtract themselves from any drag row they overlap, wherever they render.
|
||||
Electron composes app-regions from geometry in document order, so a drag rule
|
||||
must sit on a chrome row that precedes the overlays covering it — never on a
|
||||
content container, which would override every overlay mounted earlier in the
|
||||
DOM. One selector on the native elements, ARIA roles, and tabindexed custom
|
||||
widgets replaces per-component no-drag opt-outs. */
|
||||
html[data-platform='darwin'] :is(
|
||||
button, a, input, select, textarea, summary, [contenteditable='true'], [tabindex],
|
||||
[role='dialog'], [role='alertdialog'], [role='menu'], [role='listbox'], [role='tooltip'],
|
||||
|
||||
@@ -13,6 +13,7 @@ import { bootClient } from './boot-client.ts'
|
||||
import { BootPage } from './boot-page.ts'
|
||||
import { mountClient } from './mount.ts'
|
||||
import { getStaticModules } from './seed.ts'
|
||||
import { installWindowDragRecall } from './window-drag/recall.ts'
|
||||
import './base.css'
|
||||
|
||||
/** Module transport hook replaced by jsdom tests. */
|
||||
@@ -24,6 +25,7 @@ export class AppWebEntry {
|
||||
private readonly seams: BootSeams | undefined
|
||||
private readonly page: BootPage
|
||||
private ctx: Context | undefined
|
||||
private stopDragRecall: (() => void) | undefined
|
||||
private modules!: ClientModuleSystem
|
||||
private manifest!: BootManifest
|
||||
|
||||
@@ -87,6 +89,11 @@ export class AppWebEntry {
|
||||
if (onFailure === undefined || state !== 'failed') this.page.setState(name, state)
|
||||
},
|
||||
})
|
||||
// The shell owns the one watcher that keeps Electron's window drag rects in
|
||||
// step with the rows that own them (electron#32341), so no chrome row has to
|
||||
// know that trap. It installs before the first mount, so the surface the
|
||||
// renderer draws is the one the first frame measures.
|
||||
this.stopDragRecall = installWindowDragRecall({ document: this.container.ownerDocument })
|
||||
await mountClient(ctx, this.container)
|
||||
} catch (reason) {
|
||||
console.error(reason)
|
||||
@@ -97,6 +104,8 @@ export class AppWebEntry {
|
||||
|
||||
/** Dispose the client plugin tree and whichever page owns the mount point. */
|
||||
async dispose(): Promise<void> {
|
||||
this.stopDragRecall?.()
|
||||
this.stopDragRecall = undefined
|
||||
const ctx = this.ctx
|
||||
this.ctx = undefined
|
||||
if (ctx !== undefined) await ctx.fiber.dispose()
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Window drag recollection for the macOS desktop shell. Electron rebuilds the
|
||||
* window's `-webkit-app-region` rects only when a style pass changes a computed
|
||||
* app-region value (electron#32341), and Blink skips hidden boxes when it collects
|
||||
* them, so a chrome row that appears, moves, or disappears without such a change
|
||||
* leaves the native window holding the previous geometry: a press inside the row
|
||||
* drags from where it used to be, and a row that slid under the pointer keeps its
|
||||
* controls unreachable or its blank runs undraggable. This module owns the one
|
||||
* watcher that closes that gap, so no chrome row has to know the trap.
|
||||
*
|
||||
* A row's viewport box is the measurement: every row in this composition shows and
|
||||
* hides by mounting, unmounting, or moving, so a changed box is the signal. The
|
||||
* watcher starts on a DOM change that touches a marked row — inside it, on it, on
|
||||
* an ancestor that holds one, on a container it lives in, or adding or removing one
|
||||
* — on a marked row's box resizing, or on a transition or animation starting on an
|
||||
* element that holds one, and then measures every marked row once per frame for as
|
||||
* long as any box keeps changing. Each frame that changed sets the recall mark, and
|
||||
* the frame that finds the same geometry clears it and stops once the short grace
|
||||
* window below has passed: that clear is the collection the steady state comes from.
|
||||
* @module @deepseek-ai/dsh-client-web/src/window-drag/recall
|
||||
*/
|
||||
import { DRAG_MARK, RECALL_MARK } from './regions.ts'
|
||||
|
||||
/** The selector of an element that owns a window drag region. */
|
||||
const ROW_SELECTOR = `[${DRAG_MARK}]`
|
||||
|
||||
/**
|
||||
* Quiet frames a report tolerates before the loop stops again. A CSS transition's
|
||||
* first frame still reports the box's from-value, so a report that arrives before
|
||||
* the surface starts moving must not end the loop on its first unchanged sample.
|
||||
*/
|
||||
const GRACE_FRAMES = 2
|
||||
|
||||
/** The event types that mark a box starting or finishing a transition or animation. */
|
||||
const MOTION_EVENTS = ['transitionstart', 'transitionend', 'animationstart', 'animationend'] as const
|
||||
|
||||
/** How the shell installs the one window drag watcher. */
|
||||
export interface WindowDragRecallOptions {
|
||||
/** Document whose marked rows are watched. */
|
||||
readonly document: Document
|
||||
/**
|
||||
* Schedules one frame callback and returns its canceller. The callback runs
|
||||
* asynchronously, as `requestAnimationFrame` does.
|
||||
*/
|
||||
readonly scheduleFrame?: (frame: () => void) => () => void
|
||||
/**
|
||||
* Watches one row's border box and calls `changed` whenever it resizes, returning
|
||||
* a disposer. Defaults to a `ResizeObserver`; a document without one watches no
|
||||
* boxes and relies on the DOM changes alone.
|
||||
*/
|
||||
readonly watchBox?: (row: Element, changed: () => void) => () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the window's marked drag rows and pulse the recall mark whenever their
|
||||
* geometry can have moved. Installs nothing outside the darwin platform, where no
|
||||
* app-region rule exists.
|
||||
* @param options - the document to watch and the two scheduling seams.
|
||||
* @returns a disposer that stops watching, drops any pending frame, and clears the mark.
|
||||
*/
|
||||
export function installWindowDragRecall(options: WindowDragRecallOptions): () => void {
|
||||
const doc = options.document
|
||||
if (doc.documentElement.dataset.platform !== 'darwin') return () => {}
|
||||
const scheduleFrame = options.scheduleFrame ?? defaultScheduleFrame
|
||||
const watchBox = options.watchBox ?? defaultWatchBox
|
||||
/** The last frame's measurement per row, the baseline a change is read against. */
|
||||
let geometry = new Map<Element, string>()
|
||||
/** One box watcher per row currently rendered. */
|
||||
const boxes = new Map<Element, () => void>()
|
||||
let scheduled = false
|
||||
let disposed = false
|
||||
let cancelPending: (() => void) | undefined
|
||||
/** Quiet frames left before the loop may stop; refreshed by every report. */
|
||||
let grace = 0
|
||||
|
||||
/**
|
||||
* Measure every marked row and pulse when any box differs from the last frame's.
|
||||
* Runs once per frame while the surface keeps moving.
|
||||
*/
|
||||
const measure = (): void => {
|
||||
// Clearing first is part of the pulse, and the clear must be flushed before the
|
||||
// measurement below reads it: one style pass between the two writes is what makes
|
||||
// Electron observe a computed app-region value that changed back. Reordering these
|
||||
// two statements silently drops the pulse.
|
||||
doc.body.removeAttribute(RECALL_MARK)
|
||||
const rows = Array.from(doc.querySelectorAll(ROW_SELECTOR))
|
||||
watchBoxes(rows)
|
||||
const next = new Map(rows.map(row => [row, readBox(row)] as const))
|
||||
const moved = rows.length !== geometry.size
|
||||
|| rows.some(row => geometry.get(row) !== next.get(row))
|
||||
geometry = next
|
||||
if (moved) {
|
||||
doc.body.setAttribute(RECALL_MARK, '')
|
||||
grace = 0
|
||||
schedule()
|
||||
return
|
||||
}
|
||||
if (grace === 0) return
|
||||
grace -= 1
|
||||
schedule()
|
||||
}
|
||||
|
||||
/** Report that the surface may be moving, opening the grace window again. */
|
||||
const arm = (): void => {
|
||||
grace = GRACE_FRAMES
|
||||
schedule()
|
||||
}
|
||||
|
||||
/** Schedule one frame, keeping at most one pending. */
|
||||
const schedule = (): void => {
|
||||
if (scheduled) return
|
||||
scheduled = true
|
||||
cancelPending = scheduleFrame(() => {
|
||||
scheduled = false
|
||||
cancelPending = undefined
|
||||
// A seam that cannot cancel has already handed the frame over; a disposed
|
||||
// watcher must not measure, mark, or re-arm from it.
|
||||
if (disposed) return
|
||||
measure()
|
||||
})
|
||||
}
|
||||
|
||||
/** Keep one box watcher per live row, re-watching when its frame moves or unmounts. */
|
||||
const watchBoxes = (rows: readonly Element[]): void => {
|
||||
for (const [row, dispose] of boxes) {
|
||||
if (rows.includes(row)) continue
|
||||
dispose()
|
||||
boxes.delete(row)
|
||||
}
|
||||
for (const row of rows) {
|
||||
if (boxes.has(row)) continue
|
||||
boxes.set(row, watchBox(row, arm))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a motion event can be moving a marked row: the transitioning element is
|
||||
* one, holds one, or lives inside one.
|
||||
* @param target - the event target to classify.
|
||||
* @returns true when the drag surface has to be re-measured.
|
||||
*/
|
||||
const movesRows = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false
|
||||
return target.closest(ROW_SELECTOR) !== null || target.querySelector(ROW_SELECTOR) !== null
|
||||
}
|
||||
|
||||
/** Re-arm for a transition or animation that can be sliding a marked row. */
|
||||
const onMotion = (event: Event): void => {
|
||||
if (movesRows(event.target)) arm()
|
||||
}
|
||||
|
||||
const observer = new MutationObserver((records) => {
|
||||
if (records.some(record => touchesRows(record, geometry.keys()))) arm()
|
||||
})
|
||||
observer.observe(doc.body, { subtree: true, childList: true, attributes: true, characterData: true })
|
||||
for (const type of MOTION_EVENTS) doc.addEventListener(type, onMotion, true)
|
||||
// The first frame collects the surface as it stands today.
|
||||
arm()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
observer.disconnect()
|
||||
for (const type of MOTION_EVENTS) doc.removeEventListener(type, onMotion, true)
|
||||
if (cancelPending !== undefined) cancelPending()
|
||||
for (const dispose of boxes.values()) dispose()
|
||||
boxes.clear()
|
||||
geometry.clear()
|
||||
doc.body.removeAttribute(RECALL_MARK)
|
||||
}
|
||||
}
|
||||
|
||||
/** The box a row owns this frame, in viewport pixels. */
|
||||
function readBox(row: Element): string {
|
||||
const rect = row.getBoundingClientRect()
|
||||
return `${rect.x},${rect.y},${rect.width},${rect.height}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether one DOM change can have moved the drag surface: a child list change in a
|
||||
* container that holds a marked row, or an attribute or text change that lands on a
|
||||
* marked row, inside one, or on an element holding one (a panel's open flag, a
|
||||
* column's width). A child list change in a container without a marked row is left
|
||||
* alone — a row whose box grows because its content did is a resize, which the box
|
||||
* watcher reports — and so is a mounted overlay, whose own app-region value is a
|
||||
* change of its own.
|
||||
* @param record - the mutation record to classify.
|
||||
* @param rows - the rows the last frame measured.
|
||||
* @returns true when the drag surface has to be re-measured.
|
||||
*/
|
||||
function touchesRows(record: MutationRecord, rows: Iterable<Element>): boolean {
|
||||
// The pulse writes its own mark on the body; that write is not a movement, and
|
||||
// treating it as one would re-arm the watcher from its own output every frame.
|
||||
if (record.attributeName === RECALL_MARK) return false
|
||||
for (const node of [...record.addedNodes, ...record.removedNodes]) {
|
||||
if (node instanceof Element && (node.matches(ROW_SELECTOR) || node.querySelector(ROW_SELECTOR) !== null)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
const target = record.target instanceof Element ? record.target : record.target.parentElement
|
||||
/* v8 ignore next -- the observer watches body's subtree, so a text target always has a parent element. */
|
||||
if (target === null) return false
|
||||
if (record.type === 'childList') {
|
||||
// A plain sibling inserted or removed beside a marked row shifts that row without
|
||||
// resizing it, so the box watcher never reports it. Only the container a row lives
|
||||
// in can move it, which keeps streamed appends outside the surface out of here.
|
||||
for (const row of rows) if (target.contains(row)) return true
|
||||
return false
|
||||
}
|
||||
return target.closest(ROW_SELECTOR) !== null || target.querySelector(ROW_SELECTOR) !== null
|
||||
}
|
||||
|
||||
/** Schedule one frame through the browser's animation frame queue. */
|
||||
function defaultScheduleFrame(frame: () => void): () => void {
|
||||
const handle = requestAnimationFrame(frame)
|
||||
return () => { cancelAnimationFrame(handle) }
|
||||
}
|
||||
|
||||
/** Watch one row's border box where the document has a `ResizeObserver`. */
|
||||
function defaultWatchBox(row: Element, changed: () => void): () => void {
|
||||
if (typeof ResizeObserver !== 'function') return () => {}
|
||||
const observer = new ResizeObserver(changed)
|
||||
observer.observe(row)
|
||||
return () => { observer.disconnect() }
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* The window drag-region contract for the macOS desktop shell: Electron hands
|
||||
* the page's `-webkit-app-region` boxes to the native window, which hit-tests
|
||||
* them by geometry in DOM order, ignoring stacking. The property does not inherit:
|
||||
* Blink collects one box per element whose own computed value is not `none`,
|
||||
* skipping subtrees that are not visible, and the window applies them in that
|
||||
* order: `drag` adds geometry, `no-drag` removes it. The composition is therefore
|
||||
* equivalent to "the last matching box decides" — a point is draggable when the
|
||||
* last collected box containing it is `drag`.
|
||||
*
|
||||
* This module is the executable statement of that rule. Production CSS authors
|
||||
* the boxes; tests and the browser coverage scenario both decide points through
|
||||
* this module so a claim about coverage has one meaning.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One collected app-region box, in DOM order.
|
||||
*
|
||||
* Coordinates are viewport CSS pixels, the space `getBoundingClientRect`
|
||||
* reports, so collection and composition never rescale.
|
||||
*/
|
||||
export interface RegionRect {
|
||||
/** Left edge, viewport CSS pixels. */
|
||||
readonly x: number
|
||||
/** Top edge, viewport CSS pixels. */
|
||||
readonly y: number
|
||||
/** Box width in CSS pixels. */
|
||||
readonly width: number
|
||||
/** Box height in CSS pixels. */
|
||||
readonly height: number
|
||||
/** Whether this box declares `drag` (true) or `no-drag` (false). */
|
||||
readonly draggable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The attribute a chrome row puts on the element that owns its window drag.
|
||||
* ui-web `base.css` turns the mark into the one darwin drag rule, so the row's
|
||||
* own box is the window's draggable geometry.
|
||||
*/
|
||||
export const DRAG_MARK = 'data-window-drag'
|
||||
|
||||
/**
|
||||
* The attribute the shell sets for one frame to make Electron recollect the
|
||||
* window's drag rects (electron#32341). While it is set, ui-web `base.css`
|
||||
* subtracts the marked box; the row marks inside it still declare drag and win in
|
||||
* document order, so the mark's only effect is that app-region values changed.
|
||||
*/
|
||||
export const RECALL_MARK = 'data-window-drag-recall'
|
||||
|
||||
/**
|
||||
* The interactive-element selector that subtracts a control from any drag row it
|
||||
* overlaps. ui-web `base.css` declares it for the darwin platform; this constant
|
||||
* is the single source both that sheet and its contract spec compare against, so
|
||||
* the two cannot drift.
|
||||
*/
|
||||
export const INTERACTIVE_SELECTOR = [
|
||||
'button', 'a', 'input', 'select', 'textarea', 'summary', "[contenteditable='true']", '[tabindex]',
|
||||
"[role='dialog']", "[role='alertdialog']", "[role='menu']", "[role='listbox']", "[role='tooltip']",
|
||||
"[role='button']", "[role='link']", "[role='tab']", "[role='menuitem']", "[role='menuitemcheckbox']",
|
||||
"[role='menuitemradio']", "[role='option']", "[role='checkbox']", "[role='radio']", "[role='switch']",
|
||||
"[role='slider']", "[role='combobox']", "[role='textbox']",
|
||||
].join(', ')
|
||||
|
||||
/**
|
||||
* Whether a box contains a viewport point. Edges belong to the box, matching
|
||||
* the half-open rectangles the window hit test uses.
|
||||
* @param rect - the collected box.
|
||||
* @param x - viewport x in CSS pixels.
|
||||
* @param y - viewport y in CSS pixels.
|
||||
* @returns true when the point lies inside the box.
|
||||
*/
|
||||
export function containsPoint(rect: RegionRect, x: number, y: number): boolean {
|
||||
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the window would drag when pressed at a point.
|
||||
* @param regions - collected boxes in DOM order.
|
||||
* @param x - viewport x in CSS pixels.
|
||||
* @param y - viewport y in CSS pixels.
|
||||
* @returns true when the last box containing the point is a drag box; false
|
||||
* when it is a no-drag box or no box contains the point.
|
||||
*/
|
||||
export function isDraggableAt(regions: readonly RegionRect[], x: number, y: number): boolean {
|
||||
let draggable = false
|
||||
for (const region of regions) {
|
||||
if (containsPoint(region, x, y)) draggable = region.draggable
|
||||
}
|
||||
return draggable
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
/** Shell base styles stay independent from the dynamically loaded theme bundle. */
|
||||
/**
|
||||
* Shell base styles stay independent from the dynamically loaded theme bundle,
|
||||
* and the shell sheet is where the darwin window drag surface is declared.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { INTERACTIVE_SELECTOR } from '../src/window-drag/regions.ts'
|
||||
|
||||
const THEME_PACKAGE = '@deepseek-ai/dsh-client-ui-theme'
|
||||
const baseCss = readFileSync(fileURLToPath(new URL('../src/base.css', import.meta.url)), 'utf8')
|
||||
@@ -35,6 +39,37 @@ describe('web shell base.css', () => {
|
||||
expect(baseCss).not.toContain(THEME_PACKAGE)
|
||||
})
|
||||
|
||||
it('declares exactly the interactive subtraction the drag contract publishes', () => {
|
||||
// One source for the selector: the model constant. The darwin rule must
|
||||
// declare the same list, so a new interactive role is added in one place
|
||||
// and a drift between the sheet and the composition model fails here.
|
||||
const rule = /html\[data-platform='darwin'\] :is\(([^)]*)\)\s*\{[^}]*app-region:\s*no-drag/.exec(normalizedCss)
|
||||
expect(rule, 'base.css declares no darwin interactive no-drag rule').not.toBeNull()
|
||||
const declared = rule![1]!.split(',').map(part => part.trim()).join(', ')
|
||||
expect(declared).toBe(INTERACTIVE_SELECTOR)
|
||||
})
|
||||
|
||||
it('declares the darwin drag surface through the mark chrome rows set', () => {
|
||||
// One rule for the whole surface: a chrome row marks itself
|
||||
// `data-window-drag`, so its own box is the draggable geometry and no sheet
|
||||
// has to declare drag per row. The ui-theme app-region gate holds the other
|
||||
// half — that this is the only darwin drag rule in the package tree.
|
||||
expect(normalizedCss).toContain(
|
||||
"html[data-platform='darwin'] [data-window-drag] { -webkit-app-region: drag; }",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the one-frame recall mark out of the drag surface while it is set', () => {
|
||||
// The shell's drag watcher sets the recall mark while a marked row's box is
|
||||
// moving (electron#32341 recollects the window's drag rects only on a
|
||||
// computed-value change). The box it subtracts is the body's own, and the row
|
||||
// marks inside it still win in document order, so the pulse changes what
|
||||
// Electron collects without changing the composed surface.
|
||||
expect(normalizedCss).toContain(
|
||||
"html[data-platform='darwin'] [data-window-drag-recall] { -webkit-app-region: no-drag; }",
|
||||
)
|
||||
})
|
||||
|
||||
it('auto-spaces prose while preserving literal content', () => {
|
||||
expect(baseCss).toMatch(/body\s*\{[^}]*text-autospace:\s*normal;/)
|
||||
expect(normalizedCss).toContain(
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The shell's one window drag watcher, driven through its two scheduling seams:
|
||||
* the spec owns the frame queue and the per-row box watchers, so every pulse is
|
||||
* decided by the test rather than by wall-clock timing.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { DRAG_MARK, RECALL_MARK } from '../src/window-drag/regions.ts'
|
||||
import { installWindowDragRecall } from '../src/window-drag/recall.ts'
|
||||
|
||||
/** A viewport box a row reports. */
|
||||
type Box = readonly [x: number, y: number, width: number, height: number]
|
||||
|
||||
/** The frame queue and the box watchers the watcher under test was handed. */
|
||||
interface Harness {
|
||||
readonly frames: () => number
|
||||
readonly runFrames: () => void
|
||||
readonly watched: () => Element[]
|
||||
readonly resize: (row: Element) => void
|
||||
readonly disposedWatchers: () => number
|
||||
readonly scheduleFrame: (frame: () => void) => () => void
|
||||
readonly watchBox: (row: Element, changed: () => void) => () => void
|
||||
}
|
||||
|
||||
/** The seams and views one spec needs to drive the watcher. */
|
||||
function harness(): Harness {
|
||||
const queue: (() => void)[] = []
|
||||
const watchers = new Map<Element, () => void>()
|
||||
const resizers = new Map<Element, () => void>()
|
||||
let disposed = 0
|
||||
return {
|
||||
frames: () => queue.length,
|
||||
runFrames: () => {
|
||||
const pending = queue.splice(0, queue.length)
|
||||
for (const frame of pending) frame()
|
||||
},
|
||||
watched: () => [...watchers.keys()],
|
||||
resize: (row) => {
|
||||
const changed = resizers.get(row)
|
||||
if (changed === undefined) throw new Error('the row is not watched')
|
||||
changed()
|
||||
},
|
||||
disposedWatchers: () => disposed,
|
||||
scheduleFrame: (frame) => {
|
||||
queue.push(frame)
|
||||
return () => {
|
||||
const index = queue.indexOf(frame)
|
||||
if (index !== -1) queue.splice(index, 1)
|
||||
}
|
||||
},
|
||||
watchBox: (row, changed) => {
|
||||
watchers.set(row, changed)
|
||||
resizers.set(row, changed)
|
||||
return () => {
|
||||
watchers.delete(row)
|
||||
resizers.delete(row)
|
||||
disposed += 1
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Place a row's reported box, so a measurement change is the spec's decision. */
|
||||
function place(row: Element, box: Box): void {
|
||||
const [x, y, width, height] = box
|
||||
Object.defineProperty(row, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
x, y, width, height, top: y, left: x, right: x + width, bottom: y + height, toJSON: () => ({}),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/** Append one marked row to the body. */
|
||||
function row(box: Box = [0, 0, 100, 52]): HTMLDivElement {
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute(DRAG_MARK, '')
|
||||
place(element, box)
|
||||
document.body.append(element)
|
||||
return element
|
||||
}
|
||||
|
||||
/** Let a queued MutationObserver callback run. */
|
||||
async function flushMutations(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* Run frames until the watcher stops re-arming. Every report opens a short grace
|
||||
* window, so a settled surface needs more than one frame to fall idle.
|
||||
* @param seams - the frame queue under test.
|
||||
*/
|
||||
function settle(seams: Harness): void {
|
||||
for (let frame = 0; frame < 8 && seams.frames() > 0; frame += 1) seams.runFrames()
|
||||
}
|
||||
|
||||
/** Whether the recall mark is currently set. */
|
||||
function marked(): boolean {
|
||||
return document.body.hasAttribute(RECALL_MARK)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.documentElement.dataset.platform = 'darwin'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete document.documentElement.dataset.platform
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('window drag watcher installation', () => {
|
||||
it('installs nothing outside the darwin platform', async () => {
|
||||
delete document.documentElement.dataset.platform
|
||||
const seams = harness()
|
||||
const stop = installWindowDragRecall({
|
||||
document, scheduleFrame: seams.scheduleFrame, watchBox: seams.watchBox,
|
||||
})
|
||||
const element = row()
|
||||
place(element, [0, 10, 100, 52])
|
||||
element.setAttribute('class', 'moved')
|
||||
await flushMutations()
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(false)
|
||||
expect(seams.watched()).toEqual([])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('collects the surface once when it is installed', () => {
|
||||
const seams = harness()
|
||||
const stop = installWindowDragRecall({
|
||||
document, scheduleFrame: seams.scheduleFrame, watchBox: seams.watchBox,
|
||||
})
|
||||
row()
|
||||
expect(seams.frames()).toBe(1)
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(true)
|
||||
expect(seams.frames()).toBe(1)
|
||||
seams.runFrames()
|
||||
// The steady-state collection: the mark is gone and the watcher is idle.
|
||||
expect(marked()).toBe(false)
|
||||
expect(seams.frames()).toBe(0)
|
||||
stop()
|
||||
expect(seams.disposedWatchers()).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps pulsing while a box moves and stops when the geometry settles', () => {
|
||||
const seams = harness()
|
||||
const element = row()
|
||||
const stop = installWindowDragRecall({
|
||||
document, scheduleFrame: seams.scheduleFrame, watchBox: seams.watchBox,
|
||||
})
|
||||
seams.runFrames()
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(false)
|
||||
|
||||
// A box watcher reports a resize: the row keeps sliding for three frames.
|
||||
place(element, [0, 20, 100, 52])
|
||||
seams.resize(element)
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(true)
|
||||
place(element, [0, 30, 100, 52])
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(true)
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(false)
|
||||
expect(seams.frames()).toBe(0)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('keeps at most one frame pending across several reported changes', async () => {
|
||||
const seams = harness()
|
||||
const element = row()
|
||||
const stop = installWindowDragRecall({
|
||||
document, scheduleFrame: seams.scheduleFrame, watchBox: seams.watchBox,
|
||||
})
|
||||
seams.runFrames()
|
||||
seams.runFrames()
|
||||
element.setAttribute('class', 'one')
|
||||
await flushMutations()
|
||||
element.setAttribute('class', 'two')
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('pulses for a row that was removed, and drops its box watcher', async () => {
|
||||
const seams = harness()
|
||||
const element = row()
|
||||
const other = row([100, 0, 100, 52])
|
||||
const stop = installWindowDragRecall({
|
||||
document, scheduleFrame: seams.scheduleFrame, watchBox: seams.watchBox,
|
||||
})
|
||||
seams.runFrames()
|
||||
seams.runFrames()
|
||||
expect(seams.watched()).toEqual([element, other])
|
||||
|
||||
element.remove()
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(1)
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(true)
|
||||
expect(seams.disposedWatchers()).toBe(1)
|
||||
expect(seams.watched()).toEqual([other])
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('what re-arms the watcher', () => {
|
||||
/** Install over an empty body, run the collection frame, and report the seams. */
|
||||
function installed(): { seams: Harness; stop: () => void } {
|
||||
const seams = harness()
|
||||
const stop = installWindowDragRecall({
|
||||
document, scheduleFrame: seams.scheduleFrame, watchBox: seams.watchBox,
|
||||
})
|
||||
seams.runFrames()
|
||||
seams.runFrames()
|
||||
return { seams, stop }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['an attribute on a marked row', (element: HTMLElement) => { element.setAttribute('class', 'elsewhere') }],
|
||||
['an attribute inside a marked row', (element: HTMLElement) => {
|
||||
const child = document.createElement('span')
|
||||
element.append(child)
|
||||
child.setAttribute('class', 'chip')
|
||||
}],
|
||||
['a text change inside a marked row', (element: HTMLElement) => { element.firstChild!.textContent = 'renamed' }],
|
||||
['an attribute on an element holding a marked row', (element: HTMLElement) => {
|
||||
element.parentElement!.setAttribute('class', 'panel-open')
|
||||
}],
|
||||
])('re-arms on %s', async (_name, change) => {
|
||||
const { seams, stop } = installed()
|
||||
const element = row()
|
||||
element.append(document.createTextNode('title'))
|
||||
seams.runFrames()
|
||||
seams.runFrames()
|
||||
change(element)
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a node that contains a marked row', () => {
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.append(row())
|
||||
document.body.append(wrapper)
|
||||
}],
|
||||
['a marked row itself', () => { document.body.append(row()) }],
|
||||
])('re-arms on a child list change adding %s', async (_name, mutate) => {
|
||||
const { seams, stop } = installed()
|
||||
mutate()
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('re-arms on a plain sibling inserted in a container that holds a marked row', async () => {
|
||||
const { seams, stop } = installed()
|
||||
const element = row()
|
||||
await flushMutations()
|
||||
settle(seams)
|
||||
expect(seams.frames()).toBe(0)
|
||||
|
||||
// The row is not the container's first child here, so a sibling inserted beside
|
||||
// it shifts it without resizing it: only this child-list report can see that.
|
||||
element.before(document.createElement('span'))
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('ignores a change that cannot move the surface: a change in a container without a marked row', async () => {
|
||||
const { seams, stop } = installed()
|
||||
row()
|
||||
await flushMutations()
|
||||
settle(seams)
|
||||
expect(seams.frames()).toBe(0)
|
||||
|
||||
// The transcript appends beside the rows, never into the containers holding them.
|
||||
const transcript = document.createElement('div')
|
||||
document.body.append(transcript)
|
||||
await flushMutations()
|
||||
settle(seams)
|
||||
expect(seams.frames()).toBe(0)
|
||||
transcript.append(document.createElement('span'), document.createTextNode('streamed'))
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(0)
|
||||
expect(marked()).toBe(false)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('keeps measuring after a report whose box has not moved yet', async () => {
|
||||
const { seams, stop } = installed()
|
||||
const element = row()
|
||||
await flushMutations()
|
||||
settle(seams)
|
||||
expect(seams.watched()).toEqual([element])
|
||||
|
||||
// A CSS transition reports its start before the box moves: the first sample still
|
||||
// equals the last frame's geometry, and the slide that follows has to keep pulsing.
|
||||
element.setAttribute('class', 'sliding')
|
||||
await flushMutations()
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(false)
|
||||
place(element, [0, 14, 100, 52])
|
||||
seams.runFrames()
|
||||
expect(marked()).toBe(true)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('re-arms only for a transition on a marked row, or on an element holding one', async () => {
|
||||
const { seams, stop } = installed()
|
||||
const holder = document.createElement('div')
|
||||
const plain = document.createElement('div')
|
||||
document.body.append(holder, plain)
|
||||
const element = document.createElement('div')
|
||||
element.setAttribute(DRAG_MARK, '')
|
||||
place(element, [0, 0, 100, 52])
|
||||
holder.append(element)
|
||||
await flushMutations()
|
||||
settle(seams)
|
||||
expect(seams.frames()).toBe(0)
|
||||
|
||||
// A transition elsewhere on the page, and a target that is not an element, move no row.
|
||||
plain.dispatchEvent(new Event('transitionstart'))
|
||||
document.dispatchEvent(new Event('transitionstart'))
|
||||
expect(seams.frames()).toBe(0)
|
||||
holder.dispatchEvent(new Event('transitionstart'))
|
||||
expect(seams.frames()).toBe(1)
|
||||
settle(seams)
|
||||
element.dispatchEvent(new Event('animationstart'))
|
||||
expect(seams.frames()).toBe(1)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('leaves a marked row out of the watch set while it stays put', async () => {
|
||||
const { seams, stop } = installed()
|
||||
const element = row()
|
||||
await flushMutations()
|
||||
seams.runFrames()
|
||||
expect(seams.watched()).toEqual([element])
|
||||
element.setAttribute('class', 'same-row')
|
||||
await flushMutations()
|
||||
seams.runFrames()
|
||||
expect(seams.watched()).toEqual([element])
|
||||
expect(seams.disposedWatchers()).toBe(0)
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the shell defaults', () => {
|
||||
it('drives the pulse from the animation frame queue and observes boxes', async () => {
|
||||
const frames: FrameRequestCallback[] = []
|
||||
const cancelled: number[] = []
|
||||
const observed: Element[] = []
|
||||
const resizers: (() => void)[] = []
|
||||
let disconnected = 0
|
||||
vi.stubGlobal('requestAnimationFrame', (frame: FrameRequestCallback) => frames.push(frame))
|
||||
vi.stubGlobal('cancelAnimationFrame', (handle: number) => { cancelled.push(handle) })
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
constructor(callback: () => void) { resizers.push(callback) }
|
||||
observe(row: Element): void { observed.push(row) }
|
||||
disconnect(): void { disconnected += 1 }
|
||||
})
|
||||
|
||||
const element = row()
|
||||
const stop = installWindowDragRecall({ document })
|
||||
expect(frames).toHaveLength(1)
|
||||
frames.splice(0, 1)[0]!(0)
|
||||
expect(observed).toEqual([element])
|
||||
expect(marked()).toBe(true)
|
||||
frames.splice(0, 1)[0]!(0)
|
||||
expect(marked()).toBe(false)
|
||||
expect(frames).toHaveLength(0)
|
||||
|
||||
// A reported resize re-arms, and disposing with that frame pending cancels it.
|
||||
resizers[0]!()
|
||||
expect(frames).toHaveLength(1)
|
||||
stop()
|
||||
expect(cancelled).toHaveLength(1)
|
||||
expect(disconnected).toBe(1)
|
||||
expect(marked()).toBe(false)
|
||||
await flushMutations()
|
||||
})
|
||||
|
||||
it('watches no boxes where the document has no ResizeObserver', () => {
|
||||
vi.stubGlobal('ResizeObserver', undefined)
|
||||
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||
vi.stubGlobal('cancelAnimationFrame', () => {})
|
||||
const stop = installWindowDragRecall({ document })
|
||||
row()
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disposal', () => {
|
||||
it('never measures from a frame a seam could not cancel', async () => {
|
||||
const queue: (() => void)[] = []
|
||||
const element = row()
|
||||
const stop = installWindowDragRecall({
|
||||
document,
|
||||
// A seam whose canceller drops the frame on the floor: the frame still runs.
|
||||
scheduleFrame: (frame) => {
|
||||
queue.push(frame)
|
||||
return () => {}
|
||||
},
|
||||
watchBox: () => () => {},
|
||||
})
|
||||
expect(queue).toHaveLength(1)
|
||||
stop()
|
||||
queue.splice(0, 1)[0]!()
|
||||
expect(marked()).toBe(false)
|
||||
expect(queue).toHaveLength(0)
|
||||
|
||||
// A disposed watcher also ignores the DOM it used to watch.
|
||||
element.setAttribute('class', 'after-dispose')
|
||||
await flushMutations()
|
||||
expect(queue).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('cancels a pending frame, clears the mark, and stops watching', async () => {
|
||||
const seams = harness()
|
||||
const element = row()
|
||||
const stop = installWindowDragRecall({
|
||||
document, scheduleFrame: seams.scheduleFrame, watchBox: seams.watchBox,
|
||||
})
|
||||
seams.runFrames()
|
||||
seams.runFrames()
|
||||
element.setAttribute('class', 'moved')
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(1)
|
||||
// Set the mark the way a running frame would, so disposal has to clear it.
|
||||
document.body.setAttribute(RECALL_MARK, '')
|
||||
stop()
|
||||
expect(seams.frames()).toBe(0)
|
||||
expect(marked()).toBe(false)
|
||||
|
||||
element.setAttribute('class', 'after-dispose')
|
||||
await flushMutations()
|
||||
expect(seams.frames()).toBe(0)
|
||||
expect(marked()).toBe(false)
|
||||
expect(seams.disposedWatchers()).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment node
|
||||
/**
|
||||
* The composition rule every coverage claim is decided by: Electron applies
|
||||
* app-region boxes by geometry in DOM order, so the last box containing a
|
||||
* point decides it. The cases below pin the direction of that order (a later
|
||||
* `no-drag` wins over an earlier `drag`, a later `drag` re-adds what an
|
||||
* earlier `no-drag` removed) and the edge semantics callers rely on.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
INTERACTIVE_SELECTOR, containsPoint, isDraggableAt, type RegionRect,
|
||||
} from '../src/window-drag/regions.ts'
|
||||
|
||||
const box = (x: number, y: number, width: number, height: number, draggable: boolean): RegionRect =>
|
||||
({ x, y, width, height, draggable })
|
||||
|
||||
describe('window drag-region composition', () => {
|
||||
it('keeps a point outside every box undraggable', () => {
|
||||
expect(isDraggableAt([box(0, 0, 100, 52, true)], 50, 80)).toBe(false)
|
||||
expect(isDraggableAt([], 50, 20)).toBe(false)
|
||||
})
|
||||
|
||||
it('drags inside a lone band and stops at its edges', () => {
|
||||
const band = [box(0, 0, 100, 52, true)]
|
||||
expect(isDraggableAt(band, 0, 0)).toBe(true)
|
||||
expect(isDraggableAt(band, 99, 51)).toBe(true)
|
||||
expect(isDraggableAt(band, 100, 51)).toBe(false)
|
||||
expect(isDraggableAt(band, 99, 52)).toBe(false)
|
||||
})
|
||||
|
||||
it('lets a later no-drag box subtract an earlier drag box', () => {
|
||||
const regions = [box(0, 0, 100, 52, true), box(20, 10, 28, 28, false)]
|
||||
expect(isDraggableAt(regions, 34, 24)).toBe(false)
|
||||
expect(isDraggableAt(regions, 4, 24)).toBe(true)
|
||||
})
|
||||
|
||||
it('lets a later drag box re-add what an earlier no-drag box removed', () => {
|
||||
const regions = [box(0, 0, 100, 52, true), box(20, 10, 28, 28, false), box(20, 10, 28, 28, true)]
|
||||
expect(isDraggableAt(regions, 34, 24)).toBe(true)
|
||||
})
|
||||
|
||||
it('decides by document order, not by box size or direction', () => {
|
||||
const wide = box(0, 0, 100, 52, true)
|
||||
const small = box(20, 10, 28, 28, false)
|
||||
expect(isDraggableAt([wide, small], 34, 24)).toBe(false)
|
||||
expect(isDraggableAt([small, wide], 34, 24)).toBe(true)
|
||||
})
|
||||
|
||||
it('treats edges as half-open so adjacent boxes never both claim a point', () => {
|
||||
expect(containsPoint(box(0, 0, 10, 10, true), 10, 5)).toBe(false)
|
||||
expect(containsPoint(box(0, 0, 10, 10, true), 9.999, 9.999)).toBe(true)
|
||||
})
|
||||
|
||||
it('publishes the interactive selector base.css declares', () => {
|
||||
for (const part of ['button', 'a', "[role='tab']", '[tabindex]', "[contenteditable='true']"]) {
|
||||
expect(INTERACTIVE_SELECTOR.split(', ')).toContain(part)
|
||||
}
|
||||
expect(INTERACTIVE_SELECTOR).not.toContain(':is(')
|
||||
})
|
||||
})
|
||||
Generated
+6
@@ -4651,12 +4651,18 @@ importers:
|
||||
'@types/react':
|
||||
specifier: ~18.3.1
|
||||
version: 18.3.31
|
||||
'@types/react-dom':
|
||||
specifier: ~18.3.0
|
||||
version: 18.3.7(@types/react@18.3.31)
|
||||
clsx:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.1
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
|
||||
packages/client/ui-settings-models:
|
||||
dependencies:
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
"apps/web/tests/chat-long-interactions.e2e.ts",
|
||||
"apps/web/tests/chat-continuous-conversation.e2e.ts",
|
||||
"apps/web/tests/composer-tab-geometry.e2e.ts",
|
||||
"apps/web/tests/window-drag-coverage.e2e.ts",
|
||||
"apps/web/tests/complex-history.perf.ts",
|
||||
"apps/web/tests/pwsh-terminal.e2e.ts",
|
||||
"apps/web/tests/workflow-run.e2e.ts",
|
||||
|
||||
Reference in new issue
Block a user