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(, document.body) + return } 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()