docs(09-29): add UI container optimization plan and execution summary (code uncommitted)

This commit is contained in:
AncTe committed 2026-10-27 18:37:26 +08:00
1 parent 9bf8f296c4
commit e2415a7a48
4 files changed
+797

No files matched your search

@@ -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'."
]
}
@@ -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,不提交代码;视觉/滚动/内存表现留给用户人工确认。
## 明确不做
- 不接入口。
- 不做持久化。
- 不做冲突检测 / 恢复默认。
- 不改按键注册表。
@@ -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` 五个文件的每个 `<Scroll>` 块内都没有 `<container`;`equipbox` 的描述区 / 装备槽区 / 拥有装备区三处外层包装容器全部删除,`Scroll` 与其相邻文本直接定位在根容器上;`hotkey` 左右两栏的两个内容总高容器、分组行容器、指令行容器、按键区容器、按键框容器(共 6 个 `container`)全部删除"
- "**平铺子元素用「单层 vnode 数组」实现,不引入 Fragment、不引入新的包裹容器**:每个列表在渲染函数内用 `const xxx: VNode[] = []` 逐项 push 单个元素(`text` / `icon` / `g-rect` / `g-rectr` / `TextContent`),再把整个数组作为 `Scroll` 的唯一 children 传入;`loc` 按行计算绝对坐标(`[x, y + 偏移]`),不再有行容器承载定位;**`loc` 数组只有在与循环变量无关(不引用 `i` / `y` / `cellX` / `boxTop` / `row` / `top`)时才允许提为文件作用域常量**,循环相关的 `loc` 一律保持内联、允许 Prettier(printWidth 80)把它折成多行(**不得为「折行就提常量」而硬提一个依赖循环变量的常量,那种常量在文件作用域根本算不出来**);**任何被提出的常量名必须已在 OI-2 的文件作用域清单中列明,未列明的一律不得新增**;提出的**常量必须显式标注为 `ElementLocator`**(`const X: ElementLocator = [...]`;裸数组字面量会被推成 `number[]`,不可赋给 `ElementLocator` 这个固定元组,`loc` 属性会报类型错误),需要的文件从 `@motajs/render` 以**独立一行的普通导入**引入该类型,字面形态必须是 `import { ElementLocator } from '@motajs/render';`(不得并入其它导入、不得折行;禁止 `import type`;未真正用到该类型时不引入,避免未使用导入报错);**不要求 `loc={` 物理单行**"
- "**`book` 的临界信息恒显示、缺失为 `0`**:`伤害` / `临界` / `减伤` / `1 防` 四项无条件渲染,`critical` 为 null 时分别显示 `format(0)`;`1 防` 在 `critical` 或 `def1` 任一为 null 时显示 `format(0)`;不再使用 `critical && (...)` / `critical && def1 && (...)` 条件渲染"
- "**`bookDetail` 的特殊属性行平铺并测量真实高度**:删除每行 `container`,标题 `text` 与 `TextContent` 直接作为 `Scroll` 子元素;`TextContent` 使用 `autoHeight` + `onUpdateHeight` 取得实际高度,按 `code` 记录到 `textHeights`,再由 `specialRows` 一次遍历(行高只在循环内用局部 `top` 累加,**不写入接口**)给出每行标题 / 描述纵坐标与实测描述高度,长描述不会压到下一行;`specials` 变化或模式切换时按当前 `code` 重建高度表(保留仍存在条目的已测高度、丢弃已消失条目),不盲目清空;**`TextContent` 在 setup 时只复制一次 `props.loc`(`textbox.tsx:122-124`),因此每个 `TextContent` 的 `key` 由 `code` + `textTop` + 实测高度三段拼成,行位置或实测高度变化时重挂载到新 `loc`**,高度回调写入相同值时不再触发重算,重挂载一次后收敛;首次测量前用回退高度 `SPECIAL_MIN_HEIGHT`"
- "**`bookDetail` 的临界信息恒显示、缺失为 `0`**:`加攻` 恒渲染且 `critical` 为 null 时显示 `format(0)`;`减伤` 恒渲染且 `critical` 为 null 时显示 `format(0)`;不再使用 `critical.value && (...)` 条件渲染"
- "**显示开关按位置分两类(`Scroll` 拥有直接子元素的 `hidden`,因此不能对 `Scroll` 直接子元素用 `hidden`)**:`Scroll` 之外的显示开关优先 `hidden`,共 5 处(`equipbox` 的未选择装备 / 装备名 / 装备孔 / 拖拽浮层;`toolbox` 的未选择提示移到说明 `Scroll` 外的根层、且写在说明 `Scroll` 之前(更早的兄弟节点,避免提示 `text` 盖住 `Scroll` 矩形并拦截指针事件));`Scroll` 直接子元素改用条件渲染 / 动态空文本 / `icon` 的 0 回退,共 4 处(`equipbox` 属性增减值、描述 `TextContent`、槽位图标;`toolbox` 描述 `TextContent`);**`hotkey` 的选中高亮不是显示开关**——它不改元素挂载、不用 `hidden`,只是既有 `g-rectr` 的 `strokeStyle` / `lineWidth` 随 `isSelected` 变化(`hotkey` 因此没有任何显示开关,也不计入这 4 处);数据访问一律用安全回退(如 `equip ? equip.item.name : ''`、`slotEquip ? slotEquip.item.num : 0`、`current ? current.raw.text : ''`),不使用 `?.`、不在 `Scroll` 外使用 `{cond && ...}`"
- "**`equipbox` 保留拖拽与点击命中语义**:拥有装备行的 `g-rectr` 承载 `onDown`(开始按下跟踪);槽位 `g-rectr` 承载 `onEnter` / `onLeave`(悬停槽位),槽位 `icon` 同时承载 `onEnter` / `onLeave` / `onDown`(与原先「只有图标可发起拖动、整格可悬停」一致);被覆盖的 `icon` / `text` 加 `noevent`,让事件下穿到作为命中目标的 `g-rectr`;`clickItem` / `startPress` / `movePress` / `endPress` 逻辑体零改动;**命中矩形不再沿用原整格容器:槽位 `g-rectr` 四周各缩进 4px、拥有装备行上下各缩进 2px(左右各 4px)**,该内缩作为行为变化在 Task 0 报备,默认不额外加满格透明命中层"
- "**`toolbox` 保留点击/使用语义**:两列每行由 `g-rectr`(行底 + 点击目标)与 `icon` / 名称 `text` / 数量 `text`(全部 `noevent`)直接组成,`onClick={() => 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` 标识符或 `<Selection` 元素);左栏 `Scroll` 直接子元素为透明 `g-rectr`(点击目标;**不能写 `g-rect`**,因为既有 `src/client/ui/__test__/hotkeySettingsGate.test.ts` 第 91 行断言 `hotkey.tsx` 去注释后不匹配 `/<g-rect[\\s>]/`,而本计划不允许改那个测试文件)与分组名 `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`;根 `<container loc={FULL_LOC}>` 加 `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` 块内既无 `<container`**也无 `hidden=`**(显示开关必须落在 `Scroll` 外;位置由 `getScrollBlocks()` 逐块断言,不靠全文子串);②5 个文件各只有 1 个 `<container>`(根),且 `equipbox` 不再含描述区 / 拥有装备区旧包装容器的 `loc` 片段;③`hotkey` 恰好 2 个 `Scroll`、两栏平铺、含 `commandRows`、无 `getCommandTop`;**文件内不含任何 `Selection` 标识符或 `<Selection` 元素**(去注释后 `/Selection/` 不匹配,因此 `import { … 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`(`TextContent` 的 `key` 由 `code` + `textTop` + 实测高度构成);⑦9 处显示开关的既定形态(`Scroll` 外 5 处 `hidden`、`Scroll` 内 4 处条件 / 动态空文本 / 0 回退;**`hotkey` 没有显示开关**——选中态是 `g-rectr` 的边框颜色状态,既不挂载也不隐藏元素,不计入这 9 处)且旧条件渲染消失,**并逐个断言命中层被覆盖的兄弟元素带 `noevent`**(`book` 的名称 / `伤害`、`equipbox` 拥有装备行名称与图标 / 槽名、`toolbox` 名称与数量、`hotkey` 分组名 / 指令名 / 组合文本);⑧`hotkey` 的第一个 `<container>` 开始标签内含 `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<unknown>`、`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`;根 `<container>` 加 `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` 只看到单个子元素,虚拟滚动退化为整块显隐;因此闸门逐个 `<Scroll>` 块断言内部没有 `<container`"
- "透明 `g-rect` / `g-rectr` 命中层 ← 上层 `text` / `icon` 的 `noevent` 下穿 — 少写一个 `noevent`,该元素就会吞掉点击,行点击 / 按键框选中 / 拖拽起手全部失效;因此每个列表行的可点击层与其上覆盖元素成对出现"
- "`equipbox` 槽位 `icon` 的 `onDown` / `onEnter` / `onLeave` → `startPress` / `hoverSlot` — 行容器删除后这些事件必须落在仍存在的元素上,否则拖拽起手与放置目标判定失效;因此槽位用 `g-rectr` + `icon` 双重悬停、`icon` 单独承担拖动起手"
- "`commandRows` 一次遍历 → 指令名 / 按键框(含选中边框)/ 组合文本的同一 `top` — 两侧用不同算法会导致框与文字错位;因此删除 `getCommandTop()` 后所有右栏元素只读 `row.top`"
- "受保护文件哈希(`scroll.tsx` / `textbox.tsx` / `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts`,共 5 个) → 基线 — 证明本计划既没有改 `Scroll` / `TextContent` 实现来「绕过」虚拟滚动或高度测量,也没有偷偷改设置入口或按键注册"
assumptions:
- "**规划日工作树快照(仅作参考,执行期以 Task 1 写出的基线为准)**:HEAD 为 `9bf8f296c47a85f19c762072f9b73214fe329ee3`;`git status --porcelain -uall -- src/client/ui packages-user/client-base/src/components/scroll.tsx packages-user/client-base/src/components/textbox.tsx` 无输出,即 5 个 UI 文件与 `scroll.tsx` / `textbox.tsx` 在规划日均为干净状态。**但整仓工作树本来就是脏的**(`AGENTS.md`、`packages-user/client-base/src/map/*` 等既有改动 + 未跟踪文件),因此范围闸门不能只看 pathspec 作用域的 `git status`,必须对照 Task 1 记录的全仓脏文件快照"
- "**`keyAdaptationGate.test.ts` 在改动前就是红的**:规划日该文件 15 个用例中有 5 个失败,属既存状态、**不在本计划范围**。本计划既不修它,也不以它为绿作验收;SUMMARY 只如实记录它是既存失败(不得把它与新改动混为一谈)"
- "**`Scroll` 的实现事实(不改)**:`packages-user/client-base/src/components/scroll.tsx` 的渲染根是 `<container>` 内嵌 `<container-custom>`,`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` 的 `/<g-rect[\\s>]/` 断言不允许 `hotkey.tsx` 出现 `<g-rect`,且本计划不允许改那个测试文件;透明填充不产生可见像素,命中与整行点击等价,**该 `g-rectr` 同时承载选中边框(`strokeStyle` / `lineWidth`)**(若你坚持用 `g-rect`,需要先批准改那条既有断言);⑥`Scroll` 直接子元素上的 `hidden` 会被 `Scroll` 的 `show()` / `hide()` 覆盖,本计划已按位置分流,但这是系统层既有行为,不改;⑦`book.tsx` 两条行内 JSX 注释的迁移见 OI-5;⑧`src/client/ui/__test__/keyAdaptationGate.test.ts` 在改动前就是红的(15 个用例 5 个失败),属既存问题、不在本计划范围,本计划不修、也不以它为绿作验收;⑨平铺后各列表可滚动内容末端会略微变短(`hotkey` 右栏约 16px、`equipbox` 拥有装备区约 2px / 状态区约 4.5px、`toolbox` 约 2px),只影响末端空白"
- "**OI-4(执行期硬停条件)**:若把某个列表改成「单层 `VNode[]` 作为 `Scroll` 唯一 children」后发现运行时行为与预期不符(例如 `container-custom` 只认单个子元素),**必须停下上报**,不得自行回退为「包一层 `container`」,也不得改 `scroll.tsx`"
- "**OI-5(既有注释处置,需在 Task 0 确认)**:`book.tsx` 第 159 行 `{/* 数值列表区 */}` 与第 195 行 `{/* 临界信息区 */}` 位于将被删除的行 `container` 内。按 AGENTS.md 不得擅自修改已有注释,本计划默认把这两条注释**原样迁移**到平铺 push 的对应分组处(文本完全不变,仅由 JSX 注释 `{/* ... */}` 改为单行 `//`,因为 `VNode[]` 的 push 代码不在 JSX 内);若你要求保留 JSX 注释形式或其它位置,请在 Task 0 指明。未经确认不删除、不改写"
- "**OI-6(计划体量与拆分,需在 Task 0 确认)**:本计划含 4 个 `auto` 任务、改 5 个 UI 文件,估算已从 120k 下调到 90k(低于 `smart_zone_tokens` 默认 100k,仍属未校准)。若你认为单个计划仍过大,请在 Task 0 明确回复「拆成两片」,我会拆为 A(`book` + `bookDetail` + 闸门骨架)与 B(`equipbox` + `toolbox` + `hotkey` + 收尾闸门);按 D-01 我不会单方面拆分"
---
<!-- planner-discipline-allow: critical && -->
<!-- planner-discipline-allow: getCommandTop -->
<!-- planner-discipline-allow: container -->
<!-- planner-discipline-allow: {isSelected && -->
<!-- planner-discipline-allow: hidden= -->
<objective>
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` 完全移除**(无导入、无 `<Selection`、无 `if (isSelected)` 条件挂载);`commandRows` 一次遍历预计算偏移,删除 O(n²) 的 `getCommandTop()`;根容器加 `nocache`(NOTES §5);捕获 / 写回 / 无入口 / 无持久化 / 无冲突检测 / 无恢复默认口径零改动。
- **验证**:新增 `?raw` 源码闸门 `src/client/ui/__test__/uiContainerGate.test.ts`(9 组断言;其中「显示开关位置」用 `getScrollBlocks()` 逐块断言 `Scroll` 内没有 `hidden=`,「命中层覆盖」逐个断言被覆盖兄弟元素带 `noevent`)+ eslint(前后基线只判 NEW,路径归一化口径固定)+ `pnpm check:type`(整仓,只判改动文件集合内 NEW)+ **5 个**受保护文件哈希(`scroll.tsx` / `textbox.tsx` / `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts`)/ HEAD(允许 `.planning` 聚合提交)/ 范围闸门(对照基线记录的**全仓脏文件快照**,因为工作树本来就脏,pathspec 作用域的 `git status` 看不见范围外写入)。**不做浏览器 UAT,不提交任何代码。** 另:`src/client/ui/__test__/keyAdaptationGate.test.ts` 在改动前即为 15 个用例中 5 个失败(既存、不在本计划范围),本计划不修它、不声称全部既有闸门为绿。
**约束:** 只动 `files_modified` 的 9 个路径;不改 `Scroll` / `TextContent` 实现、不改设置入口与按键注册;不改既有注释与 jsDoc(`book.tsx` 两条行内 JSX 注释按 OI-5 原样迁移除外);不新增依赖;不新增未获批准或未在 Task 0 列出的命名;不顺手修复任何无关诊断。
**修改方式声明(AGENTS.md):** 本计划的全部文件写入(写基线、改 5 个 UI 文件、新增 1 个闸门测试、写摘要)由**派发子代理(`gsd-executor`)执行**,非内联执行;除 `files_modified` 外无任何写入。**Task 0 只汇报、不写任何文件**;`09-29-BASELINE.json` 是 **Task 1 在收到「可以执行」之后、编辑任何代码之前的第一步**写出(只写一次,此后不得重写)。「开始执行」的界限仍是「可以执行」四个字。
**可见跳过:** spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)。本阶段无 requirement ID,不创建 RESEARCH.md / VALIDATION.md,Nyquist 验证不适用。
</objective>
## 本计划产出物(Artifacts this plan produces)
| 类型 | 名称 / 路径 | 说明 |
|------|-------------|------|
| 修改 | `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 |
<execution_context>
@C:/Users/13194/.config/opencode/gsd-core/workflows/execute-plan.md
@C:/Users/13194/.config/opencode/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/09-minimum-browser-runnable/09-CONTEXT.md
@.planning/phases/09-minimum-browser-runnable/09-PATTERNS.md
@.planning/phases/09-minimum-browser-runnable/09-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
</context>
<tasks>
<task type="checkpoint:human-verify" gate="blocking-human">
<name>Task 0: 预执行汇报关卡 —— 汇报「修 `container` 过度嵌套 / `Scroll` 虚拟滚动失效 + 临界信息 0 值显示」的要做什么 / 为什么 / 怎么做 / 影响范围、逐文件容器删除清单、行为变化、命名清单、验证方式,等待「可以执行」</name>
<precondition>工作树仍是改动前状态,可用以下事实自检:`src/client/ui/book.tsx` 第 127-235 行的 `Scroll` 内每行是一个 `<container>`,临界四项分别被 `critical &&` / `critical && def1 &&` 包裹,行内还有 `{/* 数值列表区 */}` / `{/* 临界信息区 */}` 两条 JSX 注释;`src/client/ui/bookDetail.tsx` 第 170-195 行的 `Scroll` 内每行是一个 `<container>`,`TextContent` 未使用 `autoHeight` / `onUpdateHeight`,且 `textbox.tsx:122-124` 只在挂载时复制一次 `props.loc`;`src/client/ui/equipbox.tsx` 存在三个外层 `<container>`(描述区、装备槽区、拥有装备区),共 7 处条件渲染;`src/client/ui/toolbox.tsx` 两个列表每行是一个 `<container>`,说明区用 `!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` 不存在。</precondition>
<files>无(本任务不写任何文件,全部只读命令;`09-29-BASELINE.json` 改由 Task 1 在获批后、改代码前写出)</files>
<read_first>
- .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(注释 / 类型 / 无 `?.` / 无内联对象类型规范)
</read_first>
<what-built>
一份待批准的执行计划(`09-29-PLAN.md`):把 5 个列表型 UI 的 `container` 嵌套拉平到 `Scroll` 直接子元素、补齐 `book` / `bookDetail` 缺失临界信息的 `0` 值、修正 `bookDetail` 特殊属性长描述重叠(含 `key` 重挂载)、给 `hotkey` 根加 `nocache`,用源码形状闸门 + eslint / 类型前后基线验收。
</what-built>
<action>
**停下并向用户汇报,等待恰好回复「可以执行」后才继续;本任务不写任何文件(含 `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` 不含 `<g-rect`(R-1 的「不画背景」检查),而本计划不允许改那个测试文件;透明 `g-rectr` 不绘制任何可见内容,命中与整行点击效果和 `g-rect` 等价。**选中分组只由该行 `g-rectr` 的边框表示**:`fill stroke` + `fillStyle="transparent"` + `strokeStyle={isSelected ? 'gold' : 'transparent'}` + `lineWidth={isSelected ? 2 : 1}`,不为选中态新增任何元素、不用 `hidden`、不做条件渲染。右栏直接子元素为指令名 `text`(`noevent`)+ 每个按键组合的两件套(`g-rectr` 点击目标 / 组合 `text`(`noevent`));**按键框的选中态同样落在既有 `g-rectr` 上**:`strokeStyle={isSelected ? 'gold' : 'transparent'}` + `lineWidth={isSelected ? 2 : 1}`,不再有单独的选中元素。**`Selection` 从本文件完全移除**:`@user/client-base` 的导入改为 `import { IGameUIPropsBase, Scroll, useKey } from '@user/client-base';`,文件内不得出现 `Selection` / `<Selection` / `if (isSelected) { …push(…) }`。新增 `commandRows` 一次遍历预计算每条指令的 `top`(`ICommandRow` 只保留 `command` / `top`,不保留未使用的 `height`),删除 `getCommandHeight` / `getCommandTop` / `getContentHeight`。根容器 `<container loc={FULL_LOC}>` **加 `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` 不含 `<g-rect`,见 OI-3 ⑤),且这个 `g-rectr` 同时承载选中边框;**选中视觉由原来的独立 `Selection` 元素改为同一矩形的 `strokeStyle` 边框**(视觉接近,但不保证像素级一致);根 `<container>` 加 `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 个列表文件的每个 `<Scroll>` 块内既无 `<container` **也无 `hidden=`**(用 `getScrollBlocks()` 逐块断言;显示开关的位置靠这个检查,而不是全文子串);②5 个文件各只有 1 个 `<container>`(根),且 `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` 的第一个 `<container>` 开始标签内含 `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 <baseline>..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: { <完整仓库相对路径>: <sha> ×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 的第一步;执行器不提交任何代码。「开始执行」的界限仍是你的「可以执行」四个字。
</action>
<how-to-verify>
逐条核对 ①–⑬ 是否与你的意图一致,特别是:①五个列表文件的容器删除范围是否就是你要的(`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` 记为既存失败——这三条口径是否接受。
</how-to-verify>
<resume-signal>仅当完全同意时回复恰好四个字「可以执行」;任何修改意见(**尤其是 OI-1 的过期 TODO 是否一并更新、OI-5 的两条行内 JSX 注释迁移、OI-2 的文件内私有命名是否需要改名、OI-6 是否要拆成两片**)请直接说明,本计划会据此改写后再来一次汇报。</resume-signal>
</task>
<task type="auto">
<name>Task 1: 写出改动前基线,然后平铺 `book.tsx`(临界四项 0 值)与 `bookDetail.tsx`(特殊属性高度测量 + 临界两项 0 值)</name>
<files>.planning/phases/09-minimum-browser-runnable/09-29-BASELINE.json(新增,第一步写出), src/client/ui/book.tsx, src/client/ui/bookDetail.tsx</files>
<action>
**依赖 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 (<container loc={FULL_LOC}> ... ); }`。**根 `<container loc={FULL_LOC}>` 原样保留,不加 `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) `<g-rect>`:`loc={[0, y, LIST_WIDTH, ROW_HEIGHT]}`、`fill`、`fillStyle="transparent"`、`cursor="pointer"`、`onClick={() => openDetail(view)}`;
2) `<icon icon={base.code} loc={[8, y + 16, 40, 40]} noevent />`;
3) 名称 `<text text={base.getAttribute('name')} loc={[56, y + 14]} anc={[0, 0]} font={nameFont} fillStyle="#ffffff" noevent />`;
4) 特殊属性 `<text text={specialText(base)} loc={[56, y + 36]} anc={[0, 0]} font={statFont} fillStyle="#ffd27f" noevent />`;
5) 生命 `<text text={\`生命 ${format(base.getAttribute('hp'))}\`} loc={[statX(0), y + 22]} anc={[0, 0.5]} font={statFont} fillStyle="lightgreen" noevent />`;
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) `伤害` `<text text={\`伤害 ${format(damage)}\`} loc={[statX(0), y + 52]} anc={[0, 0.5]} font={statFont} fillStyle="#ffffff" noevent />`;
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` 改为 `<Scroll noscroll loc={[LIST_LEFT, LIST_TOP, LIST_WIDTH, LIST_HEIGHT]}>{rows}</Scroll>`(**只有这一个 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<unknown>`(`iterateSpecials()` 返回 `ISpecial<any>`,`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<Record<number, number>>({});`,注释说明「按特殊属性 code 记录实测描述高度」;
- `const updateTextHeight = (code: number, height: number): void => { if (textHeights.value[code] === height) return; textHeights.value[code] = height; };`,注释说明「同值不写,避免高度回调与重挂载互相触发;供后续行计算纵坐标」;
- `watch(specials, () => { const next: Record<number, number> = {}; for (const special of specials.value) { const height = textHeights.value[special.code]; if (height) next[special.code] = height; } textHeights.value = next; });`,注释说明「只保留仍存在的条目;不整体清空,否则没有重新上报高度的条目会退回最小高度、长描述重新重叠」;
- `const specialRows = computed<ISpecialRow[]>(() => { 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` 改为 `<Scroll noscroll loc={[SPECIAL_LEFT, SPECIAL_TOP, SPECIAL_WIDTH, SPECIAL_HEIGHT]}>`,children 用一个单层 `VNode[]`(在渲染函数内先 `const specialNodes: VNode[] = [];`,对 `specialRows.value` 逐行 push 两个元素,最后 `{specialNodes}`):
- 标题 `<text text={\`${row.special.getSpecialName()}:\`} loc={[0, row.labelTop]} anc={[0, 0]} font={specialFont} fillStyle={row.special.getNameColor()} key={\`label-${row.special.code}\`} />`;
- 描述 `<TextContent text={row.special.getDescription()} width={SPECIAL_WIDTH} loc={[0, row.textTop, SPECIAL_WIDTH, SPECIAL_MIN_HEIGHT]} font={specialFont} fillStyle="#dddddd" autoHeight onUpdateHeight={height => 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 && (...)` 的两处条件渲染,改为恒渲染:`加攻` 为 `<text text={\`加攻 ${format(critical.value ? critical.value.nextDiff : 0)}\`} loc={[24, 282]} anc={[0, 0.5]} font={statFont} fillStyle="lightsalmon" />`;`减伤` 为 `<text text={\`减伤 ${format(critical.value ? -critical.value.damageDiff : 0)}\`} loc={[160, 282]} anc={[0, 0.5]} font={statFont} fillStyle="lightpink" />`。**不要用 `?.`。**
7. 保留标题、五个基础数值、`临界` 小标题、切换按钮、返回按钮的既有位置与文案。**根 `<container loc={FULL_LOC}>` 原样保留,不加 `nocache`。**
8. **不得修改任何既有注释或 jsDoc**;新增注释遵循 dev.md(单行 `//`、`//` 后留空格、说明「为什么」)。
</action>
<verify>
<automated>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')"</automated>
<automated>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(/<container(?=[\s>])/g)||[]).length;if(n!==1)bad.push('expected exactly 1 container, got '+n);let i=0;while(true){const a=c.indexOf('<Scroll',i);if(a<0)break;const e=c.indexOf('</Scroll>',a);if(e<0){bad.push('unclosed Scroll');break}if(/<container(?=[\s>])/.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')"</automated>
<automated>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<unknown>','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(/<container(?=[\s>])/g)||[]).length;if(n!==1)bad.push('expected exactly 1 container, got '+n);let i=0;while(true){const a=c.indexOf('<Scroll',i);if(a<0)break;const e=c.indexOf('</Scroll>',a);if(e<0){bad.push('unclosed Scroll');break}if(/<container(?=[\s>])/.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')"</automated>
<automated>cmd /c "pnpm exec eslint src/client/ui/book.tsx src/client/ui/bookDetail.tsx"</automated>
<fails_when>基线缺失或字段残缺、`hashes` 不是 5 条或缺 `scroll.tsx` / `textbox.tsx`、`dirty` 不是数组;`book.tsx` 仍保留旧条件渲染、缺任一 0 值格式化表达式或 `VNode` 导入、`<container>` 计数不是 1、`Scroll` 内出现 `container`;`bookDetail.tsx` 缺 `autoHeight` / `onUpdateHeight` / `textHeights` / `specialRows` / `textTop` / `textHeight` / `ISpecial<unknown>`、仍保留旧条件渲染、`<container>` 计数不是 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 诊断门禁中被拦下。</fails_when>
</verify>
<done>`09-29-BASELINE.json` 已在改代码前写出且只写一次;`book.tsx` 每行是 13 个 `Scroll` 直接子元素(透明命中矩形 + 图标 + 名称 + 特殊属性 + 五个基础数值 + 四个恒显示的临界数值),四项临界信息缺失时显示 `0`,`<container>` 只剩根,两条行内注释按 OI-5 原样迁移;`bookDetail.tsx` 每行是标题 `text` + 带 `autoHeight` / `onUpdateHeight` 的 `TextContent`,`specialRows` 一次遍历给出逐行纵坐标 / 实测高度,`TextContent` 的 `key` 含 `code` / `textTop` / `textHeight`,`加攻` / `减伤` 恒显示且缺失为 `0`,`<container>` 只剩根;eslint 无报错。</done>
</task>
<task type="auto">
<name>Task 2: 平铺 `equipbox.tsx`(删三处外层容器 + 行平铺 + 按位置分流显示开关)与 `toolbox.tsx`(两列行平铺 + 未选择提示移出 `Scroll`)</name>
<files>src/client/ui/equipbox.tsx, src/client/ui/toolbox.tsx</files>
<action>
**依赖 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]}`),把其子元素按绝对坐标直接放到根 `<container>` 下:
- `未选择装备` `<text ... loc={[RIGHT_LEFT + 8, DESC_TOP + 12]} anc={[0, 0]} font={labelFont} fillStyle="#888888" hidden={!!equip} />`;
- 装备名 `<text text={equip ? equip.item.name : ''} loc={[RIGHT_LEFT + 8, DESC_TOP + 12]} anc={[0, 0]} font={titleFont} fillStyle="gold" hidden={!equip} />`;
- 装备孔 `<text text={equip ? \`装备孔:${equip.item.equip.slots.join(' / ')}\` : ''} loc={[RIGHT_LEFT + 8, DESC_TOP + 36]} anc={[0, 0]} font={labelFont} fillStyle="#bbbbbb" hidden={!equip} />`;
- 描述 `Scroll`(**恒挂载**)`<Scroll noscroll loc={DESC_SCROLL_LOC}>`,内部只有一个 `TextContent`:`text={equip ? equip.item.text : ''}`、`width={RIGHT_WIDTH - 16}`、`loc={[8, 4, RIGHT_WIDTH - 16, DESC_HEIGHT - 60]}`、`font={labelFont}`、`fillStyle="#dddddd"`(**不加 `hidden`**:`Scroll` 会覆盖它;空文本自然不绘制);
- 装备槽 `Scroll`(**恒挂载**)`<Scroll hor noscroll loc={SLOT_SCROLL_LOC}>{slotNodes}</Scroll>`;
- 拥有装备标题 `<text text="拥有装备" loc={[RIGHT_LEFT + 8, OWNED_TOP + 2]} anc={[0, 0]} font={titleFont} fillStyle="gold" />`;
- 拥有装备 `Scroll`(**恒挂载**)`<Scroll noscroll loc={OWNED_SCROLL_LOC}>{ownedNodes}</Scroll>`。
5. **状态行平铺**(原 `Scroll` 内每行 `container` 删除):对 `props.attributes` 逐项,`const y = i * STATUS_ROW_HEIGHT;`,push 三个元素:
- 属性名 `<text text={attr.name} loc={[8, y + STATUS_ROW_HEIGHT / 2]} anc={[0, 0.5]} font={labelFont} fillStyle="#dddddd" key={\`name-${attr.id}\`} />`;
- 数值 `<text text={String(value)} loc={[valueX, y + STATUS_ROW_HEIGHT / 2]} anc={[1, 0.5]} font={labelFont} fillStyle="#ffffff" key={\`value-${attr.id}\`} />`(`valueX` 仍按 `delta === 0 ? STATUS_WIDTH - 8 : STATUS_WIDTH - 60` 计算);
- 增减值 `<text text={delta === 0 ? '' : \`(${delta > 0 ? '+' : ''}${delta})\`} loc={[STATUS_WIDTH - 8, y + STATUS_ROW_HEIGHT / 2]} anc={[1, 0.5]} font={labelFont} fillStyle={delta > 0 ? '#57ff78' : '#ff5a5a'} key={\`delta-${attr.id}\`} />`(**`Scroll` 内不用 `hidden`**:`delta === 0` 时用空文本代替条件渲染)。
把 `props.attributes.map(...)` 改为 `for` 循环 + `statusNodes.push(...)`;`<Scroll noscroll loc={STATUS_SCROLL_LOC}>{statusNodes}</Scroll>`。
6. **槽位平铺**(原每槽 `container` 删除):对 `hero.equip.slots` 逐项,`const slotEquip = equippedList.value[i]; const isSelected = !!slotEquip && slotEquip.uid === selected.value; const cellX = i * SLOT_CELL_WIDTH;`,push 三个元素:
- 槽底 `<g-rectr loc={[cellX + 4, 4, SLOT_CELL_WIDTH - 8, SLOT_HEIGHT - 8]} circle={[6]} fill stroke fillStyle="rgba(255, 255, 255, 0.08)" strokeStyle={isSelected ? 'gold' : '#666666'} lineWidth={isSelected ? 2 : 1} cursor="pointer" onEnter={() => { hoverSlot.value = i; }} onLeave={() => { if (hoverSlot.value === i) hoverSlot.value = -1; }} key={\`slot-${i}\`} />`;
- 图标 `<icon icon={slotEquip ? slotEquip.item.num : 0} loc={[cellX + (SLOT_CELL_WIDTH - SLOT_ICON_SIZE) / 2, 14, SLOT_ICON_SIZE, SLOT_ICON_SIZE]} cursor="pointer" onEnter={() => { hoverSlot.value = i; }} onLeave={() => { if (hoverSlot.value === i) hoverSlot.value = -1; }} onDown={ev => { if (slotEquip) startPress(slotEquip.uid, i, ev.absoluteX, ev.absoluteY); }} key={\`icon-${i}\`} />` —— **拖动起手仍只在图标上**,整格仍负责悬停(与改动前一致);**不加 `hidden`**:未装备时 `icon` 为 `0`,`Icon.setIcon(0)` 直接清空绘制,且 `hidden` 会被 `Scroll` 覆盖;
- 槽名 `<text text={name} loc={[cellX + SLOT_CELL_WIDTH / 2, SLOT_HEIGHT - 12]} anc={[0.5, 0.5]} font={labelFont} fillStyle="#cccccc" noevent key={\`name-${i}\`} />`。
7. **拥有装备行平铺**(原每行 `container` 删除):对 `owned.value` 逐项,`const y = i * OWNED_ROW_HEIGHT; const isSelected = item.uid === selected.value;`,push 三个元素:
- 行底/命中层 `<g-rectr loc={[4, y + 2, RIGHT_WIDTH - 8, OWNED_ROW_HEIGHT - 4]} circle={[4]} fill fillStyle={isSelected ? 'rgba(255, 204, 0, 0.28)' : 'rgba(255, 255, 255, 0.05)'} cursor="pointer" onDown={ev => startPress(item.uid, -1, ev.absoluteX, ev.absoluteY)} key={\`row-${item.uid}\`} />`(命中范围比原整行容器左右各缩 4px、上下各缩 2px,已在 Task 0 报备;若用户要求整格命中,再加一层透明满格 `g-rect`,默认不加);
- 图标 `<icon icon={item.item.num} loc={[10, y + 6, OWNED_ICON_SIZE, OWNED_ICON_SIZE]} noevent key={\`icon-${item.uid}\`} />`;
- 名称 `<text text={item.item.name} loc={[58, y + OWNED_ROW_HEIGHT / 2]} anc={[0, 0.5]} font={labelFont} fillStyle="#eeeeee" noevent key={\`name-${item.uid}\`} />`。
8. **拖拽浮层**由条件渲染改为恒挂载:`<icon icon={dragNum.value > 0 ? dragNum.value : 0} loc={[dragX.value - DRAG_ICON_SIZE / 2, dragY.value - DRAG_ICON_SIZE / 2, DRAG_ICON_SIZE, DRAG_ICON_SIZE]} noevent zIndex={100} hidden={!dragging.value} />`(回退到 `0` 会让 `Icon.setIcon(0)` 直接清空绘制,不会像 `-1` 那样在未拖拽时刷 `logger.warn`)。删除原来的 `dragging.value && (...)` 包裹。
9. `状态` 标题、`退出` 按钮、根 `<container loc={FULL_LOC} onMove=... onUp=...>` 原样保留(根不加 `nocache`)。**不得修改任何既有注释或 jsDoc。**
10. 长 `loc` 用上面的 4 个 `*_SCROLL_LOC` 常量——它们只由既有布局常量组成、**与循环变量无关**,所以可以安全上提,且均已标注 `: ElementLocator`;而状态行 / 槽位 / 拥有装备行的行内 `loc` 引用 `y` / `cellX`,**属于循环相关,必须保持内联并允许 Prettier 折行,不得上提**(也不要为它们新增常量名或 `ElementLocator` 之外的导入);**不得为了单行而违反 Prettier 格式**;不得再出现 `container`(根除外)。
**B. `src/client/ui/toolbox.tsx`**
1. 顶部 `vue` 导入追加 `VNode`:`import { computed, defineComponent, ref, VNode } from 'vue';`。**仅当第 6 步真的提出了 `PERM_SCROLL_LOC` / `CONS_SCROLL_LOC`(即对应 `Scroll` 的 `loc` 被 Prettier 折行)**,才补**独立一行**的 `import { ElementLocator } from '@motajs/render';`(普通导入,字面形态不得改动、不得并入其它导入);没有提出就**不要**引入(未使用导入会被 eslint 判错)。其它既有导入不变。
2. 渲染函数改为「先建数组、再返回 JSX」:`const permNodes: VNode[] = [];`、`const consNodes: VNode[] = [];`。
3. 两个列表各用一个 `for` 循环(`const y = i * ROW_HEIGHT;`、`const isSelected = item.num === selected.value;`)push 四个元素,**顺序固定**(`key` 用 `row-${item.num}` / `icon-${item.num}` / `name-${item.num}` / `count-${item.num}`):
- `<g-rectr loc={[4, y + 2, COLUMN_WIDTH - 8, ROW_HEIGHT - 4]} circle={[4]} fill fillStyle={isSelected ? 'rgba(255, 204, 0, 0.28)' : 'rgba(255, 255, 255, 0.05)'} cursor="pointer" onClick={() => clickItem(item)} />`(命中范围比原整行容器左右各缩 4px、上下各缩 2px,已在 Task 0 报备);
- `<icon icon={item.num} loc={[10, y + 7, ROW_ICON_SIZE, ROW_ICON_SIZE]} noevent />`;
- `<text text={item.raw.name} loc={[50, y + ROW_HEIGHT / 2]} anc={[0, 0.5]} font={itemFont} fillStyle="#eeeeee" noevent />`;
- `<text text={core.formatBigNumber(item.count)} loc={[COLUMN_WIDTH - 8, y + ROW_HEIGHT / 2]} anc={[1, 0.5]} font={itemFont} fillStyle="#cccccc" noevent />`。
两个 `Scroll` 分别改为 `<Scroll noscroll loc={[0, LIST_TOP, COLUMN_WIDTH, LIST_HEIGHT]}>{permNodes}</Scroll>` 与 `<Scroll noscroll loc={[COLUMN_WIDTH, LIST_TOP, COLUMN_WIDTH, LIST_HEIGHT]}>{consNodes}</Scroll>`。
4. 说明区:**未选择提示移出 `Scroll`,放到根层**并用 `hidden`,**且必须排在说明 `Scroll` 之前**;描述 `TextContent` 留在 `Scroll` 内恒挂载 + 空文本回退:
- 根层提示 `<text text="未选择道具" loc={[INFO_LEFT + 8, DESC_TOP + 4]} anc={[0, 0]} font={descFont} fillStyle="#888888" hidden={!!current} />` —— 「有选中项时隐藏」,因此是 `hidden={!!current}`;坐标 = 原 `Scroll` 内 `[8, 4]` 加上 `Scroll` 的 `[INFO_LEFT, DESC_TOP]`。**这个 `text` 必须作为说明 `Scroll` 之前的兄弟节点输出**(在 JSX 里写在 `<Scroll ...>` 的上一行):本渲染系统按兄弟顺序绘制,写在 `Scroll` 之后就会盖在 `Scroll` 矩形上并拦截其中的指针事件;写在之前则被 `Scroll` 覆盖,视觉位置不变、指针事件全部落到 `Scroll` 内容上;
- 描述 `Scroll` 内的 `<TextContent text={current ? current.raw.text : ''} width={COLUMN_WIDTH - 16} loc={[8, 4, COLUMN_WIDTH - 16, DESC_HEIGHT - 8]} font={descFont} fillStyle="#dddddd" />`,**不加 `hidden`**(会被 `Scroll` 覆盖;空文本自然不绘制)。
删除 `!current && (...)` 与 `current && (...)` 两段条件渲染;说明 `Scroll` 恒挂载且只含 `TextContent`。
5. 道具名称 `text` 仍用 `current ? current.raw.name : '未选择道具'`,`clickItem` 及其「再点使用」逻辑零改动;`退出` 与根 `<container loc={FULL_LOC}>` 原样保留(根不加 `nocache`)。
6. 两个 `Scroll` 的 `loc`(`[0, LIST_TOP, COLUMN_WIDTH, LIST_HEIGHT]` 与 `[COLUMN_WIDTH, LIST_TOP, COLUMN_WIDTH, LIST_HEIGHT]`)是本文件唯一的循环无关 `loc`:**仅当 Prettier 把它们折行时**才提为文件级常量,名字只能用 OI-2 已列明的 `PERM_SCROLL_LOC` / `CONS_SCROLL_LOC`,并标注 `: ElementLocator` + 独立一行 `import { ElementLocator } from '@motajs/render';`;**行内 `loc`(引用 `y` / `isSelected`)属于循环相关,保持内联、不得上提**;不得再出现 `container`(根除外)。**不得修改任何既有注释或 jsDoc。**
</action>
<verify>
<automated>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(/<container(?=[\s>])/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('<Scroll',i);if(a<0)break;const z=pair[1].indexOf('</Scroll>',a);if(z<0){bad.push(pair[0]+' unclosed Scroll');break}if(/<container(?=[\s>])/.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')"</automated>
<automated>cmd /c "pnpm exec eslint src/client/ui/equipbox.tsx src/client/ui/toolbox.tsx"</automated>
<fails_when>两文件 `<container>` 计数不为 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`)。</fails_when>
</verify>
<done>`equipbox.tsx` 的 `<container>` 只剩根:描述区 / 装备槽区 / 拥有装备区三处外层容器已删除,状态行 / 槽位 / 拥有装备行全部平铺;7 处显示开关按位置分流(`Scroll` 外 4 处 `hidden`,`Scroll` 内增减值空文本 / 描述 `TextContent` 空文本 / 槽位图标 0 回退);4 个长 `loc` 常量就位并都标注 `: ElementLocator`;槽位悬停与图标起手拖动、整行按下拖动保持(命中矩形内缩 2-4px 已报备);`toolbox.tsx` 两列列表每行平铺为 `g-rectr` + `icon` + 名称 + 数量,未选择提示在 `Scroll` 外用 `hidden`、描述 `TextContent` 恒挂载 + 空文本,`clickItem` 逻辑不变;eslint 无报错。</done>
</task>
<task type="auto">
<name>Task 3: 全平铺 `hotkey.tsx`(删 6 个容器 + `commandRows` 预计算 + 移除 `Selection`、选中态改用带边框 `g-rectr` + 根 `nocache`)</name>
<files>src/client/ui/hotkey.tsx</files>
<action>
**依赖 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` 标识符或 `<Selection` 元素。
2. 文件级新增常量(放在既有 `COMMAND_GAP` / `CONTENT_FONT` 附近,各一行说明):`BOX_LEFT = RIGHT_WIDTH - KEY_BOX_WIDTH - LEFT_PAD`、`BOX_FILL_HEIGHT = KEY_BOX_HEIGHT - 4`、`GROUP_TEXT_LEFT = 8`、`COMMAND_TEXT_LEFT = 8`、`COMMAND_TEXT_TOP = 12`。
3. 文件级新增 `interface ICommandRow`(对象类型必须先声明接口):`readonly command: IHotkeyCommand`、`readonly top: number`,每个成员一行 jsDoc。**不要保留 `height` 成员**(渲染只读 `top`,高度只在 `commandRows` 内作为局部变量累加)。
4. 用 `commandRows` 取代三个旧函数:删除 `getCommandHeight`、`getCommandTop`、`getContentHeight`(**`getCommandTop` 的 O(n²) 循环必须完全消失**),新增 `const commandRows = computed<ICommandRow[]>(() => { 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 两个元素,顺序固定:
- `<g-rectr loc={[0, top, GROUP_WIDTH, GROUP_ROW_HEIGHT]} fill stroke fillStyle="transparent" strokeStyle={isSelected ? 'gold' : 'transparent'} lineWidth={isSelected ? 2 : 1} cursor="pointer" onClick={() => selectGroup(id)} key={\`hit-${id}\`} />`(**用 `g-rectr` 不用 `g-rect`**:既有 `hotkeySettingsGate.test.ts` 断言 `hotkey.tsx` 不含 `<g-rect`;透明填充不产生可见像素,命中与整行点击等价;**选中分组只由这里的 `strokeStyle` / `lineWidth` 表示**);
- `<text text={group.name} loc={[GROUP_TEXT_LEFT, top + GROUP_ROW_HEIGHT / 2]} anc={[0, 0.5]} font={CONTENT_FONT} fillStyle={isSelected ? 'gold' : '#ffffff'} noevent key={`name-${id}`} />`。
**左栏没有任何 `Selection`、没有 `selectedGroupIndex`、没有 `if (isSelected)` 条件挂载、没有 `hidden`。**
`Scroll` 改为 `<Scroll loc={[LEFT_PAD, LIST_TOP, GROUP_WIDTH, LIST_HEIGHT]}>{groupNodes}</Scroll>`。
7. **右栏**:删除内容总高容器、指令行容器、按键区容器、按键框容器。对 `commandRows.value` 逐项(`const list = combinationLists.value[commandIndex] ?? [];`)先把指令名 push 进 `commandNodes`:
- `<text text={row.command.name} loc={[COMMAND_TEXT_LEFT, row.top + COMMAND_TEXT_TOP]} anc={[0, 0]} font={CONTENT_FONT} fillStyle="#ffffff" noevent key={\`name-${row.command.command}\`} />`;
再对 `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`,**顺序固定**:
- `<g-rectr loc={[BOX_LEFT, boxTop, KEY_BOX_WIDTH, BOX_FILL_HEIGHT]} circle={[4]} fill stroke fillStyle="rgba(255, 255, 255, 0.08)" strokeStyle={isSelected ? 'gold' : 'transparent'} lineWidth={isSelected ? 2 : 1} cursor="pointer" onClick={() => selectKey(row.command, index)} key={\`box-${commandIndex}-${index}\`} />`(**选中态就是这个既有矩形的边框**,不额外 push 任何元素、不用 `hidden`);
- `<text text={formatCombination(showing)} loc={[BOX_LEFT + KEY_BOX_WIDTH / 2, boxTop + BOX_FILL_HEIGHT / 2]} anc={[0.5, 0.5]} font={CONTENT_FONT} fillStyle={isSelected ? 'gold' : '#ffffff'} noevent key={\`text-${commandIndex}-${index}\`} />`。
`Scroll` 改为 `<Scroll loc={[RIGHT_LEFT, LIST_TOP, RIGHT_WIDTH, LIST_HEIGHT]}>{commandNodes}</Scroll>`。
8. 左栏的透明 `g-rectr` 是本次新增的**唯一命中层**,它同时承载选中边框;右栏按键框的 `g-rectr` 本来就是命中层,现在同时承载选中边框。`hotkey.tsx` 内**不得出现 `<g-rect`**(既有 `hotkeySettingsGate.test.ts` 的 R-1 断言),**不要新增整屏图形、不要引入 `Background`、不要新增 `Selection`、不要用 `Selection` 导入**;根 `<container loc={FULL_LOC}>` **加 `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(单行 `//`、`//` 后留空格、说明「为什么」)。
</action>
<verify>
<automated>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(/<container(?=[\s>])/g)||[]).length;if(n!==1)bad.push('hotkey expected exactly 1 container, got '+n);const rootTag=h.slice(h.indexOf('<container'), h.indexOf('>', h.indexOf('<container'))+1);if(rootTag.indexOf('nocache')<0)bad.push('hotkey root has no nocache');const sc=(h.match(/<Scroll/g)||[]).length;if(sc!==2)bad.push('hotkey expected 2 Scroll, got '+sc);if(/<g-rect[\s>]/.test(h))bad.push('hotkey must not contain g-rect');let i=0;while(true){const a=h.indexOf('<Scroll',i);if(a<0)break;const z=h.indexOf('</Scroll>',a);if(z<0){bad.push('hotkey unclosed Scroll');break}const block=h.slice(a,z);if(/<container(?=[\s>])/.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')"</automated>
<automated>cmd /c "pnpm exec eslint src/client/ui/hotkey.tsx"</automated>
<fails_when>`hotkey.tsx` 仍含 `Selection` / `<Selection` / `if (isSelected)` / `hidden={!isSelected}` / `{isSelected &&` / `isSelected && groupNodes.push(` / `isSelected && commandNodes.push(`(`isSelected && …push(...)` 表达式语句会被 `@typescript-eslint/no-unused-expressions` 判错;选中态必须是 `g-rectr` 的边框,不允许条件挂载任何元素)、仍含 `getCommandTop` / `getCommandHeight` / `getContentHeight`、`strokeStyle={ …isSelected ? …}` 或 `lineWidth={ …isSelected ? …}` 不足 2 处(左栏分组行 + 右栏按键框各一处)、不是恰好两个 `Scroll`、`<container>` 计数不为 1、任一 `Scroll` 内仍有 `container` 或 `hidden=`、出现 `<g-rect`(必须用 `g-rectr`)、缺 `commandRows` / `ICommandRow` / `BOX_LEFT` / `BOX_FILL_HEIGHT` / `setKeyCombination(`、根 `nocache` 缺失;eslint 报错;或 node 闸门非零退出。**不再检查 `loc` 是否物理单行**(Prettier 优先);漏写 `: ElementLocator` 会被 Task 4 的 `pnpm check:type` NEW 诊断门禁拦下。</fails_when>
</verify>
<done>`hotkey.tsx` 两栏都是平铺的 `Scroll` 直接子元素(6 个容器全删,`<container>` 只剩根且带 `nocache`),`commandRows` 一次遍历给出每行 `top`(`ICommandRow` 无未使用的 `height`),`getCommandTop` / `getCommandHeight` / `getContentHeight` 全部删除;**`Selection` 已从导入与模板中完全移除**,选中态改为既有 `g-rectr` 的边框:左栏分组行与右栏按键框各用 `strokeStyle={isSelected ? 'gold' : 'transparent'}` / `lineWidth={isSelected ? 2 : 1}` 表示,无新增元素、无 `hidden`、无 `if (isSelected)` 条件挂载;行点击与按键框点击仍可用,捕获与写回逻辑不变;eslint 无报错。</done>
</task>
<task type="auto">
<name>Task 4: 新增 `?raw` 源码闸门 `uiContainerGate.test.ts`,跑 eslint / 类型前后基线 / 哈希与范围门禁,写执行摘要</name>
<files>src/client/ui/__test__/uiContainerGate.test.ts, .planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md</files>
<action>
**依赖 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[]` —— 循环查找 `<Scroll` 与其后第一个 `</Scroll>`,返回每段(含两端标签),找不到闭合就停止;
- `firstContainerTag(source: string): string` —— 从第一个 `/<container(?=[\s>])/` 匹配处截到其后第一个 `>` 的子串(`<container-custom>` 不会被误认);
- `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,且每一段都不匹配 `/<container(?=[\s>])/`、**也都不匹配 `/hidden\s*=/`**(显示开关的位置必须靠这个逐块断言来证明,不能只查全文有没有 `hidden`——`Scroll` 外的 `hidden` 是允许且必需的)。
2) **`Scroll` 外没有包装 `container`**:对五个文件,用 `stripComments(src).match(/<container(?=[\s>])/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`)。**不要用 `/<container\b[^>]*>\s*<Scroll\b/` 之类的紧邻正则**:`equipbox` 根 `<container>` 的 `onMove={...}` 里含 `>`,正则会提前截断而近似恒真。
3) **`hotkey` 两栏平铺、无 `Selection`、选中态为边框**:`getScrollBlocks(stripComments(hotkeySource)).length` 为 `2`;两段都不含 `<container` 与 `hidden=`;`flatten(stripComments(hotkeySource))` 含 `commandRows` 与 `ICommandRow`,且不含 `getCommandTop` / `getCommandHeight` / `getContentHeight`;**`stripComments(hotkeySource)` 不含 `Selection` 这个词**(`/Selection/` 不匹配——导入与标签里都不能有;注释会被 `stripComments` 剥离,不影响断言);**左栏分组行与右栏按键框各自带边框**:`tagAround(stripComments(hotkeySource), 'g-rectr', 'selectGroup(id)')` 与 `tagAround(..., 'g-rectr', 'selectKey(row.command, index)')` 都含 `strokeStyle`、`lineWidth` 与 `isSelected`,且全文 `/strokeStyle=\{\s*isSelected\s*\?/g` 与 `/lineWidth=\{\s*isSelected\s*\?/g` 计数各为 `2`;不含 `if (isSelected)`、不含 `hidden={!isSelected}`、不含 `{isSelected &&`、不含 `isSelected && groupNodes.push` / `isSelected && commandNodes.push` 表达式语句(`@typescript-eslint/no-unused-expressions` 会判错)。
4) **`book` 四项临界字段缺失回退为 0**:`flatten(stripComments(bookSource))` 含 `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;`、`伤害`、`临界`、`减伤`、`1 防`;`stripComments(bookSource)` 不匹配 `/\{\s*critical\s*&&/`,也不含 `def1 && (`。
5) **`bookDetail` 两项临界字段缺失回退为 0**:`flatten(stripComments(bookDetailSource))` 含 `format(critical.value ? critical.value.nextDiff : 0)` 与 `format(critical.value ? -critical.value.damageDiff : 0)`、`加攻`、`减伤`;`stripComments(bookDetailSource)` 不匹配 `/\{\s*critical\.value\s*&&/`。
6) **`bookDetail` 用高度回调 + `key` 重挂载计算行位置**:`flatten(stripComments(bookDetailSource))` 含 `autoHeight`、`onUpdateHeight`、`textHeights`、`specialRows`、`SPECIAL_MIN_HEIGHT`、`getDescription()`、`textTop`、`textHeight`、`ISpecialRow`、`ISpecial<unknown>`、`${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('<icon')` 会落到 -1);
- `tagAround(stripComments(equipBoxSource), 'text', 'item.item.name')`、`tagAround(..., 'icon', 'item.item.num')`、`tagAround(..., 'text', 'text={name}')`(槽名)都含 `noevent`;而 `tagAround(..., 'icon', 'slotEquip ? slotEquip.item.num : 0')` **不含** `noevent`(图标是拖动起手,`onDown` 必须留在它上面);
- `tagAround(stripComments(toolBoxSource), 'text', 'item.raw.name')`、`tagAround(..., 'text', 'core.formatBigNumber(item.count)')` 都含 `noevent`;而 `tagAround(..., 'g-rectr', 'clickItem(item)')` **不含** `noevent`(它是点击目标);
- `tagAround(stripComments(hotkeySource), 'text', 'group.name')`、`tagAround(..., 'text', 'row.command.name')`、`tagAround(..., 'text', 'formatCombination(showing)')` 都含 `noevent`;而 `tagAround(..., 'g-rectr', 'selectGroup(id)')` 与 `tagAround(..., 'g-rectr', 'selectKey(row.command, index)')` **不含** `noevent`(它们是命中层)。
- 任一 `tagAround` 返回空串即视为失败(说明 needle 或标签名写错)。
8) **`hotkey` 根容器有 `nocache`**:`firstContainerTag(stripComments(hotkeySource))` 含 `nocache`。
9) **长 `loc` 已提为常量且类型正确**:`flatten(stripComments(equipBoxSource))` 含 `STATUS_SCROLL_LOC`、`DESC_SCROLL_LOC`、`SLOT_SCROLL_LOC`、`OWNED_SCROLL_LOC`,且这四个常量都在 `Scroll` 的 `loc` 中被引用;**并且每个常量都带 `: ElementLocator` 注解**(`flatten` 里应出现 `STATUS_SCROLL_LOC: ElementLocator` / `DESC_SCROLL_LOC: ElementLocator` / `SLOT_SCROLL_LOC: ElementLocator` / `OWNED_SCROLL_LOC: ElementLocator`),且 `stripComments(equipBoxSource)` 含**独立一行**的 `import { ElementLocator } from '@motajs/render';`(普通导入,禁止 `import type`;**不得把该类型并入其它 `@motajs/render` 导入,否则这里的字面量断言会失败**)。**不断言 `loc={` 必须物理单行**——Prettier 的 printWidth 80 优先;只有循环无关的 `loc` 才上提,循环相关的行内 `loc` 保持折行形态。
- **不要**在测试里导入任何 `.tsx` 组件本体;只使用 `?raw` 字符串。**不要**读取 `.planning/` 或基线文件;闸门只读源码。
**B. 写 `.planning/phases/09-minimum-browser-runnable/09-29-SUMMARY.md`:**
- 逐文件记录容器删除结果(每个文件现在只剩根 `container`)、平铺后的 `Scroll` 直接子元素构成。
- 记录行为变化:`book` 四项 / `bookDetail` 两项临界信息缺失显示 `0`;`bookDetail` 特殊属性行按实测高度撑开、`TextContent` 以 `code` + `textTop` + `textHeight` 为 `key` 重挂载、`specials` 变化时按 `code` 重建高度表;9 处显示开关按位置分流(`Scroll` 外 5 处 `hidden`、`Scroll` 内 4 处动态空文本 / 0 回退;`hotkey` 无显示开关);`hotkey` 根加 `nocache`;`hotkey` 删除三个旧函数并改用 `commandRows`;`hotkey` 完全移除 `Selection`,选中态改为既有 `g-rectr` 的 `strokeStyle` / `lineWidth` 边框(左栏分组行与右栏按键框各一处,无新增元素、无 `hidden`、无条件挂载);`book` 两条行内注释按 OI-5 原样迁移;命中矩形内缩 2-4px;`toolbox` 未选择提示移到说明 `Scroll` 之前;以及**可滚动内容末端的减小量**(`hotkey` 右栏末行后少约 16px `COMMAND_GAP`、`equipbox` 拥有装备区约 2px / 状态区约 4.5px、`toolbox` 两列约 2px)。
- 记录门禁结果(eslint / `pnpm check:type` 前后基线只判 NEW / vitest 源码闸门 9 组断言 / **5 个**受保护文件哈希与 HEAD 政策 / 改动范围(全仓脏文件快照比较))。
- 明确声明:`estimate` 未校准(`confidence: low`);**不做浏览器 UAT,不宣称滚动 / 内存 / 点击命中 / 拖拽行为已验证**;`book.tsx` 的过期 TODO 按 OI-1 未改;**`keyAdaptationGate.test.ts` 是既存失败(15 个用例 5 个失败,改动前即如此),不在本计划范围、本计划不修**;**执行器 0 次代码提交**(`.planning` 产物可能由 GSD 编排器按 `commit_docs: true` 聚合提交),代码工作树留给用户审查。
**C. 收尾门禁(全部在写 SUMMARY 前跑通):** 见本任务下方的 automated 验证命令。
- eslint:跑 6 个改动文件,与 `09-29-BASELINE.json` 的 `eslint` 基线比较,只判 NEW。
- `pnpm check:type`:整仓运行,按 6 个改动文件过滤,只判 NEW;基线中存在、执行后消失的诊断允许。
- 受保护文件哈希(**5 个**):`scroll.tsx` / `textbox.tsx` / `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` 与基线逐字相等。**HEAD 政策**:HEAD 与基线相同,**或** HEAD 不同但 `git diff --name-only <baseline>..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。
</action>
<verify>
<automated>cmd /c "pnpm exec vitest run src/client/ui/__test__/uiContainerGate.test.ts"</automated>
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-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+')')"</automated>
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-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+')')"</automated>
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-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+')')"</automated>
<automated>node -e "const cp=require('child_process'),fs=require('fs');const b=JSON.parse(fs.readFileSync('.planning/phases/09-minimum-browser-runnable/09-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)')"</automated>
<fails_when>闸门测试失败、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 个允许路径里,也不由本计划修复。</fails_when>
</verify>
<done>`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` 为既存失败(不在本计划范围)。</done>
</task>
</tasks>
<threat_model>
## 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 | 源码闸门逐段扫描 `<Scroll>...</Scroll>` 断言无 `/<container(?=[\s>])/`;每个文件断言 `<container>` 计数恰好 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 | 本计划**不新增任何依赖、不执行安装命令**;若执行期出现安装需求,必须停下上报,不得自行安装 |
</threat_model>
<verification>
- **源码形状**:`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` 的 `/<g-rect[\s>]/` 断言仍为绿(`hotkey.tsx` 只用透明 `g-rectr` 做命中层,Task 3 的 node 闸门会提前拦住 `<g-rect`)。**`keyAdaptationGate.test.ts` 在改动前就是红的(15 个用例 5 个失败)**,属既存状态、不在本计划范围;本计划不修它、不声称全部既有闸门为绿,SUMMARY 如实记录。
- **无代码提交**:HEAD 与 `09-29-BASELINE.json` 的 `head` 相同,或 `git diff --name-only <baseline>..HEAD` 只含 `.planning/` 路径(编排器聚合提交);任何非 `.planning` 路径的提交都判失败。
- **无浏览器验证声明**:SUMMARY 明确写入「未做浏览器 UAT,未宣称滚动 / 内存 / 点击命中 / 拖拽行为已验证」;`book.tsx` 的过期 TODO 按 OI-1 未改。
</verification>
<success_criteria>
- [ ] `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` 为既存失败。
</success_criteria>
<output>
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.
</output>
@@ -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` 仍不含 `<g-rect`) |
| 既存失败 | `keyAdaptationGate.test.ts` | **15 个用例中 5 个失败,改动前即如此(同一次改动前运行同为 5 失败),属既存问题、不在本计划范围,本计划不修、也不声称全部既有闸门为绿** |
| eslint NEW | 6 个改动文件 vs 基线 | 无 NEW(基线 0 → 当前 0) |
| 类型 NEW | `pnpm check:type` 整仓,按 6 个改动文件过滤。归一化与基线逐字一致并保留 `src/` / `packages-user/` 前缀 | 无 NEW(基线 6 → 当前 6) |
| 受保护哈希 | 5 个文件:`scroll.tsx` / `textbox.tsx` / `settings.tsx` / `ui/index.ts` / `misc/hotkey.ts` | 与基线逐字相等 |
| HEAD 政策 | 与基线比较,允许 `.planning` 聚合提交 | HEAD 未变(`9bf8f296…`),满足 |
| 范围 | 整仓 `git status --porcelain -uall` 对照基线 `dirty` 快照 + 6 个允许路径 | 通过:24 条脏路径 = 18 条既存 + 6 个允许路径,无范围外写入 |
## 声明与遗留
- **未做浏览器 UAT**:滚动、内存、点击命中、拖拽手感均未验证,本摘要不宣称任何视觉 / 滚动 / 内存 / 拖拽行为已通过。
- **执行器 0 次代码提交**:未运行任何 git 写命令(`add` / `commit` / `stash` / `checkout` / `restore` / `worktree` / `reset`);5 个 UI 文件与新增测试留在工作树供用户审查。3 个 `.planning` 产物可能由编排器按 `commit_docs: true` 聚合提交,不属于本计划的代码提交。
- **估算未校准**:计划 `estimate.confidence: low`;实际改动 5 个 UI 文件 + 1 个测试文件(约 71k 字符,按 chars/4 计约 17.9k tokens),仅作记录,不作为校准依据。
- **OI-1**:`book.tsx` `getCritical` 上方的过期 TODO(写着「留空」)按用户要求原样保留,与实际「缺失显示 0」不一致。
- **OI-3 / 既有实现**:`Scroll` 的 `padEnd`、`TextContent` 的 `props.hidden` 等系统层既有行为未动;`Scroll` 直接子元素上的 `hidden` 会被 `Scroll` 覆盖,因此只在 `Scroll` 外使用。
- **无新增依赖、未执行构建、未改按键注册与设置入口。**
## Deviations from Plan
1. **[Rule 3 - 实现细节] 测试辅助 `stripComments` 的 JSX 注释正则使用 `[ \t]*` 而非跨行空白**
- **Found during:** Task 4 编写闸门测试
- **Issue:** 若用 `/\{\s*\/\*[^]*?\*\/\s*\}/g`,组件体的 `{` 紧跟 jsDoc 注释时会被当作 JSX 注释起点,整段代码被吞掉(`book.tsx` 从约 9.8k 字符被削到约 2.2k,所有肯定断言假红)。
- **Fix:** 改为 `/\{[ \t]*\/\*[^]*?\*\/[ \t]*\}/g`(JSX 注释与花括号必须在同一行),仍保持「先去 JSX 注释 → 再去块注释 → 再删行注释」的顺序。
- **Files modified:** `src/client/ui/__test__/uiContainerGate.test.ts`
- **Commit:** 无(按计划不提交代码)
2. 无其它偏离;计划要求的文件范围、命名、行为口径与门禁均按 Task 1–4 执行。
## Self-Check: PASSED
- 6 个代码 / 测试路径均存在并包含预期改动:`book.tsx`、`bookDetail.tsx`、`equipbox.tsx`、`toolbox.tsx`、`hotkey.tsx`、`__test__/uiContainerGate.test.ts`。
- `09-29-BASELINE.json` 只在改动前写出一次;`09-29-SUMMARY.md` 已写出。
- 所有自动化门禁均以非零退出即失败的方式实际运行并通过(见上表)。
- 未执行任何 git 写命令;HEAD 仍为 `9bf8f296c47a85f19c762072f9b73214fe329ee3`。