docs(09-23): plan redesigned hotkey system (HotkeyCommand + HotkeySystem)

This commit is contained in:
unanmed 2026-10-21 19:55:25 +08:00
parent 6c19e1f060
commit b04ff5d209

View File

@ -0,0 +1,396 @@
---
phase: 09-minimum-browser-runnable
plan: 23
type: execute
wave: 20
depends_on: []
files_modified:
- packages/system/src/hotkey/command.ts
- packages/system/src/hotkey/system.ts
- packages/system/src/hotkey/type.ts
- packages/system/src/hotkey/index.ts
- packages/system/package.json
- pnpm-lock.yaml
- packages/system/src/hotkey/__test__/command.test.ts
- packages/system/src/hotkey/__test__/system.test.ts
- .planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md
autonomous: false
requirements: []
estimate:
tokens: 95000
raw_tokens: 95000
tasks: 3
confidence: low
must_haves:
truths:
- "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md / D-03)**:先汇报「实现用户重新设计的按键系统」的要做什么 / 为什么 / 大致怎么做 / 影响范围,列明最小范围(`command.ts` 新增、`system.ts` 新增、`type.ts` 核验、`index.ts` 导出、`packages/system/package.json` 补 `@motajs/animate`、两个测试文件、`09-23-SUMMARY.md`)、完整语义口径(默认模式 / 作用域 / 组合规则 / holding / 优先级与 block / 激励驱动时序 / listen)、验证方式、已知边界与待批准命名(OI-1 / OI-2 / OI-3 / OI-4),然后停下等待「可以执行」。"
- "**`type.ts` 为权威设计、不得改写(用户决策 1 / dev.md)**:`IHotkeyCommand` / `IHotkeySystem` / `IHotkeyCommandInit` / `IHotkeyEmitContext` / `IHotkeyEmitInfo` / `HotkeyEmitType` / `HotkeyEmitMode` 的定义与 jsDoc 一律逐字保留;**该接口已无 `emit` 成员**(HEAD `6c19e1f`「feat: 完善 Hotkey 类型」中已不存在),故用户决策 2「删除 `emit` 方法」以**核验代替删除**:若核验发现 `emit` 存在则只删该方法,其余成员不动;核验后 `type.ts` 与 HEAD 逐字节一致。"
- "**`IExcitable<number>` 只加在类上(用户决策 1)**:`HotkeySystem` 的声明形如 `export class HotkeySystem implements IHotkeySystem, IExcitable<number>`;**`IHotkeySystem` 接口不得出现 `excited` 成员**,`type.ts` 不得因此新增任何接口成员。"
- "**`addCommand` 语义(用户决策 5)**:`IHotkeyCommandInit` 的 `priority` / `block` 由构造器设置后**不可变**(类字段以 `readonly` 语义实现,接口本身即 `readonly`);`mode` 缺省为 `HotkeyEmitMode.KeyUp`,`timeout` / `interval` / `dt` 缺省为 0;`defaults` 深拷贝进指令(改调用方数组不影响指令);**重复 id 发 warn 204、不覆盖、返回已有对象**(注册表条目数不变)。"
- "**无根作用域语义(用户决策 5)**:初始没有作用域;未进入任何作用域时 `when` / `implement` 发 **warn 201** 并忽略(`when` 仍返回 `this`,`implement` 仍返回 `this`);`implement` 指定未注册的指令发 **warn 202** 并忽略;检查顺序为**先作用域 201、再存在性 202**。"
- "**作用域语义(用户决策 5)**:`enterScope()` 压栈并返回新 `symbol`,新作用域**默认无额外条件**(视为 `true`);`leaveScope()` 无参退到上一级(删除栈顶作用域);`leaveScope(sym)` **退回到该作用域**(删除其上方全部作用域及其实现 / 条件,`sym` 保留为栈顶);`sym` 不存在发 **warn 205** 并忽略;空栈调用无参 `leaveScope()` 同样发 warn 205。**只有栈顶作用域的条件与实现生效**:进入子作用域后父子作用域的条件与实现都不生效,回退后恢复。"
- "**KeyUp 组合规则(用户决策 5)**:可选辅助键(`Ctrl` / `Shift` / `Alt` / `Meta`)+ **至多一个常规键**;1 个常规键 → 该键抬起且组合中其余辅助键仍在 `holding` 时触发;0 个常规键 → 组合**恰好 1 个**辅助键时,该辅助键抬起触发;≥2 个常规键 → **非法**,发 **warn 203** 且永不触发;0 常规 + ≥2 辅助 → 不触发且不额外 warn;空组合永不触发(不发 warn);**203 的发出时机只有两处**:`setKeyCombination`(当前 `mode` 为 `KeyUp` 时)与 `keyUp()`(校验现有全部组合),每次调用最多发出一次(同一指令不重复刷同一条消息)。"
- "**KeyDown 类模式(Once / Continuous / Throttle)组合规则(用户决策 5)**:按键组合是 `holding` 的**子集**即匹配(`keys ⊆ holding`,组合为空视为不匹配);**修饰键只有写进 `keys` 才构成限制**(例如组合 `[KeyS]` 在按住 Ctrl 时仍触发;组合 `[Ctrl, KeyS]` 必须 Ctrl 与 S 同时按住)。"
- "**`holding` 与上下文(用户决策 5)**:`down` / `up` 由系统自己维护 `holding`(按按下顺序的数组、去重);辅助键 `Ctrl` / `Shift` / `Alt` / `Meta` 同样进入 `holding`;`ctx.ctrl` / `shift` / `alt` / `meta` 由 `holding` 推导;**KeyDown 的 `holding` 含触发键,KeyUp 的 `holding` 不含被抬起的键**;浏览器 `event.repeat` 一律忽略(`listen` 中跳过,`down` 再去重兜底);`up` 针对未处于 `holding` 的键直接返回 `false`、不派发。"
- "**优先级与 block(用户决策 5)**:同一触发批次内按 `priority` **降序**执行;同优先级全部触发(不保证相互顺序);`block === true` 且**实际触发**的指令阻断**严格更低**优先级;**block 只按当前作用域内已实现且实际触发的指令计算**——匹配但无实现的指令既不触发也不阻断;该规则对 `down` / `up` 与激励驱动的批次一视同仁。"
- "**时序完全由激励源驱动(用户决策 5)**:时间取值一律是 `excited(payload)` 的 `payload`(内部记录最近一次 `now`,初值 0),**不使用任何浏览器定时器**;`timeout === 0` 时首次触发在 `down` / `up` 当刻执行,`timeout > 0` 时进入待触发数组、之后每个 `excited` 检查 `payload - 入队时间 >= timeout`;`interval` 为首次与第二次的最小间隔;`dt` 为 Throttle 下第二次之后相邻两次的最小间隔;`Once` 只触发一次;`KeyUp` 模式只在抬起时匹配、单次触发;`down` / `up` 返回「是否至少有一个已实现指令被触发(含将要延时触发的)」。"
- "**后续触发与清理(用户决策 5 + 本计划假设)**:`Continuous` 首次触发后每次 `excited` 均触发;`Throttle` 首次后相邻两次间隔不小于 `dt`;待触发 / 持续项在以下三种情况被丢弃:**组合不再被 `holding` 满足**(KeyDown 类模式;KeyUp 类延时项不受此限)、**作用域已不是入队时的栈顶作用域**、**指令当前 `mode` 与入队时快照不同**。"
- "**`excitedBy` / `listen`(用户决策 5)**:`excitedBy(excitation)` 只保留一个激励源,重复调用先 `revoke` 旧控制器再 `excitation.add(this)`(`IExcitable<number>` 只在类上实现);`listen(target)` 在 `target` 上注册 `keydown` / `keyup`,用 `EVENT_KEY_CODE_MAP[event.keyCode]` 映射(无映射跳过),`keydown` 忽略 `repeat`,`down` / `up` 返回 `true` 时 `preventDefault()`;`listen(null)` 移除监听;`listening` 暴露当前元素;同元素重复 `listen` 为空操作。"
- "**依赖与导出(用户决策 3 / 1)**:`packages/system/package.json` 的 `dependencies` 补 `@motajs/animate: workspace:*`(`@motajs/common` 保留),并据 `pnpm install --lockfile-only` 更新 `pnpm-lock.yaml`(`packages/system` 导入器出现 animate 链接);`hotkey/index.ts` 追加 `export * from './command';` 与 `export * from './system';`,其余导出行不动;旧实现 `hotkey.ts` / `keyboard.ts` **零改动**。"
- "**测试是必需产物(用户决策 + AGENTS.md)**:`packages/system/src/hotkey/__test__/command.test.ts` 与 `__test__/system.test.ts`,**每个 `it` 前一行注释**说明该用例覆盖什么;Node 环境无真实 DOM,`listen` 用最小 fake 元素(记录注册的监听器并手动派发伪事件);断言用 `vi.spyOn(logger, 'warn')` 校验 201–205 的码与参数,不依赖控制台文本。"
- "**验证可运行且范围诚实(Nyquist)**:`cmd /c \"pnpm exec vitest run <两个测试文件>\"`、`cmd /c \"pnpm exec eslint <改动文件>\"`、按文件过滤的类型闸门(`vue-tsc --noEmit` 全仓现有 221 行错误中 `hotkey.ts` / `keyboard.ts` 各有 1 条 legacy 错误,属既定边界,闸门只对 `command.ts` / `system.ts` / `index.ts` / `__test__/**` 报错即失败)、结构闸门(导出形态、注释剥离后的负向断言、`it` 前注释、未改旧实现)与 `git status` 范围闸门;**不端到端跑 `build:game`**,不接线 `packages-user` 的 `IClientHotkey`。"
- "**命名须先获批(AGENTS.md / D-04)**:OI-1(测试文件路径与文件名)、OI-2(导出的辅助函数名 `isAuxiliaryKey`)、OI-3(内部接口 / 类型别名 / 私有成员命名清单)、OI-4(三处语义确认项)。未获批前不得落码。"
artifacts:
- path: packages/system/src/hotkey/command.ts
provides: "`export class HotkeyCommand implements IHotkeyCommand`:构造器接收指令 id 与 `IHotkeyCommandInit`(`priority` / `block` 只读,`mode` 缺省 `KeyUp`,`timeout` / `interval` / `dt` 缺省 0,`defaults` 深拷贝并经 `setKeys` + `setKeyCombination` 落地);按键组合数组(`length` 同步)、`setKeys` 扩容补空组合 / 缩容截断、`setKeyCombination`(越界索引忽略、写入副本、`mode` 为 `KeyUp` 时校验并发 warn 203)、`getKeyCombination`(越界返回空数组、返回副本)、`iterateKeyCombinations`(索引升序、产出副本);`keyUp` / `once` / `continuous` / `throttle` 设置 `mode` 与对应时间参数(未传参时保留现值),`keyUp()` 额外校验现有组合并在存在非法组合时发一次 warn 203;导出辅助函数 `isAuxiliaryKey`(`Ctrl` / `Shift` / `Alt` / `Meta` 判定)供 `system.ts` 复用(命名待批准,OI-2)"
- path: packages/system/src/hotkey/system.ts
provides: "`export class HotkeySystem implements IHotkeySystem, IExcitable<number>`:指令注册表(`addCommand` 含 warn 204 与返回已有对象、`getCommand`、`deleteCommand` 连带清理各作用域实现与待触发项、`iterateCommands`);作用域栈(`enterScope` / `leaveScope` 含 warn 205、`when` / `implement` 含 warn 201 / 202、仅栈顶作用域生效);`holding` 维护与上下文构造;统一派发(`down` / `up` → 收集栈顶已实现匹配 → 优先级降序 → block 只按实际触发计算 → 立即执行或入队);KeyUp 组合匹配;KeyDown 子集匹配;`excited` / `excitedBy` 的激励驱动时序(timeout / interval / dt,无定时器);`listen` 的映射、`repeat` 跳过、`preventDefault`、`listening` 与解绑"
- path: packages/system/src/hotkey/type.ts
provides: "权威设计文件:**保持零改动**(接口已无 `emit` 成员);仅在核验发现 `emit` 方法存在时删除该方法,其余成员与 jsDoc 逐字保留"
- path: packages/system/src/hotkey/index.ts
provides: "追加 `export * from './command';` 与 `export * from './system';`,保留既有 4 行导出(`hotkey` / `keyboard` / `keyCodes` / `type`)"
- path: packages/system/package.json
provides: "`dependencies` 增加 `\"@motajs/animate\": \"workspace:*\"`(置于 `@motajs/common` 之前,保持 4 空格缩进)"
- path: pnpm-lock.yaml
provides: "`packages/system` 导入器新增 animate 依赖链接(仅由 `pnpm install --lockfile-only` 生成,不手改)"
- path: packages/system/src/hotkey/__test__/command.test.ts
provides: "指令对象单测:缺省值 / defaults 深拷贝 / `setKeys` 扩缩容 / `setKeyCombination` 越界与副本 / `iterateKeyCombinations` / KeyUp 非法组合 warn 203(`setKeyCombination` 与 `keyUp()` 两个时机)/ 四个模式方法的字段与 `mode` 变更;每个 `it` 前一行注释"
- path: packages/system/src/hotkey/__test__/system.test.ts
provides: "系统单测:warn 201 / 202 / 204 / 205、作用域进出与条件隔离、KeyUp 四条规则、KeyDown 子集与辅助键、holding 与上下文、优先级与 block(含无实现不阻断)、激励驱动的 timeout / interval / dt(含松键与作用域退出丢弃)、`down` / `up` 返回值、`excitedBy` 切换、`listen` 映射 / repeat / preventDefault / 解绑;每个 `it` 前一行注释"
- path: .planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md
provides: "执行摘要:最终语义口径(含 OI-4 的裁定结果)、命名获批情形、门禁结果与已知边界(未接线 `IClientHotkey`、未跑全量构建、`hotkey.ts` / `keyboard.ts` 的 legacy 类型错误不修)、`estimate.tokens=95000` 为未校准估算的声明"
key_links:
- "`HotkeyCommand`(`command.ts`)↔ `IHotkeySystem.addCommand` / `getCommand`(`system.ts`)—— 指令的 `mode` / `priority` / `block` / 组合是系统派发的唯一数据源"
- "`HotkeyCommand` 的 `mode`(可经 `keyUp` / `once` / `continuous` / `throttle` 变更)↔ `system.ts` 的 `down` / `up` 路由 —— 同一指令在 KeyUp 与 KeyDown 两类派发中不会重复触发"
- "`isAuxiliaryKey`(`command.ts`)↔ `system.ts` 的 KeyUp 组合拆分与 KeyDown 子集判定 —— 辅助键集合(含 `Meta`)只定义一次,避免两套口径"
- "`holding`(`system.ts`)↔ `IHotkeyEmitContext` 的 `holding` / `ctrl` / `shift` / `alt` / `meta` —— KeyDown 含触发键、KeyUp 不含被抬起键,四个布尔值全部由 `holding` 推导"
- "待触发项(入队快照:`mode` / `timeout` / `interval` / `dt` / 作用域 `symbol` / `holding` 上下文)↔ `excited(payload)` 的 `payload` —— 时间推进、丢弃与首次 / 后续间隔判定的唯一入口"
- "`excitedBy` 持有的 `IExcitableController` ↔ `excitation.add(this)` —— 切换激励源时先 `revoke`,保证只有一个激励源在驱动本系统"
- "`listen` 注册的 `keydown` / `keyup` 与 `EVENT_KEY_CODE_MAP`(`keyCodes.ts`)↔ `down` / `up` 的布尔返回 —— 只有真正触发(含入队)时才 `preventDefault()`"
- "`hotkey/index.ts` 的新增导出 ↔ `packages-user/client-base/src/hotkey/types.ts` 中用户 WIP 的 `IClientHotkey.system: IHotkeySystem` —— 本计划只提供实现,不接线"
assumptions:
- "**`type.ts` 已无 `emit`(已核验):** 当前 HEAD `6c19e1f` 的 `IHotkeySystem` 只有 `listening` / `excitedBy` / `enterScope` / `leaveScope` / `getCommand` / `addCommand` / `deleteCommand` / `iterateCommands` / `when` / `implement` / `down` / `up` / `listen`,无 `emit`;因此用户决策 2 的落地形态是「核验 + 零改动」。"
- "**测试收集与解析:** 根 `vite.config.ts` 的 `test` 未设 `include`,vitest 默认收集 `**/*.test.ts`,故 `packages/system/src/hotkey/__test__/*.test.ts` 会被 `vitest run <路径>` 直接命中;`@motajs/*` 前缀由 `vite.config.ts` 的 glob 别名指向 `packages/*/src`,`tsconfig.json` 的 `paths` 同理,故新增依赖声明不影响测试解析。"
- "**类型闸门基线(已实测):** `cmd /c \"pnpm exec vue-tsc --noEmit\"` 当前退出码 2、共 221 行错误,其中与本计划相关目录只有 `hotkey.ts` / `keyboard.ts` 各 1 条 legacy 错误(`Module '@motajs/client-base' has no exported member 'KeyCode'`)。闸门按文件过滤到 `command.ts` / `system.ts` / `index.ts` / `__test__/**`,避免被既有 legacy 错误与用户 WIP 干扰。"
- "**`logger` 在 Node 下可用:** `packages/common/src/logger.ts` 用 `typeof document` / `typeof main` 守卫浏览器宿主;测试沿用既有约定 `vi.hoisted` 里 `vi.stubGlobal('main', { replayChecking: true })` 与 `vi.stubGlobal('location', { origin: 'http://localhost' })`;断言直接 spy `logger.warn`。"
- "**`EVENT_KEY_CODE_MAP` 为稀疏数组(`new Array(230)`):** 无映射的 `keyCode` 取到 `undefined`,`KeyCode.Unknown` 为 0;两者统一按「无映射」跳过(写作 `if (!key) return;`,不引入 `lodash-es`——`packages/system` 不声明该依赖,用户决策 3 只批准补 `@motajs/animate`)。"
- "**同一事件对同一指令只触发一次:** 一条指令可能有多组组合同时命中(如 `[KeyS]` 与 `[Ctrl, KeyS]`),本计划取**最小匹配索引**触发一次,避免同一按键事件重复执行同一指令。"
- "**组合读取返回副本:** `getKeyCombination` / `iterateKeyCombinations` 返回拷贝,调用方修改不影响指令内部组合;`setKeyCombination` 同样写入拷贝。"
- "**作用域实现随作用域一起消失:** `leaveScope` 删除的作用域中的实现与条件不再参与判定;同时丢弃属于已退出作用域或父作用域的待触发项,保证「只在栈顶生效」的一致语义。"
- "**`pnpm install --lockfile-only` 足够:** 声明的是 workspace 依赖,测试与类型检查都走 Vite 别名 / tsconfig `paths`,不需要链接 `node_modules`;只更新 `pnpm-lock.yaml` 的 `packages/system` 导入器。"
- "**`packages/system` 现有测试为零:** `packages/` 下此前没有任何 `__test__` 目录或 `*.test.ts`(实测),本计划是 `packages/system` 的第一个测试落点,故 `__test__/` 目录由 Task 1 创建。"
prohibitions:
- "不得在用户回复通过 Task 0 关卡之前执行任何任务或派发执行子代理。"
- "**未经批准的命名一律不得使用:** `isAuxiliaryKey`(OI-2)、测试文件路径与文件名(OI-1)、内部接口 / 类型别名 / 私有成员命名(OI-3)。若用户给出其它命名,按用户命名执行(门禁只绑定 `HotkeyCommand` / `HotkeySystem` 两个类名与文件路径,不绑定私有名与辅助函数名);若用户改的是**文件名或两个类名**,必须先按新命名修订本计划(含 `files_modified` 与门禁命令)并重新获批。"
- "**不得改 `type.ts` 的既有定义:** 接口成员、`const enum`、jsDoc 一律逐字保留;除「`emit` 方法存在时删除该方法」外不得有任何改动,也不得因为 `IExcitable<number>` 而往 `IHotkeySystem` 加成员。"
- "**不得改旧实现:** `packages/system/src/hotkey/hotkey.ts` 与 `keyboard.ts` 零改动(含其 `document.addEventListener` 顶层副作用、`isAssist` 等导出),即使它们与新模式命名相近。"
- "**不得用定时器驱动时序:** 时间只能来自 `excited(payload)` 的 `payload`;不得引入任何浏览器定时器或 `Date.now()` 计时(`Date.now()` 只允许不出现)。"
- "**不得新增日志码:** 只使用已批准的 warn 201 / 202 / 203 / 204 / 205,消息文本逐字采用用户批准版本;不得改 `logger.json` 既有条目、不得新增 error 段条目。"
- "**不得新增外部依赖:** 除 `@motajs/animate`(workspace)外不改 `packages/system/package.json`;不引入 `lodash-es`、不手改 `pnpm-lock.yaml`(只由 `pnpm install --lockfile-only` 生成)。"
- "**不得越界接线:** 不改 `packages-user/**`(含用户 WIP 的 `client-base/src/hotkey/types.ts`)、不改 `src/**`、`public/**`、`script/**`、`vite.config.ts`、`tsconfig.json`、`eslint.config.js`、`dev.md`、`AGENTS.md`。"
- "**不得跑全量构建或全仓类型修复:** 不执行 `build:game` / `build:packages` / `check:type` 的修复动作,不修 `hotkey.ts` / `keyboard.ts` 的 legacy 类型错误(09-21 已记录为既定边界)。"
- "**不得用 jsdom / 真实 DOM:** 测试保持默认 Node 环境,`listen` 用最小 fake 元素与伪事件;不得改 vitest 配置。"
- "**不得在 `node -e` 门禁里用双引号转义或 `$` / 反引号:** PowerShell 5.1 下内联脚本只允许单引号字面量;命令一律 `cmd /c \"pnpm exec ...\"`(裸 `pnpm` 会因 `pnpm.ps1` 的 `PSSecurityException` 失败)。"
- "**不得伪造门禁:** 不得删减测试用例充数、不得把未实现的模式写成「已通过」;测试必须真实覆盖 `priority` / `block` / `timeout` / `interval` / `dt` 的分支。"
- "**既有注释 / jsDoc 一律逐字保留(AGENTS.md):** 新逻辑的说明只写在新文件与新成员上,不得改写 `type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` 的既有注释。"
open_items:
- "**OI-1(测试文件路径与文件名):** 提议 `packages/system/src/hotkey/__test__/command.test.ts` 与 `packages/system/src/hotkey/__test__/system.test.ts`(与被测文件同名、camelCase、与 `packages-user/*/src/**/__test__` 既有约定一致)。备选:合并为 `__test__/hotkey.test.ts`,或按行为命名(`keyUp.test.ts` / `mode.test.ts`)。**报备待批准**。"
- "**OI-2(导出的辅助函数):** 提议 `export function isAuxiliaryKey(key: KeyCode): boolean`(`Ctrl` / `Shift` / `Alt` / `Meta`),供 `system.ts` 复用;备选:`isModifierKey`、`isAssistKey`(注意 `hotkey.ts` 已有 `isAssist`,仅覆盖三键、语义不同,不复用)。**待批准**。"
- "**OI-3(内部命名清单,均不导出):** 接口 `IHotkeyScope`(`symbol` / `condition` / `handlers`)、`IHotkeyMatch`(`id` / `command` / `index` / `keys` / `handler` / 可选 `entry`)、`IHotkeyPending`(`id` / `command` / `index` / `keys` / `handler` / `context` / `scope` / `mode` / `timeout` / `interval` / `dt` / `time` / `last` / `fired`);类型别名 `HotkeyHandler`;`HotkeyCommand` 私有字段 `command`(指令 id,供 warn 203 的 `$1`);`HotkeySystem` 私有字段 `commands` / `scopes` / `holding` / `pending` / `now` / `controller` / `keyDownListener` / `keyUpListener`,私有方法 `getScope` / `createContext` / `dispatch` / `runMatches` / `matchKeyDown` / `matchKeyUp` / `flushPending` / `dropPending`。**待批准**(若不批准则按用户命名,门禁不受影响)。"
- "**OI-4(三处语义请确认,未回复即按提议执行):** ①**松键取消待触发项**:KeyDown 类模式(Once / Continuous / Throttle)的待触发项在其组合不再被 `holding` 满足时丢弃(含 `Once` 的 `timeout` 未到就松键的情形),KeyUp 类的延时项不受此限;②**同一事件同一指令只触发一次**(多组组合同时命中时取最小索引);③**空栈无参 `leaveScope()`** 也发 warn 205(复用同一条「不存在作用域」消息)。"
- "**已知边界(不在本计划):** ①不接线 `packages-user/client-base/src/hotkey`(用户 WIP 的 `IClientHotkey.system` 只依赖接口,不依赖实现);②不端到端跑 `build:game`(09-21 / 09-22 已记录:渲染端仍有用户 WIP 的 stale import);③`hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误保持原状;④v1 不提供存档序列化(`type.ts` 第 4 行的 TODO 仍保留)。"
---
<objective>
Phase 9 · 第 23 个计划 —— **实现用户重新设计的按键系统**(`packages/system/src/hotkey/type.ts` 已定型的接口落地),最小范围,只新增两个实现文件与其测试。
**要解决的问题:** `type.ts` 已由用户重新设计完成(`IHotkeyCommand` / `IHotkeySystem` / `IHotkeyEmitInfo` 等),但仓库里**没有任何实现**:旧的 `hotkey.ts` / `keyboard.ts` 仍是 legacy 接口(`emitKey` + `assist` 位掩码 + 全局 `document` 监听),无法与之对接;用户 WIP 的 `IClientHotkey.system: IHotkeySystem` 也无对象可拿。本计划把新设计落地为 `HotkeyCommand` 与 `HotkeySystem`。
**做法(最小):**
- `packages/system/src/hotkey/command.ts`(新):`HotkeyCommand implements IHotkeyCommand`,负责按键组合集合、四个模式方法(`keyUp` / `once` / `continuous` / `throttle`)与 KeyUp 非法组合告警 203;导出辅助函数 `isAuxiliaryKey` 供系统复用。
- `packages/system/src/hotkey/system.ts`(新):`HotkeySystem implements IHotkeySystem, IExcitable<number>`,负责指令注册表、作用域栈(`when` / `implement` 只在栈顶生效)、`holding` 维护、统一派发(优先级 / block)、KeyUp 与 KeyDown 两套组合规则、由激励源驱动的 `timeout` / `interval` / `dt` 时序、`excitedBy` 与 `listen`。
- `packages/system/src/hotkey/index.ts`:追加两个导出;`packages/system/package.json` + `pnpm-lock.yaml`:补 `@motajs/animate`(workspace)。
- `type.ts`:核验已无 `emit` 成员(HEAD `6c19e1f`),保持零改动;`hotkey.ts` / `keyboard.ts` **原样保留**。
- 测试:`packages/system/src/hotkey/__test__/command.test.ts` 与 `__test__/system.test.ts`(Node 环境、fake 元素、每个 `it` 前一行注释)。
**约束:** 只动上述文件;不改旧实现、不改 `type.ts` 定义、不接线 user 代码、不跑全量构建、不新增外部依赖。
**修改方式声明(AGENTS.md):** 本计划执行含文件写入(新增 2 个实现文件 + 2 个测试文件 + SUMMARY,修改 `index.ts` / `package.json` / `pnpm-lock.yaml`),由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入。
</objective>
<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-22-SUMMARY.md
@AGENTS.md
@dev.md
@packages/system/src/hotkey/type.ts
@packages/system/src/hotkey/keyCodes.ts
@packages/system/src/hotkey/index.ts
@packages/system/src/hotkey/hotkey.ts
@packages/system/src/hotkey/keyboard.ts
@packages/system/src/ui/controller.ts
@packages/animate/src/types.ts
@packages/animate/src/excitation.ts
@packages/common/src/logger.ts
@packages/common/src/logger.json
@packages/system/package.json
@packages-user/client-base/src/hotkey/types.ts
</context>
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 0: 预执行汇报关卡 —— 汇报新按键系统的实现范围、完整语义口径、验证方式与待批准命名,等待「可以执行」</name>
<precondition>`packages/system/src/hotkey/type.ts` 为 HEAD `6c19e1f` 的权威设计且 `IHotkeySystem` 无 `emit` 成员;`logger.json` 的 `warn` 段最大编号为 200(201–205 空闲);`packages/system/package.json` 仅声明 `@motajs/common`;`@motajs/animate` 包存在且 `packages/animate/src/types.ts` 含 `IExcitable` / `IExcitableController` / `IExcitation`;`command.ts` / `system.ts` / `hotkey/__test__/` 均尚不存在。</precondition>
<files>(不改仓库文件)</files>
<read_first>
- packages/system/src/hotkey/type.ts(权威设计:`IHotkeyCommand` / `IHotkeySystem` / `IHotkeyCommandInit` / `IHotkeyEmitContext` / `IHotkeyEmitInfo` / `HotkeyEmitMode`)
- packages/system/src/hotkey/keyCodes.ts(`KeyCode` 枚举与 `EVENT_KEY_CODE_MAP`)
- packages/system/src/hotkey/hotkey.ts、keyboard.ts(旧实现,供对照,仅确认「原样保留」的边界)
- packages/animate/src/types.ts、excitation.ts(`IExcitable` / `IExcitableController` / `IExcitation` / `excitation.add` 的返回与 `revoke` 语义)
- packages/common/src/logger.ts、logger.json(`logger.warn(code, ...params)` 与 warn 200 的基线位置)
- packages/system/package.json、pnpm-lock.yaml(`packages/system` 导入器现状)
- packages-user/client-base/src/hotkey/types.ts(用户 WIP:`IClientHotkey.system: IHotkeySystem`,本计划不接线)
- .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-01 / D-03 / D-04 / D-06)、09-22-SUMMARY.md(既有边界)
- AGENTS.md(执行前须获批、命名须先反馈、修改方式须预先声明)、dev.md(命名、jsDoc、无副作用、无循环引用、类先 interface 后 implements、限制 `?.` 与非空判断口径)
</read_first>
<action>
**停下并向用户汇报,等待回复「可以执行」后才继续;本任务不修改任何仓库文件。**
① **要解决的问题:** `type.ts` 已完成新按键系统设计(`IHotkeyCommand` / `IHotkeySystem`),但仓库无实现;旧 `hotkey.ts` / `keyboard.ts` 是 legacy 接口(`emitKey` + `assist` 位掩码 + 模块顶层 `document` 监听),无法对接;用户 WIP 的 `IClientHotkey.system` 无对象可用。
② **范围(最小):** 新增 `packages/system/src/hotkey/command.ts`(`HotkeyCommand implements IHotkeyCommand`)、新增 `packages/system/src/hotkey/system.ts`(`HotkeySystem implements IHotkeySystem, IExcitable<number>`)、`hotkey/index.ts` 追加两个导出、`packages/system/package.json` 补 `@motajs/animate`(workspace)并由 `pnpm install --lockfile-only` 更新 `pnpm-lock.yaml`、新增两个测试文件、写 `09-23-SUMMARY.md`。**`type.ts` 预期零改动**(核验 `emit`:若存在则只删该方法)、`hotkey.ts` / `keyboard.ts` **零改动**;不改 `packages-user/**`、`src/**`、`public/**`、`script/**`、`vite.config.ts`、`tsconfig.json`、`eslint.config.js`、`dev.md`、`AGENTS.md`。
③ **指令对象口径:** `addCommand(command, init)` 用 `IHotkeyCommandInit`;`priority` / `block` 构造后不可变;`mode` 缺省 `KeyUp`,`timeout` / `interval` / `dt` 缺省 0;`defaults` 深拷贝;重复 id → warn 204、不覆盖、返回已有对象。`setKeys(count)` 扩容补空组合 / 缩容截断;`setKeyCombination(index, keys)` 越界索引忽略、写入副本,当前 `mode` 为 `KeyUp` 时校验并发 warn 203;`getKeyCombination` 越界返回空数组、返回副本;`iterateKeyCombinations` 索引升序。`keyUp(t)` / `once(t)` / `continuous(i, t)` / `throttle(dt, i, t)` 设置 `mode` 与对应时间参数(未传参保留现值),`keyUp()` 另外校验现有全部组合、存在非法组合时发一次 warn 203。
④ **系统口径:** 无根作用域,未进入作用域时 `when` / `implement` → warn 201 并忽略(`when` 仍返回 `this`);`implement` 未注册指令 → warn 202 并忽略(先 201 后 202);`enterScope()` 压栈返回 `symbol`、新作用域默认无额外条件;`leaveScope()` 退一级,`leaveScope(sym)` 删掉 `sym` 之上全部作用域而保留 `sym`,`sym` 不存在 → warn 205(空栈无参调用同样 205);**只有栈顶作用域的条件与实现生效**,进入子作用域后父作用域条件与实现都不生效、回退后恢复。`deleteCommand` 连带清理各作用域实现与待触发项。
⑤ **触发口径:** `holding` 由系统维护(按下顺序、去重、辅助键 `Ctrl` / `Shift` / `Alt` / `Meta` 也在内),`ctx.ctrl` / `shift` / `alt` / `meta` 由 `holding` 推导;KeyDown 的 `holding` 含触发键、KeyUp 的不含被抬起键;`event.repeat` 忽略;`up` 对不在 `holding` 的键直接返回 `false`。**KeyDown 类模式(Once / Continuous / Throttle)** 组合为 `holding` 子集即匹配(修饰键写进 `keys` 才构成限制,空组合不匹配);**KeyUp 模式** 规则为「可选辅助键 + 至多一个常规键」:1 个常规键 → 该键抬起且其余辅助键仍在 `holding` 时触发;0 个常规键 → 组合恰好 1 个辅助键时该键抬起触发;≥2 个常规键 → 非法、warn 203、永不触发;0 常规 + ≥2 辅助 → 不触发;空组合 → 不触发、不 warn。优先级降序执行,同优先级全部触发,`block` 阻断严格更低优先级且**只按已实现且实际触发的指令计算**;`down` / `up` 返回是否至少有一个已实现指令被触发(含将要延时触发的)。
⑥ **时序口径:** 时间只来自 `excited(payload)` 的 `payload`(内部记录最近 `now`,初值 0),不使用任何浏览器定时器、不用 `Date.now()`;`timeout === 0` 在 `down` / `up` 当刻执行,`timeout > 0` 入待触发数组、其后每个 `excited` 检查 `payload - 入队时间 >= timeout`;`interval` 是首次与第二次的最小间隔,`dt` 是 Throttle 第二次之后相邻两次的最小间隔;`Once` 单次、`KeyUp` 只在抬起时单次。待触发项在「组合不再被 `holding` 满足(仅 KeyDown 类)」「作用域已不是入队时栈顶」「指令当前 `mode` 与入队快照不同」三种情况被丢弃。`excitedBy` 只保留一个激励源(先 `revoke` 旧控制器再 `excitation.add(this)`);`listen(target)` 注册 `keydown` / `keyup`、用 `EVENT_KEY_CODE_MAP[event.keyCode]` 映射、跳过 `repeat`、触发时 `preventDefault()`、`listen(null)` 解绑、`listening` 暴露当前元素。
⑦ **验证方式:** `cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"`;`cmd /c "pnpm exec eslint <改动文件>"`;**按文件过滤的类型闸门**(`vue-tsc --noEmit` 全仓现有 221 行错误、其中 `hotkey.ts` / `keyboard.ts` 各 1 条 legacy 错误属既定边界,闸门只对本计划的 `command.ts` / `system.ts` / `index.ts` / `__test__/**` 报错即失败);结构闸门(导出形态、注释剥离后的负向断言、`it` 前一行注释);旧实现与 `type.ts` 的 `git diff --exit-code` 零改动闸门;`git status` 范围闸门(只允许本计划的文件 + 用户既有 WIP)。**不端到端跑 `build:game`**,不接线 `packages-user/**`。
⑧ **已知边界(原样告知):** ①不接线用户 WIP 的 `IClientHotkey`(只提供实现);②不跑全量构建(渲染端仍有用户 WIP 的 stale import,09-21 / 09-22 已记录);③`hotkey.ts` / `keyboard.ts` 的 2 条 legacy 类型错误保持原状、不修;④不提供存档序列化(`type.ts` 第 4 行 TODO 保留)。
⑨ **待批准命名(须用户确认):** OI-1 测试文件(`__test__/command.test.ts` 与 `__test__/system.test.ts`);OI-2 导出辅助函数 `isAuxiliaryKey`;OI-3 内部接口 `IHotkeyScope` / `IHotkeyMatch` / `IHotkeyPending`、类型别名 `HotkeyHandler`、`HotkeyCommand` 私有字段 `command`、`HotkeySystem` 私有字段 `commands` / `scopes` / `holding` / `pending` / `now` / `controller` / `keyDownListener` / `keyUpListener` 与私有方法 `getScope` / `createContext` / `dispatch` / `runMatches` / `matchKeyDown` / `matchKeyUp` / `flushPending` / `dropPending`;OI-4 三处语义确认(松键取消 KeyDown 类待触发项、同一事件同一指令只触发一次且取最小索引、空栈无参 `leaveScope()` 也发 205)。**未获批前不落码**;若用户改的是文件名或两个类名,先按新命名修订本计划(含 `files_modified` 与门禁命令)再重新汇报。
⑩ **不在本增量:** 不改旧实现、不改 `type.ts` 既有定义、不新增日志码(只用 201–205)、不新增外部依赖、不新增 vitest 配置、不加存档序列化、不改构建脚本。
</action>
<decision>是否按本计划(09-23,最小范围)实现用户重新设计的按键系统:新增 `packages/system/src/hotkey/command.ts`(`HotkeyCommand implements IHotkeyCommand`)与 `packages/system/src/hotkey/system.ts`(`HotkeySystem implements IHotkeySystem, IExcitable<number>`,接口本身不含 `excited`),从 `hotkey/index.ts` 导出,`packages/system/package.json` 补 `@motajs/animate`(workspace)并更新 lockfile,补两个测试文件与 SUMMARY;`type.ts` 保持零改动(已无 `emit`)、旧实现 `hotkey.ts` / `keyboard.ts` 原样保留、不接线 user 代码、不跑全量构建。</decision>
<context>
AGENTS.md / D-03 / D-04 要求执行前与命名先获批;`.planning/config.json` 为 `mode: yolo` 且 `auto_advance: false`,故本关卡显式 `blocking-human` 且 `autonomous: false`。执行期含写操作(新增 2 个实现 + 2 个测试 + SUMMARY,修改 `index.ts` / `package.json` / `pnpm-lock.yaml`),无删除(`type.ts` 无 `emit`,故无删除动作)。已核验:`packages/system/src/hotkey/type.ts` 为 HEAD `6c19e1f` 的设计且无 `emit`;`logger.json` warn 段止于 200(201–205 空闲);`packages/system/package.json` 仅声明 `@motajs/common`;`packages/` 下此前无任何 `__test__` 或 `*.test.ts`;`vue-tsc --noEmit` 现有 221 行错误、`packages/system/src/hotkey/` 只有 `hotkey.ts` / `keyboard.ts` 各 1 条 `KeyCode` 导出缺失的 legacy 错误。
</context>
<options>
<option id="execute-default">
<name>可以执行(按本计划默认口径,OI-1 / OI-2 / OI-3 采用提议命名,OI-4 三条按提议执行)</name>
<pros>把已定型的接口一次落地为可用实现:KeyUp 与 KeyDown 两类模式、优先级 / block、作用域隔离、激励驱动的 timeout / interval / dt、listen 全部可单测;旧实现零风险保留;只补一个 workspace 依赖</pros>
<cons>实现面较大(两个类约 600+ 行、测试约 700+ 行);未接线 user 代码,浏览器里暂时无人调用;`hotkey.ts` / `keyboard.ts` 的 legacy 错误仍在</cons>
</option>
<option id="revise">
<name>修订 OI-1 / OI-2 / OI-3 命名或 OI-4 的语义口径后重新汇报</name>
<pros>避免带着未确认的命名或语义落码</pros>
<cons>须修订 PLAN.md 后重新汇报,执行延后</cons>
</option>
<option id="split">
<name>拆成两个计划:先只做 `command.ts` + KeyUp 通路,再单独做 KeyDown 模式与激励时序</name>
<pros>每次改动面更小、审阅更容易</pros>
<cons>先落地的 KeyUp 通路仍需要 `system.ts` 骨架,拆开后两次改同一文件;本计划的 Task 1 / Task 2 已按此顺序切分,拆分收益有限</cons>
</option>
</options>
<resume-signal>回复「可以执行」即可;如需调整命名或 OI-4 的语义口径,请在回复中一并指明。</resume-signal>
<verify>
<automated>node -e "const fs=require('fs');const bad=[];const need=['packages/system/src/hotkey/type.ts','packages/system/src/hotkey/keyCodes.ts','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/index.ts','packages/system/package.json','packages/common/src/logger.json','packages/animate/src/types.ts','packages/animate/package.json','dev.md','AGENTS.md'];for(const p of need){if(!fs.existsSync(p))bad.push('missing '+p)}const type=fs.readFileSync('packages/system/src/hotkey/type.ts','utf8');if(/emit\s*\(/.test(type))bad.push('type.ts still declares an emit method - this plan expects it already removed at HEAD 6c19e1f');if(type.indexOf('interface IHotkeySystem')<0||type.indexOf('interface IHotkeyCommand')<0||type.indexOf('excitedBy')<0)bad.push('type.ts is not the expected hotkey design');const log=JSON.parse(fs.readFileSync('packages/common/src/logger.json','utf8'));for(const c of [201,202,203,204,205]){if(log.warn[c]!==undefined)bad.push('warn '+c+' already used')}if(!log.warn[200])bad.push('warn 200 missing - logger.json is not the expected baseline');const pkg=JSON.parse(fs.readFileSync('packages/system/package.json','utf8'));if(pkg.dependencies&&pkg.dependencies['@motajs/animate'])bad.push('@motajs/animate already declared in packages/system');const anim=JSON.parse(fs.readFileSync('packages/animate/package.json','utf8'));if(anim.name!=='@motajs/animate')bad.push('animate package name mismatch');for(const p of ['packages/system/src/hotkey/command.ts','packages/system/src/hotkey/system.ts','packages/system/src/hotkey/__test__']){if(fs.existsSync(p))bad.push('must not exist yet: '+p)}const kc=fs.readFileSync('packages/system/src/hotkey/keyCodes.ts','utf8');if(kc.indexOf('EVENT_KEY_CODE_MAP')<0||kc.indexOf('export enum KeyCode')<0)bad.push('keyCodes.ts is not the expected baseline');if(bad.length){console.error('PRESTATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK prestate: design present, emit already removed, warn 201-205 free, animate undeclared, new files absent')"</automated>
<fails_when>non-zero exit,或 stderr 含 "PRESTATE FAILED":必需文件缺失 / `type.ts` 含 `emit` 方法(说明用户又改了设计,需先核对本计划)/ `type.ts` 不是预期设计 / warn 201–205 已被占用 / warn 200 缺失(`logger.json` 不是预期基线)/ `packages/system` 已声明 animate / animate 包名不符 / `command.ts`、`system.ts`、`__test__/` 已存在(说明实现已提前开始)/ `keyCodes.ts` 缺 `EVENT_KEY_CODE_MAP` 或 `KeyCode` 枚举</fails_when>
<human-check>用户明确回复「可以执行」;若就 OI-1 / OI-2 / OI-3 / OI-4 指定了不同命名或口径,须先按新命名或新口径修订本计划(门禁命令、测试用例与 SUMMARY 关键字同步)并重新获批,未修订前不得执行</human-check>
</verify>
<acceptance_criteria>
- 用户回复中明确包含「可以执行」
- 汇报涵盖:要解决的问题、最小范围与「其余文件一律不动」清单、指令对象口径、系统口径(作用域 / 201 / 202 / 204 / 205)、触发口径(holding / KeyUp 四条规则 / KeyDown 子集 / 优先级与 block)、时序口径(激励驱动、无定时器)、验证方式、四项已知边界、OI-1 / OI-2 / OI-3 / OI-4
- 已说明 `type.ts` 当前**无 `emit` 成员**(HEAD `6c19e1f`),决策 2 以核验代替删除,核验后与 HEAD 逐字节一致
- 已说明 `IExcitable<number>` **只加在 `HotkeySystem` 类上**,`IHotkeySystem` 接口不新增成员
- 已说明旧实现 `hotkey.ts` / `keyboard.ts` **零改动**(含其模块顶层 `document` 监听)
- 已说明不接线用户 WIP 的 `IClientHotkey`、不跑全量 `build:game`、不修 legacy 类型错误、不做存档序列化
- 已说明若用户改文件名或两个类名,须先修订本计划再执行
- 已运行 `<automated>` 前置校验并通过(`OK prestate: ...`)
</acceptance_criteria>
<done>用户明确回复「可以执行」;在此之前未执行任何任务、未修改任何仓库文件、未派发任何执行子代理</done>
</task>
<task type="tracer">
<name>Task 1: 端到端打通「一条 KeyUp 热键」—— `HotkeyCommand` + `HotkeySystem` 骨架(作用域 / 注册 / KeyUp 派发 / 优先级与 block / listen)+ 依赖与导出</name>
<precondition>Task 0 已闭环(用户回复「可以执行」;OI-1 / OI-2 / OI-3 / OI-4 已获批或按提议默认执行)。`packages/system/src/hotkey/type.ts` 为权威设计且无 `emit` 成员;`logger.json` 的 warn 201–205 仍空闲;`command.ts` / `system.ts` / `hotkey/__test__/` 尚不存在;`@motajs/animate` 尚未在 `packages/system/package.json` 声明。</precondition>
<files>packages/system/src/hotkey/command.ts, packages/system/src/hotkey/system.ts, packages/system/src/hotkey/type.ts, packages/system/src/hotkey/index.ts, packages/system/package.json, pnpm-lock.yaml, packages/system/src/hotkey/__test__/command.test.ts, packages/system/src/hotkey/__test__/system.test.ts</files>
<read_first>
- packages/system/src/hotkey/type.ts(全部接口与 jsDoc;`HotkeyEmitMode` 的四个成员顺序决定了数值,实现不得依赖字面量)
- packages/system/src/hotkey/keyCodes.ts(`KeyCode` 的 `Ctrl` / `Shift` / `Alt` / `Meta` 与 `EVENT_KEY_CODE_MAP`)
- packages/system/src/ui/controller.ts(本包既有类的风格:先 interface 后 `implements`、显式成员类型、`private` region 分区、私有方法置于调用者之前)
- packages/animate/src/types.ts、excitation.ts(`IExcitable` / `IExcitableController.revoke` / `IExcitation.add` 的返回值可能为 `null`)
- packages/common/src/logger.ts、logger.json(`logger.warn(code, ...params)` 的调用形态与 `$1` 占位)
- packages-user/data-common/src/replay/__test__/func.test.ts(测试风格:文件头一行注释、`vi.hoisted` 里 stub `main` / `location`、每个 `it` 前一行注释)
- AGENTS.md(命名须先获批、既有注释不得修改、测试注释要求)、dev.md(命名、jsDoc、无循环引用、类必须先 interface、显式成员类型、禁止类型专用导入、限制 `?.` 与非空判断口径、CRLF)
</read_first>
<action>
**本任务交付一个可运行的最小闭环:一条 KeyUp 热键能在 `listen` 的元素上被按下 / 抬起并触发实现,且作用域、优先级 / block、告警 201 / 202 / 204 / 205 与 KeyUp 规则(含 203)全部就位。KeyDown 类模式的触发与时序留给 Task 2,本任务只在派发处预留接缝,不实现其触发。**
(1) **`packages/system/package.json`:** `dependencies` 增加 `"@motajs/animate": "workspace:*"`(排在 `@motajs/common` 之前,保持 4 空格缩进与既有键序风格),`@motajs/common` 保留不动。随后执行 `cmd /c "pnpm install --lockfile-only"` 更新 `pnpm-lock.yaml`(`packages/system` 导入器出现 animate 链接),**不得手改 lockfile**。
(2) **`packages/system/src/hotkey/type.ts`:** 核验 `IHotkeySystem` 无 `emit` 成员(当前 HEAD 已无)→ **零改动**;仅当核验发现 `emit` 方法存在时才删除该方法,其余成员、`const enum`、jsDoc 逐字保留。不得为 `IExcitable` 增加任何接口成员。
(3) **`packages/system/src/hotkey/command.ts`(新建):** 首行起连续 `import`(`@motajs/common` 的 `logger`、`./keyCodes` 的 `KeyCode`、`./type` 的 `HotkeyEmitMode` / `IHotkeyCommand` / `IHotkeyCommandInit`),`import` 之间不得插注释或空行。
- 导出辅助函数 `isAuxiliaryKey`(OI-2 命名):判断 `Ctrl` / `Shift` / `Alt` / `Meta`;只写一行说明性 jsDoc。
- 导出 `class HotkeyCommand implements IHotkeyCommand`:私有字段 `command`(指令 id,供 warn 203 的 `$1`);`priority` / `block` 为只读字段(构造器赋值后不再变更);`mode` / `interval` / `timeout` / `dt` 为可写字段(接口侧仍是 `readonly`,实现侧允许模式方法改写);`length` 与内部组合数组同步;私有组合数组以二维 `KeyCode` 数组保存。
- 构造器:接收指令 id 与 `IHotkeyCommandInit`;`mode` 取 `init.mode` 缺省 `HotkeyEmitMode.KeyUp`,`timeout` / `interval` / `dt` 缺省 0;用 `setKeys(init.defaults.length)` + 逐个 `setKeyCombination` 落地 `defaults`(**因此缺省 `KeyUp` 模式下非法默认组合会自然触发 203**),不直接引用调用方数组。
- `setKeys(count)`:负值或非整数忽略;扩容补空组合;缩容截断;同步 `length`。
- `setKeyCombination(index, keys)`:索引非整数或越界忽略(不告警);写入拷贝;若当前 `mode` 为 `HotkeyEmitMode.KeyUp` 且组合含 **2 个及以上**常规键(用 `isAuxiliaryKey` 拆分)→ 调用 warn 203 并传指令 id(组合仍保存,供读取与后续 `keyUp()` 复核)。
- `getKeyCombination(index)`:越界返回空数组;返回拷贝。
- `iterateKeyCombinations()`:生成器,索引升序产出 `[index, keys]`,`keys` 为拷贝。
- `keyUp(timeout?)`:`mode` 置 `KeyUp`;`timeout` 有值则更新;随后校验全部组合,**存在任一非法组合时只发一次 warn 203**。
- `once(timeout?)` / `continuous(interval?, timeout?)` / `throttle(dt, interval?, timeout?)`:置对应 `mode`,参数有值则更新对应字段(未传则保留现值);`throttle` 的 `dt` 为必填。
- jsDoc:接口已声明的成员**不重复写**(dev.md 继承注释规则);只为新增的非接口成员与 `isAuxiliaryKey` 写注释。私有方法置于调用它的方法之前。
(4) **`packages/system/src/hotkey/system.ts`(新建):** 首行起连续 `import`(`@motajs/animate` 的 `IExcitable` / `IExcitableController` / `IExcitation`、`@motajs/common` 的 `logger`、`./command` 的 `HotkeyCommand` / `isAuxiliaryKey`、`./keyCodes` 的 `EVENT_KEY_CODE_MAP` / `KeyCode`、`./type` 的 `HotkeyEmitMode` / `HotkeyEmitType` / `IHotkeyCommand` / `IHotkeyCommandInit` / `IHotkeyEmitContext` / `IHotkeyEmitInfo` / `IHotkeySystem`)。
- 文件内声明内部 `interface IHotkeyScope`(`symbol` / `condition: (() => boolean) | null` / `handlers: Map<string, HotkeyHandler>`)、`interface IHotkeyMatch`(`id` / `command` / `index` / `keys` / `handler` / 可选 `entry`)、`interface IHotkeyPending`(字段见 `must_haves` 的 OI-3)、类型别名 `HotkeyHandler`;**均不导出**。
- 导出 `class HotkeySystem implements IHotkeySystem, IExcitable<number>`:字段 `listening: HTMLElement | null = null`;私有 `commands: Map<string, IHotkeyCommand>`、`scopes: IHotkeyScope[]`、`holding: KeyCode[]`、`pending: IHotkeyPending[]`、`now: number`、`controller: IExcitableController<number> | null`、`keyDownListener` / `keyUpListener`(显式类型的箭头成员,内部调用统一的事件处理路径)。
- 指令注册表:`addCommand`(已存在 → warn 204 并返回已有对象;否则 `new HotkeyCommand` 存入并返回)、`getCommand`(缺失返回 `null`)、`deleteCommand`(删注册表条目,并清理各作用域 `handlers` 中该 id 与 `pending` 中该指令的项,不告警)、`iterateCommands`(生成器遍历注册表)。
- 作用域:`getScope()` 返回栈顶或 `null`(私有,置于调用它的方法之前);`enterScope()` 建新作用域(`condition` 为 `null`、空 `handlers`)压栈并返回其 `symbol`;`leaveScope(sym?)` 无参时弹栈顶(空栈 → warn 205)、有参时按 `symbol` 找到索引、把栈截到该索引 + 1(未找到 → warn 205),两种退出后都调用 `dropPending()`;`dropPending()` 丢弃 `pending` 中「作用域已不是当前栈顶」「指令当前 `mode` 与入队快照不同」「KeyDown 类模式组合已不被 `holding` 满足」的项(KeyUp 类延时项不受 `holding` 条件)。`when` / `implement` 无作用域 → warn 201(返回 `this`);`implement` 再校验 id 是否存在,缺失 → warn 202(返回 `this`)。
- `holding` 与上下文:`createContext(type, key)` 返回 `IHotkeyEmitContext`(`holding` 为当前数组拷贝,`ctrl` / `shift` / `alt` / `meta` 由 `holding` 推导)。
- 派发接缝:`dispatch(type, key)` 收集**当前栈顶作用域**内所有已注册、条件为真(`condition` 为 `null` 视为真)、且 `mode` 与 `type` 同类的匹配项(`matchKeyDown` / `matchKeyUp`,同一指令多组组合命中时取**最小索引**一条);按 `command.priority` 降序排序后交给 `runMatches`;`runMatches` 维护一个「已封锁优先级」值:某条指令执行(或入队)后若其 `block` 为真则记录其 `priority`,后续 `priority` 严格更低的匹配项直接结束循环(同优先级全部执行)。**匹配但无实现(不在栈顶作用域 `handlers` 中)的指令不得进入匹配集**,因此不阻断。`down(key)`:键已在 `holding` 中则返回 `false`;否则入 `holding` 后派发 KeyDown 类(本任务对 KeyDown 类返回 `false`,仅为 Task 2 预留);`up(key)`:键不在 `holding` 中直接返回 `false`;否则先从 `holding` 移除、再 `dropPending()`、再派发 KeyUp 类。
- `matchKeyUp(keys, key)`:按 `must_haves` 的四条规则(0 常规 + 恰好 1 辅助、1 常规且等于被抬起键且其余辅助键仍在 `holding`、≥2 常规非法、空组合不匹配)返回布尔。
- `matchKeyDown(keys)`:本任务先实现「空组合不匹配 + `keys` 每项都在 `holding` 中」的子集判定,供 Task 2 直接复用。
- `runMatches` 的执行分支本任务只需处理 `mode` 为 `KeyUp` 的情形:`timeout` 为 0 时当刻以 `{ command: id, index, keys, context }` 调用 handler;`timeout` 大于 0 时入 `pending` 快照(`fired` 0、`time` 为当前 `now`),返回值计入「将要延时触发」。其余模式的执行分支留空(返回 `false`),不得写成会误触发的实现。
- `excitedBy(excitation)`:先 `revoke` 旧控制器(若有)再 `excitation.add(this)`,保存返回的控制器(可能为 `null`);不保存激励源对象本身。`excited(payload)`:记录 `now`(本任务不做待触发处理,函数体只更新时间;Task 2 在同一位置扩展)。
- `listen(target)`:与 `listening` 相同则空操作;否则先解绑旧元素的两个监听、再设置 `listening`、再为新元素(非 `null` 时)注册;`keydown` 处理跳过 `repeat`、用 `EVENT_KEY_CODE_MAP[event.keyCode]` 映射、无映射(`undefined` 与 0)跳过、`down` 返回真时 `preventDefault()`;`keyup` 同理调用 `up`。
- jsDoc:只为新增的非接口成员写注释;接口成员不重复注释。不得出现循环引用(`command.ts` 不引入 `system.ts`)。
(5) **`packages/system/src/hotkey/index.ts`:** 在既有 4 行导出中按字母序插入 `export * from './command';`(首位)与 `export * from './system';`(`./keyCodes` 之后),其余行不动。
(6) **`packages/system/src/hotkey/__test__/command.test.ts`(新建):** 文件头一行注释说明覆盖范围;`vi.hoisted` 中 stub `main` / `location`(沿用既有测试约定);`afterEach` 恢复 spy、`afterAll` 还原全局。用例(每个 `it` 前一行注释):缺省值与 `defaults` 深拷贝;`setKeys` 扩容与缩容;`setKeyCombination` 越界忽略与返回副本;`iterateKeyCombinations` 顺序与拷贝;`mode` 为 `KeyUp` 时非法组合 warn 203 一次且组合仍可读;`mode` 为 `KeyUp` 时「单常规键 + 辅助键」不告警;`keyUp()` 切换 `mode` 并对已有非法组合发一次 203;四个模式方法分别设置 `mode` 与 `timeout` / `interval` / `dt`、未传参时保留现值。断言一律 `vi.spyOn(logger, 'warn')` 校验码与参数。
(7) **`packages/system/src/hotkey/__test__/system.test.ts`(新建):** 文件头一行注释;构造 fake 元素(`addEventListener` / `removeEventListener` 记录处理器)与伪事件(`keyCode` / `repeat` / `preventDefault`)。用例(每个 `it` 前一行注释):无作用域时 `when` / `implement` 各发 201 且 `when` 返回 `this`;`implement` 未注册指令发 202;重复 `addCommand` 发 204 且返回已有对象、注册表大小不变;`deleteCommand` 后 `getCommand` 返回 `null` 且实现被清理;`iterateCommands` 遍历;`enterScope` 返回 `symbol`、`leaveScope()` 回上一级、`leaveScope(sym)` 保留 `sym` 并删除其上方作用域、未知 `symbol` 发 205、空栈无参发 205;子作用域激活时父作用域的条件与实现都不生效、退出后恢复;KeyUp 四条规则(单常规 + 辅助、0 常规 + 1 辅助、0 常规 + 2 辅助不触发、≥2 常规永不触发);`ctx` 的 `type` / `key` / `holding`(KeyUp 不含被抬起键)/ `ctrl` 等布尔;优先级降序、`block` 阻断严格更低、同优先级全部执行、匹配但无实现不阻断;`down` / `up` 的布尔返回;`listen` 的映射、`repeat` 跳过、`preventDefault` 只在触发时调用、`listen(null)` 解绑。**KeyDown 类模式的触发与时序不在本任务断言范围内。**
(8) 全部新文件以 CRLF 保存(`.prettierrc` 的 `endOfLine`),并在写完后对改动文件执行一次 `cmd /c "pnpm exec prettier --write <文件>"`。
</action>
<verify>
<automated>cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit,或任一用例失败:KeyUp 规则、作用域、告警 201 / 202 / 203 / 204 / 205、优先级 / block、`listen` 行为与设计不符</fails_when>
<automated>cmd /c "pnpm exec eslint packages/system/src/hotkey/command.ts packages/system/src/hotkey/system.ts packages/system/src/hotkey/index.ts packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit:存在 eslint 报告(含未使用变量、格式、`no-console`)</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const strip=p=>fs.readFileSync(p,'utf8').split(/\r?\n/).filter(l=>!/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');const cmdSrc=fs.readFileSync('packages/system/src/hotkey/command.ts','utf8');const sysSrc=fs.readFileSync('packages/system/src/hotkey/system.ts','utf8');if(!/class HotkeyCommand[\s\S]{0,40}implements IHotkeyCommand/.test(cmdSrc))bad.push('command.ts must declare class HotkeyCommand implementing IHotkeyCommand');if(!/class HotkeySystem[\s\S]{0,80}implements IHotkeySystem,\s*IExcitable<number>/.test(sysSrc))bad.push('system.ts must declare class HotkeySystem implementing IHotkeySystem and IExcitable<number> on the class');const idx=fs.readFileSync('packages/system/src/hotkey/index.ts','utf8');for(const m of ['./command','./system','./hotkey','./keyboard','./keyCodes','./type']){if(idx.indexOf(m)<0)bad.push('index.ts missing export for '+m)}const pkg=JSON.parse(fs.readFileSync('packages/system/package.json','utf8'));if(!pkg.dependencies||pkg.dependencies['@motajs/animate']!=='workspace:*')bad.push('packages/system must depend on @motajs/animate workspace');const lock=fs.readFileSync('pnpm-lock.yaml','utf8');const seg=lock.slice(lock.indexOf('packages/system:'),lock.indexOf('packages/types:'));if(seg.indexOf('@motajs/animate')<0)bad.push('pnpm-lock.yaml packages/system importer lacks animate');for(const p of ['packages/system/src/hotkey/command.ts','packages/system/src/hotkey/system.ts']){const c=strip(p);if(/(^|[^A-Za-z])import\s+type\s/.test(c))bad.push('type-only import in '+p);if(/(^|[^A-Za-z])(set)(Timeout|Interval)\s*\(/.test(c))bad.push('browser timer used in '+p);if(c.indexOf('Date.now(')>=0)bad.push('Date.now used in '+p)}for(const c of [201,202,203,204,205]){if(cmdSrc.indexOf('warn('+c)<0&&sysSrc.indexOf('warn('+c)<0)bad.push('warn '+c+' not referenced by command.ts or system.ts')}const tests=['packages/system/src/hotkey/__test__/command.test.ts','packages/system/src/hotkey/__test__/system.test.ts'];for(const p of tests){if(!fs.existsSync(p)){bad.push('missing '+p);continue}const lines=fs.readFileSync(p,'utf8').split(/\r?\n/);lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push(p+':'+(i+1)+' it() without a preceding comment line')}})}if(bad.length){console.error('STRUCTURE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK structure: classes/exports/dependency/warns/tests/comment-discipline')"</automated>
<fails_when>non-zero exit,或 stderr 含 "STRUCTURE GATE FAILED":两个类的 `implements` 形态不符 / `index.ts` 导出缺失 / `package.json` 未声明 animate / lockfile 未更新 / 类型专用导入 / 浏览器定时器 / `Date.now` / 201–205 未全部被引用 / 测试文件缺失 / 某 `it` 前没有注释行</fails_when>
<automated>node -e "const cp=require('child_process');const out=cp.spawnSync('cmd',['/c','pnpm exec vue-tsc --noEmit'],{encoding:'utf8',timeout:170000});const text=(out.stdout||'')+(out.stderr||'');const bad=text.split(/\r?\n/).filter(l=>/packages[\\/]system[\\/]src[\\/]hotkey[\\/](command|system|index|__test__)/.test(l));if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK hotkey files type-check clean (repo has 2 pre-existing legacy errors in hotkey.ts/keyboard.ts, out of scope)')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":`command.ts` / `system.ts` / `index.ts` / `__test__/**` 存在类型错误(`hotkey.ts` / `keyboard.ts` 的 2 条既有 legacy 错误不在此列)</fails_when>
<automated>node -e "const cp=require('child_process');const r=cp.spawnSync('git',['status','--porcelain','--','packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/type.ts'],{encoding:'utf8'});const out=(r.stdout||'').trim();if(r.status!==0||out.length>0){console.error('LEGACY GATE FAILED :: status='+r.status+' dirty='+out);process.exit(1)}console.log('OK legacy hotkey.ts/keyboard.ts/type.ts unchanged vs HEAD')"</automated>
<fails_when>non-zero exit,或 stderr 含 "LEGACY GATE FAILED":旧实现或 `type.ts` 在工作树中被改动(`type.ts` 预期零改动,因接口已无 `emit`);本闸门刻意针对「未提交工作树 vs HEAD」,退出码由 `spawnSync` 显式捕获,不依赖管道最后一段状态</fails_when>
</verify>
<done>两个测试文件全绿;eslint、结构闸门、按文件过滤的类型闸门与「旧实现 / `type.ts` 零改动」闸门全部通过;`HotkeyCommand` 与 `HotkeySystem` 已从 `hotkey/index.ts` 导出,`@motajs/animate` 已声明且 lockfile 已更新;KeyUp 热键端到端可用(含作用域、201 / 202 / 203 / 204 / 205、优先级与 block、`listen`)</done>
</task>
<task type="auto">
<name>Task 2: 补齐 KeyDown 三类模式与激励驱动时序 —— Once / Continuous / Throttle、timeout / interval / dt、待触发生命周期</name>
<precondition>Task 1 已完成并通过其全部闸门(`command.ts` / `system.ts` 存在,`dispatch` / `runMatches` / `matchKeyDown` / `dropPending` / `pending` 接缝已就位,`excited` 目前只更新 `now`)。用户已批准本计划执行(Task 0)。</precondition>
<files>packages/system/src/hotkey/system.ts, packages/system/src/hotkey/__test__/system.test.ts</files>
<read_first>
- packages/system/src/hotkey/system.ts(Task 1 的接缝:`dispatch` / `runMatches` / `matchKeyDown` / `dropPending` / `pending` / `excited`)
- packages/system/src/hotkey/command.ts(`mode` / `timeout` / `interval` / `dt` 的读写方式,以及 `once` / `continuous` / `throttle` 的字段变更)
- packages/animate/src/excitation.ts(`excitation.excite(payload)` 的调用时机与 `payload` 语义,供假激励源对齐)
- .planning/phases/09-minimum-browser-runnable/09-23-PLAN.md(Task 2 的 `<action>` 与 `must_haves` 的时序口径)
</read_first>
<action>
**只扩展 `system.ts` 的执行分支与 `excited` 处理,不改 Task 1 已通过的 KeyUp 行为与对外接口。**
(1) **`down` 路由:** `down(key)` 在 `holding` 入栈后派发 KeyDown 类时,`runMatches` 按 `command.mode` 分派:`KeyUp` 不参与(保持 Task 1 行为);`Once` / `Continuous` / `Throttle` 参与。
(2) **`runMatches` 的执行分支(按模式与 `timeout`):**
- `timeout` 为 0:当刻以 `{ command: id, index, keys, context }` 调用 handler;计数为「已触发」。
- `timeout` 大于 0:入 `pending`(`fired` 0、`time` 为当前 `now`、快照 `mode` / `timeout` / `interval` / `dt` / 作用域 `symbol` / `handler` / `context` / `keys` / `index` / `id`),计数为「将要延时触发」。返回值因此为真。
- 立即触发的后续项:`Once` 不入队(只此一次);`KeyUp` 不入队(Task 1 行为不变);`Continuous` / `Throttle` 入队且 `fired` 置 1、`last` 置当前 `now`,以便第二次及之后由激励推进。
- `block` 记录逻辑不变(同一批次里执行或入队都算「实际触发」)。
(3) **`excited(payload)`:** 更新 `now` 后调用 `flushPending()`;`flushPending` 先 `dropPending()`,再把到期项按其 `command.priority` 降序组成一批(同优先级全部执行,`block` 阻断严格更低优先级,规则与 `dispatch` 一致),执行时以上次快照的 `context` 作为 `IHotkeyEmitInfo.context`。到期判定:`fired` 为 0 → `payload - time >= timeout`;`fired` 为 1 → `payload - last >= interval`;`fired` 大于等于 1 时 `Continuous` 之后每个 `excited` 都触发、`Throttle` 需 `payload - last >= dt`。执行后更新 `last` 与 `fired`;`Once` 与 `KeyUp` 模式的项在触发后从 `pending` 移除,`Continuous` / `Throttle` 保留直到被 `dropPending` 丢弃。**时间只能来自 `payload`,不得引入浏览器定时器或 `Date.now()`。**
(4) **`dropPending` 扩展:** 在 Task 1 的三条丢弃条件之外,确保 `up` 时对整个 `holding` 重新评估(KeyDown 类项的组合必须仍被 `holding` 满足;KeyUp 类延时项不受 `holding` 限制),并在 `enterScope` / `leaveScope` 之后同样调用(栈顶变化即丢弃旧作用域项)。
(5) **`deleteCommand` / 模式切换的一致性:** 删除指令时清掉其 `pending` 项(Task 1 已要求,本任务复核其覆盖率);指令 `mode` 被 `once` / `continuous` / `throttle` / `keyUp` 改写后,`dropPending` 会因「入队快照模式与当前模式不同」丢弃其旧项,避免用过期参数继续触发。
(6) **`command.ts` 不改**:若发现 Task 1 的模式方法未正确写入 `mode` / `timeout` / `interval` / `dt`,只做最小修正,并同步 `command.test.ts`(该文件不新增用例,除非修正行为)。
(7) **`system.test.ts` 追加用例(每个 `it` 前一行注释):** `Once` 按下即触发一次、重复 `down` 不重复触发、抬起后再按下可再次触发;KeyDown 子集语义(`[Ctrl, KeyS]` 需两键同时按住;`[KeyS]` 在按住 Ctrl 时仍触发,证明辅助键未写进组合不构成限制);`Continuous` 在 `timeout` 为 0 时当刻触发、后续按 `interval` 与逐帧触发;`Continuous` 在 `timeout` 大于 0 时未到期不触发、到期触发首次、再按 `interval` 触发第二次;`Throttle` 第二次需 `interval`、之后需 `dt`;`up` 后不再触发(组合不再满足即丢弃);`Once` 的 `timeout` 未到就松键则不触发(OI-4 ①);`excitedBy` 重复调用时旧控制器被 `revoke`、新控制器被 `add`,且未绑定激励源时 `timeout` 项永不触发;模式被改为 `KeyUp` 后旧待触发项被丢弃;`down` / `up` 在入队场景下仍返回真。假激励源用最小对象实现(`payload` / `excite` / `add` / `remove` / `destroy`),`add` 返回带 `revoke` 的控制器并记录。
(8) 改动文件以 CRLF 保存,写完后执行 `cmd /c "pnpm exec prettier --write packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"`。
</action>
<verify>
<automated>cmd /c "pnpm exec vitest run packages/system/src/hotkey/__test__/command.test.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit,或任一用例失败:`Once` / `Continuous` / `Throttle` 的触发次数与间隔、`timeout` / `interval` / `dt` 的边界、松键与作用域退出后的丢弃、模式切换后的丢弃、返回值语义与设计不符</fails_when>
<automated>cmd /c "pnpm exec eslint packages/system/src/hotkey/system.ts packages/system/src/hotkey/__test__/system.test.ts"</automated>
<fails_when>non-zero exit:存在 eslint 报告</fails_when>
<automated>node -e "const fs=require('fs');const bad=[];const strip=p=>fs.readFileSync(p,'utf8').split(/\r?\n/).filter(l=>!/^\s*(\/\/|\*|\/\*)/.test(l)).join('\n');const c=strip('packages/system/src/hotkey/system.ts');for(const m of ['HotkeyEmitMode.Once','HotkeyEmitMode.Continuous','HotkeyEmitMode.Throttle']){if(c.indexOf(m)<0)bad.push('system.ts does not reference '+m)}if(/(^|[^A-Za-z])(set)(Timeout|Interval)\s*\(/.test(c))bad.push('browser timer used in system.ts');if(c.indexOf('Date.now(')>=0)bad.push('Date.now used in system.ts');if(/(^|[^A-Za-z])import\s+type\s/.test(c))bad.push('type-only import in system.ts');if(c.indexOf('flushPending')<0||c.indexOf('dropPending')<0)bad.push('pending lifecycle helpers missing');const lines=fs.readFileSync('packages/system/src/hotkey/__test__/system.test.ts','utf8').split(/\r?\n/);let its=0;lines.forEach((l,i)=>{if(/(^|[^A-Za-z.])it\(/.test(l)){its++;let j=i-1;while(j>=0&&lines[j].trim()==='')j--;if(j<0||!lines[j].trim().startsWith('//'))bad.push('system.test.ts:'+(i+1)+' it() without a preceding comment line')}});if(its<20)bad.push('system.test.ts has only '+its+' it() cases, expected at least 20 covering both tasks');if(bad.length){console.error('MODE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK mode gate: three KeyDown modes wired, excitation-driven timing, '+its+' system test cases')"</automated>
<fails_when>non-zero exit,或 stderr 含 "MODE GATE FAILED":三类 KeyDown 模式未被引用 / 出现浏览器定时器或 `Date.now` / 类型专用导入 / 待触发生命周期方法缺失 / `it` 前无注释 / 用例少于 20 条</fails_when>
<automated>node -e "const cp=require('child_process');const out=cp.spawnSync('cmd',['/c','pnpm exec vue-tsc --noEmit'],{encoding:'utf8',timeout:170000});const text=(out.stdout||'')+(out.stderr||'');const bad=text.split(/\r?\n/).filter(l=>/packages[\\/]system[\\/]src[\\/]hotkey[\\/](command|system|index|__test__)/.test(l));if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK hotkey files type-check clean')"</automated>
<fails_when>non-zero exit,或 stderr 含 "TYPECHECK FAILED":本计划文件出现类型错误</fails_when>
<automated>node -e "const cp=require('child_process');const bad=[];const lines=cp.execSync('git status --porcelain',{encoding:'utf8'}).split(/\r?\n/).filter(Boolean);const allowed=['packages/system/src/hotkey/command.ts','packages/system/src/hotkey/system.ts','packages/system/src/hotkey/index.ts','packages/system/src/hotkey/__test__/','packages/system/package.json','pnpm-lock.yaml','.planning/phases/09-minimum-browser-runnable/09-23-'];for(const l of lines){const p=l.slice(3).trim();if(!p.startsWith('packages/'))continue;if(!allowed.some(a=>p===a||p.startsWith(a)))bad.push('out-of-scope change: '+p)}for(const l of lines){const p=l.slice(3).trim();if(['packages/system/src/hotkey/hotkey.ts','packages/system/src/hotkey/keyboard.ts','packages/system/src/hotkey/type.ts'].indexOf(p)>=0)bad.push('must not be modified: '+p)}if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope gate: only plan files changed under packages/')"</automated>
<fails_when>non-zero exit,或 stderr 含 "SCOPE GATE FAILED":`packages/` 下出现计划外改动,或旧实现 / `type.ts` 被修改</fails_when>
</verify>
<done>`Once` / `Continuous` / `Throttle` 三类按下模式与 `timeout` / `interval` / `dt` 全部由激励源 payload 驱动且无定时器;待触发项在松键、作用域退出、模式切换时被正确丢弃;`down` / `up` 返回值含入队场景;`system.test.ts` 至少 20 条用例全绿;eslint、结构闸门、类型闸门与范围闸门全部通过</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| 浏览器事件 → 系统 | `keydown` / `keyup` 的 `keyCode` / `repeat` 来自渲染进程外部,经 `EVENT_KEY_CODE_MAP` 映射后进入 `down` / `up` |
| 调用方 → 指令对象 | 调用方通过 `setKeys` / `setKeyCombination` / 模式方法改写指令状态,并读取组合副本 |
| 激励源 → 系统 | `excited(payload)` 的 `payload` 决定全部时序,外部激励源可推进任意时间步 |
| 作用域 → 实现 | 只有栈顶作用域的实现与条件参与判定,作用域切换即撤销上方实现 |
| 依赖边界 | `packages/system` 新增 `@motajs/animate`(workspace)依赖,lockfile 由 pnpm 生成 |
| 写入边界 | 本增量是写操作,只允许写 `files_modified` 白名单 |
**威胁级别说明(security_enforcement = ASVS L1,block on high):** 本计划为**纯内存按键系统实现**,不执行第三方代码、不引入网络面、不新增外部依赖(仅同仓库 workspace 依赖),**不存在未缓解的 `high` 级威胁**;`T-09-23-SC` 虽标 `high`,已由「workspace 依赖 + lockfile 由 pnpm 生成 + 不新增第三方包」缓解。
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-09-23-01 | Tampering | `listen` 的事件映射(`EVENT_KEY_CODE_MAP`)与 `event.repeat` 处理 | medium | mitigate | `repeat` 一律跳过;无映射(`undefined` / 0)跳过;`down` 对已在 `holding` 的键去重兜底;`listen(null)` 完整解绑,避免残留处理器 |
| T-09-23-02 | Elevation of privilege | 作用域隔离(`when` / `implement` 只在栈顶生效) | high | mitigate | 派发只遍历栈顶作用域的 `handlers`;`enterScope` / `leaveScope` 后调用 `dropPending()` 丢弃旧作用域项;测试覆盖「子作用域激活时父作用域条件与实现均不生效」与「退出后恢复」 |
| T-09-23-03 | Denial of service | `holding` / `pending` 无界增长 | medium | mitigate | `holding` 去重且长度受物理按键数限制;`pending` 在松键、作用域退出、模式切换时被 `dropPending` 清理,`Once` / `KeyUp` 触发后移除;测试覆盖丢弃路径 |
| T-09-23-04 | Tampering | `type.ts` / 旧实现被误改 | high | mitigate | `git diff --exit-code` 针对 `hotkey.ts` / `keyboard.ts` / `type.ts`;`SCOPE GATE` 校验 `packages/` 下无计划外改动;prohibitions 明列 |
| T-09-23-05 | Repudiation | 告警码与消息被改写或新增 | low | mitigate | 只用已批准的 warn 201–205,消息逐字采用;结构闸门校验 201–205 均被引用;不改 `logger.json` 既有条目 |
| T-09-23-06 | Information disclosure | 无网络 / 无存储 / 无日志输出敏感数据 | low | accept | 系统只在内存中保存按键状态与指令 id,不写存储、不发网络请求;`logger.warn` 只输出指令 id |
| T-09-23-SC | Tampering | 依赖安装(`@motajs/animate`) | high | mitigate | 只新增同仓库 `workspace:*` 依赖,不引入第三方包;`pnpm-lock.yaml` 由 `pnpm install --lockfile-only` 生成,不手改;`SCOPE GATE` 限制 `packages/` 改动范围 |
</threat_model>
<verification>
- **Task 0**:`PRESTATE` 闸门通过;用户明确回复「可以执行」(含 OI-1 / OI-2 / OI-3 / OI-4 的裁定)。
- **Task 1**:`command.test.ts` + `system.test.ts` 全绿;eslint;结构闸门(类 `implements` 形态、`index.ts` 六个导出、animate 依赖与 lockfile、无类型专用导入 / 无定时器 / 无 `Date.now`、201–205 均被引用、`it` 前注释);按文件过滤的类型闸门;`hotkey.ts` / `keyboard.ts` / `type.ts` 零改动。
- **Task 2**:两个测试文件全绿(`system.test.ts` 不少于 20 条用例);eslint;`MODE GATE`(三类 KeyDown 模式接线、激励驱动、生命周期方法);类型闸门;`SCOPE GATE`(`packages/` 下无计划外改动)。
- **范围诚实**:不跑 `build:game` / `build:packages` / `check:type` 的修复动作;不修 `hotkey.ts` / `keyboard.ts` 的 legacy 类型错误;不接线 `packages-user/**`;SUMMARY 须如实记录这三条边界,不得表述为「浏览器端已可用」。
- **既有注释**:`type.ts` / `hotkey.ts` / `keyboard.ts` / `keyCodes.ts` 的既有 jsDoc 与注释零修改。
</verification>
<success_criteria>
- `packages/system/src/hotkey/command.ts` 与 `system.ts` 存在并通过全部闸门;`HotkeyCommand` 实现 `IHotkeyCommand`,`HotkeySystem` 实现 `IHotkeySystem` 与 `IExcitable<number>`(仅类上)。
- 默认 `mode` 为 `KeyUp`;`priority` / `block` 构造后不可变;重复 id → warn 204 且返回已有对象。
- 无作用域时 `when` / `implement` → warn 201;`implement` 未注册指令 → warn 202;`leaveScope` 未知作用域 → warn 205。
- 作用域:`enterScope` 压栈返回 `symbol`、新作用域无额外条件、`leaveScope(sym)` 保留 `sym`、只有栈顶作用域生效。
- KeyUp 四条规则与 203 的两个发出时机符合口径;KeyDown 组合为 `holding` 子集即触发,辅助键写进组合才构成限制。
- `holding` / `ctx` 推导正确(KeyDown 含触发键、KeyUp 不含被抬起键);`event.repeat` 被忽略。
- 优先级降序、同优先级全触发、`block` 只按已实现且实际触发的指令阻断严格更低优先级。
- 全时序由 `excited(payload)` 驱动(`timeout` / `interval` / `dt`),无任何定时器与 `Date.now`。
- `excitedBy` 单激励源切换(先 `revoke` 再 `add`);`listen` 映射 / `repeat` / `preventDefault` / 解绑 / `listening` 符合口径。
- `packages/system/package.json` 声明 `@motajs/animate: workspace:*`,`pnpm-lock.yaml` 已由 pnpm 更新;`hotkey/index.ts` 导出新模块;旧实现与 `type.ts` 零改动。
- SUMMARY 记录最终语义口径(含 OI-4 裁定)、命名获批情形、门禁结果与已知边界(未接线 user 代码、未跑全量构建、legacy 类型错误保留),并声明 `estimate.tokens=95000` 为未校准估算(`confidence: low`)。
</success_criteria>
<output>
Create `.planning/phases/09-minimum-browser-runnable/09-23-SUMMARY.md` when done
</output>