diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml new file mode 100644 index 0000000000..0bdbf1a165 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md new file mode 100644 index 0000000000..c73d9230a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.md @@ -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 | diff --git a/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md new file mode 100644 index 0000000000..81fb9bbcc2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-19-window-drag-coverage-contract.zh.md @@ -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 区域而非标题栏,因此那里没有系统动作——只有控件保留自己的双击 | diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml index 061872368c..79c5e6a4e4 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md index 9579a0ba47..3c1e23ad93 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md index 365ad5682c..000174a0c3 100644 --- a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md @@ -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 生命周期 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml index d15870537e..c6d3366d84 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/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 diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md index 807c79d06c..7e69fea3a4 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.md @@ -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. diff --git a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md index a521aff762..fbba185740 100644 --- a/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md +++ b/.agents/notes/implemented/feature/2026-09-13-macos-hidden-titlebar-vibrancy.zh.md @@ -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 标记。 diff --git a/apps/web/tests/window-drag-coverage.e2e.ts b/apps/web/tests/window-drag-coverage.e2e.ts new file mode 100644 index 0000000000..16e9f4851e --- /dev/null +++ b/apps/web/tests/window-drag-coverage.e2e.ts @@ -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 { + 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 { + 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 { + return page.evaluate((mark: string) => new Promise((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 { + 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 }> { + 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) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 1db54896f7..d519b86ba3 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -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" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 015f305a4b..2cb7e9b81c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 153c505250f5654f0aeade23c221e19554294641 -README.zh.md: c849b1bdfd033e07cac382c4b62cd0df35333c8e +README.md: 4283d902f5be2328d08e7bd0d00c64b0835411e9 +README.zh.md: 72493ef348413c4051509cff245bcb665187b93a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 153c505250..4283d902f5 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c849b1bdfd..72493ef348 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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 快照。 diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx index a8f2e6b122..b3b07c2b6b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationHeader.tsx @@ -15,7 +15,7 @@ export function ConversationHeader({ sessionId, useSession, useConversation, ren const blank = session === undefined || conversation === undefined || (session.blank && conversationPhase(session, conversation) === 'blank') return ( -
+
{renderSlot('conversation.header.leading', {})}
diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 4bd7aa80b2..2db30edd64 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -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, diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index d17f52e3f8..f1179cf32b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -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({ {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.
{tabs.map(viewTab => (
- + ) } @@ -1221,7 +1221,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
{showsCards ? ( -
+

{t('title')}

{t('intro')}

diff --git a/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css b/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css index 39f0da5deb..ff022a9136 100644 --- a/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css +++ b/packages/client/ui-settings-account/src/client/PlatformOverlay.module.css @@ -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 { diff --git a/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx b/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx index c62f4e4c4a..b580238b3c 100644 --- a/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx +++ b/packages/client/ui-settings-account/src/client/PlatformOverlay.tsx @@ -57,7 +57,7 @@ export function PlatformOverlay({ bridge, page, backLabel, loadingLabel, failure } }, [bridge, page, attempt]) return createPortal(
-
+