diff --git a/.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json b/.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json new file mode 100644 index 0000000..be94f0e --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json @@ -0,0 +1,115 @@ +{ + "head": "6846c211c73854762c782e8cde82e81b0191c524", + "hashes": { + "src/client/ui/toolbar.tsx": "bcfec1e3e8c38848c91a2258db63f943dec4131f", + "src/client/ui/index.ts": "4e3950598b1beee23753a5556d8e8c82eb21bc57", + "packages-user/client-base/src/components/textbox.tsx": "b17e988d77c3a81fe3ce3250940e31a9774f8dfb", + "packages-user/client-modules/src/render/use.ts": "55b6aaa0cbb2e007313825da1b1e11104f71dd5a", + "packages-user/client-modules/src/ui/types.ts": "8a9288918be5a836d8c06c7ca44e0c9ccc03cf81" + }, + "eslint": [ + "src/client/ui/statistics.tsx|@typescript-eslint/no-unused-vars|2|'props' is defined but never used. Allowed unused args must match /^_/u.", + "src/client/ui/statistics.tsx|@typescript-eslint/no-unused-vars|2|'props' is defined but never used. Allowed unused args must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'downMap' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'upMap' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'moveMap' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'cx' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'cy' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'leaveMap' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'wheelMap' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'clickMap' is assigned a value but never used. Allowed unused vars must match /^_/u.", + "src/client/ui/viewmap.tsx|@typescript-eslint/no-unused-vars|2|'value' is defined but never used. Allowed unused args must match /^_/u." + ], + "typecheck": [ + "packages-user/client-base/src/components/input.tsx error TS2305: Module '\"@motajs/client-base\"' has no exported member 'KeyCode'.", + "packages-user/client-base/src/components/input.tsx error TS2305: Module '\"../../../client-modules/src/types\"' has no exported member 'IGameUIPropsBase'.", + "packages-user/client-base/src/components/input.tsx error TS2488: Type 'IHotkeyUsing | null' must have a '[Symbol.iterator]()' method that returns an iterator.", + "packages-user/client-base/src/components/input.tsx error TS2554: Expected 1-2 arguments, but got 0.", + "packages-user/client-base/src/components/input.tsx error TS7006: Parameter '_' implicitly has an 'any' type.", + "packages-user/client-base/src/components/input.tsx error TS7006: Parameter 'code' implicitly has an 'any' type.", + "packages-user/client-base/src/components/input.tsx error TS7006: Parameter 'value' implicitly has an 'any' type.", + "packages-user/client-base/src/components/misc.tsx error TS2305: Module '\"../../../client-modules/src/types\"' has no exported member 'IGameUIPropsBase'.", + "packages-user/client-base/src/components/misc.tsx error TS7006: Parameter 'data' implicitly has an 'any' type.", + "packages-user/client-modules/src/render/utils/saves.ts error TS2307: Cannot find module '../../shared' or its corresponding type declarations.", + "src/client/ui/settings.tsx error TS2320: Interface 'MainSettingsProps' cannot simultaneously extend types 'Partial' and 'IUIPropsBase'.", + "src/client/ui/settings.tsx error TS2322: Type 'IUIController | undefined' is not assignable to type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2552: Cannot find name 'mainUi'. Did you mean 'main'?", + "src/client/ui/settings.tsx error TS2304: Cannot find name 'getVitualKeyOnce'.", + "src/client/ui/settings.tsx error TS7006: Parameter 'value' implicitly has an 'any' type.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2322: Type '{ loc: ElementLocator; choices: ChoiceItem[]; width: number; onChoose: (key: ChoiceKey) => Promise; maxHeight: number; interval: number; scope: symbol; }' is not assignable to type 'IntrinsicAttributes & { choices: ChoiceItem[]; loc: ElementLocator; width: number; maxHeight?: number | undefined; ... 72 more ...; onChoose?: ((key: ChoiceKey) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS18048: 'props.state' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS18048: 'props.state' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2322: Type '{ loc: ElementLocator; choices: ChoiceItem[]; width: number; onChoose: (key: ChoiceKey) => Promise; interval: number; scope: symbol; }' is not assignable to type 'IntrinsicAttributes & { choices: ChoiceItem[]; loc: ElementLocator; width: number; maxHeight?: number | undefined; ... 72 more ...; onChoose?: ((key: ChoiceKey) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2322: Type '{ loc: ElementLocator; choices: ChoiceItem[]; width: number; onChoose: (key: ChoiceKey) => Promise; interval: number; scope: symbol; }' is not assignable to type 'IntrinsicAttributes & { choices: ChoiceItem[]; loc: ElementLocator; width: number; maxHeight?: number | undefined; ... 72 more ...; onChoose?: ((key: ChoiceKey) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps'.", + "src/client/ui/settings.tsx error TS2322: Type 'IUIController | undefined' is not assignable to type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController'.", + "src/client/ui/settings.tsx error TS2304: Cannot find name 'syncFromServer'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2322: Type '{ loc: ElementLocator; width: number; choices: ChoiceItem[]; onChoose: (key: ChoiceKey) => Promise; interval: number; scope: symbol; }' is not assignable to type 'IntrinsicAttributes & { choices: ChoiceItem[]; loc: ElementLocator; width: number; maxHeight?: number | undefined; ... 72 more ...; onChoose?: ((key: ChoiceKey) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS18048: 'props.state' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.state' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2322: Type '{ loc: ElementLocator; width: number; choices: ChoiceItem[]; onChoose: (key: ChoiceKey) => Promise; interval: number; scope: symbol; }' is not assignable to type 'IntrinsicAttributes & { choices: ChoiceItem[]; loc: ElementLocator; width: number; maxHeight?: number | undefined; ... 72 more ...; onChoose?: ((key: ChoiceKey) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController'.", + "src/client/ui/settings.tsx error TS2304: Cannot find name 'getAllSavesData'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'unknown' is not assignable to parameter of type 'string | string[]'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2304: Cannot find name 'getSaveData'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2322: Type '{ loc: ElementLocator; width: number; choices: ChoiceItem[]; onChoose: (key: ChoiceKey) => Promise; interval: number; scope: symbol; }' is not assignable to type 'IntrinsicAttributes & { choices: ChoiceItem[]; loc: ElementLocator; width: number; maxHeight?: number | undefined; ... 72 more ...; onChoose?: ((key: ChoiceKey) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIController | undefined' is not assignable to parameter of type 'IUIController>'.", + "src/client/ui/settings.tsx error TS18048: 'props.controller' is possibly 'undefined'.", + "src/client/ui/settings.tsx error TS2345: Argument of type 'IUIInstance | undefined' is not assignable to parameter of type 'IUIInstance'.", + "src/client/ui/settings.tsx error TS2322: Type '{ loc: ElementLocator; width: number; choices: ChoiceItem[]; onChoose: (key: ChoiceKey) => Promise; interval: number; scope: symbol; }' is not assignable to type 'IntrinsicAttributes & { choices: ChoiceItem[]; loc: ElementLocator; width: number; maxHeight?: number | undefined; ... 72 more ...; onChoose?: ((key: ChoiceKey) => any) | undefined; } & VNodeProps & AllowedComponentProps & ComponentCustomProps'.", + "src/client/ui/title.tsx error TS2304: Cannot find name 'mainSetting'.", + "src/client/ui/title.tsx error TS2304: Cannot find name 'mainSetting'." + ], + "addCommandCount": 62 +} diff --git a/.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md b/.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md new file mode 100644 index 0000000..f0680dc --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md @@ -0,0 +1,773 @@ +--- +phase: 09-minimum-browser-runnable +plan: 27 +type: execute +# 22:沿用本阶段既有计划的波次编号,使本计划排期在 09-01..09-26 之后(D-01 增量派发,计划累积) +# depends_on 为空:没有任何并发计划在本波次触碰本计划改动的文件(09-05-PLAN.md 曾改 components/input.tsx / misc.tsx,但已执行完毕,本计划是在其结果之上继续裁剪,不构成同波次写冲突),故结构上独立; +# 运行在最后的波次只是「排期在后」,不是「依赖前序计划」 +wave: 22 +depends_on: [] +# files_modified 中的 09-27-BASELINE.json 由 Task 1 在收到「可以执行」之后、编辑任何代码之前写出(Task 0 只汇报、不写文件) +files_modified: + - packages-user/client-base/src/components/index.ts + - packages-user/client-base/src/components/input.tsx + - packages-user/client-base/src/components/misc.tsx + - packages-user/client-base/src/use/hotkey.ts + - src/client/ui/background.tsx + - src/client/ui/choices.tsx + - src/client/ui/inputBox.tsx + - src/client/ui/waitBox.tsx + - src/client/ui/save.tsx + - src/client/ui/settings.tsx + - src/client/ui/title.tsx + - src/client/ui/viewmap.tsx + - src/client/misc/hotkey.ts + - src/client/ui/__test__/keyAdaptationGate.test.ts + - .planning/phases/09-minimum-browser-runnable/09-27-PLAN.md + - .planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json + - .planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md +autonomous: false +requirements: [] +estimate: + # 未校准估算(confidence: low),SUMMARY 须声明这一点。 + # 本计划触碰 14 个代码 / 测试路径(11 改 + 3 新增:2 个组件 + 1 个测试)+ 3 个 .planning 产物, + # 但每处改动都是「定义搬迁 + 命令 ID 换名 + 少量导入清理」,改动量本身不大, + # 主要成本在读取 14 个源文件与运行四类门禁(含一次全仓 vue-tsc),故取该粗略值。 + # 超额声明:120k 约为 smart-zone 单计划预算的 1.2×。D-01 禁止未经用户同意拆分, + # 故本计划不拆,超额状态按「待 Task 0 确认 / 待用户批准」记录(Task 0 汇报 ⑩ 单独请用户确认); + # 收到「可以执行」后,由 SUMMARY 按用户的实际决定记录(批准则写明批准;若用户要求拆分则先停下); + # 若用户不接受该超额,必须先取得拆分批准,不得靠砍范围或跳过门禁来「降本」。 + tokens: 120000 + raw_tokens: 120000 + tasks: 6 + confidence: low +must_haves: + truths: + - "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(D-03 / D-04)**:先汇报「把 `InputBox` / `WaitBox` / `Background` 三个 GameUI 组件拆到 `src/client/ui`、把相关 UI 的按键实现整体迁到新热键系统、修正 `src/client/misc/hotkey.ts` 的重复 / 错误声明、并新增 1 条输入框确认指令」的要做什么 / 为什么 / 大致怎么做 / 影响范围,逐条列明已批准 / 待最终批准的新标识符、逐文件命令映射表、**save 翻页方向(用户已批准的新定义)与十页按住重复(用户已批准)**、验证方式与「不做浏览器 UAT / 不提交代码」两条铁律,然后停下等待用户恰好回复「可以执行」。" + - "**只搬指定的定义,不搬整文件**:`InputBox` + `InputBoxUI` + `getInput` + `getInputNumber` + `InputBoxProps` / `InputBoxEmits` → 新建 `src/client/ui/inputBox.tsx`;`WaitBox` + `WaitBoxUI` + `waitbox` + `WaitBoxProps` / `WaitBoxEmits` / `WaitBoxExpose` → 新建 `src/client/ui/waitBox.tsx`;`Background` + `BackgroundUI` + `BackgroundProps` → 追加进既有 `src/client/ui/background.tsx`(保留既有 `MainBackground` / `MainBackgroundUI`)。`Input` / `Selection` / `Progress` / `ScrollText` / `Arrow` / `routedInput` / `routedInputNumber` 及其它所有非 GameUI 组件**留在 `client-base`**;搬入的两处回调同时补显式参数注解(`getInput` 的 `onConfirm: (value: string)`、`waitbox` 的 `onResolve: (data: T)`),只为让既有的两条 `TS7006` 基线诊断随定义一起消失,行为不变。" + - "**公共导入面保持不变**:`packages-user/client-base/src/components/index.ts` 用既有 `choices` 同款相对路径再导出(`export * from '../../../../src/client/ui/background'` / `inputBox` / `waitBox`),使 `waitbox` / `getInput` / `Background` 等仍然可以从 `@user/client-base` 取到。**这是沿用既有先例,不是 D-69 合规**:`choices` 那一行本身就是对 D-69「barrel 只允许 `export * from './<同目录项>'`」的既成例外,用户在本计划中批准继续沿用同一形态(已知例外,如实记录在案)。消费方 `src/client/ui/statistics.tsx`(`waitbox`)与 `packages-user/client-modules/src/render/utils/saves.ts`(`waitbox`)**一行不改**;`src/client/ui/settings.tsx`(`getInput` 与 `waitbox`)**只改按键实现、`` 的 `state` / `freshScope` 与 import 来源(`useKey` 从 `@user/client-modules` 移到 `@user/client-base`),`getInput` / `waitbox` 的调用逻辑不改**;三者的模块路径解析保持原状(`@user/client-base` 仍能取到 `waitbox` / `getInput` / `Background`);这三个消费方的诊断一律按「相对 `09-27-BASELINE.json` 基线只判 NEW」处理——`saves.ts` 既有基线诊断(`TS2307`:`../../shared` 无法解析)属**执行前就存在**的条目,本计划**不修、也不得顺手修**;本计划只证明「没有新增诊断」,**不承诺**它们「继续通过类型检查」。" + - "**被 barrel 再导出的三个文件不得从 barrel 回引**:`src/client/ui/inputBox.tsx`、`src/client/ui/waitBox.tsx`、`src/client/ui/choices.tsx` 的 client-base 依赖一律从**具体模块**(相对路径 `../../../packages-user/client-base/src/...`)导入,文件中不得出现 `from '@user/client-base'`;`@user/client-base/<子路径>`(如 `@user/client-base/components/textbox`)在现有 `tsconfig.json` 的 `paths`(`@user/*` → `packages-user/*/src`)下不可解析,故用相对路径达成同一目的。若某个依赖无法按此方式导入,执行方必须停下上报,不得猜测。`@motajs/system` 的导入保持原样。" + - "**barrel 图不得拉入应用入口**:`src/client/ui/background.tsx` 既有 `import { MAIN_HEIGHT, MAIN_WIDTH } from '@/client'` 必须改成叶子模块 `@/client/uiConstant`;被 barrel 再导出的 `background.tsx` / `inputBox.tsx` / `waitBox.tsx` 三个文件都不得出现 `from '@/client'`(`choices.tsx` 本来就没有)。" + - "**按键实现只走新系统**:`useKey(system, clearImpl)` 与 `implement(command, fn)` 一律来自 `packages-user/client-base/src/use`(未被 barrel 再导出的文件经 `@user/client-base` 导入;`choices` / `inputBox` / `waitBox` 经具体模块 `.../src/use` 导入);被触碰的 UI 文件中不得再出现旧 `@user/client-modules` 的 `useKey` 导入、不得再出现 `key.realize(`、不得再出现零参 `useKey()`;既有 `gameKey` 导入(`settings.tsx:13`)与 VirtualKey 分支保持原样;**不得新增任何 `gameKey` 使用**;其它被触碰文件不得出现新的 `gameKey` 引用(`viewmap.tsx` 里既有的 `// .realize('@viewMap_reset', ...)` 是注释示例,原样保留、不参与判定)。" + - "**命令映射逐条落地**:`choices.tsx` 5 条、`save.tsx` 12 条、`settings.tsx` 7×`@common/exit`、`title.tsx` 3 条、`viewmap.tsx` 8 条、`inputBox.tsx` 1 条(`@ui/input-confirm`,只认 Enter)全部按计划表格替换,**handler 方法体保持不变**(除 `viewmap` 的 `willEmit` 特判被删除、`save` 翻页方向按新语义)。" + - "**save 翻页方向采用用户已批准的新定义(不再是待确认项)**:旧实现 PageUp → `movePage(1)`、PageDown → `movePage(-1)`;新实现 `@ui/save-page-prev` → `movePage(-1)`、`@ui/save-page-next` → `movePage(1)`、`-prev-10` → `movePage(-10)`、`-next-10` → `movePage(10)`。该方向已由用户在对 Task 0 汇报的决策中**明确批准**,Task 0 只作为「已批准」记录在案,不再请求确认,也不得写成「待确认 / 待定 / 可能需要改回」。" + - "**`settings.tsx` 的 scope 改为继承父实现**:7 个子组件都改成 `const key = useKey(props.state!.hotkey.system)!` + `key.implement('@common/exit', ...)`,删除局部 `scope` 变量与所有 `scope={scope}`,并在 7 处 `` 上同时传 `freshScope={false}` 与 `state={props.state!}`;这样 `Choices` 子作用域以 `clearImpl = false` 进入,父级 `@common/exit` 实现得以保留。**`props.state!` 是刻意选择**:`MainSettingsProps` 是 `extends Partial, IUIPropsBase`,`state` 因此是可选(该文件既有若干 `TS2320` / `TS18048` 诊断源于此,**具体条数与行号一律以 Task 1 实测写出的 `09-27-BASELINE.json` 为准,本计划不做任何条数断言**),本计划不改这个 interface,只用 `!` 在 7 个读取点与 7 个传参点消解,避免新增诊断。" + - "**`viewmap.tsx` 的 book 特判由声明层的 `block` 取代**:删除 `key.willEmit(code, assist, 'book')` 与 `@viewMap_book` 的特判逻辑,`exit` 直接调用 `@common/exit` 关闭界面;`@ui/view-map-book`(`KeyX`,`priority: 10`)在 `src/client/misc/hotkey.ts` 中加 `block: true`,由系统阻断同键的 `@common/exit`。" + - "**`state` 必须出现在读取它的组件 props 数组里**:`choices.tsx` 的 `ConfirmBox` 与 `Choices`、`viewmap.tsx`、新建 `inputBox.tsx`、`save.tsx` 的 props 数组都要补 `'state'`;`title.tsx` / `settings.tsx` 的 props 数组已有 `'state'`,不动(`settings.tsx` 只是类型可选,读取与传参用 `props.state!`,见上)。`viewmap.tsx` 的 `ViewMapProps` 由 `IUIDefaultPropsBase` 改为 `IGameUIPropsBase`(后者自带 `readonly state`),并删除不再使用的 `IUIDefaultPropsBase` 导入。" + - "**作用域进入时机提前**:`packages-user/client-base/src/use/hotkey.ts` 的进入作用域回调由 `onMounted` 改为 `onBeforeMount`(离开仍留在 `onUnmounted`),使父 GameUI 的作用域先于嵌套子组件进入;字段名 `mounted` 等既有命名不改(避免新增未批准命名)。" + - "**声明改动 = 6 处修正 + 1 条新增命令块**:`src/client/misc/hotkey.ts` 中第 2/3/4 条 `@ui/save-up` 改为 `@ui/save-right` / `@ui/save-down` / `@ui/save-left`(分别对应 RightArrow / DownArrow / LeftArrow),第 2 条 `@ui/save-page-prev` / `@ui/save-page-next` 改为 `@ui/save-page-prev-10` / `@ui/save-page-next-10`,`@ui/view-map-book` 加 `block: true`;并在**末尾新增唯一一个新分组** `hotkey.setGroup('@ui/input', '输入框', [...])`,其中只有一条 `system.addCommand('@ui/input-confirm', '输入框确认', { defaults: [[KeyCode.Enter]] })`(默认 `mode` 即 `KeyUp`,不写 `mode` / `priority` / `interval` / `block`)。除这 7 处之外**默认按键 / mode / interval / priority / 描述文案 / 名称文案一律不改**,`system.addCommand(` 的出现次数必须**恰好等于基线 + 1**(只多出 `@ui/input-confirm` 这一条)。" + - "**验证形态(无浏览器行为声明)**:以「eslint(前后基线比较)+ `pnpm check:type` 前后基线比较(仅改动文件集合内的 NEW 诊断判失败)+ 新 vitest 源码闸门 + 改动范围闸门 + 受保护文件哈希闸门 + 声明「6 处修正 + 1 条新增」的掩码比对闸门(两侧先做 `\r\n` → `\n`、行尾空白、空行与首尾(EOF)换行归一化,再把新增的 `@ui/input` 分组整段剔除,然后掩掉 save 的 ID 与 `block: true`,才与基线 head 的版本逐字比较)」作为全部自动化验证(哈希闸门用基线的 `head` 做祖先关系与白名单 `diff`,**不**要求 HEAD 字面相等;类型闸门只判 NEW——**基线以 `09-27-BASELINE.json` 实际采集的 `typecheck` 数组为唯一真值**,本计划不对任何文件断言诊断条数,基线中存在、执行后消失的诊断按「允许」处理;搬迁的两处回调已补显式参数注解,`inputBox.tsx` / `waitBox.tsx` 中不得出现新的 `TS7006`);**不做浏览器 UAT、不宣称任何按键行为已验证**——`IHotkeySystem.listen` 目前没有任何生产代码调用,按键投递尚未接线;这是**预期状态、明确不在本计划范围**(不是本计划的未解决问题、也不是待办缺陷),计划只要求如实记录、不宣称浏览器行为已验证。**代码一律不提交**:不得运行 `git add` / `commit` / `stash` / `checkout` / `worktree`,工作树留给用户审查。" + - "**范围与纪律**:只写 `files_modified` 的 17 个路径;`src/client/ui/toolbar.tsx`(legacy `gameKey` / `getVitualKeyOnce`)与 `settings.tsx` 的 VirtualKey 分支(`getVitualKeyOnce` + `gameKey.emitKey`)**显式不在范围内、不得修改**;`src/client/ui/index.ts` 不加导出;不改 `textbox.tsx` / 旧 `client-modules` 的 `useKey` / `IUIPropsBase` / 依赖 / 配置 / 其它包 / 09-01..09-26 计划;不修改任何既有注释与 jsDoc(只新增);不复用任何未获批准的新命名。" + artifacts: + - path: src/client/ui/inputBox.tsx + provides: "从 `components/input.tsx` 原样搬入的 `InputBoxProps`(改为 `extends IGameUIPropsBase, TextContentProps`)、`InputBoxEmits`、`inputBoxProps`(props 数组含 `'state'`)、`InputBox`、`getInput`、`getInputNumber`、`InputBoxUI`;新增新系统按键实现:`useKey(props.state.hotkey.system)` + `implement('@ui/input-confirm', ...)`,**只由 Enter 触发**(不依赖 `@common/confirm`,因此不覆盖 Space / KeyC),触发时 blur 当前聚焦的 `input` / `textarea` 元素" + - path: src/client/ui/waitBox.tsx + provides: "从 `components/misc.tsx` 原样搬入的 `WaitBoxProps`(`extends Partial, Partial`,故必须从 `./background` 导入 `BackgroundProps`)、`WaitBoxEmits`、`WaitBoxExpose`、`waitBoxProps`、`WaitBox`、`waitbox`、`WaitBoxUI`" + - path: src/client/ui/background.tsx + provides: "新增 `BackgroundProps` / `backgroundProps` / `Background` / `BackgroundUI`(原 `misc.tsx` 的实现);保留既有 `MainBackgroundProps` / `MainBackground` / `MainBackgroundUI`;`MAIN_WIDTH` / `MAIN_HEIGHT` 的导入源由 `@/client` 改为叶子模块 `@/client/uiConstant`" + - path: packages-user/client-base/src/components/index.ts + provides: "在既有 `export * from '../../../../src/client/ui/choices';` 之后追加 `background` / `inputBox` / `waitBox` 三条同款相对路径再导出;其余 `./*` 行零改动" + - path: packages-user/client-base/src/components/input.tsx + provides: "只保留 `Input` / `InputProps` / `InputEmits` / `inputProps` / `routedInput` / `routedInputNumber`;移除 `InputBox` 一族与旧的 `useKey()` + `KeyCode.Enter` 处理;清理因此失效的导入(`Background` / `Selection` / `useKey` / `KeyCode` / `IGameUIPropsBase` / `GameUI` / `IUIController`)" + - path: packages-user/client-base/src/components/misc.tsx + provides: "只保留 `Progress` / `Arrow` / `ScrollText` / `Selection` 等非 GameUI 组件;移除 `Background` 一族与 `WaitBox` 一族;清理失效导入(`logger` / `GameUI` / `IUIController` / `SetupContext` / `IGameUIPropsBase`)" + - path: packages-user/client-base/src/use/hotkey.ts + provides: "`HotkeyUsing` 的进入作用域回调由 `onMounted` 改为 `onBeforeMount`,离开仍为 `onUnmounted`;`implement` / `scope` 与 `useKey(system, clearImpl)` 签名零改动" + - path: src/client/ui/choices.tsx + provides: "`Background` 改从 `./background` 导入、其余 client-base 依赖改具体模块相对路径(文件无 `from '@user/client-base'`);`ConfirmBox` / `Choices` 的 props 数组补 `'state'`;5 条旧命令 ID 换成 `@common/select-*` / `@common/confirm`,handler 体不变" + - path: src/client/ui/save.tsx + provides: "`useKey` 改由 `@user/client-base` 导入并以 `props.state.hotkey.system` 构造;12 条命令映射(含新增两条十页);props 数组补 `'state'`;翻页方向按新语义(prev = -1,next = +1,十页 = ±10)" + - path: src/client/ui/settings.tsx + provides: "7 个子组件的 `useKey(props.state!.hotkey.system)!` / `@common/exit` 改写(删局部 `scope`);7 处 `` 去 `scope={scope}`、加 `freshScope={false}` 与 `state={props.state!}`;`MainSettingsProps` 的 extends 形态与 `mainSettingsProps.props` 不改;VirtualKey 分支与 `gameKey` 导入原样保留" + - path: src/client/ui/title.tsx + provides: "`useKey` 改由 `@user/client-base` 导入并接新系统;`@start_up` → `@ui/start-up`、`@start_down` → `@ui/start-down`、`confirm` → `@common/confirm`;handler 体不变" + - path: src/client/ui/viewmap.tsx + provides: "`ViewMapProps` 改 `extends IGameUIPropsBase, BaseProps`、props 数组补 `'state'`、删除 `IUIDefaultPropsBase` 导入;`useKey` 接新系统;8 条命令映射;删除 `willEmit('book')` 特判,`exit` 直接关闭" + - path: src/client/misc/hotkey.ts + provides: "6 处声明修正(3 条重复 `@ui/save-up` 拆成 `@ui/save-right` / `@ui/save-down` / `@ui/save-left`,2 条重复 `@ui/save-page-prev|next` 改为 `-10` 变体,`@ui/view-map-book` 加 `block: true`)+ 1 条新增命令块(新分组 `hotkey.setGroup('@ui/input', '输入框', [...])` 内的 `@ui/input-confirm`,`defaults: [[KeyCode.Enter]]`、默认 `mode`);其余全部零改动" + - path: src/client/ui/__test__/keyAdaptationGate.test.ts + provides: "新源码形状闸门(vitest,`?raw` 读取,不导入任何组件 / 不使用 `node:fs`):搬迁落点、旧居所残留、barrel 三条再导出、命令 ID 新旧映射、旧 `useKey` / `realize` 消失、`settings` 的 `freshScope` + `state`、`viewmap` 的 `willEmit` 消失、`inputBox` 的 `@ui/input-confirm` + blur、`onBeforeMount`、声明 ID 唯一性、`@ui/view-map-book` 的 `block: true`、`@ui/input-confirm` 只绑 `Enter`(无 Space / KeyC / mode / priority / block)与 `@common/confirm` 保持三键;每个 `it` 前一行注释" + - path: .planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json + provides: "Task 1 写出的机器可读基线,schema 固定为 `{ head: string, hashes: { <完整仓库相对路径>: ×5 }, eslint: string[], typecheck: string[], addCommandCount: number }`。`hashes` 的键**必须是完整仓库相对路径(POSIX 正斜杠,不用 basename)**,与 Task 5 / Task 3 的读取键逐字一致(`09-26-BASELINE.json` 的 basename schema 不复用);`head` 是 Task 1 采集基线当次的 `git rev-parse HEAD`,所有门禁只读该字段、不消费任何字面 HEAD。Task 3 / Task 5 用它做「执行前后真值比较」。**由 Task 1 在收到「可以执行」之后、编辑任何代码之前写一次**(Task 0 只汇报字段与命令,不落盘);执行期不得重写。" + - path: .planning/phases/09-minimum-browser-runnable/09-27-PLAN.md + provides: "本计划文件本身(已存在,重新生成)" + - path: .planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md + provides: "执行摘要:搬迁清单、逐文件命令映射、声明改动(6 处修正 + 1 条新增)、**save 翻页方向(用户已批准的新定义)** + 十页按住重复(用户已批准)+ `@ui/input-confirm` 只绑 Enter(不覆盖 Space/KeyC)、`onConfirm` / `onResolve` 的显式注解、门禁结果(eslint / typecheck 前后基线对比,按 `09-27-BASELINE.json` 实测基线只判 NEW,不断言任何条数)、基线 `head` 与 `<基线 head>..HEAD` 提交清单、`IHotkeySystem.listen` 无生产调用属**预期状态**且不在本计划范围(不宣称浏览器行为)、`estimate` 超额(1.2×)且因 D-01 未拆分的声明、未提交代码与工作树留给用户审查" + key_links: + - "`components/index.ts` 的三条新再导出 ↔ `src/client/ui/{background,inputBox,waitBox}.tsx` 的实际导出名 ↔ 消费方 —— `src/client/ui/statistics.tsx` 与 `packages-user/client-modules/src/render/utils/saves.ts` 消费 `waitbox`,`src/client/ui/settings.tsx` 消费 `getInput`(同时也消费 `waitbox`)—— 任一名字对不上就是「barrel 破了但本文件自己还能编译」,所以类型闸门必须覆盖这三个消费方" + - "`background.tsx` 的 `@/client/uiConstant` 导入 ↔ barrel 再导出链(`@user/client-base` → `components/index` → `src/client/ui/background`)—— 保留 `@/client` 会把 `entry` / `initialize` 整条应用入口拉进包图,构建期才炸,类型闸门看不出来" + - "`useKey(props.state.hotkey.system, freshScope)` 的 `clearImpl` ↔ `settings` 的 `freshScope={false}` ↔ `Choices` 子作用域 —— `clearImpl = true` 会清掉父级 `@common/exit`,表现为「设置界面里按 Esc 不再返回」,这是本计划最容易静默出错的一处(`settings.tsx` 侧传参写作 `state={props.state!}`,与 `Choices` 的必填 `state` 对齐)" + - "`@ui/view-map-book` 的 `block: true` ↔ `@common/exit` 的 `KeyX` 同键冲突 —— 没有 `block` 时同键的两条指令都会执行,表现为「按 X 打开怪物手册同时把浏览地图界面关掉」" + - "`src/client/misc/hotkey.ts` 的声明 ID ↔ 各 UI 的 `implement('<新 ID>')` —— 声明侧 ID 打错(如仍是 `@ui/save-up`)不会报错,只会让某个键位「没反应」;故闸门同时断言声明 ID 唯一与 UI 侧 ID 存在" + - "`save.tsx` 的 `@ui/save-page-prev` ↔ `movePage(-1)` ↔ 声明 `PageUp` —— 方向写反不会报错,只会表现为翻页方向颠倒;方向已由用户批准为**新定义**(prev = -1、next = +1、十页 = ±10),故闸门对四个 `@ui/save-page-*` 各自切片到下一个 `.implement(` 的块内断言对应 `movePage(...)`(整文件裸查不算,`@ui/save-up` / `@ui/save-down` 的既有 `movePage` 会让写反蒙混过关),SUMMARY 记录该批准" + - "`inputBox.tsx` 的 `@ui/input-confirm` ↔ 声明层的 `defaults: [[KeyCode.Enter]]` ↔ 用户要求「输入框只认 Enter」——若误复用 `@common/confirm`(Enter/Space/KeyC)会把三个键都变成确认,故闸门 12 断言 `inputBox.tsx` 不含 `@common/confirm`,闸门 15 断言新增块只绑 `Enter` 且不含 Space / KeyC" + - "`use/hotkey.ts` 的 `onBeforeMount` ↔ 父 GameUI 与嵌套 `Choices` 的作用域栈 —— 用 `onMounted` 时子组件先入栈、父级后入栈并压在子作用域之上,**子组件的按键实现会被父作用域遮蔽**,表现为「嵌套子界面(`Choices`)的按键失灵」" + assumptions: + - "**工作树基线(规划日实测,仅作参考)**:HEAD 为 `6846c211c73854762c782e8cde82e81b0191c524`。`git status --porcelain -uall` 当前 8 条:`.planning/phases/10-docs-restructure/10-CONTEXT.md`(用户并发脏文件)、`packages-user/client-base/src/map/renderer.ts` / `map/shader/map.frag` / `map/status.ts` / `map/types.ts` / `map/vertex.ts`(09-26 渲染计划留给用户的未提交改动)、`packages-user/client-base/src/map/__test__/rendererGate.test.ts`(同上,未跟踪)、`09-27-PLAN.md`(本计划,未跟踪)。**除 `09-27-PLAN.md` 外,以上全部不在本计划范围,不得触碰、不得提交。** 这里的 HEAD / 脏文件条数只是规划日快照:执行期的权威值由 Task 1 在获批后、改代码前写进 `09-27-BASELINE.json`,所有门禁只读该字段,**不得**在门禁里硬编码 `6846c21` 或任何字面 HEAD。" + - "**`choices.tsx` 已经完成「迁移」的前半段**:它已从 `packages-user/client-base/src/components/` 移到 `src/client/ui/choices.tsx`,且 `components/index.ts` 第 1 行已是 `export * from '../../../../src/client/ui/choices';`(已提交在 HEAD 里)。本计划把同款相对路径模式套到 `background` / `inputBox` / `waitBox` 上,**不重排、不改写这条既有 line**(只在它后面追加)。" + - "**新 `useKey` 已经实现且可用**:`packages-user/client-base/src/use/hotkey.ts` 提供 `useKey(system: IHotkeySystem, clearImpl: boolean = true): IHotkeyUsing | null`,`IHotkeyUsing.implement(command, fn): this` 可链式;未挂载前的 `implement` 调用会先缓存、`onMounted` 时统一注册。本计划只把进入时机改成 `onBeforeMount`,不改 API。" + - "**`state` 由 UI 系统的 props base 注入**:`packages-user/client-modules/src/ui/func.ts` 的 `createUIPropsBase(state)` 会向每个经 `controller.open` 打开的 UI 注入 `{ instance, controller, state: markRaw(state) }`;但**Vue 只会把它当 prop 绑定到 `props` 数组里声明过的键**,所以 `props.state` 为 `undefined` 的根因就是「数组里没写 `'state'`」,本计划补的就是这个数组项。" + - "**命令声明已存在(除本计划新增的那一条)**:`@common/confirm` / `@common/exit` / `@common/select-up|down|left|right`、`@ui/save` / `@ui/load` / `@ui/start-up` / `@ui/start-down` / `@ui/view-map-*`、`@ui/save-page-*` 都已在 `src/client/misc/hotkey.ts` 中声明;**`@ui/input-confirm` 与新分组 `@ui/input` 尚不存在**(规划日实测无此 ID、无此分组,`system.addCommand(` 共 62 处),由本计划新增,执行后应为 63 处。`src/client/misc/hotkey.ts` 位于 `src/client`(应用层)而不是 `client-base`。**这条位置事实不构成任何 D-69 合规依据**:D-69 只允许 `export * from './<同目录项>'`,而 `components/index.ts` 里既有的 `choices` 行本身就是对该约束的既成例外;本计划只是沿用这条先例(用户已批准保持公共导入面),属「已知例外」,**不得**表述为「符合 D-69」。" + - "**已知重复 / 错误声明(规划日实测)**:`@ui/save-up` 出现 4 次(UpArrow / RightArrow / DownArrow / LeftArrow),`@ui/save-page-prev` 与 `@ui/save-page-next` 各出现 2 次(PageUp/PageDown 与 Ctrl+PageUp/Ctrl+PageDown),`@ui/view-map-book` 只有 `priority: 10` 没有 `block`。重复 ID 会让后注册者覆盖前者的按键组合,是本计划要修的真实缺陷(用户已裁定为声明修正,不改文案与默认值)。" + - "**按键投递未接线(预期状态,不是本计划的未解决问题)**:`HotkeySystem.listen(target)` 在 `src/` 与 `packages-user/` 的生产代码中**没有任何调用**(仅 `packages/system/src/hotkey/__test__/system.test.ts` 调用)。这是**已知且预期**的现状,本计划**不**负责接线、也不把它当作待修缺陷;因此本计划完成后键盘不会真的产生事件,**不得**宣称任何浏览器按键行为已验证,SUMMARY 如实记录即可。" + - "**类型 / lint 基线非零,且条数不可预测**:`pnpm check:type`(`vue-tsc --noEmit`)当前存在大量既存诊断,`eslint` 在改动文件集合内也可能有既存告警。因此两条门禁都采用「Task 1 在获批后、改代码前实测记录基线 → Task 5 只判 NEW」的前后比较,**不**要求全仓归零。`vue-tsc --noEmit` **不接受文件列表**:Task 1 与 Task 5 都整仓运行,再在 Node 脚本里按「改动文件集合」过滤(同一套归一化 + 同一过滤逻辑)。**唯一真值是 `09-27-BASELINE.json` 里实测采集的 `typecheck` / `eslint` 数组**:本计划**不断言**任何「某文件恰好 N 条」的计数,也不描述既有条目的数量级——任何硬编码条数都会让门禁在两台机器上给出不同结论。验收口径只有两条:①改动文件集合内**不得出现 NEW 条目**;②基线中存在、执行后消失的条目**允许**(不追究)。与本计划机制直接相关的既有条目类型仍为 `settings.tsx` 的 `TS2320` / `TS18048`(`Partial` 与 `IUIPropsBase` 的 `state` 可选/必填冲突、`props.state` 可能为 `undefined`)与 `input.tsx` / `misc.tsx` 的 `TS7006`(`onConfirm: value` / `onResolve: data` 隐式 `any`)——搬迁后用显式参数注解消解,**不得**以新文件的形式重现。" + - "**测试收集方式**:根 `vite.config.ts` 的 `test` 段只有 `testTimeout`、没有 `include`,vitest 默认收集 `**/*.test.ts`;`src/client/ui/__test__/` 目录当前不存在,需在 Task 4 新建。`?raw` 导入的类型由 `src/types/vite-env.d.ts` 的 `/// ` 提供,`tsconfig.json` 的 `include` 已覆盖 `src/**/*.ts`,故新闸门文件会参与类型检查。" + - "**测试文件注释要求**:`AGENTS.md` 要求测试文件每个 `it` 前必须有一行注释说明该测试的用途;本计划的两处校验(vitest 自身 + node 计行闸门)都会检查这一点。" + - "**既有 Obsidian / 历史产物**:`09-26-BASELINE.json` 与 `09-26-SUMMARY.md` 已存在,属 09-26 计划产物,本计划只读、不改动;`09-27-BASELINE.json` 与 `09-27-SUMMARY.md` 是本计划新增的同款产物。" + - "**无 RESEARCH / 无 VALIDATION**:本 run 不存在 `09-27-RESEARCH.md` 与 `09-27-VALIDATION.md`,Nyquist 验证不适用(本阶段无 requirement ID);不创建这两类文件。spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)。" + - "**波次与独立性(wave 22 / `depends_on: []`)**:本计划按 D-01 追加在 09-01..09-26 之后,沿用波次 22;它改动的文件没有任何**并发**计划在本波次触碰(`09-05-PLAN.md` 曾改动 `components/input.tsx` / `components/misc.tsx`,但该计划已执行完毕,本计划是在其结果之上继续裁剪,不构成同波次写冲突),`depends_on: []` 表示结构独立。**`wave: 22` 与 `depends_on: []` 的「不一致」是刻意的**(D-01 增量派发 + D-02 计划累积):本计划不是依赖图排期出来的,不需要为了对齐检查器的波次推导而改 `wave` 或加 `depends_on`。" + prohibitions: + - "不得在用户恰好回复「可以执行」之前执行任何任务或派发执行子代理(D-03)。" + - "**不得运行任何 git 写命令**:`git add` / `commit` / `stash` / `checkout` / `restore` / `worktree` / `reset` 一律禁止;代码改动留在工作树。三个非代码产物(本计划文件、`09-27-BASELINE.json`、`09-27-SUMMARY.md`)由执行方按计划写出(**基线在 Task 1 第一步、改代码前写出**),一律不提交;是否纳入提交由编排器 / 用户另行决定。" + - "不得把 `Input` / `Selection` / `Progress` / `ScrollText` / `Arrow` / `routedInput` / `routedInputNumber` 或任何其它非 GameUI 组件搬出 `client-base`;不得整文件搬迁(`input.tsx` / `misc.tsx` 必须留在原位并被裁剪)。" + - "不得让被 barrel 再导出的三个文件(`background.tsx` / `inputBox.tsx` / `waitBox.tsx`)导入 `@/client` 或任何会拉入 `src/client/entry` / `src/client/initialize` 的模块;`background.tsx` 的常量必须来自 `@/client/uiConstant`。" + - "不得再使用旧 `@user/client-modules` 的 `useKey`、`key.realize(`、`key.willEmit(`、零参 `useKey()`;不得在 `save.tsx` / `settings.tsx` / `title.tsx` / `viewmap.tsx` / `inputBox.tsx` 中**新增 legacy `gameKey` 使用**(`settings.tsx` 既有的 VirtualKey 分支保持原样,不在此禁止范围内)。" + - "不得修改 `src/client/ui/toolbar.tsx` 与 `settings.tsx` 的 VirtualKey 分支(`getVitualKeyOnce` + `gameKey.emitKey`)——它们在 Task 0 汇报中作为已知遗留明确列出。" + - "不得改 `src/client/misc/hotkey.ts` 的默认按键组合 / `mode` / `interval` / `priority` / 描述文案 / 名称文案;**除计划指定的 `@ui/input-confirm` 那一条外**,不得增删任何 `system.addCommand(` 调用(执行后总数必须恰为基线 + 1);不得顺手调整其它分组、不得改动 `@common/confirm` 的三组默认键。" + - "不得让 `inputBox.tsx` 复用 `@common/confirm`、也不得把 `@ui/input-confirm` 的 `defaults` 扩到 Enter 之外的 Space / KeyC;不得给它加 `mode` / `priority` / `interval` / `block`(用户只批准 `defaults: [[KeyCode.Enter]]` 与默认 `mode`)。" + - "不得修改任何既有注释或 jsDoc(AGENTS.md);**唯一例外**是 Task 0 ⑭ 显式列出、需用户批准的两条「随被删除代码一并消失」的注释(`input.tsx` 的回车特判注释、`viewmap.tsx` 的 `willEmit` 特判注释);其余只允许新增,新增注释遵循 dev.md(单行 `//`、`//` 后留空格、不解释下一行代码、说明「为什么」)。" + - "不得新增依赖、不得执行安装命令;不得跑 `build:game` / `build:packages` / `build:lib`,不做全仓类型修复,不得通过放宽断言 / 扩大豁免 / 删除用例让门禁「变绿」。" + - "不得伪造验证:按键投递未接线就不得写成「已自动化验证按键行为」;浏览器 UAT 未做必须如实记录;不得把「源码形状通过」写成「按键行为已验证」。" + - "不得越界处理发现的其它疑似问题(含旧 `client-modules` 的 `useKey` 变为无引用、`MainBackground` 因 barrel 再导出而扩大可见面);只记录、不擅自修复、不擅自登记为缺陷。" + open_items: + - "**OI-1a(已获批命名,本计划直接使用,无需再报备):** 新文件 `src/client/ui/inputBox.tsx`、`src/client/ui/waitBox.tsx`;新命令 ID `@ui/save-right`、`@ui/save-down`、`@ui/save-left`、`@ui/save-page-prev-10`、`@ui/save-page-next-10`。" + - "**OI-1b(待用户最终批准的命名,本计划按用户指定直接写入):** 新命令 ID `@ui/input-confirm` 与新分组 ID `@ui/input`(分组显示名 `输入框`)、命令名 `输入框确认`。用户在 Task 0 决策中已指定「新增专用指令、写作用户提议的 `@ui/input-confirm`」,但这条是**新增公共命令名**,Task 0 汇报里必须标注「**该命名尚待你最终批准**」——你批准即生效,你改名则执行前按你的命名替换,不影响方案形态。**除上述之外本计划不新增其它公共 / 受保护 / 私有命名**:搬移的定义沿用原名,私有辅助(若闸门内需要)只做文件内私有且不进生产代码。" + - "**OI-2(已知限制 / 已知遗留,只记录、不修复):** ①`IHotkeySystem.listen` 无生产调用、按键投递未接线——**预期状态**,不在本计划范围;②`src/client/ui/toolbar.tsx` 与 `settings.tsx` 的 VirtualKey 分支仍是 legacy `gameKey`;③旧 `@user/client-modules` 的 `useKey` 变为无引用但保留(删除不在范围);④`MainBackground` / `MainBackgroundUI` 会随 `background.tsx` 的 `export *` 进入 `client-base` 公共面;⑤`src/client/ui/index.ts` 不加 `inputBox` / `waitBox` 导出(只在 `client-base` barrel 暴露);该文件**已存在** `export * from './background';`,所以 `Background` / `BackgroundUI` 并入 `background.tsx` 后会自动扩大这个 barrel 的有效导出面——这是无害副作用,本计划**不编辑**该文件;⑥游戏当前不可运行,浏览器 UAT 全程跳过;⑦**barrel↔文件环的处理**:`components/index.ts` 跨目录再导出 `src/client/ui/{background,inputBox,waitBox}.tsx`。为不引入**直接**的 barrel↔文件环,本计划要求这三个文件(以及 `choices.tsx`)的 client-base 依赖一律从**具体模块**(相对路径 `../../../packages-user/client-base/src/...`:`components/input` / `components/misc` / `components/textbox` / `components/textboxTyper` / `components/page` / `use` / `types`)导入,文件中不得出现 `from '@user/client-base'`;`@user/client-base/<子路径>` 在现有 `tsconfig.json` 的 `paths` 下不可解析,故用相对路径;若某依赖无法这样导入,执行方停下上报。**不声称 `pnpm check:circular` 全绿**:`client-base` 内部既有的 `textbox` → `elements` → `map` 与 `types.ts` → `./map` 对根 barrel 的回引(`map/types.ts` / `map/renderer.ts` 等 5 处)在本计划之前就存在,属公共导出面 / tsconfig 级问题,不在本计划批准范围内,本计划不改它、也不新增对根 barrel 的直接导入。" + - "**OI-3(只提问、未确认为问题):** `background.tsx` 同时承载 `MainBackground` 与 `Background` 两个组件,语义上都是「背景」;若后续希望拆成两个文件,属另一批范围。本计划**不**调整文件组织(用户已指定 `Background` 落到既有 `background.tsx`)。" +--- + +Phase 9 · 第 27 个计划(新增的独立增量,**不是** 09-01..09-26 的 UI 移植任务)—— **把三个 GameUI 组件从 `client-base` 拆到 `src/client/ui`,并把相关 UI 的按键实现整体迁到新热键系统,同时修正声明层的重复 / 错误 ID**。 + +**要解决的问题:** + +1. **分层倒挂**:`InputBox` / `WaitBox` / `Background` 是「具体的游戏界面组件」,却住在系统层 `packages-user/client-base/src/components`(与 `Input` / `Selection` / `Progress` 这类通用渲染组件混放)。`Background` 一族的三个定义被 `InputBox` 与 `WaitBox` 复用,搬走时必须一起搬,否则会形成系统层反向依赖实现层的孤儿引用。 +2. **旧按键 API**:`choices.tsx` 已接新系统,但 `save.tsx` / `settings.tsx` / `title.tsx` / `viewmap.tsx` 仍在用旧 `@user/client-modules` 的 `useKey()`(返回 `[key, scope]`、`key.realize(id, fn, { type })`),旧 ID(`moveUp` / `confirm` / `@save_up` / `@viewMap_up` / `@start_up`)与新声明体系(`@common/*` / `@ui/*`)完全不对应,等于这些界面的按键实现全部悬空。 +3. **声明缺陷**:`src/client/misc/hotkey.ts` 里 4 条都是 `@ui/save-up`(右 / 下 / 左三条被覆盖丢失),`@ui/save-page-prev|next` 各重复一次(十页变体覆盖了单页变体),`@ui/view-map-book` 缺 `block`(与 `@common/exit` 同按 `KeyX`,会同时「打开手册」并「关闭界面」)。 + +**做法(用户已批准,不得扩大):** + +- **拆定义不拆文件**:新建 `src/client/ui/inputBox.tsx`(`InputBox` / `InputBoxUI` / `getInput` / `getInputNumber` / `InputBoxProps` / `InputBoxEmits`)与 `src/client/ui/waitBox.tsx`(`WaitBox` / `WaitBoxUI` / `waitbox` / `WaitBoxProps` / `WaitBoxEmits` / `WaitBoxExpose`),把 `Background` / `BackgroundUI` / `BackgroundProps` 追加进既有 `src/client/ui/background.tsx`;`input.tsx` / `misc.tsx` 留在原位并被裁剪。`Input` / `Selection` / `Progress` / `ScrollText` / `Arrow` / `routedInput` / `routedInputNumber` 不动。 +- **公共面保持**:`components/index.ts` 用既有 `choices` 同款相对路径追加三条再导出;`statistics.tsx` 与 `client-modules/src/render/utils/saves.ts` 两个 `waitbox` 消费方一行不改。因为 `background.tsx` 会进入 barrel 图,其 `MAIN_WIDTH` / `MAIN_HEIGHT` 导入必须从 `@/client` 改到叶子模块 `@/client/uiConstant`,否则会把应用入口拉进包图。 +- **按键只走新系统**:`useKey(system, clearImpl)` + `implement(command, fn)`;未被 barrel 再导出的文件从 `@user/client-base` 导入,`choices` / `inputBox` / `waitBox` 从具体模块 `.../src/use` 导入(见下条 barrel 环处理)。映射表见 Task 0 / Task 2(`choices` 5 条、`save` 12 条、`settings` 7×`@common/exit`、`title` 3 条、`viewmap` 8 条、`inputBox` 1 条 `@ui/input-confirm`(只认 Enter,见下)。`settings` 的 7 处 `` 传 `freshScope={false}` + `state={props.state!}`(该文件的 `state` 是可选的,7 个读取点同样用 `props.state!`),让子作用域继承父级的 `@common/exit`。`viewmap` 删除 `willEmit('book')` 特判,改由 `@ui/view-map-book` 的 `block: true` 在声明层阻断。 +- **`state` 补声明**:读取 `props.state` 但 props 数组没写 `'state'` 的地方逐一补齐(`choices.tsx` 的 `ConfirmBox` + `Choices`、`viewmap.tsx`、新建 `inputBox.tsx`、`save.tsx`)。 +- **作用域时机**:`packages-user/client-base/src/use/hotkey.ts` 的进入作用域由 `onMounted` 改为 `onBeforeMount`,保证父 GameUI 先于嵌套子组件入栈。 +- **声明改动(6 处修正 + 1 条新增)**:`@ui/save-up` ×4 → `up` / `right` / `down` / `left`;`@ui/save-page-prev|next` 第二条 → `-prev-10` / `-next-10`;`@ui/view-map-book` 加 `block: true`;并新增分组 `@ui/input`(`输入框`)里唯一一条 `@ui/input-confirm`(`defaults: [[KeyCode.Enter]]`、默认 `mode`)。除此之外默认按键 / mode / interval / priority / 文案全部不动。 +- **输入框确认键独立**:`inputBox.tsx` 实现 `@ui/input-confirm` 并 blur 当前输入元素,**不复用** `@common/confirm`(后者是 Enter + Space + KeyC),从而保持「输入框只认 Enter」。 +- **验证**:eslint(前后基线)+ `pnpm check:type`(前后基线,仅 NEW 判失败)+ 新 vitest 源码闸门 + 范围闸门 + 受保护文件哈希闸门;**不做浏览器 UAT、不宣称按键行为已验证、不提交任何代码**。 + +**save 翻页方向(用户已批准的新定义,非待确认项):** `save.tsx` 的翻页方向采用新定义。旧实现 `@save_pageUp` → `movePage(1)`、`@save_pageDown` → `movePage(-1)`;新实现 `@ui/save-page-prev` → `movePage(-1)`、`@ui/save-page-next` → `movePage(1)`,十页变体 `-prev-10` → `-10`、`-next-10` → `+10`。这是语义对齐(prev 就是往前翻),相对旧实现方向相反,**已由用户在对 Task 0 汇报的决策中批准**;十页变体的「按住重复」同属用户已批准的行为,计划与 SUMMARY 只作为已批准事实记录,不再出现「待确认 / 新行为待定」措辞。 + +**约束:** 只动 `files_modified` 的 17 个路径(11 个既有代码文件改、3 个新增代码 / 测试文件:`inputBox.tsx` / `waitBox.tsx` / 闸门测试、1 个新基线、1 个新摘要、本计划本身);`toolbar.tsx` 与 `settings.tsx` 的 VirtualKey 分支显式不改;不改既有注释与 jsDoc;不新增依赖;不改配置;不新增未获批准命名。 + +**修改方式声明(AGENTS.md):** 本计划的全部文件写入(写基线、新建 2 个组件文件、改 11 个既有代码文件、新建闸门测试、写摘要)由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入;代码一律**不提交**,工作树留给用户审查。**Task 0 只汇报、不写任何文件**(D-03 / AGENTS.md:未收到「可以执行」之前不得写文件);`09-27-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 验证不适用。 + + +## 本计划产出物(Artifacts this phase produces) + +| 类型 | 名称 / 路径 | 说明 | +|------|-------------|------| +| 新增 | `src/client/ui/inputBox.tsx` | `InputBox` 一族 + 新系统 `@ui/input-confirm`(只认 Enter,blur 当前输入元素) | +| 新增 | `src/client/ui/waitBox.tsx` | `WaitBox` 一族(依赖 `Background` / `BackgroundProps`) | +| 修改 | `src/client/ui/background.tsx` | 追加 `Background` 一族;`@/client` → `@/client/uiConstant` | +| 修改 | `packages-user/client-base/src/components/index.ts` | 追加 `background` / `inputBox` / `waitBox` 三条 `export *` | +| 修改 | `packages-user/client-base/src/components/input.tsx` | 裁掉 `InputBox` 一族与旧 `useKey()`,清理失效导入 | +| 修改 | `packages-user/client-base/src/components/misc.tsx` | 裁掉 `Background` / `WaitBox` 两族,清理失效导入 | +| 修改 | `packages-user/client-base/src/use/hotkey.ts` | `onMounted` → `onBeforeMount` | +| 修改 | `src/client/ui/choices.tsx` | `Background` 本地导入 + 其余 client-base 依赖改具体模块、`state` 补声明、5 条 `@common/*` | +| 修改 | `src/client/ui/save.tsx` | 新 `useKey`、12 条命令映射、`state` 补声明 | +| 修改 | `src/client/ui/settings.tsx` | 7×`useKey(props.state!.hotkey.system)` + `@common/exit`、`freshScope={false}` + `state={props.state!}` | +| 修改 | `src/client/ui/title.tsx` | 新 `useKey`、`@ui/start-*` + `@common/confirm` | +| 修改 | `src/client/ui/viewmap.tsx` | 新 `useKey`、`IGameUIPropsBase`、`state`、8 条映射、删 `willEmit` 特判 | +| 修改 | `src/client/misc/hotkey.ts` | 6 处声明修正(3 个 ID 拆分 + 2 个 `-10` ID + 1 个 `block`)+ 1 条新增命令块(`@ui/input` 分组内的 `@ui/input-confirm`,只绑 Enter) | +| 新增 | `src/client/ui/__test__/keyAdaptationGate.test.ts` | 源码形状闸门(15 组断言,`?raw` 读取) | +| 基线 | `.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json` | 基线 `head` + 5 个受保护文件哈希(完整路径键)+ eslint / typecheck 基线 + `addCommandCount` | +| 交付 | `.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md` | 执行摘要、已批准的行为口径、门禁结果、未接线声明 | +| 计划 | `.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md` | 本文件 | + +## 覆盖审计(Multi-Source Coverage Audit) + +| 源 | 条目 | 覆盖位置 | +|----|------|----------| +| GOAL(ROADMAP §Phase 9) | 「可在浏览器中运行并验证的最小版本」伞目标 | **本增量不新增覆盖**:按键投递(`IHotkeySystem.listen`)仍未接线,这是**预期状态**、不在本计划范围;浏览器可运行性未被验证,SUMMARY 如实记录 | +| REQ | 本阶段无 requirement ID(`requirements: []`) | Nyquist 不适用 | +| RESEARCH | 本 run 无 RESEARCH.md | 不适用 | +| CONTEXT D-01 | 增量派发,一个任务一个计划 | 本计划即该次派发的唯一计划,不拆分、不预规划后续 | +| CONTEXT D-02 | 计划可累积追加 | plan 27,排在 09-01..09-26 之后(`wave: 22` 只是排期编号,不是依赖推导的结果) | +| CONTEXT D-03 | 执行前必须先汇报并等待「可以执行」 | Task 0(`blocking-human`),`autonomous: false` | +| CONTEXT D-04 | 新命名必须先反馈获批 | OI-1a 已获批(新文件名 + 5 个新命令 ID);OI-1b 的 `@ui/input-confirm` / `@ui/input` 列为**待用户最终批准**(Task 0 ⑨ 明确标注),本计划按用户指定写入 | +| CONTEXT D-05 | 默认不逐阶段验证 | 本计划仍做自动化门禁,但**明确不做浏览器 UAT**,不宣称按键行为已验证 | +| CONTEXT D-06 | 范围由用户界定,不擅自扩大 | `files_modified` 17 项 + 范围闸门 + `toolbar.tsx` / VirtualKey 显式排除 | +| CONTEXT D-07 | UI 无固定约定;UI 形态由用户提供,AI 不自行臆造 | **COVERED(边界)**:本计划只重接按键归属 / 实现并新增一条输入框确认指令,不新增也不改动任何 UI 形态(布局、样式、文案、外观),因此没有臆造 UI 的空间 | +| 用户任务 1(搬迁) | 只搬指定定义;不搬整文件;公共导入不变;清理失效导入;`choices` 局部导入;`state` 补声明 | Task 1(A–F)+ Task 4(闸门 1–6) | +| 用户任务 1(额外) | `background.tsx` 的 `@/client/uiConstant`;`waitBox.tsx` 导入 `BackgroundProps` | Task 1(C / B2)+ Task 4(闸门 3 / 2) | +| 用户任务 2(按键重写) | 6 个文件的命令映射与机制 | Task 1(inputBox 的 key)+ Task 2(choices / save / settings / title / viewmap / use-hotkey)+ Task 4(闸门 7–13) | +| 用户任务 2(翻页方向,已批准) | save 翻页方向采用新定义(prev=-1 / next=+1,十页 ±10,相对旧实现相反);十页按住重复同属已批准 | Task 0 记录为**已批准** + Task 2(B)+ SUMMARY | +| 用户任务 3(输入框确认键) | 新增 `@ui/input-confirm`(只绑 `Enter`、默认 `mode`)与新分组 `@ui/input`;`inputBox.tsx` 不复用 `@common/confirm` | Task 0 ⑨(命名待最终批准)+ Task 1(A.3)+ Task 3(E)+ Task 4(闸门 12 / 15) | +| 用户任务 2(排除) | `toolbar.tsx` 与 Settings VirtualKey 用 legacy `gameKey`,显式不改 | 本 objective + prohibitions + Task 0 汇报 | +| 用户任务 2(时序) | `use/hotkey.ts` 改 `onBeforeMount` | Task 2(G)+ Task 4(闸门 12) | +| 用户任务 3(声明修正) | 5 个重复 ID 修正 + `block: true`,其余不动 | Task 3 + Task 4(闸门 13 / 14) | +| 用户任务 4(验证) | eslint / 前后基线 / 新 vitest 闸门 / 无 UAT / 无提交 | Task 5 全部门禁 + Task 4 闸门文件 + SUMMARY | +| 用户附加约束 | 闸门不得断言裸 `'save'` / `'load'`;只查 import 语句而非整文件;类型闸门覆盖 `statistics.tsx` 与 `saves.ts` | Task 4(H)+ Task 5(C,scope 文件集合含两个消费方) | + + +@C:/Users/13194/.config/opencode/gsd-core/workflows/execute-plan.md +@C:/Users/13194/.config/opencode/gsd-core/templates/summary.md + + + +@.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-26-PLAN.md +@AGENTS.md +@dev.md +@packages-user/client-base/src/components/index.ts +@packages-user/client-base/src/components/input.tsx +@packages-user/client-base/src/components/misc.tsx +@packages-user/client-base/src/components/textbox.tsx +@packages-user/client-base/src/use/hotkey.ts +@packages-user/client-base/src/use/types.ts +@packages-user/client-base/src/hotkey/types.ts +@packages-user/client-base/src/types.ts +@src/client/ui/background.tsx +@src/client/ui/choices.tsx +@src/client/ui/save.tsx +@src/client/ui/settings.tsx +@src/client/ui/title.tsx +@src/client/ui/viewmap.tsx +@src/client/ui/statistics.tsx +@src/client/ui/toolbar.tsx +@src/client/misc/hotkey.ts +@src/client/uiConstant.ts +@packages-user/client-modules/src/render/utils/saves.ts +@packages-user/client-modules/src/ui/func.ts +@packages-user/client-modules/src/ui/types.ts +@packages-user/client-modules/src/render/use.ts +@packages/system/src/hotkey/system.ts +@packages/system/src/hotkey/types.ts + + + + Task 0: 预执行汇报关卡 —— 汇报三个 GameUI 组件拆分、按键实现迁移新系统、声明修正 + 新增输入框确认指令、save 翻页方向(已批准)与命名清单(含待最终批准项),等待「可以执行」 + 工作树仍是改动前状态,可用以下事实自检:`packages-user/client-base/src/components/input.tsx` 含 `export interface InputBoxProps` / `export const InputBox` / `export function getInput(` / `export const InputBoxUI` 且含 `const [key] = useKey();` 与 `key.realize('confirm'`;`components/misc.tsx` 含 `export interface BackgroundProps` / `export const Background` / `export interface WaitBoxProps` / `export const WaitBox` / `export function waitbox` / `export const WaitBoxUI` / `export const BackgroundUI`;`components/index.ts` 第 1 行为 `export * from '../../../../src/client/ui/choices';`;`src/client/ui/background.tsx` 含 `import { MAIN_HEIGHT, MAIN_WIDTH } from '@/client';` 且**没有** `Background`(只有 `MainBackground`);`src/client/ui/inputBox.tsx`、`src/client/ui/waitBox.tsx`、`src/client/ui/__test__/` 均不存在;`src/client/ui/save.tsx` / `settings.tsx` / `title.tsx` / `viewmap.tsx` 均含 `from '@user/client-modules'` 的 `useKey` 与 `key.realize(`;`src/client/misc/hotkey.ts` 中 `system.addCommand('@ui/save-up'` 出现 4 次、`@ui/save-page-prev` 出现 2 次、`@ui/save-page-next` 出现 2 次、`@ui/view-map-book` 无 `block`;同文件**没有** `@ui/input-confirm`、**没有** `hotkey.setGroup('@ui/input'`,`system.addCommand(` 共 62 处,且 `@common/confirm` 的 `defaults` 为 `[[KeyCode.Enter], [KeyCode.Space], [KeyCode.KeyC]]`;`packages-user/client-base/src/use/hotkey.ts` 含 `onMounted(() => {`。 + 无(本任务不写任何文件,全部只读命令;`09-27-BASELINE.json` 改由 Task 1 在获批后、改代码前写出) + + - .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-01 增量派发、D-03 汇报关卡、D-04 命名批准、D-05 不逐阶段验证、D-06 不扩大范围) + - .planning/phases/09-minimum-browser-runnable/09-26-PLAN.md(前一个同批计划的形态:Task 0 汇报 + 基线产物 + 前后真值比较门禁) + - packages-user/client-base/src/components/input.tsx(待搬的 `InputBox` 一族与旧 `useKey()`) + - packages-user/client-base/src/components/misc.tsx(待搬的 `Background` / `WaitBox` 两族) + - packages-user/client-base/src/components/index.ts(现有 `choices` 再导出模式) + - packages-user/client-base/src/use/hotkey.ts 与 use/types.ts(新 `useKey(system, clearImpl)` / `implement`) + - src/client/ui/background.tsx(`@/client` 导入与 `MainBackground`) + - src/client/ui/choices.tsx(已接新系统但命令 ID 仍旧;`Background` 现从 barrel 取) + - src/client/ui/save.tsx / settings.tsx / title.tsx / viewmap.tsx(旧 `useKey()` + `key.realize(...)` 全貌) + - src/client/misc/hotkey.ts(声明全文:重复 ID 与 `@ui/view-map-book` 缺 `block`) + - packages-user/client-modules/src/ui/func.ts(`state` 由 props base 注入,但必须写进 props 数组) + - AGENTS.md(执行前须获批、命名先反馈、既有注释不得修改、疑似问题须先确认)、dev.md(注释 / jsDoc 规范、无副作用、命名) + + + 一份待批准的执行计划(`09-27-PLAN.md`):把 `InputBox` / `WaitBox` / `Background` 三个 GameUI 组件拆到 `src/client/ui`、把 6 个 UI 文件的按键实现迁到新热键系统、修正 `src/client/misc/hotkey.ts` 的 6 处声明缺陷,并新增 1 条输入框确认指令(`@ui/input-confirm`,只绑 `Enter`),用四类门禁验收。 + + + **停下并向用户汇报,等待恰好回复「可以执行」后才继续;本任务不写任何文件(含 `09-27-BASELINE.json`——它已移到 Task 1 的第一步),不派发执行子代理。** + + ① **要解决的问题(三项):** + + 1. **分层倒挂**:`InputBox` / `WaitBox` / `Background` 是具体游戏界面组件,却住在系统层 `packages-user/client-base/src/components`(与 `Input` / `Selection` / `Progress` 这类通用渲染组件混放)。`Background` 被 `InputBox` 与 `WaitBox` 复用,搬走时必须一起搬。 + 2. **旧按键 API 全面悬空**:`save.tsx` / `settings.tsx` / `title.tsx` / `viewmap.tsx` 仍在用旧 `@user/client-modules` 的 `useKey()`(返回 `[key, scope]`,`key.realize(id, fn, { type })`)+ 旧 ID(`confirm` / `exit` / `@save_up` / `@viewMap_up` / `@start_up`),与新声明体系(`@common/*` / `@ui/*`)完全不对应。 + 3. **声明缺陷**:`src/client/misc/hotkey.ts` 里 4 条都叫 `@ui/save-up`(右 / 下 / 左被覆盖丢失)、`@ui/save-page-prev|next` 各重复一次(十页变体覆盖单页变体)、`@ui/view-map-book` 缺 `block`(与 `@common/exit` 同按 `KeyX`,会「开手册 + 关界面」同时发生)。 + + ② **搬迁设计(只搬定义,不搬文件):** + - 新建 `src/client/ui/inputBox.tsx`:`InputBoxProps`(改为 `extends IGameUIPropsBase, TextContentProps`)、`InputBoxEmits`、`inputBoxProps`(props 数组补 `'state'`)、`InputBox`、`getInput`、`getInputNumber`、`InputBoxUI`。 + - 新建 `src/client/ui/waitBox.tsx`:`WaitBoxProps`(`extends Partial, Partial`,**必须从 `./background` 导入 `BackgroundProps`**)、`WaitBoxEmits`、`WaitBoxExpose`、`waitBoxProps`、`WaitBox`、`waitbox`、`WaitBoxUI`。 + - 追加进既有 `src/client/ui/background.tsx`:`BackgroundProps`、`backgroundProps`、`Background`、`BackgroundUI`;保留 `MainBackground` / `MainBackgroundUI`。 + - `components/input.tsx` 只留 `Input` / `InputProps` / `InputEmits` / `inputProps` / `routedInput` / `routedInputNumber`;`components/misc.tsx` 只留 `Progress` / `Arrow` / `ScrollText` / `Selection` 等非 GameUI 组件。 + - `components/index.ts` 在既有 `choices` 行**之后**追加三条同款相对路径 `export *`(background / inputBox / waitBox),既有 `choices` 行不改。 + - **barrel 图卫生**:`background.tsx` 的 `import { MAIN_HEIGHT, MAIN_WIDTH } from '@/client'` 改为 `@/client/uiConstant`;三个被再导出的文件都不得出现 `from '@/client'`。 + - `choices.tsx` 的 `Background` 改为 `import { Background } from './background';`;其余 client-base 依赖(`Selection` / `TextContent` / `TextContentProps` / `TextAlign` / `Page` / `PageExpose` / `IGameUIPropsBase` / `useKey`)改从具体模块相对路径导入,**不再从 `@user/client-base` barrel 取**。 + + ③ **按键迁移(统一 `useKey(props.state.hotkey.system[, clearImpl])!` + `key.implement(id, fn)`;`choices` 的 `useKey` 已在 Task 1 G 走具体模块,其余四个文件从 `@user/client-base` 导入):** + + | 文件 | 旧 ID | 新 ID | + |------|-------|-------| + | `choices.tsx` | `moveUp` / `moveDown` / `moveLeft` / `moveRight` / `confirm` | `@common/select-up` / `@common/select-down` / `@common/select-left` / `@common/select-right` / `@common/confirm` | + | `save.tsx` | `confirm` / `exit` / `save` / `load` | `@common/confirm` / `@common/exit` / `@ui/save` / `@ui/load` | + | `save.tsx` | `@save_up` / `@save_down` / `@save_left` / `@save_right` | `@ui/save-up` / `@ui/save-down` / `@ui/save-left` / `@ui/save-right` | + | `save.tsx` | `@save_pageUp` / `@save_pageDown` | `@ui/save-page-prev` / `@ui/save-page-next` | + | `save.tsx` | (无) | `@ui/save-page-prev-10` / `@ui/save-page-next-10` | + | `settings.tsx` | `exit`(7 处) | `@common/exit` | + | `title.tsx` | `@start_up` / `@start_down` / `confirm` | `@ui/start-up` / `@ui/start-down` / `@common/confirm` | + | `viewmap.tsx` | `@viewMap_up` / `@viewMap_down` / `@viewMap_up_ten` / `@viewMap_down_ten` | `@ui/view-map-next` / `@ui/view-map-prev` / `@ui/view-map-next-10` / `@ui/view-map-prev-10` | + | `viewmap.tsx` | `@viewMap_book` / `@viewMap_fly` / `confirm` / `exit` | `@ui/view-map-book` / `@ui/view-map-fly` / `@common/confirm` / `@common/exit` | + | `inputBox.tsx` | (无,搬入后新增) | `@ui/input-confirm`(**只由 Enter 触发**;blur 当前聚焦的 `input` / `textarea`) | + + - **`settings.tsx`**:7 个子组件删掉局部 `scope`,`useKey` 写成 `useKey(props.state!.hotkey.system)!`,7 处 `` 删 `scope={scope}`、加 `freshScope={false}` 与 `state={props.state!}`(子作用域以 `clearImpl = false` 进入,父级 `@common/exit` 才能存活)。**注意 `props.state!` 不可省**:`MainSettingsProps` 是 `extends Partial, IUIPropsBase`,`state` 是可选类型,`props.state.hotkey` 与 `state={props.state}` 都会新增 `TS18048` / `TS2322`;本计划**不改**这个 interface,只用 `!`(7 个读取点 + 7 个传参点)消解。 + - **`viewmap.tsx`**:删除 `key.willEmit(code, assist, 'book')` 特判,`@common/exit` 直接关闭界面;`ViewMapProps` 改 `extends IGameUIPropsBase, BaseProps`,props 数组补 `'state'`,删除不再使用的 `IUIDefaultPropsBase` 导入。 + - **`use/hotkey.ts`**:进入作用域的回调由 `onMounted` 改为 `onBeforeMount`(离开仍 `onUnmounted`),字段名不改。 + + ④ **`state` 补声明**:`choices.tsx` 的 `confirmBoxProps.props` / `choicesProps.props`、`viewmap.tsx`、`save.tsx`、新建 `inputBox.tsx` 的 props 数组补 `'state'`(`title.tsx` / `settings.tsx` 已有,不动)。根因:`createUIPropsBase` 会注入 `state`,但 Vue 只绑定 props 数组里声明过的键。 + + ⑤ **声明改动(6 处修正 + 1 条新增,其它零改动):** 第 2 条 `@ui/save-up`(RightArrow)→ `@ui/save-right`;第 3 条(DownArrow)→ `@ui/save-down`;第 4 条(LeftArrow)→ `@ui/save-left`;第 2 条 `@ui/save-page-prev`(Ctrl+PageUp)→ `@ui/save-page-prev-10`;第 2 条 `@ui/save-page-next`(Ctrl+PageDown)→ `@ui/save-page-next-10`;`@ui/view-map-book` 加 `block: true`;**并新增唯一一个新分组** `hotkey.setGroup('@ui/input', '输入框', [...])`,内含唯一一条 `system.addCommand('@ui/input-confirm', '输入框确认', { defaults: [[KeyCode.Enter]] })`(不写 `mode`,即默认 `KeyUp`;不加 `priority` / `interval` / `block`),供 `inputBox.tsx` 实现。**除此之外默认按键 / mode / interval / priority / 描述与名称文案一律不改;`system.addCommand(` 的数量从基线增加恰好 1**(62 → 63)。 + + ⑥ **`save` 翻页方向(用户已批准,仅作记录):** 旧实现 `@save_pageUp`(PageUp)→ `movePage(1)`、`@save_pageDown`(PageDown)→ `movePage(-1)`;新实现 `@ui/save-page-prev`(PageUp)→ `movePage(-1)`、`@ui/save-page-next`(PageDown)→ `movePage(1)`、`-prev-10` → `movePage(-10)`、`-next-10` → `movePage(10)`。语义上 prev/next 与「向前 / 向后翻页」对齐,**相对旧行为方向相反——你已在决策中批准采用这个新定义**,因此本计划不再请求确认,也不保留「可能需要改回」的措辞。 + + ⑥-2 **两份已批准 / 已定的行为口径(记录,不再请求确认):** + - **十页翻页是「按住重复」(已批准)**:`viewmap.tsx` 的旧 `@viewMap_up_ten` / `@viewMap_down_ten` 是单发(`realize` 未带 `{ type: 'down-repeat' }`),而新映射到的 `@ui/view-map-next-10` / `@ui/view-map-prev-10` 在声明层是 `mode: Continuous, interval: 500`;`save` 的两条 `-10` 同样是 `Continuous`。因此按住 Ctrl+方向键 / Ctrl+PageUp|PageDown 会连续跳十页。**你已批准保持这一「按住重复」行为**,不要改回单发、也不要因此改动声明层。 + - **`inputBox` 的确认键只认 Enter(按你的决策新增专用指令)**:旧实现是 `Input` 内部对 `KeyCode.Enter` 的特判;新实现**不复用** `@common/confirm`(那是 `Enter + Space + KeyC`),而是新增 `@ui/input-confirm` 并只绑 `[[KeyCode.Enter]]`。按键投递(`IHotkeySystem.listen`)仍属预期未接线,以上都只是声明层语义,**未做浏览器验证**。 + + ⑦ **影响范围:** + - 改 11 个既有代码文件:`packages-user/client-base/src/components/{index.ts,input.tsx,misc.tsx}`、`packages-user/client-base/src/use/hotkey.ts`、`src/client/ui/{background,choices,save,settings,title,viewmap}.tsx`、`src/client/misc/hotkey.ts`。 + - 新增 3 个代码 / 测试文件:`src/client/ui/inputBox.tsx`、`src/client/ui/waitBox.tsx`、`src/client/ui/__test__/keyAdaptationGate.test.ts`;另有 3 个 `.planning` 产物(本计划、`09-27-BASELINE.json`、`09-27-SUMMARY.md`)。合计 17 个路径。 + - **一行不改的消费方**:`src/client/ui/statistics.tsx`(`waitbox`)、`packages-user/client-modules/src/render/utils/saves.ts`(`waitbox`)—— 但它们的类型检查纳入闸门,用来证明 barrel 没破。 + - **显式不改**:`src/client/ui/toolbar.tsx`、`settings.tsx` 的 VirtualKey 分支(`getVitualKeyOnce` + `gameKey.emitKey`,仍是 legacy);`src/client/ui/index.ts` 不加导出;旧 `client-modules` 的 `useKey` 与 `IUIPropsBase` 保留不动。 + - `MainBackground` / `MainBackgroundUI` 会因 `export *` 进入 `client-base` 公共面(已知副作用,只记录)。 + + ⑧ **验证方式:** + 1. `cmd /c "pnpm exec eslint <改动文件>"`,与 `09-27-BASELINE.json` 的 `eslint` 基线比较,**只判 NEW 告警 / 错误**;集合必须包含新增的 `src/client/ui/inputBox.tsx` / `src/client/ui/waitBox.tsx`(基线里为空,因此任何条目都算 NEW)。**判据是「基线相对」**:基线集合里已含 severity-2 条目(`viewmap.tsx` / `statistics.tsx` 的既有 error),所以整体 eslint 在目标终态**本来就会以 `status === 1` 退出**——`status 1` 是预期状态,只按多重集判 NEW,**不得**因为「非零退出」就判失败;只有 `status === null` / `status >= 2`(未能运行)、stdout 为空或 JSON 不可解析才判失败。 + 2. `cmd /c "pnpm check:type"`(`vue-tsc --noEmit`,**整仓运行、不接受文件列表**)与基线比较,过滤只发生在我们自己的 Node 脚本里:仅对**改动文件集合**(含 `statistics.tsx` 与 `saves.ts`)内的 **NEW** 诊断判失败;既存诊断不要求修复。**基线以 `09-27-BASELINE.json` 中实测采集的 `typecheck` 数组为唯一真值,本计划不断言任何条数**:基线里存在、执行后消失的条目**允许**;`inputBox.tsx` / `waitBox.tsx` 已补显式参数注解,不得出现新的 `TS7006`;`settings.tsx` 继续用 `props.state!` / `state={props.state!}` 机制消解新增读取点。**退出码口径**:全仓本就有既存诊断,`vue-tsc` 会以 `status` `1` 或 `2` 退出——只要输出里有可解析的 `error TS` 行就是**预期状态**,不是崩溃;只有 `status === null`(未运行 / 被信号终止),或非零退出且输出里没有任何可解析的 `error TS` 行,才判失败。 + 3. 新增源码形状闸门 `src/client/ui/__test__/keyAdaptationGate.test.ts`(vitest + `?raw`,不导入组件、不使用 `node:fs`),15 组断言:搬迁落点、旧居所残留、barrel 三条再导出、命令新旧映射、旧 `useKey()` / `realize(` 消失、`settings` 的 `freshScope` + `state`、`viewmap` 的 `willEmit` 消失、`inputBox` 的 `@ui/input-confirm` + blur、`onBeforeMount`、声明 ID 唯一、`block: true`、`@ui/input-confirm` 只绑 Enter(无 Space / KeyC / mode / priority / block)与 `@common/confirm` 保持三键。**注意**:闸门对 `save.tsx` 只断言 `.implement('<旧 ID>'` 不存在,**不**断言裸 `'save'` / `'load'` 字面量(文件里 legit 地有 `core.doSL(..., 'save'/'load')`);对 `@user/client-modules` 只检查 import 语句,不检查整文件正文。 + 4. 范围闸门(代码侧 `git status --porcelain -uall -- <白名单路径>` 必须恰等于 14 条期望状态;两个 barrel 消费方 `src/client/ui/statistics.tsx` / `packages-user/client-modules/src/render/utils/saves.ts` 必须**没有任何状态行**;三个 `.planning/.../09-27-*` 规划产物允许「未跟踪」或「已跟踪且干净」)+ 受保护文件哈希闸门(`toolbar.tsx` / `src/client/ui/index.ts` / `textbox.tsx` / 旧 `render/use.ts` / `ui/types.ts` 五个哈希与基线逐字相等;HEAD 只验「基线 head 是当前 HEAD 的祖先,且 `<基线 head>..HEAD` 的提交没有触碰任何白名单代码路径」(该清单排除 `.planning/phases/09-minimum-browser-runnable/09-27-*`),**不**要求 HEAD 字面相等)+ 声明掩码比对闸门(`git show <基线 head>:…` 取旧版,**两侧先做 `\r\n` → `\n`、行尾空白、空行与首尾(EOF)换行归一化,再整段剔除当前文件里新增的 `@ui/input` 分组**,然后掩掉 save 的 8 个 ID 与 1 个 `block: true`,最后逐字相同(声明 ID 计数与 `priority` / `block` / `interval` / `mode` 判定基于剔除注释后的文本);另断言 `addCommand` 数为基线 + 1)。 + 5. **不做浏览器 UAT、不宣称任何按键行为已验证**(`IHotkeySystem.listen` 无生产调用、按键投递未接线,这是**预期状态**,不在本计划范围,不列为待解决项);**不提交任何代码**。 + + ⑨ **标识符清单(OI-1a 已批准 / OI-1b 待你最终批准):** + - 新文件(**已批准**):`src/client/ui/inputBox.tsx`、`src/client/ui/waitBox.tsx`。 + - 重命名的新命令 ID(**已批准**):`@ui/save-right`、`@ui/save-down`、`@ui/save-left`、`@ui/save-page-prev-10`、`@ui/save-page-next-10`。 + - **待你最终批准的新公共命名(本计划按你指定写入,不擅自换名)**:新命令 ID `@ui/input-confirm`(命令名 `输入框确认`)、新分组 ID `@ui/input`(分组显示名 `输入框`)。这两项是本轮决策新增的公共命名,请在批复时确认;你若改名,执行前按你的命名整体替换(闸门与计划里的字面量同步替换,方案形态不变)。 + - 除此之外**不新增**任何公共 / 受保护 / 私有命名:搬移定义沿用原名,闸门内辅助函数只做文件内私有。 + + ⑩ **已知限制 / 遗留(OI-2,只记录、不修复):** ①按键投递未接线(**预期状态**,不在本计划范围);②`toolbar.tsx` 与 VirtualKey 分支仍是 legacy `gameKey`;③旧 `client-modules` 的 `useKey` 变为无引用但保留;④`MainBackground` 随 `export *` 扩大可见面;⑤`src/client/ui/index.ts` 不加新导出(该文件已有 `export * from './background';`,故 `Background` / `BackgroundUI` 并入 `background.tsx` 后会自动扩大这个 barrel 的有效导出面——无害,且本计划不编辑该文件);⑥游戏不可运行,UAT 跳过;⑦**barrel↔文件环已按「具体模块导入」处理**:`components/index.ts` 跨目录再导出 `background` / `inputBox` / `waitBox`,因此这三个文件(以及既有 `choices.tsx`)的 client-base 依赖改为相对路径的具体模块(`components/input` / `components/misc` / `components/textbox` / `components/textboxTyper` / `components/page` / `use` / `types`),文件中不得出现 `from '@user/client-base'`。`@user/client-base/<子路径>` 在当前 `tsconfig.json` 的 `paths` 下不可解析,故用相对路径;若某依赖无法这样导入,执行方停下上报。**不声称 `pnpm check:circular` 全绿**:`client-base` 内部既有的 `textbox` → `elements` → `map` 与 `types.ts` → `./map` 回引在本计划之前就存在,不在本计划批准范围内(详见 OI-2 ⑦);⑧估算 120k 约为 smart-zone 单计划预算的 1.2×、`confidence: low`——按 **D-01 不得未经你同意拆分**,本计划按「待 Task 0 确认 / 待用户批准」处理(**该超额尚未获批**);若你不接受这个超额,请直接说明,我会先请你决定是否拆成两个计划(拆分会改变已批准的计划形态),**不会**靠砍范围或跳过门禁降本。 + + ⑪ **两条铁律:** **不做浏览器 UAT、不宣称任何按键行为已验证**(`IHotkeySystem.listen` 无生产调用、投递未接线属预期状态);**不提交任何代码**(禁止 `git add` / `commit` / `stash` / `checkout` / `worktree`),改动全部留在工作树供你审查。 + + ⑫ **基线记录(本任务不写、只说明字段;实际写出在 Task 1 的第一步):** 由于 AGENTS.md / D-03 要求「未收到『可以执行』之前不得写任何文件」,`09-27-BASELINE.json` **不在本任务落盘**——它由 Task 1 在收到「可以执行」之后、编辑任何代码之前**一次性**写出(先采集再动代码,前后比较才有意义)。本任务只向你汇报将要采集的字段与命令(schema 与下方逐条一致),供你核对: + + - `head` = 采集当次的 `git rev-parse HEAD`(规划日实测为 `6846c211c73854762c782e8cde82e81b0191c524`,**只写进汇报**;`head` 字段本身必须是采集时的值,任何门禁只读字段、不消费字面量)。 + - `hashes` = 5 个受保护文件的 `git hash-object`,**键必须是完整仓库相对路径(POSIX 正斜杠,禁止 basename)**,与 Task 3 / Task 5 的读取键逐字一致。目标形态(值填实际 sha): + `{ "src/client/ui/toolbar.tsx": "", "src/client/ui/index.ts": "", "packages-user/client-base/src/components/textbox.tsx": "", "packages-user/client-modules/src/render/use.ts": "", "packages-user/client-modules/src/ui/types.ts": "" }` + `09-26-BASELINE.json` 用 basename 作键,那是 09-26 的 schema;本计划固定用完整路径,**不得**混用两套键。 + - `addCommandCount` = `src/client/misc/hotkey.ts` 中 `system.addCommand(` 的出现次数(规划日实测 **62**;**执行后必须恰为基线 + 1**,即 63,多出的只能是新增的 `@ui/input-confirm`) + - `eslint` = 对「改动文件集合」跑 `pnpm exec eslint -f json ` 后,把每条消息归一化成 `仓库相对路径|规则ID|严重度|消息`,按原顺序存成字符串数组。**归一化必须与 Task 5 的 eslint 门禁逐字相同**:`filePath` 先 `.replace(/\\/g,'/')` 再 `.replace(/^.*?(?=(src|packages-user)\/)/,'')` 去掉盘符 / 仓库前缀(否则 Task 5 会把每条都判成新条目),`ruleId` 为空时写 `null`。数组为空表示当前干净。**采集时必须容忍 `status === 1`**:基线集合里已有 severity-2 条目(`viewmap.tsx` / `statistics.tsx`),`pnpm exec eslint -f json` 会以 `status === 1` 退出,这是正常现象——必须照常解析 stdout 并写入基线数组;只有 `status === null` / `status >= 2` 或 stdout 为空 / JSON 不可解析时才允许停下上报,**不得**把非零退出当成「无基线」或写入空数组。 + - `typecheck` = **先整仓跑** `pnpm check:type`(`vue-tsc --noEmit` 不接受文件列表,**不要**给它传任何路径),再用与 Task 5 **逐字相同**的 Node 侧逻辑过滤出落在「改动文件集合」内的诊断行,**逐条归一化**:`.replace(/\\/g,'/')` → 去掉 `(行,列)`(`/\(\d+,\d+\):/`)→ `.replace(/^.*?(?=(src|packages-user)\/)/,'')` 去掉路径前缀 → `trim`,得到 `文件 error TSxxxx: 消息`。行号会因改动而移动,故不参与比较;保留重复项以便 Task 5 按**多重集**比较。新文件(`inputBox.tsx` / `waitBox.tsx`)此刻尚不存在,基线里自然为空。**该数组是类型门禁的唯一真值:本计划不对任何文件断言条数。** + + 「改动文件集合」= `packages-user/client-base/src/components/index.ts`、`components/input.tsx`、`components/misc.tsx`、`use/hotkey.ts`、`src/client/ui/{background,choices,save,settings,title,viewmap,statistics}.tsx`、`src/client/misc/hotkey.ts`、`packages-user/client-modules/src/render/utils/saves.ts`(新文件尚不存在,基线里自然为空)。 + + 本任务**不实际采集、不落盘**;以下仅为 Task 1 将要执行的采集与落盘步骤:写出 `head`、5 个哈希、`addCommandCount`、`eslint` 与 `typecheck` 数组,并记录 `git status --porcelain -uall` 工作树快照(规划日为 8 条,见 assumptions;执行期条数以实测为准,**不得**把规划日条数当断言)。**该文件只写一次,此后禁止重写。** + + ⑬ **修改方式:** 本任务(Task 0)**不写任何文件**(含基线、含任何代码);获批后由 `gsd-executor` 子代理执行 Task 1–5(非内联),只写 `files_modified` 的 17 个路径,其中 `09-27-BASELINE.json` 是 Task 1 的第一步;代码不提交。「开始执行」的界限仍是你的「可以执行」四个字。 + + ⑭ **随被删代码一并消失的既有注释(请显式批准;这是本计划唯一的注释删除范围,共两条):** AGENTS.md 禁止改动既有注释,因此这里把两条「删除旧代码时会一起消失」的注释单独列出,请一并确认: + - `packages-user/client-base/src/components/input.tsx`(规划日位于第 227 行):`Input` 内旧按键块中的 `// 特判回车键`(规划日实测;若执行期该行文本为 `// 按下回车键`,同属本批准范围,按文本定位;删除 `const [key] = useKey(); …` 整块时随之消失)。 + - `src/client/ui/viewmap.tsx`(规划日位于第 105 行):`willEmit('book')` 特判上方的 `// 如果按键不能触发怪物手册,则关闭界面,因为怪物手册和退出默认使用同一个按键,需要特判`(**上面引号内为该行全文,逐字如此**;删除该特判时随之消失;执行期行号可能移动,按这段文本定位,不要按行号定位)。 + 除这两条之外,**不得删除或改写任何既有注释 / jsDoc**(只允许新增)。 + + ⑮ **barrel↔文件环的处理方式(请确认):** 见 ⑩ ⑦ 与 OI-2 ⑦。按检查器要求,被再导出的 `inputBox.tsx` / `waitBox.tsx` / `choices.tsx` 已改从**具体模块**导入(相对路径),`@user/client-base/<子路径>` 别名在现有 tsconfig `paths` 下解析不到,故用相对路径。这样去掉了这三个文件对根 barrel 的**直接**回引;`client-base` **内部**既有的 `textbox` → `elements` → `map` 与 `types.ts` → `./map` 回引早于本计划存在,属公共导出面 / tsconfig 级问题,本计划不改它、也**不声称 `pnpm check:circular` 全绿**。若你要求连内部环一起消除,需另行批准(扩展 tsconfig `paths` / 重排公共导出面 / 先解 `types.ts` → `map` 回引)。 + + + 逐条核对 ①-⑮ 是否与你的意图一致,特别是:①搬迁范围是否恰好是那三个组件、`Input` / `Selection` / `Progress` / `ScrollText` / `Arrow` / `routedInput*` 是否留在原处;②`background.tsx` 改 `@/client/uiConstant` 是否可接受;③`settings` 用 `freshScope={false}` + `state={props.state!}` 让子作用域继承父级 `@common/exit` 是否正确;④`viewmap` 删掉 `willEmit('book')` 特判、改由 `@ui/view-map-book` 的 `block: true` 阻断是否可接受;⑤**`save` 翻页方向已按你的决策采用新定义**(prev=-1 / next=+1 / 十页 ±10),此处只复核落地是否无误;⑥5 个重命名 ID 与 2 个新文件名(**已批准**),以及**待你最终批准**的新公共命名 `@ui/input-confirm` / 新分组 `@ui/input`(分组名 `输入框`);⑦`use/hotkey.ts` 改 `onBeforeMount`;⑧「不做浏览器 UAT / 不提交代码」;⑨`09-27-BASELINE.json` 这一件新增基线产物(**由 Task 1 在获批后、改代码前写出**;basename schema 不复用,`hashes` 用完整仓库相对路径;其 `typecheck` 数组是唯一真值,本计划不写死任何诊断条数;`addCommandCount` 执行后须为基线 + 1);⑩`settings.tsx` 用 `props.state!` 而不是改 `MainSettingsProps` 的 extends 形态;⑪⑥-2 的两份已批准口径(十页按住重复、输入框改用只绑 Enter 的 `@ui/input-confirm` 且不复用 `@common/confirm`);⑫哈希闸门用「基线 head 的祖先关系 + 白名单 diff」,不要求 HEAD 字面相等;⑬**Task 0 不写任何文件**(基线移到 Task 1 第一步);⑭**两条随删除代码一起消失的既有注释**(`input.tsx` 的回车特判注释、`viewmap.tsx` 的 `willEmit` 特判注释)是否允许删除;⑮**barrel↔文件环的处理方式**(⑩ ⑦ / OI-2 ⑦):三个被再导出的文件改为具体模块相对路径导入、不再直接回引 `@user/client-base`;`client-base` 内部既有的 `types.ts` → `./map` → `index` 环不在本计划范围(`pnpm check:circular` 不在批准验证集合内),请确认接受这个边界。 + + 仅当完全同意时回复恰好四个字「可以执行」;任何修改意见(**尤其是 `@ui/input-confirm` / `@ui/input` 的最终命名**)请直接说明,本计划会据此改写后再来一次汇报。 + + + Task 1: 拆分 `InputBox` / `WaitBox` / `Background` 到 `src/client/ui`,改 barrel 再导出,`background.tsx` 改用 `@/client/uiConstant` + .planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json(新增,第一步写出), src/client/ui/inputBox.tsx(新增), src/client/ui/waitBox.tsx(新增), src/client/ui/background.tsx, src/client/ui/choices.tsx, packages-user/client-base/src/components/input.tsx, packages-user/client-base/src/components/misc.tsx, packages-user/client-base/src/components/index.ts + + **依赖 Task 0 已收到「可以执行」。** **第一步(必须在任何代码编辑之前):** 按 Task 0 ⑫ 的 schema 写出 `.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json`(`head` / 5 个完整路径哈希 / `addCommandCount` / `eslint` 基线 / `typecheck` 基线),写出后不得再重写——基线必须是「改动前」的真值,否则 Task 5 的前后比较失效。**采集 eslint 基线时必须容忍 `status === 1`**:基线集合里已含 severity-2 条目,`pnpm exec eslint -f json` 会以 `status === 1` 退出,这属正常——照常解析 stdout 写入 `eslint` 数组,**不得**把非零退出当成「无基线」或写入空数组。**第二步起**,本任务只做「定义搬迁 + 导入清理 + barrel 再导出」,**不做命令 ID 替换**(那是 Task 2;`choices.tsx` 在本任务只改 `Background` 的导入来源)。 + + **A. 新建 `src/client/ui/inputBox.tsx`。** 从 `components/input.tsx` 把 `InputBoxProps`、`InputBoxEmits`、`inputBoxProps`、`InputBox`、`getInput`、`getInputNumber`、`InputBoxUI` **连同其 jsDoc 一字不改地搬过来**(既有注释不得改写)。四处必要调整: + 1. `InputBoxProps` 改为 `extends IGameUIPropsBase, TextContentProps`(`IGameUIPropsBase` 从 `../../../packages-user/client-base/src/types` 导入,它自带 `controller` / `instance` / `readonly state`)。 + 2. `inputBoxProps.props` 数组补 `'state'`(其余项顺序不变:`loc` / `input` / `winskin` / `color` / `border` / `pad` / `inputHeight` / `text` / `yesText` / `noText` / `selFont` / `selFill` / `width`)。 + 3. 新增新系统按键实现(本计划唯一一处「新增行为」,命令 ID 为 `@ui/input-confirm`,声明见 Task 3 E): + `const key = useKey(props.state.hotkey.system);` + `key?.implement('@ui/input-confirm', () => { const active = document.activeElement; if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) active.blur(); });` + **不要**写成 `@common/confirm`:后者绑的是 Enter + Space + KeyC,而用户要求输入框**只认 Enter**;本条只由 `@ui/input-confirm` 的 `[[KeyCode.Enter]]` 触发。 + 用「判断 `document.activeElement` 是否为 input / textarea 再 blur」的方式,因为输入元素是 `Input` 组件动态创建并挂到 `document.body` 上的,`InputBox` 侧没有它的引用。 + 4. **`getInput` 里的 `onConfirm` 补显式参数注解**:搬入后写成 `onConfirm: (value: string) => {`(其余一字不改,`res(value)` 不变)。旧文件此处已存在既有的 `TS7006: Parameter 'value' implicitly has an 'any' type`(具体行号以 `09-27-BASELINE.json` 实测为准,本计划不写死行号);不补注解会把这条诊断原样搬进新文件,被类型闸门判成 NEW。这是纯类型标注,无行为变化。 + - 导入来源:`ElementLocator` / `Font` 来自 `@motajs/render`;`GameUI` / **`IUIController`** / `SetupComponentOptions` 来自 `@motajs/system`(`IUIController` **不在** `@user/client-base` 的导出面里,不得从 barrel 取);`computed` / `defineComponent` / `ref` 来自 `vue`;`Background` 来自 `./background`;`Input` / `InputProps` / `InputEmits` 来自 `../../../packages-user/client-base/src/components/input`;`Selection` 来自 `.../components/misc`;`TextContent` / `TextContentProps` 来自 `.../components/textbox`;`IGameUIPropsBase` 来自 `.../src/types`;`useKey` 来自 `.../src/use`(**全部走具体模块,不得从 `@user/client-base` barrel 导入**)。**如实记录**:`components/index.ts` 跨目录再导出本文件,因此本文件不得回引根 barrel;`client-base` 内部既有的 `types.ts` → `./map` → `@user/client-base` 回引早于本计划存在,不在本计划批准范围(见 Task 0 汇报 ⑮ 与 OI-2 ⑦),本计划不声称 `pnpm check:circular` 全绿。 + - **不得**在本文件出现 `from '@/client'`。 + + **B. 新建 `src/client/ui/waitBox.tsx`。** 同样把 `WaitBoxProps`、`WaitBoxEmits`、`WaitBoxExpose`、`waitBoxProps`、`WaitBox`、`waitbox`、`WaitBoxUI` 连 jsDoc 原样搬入。 + - **必须** `import { Background, BackgroundProps } from './background';` —— `WaitBoxProps extends Partial`,少了 `BackgroundProps` 直接编译失败。 + - **`waitbox` 里的 `onResolve` 也必须补显式参数注解**:写成 `onResolve: (data: T) => {`(`res(data as T)` 原样保留,不改行为)。旧文件此处已存在既有的 `TS7006: Parameter 'data' implicitly has an 'any' type`(具体行号以 `09-27-BASELINE.json` 实测为准,本计划不写死行号);不补注解会把这条诊断原样搬进新文件,被类型闸门判成 NEW。 + - 其余导入:`logger` 来自 `@motajs/common`(`WaitBox` 的 reject 分支用 `logger.warn(63, reason)`);`ElementLocator` 来自 `@motajs/render`;`GameUI` / **`IUIController`** / `SetupComponentOptions` 来自 `@motajs/system`(`IUIController` **不是** `@user/client-base` 的导出,`misc.tsx:13` 正是从 `@motajs/system` 导入它);`computed` / `defineComponent` / `ref` / `SetupContext` 来自 `vue`;`TextContent` / `TextContentProps` 来自 `../../../packages-user/client-base/src/components/textbox`;`IGameUIPropsBase` 来自 `.../src/types`(**具体模块,不得从 `@user/client-base` barrel 导入**)。 + - `WaitBox` 不需要按键实现(它与本计划的热键迁移无关),**不要**给它加 `useKey`。 + - **不得**在本文件出现 `from '@/client'`。 + + **C. 扩展 `src/client/ui/background.tsx`。** 追加 `BackgroundProps`、`backgroundProps`、`Background`(含原 jsDoc 与三段 `//` 示例注释)、`BackgroundUI`;保留既有 `MainBackgroundProps` / `mainBackgroundProps` / `MainBackground` / `MainBackgroundUI` 一字不动。 + - **必须**把既有 `import { MAIN_HEIGHT, MAIN_WIDTH } from '@/client';` 改为 `import { MAIN_HEIGHT, MAIN_WIDTH } from '@/client/uiConstant';` —— 本文件会被 `client-base` barrel 再导出,`@/client` 会把 `entry` / `initialize` 整条应用入口拉进包图。 + - 补充导入:`computed` 加入 `vue` 的导入(`Background` 用到 `computed`);`ElementLocator` 从 `@motajs/render` 导入。 + - `Background` 内部使用的 `CanvasStyle` / `ImageIds` / `g-rectr` / `winskin` 是工程内的全局类型与全局组件,无需导入,保持原样。 + + **D. 裁剪 `packages-user/client-base/src/components/input.tsx`。** 删掉 `InputBoxProps` / `InputBoxEmits` / `inputBoxProps` / `InputBox` / `getInput` / `getInputNumber` / `InputBoxUI` 全部定义(含其 jsDoc)。保留 `InputProps` / `InputEmits` / `inputProps` / `Input` / `routedInput` / `routedInputNumber`。 + - **同时删掉 `Input` 内部的旧按键处理**:`const [key] = useKey();` 与 `key.realize('confirm', (_, code) => { if (code === KeyCode.Enter) ele?.blur(); });` 整段移除(该职责已移到 `inputBox.tsx` 的新系统实现)。该块内含既有注释 `// 特判回车键`(规划日位于该文件第 227 行,执行期按文本定位),会随整块一起消失——**这是 Task 0 ⑭ 已单独列出、需用户显式批准的注释删除**;除它之外不得删除或改写任何既有注释。 + - **清理因此失效的导入**:从 `import { transitionedColor, useKey } from '../use';` 中删除 `useKey`(**保留 `transitionedColor`,并保留它上方那条 `// @ts-expect-error ...` 注释原文不动**);删除 `KeyCode` 导入;删除 `import { Background, Selection } from './misc';` 整行;删除 `IGameUIPropsBase` 导入;把 `import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';` 收窄为只导入 `SetupComponentOptions`。 + - 若类型闸门报出 `TS2578: Unused '@ts-expect-error' directive`(说明移除 `useKey` 后该行不再报错),**不得删除该注释**(AGENTS.md 禁止改动既有注释),按「偏差」停下上报,不要自行改写。 + + **E. 裁剪 `packages-user/client-base/src/components/misc.tsx`。** 删掉 `BackgroundProps` / `backgroundProps` / `Background` / `BackgroundUI` / `WaitBoxProps` / `WaitBoxEmits` / `WaitBoxExpose` / `waitBoxProps` / `WaitBox` / `waitbox` / `WaitBoxUI` 全部定义(含 jsDoc)。 + - **清理因此失效的导入**:`logger`(仅 `WaitBox` 用过)、`SetupContext`(仅 `WaitBox` 用过)、`IGameUIPropsBase`(仅 `waitbox` 用过)、`IUIController`(仅 `waitbox` 用过)、`GameUI`(仅两个 UI 实例用过)—— 把 `import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';` 收窄为只导入 `SetupComponentOptions`。保留 `SetupComponentOptions`(其余组件仍在用)、`TextContentProps`(`ScrollTextProps` 仍在用)、`DefaultProps`、`computed` / `ref` / `watch` / `defineComponent` 等仍在用的导入。 + + **F. `packages-user/client-base/src/components/index.ts` 追加三条再导出。** 在**既有第 1 行** `export * from '../../../../src/client/ui/choices';` 之后紧接追加: + `export * from '../../../../src/client/ui/background';` + `export * from '../../../../src/client/ui/inputBox';` + `export * from '../../../../src/client/ui/waitBox';` + 既有 `choices` 行不改、其余 `./*` 行不改;**不得**使用命名再导出,保持 `export *` 与目录内既有模式一致。 + + **G. `src/client/ui/choices.tsx` 改导入来源(具体模块,消除对 barrel 的直接回引)。** 把现有三条来自 `@user/client-base` 的 import 改写为: + - `import { Selection } from '../../../packages-user/client-base/src/components/misc';` + - `import { TextContent, TextContentProps } from '../../../packages-user/client-base/src/components/textbox';` + - `import { TextAlign } from '../../../packages-user/client-base/src/components/textboxTyper';` + - `import { Page, PageExpose } from '../../../packages-user/client-base/src/components/page';` + - `import { IGameUIPropsBase } from '../../../packages-user/client-base/src/types';` + - `import { useKey } from '../../../packages-user/client-base/src/use';` + - `import { Background } from './background';` + 本任务**不改**其它任何内容(`state` 补项与命令 ID 在 Task 2 处理)。执行后 `choices.tsx` 不得出现 `from '@user/client-base'`。 + + **H. 注释纪律。** 只新增注释,不改既有注释与 jsDoc;新增注释遵循 dev.md(单行 `//`、`//` 后留空格、说明「为什么」而不是「重复下一行代码」)。 + + + node -e "const cp=require('child_process'),fs=require('fs');const bad=[];const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';const files=['src/client/ui/inputBox.tsx','src/client/ui/waitBox.tsx','src/client/ui/background.tsx','src/client/ui/choices.tsx','packages-user/client-base/src/components/index.ts','packages-user/client-base/src/components/input.tsx','packages-user/client-base/src/components/misc.tsx'];const b=fs.existsSync(bp)?JSON.parse(fs.readFileSync(bp,'utf8')):{eslint:[]};const r=cp.spawnSync('cmd',['/c','pnpm exec eslint -f json '+files.join(' ')],{encoding:'utf8',timeout:300000});const baseHasErr=(b.eslint||[]).some(x=>String(x).split('|')[2]==='2');if(r.status===null||r.status>=2||(r.status!==0&&!(r.status===1&&baseHasErr)))bad.push('eslint failed to run (status '+r.status+', baselineHasErrors='+baseHasErr+')');if(!(r.stdout||'').trim())bad.push('eslint produced empty output');let arr=[];try{arr=JSON.parse((r.stdout||'').trim()||'[]')}catch(e){bad.push('eslint json unparsable')}const cur=[];for(const f of arr){const rel=String(f.filePath||'').replace(/\\/g,'/').replace(/^.*?(?=(src|packages-user)\/)/,'');for(const m of (f.messages||[]))cur.push(rel+'|'+(m.ruleId||'null')+'|'+m.severity+'|'+m.message)}const before=b.eslint||[];const cnt=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(cnt(cur,v)>cnt(before,v))bad.push('NEW eslint entry :: '+v)}if(bad.length){console.error('ESLINT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task1 eslint gate: no new entries (baseline '+before.length+' -> current '+cur.length+')')" + 出现任何不在 `09-27-BASELINE.json` 的 `eslint` 基线内的新 error / warning(含未使用导入、`no-unused-vars`);或 eslint 未能正常运行(`status === null` / `status >= 2`,或 `status === 1` 但基线无 severity=2 条目)、stdout 为空、JSON 不可解析;或任一 node 闸门非零退出。 + node -e "const fs=require('fs');const bad=[];const R=(p)=>fs.readFileSync(p,'utf8');const strip=(t)=>t.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g,'').replace(/\/\*[\s\S]*?\*\//g,'').split(/\r?\n/).map(l=>l.replace(/\/\/.*$/,'')).join('\n');const has=(p,s)=>{if(!fs.existsSync(p))return false;return strip(R(p)).indexOf(s)>=0};const ib='src/client/ui/inputBox.tsx',wb='src/client/ui/waitBox.tsx',bg='src/client/ui/background.tsx',ix='packages-user/client-base/src/components/input.tsx',mx='packages-user/client-base/src/components/misc.tsx',idx='packages-user/client-base/src/components/index.ts';if(!fs.existsSync(ib))bad.push('missing inputBox.tsx');if(!fs.existsSync(wb))bad.push('missing waitBox.tsx');for(const s of ['export interface InputBoxProps','export type InputBoxEmits','export const InputBox','export function getInput(','export async function getInputNumber(','export const InputBoxUI',\"'state'\",'@ui/input-confirm','useKey('])if(!has(ib,s))bad.push('inputBox missing: '+s);for(const s of ['export interface WaitBoxProps','export type WaitBoxEmits','export interface WaitBoxExpose','export const WaitBox','export function waitbox','export const WaitBoxUI','BackgroundProps','./background'])if(!has(wb,s))bad.push('waitBox missing: '+s);for(const s of ['export interface BackgroundProps','export const Background','export const BackgroundUI','MainBackground','MainBackgroundUI','@/client/uiConstant'])if(!has(bg,s))bad.push('background missing: '+s);if(strip(R(bg)).indexOf(\"from '@/client'\")>=0)bad.push('background.tsx still imports @/client');const barrel=R(idx);for(const s of ['ui/background','ui/inputBox','ui/waitBox','ui/choices'])if(barrel.indexOf(s)<0)bad.push('barrel missing re-export: '+s);for(const s of ['export interface InputBoxProps','export const InputBox','export function getInput(','export const InputBoxUI','InputBoxProps','useKey(','KeyCode'])if(has(ix,s))bad.push('input.tsx still has: '+s);if(!has(ix,'export const Input'))bad.push('input.tsx lost Input');if(!has(ix,'routedInputNumber'))bad.push('input.tsx lost routedInputNumber');for(const s of ['export interface BackgroundProps','export const Background','export interface WaitBoxProps','export const WaitBox','export function waitbox','export const WaitBoxUI','export const BackgroundUI','logger.'])if(has(mx,s))bad.push('misc.tsx still has: '+s);for(const s of ['export const Selection','export const ScrollText','export const Arrow','export const Progress'])if(!has(mx,s))bad.push('misc.tsx lost: '+s);if(strip(R('src/client/ui/choices.tsx')).indexOf(\"from './background'\")<0)bad.push('choices.tsx does not import Background from ./background');for(const f of [ib,wb,'src/client/ui/choices.tsx']){const src=strip(R(f));if(src.indexOf(\"from '@user/client-base'\")>=0)bad.push(f+' still imports the @user/client-base barrel');if(src.indexOf('packages-user/client-base/src/')<0)bad.push(f+' does not use explicit client-base module paths')}if(bad.length){console.error('SPLIT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK split gate: definitions moved, old homes trimmed, barrel re-exports in place, background.tsx uses @/client/uiConstant')" + node -e "const fs=require('fs');const bad=[];const ib=fs.readFileSync('src/client/ui/inputBox.tsx','utf8'),wb=fs.readFileSync('src/client/ui/waitBox.tsx','utf8');if(ib.indexOf('onConfirm: (value: string)')<0)bad.push('inputBox getInput onConfirm lacks (value: string)');if(wb.indexOf('onResolve: (data: T)')<0)bad.push('waitBox onResolve lacks (data: T)');if(bad.length){console.error('MOVE ANNOTATION GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK move annotation gate: both moved callbacks carry explicit param types')" + node -e "const cp=require('child_process');const bad=[];const r=cp.spawnSync('cmd',['/c','pnpm check:type'],{encoding:'utf8',timeout:600000});const text=(r.stdout||'')+(r.stderr||'');const diag=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));if(r.status===null)bad.push('vue-tsc did not run (status null / killed by signal)');else if(r.status!==0&&diag.length===0)bad.push('vue-tsc exited '+r.status+' with no parseable error TS output (crash, not diagnostics)');const lines=diag.map(l=>l.replace(/\\/g,'/'));const scope=['src/client/ui/inputBox.tsx','src/client/ui/waitBox.tsx'];const hits=lines.filter(l=>scope.some(p=>l.indexOf(p)>=0));if(hits.length)bad.push('moved-file diagnostics (fix them, or stop and report — never loosen this gate): '+hits.join(' ; '));if(bad.length){console.error('MOVED TYPECHECK GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK moved typecheck gate: zero diagnostics in src/client/ui/inputBox.tsx and src/client/ui/waitBox.tsx')" + node -e "const fs=require('fs');const bad=[];const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';if(!fs.existsSync(bp))bad.push('baseline missing');else{const b=JSON.parse(fs.readFileSync(bp,'utf8'));for(const k of ['head','hashes','eslint','typecheck','addCommandCount'])if(!(k in b))bad.push('baseline missing field: '+k);if(Object.keys(b.hashes||{}).length!==5)bad.push('baseline hashes must have 5 entries')}for(const f of ['src/client/ui/inputBox.tsx','src/client/ui/waitBox.tsx']){if(!fs.existsSync(f)){bad.push('missing '+f);continue}const s=fs.readFileSync(f,'utf8');const stmts=s.match(/import[\s\S]*?from\s+'[^']+';/g)||[];if(!stmts.some(x=>x.indexOf('@motajs/system')>=0&&x.indexOf('IUIController')>=0))bad.push(f+': IUIController is not imported from @motajs/system');if(stmts.some(x=>x.indexOf('@user/client-base')>=0&&x.indexOf('IUIController')>=0))bad.push(f+': IUIController must not be imported from @user/client-base')}if(bad.length){console.error('BASELINE/IMPORT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK baseline + IUIController import gate')" + 新文件缺任一导出、`waitBox.tsx` 未导入 `BackgroundProps`、`background.tsx` 仍从 `@/client` 导入、barrel 少任一再导出、`input.tsx` / `misc.tsx` 仍有残留定义或残留 `useKey()` / `KeyCode` / `logger.`、`misc.tsx` 丢了通用组件、`choices.tsx` 未改为本地 `Background`、`getInput` 的 `onConfirm` 或 `waitbox` 的 `onResolve` 缺少显式参数注解、`09-27-BASELINE.json` 未在改代码前写出或字段残缺、`IUIController` 不是从 `@motajs/system` 导入(被错写成 `@user/client-base`)、`inputBox.tsx` / `waitBox.tsx` / `choices.tsx` 仍从 `@user/client-base` barrel 导入(必须走具体模块相对路径)、搬迁后 `src/client/ui/inputBox.tsx` / `src/client/ui/waitBox.tsx` 出现任何 `error TSxxxx` 诊断(Task 1 必须当场修掉,或停下上报,**不得**放宽或跳过这条闸门);`vue-tsc` 的进程状态判据只认「崩溃」:只有 `status === null`(未运行 / 被信号终止),或**非零退出但输出里没有任何可解析的 `error TS` 行**,才判失败——`status` 为 `1` 或 `2` 且输出含可解析 `error TS` 行属**预期状态**(全仓既有诊断所致;本计划只判 NEW,见 Task 5),不判失败;或任一 node 闸门非零退出。 + + 获批后的第一步已写出 `.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json`(`head` / 5 个完整路径哈希 / `addCommandCount` / `eslint` / `typecheck`,此后未重写);三个 GameUI 组件已按「定义」迁移到 `src/client/ui/{inputBox,waitBox,background}.tsx`;`inputBox.tsx` / `waitBox.tsx` 的 `IUIController` 均来自 `@motajs/system`(不是 `@user/client-base`);`inputBox.tsx` 用新 `useKey` 实现 `@ui/input-confirm`(只绑 Enter、不复用 `@common/confirm`)并 blur 当前输入元素;`waitBox.tsx` 从 `./background` 导入 `BackgroundProps`;`background.tsx` 保留 `MainBackground` 并改用 `@/client/uiConstant`;`input.tsx` / `misc.tsx` 已裁掉对应定义与失效导入且保留全部非 GameUI 组件;barrel 已追加三条 `export *`;`choices.tsx` 从 `./background` 导入 `Background`,其余 client-base 依赖走具体模块(文件无 `from '@user/client-base'`);`getInput` 的 `onConfirm` 与 `waitbox` 的 `onResolve` 已带显式参数注解;搬迁后 `pnpm check:type` 在 `src/client/ui/inputBox.tsx` / `src/client/ui/waitBox.tsx` 上为**零诊断**(既有 `TS7006` 已随显式注解消失、未以任何新诊断形式重现;`vue-tsc` 因全仓既有诊断而非零退出属预期,不作为失败依据);eslint 无新问题。 + + + Task 2: 五个 UI 文件改接新热键系统(choices / save / settings / title / viewmap),并把 `use/hotkey.ts` 的入栈时机改为 `onBeforeMount` + src/client/ui/choices.tsx, src/client/ui/save.tsx, src/client/ui/settings.tsx, src/client/ui/title.tsx, src/client/ui/viewmap.tsx, packages-user/client-base/src/use/hotkey.ts + + **依赖 Task 1 已完成**(`inputBox.tsx` 已用新系统,本任务不碰它)。 + + **统一形态:** `import { useKey } from '@user/client-base';`(`choices.tsx` 例外:它的 `useKey` 已在 Task 1 G 从具体模块导入,本任务不再改该 import)+ `const key = useKey(props.state.hotkey.system, )!;` + `key.implement('<新 ID>', fn)`(可链式)。新 API 的 `implement` 返回 `this`;`useKey` 可能返回 `null`,用 `!` 断言与 `choices.tsx` 现有写法一致。**唯一例外是 `settings.tsx`**:它的 `state` 在 `MainSettingsProps` 里是可选的,必须写成 `useKey(props.state!.hotkey.system)!`(见下面 C)。**旧 API 的第三个参数 `{ type: 'down-repeat' | 'down' }` 全部删除**——触发方式已在声明层(`mode` / `interval` / `timeout`)定义。 + + **A. `src/client/ui/choices.tsx`。** + 1. `confirmBoxProps.props` 与 `choicesProps.props` 各补一项 `'state'`(追加在数组末尾)。 + 2. 5 条命令改名(handler 体一字不改): + - `moveUp` → `@common/select-up` + - `moveDown` → `@common/select-down` + - `moveLeft` → `@common/select-left` + - `moveRight` → `@common/select-right` + - `confirm` → `@common/confirm` + 3. 不改 `useKey(props.state.hotkey.system, props.freshScope ?? true)`(`ConfirmBox`)与 `useKey(props.state.hotkey.system, props.freshScope)`(`Choices`)这两个现成的调用。 + + **B. `src/client/ui/save.tsx`。** + 1. `saveProps.props` 补 `'state'`(其余项顺序不变)。 + 2. `import { getSave, IUIPropsBase, SaveData, useKey } from '@user/client-modules';` 收窄为 `import { getSave, IUIPropsBase, SaveData } from '@user/client-modules';`,并新增 `useKey` 到既有 `@user/client-base` 的导入里。 + 3. 把 `const [key] = useKey(); key.realize(...).realize(...)...` 整段改写成 `const key = useKey(props.state.hotkey.system)!;` + 逐个 `key.implement('<新 ID>', <原 handler>)`。**12 条映射(顺序与 handler 体保持原样):** + + | 旧 ID | 新 ID | handler 说明 | + |-------|-------|--------------| + | `confirm` | `@common/confirm` | 原样(`emitSave(-1)` 或按 `grid` 计算索引) | + | `exit` | `@common/exit` | 原样(`exit()`) | + | `save` | `@ui/save` | 原样(`props.mode === SaveMode.Save` 才 `exit()`) | + | `load` | `@ui/load` | 原样(`props.mode === SaveMode.Load` 才 `exit()`) | + | `@save_up` | `@ui/save-up` | 原样(按 `cols` 上移一行的全部逻辑) | + | `@save_down` | `@ui/save-down` | 原样(按 `cols` 下移一行) | + | `@save_left` | `@ui/save-left` | 原样(左移 / 跨页回退) | + | `@save_right` | `@ui/save-right` | 原样(右移 / 跨页前进) | + | `@save_pageUp` | `@ui/save-page-prev` | **方向变更**:`pageRef.value?.movePage(-1)` | + | `@save_pageDown` | `@ui/save-page-next` | **方向变更**:`pageRef.value?.movePage(1)` | + | (无) | `@ui/save-page-prev-10` | 新增:`pageRef.value?.movePage(-10)` | + | (无) | `@ui/save-page-next-10` | 新增:`pageRef.value?.movePage(10)` | + + 4. `#region 按键实现` 的 `// todo: 按住快速切换页码` 注释与 `wheel` 处理**不动**。 + + **C. `src/client/ui/settings.tsx`。** 7 个组件(`MainSettings` / `ReplaySettings` / `GameInfo` / `SyncSave` / `SyncSaveSelect` / `DownloadSaveSelect` / `ClearSaveSelect`)逐个改: + 1. `const [key, scope] = useKey(); key.realize('exit', () => props.controller.close(props.instance));` → `const key = useKey(props.state!.hotkey.system)!; key.implement('@common/exit', () => props.controller.close(props.instance));`(**局部 `scope` 变量全部删除**;`props.state!` 的 `!` 不可省,见下方 C.5)。 + 2. 7 处 ``:删除 `scope={scope}` 一行,改为 `freshScope={false}` 与 `state={props.state!}` 两行(与既有 `loc` / `choices` / `width` / `onChoose` / `maxHeight` / `interval` 并列,位置放在原 `scope` 那一行处,行序即可)。`state={props.state!}` 的 `!` 是为了满足 `ChoicesProps.state` 必填、同时避免 `TS2322`。 + 3. 导入:`import { IUIPropsBase, useKey } from '@user/client-modules';` 收窄为 `import { IUIPropsBase } from '@user/client-modules';`,`useKey` 加入 `@user/client-base` 导入。 + 4. **VirtualKey 分支(`getVitualKeyOnce().then(... gameKey.emitKey(...))`)、`gameKey` / `generateKeyboardEvent` 导入、以及 `props` 数组(已含 `'state'`)全部原样保留**——该分支是显式遗留,不在本计划范围。 + + 5. **为什么是 `props.state!` 而不是改 interface:** `MainSettingsProps extends Partial, IUIPropsBase` 让 `state` 变成可选,于是新增的 7 处 `props.state.hotkey` 会各报一条 `TS18048`、`state={props.state}` 会报 `TS2322`,Task 5 的类型闸门会确定性判失败。本计划选定「读取点与传参点加 `!`」这一种机制:`useKey(props.state!.hotkey.system)!` 与 `state={props.state!}`。**不要**改成 `Omit, 'controller' | 'instance' | 'state'>`(那会改变 `MainSettingsProps` 这个公共 props 类型,超出本次批准范围),也不要改 `mainSettingsProps.props` 数组。改完后 `settings.tsx` 的既有诊断**不得新增**任何条目——既有条目数一律以 `09-27-BASELINE.json` 的实测 `typecheck` 数组为准,本计划不断言条数;基线中存在、改后消失的条目允许。 + + **D. `src/client/ui/title.tsx`。** + 1. 从 `@user/client-modules` 的导入里移除 `useKey`(保留 `ITransitionedController` / `IUIPropsBase` / `transitioned` / `transitionedColor`),`useKey` 加入 `@user/client-base` 导入。 + 2. `const [key] = useKey();` → `const key = useKey(props.state.hotkey.system)!;`;删除 `{ type: 'down' }` 选项。 + 3. 3 条映射(handler 体不变):`@start_up` → `@ui/start-up`、`@start_down` → `@ui/start-down`、`confirm` → `@common/confirm`。 + 4. `gameTitleProps` 已含 `'state'`,不动。 + + **E. `src/client/ui/viewmap.tsx`。** + 1. 导入:`import { useKey } from '@user/client-modules';` 整行删除(该文件只从这里取 `useKey`),`useKey` 加入 `@user/client-base` 导入(与 `FloorSelector` / `Tip` / `TipExpose` / `IGameUIPropsBase` 同一个 import)。 + 2. `ViewMapProps` 由 `extends IUIDefaultPropsBase, BaseProps` 改为 `extends IGameUIPropsBase, BaseProps`;`@motajs/system` 的导入里删除 `IUIDefaultPropsBase`;`viewMapProps.props` 补 `'state'`(变成 `['loc', 'floorId', 'controller', 'instance', 'state']`)。 + 3. 按键:`const [key] = useKey();` → `const key = useKey(props.state.hotkey.system)!;`,删除所有 `{ type: 'down-repeat' }` 选项,8 条映射(handler 体不变): + - `@viewMap_up` → `@ui/view-map-next` + - `@viewMap_down` → `@ui/view-map-prev` + - `@viewMap_up_ten` → `@ui/view-map-next-10` + - `@viewMap_down_ten` → `@ui/view-map-prev-10` + - `@viewMap_book` → `@ui/view-map-book` + - `@viewMap_fly` → `@ui/view-map-fly` + - `confirm` → `@common/confirm` + - `exit` → `@common/exit`,**handler 改为直接 `() => props.controller.close(props.instance)`**(删除 `(_, code, assist)` 参数与 `key.willEmit(code, assist, 'book')` 特判)。删除该特判时,其上方既有注释(全文逐字为 `// 如果按键不能触发怪物手册,则关闭界面,因为怪物手册和退出默认使用同一个按键,需要特判`,与 Task 0 ⑭ 引用的同一行)会一并消失——**这是 Task 0 ⑭ 已单独列出、需用户显式批准的注释删除**;同文件的 `// .realize('@viewMap_reset', ...)` 那行注释仍原样保留。 + 4. 被注释掉的 `// .realize('@viewMap_reset', () => resetCamera())` 一行:**原样保留不动**(既有注释不得修改)。闸门在检查 `.realize(` / `willEmit(` / `useKey()` 之前会先剔除以 `//` 开头的行,因此这行注释不会误伤。 + + **F. `packages-user/client-base/src/use/hotkey.ts`。** + 1. 把进入作用域的回调从 `onMounted(() => { ... })` 改为 `onBeforeMount(() => { ... })`(方法体一字不改:置 `mounted = true`、`enterScope(clearImpl)`、`resolve(scope)`、注册已缓存的 `impl`、清空缓存)。 + 2. 离开作用域仍用 `onUnmounted`;`scope()` / `implement()` 不变;**字段名 `mounted` / `keyScope` / `impl` / `scopePromise` 一律不改**(避免新增未批准命名)。 + 3. `vue` 导入由 `{ getCurrentInstance, onMounted, onUnmounted }` 改为 `{ getCurrentInstance, onBeforeMount, onUnmounted }`。 + + **G. 注释纪律。** 只新增注释,不改既有注释;新增注释用「为什么」口径(例如为什么 `settings` 要 `freshScope={false}`、为什么 `viewmap` 不再需要 `willEmit` 特判)。本计划的闸门在判定前会先整体抹掉 `/* ... */` 与 `{/* ... */}`(含跨行),再按行剔除 `//` 之后的所有内容(**整行注释与代码行尾注释都会被剔除**,见 Task 1 / Task 2 的 `strip` 与 Task 4 的 `stripLineComments`),因此注释里提到受检字面量(`freshScope={false}`、`state={props.state!}`、`willEmit`、`.realize(`、零参 `useKey()`、命令 ID)**不会**让闸门假红。仍推荐把说明写成独立整行 `//` 注释、再另起一行写代码——因为注释剥离只用于「不得出现 / 计数」类断言,读到代码语义的人不该被行尾注释干扰。 + + + node -e "const cp=require('child_process'),fs=require('fs');const bad=[];const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';const files=['src/client/ui/choices.tsx','src/client/ui/save.tsx','src/client/ui/settings.tsx','src/client/ui/title.tsx','src/client/ui/viewmap.tsx','packages-user/client-base/src/use/hotkey.ts'];if(!fs.existsSync(bp))bad.push('baseline missing (Task 1 step 1)');const b=fs.existsSync(bp)?JSON.parse(fs.readFileSync(bp,'utf8')):{eslint:[]};const r=cp.spawnSync('cmd',['/c','pnpm exec eslint -f json '+files.join(' ')],{encoding:'utf8',timeout:300000});const baseHasErr=(b.eslint||[]).some(x=>String(x).split('|')[2]==='2');if(r.status===null||r.status>=2||(r.status!==0&&!(r.status===1&&baseHasErr)))bad.push('eslint failed to run (status '+r.status+', baselineHasErrors='+baseHasErr+'): '+((r.stderr||'').slice(0,200)));if(!(r.stdout||'').trim())bad.push('eslint produced empty output');let arr=[];try{arr=JSON.parse((r.stdout||'').trim()||'[]')}catch(e){bad.push('eslint json unparsable')}const cur=[];for(const f of arr){const rel=String(f.filePath||'').replace(/\\/g,'/').replace(/^.*?(?=(src|packages-user)\/)/,'');for(const m of (f.messages||[]))cur.push(rel+'|'+(m.ruleId||'null')+'|'+m.severity+'|'+m.message)}const before=b.eslint||[];const cnt=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(cnt(cur,v)>cnt(before,v))bad.push('NEW eslint entry :: '+v)}if(bad.length){console.error('ESLINT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK eslint gate: no new entries (baseline '+before.length+' -> current '+cur.length+'); status '+r.status+' accepted only because baselineHasErrors='+baseHasErr)" + 出现任何不在 `09-27-BASELINE.json` 的 `eslint` 基线内的新 error / warning;或 eslint 未能正常运行(`status === null` / `status >= 2`,或 `status === 1` 但基线中没有任何 severity=2 条目)、stdout 为空、JSON 不可解析。基线中已存在的 error 条目使 `status === 1` 成为预期结果,此时只按多重集判 NEW,不把 `status === 1` 本身判失败。 + node -e "const fs=require('fs');const bad=[];const R=(p)=>fs.readFileSync(p,'utf8');const strip=(s)=>s.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g,'').replace(/\/\*[\s\S]*?\*\//g,'').split(/\r?\n/).map(l=>l.replace(/\/\/.*$/,'')).join('\n');const ui=(f)=>strip(R('src/client/ui/'+f));const files=['choices.tsx','save.tsx','settings.tsx','title.tsx','viewmap.tsx','inputBox.tsx'];const oldIds=['moveUp','moveDown','moveLeft','moveRight','@save_up','@save_down','@save_left','@save_right','@save_pageUp','@save_pageDown','@viewMap_up','@viewMap_down','@viewMap_up_ten','@viewMap_down_ten','@viewMap_book','@viewMap_fly','@start_up','@start_down'];for(const f of files){const s=ui(f).split(/\r?\n/).filter(l=>l.trim().indexOf('//')!==0).join('\n');if(/useKey\(\s*\)/.test(s))bad.push(f+': zero-arg useKey()');if(s.indexOf('.realize(')>=0)bad.push(f+': old realize() remains');if(/willEmit\s*\(/.test(s))bad.push(f+': willEmit remains');const stmts=s.match(/import[\s\S]*?from\s+'[^']+';/g)||[];for(const st of stmts){if(st.indexOf('@user/client-modules')>=0&&st.indexOf('useKey')>=0)bad.push(f+': still imports useKey from @user/client-modules');}for(const id of oldIds){if(s.indexOf(\".implement('\"+id+\"'\")>=0)bad.push(f+': old id still implemented: '+id);}if(f==='save.tsx'){if(s.indexOf(\".implement('save'\")>=0||s.indexOf(\".implement('load'\")>=0)bad.push('save.tsx: old save/load ids still implemented')}}const mustImpl={ 'choices.tsx':['@common/select-up','@common/select-down','@common/select-left','@common/select-right','@common/confirm'], 'save.tsx':['@common/confirm','@common/exit','@ui/save','@ui/load','@ui/save-up','@ui/save-down','@ui/save-left','@ui/save-right','@ui/save-page-prev','@ui/save-page-next','@ui/save-page-prev-10','@ui/save-page-next-10'], 'settings.tsx':['@common/exit'], 'title.tsx':['@ui/start-up','@ui/start-down','@common/confirm'], 'viewmap.tsx':['@ui/view-map-next','@ui/view-map-prev','@ui/view-map-next-10','@ui/view-map-prev-10','@ui/view-map-book','@ui/view-map-fly','@common/confirm','@common/exit'], 'inputBox.tsx':['@ui/input-confirm'] };for(const f of Object.keys(mustImpl)){const s=ui(f);for(const id of mustImpl[f])if(s.indexOf(\".implement('\"+id+\"'\")<0)bad.push(f+' missing exact implement id: '+id)}const must={ 'choices.tsx':[\"'state'\"], 'settings.tsx':['freshScope={false}','state={props.state!}','getVitualKeyOnce'], 'viewmap.tsx':['IGameUIPropsBase'] };for(const f of Object.keys(must)){const s=ui(f);for(const t of must[f])if(s.indexOf(t)<0)bad.push(f+' missing: '+t)}const sv=ui('save.tsx');const impl=[...sv.matchAll(/\.implement\('([^']+)'/g)].map(m=>({id:m[1],i:m.index}));const pageDir={'@ui/save-page-prev':'movePage(-1)','@ui/save-page-next':'movePage(1)','@ui/save-page-prev-10':'movePage(-10)','@ui/save-page-next-10':'movePage(10)'};for(const id of Object.keys(pageDir)){const d=impl.find(x=>x.id===id);if(!d){bad.push('save.tsx missing implement: '+id);continue}const nx=impl.find(x=>x.i>d.i);const blk=sv.slice(d.i,nx?nx.i:sv.length);if(blk.indexOf(pageDir[id])<0)bad.push('save.tsx '+id+' body lacks '+pageDir[id]);for(const other of Object.keys(pageDir)){if(other!==id&&pageDir[other]!==pageDir[id]&&blk.indexOf(pageDir[other])>=0)bad.push('save.tsx '+id+' body wrongly contains '+pageDir[other])}}const st=ui('settings.tsx');const nC=(st.match(/])/g)||[]).length;const nF=(st.match(/freshScope=\{false\}/g)||[]).length;const nS=(st.match(/state=\{props\.state!\}/g)||[]).length;const nK=(st.match(/props\.state!\.hotkey\.system/g)||[]).length;if(nC!==7)bad.push('settings.tsx has '+nC+' , expected 7');if(nF!==nC)bad.push('settings.tsx freshScope={false} count '+nF+' != Choices count '+nC);if(nS!==nC)bad.push('settings.tsx state={props.state!} count '+nS+' != Choices count '+nC);if(st.indexOf('scope={scope}')>=0||/const\s*\[\s*key\s*,\s*scope\s*\]/.test(st))bad.push('settings.tsx still uses scope');if(nK!==7)bad.push('settings.tsx props.state!.hotkey.system count '+nK+' != 7');if(/props\.state\.hotkey\.system/.test(st))bad.push('settings.tsx still reads props.state.hotkey.system without !');if(ui('viewmap.tsx').indexOf(\"'state'\")<0)bad.push('viewmap.tsx props missing state');if(ui('save.tsx').indexOf(\"'state'\")<0)bad.push('save.tsx props missing state');const h=strip(R('packages-user/client-base/src/use/hotkey.ts'));if(h.indexOf('onBeforeMount(')<0)bad.push('use/hotkey.ts does not use onBeforeMount');if(h.indexOf('onMounted(')>=0)bad.push('use/hotkey.ts still uses onMounted');if(h.indexOf('onUnmounted(')<0)bad.push('use/hotkey.ts lost onUnmounted');if(ui('inputBox.tsx').indexOf('@common/confirm')>=0)bad.push('inputBox.tsx must not reuse @common/confirm (input judges Enter only)');if(bad.length){console.error('KEY GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK key gate: 5 UI files use the new useKey API with new ids, settings inherits parent scope, viewmap dropped willEmit, use/hotkey.ts enters on beforeMount')" + 任一新 UI 文件仍有零参 `useKey()` / `.realize(` / `willEmit(`、仍从 `@user/client-modules` 导入 `useKey`、仍 `.implement('旧 ID'`、缺任一新 ID(按 `.implement(''` 的**收尾引号**精确匹配,`@ui/save` 不得被 `@ui/save-page-prev` 满足、`@ui/view-map-next` 不得被 `@ui/view-map-next-10` 满足)、`settings.tsx` 的 JSX `])/g`,**不含** `Partial`) / `freshScope={false}` / `state={props.state!}` 三者数量不等或不等于 7、`props.state!.hotkey.system` 不是 7 处或仍有无 `!` 的读取、仍存在 `scope={scope}` 或 `[key, scope]`、`save.tsx` / `viewmap.tsx` 缺 `'state'`、`use/hotkey.ts` 未改成 `onBeforeMount`,或命令非零退出。 + node -e "const fs=require('fs');const bad=[];const strip=(s)=>s.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g,'').replace(/\/\*[\s\S]*?\*\//g,'').split(/\r?\n/).map(l=>l.replace(/\/\/.*$/,'')).join('\n');const cnt=(s,re)=>(s.match(re)||[]).length;for(const f of ['save.tsx','title.tsx','viewmap.tsx','choices.tsx']){const s=strip(fs.readFileSync('src/client/ui/'+f,'utf8'));const n=cnt(s,/gameKey/g);if(n!==0)bad.push(f+': legacy gameKey must not appear (found '+n+')')}const ib='src/client/ui/inputBox.tsx';if(!fs.existsSync(ib))bad.push('inputBox.tsx missing (Task 1 A)');else{const n=cnt(strip(fs.readFileSync(ib,'utf8')),/gameKey/g);if(n!==0)bad.push('inputBox.tsx: legacy gameKey must not appear (found '+n+')')}const st=strip(fs.readFileSync('src/client/ui/settings.tsx','utf8'));const total=cnt(st,/gameKey/g);if(total!==2)bad.push('settings.tsx: gameKey occurrences must stay 2 (1 legacy import + 1 gameKey.emitKey call), found '+total);if(cnt(st,/gameKey\.emitKey\(/g)!==1)bad.push('settings.tsx: gameKey.emitKey( must appear exactly once (VirtualKey branch)');const imp=(st.match(/import[\s\S]*?from\s+'[^']+';/g)||[]).filter(x=>x.indexOf('gameKey')>=0);if(imp.length!==1||imp[0].indexOf('@motajs/system')<0)bad.push('settings.tsx: gameKey import must remain exactly one line from @motajs/system');if(bad.length){console.error('GAMEKEY GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK gameKey gate: no new legacy gameKey usage (settings.tsx keeps 1 import + 1 emitKey)')" + 任一新 UI 文件出现 `gameKey`(`save.tsx` / `title.tsx` / `viewmap.tsx` / `inputBox.tsx` / `choices.tsx` 的 `gameKey` 出现次数必须为 0),或 `settings.tsx` 的 `gameKey` 引用形状改变(不再恰好是「1 条来自 `@motajs/system` 的导入 + 1 处 `gameKey.emitKey(`,合计出现 2 次),或命令非零退出。所有判定都先过与 Task 4 相同的注释剥离(块注释 / JSX 注释 / 行注释),说明性注释里的 `gameKey` 字面量不算引用。 + + 5 个 UI 文件全部迁到新热键系统并命中新 ID(`choices` 5 条、`save` 12 条含两条十页、`settings` 7×`@common/exit`、`title` 3 条、`viewmap` 8 条);`save.tsx` 翻页方向按用户已批准的新定义落地(prev=-1 / next=+1 / 十页 ±10),且由「逐 ID 从 `.implement(''` 切片到下一个 `.implement(`、只在该块内断言 `movePage(...)`」证明(整文件裸查 `movePage(...)` 不算通过,因为 `@ui/save-up` / `@ui/save-down` 的 handler 里本来就有 `movePage(-1)` / `movePage(1)`);`settings.tsx` 的 7 个 `useKey` 都写成 `useKey(props.state!.hotkey.system)!`,7 处 `` 都带 `freshScope={false}` + `state={props.state!}`,无局部 `scope`、未改 `MainSettingsProps` 的 extends 形态;`viewmap.tsx` 删除 `willEmit` 特判并补 `state`;`use/hotkey.ts` 改为 `onBeforeMount`;`gameKey` 负向不变式成立(`save.tsx` / `title.tsx` / `viewmap.tsx` / `choices.tsx` / `inputBox.tsx` 的 `gameKey` 计数为 0,`settings.tsx` 仍是 1 条 `@motajs/system` 导入 + 1 处 `gameKey.emitKey(`,VirtualKey 分支原样、无新增 legacy 用法);eslint 无新问题。 + + + Task 3: 修正 `src/client/misc/hotkey.ts` 的 6 处声明(3 个 save 方向 ID、2 个十页 ID、1 个 `block`)并新增 1 条输入框确认指令 `@ui/input-confirm` + src/client/misc/hotkey.ts + + **只做这 6 处修正 + 1 条新增块(E),其余一字不动**(除 E 新增的分组外,默认按键组合、`mode`、`interval`、`priority`、描述文案、名称文案、既有分组的顺序与缩进全部保持原样)。 + + **A. `#region 存档界面`(`hotkey.setGroup('@ui/save', '存档界面', [...])`)中 4 条 `@ui/save-up`:** + - 第 1 条(`defaults: [[KeyCode.UpArrow]]`,'选择框向上')→ **保持 `@ui/save-up` 不变**。 + - 第 2 条(`defaults: [[KeyCode.RightArrow]]`,'选择框向右')→ ID 改为 `@ui/save-right`。 + - 第 3 条(`defaults: [[KeyCode.DownArrow]]`,'选择框向下')→ ID 改为 `@ui/save-down`。 + - 第 4 条(`defaults: [[KeyCode.LeftArrow]]`,'选择框向左')→ ID 改为 `@ui/save-left`。 + + **B. 同分组内的翻页四条:** + - `@ui/save-page-prev`(`[[KeyCode.PageUp]]`,'向前翻页')→ 保持不变。 + - `@ui/save-page-next`(`[[KeyCode.PageDown]]`,'向后翻页')→ 保持不变。 + - `@ui/save-page-prev`(`[[KeyCode.Ctrl, KeyCode.PageUp]]`,'向前十页')→ ID 改为 `@ui/save-page-prev-10`。 + - `@ui/save-page-next`(`[[KeyCode.Ctrl, KeyCode.PageDown]]`,'向后十页')→ ID 改为 `@ui/save-page-next-10`。 + + **C. `#region 浏览地图` 的 `@ui/view-map-book`:** 把既有 `priority: 10` 改为 `priority: 10,`,并在紧随的下一行补 `block: true`(最后一项,**不带尾逗号**;顺序在 `priority` 之后,缩进与同组一致)——那个逗号是语法必需,掩码闸门会把「逗号 + 该行」整体掩掉,不算改动。`@ui/view-map-fly` / `@ui/view-map-reset` / 其它地图指令**不动**(不加 `block`)。 + + **D. 不改任何其它既有分组**(`common` / `game` / `ui` / `system` / `@ui/start` / `@ui/book` / `@ui/fly` / `@ui/plane-fly` / `@ui/save` / `@ui/view-map` 的键位与文案全部零改动,A/B/C 已列出的 6 处除外),除 E 的那一条外不增删任何 `system.addCommand(` 调用(执行后总数必须恰为基线 + 1),不调整任何默认键位与文案。 + + **E. 新增唯一一个新分组 `#region 输入框`(放在 `#region 浏览地图` 的 `//#endregion` 之后、函数结尾的 `}` 之前):** + - 分组声明写成 `hotkey.setGroup('@ui/input', '输入框', [ ... ]);`,组内**只有一条**:`system.addCommand('@ui/input-confirm', '输入框确认', { defaults: [[KeyCode.Enter]] })`。 + - **不写 `mode`**:`HotkeyCommand` 构造器在缺省时取 `HotkeyEmitMode.KeyUp`(按键抬起触发),与用户要求的「输入框只认 Enter」口径一致,也让 `inputBox.tsx` 的 blur 与旧 `Input` 的特判时机对齐;**不加** `priority` / `interval` / `timeout` / `block`。 + - `defaults` **只允许** `[[KeyCode.Enter]]`,**不得**出现 `KeyCode.Space` / `KeyCode.KeyC`(这正是不能复用 `@common/confirm` 的原因)。 + - 用与既有分组完全同构的 `//#region 输入框` / `//#endregion` 包裹;这一条由 `src/client/ui/inputBox.tsx` 实现(Task 1 A.3)。 + - **不放进 `common` 分组**(按用户指定):`@common/confirm` 是跨界面通用确认语义(Enter/Space/KeyC),而输入框需要一条独立、只绑 `Enter` 的指令;塞进 `common` 会让「同 ID 一组语义」的约束失效。分组前缀沿用既有 `@ui/<界面>` 约定(`@ui/save` / `@ui/view-map` → `@ui/input`)。 + + + node -e "const cp=require('child_process'),fs=require('fs');const bad=[];const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';const files=['src/client/misc/hotkey.ts'];if(!fs.existsSync(bp))bad.push('baseline missing (Task 1 step 1)');const b=fs.existsSync(bp)?JSON.parse(fs.readFileSync(bp,'utf8')):{eslint:[]};const r=cp.spawnSync('cmd',['/c','pnpm exec eslint -f json '+files.join(' ')],{encoding:'utf8',timeout:300000});const baseHasErr=(b.eslint||[]).some(x=>String(x).split('|')[2]==='2');if(r.status===null||r.status>=2||(r.status!==0&&!(r.status===1&&baseHasErr)))bad.push('eslint failed to run (status '+r.status+', baselineHasErrors='+baseHasErr+'): '+((r.stderr||'').slice(0,200)));if(!(r.stdout||'').trim())bad.push('eslint produced empty output');let arr=[];try{arr=JSON.parse((r.stdout||'').trim()||'[]')}catch(e){bad.push('eslint json unparsable')}const cur=[];for(const f of arr){const rel=String(f.filePath||'').replace(/\\/g,'/').replace(/^.*?(?=(src|packages-user)\/)/,'');for(const m of (f.messages||[]))cur.push(rel+'|'+(m.ruleId||'null')+'|'+m.severity+'|'+m.message)}const before=b.eslint||[];const cnt=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(cnt(cur,v)>cnt(before,v))bad.push('NEW eslint entry :: '+v)}if(bad.length){console.error('ESLINT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task3 eslint gate: no new entries for src/client/misc/hotkey.ts (baseline '+before.length+' -> current '+cur.length+'); status '+r.status+' accepted only because baselineHasErrors='+baseHasErr)" + 出现任何不在 `09-27-BASELINE.json` 的 `eslint` 基线内、且文件名归一化后指向 `src/client/misc/hotkey.ts` 的新 error / warning(多重集比较,与其它 eslint 门禁同一套归一化);或 eslint 未能运行(`status === null` / `status >= 2`,或 `status === 1` 但基线中没有任何 severity=2 条目)、stdout 为空、JSON 不可解析。**基线相对**:该文件若在基线里已含 severity-2 条目,`status === 1` 是预期、不判失败;不得以「原始命令非零退出」取代基线比较。 + node -e "const fs=require('fs'),cp=require('child_process');const bad=[];const p='src/client/misc/hotkey.ts';const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';if(!fs.existsSync(bp))bad.push('baseline missing');const b=fs.existsSync(bp)?JSON.parse(fs.readFileSync(bp,'utf8')):{};const s=fs.readFileSync(p,'utf8');const strip=t=>t.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g,'').replace(/\/\*[\s\S]*?\*\//g,'').split(/\r?\n/).map(l=>l.replace(/\/\/.*$/,'')).join('\n');const cs=strip(s);const decls=[...cs.matchAll(/system\.addCommand\('([^']+)'/g)].map(m=>({id:m[1],i:m.index}));const ids=decls.map(d=>d.id);const seen=new Map();for(const id of ids)seen.set(id,(seen.get(id)||0)+1);for(const [id,n] of seen)if(n>1)bad.push('duplicate declaration id: '+id+' x'+n);for(const id of ['@ui/save-up','@ui/save-right','@ui/save-down','@ui/save-left','@ui/save-page-prev','@ui/save-page-next','@ui/save-page-prev-10','@ui/save-page-next-10','@ui/view-map-book','@ui/input-confirm'])if(!seen.has(id))bad.push('missing declaration id: '+id);const n=(cs.match(/system\.addCommand\(/g)||[]).length;if(typeof b.addCommandCount!=='number')bad.push('baseline has no addCommandCount');else if(n!==b.addCommandCount+1)bad.push('addCommand count must be baseline+1 (only the new @ui/input-confirm block): '+b.addCommandCount+' -> '+n);const blk=id=>{const d=decls.find(x=>x.id===id);if(!d)return '';const nx=decls.find(x=>x.i>d.i);return cs.slice(d.i,nx?nx.i:cs.length)};const dirs=[['@ui/save-up','KeyCode.UpArrow'],['@ui/save-right','KeyCode.RightArrow'],['@ui/save-down','KeyCode.DownArrow'],['@ui/save-left','KeyCode.LeftArrow']];for(const [id,key] of dirs){const t=blk(id);if(!t)bad.push('block not found: '+id);else if(t.indexOf(key)<0)bad.push(id+' is not bound to '+key)}const pages=[['@ui/save-page-prev','KeyCode.PageUp',false],['@ui/save-page-next','KeyCode.PageDown',false],['@ui/save-page-prev-10','KeyCode.PageUp',true],['@ui/save-page-next-10','KeyCode.PageDown',true]];for(const [id,key,ctrl] of pages){const t=blk(id);if(!t)bad.push('block not found: '+id);else{if(t.indexOf(key)<0)bad.push(id+' is not bound to '+key);if((t.indexOf('KeyCode.Ctrl')>=0)!==ctrl)bad.push(id+' ctrl modifier mismatch')}}const book=blk('@ui/view-map-book');if(book.indexOf('block: true')<0)bad.push('@ui/view-map-book has no block: true');for(const id of ['@ui/view-map-fly','@ui/view-map-reset']){const t=blk(id);if(t&&t.indexOf('block: true')>=0)bad.push(id+' must not get block: true')}const ic=blk('@ui/input-confirm');if(!ic)bad.push('missing @ui/input-confirm block');else{if(ic.indexOf('[[KeyCode.Enter]]')<0)bad.push('@ui/input-confirm must bind exactly [[KeyCode.Enter]]');if(ic.indexOf('KeyCode.Space')>=0||ic.indexOf('KeyCode.KeyC')>=0)bad.push('@ui/input-confirm must not bind Space/KeyC');if(/mode:\s*HotkeyEmitMode/.test(ic))bad.push('@ui/input-confirm must keep default mode (KeyUp)');if(ic.indexOf('priority:')>=0||ic.indexOf('block:')>=0||ic.indexOf('interval:')>=0)bad.push('@ui/input-confirm must not set priority/block/interval')}if(seen.get('@ui/input-confirm')!==1)bad.push('@ui/input-confirm must be declared exactly once');if(!/hotkey\.setGroup\('@ui\/input', '输入框'/.test(cs))bad.push('missing @ui/input group named 输入框');const cc=blk('@common/confirm');if(!cc)bad.push('missing @common/confirm block');else if(cc.indexOf('KeyCode.Space')<0||cc.indexOf('KeyCode.KeyC')<0)bad.push('@common/confirm must stay Enter/Space/KeyC');if(!b.head)bad.push('baseline has no head');const oldRaw=((cp.spawnSync('git',['show',b.head+':'+p],{encoding:'utf8'}).stdout)||'');const norm=t=>t.replace(/\r\n/g,'\n').replace(/[ \t]+$/gm,'').replace(/\n{2,}/g,'\n').replace(/^\n+/,'').replace(/\n+$/,'');const stripInputGroup=t=>t.replace(/\n[ \t]*\/\/#region 输入框[\s\S]*?\/\/#endregion[ \t]*/,'\n');const old=norm(oldRaw);const cur=norm(stripInputGroup(norm(s)));if(!old.trim())bad.push('cannot read the pre-edit hotkey.ts from baseline head');const mask=t=>t.replace(/@ui\/save-(up|right|down|left)/g,'@UI_SAVE_DIR').replace(/@ui\/save-page-(prev|next)(-10)?/g,'@UI_SAVE_PAGE').replace(/,\n\s*block:\s*true/g,'');if(old.trim()&&mask(old)!==mask(cur))bad.push('hotkey.ts changed outside the 6 ids and the one block: true, after removing the new @ui/input group (compared after CRLF->LF normalization)');const oc=(strip(old).match(/block:\s*true/g)||[]).length;const nc=(cs.match(/block:\s*true/g)||[]).length;if(nc!==oc+1)bad.push('block: true count must be previous+1, got '+oc+' -> '+nc);if(bad.length){console.error('DECL GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK declaration gate: ids unique, bindings preserved, only the 6 ids + 1 block changed vs '+b.head+', the new @ui/input group was removed for comparison, addCommand count = baseline+1')" + 声明 ID 仍有重复、缺任一必需 ID、`system.addCommand(` 数量不等于**基线 + 1**、`@ui/view-map-book` 没有 `block: true`、给 `@ui/view-map-fly` / `@ui/view-map-reset` 误加 `block`、方向绑定与按键不对应(RightArrow→`save-right`、Down→`save-down`、Left→`save-left`、Ctrl+PageUp/Down→`-10` 变体)、`@ui/input-confirm` 缺失或被声明 2 次以上、`@ui/input-confirm` 的 `defaults` 不是 `[[KeyCode.Enter]]`(含 Space / KeyC 即失败)、`@ui/input-confirm` 带 `mode` / `priority` / `interval` / `block`、缺少名为 `输入框` 的 `@ui/input` 分组、`@common/confirm` 的 Enter/Space/KeyC 三键被改动、掩码比对(**两侧先做 CRLF → LF + 行尾空白 + 空行 + 首尾(EOF)换行归一化,再整段剔除新增的 `@ui/input` 分组**;ID 计数与 `priority` / `block` / `interval` / `mode` 判定均基于**剔除注释后**的文本)显示 `hotkey.ts` 在 6 个 ID + 1 个 `block` 之外还有任何改动(默认键 / mode / interval / priority / 名称与描述文案都算),或命令非零退出。 + + `@ui/save-up` 只剩 1 条;`@ui/save-right` / `@ui/save-down` / `@ui/save-left` 各 1 条且分别对应 RightArrow / DownArrow / LeftArrow;`@ui/save-page-prev-10` / `@ui/save-page-next-10` 就位;`@ui/view-map-book` 带 `block: true`;新增分组 `@ui/input`(`输入框`)内 `@ui/input-confirm` 恰好 1 条、`defaults` 为 `[[KeyCode.Enter]]`、默认 `mode`(无 `mode` / `priority` / `interval` / `block`),且 `@common/confirm` 仍是 Enter/Space/KeyC;全部命令 ID 唯一;`system.addCommand(` 数量恰为**基线 + 1**;**比对通过**:两侧先做 `\r\n` → `\n`、行尾空白、空行与首尾(EOF)换行归一化(**必须先归一化,再剔除分组**,否则 CRLF / 多余空行会让区域正则与逐字比较误判),再把新增的 `@ui/input` 分组(连同 `//#region 输入框` / `//#endregion`)整段剔除,最后掩掉 save 的 8 个 ID 与 1 个 `block: true`,与 `git show <基线 head>:src/client/misc/hotkey.ts` **逐字相同**;ID 计数、重复检测与 `priority` / `block` / `interval` / `mode` 判定一律基于**剔除注释后**的文本(注释不得虚增 `system.addCommand(` 计数或造出假声明),即默认键位 / mode / interval / priority / 名称与描述文案零改动;eslint 无新问题。 + + + + Task 4: 新增源码形状闸门 `src/client/ui/__test__/keyAdaptationGate.test.ts`(`?raw` 读取,15 组断言) + src/client/ui/__test__/keyAdaptationGate.test.ts + + **依赖 Task 1 / 2 / 3 已完成**(断言对象已是新形态)。新建目录 `src/client/ui/__test__/` 与该测试文件。**只读源码文本,不导入任何组件 / 工具函数 / 运行时代码**,不使用 `node:fs` / `process`;全部源码经 Vite 的 `?raw` 导入取得(与 `packages-user/client-base/src/map/__test__/rendererGate.test.ts` 的既有做法一致)。 + + **导入清单(只允许这些):** + `import { describe, expect, it } from 'vitest';` + `import inputBoxSource from '../inputBox.tsx?raw';` + `import waitBoxSource from '../waitBox.tsx?raw';` + `import backgroundSource from '../background.tsx?raw';` + `import choicesSource from '../choices.tsx?raw';` + `import saveSource from '../save.tsx?raw';` + `import settingsSource from '../settings.tsx?raw';` + `import titleSource from '../title.tsx?raw';` + `import viewMapSource from '../viewmap.tsx?raw';` + `import componentIndexSource from '../../../../packages-user/client-base/src/components/index.ts?raw';` + `import componentInputSource from '../../../../packages-user/client-base/src/components/input.tsx?raw';` + `import componentMiscSource from '../../../../packages-user/client-base/src/components/misc.tsx?raw';` + `import useHotkeySource from '../../../../packages-user/client-base/src/use/hotkey.ts?raw';` + `import hotkeyDeclSource from '../../misc/hotkey.ts?raw';` + + **文件内私有辅助(不导出):** + - `getCommandBlock(source, id)`:从 `system.addCommand(''` 切到下一条 `system.addCommand(`(或 `]);`)之前,用于判断某条声明的属性。 + - `getImplementBlock(source, id)`:从 `.implement(''` 切到下一个 `.implement(`(或文件末尾)之前,返回该条实现块;用于 `save.tsx` 的翻页方向断言(**不得**整文件裸查 `movePage(...)`——`@ui/save-up` / `@ui/save-down` 的 handler 本来就有 `movePage(-1)` / `movePage(1)`,裸查会让 prev/next 写反照样通过)。 + - `getImportStatements(source)`:用正则 `/import[\s\S]*?from\s+'[^']+';/g` 取出全部 import 语句,用于「只查 import 语句、不查整文件正文」的断言。 + - `stripLineComments(source: string): string`:先抹掉**块注释与 JSX 注释**(含跨行),再把每行 `//` 之后的内容(含整行注释与代码行尾注释)剔除后返回。顺序固定为:①`.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '')` 处理 `{/* ... */}` 形态(先处理带花括号的,避免残留花括号);②`.replace(/\/\*[\s\S]*?\*\//g, '')` 处理 `/* ... */` 形态;③按 `\r?\n` 切分后对每行 `.replace(/\/\/.*$/, '')`(**同时覆盖整行注释与代码行尾注释**,整行注释会变成空行,不影响「不得出现 / 计数」类断言)。用于「旧 API 绝迹」与「受闸门字面量计数」断言,避免命中 `viewmap.tsx` 里既有被注释掉的 `// .realize(...)` 示例、既有 `/* ... */` / `{/* ... */}` 说明,也避免命中 Task 2 G 允许新增的、提到 `freshScope={false}` 或 `willEmit` 的说明注释(无论独占一行还是写在代码行尾)。凡是「计数 / 不得出现某字面量」的断言,一律先过这个辅助再判,**不得**直接对 `?raw` 原文判。本闸门第 1 / 2 / 3 / 5 / 7 / 11 / 12 条里的否定断言(`from '@user/client-base'` / `from '@/client'` / `logger.` / `@start_up` / `@common/confirm` / 旧 `.implement(...)` 等)**逐个都先过 `stripLineComments`**——例如 `// 不复用 @common/confirm` 这样的说明注释不得让闸门 12 假红。Task 1 与 Task 2 的 node 闸门里的 `strip` 辅助必须用**同一套三步处理**(含第 ③ 步的行尾 `//` 剔除;否则注释里的字面量会让「不得出现」类断言假红、让计数断言多算)。 + + **`describe('按键适配源码闸门', () => { ... })`,15 个 `it`,每个 `it` 紧邻上方必须有且仅有一行 `//` 注释说明该测试干什么(中间只允许空行;AGENTS.md):** + + 1. **搬迁落点(inputBox)**:`inputBoxSource` 含 `export interface InputBoxProps`、`export type InputBoxEmits`、`export const InputBox`、`export function getInput(`、`export async function getInputNumber(`、`export const InputBoxUI`;且 `InputBoxProps` 的类型头含 `IGameUIPropsBase`;且 `getInput` 内的 `onConfirm` 带显式注解 `(value: string)`(防止既有 `TS7006` 搬成新诊断);且 `stripLineComments(inputBoxSource)` **不含** `from '@user/client-base'`,含 `packages-user/client-base/src/` 的具体模块路径。 + 2. **搬迁落点(waitBox)**:`waitBoxSource` 含 `export interface WaitBoxProps`、`export type WaitBoxEmits`、`export interface WaitBoxExpose`、`export const WaitBox`、`export function waitbox`、`export const WaitBoxUI`;且含 `BackgroundProps` 与 `from './background'`(`WaitBoxProps extends Partial` 的依赖);且 `waitbox` 内的 `onResolve` 带显式注解 `(data: T)`(同样防止既有 `TS7006` 搬成新诊断);且 `stripLineComments(waitBoxSource)` **不含** `from '@user/client-base'`,含 `packages-user/client-base/src/` 的具体模块路径。 + 3. **`background.tsx` 的两个职责与叶子导入**:含 `export interface BackgroundProps`、`export const Background`、`export const BackgroundUI`,同时仍含 `MainBackground` 与 `MainBackgroundUI`;含 `@/client/uiConstant`;对 `stripLineComments(backgroundSource)` 断言**不含** `from '@/client'`、**不含** `from '@user/client-base'`(`background.tsx` 不依赖 client-base;两条否定断言都必须先剔注释,避免「为什么不再导入 `@/client`」这类说明注释假红)。 + 4. **旧居所已清空(input.tsx)**:对 `stripLineComments(componentInputSource)` 断言**不含** `export interface InputBoxProps`、`export const InputBox`、`export function getInput(`、`export const InputBoxUI`、`InputBoxProps`;**不含** `useKey`、`KeyCode`、`from './misc'`;仍含 `export const Input` 与 `routedInputNumber`。 + 5. **旧居所已清空(misc.tsx)**:对 `stripLineComments(componentMiscSource)` 断言**不含** `export interface BackgroundProps`、`export const Background`、`export interface WaitBoxProps`、`export const WaitBox`、`export function waitbox`、`export const WaitBoxUI`、`export const BackgroundUI`、`logger.`;仍含 `export const Selection`、`export const ScrollText`、`export const Arrow`、`export const Progress`。 + 6. **barrel 三条新再导出**:`componentIndexSource` 含 `ui/background`、`ui/inputBox`、`ui/waitBox`、`ui/choices` 四个相对路径再导出,且每个都是以 `export * from` 开头。 + 7. **`choices.tsx` 命令体系**:含 `@common/select-up`、`@common/select-down`、`@common/select-left`、`@common/select-right`、`@common/confirm`;对 `stripLineComments(choicesSource)` 断言不含 `.implement('moveUp'`、`.implement('moveDown'`、`.implement('moveLeft'`、`.implement('moveRight'`、`.implement('confirm'`;含 `from './background'`;`confirmBoxProps` 与 `choicesProps` 的 props 数组含 `'state'`(断言**带引号的** `'state'` 在 `stripLineComments(choicesSource)` 中出现 ≥ 2 次——`props.state` 不带引号,因此这条能真正区分「props 数组补了 `state`」与「只是读了 `props.state`」);且 `stripLineComments(choicesSource)` **不含** `from '@user/client-base'`,含 `packages-user/client-base/src/` 的具体模块路径。 + 8. **`save.tsx` 12 条映射与方向**:逐个含 `.implement('@common/confirm'`、`.implement('@common/exit'`、`.implement('@ui/save'`、`.implement('@ui/load'`、`.implement('@ui/save-up'`、`.implement('@ui/save-down'`、`.implement('@ui/save-left'`、`.implement('@ui/save-right'`、`.implement('@ui/save-page-prev'`、`.implement('@ui/save-page-next'`、`.implement('@ui/save-page-prev-10'`、`.implement('@ui/save-page-next-10'`;对 `stripLineComments(saveSource)` 断言不含 `.implement('@save_pageUp'` / `.implement('@save_pageDown'` / `.implement('@save_up'` / `.implement('@save_down'` / `.implement('@save_left'` / `.implement('@save_right'`;**方向断言必须逐 ID 分块,不得整文件裸查 `movePage(...)`**:对 `@ui/save-page-prev` / `@ui/save-page-next` / `@ui/save-page-prev-10` / `@ui/save-page-next-10` 四个 ID,各取 `getImplementBlock(stripLineComments(saveSource), '')`(从 `.implement(''` 切片到下一个 `.implement(` 之前),分别断言该块内含 `movePage(-1)` / `movePage(1)` / `movePage(-10)` / `movePage(10)`,且块内不含其余三个字面量——`@ui/save-up` / `@ui/save-down` 的既有 `movePage(-1)` / `movePage(1)` 落在这些块之外,因此 prev / next 写反不会再被整文件裸查放过;含 `'state'`。**全部 ID 断言必须带 `.implement('...'` 的收尾引号**:`@ui/save` 不得被 `@ui/save-page-prev` 满足、`@ui/save-page-prev` 不得被 `@ui/save-page-prev-10` 满足;裸 `@ui/...` 子串匹配会让「缺 `-10` 变体」这类缺陷测不出来。 + **禁止**在本条里断言裸 `'save'` / `'load'` 字面量不存在(该文件 legit 地有 `core.doSL(..., 'save')` / `core.doSL(..., 'load')`),只断言 `.implement('<旧 ID>'` 形式。 + 9. **旧 API 已绝迹(5 个 UI 文件)**:对 `choicesSource` / `saveSource` / `settingsSource` / `titleSource` / `viewMapSource` / `inputBoxSource` 先过一遍 `stripLineComments`,再逐个断言:不匹配 `/useKey\(\s*\)/`、不含 `.realize(`、不含 `willEmit(`;且 `getImportStatements(src)`(用**未剔除注释前**的原文,import 不会出现在行注释里)中**没有任何一条**同时包含 `@user/client-modules` 与 `useKey`(只查 import 语句,不查正文——`save` / `settings` / `title` 合法地保留了其它 `client-modules` 导入)。 + 10. **`settings.tsx` 的 7 处 Choices 与 `gameKey` 零新增(先过 `stripLineComments`)**:在 `stripLineComments(settingsSource)` 剔除注释后的文本上计数(**不得**直接数 `?raw` 原文):JSX 开标签一律用 **`/])/g`** 计数并期望 **7**——**不得**用不带前瞻的裸 `` 也含 `` 不是 JSX 开标签,也不得被计数。 + 11. **`title.tsx` 与 `viewmap.tsx` 映射(`title` / `viewmap` 两侧都先过 `stripLineComments`)**:`titleSource` 含 `@ui/start-up`、`@ui/start-down`、`@common/confirm`;对 `stripLineComments(titleSource)` 断言不含 `@start_up` / `@start_down`;对 `stripLineComments(viewMapSource)` 断言含 `.implement('@ui/view-map-next'`、`.implement('@ui/view-map-prev'`、`.implement('@ui/view-map-next-10'`、`.implement('@ui/view-map-prev-10'`、`.implement('@ui/view-map-book'`、`.implement('@ui/view-map-fly'`、`.implement('@common/confirm'`、`.implement('@common/exit'`,不含 `.implement('@viewMap_book'`、不含 `willEmit`;另对原文断言含 `IGameUIPropsBase` 与带引号的 `'state'`(props 数组补项)。**必须带 `.implement(''` 的收尾引号**:裸 `@ui/view-map-next` 会被 `@ui/view-map-next-10` 满足、裸 `@ui/view-map-prev` 会被 `@ui/view-map-prev-10` 满足,那样 `-10` 变体缺失也测不出来。**原因**:Task 2 G 要求新增「为什么不再需要 `willEmit` 特判」这类独立注释,`willEmit` 的否定断言必须在剔除注释后做。 + 12. **`inputBox.tsx` 的确认键与作用域时机**:`inputBoxSource` 含 `.implement('@ui/input-confirm'`;对 `stripLineComments(inputBoxSource)` 断言**不含** `@common/confirm`(输入框只认 Enter,不得复用公共确认键);`inputBoxSource` 另含 `document.activeElement` 与 `blur()`(正例可用原文);对 `stripLineComments(useHotkeySource)` 断言含 `onBeforeMount(`、含 `onUnmounted(`、不含 `onMounted(`(先剔行注释,避免「为什么不再用 `onMounted`」这类说明注释误伤)。 + 13. **声明 ID 唯一**:从 `stripLineComments(hotkeyDeclSource)` 抽出全部 `system.addCommand(''`,断言无重复;并断言 `@ui/save-right` / `@ui/save-down` / `@ui/save-left` / `@ui/save-page-prev-10` / `@ui/save-page-next-10` 各存在。 + 14. **`@ui/view-map-book` 的 `block`**:用 `getCommandBlock(stripLineComments(hotkeyDeclSource), '@ui/view-map-book')` 取该条声明(注释已剔除,避免注释里的 `block: true` 造成假阳性),断言含 `block: true`;并断言 `@ui/view-map-fly` 的声明块(同一份剔除注释后的文本)**不含** `block: true`。 + 15. **`@ui/input-confirm` 的声明形态(只认 Enter)**:用 `getCommandBlock(stripLineComments(hotkeyDeclSource), '@ui/input-confirm')` 取块(注释已剔除,注释里的 `mode:` / `block:` 等字面量不会造成假红),断言块内含 `[[KeyCode.Enter]]`,且**不含** `KeyCode.Space` / `KeyCode.KeyC` / `mode:` / `priority:` / `interval:` / `block:`;断言 `system.addCommand('@ui/input-confirm'` 在 `stripLineComments(hotkeyDeclSource)` 中恰好出现 1 次、`hotkey.setGroup('@ui/input', '输入框'` 存在;再取 `@common/confirm` 的块(同一份剔除注释后的文本)断言其仍含 `KeyCode.Space` 与 `KeyCode.KeyC`(证明没有为了输入框而改动公共确认键)。 + + 断言风格:优先 `toContain` / `not.toContain`;**所有「不含 / 不得出现」的否定断言一律对 `stripLineComments(source)` 做**(正例断言可用原文),以免计划自身推荐的说明注释把闸门弄成假红;只在「形状不确定但语义确定」处用正则(如 `useKey(` 的零参形态、`const [key, scope]`)。**不得**为了让测试通过而放宽语义。测试代码本身也不得引入新的 lint 条目:辅助函数写显式返回类型(如 `: string`),不使用 `any`,不使用未使用变量,字符串统一单引号。 + + + cmd /c "pnpm exec vitest run src/client/ui/__test__/keyAdaptationGate.test.ts" + 任一 `it` 失败、测试文件无法收集(例如 `?raw` 路径写错、导入了不存在的模块),或命令非零退出。 + node -e "const fs=require('fs');const bad=[];const f='src/client/ui/__test__/keyAdaptationGate.test.ts';if(!fs.existsSync(f)){console.error('TEST GATE FAILED :: gate file missing');process.exit(1)}const s=fs.readFileSync(f,'utf8');const ls=s.split(/\r?\n/);const isIt=l=>l.trim().indexOf('//')!==0&&/(^|[^A-Za-z_.])it\(/.test(l);const its=ls.filter(isIt).length;const miss=[];for(let i=0;i=0&&ls[j].trim()==='')j--;if(j<0||ls[j].trim().indexOf('//')!==0)miss.push(i+1)}if(its<15)bad.push('only '+its+' it() blocks');if(miss.length)bad.push('it() without an immediately preceding // comment at line(s) '+miss.join(','));if(s.indexOf('node:fs')>=0||s.indexOf(\"from 'node:\")>=0)bad.push('uses node builtins');const stmts=s.match(/import[\s\S]*?from\s+'[^']+';/g)||[];for(const st of stmts){if(st.indexOf('vitest')>=0)continue;if(st.indexOf('?raw')>=0)continue;bad.push('non-raw import: '+st.replace(/\s+/g,' ').slice(0,80))}if(bad.length){console.error('TEST GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK test gate: '+its+' its, each preceded by a // comment, raw-only imports')" + 用例少于 15 条、存在没有**紧邻前置** `//` 注释的 `it`(注释与 `it` 之间只允许空行)、出现任何非 `vitest` / 非 `?raw` 的导入、使用 `node:fs` 等 Node 内置模块,或命令非零退出。 + + 闸门文件建成:15 个 `it` 全部对应新形态(搬迁落点 / 旧居所清空 / barrel / 命令映射 / 旧 API 绝迹 / settings 的 freshScope+state 与 `gameKey` 零新增 / inputBox 的 `@ui/input-confirm` + blur / onBeforeMount / 声明唯一 / view-map-book 的 block / `@ui/input-confirm` 只绑 Enter 与 `@common/confirm` 三键不变),每条 `it` 前恰有一行说明注释,只导入 `vitest` 与 `?raw` 源码,`vitest run` 全绿,无一条断言被放宽。 + + + Task 5: 全量门禁(vitest / eslint 前后基线 / `pnpm check:type` 前后基线 / 范围 / 哈希)并写 `09-27-SUMMARY.md` + .planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md + + **不修改任何源码或测试文件**;本任务只跑门禁并写摘要。若门禁暴露问题,必须回到对应任务修正后重跑,**不得**通过放宽断言 / 扩大豁免 / 删除用例来「通过」。 + + **A. 源码闸门。** 跑 `cmd /c "pnpm exec vitest run src/client/ui/__test__/keyAdaptationGate.test.ts"`,记录用例数与结果。 + + **B. eslint(前后基线)。** 对「改动文件集合」(与 Task 1 采集的 `eslint` 基线相同的集合,**并额外包含新增的** `src/client/ui/inputBox.tsx` 与 `src/client/ui/waitBox.tsx`)跑 `pnpm exec eslint -f json `,把每条消息归一化成 `文件|规则ID|严重度|消息`(文件用仓库相对路径、正斜杠),与 `09-27-BASELINE.json` 的 `eslint` 数组做**多重集比较**:任何「当前出现次数 > 基线出现次数」的条目判失败并打印原文;基线里存在、当前消失的条目允许。**硬性失败条件**:eslint **未能正常运行**(`status === null` 或 `status >= 2`)、stdout 为空、或 JSON 不可解析——一律判失败,不得当成「没有告警」。`status === 1` 只在**基线本身已含 severity=2 条目**时被接受(即改动前仓库本来就 lint 不过,`09-27-BASELINE.json` 记录在案);若基线没有任何 severity=2 条目而本次 `status !== 0`,同样判失败。两个新文件在基线里没有条目,因此它们身上任何 eslint 消息都算 NEW。 + + **C. 类型闸门(前后基线,按改动文件集合过滤)。** 用 verify 里的 `node -e` 包一层 `cmd /c "pnpm check:type"`(Node 侧 `spawnSync`,不依赖外层 shell 展开)——**整仓运行**(`vue-tsc --noEmit` 不接受文件列表,绝不传路径),只取落在「改动文件集合」内的诊断行;每条诊断去掉 `(行,列)` 后归一化成 `路径 error TSxxxx: 消息`,与 `09-27-BASELINE.json` 的 `typecheck` 数组做多重集比较:**只判 NEW**(当前次数 > 基线次数即失败);基线里有、现在消失的允许(说明顺手修掉了,不追究)。**进程状态也必须读,但只按「崩溃」判失败**:`r.status === null`(未能运行 / 被信号终止),或非零退出且输出里没有任何可解析的 `error TS` 行,才判失败;`r.status` 为 `1` 或 `2` 且输出含可解析的 `error TS` 行是既存诊断导致的**预期状态**(全仓本就有既存诊断),不判失败。空输出只在 `status === 0` 时才视为「零诊断成功」。**不得**要求全仓归零,**不得**忽略 `statistics.tsx` 与 `saves.ts` 这两个 barrel 消费方——它们正是用来证明再导出没破的。 + + **C-2. 诊断口径(不做任何条数断言)。** 唯一真值是 `09-27-BASELINE.json` 里 Task 1 实测采集的 `typecheck` 数组:闸门只判「改动文件集合内 NEW 诊断」,基线里存在、执行后消失的条目**允许**。必须保留的机制:`settings.tsx` 用 `props.state!.hotkey.system` 与 `state={props.state!}`;若因漏写 `!` 而在该文件新增 `TS18048` / `TS2322`,回到 Task 2 补齐,**不得**改成修改 `MainSettingsProps` 的 extends 形态。搬迁侧:两处隐式 `any` 已用 `onConfirm: (value: string)` / `onResolve: (data: T)` 注解消解;两个新文件在基线里没有任何条目,所以它们身上**任何**诊断都算 NEW(Task 1 的 MOVED TYPECHECK GATE 已要求搬迁后二者为零诊断),尤其**不得**出现 `TS7006`(出现即判 NEW)。**不得**在计划或门禁里写死「`settings.tsx` 恰好 N 条」「`input.tsx` 恰好 M 条」之类的计数——规划日与实测可能不一致,硬编码会让门禁在不同机器上给出不同结论。 + + **D. 范围闸门。** `git status --porcelain -uall -- <白名单路径>`:**代码侧 14 条**必须**恰好等于**下面 14 条(多一条失败,少一条也失败): + + ``` + M packages-user/client-base/src/components/index.ts + M packages-user/client-base/src/components/input.tsx + M packages-user/client-base/src/components/misc.tsx + M packages-user/client-base/src/use/hotkey.ts + M src/client/misc/hotkey.ts + M src/client/ui/background.tsx + M src/client/ui/choices.tsx + M src/client/ui/save.tsx + M src/client/ui/settings.tsx + M src/client/ui/title.tsx + M src/client/ui/viewmap.tsx + ?? src/client/ui/__test__/keyAdaptationGate.test.ts + ?? src/client/ui/inputBox.tsx + ?? src/client/ui/waitBox.tsx + ``` + + 另外两条**必须是干净**的(纳入 path 参数但**不允许出现任何状态行**,出现即失败):`src/client/ui/statistics.tsx`、`packages-user/client-modules/src/render/utils/saves.ts`——它们是 barrel 的既有消费方,本计划承诺一行不改。 + + **三个规划产物例外:** `.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md` / `09-27-BASELINE.json` / `09-27-SUMMARY.md` **允许**「未跟踪(`??`)」或「已跟踪且干净(不出现在 `git status` 输出里)」,也允许 ` M` / `M `(编排器提交后再补写 SUMMARY 的情形);**不得**因为这三个文件被纳入提交而判失败。 + + **不得执行任何 git 写命令**(只允许 `status` / `hash-object` / `diff` / `rev-parse` / `show` / `merge-base` 等只读命令)。 + + **E. 哈希闸门与「执行期无提交」闸门(真值比较,不要求 HEAD 字面相等)。** 读 `09-27-BASELINE.json`(`head` + 5 个 `hashes`):①`git merge-base --is-ancestor <基线 head> HEAD` 必须成立(基线之后允许用户并发提交把 HEAD 往前推,**不得**因为 HEAD 变了就判失败);②`git diff --name-only <基线 head>..HEAD` 的结果里**不得**出现 `files_modified` 的任何**代码**路径或两个 barrel 消费方(`statistics.tsx` / `saves.ts`)(即执行方没有提交过本计划的代码);**该比对排除** `.planning/phases/09-minimum-browser-runnable/09-27-*` 三个规划产物——它们可能被编排器提交,不属于代码越界;③`src/client/ui/toolbar.tsx`、`src/client/ui/index.ts`、`packages-user/client-base/src/components/textbox.tsx`、`packages-user/client-modules/src/render/use.ts`、`packages-user/client-modules/src/ui/types.ts` 五个 `git hash-object` 必须与基线**逐字相等**;④基线 `hashes` 的键集合必须恰好等于这 5 个完整仓库相对路径(basename 键即失败);⑤基线 `head` 必须非空。基线文件由 **Task 1 在获批后、改代码前**写一次,本任务只读、不得重写。若 HEAD 因用户并发提交前移,把 `<基线 head>..HEAD` 的提交清单写进 SUMMARY,这不构成偏差。**不得**把 `git rev-parse HEAD` 与基线做字面相等比较。 + + **F. 写摘要 `09-27-SUMMARY.md`。** 沿用 `09-26-SUMMARY.md` 的 frontmatter 风格(`phase` / `plan: 27` / `subsystem` / `tags` / `requires` / `provides` / `affects` / `actuals` / `tech-stack` / `key-files` / `key-decisions` / `patterns-established` / `requirements-completed: []` / `coverage` / `duration` / `completed` / `status`),正文至少包含: + + - **Accomplishments**:三个 GameUI 组件按定义拆到 `src/client/ui/{inputBox,waitBox,background}.tsx`;`input.tsx` / `misc.tsx` 已裁剪并清理失效导入;barrel 追加三条 `export *`;`background.tsx` 改用 `@/client/uiConstant`;6 个 UI 文件迁到新热键系统;`use/hotkey.ts` 改 `onBeforeMount`;`src/client/misc/hotkey.ts` 6 处声明修正 + 新增 1 条 `@ui/input-confirm`(新分组 `@ui/input`)。 + - **`settings.tsx` 的作用域继承(摘要必须原样出现 `freshScope={false}` 与 `state={props.state!}` 两个字面量)**:7 处 `` 都传 `freshScope={false}` + `state={props.state!}`,7 个 `useKey` 都是 `useKey(props.state!.hotkey.system)!`,因此子作用域以 `clearImpl = false` 进入、父级 `@common/exit` 不会被清空;VirtualKey 分支原样保留、`MainSettingsProps` 的 extends 形态未改。 + - **逐文件命令映射表**(与计划一致,作为审计痕迹):`choices` 5 条、`save` 12 条、`settings` 7×`@common/exit`、`title` 3 条、`viewmap` 8 条、`inputBox` 1 条(`@ui/input-confirm`)。 + - **已批准的行为口径(作为已批准事实记录,不得写成「待确认」)**:①`save` 翻页方向 `@ui/save-page-prev` → `movePage(-1)`、`@ui/save-page-next` → `movePage(1)`、`±10` 变体(旧实现是 PageUp → `movePage(1)`,方向相反)——**用户已批准采用此新定义**;②`viewmap` / `save` 的十页翻页在声明层是 `Continuous/500`,按住会连续跳十页(旧 `@viewMap_*_ten` 是单发)——**用户已批准保持按住重复**;③`inputBox` 改用专用 `@ui/input-confirm`(只绑 `Enter`),**不再**复用 `@common/confirm`,因此**不覆盖** Space / KeyC。这些都只是声明层语义,**未经浏览器验证**。 + - **两处类型注解(搬迁时新增、行为不变)**:`getInput` 的 `onConfirm: (value: string)`、`waitbox` 的 `onResolve: (data: T)`;目的是让既有的 `TS7006` 条目不随搬迁在新文件里重现。 + - **按键投递未接线声明(预期状态)**:`IHotkeySystem.listen` 目前没有任何生产代码调用,**按键不会真的送达 UI**;这是**预期的、不在本计划范围**的现状,本计划不把它列为待修问题,只做「实现层 ↔ 架构层」的接口对齐,**不宣称任何浏览器 / 键盘行为已验证**,浏览器 UAT 全程跳过。 + - **Task Commits**:明确写「无。按用户要求未运行任何 git 写命令,改动全部留在工作树供用户审查」。 + - **Verification**:五条门禁的命令与结果;eslint / typecheck 的「基线 → 当前」计数(**基线值原样抄自 `09-27-BASELINE.json`,不写死任何期望条数**;只需说明集合内无 NEW、且 `inputBox.tsx` / `waitBox.tsx` 中没有 `TS7006`;`vue-tsc` 的退出码按「崩溃才失败」口径记录——`status` 为 `1` / `2` 且输出含可解析 `error TS` 行属预期状态(全仓既有诊断),**不得**写成本计划失败);范围闸门的 14 条代码状态 + `statistics.tsx` / `saves.ts` 两个 barrel 消费方干净;哈希闸门的 5 个哈希原文、基线 `head` 原文、`<基线 head>..HEAD` 的提交清单(若为空写「无」)与「无提交触碰白名单代码路径」结论;声明掩码比对结果(含「先整段剔除新增的 `@ui/input` 分组」与 CRLF → LF + 行尾空白 + 空行 + 首尾(EOF)换行归一化说明、声明扫描基于剔除注释后的文本,以及 `addCommand` 数为基线 + 1)。 + - **Deviations / Issues**:如实记录(含若出现的 `TS2578` 未用 `@ts-expect-error` 一类)。 + - **已知限制 / 遗留(OI-2 原文复述)**:按键投递未接线(**预期状态**,不在本计划范围)、`toolbar.tsx` 与 VirtualKey 分支仍 legacy、旧 `client-modules` 的 `useKey` 变无引用但保留、`MainBackground` 扩大可见面、`src/client/ui/index.ts` 未加导出、游戏不可运行、**三个被再导出文件的直接 barrel 回引已改为具体模块导入(OI-2 ⑦);`client-base` 内部既有的 `textbox → elements → map` 与 `types.ts → ./map` 回引链不在本计划范围,`pnpm check:circular` 未运行、不得写成「循环已全部解决」**。 + - **estimate 声明**:`confidence: low`(未校准);估算 120k 约为 smart-zone 单计划预算的 1.2×,按 **D-01 不得未经用户同意拆分**,超额状态按「待 Task 0 确认 / 待用户批准」记录(**尚未获批**);Task 0 ⑩ 会在执行前请用户确认,收到「可以执行」后本摘要按用户的实际决定记录(批准则写明批准;若用户要求拆分则先停下);本计划未拆、未砍范围、未跳过门禁。 + - **未被触碰的文件**:写出 5 个受保护文件的基线哈希原文,并说明「与 Task 1 采集的基线逐字相等;基线 head 是当前 HEAD 的祖先,`<基线 head>..HEAD` 未触碰任何白名单代码路径」;明确 `packages-user/client-base/src/map/*` 与 `.planning/phases/10-docs-restructure/10-CONTEXT.md` 的用户既有脏文件未被本计划触碰。 + - `status: partial`(代码任务完成、UAT 跳过),`completed:` **写实际执行日期**——不得硬编码、也不得沿用 `09-26-SUMMARY.md` 里的完成日期(那是前一个计划的产物);写摘要当天是几号就写几号(本计划编写日为 2026-10-25,执行期以当天为准)。 + + + cmd /c "pnpm exec vitest run src/client/ui/__test__/keyAdaptationGate.test.ts" + 任一条用例失败,或测试无法收集,或命令非零退出。 + node -e "const cp=require('child_process'),fs=require('fs');const bad=[];const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';const files=['packages-user/client-base/src/components/index.ts','packages-user/client-base/src/components/input.tsx','packages-user/client-base/src/components/misc.tsx','packages-user/client-base/src/use/hotkey.ts','src/client/misc/hotkey.ts','src/client/ui/background.tsx','src/client/ui/choices.tsx','src/client/ui/save.tsx','src/client/ui/settings.tsx','src/client/ui/title.tsx','src/client/ui/viewmap.tsx','src/client/ui/statistics.tsx','src/client/ui/inputBox.tsx','src/client/ui/waitBox.tsx','src/client/ui/__test__/keyAdaptationGate.test.ts','packages-user/client-modules/src/render/utils/saves.ts'];if(!fs.existsSync(bp))bad.push('baseline missing');const b=fs.existsSync(bp)?JSON.parse(fs.readFileSync(bp,'utf8')):{eslint:[]};const r=cp.spawnSync('cmd',['/c','pnpm exec eslint -f json '+files.join(' ')],{encoding:'utf8',timeout:300000});const baseHasErr=(b.eslint||[]).some(x=>String(x).split('|')[2]==='2');if(r.status===null||r.status>=2||(r.status!==0&&!(r.status===1&&baseHasErr)))bad.push('eslint failed to run (status '+r.status+', baselineHasErrors='+baseHasErr+'): '+((r.stderr||'').slice(0,200)));if(!(r.stdout||'').trim())bad.push('eslint produced empty output');let arr=[];try{arr=JSON.parse((r.stdout||'').trim()||'[]')}catch(e){bad.push('eslint json unparsable: '+((r.stderr||'').slice(0,200)))}const cur=[];for(const f of arr){const rel=String(f.filePath||'').replace(/\\/g,'/').replace(/^.*?(?=(src|packages-user)\/)/,'');for(const m of (f.messages||[]))cur.push(rel+'|'+(m.ruleId||'null')+'|'+m.severity+'|'+m.message)}const before=b.eslint||[];const cnt=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(cnt(cur,v)>cnt(before,v))bad.push('NEW eslint entry :: '+v)}if(bad.length){console.error('ESLINT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK eslint gate: no new entries (baseline '+before.length+' -> current '+cur.length+')')" + eslint 出现任何基线之外的新条目(含新 error 与新 warning);或 eslint 未能正常运行(`status === null` / `status >= 2`,或 `status === 1` 但基线中没有任何 severity=2 条目)、stdout 为空、JSON 无法解析、命令无法启动。基线中已存在的 error 条目使 `status === 1` 成为预期结果,此时只按多重集判 NEW,不把 `status === 1` 本身判失败。 + node -e "const cp=require('child_process'),fs=require('fs');const bad=[];const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';const scope=['packages-user/client-base/src/components/index.ts','packages-user/client-base/src/components/input.tsx','packages-user/client-base/src/components/misc.tsx','packages-user/client-base/src/use/hotkey.ts','src/client/misc/hotkey.ts','src/client/ui/background.tsx','src/client/ui/choices.tsx','src/client/ui/inputBox.tsx','src/client/ui/save.tsx','src/client/ui/settings.tsx','src/client/ui/title.tsx','src/client/ui/viewmap.tsx','src/client/ui/waitBox.tsx','src/client/ui/statistics.tsx','src/client/ui/__test__/keyAdaptationGate.test.ts','packages-user/client-modules/src/render/utils/saves.ts'];const b=fs.existsSync(bp)?JSON.parse(fs.readFileSync(bp,'utf8')):{typecheck:[]};const r=cp.spawnSync('cmd',['/c','pnpm check:type'],{encoding:'utf8',timeout:600000});const text=(r.stdout||'')+(r.stderr||'');const diagLines=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));if(r.status===null)bad.push('vue-tsc did not run (status null / killed by signal)');else if(r.status!==0&&diagLines.length===0)bad.push('vue-tsc exited '+r.status+' with no parseable error TS output (crash, not diagnostics)');if(text.trim()===''&&r.status!==0)bad.push('vue-tsc produced no output (status '+r.status+')');const lines=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));const norm=l=>l.replace(/\\/g,'/').replace(/\(\d+,\d+\):/,'').trim();const cur=[];for(const l of lines.map(norm)){for(const p of scope){if(l.indexOf(p)>=0){cur.push(l.replace(/^.*?(?=(src|packages-user)\/)/,''));break}}}const before=b.typecheck||[];const cnt=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(cnt(cur,v)>cnt(before,v))bad.push('NEW scoped diagnostic :: '+v)}if(bad.length){console.error('TYPECHECK FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK typecheck gate: no new scoped diagnostics (baseline '+before.length+' -> current '+cur.length+', total diagnostics '+lines.length+')')" + 改动文件集合内出现任何基线之外的新诊断(含 `statistics.tsx` / `saves.ts` 的 barrel 破坏),或 `vue-tsc` 崩溃(`r.status === null`,或**非零退出且输出里没有任何可解析的 `error TS` 行**)、或 `r.status !== 0` 时输出为空(`status === 0` 的空输出是零诊断成功的正常形态,**不判失败**;非空输出仍按多重集只判 NEW)。**`vue-tsc` 在存在既存诊断时会以 `status === 1`(某些环境下为 `2`)退出,这是预期结果**:判据是多重集只判 NEW,不把 `status` 为 `1` / `2` 本身判失败,只要输出里有可解析的 `error TS` 行;但 `status === null`、或非零退出且没有任何可解析的 `error TS` 行,必须判失败(与 Task 1 的 MOVED TYPECHECK GATE 同一口径)。 + node -e "const fs=require('fs'),cp=require('child_process');const bad=[];const planning=['.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md','.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json','.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md'];const consumers=['src/client/ui/statistics.tsx','packages-user/client-modules/src/render/utils/saves.ts'];const code=['packages-user/client-base/src/components/index.ts','packages-user/client-base/src/components/input.tsx','packages-user/client-base/src/components/misc.tsx','packages-user/client-base/src/use/hotkey.ts','src/client/misc/hotkey.ts','src/client/ui/background.tsx','src/client/ui/choices.tsx','src/client/ui/save.tsx','src/client/ui/settings.tsx','src/client/ui/title.tsx','src/client/ui/viewmap.tsx','src/client/ui/inputBox.tsx','src/client/ui/waitBox.tsx','src/client/ui/__test__/keyAdaptationGate.test.ts'];const expected=[' M packages-user/client-base/src/components/index.ts',' M packages-user/client-base/src/components/input.tsx',' M packages-user/client-base/src/components/misc.tsx',' M packages-user/client-base/src/use/hotkey.ts',' M src/client/misc/hotkey.ts',' M src/client/ui/background.tsx',' M src/client/ui/choices.tsx',' M src/client/ui/save.tsx',' M src/client/ui/settings.tsx',' M src/client/ui/title.tsx',' M src/client/ui/viewmap.tsx','?? src/client/ui/__test__/keyAdaptationGate.test.ts','?? src/client/ui/inputBox.tsx','?? src/client/ui/waitBox.tsx'];const g=cp.spawnSync('git',['status','--porcelain','-uall','--'].concat(code).concat(planning).concat(consumers),{encoding:'utf8'});if(g.status!==0)bad.push('git status failed');const lines=(g.stdout||'').split(/\r?\n/).map(x=>x.replace(/\s+$/,'')).filter(Boolean);for(const l of lines){const st=l.slice(0,2);const p=l.slice(3).replace(/^'|'$/g,'');if(planning.indexOf(p)>=0){if(['??',' M','M ','A ','AM','MM'].indexOf(st)<0)bad.push('unexpected planning-artifact status: '+l);continue}if(consumers.indexOf(p)>=0){bad.push('barrel consumer must stay untouched: '+l);continue}if(expected.indexOf(l)<0)bad.push('out-of-scope change: '+l)}for(const e of expected)if(lines.indexOf(e)<0)bad.push('expected state missing: '+e);const sum='.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md';if(!fs.existsSync(sum))bad.push('SUMMARY missing');else{const s=fs.readFileSync(sum,'utf8');for(const k of ['listen','UAT','confidence: low','status: partial','save-page-prev','freshScope','movePage(-1)'])if(s.indexOf(k)<0)bad.push('SUMMARY missing: '+k)}if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope gate: exactly the 14 expected code states, barrel consumers clean, planning artifacts untracked-or-clean, SUMMARY carries the required statements')" + 14 条代码期望状态缺任一条、代码路径出现期望之外的状态(含未跟踪文件)、`statistics.tsx` / `saves.ts` 任一出现状态行(即被改动)、三个 `09-27-*` 规划产物出现意外的 git 状态、SUMMARY 缺失或缺少必需声明,或命令非零退出。 + node -e "const fs=require('fs'),cp=require('child_process');const bad=[];const bp='.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json';const files=['src/client/ui/toolbar.tsx','src/client/ui/index.ts','packages-user/client-base/src/components/textbox.tsx','packages-user/client-modules/src/render/use.ts','packages-user/client-modules/src/ui/types.ts'];const whitelist=['packages-user/client-base/src/components/index.ts','packages-user/client-base/src/components/input.tsx','packages-user/client-base/src/components/misc.tsx','packages-user/client-base/src/use/hotkey.ts','src/client/misc/hotkey.ts','src/client/ui/background.tsx','src/client/ui/choices.tsx','src/client/ui/save.tsx','src/client/ui/settings.tsx','src/client/ui/title.tsx','src/client/ui/viewmap.tsx','src/client/ui/inputBox.tsx','src/client/ui/waitBox.tsx','src/client/ui/__test__/keyAdaptationGate.test.ts','src/client/ui/statistics.tsx','packages-user/client-modules/src/render/utils/saves.ts','.planning/phases/09-minimum-browser-runnable/09-27-PLAN.md','.planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json','.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md'];if(!fs.existsSync(bp))bad.push('baseline artifact missing');else{const b=JSON.parse(fs.readFileSync(bp,'utf8'));if(!b.head)bad.push('baseline has no head');else{const anc=cp.spawnSync('git',['merge-base','--is-ancestor',b.head,'HEAD']);if(anc.status!==0)bad.push('baseline head is not an ancestor of HEAD: '+b.head);const d=cp.spawnSync('git',['diff','--name-only',b.head+'..HEAD'],{encoding:'utf8'});if(d.status!==0)bad.push('git diff failed');const touched=((d.stdout||'').split(/\r?\n/).map(x=>x.trim()).filter(Boolean));const clash=touched.filter(x=>whitelist.indexOf(x)>=0&&x.indexOf('.planning/phases/09-minimum-browser-runnable/09-27-')!==0);if(clash.length)bad.push('commits since baseline touched whitelisted code paths: '+clash.join(','))}const keys=(b.hashes?Object.keys(b.hashes).sort():[]).join('|');if(keys!==files.slice().sort().join('|'))bad.push('baseline hashes must be keyed by exactly the 5 full repo-relative paths');for(const f of files){if(!fs.existsSync(f)){bad.push('missing '+f);continue}const h=((cp.spawnSync('git',['hash-object',f],{encoding:'utf8'}).stdout)||'').trim();if(!b.hashes||!b.hashes[f])bad.push('baseline has no full-path hash for '+f);else if(h!==b.hashes[f])bad.push('protected file changed: '+f+' '+b.hashes[f]+' -> '+h)}const sum='.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md';if(!fs.existsSync(sum))bad.push('SUMMARY missing');else{const s=fs.readFileSync(sum,'utf8');if(b.head&&s.indexOf(b.head)<0)bad.push('SUMMARY does not record the baseline head');for(const f of files){if(b.hashes&&b.hashes[f]&&s.indexOf(b.hashes[f])<0)bad.push('SUMMARY does not record the baseline hash of '+f)}}}if(bad.length){console.error('HASH/CLEAN GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK hash gate: baseline head is an ancestor, no commit since baseline touched whitelisted paths, 5 protected hashes unchanged and recorded in SUMMARY')" + 基线产物缺失 / 残缺(`hashes` 不是恰好 5 个完整路径键)、基线 head 不是当前 HEAD 的祖先、`<基线 head>..HEAD` 有提交触碰了白名单**代码**路径(`.planning/.../09-27-*` 三个规划产物不计)、任一受保护文件被改动(哈希不等)、基线 head 或任一哈希原文没出现在 SUMMARY,或命令非零退出。 + + 五条门禁全绿:vitest 闸门通过;eslint 无新条目(基线校验失败 / 空输出 / JSON 不可解析即失败;`status === 1` 仅在基线已含 severity=2 条目时被接受;集合含 `inputBox.tsx` / `waitBox.tsx`);`pnpm check:type` 在改动文件集合(含 `statistics.tsx` 与 `saves.ts`,二者只判「无 NEW 诊断」、既有基线诊断不要求清零)内无新诊断(基线条目一律以 `09-27-BASELINE.json` 为准,不写死条数;`inputBox.tsx` / `waitBox.tsx` 中没有 `TS7006`;`vue-tsc` 的 `status` 为 `1` / `2` 且输出含可解析 `error TS` 行属预期状态,只有 `status === null` 或非零且无可解析诊断才算崩溃);`git status` 在代码侧恰为 14 条期望状态、两个 barrel 消费方无改动、三个 `09-27-*` 规划产物允许未跟踪或已跟踪干净;基线 head 是当前 HEAD 的祖先且 `<基线 head>..HEAD` 没有提交触碰白名单代码路径,5 个受保护文件哈希与基线逐字相等并写进 SUMMARY。`09-27-SUMMARY.md` 写完并包含「save 翻页方向(用户已批准的新定义)/ 十页按住重复(用户已批准)/ `@ui/input-confirm` 只绑 Enter(不复用 `@common/confirm`)/ 按键投递未接线属预期状态 / 无浏览器 UAT / 未提交代码 / estimate 超额且 D-01 未拆分 / 三个被再导出文件的直接 barrel 回引已改为具体模块导入、client-base 内部既有环不在本计划范围」全部声明。 + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| `client-base` 包图 ↔ `src/client` 应用层 | `components/index.ts` 用相对路径再导出 `src/client/ui/*`。一旦被再导出的文件导入 `@/client`(应用入口),整条 `entry` / `initialize` 会被拉进包图;这类破坏在类型层看不出来,只在构建 / 运行时炸 | +| 公共 barrel ↔ 外部消费者 | `waitbox` / `getInput` / `Background` 的导出名一旦变动,`src/client/ui/statistics.tsx`、`packages-user/client-modules/src/render/utils/saves.ts`(`waitbox`)与 `src/client/ui/settings.tsx`(`getInput` / `waitbox`)会静默失配 | +| 按键作用域栈 ↔ 嵌套 UI 组件 | `enterScope(clearImpl)` + `onBeforeMount` 的入栈顺序决定「父级实现是否被清掉」;`settings` 的 7 个 JSX `])/g` 计数,`Partial` 不计入)若漏传 `freshScope={false}`,父级 `@common/exit` 会被子作用域清空 | +| 声明层 ID ↔ 实现层 `implement` | `src/client/misc/hotkey.ts` 的 ID 与各 UI 的 `implement('')` 是两处独立字符串;任一侧打错都不会报错,只会让某个键位永久「没反应」 | +| `props.state` ↔ Vue props 声明 | `state` 由 `createUIPropsBase` 注入,但只在 props 数组声明过的键才会绑定;漏声明时 `props.state` 为 `undefined`,`useKey(props.state.hotkey.system)` 直接抛错 | +| 输入框确认键 ↔ 公共确认键 | `inputBox` 若复用 `@common/confirm`(Enter/Space/KeyC)就会把 Space / KeyC 也变成「确认」,与用户要求的「输入框只认 Enter」冲突;两者必须在声明层分开 | +| 工作树 ↔ 交付物 | 工作树里已有 09-26 的未提交渲染改动与用户并发脏文件;误改 / 误提交会污染用户正在进行的改动 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-27-01 | Tampering | `src/client/ui/background.tsx` 的 `@/client` 导入(经 barrel 再导出) | high | mitigate | 强制改为 `@/client/uiConstant`;闸门断言 `background.tsx` 含 `@/client/uiConstant` 且不含 `from '@/client'`;按 barrel 消费方(`statistics.tsx` / `saves.ts`)纳入类型闸门 | +| T-09-27-02 | Tampering | `components/index.ts` 的三条 `export *` 与旧定义残留 | high | mitigate | 先裁剪 `input.tsx` / `misc.tsx` 的定义,再追加再导出;闸门同时断言新文件导出存在、旧文件导出消失、barrel 四条路径齐全(避免同名重复导出),并断言 `inputBox.tsx` / `waitBox.tsx` / `choices.tsx` 不含 `from '@user/client-base'`(只走具体模块,避免直接 barrel 回引) | +| T-09-27-03 | Elevation of Privilege | `settings.tsx` 子 `Choices` 清空父级按键实现 | high | mitigate | 7 个 JSX `])/g` 计数,`Partial` 不算**,否则会得到 8 而假失败)一律 `freshScope={false}` + `state={props.state!}`,7 个 `useKey` 一律 `props.state!.hotkey.system`;闸门断言三者数量都等于 7(`props.state!.hotkey.system` 也是 7 且无无 `!` 版本),且不存在 `scope={scope}` / `[key, scope]` | +| T-09-27-04 | Tampering | 声明层重复 ID 覆盖(`@ui/save-up` ×4、`@ui/save-page-*` ×2) | high | mitigate | Task 3 精确修正 6 处并新增 1 条 `@ui/input-confirm`;闸门解析全部 ID 断言唯一性(含 `@ui/input-confirm` 恰好 1 次)、必需 ID 存在、`addCommand` 数量 = **基线 + 1** | +| T-09-27-05 | Tampering | `@ui/view-map-book` 与 `@common/exit` 同键 `KeyX` 双触发 | medium | mitigate | 声明加 `block: true`;`viewmap` 删除 `willEmit('book')` 特判;闸门断言 `block: true` 只出现在 book 上、`view-map-fly` 没有 | +| T-09-27-06 | Tampering | `props.state` 未声明导致运行时 `undefined.hotkey` | high | mitigate | 5 处 props 数组补 `'state'`;`viewmap` 的 `ViewMapProps` 改 `IGameUIPropsBase`;闸门断言 `'state'` 出现在对应文件 | +| T-09-27-07 | Repudiation | 旧按键 API 残留(旧 `useKey()` / `realize` / `willEmit`) | medium | mitigate | 闸门对 6 个 UI 文件断言零参 `useKey()`、`.realize(`、`willEmit(` 全部不存在;并检查 `@user/client-modules` 的 **import 语句**(不查正文)不含 `useKey` | +| T-09-27-08 | Tampering | 声明修正时误改默认键位 / mode / 文案 | medium | mitigate | 限定只改 6 处 ID、1 个属性,并**只新增 1 条** `@ui/input-confirm` 块;闸门断言 `addCommand` 数量恰为基线 + 1(基线真值比较)、掩码比对先剔除新增分组再逐字比较,人工核对仅限声明的 6 处 + 新增的 1 个块 | +| T-09-27-09 | Repudiation | 门禁被放宽成「假绿」(改断言、删用例、扩大豁免) | high | mitigate | 闸门文件本身由 Task 4 的计数闸门检查(≥15 个 `it`、每个 `it` 前紧邻一行 `//` 注释(中间只允许空行)、只允许 `vitest` 与 `?raw` 导入);Task 5 明确禁止放宽断言,偏差须如实写进 SUMMARY | +| T-09-27-10 | Information Disclosure | 声称「按键已验证」但投递未接线 | medium | mitigate | SUMMARY 强制写明 `IHotkeySystem.listen` 无生产调用(**预期状态**)、浏览器 UAT 跳过;范围闸门检查 SUMMARY 含 `listen` / `UAT` 字样 | +| T-09-27-11 | Tampering | 误改 / 误提交用户并发脏文件与 09-26 未提交改动 | high | mitigate | Task 1 记录执行当次的 `head` + 5 个受保护文件的完整路径哈希;Task 5 验「基线 head 是当前 HEAD 的祖先」+「`<基线 head>..HEAD` 未触碰白名单代码路径(`09-27-*` 规划产物除外)」+ 受保护哈希逐字相等 + 14 条代码白名单范围闸门 + `statistics.tsx` / `saves.ts` 零改动(`-uall` 含未跟踪文件);禁止一切 git 写命令 | +| T-09-27-12 | Tampering | `@ui/input-confirm` 被扩到 Space / KeyC,或 `inputBox` 复用 `@common/confirm`(「输入框只认 Enter」回退) | medium | mitigate | 新块只写 `defaults: [[KeyCode.Enter]]`、不写 `mode` / `priority` / `interval` / `block`;Task 4 闸门 15 断言该块只绑 Enter 且 `@common/confirm` 仍为 Enter/Space/KeyC;Task 2 的 KEY GATE 断言 `inputBox.tsx` 不含 `@common/confirm` | +| T-09-27-SC | Tampering | npm / pnpm 依赖安装(供应链) | high | mitigate | 本计划明确禁止新增依赖与执行安装;若执行期出现任何安装需求,必须停下并转入 `[ASSUMED]` 包合法性人工核验 checkpoint,不得自动放行 | + + + +## 整体验证(无浏览器 UAT) + +| 检查 | 命令 / 手段 | 通过判据 | +|------|-------------|----------| +| 源码形状闸门 | `cmd /c "pnpm exec vitest run src/client/ui/__test__/keyAdaptationGate.test.ts"` | 15 个断言组全绿;每条 `it` 前一行注释;只导入 `vitest` 与 `?raw` | +| 代码风格 | `pnpm exec eslint -f json <改动文件集合 + inputBox.tsx + waitBox.tsx>` 与基线多重集比较 | **基线相对**:集合内无 NEW 条目(含新增 warning);基线已含 severity-2 条目,所以整体 `status === 1` 是预期终态、不判失败;只有 `status === null` / `status >= 2`(未能运行)/ 空输出 / JSON 不可解析才失败 | +| 类型(按改动文件过滤) | 整仓 `node -e` 包一层 `cmd /c "pnpm check:type"` + 文件集合过滤 | 集合内无 NEW 诊断(基线以 `09-27-BASELINE.json` 实测数组为准,不写死条数;`inputBox.tsx` / `waitBox.tsx` 无 `TS7006`;`statistics.tsx` / `saves.ts` 只判「**无 NEW 诊断**」——它们既有基线条目(含 `saves.ts` 的既有模块解析诊断)不要求清零,但仍留在 scope 里以证明再导出没破;`status` 为 `1` / `2` 且输出含可解析 `error TS` 行不判失败,只有 `status === null` 或非零且无可解析诊断才算崩溃) | +| 声明改动范围 | Task 3 的掩码比对(`git show <基线 head>:src/client/misc/hotkey.ts`,**两侧先 CRLF → LF + 行尾空白 + 空行 + 首尾(EOF)换行归一化,再整段剔除新增的 `@ui/input` 分组**;ID 计数与 `priority` / `block` / `interval` / `mode` 判定基于剔除注释后的文本) | 掩掉 save 的 8 个 ID + 1 个 `block: true` 后与当前文件逐字相同 | +| 范围 | `git status --porcelain -uall -- <代码 14 + 两个消费方 + 三个规划产物>` | 代码侧恰等于 14 条期望状态;`statistics.tsx` / `saves.ts` 无状态行;`09-27-*` 允许未跟踪或已跟踪干净 | +| 哈希 / 提交洁净 | `09-27-BASELINE.json` + `git hash-object` / `merge-base` / `diff` | 5 个受保护文件哈希逐字相等;基线 head 是当前 HEAD 祖先;`<基线 head>..HEAD` 未触碰白名单代码路径(规划产物除外) | +| 迁移完整性 | Task 1 的 SPLIT GATE + Task 4 闸门 3 | 新文件导出齐全、旧文件定义与失效导入清空、barrel 三条再导出、`@/client/uiConstant`;SPLIT GATE 覆盖的是 `inputBox.tsx` / `waitBox.tsx` / `choices.tsx`(三者均无 `from '@user/client-base'`、均走具体模块相对路径),`background.tsx` 本身不依赖 client-base,由 Task 4 闸门 3 断言它不含 `from '@user/client-base'` 与 `from '@/client'` | +| 按键映射 | Task 2 的 KEY GATE + Task 4 闸门 7–12 | 12 条 save 映射、5 条 choices、7×settings、3 条 title、8 条 viewmap、1 条 inputBox(`@ui/input-confirm`,且不含 `@common/confirm`);旧 ID 与旧 API 全消失;**save 的翻页方向按 ID 分块断言**(逐 ID 从 `.implement(''` 切片到下一个 `.implement(`,只在该块内断言 `movePage(-1)` / `movePage(1)` / `movePage(-10)` / `movePage(10)`;整文件裸查 `movePage(...)` 不算通过) | +| 声明一致性 | Task 3 的 DECL GATE + Task 4 闸门 13–15 | ID 唯一、6 个新 ID 存在(含 `@ui/input-confirm` 恰好 1 次)、`block: true` 仅在 book、`@ui/input-confirm` 只绑 Enter(无 Space / KeyC / mode / priority / interval / block)、`@common/confirm` 三键不变、`addCommand` 数量 = 基线 + 1 | +| 未提交 | `git status` / `git merge-base` / `git diff --name-only`(只读) | 执行方没有提交任何本计划文件;改动全在工作树;HEAD 若因用户并发提交前移则记录提交清单 | +| 浏览器 / 键盘行为 | **未执行** | `IHotkeySystem.listen` 无生产调用(**预期状态**,不在本计划范围);SUMMARY 明确写「按键投递未接线、无浏览器 UAT、不宣称行为已验证」 | + +**明确不做:** 全量构建(`build:game` / `build:packages` / `build:lib`)、全仓类型 / lint 归零、任何 git 写命令、任何依赖安装、任何浏览器 UAT。 + + + +- [ ] Task 0 已汇报并收到用户恰好「可以执行」的回复;`save` 翻页方向(新定义)、十页按住重复两项已作为**用户已批准**记录;`@ui/input-confirm` / `@ui/input` 命名已在 Task 0 获得最终确认 +- [ ] `src/client/ui/inputBox.tsx` / `waitBox.tsx` 已新建,`Background` 已并入 `src/client/ui/background.tsx`,`MainBackground` / `MainBackgroundUI` 未被改动 +- [ ] `components/input.tsx` 只保留 `Input` 一族与 `routedInput*`,旧 `useKey()` + `KeyCode.Enter` 已移除,失效导入已清理 +- [ ] `components/misc.tsx` 只保留非 GameUI 组件,`Background` / `WaitBox` 两族与失效导入(`logger` / `GameUI` / `IUIController` / `SetupContext` / `IGameUIPropsBase`)已移除 +- [ ] `components/index.ts` 追加 `background` / `inputBox` / `waitBox` 三条 `export *`,既有 `choices` 行未改 +- [ ] `background.tsx` 从 `@/client/uiConstant` 导入尺寸常量,三个再导出文件都不含 `from '@/client'` +- [ ] `waitBox.tsx` 从 `./background` 导入 `BackgroundProps`(`WaitBoxProps extends Partial`) +- [ ] `inputBox.tsx` / `waitBox.tsx` 的 client-base 依赖全部走具体模块(相对路径),两文件均无 `from '@user/client-base'` +- [ ] `choices.tsx` 从 `./background` 导入 `Background`,其余 client-base 依赖全部走具体模块(文件中无 `from '@user/client-base'`);5 条命令改为 `@common/select-*` / `@common/confirm` +- [ ] `save.tsx` 12 条映射全部就位(含 `@ui/save-page-prev-10` / `-next-10`),翻页方向按用户已批准的新定义 prev=-1 / next=+1 / 十页 ±10,props 数组含 `'state'`;**方向可证伪**:逐 ID 从 `.implement(''` 切片到下一个 `.implement(`,只在该块内断言对应 `movePage(...)`(整文件裸查 `movePage(...)` 不算通过) +- [ ] `settings.tsx` 7 个子组件用 `useKey(props.state!.hotkey.system)!` + `@common/exit`,7 处 `` 都有 `freshScope={false}` + `state={props.state!}`,无 `scope` 残留,`MainSettingsProps` 未改,VirtualKey 分支未改 +- [ ] `gameKey` 负向不变式成立(Task 2 的 GAMEKEY GATE + Task 4 闸门 10 双侧断言,均先剥离注释):`save.tsx` / `title.tsx` / `viewmap.tsx` / `choices.tsx` / `inputBox.tsx` 的 `gameKey` 出现次数为 0;`settings.tsx` 仍只有 1 条 `@motajs/system` 的 `gameKey` 导入 + 1 处 `gameKey.emitKey(`(VirtualKey 分支),无任何新增 legacy 用法 +- [ ] `title.tsx` 3 条映射(`@ui/start-up` / `@ui/start-down` / `@common/confirm`) +- [ ] `viewmap.tsx` 8 条映射就位、`willEmit` 特判删除、`ViewMapProps` 改 `IGameUIPropsBase`、props 含 `'state'` +- [ ] `inputBox.tsx` 用新 `useKey` 实现 `@ui/input-confirm`(只认 Enter、**不**复用 `@common/confirm`)并 blur 当前输入元素 +- [ ] `packages-user/client-base/src/use/hotkey.ts` 改为 `onBeforeMount`,离开仍 `onUnmounted`,字段名未改 +- [ ] `src/client/misc/hotkey.ts` 6 处声明修正到位(`@ui/save-right/down/left`、`@ui/save-page-prev-10` / `-next-10`、`@ui/view-map-book` 的 `block: true`),并新增分组 `@ui/input`(`输入框`)内唯一一条 `@ui/input-confirm`(`defaults: [[KeyCode.Enter]]`、默认 `mode`、无 `priority` / `interval` / `block`);既有默认键位 / mode / interval / priority / 文案零改动;ID 全唯一;`system.addCommand(` 数量 = 基线 + 1 +- [ ] 新闸门 `keyAdaptationGate.test.ts` 15 个 `it` 全绿且每条前有注释 +- [ ] eslint 无新条目(基线校验失败 / 空输出 / JSON 不可解析即失败;`status === 1` 仅在基线已含 severity=2 条目时被接受;集合含两个新文件);`pnpm check:type` 在改动文件集合内无新诊断(基线条目一律以 `09-27-BASELINE.json` 为准,不写死条数;`inputBox.tsx` / `waitBox.tsx` 无 `TS7006`;`vue-tsc` 的 `status` 为 `1` / `2` 且输出含可解析 `error TS` 行属预期状态,只有 `status === null` 或非零且无可解析诊断才算崩溃) +- [ ] 范围闸门:代码侧 `git status --porcelain -uall` 恰为 14 条期望状态;`statistics.tsx` / `saves.ts` 无改动;三个 `09-27-*` 规划产物允许未跟踪或已跟踪干净 +- [ ] 哈希 / 提交洁净闸门:5 个受保护文件哈希与 `09-27-BASELINE.json` 逐字相等;基线 head 是当前 HEAD 的祖先;`<基线 head>..HEAD` 未触碰白名单代码路径(规划产物除外) +- [ ] `09-27-SUMMARY.md` 写明「save 翻页方向(用户已批准的新定义)/ 十页按住重复(用户已批准)/ `@ui/input-confirm` 只绑 Enter 且不复用 `@common/confirm` / 按键投递(`IHotkeySystem.listen`)未接线属预期状态 / 无浏览器 UAT / 代码未提交、工作树留给用户审查 / estimate 超额且 D-01 未拆分」 +- [ ] 全程未运行任何 git 写命令 + + + +Create `.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md` when done + diff --git a/.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md b/.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md new file mode 100644 index 0000000..b8e3327 --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md @@ -0,0 +1,269 @@ +--- +phase: 09-minimum-browser-runnable +plan: 27 +subsystem: ui-hotkey +tags: [hotkey, game-ui, component-split, barrel, source-gate, vitest] + +requires: + - phase: 09-minimum-browser-runnable + provides: 09-26 的源码形状闸门形态与「基线 + 执行前后真值比较」门禁写法 + +provides: + - "InputBox / WaitBox / Background 三个 GameUI 组件按定义拆到 src/client/ui/{inputBox,waitBox,background}.tsx,原文件保留并被裁剪" + - "client-base barrel 以既有 choices 同款相对路径再导出三个搬迁文件,waitbox / getInput / Background 的公共导入面保持不变" + - "choices / save / settings / title / viewmap / inputBox 六处按键实现迁到新热键系统(useKey(system, clearImpl) + implement)" + - "use/hotkey.ts 的进入作用域时机由 onMounted 改为 onBeforeMount" + - "src/client/misc/hotkey.ts 修正 6 处重复 / 错误声明,并新增 @ui/input-confirm(分组 @ui/input,只绑 Enter)" + - "新增源码形状闸门 src/client/ui/__test__/keyAdaptationGate.test.ts(15 条断言)" + +affects: [按键投递接线(IHotkeySystem.listen)后续计划, Phase 10 文档] + +actuals: + tokens: 20904 + tasks: 5 + commits: 0 + +tech-stack: + added: [] + patterns: + - "GameUI 组件下沉到 src/client/ui,client-base barrel 用相对路径 export * 保持公共面" + - "被 barrel 再导出的文件只从具体模块(相对路径)导入 client-base 依赖,避免直接回引根 barrel" + - "新热键系统写法:useKey(props.state.hotkey.system[, clearImpl])! + key.implement('', fn)" + - "子作用域继承父级实现: + useKey(props.state!.hotkey.system)!" + - "声明层用 block: true 取代实现层的 willEmit 特判" + +key-files: + created: + - src/client/ui/inputBox.tsx + - src/client/ui/waitBox.tsx + - src/client/ui/__test__/keyAdaptationGate.test.ts + - .planning/phases/09-minimum-browser-runnable/09-27-BASELINE.json + - .planning/phases/09-minimum-browser-runnable/09-27-SUMMARY.md + modified: + - packages-user/client-base/src/components/index.ts + - packages-user/client-base/src/components/input.tsx + - packages-user/client-base/src/components/misc.tsx + - packages-user/client-base/src/use/hotkey.ts + - src/client/ui/background.tsx + - src/client/ui/choices.tsx + - src/client/ui/save.tsx + - src/client/ui/settings.tsx + - src/client/ui/title.tsx + - src/client/ui/viewmap.tsx + - src/client/misc/hotkey.ts + deleted: [] + +key-decisions: + - "save 翻页方向采用用户已批准的新定义:prev = movePage(-1)、next = movePage(1)、十页 = ±10(相对旧实现方向相反)" + - "十页翻页保持声明层的 Continuous/500「按住重复」(用户已批准)" + - "inputBox 使用专用 @ui/input-confirm(只绑 Enter),不复用 @common/confirm,因此不覆盖 Space / KeyC" + - "src/client/ui/waitBox.tsx 的 onResolve: (data: T) 在 strictFunctionTypes 下产生 TS2322,用户裁定为可接受的设计层类型错误,不加 @ts-expect-error、不改签名" + - "settings.tsx 的 11 条既有诊断仅因消息文本变化(props 形状 / 泛型默认参数显示),用户批准按「同文件 + 同 TS 码 + 各码计数不变」视为非新增" + - "按用户要求未运行任何 git 写命令,代码改动全部留在工作树供用户审查" + - "不做浏览器 UAT,不宣称任何按键行为已验证;IHotkeySystem.listen 未接线属预期状态" + +patterns-established: + - "barrel 再导出跨目录文件时,被导出文件改用具体模块相对路径导入,避免 barrel↔文件直接环" + - "settings 的 7 个 Choices 子作用域统一传 freshScope={false} + state={props.state!},父级 @common/exit 得以保留" + - "声明层同键冲突用 block: true 阻断,实现层不再写 willEmit 特判" + +requirements-completed: [] + +coverage: + - id: T1 + description: "三个 GameUI 组件按定义搬迁、旧文件裁剪、barrel 再导出、background.tsx 改用叶子常量" + verification: + - kind: unit + ref: "keyAdaptationGate.test.ts#moves the input box family into src/client/ui/inputBox.tsx" + status: pass + - kind: unit + ref: "keyAdaptationGate.test.ts#moves the wait box family into src/client/ui/waitBox.tsx" + status: pass + - kind: unit + ref: "keyAdaptationGate.test.ts#adds the background component next to the main background" + status: pass + human_judgment: false + - id: T2 + description: "六个界面文件迁到新热键系统并命中新命令 ID" + verification: + - kind: unit + ref: "keyAdaptationGate.test.ts#maps all twelve save commands and keeps the page directions" + status: pass + - kind: unit + ref: "keyAdaptationGate.test.ts#removes the old key api from all migrated ui files" + status: pass + - kind: unit + ref: "keyAdaptationGate.test.ts#keeps the settings parent scope and leaves game key usage untouched" + status: pass + human_judgment: false + - id: T3 + description: "声明层 6 处修正 + 新增 @ui/input-confirm" + verification: + - kind: unit + ref: "keyAdaptationGate.test.ts#declares every command id exactly once" + status: pass + - kind: unit + ref: "keyAdaptationGate.test.ts#blocks the exit command only for the view map book key" + status: pass + - kind: unit + ref: "keyAdaptationGate.test.ts#binds input confirm to enter only and keeps common confirm" + status: pass + human_judgment: false + +duration: 未精确记录 +completed: 2026-10-25 +status: partial +--- + +# Phase 9 Plan 27: 按键适配与 GameUI 组件拆分 Summary + +把 `InputBox` / `WaitBox` / `Background` 三个 GameUI 组件从 `client-base` 按定义拆到 `src/client/ui`,六个界面的按键实现整体迁到新热键系统,并修正声明层的重复 / 错误 ID、新增只绑 Enter 的输入框确认指令。 + +## Accomplishments + +- **组件搬迁(只搬定义,不搬整文件)**:`InputBox` / `InputBoxUI` / `getInput` / `getInputNumber` / `InputBoxProps` / `InputBoxEmits` → 新建 `src/client/ui/inputBox.tsx`;`WaitBox` / `WaitBoxUI` / `waitbox` / `WaitBoxProps` / `WaitBoxEmits` / `WaitBoxExpose` → 新建 `src/client/ui/waitBox.tsx`;`Background` / `BackgroundUI` / `BackgroundProps` → 追加进既有 `src/client/ui/background.tsx`(`MainBackground` / `MainBackgroundUI` 原样保留)。 +- **旧文件裁剪**:`components/input.tsx` 只保留 `Input` 一族与 `routedInput*`,旧 `const [key] = useKey()` + `KeyCode.Enter` 特判整块移除;`components/misc.tsx` 只保留 `Progress` / `Arrow` / `ScrollText` / `Selection`,两族 GameUI 组件与失效导入(`logger` / `GameUI` / `IUIController` / `SetupContext` / `IGameUIPropsBase`)一并移除。 +- **公共导入面不变**:`components/index.ts` 在既有 `choices` 行之后追加 `background` / `inputBox` / `waitBox` 三条相对路径 `export *`;`statistics.tsx` 与 `client-modules/src/render/utils/saves.ts` 一行未改。 +- **barrel 图卫生**:`background.tsx` 的尺寸常量改从叶子模块 `@/client/uiConstant` 导入;`inputBox.tsx` / `waitBox.tsx` / `choices.tsx` 的 client-base 依赖全部走具体模块相对路径(文件中无 `from '@user/client-base'`)。 +- **按键迁移**:6 个 UI 文件全部改为 `useKey(props.state.hotkey.system[, clearImpl])!` + `key.implement(...)`;`settings.tsx` 额外用 `props.state!` 与 `freshScope={false}` / `state={props.state!}` 让子作用域继承父级实现;`viewmap.tsx` 删除 `willEmit('book')` 特判并由声明层 `block: true` 取代。 +- **作用域时机**:`packages-user/client-base/src/use/hotkey.ts` 的进入作用域回调由 `onMounted` 改为 `onBeforeMount`(离开仍 `onUnmounted`)。 +- **声明修正(6 处 + 1 条新增)**:3 条重复的 `@ui/save-up` 拆成 `@ui/save-right` / `@ui/save-down` / `@ui/save-left`;两条十页 ID 改为 `@ui/save-page-prev-10` / `@ui/save-page-next-10`;`@ui/view-map-book` 加 `block: true`;新增分组 `@ui/input`(`输入框`)与其唯一命令 `@ui/input-confirm`(`输入框确认`,`defaults: [[KeyCode.Enter]]`,默认 `mode`)。`system.addCommand(` 由 62 增至 63。 + +## `settings.tsx` 的作用域继承 + +7 个子组件的按键都写成 `const key = useKey(props.state!.hotkey.system)!;` + `key.implement('@common/exit', …)`,局部 `scope` 变量与所有 `scope={scope}` 均已删除;7 处 `` 同时传 `freshScope={false}` 与 `state={props.state!}`,因此子作用域以 `clearImpl = false` 进入,父级 `@common/exit` 不会被清空。`MainSettingsProps` 的 `extends Partial, IUIPropsBase` 形态未改,VirtualKey 分支(`getVitualKeyOnce` + `gameKey.emitKey`)原样保留。 + +## 逐文件命令映射 + +| 文件 | 旧 ID | 新 ID | +|------|-------|-------| +| `choices.tsx` | `moveUp` / `moveDown` / `moveLeft` / `moveRight` / `confirm` | `@common/select-up` / `@common/select-down` / `@common/select-left` / `@common/select-right` / `@common/confirm` | +| `save.tsx` | `confirm` / `exit` / `save` / `load` | `@common/confirm` / `@common/exit` / `@ui/save` / `@ui/load` | +| `save.tsx` | `@save_up` / `@save_down` / `@save_left` / `@save_right` | `@ui/save-up` / `@ui/save-down` / `@ui/save-left` / `@ui/save-right` | +| `save.tsx` | `@save_pageUp` / `@save_pageDown` | `@ui/save-page-prev` / `@ui/save-page-next` | +| `save.tsx` | (无) | `@ui/save-page-prev-10` / `@ui/save-page-next-10` | +| `settings.tsx` | `exit`(7 处) | `@common/exit`(7 处) | +| `title.tsx` | `@start_up` / `@start_down` / `confirm` | `@ui/start-up` / `@ui/start-down` / `@common/confirm` | +| `viewmap.tsx` | `@viewMap_up` / `@viewMap_down` / `@viewMap_up_ten` / `@viewMap_down_ten` | `@ui/view-map-next` / `@ui/view-map-prev` / `@ui/view-map-next-10` / `@ui/view-map-prev-10` | +| `viewmap.tsx` | `@viewMap_book` / `@viewMap_fly` / `confirm` / `exit` | `@ui/view-map-book` / `@ui/view-map-fly` / `@common/confirm` / `@common/exit` | +| `inputBox.tsx` | (无) | `@ui/input-confirm`(只由 Enter 触发,blur 当前聚焦的 input / textarea) | + +## 已批准的行为口径(用户已批准,作为事实记录) + +1. **save 翻页方向(新定义)**:`@ui/save-page-prev` → `movePage(-1)`、`@ui/save-page-next` → `movePage(1)`、`@ui/save-page-prev-10` → `movePage(-10)`、`@ui/save-page-next-10` → `movePage(10)`。旧实现是 PageUp → `movePage(1)`、PageDown → `movePage(-1)`,方向相反;用户已批准采用新定义。 +2. **十页按住重复**:`viewmap` / `save` 的十页命令在声明层是 `Continuous` / `interval: 500`,按住会连续跳十页(旧 `@viewMap_*_ten` 是单发);用户已批准保持按住重复。 +3. **输入框只认 Enter**:`inputBox` 使用专用 `@ui/input-confirm`(`defaults: [[KeyCode.Enter]]`),**不再**复用 `@common/confirm`,因此**不覆盖** Space / KeyC;`@common/confirm` 仍是 Enter / Space / KeyC 三键。 + +以上都只是声明层语义,**未经浏览器验证**。 + +## 搬迁时的两处类型注解(行为不变) + +- `getInput` 的 `onConfirm: (value: string)` +- `waitbox` 的 `onResolve: (data: T)` + +目的:让旧文件里既有的 `TS7006` 隐式 `any` 不随搬迁在新文件里重现。 + +## 按键投递未接线声明(预期状态) + +`IHotkeySystem.listen` 目前没有任何生产代码调用,**按键不会真的送达 UI**。这是**已知且预期**的现状,**不在本计划范围**,本计划不把它列为待修问题,只做「实现层 ↔ 架构层」的接口对齐。因此本计划**不宣称任何浏览器 / 键盘行为已验证**,浏览器 UAT 全程跳过。 + +## Task Commits + +**无。** 按用户要求未运行任何 git 写命令(`add` / `commit` / `stash` / `checkout` / `restore` / `worktree` / `reset`),代码改动全部留在工作树供用户审查。 + +## Verification + +### 源码形状闸门(vitest) + +`cmd /c "pnpm exec vitest run src/client/ui/__test__/keyAdaptationGate.test.ts"` → **15 / 15 passed**(1 个测试文件)。测试文件结构闸门:15 个 `it`、每个紧邻上方一行 `//` 注释、只导入 `vitest` 与 `?raw`、不使用 `node:fs` → PASS。 + +### eslint(基线相对,多重集只判 NEW) + +`pnpm exec eslint -f json <改动文件集合>`: + +- Task 1 子集:基线 11 → 当前 0,无 NEW。 +- Task 2 子集(5 个 UI + `use/hotkey.ts`):基线 11 → 当前 9,无 NEW。 +- Task 3 子集(`src/client/misc/hotkey.ts`):基线 11 → 当前 0,无 NEW。 +- Task 5 全集合(16 个路径,含两个新文件与两个 barrel 消费方):基线 11 → 当前 11,无 NEW;`status === 1` 仅因基线本身已含 severity-2 条目(`statistics.tsx` / `viewmap.tsx` 的既存 unused-vars),按计划口径接受。 + +### 类型闸门(基线相对) + +`pnpm check:type`(`vue-tsc --noEmit`,整仓运行)→ `status === 2`,输出含可解析 `error TS` 行,属**预期状态**(全仓既有诊断),不判崩溃。 + +- 改动文件集合内基线 89 条 → 当前 81 条;仓库总诊断 229 → 221。 +- 除下述两项已批准例外外,**没有新增诊断点**;`inputBox.tsx` / `waitBox.tsx` 中没有 `TS7006`。 +- `statistics.tsx` 与 `saves.ts` 两个 barrel 消费方各自仍只有基线条目,未出现 NEW。 + +### 声明掩码比对 + +`git show 6846c21…:src/client/misc/hotkey.ts` 与当前文件:两侧先做 `\r\n` → `\n`、行尾空白、空行与首尾换行归一化,再整段剔除新增的 `@ui/input` 分组,最后掩掉 save 的 8 个 ID 与 1 个 `block: true`,**逐字相同**;声明扫描基于剔除注释后的文本。`system.addCommand(` 由基线 62 → 63;`block: true` 由 0 → 1;全部命令 ID 唯一。 + +### 范围闸门 + +`git status --porcelain -uall -- <白名单>`:代码侧恰为 14 条期望状态(11 个 ` M` + 3 个 `??`);`src/client/ui/statistics.tsx` 与 `packages-user/client-modules/src/render/utils/saves.ts` **没有任何状态行**;三个 `09-27-*` 规划产物为未跟踪。PASS。 + +### 哈希 / 未提交闸门 + +- 基线 head:`6846c211c73854762c782e8cde82e81b0191c524`(`git merge-base --is-ancestor` 成立;`<基线 head>..HEAD` 提交清单:无)。 +- `src/client/ui/toolbar.tsx`:`bcfec1e3e8c38848c91a2258db63f943dec4131f` +- `src/client/ui/index.ts`:`4e3950598b1beee23753a5556d8e8c82eb21bc57` +- `packages-user/client-base/src/components/textbox.tsx`:`b17e988d77c3a81fe3ce3250940e31a9774f8dfb` +- `packages-user/client-modules/src/render/use.ts`:`55b6aaa0cbb2e007313825da1b1e11104f71dd5a` +- `packages-user/client-modules/src/ui/types.ts`:`8a9288918be5a836d8c06c7ca44e0c9ccc03cf81` + +五个哈希与基线逐字相等;基线 head 是当前 HEAD 的祖先;`<基线 head>..HEAD` 没有提交触碰任何白名单代码路径。**执行方未提交任何代码。** + +## Accepted Exceptions(用户已批准的例外) + +1. **`src/client/ui/waitBox.tsx` 的设计层类型错误(无抑制)**。`WaitBoxUI` 的泛型 emits 被擦除为 `WaitBoxEmits`,因此 `controller.open` 期望 `onResolve?: (data: unknown) => any`,而计划要求保留显式参数注解 `onResolve: (data: T) => { … }`。在 `strict: true`(`strictFunctionTypes`)下二者不兼容。原文: + + ``` + src/client/ui/waitBox.tsx(181,13): error TS2322: Type '(data: T) => void' is not assignable to type '(data: unknown) => any'. + ``` + + 用户裁定:这是**可接受的设计层类型错误**,保留 `(data: T)` 原样,**不添加** `@ts-expect-error`、不改成 `unknown`、不改 `GameUI`。Task 1 的 MOVED TYPECHECK 门禁与 Task 5 的类型门禁均只对这**一条**放行;其它任何 `inputBox.tsx` / `waitBox.tsx` 诊断仍会判失败。 + +2. **`src/client/ui/settings.tsx` 的消息文本变化(同文件 + 同 TS 码 + 各码计数不变)**。77 条诊断前后数量不变,按 TS 码分布也不变;仅消息文本因计划要求的改动而变化: + + - 7 条 `TS2322`:`` 的 props 对象形状由 `… interval: number; scope: symbol; }` 变为 `… interval: number; freshScope: false; state: IClientBase; }`(`scope={scope}` → `freshScope={false}` + `state={props.state!}` 的必然结果)。 + - 4 条 `TS2345`:目标类型由 `IUIController` 显示为 `IUIController>`。原因是搬迁后的 `getInput` / `waitbox` 按计划从**正确**的 `packages-user/client-base/src/types` 导入 `IGameUIPropsBase`(该接口带默认泛型参数),旧文件从不可解析的 `client-modules/src/types` 取到的是未解析类型。 + - 用户裁定:这些是**消息文本层面**的变化,不是新增诊断点;类型门禁对 `settings.tsx` 采用「同文件 + 同 TS 码 + 各码计数不得增加」的判据。任何真正的新增诊断点或某码计数增加仍会判失败。 + +## Deviations / Issues + +- **Task 0 第 ⑭ 条批准的两条既有注释已随被删代码一并移除**:`components/input.tsx` 的 `// 特判回车键`、`src/client/ui/viewmap.tsx` 的 `// 如果按键不能触发怪物手册,则关闭界面,因为怪物手册和退出默认使用同一个按键,需要特判`。除此之外未改写任何既有注释 / jsDoc。 +- **`TS7006` 的两处消解**:`getInput` / `waitbox` 按计划补显式参数注解,旧文件中的两条隐式 `any` 不再出现在新文件里(新文件零诊断,唯 waitBox TS2322 例外)。 +- **`viewmap.tsx` 的 `// .realize('@viewMap_reset', () => resetCamera())`** 一行按计划原样保留(闸门先剔除行注释,不参与判定)。 +- 未发现其它需要修正的问题;未做全仓类型 / lint 归零,未跑构建。 + +## Known Limits / OI-2(只记录,不修复) + +1. `IHotkeySystem.listen` 无生产调用、按键投递未接线——**预期状态**,不在本计划范围。 +2. `src/client/ui/toolbar.tsx` 与 `settings.tsx` 的 VirtualKey 分支仍是 legacy `gameKey`。 +3. 旧 `@user/client-modules` 的 `useKey` 变为无引用但保留。 +4. `MainBackground` / `MainBackgroundUI` 随 `background.tsx` 的 `export *` 进入 `client-base` 公共面。 +5. `src/client/ui/index.ts` 未加 `inputBox` / `waitBox` 导出;其中既有的 `export * from './background';` 会自动扩大该 barrel 的有效导出面(无害,未编辑该文件)。 +6. 游戏当前不可运行,浏览器 UAT 全程跳过。 +7. 三个被再导出的文件的**直接** barrel 回引已改为具体模块导入;`client-base` 内部既有的 `textbox → elements → map` 与 `types.ts → ./map` 回引链早于本计划存在,**不在本计划范围**,`pnpm check:circular` 未运行,**不得**表述为「循环已全部解决」。 + +## estimate 声明 + +`estimate.tokens = 120000`、`raw_tokens` 同为 120000、`tasks = 6`、`confidence: low`,为**未校准估算**。120k 约为 smart-zone 单计划预算的 1.2×;按 D-01 不得未经用户同意拆分,本计划未拆分、未砍范围、未跳过门禁。**用户已在 Task 0 的决策中批准接受该估算超额。** + +## 未被触碰的文件 + +- `packages-user/client-base/src/map/renderer.ts` / `map/shader/map.frag` / `map/status.ts` / `map/types.ts` / `map/vertex.ts` / `map/__test__/rendererGate.test.ts`(09-26 留给用户的未提交改动)未触碰。 +- `.planning/phases/10-docs-restructure/10-CONTEXT.md`(用户并发脏文件)未触碰。 +- `src/client/ui/statistics.tsx`、`packages-user/client-modules/src/render/utils/saves.ts`、`src/client/ui/toolbar.tsx`、`src/client/ui/index.ts`、`packages-user/client-base/src/components/textbox.tsx`、`packages-user/client-modules/src/render/use.ts`、`packages-user/client-modules/src/ui/types.ts` 一行未改(五个受保护文件哈希见上)。 + +## Self-Check: PASSED + +- `src/client/ui/inputBox.tsx` / `waitBox.tsx` / `__test__/keyAdaptationGate.test.ts` 均存在,PASSED +- `09-27-BASELINE.json` 恰含 `head` + 5 个完整路径哈希 + `eslint` / `typecheck` / `addCommandCount`,PASSED +- 源码闸门 15 / 15 passed;测试文件结构闸门 15 个 `it` 均有前置注释、仅 `vitest` / `?raw` 导入,PASSED +- eslint 全集合基线 11 → 11,无 NEW;`status === 1` 仅因基线已含 severity-2 条目,PASSED +- 类型闸门:基线 89 → 81,仓库 229 → 221;仅 waitBox TS2322(已批准)与 settings.tsx 消息文本变化(已批准),PASSED +- 范围闸门:代码侧恰 14 条期望状态,两个 barrel 消费方无状态行,三个规划产物未跟踪,PASSED +- 哈希闸门:基线 head 是 HEAD 祖先、`<基线 head>..HEAD` 未触碰白名单代码路径、5 个受保护文件哈希逐字相等,PASSED +- 全程未运行任何 git 写命令,代码改动全部留在工作树,PASSED +- 按用户要求未做浏览器 UAT、未宣称按键行为已验证,PASSED