修复 AGC 弹窗打开时自绘标题栏窗口按钮失效

- ThemedModal 的焦点陷阱只放行落在 [data-window-chrome-bar] 内的点击,标题栏拖拽与最小化/最大化/关闭恢复可用,工作区内容点击仍被拦住
- WindowChrome 标题栏加 data-window-chrome-bar 标记,作为这条约定的唯一契约点
- styles.css 明确「全屏弹层一律从标题栏下方开始」,.app-update-overlay 从 inset:0 改为标题栏下方,.game-publish-progress-overlay 显式声明 top
- 新增 tests/windowChromeOverlayContract.test.ts 覆盖 7 个全屏弹层;themedModal / WindowChrome 用例补「标题栏点击放行 + 工作区点击仍被拦」回归;gamePublishFeedback 用例按新口径断言
- pitfalls 记录该静默失效的机制与现行口径
This commit is contained in:
kdletters
2026-09-23 19:04:24 +08:00
parent ebb288a6a4
commit c38d07044a
8 changed files with 206 additions and 6 deletions
@@ -149,7 +149,11 @@ export function WindowChrome({ children }: WindowChromeProps) {
<WindowChromeContext.Provider value={contextValue}>
<div className="window-chrome">
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
<header className="window-chrome__bar" aria-label="窗口标题栏">
<header
className="window-chrome__bar"
data-window-chrome-bar
aria-label="窗口标题栏"
>
<div className="window-chrome__leading">
<div
className="window-chrome__brand"
@@ -4,6 +4,23 @@ import { createPortal } from 'react-dom';
type ThemedModalTheme = 'light' | 'dark';
/**
* 自绘标题栏的标记:它是窗口边框,不属于模态内容。
*
* focus-trap 默认会拦下模态之外的所有点击(`click` 事件在 document 捕获阶段直接
* `stopImmediatePropagation`),所以任何弹窗打开时「最小化 / 最大化 / 关闭」和标题栏
* 拖拽都会静默失效。这里只对落在标题栏内的目标放行;页面内容仍然由遮罩和焦点陷阱
* 挡在模态之外,点空白处不会误触底层界面。
*/
const WINDOW_CHROME_BAR_SELECTOR = '[data-window-chrome-bar]';
function isWindowChromeBarTarget(target: EventTarget | null) {
return (
target instanceof Element &&
target.closest(WINDOW_CHROME_BAR_SELECTOR) !== null
);
}
export type ThemedModalProps = {
open: boolean;
ariaLabel: string;
@@ -55,6 +72,7 @@ export function ThemedModal({
escapeDeactivates: false,
fallbackFocus: () => panelRef.current!,
returnFocusOnDeactivate: true,
allowOutsideClick: (event) => isWindowChromeBarTarget(event.target),
}}
>
<div
+17 -3
View File
@@ -117,7 +117,11 @@ body {
.app-update-overlay {
position: fixed;
z-index: 260;
inset: 0;
/* 中文注释:全屏弹层一律从自绘标题栏下方开始,标题栏的最小化 / 最大化 / 关闭必须始终可用。 */
top: var(--window-chrome-height);
right: 0;
bottom: 0;
left: 0;
display: grid;
padding: 24px;
background: rgb(35 20 12 / 48%);
@@ -209,7 +213,13 @@ body {
}
:root {
/* 网页内自绘标题栏占用的顶部高度;portal 到 body 的固定弹层也要从它下方开始。 */
/*
* 网页内自绘标题栏占用的顶部高度
*
* 约定portal body 的全屏固定弹层一律从标题栏下方开始`top: var(--window-chrome-height)`
* 标题栏是窗口边框不是弹层内容 弹出任何面板时最小化 / 最大化 / 关闭和拖拽都必须
* 保持可用模态内部的焦点陷阱也必须放行落在标题栏上的点击 `ThemedModal`
*/
--window-chrome-height: 50px;
}
@@ -9744,7 +9754,11 @@ iframe.preview-frame {
.game-publish-progress-overlay {
position: fixed;
z-index: 500;
inset: 0;
/* 中文注释:发布进行中仍然要能最小化 / 关闭窗口,遮罩只压住工作区。 */
top: var(--window-chrome-height);
right: 0;
bottom: 0;
left: 0;
background: rgb(35 24 19 / 62%);
backdrop-filter: blur(3px);
pointer-events: auto;
@@ -2,12 +2,25 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GameCreatorDirectActiveTurn } from '../src/app/types';
import { ThemedModal } from '../src/components/modal/ThemedModal';
import { WindowChrome } from '../src/components/WindowChrome';
import { useWindowChrome } from '../src/components/windowChromeContext';
const nativeWindow = vi.hoisted(() => ({
minimize: vi.fn(),
toggleMaximize: vi.fn(),
isMaximized: vi.fn(),
close: vi.fn(),
label: 'client',
}));
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: () => nativeWindow,
}));
function TitleSetter({ value }: { value: string }) {
const { setTitle } = useWindowChrome();
return (
@@ -36,6 +49,14 @@ function ActiveRunsSetter({
}
describe('WindowChrome', () => {
beforeEach(() => {
nativeWindow.minimize.mockReset();
nativeWindow.toggleMaximize.mockReset();
nativeWindow.isMaximized.mockReset();
nativeWindow.close.mockReset();
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
});
it('renders the陶泥儿 brand, default title, and controls', async () => {
const user = userEvent.setup();
render(
@@ -141,4 +162,35 @@ describe('WindowChrome', () => {
);
expect(screen.getAllByRole('menuitem')).toHaveLength(2);
});
/**
* 回归:发布面板等 ThemedModal 弹窗打开时,标题栏在模态之外,焦点陷阱曾把
* 标题栏上的点击一起拦下 —— 三个窗口按钮看着正常但点不动。
*/
it('keeps the window controls working while a modal covers the workspace', async () => {
const user = userEvent.setup();
nativeWindow.minimize.mockResolvedValue(undefined);
nativeWindow.toggleMaximize.mockResolvedValue(undefined);
nativeWindow.close.mockResolvedValue(undefined);
nativeWindow.isMaximized.mockResolvedValue(false);
(window as unknown as Record<string, unknown>).__TAURI_INTERNALS__ = {};
render(
<WindowChrome>
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
<button type="button"></button>
</ThemedModal>
</WindowChrome>,
);
await screen.findByRole('dialog', { name: '测试弹窗' });
await user.click(screen.getByRole('button', { name: '最小化' }));
expect(nativeWindow.minimize).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '最大化' }));
expect(nativeWindow.toggleMaximize).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '关闭' }));
expect(nativeWindow.close).toHaveBeenCalledTimes(1);
});
});
@@ -266,7 +266,11 @@ describe('客户端发布入口的可见反馈', () => {
1440,
);
expect(declaration(overlay, 'position')).toBe('fixed');
expect(declaration(overlay, 'inset')).toBe('0');
// 遮罩从自绘标题栏下方开始:发布进行中仍然要能最小化 / 关闭窗口。
expect(declaration(overlay, 'top')).toBe('var(--window-chrome-height)');
expect(declaration(overlay, 'right')).toBe('0');
expect(declaration(overlay, 'bottom')).toBe('0');
expect(declaration(overlay, 'left')).toBe('0');
expect(declaration(overlay, 'z-index')).toBe('500');
expect(declaration(overlay, 'pointer-events')).toBe('auto');
expect(declaration(overlay, 'background')).toBe('rgb(35 24 19 / 62%)');
@@ -35,6 +35,34 @@ function ModalHarness({ noFocusableContent = false }) {
);
}
/**
* 标题栏在模态之外,但它是窗口边框:弹窗打开时最小化 / 最大化 / 关闭必须照常可点。
* 工作区内容反过来仍要被模态挡住,不能因为放行标题栏就一起漏过去。
*/
function WindowChromeHarness({
onMinimize,
onWorkspaceClick,
}: {
onMinimize: () => void;
onWorkspaceClick: () => void;
}) {
return (
<>
<div className="window-chrome__bar" data-window-chrome-bar>
<button type="button" onClick={onMinimize}>
</button>
</div>
<button type="button" onClick={onWorkspaceClick}>
</button>
<ThemedModal open onClose={() => undefined} ariaLabel="测试弹窗">
<button type="button"></button>
</ThemedModal>
</>
);
}
describe('ThemedModal', () => {
beforeEach(() => {
vi.spyOn(HTMLElement.prototype, 'getClientRects').mockImplementation(
@@ -105,4 +133,23 @@ describe('ThemedModal', () => {
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(document.activeElement).toBe(opener);
});
it('lets window title bar clicks through while workspace clicks stay trapped', async () => {
const user = userEvent.setup();
const onMinimize = vi.fn();
const onWorkspaceClick = vi.fn();
render(
<WindowChromeHarness
onMinimize={onMinimize}
onWorkspaceClick={onWorkspaceClick}
/>,
);
await screen.findByRole('dialog', { name: '测试弹窗' });
await user.click(screen.getByRole('button', { name: '最小化' }));
expect(onMinimize).toHaveBeenCalledTimes(1);
await user.click(screen.getByRole('button', { name: '工作区按钮' }));
expect(onWorkspaceClick).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,53 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { repoPath } from './repoPath';
import { parseStyleSheet } from './styleCascade';
const STYLES_PATH = repoPath('apps/ai-game-creator-shell/src/styles.css');
/**
* 全屏弹层清单:每一层都必须从自绘标题栏下方开始。
*
* 标题栏是窗口边框,不是弹层内容 —— 只要有一个全屏遮罩盖住它,弹窗打开时
* 「最小化 / 最大化 / 关闭」就会被挡住。焦点陷阱那一半的问题见
* `themedModal.test.tsx` 与 `WindowChrome.test.tsx`;新增全屏弹层时把类名加进这份清单。
*/
const WINDOW_CHROME_SAFE_OVERLAYS = [
// ThemedModal 与共享弹层的通用遮罩:top 由这条规则统一抬到标题栏下方。
'.fixed.inset-0',
'.app-update-overlay',
'.game-publish-progress-overlay',
'.launcher-dialog-backdrop',
'.settings-overlay',
'.game-approval-backdrop',
'.project-chat-settings-backdrop',
] as const;
function declarationsForSelector(css: string, selector: string) {
const merged = new Map<string, string>();
for (const rule of parseStyleSheet(css)) {
if (!rule.selectors.includes(selector)) {
continue;
}
for (const [property, value] of rule.declarations) {
merged.set(property, value);
}
}
return merged;
}
describe('窗口标题栏与全屏弹层的层叠约定', () => {
const css = readFileSync(STYLES_PATH, 'utf8');
it.each(WINDOW_CHROME_SAFE_OVERLAYS)('%s 从标题栏下方开始', (selector) => {
const declarations = declarationsForSelector(css, selector);
expect(
declarations.get('top'),
`${selector} 必须声明 top: var(--window-chrome-height)`,
).toBe('var(--window-chrome-height)');
});
});
@@ -1,5 +1,13 @@
# 踩坑与排障记录
## 2026-09-23 弹窗打开时自绘标题栏的最小化 / 最大化 / 关闭静默失效
- **现象**:AGC 打开「发布到游戏广场」面板(以及其它任何弹窗)后,右上角三个窗口按钮点了没有任何反应,拖拽标题栏也不能移动窗口;关掉弹窗立刻恢复。标题栏看着完全正常,遮罩也明显只压住了下面的工作区,所以很容易误判成「按钮自己坏了」或 Tauri 窗口 API 挂了。
- **原因**:标题栏在模态之外,但它是窗口边框。`ThemedModal` 用的 `focus-trap-react` 在 **document 捕获阶段**监听 `mousedown`/`touchstart`/`click`:模态外的点击一律 `preventDefault()``click` 还会 `stopImmediatePropagation()`。React 的监听挂在 document 内的根容器上,捕获阶段就被掐掉的 `click` 永远到不了 React,于是既不报错也不执行 —— 与「焦点陷阱吞掉模态外点击」是同一类问题(见 2026-09-20 发布面板焦点陷阱那条)。另有一条独立的同类缺陷:`.app-update-overlay``inset: 0`,把标题栏真的盖住了,更新弹窗期间按钮被遮罩挡住。
- **处理(现行口径)**:① 全屏弹层一律从标题栏下方开始(`top: var(--window-chrome-height)`),不得用 `inset: 0` 盖住标题栏;② `ThemedModal` 的焦点陷阱用 `allowOutsideClick` 只放行落在 `[data-window-chrome-bar]` 内的目标,工作区内容点击继续被拦;③ 新增全屏弹层时把类名补进 `apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts` 的清单。
- **验证**`npx vitest run apps/ai-game-creator-shell/tests/themedModal.test.tsx apps/ai-game-creator-shell/tests/WindowChrome.test.tsx apps/ai-game-creator-shell/tests/windowChromeOverlayContract.test.ts`(标题栏点击放行、工作区点击仍被拦、7 个全屏弹层都在标题栏下方);两个新增用例去掉修复后确实失败,确认能守住这条约定。
- **关联**`apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx``apps/ai-game-creator-shell/src/components/WindowChrome.tsx``apps/ai-game-creator-shell/src/styles.css`
## Direct 宿主继续请求不能重发原始用户条目
原始 `direct_user_item` 同时参与历史持久化和模型输入转换;验收或错误反馈更新了 prompt 后,如果发送层仍优先转换原始条目,模型会收到重复的用户输入,而本地历史按 itemId 去重后只显示一次。首次请求与宿主继续必须显式区分:首次保留结构化输入,继续发送当次反馈,原始条目只保留历史与事件关联职责。GUI、CLI 的两条循环都要覆盖;只改反馈文本或清空原始条目不完整。见 [Direct 宿主继续请求输入修复](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#2026-09-23-direct-宿主继续请求输入修复)。