docs(09-28): add hotkey settings UI plan and execution summary (code uncommitted)

This commit is contained in:
unanmed 2026-10-26 21:38:29 +08:00
parent 63e960ec29
commit 766cb3c68c
3 changed files with 696 additions and 0 deletions

View File

@ -0,0 +1,10 @@
{
"head": "9fee3c4392b4fc8bf7c8d19447d7b525cd37a845",
"hashes": {
"src/client/ui/settings.tsx": "c5cca14cde8ba813ac4ef5e624e8b359470c472d",
"src/client/ui/index.ts": "4e3950598b1beee23753a5556d8e8c82eb21bc57",
"src/client/misc/hotkey.ts": "6e884ab4f36cdff958a3490697406a00f67d2e7f"
},
"eslint": [],
"typecheck": []
}

View File

@ -0,0 +1,488 @@
---
phase: 09-minimum-browser-runnable
plan: 28
type: execute
# 23:沿用本阶段既有计划的波次编号,使本计划排期在 09-01..09-27 之后(D-01 增量派发,计划累积)
# depends_on 为空:本计划改动的文件没有任何并发计划在本波次触碰,故结构上独立;
# 运行在最后的波次只是「排期在后」,不是「依赖前序计划」
wave: 23
depends_on: []
# 09-28-BASELINE.json 由 Task 1 在收到「可以执行」之后、编辑任何代码之前写出(Task 0 只汇报、不写文件)
files_modified:
- packages-user/client-base/src/hotkey/types.ts
- packages-user/client-base/src/hotkey/hotkey.ts
- src/client/ui/hotkey.tsx
- src/client/ui/__test__/hotkeySettingsGate.test.ts
- .planning/phases/09-minimum-browser-runnable/09-28-PLAN.md
- .planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json
- .planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md
autonomous: false
requirements: []
estimate:
# 未校准估算(confidence: low),SUMMARY 须声明这一点。
# 本计划只触碰 2 个既有代码文件(各新增 1 个方法)、1 个新 UI 文件、1 个新测试文件 + 3 个 .planning 产物;
# 主要成本在读取 UI 原语(Scroll / Selection / g-rectr / text / container)、旧 hotkey.vue 的布局语义、
# 以及跑两次全仓 vue-tsc(基线 / 收尾)与 eslint / vitest。tokens 为 raw 的 2 倍,
# 与 09-19 的换算口径一致;仍属未校准,SUMMARY 如实记录。该值在单计划 smart-zone 预算内,不需要拆分。
tokens: 90000
raw_tokens: 45000
tasks: 4
confidence: low
must_haves:
truths:
- "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md 硬要求)**:先汇报「只实现热键设置界面(不含入口接入、不含持久化、不含冲突检测 / 恢复默认)、新增 `iterateGroups()` 分组迭代 API、并按旧 hotkey.vue 的左分组右指令布局重构」的要做什么 / 为什么 / 大致怎么做 / 影响范围,逐条列明已批准标识符、待确认的文件内私有命名与 GameUI id 字符串、行为口径(Ctrl / Shift / Alt 可作主键、无持久化、捕获期阻断热键系统)、验证方式,以及「不做浏览器 UAT / 执行器不提交任何代码」两条铁律,然后停下等待用户恰好回复「可以执行」。"
- "**只做热键设置界面本身,不接入口**:新建 `src/client/ui/hotkey.tsx`,导出 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`;**不改** `src/client/ui/settings.tsx`、**不改** `src/client/ui/index.ts`、不新增任何设置菜单项或打开逻辑。本计划完成后该模块只「可被后续计划导入」,应用内没有任何入口,这是**用户明确的决定**,不是遗漏。"
- "**不做持久化**:`hotkey.tsx` 内不得出现保存 / 读取存档相关调用;编辑只作用于当前会话内存中的 `IHotkeyCommand` 实例,页面刷新即丢失。`setKeyCombination(index, combination)` 是唯一的写回路径。"
- "**不做冲突检测、不做恢复默认**:不得加入任何按键冲突判定、冲突提示、默认键恢复 / 重置按钮或逻辑。每个按键组合只负责「点选 → 捕获 → 写回」这一条路径。"
- "**`iterateGroups()` 只新增这一个 API**:在 `packages-user/client-base/src/hotkey/types.ts` 的 `IClientHotkey` 接口中新增带 jsDoc 的 `iterateGroups()`,签名返回 `[id: string, group: IHotkeyCommandGroup]` 的可迭代对象;在 `packages-user/client-base/src/hotkey/hotkey.ts` 的 `ClientHotkey` 中实现为直接返回底层分组 Map 的 entries。`setGroup` / `addCommandToGroup` / `getGroup` / `getUngrouped` / `saveState` / `loadState` 与其它热键 API 一律零改动。"
- "**布局与旧 hotkey.vue 对齐**:全屏(`FULL_LOC`)两栏;左栏是分组列表(用 `Scroll` 承载,分组数超过一屏时纵向滚动,不设分组上限假设),右栏是当前分组的全部指令;每条指令显示名称,并为**每一个按键组合**渲染一个独立、可点击的框(同一指令的多组合纵向堆叠,右栏右对齐)。切换分组会切换右栏并取消当前捕获。"
- "**捕获语义(用户决定)**:点击按键框后高亮该框,并以**捕获阶段**在 `document` 上监听 `keydown` / `keyup`;两个处理器都调用 `preventDefault()` 与 `stopPropagation()`,并调用 `stopImmediatePropagation()` 以确保热键系统不会处理这些按键。按住期间选中框实时显示当前按住的组合;**第一次 keyup 结束捕获**,被松开的键是主键,仍按住的其他键按按下顺序作为辅助键,即组合 = `[...仍按住且不等于松开键, 松开键]`(例:按 Ctrl、W、J 后松开 W,显示并写入 `Ctrl + J + W`)。`Ctrl` / `Shift` / `Alt` **允许作为主键**,不做排除。"
- "**显示与事件映射**:组合显示使用 `KeyCodeUtils.toString` 逐个转换后以 `' + '` 连接;空组合显示 `未设置`。DOM 事件通过 `EVENT_KEY_CODE_MAP` 映射为 `KeyCode`,映射不到或映射为 `KeyCode.Unknown` 时忽略。不使用字符串键或新的事件名。"
- "**UI 原语与 R-1**:使用当前 UI 原语(`container` / `text` / `Scroll` / `Selection` 与 `g-rectr`);**不绘制 UI 背景**(遵循 R-1,背景交给 UI 系统的背景控制器),即不出现整屏 `<g-rect>`、不引入 `Background` 组件。非捕获状态用新 `useKey` 实现 `@common/exit`(仅代码形状;因 `IHotkeySystem.listen` 无生产调用,**不宣称键盘关闭已验证**),并以左上角可点击的 `返回` 文本作为实际关闭手段;捕获状态下 `@common/exit` 的实现必须直接返回,不关闭界面。"
- "**验证形态**:eslint(改动文件)+ `pnpm check:type` 前后基线比较(仅改动文件集合内的 NEW 诊断判失败,既有基线诊断允许消失)+ 新增 `?raw` 源码闸门 vitest + 受保护文件哈希 / HEAD 未变闸门 + 改动范围闸门。**不做浏览器 UAT、不宣称任何视觉或按键行为已验证**(`IHotkeySystem.listen` 目前没有生产代码调用,按键投递未接线属预期状态);**本计划执行器 0 次代码提交**:不得运行 `git add` / `commit` / `stash` / `checkout` / `worktree` / `reset`,代码工作树留给用户审查;三个 `.planning` 产物可能由 GSD 编排器按 `.planning/config.json` 的 `commit_docs: true` 聚合提交,不构成本计划的代码提交。"
- "**范围与纪律**:只写 `files_modified` 的 7 个路径;不改 `src/client/ui/settings.tsx` / `src/client/ui/index.ts` / `src/client/misc/hotkey.ts`(按键注册)/ 任何其它 UI / 依赖 / 配置;不修改任何既有注释与 jsDoc(只允许新增);不新增任何未获批准或未在 Task 0 列出的公共命名;不顺手修复任何无关诊断。"
artifacts:
- path: packages-user/client-base/src/hotkey/types.ts
provides: "在 `IClientHotkey` 接口中新增带 jsDoc 的 `iterateGroups()` 方法声明,返回底层分组 Map entries 的可迭代对象(`[id, group]` 元组);其余接口成员与既有注释零改动。"
- path: packages-user/client-base/src/hotkey/hotkey.ts
provides: "`ClientHotkey` 实现 `iterateGroups()`,方法体直接返回 `this.groups.entries()`(不复制、不排序、不创建新 Map);其余方法零改动。"
- path: src/client/ui/hotkey.tsx
provides: "新热键设置界面:`HotkeySettingsProps`(extends `IGameUIPropsBase`)、`hotkeySettingsProps`(props 数组含 controller / instance / state)、`HotkeySettings`、`HotkeySettingsUI`。左分组(`Scroll` 承载)右指令、每组合一个可点击框、捕获阶段监听与首 keyup 主键规则、`setKeyCombination` 写回、`KeyCodeUtils` + `' + '` 显示、`EVENT_KEY_CODE_MAP` 映射、`FULL_LOC` 全屏、无自绘背景、`@common/exit` + `返回` 关闭(`返回` 为实际关闭手段)。"
- path: src/client/ui/__test__/hotkeySettingsGate.test.ts
provides: "新源码形状闸门(vitest,`?raw` 读取,不导入任何 Vue 组件 / 不使用 `node:fs`):断言 `iterateGroups` 同时存在于接口与实现;`hotkey.tsx` 无持久化调用;设置入口与 UI 导出清单未接入;document 监听使用捕获阶段并调用 preventDefault / stopPropagation / stopImmediatePropagation;使用 `setKeyCombination`;显示使用 `KeyCodeUtils` 与 `' + '`;无冲突检测与恢复默认;分组来自 `iterateGroups`;事件映射使用 `EVENT_KEY_CODE_MAP` 且忽略 `KeyCode.Unknown`;全屏使用 `FULL_LOC` 且无自绘背景;关闭使用 `@common/exit` 与 `返回`。所有否定断言先经 `stripComments` 去注释;中文(`未设置` / `返回`)与 `' + '` 字面量断言也放在本 vitest(Task 2 的 `node -e` 保持纯 ASCII)。每个 `it` 前一行注释。"
- path: .planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json
provides: "Task 1 在收到「可以执行」之后、编辑任何代码之前写出的机器可读基线:`{ head: string, hashes: { <完整仓库相对路径>: <sha> ×3 }, eslint: string[], typecheck: string[] }`。`hashes` 保护 `src/client/ui/settings.tsx` / `src/client/ui/index.ts` / `src/client/misc/hotkey.ts`;`eslint` / `typecheck` 只采集当时已存在的两个改动文件(`hotkey/types.ts`、`hotkey/hotkey.ts`)。只写一次,执行期不得重写。"
- path: .planning/phases/09-minimum-browser-runnable/09-28-PLAN.md
provides: "本计划文件本身(已存在,重新生成)。"
- path: .planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md
provides: "执行摘要:新增 API、界面行为口径(Ctrl / Shift / Alt 可作主键、首 keyup 主键规则、无持久化 / 无冲突 / 无恢复默认 / 无入口)、门禁结果(eslint / typecheck 前后基线只判 NEW / vitest 源码闸门 / 哈希与 HEAD 未变 / 范围)、`estimate` 未校准声明、浏览器 UAT 留给用户的说明、执行器 0 次代码提交(`.planning` 产物可能由编排器按 `commit_docs: true` 聚合提交)与代码工作树留给用户审查。"
key_links:
- "`IClientHotkey.iterateGroups()` ↔ `ClientHotkey.groups` Map ↔ `hotkey.tsx` 左栏分组列表 —— 任一侧签名或返回形态对不上,界面拿不到分组;因此闸门同时断言接口声明与实现返回 `this.groups.entries()`。"
- "`command.iterateKeyCombinations()` / `command.length` ↔ `combinationLists` 快照 ↔ 每个组合一个框 —— 用快照而不是每次渲染重新读类实例,是因为 `IHotkeyCommand` 不是响应式对象;写回后必须重建快照,否则界面仍显示旧组合。"
- "`setKeyCombination(index, combination)` ↔ `combinationLists` 重建 ↔ 选中框最终显示 —— 只写回不重建会让「松开按键后框里还是旧值」,这是本界面最容易静默出错的一处。"
- "document 捕获阶段监听 ↔ 热键系统自身的 `keydown` / `keyup` —— 捕获处理器必须 `preventDefault()` + `stopPropagation()` + `stopImmediatePropagation()`,否则捕获期间可能触发 `@common/exit` 等已实现指令。`@common/exit` 的实现再加一层「捕获中直接返回」的守卫。"
- "第一次 keyup 的「松开键为主键」规则 ↔ `heldKeys` 按下顺序 ↔ 最终组合顺序 —— 写成 `[...held.filter(不等于松开键), 松开键]`;写成 `[松开键, ...held]` 会与用户给出的 `Ctrl + J + W` 示例不符。"
- "`EVENT_KEY_CODE_MAP` ↔ `KeyCode` ↔ `KeyCodeUtils.toString` —— 事件码映射与显示必须使用同一套按键枚举;忽略 `KeyCode.Unknown`,否则会写入不可显示的空键。"
- "`src/client/ui/settings.tsx` / `src/client/ui/index.ts` / `src/client/misc/hotkey.ts` 的哈希 ↔ 基线 —— 三处分别是「设置入口」「UI 导出清单」「按键注册」,它们不变才证明本计划没有偷偷接入口或改注册。"
assumptions:
- "**规划日工作树快照(仅作参考,执行期以 Task 1 写出的基线为准)**:HEAD 为 `dc2cc0b2b71d22d833f8aec7f4cb3bcd46ae1a4a`;`git status --porcelain -uall` 含 `packages-user/client-base/src/map/{renderer.ts,shader/map.frag,status.ts,types.ts,vertex.ts}`、`packages-user/client-base/src/use/hotkey.ts`、`packages-user/client-modules/src/render/use.ts`、`src/data/plugin/index.ts`、`packages-user/client-base/src/map/__test__/rendererGate.test.ts`,以及若干 `.planning/phases/10-docs-restructure/*` 文件。**以上全部不在本计划范围,不得触碰、不得提交。** 执行期权威值由 `09-28-BASELINE.json` 记录;所有门禁只读该文件的 `head`,不使用这里的规划日字面量。"
- "**规划日实测:`src/client/ui/hotkey.tsx` 与 `src/client/ui/__test__/hotkeySettingsGate.test.ts` 均不存在**;`src/client/ui/__test__/` 目录已存在(含 09-27 的 `keyAdaptationGate.test.ts`)。"
- "**规划日实测:`src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts` 均不在脏文件列表中**,因此基线哈希取的就是当前工作树内容;本计划不修改它们,收尾用哈希闸门证明。"
- "**规划日实测:`IClientHotkey` 目前没有 `iterateGroups`**,`ClientHotkey` 的 `groups` 是 `private readonly groups: Map<string, IHotkeyCommandGroup>`,可直接 `this.groups.entries()` 暴露为 `[id, group]` 可迭代对象;只有一个 `implements IClientHotkey` 的实现类(`ClientHotkey`),新增方法不会破坏其它实现。"
- "**`IHotkeyCommand` 不是 `@user/client-base` 的导出**:它来自 `@motajs/system`(`packages/system/src/hotkey/types.ts`),`client-base` 的 `hotkey/types.ts` 只是 import 了它而没有再导出。故 `hotkey.tsx` 的类型导入必须从 `@motajs/system` 取,不能从 `@user/client-base` 取。`IHotkeyCommandGroup` 则由 `@user/client-base` 的 `hotkey/types.ts` 导出。"
- "**组合为空时也会被迭代**:`HotkeyCommand.iterateKeyCombinations()` 对每个索引都 yield(未设置的组合是空数组),因此「空组合显示 `未设置`」可以只靠 `formatCombination` 处理。"
- "**`EVENT_KEY_CODE_MAP` 为稀疏数组且没有 Meta 事件码**:无映射的 `keyCode` 得到 `undefined`,`KeyCode.Unknown` 为 `0`。用户只要求 `Ctrl` / `Shift` / `Alt` 可作主键,因此本计划不新增 Meta 的本地映射,也不改 `keyCodes.ts`。"
- "**UI 原语可用**:`Scroll` / `Selection` 来自 `@user/client-base` 的 components,`g-rectr` / `text` / `container` 是工程全局标签;`Font.defaults({ size, weight })` 与 `FULL_LOC` / `MAIN_WIDTH` / `MAIN_HEIGHT` 的用法与 `book.tsx` / `toolbox.tsx` / `09-27` 的 `background.tsx` 一致。`FULL_LOC` 从叶子模块 `@/client/uiConstant` 导入,避免把应用入口拉进模块图。"
- "**dev.md 约束**:不得出现 `import type`;尽量避免 `?.`;对象类型必须先声明 `interface`(因此选中态用「命令 ref + 索引 ref」两个标量,不引入内联对象类型);`//` 后留空格;既有注释只增不改。"
- "**测试收集方式**:根 `vite.config.ts` 的 `test` 段没有 `include`,vitest 默认收集 `**/*.test.ts`;`?raw` 导入类型由 `src/types/vite-env.d.ts` 中的 Vite 客户端类型引用提供;`tsconfig.json` 的 `include` 覆盖 `src/**/*.ts`,新闸门文件会参与类型检查。"
- "**无 RESEARCH / 无 VALIDATION**:本 run 不存在 `09-28-RESEARCH.md` 与 `09-28-VALIDATION.md`,Nyquist 验证不适用(本阶段无 requirement ID);不创建这两类文件。spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)。"
- "**波次与独立性(wave 23 / `depends_on: []`)**:本计划按 D-01 追加在 09-01..09-27 之后;它改动的文件没有任何**并发**计划在本波次触碰,`depends_on: []` 表示结构独立。**`wave: 23` 与 `depends_on: []` 的「不一致」是刻意的**(D-01 增量派发 + D-02 计划累积)。"
prohibitions:
- "不得在用户恰好回复「可以执行」之前执行任何任务或派发执行子代理(AGENTS.md / D-03)。"
- "**不得运行任何 git 写命令**:`git add` / `commit` / `stash` / `checkout` / `restore` / `worktree` / `reset` 一律禁止;代码改动留在工作树,是否提交由用户另行决定;三个 `.planning` 产物可能被编排器按 `commit_docs: true` 聚合提交,不属于本计划的代码提交。"
- "不得修改 `src/client/ui/settings.tsx`(设置入口)、`src/client/ui/index.ts`(UI 导出清单)、`src/client/misc/hotkey.ts`(按键注册);不得新增任何打开热键设置的菜单项、路由或导出。"
- "不得在 `hotkey.tsx` 中加入任何持久化调用或写存档字段;不得调用保存 / 读取存档相关 API;不得在其它文件为热键设置界面新增持久化钩子。"
- "不得加入按键冲突检测、冲突提示、恢复默认 / 重置按键的逻辑或按钮。"
- "不得改动 `IClientHotkey` / `ClientHotkey` 的其它方法(含分组增删、查询、存档与读取);不得让 `iterateGroups()` 复制 Map、排序或遗漏条目。"
- "不得绘制 UI 背景(不出现整屏 `<g-rect>`、不引入 `Background`);不得改其它 UI 的视觉或布局。"
- "不得修改任何既有注释或 jsDoc(AGENTS.md);只允许新增注释,新增注释遵循 dev.md(单行 `//`、`//` 后留空格、说明「为什么」)。"
- "不得新增依赖、不得执行安装命令;不得跑 `build:game` / `build:packages` / `build:lib`;不得通过放宽断言 / 扩大豁免 / 删除用例让门禁「变绿」。"
- "不得伪造验证:按键投递未接线就不得写成「已验证按键行为」;浏览器 UAT 未做必须如实记录;不得把「源码形状通过」写成「界面行为已验证」。"
- "不得越界处理发现的其它疑似问题(含 `IHotkeySystem.listen` 无生产调用、Meta 键未映射、`getUngrouped()` 未在本界面展示);只记录、不擅自修复、不擅自登记为缺陷。"
open_items:
- "**OI-1(用户已批准的命名,本计划直接使用,无需再报备):** 文件 `src/client/ui/hotkey.tsx`;组件 / 类型 `HotkeySettings`、`HotkeySettingsUI`、`HotkeySettingsProps`;新 API `iterateGroups`。"
- "**OI-2(文件内私有命名,按 AGENTS.md 在 Task 0 一并报备,不导出、不进入公共 API):** `hotkeySettingsProps`;refs `groups`、`selectedGroup`、`commands`、`combinationLists`、`selectedCommand`、`selectedIndex`、`heldKeys`;辅助函数 `formatCombination`、`refreshCombinations`、`getCommandHeight`、`getCommandTop`、`getContentHeight`、`selectGroup`、`selectKey`、`removeCapture`、`cancelCapture`、`onKeyDown`、`onKeyUp`、`close`;布局常量 `LEFT_PAD`、`TOP_PAD`、`GROUP_WIDTH`、`GROUP_ROW_HEIGHT`、`LIST_TOP`、`RIGHT_LEFT`、`RIGHT_WIDTH`、`LIST_HEIGHT`、`KEY_BOX_WIDTH`、`KEY_BOX_HEIGHT`、`COMMAND_GAP`、`CONTENT_FONT`;`GameUI` 的 name 字符串 `'hotkey-settings'`(沿用既有 kebab-case id 形态,不是 JS 标识符)。**若你要求改名,执行前按你的命名整体替换,方案形态不变。**"
- "**OI-3(已知限制 / 已知遗留,只记录、不修复):** ①无入口接入——本计划完成后应用内没有打开该界面的路径,模块只供后续计划导入(用户决定);②无持久化——编辑仅在当前会话内存,刷新即丢失(用户决定);③无冲突检测、无恢复默认(用户决定);④`getUngrouped()` 返回的未分组按键不在本界面展示(用户只要求按分组展示);⑤Meta 键未映射(用户只要求 Ctrl / Shift / Alt 可作主键);⑥`IHotkeySystem.listen` 无生产调用、按键投递未接线属**预期状态**,不在本计划范围;⑦浏览器 UAT 全程跳过,视觉与按键行为由用户自行验证。"
- "**OI-4(只提问、未确认为问题):** 分组名称与指令名称直接使用 `IHotkeyCommandGroup.name` / `IHotkeyCommand.name` 的既有文案;若后续希望支持搜索、多语言或分组排序,属另一批范围,本计划不实现。"
---
<!-- planner-discipline-allow: saveState -->
<!-- planner-discipline-allow: setGlobal -->
<!-- planner-discipline-allow: loadState -->
<!-- planner-discipline-allow: conflict -->
<!-- planner-discipline-allow: restoreDefault -->
<!-- planner-discipline-allow: resetDefault -->
<objective>
Phase 9 · 第 28 个计划(新增的独立增量)—— **只实现热键设置界面本身,并新增 `iterateGroups()` 分组迭代 API**。
**要解决的问题:**
1. **没有可用的热键设置界面**:旧 `packages/legacy-ui/src/ui/hotkey.vue` 提供了「左分组、右指令、每条指令多个可点击按键框」的设置形态,但新 UI 体系里没有对应实现,用户无法查看 / 修改按键组合。
2. **缺少分组迭代 API**:`IClientHotkey` 只能按 id 查单个分组(`getGroup`)或取未分组指令(`getUngrouped`),没有「遍历所有分组」的公共入口,界面无法枚举左栏。
3. **按键组合的编辑缺少最小闭环**:需要在不接入口、不持久化、不做冲突检测 / 恢复默认的前提下,先交付一个可被后续计划直接导入的界面:点框 → 捕获 → 按首次 keyup 规则合成组合 → `setKeyCombination` 写回内存。
**做法(用户决定,不得扩大):**
- **新增 API**:`IClientHotkey.iterateGroups()`(带 jsDoc)返回 `[id, group]` 可迭代对象;`ClientHotkey` 实现为 `this.groups.entries()`。
- **新增界面** `src/client/ui/hotkey.tsx`:
- `FULL_LOC` 全屏两栏;左栏分组列表(`iterateGroups()`,用 `Scroll` 承载,超出一屏可滚动),右栏当前分组的全部指令;每条指令名称 + **每个按键组合一个独立可点击框**,同指令的多个框纵向堆叠并右对齐(对齐旧 `hotkey.vue`)。
- 选中分组切换右栏并取消捕获;点击按键框高亮该框并进入捕获。
- 捕获阶段在 `document` 上监听 `keydown` / `keyup`,两个处理器都 `preventDefault()` + `stopPropagation()` + `stopImmediatePropagation()`;按住期间选中框实时显示当前组合;**首次 keyup** 结束捕获,松开键为主键,仍按住的键按按下顺序作辅助键。
- `Ctrl` / `Shift` / `Alt` 允许作为主键(不排除修饰键)。
- 写回 `command.setKeyCombination(index, combination)`;**不持久化**。
- 显示 `KeyCodeUtils.toString` + `' + '`;空组合 `未设置`;事件经 `EVENT_KEY_CODE_MAP` 映射,忽略 `KeyCode.Unknown`。
- 使用 `container` / `text` / `Scroll` / `Selection` / `g-rectr`;**不画背景**(R-1);非捕获时以 `useKey` 实现 `@common/exit`(**仅代码形状,未验证**:`IHotkeySystem.listen` 无生产调用),实际关闭手段是左上角可点击 `返回`;捕获时 `@common/exit` 直接返回。
- 导出 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`;**不改** `settings.tsx`、**不改** `ui/index.ts`、**不改**按键注册。
- **验证**:eslint(改动文件)+ `pnpm check:type` 前后基线(仅改动文件集合内的 NEW 诊断判失败)+ 新增 `?raw` 源码闸门 vitest + 受保护文件哈希 / HEAD 未变 + 改动范围闸门;**不做浏览器 UAT、执行器不提交任何代码**(`.planning` 产物可能由编排器按 `commit_docs: true` 聚合提交)。
**约束:** 只动 `files_modified` 的 7 个路径;不改既有注释与 jsDoc;不新增依赖;不改配置;不新增未获批准或未在 Task 0 报备的公共命名;不顺手修复任何无关诊断。
**修改方式声明(AGENTS.md):** 本计划的全部文件写入(写基线、改 2 个 hotkey API 文件、新建 1 个 UI、新建 1 个闸门测试、写摘要)由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入;**Task 0 只汇报、不写任何文件**;`09-28-BASELINE.json` 由 **Task 1 在收到「可以执行」之后、编辑任何代码之前的第一步**写出(只写一次,此后不得重写)。「开始执行」的界限仍是「可以执行」四个字。
**可见跳过:** spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)。本阶段无 requirement ID,不创建 RESEARCH.md / VALIDATION.md,Nyquist 验证不适用。
</objective>
## 本计划产出物(Artifacts this plan produces)
| 类型 | 名称 / 路径 | 说明 |
|------|-------------|------|
| 修改 | `packages-user/client-base/src/hotkey/types.ts` | `IClientHotkey` 新增带 jsDoc 的 `iterateGroups()` |
| 修改 | `packages-user/client-base/src/hotkey/hotkey.ts` | `ClientHotkey.iterateGroups()` 返回 `this.groups.entries()` |
| 新增 | `src/client/ui/hotkey.tsx` | 热键设置界面(导出 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`) |
| 新增 | `src/client/ui/__test__/hotkeySettingsGate.test.ts` | 源码形状闸门(`?raw`,12 组断言) |
| 基线 | `.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json` | `head` + 3 个受保护文件哈希 + eslint / typecheck 基线 |
| 交付 | `.planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md` | 执行摘要、行为口径、门禁结果、未接入 / 未持久化 / 未 UAT 声明、执行器 0 次代码提交 |
| 计划 | `.planning/phases/09-minimum-browser-runnable/09-28-PLAN.md` | 本文件 |
## 覆盖审计(Multi-Source Coverage Audit)
| 源 | 条目 | 覆盖位置 |
|----|------|----------|
| GOAL(ROADMAP §Phase 9) | 「可在浏览器中运行并验证的最小版本」伞目标 | **本增量不新增覆盖**:只交付界面与分组 API,无入口接入、无浏览器验证;SUMMARY 如实记录 |
| REQ | 本阶段无 requirement ID(`requirements: []`) | Nyquist 不适用 |
| RESEARCH | 本 run 无 RESEARCH.md | 不适用 |
| CONTEXT D-01 | 增量派发,一个任务一个计划 | 本计划即该次派发的唯一计划,不拆分 |
| CONTEXT D-02 | 计划可累积追加 | plan 28,排在 09-01..09-27 之后(`wave: 23` 只是排期编号) |
| CONTEXT D-03 | 执行前必须先汇报并等待「可以执行」 | Task 0(`blocking-human`),`autonomous: false` |
| CONTEXT D-04 | 新命名必须先反馈获批 | OI-1(已批准)+ OI-2(文件内私有命名与 GameUI id 字符串,Task 0 报备) |
| CONTEXT D-06 | 范围由用户界定,不擅自扩大 | `files_modified` 7 项 + 范围闸门 + 三处受保护文件哈希 |
| 用户决定 · 入口 | 不接入口,只导出界面 | 本 objective / prohibitions / Task 2 / Task 3 闸门(settings.tsx 与 index.ts 不变) |
| 用户决定 · 持久化 | 不持久化,编辑仅在内存 | 本 objective / prohibitions / Task 2 / Task 3 闸门 |
| 用户决定 · 冲突与默认 | 不做冲突检测、不做恢复默认 | 本 objective / prohibitions / Task 3 闸门 |
| 用户决定 · 组合框 | 每个组合一个可点击框,纵向堆叠 | Task 2 布局与渲染 / Task 3 闸门 |
| 用户决定 · 修饰键 | `Ctrl` / `Shift` / `Alt` 可作主键 | Task 2 捕获逻辑 / Task 0 汇报 |
| 用户决定 · 捕获 | 捕获阶段、阻断系统、首 keyup 主键规则 | Task 2 / Task 3 闸门 / Task 0 示例 |
| 用户决定 · 显示 | `KeyCodeUtils` + `' + '`、空为 `未设置`、`EVENT_KEY_CODE_MAP` | Task 2 / Task 3 闸门 |
| 用户决定 · 原语与 R-1 | `Scroll` / `Selection` / `g-rectr` / `text` / `container`,无背景 | Task 2 / Task 3 闸门 |
| 用户决定 · 关闭 | `@common/exit`(非捕获)+ `返回` 文本 | Task 2 / Task 3 闸门 |
| 用户决定 · 验证 | eslint / 前后基线 / `?raw` 闸门 / 无 UAT / 无提交 | Task 1–3 门禁 + Task 3 闸门文件 + SUMMARY |
| 用户决定 · 不扩大 | 不改按键注册、不改其它 UI | prohibitions + 哈希闸门 |
<execution_context>
@C:/Users/13194/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/13194/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md
@.planning/phases/09-minimum-browser-runnable/09-PATTERNS.md
@.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md
@AGENTS.md
@dev.md
@packages-user/client-base/src/hotkey/types.ts
@packages-user/client-base/src/hotkey/hotkey.ts
@packages-user/client-base/src/hotkey/index.ts
@packages-user/client-base/src/index.ts
@packages-user/client-base/src/types.ts
@packages-user/client-base/src/components/scroll.tsx
@packages-user/client-base/src/components/misc.tsx
@packages-user/client-base/src/use/hotkey.ts
@packages/system/src/hotkey/types.ts
@packages/system/src/hotkey/keyCodes.ts
@packages/system/src/hotkey/command.ts
@packages/system/src/hotkey/system.ts
@src/client/uiConstant.ts
@src/client/ui/choices.tsx
@src/client/ui/book.tsx
@src/client/ui/toolbox.tsx
@src/client/ui/inputBox.tsx
@src/client/ui/__test__/keyAdaptationGate.test.ts
@src/client/misc/hotkey.ts
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 0: 预执行汇报关卡 —— 汇报「只实现热键设置界面 + 新增 iterateGroups()」的要做什么 / 为什么 / 怎么做 / 影响范围、已批准与待确认命名、行为口径、验证方式,等待「可以执行」</name>
<precondition>工作树仍是改动前状态,可用以下事实自检:`src/client/ui/hotkey.tsx` 与 `src/client/ui/__test__/hotkeySettingsGate.test.ts` 均不存在;`packages-user/client-base/src/hotkey/types.ts` 的 `IClientHotkey` 中**没有** `iterateGroups`(只有 `setGroup` / `addCommandToGroup` / `getGroup` / `getUngrouped`);`packages-user/client-base/src/hotkey/hotkey.ts` 的 `ClientHotkey` 有 `private readonly groups: Map<string, IHotkeyCommandGroup>` 且**没有** `iterateGroups`;`src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts` 均存在且不在脏文件列表中;`src/client/ui/__test__/` 目录存在(含 `keyAdaptationGate.test.ts`);`src/client/uiConstant.ts` 导出 `FULL_LOC` / `MAIN_WIDTH` / `MAIN_HEIGHT`。</precondition>
<files>无(本任务不写任何文件,全部只读命令;`09-28-BASELINE.json` 改由 Task 1 在获批后、改代码前写出)</files>
<read_first>
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-01 增量派发、D-03 汇报关卡、D-04 命名批准、D-06 不扩大范围)
- .planning/phases/09-minimum-browser-runnable/09-27-PLAN.md(前一个同批计划的形态:Task 0 汇报 + 基线 + 前后真值比较门禁)
- packages-user/client-base/src/hotkey/types.ts / hotkey.ts(待新增的 API 与底层分组 Map)
- packages/system/src/hotkey/types.ts / command.ts / keyCodes.ts(`IHotkeyCommand`、`iterateKeyCombinations`、`setKeyCombination`、`KeyCodeUtils`、`EVENT_KEY_CODE_MAP`)
- packages-user/client-base/src/components/{scroll,misc}.tsx 与 src/client/ui/{choices,book,toolbox}.tsx(`Scroll` / `Selection` / `g-rectr` / `text` / `container` 的现有用法)
- packages-user/client-base/src/use/hotkey.ts(`useKey(system, clearImpl)` / `implement`)
- src/client/misc/hotkey.ts(`@common/exit` 的声明,本计划不改它)
- AGENTS.md(执行前须获批、命名先反馈、既有注释不得修改、疑似问题须先确认)、dev.md(注释 / jsDoc / 类型 / 无副作用规范)
</read_first>
<what-built>
一份待批准的执行计划(`09-28-PLAN.md`):只新增热键设置界面(`src/client/ui/hotkey.tsx`)与 `iterateGroups()` 分组迭代 API,不接入口、不持久化、不做冲突检测 / 恢复默认,用 eslint + 类型前后基线 + 源码形状闸门验收。
</what-built>
<action>
**停下并向用户汇报,等待恰好回复「可以执行」后才继续;本任务不写任何文件(含 `09-28-BASELINE.json`——它由 Task 1 的第一步写出),不派发执行子代理。**
① **要解决的问题(三项):**
1. **没有热键设置界面**:旧 `packages/legacy-ui/src/ui/hotkey.vue`(可从 `git show f53f611^:packages/legacy-ui/src/ui/hotkey.vue` 查看)有「左分组右指令、每组合一个可点击框」的形态,新 UI 体系里没有对应实现。
2. **没有分组迭代 API**:`IClientHotkey` 只能按 id 查单个分组或取未分组指令,界面无法枚举左栏。
3. **组合编辑缺最小闭环**:需要在不接入口、不持久化、不做冲突 / 恢复默认的前提下,先交付一个可被后续计划直接导入的界面。
② **新增 API(只加这一个):**
- `IClientHotkey.iterateGroups()`:接口中新增带 jsDoc 的方法声明,返回 `[id, group]` 元组的可迭代对象。
- `ClientHotkey.iterateGroups()`:实现直接返回底层分组 Map 的 entries(不复制、不排序)。
- 其它热键 API(`setGroup` / `addCommandToGroup` / `getGroup` / `getUngrouped` / `saveState` / `loadState`)零改动。
③ **界面设计(对齐旧 hotkey.vue,只做用户要求的形态):**
- `FULL_LOC` 全屏两栏:左栏分组列表(数据来自 `iterateGroups()`),右栏当前分组的全部指令;每条指令显示名称,并为其**每一个按键组合**渲染一个独立可点击框;同一指令的多个框纵向堆叠、右对齐(旧 UI 就是这样堆叠的)。
- 左栏选中项与按键框用 `Selection` 高亮,框底用 `g-rectr`;长列表用 `Scroll`;文字用 `text`;外层用 `container`。
- **不绘制 UI 背景**(R-1):不出现整屏 `g-rect`、不引入 `Background`。
- 左上角放可点击 `返回` 文本;非捕获状态用新 `useKey` + `@common/exit` 关闭。
- **不做**入口接入(不改 `settings.tsx`)、**不做**持久化、**不做**冲突检测、**不做**恢复默认。
④ **交互与行为口径(请重点核对):**
- 点击分组:切换右栏,并取消当前捕获。
- 点击按键框:高亮该框,并以**捕获阶段**在 `document` 上监听 `keydown` / `keyup`;两个处理器都 `preventDefault()` + `stopPropagation()` + `stopImmediatePropagation()`,确保热键系统在捕获期间不处理这些按键。
- 按住期间:选中框实时显示当前按住的组合(按按下顺序)。
- **第一次 keyup 结束捕获**:被松开的键是**主键**,仍按住的其他键按按下顺序作为辅助键,组合 = 仍按住(去掉松开键)后接松开键。**示例:按 Ctrl、W、J 后松开 W → 显示并写入 `Ctrl + J + W`。**
- **`Ctrl` / `Shift` / `Alt` 允许作为主键**(不排除修饰键)。
- 写回:`command.setKeyCombination(index, combination)`;**不持久化**,刷新即丢失。
- 显示:`KeyCodeUtils.toString` 逐个转换后以 `' + '` 连接;空组合显示 `未设置`。
- 事件映射:`EVENT_KEY_CODE_MAP`(与热键系统同一套映射);忽略 `KeyCode.Unknown`。Meta 键不在本次范围(你只要求 Ctrl / Shift / Alt)。
- 关闭:非捕获时 `@common/exit` 与 `返回` 都能关闭;捕获时 `@common/exit` 直接返回、不关闭界面。
⑤ **影响范围:**
- 修改 2 个既有代码文件:`packages-user/client-base/src/hotkey/types.ts`、`packages-user/client-base/src/hotkey/hotkey.ts`(各新增 1 个方法)。
- 新增 2 个代码 / 测试文件:`src/client/ui/hotkey.tsx`、`src/client/ui/__test__/hotkeySettingsGate.test.ts`。
- 另有 3 个 `.planning` 产物:本计划、`09-28-BASELINE.json`、`09-28-SUMMARY.md`。合计 7 个路径。
- **显式不改**:`src/client/ui/settings.tsx`(设置入口)、`src/client/ui/index.ts`(UI 导出清单)、`src/client/misc/hotkey.ts`(按键注册)、其它任何 UI、依赖与配置。
- 本计划完成后应用内**没有**打开该界面的入口——这是你的决定,不是遗漏;模块只供后续计划导入。
⑥ **验证方式:**
1. `pnpm exec eslint` 跑改动文件;只判 NEW(基线由 Task 1 采集)。
2. `pnpm check:type`(`vue-tsc --noEmit`,整仓运行)前后基线比较:只对改动文件集合内的 **NEW** 诊断判失败;既有基线诊断允许消失;不修无关错误。
3. 新增 `src/client/ui/__test__/hotkeySettingsGate.test.ts`(vitest + `?raw`,不导入组件、不使用 `node:fs`),断言:`iterateGroups` 同时在接口与实现;`hotkey.tsx` 无持久化调用;`settings.tsx` 与 `ui/index.ts` 未接入;document 监听使用捕获阶段并调用 `preventDefault` / `stopPropagation` / `stopImmediatePropagation`;使用 `setKeyCombination`;显示使用 `KeyCodeUtils` 与 `' + '`;无冲突检测与恢复默认;分组来自 `iterateGroups`;使用 `EVENT_KEY_CODE_MAP` 且忽略 `KeyCode.Unknown`;`FULL_LOC` 且无自绘背景;关闭使用 `@common/exit` 与 `返回`。**每个 `it` 前一行注释。**
4. 受保护文件哈希闸门(`settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 与基线逐字相等)+ HEAD 与基线一致(证明执行器在 Task 1→Task 3 期间没有提交代码;编排器的 `.planning` 聚合提交另计)。
5. 改动范围闸门:只有 4 个代码 / 测试路径出现变更。
⑦ **标识符清单:**
- **已批准(直接使用)**:文件 `src/client/ui/hotkey.tsx`;`HotkeySettings`、`HotkeySettingsUI`、`HotkeySettingsProps`;新 API `iterateGroups`。
- **文件内私有命名(不导出,按 AGENTS.md 在此一并报备,见 OI-2)**:`hotkeySettingsProps`;refs `groups`、`selectedGroup`、`commands`、`combinationLists`、`selectedCommand`、`selectedIndex`、`heldKeys`;辅助函数 `formatCombination`、`refreshCombinations`、`getCommandHeight`、`getCommandTop`、`getContentHeight`、`selectGroup`、`selectKey`、`removeCapture`、`cancelCapture`、`onKeyDown`、`onKeyUp`、`close`;布局常量 `LEFT_PAD`、`TOP_PAD`、`GROUP_WIDTH`、`GROUP_ROW_HEIGHT`、`LIST_TOP`、`RIGHT_LEFT`、`RIGHT_WIDTH`、`LIST_HEIGHT`、`KEY_BOX_WIDTH`、`KEY_BOX_HEIGHT`、`COMMAND_GAP`、`CONTENT_FONT`;`GameUI` 的 name 字符串 `'hotkey-settings'`(沿用既有 kebab-case id 形态)。**你若要求改名,执行前按你的命名整体替换。**
- 除上述之外**不新增**任何公共 / 受保护命名。
⑧ **已知限制 / 遗留(只记录、不修复):** ①无入口接入;②无持久化;③无冲突检测、无恢复默认;④`getUngrouped()` 的未分组指令不在本界面展示;⑤Meta 键未映射;⑥`IHotkeySystem.listen` 无生产调用、按键投递未接线属**预期状态**;⑦浏览器 UAT 全程跳过,视觉与行为由你自己验证。
⑨ **两条铁律:** **不做浏览器 UAT、不宣称任何视觉或按键行为已验证**;**执行器不提交任何代码**(禁止 `git add` / `commit` / `stash` / `checkout` / `worktree` / `reset`),代码改动留在工作树供你审查。三个 `.planning` 产物可能由 GSD 编排器按 `commit_docs: true` 聚合提交,不算本计划的代码提交。
⑩ **基线记录(本任务不写、只说明字段;实际写出在 Task 1 的第一步):** schema 固定为 `{ head: string, hashes: { <完整仓库相对路径>: <sha> ×3 }, eslint: string[], typecheck: string[] }`。
- `head` = 采集当次的 `git rev-parse HEAD`(规划日实测值为 `dc2cc0b2b71d22d833f8aec7f4cb3bcd46ae1a4a`,**仅供本次汇报参考**;字段必须写入采集当次的值,后续所有门禁只读 `09-28-BASELINE.json` 的 `head`,不使用该字面量)。
- `hashes` = `src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts` 的 `git hash-object`,键必须是完整仓库相对路径(POSIX 正斜杠)。
- `eslint` = 对当时已存在的两个改动文件(`packages-user/client-base/src/hotkey/types.ts`、`packages-user/client-base/src/hotkey/hotkey.ts`)跑 `pnpm exec eslint -f json`,把每条消息归一化为 `仓库相对路径|规则ID|严重度|消息`。
- `typecheck` = 先整仓跑 `pnpm check:type`,再把落在上述两个文件内的诊断归一化为 `文件 error TSxxxx: 消息`(去掉行列号与路径前缀)。该数组是类型门禁的唯一真值,本计划不对任何文件断言条数。
⑪ **修改方式:** 本任务(Task 0)**不写任何文件**(含基线、含任何代码);获批后由 `gsd-executor` 子代理执行 Task 1–3(非内联),只写 `files_modified` 的 7 个路径,其中 `09-28-BASELINE.json` 是 Task 1 的第一步;执行器不提交任何代码(`.planning` 产物可能由编排器按 `commit_docs: true` 聚合提交)。「开始执行」的界限仍是你的「可以执行」四个字。
</action>
<how-to-verify>
逐条核对 ①–⑪ 是否与你的意图一致,特别是:①是否只做「界面 + `iterateGroups()`」,不接入口、不改 `settings.tsx` / `ui/index.ts` / 按键注册;②是否**完全没有**持久化调用;③是否**完全没有**冲突检测与恢复默认;④每组合一个可点击框、同指令纵向堆叠是否与旧 UI 一致;⑤`Ctrl` / `Shift` / `Alt` 可作主键、首次 keyup 的主键规则与 `Ctrl + J + W` 示例是否正确;⑥捕获阶段 + `preventDefault` / `stopPropagation` / `stopImmediatePropagation` 是否满足;⑦`KeyCodeUtils` + `' + '` / `未设置` / `EVENT_KEY_CODE_MAP` 是否满足;⑧`FULL_LOC` + 不画背景 + `@common/exit` + `返回` 是否满足;⑨OI-2 的文件内私有命名与 `'hotkey-settings'` 是否需要改名;⑩「不做浏览器 UAT / 执行器不提交代码」;⑪基线字段与「Task 1 第一步写出」的安排。
</how-to-verify>
<resume-signal>仅当完全同意时回复恰好四个字「可以执行」;任何修改意见(**尤其是 OI-2 的文件内私有命名或 `'hotkey-settings'` 需要改名**)请直接说明,本计划会据此改写后再来一次汇报。</resume-signal>
</task>
<task type="auto">
<name>Task 1: 写出改动前基线,并为 `IClientHotkey` / `ClientHotkey` 新增 `iterateGroups()`</name>
<files>.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json(新增,第一步写出), packages-user/client-base/src/hotkey/types.ts, packages-user/client-base/src/hotkey/hotkey.ts</files>
<action>
**依赖 Task 0 已收到「可以执行」。** **第一步(必须在任何代码编辑之前):** 按 Task 0 ⑩ 的 schema 写出 `.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json`,内容包括:
- `head`:当前 `git rev-parse HEAD`。
- `hashes`:`src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts` 的 `git hash-object`,键用完整仓库相对路径(POSIX 正斜杠),值用小写 sha。
- `eslint`:对 `packages-user/client-base/src/hotkey/types.ts` 与 `packages-user/client-base/src/hotkey/hotkey.ts` 跑 `pnpm exec eslint -f json`,把每条消息归一化为 `仓库相对路径|规则ID|严重度|消息`(`ruleId` 为空写 `null`,路径先统一正斜杠并去掉盘符 / 仓库前缀);数组为空表示干净。`eslint` 以 `status === 1` 退出属于「有告警 / 错误」的正常结果,必须照常解析 stdout 并写入,不得当成「无基线」写空数组;只有未运行 / stdout 为空 / JSON 不可解析才停下上报。
- `typecheck`:先整仓跑 `pnpm check:type`(不要传文件列表),把输出中落在上述两个文件内的 `error TS` 行归一化为 `文件 error TSxxxx: 消息`(去掉 `(行,列):` 与路径前缀、`trim`),保留重复项。该文件只写一次,此后不得重写。
**第二步:** 在 `packages-user/client-base/src/hotkey/types.ts` 的 `IClientHotkey` 接口中,紧接 `getUngrouped()` 之后新增带 jsDoc 的方法声明:
- 方法名 `iterateGroups`,无参数;
- 返回类型是 `[id: string, group: IHotkeyCommandGroup]` 元组的可迭代对象(与 `getUngrouped()` 一样使用 `Iterable`);
- jsDoc 用换行风格,说明「迭代所有的按键分组」,不写多余的参数说明(无参数);
- 其它接口成员、成员顺序与既有注释零改动;接口成员之间保留既有空行。
**第三步:** 在 `packages-user/client-base/src/hotkey/hotkey.ts` 的 `ClientHotkey` 中实现该方法:
- 紧跟 `getUngrouped()` 之后添加,方法体 `return this.groups.entries();`;
- 不复制 Map、不排序、不构造新 Map、不新增字段;
- 实现方法不重复写 jsDoc(dev.md:implements 而来的 API 不重复注释);
- 其它方法零改动,`groups` 字段声明保持 `private readonly groups: Map<string, IHotkeyCommandGroup> = new Map();` 原样。
**注释纪律:** 只新增注释,不改既有注释与 jsDoc;新增注释遵循 dev.md(单行 `//`、`//` 后留空格、说明「为什么」)。
</action>
<verify>
<automated>node -e "const fs=require('fs');const b='.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json';const bad=[];if(!fs.existsSync(b))bad.push('baseline missing');else{const j=JSON.parse(fs.readFileSync(b,'utf8'));for(const k of ['head','hashes','eslint','typecheck'])if(!(k in j))bad.push('missing '+k);if(Object.keys(j.hashes||{}).length!==3)bad.push('hashes must have 3 entries')}const t=fs.readFileSync('packages-user/client-base/src/hotkey/types.ts','utf8');if(t.indexOf('iterateGroups')<0)bad.push('types.ts missing iterateGroups');if(!/\/\*\*[\s\S]*?\*\/\s*iterateGroups\(\)/.test(t))bad.push('types.ts iterateGroups has no jsDoc');if(!/iterateGroups\(\)\s*:\s*Iterable/.test(t))bad.push('types.ts iterateGroups return type missing Iterable');const h=fs.readFileSync('packages-user/client-base/src/hotkey/hotkey.ts','utf8');if(!/iterateGroups\(\)[\s\S]*?this\.groups\.entries\(\)/.test(h))bad.push('hotkey.ts iterateGroups must return this.groups.entries()');if(bad.length){console.error('TASK1 GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task1: baseline + iterateGroups')"</automated>
<fails_when>基线文件缺失或字段残缺、`hashes` 不是 3 条、接口没有 `iterateGroups` 或其 jsDoc 缺失、实现没有返回 `this.groups.entries()`、或任一 node 闸门非零退出。</fails_when>
</verify>
<done>`09-28-BASELINE.json` 已在改代码前写出且只写一次;`IClientHotkey.iterateGroups()` 带 jsDoc;`ClientHotkey.iterateGroups()` 返回 `this.groups.entries()`;其它热键 API 零改动。</done>
</task>
<task type="auto">
<name>Task 2: 新建 `src/client/ui/hotkey.tsx` —— 全屏两栏、每组合一个可点击框、捕获阶段编辑、内存写回</name>
<files>src/client/ui/hotkey.tsx</files>
<action>
**依赖 Task 1 已完成**(`iterateGroups()` 可用)。只新建这一个文件,不改任何其它文件。
**A. 导入(全部走普通导入,禁止 `import type`;import 连续排列,中间不加注释 / 空行):**
- `@motajs/system`:`EVENT_KEY_CODE_MAP`、`GameUI`、`IHotkeyCommand`、`KeyCode`、`KeyCodeUtils`、`SetupComponentOptions`。**`IHotkeyCommand` 必须从 `@motajs/system` 取**(`@user/client-base` 没有导出它)。
- `@motajs/render`:`Font`。
- `vue`:`computed`、`defineComponent`、`onUnmounted`、`ref`、`shallowRef`。
- `@user/client-base`:`IGameUIPropsBase`、`Scroll`、`Selection`、`useKey`。
- `@/client/uiConstant`:`FULL_LOC`、`MAIN_HEIGHT`、`MAIN_WIDTH`(叶子模块,避免拉入应用入口)。
**B. Props 与组件骨架(命名沿用 OI-1 / OI-2):**
- `export interface HotkeySettingsProps extends IGameUIPropsBase {}`(不写字段;`state.hotkey` 从 `IGameUIPropsBase` 的 `state` 取)。
- `const hotkeySettingsProps = { props: ['controller', 'instance', 'state'] } satisfies SetupComponentOptions<HotkeySettingsProps>;`
- `export const HotkeySettings = defineComponent<HotkeySettingsProps>(props => { ... }, hotkeySettingsProps);`
- 文件末尾 `export const HotkeySettingsUI = new GameUI('hotkey-settings', HotkeySettings);`
- 组件与 `HotkeySettingsUI` 上方各加一段简短 jsDoc(说明用途),接口本身不写注释(与 `Book` / `ToolBox` 的现有风格一致)。
**C. 常量与状态:**
- 布局常量(文件顶部、组件外):`LEFT_PAD = 16`、`TOP_PAD = 16`、`GROUP_WIDTH = 180`、`GROUP_ROW_HEIGHT = 24`、`LIST_TOP = TOP_PAD + 32`、`RIGHT_LEFT = GROUP_WIDTH + LEFT_PAD * 2`、`RIGHT_WIDTH = MAIN_WIDTH - RIGHT_LEFT - LEFT_PAD`、`LIST_HEIGHT = MAIN_HEIGHT - LIST_TOP - 48`、`KEY_BOX_WIDTH = 180`、`KEY_BOX_HEIGHT = 24`、`COMMAND_GAP = 12`;字体只保留一个 `CONTENT_FONT = Font.defaults({ size: 13 })`(所有文本共用,不新增未使用的常量)。
- 组件内:`const hotkey = props.state.hotkey;`;`const groups = Array.from(hotkey.iterateGroups());`(类型为 `[id, group]` 数组);`const selectedGroup = ref(groups.length > 0 ? groups[0][0] : '');`;`const commands = computed(() => { const group = hotkey.getGroup(selectedGroup.value); return group ? group.commands : []; });`;`const selectedCommand = shallowRef<IHotkeyCommand | null>(null);`、`const selectedIndex = ref(-1);`、`const heldKeys = ref<KeyCode[]>([]);`、`const combinationLists = ref<KeyCode[][][]>([]);`。**不要用内联对象类型保存选中态**(dev.md 要求对象类型必须先声明 interface);用上述两个标量即可。**`selectedCommand` 必须用 `shallowRef`**:`ref` 会把 `IHotkeyCommand` 实例包成 Proxy,导致「选中框」的 `selectedCommand.value === command` 身份比较恒为 false(高亮与 `@common/exit` 守卫都会失效);组合变化的响应性已由 `combinationLists` 快照承担,不需要深层响应式。
**D. 组合快照与显示:**
- `refreshCombinations(): void`:遍历 `commands.value`,对每条指令遍历 `command.iterateKeyCombinations()`,把每组按键 `slice()` 后放进 `KeyCode[][]`,再整体组成 `KeyCode[][][]` 赋给 `combinationLists.value`。**必须用快照**:`IHotkeyCommand` 不是 Vue 响应式对象,不重建快照的话 `setKeyCombination` 之后界面仍显示旧组合。组件初始化时调用一次,切换分组时调用,写回后调用。
- `formatCombination(keys: readonly KeyCode[])`:`keys.length === 0` 时返回 `'未设置'`;否则 `keys.map(key => KeyCodeUtils.toString(key)).join(' + ')`。**必须使用 `' + '` 字面量**。
- `getCommandHeight(command: IHotkeyCommand): number`:`Math.max(command.length, 1) * KEY_BOX_HEIGHT + COMMAND_GAP`(同一指令的多个组合纵向堆叠,10 组也不会与下一条重叠)。
- `getCommandTop(index: number): number`:累加前面所有指令的 `getCommandHeight`。
- `getContentHeight(): number`:累加当前分组所有指令的高度,作为 `Scroll` 内层容器的高度。
**E. 捕获逻辑(用户决定):**
- `removeCapture(): void`:`document.removeEventListener('keydown', onKeyDown, true)` 与 `document.removeEventListener('keyup', onKeyUp, true)`。
- `cancelCapture(): void`:先 `removeCapture()`,再把 `selectedCommand` 置 `null`、`selectedIndex` 置 `-1`、`heldKeys` 置 `[]`。
- `selectGroup(id: string): void`:`cancelCapture()` 后设置 `selectedGroup.value = id`,并 `refreshCombinations()`。
- `selectKey(command: IHotkeyCommand, index: number): void`:先 `cancelCapture()`,再设置 `selectedCommand` / `selectedIndex`,最后以**捕获阶段**注册两个监听:`document.addEventListener('keydown', onKeyDown, true)`、`document.addEventListener('keyup', onKeyUp, true)`。
- `onKeyDown(event: KeyboardEvent): void`:先 `preventDefault()`、`stopPropagation()`、`stopImmediatePropagation()`;`event.repeat` 为真时直接返回(仍然阻断系统);`const code = EVENT_KEY_CODE_MAP[event.keyCode];`,`!code` 或 `code === KeyCode.Unknown` 时**直接 `return`**;若 `heldKeys.value` 不含该键则追加(按按下顺序去重)。
- `onKeyUp(event: KeyboardEvent): void`:同样先 `preventDefault()`、`stopPropagation()`、`stopImmediatePropagation()`;`const code = EVENT_KEY_CODE_MAP[event.keyCode];`,`!code` 或 `code === KeyCode.Unknown` 时**先 `return`**(不计算组合、不改 `heldKeys`、不结束捕获);然后再算组合 `const combination = heldKeys.value.filter(key => key !== code); combination.push(code);`(即「仍按住且不等于松开键的键,按按下顺序,最后接松开键」);取 `selectedCommand.value` 与 `selectedIndex.value`,`!command` 时只 `cancelCapture()` 并返回;否则 `command.setKeyCombination(index, combination)`、`refreshCombinations()`、`cancelCapture()`。**不要持久化**。
- `onUnmounted(cancelCapture)`:组件卸载时移除监听。
- **`Ctrl` / `Shift` / `Alt` 不做排除**:它们进入 `heldKeys` 后同样可以成为最终组合的主键。
**F. 关闭:**
- `const key = useKey(props.state.hotkey.system)!;`
- `key.implement('@common/exit', () => { if (selectedCommand.value) return; props.controller.close(props.instance); });` —— 捕获中直接返回,不关闭。**该处理器只有代码形状**:`IHotkeySystem.listen` 目前无生产调用,按键投递未接线,因此不宣称键盘关闭已验证。
- `close(): void`:`cancelCapture()` 后 `props.controller.close(props.instance);`,绑定到左上角 `text` 的 `onClick`。**`返回` 文本是本计划实际的关闭手段**;键盘关闭未验证,不得写成已验证。
**G. 渲染(返回函数;只用 `container` / `text` / `Scroll` / `Selection` / `g-rectr`,不画背景):**
- 最外层 `<container loc={FULL_LOC}>`;**不出现整屏 `g-rect`、不引入 `Background`**(R-1)。
- 左上角 `<text text="返回" loc={[LEFT_PAD, TOP_PAD]} cursor="pointer" onClick={close} font={CONTENT_FONT} fillStyle="#ffffff" />`。
- 左栏:`<Scroll loc={[LEFT_PAD, LIST_TOP, GROUP_WIDTH, LIST_HEIGHT]}>`,内层 `<container loc={[0, 0, GROUP_WIDTH, Math.max(groups.length, 1) * GROUP_ROW_HEIGHT]}>`,再 `groups.map(([id, group]) => ...)` 逐行渲染;每行高度 `GROUP_ROW_HEIGHT`,`onClick={() => selectGroup(id)}`;选中行加 `<Selection loc={[0, 0, GROUP_WIDTH, GROUP_ROW_HEIGHT]} border="gold" noevent />`;行内 `<text text={group.name} ... fillStyle={选中 ? 'gold' : '#ffffff'} />`。**左栏必须用 `Scroll`**:分组条目多于 `LIST_HEIGHT / GROUP_ROW_HEIGHT` 时纵向滚动,不假设分组数量上限、不裁掉尾部条目。
- 右栏:`<Scroll loc={[RIGHT_LEFT, LIST_TOP, RIGHT_WIDTH, LIST_HEIGHT]}>`,内层 `<container loc={[0, 0, RIGHT_WIDTH, getContentHeight()]}>`,再 `commands.value.map((command, commandIndex) => ...)`。
- 每条指令:外层 `container` 位于 `[0, getCommandTop(commandIndex), RIGHT_WIDTH, getCommandHeight(command)]`;左侧 `text` 显示 `command.name`;右侧容器位于 `[RIGHT_WIDTH - KEY_BOX_WIDTH - LEFT_PAD, 0, KEY_BOX_WIDTH, 组合数 * KEY_BOX_HEIGHT]`,把 `combinationLists.value[commandIndex]` 的**每一个**组合渲染为一个独立框:
- 框容器 `loc={[0, index * KEY_BOX_HEIGHT, KEY_BOX_WIDTH, KEY_BOX_HEIGHT]}`、`cursor="pointer"`、`onClick={() => selectKey(command, index)}`;
- 框底 `<g-rectr loc={[0, 0, KEY_BOX_WIDTH, KEY_BOX_HEIGHT - 4]} circle={[4]} fill fillStyle="rgba(255, 255, 255, 0.08)" />`;
- 选中时叠加 `<Selection loc={[0, 0, KEY_BOX_WIDTH, KEY_BOX_HEIGHT - 4]} border="gold" noevent />`;
- 文本:若该框是当前选中框且 `heldKeys.value.length > 0`,显示 `formatCombination(heldKeys.value)`(实时按住的组合),否则显示 `formatCombination(该组合快照)`;居中锚点 `anc={[0.5, 0.5]}`,并**必须同时给出框内显式 `loc={[KEY_BOX_WIDTH / 2, (KEY_BOX_HEIGHT - 4) / 2]}`**(`anc` 只定义锚点,不定义位置;缺 `loc` 会落到父级原点),选中时 `fillStyle="gold"`。
- 未设置的组合槽位(`iterateKeyCombinations()` 会 yield 空数组)由 `formatCombination` 显示 `未设置`;不额外为「组合数为 0 的指令」造框(那会让 `setKeyCombination` 收到越界索引)。
- 所有 `map` 元素加 `key`(指令用 `command.command`,组合用索引),避免 Vue 警告。
**H. 类型与风格纪律:** 不用 `?.`(用显式 `if` / `!`);不用 `import type`;不写内联对象类型;不写 `try-catch`;不新增导出(除 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`);不修改任何既有文件;不新增依赖。
</action>
<verify>
<automated>node -e "const fs=require('fs');const p='src/client/ui/hotkey.tsx';const bad=[];if(!fs.existsSync(p)){console.error('hotkey.tsx missing');process.exit(1)}const s=fs.readFileSync(p,'utf8');const c=s.replace(/\/\*[^]*?\*\//g,'').replace(/^[ \t]*\/\/[^\n]*/gm,'');const need=['export interface HotkeySettingsProps','export const HotkeySettings','export const HotkeySettingsUI','FULL_LOC','iterateGroups(','iterateKeyCombinations','setKeyCombination(','EVENT_KEY_CODE_MAP','KeyCode.Unknown','KeyCodeUtils.toString','@common/exit','preventDefault()','stopPropagation()','stopImmediatePropagation()'];for(const n of need)if(s.indexOf(n)<0)bad.push('missing :: '+n);if(!/addEventListener\(\s*'keydown'\s*,[^,]+,\s*true\s*\)/.test(s))bad.push('keydown listener is not capture-phase');if(!/addEventListener\(\s*'keyup'\s*,[^,]+,\s*true\s*\)/.test(s))bad.push('keyup listener is not capture-phase');for(const n of ['saveState','setGlobal','loadState','conflict','restoreDefault','resetDefault'])if(c.indexOf(n)>=0)bad.push('forbidden :: '+n);if(/<g-rect[\s>]/.test(c))bad.push('background g-rect found (R-1)');if(c.indexOf('<Background')>=0)bad.push('Background component must not be used (R-1)');if(bad.length){console.error('TASK2 GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task2: hotkey settings UI shape')"</automated>
<automated>cmd /c "pnpm exec eslint src/client/ui/hotkey.tsx"</automated>
<fails_when>缺任一导出或关键行为、按键监听不是捕获阶段、出现持久化 / 冲突 / 恢复默认相关调用、出现整屏 `g-rect` 或 `Background`、eslint 报错、或 node 闸门非零退出。**该 `node -e` 刻意保持纯 ASCII 并对去注释后的文本做否定断言**:中文(`未设置` / `返回`)与 `' + '` 字面量断言由 Task 3 的 `?raw` vitest 负责,说明性注释不会让本闸门假红。</fails_when>
</verify>
<done>`src/client/ui/hotkey.tsx` 已创建并导出 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`;左分组右指令(左栏用 `Scroll` 承载)、每组合一个可点击框;捕获阶段监听且首 keyup 按「松开键为主键」合成组合、未知键先 `return`;`setKeyCombination` 写回且无持久化;`KeyCodeUtils` + `' + '` 显示、空组合 `未设置`(由 Task 3 vitest 断言);`EVENT_KEY_CODE_MAP` 映射并忽略 `KeyCode.Unknown`;`FULL_LOC` 全屏且无自绘背景;`返回` 文本关闭,捕获时 `@common/exit` 不关闭(键盘关闭仅代码形状、未验证);eslint 无报错。</done>
</task>
<task type="auto">
<name>Task 3: 新增 `?raw` 源码闸门,跑 eslint / 类型前后基线 / 哈希与范围门禁,写执行摘要</name>
<files>src/client/ui/__test__/hotkeySettingsGate.test.ts, .planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md</files>
<action>
**依赖 Task 1、Task 2 已完成。**
**A. 新建 `src/client/ui/__test__/hotkeySettingsGate.test.ts`(vitest + `?raw`,不导入任何 Vue 组件、不使用 `node:fs`):**
- 导入:`describe` / `expect` / `it` 来自 `vitest`;`../hotkey.tsx?raw`、`../../../../packages-user/client-base/src/hotkey/types.ts?raw`、`../../../../packages-user/client-base/src/hotkey/hotkey.ts?raw`、`../settings.tsx?raw`、`../index.ts?raw`。
- 写一个文件内私有辅助 `stripComments(source: string): string`:先去块注释(含 `{/* ... */}` 与 `/* ... */`),再按行去掉 `//` 行注释,用于所有否定断言(避免说明文字让闸门假红);② / ⑦ / ⑩ 的否定断言必须对 `stripComments(hotkey)` 的结果执行。**中文(`未设置` / `返回`)与 `' + '` 字面量断言只在本 vitest 里做**,Task 2 的 `node -e` 保持纯 ASCII,二者分工固定。
- **每个 `it` 前必须有一行 `//` 注释说明该测试覆盖什么(AGENTS.md / dev.md 硬要求)。** 断言至少覆盖:
1. `iterateGroups` 同时存在于 `hotkey/types.ts` 的接口声明(含 jsDoc)与 `hotkey/hotkey.ts` 的实现(实现返回 `this.groups.entries()`)。
2. `hotkey.tsx`(去注释后)**不含**保存 / 读取存档一类持久化调用:分别断言 `saveState`、`setGlobal`、`loadState` 不出现。
3. 设置入口未接入:`settings.tsx` 不含 `HotkeySettings`、不含 `hotkey.tsx`;`src/client/ui/index.ts` 不含 `./hotkey`。
4. document 监听使用捕获阶段:`addEventListener('keydown', ..., true)` 与 `addEventListener('keyup', ..., true)` 均匹配;并包含 `preventDefault()`、`stopPropagation()`、`stopImmediatePropagation()`。
5. 使用 `setKeyCombination(`。
6. 显示使用 `KeyCodeUtils.toString`、包含 `' + '`、包含 `未设置`。
7. 不含冲突检测与恢复默认:去注释并转小写后,`conflict`、`restoredefault`、`resetdefault` 均不出现。
8. 分组来自 `iterateGroups(`。
9. 事件映射使用 `EVENT_KEY_CODE_MAP` 且包含 `KeyCode.Unknown`。
10. 全屏使用 `FULL_LOC`;去注释后不含整屏 `g-rect`(正则 `/<g-rect[\s>]/` 不匹配;`g-rectr` 不算);去注释后不含 `<Background`。
11. 每个组合独立成框:包含 `iterateKeyCombinations(`。
12. 关闭使用 `@common/exit` 且包含 `返回`。
- **不要**在测试里导入任何 `.tsx` 组件本体;只使用 `?raw` 字符串。**不要**读取 `.planning/` 或基线文件;闸门只读源码。
**B. 写 `.planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md`:**
- 记录新增 API、界面行为口径(Ctrl / Shift / Alt 可作主键、首 keyup 主键规则与 `Ctrl + J + W` 示例、无持久化 / 无冲突 / 无恢复默认 / 无入口)、文件清单与导出名。
- 记录门禁结果(eslint / `pnpm check:type` 前后基线只判 NEW / vitest 源码闸门 / 受保护哈希与 HEAD 未变 / 改动范围)。
- 明确声明:`estimate` 未校准(`confidence: low`);**不做浏览器 UAT、不宣称视觉或按键行为已验证**(`IHotkeySystem.listen` 无生产调用属预期状态;键盘 `@common/exit` 仅代码形状,关闭手段是左上角 `返回`);**执行器 0 次代码提交**(`.planning` 产物可能由 GSD 编排器按 `commit_docs: true` 聚合提交),代码工作树留给用户审查。
**C. 收尾门禁(全部在写 SUMMARY 前跑通):** 见下方 `<verify>`。
- eslint:跑 4 个改动文件,与 `09-28-BASELINE.json` 的 `eslint` 基线比较,只判 NEW。
- `pnpm check:type`:整仓运行,按改动文件集合过滤,只判 NEW;基线中存在、执行后消失的诊断允许。
- 受保护文件哈希:`settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 与基线逐字相等;HEAD 与基线 `head` 相等(证明 Task 1→Task 3 期间执行器没有提交)。若 HEAD 变化来自编排器的 `.planning` 聚合提交,如实上报,不视为代码提交、也不改写基线。
- 范围:`git status --porcelain -uall -- <4 个代码 / 测试路径>` 只出现这 4 个路径,且每个都出现。
**注释纪律:** 只新增注释,不改既有注释与 jsDoc。
</action>
<verify>
<automated>cmd /c "pnpm exec vitest run src/client/ui/__test__/hotkeySettingsGate.test.ts"</automated>
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json','utf8'));const files=['packages-user/client-base/src/hotkey/types.ts','packages-user/client-base/src/hotkey/hotkey.ts','src/client/ui/hotkey.tsx','src/client/ui/__test__/hotkeySettingsGate.test.ts'];const r=cp.spawnSync('cmd',['/c','pnpm exec eslint -f json '+files.join(' ')],{encoding:'utf8',timeout:300000});const text=(r.stdout||'').trim();const bad=[];if(r.status===null||r.status>=2||!text)bad.push('eslint failed to run (status '+r.status+')');let arr=[];try{arr=JSON.parse(text||'[]')}catch(e){bad.push('eslint json unparsable')}const norm=f=>String(f.filePath||'').replace(/\\/g,'/').replace(/^.*?(?=(src|packages-user)\/)/,'');const cur=[];for(const f of arr){for(const m of (f.messages||[]))cur.push(norm(f)+'|'+(m.ruleId||'null')+'|'+m.severity+'|'+m.message)}const before=b.eslint||[];const count=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(count(cur,v)>count(before,v))bad.push('NEW eslint :: '+v)}if(bad.length){console.error('ESLINT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK eslint: no new entries (baseline '+before.length+' -> current '+cur.length+')')"</automated>
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json','utf8'));const scope=['packages-user/client-base/src/hotkey/types.ts','packages-user/client-base/src/hotkey/hotkey.ts','src/client/ui/hotkey.tsx','src/client/ui/__test__/hotkeySettingsGate.test.ts'];const r=cp.spawnSync('cmd',['/c','pnpm check:type'],{encoding:'utf8',timeout:900000});const text=(r.stdout||'')+(r.stderr||'');const diag=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));const bad=[];if(r.status===null)bad.push('vue-tsc did not run');else if(r.status!==0&&diag.length===0)bad.push('vue-tsc crashed with no parseable diagnostics');const norm=l=>l.replace(/\\/g,'/').replace(/\(\d+,\d+\):/,'').replace(/^.*?(?=(src|packages-user)\/)/,'').trim();const cur=diag.map(norm).filter(x=>scope.some(p=>x.indexOf(p)===0));const before=(b.typecheck||[]).filter(x=>scope.some(p=>x.indexOf(p)===0));const count=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(count(cur,v)>count(before,v))bad.push('NEW diagnostic :: '+v)}if(bad.length){console.error('TYPECHECK GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK typecheck: no new diagnostics in changed files (baseline '+before.length+' -> current '+cur.length+')')"</automated>
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json','utf8'));const bad=[];for(const file of Object.keys(b.hashes||{})){const r=cp.spawnSync('git',['hash-object',file],{encoding:'utf8'});if(r.status!==0)bad.push('cannot hash '+file);else if((r.stdout||'').trim()!==b.hashes[file])bad.push(file+' changed')}const head=cp.spawnSync('git',['rev-parse','HEAD'],{encoding:'utf8'});if((head.stdout||'').trim()!==b.head)bad.push('HEAD changed ('+((head.stdout||'').trim())+')');if(bad.length){console.error('PROTECTED GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK protected files unchanged and no new commits by executor')"</automated>
<automated>node -e "const cp=require('child_process');const allowed=['packages-user/client-base/src/hotkey/types.ts','packages-user/client-base/src/hotkey/hotkey.ts','src/client/ui/hotkey.tsx','src/client/ui/__test__/hotkeySettingsGate.test.ts'];const out=cp.spawnSync('git',['status','--porcelain','-uall','--',...allowed],{encoding:'utf8'}).stdout||'';const lines=out.split(/\r?\n/).filter(Boolean);const paths=lines.map(l=>l.slice(3).replace(/^\"|\"$/g,''));const bad=[];for(const p of paths)if(!allowed.includes(p))bad.push('unexpected change :: '+p);for(const p of allowed)if(!paths.includes(p))bad.push('expected change missing :: '+p);if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope: only the four code/test paths changed')"</automated>
<fails_when>闸门测试失败、eslint 出现 NEW 条目、`pnpm check:type` 在改动文件集合内出现 NEW 诊断、受保护文件哈希变化、HEAD 变化(说明执行器在 Task 1→Task 3 期间提交过)、范围之外的文件出现在 `git status`、或任一 node 闸门非零退出。</fails_when>
</verify>
<done>`hotkeySettingsGate.test.ts` 的 12 组断言全部通过;eslint 无 NEW;`pnpm check:type` 在改动文件集合内无 NEW(既有基线诊断允许消失);`settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 哈希与基线一致;HEAD 与基线一致(执行器 0 次代码提交);范围只有 4 个代码 / 测试路径;`09-28-SUMMARY.md` 已写出并如实声明未做浏览器 UAT、执行器 0 次代码提交(`.planning` 产物可能由编排器聚合提交)、估算未校准。</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 浏览器键盘事件 → 热键设置界面 | `document` 上的原始 `KeyboardEvent` 是外部输入,经 `EVENT_KEY_CODE_MAP` 转成 `KeyCode` 后才进入内部状态 |
| 热键设置界面 → 热键系统 | `setKeyCombination` 修改的是系统内共享的 `IHotkeyCommand` 实例;写入后会影响后续按键匹配 |
| 设置界面 ↔ 其它 UI | 本计划不接入口,但仍必须保证不误改 `settings.tsx` / UI 导出清单 / 按键注册 |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-28-01 | Tampering | `hotkey.tsx` 的文档按键监听 | medium | mitigate | 捕获阶段注册;`preventDefault()` + `stopPropagation()` + `stopImmediatePropagation()`;`@common/exit` 实现再加「捕获中直接返回」守卫;卸载时移除监听 |
| T-09-28-02 | Tampering | `setKeyCombination` 写回 | medium | mitigate | 只在首次 keyup 的最终组合上调用一次;`!command` 时不写;组合由已过滤掉 `KeyCode.Unknown` 的按键组成 |
| T-09-28-03 | Information Disclosure | `iterateGroups()` 暴露底层 Map | low | accept | 只返回 entries 的可迭代视图,不暴露可变 Map 对象本身;分组数据本就是界面要展示的公共信息 |
| T-09-28-04 | Denial of Service | `combinationLists` 快照重建 | low | accept | 分组 / 指令数量有限,重建成本与列表规模线性;界面为人工操作频率,无自动高频触发 |
| T-09-28-05 | Elevation of Privilege | 未接入入口 / 未持久化 | low | mitigate | 用哈希闸门证明 `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 未变;源码闸门断言无持久化调用 |
| T-09-28-SC | Tampering | npm / pnpm 安装 | high | mitigate | 本计划**不新增任何依赖、不执行安装命令**;若执行期出现安装需求,必须停下上报,不得自行安装 |
</threat_model>
<verification>
- **源码形状**:`src/client/ui/__test__/hotkeySettingsGate.test.ts` 12 组断言全部通过(`?raw`,不导入组件)。
- **静态检查**:`pnpm exec eslint` 对 4 个改动文件无 NEW;`pnpm check:type` 整仓运行后在改动文件集合内无 NEW 诊断(既有基线诊断允许消失,不修无关错误)。
- **范围**:`git status --porcelain -uall -- <4 个代码 / 测试路径>` 恰好只有这 4 个路径;`settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 哈希与基线一致。
- **无代码提交**:HEAD 与 `09-28-BASELINE.json` 的 `head` 相等(执行器 0 次代码提交);`.planning` 产物可能由 GSD 编排器按 `commit_docs: true` 聚合提交,不在此判定内。
- **无浏览器验证声明**:SUMMARY 明确写入「未做浏览器 UAT、未宣称视觉或按键行为已验证」;`IHotkeySystem.listen` 无生产调用属预期状态;键盘 `@common/exit` 仅代码形状,关闭手段是左上角 `返回`。
</verification>
<success_criteria>
- [ ] `IClientHotkey.iterateGroups()` 带 jsDoc;`ClientHotkey.iterateGroups()` 返回 `this.groups.entries()`;其它热键 API 零改动。
- [ ] `src/client/ui/hotkey.tsx` 导出 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`。
- [ ] 左分组右指令;每条指令为其每个按键组合渲染一个独立可点击框(纵向堆叠);左栏分组列表用 `Scroll` 承载,超出一屏可滚动;组合文本同时给出 `anc` 与框内 `loc`。
- [ ] 分组切换切换右栏并取消捕获;点击框高亮并进入捕获。
- [ ] document 监听在捕获阶段,`preventDefault` / `stopPropagation` / `stopImmediatePropagation` 齐全;卸载时移除。
- [ ] 首次 keyup 合成 `[...仍按住且不等于松开键, 松开键]`;`Ctrl` / `Shift` / `Alt` 可作主键。
- [ ] `setKeyCombination` 写回且无持久化;无冲突检测、无恢复默认。
- [ ] `KeyCodeUtils.toString` + `' + '`;空组合 `未设置`;`EVENT_KEY_CODE_MAP` 映射并忽略 `KeyCode.Unknown`。
- [ ] `FULL_LOC` 全屏、无自绘背景;非捕获时 `@common/exit`(仅代码形状)+ 左上角 `返回` 关闭,捕获时 `@common/exit` 不关闭;不宣称键盘关闭已验证。
- [ ] `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 未改。
- [ ] eslint 无 NEW;`pnpm check:type` 改动文件集合内无 NEW;源码闸门通过;哈希 / HEAD / 范围门禁通过。
- [ ] 无浏览器 UAT 声明;执行器 0 次代码提交(`.planning` 产物可能由编排器按 `commit_docs: true` 聚合提交)。
</success_criteria>
<output>
Create `.planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md` when done, recording the API addition, UI behavior, gate results, uncalibrated estimate, skipped browser UAT, zero executor code commits (planning docs may be aggregated by GSD per `commit_docs: true`), and the code working tree left for user review.
</output>

View File

@ -0,0 +1,198 @@
---
phase: 09-minimum-browser-runnable
plan: 28
subsystem: ui-hotkey
tags: [hotkey, game-ui, iterate-groups, source-gate, vitest, no-entry, no-persistence]
requires:
- phase: 09-minimum-browser-runnable
provides: 09-27 的 useKey 新热键系统迁移与「基线 + 执行前后真值比较」门禁写法
provides:
- "IClientHotkey.iterateGroups() / ClientHotkey.iterateGroups():直接返回底层分组 Map 的 entries(不复制、不排序)"
- "src/client/ui/hotkey.tsx:FULL_LOC 全屏左分组右指令热键设置界面,每组合一个可点击框,捕获阶段首 keyup 主键写回内存"
- "src/client/ui/__test__/hotkeySettingsGate.test.ts:12 组 ?raw 源码形状闸门,每个 it 前一行注释"
affects: [09-minimum-browser-runnable 后续入口接入计划, 热键设置持久化/冲突检测/恢复默认等后续增量]
actuals:
tokens: 5598
tasks: 3
commits: 0
tech-stack:
added: []
patterns:
- "源码形状闸门:?raw + stripComments 后做正/负断言,不导入组件、不使用 node:fs"
- "非响应式指令实例用 shallowRef + combinationLists 快照驱动渲染"
key-files:
created:
- src/client/ui/hotkey.tsx
- src/client/ui/__test__/hotkeySettingsGate.test.ts
- .planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json
- .planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md
modified:
- packages-user/client-base/src/hotkey/types.ts
- packages-user/client-base/src/hotkey/hotkey.ts
key-decisions:
- "只实现界面与 iterateGroups(),不接入口、不持久化、不做冲突检测、不做恢复默认(用户决定)"
- "selectedCommand 使用 shallowRef,避免 ref 深度代理破坏实例身份比较"
- "组合显示使用 combinationLists 快照,setKeyCombination 写回后重建快照"
- "首次 keyup 结束捕获,组合 = [...仍按住且不等于松开键, 松开键];Ctrl/Shift/Alt 可作主键"
- "@common/exit 仅代码形状(IHotkeySystem.listen 无生产调用),实际关闭手段是左上角返回文本"
- "不做浏览器 UAT,执行器不提交任何代码"
patterns-established:
- "新 UI 界面导出 Props 接口 + hotkeySettingsProps + defineComponent + XxxUI = new GameUI('kebab-id', Xxx)"
- "热键捕获在 document 捕获阶段注册 keydown/keyup,三连阻断并在卸载时移除"
requirements-completed: []
coverage:
- id: D1
description: "IClientHotkey / ClientHotkey 新增 iterateGroups() 分组迭代 API"
verification:
- kind: unit
ref: "src/client/ui/__test__/hotkeySettingsGate.test.ts#declares iterateGroups on the interface and returns the group entries"
status: pass
human_judgment: false
- id: D2
description: "热键设置界面:FULL_LOC 两栏、左分组右指令、每组合一个可点击框、捕获阶段首 keyup 主键写回内存、无自绘背景"
verification: []
human_judgment: true
rationale: "按计划跳过浏览器 UAT;视觉布局、高亮与按键捕获行为未验证,仅有源码形状闸门"
- id: D3
description: "不接入口 / 不持久化 / 无冲突检测 / 无恢复默认"
verification:
- kind: unit
ref: "src/client/ui/__test__/hotkeySettingsGate.test.ts#keeps persistence calls out of the hotkey settings ui"
status: pass
- kind: unit
ref: "src/client/ui/__test__/hotkeySettingsGate.test.ts#adds neither conflict detection nor default restoring"
status: pass
- kind: unit
ref: "src/client/ui/__test__/hotkeySettingsGate.test.ts#leaves the settings entry and ui barrel untouched"
status: pass
human_judgment: false
duration: "约 15 分钟(未精确计时)"
completed: 2026-10-26
status: complete
---
# Phase 9 Plan 28: 热键设置界面 + iterateGroups() Summary
**新增 `iterateGroups()` 分组迭代 API 与仅源码验收的热键设置界面:左分组右指令、每组合一个可点击框、捕获阶段首 keyup 主键写回内存,不接入口、不持久化、无冲突检测/恢复默认**
## Performance
- **Duration:** 约 15 分钟(未精确计时)
- **Started:** 未记录精确时间戳
- **Completed:** 2026-10-26
- **Tasks:** 3(Task 1–3;Task 0 为汇报关卡,用户回复「可以执行」)
- **Files modified:** 4 个代码/测试文件 + 2 个 `.planning` 产物(基线、摘要)
## Accomplishments
- 在 `IClientHotkey` 新增带 jsDoc 的 `iterateGroups()`,`ClientHotkey` 实现为 `return this.groups.entries()`,其余热键 API 零改动。
- 新建 `src/client/ui/hotkey.tsx`,导出 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`;左栏分组用 `Scroll` 承载,右栏为每条指令的每个按键组合渲染独立可点击框(纵向堆叠、右对齐),组合文本通过 `KeyCodeUtils.toString` + `' + '` 显示,空组合显示 `未设置`。
- 捕获语义:点击按键框后以 **捕获阶段** 在 `document` 上监听 `keydown` / `keyup`,两者均 `preventDefault()` + `stopPropagation()` + `stopImmediatePropagation()`;首次 keyup 结束捕获,松开键为主键,仍按住的键按按下顺序作为辅助键(`[...仍按住且不等于松开键, 松开键]`,与 `Ctrl + J + W` 示例一致);`Ctrl` / `Shift` / `Alt` 可作主键。
- 写回走 `command.setKeyCombination(index, combination)`,写回后重建 `combinationLists` 快照;不持久化。
- `EVENT_KEY_CODE_MAP` 映射事件码,映射不到或 `KeyCode.Unknown` 时忽略;`FULL_LOC` 全屏、不绘制背景(R-1),关闭手段为左上角可点击 `返回`,捕获中 `@common/exit` 直接返回。
- 新建 `hotkeySettingsGate.test.ts`,12 组 `?raw` 源码形状断言全部通过。
## Task Commits
**执行器 0 次代码提交。** 按计划禁令,本次执行未运行任何 git 写命令(`add` / `commit` / `stash` / `checkout` / `restore` / `worktree` / `reset`)。4 个代码/测试文件保留在工作树(2 个已修改、2 个未跟踪),交由用户审查。
三个 `.planning` 产物(本计划、`09-28-BASELINE.json`、`09-28-SUMMARY.md`)可能由 GSD 编排器按 `.planning/config.json` 的 `commit_docs: true` 聚合提交;这不属于本计划的代码提交。
## Files Created/Modified
- `packages-user/client-base/src/hotkey/types.ts` — `IClientHotkey` 新增带 jsDoc 的 `iterateGroups(): Iterable<[id: string, group: IHotkeyCommandGroup]>`
- `packages-user/client-base/src/hotkey/hotkey.ts` — `ClientHotkey.iterateGroups()` 返回 `this.groups.entries()`
- `src/client/ui/hotkey.tsx` — 新热键设置界面(导出 `HotkeySettingsProps` / `HotkeySettings` / `HotkeySettingsUI`)
- `src/client/ui/__test__/hotkeySettingsGate.test.ts` — 新源码形状闸门(12 组断言)
- `.planning/phases/09-minimum-browser-runnable/09-28-BASELINE.json` — 改动前基线(Task 1 第一步写出,只写一次)
- `.planning/phases/09-minimum-browser-runnable/09-28-SUMMARY.md` — 本摘要
## Gate Results (actual)
| 门禁 | 命令 / 方式 | 结果 |
|------|-------------|------|
| Task 1 自动化 | 计划内 `node -e` 基线 + `iterateGroups` 形状检查 | **PASS** |
| Task 2 源码形状 | 计划内 `node -e`(去注释后正/负断言) | **PASS** |
| Task 2 eslint | `pnpm exec eslint src/client/ui/hotkey.tsx` | **PASS**(exit 0) |
| Task 3 vitest | `pnpm exec vitest run src/client/ui/__test__/hotkeySettingsGate.test.ts` | **PASS**(12/12) |
| eslint 前后基线 | 4 个改动文件 vs `09-28-BASELINE.json`,只判 NEW | **PASS**(0 → 0,无 NEW) |
| typecheck 前后基线 | 整仓 `pnpm check:type`,按改动文件集合过滤,只判 NEW | **PASS**(0 → 0,无 NEW;修复见下方偏差) |
| 受保护文件哈希 | `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` vs 基线 | **PASS**(3 个哈希全部相等) |
| HEAD 未变 | `git rev-parse HEAD` vs 基线 `head` | **未满足(环境原因,非执行器提交)**:执行期间 10-36 文档计划并发提交,HEAD 由 `9fee3c4` → `f3e9ea7` → `63e960e`;`git diff --name-only 9fee3c4 HEAD` 仅含 `docs/advanced/iii/*` 与 `.planning/phases/10-docs-restructure/10-36-SUMMARY.md`,**不含本计划 4 个路径**;等价意图门禁 `no code/test path committed` **PASS**。按计划 Task 3-C 如实上报,未改写基线。 |
| 改动范围 | `git status --porcelain -uall -- <4 个路径>` | **PASS**(恰好 4 个路径) |
基线采集值:`head = 9fee3c4392b4fc8bf7c8d19447d7b525cd37a845`;`eslint = []`;`typecheck = []`(两个改动文件内)。
## Decisions Made
- 仅实现「界面 + `iterateGroups()`」,不接入口、不持久化、不做冲突检测、不做恢复默认——均为用户明确决定,不是遗漏。
- `selectedCommand` 使用 `shallowRef`,避免 `ref` 把 `IHotkeyCommand` 实例包成 Proxy 后破坏身份比较(高亮与 `@common/exit` 守卫依赖它)。
- 组合变化由 `combinationLists` 快照承担响应性;`setKeyCombination` 写回后重建快照,否则界面仍显示旧组合。
- `@common/exit` 仅提供代码形状;因 `IHotkeySystem.listen` 无生产调用,键盘关闭未验证,实际关闭为 `返回` 文本。
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] 调整未知按键判断顺序以消除 TS2367**
- **Found during:** Task 3(`pnpm check:type` 前后基线门禁)
- **Issue:** 计划要求的 `if (!code || code === KeyCode.Unknown) return;` 中,`!code` 已把 `KeyCode.Unknown`(值为 `0`)从类型联合中排除,TypeScript 判定后半段比较「无重叠」(TS2367),在 `hotkey.tsx` 产生 NEW 诊断。
- **Fix:** 两个处理器均改为 `if (code === KeyCode.Unknown || !code) return;`。语义完全等价,仍同时保留 `KeyCode.Unknown` 与真值判断两处(源码闸门与 vitest 断言不受影响)。
- **Files modified:** `src/client/ui/hotkey.tsx`
- **Verification:** `pnpm check:type` 门禁 0 → 0 无 NEW;eslint exit 0;vitest 12/12 通过。
- **Committed in:** 无(执行器 0 次代码提交)
---
**Total deviations:** 1 auto-fixed(1 个 bug)
**Impact on plan:** 仅修正类型层冗余比较,无行为与范围变化。
## Issues Encountered
- **HEAD 被并发计划推进:** 执行期间 10-36 文档计划并发提交,HEAD 从基线 `9fee3c4` 推进到 `63e960e`。已确认这些提交只含 `docs/` 与 `.planning/phases/10-docs-restructure/10-36-SUMMARY.md`,不触碰本计划任何路径;受保护文件哈希未变;本计划 4 个文件仍为工作树未提交状态。未改写基线。
- **PowerShell 执行策略阻止 `pnpm.ps1`:** 改用 `cmd /c "pnpm ..."` 调用 eslint / vitest / 类型检查。
- **`rg` 不可用:** 使用内置 grep 工具完成只读检索。
## Known Limitations (intentional, not stubs)
- 无入口接入:本计划完成后应用内没有打开该界面的路径,模块只供后续计划导入(用户决定)。
- 无持久化:编辑仅作用于当前会话内存,刷新即丢失(用户决定)。
- 无冲突检测、无恢复默认(用户决定)。
- `getUngrouped()` 的未分组指令不在本界面展示;Meta 键未映射(均属 OI-3 已知遗留)。
- `@common/exit` 键盘关闭仅为代码形状;`IHotkeySystem.listen` 无生产调用、按键投递未接线属预期状态。
- **未做浏览器 UAT:** 不宣称任何视觉布局或按键行为已验证;视觉与按键行为需用户自行验证。
## Estimate
计划 `estimate` 为**未校准估算**(`confidence: low`,`tokens: 90000` / `raw_tokens: 45000`)。实际改动 4 个文件共约 22390 字符(chars/4 ≈ **5598** tokens),低于估算属预期,但该换算未校准,不作为后续排期依据。
## User Setup Required
None — 无外部服务配置。浏览器 UAT 由用户按需自行进行。
## Next Phase Readiness
- `hotkey.tsx` 可被后续计划直接导入;入口接入、持久化、冲突检测、恢复默认均为后续独立增量。
- 工作树中的 4 个代码/测试文件等待用户审查与决定是否提交。
---
*Phase: 09-minimum-browser-runnable*
*Completed: 2026-10-26*
## Self-Check: PASSED
- 6 个交付/记录文件均存在(4 个代码/测试 + 基线 + 摘要)。
- 执行器对 4 个代码/测试路径 **0 次提交**(`git diff --name-only <baseline> HEAD -- <4 paths>` 为空);4 个文件保留在工作树。
- 受保护文件哈希与基线一致;改动范围仅 4 个代码/测试路径。