diff --git a/UI-DESIGN.md b/UI-DESIGN.md
index fd6729ce..d4ef9e5a 100644
--- a/UI-DESIGN.md
+++ b/UI-DESIGN.md
@@ -400,6 +400,9 @@
顶栏,不能保留一条未被遮罩的窗口控制色块。关闭操作由 Modal 自身提供。
- 整窗固定遮罩通过 `createPortal(..., document.body)` 挂载,避免页面层叠上下文影响;
保留 React Context、关闭与焦点管理,表单样式不得依赖已移出的页面祖先选择器。
+- 共享锚定菜单 `AnchoredMenu` 和应用通知共用 `FloatingPortal` 的非模态 `popover="manual"`,由浏览器 top layer
+ 绘制,避免被 Modal 或滚动容器遮挡。菜单挂在触发按钮所属 Modal 内,通知跟随当前活动
+ Modal,无 Modal 时挂在 `body`;不能只提高 `z-index` 或解除背景的 `inert`。
- 原生浏览器 `WebContentsView` 不受网页 `z-index` 控制。应用级 Modal 打开时统一释放
浏览器 viewport;非模态对话框、菜单、局部确认或应用通知与浏览器区域相交时也释放。
浮层关闭后只恢复当前活动实例的 viewport,不关闭、重建或刷新网页。接线与测试见
@@ -447,6 +450,9 @@
- 异步操作成功、无需立即处理的信息,以及不属于某个字段的异步失败,使用应用级 `success`、`info` 或 `error` 通知。
- 成功和信息通知默认在约 4.5 秒后自动消失;错误通知保持可见,直到用户关闭或同一去重键的更新替换它。
+- 通知出现不抢焦点;Modal 打开时,通知关闭按钮纳入当前 Modal 的 Tab 循环。聚焦通知时
+ Escape 返回原控件或当前 Modal 的可用控件,不关闭 Modal;关闭通知后同样恢复焦点。
+ 打开、关闭或切换 Modal 不重建通知,不重置自动消失计时。
- 同一语义和文案的重复通知应去重。通知正文必须有长度上限,不包含凭据、私人内容或未脱敏的提供商响应。
- 字段校验、破坏性确认、操作进度、阻塞整个页面的状态,以及需要就地重试或修正的错误保留在相关控件附近。
- 就地错误必须与对应字段或操作建立程序化关联;全局错误使用 `alert` 和 assertive 实时区域,成功与信息使用 `status` 和 polite 实时区域。
diff --git a/docs/features/document-processing/progress.md b/docs/features/document-processing/progress.md
index 1bae2130..963d3614 100644
--- a/docs/features/document-processing/progress.md
+++ b/docs/features/document-processing/progress.md
@@ -2,7 +2,7 @@
## 状态
-当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)及[附件操作布局复验](#2026-09-19-附件操作布局复验)为准。
+当前状态以 [2026-09-19 实现与验证](#2026-09-19-实现与验证)、[附件操作布局复验](#2026-09-19-附件操作布局复验)及[全局浮层复验](#2026-09-19-全局浮层复验)为准。
以下至“菜单核对检查”的内容是实施前历史记录,保留原日期与原调用数。
2026-09-18:完成规划文档和独立 HTTP 探索脚本。FR-H1 至 FR-H12 均未完成生产实现,
@@ -303,3 +303,30 @@ Worker 整体回收),是具体实现对比,保留原文。本次新增内
`git diff --check` 通过。deai-writing 扫描文档处理目录仍为既有 2 个阻断候选,按前述具体
实现对比理由保留;新增内容无阻断项,复核项保留必要的能力边界和菜单状态限制。关联文档
新增段落经人工审校;本轮没有产品代码变更,未运行产品测试或实机菜单验收。
+
+## 2026-09-19 全局浮层复验
+
+修复前源码在 Electron 中复现:“测试解析”的 `AnchoredMenu` 层级为 129,低于设置
+遮罩的 130;通知层级为 75,且仍在被设为 `inert` 的应用壳层内。两者的鼠标命中均失败。
+共享修复及后续约束见[复发缺陷规则](../../quality/recurring-defects.md#modal-内的菜单与通知被遮挡或隔离)。
+
+- `FloatingPortal.test.tsx` 覆盖 Modal 切换时保持同一通知宿主和内容、焦点循环及菜单归属。
+- `overlay-layering.electron.test.ts` 使用生产组件与受控 IPC,验证浅/深主题下的
+ 1280×800、960×720、720×640、640×420;包含通知在 Modal 前/后出现、点击关闭、
+ Tab/Shift+Tab/Escape、解析场景菜单与二级结果、应用中心、本机推理监控和原生 `dialog`。
+- 完整 App 使用当前 Main、Preload、Renderer 的独立构建及空白 profile,从侧栏设置入口
+ 修改最大页数为 99,确认生产读取值及成功通知;原生鼠标点击“测试解析”菜单后,生产 IPC
+ 解析本地 TXT,结果展示 70 字符,Escape 逐层关闭并恢复焦点。仅文件选择器返回合成文件;
+ 未替换解析、保存或通知。模型及 OCR 服务调用均为 0。
+- 已通过图片读取工具逐张审阅 fixture 的 16 张 `light/dark-宽度-menu/notification.png`,
+ 以及完整 App 的保存通知、测试菜单和解析结果三张 PNG;文案、主题、菜单与通知层级正常。
+
+临时证据位于批准目录下的 `overlay-baseline-0919`、`overlay-layering-0919` 和
+`overlay-full-app-0919-r2`。完整 App 首轮因驱动选择器错误失败,修正后通过;仍记录到
+4 条既有 data-font CSP 警告。本轮实际 UI 环境为 Windows x64、Electron 43.2.0,
+未验证 macOS、Linux 或辅助技术的实际语音播报。
+本轮临时 profile、基线缓存和独立构建已清理,保留脚本、脱敏报告、源码/产物哈希及截图。
+
+最终 `npm test`:403 个文件通过、9 个跳过,4,793 项通过、67 项跳过,耗时 909.69 秒。
+`npm run typecheck`、`npm run lint` 及隔离目录的 Electron Vite 生产构建通过;
+相关文档的 48 个相对文件链接有效,`git diff --check` 通过。
diff --git a/docs/features/document-processing/ui-design.md b/docs/features/document-processing/ui-design.md
index 72e3bbe2..73ea2de8 100644
--- a/docs/features/document-processing/ui-design.md
+++ b/docs/features/document-processing/ui-design.md
@@ -16,7 +16,7 @@
| 知识库 | `App.tsx` 已提供 `PageShell variant="master-detail"`,`KnowledgeWorkspace.tsx` 内含库导航与 DocumentsView;需在文档区域新增解析详情及返回列表,不再嵌套页面壳层或新建知识库级页签 |
| 解析预览 | 新增共享内容视图,分别嵌入设置诊断 Modal、聊天附件 Modal 和知识库文档详情;共享数据和交互,外层容器负责标题、范围、关闭与滚动 |
| 页签与选择 | `WorkspacePrimitives.tsx` 的 `PageTabs variant="segmented"` 承载正文、图片、详情;来源及过滤模式使用 `SegmentedControl`,不能用它替换页签语义 |
-| 浮层 | 沿用 `createPortal(..., document.body)`、`activateModalFocus`、`trapTabFocus`;当前没有可直接调用的通用 Modal 组件,不虚构组件 API |
+| 浮层 | Modal 沿用 body Portal、`activateModalFocus` 与 `trapTabFocus`;测试及附件菜单复用 `AnchoredMenu`,由共享 `FloatingPortal` 挂入所属 Modal 并在 top layer 绘制。通知遵循根 UI 规范 |
| 图文内容 | 复用 `MarkdownRenderer.tsx` 的排版、表格滚动和静态 HTML 处理边界;补齐受管图片与 OCR HTML 表格适配,现有 Markdown 渲染器并未直接支持这些资源 |
| 状态与反馈 | `ScopeBadge`、`EmptyState`、共享按钮、`toggle-row` Switch、应用 `onNotify`;图片多选用 Checkbox,失败重试就地显示 |
diff --git a/docs/quality/recurring-defects.md b/docs/quality/recurring-defects.md
index 1ebc3254..58861cc0 100644
--- a/docs/quality/recurring-defects.md
+++ b/docs/quality/recurring-defects.md
@@ -44,6 +44,25 @@
**历史**:先前浏览器多实例修正只覆盖了终端关闭确认;2026-09-13 用户在知识库实例
Modal 再次复现,同一检查确认其他应用级 Modal 均缺少处理。
+## Modal 内的菜单与通知被遮挡或隔离
+
+**症状**:设置中的“测试解析”菜单落在遮罩后面;设置、应用中心等全局 Modal 打开时,
+应用通知被遮挡,关闭按钮也无法点击或聚焦。
+
+**原因**:菜单挂在 `body`,却使用低于 Modal 的层级;通知留在被 Modal 设为 `inert`
+的应用壳层内,同时层级也低于 Modal。单独增大 `z-index` 无法解除模态隔离。
+
+**规则**:菜单与通知共用 `FloatingPortal`。稳定的非模态 Popover 宿主挂入所属/当前
+Modal,兼顾 top layer 绘制、辅助技术的模态归属及焦点循环;没有 Modal 时才挂在
+`body`。保留背景隔离,不在页面各自加层级常量,不因移动通知重置其计时。
+
+**防复发**:`FloatingPortal.test.tsx` 覆盖宿主移动和内容不重挂载;
+`overlay-layering.electron.test.ts` 使用真实组件、Portal、鼠标命中及原生键盘,覆盖
+解析菜单/二级结果、设置、应用中心、本机推理监控和原生 `dialog`,以及浅深主题和短窗口。
+
+**历史**:2026-09-19 同次检查确认解析测试菜单和应用级通知两处挂载机制错误,后者影响
+多个全局 Modal;统一宿主后补完整 App 的真实保存通知与 TXT 解析复验。
+
## Flex 滚动内容压缩页签和分段控件
**症状**:知识库来源选择被长参数表单压成窄缝,标签和按钮文字被裁掉。
diff --git a/src/renderer/src/AnchoredMenu.tsx b/src/renderer/src/AnchoredMenu.tsx
index daea802e..7cbd2fa7 100644
--- a/src/renderer/src/AnchoredMenu.tsx
+++ b/src/renderer/src/AnchoredMenu.tsx
@@ -1,5 +1,6 @@
import { useEffect, useEffectEvent, useLayoutEffect, useRef, type ReactNode, type RefObject } from 'react'
-import { createPortal, flushSync } from 'react-dom'
+import { flushSync } from 'react-dom'
+import { FloatingPortal } from './FloatingPortal'
import './anchored-menu.css'
export function AnchoredMenu({ anchorRef, id, label, onClose, children }: {
@@ -87,6 +88,6 @@ export function AnchoredMenu({ anchorRef, id, label, onClose, children }: {
if (document.activeElement === document.body || menu.contains(document.activeElement)) anchor?.focus()
}
}, [anchorRef])
- return createPortal(
{children}
, document.body)
+ return {children}
}
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index efb82769..377b4949 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -252,6 +252,7 @@ import {
type KeepAliveCacheEntry,
} from "./keep-alive-cache";
import { activateModalFocus, trapTabFocus } from "./dialog-focus";
+import { FloatingPortal } from "./FloatingPortal";
import {
displayErrorMessage,
displayNetworkAwareErrorMessage,
@@ -486,7 +487,7 @@ function AppNotificationItem({
);
}
-function AppNotificationViewport({
+export function AppNotificationViewport({
notifications,
dispatch,
}: {
@@ -499,7 +500,7 @@ function AppNotificationViewport({
return null;
}
return (
-
@@ -510,7 +511,7 @@ function AppNotificationViewport({
notification={notification}
/>
))}
-
+
);
}
diff --git a/src/renderer/src/FloatingPortal.test.tsx b/src/renderer/src/FloatingPortal.test.tsx
new file mode 100644
index 00000000..30e4e950
--- /dev/null
+++ b/src/renderer/src/FloatingPortal.test.tsx
@@ -0,0 +1,62 @@
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, expect, it } from 'vitest'
+import { FloatingPortal } from './FloatingPortal'
+import { activateModalFocus, trapTabFocus } from './dialog-focus'
+
+afterEach(cleanup)
+
+function Modal({ label, onClose }: { label: string; onClose: () => void }): React.JSX.Element {
+ const close = useRef(null)
+ useEffect(() => activateModalFocus(() => close.current), [])
+ return createPortal( trapTabFocus(event, event.currentTarget)}>
+
+ , document.body)
+}
+
+it('moves one live notification host into successive modals without remounting its content', async () => {
+ let mounts = 0
+ function Notice(): React.JSX.Element {
+ useEffect(() => { mounts++ }, [])
+ return Saved
+ }
+ function Fixture(): React.JSX.Element {
+ const [modal, setModal] = useState(false)
+ return
+
+ {modal && setModal(false)} />}
+
+ }
+ render()
+ const host = screen.getByRole('status').parentElement!
+ expect(host.parentElement).toBe(document.body)
+ fireEvent.click(screen.getByText('Open'))
+ await waitFor(() => expect(host.parentElement).toBe(screen.getByRole('dialog')))
+ expect(host).not.toHaveAttribute('inert')
+ expect(document.querySelector('.app-shell')!.inert).toBe(true)
+ expect(mounts).toBe(1)
+ screen.getByText('Dismiss').focus()
+ fireEvent.keyDown(screen.getByText('Dismiss'), { key: 'Tab' })
+ expect(screen.getByText('Close Settings')).toHaveFocus()
+ fireEvent.click(screen.getByText('Close Settings'))
+ await waitFor(() => expect(host.parentElement).toBe(document.body))
+ expect(mounts).toBe(1)
+ expect(document.querySelector('.app-shell')!.inert).toBe(false)
+})
+
+it('keeps an anchored floating menu inside its owning modal', async () => {
+ function Fixture(): React.JSX.Element {
+ const anchor = useRef(null)
+ const [open, setOpen] = useState(false)
+ return
+
+ {open && Scenarios
}
+
+ }
+ render()
+ fireEvent.click(screen.getByText('Test'))
+ await waitFor(() => expect(screen.getByRole('dialog')).toContainElement(screen.getByRole('menu')))
+ expect(screen.getByRole('menu').parentElement).toHaveProperty('popover', 'manual')
+})
diff --git a/src/renderer/src/FloatingPortal.tsx b/src/renderer/src/FloatingPortal.tsx
new file mode 100644
index 00000000..ac7d5a32
--- /dev/null
+++ b/src/renderer/src/FloatingPortal.tsx
@@ -0,0 +1,77 @@
+import { useLayoutEffect, useState, type ReactNode, type RefObject } from 'react'
+import { createPortal } from 'react-dom'
+import { trapTabFocus } from './dialog-focus'
+import './floating-portal.css'
+
+// Top-layer painting does not escape modal inertness: the host must also belong
+// to the active dialog. Keep the host stable so moving it preserves toast timers.
+export function FloatingPortal({ children, anchorRef }: {
+ children: ReactNode
+ anchorRef?: RefObject
+}): React.JSX.Element {
+ const [host] = useState(() => {
+ const element = document.createElement('div')
+ element.className = 'floating-portal'
+ element.popover = 'manual'
+ return element
+ })
+ useLayoutEffect(() => {
+ let focused: HTMLElement | null = null
+ let returnFocus: HTMLElement | null = null
+ const sync = (): void => {
+ const modals = Array.from(document.querySelectorAll('[aria-modal="true"], dialog:modal'))
+ const owner = anchorRef?.current?.closest('[aria-modal="true"], dialog:modal') ??
+ (anchorRef ? null : modals.reverse().find(element =>
+ !element.closest('[inert], [hidden], [aria-hidden="true"]') && getComputedStyle(element).display !== 'none'))
+ const target = owner ?? document.body
+ const active = host.contains(document.activeElement) ? document.activeElement as HTMLElement : null
+ if (host.parentElement !== target) {
+ target.append(host)
+ host.removeAttribute('inert')
+ }
+ if (!host.matches(':popover-open')) host.showPopover()
+ if (active) active.focus({ preventScroll: true })
+ if (focused && !focused.isConnected && document.activeElement === document.body) {
+ restoreFocus()
+ }
+ }
+ const restoreFocus = (): void => {
+ const modal = host.closest('[aria-modal="true"], dialog:modal')
+ const target = returnFocus?.isConnected && !returnFocus.closest('[inert], [hidden], :disabled') &&
+ (!modal || modal.contains(returnFocus)) ? returnFocus : modal?.querySelector('button:not(:disabled), [tabindex="0"]')
+ target?.focus({ preventScroll: true })
+ focused = null
+ }
+ const onFocus = (event: FocusEvent): void => {
+ focused = event.target instanceof HTMLElement ? event.target : null
+ if (event.relatedTarget instanceof HTMLElement && !host.contains(event.relatedTarget)) {
+ returnFocus = event.relatedTarget
+ }
+ }
+ const onKeyDown = (event: KeyboardEvent): void => {
+ if (event.defaultPrevented || anchorRef) return
+ const modal = host.closest('[aria-modal="true"], dialog:modal')
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ event.stopPropagation()
+ restoreFocus()
+ } else {
+ trapTabFocus(event, modal)
+ }
+ }
+ sync()
+ const observer = new MutationObserver(sync)
+ observer.observe(document.body, { childList: true, subtree: true, attributes: true,
+ attributeFilter: ['inert', 'hidden', 'aria-hidden', 'aria-modal', 'open'] })
+ host.addEventListener('focusin', onFocus)
+ host.addEventListener('keydown', onKeyDown)
+ return () => {
+ observer.disconnect()
+ host.removeEventListener('focusin', onFocus)
+ host.removeEventListener('keydown', onKeyDown)
+ if (host.contains(document.activeElement)) restoreFocus()
+ host.remove()
+ }
+ }, [anchorRef, host])
+ return createPortal(children, host)
+}
diff --git a/src/renderer/src/anchored-menu.css b/src/renderer/src/anchored-menu.css
index 60fe9b90..5b9e7b6d 100644
--- a/src/renderer/src/anchored-menu.css
+++ b/src/renderer/src/anchored-menu.css
@@ -1,6 +1,5 @@
.anchored-menu {
position: fixed;
- z-index: calc(var(--z-dialog) - 1);
box-sizing: border-box;
overflow-y: auto;
padding: var(--space-2);
diff --git a/src/renderer/src/floating-portal.css b/src/renderer/src/floating-portal.css
new file mode 100644
index 00000000..393c9d7b
--- /dev/null
+++ b/src/renderer/src/floating-portal.css
@@ -0,0 +1,23 @@
+.floating-portal {
+ position: fixed;
+ inset: 0;
+ width: auto;
+ height: auto;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ overflow: visible;
+ background: transparent;
+ color: inherit;
+ pointer-events: none;
+}
+
+.floating-portal::backdrop {
+ background: transparent;
+ pointer-events: none;
+}
+
+.floating-portal > * {
+ pointer-events: auto;
+ -webkit-app-region: no-drag;
+}
diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css
index 382829e1..7411f3c4 100644
--- a/src/renderer/src/styles.css
+++ b/src/renderer/src/styles.css
@@ -12398,7 +12398,6 @@ details.settings-section > :not(summary) + :not(summary) {
.app-notification-viewport {
position: fixed;
- z-index: 75;
top: 54px;
right: var(--space-4);
display: grid;
diff --git a/src/renderer/src/test-setup.ts b/src/renderer/src/test-setup.ts
index b62a29d3..54ee5644 100644
--- a/src/renderer/src/test-setup.ts
+++ b/src/renderer/src/test-setup.ts
@@ -7,6 +7,11 @@ if (typeof Element !== 'undefined') {
Element.prototype.scrollTo = vi.fn()
}
+// jsdom does not implement top-layer rendering; Electron tests cover it.
+if (typeof HTMLElement !== 'undefined') {
+ HTMLElement.prototype.showPopover = vi.fn()
+}
+
if (typeof ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class {
observe = vi.fn()
diff --git a/tests/overlay-layering.electron.test.ts b/tests/overlay-layering.electron.test.ts
new file mode 100644
index 00000000..dd82e8f0
--- /dev/null
+++ b/tests/overlay-layering.electron.test.ts
@@ -0,0 +1,38 @@
+// @vitest-environment node
+import { spawn } from 'node:child_process'
+import { createRequire } from 'node:module'
+import { copyFile, mkdtemp, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join, resolve } from 'node:path'
+import { createServer } from 'vite'
+import react from '@vitejs/plugin-react'
+import { expect, it } from 'vitest'
+
+it('keeps menus and notifications interactive above real Electron modals', async () => {
+ const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-overlay-layering-'))
+ const server = await createServer({ configFile: false, root: resolve('.'), cacheDir: join(directory, 'vite'),
+ plugins: [react(), { name: 'overlay-layering', configureServer(server) {
+ server.middlewares.use('/overlays.html', async (_request, response) => {
+ response.setHeader('Content-Type', 'text/html')
+ response.end(await server.transformIndexHtml('/overlays.html',
+ ''))
+ })
+ } }], server: { host: '127.0.0.1', port: 0, watch: null } })
+ try {
+ await server.listen()
+ const env: NodeJS.ProcessEnv = { ...process.env, GOODBUDDY_OVERLAY_URL: server.resolvedUrls!.local[0] + 'overlays.html',
+ GOODBUDDY_OVERLAY_DIRECTORY: directory }
+ delete env.ELECTRON_RUN_AS_NODE
+ const driver = join(directory, 'driver.mjs')
+ await copyFile(resolve('tests/support/overlay-layering-driver.mjs'), driver)
+ const child = spawn(createRequire(import.meta.url)('electron'), [driver], { env, stdio: ['ignore', 'pipe', 'pipe'] })
+ let output = ''
+ child.stdout.on('data', data => { output += data })
+ child.stderr.on('data', data => { output += data })
+ const timeout = setTimeout(() => child.kill(), 90000)
+ try {
+ const code = await new Promise((resolve, reject) => { child.once('exit', resolve); child.once('error', reject) })
+ expect(code, output).toBe(0)
+ } finally { clearTimeout(timeout) }
+ } finally { await server.close(); await rm(directory, { recursive: true, force: true }) }
+}, 120000)
diff --git a/tests/support/overlay-layering-driver.mjs b/tests/support/overlay-layering-driver.mjs
new file mode 100644
index 00000000..35ef77ee
--- /dev/null
+++ b/tests/support/overlay-layering-driver.mjs
@@ -0,0 +1,124 @@
+import { app, BrowserWindow } from 'electron'
+import assert from 'node:assert/strict'
+import { writeFile, mkdir } from 'node:fs/promises'
+import { join } from 'node:path'
+import process from 'node:process'
+import console from 'node:console'
+import { setTimeout } from 'node:timers'
+
+const url = process.env.GOODBUDDY_OVERLAY_URL
+const directory = process.env.GOODBUDDY_OVERLAY_DIRECTORY
+app.setPath('userData', join(directory, 'profile'))
+app.commandLine.appendSwitch('force-device-scale-factor', '1')
+app.whenReady().then(async () => {
+ const win = new BrowserWindow({ show: true, width: 1280, height: 800, useContentSize: true,
+ webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false } })
+ const errors = []
+ win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
+ const js = code => win.webContents.executeJavaScript(code)
+ const settle = () => js('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
+ const wait = async code => {
+ for (let i = 0; i < 1000; i++) {
+ if (await js(code)) return
+ await new Promise(resolve => setTimeout(resolve, 25))
+ }
+ throw new Error('Timeout: ' + code + '\n' + errors.join('\n'))
+ }
+ const key = async (keyCode, modifiers = []) => {
+ win.focus(); win.webContents.focus()
+ win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers })
+ win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers })
+ await settle()
+ }
+ const click = async selector => {
+ const point = await js(`(() => { const e = document.querySelector(${JSON.stringify(selector)}); e.scrollIntoView({block:'nearest'});
+ const r = e.getBoundingClientRect(); const x = r.x + r.width/2, y = r.y + r.height/2;
+ return { x: Math.round(x), y: Math.round(y), hit: e.contains(document.elementFromPoint(x, y)) }; })()`)
+ assert(point.hit, 'Occluded: ' + selector)
+ win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
+ win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
+ await settle()
+ }
+ const notify = async (tone = 'error') => {
+ await js(`window.dispatchEvent(new CustomEvent('fixture-notify', { detail: ${JSON.stringify(tone)} }))`)
+ await wait('!!document.querySelector(".app-notification")')
+ await settle()
+ }
+ const artifacts = process.env.GOODBUDDY_OVERLAY_ARTIFACTS
+ if (artifacts) await mkdir(artifacts, { recursive: true })
+ const screenshot = async name => {
+ if (artifacts) await writeFile(join(artifacts, name + '.png'), (await win.webContents.capturePage()).toPNG())
+ }
+ await win.loadURL(url)
+ await wait('!!document.querySelector("#settings")')
+ await js('document.fonts.ready')
+
+ for (const theme of ['light', 'dark']) {
+ await js(`window.dispatchEvent(new CustomEvent('fixture-theme', { detail: '${theme}' }))`)
+ for (const [width, height] of [[1280, 800], [960, 720], [720, 640], [640, 420]]) {
+ win.setContentSize(width, height); await settle()
+ await notify()
+ await click('#settings')
+ await wait('!!document.querySelector("[aria-haspopup=menu][aria-expanded=false]")')
+ assert(await js('document.querySelector(".settings-panel").contains(document.querySelector(".app-notification"))'))
+ assert(await js('document.querySelector(".app-shell").inert'))
+ const before = await js('document.activeElement.className')
+ await notify('success')
+ assert.equal(await js('document.activeElement.className'), before, 'Notification stole focus')
+ await screenshot(`${theme}-${width}-notification`)
+ await click('.app-notification--error button')
+ await click('.app-notification--success button')
+ assert(await js('!!document.activeElement.closest(".settings-panel")'), 'Dismissal lost modal focus')
+ await click('[aria-haspopup=menu][aria-expanded=false]')
+ await wait('!!document.querySelector("#document-test-menu")')
+ assert(await js('document.querySelector(".settings-panel").contains(document.querySelector("#document-test-menu"))'))
+ assert(await js('document.activeElement.matches("[role=menuitem]")'))
+ await screenshot(`${theme}-${width}-menu`)
+ await key('Escape')
+ assert(await js('!document.querySelector("#document-test-menu") && !!document.querySelector(".settings-panel")'))
+ assert(await js('document.activeElement.matches("[aria-haspopup=menu]")'))
+ await key('Space')
+ await wait('!!document.querySelector("#document-test-menu")')
+ await click('#document-test-menu [role=menuitem]')
+ await wait('!!document.querySelector(".document-parsing-diagnostic")')
+ await notify()
+ assert(await js('document.querySelector(".document-parsing-diagnostic").contains(document.querySelector(".app-notification"))'))
+ assert(await js('document.querySelector(".settings-backdrop").inert'))
+ await js('document.querySelector(".app-notification button").focus()')
+ await key('Tab')
+ assert(await js('document.activeElement.matches(".document-parsing-diagnostic header button")'))
+ await key('Tab', ['shift'])
+ assert(await js('document.activeElement.matches(".app-notification button")'))
+ await key('Escape')
+ assert(await js('!!document.querySelector(".document-parsing-diagnostic") && !document.activeElement.closest(".app-notification")'))
+ await key('Escape')
+ await wait('!document.querySelector(".document-parsing-diagnostic")')
+ assert(await js('document.querySelector(".settings-panel").contains(document.querySelector(".app-notification"))'))
+ await click('.app-notification button')
+ await key('Escape')
+ await wait('!document.querySelector(".settings-panel")')
+ assert(await js('!document.querySelector(".app-shell").inert && document.activeElement.id === "settings"'))
+ }
+ }
+ for (const id of ['applications', 'inference', 'native']) {
+ await notify()
+ await click('#' + id)
+ await wait('!!document.querySelector("[aria-modal=true], dialog[open]")')
+ await settle()
+ assert(await js('!!document.querySelector(".app-notification").closest("[aria-modal=true], dialog[open]")'))
+ await click('.app-notification button')
+ if (id === 'native') await click('dialog[open] button')
+ else await key('Escape')
+ await wait('!document.querySelector("[aria-modal=true], dialog[open]")')
+ }
+ // A notification can outlive a modal; moving its host must not restart its timer.
+ await notify('success')
+ await click('#applications')
+ await js('window.fixtureToast = document.querySelector(".app-notification")')
+ await key('Escape')
+ assert(await js('window.fixtureToast === document.querySelector(".app-notification") && document.querySelector(".floating-portal").parentElement === document.body'))
+ await wait('!document.querySelector(".app-notification")')
+ assert.deepEqual(errors, [])
+ console.log('PASS: 8 theme/size scenarios; parsing menu and nested result; notification hit testing, native Tab/Shift+Tab/Escape; application center, inference, native dialog; persistent host and timeout')
+ app.exit(0)
+}).catch(error => { console.error(error); app.exit(1) })
diff --git a/tests/support/overlay-layering-regression.tsx b/tests/support/overlay-layering-regression.tsx
new file mode 100644
index 00000000..2aefbfd7
--- /dev/null
+++ b/tests/support/overlay-layering-regression.tsx
@@ -0,0 +1,59 @@
+import { useEffect, useRef, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+import { AppNotificationViewport } from '../../src/renderer/src/App'
+import { SettingsPanel } from '../../src/renderer/src/SettingsPanel'
+import { ApplicationCenter } from '../../src/renderer/src/ApplicationCenter'
+import LocalInferencePage from '../../src/renderer/src/LocalInferencePage'
+import { defaultRuntimeSettings } from '../../src/shared/contracts'
+import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
+import { applyAppearanceTheme } from '../../src/renderer/src/theme'
+import '../../src/renderer/src/styles.css'
+
+const snapshot = {
+ settings: { chatWorkflow: 'auto', knowledgeWorkflow: 'complete-index', localOcrModelId: 'pp-ocrv6-tiny', maximumPages: 100, pageTimeoutSeconds: 60 },
+ status: { nativeParsingAvailable: true, conversionAvailable: false,
+ localOcr: { id: 'pp-ocrv6-tiny', displayName: 'PP-OCRv6 Tiny', available: false, verified: false, runtime: 'onnxruntime-web-wasm', detail: 'Fixture: not installed' } },
+ ocrModels: { rootDirectory: '', selectedDownloadSource: 'modelscope', catalog: [], installed: [], operations: [] }
+}
+Object.defineProperty(window, 'goodbuddy', { value: {
+ settings: { getRuntime: async () => ({ ...defaultRuntimeSettings, modelProfiles: [], embeddingConnections: [],
+ opencodeModelSource: { kind: 'platform' }, continueModelSource: { kind: 'platform' }, deepseekHarnessModelSource: { kind: 'platform' } }),
+ detectAgentRuntimes: async () => ({}) },
+ documentParsing: { getSnapshot: async () => snapshot, releaseResult: async () => undefined,
+ test: async () => ({ fileName: 'fixture.txt', sourceFormat: 'TXT', method: 'native', pageCount: 1,
+ ocrPageCount: 0, characterCount: 15, durationMs: 1, preview: 'Fixture result', warnings: [] }) },
+ localInference: { getSnapshot: async () => ({ services: [], tasks: [] }) }
+} })
+
+function Fixture(): React.JSX.Element {
+ const [modal, setModal] = useState('')
+ const [notifications, setNotifications] = useState>([])
+ const native = useRef(null)
+ useEffect(() => {
+ const notify = (event: Event): void => {
+ const tone = (event as CustomEvent).detail === 'success' ? 'success' : 'error'
+ setNotifications(items => [...items, { id: crypto.randomUUID(), message: 'Overlay regression notification', tone, revision: 1 }])
+ }
+ const theme = (event: Event): void => applyAppearanceTheme((event as CustomEvent).detail)
+ window.addEventListener('fixture-notify', notify)
+ window.addEventListener('fixture-theme', theme)
+ return () => { window.removeEventListener('fixture-notify', notify); window.removeEventListener('fixture-theme', theme) }
+ }, [])
+ return
+
+
+
+
+
{
+ if ('dismiss' in action) setNotifications(items => items.filter(item => item.id !== action.dismiss))
+ }} />
+ {modal === 'settings' && setModal('')}
+ onSaved={() => {}} onUpdateProject={async () => { throw new Error('Unused fixture API') }} projects={[]} onClearLocalData={async () => {}} />}
+ {modal === 'applications' && setModal('')}
+ onOpen={() => {}} onUpdate={async () => true} onRetry={() => {}} />}
+ {modal === 'inference' && setModal('')} />}
+
+
+}
+
+createRoot(document.getElementById('root')!).render()