From e2415a7a48d9a9e9d2f886e00c10ca51e91340d1 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Tue, 27 Oct 2026 18:37:26 +0800 Subject: [PATCH] docs(09-29): add UI container optimization plan and execution summary (code uncommitted) --- .../09-29-BASELINE.json | 39 ++ .../09-29-NOTES.md | 78 +++ .../09-minimum-browser-runnable/09-29-PLAN.md | 560 ++++++++++++++++++ .../09-29-SUMMARY.md | 120 ++++ 4 files changed, 797 insertions(+) create mode 100644 .planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json create mode 100644 .planning/phases/09-minimum-browser-runnable/09-29-NOTES.md create mode 100644 .planning/phases/09-minimum-browser-runnable/09-29-PLAN.md create mode 100644 .planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md diff --git a/.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json b/.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json new file mode 100644 index 0000000..812f045 --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json @@ -0,0 +1,39 @@ +{ + "head": "9bf8f296c47a85f19c762072f9b73214fe329ee3", + "hashes": { + "packages-user/client-base/src/components/scroll.tsx": "74fb21f5c9d9c7329e99b8345ba6d7ee7b1eae48", + "packages-user/client-base/src/components/textbox.tsx": "b17e988d77c3a81fe3ce3250940e31a9774f8dfb", + "src/client/ui/settings.tsx": "c5cca14cde8ba813ac4ef5e624e8b359470c472d", + "src/client/ui/index.ts": "4e3950598b1beee23753a5556d8e8c82eb21bc57", + "src/client/misc/hotkey.ts": "6e884ab4f36cdff958a3490697406a00f67d2e7f" + }, + "dirty": [ + "AGENTS.md", + "docs/advanced/iii/anon-tokyo.md", + "docs/advanced/iii/core-state.md", + "docs/advanced/iii/event-system.md", + "docs/advanced/iii/index.md", + "docs/advanced/iii/loading-system.md", + "docs/advanced/iii/passability-and-pathfinding.md", + "docs/advanced/iii/plugin-internals.md", + "docs/advanced/iii/replay-internals.md", + "packages-user/client-base/src/index.ts", + "packages-user/client-base/src/map/__test__/rendererGate.test.ts", + "packages-user/client-base/src/map/renderer.ts", + "packages-user/client-base/src/map/shader/map.frag", + "packages-user/client-base/src/map/status.ts", + "packages-user/client-base/src/map/types.ts", + "packages-user/client-base/src/map/vertex.ts", + "packages-user/client-base/src/setting/index.ts", + "packages-user/client-base/src/setting/types.ts" + ], + "eslint": [], + "typecheck": [ + "src/client/ui/book.tsx error TS2322: Type 'number' is not assignable to type 'AllNumbers | keyof IdToNumber'.", + "src/client/ui/equipbox.tsx error TS2322: Type 'number' is not assignable to type 'AllNumbers | keyof IdToNumber'.", + "src/client/ui/equipbox.tsx error TS2322: Type 'number' is not assignable to type 'AllNumbers | keyof IdToNumber'.", + "src/client/ui/equipbox.tsx error TS2322: Type 'number' is not assignable to type 'AllNumbers | keyof IdToNumber'.", + "src/client/ui/toolbox.tsx error TS2322: Type 'number' is not assignable to type 'AllNumbers | keyof IdToNumber'.", + "src/client/ui/toolbox.tsx error TS2322: Type 'number' is not assignable to type 'AllNumbers | keyof IdToNumber'." + ] +} diff --git a/.planning/phases/09-minimum-browser-runnable/09-29-NOTES.md b/.planning/phases/09-minimum-browser-runnable/09-29-NOTES.md new file mode 100644 index 0000000..f4795b6 --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-29-NOTES.md @@ -0,0 +1,78 @@ +# 09-29 待规划记录:UI 容器 / Scroll / 缓存优化 + +**状态**:待规划,尚未修改任何源码。 +**记录日期**:2026-10-26 +**来源**:用户对 09-28 `hotkey.tsx` 及 `book` / `bookDetail` / `equipbox` / `toolbox` 的复查意见。 +**参考文档**:`docs/guide/ui/perf.md`(已删除,可从 `e3d61ee^:docs/guide/ui/perf.md` 取回)。 + +## 核心原则 + +- `container` 在本渲染系统中会创建 canvas 缓存层,不是 HTML 的普通 `div`;嵌套越深,缓存与内存压力越大。 +- `Scroll` 内部应直接平铺 `text` / `Selection` / `icon` / `g-rect*` 等元素;不要再套一层大 `container`,否则 `Scroll` 只能看到单个子元素,虚拟滚动失效。 +- `Scroll` 自身已经包含 `container`,不要在 `Scroll` 外面再套 `container`。 +- `Selection`、`text` 不需要单独容器;能直接写的就直写。 +- 必须使用 `container` 且内容较少时,加 `nocache` 禁用内部缓存。 +- 需要条件显示/隐藏时优先使用 `hidden` 属性,不要用 `{cond && <...>}` 之类的条件渲染。 +- `loc` 过长导致换行时,先在顶层声明为常量,再在 JSX 中引用,避免缩进层级过深。 + +## 具体待办 + +### 1. `book.tsx` + +- 「临界」「伤害」「减伤」「1 防」等信息即使没有 `critical` 也必须显示,缺失值显示 `0`,不能整项隐藏。 +- `Scroll` 当前直接子元素是每行 `container`,虚拟滚动正常;但行容器约 11 个子元素,默认建缓存。评估改为直接平铺,或至少加 `nocache`。 +- 全屏根 `container` 是否加 `nocache` 一并评估。 + +### 2. `bookDetail.tsx` + +- 删除特殊属性列表每行的 `container`,把 `text` / `TextContent` 直接作为 `Scroll` 子元素,按行高定位。 +- `TextContent` 当前未获取实际高度,长描述会重叠;必须通过其高度回调/`autoHeight` 获取高度,并据此计算每行高度与后续行 `loc`,必要时让 `Scroll` 内容高度随之更新。 +- 「临界」「加攻」「减伤」等信息即使没有 `critical` 也必须显示,缺失值显示 `0`。 +- 全屏根 `container` 是否加 `nocache` 一并评估。 + +### 3. `equipbox.tsx` + +- 减少 `container` 嵌套: + - 描述区、装备槽区、拥有装备区外的 `container` 与内部 `Scroll` 重复定位,移除多余外层。 + - 每行槽位 / 拥有装备 / 状态行的容器按内容量处理:能平铺则平铺,否则加 `nocache`。 +- 条件显示改为 `hidden`,不要用条件渲染。 +- 全屏根 `container` 是否加 `nocache` 一并评估。 + +### 4. `toolbox.tsx` + +- 两列列表的每行 `container` 内容较少,评估直接平铺或加 `nocache`。 +- 条件显示改为 `hidden`。 +- 全屏根 `container` 是否加 `nocache` 一并评估。 + +### 5. `hotkey.tsx` + +- P0:两个 `Scroll` 内全部平铺,删除「内容总高容器」「指令行容器」「按键区容器」「按键框容器」。 +- 左栏直接写分组 `text` + 选中 `Selection`;右栏直接写指令名称 `text` + 每个组合的 `g-rectr` 点击区 + `Selection` + `text`。 +- 根 `container` 加 `nocache`。 +- 用一次遍历预计算行/键位 `top`,删除 O(n²) 的 `getCommandTop()`。 +- 保持无入口、无持久化、无冲突检测、无恢复默认、无滚动条外容器。 + +### 6. `inputBox.tsx` / `waitBox.tsx` + +- 各有 1 层根 `container`,内容较少;评估加 `nocache`。 + +## 其他全局检查 + +- 检查所有被本阶段触达的 UI,`: 条件渲染`是否应改为 `hidden`。 +- 检查所有 `Scroll` 是否有多余外层 `container`。 +- 检查所有长 `loc` 是否应提取为常量。 +- 不修改既有注释/jsDoc;新增注释遵循 dev.md。 + +## 验证预期 + +- eslint。 +- `pnpm check:type` 前后基线,只判新增诊断。 +- 源码闸门(`?raw`)断言:`Scroll` 内无包裹容器、`hidden` 使用、`nocache`、`loc` 常量、`book`/`bookDetail` 的 0 值显示。 +- 不做浏览器 UAT,不提交代码;视觉/滚动/内存表现留给用户人工确认。 + +## 明确不做 + +- 不接入口。 +- 不做持久化。 +- 不做冲突检测 / 恢复默认。 +- 不改按键注册表。 diff --git a/.planning/phases/09-minimum-browser-runnable/09-29-PLAN.md b/.planning/phases/09-minimum-browser-runnable/09-29-PLAN.md new file mode 100644 index 0000000..21c6b36 --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-29-PLAN.md @@ -0,0 +1,560 @@ +--- +phase: 09-minimum-browser-runnable +plan: 29 +type: execute +# 24:沿用本阶段既有计划的波次编号,使本计划排在 09-01..09-28 之后(D-01 增量派发,计划累积) +# depends_on 为空:本计划触碰的 5 个 UI 文件与 1 个闸门测试没有任何并发计划在本波次触碰,故结构上独立; +# 运行在最后的波次只是「排期在后」,不是「依赖前序计划」 +wave: 24 +depends_on: [] +# 09-29-BASELINE.json 由 Task 1 在收到「可以执行」之后、编辑任何代码之前写出(Task 0 只汇报、不写文件) +files_modified: + - src/client/ui/book.tsx + - src/client/ui/bookDetail.tsx + - src/client/ui/equipbox.tsx + - src/client/ui/toolbox.tsx + - src/client/ui/hotkey.tsx + - src/client/ui/__test__/uiContainerGate.test.ts + - .planning/phases/09-minimum-browser-runnable/09-29-PLAN.md + - .planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json + - .planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md +autonomous: false +requirements: [] +estimate: + # 未校准估算(confidence: low),SUMMARY 须声明这一点: + # 本计划改 5 个既有 UI 文件 + 新增 1 个闸门测试 + 3 个 .planning 产物; + # 改动本身是「删包装容器 + 平铺子元素 + 循环无关的长 loc 提常量 + 0 值回退 + 高度测量 + key 重挂载」, + # 主要成本在逐文件读 Scroll / TextContent / 图标原语语义、 + # 以及跑两轮 eslint 与一次整仓 vue-tsc(基线 / 收尾)与 vitest。 + # tokens 取 raw 的 2 倍,与 09-19..09-28 的换算口径一致;仍属未校准,SUMMARY 如实记录。 + # 90k 低于 smart_zone_tokens 默认 100k;若执行期仍觉过大,Task 0 ⑫ 提供「拆成两片」选项, + # 但按 D-01 不得由规划者单方面拆分。 + tokens: 90000 + raw_tokens: 45000 + tasks: 4 + confidence: low +must_haves: + truths: + - "**本计划第一个 block 点是 Task 0 的 `blocking-human` 汇报关卡(AGENTS.md 硬要求 / D-03)**:先汇报「修复 `container` 过度嵌套导致 `Scroll` 虚拟滚动失效 + 补齐 `book` / `bookDetail` 缺失临界信息的 0 值显示」的要做什么 / 为什么 / 大致怎么做 / 影响范围,逐条列明**行为变化**(0 值回退、`Scroll` 外 `hidden` / `Scroll` 内条件与动态回退、`TextContent` 高度测量与 `key` 重挂载、元素点击命中方式与 2-4px 内缩、`hotkey` 根 `nocache`)、逐文件容器删除清单、新增文件内私有命名清单、验证方式,以及「不做浏览器 UAT / 执行器不提交任何代码」两条铁律,然后停下等待用户恰好回复「可以执行」" + - "**只动 `files_modified` 的 9 个路径**:5 个 UI 文件、1 个新增闸门测试、3 个 `.planning` 产物;不改 `packages-user/client-base/src/components/scroll.tsx` / `textbox.tsx`(Scroll 与 TextContent 的实现保持不变)、不改 `src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts`(按键注册与设置入口保持不变)、不改任何其它 UI / 依赖 / 配置" + - "**`Scroll` 内不得直接出现 `container`,且 `Scroll` 外不得再套一层只为包住 `Scroll` 的 `container`**:`book` / `bookDetail` / `equipbox` / `toolbox` / `hotkey` 五个文件的每个 `` 块内都没有 ` clickItem(item)}` 与 `clickItem` 的「再点使用」逻辑零改动;未选择提示移到说明 `Scroll` 外的根层、**排在说明 `Scroll` 之前**(不被 `Scroll` 内容盖住、也不拦截其指针事件),并用 `hidden={!!current}`,描述 `TextContent` 留在 `Scroll` 内恒挂载 + 空文本回退;行命中矩形左右各缩 4px、上下各 2px,作为行为变化在 Task 0 报备" + - "**`hotkey` 两栏全平铺、去掉 O(n²)、选中态用带边框的 `g-rectr`、根加 `nocache`**:**完全移除 `Selection`**(从 `@user/client-base` 的导入里删掉,文件内不得再出现 `Selection` 标识符或 `]/`,而本计划不允许改那个测试文件)与分组名 `text`(`noevent`),**选中分组只由该行 `g-rectr` 的边框表示**:`fill stroke` + `fillStyle='transparent'` + `strokeStyle={isSelected ? 'gold' : 'transparent'}` + `lineWidth={isSelected ? 2 : 1}`(未选中时描边透明;不新增元素、不用 `hidden`、不做条件渲染);右栏 `Scroll` 直接子元素为指令名 `text`(`noevent`)、每个组合的 `g-rectr`(点击目标,**同一个 `g-rectr` 用 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}` 表示选中**,不再有单独的选中元素)与组合 `text`(`noevent`);行 / 框偏移由 `commandRows` 一次遍历预计算(`ICommandRow` 只保留 `command` / `top`,不保留未使用的 `height`),删除 `getCommandHeight` / `getCommandTop` / `getContentHeight`;根 `` 加 `nocache`(`09-29-NOTES.md` §5 的明确要求);按键框命中矩形高度为 `KEY_BOX_HEIGHT - 4`,比原整框少 4px,作为行为变化在 Task 0 报备;捕获、写回、`@common/exit`、`返回` 关闭、无入口 / 无持久化 / 无冲突检测 / 无恢复默认的行为口径零改动" + - "**验证形态(源码形状 + 静态检查,无浏览器声明)**:新增 `src/client/ui/__test__/uiContainerGate.test.ts`(vitest + `?raw`,不导入任何组件、不使用 `node:fs`)共 9 组断言:①5 个列表文件每个 `Scroll` 块内既无 ``(根),且 `equipbox` 不再含描述区 / 拥有装备区旧包装容器的 `loc` 片段;③`hotkey` 恰好 2 个 `Scroll`、两栏平铺、含 `commandRows`、无 `getCommandTop`;**文件内不含任何 `Selection` 标识符或 `` 开始标签内含 `nocache`;⑨`equipbox` 的 4 个长 `loc` 常量(`STATUS_SCROLL_LOC` / `DESC_SCROLL_LOC` / `SLOT_SCROLL_LOC` / `OWNED_SCROLL_LOC`,均为循环无关的 `Scroll` 外框,因此是**唯一必须上提**的 `loc`)存在、被 `Scroll` 引用,**且每个都以 `: ElementLocator` 注解、该文件已以独立一行 `import { ElementLocator } from '@motajs/render';` 引入 `ElementLocator`**;循环相关的行内 `loc` 保持内联,闸门不对它们作上提断言。另跑 eslint(改动文件,前后基线只判 NEW,路径归一化口径固定)、`pnpm check:type`(整仓,只判改动文件集合内 NEW)、**5 个**受保护文件哈希 / HEAD(允许编排器的 `.planning` 聚合提交)/ 改动范围闸门(对照基线里的全仓脏文件快照,不只靠 pathspec)。**不做浏览器 UAT,不宣称任何视觉 / 滚动 / 内存行为已验证**" + - "**范围与纪律**:不修改任何既有注释或 jsDoc(只允许新增,新增注释遵循 dev.md:单行 `//`、`//` 后留空格、说明「为什么」);不改按键注册表;不改设置入口;不新增依赖;不执行构建;不顺手修复任何无关诊断;发现疑似问题只记录、不擅自登记为缺陷" + artifacts: + - path: src/client/ui/book.tsx + provides: "怪物列表改为平铺:每行由透明 `g-rect`(`onClick` 打开详情)+ `icon` + 名称 / 特殊属性 / 五个基础数值 / 四个临界数值(`伤害` / `临界` / `减伤` / `1 防`,缺失为 `0`)共 13 个 `Scroll` 直接子元素组成,全部按 `y = i * ROW_HEIGHT` 绝对定位;`noevent` 保证点击命中透明矩形" + - path: src/client/ui/bookDetail.tsx + provides: "特殊属性列表改为平铺 + 高度测量:`vue` 导入追加 `VNode` / `watch`;新增 `ISpecialRow` 接口(`special: ISpecial`、`labelTop` / `textTop` / `textHeight`,**不含未被读取的 `height`**)、`textHeights`、`updateTextHeight`、`specialRows`、`watch(specials)`;`TextContent` 使用 `autoHeight` + `onUpdateHeight`,其 `key` 由 `code` + `textTop` + 实测高度构成,行位置变化时重挂载到新 `loc`;`加攻` / `减伤` 恒渲染、缺失为 `0`;每行无 `container`" + - path: src/client/ui/equipbox.tsx + provides: "删除描述区 / 装备槽区 / 拥有装备区三个外层 `container`,`Scroll` 与相邻文本直接定位在根容器;状态行 / 槽位 / 拥有装备行全部平铺(`g-rectr` + `icon` + `text`);7 处显示开关按位置分流(`Scroll` 外 4 处 `hidden`,`Scroll` 内 3 处动态空文本 / 0 回退);4 个长 `loc` 常量(均为循环无关的 `Scroll` 外框,均标注 `: ElementLocator`,并以独立一行 `import { ElementLocator } from '@motajs/render';` 引入该类型);循环相关的行内 `loc` 保持内联;拖拽与点击命中语义保持(命中矩形较原整格内缩 2-4px)" + - path: src/client/ui/toolbox.tsx + provides: "两列列表每行平铺为 `g-rectr` + `icon` + 名称 `text` + 数量 `text`;未选择提示移到说明 `Scroll` **之前**的根层并用 `hidden={!!current}`(不遮挡 `Scroll` 矩形内的指针事件),说明 `TextContent` 恒挂载 + 空文本回退;`clickItem` 逻辑不变" + - path: src/client/ui/hotkey.tsx + provides: "左右两栏 `Scroll` 全平铺(删除内容总高容器 / 指令行容器 / 按键区容器 / 按键框容器,共 6 个 `container`);新增 `ICommandRow`(`command` / `top`)+ `commandRows` 一次遍历预计算行偏移;**完全移除 `Selection`**(连 `@user/client-base` 的导入一起删),选中态改为既有 `g-rectr` 的边框:左栏分组行与右栏按键框都写 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}`,不新增元素、不用 `hidden`、不做条件渲染;新增常量 `BOX_LEFT` / `BOX_FILL_HEIGHT` / `GROUP_TEXT_LEFT` / `COMMAND_TEXT_LEFT` / `COMMAND_TEXT_TOP`;删除 `getCommandHeight` / `getCommandTop` / `getContentHeight`;根 `` 加 `nocache`;捕获与写回逻辑不变" + - path: src/client/ui/__test__/uiContainerGate.test.ts + provides: "新增 `?raw` 源码形状闸门(9 组断言,每个 `it` 前一行 `//` 注释):①`Scroll` 块内既无 `container` 也无 `hidden=`;②5 个文件各只含 1 个根 `container` 且 `equipbox` 旧包装容器 `loc` 片段消失;③`hotkey` 两栏平铺、无 `getCommandTop`、**无任何 `Selection`**(`/Selection/` 不匹配)、左栏分组 `g-rectr` 与右栏按键框 `g-rectr` 各自带 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}`(逐标签断言,全文各 2 处)、不含 `if (isSelected)` / `hidden={!isSelected}` / `{isSelected &&`;④`book` 四项临界字段 0 值回退;⑤`bookDetail` 两项临界字段 0 值回退;⑥`bookDetail` 使用 `autoHeight` / `onUpdateHeight` / `textTop` / `textHeight`;⑦9 处显示开关的既定形态(5 处 `hidden` + 4 处条件 / 动态 / 0 回退;`hotkey` 无显示开关,选中态是边框状态)、旧条件渲染消失,且命中层被覆盖的兄弟元素逐个带 `noevent`;⑧`hotkey` 根有 `nocache`;⑨`equipbox` 4 个长 `loc` 常量存在且都标注 `: ElementLocator`" + - path: .planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json + provides: "Task 1 在收到「可以执行」之后、编辑任何代码之前写出的机器可读基线:`{ head, hashes, dirty, eslint, typecheck }`;`hashes` 保护 **5 个**文件:`packages-user/client-base/src/components/scroll.tsx` / `packages-user/client-base/src/components/textbox.tsx` / `src/client/ui/settings.tsx` / `src/client/ui/index.ts` / `src/client/misc/hotkey.ts`;`dirty` 是当时 `git status --porcelain -uall` 的全仓脏文件路径快照(去掉 `.planning/`),用于在执行后检出范围外的写入(当前工作树本来就脏,pathspec 作用域的 `git status` 看不到范围外文件);`eslint` / `typecheck` 采集当时 5 个待改 UI 文件的诊断;只写一次" + - path: .planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md + provides: "执行摘要:逐文件容器删除与平铺结果、行为变化(0 值回退 / `hidden` / `TextContent` 高度测量 / 命中方式)、门禁结果、未做浏览器 UAT 声明、估算未校准声明、执行器 0 次代码提交(`.planning` 产物可能由编排器按 `commit_docs: true` 聚合提交)" + - path: .planning/phases/09-minimum-browser-runnable/09-29-PLAN.md + provides: "本计划文件本身" + key_links: + - "`TextContent.onUpdateHeight` → `textHeights[code]` → `specialRows` → 后续行 `loc` — 缺任一环,长描述就会压到下一行;因此闸门同时断言 `autoHeight` 与 `onUpdateHeight` 存在,并要求 `specialRows` 由高度表推导" + - "`Scroll` 的直接子元素 → 虚拟滚动可见性判定 — 一旦某行重新被 `container` 包住,`Scroll` 只看到单个子元素,虚拟滚动退化为整块显隐;因此闸门逐个 `` 块断言内部没有 `` 内嵌 ``,`slots.default?.()` 的结果成为 `container-custom` 的直接子元素;`updateScroll` 遍历 `content.value.children` 逐个测量边界并挂 `transform` 监听,因此**直接子元素越细,虚拟滚动越准**;其文件头注释明确「建议平铺、不要用一个 container 包裹所有内容」" + - "**`TextContent` 的实现事实(不改)**:`packages-user/client-base/src/components/textbox.tsx` 中 `autoHeight` 会把自身 `loc` 的高度改写为实测高度,并在 `updateLoc()` 中 `emit('updateHeight', height)`;`watch(props)` 在任一 prop 变化时 `retype()`,因此文案变化会重新上报高度;**`props.loc` 只在 setup 时被复制进内部 `loc`(`textbox.tsx:122-124`),之后改 `props.loc` 不会移动元素,必须用 `key` 重挂载**;`props.hidden` 不是声明 prop,`hidden` 会作为 attr 落到根元素上" + - "**`Scroll` 的 `hidden` 事实**:`Scroll` 的 props 数组未声明 `hidden`(声明的是 `hor` / `noscroll` / `loc` / `padEnd`),但类型 `extends DefaultProps`(含 `hidden`),且 `inheritAttrs` 默认开启;**`Scroll` 每次 `updateScroll` / `checkItem` 会对每个直接子元素调用 `show()` / `hide()`(`scroll.tsx:202-214,289-310`),`hidden` 又走 `item.ts:411-422` 的同一 `hidden` 标志**,所以 `Scroll` 直接子元素上的 `hidden` 会被覆盖;本计划因此只在 `Scroll` 外使用 `hidden`,`Scroll` 内用条件渲染 / 动态空文本 / 0 回退;**本计划不使用 `Selection`**(`hotkey` 的选中态改用 `g-rectr` 的 `strokeStyle` / `lineWidth`),因此无需考虑它的 `hidden` 行为" + - "**`icon` 的安全回退**:`Icon.setIcon(0)` 会走 `id === 0` 分支直接清空 `renderable`,不报错、不绘制,因此 `slotEquip ? slotEquip.item.num : 0` 是安全回退;`icon` 的 props 类型为 `AllNumbers | AllIds`,`0` 合法" + - "**`noevent` 语义**:`BaseProps.noevent` 的注释为「这个元素是否不会触发任何交互事件(cursor 属性也会无效),当执行到此元素时,会下穿至下一个元素」,因此列表行用「透明矩形做命中层 + 上层文本/图标 `noevent`」即可在无容器的情况下保留点击" + - "**数组子元素**:`Scroll` 的 slot 类型为 `default: () => VNode | VNode[]`(`scroll.tsx` 内 `ScrollSlots`),`choices.tsx` 的 `Page` slot 也已经返回 `VNode[]`;本计划用 `VNode[]` 逐项 push 单个元素(**不是**嵌套数组 / Fragment)以避免任何扁平化歧义" + - "**共 5 个待改 UI 文件在规划日均已存在**,且都没有在 09-01..09-28 之外被其它未完成计划占用;`src/client/ui/__test__/` 目录已存在(含 `keyAdaptationGate.test.ts` / `hotkeySettingsGate.test.ts`)" + - "**`book.tsx` 既有 TODO 注释与新行为冲突**:`getCritical` 上方有「伤害 / 临界 / 减伤 / 「1 防」留空」的 TODO;按 AGENTS.md「不得擅自修改任何已有注释」,本计划**不改它**,只在 Task 0 报备(见 OI-1)" + - "**dev.md 约束**:不得出现 `import type`;尽量避免 `?.`;对象类型必须先声明 `interface`;`//` 后留空格;既有注释只增不改;`VNode` 用普通导入(`scroll.tsx` 已有先例)" + - "**测试收集方式**:根 `vite.config.ts` 的 `test` 段没有 `include`,vitest 默认收集 `**/*.test.ts`;`?raw` 导入类型由 `src/types/vite-env.d.ts` 的 Vite 客户端类型引用提供;`tsconfig.json` 的 `include` 覆盖 `src/**/*.ts`,新闸门文件会参与类型检查" + - "**无 RESEARCH / 无 VALIDATION**:本 run 不存在 `09-29-RESEARCH.md` 与 `09-29-VALIDATION.md`,Nyquist 验证不适用(本阶段无 requirement ID);不创建这两类文件。spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)" + - "**波次与独立性(`wave: 24` / `depends_on: []`)**:本计划按 D-01 追加在 09-01..09-28 之后;它改动的文件没有任何**并发**计划在本波次触碰,`depends_on: []` 表示结构独立;`wave: 24` 与 `depends_on: []` 的「不一致」是刻意的(D-01 增量派发 + D-02 计划累积)" + prohibitions: + - "不得在用户恰好回复「可以执行」之前执行任何任务或派发执行子代理(AGENTS.md / D-03)" + - "**不得运行任何 git 写命令**:`git add` / `commit` / `stash` / `checkout` / `restore` / `worktree` / `reset` 一律禁止;代码改动留在工作树,是否提交由用户另行决定;三个 `.planning` 产物可能被编排器按 `commit_docs: true` 聚合提交,不属于本计划的代码提交" + - "不得修改 `packages-user/client-base/src/components/scroll.tsx` 与 `textbox.tsx`(不允许通过改 `Scroll` / `TextContent` 实现来「修好」虚拟滚动或高度测量)" + - "不得修改 `src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts`(设置入口、UI 导出清单、按键注册)" + - "不得在 `Scroll` 内新增任何 `container` / `template` / 包裹层;不得在 `Scroll` 外新增只为包住 `Scroll` 的 `container`" + - "不得修改任何既有注释或 jsDoc;`book.tsx` 里那条已经与 0 值新行为矛盾的 TODO 也必须原样保留(见 OI-1);`book.tsx` 行内两条 JSX 注释(第 159 行 `{/* 数值列表区 */}`、第 195 行 `{/* 临界信息区 */}`)只允许按 OI-5 原样迁移(文本不变,`{/* */}` 改为单行 `//`)" + - "不得新增依赖、不得执行安装命令;不得跑 `build:game` / `build:packages` / `build:lib`" + - "不得用条件渲染(`{cond && ...}`)表达 `Scroll` **外**的显示开关;`Scroll` 直接子元素因 `Scroll` 拥有 `hidden`,只允许条件渲染 / 动态空文本 / `icon` 的 0 回退;不得用 `?.` 取值;不得新增内联对象类型(必须先声明 `interface`)" + - "不得新增任何未在 Task 0 报备的**文件作用域标识符**(模块级常量、`interface`、文件级函数)或**公共 / 受保护 / 导出的命名**;循环内 / 函数内的局部变量(如 `damage` / `nextDiff` / `y` / `boxTop`)不受此限,但已在 OI-2 逐条列出以便你核对;**`loc` 常量只在循环无关时允许上提,且常量名必须已在 OI-2 的 (a) 清单中列明;循环相关的 `loc`(引用 `i` / `y` / `cellX` / `boxTop` / `row` / `top`)一律保持内联(允许 Prettier 折行),不得为「提常量」而改动算法或预先计算**;不得改动按键注册表、`GameUI` id 字符串或界面行为口径(捕获规则、`setKeyCombination` 写回、`@common/exit` + `返回`)" + - "不得伪造验证:源码形状通过不等于视觉 / 滚动 / 内存表现已验证;浏览器 UAT 未做必须如实记录" + - "不得越界处理发现的其它疑似问题(如 `Scroll` 的 `updateScroll` 里 `padEnd.value` 与 `props.padEnd` 混用、`TextContent` 的 `props.hidden` 未声明等);只记录、不擅自修复、不擅自登记为缺陷" + open_items: + - "**OI-1(需要用户定夺,执行前未决)**:`book.tsx` 的 `getCritical` 上方既有注释写着「伤害 / 临界 / 减伤 / 「1 防」留空」,与本计划的「缺失显示 0」直接矛盾。按 AGENTS.md 不得擅自改既有注释,**本计划默认不改它**;若你希望同步改这条 TODO,请在 Task 0 明确回复,否则它会作为「已过期注释」留在代码里" + - "**OI-2(命名报备,按 AGENTS.md 在 Task 0 一并报备,不导出、不进入公共 API)**——分两类:**(a) 文件作用域新增(模块级常量 / `interface` / 文件级函数,属于「必须先反馈获批」的范围)**:`bookDetail.tsx` 的 `ISpecialRow`(成员 `special` / `labelTop` / `textTop` / `textHeight`,**不含未被读取的 `height`**)与常量 `SPECIAL_LEFT` / `SPECIAL_WIDTH` / `SPECIAL_TOP` / `SPECIAL_HEIGHT` / `SPECIAL_LABEL_HEIGHT` / `SPECIAL_MIN_HEIGHT` / `SPECIAL_GAP`;`equipbox.tsx` 的常量 `DESC_LIST_TOP` / `DESC_LIST_HEIGHT` / `OWNED_LIST_TOP` / `OWNED_LIST_HEIGHT` / `STATUS_SCROLL_LOC` / `DESC_SCROLL_LOC` / `SLOT_SCROLL_LOC` / `OWNED_SCROLL_LOC`;**`toolbox.tsx` 的 2 个可选 `loc` 常量 `PERM_SCROLL_LOC` / `CONS_SCROLL_LOC`(仅当对应 `Scroll` 的 `loc` 被 Prettier 折行时才提出,未折行则不新增)**;`hotkey.tsx` 的 `ICommandRow`(成员 `command` / `top`)与常量 `BOX_LEFT` / `BOX_FILL_HEIGHT` / `GROUP_TEXT_LEFT` / `COMMAND_TEXT_LEFT` / `COMMAND_TEXT_TOP`,**外加 2 个可选 `loc` 常量 `GROUP_SCROLL_LOC` / `COMMAND_SCROLL_LOC`(同样仅在折行时提出)**;闸门测试的 `stripComments` / `flatten` / `getScrollBlocks` / `firstContainerTag` / `tagAround`;以及按需以**独立一行** `import { ElementLocator } from '@motajs/render';` 引入的 `ElementLocator` 类型导入。**`book.tsx` / `bookDetail.tsx` 不新增任何 `loc` 常量**(它们的 `loc` 全部引用循环变量 `i` / `y` / `row.*`,按规则保持内联,允许 Prettier 折成多行)。**(b) 循环 / 函数内局部变量(仅报备,不进入公共 API、不改文件作用域)**:`book.tsx` 渲染函数内的 `rows` / `damage` / `nextDiff` / `damageDiff` / `def1Damage` 与循环内 `i` / `base` / `locator` / `critical` / `def1` / `y`;`bookDetail.tsx` 组件内的 `textHeights` / `updateTextHeight` / `specialRows` 与渲染函数内的 `specialNodes` / `row`(`TextContent` 回调参数 `height`);`equipbox.tsx` 渲染函数内的 `equip` / `d` / `statusNodes` / `slotNodes` / `ownedNodes` 与循环内 `i` / `y` / `cellX` / `slotEquip` / `isSelected` / `value` / `delta` / `valueX` / `name` / `item`;`toolbox.tsx` 渲染函数内的 `permNodes` / `consNodes` 与循环内 `i` / `y` / `isSelected` / `item`;`hotkey.tsx` 组件内的 `commandRows` 与渲染函数内的 `groupNodes` / `commandNodes` / `groupIndex` / `top` / `isSelected` / `id` / `list` / `commandIndex` / `index` / `keys` / `boxTop` / `showing` / `row`。**你若要求改名,执行前按你的命名整体替换,方案形态不变;若只关心 (a) 类命名,(b) 类无需审批**" + - "**OI-3(已知限制 / 已知遗留,只记录、不修复)**:①浏览器 UAT 全程跳过,滚动、内存、点击命中、拖拽手感由你自己验证;②`book.tsx` 的过期 TODO 未改(OI-1);③`hotkey` 根按 `09-29-NOTES.md` §5 加 `nocache`;`book` / `bookDetail` / `equipbox` / `toolbox` 的全屏根 `container` 评估后**不加** `nocache`(内容多、根缓存可复用,NOTES 对这四个只要求「评估」);④`Scroll` 的 `padEnd` 与 `TextContent` 的 `props.hidden` 等既有实现细节不在本计划范围;⑤`hotkey` 左栏命中层用透明 `g-rectr` 而不是 `g-rect`——既有 `hotkeySettingsGate.test.ts` 的 `/]/` 断言不允许 `hotkey.tsx` 出现 ` + + + + + + +Phase 9 · 第 29 个计划(新增的独立增量)——**修复 UI 中 `container` 过度嵌套导致的 `Scroll` 虚拟滚动失效,并补齐缺失临界信息的 0 值显示**。 + +**要解决的问题:** + +1. **`Scroll` 虚拟滚动被打穿**:`book` / `bookDetail` / `equipbox` / `toolbox` / `hotkey` 里,`Scroll` 的直接子元素常被每行 `container` 包住,甚至 `Scroll` 外还套一层 `container`。本渲染系统的 `container` 会创建画布缓存层,`Scroll` 只能按「直接子元素」逐个测量与显隐;每行一个容器意味着 `Scroll` 只看到单个大子元素,画面外不渲染的优化退化成整块显隐,同时加深缓存层级、放大内存压力。 +2. **临界信息被整项隐藏**:`book` 的「伤害 / 临界 / 减伤 / 1 防」与 `bookDetail` 的「加攻 / 减伤」在 `critical` 为 null 时整项消失,用户无法区分「没有数据」与「数值为 0」。 +3. **`bookDetail` 特殊属性长描述重叠**:`TextContent` 未取实际高度,每行固定 44 像素,长描述会压到下一行。 +4. **条件渲染让元素反复挂载/卸载**:多处用 `{cond && <...>}` 表达显示开关,反复创建销毁渲染元素,既浪费又不利于命中区域稳定。 + +**做法(用户决定,不得扩大):** + +- **`book.tsx`**:四项临界信息恒渲染、缺失显示 `0`;每行改为「透明 `g-rect`(点击打开详情)+ `icon` + 11 个 `text`」共 13 个 `Scroll` 直接子元素,绝对定位,上层元素 `noevent`;根容器不加 `nocache`。 +- **`bookDetail.tsx`**:删除每行 `container`,标题 `text` + `TextContent` 直接进 `Scroll`;`TextContent` 用 `autoHeight` + `onUpdateHeight` 上报真实高度,`specialRows` 一次遍历累加出每行位置;因 `TextContent` 只在挂载时复制 `props.loc`,每个 `TextContent` 的 `key` 由 `code` + `textTop` + 实测高度构成,位置变化时重挂载;`specials` 变化或切换模式时按 `code` 重建高度表;`加攻` / `减伤` 恒渲染、缺失 `0`;根容器不加 `nocache`。 +- **`equipbox.tsx`**:删除描述区 / 装备槽区 / 拥有装备区三个外层 `container`,`Scroll` 与相邻文本直接定位在根容器;状态行 / 槽位 / 拥有装备行全部平铺;7 处显示开关按位置分流(`Scroll` 外 4 处 `hidden`,`Scroll` 内 3 处动态空文本 / 0 回退);拖拽起手、悬停槽位、再点使用语义保持(命中矩形较原整格内缩 2-4px)。 +- **`toolbox.tsx`**:两列列表行平铺(`g-rectr` + `icon` + 名称 + 数量);未选择提示移到说明 `Scroll` 外的根层、**并写在说明 `Scroll` 之前**(更早的兄弟节点,避免提示 `text` 覆盖 `Scroll` 矩形内、拦截指针事件)并用 `hidden`,说明 `TextContent` 恒挂载 + 空文本回退。 +- **`hotkey.tsx`**:两个 `Scroll` 内全部平铺(共 6 个 `container` 删除),左栏为透明 `g-rectr` 命中层 + 分组名(`g-rectr` 而非 `g-rect`,以保持既有 `hotkeySettingsGate.test.ts` 的「无 `g-rect`」断言为绿),**选中分组只由该行 `g-rectr` 的边框表示**(`strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}`,不新增元素、不用 `hidden`、不做条件渲染);右栏为指令名 + 每组合的 `g-rectr`(同一个矩形用同样的 `strokeStyle` / `lineWidth` 表示选中)+ 组合文本,**全文件 `Selection` 完全移除**(无导入、无 ` + +## 本计划产出物(Artifacts this plan produces) + +| 类型 | 名称 / 路径 | 说明 | +|------|-------------|------| +| 修改 | `src/client/ui/book.tsx` | 列表平铺 + 四项临界信息 0 值回退 | +| 修改 | `src/client/ui/bookDetail.tsx` | 特殊属性行平铺 + `TextContent` 高度测量 + 两项临界信息 0 值回退 | +| 修改 | `src/client/ui/equipbox.tsx` | 删除三处外层容器、行平铺、显示开关按位置分流(4 处 `hidden` + 3 处动态回退) | +| 修改 | `src/client/ui/toolbox.tsx` | 两列行平铺、未选择提示移到 `Scroll` 外用 `hidden` | +| 修改 | `src/client/ui/hotkey.tsx` | 两栏全平铺、`commandRows` 预计算、删除 `getCommandTop`、移除 `Selection` 改用带边框的 `g-rectr` 表示选中、根加 `nocache` | +| 新增 | `src/client/ui/__test__/uiContainerGate.test.ts` | `?raw` 源码形状闸门(9 组断言) | +| 基线 | `.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json` | `head` + **5 个**受保护文件哈希 + 全仓脏文件快照 + eslint / typecheck 基线 | +| 交付 | `.planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md` | 执行摘要、行为变化、门禁结果、未 UAT / 0 次代码提交声明 | +| 计划 | `.planning/phases/09-minimum-browser-runnable/09-29-PLAN.md` | 本文件 | + +## 覆盖审计(Multi-Source Coverage Audit) + +| 源 | 条目 | 覆盖位置 | +|----|------|----------| +| GOAL(ROADMAP §Phase 9) | 「可在浏览器中运行并验证的最小版本」伞目标 | **本增量不新增入口覆盖**:只修 UI 渲染结构与显示口径,不做浏览器验证;SUMMARY 如实记录 | +| REQ | 本阶段无 requirement ID(`requirements: []`) | Nyquist 不适用 | +| RESEARCH | 本 run 无 RESEARCH.md / 本计划无 DISCOVERY.md | 不适用(参考已删除的 `docs/guide/ui/perf.md`,从 `e3d61ee^` 取回) | +| CONTEXT D-01 | 增量派发,一个任务一个计划 | 本计划即该次派发的唯一计划,不拆分 | +| CONTEXT D-02 | 计划可累积追加 | plan 29,排在 09-01..09-28 之后(`wave: 24` 只是排期编号) | +| CONTEXT D-03 | 执行前必须先汇报并等待「可以执行」 | Task 0(`blocking-human`),`autonomous: false` | +| CONTEXT D-04 | 新命名必须先反馈获批 | OI-2(文件内私有命名,Task 0 报备) | +| CONTEXT D-05 | 默认不做阶段验证,除非用户明确要求 | 用户本次明确要求源码闸门 + eslint + 类型基线,全部落在 Task 1–4 | +| CONTEXT D-06 | 范围由用户界定,不擅自扩大 | `files_modified` 9 项 + 范围闸门 + **5 处**受保护文件哈希(含 `textbox.tsx`) | +| 用户决定 1 · `book` | 四项临界信息恒显示、缺失 0;行平铺、保留点击;根不加 `nocache` | 本 objective / Task 1 / Task 4 闸门 | +| 用户决定 2 · `bookDetail` | 删行容器、`TextContent` 高度测量 + `key` 重挂载、`specials` 变化重建、两项临界 0 值、无行容器 | 本 objective / Task 1 / Task 4 闸门 | +| 用户决定 3 · `equipbox` | 删除三处外层容器、行平铺、必要时 `nocache`、7 处显示开关按位置分流、保留拖拽点击 | 本 objective / Task 2 / Task 4 闸门 | +| 用户决定 4 · `toolbox` | 两行容器平铺、未选择提示移到 `Scroll` 外用 `hidden`、保留点击使用 | 本 objective / Task 2 / Task 4 闸门 | +| 用户决定 5 · `hotkey` | 根可留、两栏全平铺(6 个容器删除)、`commandRows` 预计算、删 `getCommandTop`、**移除 `Selection`,选中态改用带边框的 `g-rectr`(`strokeStyle` / `lineWidth`)**、根加 `nocache`、行为口径不变 | 本 objective / Task 3 / Task 4 闸门 | +| 全局规则 | `Scroll` 内外不加容器、`text` 无需容器、`Scroll` 外用 `hidden` / `Scroll` 内不用 `hidden`、**只有循环无关的长 `loc` 提常量(名字须在 OI-2 列明)**、不改注释、不改按键注册、无浏览器 UAT | objective 约束 / prohibitions / Task 1–4 / 闸门 | +| 验证要求 | 源码闸门 9 组断言(含 `Scroll` 内无 `hidden=` 的位置断言、`noevent` 配对断言、`loc` 常量 `: ElementLocator` 注解)、eslint 与类型前后基线 NEW-only、范围 / 哈希(5 个)/ HEAD 政策(允许 `.planning` 聚合提交)、执行器 0 次代码提交 | Task 4 全部门禁 + SUMMARY | + + +@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-28-PLAN.md +@.planning/phases/09-minimum-browser-runnable/09-29-NOTES.md +@AGENTS.md +@dev.md +@packages-user/client-base/src/components/scroll.tsx +@packages-user/client-base/src/components/textbox.tsx +@packages-user/client-base/src/components/misc.tsx +@packages-user/client-base/src/elements/misc.ts +@packages-user/client-base/src/elements/props.ts +@packages/render-vue/src/props.ts +@src/client/ui/book.tsx +@src/client/ui/bookDetail.tsx +@src/client/ui/equipbox.tsx +@src/client/ui/toolbox.tsx +@src/client/ui/hotkey.tsx +@src/client/ui/save.tsx +@src/client/ui/viewmap.tsx +@src/client/ui/choices.tsx +@src/client/ui/__test__/hotkeySettingsGate.test.ts +@src/client/ui/__test__/keyAdaptationGate.test.ts + + + + + Task 0: 预执行汇报关卡 —— 汇报「修 `container` 过度嵌套 / `Scroll` 虚拟滚动失效 + 临界信息 0 值显示」的要做什么 / 为什么 / 怎么做 / 影响范围、逐文件容器删除清单、行为变化、命名清单、验证方式,等待「可以执行」 + 工作树仍是改动前状态,可用以下事实自检:`src/client/ui/book.tsx` 第 127-235 行的 `Scroll` 内每行是一个 ``,临界四项分别被 `critical &&` / `critical && def1 &&` 包裹,行内还有 `{/* 数值列表区 */}` / `{/* 临界信息区 */}` 两条 JSX 注释;`src/client/ui/bookDetail.tsx` 第 170-195 行的 `Scroll` 内每行是一个 ``,`TextContent` 未使用 `autoHeight` / `onUpdateHeight`,且 `textbox.tsx:122-124` 只在挂载时复制一次 `props.loc`;`src/client/ui/equipbox.tsx` 存在三个外层 ``(描述区、装备槽区、拥有装备区),共 7 处条件渲染;`src/client/ui/toolbox.tsx` 两个列表每行是一个 ``,说明区用 `!current &&` / `current &&` 条件渲染;`src/client/ui/hotkey.tsx` 含 `getCommandTop` 且两个 `Scroll` 内共有六个 `container`(左栏 2 + 右栏 4),两处选中高亮当前是条件渲染的独立元素;`09-29-NOTES.md` §5 要求 hotkey 根加 `nocache`;`src/client/ui/__test__/uiContainerGate.test.ts` 不存在。 + 无(本任务不写任何文件,全部只读命令;`09-29-BASELINE.json` 改由 Task 1 在获批后、改代码前写出) + + - .planning/phases/09-minimum-browser-runnable/09-29-NOTES.md(本次改动的问题记录与全局原则) + - .planning/phases/09-minimum-browser-runnable/09-CONTEXT.md(D-01 增量派发、D-03 汇报关卡、D-04 命名批准、D-06 不扩大范围) + - .planning/phases/09-minimum-browser-runnable/09-28-PLAN.md(前一个同批计划的形态:Task 0 汇报 + 基线 + 前后真值比较门禁) + - `docs/guide/ui/perf.md` 的删除前版本(`git show e3d61ee^:docs/guide/ui/perf.md`,只读):避免元素平铺、用 `Scroll` 优化平铺、`cache` / `nocache` 默认表、用 `hidden` 而非条件表达式 + - packages-user/client-base/src/components/scroll.tsx(`Scroll` 按直接子元素测量与显隐、自身已含 `container`、文件头「建议平铺」注释) + - packages-user/client-base/src/components/textbox.tsx(`autoHeight` / `onUpdateHeight` / `getHeight` / `watch(props)` 的实际行为) + - packages-user/client-base/src/elements/{misc.ts,props.ts}(`icon` 的 `setIcon(0)` 与 `IconProps`、图形元素的 `strokeStyle` / `lineWidth`) + - 5 个待改文件:src/client/ui/{book,bookDetail,equipbox,toolbox,hotkey}.tsx + - src/client/ui/{save,viewmap,choices}.tsx(`hidden` / `nocache` / 长 `loc` 提常量的现有写法参考) + - AGENTS.md(执行前须获批、命名先反馈、既有注释不得修改、疑似问题须先确认、修改方式须预先声明)、dev.md(注释 / 类型 / 无 `?.` / 无内联对象类型规范) + + + 一份待批准的执行计划(`09-29-PLAN.md`):把 5 个列表型 UI 的 `container` 嵌套拉平到 `Scroll` 直接子元素、补齐 `book` / `bookDetail` 缺失临界信息的 `0` 值、修正 `bookDetail` 特殊属性长描述重叠(含 `key` 重挂载)、给 `hotkey` 根加 `nocache`,用源码形状闸门 + eslint / 类型前后基线验收。 + + + **停下并向用户汇报,等待恰好回复「可以执行」后才继续;本任务不写任何文件(含 `09-29-BASELINE.json`——它由 Task 1 的第一步写出),不派发执行子代理。** + + ① **要解决的问题(四项):** + 1. **`Scroll` 虚拟滚动被打穿、缓存层级过深**:`book` / `bookDetail` / `equipbox` / `toolbox` / `hotkey` 里 `Scroll` 的直接子元素被每行 `container` 包住,`equipbox` 还有三个外层包装容器、`hotkey` 两个 `Scroll` 内还有 6 个容器(左右各一个内容总高容器 + 分组行 / 指令行 / 按键区 / 按键框)。`container` 在本系统会建画布缓存,`Scroll` 只按直接子元素测量与显隐,于是「画面外不渲染」退化为整块显隐,并放大缓存与内存压力。 + 2. **临界信息整项消失**:`book` 的「伤害 / 临界 / 减伤 / 1 防」和 `bookDetail` 的「加攻 / 减伤」在 `critical` 为 null 时整项不渲染,用户无法区分「无数据」与「0」。 + 3. **`bookDetail` 长描述重叠**:`TextContent` 未取真实高度,每行固定 44 像素,长描述会压到下一行。 + 4. **条件渲染反复挂卸元素**:多处用条件表达式表达显示开关,元素反复创建销毁,命中区域也不稳定。 + + ② **逐文件做什么(严格按此范围):** + - `book.tsx`:四项临界信息**无条件渲染**;`critical` 为 null 时显示 `format(0)`;`1 防` 在 `critical` 或 `def1` 任一为 null 时显示 `format(0)`。每行改为 13 个 `Scroll` 直接子元素:透明 `g-rect`(铺满整行、`cursor="pointer"`、`onClick` 打开详情)+ `icon` + 名称 `text` + 特殊属性 `text` + 五个基础数值 `text` + 四个临界 `text`,全部按 `y = i * ROW_HEIGHT` 绝对定位;除命中矩形外全部 `noevent`。原行内 `{/* 数值列表区 */}` / `{/* 临界信息区 */}` 两条注释按 OI-5 原样迁移到对应 push 分组。根容器**不加** `nocache`。 + - `bookDetail.tsx`:删除特殊属性每行的 `container`;标题 `text` 与 `TextContent` 直接作为 `Scroll` 子元素;`TextContent` 加 `autoHeight` + `onUpdateHeight`,把实测高度按特殊属性 `code` 记入 `textHeights`,再由 `specialRows` **一次遍历**(行高只在循环内用局部 `top` 累加,**不写入接口**)给出每行标题纵坐标 / 描述纵坐标 / 实测描述高度。因 `TextContent` 只复制一次 `props.loc`,每个 `TextContent` 的 `key` 由 `code` + `textTop` + 实测高度构成,行位置或高度变化时重挂载到新 `loc`;同一高度重复上报不再改变 `key`,重挂载一次后收敛;首次测量前用 `SPECIAL_MIN_HEIGHT` 回退。`specials` 变化或切换基础/计算模式时,按当前 `code` 重建高度表(保留仍存在条目的已测高度、丢弃已消失条目)。`加攻` / `减伤` 无条件渲染,`critical` 为 null 时显示 `format(0)`。根容器**不加** `nocache`。 + - `equipbox.tsx`:删除描述区、装备槽区、拥有装备区**三个外层 `container`**,把其中的 `Scroll` 与相邻文本按绝对坐标直接放在根容器上;状态行 / 槽位 / 拥有装备行**全部平铺**(不再有行 `container`)。**7 处显示开关按位置分流**:`Scroll` 外 4 处用 `hidden`(未选择装备 `hidden={!!equip}`、装备名 / 装备孔 `hidden={!equip}`、拖拽浮层 `hidden={!dragging.value}`);`Scroll` 直接子元素 3 处不用 `hidden`(属性增减值改为 `delta === 0 ? '' : ...` 动态空文本、描述 `TextContent` 改为 `text={equip ? equip.item.text : ''}` 恒挂载、槽位图标改为 `icon={slotEquip ? slotEquip.item.num : 0}` 的 0 回退——`Icon.setIcon(0)` 直接清空绘制)。数据访问全部改安全回退(`equip ? equip.item.name : ''`、`slotEquip ? slotEquip.item.num : 0`、`current ? current.raw.text : ''`)。保留拖拽与点击:拥有装备行的 `g-rectr` 承担 `onDown`,上层 `icon` / `text` 加 `noevent`;槽位 `g-rectr` 承担 `onEnter` / `onLeave`,槽位 `icon` 同时承担 `onEnter` / `onLeave` / `onDown`(与原先「只有图标能起手拖动、整格可悬停」一致)。命中矩形较原整格内缩 2-4px(槽位四周 4px、拥有装备行上下 2px),作为行为变化在此报备;默认不加满格透明命中层。4 个长 `loc` 提为常量 `STATUS_SCROLL_LOC` / `DESC_SCROLL_LOC` / `SLOT_SCROLL_LOC` / `OWNED_SCROLL_LOC`(这 4 个只由既有布局常量组成、**与循环变量无关**,所以可以安全上提),**每个都必须写成 `const X: ElementLocator = [...]`**(裸数组字面量会被推成 `number[]`,赋不给 `loc` 的 `ElementLocator` 固定元组,会直接类型报错),并在该文件以**独立一行** `import { ElementLocator } from '@motajs/render';` 补入该类型。**反过来,任何引用循环变量(`i` / `y` / `cellX` / `boxTop` / `row` / `top`)的 `loc` 一律不得上提为文件作用域常量**,保持内联、允许 Prettier 折行——所以 `book` / `bookDetail` 的 `loc` 全部保持内联、不新增常量;`toolbox` / `hotkey` 只有两个循环无关的 `Scroll` 外框可能上提(`PERM_SCROLL_LOC` / `CONS_SCROLL_LOC` 与 `GROUP_SCROLL_LOC` / `COMMAND_SCROLL_LOC`,名字已在 OI-2 列明,**未被 Prettier 折行就不新增**)。凡提出来的 `loc` 常量**一律要带 `: ElementLocator` 注解**。`startPress` / `movePress` / `endPress` / `clickItem` 等逻辑体零改动。 + - `toolbox.tsx`:两列列表每行平铺为 `g-rectr`(行底 + 点击目标)+ `icon` + 名称 `text` + 数量 `text`(除 `g-rectr` 外全 `noevent`);「未选择道具」提示**移到说明 `Scroll` 外的根层**并用 `hidden={!!current}`(这是 `hidden` 唯一可靠的位置),**且必须写在说明 `Scroll` 之前**(在 JSX 中作为更早的兄弟节点,这样 `Scroll` 绘制在其上方,提示文本不会盖住 `Scroll` 矩形、不会拦截其中的指针事件);说明 `TextContent` 留在 `Scroll` 内恒挂载,用 `text={current ? current.raw.text : ''}` 空文本回退,**不给 `Scroll` 直接子元素加 `hidden`**;`clickItem` 的「再点使用」逻辑不变。 + - `hotkey.tsx`:两个 `Scroll` 内**全部平铺**,删除内容总高容器 ×2、分组行容器、指令行容器、按键区容器、按键框容器(共 **6 个** `container`)。左栏直接子元素为透明 `g-rectr`(点击目标)+ 分组名 `text`(`noevent`)——**这里用 `g-rectr` 而不是你说的 `g-rect`**:既有 `hotkeySettingsGate.test.ts` 会断言 `hotkey.tsx` 不含 `` **加 `nocache`**(`09-29-NOTES.md` §5 的明确要求);按键框命中矩形高 `KEY_BOX_HEIGHT - 4`,比原整框少 4px,作为行为变化在此报备。捕获阶段监听、首次 `keyup` 主键规则、`setKeyCombination` 写回、`@common/exit` + `返回` 关闭、无入口 / 无持久化 / 无冲突检测 / 无恢复默认,全部不变。 + ③ **需要你重点核对的行为变化(这是本次实际能感知到的差异):** + - **`book` 的临界四项与 `bookDetail` 的「加攻 / 减伤」在无数据时从「整项消失」变为「显示 0」**。 + - **`book` 每行的可点击区域仍是整行**,但实现方式从「行容器 `onClick`」变为「透明 `g-rect` 命中层 + 上层 `noevent`」;视觉无变化。 + - **`bookDetail` 特殊属性行的高度改为按真实描述高度撑开**,长描述不再压到下一行,`Scroll` 的内容长度随高度回调自动更新(不再有固定 44 像素的假设)。 + - **显示开关按位置分两类**:`Scroll` 外的 5 处改为 `hidden`(`equipbox` 未选择/装备名/装备孔/拖拽浮层、`toolbox` 未选择提示),元素始终挂载;`Scroll` 直接子元素的 4 处不能用 `hidden`(会被 `Scroll` 的 `show()` / `hide()` 覆盖),改用动态空文本 / 条件渲染 / `icon` 的 0 回退(`equipbox` 属性增减值与描述 `TextContent`、槽位图标;`toolbox` 描述 `TextContent`)。**`hotkey` 的选中高亮不属于显示开关**:它是既有 `g-rectr` 的边框颜色 / 线宽随 `isSelected` 变化,元素始终挂载、从不隐藏、也从不条件挂载。 + - **`hotkey` 的选中高亮改为边框**:不再是「选中行才挂载 `Selection`」,而是每行的 `g-rectr` 始终存在,选中行把描边从透明切换为 `gold`(`lineWidth` 2 / 1)。左栏分组行与右栏按键框都是这一种做法,**没有条件挂载、没有单独的选中元素、`Selection` 已完全移除**。 + - **`bookDetail` 特殊属性 `TextContent` 会在行位置或实测高度变化时重挂载一次**:因为 `TextContent` 只在挂载时复制 `props.loc`,`key` 变化是唯一能把它移到新 `loc` 的手段;高度回调写入相同值后 `key` 不再变化,不会循环。 + - **`equipbox` 槽位的拖动起手仍然只在图标上**(与现在一致),整格仍负责悬停判定;拥有装备行仍可整行按下拖动。**命中矩形较原整格容器内缩 2-4px**(槽位四周 4px、拥有装备行上下 2px、`toolbox` 行左右 4px / 上下 2px),这是本次能感知到的命中范围变化,若不接受可改为额外加一层满格透明命中层。 + - **`hotkey` 左栏点击整行切换分组、右栏点击按键框进入捕获**,命中区域与现在基本一致(按键框命中高度少 4px);实现差异是左栏命中层用透明 `g-rectr` 而不是透明 `g-rect`(既有 `hotkeySettingsGate.test.ts` 断言 `hotkey.tsx` 不含 `` 加 `nocache`。 + - **去掉行容器后,各列表的可滚动内容末端会略微变短(可观测,但只影响末尾空白,不影响可用内容)**:`hotkey` 右栏原本由内容总高容器把高度算到「最后一行 + `COMMAND_GAP`」,平铺后 `Scroll` 的可滚动范围只到最后一行底部,末端的 `COMMAND_GAP`(约 16px)空白消失,滚动范围相应少约 16px;`equipbox` 拥有装备区末行(命中矩形上下各内缩 2px)内容底边比原行容器少约 2px,状态区因文本垂直居中、底边不到行容器底部而少约 4.5px;`toolbox` 两列末行(行底矩形上下各内缩 2px)少约 2px。这些差异只体现在「能滚到多远」的末端极小余量上,不改变任何可见内容。 + - **`toolbox` 的「未选择道具」提示写在说明 `Scroll` 之前**,因此不再位于 `Scroll` 矩形之上,不会拦截 `Scroll` 内的指针事件;视觉位置不变(同一绝对坐标)。 + + ④ **影响范围:** + - 修改 5 个既有 UI 文件:`src/client/ui/{book,bookDetail,equipbox,toolbox,hotkey}.tsx`。 + - 新增 1 个测试文件:`src/client/ui/__test__/uiContainerGate.test.ts`。 + - 另有 3 个 `.planning` 产物:本计划、`09-29-BASELINE.json`、`09-29-SUMMARY.md`。合计 9 个路径。 + - **显式不改**:`packages-user/client-base/src/components/scroll.tsx` 与 `textbox.tsx`(不允许改 `Scroll` / `TextContent` 实现来「修好」问题)、`src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts`(按键注册)、其它任何 UI、依赖与配置。 + + ⑤ **验证方式:** + 1. `pnpm exec eslint` 跑 6 个代码 / 测试文件,与基线比较**只判 NEW**;路径归一化口径固定为「反斜杠转正斜杠后,从 `src/client/ui/` 开始截取」,Task 1 采集与 Task 4 比较必须使用同一段归一化代码。 + 2. `pnpm check:type`(整仓 `vue-tsc --noEmit`)前后基线比较:只对改动文件集合内的 **NEW** 诊断判失败;既有基线诊断允许消失;不修无关错误。**归一化必须与 Task 4 类型门禁的 `norm` 逐字相同并保留 `src/` / `packages-user/` 前缀**:把 Task 4 验证命令里的这一段**原样复制**、不要改写 —— `const norm=l=>l.replace(/\\/g,'/').replace(/\(\d+,\d+\):/,'').replace(/^.*?(?=(src|packages-user)\/)/,'').trim();`,再用 `scope.some(p=>x.indexOf(p)===0)` 过滤;Task 1 采集基线时**不得剥掉 `src/` 前缀**,否则每条既存诊断都会在 Task 4 被误判成 NEW。 + 3. 新增 `src/client/ui/__test__/uiContainerGate.test.ts`(vitest + `?raw`,不导入组件、不使用 `node:fs`),9 组断言:①5 个列表文件的每个 `` 块内既无 ``(根),且 `equipbox` 描述区 / 拥有装备区两个旧包装容器的 `loc` 字面量消失(装备槽区的外接矩形被槽 `Scroll` 继续复用,由根计数覆盖);③`hotkey` 恰好两个 `Scroll`、两栏都平铺、含 `commandRows`、不含 `getCommandTop`;**不含任何 `Selection`**(去注释后 `/Selection/` 不匹配);左栏分组 `g-rectr` 与右栏按键框 `g-rectr` **各自**带 `strokeStyle={isSelected ? 'gold' : 'transparent'}` 与 `lineWidth={isSelected ? 2 : 1}`(用 `tagAround()` 逐标签断言,全文各 2 处),不含 `if (isSelected)`、`hidden={!isSelected}`、`{isSelected &&`;④`book` 四项临界字段的 0 值回退表达式存在且旧条件渲染消失;⑤`bookDetail` 两项临界字段的 0 值回退表达式存在且旧条件渲染消失;⑥`bookDetail` 含 `autoHeight` / `onUpdateHeight` / `textHeights` / `specialRows` / `textTop` / `textHeight`;⑦9 处显示开关的既定形态存在且旧条件渲染消失:`Scroll` 外 5 处 `hidden`(`equipbox` 4 + `toolbox` 1),`Scroll` 内 4 处(`equipbox` 3 + `toolbox` 1;`hotkey` 无显示开关,选中态是 `g-rectr` 的边框状态),**并逐个断言命中层被覆盖的兄弟元素带 `noevent`**(`book` 的名称 / `伤害` / `icon`、`equipbox` 拥有装备行名称与图标、槽名、`toolbox` 名称与数量、`hotkey` 分组名 / 指令名 / 组合文本),同时断言这些命中层本身**不带** `noevent`;⑧`hotkey` 的第一个 `` 开始标签内含 `nocache`;⑨`equipbox` 的 4 个长 `loc` 常量(`STATUS_SCROLL_LOC` / `DESC_SCROLL_LOC` / `SLOT_SCROLL_LOC` / `OWNED_SCROLL_LOC`)存在、被 `Scroll` 引用,**且都带 `: ElementLocator` 注解、该文件已以独立一行 `import { ElementLocator } from '@motajs/render';` 引入 `ElementLocator`**。**不要求任何 `loc={` 物理单行**(Prettier printWidth 80 优先):只有循环无关的 `loc` 才上提,循环相关的 `loc` 保持内联。 + 4. 受保护文件哈希闸门(**5 个**文件与基线逐字相等:`scroll.tsx`、`textbox.tsx`、`settings.tsx`、`ui/index.ts`、`misc/hotkey.ts`);HEAD 与基线相同,**或** HEAD 不同但 `git diff --name-only ..HEAD` 的每个路径都以 `.planning/` 开头(编排器聚合提交),否则失败。 + 5. 改动范围闸门:只有 6 个代码 / 测试路径出现变更,**并且**用整仓 `git status --porcelain -uall`(不加 pathspec)与基线记录的脏文件快照 `dirty` 比较——任何「不在快照里、且不属于这 6 个路径」的新脏路径都判为范围外写入并失败(当前工作树本来就脏,只看 pathspec 的 `git status` 无法发现范围外写入)。 + 6. **不做浏览器 UAT**:滚动、内存、点击命中、拖拽手感全部留给你人工确认,SUMMARY 如实记录。 + 7. **既有闸门状态如实报备**:`hotkeySettingsGate.test.ts` 应仍为绿;`keyAdaptationGate.test.ts` 在改动前就是红的(15 个用例 5 个失败),属既存问题、不在本计划范围,本计划不修、也不声称既有闸门全绿。 + + ⑥ **命名清单:** + - **无新增公共 / 受保护导出**;新增命名分两类,均已在 OI-2 逐条列明:**(a) 文件作用域**(`bookDetail` 的 `ISpecialRow`(`special` / `labelTop` / `textTop` / `textHeight`,**无 `height`**)+ 7 个 `SPECIAL_*` 常量;`equipbox` 的 4 个 `*_LIST_*` + 4 个 `*_SCROLL_LOC` 常量;`toolbox` 的 2 个**可选** `loc` 常量 `PERM_SCROLL_LOC` / `CONS_SCROLL_LOC`(仅在 Prettier 折行时提出);`hotkey` 的 `ICommandRow` + 5 个布局常量 + 2 个**可选** `loc` 常量 `GROUP_SCROLL_LOC` / `COMMAND_SCROLL_LOC`(同样仅在折行时提出);闸门测试的 5 个辅助函数 `stripComments` / `flatten` / `getScrollBlocks` / `firstContainerTag` / `tagAround`;以及按需以独立一行 `import { ElementLocator } from '@motajs/render';` 引入的 `ElementLocator` 类型导入——**需要你批准**;**`book` / `bookDetail` 不新增 `loc` 常量**,其 `loc` 全部引用循环变量、按规则保持内联);**(b) 循环 / 函数内局部**(`book` 的 `rows` / `damage` / `nextDiff` / `damageDiff` / `def1Damage`、`equipbox` 的 `statusNodes` / `slotNodes` / `ownedNodes` / `y` / `cellX`、`toolbox` 的 `permNodes` / `consNodes`、`hotkey` 的 `commandRows` / `groupNodes` / `commandNodes` / `boxTop` / `showing` 等——**仅报备,不涉及文件作用域/公共命名**)。**你若要求改名,执行前按你的命名整体替换,方案形态不变。** + - **既有 `GameUI` id 字符串、导出名、按键命令 id 一律不改。** + + ⑦ **已知问题 / 需要你定夺:** ①**OI-1**:`book.tsx` 的 `getCritical` 上方有一条既存 TODO,写着「伤害 / 临界 / 减伤 / 「1 防」留空」,与本次「缺失显示 0」直接矛盾。按 AGENTS.md「不得擅自修改任何已有注释」,**本计划默认不改这条注释**;若你希望同步改掉它,请在回复中一并说明。②**OI-5**:`book.tsx` 两条行内 JSX 注释(`{/* 数值列表区 */}`、`{/* 临界信息区 */}`)默认原样迁移到平铺 push 分组(文本不变,仅改单行 `//`),请确认。③**OI-6**:估算已下调到 90k;若你仍觉得单个计划过大,可回复「拆成两片」,我不会单方面拆分。 + + ⑧ **已知限制 / 遗留(只记录、不修复):** ①浏览器 UAT 全程跳过;②`book` 的过期 TODO 未改(OI-1);③`hotkey` 根按 NOTES §5 加 `nocache`,`book` / `bookDetail` / `equipbox` / `toolbox` 根评估后不加;④`Scroll` 的 `padEnd` 与 `TextContent` 的 `props.hidden` 等既有实现细节不在本计划范围;⑤`Scroll` 直接子元素上的 `hidden` 会被 `Scroll` 覆盖,这是系统层既有行为,本计划只在 `Scroll` 外使用 `hidden`;⑥`src/client/ui/__test__/keyAdaptationGate.test.ts` 在改动前就是红的(15 个用例 5 个失败),属既存问题、不在本计划范围,本计划不修、也不声称既有闸门全绿。 + + ⑨ **两条铁律:** **不做浏览器 UAT、不宣称任何视觉 / 滚动 / 内存 / 拖拽行为已验证**;**执行器不提交任何代码**(禁止 `git add` / `commit` / `stash` / `checkout` / `worktree` / `reset`),代码改动留在工作树供你审查。三个 `.planning` 产物可能由 GSD 编排器按 `commit_docs: true` 聚合提交,不算本计划的代码提交。 + + ⑩ **基线记录(本任务不写、只说明字段;实际写出在 Task 1 的第一步):** schema 固定为 `{ head: string, hashes: { <完整仓库相对路径>: ×5 }, dirty: string[], eslint: string[], typecheck: string[] }`。 + - `head` = 采集当次的 `git rev-parse HEAD`(规划日实测值为 `9bf8f296c47a85f19c762072f9b73214fe329ee3`,**仅供本次汇报参考**;字段必须写入采集当次的值,后续所有门禁只读 `09-29-BASELINE.json` 的 `head`)。 + - `hashes` = `packages-user/client-base/src/components/scroll.tsx`、`packages-user/client-base/src/components/textbox.tsx`、`src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts` 的 `git hash-object`(**5 条**),键必须是完整仓库相对路径(POSIX 正斜杠)。 + - `dirty` = 采集当时 `git status --porcelain -uall` 的**全仓**脏文件路径快照(剥掉状态列,去掉 `.planning/` 下的路径);当前工作树本来就有既有改动与未跟踪文件,这份快照用于区分「本来就脏」与「执行期新写入」,使范围闸门能发现 pathspec 之外的写入。 + - `eslint` = 对当时 5 个待改 UI 文件跑 `pnpm exec eslint -f json`,把每条消息归一化为 `仓库相对路径|规则ID|严重度|消息`。**路径归一化口径已固定,Task 1 与 Task 4 必须逐字使用同一段代码**:`const norm = p => { const s = String(p).replace(/\\/g, '/'); const i = s.indexOf('src/client/ui/'); return i >= 0 ? s.slice(i) : s; };`,再与 `String(m.ruleId || 'null')`、`String(m.severity)`、`String(m.message).trim()` 用 `|` 连接。`current` 与 `baseline` 都按此口径生成,比较时才不会因盘符 / 反斜杠前缀差异误判。 + - `typecheck` = 先整仓跑 `pnpm check:type`,再**原样复制 Task 4 类型门禁的 `norm`**(不要改写、不要调空格)——`const norm=l=>l.replace(/\\/g,'/').replace(/\(\d+,\d+\):/,'').replace(/^.*?(?=(src|packages-user)\/)/,'').trim();`,**保留 `src/` / `packages-user/` 路径前缀**(只裁掉 `(行,列):` 与盘符 / 反斜杠之类的前置噪声),再用同一段 `scope.some(p=>x.indexOf(p)===0)` 过滤出落在上述 5 个文件内的诊断。**两处必须逐字一致**,否则基线里的每条既存诊断都会被 Task 4 误判为 NEW。该数组是类型门禁的唯一真值,本计划不对任何文件断言条数。 + + ⑪ **修改方式:** 本任务(Task 0)**不写任何文件**(含基线、含任何代码);获批后由 `gsd-executor` 子代理执行 Task 1–4(非内联),只写 `files_modified` 的 9 个路径,其中 `09-29-BASELINE.json` 是 Task 1 的第一步;执行器不提交任何代码。「开始执行」的界限仍是你的「可以执行」四个字。 + + + 逐条核对 ①–⑬ 是否与你的意图一致,特别是:①五个列表文件的容器删除范围是否就是你要的(`equipbox` 三个外层 + 行容器全平铺;`hotkey` **6 个**容器全删;`book` / `bookDetail` / `toolbox` 行容器全删);②`book` 四项与 `bookDetail` 两项临界信息「缺失显示 0」是否就是你要的口径(而不是隐藏或留空);③`bookDetail` 的「按真实高度累加行位置 + 用 `key` 重挂载 `TextContent` + `specials` 变化重建高度表」是否接受;④9 处显示开关的分流是否符合预期(`Scroll` 外 5 处 `hidden`、`Scroll` 内 4 处条件 / 动态 / 0 回退,因为 `Scroll` 拥有 `hidden`;`hotkey` 的选中态是边框颜色状态、不是显示开关);⑤`equipbox` 槽位「仅图标起手拖动、整格可悬停」的命中方式是否保持得对,**内缩 2-4px 的命中范围变化是否接受**;⑥`hotkey` 除结构外行为是否零变化(捕获、写回、无入口 / 无持久化 / 无冲突 / 无恢复默认),以及根加 `nocache` 是否符合 NOTES §5;⑦OI-1(`book.tsx` 过期 TODO 是保留还是允许一并更新)与 OI-5(两条行内 JSX 注释的原样迁移);⑧OI-2 的文件内私有命名是否需要改名;⑨OI-6(是否要拆成两片);⑩「不做浏览器 UAT / 执行器不提交代码」与基线字段、HEAD 允许 `.planning` 聚合提交的口径;⑪「**只有循环无关的 `loc` 才上提为常量**、循环相关的 `loc` 保持内联(Prettier 折行即可),上提的常量名必须已在 OI-2 列明、一律标注 `: ElementLocator`、并以独立一行 `import { ElementLocator } from '@motajs/render';` 导入该类型」是否接受;⑫`hotkey` **完全移除 `Selection`**(含导入),左栏分组 `g-rectr` 与右栏按键框 `g-rectr` 各自用 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}` 表示选中(不新增元素、不用 `hidden`、不做条件挂载、不用 `isSelected &&` 表达式语句)是否接受;⑬范围闸门改为「全仓脏文件快照 + 6 个允许路径」比较(因为工作树本来就脏)、受保护哈希扩到 **5 个**(含 `textbox.tsx`)、`keyAdaptationGate.test.ts` 记为既存失败——这三条口径是否接受。 + + 仅当完全同意时回复恰好四个字「可以执行」;任何修改意见(**尤其是 OI-1 的过期 TODO 是否一并更新、OI-5 的两条行内 JSX 注释迁移、OI-2 的文件内私有命名是否需要改名、OI-6 是否要拆成两片**)请直接说明,本计划会据此改写后再来一次汇报。 + + + + Task 1: 写出改动前基线,然后平铺 `book.tsx`(临界四项 0 值)与 `bookDetail.tsx`(特殊属性高度测量 + 临界两项 0 值) + .planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json(新增,第一步写出), src/client/ui/book.tsx, src/client/ui/bookDetail.tsx + + **依赖 Task 0 已收到「可以执行」。** **第一步(必须在任何代码编辑之前):** 按 Task 0 ⑩ 的 schema 写出 `.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json`: + - `head`:当前 `git rev-parse HEAD`。 + - `hashes`:`packages-user/client-base/src/components/scroll.tsx`、`packages-user/client-base/src/components/textbox.tsx`、`src/client/ui/settings.tsx`、`src/client/ui/index.ts`、`src/client/misc/hotkey.ts` 的 `git hash-object`(**5 条**,`textbox.tsx` 必须在内),键用完整仓库相对路径(POSIX 正斜杠),值用小写 sha。 + - `dirty`:跑 `git status --porcelain -uall`(**不加 pathspec**),取出每条的状态列之后的路径、去掉 `.planning/` 开头的条目,按字典序排好写入数组。当前工作树本来就脏(`AGENTS.md`、`packages-user/client-base/src/map/*`、若干未跟踪文件),这份快照是执行后判断「有没有范围外新写入」的唯一依据。 + - `eslint`:对 5 个待改 UI 文件(`book` / `bookDetail` / `equipbox` / `toolbox` / `hotkey`)跑 `pnpm exec eslint -f json`,把每条消息归一化为 `仓库相对路径|规则ID|严重度|消息`;**归一化代码必须与 Task 4 逐字相同**:`const norm = p => { const s = String(p).replace(/\\/g, '/'); const i = s.indexOf('src/client/ui/'); return i >= 0 ? s.slice(i) : s; };`,条目为 `norm(f.filePath) + '|' + String(m.ruleId || 'null') + '|' + String(m.severity) + '|' + String(m.message).trim()`。数组为空表示干净。`eslint` 以 `status === 1` 退出属于「有告警 / 错误」的正常结果,必须照常解析 stdout 并写入,不得当成「无基线」写空数组;只有未运行 / stdout 为空 / JSON 不可解析才停下上报。 + - `typecheck`:先整仓跑 `pnpm check:type`(不要传文件列表),再**原样复制 Task 4 类型门禁的 `norm`**(不要改写、不要调空格、不要剥前缀)——`const norm=l=>l.replace(/\\/g,'/').replace(/\(\d+,\d+\):/,'').replace(/^.*?(?=(src|packages-user)\/)/,'').trim();`,随后用同一段 `scope.some(p=>x.indexOf(p)===0)` 过滤出落在上述 5 个文件内的诊断,保留重复项。**产物里必须保留 `src/` / `packages-user/` 前缀**(否则 Task 4 会把每条既存诊断都判成 NEW)。该文件只写一次,此后不得重写。 + + **第二步:`src/client/ui/book.tsx`。** + 1. 顶部 `vue` 导入追加 `VNode`(普通导入,禁止 `import type`;与 `scroll.tsx` 的既有写法一致):`import { computed, defineComponent, VNode } from 'vue';`。**本文件的 `loc` 全部引用 `y` / `i`(循环相关),按规则不上提**,因此**不引入** `ElementLocator`;万一提出(名字不得超出 OI-2 已列清单,本计划预期为空),必须是**独立一行** `import { ElementLocator } from '@motajs/render';`(普通导入,禁止 `import type`,不得并入其它导入);不需要引入时**不要**引入它(未使用导入会被 eslint 判错)。`bookProps`、`BookUI` 与其它既有常量零改动。 + 2. 渲染函数改为「先建数组、再返回 JSX」:把 `return () => (...)` 改成 `return () => { const rows: VNode[] = []; ...; return ( ... ); }`。**根 `` 原样保留,不加 `nocache`。** + 3. 在原 `Scroll` 位置先用单层数组收集行元素(**不得嵌套数组、不得 Fragment、不得包 `container`**):对 `enemies.value` 逐项 `for` 循环(`i` 为索引),每项先算 `base`、`locator`、`critical`、`def1`、`y = i * ROW_HEIGHT`,再算四个回退值: + - `const damage = critical ? critical.baseInfo.damage : 0;` + - `const nextDiff = critical ? critical.nextDiff : 0;` + - `const damageDiff = critical ? -critical.damageDiff : 0;` + - `const def1Damage = critical && def1 ? critical.baseInfo.damage - def1.baseInfo.damage : 0;` + 然后按**如下顺序**把 13 个元素 `push` 进 `rows`(每个元素都必须有唯一 `key`,键名分别用 `hit-${base.code}` / `icon-${base.code}` / `name-${base.code}` / `special-${base.code}` / `hp-${base.code}` / `atk-${base.code}` / `def-${base.code}` / `money-${base.code}` / `exp-${base.code}` / `dmg-${base.code}` / `crit-${base.code}` / `reduce-${base.code}` / `def1-${base.code}`): + 1) ``:`loc={[0, y, LIST_WIDTH, ROW_HEIGHT]}`、`fill`、`fillStyle="transparent"`、`cursor="pointer"`、`onClick={() => openDetail(view)}`; + 2) ``; + 3) 名称 ``; + 4) 特殊属性 ``; + 5) 生命 ``; + 6) 攻击(`format(base.getAttribute('atk'))`、`lightcoral`)、7) 防御(`def`、`lightblue`)、8) 金币(`money`、`lightyellow`)、9) 经验(`exp`、`lawngreen`),四者均为 `loc={[statX(1..4), y + 22]}` / `anc={[0, 0.5]}` / `noevent`; + 10) `伤害` ``; + 11) `临界`(`format(nextDiff)`、`lightsalmon`)、12) `减伤`(`format(damageDiff)`、`lightpink`)、13) `1 防`(`format(def1Damage)`、`cyan`),三者均为 `loc={[statX(1..3), y + 52]}` / `anc={[0, 0.5]}` / `noevent`。 + 4. `Scroll` 改为 `{rows}`(**只有这一个 children,且不能包容器**)。原来的 `enemies.value.map(...)` 返回行容器的写法整段删除。 + 5. 删除全部 `critical && (...)` 与 `critical && def1 && (...)` 条件渲染;四个临界 `text` 恒渲染。**不得修改任何既有注释**(含 `getCritical` 上方那条写着「留空」的 TODO,见 OI-1;也不要新增与它矛盾的解释性注释,保持原样)。原第 159 行 `{/* 数值列表区 */}` 与第 195 行 `{/* 临界信息区 */}` 按 OI-5 **原样迁移**:在 push 第 5 个元素前写 `// 数值列表区`、在 push 第 10 个元素前写 `// 临界信息区`(文字逐字不变,只把 JSX 注释改为单行 `//`,因为 push 代码不在 JSX 内)。 + 6. 行内 `loc` 直接使用已算好的 `y` / `i`。**本文件所有 `loc` 都引用循环变量,属于循环相关,一律保持内联**:Prettier(printWidth 80)可以把 `loc={[...]}` 折成多行,**不得为「折行就提常量」而把依赖 `y` 的数组上提**(那种常量在文件作用域算不出来);`book` 因此**不新增 `loc` 常量、不引入 `ElementLocator`**。**不得为了保持单行而违反 Prettier 格式。** + + **第三步:`src/client/ui/bookDetail.tsx`。** + 1. 导入调整(只增不改):`vue` 追加 `VNode` 与 `watch`(普通导入,禁止 `import type`),写成 `import { computed, defineComponent, ref, VNode, watch } from 'vue';`;`@user/data-base` 追加 `ISpecial`(写成 `{ IEnemy, IReadonlyEnemy, ISpecial }`);**本文件的 `loc` 全部引用 `row.labelTop` / `row.textTop`(循环相关),按规则不上提,因此不引入 `ElementLocator`**;万一提出(名字不得超出 OI-2,本计划预期为空),必须是独立一行 `import { ElementLocator } from '@motajs/render';`(普通导入,禁止 `import type`,不得并入其它导入)。其它既有导入一律不变。 + 2. 文件级新增常量(放在既有字体常量之后,逐个带 jsDoc 或单行说明「为什么是这些值」的注释):`SPECIAL_LEFT = 24`、`SPECIAL_WIDTH = MAIN_WIDTH - 48`、`SPECIAL_TOP = 92`、`SPECIAL_HEIGHT = 150`、`SPECIAL_LABEL_HEIGHT = 22`、`SPECIAL_MIN_HEIGHT = 20`、`SPECIAL_GAP = 10`。 + 3. 文件级新增 `interface ISpecialRow`(对象类型必须先声明接口,不得内联):成员 `readonly special: ISpecial`(`iterateSpecials()` 返回 `ISpecial`,`any` 可赋给 `unknown`,避免把 `any` 带进行类型)、`readonly labelTop: number`、`readonly textTop: number`、`readonly textHeight: number`,每个成员一行 jsDoc 说明用途。**不要声明 `height` 成员**:渲染只读 `labelTop` / `textTop` / `textHeight`,行高只在 `specialRows` 内用局部 `top` 累加,声明一个从不被读取的字段只会变成死字段。 + 4. 组件内新增(放在 `specials` 之后、`critical` 之前): + - `const textHeights = ref>({});`,注释说明「按特殊属性 code 记录实测描述高度」; + - `const updateTextHeight = (code: number, height: number): void => { if (textHeights.value[code] === height) return; textHeights.value[code] = height; };`,注释说明「同值不写,避免高度回调与重挂载互相触发;供后续行计算纵坐标」; + - `watch(specials, () => { const next: Record = {}; for (const special of specials.value) { const height = textHeights.value[special.code]; if (height) next[special.code] = height; } textHeights.value = next; });`,注释说明「只保留仍存在的条目;不整体清空,否则没有重新上报高度的条目会退回最小高度、长描述重新重叠」; + - `const specialRows = computed(() => { const rows: ISpecialRow[] = []; let top = 0; for (const special of specials.value) { const measured = textHeights.value[special.code]; const textHeight = measured ?? SPECIAL_MIN_HEIGHT; rows.push({ special, labelTop: top, textTop: top + SPECIAL_LABEL_HEIGHT, textHeight }); top += SPECIAL_LABEL_HEIGHT + textHeight + SPECIAL_GAP; } return rows; });` —— **一次遍历**,不得再写「第 n 行前面所有行求和」的二次循环;**行高不进接口**(`top` 的累加量就是行高,只在循环内使用);`textHeight` 同时供 `TextContent` 的 `key` 使用。 + 5. `Scroll` 改为 ``,children 用一个单层 `VNode[]`(在渲染函数内先 `const specialNodes: VNode[] = [];`,对 `specialRows.value` 逐行 push 两个元素,最后 `{specialNodes}`): + - 标题 ``; + - 描述 ` updateTextHeight(row.special.code, height)} key={`${row.special.code}-${row.textTop}-${row.textHeight}`} />`。 + **两元素都直接作为 `Scroll` 子元素,不得包 `container`;`TextContent` 必须同时有 `autoHeight` 与 `onUpdateHeight`。这两个 `loc` 都引用 `row.*`(循环相关),因此保持内联、允许 Prettier 折行,不得上提成文件作用域常量,也不为此新增 `ElementLocator` 导入。** + **为什么用 `key` 重挂载(必须写进代码注释):** `TextContent` 在 setup 时把 `props.loc` 复制进内部 `loc`(`textbox.tsx:122-124`),之后改 `props.loc` 不会移动元素;`key` 同时包含 `textTop`(前置行高度变化时改变)与 `textHeight`(本行实测高度变化时改变),`key` 一变 Vue 就重挂载,让新 `loc` 生效。**循环防止:** ①`updateTextHeight` 仅在 `textHeights.value[code] !== height` 时写入,同值上报不触发更新;②重挂载后测得的高度与写入值相同,`key` 不再变化,不会再次重挂载;③首次测量前统一用 `SPECIAL_MIN_HEIGHT` 作为回退高度,先排版再按实测撑开,收敛后布局稳定。 + 6. 删除 `critical.value && (...)` 的两处条件渲染,改为恒渲染:`加攻` 为 ``;`减伤` 为 ``。**不要用 `?.`。** + 7. 保留标题、五个基础数值、`临界` 小标题、切换按钮、返回按钮的既有位置与文案。**根 `` 原样保留,不加 `nocache`。** + 8. **不得修改任何既有注释或 jsDoc**;新增注释遵循 dev.md(单行 `//`、`//` 后留空格、说明「为什么」)。 + + + node -e "const fs=require('fs');const b='.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json';const bad=[];if(!fs.existsSync(b))bad.push('baseline missing');else{const j=JSON.parse(fs.readFileSync(b,'utf8'));for(const k of ['head','hashes','dirty','eslint','typecheck'])if(!(k in j))bad.push('missing '+k);if(Object.keys(j.hashes||{}).length!==5)bad.push('hashes must have 5 entries');if(!Object.prototype.hasOwnProperty.call(j.hashes||{},'packages-user/client-base/src/components/textbox.tsx'))bad.push('textbox.tsx missing from hashes');if(!Object.prototype.hasOwnProperty.call(j.hashes||{},'packages-user/client-base/src/components/scroll.tsx'))bad.push('scroll.tsx missing from hashes');if(!Array.isArray(j.dirty))bad.push('dirty must be an array')}if(bad.length){console.error('TASK1 BASELINE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task1: baseline')" + node -e "const fs=require('fs');const c=fs.readFileSync('src/client/ui/book.tsx','utf8').replace(/\/\*[^]*?\*\//g,'').replace(/^[ \t]*\/\/[^\n]*/gm,'');const f=c.replace(/\s+/g,' ');const bad=[];for(const n of ['format(damage)','format(nextDiff)','format(damageDiff)','format(def1Damage)','transparent','noevent','VNode'])if(f.indexOf(n)<0)bad.push('missing :: '+n);if(f.indexOf('{critical &&')>=0)bad.push('old conditional critical remains');if(c.indexOf('def1 &&')>=0)bad.push('old conditional def1 remains');const n=(c.match(/])/g)||[]).length;if(n!==1)bad.push('expected exactly 1 container, got '+n);let i=0;while(true){const a=c.indexOf('',a);if(e<0){bad.push('unclosed Scroll');break}if(/])/.test(c.slice(a,e)))bad.push('container inside Scroll');i=e+1}if(bad.length){console.error('TASK1 BOOK GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task1: book.tsx')" + node -e "const fs=require('fs');const c=fs.readFileSync('src/client/ui/bookDetail.tsx','utf8').replace(/\/\*[^]*?\*\//g,'').replace(/^[ \t]*\/\/[^\n]*/gm,'');const f=c.replace(/\s+/g,' ');const bad=[];for(const n of ['autoHeight','onUpdateHeight','textHeights','specialRows','textTop','textHeight','ISpecialRow','ISpecial','format(critical.value ? critical.value.nextDiff : 0)','format(critical.value ? -critical.value.damageDiff : 0)'])if(f.indexOf(n)<0)bad.push('missing :: '+n);if(f.indexOf('{critical.value &&')>=0)bad.push('old conditional critical remains');const n=(c.match(/])/g)||[]).length;if(n!==1)bad.push('expected exactly 1 container, got '+n);let i=0;while(true){const a=c.indexOf('',a);if(e<0){bad.push('unclosed Scroll');break}if(/])/.test(c.slice(a,e)))bad.push('container inside Scroll');i=e+1}if(bad.length){console.error('TASK1 BOOKDETAIL GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task1: bookDetail.tsx')" + cmd /c "pnpm exec eslint src/client/ui/book.tsx src/client/ui/bookDetail.tsx" + 基线缺失或字段残缺、`hashes` 不是 5 条或缺 `scroll.tsx` / `textbox.tsx`、`dirty` 不是数组;`book.tsx` 仍保留旧条件渲染、缺任一 0 值格式化表达式或 `VNode` 导入、`` 计数不是 1、`Scroll` 内出现 `container`;`bookDetail.tsx` 缺 `autoHeight` / `onUpdateHeight` / `textHeights` / `specialRows` / `textTop` / `textHeight` / `ISpecial`、仍保留旧条件渲染、`` 计数不是 1、`Scroll` 内出现 `container`;eslint 报错;或任一 node 闸门非零退出。**该 `node -e` 刻意保持纯 ASCII 并对去注释后的文本做否定断言**:中文标签(`伤害` / `临界` / `减伤` / `1 防` / `加攻`)由 Task 4 的 `?raw` vitest 负责,说明性注释不会让本闸门假红;**不再检查 `loc` 是否物理单行**(Prettier 优先);本任务的两个文件**不得上提任何 `loc` 常量**(其 `loc` 全部循环相关);若违规上提且漏写 `: ElementLocator`,`pnpm check:type` 会因 `number[]` 不可赋给 `ElementLocator` 报错并在 Task 4 的 NEW 诊断门禁中被拦下。 + + `09-29-BASELINE.json` 已在改代码前写出且只写一次;`book.tsx` 每行是 13 个 `Scroll` 直接子元素(透明命中矩形 + 图标 + 名称 + 特殊属性 + 五个基础数值 + 四个恒显示的临界数值),四项临界信息缺失时显示 `0`,`` 只剩根,两条行内注释按 OI-5 原样迁移;`bookDetail.tsx` 每行是标题 `text` + 带 `autoHeight` / `onUpdateHeight` 的 `TextContent`,`specialRows` 一次遍历给出逐行纵坐标 / 实测高度,`TextContent` 的 `key` 含 `code` / `textTop` / `textHeight`,`加攻` / `减伤` 恒显示且缺失为 `0`,`` 只剩根;eslint 无报错。 + + + + Task 2: 平铺 `equipbox.tsx`(删三处外层容器 + 行平铺 + 按位置分流显示开关)与 `toolbox.tsx`(两列行平铺 + 未选择提示移出 `Scroll`) + src/client/ui/equipbox.tsx, src/client/ui/toolbox.tsx + + **依赖 Task 1 已完成。只改这两个文件;两者都不改逻辑体(`startPress` / `movePress` / `endPress` / `clickItem` / `getTargetSlot` / `collect` / `selectedItem` 等)。** + + **A. `src/client/ui/equipbox.tsx`** + 1. 顶部 `vue` 导入追加 `VNode`:`import { computed, defineComponent, ref, VNode } from 'vue';`;**并以独立一行的普通导入补入 `ElementLocator`**:字面形态必须就是 `import { ElementLocator } from '@motajs/render';`(第 2 步要声明 4 个带该注解的 `loc` 常量,必须引入;**不得并入其它 `@motajs/render` 导入、不得折行**——闸门按这个字面量断言;禁止 `import type`)。其它既有导入不变。 + 2. 文件级新增常量(放在既有 `OWNED_HEADER` / `OWNED_ROW_HEIGHT` 附近,各带一行说明):`DESC_LIST_TOP = DESC_TOP + 56`、`DESC_LIST_HEIGHT = DESC_HEIGHT - 56`、`OWNED_LIST_TOP = OWNED_TOP + OWNED_HEADER`、`OWNED_LIST_HEIGHT = OWNED_HEIGHT - OWNED_HEADER`;以及 4 个长 `loc` 常量——**每个都必须显式标注 `: ElementLocator`**(裸数组字面量会被推成 `number[]`,赋不给 `loc` 的固定元组,会直接类型报错):`const STATUS_SCROLL_LOC: ElementLocator = [0, STATUS_HEADER, STATUS_WIDTH, MAIN_HEIGHT - STATUS_HEADER]`、`const DESC_SCROLL_LOC: ElementLocator = [RIGHT_LEFT, DESC_LIST_TOP, RIGHT_WIDTH, DESC_LIST_HEIGHT]`、`const SLOT_SCROLL_LOC: ElementLocator = [RIGHT_LEFT, SLOT_TOP, RIGHT_WIDTH, SLOT_HEIGHT]`、`const OWNED_SCROLL_LOC: ElementLocator = [RIGHT_LEFT, OWNED_LIST_TOP, RIGHT_WIDTH, OWNED_LIST_HEIGHT]`。 + 3. 渲染函数改为「先建三个数组、再返回 JSX」:在 `return () => { ... }` 内先算 `const equip = selectedEquip.value; const d = diff.value;`,再建 `const statusNodes: VNode[] = [];`、`const slotNodes: VNode[] = [];`、`const ownedNodes: VNode[] = [];` 三个数组并逐项 push。 + 4. **删除三个外层 `container`**(描述区 `loc={[RIGHT_LEFT, DESC_TOP, RIGHT_WIDTH, DESC_HEIGHT]}`、装备槽区 `loc={[RIGHT_LEFT, SLOT_TOP, RIGHT_WIDTH, SLOT_HEIGHT]}`、拥有装备区 `loc={[RIGHT_LEFT, OWNED_TOP, RIGHT_WIDTH, OWNED_HEIGHT]}`),把其子元素按绝对坐标直接放到根 `` 下: + - `未选择装备` ` + + node -e "const fs=require('fs');const bad=[];const read=p=>fs.readFileSync(p,'utf8');const strip=s=>s.replace(/\/\*[^]*?\*\//g,'').replace(/^[ \t]*\/\/[^\n]*/gm,'');const e=strip(read('src/client/ui/equipbox.tsx'));const ef=e.replace(/\s+/g,' ');const t=strip(read('src/client/ui/toolbox.tsx'));const tf=t.replace(/\s+/g,' ');for(const n of ['hidden={!!equip}','hidden={!equip}','hidden={!dragging.value}','delta === 0 ? \'\'','equip ? equip.item.text : \'\'','slotEquip ? slotEquip.item.num : 0','DESC_LIST_TOP','DESC_LIST_HEIGHT','OWNED_LIST_TOP','OWNED_LIST_HEIGHT','STATUS_SCROLL_LOC','DESC_SCROLL_LOC','SLOT_SCROLL_LOC','OWNED_SCROLL_LOC','statusNodes','slotNodes','ownedNodes','import { ElementLocator }','STATUS_SCROLL_LOC: ElementLocator','DESC_SCROLL_LOC: ElementLocator','SLOT_SCROLL_LOC: ElementLocator','OWNED_SCROLL_LOC: ElementLocator'])if(ef.indexOf(n)<0)bad.push('equip missing :: '+n);for(const n of ['{!equip &&','{equip &&','{delta !== 0 &&','{slotEquip &&','{dragging.value &&','hidden={!slotEquip}','hidden={delta === 0}'])if(e.indexOf(n)>=0)bad.push('equip leftover :: '+n);for(const n of ['hidden={!!current}','permNodes','consNodes','current ? current.raw.text : \'\''])if(tf.indexOf(n)<0)bad.push('toolbox missing :: '+n);for(const n of ['{!current &&','{current &&','hidden={!current}'])if(t.indexOf(n)>=0)bad.push('toolbox leftover :: '+n);for(const pair of [['equipbox',e],['toolbox',t]]){const n=(pair[1].match(/])/g)||[]).length;if(n!==1)bad.push(pair[0]+' expected exactly 1 container, got '+n);let i=0;while(true){const a=pair[1].indexOf('',a);if(z<0){bad.push(pair[0]+' unclosed Scroll');break}if(/])/.test(pair[1].slice(a,z)))bad.push(pair[0]+' container inside Scroll');i=z+1}}if(bad.length){console.error('TASK2 GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task2: equipbox + toolbox')" + cmd /c "pnpm exec eslint src/client/ui/equipbox.tsx src/client/ui/toolbox.tsx" + 两文件 `` 计数不为 1、`Scroll` 内仍有 `container`;`equipbox` 缺任一指定的 `hidden` / 动态空文本 / 0 回退、或仍保留旧条件渲染(`{!equip &&` / `{equip &&` / `{delta !== 0 &&` / `{slotEquip &&` / `{dragging.value &&`)、缺四个 `*_LIST_*` 或四个 `*_SCROLL_LOC` 常量、**缺 `ElementLocator` 导入或某个 `*_SCROLL_LOC` 没写 `: ElementLocator` 注解**、缺三个节点数组;`toolbox` 仍用条件渲染、缺 `hidden={!!current}` 或 `permNodes` / `consNodes`、或 `TextContent` 上仍有 `hidden`;eslint 报错;或 node 闸门非零退出。**不再检查 `loc` 是否物理单行**(Prettier 优先);漏写 `: ElementLocator` 也会被 Task 4 的 `pnpm check:type` NEW 诊断门禁拦下(`number[]` 不可赋给 `ElementLocator`)。 + + `equipbox.tsx` 的 `` 只剩根:描述区 / 装备槽区 / 拥有装备区三处外层容器已删除,状态行 / 槽位 / 拥有装备行全部平铺;7 处显示开关按位置分流(`Scroll` 外 4 处 `hidden`,`Scroll` 内增减值空文本 / 描述 `TextContent` 空文本 / 槽位图标 0 回退);4 个长 `loc` 常量就位并都标注 `: ElementLocator`;槽位悬停与图标起手拖动、整行按下拖动保持(命中矩形内缩 2-4px 已报备);`toolbox.tsx` 两列列表每行平铺为 `g-rectr` + `icon` + 名称 + 数量,未选择提示在 `Scroll` 外用 `hidden`、描述 `TextContent` 恒挂载 + 空文本,`clickItem` 逻辑不变;eslint 无报错。 + + + + Task 3: 全平铺 `hotkey.tsx`(删 6 个容器 + `commandRows` 预计算 + 移除 `Selection`、选中态改用带边框 `g-rectr` + 根 `nocache`) + src/client/ui/hotkey.tsx + + **依赖 Task 2 已完成。只改这个文件;`hotkey.tsx` 的捕获 / 写回 / 关闭 / 无入口 / 无持久化 / 无冲突 / 无恢复默认行为口径零改动。** + + **A. `src/client/ui/hotkey.tsx`** + 1. 顶部 `vue` 导入追加 `VNode`:`import { computed, defineComponent, onUnmounted, ref, shallowRef, VNode } from 'vue';`。**仅当第 9 步真的提出了 `GROUP_SCROLL_LOC` / `COMMAND_SCROLL_LOC`(即对应 `Scroll` 的 `loc` 被 Prettier 折行)**,才补**独立一行**的 `import { ElementLocator } from '@motajs/render';`(普通导入,字面形态不得改动、不得并入其它导入);没有提出就**不要**引入。其它既有导入不变。**同时必须从 `@user/client-base` 的导入里删掉 `Selection`**:把 `import { IGameUIPropsBase, Scroll, Selection, useKey } from '@user/client-base';` 改成 `import { IGameUIPropsBase, Scroll, useKey } from '@user/client-base';`——改动后文件内不得出现 `Selection` 标识符或 `(() => { const rows: ICommandRow[] = []; let top = 0; for (const command of commands.value) { const height = Math.max(command.length, 1) * KEY_BOX_HEIGHT + COMMAND_GAP; rows.push({ command, top }); top += height; } return rows; });`(一次遍历,`height` 只是局部变量)。 + 5. 渲染函数改为「先建两个数组、再返回 JSX」:`const groupNodes: VNode[] = [];`、`const commandNodes: VNode[] = [];`。 + 6. **左栏**:删除内容总高容器与分组行容器。**顺序固定为「每行先 push `g-rectr`,再 push 分组名 `text`」**。用 `for (let groupIndex = 0; groupIndex < groups.length; groupIndex++)` 逐项取 `const [id, group] = groups[groupIndex];`(`groups` 是渲染函数内由 `hotkey.iterateGroups()` 得到的普通数组,`selectedGroup` 默认指向首个分组),每组取 `const top = groupIndex * GROUP_ROW_HEIGHT; const isSelected = id === selectedGroup.value;`,push 两个元素,顺序固定: + - ` selectGroup(id)} key={\`hit-${id}\`} />`(**用 `g-rectr` 不用 `g-rect`**:既有 `hotkeySettingsGate.test.ts` 断言 `hotkey.tsx` 不含 ``。 + **左栏没有任何 `Selection`、没有 `selectedGroupIndex`、没有 `if (isSelected)` 条件挂载、没有 `hidden`。** + `Scroll` 改为 `{groupNodes}`。 + 7. **右栏**:删除内容总高容器、指令行容器、按键区容器、按键框容器。对 `commandRows.value` 逐项(`const list = combinationLists.value[commandIndex] ?? [];`)先把指令名 push 进 `commandNodes`: + - ``; + 再对 `list` 用**内层 `for` 循环**(`for (let index = 0; index < list.length; index++)`,不要用 `flatMap` 造嵌套数组)生成每个组合的两个元素(`const keys = list[index]; const boxTop = row.top + index * KEY_BOX_HEIGHT;`、`const isSelected = selectedCommand.value === row.command && selectedIndex.value === index;`、`const showing = isSelected && heldKeys.value.length > 0 ? heldKeys.value : keys;`),逐个 push 进 `commandNodes`,**顺序固定**: + - ` selectKey(row.command, index)} key={\`box-${commandIndex}-${index}\`} />`(**选中态就是这个既有矩形的边框**,不额外 push 任何元素、不用 `hidden`); + - ``。 + `Scroll` 改为 `{commandNodes}`。 + 8. 左栏的透明 `g-rectr` 是本次新增的**唯一命中层**,它同时承载选中边框;右栏按键框的 `g-rectr` 本来就是命中层,现在同时承载选中边框。`hotkey.tsx` 内**不得出现 `` **加 `nocache`**(NOTES §5)。**全文件没有任何 `Selection`,也没有 `if (isSelected)` 条件挂载;选中态完全由 `g-rectr` 的 `strokeStyle` / `lineWidth` 表达。** + 9. 捕获监听(捕获阶段 + `preventDefault` / `stopPropagation` / `stopImmediatePropagation`)、heldKeys 规则、`setKeyCombination` 写回、`refreshCombinations` 快照、`@common/exit` 守卫、`close` 与 `返回` 文本全部保持原样。**不得修改任何既有注释或 jsDoc。** 两个 `Scroll` 的 `loc`(`[LEFT_PAD, LIST_TOP, GROUP_WIDTH, LIST_HEIGHT]` 与 `[RIGHT_LEFT, LIST_TOP, RIGHT_WIDTH, LIST_HEIGHT]`)是本文件唯一的循环无关 `loc`:**仅当 Prettier 折行时**才提为 `GROUP_SCROLL_LOC` / `COMMAND_SCROLL_LOC`(名字已在 OI-2 列明),并标注 `: ElementLocator` + 独立一行 `import { ElementLocator } from '@motajs/render';`;**行内 `loc`(引用 `top` / `boxTop`)属于循环相关,保持内联、不得上提**;除根外不得出现 `container`。 + + **注释纪律:** 只新增注释,不改既有注释与 jsDoc;新增注释遵循 dev.md(单行 `//`、`//` 后留空格、说明「为什么」)。 + + + node -e "const fs=require('fs');const bad=[];const read=p=>fs.readFileSync(p,'utf8');const strip=s=>s.replace(/\/\*[^]*?\*\//g,'').replace(/^[ \t]*\/\/[^\n]*/gm,'');const h=strip(read('src/client/ui/hotkey.tsx'));const hf=h.replace(/\s+/g,' ');for(const n of ['commandRows','ICommandRow','BOX_LEFT','BOX_FILL_HEIGHT','groupNodes','commandNodes','setKeyCombination('])if(hf.indexOf(n)<0)bad.push('hotkey missing :: '+n);if(h.indexOf('Selection')>=0)bad.push('hotkey must not reference Selection');if(/if \(isSelected\)/.test(h))bad.push('hotkey must not conditionally mount anything');if(/\{isSelected &&/.test(h))bad.push('hotkey must not use isSelected && JSX expression');if(/isSelected && (?:groupNodes|commandNodes)\.push/.test(h))bad.push('hotkey must not use isSelected && push statement');const selStroke=(h.match(/strokeStyle=\{\s*isSelected\s*\?/g)||[]).length;if(selStroke!==2)bad.push('hotkey expected 2 strokeStyle checks, got '+selStroke);const selWidth=(h.match(/lineWidth=\{\s*isSelected\s*\?/g)||[]).length;if(selWidth!==2)bad.push('hotkey expected 2 lineWidth checks, got '+selWidth);for(const n of ['getCommandTop','getCommandHeight','getContentHeight','hidden={!isSelected}'])if(h.indexOf(n)>=0)bad.push('hotkey leftover :: '+n);const n=(h.match(/])/g)||[]).length;if(n!==1)bad.push('hotkey expected exactly 1 container, got '+n);const rootTag=h.slice(h.indexOf('', h.indexOf(']/.test(h))bad.push('hotkey must not contain g-rect');let i=0;while(true){const a=h.indexOf('',a);if(z<0){bad.push('hotkey unclosed Scroll');break}const block=h.slice(a,z);if(/])/.test(block))bad.push('hotkey container inside Scroll');if(/hidden\s*=/.test(block))bad.push('hotkey hidden inside Scroll');i=z+1}if(bad.length){console.error('TASK3 GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK task3: hotkey')" + cmd /c "pnpm exec eslint src/client/ui/hotkey.tsx" + `hotkey.tsx` 仍含 `Selection` / `` 计数不为 1、任一 `Scroll` 内仍有 `container` 或 `hidden=`、出现 ` + + `hotkey.tsx` 两栏都是平铺的 `Scroll` 直接子元素(6 个容器全删,`` 只剩根且带 `nocache`),`commandRows` 一次遍历给出每行 `top`(`ICommandRow` 无未使用的 `height`),`getCommandTop` / `getCommandHeight` / `getContentHeight` 全部删除;**`Selection` 已从导入与模板中完全移除**,选中态改为既有 `g-rectr` 的边框:左栏分组行与右栏按键框各用 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}` 表示,无新增元素、无 `hidden`、无 `if (isSelected)` 条件挂载;行点击与按键框点击仍可用,捕获与写回逻辑不变;eslint 无报错。 + + + + Task 4: 新增 `?raw` 源码闸门 `uiContainerGate.test.ts`,跑 eslint / 类型前后基线 / 哈希与范围门禁,写执行摘要 + src/client/ui/__test__/uiContainerGate.test.ts, .planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md + + **依赖 Task 1、2、3 已完成。** + + **A. 新建 `src/client/ui/__test__/uiContainerGate.test.ts`(vitest + `?raw`,不导入任何 Vue 组件、不使用 `node:fs`)。** + 1. 导入(连续排列,中间不加注释 / 空行):`describe` / `expect` / `it` 来自 `vitest`;`../book.tsx?raw`、`../bookDetail.tsx?raw`、`../equipbox.tsx?raw`、`../toolbox.tsx?raw`、`../hotkey.tsx?raw`,默认导入名分别为 `bookSource` / `bookDetailSource` / `equipBoxSource` / `toolBoxSource` / `hotkeySource`。 + 2. 五个文件内私有辅助函数(每个带一行说明注释): + - `stripComments(source: string): string` —— 先去 `{/* ... */}`,再去 `/* ... */`,再按行去掉 `//` 行注释,保留换行(用于否定断言); + - `flatten(source: string): string` —— `source.replace(/\s+/g, ' ').trim()`,把跨行书写的表达式压成单行(用于肯定断言); + - `getScrollBlocks(source: string): string[]` —— 循环查找 ``,返回每段(含两端标签),找不到闭合就停止; + - `firstContainerTag(source: string): string` —— 从第一个 `/])/` 匹配处截到其后第一个 `>` 的子串(`` 不会被误认); + - `tagAround(source: string, tag: string, needle: string): string` —— 先 `const i = source.indexOf(needle)`,**紧接着 `if (i < 0) return '';`**(否则 `lastIndexOf('<' + tag, -1)` 会从文件开头倒退、`indexOf('/>', -1)` 会命中文件里第一个自闭合标签,负向断言就会抽到无关标签而假通过);再 `source.slice(source.lastIndexOf('<' + tag, i), source.indexOf('/>', i) + 2)`,返回包含该 needle 的那个自闭合元素标签原文。**用于把「某个被覆盖的兄弟元素是否带 `noevent`」变成逐元素断言,而不是全文子串存在性**;找不到 needle 时返回空串(调用方据此判失败)。 + 3. 断言(**每个 `it` 前必须有一行 `//` 注释说明覆盖内容**): + 1) **`Scroll` 块内既无 `container` 也无 `hidden=`**:对 `book` / `bookDetail` / `equipBox` / `toolBox` / `hotkey` 五个文件,`getScrollBlocks(stripComments(src))` 的长度大于 0,且每一段都不匹配 `/])/`、**也都不匹配 `/hidden\s*=/`**(显示开关的位置必须靠这个逐块断言来证明,不能只查全文有没有 `hidden`——`Scroll` 外的 `hidden` 是允许且必需的)。 + 2) **`Scroll` 外没有包装 `container`**:对五个文件,用 `stripComments(src).match(/])/g)` 计数必须恰好为 `1`(只有根;任何旧包装容器或行容器都会让计数 ≥2)。另外 `flatten(stripComments(equipBoxSource))` 不含 `RIGHT_LEFT, DESC_TOP, RIGHT_WIDTH, DESC_HEIGHT` 与 `RIGHT_LEFT, OWNED_TOP, RIGHT_WIDTH, OWNED_HEIGHT` 两个旧包装容器的 `loc` 片段(描述区改用 `DESC_SCROLL_LOC`,拥有装备区改用 `OWNED_SCROLL_LOC`)。**不要用 `/]*>\s*` 的 `onMove={...}` 里含 `>`,正则会提前截断而近似恒真。 + 3) **`hotkey` 两栏平铺、无 `Selection`、选中态为边框**:`getScrollBlocks(stripComments(hotkeySource)).length` 为 `2`;两段都不含 ``、`${row.special.code}-${row.textTop}-${row.textHeight}`(`TextContent` 的 `key`)。 + 7) **显示开关按位置分流、旧条件渲染消失、命中层与覆盖元素成对**: + - **位置**(已在断言 1 由 `getScrollBlocks()` 逐块保证 `Scroll` 内无 `hidden=`,这里只补正向存在性):`flatten(stripComments(equipBoxSource))` 含 `hidden={!!equip}`、`hidden={!equip}`、`hidden={!dragging.value}`、`delta === 0 ? ''`、`equip ? equip.item.text : ''`、`slotEquip ? slotEquip.item.num : 0`,且不含 `{!equip &&`、`{equip &&`、`{delta !== 0 &&`、`{slotEquip &&`、`{dragging.value &&`、`hidden={!slotEquip}`、`hidden={delta === 0}`;`flatten(stripComments(toolBoxSource))` 含 `hidden={!!current}` 与 `current ? current.raw.text : ''`,且不含 `{!current &&`、`{current &&`、`hidden={!current}`;**并且提示必须排在说明 `Scroll` 之前**:`stripComments(toolBoxSource).indexOf('hidden={!!current}')` 必须小于 `stripComments(toolBoxSource).indexOf("current ? current.raw.text : ''")`(提示在前,`Scroll` 绘制在其上方,不拦截 `Scroll` 内的指针事件);`stripComments(hotkeySource)` 不含 `Selection`,且 `tagAround(stripComments(hotkeySource), 'g-rectr', 'selectGroup(id)')` 与 `tagAround(..., 'g-rectr', 'selectKey(row.command, index)')` 都含 `strokeStyle` / `lineWidth`;**`hotkey` 不参与「9 处显示开关」计数**:选中态是边框颜色状态,元素始终挂载,不含 `hidden={!isSelected}` 与 `{isSelected &&`。 + - **`noevent` 配对(逐个元素断言,不用全文子串)**:用 `tagAround()` 取出元素标签后断言—— + - `tagAround(stripComments(bookSource), 'text', "base.getAttribute('name')")`、`tagAround(..., 'text', 'format(damage)')`、`tagAround(..., 'icon', 'icon={base.code}')` 都含 `noevent`(**needle 用 `icon={base.code}` 而不是 `base.code`**:`base.code` 在更早的 `hit-${base.code}` key 里就出现过,`lastIndexOf('..HEAD` 的每个路径都以 `.planning/` 开头(编排器聚合提交);任何非 `.planning` 路径的提交都判失败(说明执行器提交了代码)。不得用「HEAD 必须完全相等」作为唯一条件。 + - 范围(**两条都要**):①`git status --porcelain -uall -- <6 个代码 / 测试路径>` 只出现这 6 个路径,且每个都出现;②**整仓**`git status --porcelain -uall`(不加 pathspec)里的每条路径,必须要么属于这 6 个路径,要么出现在 `09-29-BASELINE.json` 的 `dirty` 快照里,要么以 `.planning/` 开头——否则说明有范围外写入。**为什么必须加第 ② 条**:当前工作树在开工前就是脏的(`AGENTS.md`、`packages-user/client-base/src/map/*`、若干未跟踪文件),pathspec 作用域的 `git status` 只回报这 6 个路径,根本看不见范围外的写入。 + + **注释纪律:** 只新增注释,不改既有注释与 jsDoc。 + + + cmd /c "pnpm exec vitest run src/client/ui/__test__/uiContainerGate.test.ts" + node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json','utf8'));const files=['src/client/ui/book.tsx','src/client/ui/bookDetail.tsx','src/client/ui/equipbox.tsx','src/client/ui/toolbox.tsx','src/client/ui/hotkey.tsx','src/client/ui/__test__/uiContainerGate.test.ts'];const r=cp.spawnSync('cmd',['/c','pnpm exec eslint -f json '+files.join(' ')],{encoding:'utf8',timeout:300000});const text=(r.stdout||'').trim();const bad=[];if(r.status===null||r.status>=2||!text)bad.push('eslint failed to run (status '+r.status+')');let arr=[];try{arr=JSON.parse(text||'[]')}catch(e){bad.push('eslint json unparsable')}const norm=p=>{const s=String(p).replace(/\\/g,'/');const i=s.indexOf('src/client/ui/');return i>=0?s.slice(i):s};const cur=[];for(const f of arr){for(const m of (f.messages||[]))cur.push(norm(f.filePath)+'|'+String(m.ruleId||'null')+'|'+String(m.severity)+'|'+String(m.message).trim())}const before=b.eslint||[];const count=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(count(cur,v)>count(before,v))bad.push('NEW eslint :: '+v)}if(bad.length){console.error('ESLINT GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK eslint: no new entries (baseline '+before.length+' -> current '+cur.length+')')" + node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json','utf8'));const scope=['src/client/ui/book.tsx','src/client/ui/bookDetail.tsx','src/client/ui/equipbox.tsx','src/client/ui/toolbox.tsx','src/client/ui/hotkey.tsx','src/client/ui/__test__/uiContainerGate.test.ts'];const r=cp.spawnSync('cmd',['/c','pnpm check:type'],{encoding:'utf8',timeout:900000});const text=(r.stdout||'')+(r.stderr||'');const diag=text.split(/\r?\n/).filter(l=>/error TS\d+/.test(l));const bad=[];if(r.status===null)bad.push('vue-tsc did not run');else if(r.status!==0&&diag.length===0)bad.push('vue-tsc crashed with no parseable diagnostics');const norm=l=>l.replace(/\\/g,'/').replace(/\(\d+,\d+\):/,'').replace(/^.*?(?=(src|packages-user)\/)/,'').trim();const cur=diag.map(norm).filter(x=>scope.some(p=>x.indexOf(p)===0));const before=(b.typecheck||[]).filter(x=>scope.some(p=>x.indexOf(p)===0));const count=(a,v)=>{let n=0;for(const x of a)if(x===v)n++;return n};for(const v of new Set(cur)){if(count(cur,v)>count(before,v))bad.push('NEW diagnostic :: '+v)}if(bad.length){console.error('TYPECHECK GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK typecheck: no new diagnostics in changed files (baseline '+before.length+' -> current '+cur.length+')')" + node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json','utf8'));const bad=[];for(const file of Object.keys(b.hashes||{})){const r=cp.spawnSync('git',['hash-object',file],{encoding:'utf8'});if(r.status!==0)bad.push('cannot hash '+file);else if((r.stdout||'').trim()!==b.hashes[file])bad.push(file+' changed')}const head=(cp.spawnSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).stdout||'').trim();if(head!==b.head){const d=cp.spawnSync('git',['diff','--name-only',b.head+'..'+head],{encoding:'utf8'});if(d.status!==0)bad.push('HEAD changed and diff failed ('+b.head+'..'+head+')');else{const paths=(d.stdout||'').split(/\r?\n/).filter(Boolean);const nonPlan=paths.filter(p=>!p.startsWith('.planning/'));if(nonPlan.length)bad.push('HEAD changed with non-planning commits :: '+nonPlan.join(','))}}if(bad.length){console.error('PROTECTED GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK protected files unchanged; HEAD policy satisfied ('+b.head+' -> '+head+')')" + node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json','utf8'));const allowed=['src/client/ui/book.tsx','src/client/ui/bookDetail.tsx','src/client/ui/equipbox.tsx','src/client/ui/toolbox.tsx','src/client/ui/hotkey.tsx','src/client/ui/__test__/uiContainerGate.test.ts'];const bad=[];if(!Array.isArray(b.dirty))bad.push('baseline dirty snapshot missing');const out=cp.spawnSync('git',['status','--porcelain','-uall'],{encoding:'utf8'}).stdout||'';const paths=out.split(/\r?\n/).filter(Boolean).map(l=>l.slice(3).replace(/^\"|\"$/g,'')).filter(p=>p.indexOf('.planning/')!==0);const before=new Set((b.dirty||[]).map(p=>String(p).replace(/\\/g,'/')));for(const p of paths)if(!allowed.includes(p)&&!before.has(p))bad.push('out-of-scope write :: '+p);for(const p of allowed)if(!paths.includes(p))bad.push('expected change missing :: '+p);if(bad.length){console.error('SCOPE GATE FAILED :: '+bad.join(' ; '));process.exit(1)}console.log('OK scope: only the six code/test paths added to the dirty tree ('+paths.length+' dirty paths total, '+before.size+' pre-existing)')" + 闸门测试失败、eslint 出现 NEW 条目(归一化口径必须与 Task 1 相同)、`pnpm check:type` 在改动文件集合内出现 NEW 诊断(`typecheck` 归一化的 `norm` 也必须与 Task 1 逐字相同、**保留 `src/` / `packages-user/` 前缀**;含 `loc` 常量漏写 `: ElementLocator` 导致的 `number[]` 不可赋值错误)、受保护文件哈希变化(**5 个**,含 `textbox.tsx`)、HEAD 变化且 `baseline..HEAD` 含非 `.planning` 路径(说明执行器提交了代码)、范围内路径缺失、**整仓脏文件里出现既不在基线 `dirty` 快照也不在 6 个允许路径中的新路径(范围外写入)**、或任一 node 闸门非零退出。**注意不要把 `keyAdaptationGate.test.ts` 的既存失败计入本闸门**:它不在 6 个允许路径里,也不由本计划修复。 + + `uiContainerGate.test.ts` 的 9 组断言全部通过(含 `Scroll` 块内无 `hidden=`、`noevent` 逐元素配对、`loc` 常量 `: ElementLocator` 注解、`hotkey` 内不含 `Selection` 且左栏分组 `g-rectr` / 右栏按键框 `g-rectr` 各带 `strokeStyle={isSelected ? …}` / `lineWidth={isSelected ? …}` 的断言);eslint 无 NEW(归一化口径与 Task 1 一致);`pnpm check:type` 在改动文件集合内无 NEW(既有基线诊断允许消失);`scroll.tsx` / `textbox.tsx` / `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 五个哈希与基线一致;HEAD 与基线相同或仅含 `.planning` 聚合提交(执行器 0 次代码提交);6 个代码 / 测试路径全部出现且整仓脏文件相对基线快照没有新增范围外路径;`09-29-SUMMARY.md` 已写出并如实声明未做浏览器 UAT、执行器 0 次代码提交、估算未校准、`book.tsx` 过期 TODO 未改、命中矩形内缩 2-4px、`keyAdaptationGate.test.ts` 为既存失败(不在本计划范围)。 + + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| 渲染引擎 ↔ 本计划改动的 UI | `Scroll` / `TextContent` / `g-rectr` (`strokeStyle` / `lineWidth`)/ `icon` 的实现由系统层提供,本计划只改变调用方式;调用方式一旦违反其契约(`Scroll` 直接子元素、`TextContent` 高度回调、`noevent` 下穿),界面会静默退化 | +| 指针 / 滚轮事件 ↔ 列表行 | 去掉行容器后,命中层与上层覆盖元素的关系变成显式契约:上层元素漏写 `noevent` 就会吞掉点击与拖拽起手 | +| 特殊属性文案 ↔ 行位置计算 | `TextContent` 上报的高度是异步的(挂载后 / 文案变化时),行位置依赖这份回调;高度表被误清空会让长描述重新重叠 | +| 计划范围 ↔ 系统层实现 | `scroll.tsx` / `textbox.tsx` 是不可改的底线;改它们等于用系统层「兜底」掩盖调用错误 | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-09-29-01 | Tampering | 5 个列表文件里的 `Scroll` 子元素结构 | high | mitigate | 源码闸门逐段扫描 `...` 断言无 `/])/`;每个文件断言 `` 计数恰好 1(仅根)——任何外层包装或行容器都会让计数 ≥2;`equipbox` 额外断言描述区 / 拥有装备区两个旧包装容器的 `loc` 片段消失。不使用会被 `onMove={...}` 内 `>` 截断的紧邻正则 | +| T-09-29-02 | Denial of Service | `container` 缓存层导致的内存与重绘压力 | medium | mitigate | 删除三处外层容器与全部行容器;`hotkey` 根加 `nocache`;不新增任何 `container` | +| T-09-29-03 | Tampering | `book` / `equipbox` / `toolbox` / `hotkey` 的点击与拖拽命中 | high | mitigate | 命中层(透明 `g-rect` / `g-rectr`)与上层元素成对出现,上层一律 `noevent`;闸门用 `tagAround()` **逐个元素**断言被覆盖兄弟带 `noevent`、命中层本身**不带** `noevent`(`book` 名称 / `伤害` / 图标、`equipbox` 拥有装备行名称与图标 / 槽名、`toolbox` 名称与数量、`hotkey` 分组名 / 指令名 / 组合文本 vs. 三个 `g-rect` / `g-rectr` 点击目标);槽位图标同时承担 `onEnter` / `onLeave` / `onDown`;`startPress` / `clickItem` 逻辑体零改动;命中矩形内缩 2-4px 已在 Task 0 报备;浏览器 UAT 交给用户并如实声明未验证 | +| T-09-29-04 | Denial of Service | `bookDetail` 高度回调与 `TextContent` 重挂载引发的更新循环 | medium | mitigate | `updateTextHeight` 同值不写;`key` 含 `textTop` / `textHeight`,高度收敛后 `key` 稳定、不再重挂载;首次测量用 `SPECIAL_MIN_HEIGHT` 回退;高度表只在 `specials` 变化时按现存 `code` 重建 | +| T-09-29-05 | Tampering | 用改 `scroll.tsx` / `textbox.tsx` 来「修好」虚拟滚动或高度测量 | high | mitigate | **5 个**文件进入受保护哈希列表(`scroll.tsx` / `textbox.tsx` / `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts`)并与基线逐字比较;范围闸门用「全仓 `git status` 对照基线 `dirty` 快照 + 6 个允许路径」检出范围外写入(工作树本来就脏,pathspec 作用域看不到);OI-4 规定遇到数组子元素异常必须停下上报 | +| T-09-29-06 | Elevation of Privilege | 顺手改设置入口 / 按键注册 / UI 导出清单 | medium | mitigate | `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 进入受保护哈希列表并与基线逐字比较 | +| T-09-29-07 | Tampering | 在 `Scroll` 直接子元素上使用 `hidden`,被 `Scroll` 的 `show()` / `hide()` 覆盖 | high | mitigate | 显示开关按位置分流:`Scroll` 外 5 处 `hidden`,`Scroll` 内 4 处动态空文本 / 0 回退;`hotkey` 的选中态不使用任何显示开关,只改既有 `g-rectr` 的 `strokeStyle` / `lineWidth`;闸门用 `getScrollBlocks()` **逐块**断言 `Scroll` 内不存在任何 `hidden=`(位置断言,而非全文子串),并断言 `hotkey` 内不含 `Selection`、两处 `g-rectr` 各带选中边框 | +| T-09-29-SC | Tampering | npm / pnpm 安装 | high | mitigate | 本计划**不新增任何依赖、不执行安装命令**;若执行期出现安装需求,必须停下上报,不得自行安装 | + + + +- **源码形状**:`src/client/ui/__test__/uiContainerGate.test.ts` 9 组断言全部通过(`?raw`,不导入组件):`Scroll` 块内既无 `container` 也无 `hidden=`(位置断言,用 `getScrollBlocks()` 逐块扫描);5 个文件各只有 1 个根 `container`;`hotkey` 两栏平铺、无三个旧函数、**不含任何 `Selection`**、左栏分组 `g-rectr` 与右栏按键框 `g-rectr` 各带 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}`;`book` 四项 / `bookDetail` 两项临界信息 0 值回退;`bookDetail` 使用 `autoHeight` / `onUpdateHeight` / `textTop` / `textHeight` 并以 `code` + `textTop` + 实测高度为 `key`;9 处显示开关按位置分流(`Scroll` 外 5 处 `hidden`、`Scroll` 内 4 处动态空文本 / 0 回退;`hotkey` 无显示开关)、旧条件渲染消失,且被覆盖兄弟元素逐个带 `noevent`、命中层自身不带;`hotkey` 根有 `nocache`;`equipbox` 4 个长 `loc` 常量存在且都标注 `: ElementLocator`。 +- **静态检查**:`pnpm exec eslint` 对 6 个改动文件无 NEW(路径归一化口径在 Task 1 与 Task 4 中逐字一致);`pnpm check:type` 整仓运行后在 6 个改动文件内无 NEW 诊断(既有基线诊断允许消失,不修无关错误;`loc` 常量漏写 `: ElementLocator` 会在这里报 `number[]` 不可赋值)。 +- **范围**:`git status --porcelain -uall -- <6 个代码 / 测试路径>` 恰好只有这 6 个路径;**并且**整仓 `git status --porcelain -uall` 里没有「不在基线 `dirty` 快照、也不在这 6 个路径、也不在 `.planning/` 下」的新路径;**5 个**受保护文件(`scroll.tsx` / `textbox.tsx` / `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts`)哈希与基线一致。 +- **既有闸门**:`src/client/ui/__test__/hotkeySettingsGate.test.ts` 的 `/]/` 断言仍为绿(`hotkey.tsx` 只用透明 `g-rectr` 做命中层,Task 3 的 node 闸门会提前拦住 `..HEAD` 只含 `.planning/` 路径(编排器聚合提交);任何非 `.planning` 路径的提交都判失败。 +- **无浏览器验证声明**:SUMMARY 明确写入「未做浏览器 UAT,未宣称滚动 / 内存 / 点击命中 / 拖拽行为已验证」;`book.tsx` 的过期 TODO 按 OI-1 未改。 + + + +- [ ] `book.tsx`:四项临界信息恒渲染、缺失为 `0`;每行 13 个 `Scroll` 直接子元素;透明命中矩形 + 上层 `noevent`;两条行内注释按 OI-5 原样迁移;根不加 `nocache`。 +- [ ] `bookDetail.tsx`:特殊属性每行只有标题 `text` + `TextContent`;`autoHeight` + `onUpdateHeight` 生效;`specialRows` 一次遍历给出逐行位置与 `textHeight`;`TextContent` 的 `key` 含 `code` / `textTop` / `textHeight`,能重挂载到新 `loc`;`specials` 变化时按 `code` 重建高度表;`加攻` / `减伤` 恒渲染、缺失为 `0`。 +- [ ] `equipbox.tsx`:三处外层容器删除;状态行 / 槽位 / 拥有装备行全部平铺;7 处显示开关按位置分流(`Scroll` 外 4 处 `hidden`、`Scroll` 内 3 处动态空文本 / 0 回退);4 个长 `loc` 常量且都标注 `: ElementLocator`;拖拽起手与点击语义保持(命中矩形内缩 2-4px 已报备)。 +- [ ] `toolbox.tsx`:两列每行平铺为 `g-rectr` + `icon` + 名称 + 数量;未选择提示在说明 `Scroll` **之前**的根层、用 `hidden`、不会拦截 `Scroll` 内指针事件;描述 `TextContent` 恒挂载 + 空文本;`clickItem` 逻辑不变。 +- [ ] `hotkey.tsx`:两个 `Scroll` 内零 `container`(共删 6 个);`commandRows` 一次遍历且 `ICommandRow` 无 `height`;`getCommandTop` / `getCommandHeight` / `getContentHeight` 删除;**`Selection` 完全移除**(导入、模板、注释都不含该词),选中态改为既有 `g-rectr` 的边框:左栏分组行与右栏按键框各写 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}`,不新增元素、不用 `hidden`、不做 `if (isSelected)` 条件挂载、不用 `isSelected &&` 表达式语句;捕获与写回口径不变;根加 `nocache`。 +- [ ] **只有循环无关的 `loc` 被上提为常量**(`equipbox` 的 4 个 `*_SCROLL_LOC`;`toolbox` / `hotkey` 各最多 2 个 `*_SCROLL_LOC`,且仅在被 Prettier 折行时),**一律标注 `: ElementLocator`**、以独立一行 `import { ElementLocator } from '@motajs/render';` 引入;**循环相关的 `loc`(引用 `i` / `y` / `cellX` / `boxTop` / `row` / `top`)保持内联、允许 Prettier 折行,未被上提**;`Scroll` 内的显示开关不使用 `hidden`;根之外无 `container`。 +- [ ] `uiContainerGate.test.ts` 9 组断言通过(含 `Scroll` 内无 `hidden=`、`noevent` 逐元素配对、`loc` 常量注解、`hotkey` 内无 `Selection` 且左栏分组 `g-rectr` / 右栏按键框 `g-rectr` 各带选中边框);eslint 无 NEW(归一化口径一致);`pnpm check:type` 改动文件集合内无 NEW;5 个哈希 / HEAD 政策 / 范围门禁(含全仓脏文件对比)通过。 +- [ ] `09-29-SUMMARY.md` 写出,含行为变化(0 值回退 / 9 处显示开关分流;`hotkey` 选中态是边框状态、不计入显示开关 / `TextContent` key 重挂载 / 命中内缩 2-4px / `toolbox` 提示位置 / 列表末端可滚动范围减小量 / hotkey 根 `nocache` / 移除 `Selection` 并改用 `g-rectr` 的 `strokeStyle` / `lineWidth` 表示选中)、门禁结果、未 UAT 声明、估算未校准声明、执行器 0 次代码提交声明(HEAD 或仅 `.planning` 聚合提交)、OI-1(过期 TODO 未改)、OI-5(两条注释原样迁移)、`keyAdaptationGate.test.ts` 为既存失败。 + + + +Create `.planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md` when done, recording each file's container removal and flattened `Scroll` child layout, the behavior changes (0 fallbacks, the 9 display toggles split by position — 5 `hidden` outside `Scroll`, 4 dynamic-empty-text / 0-fallback inside, `hotkey` has no display toggle because selection is a `g-rectr` border state, `TextContent` key remounting and height measurement, removal of `Selection` in favor of `strokeStyle` / `lineWidth` on the group-row and key-box `g-rectr`, 2-4px hit-target inset, the shrunken scrollable extent at each list's trailing edge, the toolbox hint moved before the description `Scroll`, hotkey root `nocache`), gate results (including the `loc` constants annotated `: ElementLocator`, the no-`hidden=`-inside-`Scroll` position assertions and the per-element `noevent` pairing), uncalibrated estimate, skipped browser UAT, the untouched stale TODO in `book.tsx`, the OI-5 verbatim comment migration, the pre-existing `keyAdaptationGate.test.ts` failures that are out of scope, zero executor code commits (HEAD unchanged or `.planning`-only aggregated commits), and the code working tree left for user review. + diff --git a/.planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md b/.planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md new file mode 100644 index 0000000..3c820d8 --- /dev/null +++ b/.planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md @@ -0,0 +1,120 @@ +--- +phase: 09-minimum-browser-runnable +plan: 29 +subsystem: client-ui +tags: + - ui + - scroll + - flatten + - book + - bookDetail + - equipbox + - toolbox + - hotkey +requires: [] +provides: + - 5 个列表型 UI 的 Scroll 直接子元素平铺 + - book / bookDetail 临界信息 0 值回退 + - bookDetail 特殊属性行按实测高度排版 + - hotkey 两栏平铺、commandRows 预计算、Selection 移除 + - uiContainerGate 源码形状闸门 +affects: + - src/client/ui + - src/client/ui/__test__ +tech-stack: + added: [] + patterns: + - VNode[] 作为 Scroll 的唯一 children + - TextContent autoHeight + key 重挂载 + - 显示开关按 Scroll 内外分流 +key-files: + created: + - src/client/ui/__test__/uiContainerGate.test.ts + modified: + - src/client/ui/book.tsx + - src/client/ui/bookDetail.tsx + - src/client/ui/equipbox.tsx + - src/client/ui/toolbox.tsx + - src/client/ui/hotkey.tsx +decisions: + - bookDetail 的高度表按特殊属性 code 保留,TextContent 的 key 由 code + textTop + textHeight 组成 + - hotkey 选中态落在既有 g-rectr 的 strokeStyle / lineWidth 上,Selection 完全移除 + - toolbox 的未选择提示放在说明 Scroll 之前的根层,避免拦截 Scroll 内的指针事件 +metrics: + duration: "~12 min" + completed: 2026-10-27 +status: complete +actuals: + tokens: 17871 + tasks: 4 + commits: 0 + plan_head_before: 9bf8f296c47a85f19c762072f9b73214fe329ee3 +--- + +# Phase 09 Plan 29: 列表容器平铺与临界信息 0 值回退 Summary + +把 5 个列表型 UI 的 `container` 嵌套拉平为 `Scroll` 直接子元素,补齐 `book` / `bookDetail` 缺失临界信息的 `0` 值显示,`bookDetail` 长描述按实测高度排版,`hotkey` 移除 `Selection` 改用既有 `g-rectr` 的边框表示选中。 + +## 逐文件结果 + +| 文件 | `container` | 平铺后的 `Scroll` 直接子元素 | +|------|-------------|------------------------------| +| `book.tsx` | 只剩根(1) | 每行 13 个:透明 `g-rect` 命中层 + `icon` + 名称 / 特殊属性 + 5 个基础数值 + 4 个临界数值,全部按 `y = i * ROW_HEIGHT` 绝对定位;除命中层外全部 `noevent` | +| `bookDetail.tsx` | 只剩根(1) | 每行 2 个:标题 `text` + 带 `autoHeight` / `onUpdateHeight` 的 `TextContent`;行位置由 `specialRows` 一次遍历累加 | +| `equipbox.tsx` | 只剩根(1) | 状态行每条 3 个(属性名 / 数值 / 增减值);槽位每格 3 个(`g-rectr` + `icon` + 槽名);拥有装备行每行 3 个(`g-rectr` + `icon` + 名称);描述区 `Scroll` 只含一个 `TextContent` | +| `toolbox.tsx` | 只剩根(1) | 两列每行 4 个:`g-rectr` 命中层 + `icon` + 名称 `text` + 数量 `text`;说明 `Scroll` 只含一个 `TextContent` | +| `hotkey.tsx` | 只剩根(1,带 `nocache`) | 左栏每组 2 个(透明 `g-rectr` + 分组名 `text`);右栏每条指令 1 个名称 `text` + 每个组合 2 个(`g-rectr` + 组合 `text`) | + +删除数量:`equipbox` 3 个外层包装容器 + 3 类行容器;`hotkey` 6 个容器(内容总高容器 ×2、分组行、指令行、按键区、按键框);`book` / `bookDetail` / `toolbox` 全部行容器。`book` / `bookDetail` / `equipbox` / `toolbox` 的根 `container` 评估后不加 `nocache`;`hotkey` 根按 `09-29-NOTES.md` §5 加 `nocache`。 + +## 行为变化(实际实现口径) + +- **临界信息缺失显示 `0`**:`book` 的 `伤害` / `临界` / `减伤` / `1 防` 四项恒渲染,`critical` 为 null 时显示 `format(0)`,`1 防` 在 `critical` 或 `def1` 任一为 null 时显示 `format(0)`;`bookDetail` 的 `加攻` / `减伤` 同样恒渲染、缺失为 `0`。旧的条件渲染已消失。 +- **显示开关按位置分流(共 9 处)**:`Scroll` 外 5 处使用 `hidden`(`equipbox` 未选择装备 / 装备名 / 装备孔 / 拖拽浮层,`toolbox` 未选择提示);`Scroll` 直接子元素 4 处改用动态空文本 / `icon` 的 0 回退(`equipbox` 属性增减值、描述 `TextContent`、槽位图标;`toolbox` 描述 `TextContent`)。`hotkey` **没有显示开关**:选中态是既有 `g-rectr` 的边框状态,不挂载 / 不隐藏元素。 +- **`bookDetail` 长描述不再重叠**:`TextContent` 用 `autoHeight` + `onUpdateHeight` 上报实测高度,按 `code` 记入 `textHeights`,`specialRows` 一次遍历给出每行 `labelTop` / `textTop` / `textHeight`;`TextContent` 的 `key` 由 `code` + `textTop` + `textHeight` 组成,行位置或实测高度变化时重挂载到新 `loc`;同值高度回调不写入,重挂载一次后收敛;首次测量前用 `SPECIAL_MIN_HEIGHT` 回退;`specials` 变化时只保留仍存在条目的高度。 +- **`hotkey` 选中态改为边框**:完全移除 `Selection`(含 `@user/client-base` 的导入);左栏分组行与右栏按键框各自由既有 `g-rectr` 的 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}` 表示,无新增元素、无 `hidden`、无 `if (isSelected)` 条件挂载。 +- **`hotkey` 去掉 O(n²)**:删除 `getCommandHeight` / `getCommandTop` / `getContentHeight`,新增 `ICommandRow`(`command` / `top`)与 `commandRows` 一次遍历预计算。 +- **命中方式与内缩**:`book` 整行仍是透明命中层;`equipbox` 槽位 `g-rectr` 四周内缩 4px、拥有装备行上下各 2px(左右各 4px),槽位拖动起手仍只在 `icon` 上、整格负责悬停;`toolbox` 行左右各 4px / 上下各 2px;`hotkey` 按键框命中高度为 `KEY_BOX_HEIGHT - 4`。 +- **`toolbox` 提示位置**:未选择提示移到说明 `Scroll` 之前(同一绝对坐标),被 `Scroll` 覆盖,不再拦截其指针事件;描述 `TextContent` 恒挂载 + 空文本回退。 +- **列表末端可滚动范围略减**:`hotkey` 右栏约 16px(`COMMAND_GAP`)、`equipbox` 拥有装备区约 2px / 状态区约 4.5px、`toolbox` 约 2px,只影响末端空白。 +- **注释**:`book.tsx` 两条行内 JSX 注释按 OI-5 原样迁移为 `// 数值列表区` / `// 临界信息区`(文本不变);`getCritical` 上方的过期 TODO 按 OI-1 原样保留;未修改任何既有注释或 jsDoc。 + +## 门禁结果(实测) + +| 门禁 | 命令 | 结果 | +|------|------|------| +| 基线 | Task 1 第一步写出 `09-29-BASELINE.json` | `head` `9bf8f296…`,`hashes` 5 条,`dirty` 18 条,`eslint` 0 条,`typecheck` 6 条(均为既存 `Type 'number' is not assignable to type 'AllNumbers \| keyof IdToNumber'`) | +| 源码闸门 | `pnpm exec vitest run src/client/ui/__test__/uiContainerGate.test.ts` | **9/9 通过**(Scroll 块内无 `container` / `hidden=`、5 文件各 1 个根 `container`、`hotkey` 无 `Selection` 且两处 `g-rectr` 各带边框、`book` / `bookDetail` 0 值回退、`bookDetail` 高度测量与 key、显示开关分流 + `noevent` 逐元素配对、`hotkey` 根 `nocache`、4 个 `loc` 常量带 `: ElementLocator`) | +| 既有闸门 | `hotkeySettingsGate.test.ts` | **12/12 通过**(`hotkey.tsx` 仍不含 `