Files
crearte-monorepo/docs/specs/2026-10-02-p12-narrow-viewport-overflow-design.md
T
XingfenD b9e59aee83 docs: P12 narrow-viewport batch delivered (crearte 0.25.0 / server 0.17.1 / deploy 0.7.1)
Register the P12 spec and plan in the ROADMAP doc index, add the P12 batch row,
and update two backlog lines that this batch's measurements settled:

- The 'mobile header / hamburger menu' backlog entry carried since P9-B is
  falsified and removed: nav content width is only 74-158px and measures zero
  horizontal overflow at 320/360/375/412/768/1280px. Building it would have
  shipped a hamburger menu nobody needs.
- Nav links wrapping per-character at phone widths stays parked pending a
  design decision, not a CSS tweak: it is cosmetic only (no overflow, no
  clipping, no lost function), whitespace-nowrap costs +11px at 320px, and all
  seven gap-reduction variants measured fail at 320px + long name because
  logo 77px + nowrap nav 138-158px + truncated name 96px do not fit. It
  competes for the same pixels as the header fix.
- The P11 polish backlog is partially retired: three of P9-B's four items plus
  P11-N5/N6 are closed by this batch; the remaining nine P11 minor notes are
  documentation-only with no actionable change.

Merged and pushed: crearte e59a171 (0.25.0), crearte-server d56c593 (0.17.1),
crearte-deploy 529a988 (0.7.1). Three task-level reviews plus a whole-branch
final review verdict APPROVE with notes, zero critical and zero important; all
notes adjudicated before merge. The final reviewer independently reproduced
three mutations rather than accepting the controller's claims.
2026-10-02 21:16:43 +08:00

362 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# P12 窄屏溢出修复与打磨批 — 设计文档
- 日期:2026-10-02
- 涉及仓库:`crearte`(主)+ `crearte-server`(测试)+ `crearte-deploy`(CI)+ wrapper(记账)
- 前置:P11(`docs/specs/2026-10-02-p11-server-hardening-design.md`,已交付 server 0.17.0 / deploy 0.7.0 / crearte 0.24.1)
- 证据:`crearte/.superpowers/sdd-p12/FINDINGS-survey.md`(16 轮 throwaway spike 的实测输出固化,spike 文件已删、工作树净)
本批**零新功能、零后端行为变更**。做两件事:① 修四个实测可达的窄屏溢出缺陷;② 清偿 P9-B / P11 挂账的可动手打磨项,并把「本批缺陷类型」变成机器守卫(正面应用 P11 N6 的教训)。
---
## 1. 缺口(全部实测,非推断)
度量口径统一为 `document.documentElement.scrollWidth - clientWidth`(>0 即页面横向溢出),在真实构建产物上由 Playwright 实测(`build:e2e` + `serve-runtime.mjs --port 4173`)。
### 缺口 A(D1a):合法长用户名把页头撑出视口,且波及**全站每个路由**
`AppHeader.vue:102` 的 `<summary>` 直接插值 `{{ user.display_name }} ▾`,无任何宽度约束。60 字符 **ASCII** 名在 `word-break: normal` 下不可断行,summary 索取 484px 内容宽:
| 视口 | 实测 |
|---|---|
| 320px | `doc=700/320` → **+380px**;`sumW=484 sumRight=700` |
| 375px | `doc=700/375` → **+325px** |
| 768px | `/` ok;`/account` **+40px** — spike 17 反证更正:此 +40 由**本缺口(页头)**驱动,非缺口 B(回退 dd 修复 @768 仍 `over=+0`;回退页头 @768 → 红)。原表把它记在缺口 B 名下是归因错误 |
**可达性是硬事实,不是敌意构造**:后端 `internal/handler/auth.go:25` `maxDisplayNameLen = 60`、`:76` 错误文案「display name must be 1-60 characters」;前端 `app/auth/validation.ts:4` `DISPLAY_NAME_MAX = 60`、`RegisterView.vue:90` `maxlength="60"`。即**任何注册用户填满合法上限即触发**,且因页头全站常驻,`/`、`/games`、`/docs`、`/creator`、`/login`、`/account`、`/admin/*` 无一幸免。
24 字 **CJK** 名不触发(CJK 可断行,实测 `doc=320/320 ok`)——缺陷取决于**字符可断性**而非长度本身,故修复必须针对「不可断行串」而非「截断到 N 字」。
### 缺口 B(D1b):账号页昵称 `dd` 逃逸
`AccountView.vue:190` 的 `<dd class="text-sm font-bold">` 位于 `flex flex-wrap items-baseline gap-2` 内,但 60 字 ASCII 不可断行且 flex item 默认 `min-width:auto` 拒绝收缩:320px 实测 `ddW=542 ddRight=592 ddEscapes=true`(视口 320),`ddWhiteSpace=normal ddOverflowWrap=normal`。
**牙口位置(spike 17 2×2 消融实测)**:dd 固有宽 542px、左缘 50px → 右缘恒为 592px,故它在**任何 <592px 的视口**都溢出:回退本修复 @320px → `over=+272`(`scrollWidth=592`,即 `ddRight=592`)、@375px → `over=+217`。但 @768px dd 右缘 672 < 768,**本修复在 768 非必要**——该视口 `/account` 的 +40px 是缺口 A(页头)驱动(见上表更正)。即:dd 修复的牙在 320/375,页头修复的牙在全视口;两者独立必要、缺一不可,但**不是同一视口的同一症状**。
### 缺口 C(D2):目录页排序 select 的 `shrink-0`
`ResultMeta.vue:22` 的 `<div class="relative shrink-0">` 包裹排序 `<select>`,`shrink-0` 使其拒绝收缩。实测 `/games` @320px `doc=323/320` → **+3px**,**短名短数据也复现**(与缺口 A 无关);360px 及以上无溢出。spike 7 的四候选对照精确定位到它:仅「移除该包裹层 `shrink-0` + `min-width:0`」把 +3 归零,其余三个候选无效。
### 缺口 D(D3):五个 admin 表格零 overflow 包裹层,**短数据也溢出**
静态审计:`app/**` 共 **5 个 `<table>`**(`AdminUsersView.vue:115` 6 列、`AdminView.vue:150/173/220` 共 13 列、`AdminAuditView.vue:60` 5 列),`overflow-x`/`overflow-auto` 在表格上下文中**零命中**,全部 `table-layout: auto`、父级 `overflow-x: visible`。
短名短邮箱数据实测(与 display_name 长度无关):
| 路由 | 320px | 375px | 768px+ |
|---|---|---|---|
| `/admin/users` | **+76px**(`t0 w=364 right=396`) | **+21px** | ok |
| `/admin/audit` | **+38px**(`t0 w=326 right=358`) | ok | ok |
| `/admin` | ok(`t0 w=282`) | ok | ok |
根因:表格 `auto` 布局的 min-content 宽由各列固有内容决定(邮箱等宽串、`toLocaleString('zh-CN')` 日期、角色徽章、操作按钮),`w-full` 只是 `width:100%` 的**建议**,撑不破 min-content 下限;父级无裁剪 → 直接推宽文档。
### 缺口 E(D5):长名下用户下拉菜单逃逸视口
`AppHeader.vue:103` 的菜单是 `absolute right-0 w-32`,相对 `relative` 的 `<details>` 定位。缺口 A 使 details 本身宽 484px 且右边缘在 700px,菜单随之被推出屏外:60 字名 @320px 实测 `menu {left:357, right:485, escapesRight:true}`。**修好缺口 A 即自动修好本项**(spike 15/16 全部变体实测 `escapesRight:false`),无需独立改动,但须有断言钉住。
### 非缺口(勘查证伪,避免做无用功)
- **「移动端页头 / 汉堡菜单」挂账证伪**:nav 三链接内容宽仅 **74–158px**,320/360/375/412/768/1280px **全部零横向溢出**(spike 1+4)。ROADMAP 与账本中的该项挂账应从「待启动」改为「已证伪,删除」。
- **D4(nav 链接逐字换行)park**:320px 下「作品」渲染为 `作/品`(link `h=45`、`navH=45` vs 行高 `h-14`=56px),所有手机宽度都发生(含 375px = iPhone 12/13/14)。属**外观级**:无溢出、无截断(`selfOverflow=false`)、无功能损失。修法 `whitespace-nowrap` 实测在 320px 引入 **+11px** 溢出(demand 347 > 320),七种 gap 收缩组合(C1–C6)在「320px + 长名」下**全部失败**(+19~+75px)——logo(77) + nowrap nav(138–158) + 截断名(96) 物理放不下,与缺口 A 争同一份空间。需设计决策(更短的名上限 / 汉堡菜单 / nav 缩写),**不入本批**,转独立决策项。
---
## 2. 决策表
| # | 主题 | 定案 | 理由(实测依据) |
|---|---|---|---|
| D-A | `<summary>` 怎么截断 | **flex 三件**:`summary` 加 `flex max-w-[6rem] min-w-0 items-center gap-1`;名字包一层 `<span class="truncate min-w-0">`;`▾` 包一层 `<span class="shrink-0" aria-hidden="true">`;`summary` 加 `:title="user.display_name"` | spike 15 证伪了「运行时把名字节点包起来、▾ 留在外面」这条路:**Vue 把 `{{ user.display_name }} ▾` 编译成单个文本节点**,任何基于 `childNodes[0]` 的拆分都会把 `▾` 一起搬进截断 span(V2–V4 实测 `caret=HIDDEN`)。必须改模板显式拆两个 span。spike 16 六变体实测:F1(flex 无 cap)在 60 字名时 320px **+390**、375px 同样 ❌(纯 shrink 无效,summary 仍索取内容宽;375px 的具体 px 当时未归档,终审 N-F2 故删去原写的「+405」——该数无出处,且不承载任何结论:归档的「F1 仍 ❌」已足够);F2(7rem)320px **+7**;**F3(6rem)320/375px × {短名, 60字ASCII, 60字CJK} 六格全 `over=+0` 且 `caret=VIS`**;F4/F5(5/4rem)也过但 summary 更窄、无额外收益。6rem 同时是 spike 15 整体截断形态的实测赢家(`sumRight=311 ≤ 320`),两种形态上限一致 → 定 6rem。`title` 承载全名(鼠标悬停可读全文),避免截断丢信息。 |
| D-B | 包裹层为什么必须 `relative` | 五个表格的包裹层统一为 `class="relative overflow-x-auto pr-1 pb-1"` | **`relative` 不是装饰,是修 bug**:P9-B 为空「操作」列头补的 `<span class="sr-only">操作</span>` 是 `position:absolute` + `margin:-1px`,而 Tailwind 的 `sr-only` **不含 `top`/`left`** → 其包含块是最近的**已定位**祖先。`position:static` 的 `overflow-x:auto` 包裹层不构成包含块,该 span 逃出滚动裁剪,**独自**把文档撑宽:烧蚀实验(逐元素 `display:none` + 重测权威度量)定位到 `d=11 span.sr-only l=351 r=352 w=1`,短数据残差 `doc=352` = 其右边缘 352;长数据 `doc=1061` = 其右边缘 1061。加 `relative` 后残差归零(spike 12 W1 实测 `over=+0`;W2「th 加 relative」同样 `over=+0`,选 W1 因改动集中在一处新增元素而非逐个 `<th>`)。这也解释了 spike 9 的 `trueOffenders=0` 矛盾:offender 扫描把「有可滚动祖先」的元素当合法溢出筛掉了,而它恰恰逃出了容器——**边框盒扫描看不见它,只有烧蚀能**。 |
| D-C | 包裹层要不要留 padding | 留 `pr-1 pb-1`(4px) | 五个表格都带 `shadow-hard` = `4px 4px 0 #141414`(`main.css:20`)。overflow 裁剪发生在 **padding box**,无 padding 时 `roomBottom=0`(阴影被裁),`pr-1 pb-1` 时 `roomBottom=4` 恰好容下(spike 7 Q2 / spike 12 W3 对照实测)。Tailwind v4 `p*-1` = 0.25rem = 4px,与阴影偏移逐值相等。 |
| D-D | `v-else` 怎么处置 | **`v-else` 从 `<table>` 上移到包裹层** | 三个表格是 `<p v-if="空态">` + `<table v-else>` 的**相邻兄弟配对**(`AdminUsersView.vue:114-115`、`AdminView.vue:149-150`、`AdminAuditView.vue:59-60`)。把 `<table>` 包进 `<div>` 而不迁移 `v-else`,会使 `v-else` 失去配对对象 → Vue 编译报错或空态/表格逻辑错乱。另两个表格(`AdminView.vue:173/220`)无 `v-if` 兄弟,仅包裹、不涉 `v-else`。 |
| D-E | 排序 select 怎么改 | `ResultMeta.vue:22` 的 `relative shrink-0` → `relative min-w-0` | spike 7 四候选对照中唯一把 `/games` @320px 的 +3px 归零的就是它(候选 B);同排的 `shrink-0` 计数 `<p>`(`:20`)与「筛选」按钮实测**不是**驱动元素(候选 C/D 无效)。保留 `relative`(`PhCaretDown` 靠它绝对定位)。 |
| D-F | `dd` 怎么改 | `AccountView.vue:190` 的 `text-sm font-bold` → `text-sm font-bold min-w-0 wrap-anywhere` | `min-w-0` 解除 flex item 的 `min-width:auto` 下限(否则拒绝收缩),`wrap-anywhere`(`overflow-wrap:anywhere`)让不可断行串可在任意位置折行。已查包确认 Tailwind 4.3.3 **存在** `wrap-anywhere` 工具类(`node_modules/tailwindcss/dist/lib.js` 命中),非推断。**不用** `break-all`:它对 CJK/拉丁混排断词更粗暴,且本处只需「允许在任意点折行」而非「强制逐字断」。 |
| D-G | 可访问名怎么保住 | 不改任何 `aria-*`、`scope`、`sr-only` 结构;`▾` 的 span 加 `aria-hidden="true"` | `▾` 是纯装饰字形(既有代码里它就是文本节点的一部分,从未参与语义)。加 `aria-hidden` 后 `<summary>` 的可访问名从「用户名 ▾」变为「用户名」——**语义更准**(`▾` 不是名字的一部分),且 `e2e/ux.spec.ts:26` 的 locator `header details summary` 与断言(`toBeVisible` / 点击 / `details[open]` 计数)不依赖可访问名文本,实测不破。`e2e/ux.spec.ts:25` 的注释「details summary 的可访问名即用户名」在改动后**反而更准确**(原先含 `▾`),同批更新该注释措辞。 |
| D-H | 机器守卫怎么做 | 两层:① **e2e 视口守卫** `e2e/responsive.spec.ts`(进 CI 主腿);② **源码级守卫** `app/lib/tableOverflow.test.ts`(沿用 `tableScope.test.ts` 范式) | P11 终审 N6 的核心观察:**文档层防御密集但无机器守卫**,改回坏配置不会被任何测试抓住。本批把这个教训正面应用——D1/D2/D3 全部是「人眼看构建产物才会发现」的缺陷,正是 e2e 视口断言的用武之地。`playwright.config.ts` 的 `testIgnore: /noauth\.spec\.ts/` 意味着新增 `responsive.spec.ts` **自动进 CI `e2e` job**(`validate.yml:50` 跑 `npm run e2e`),无需改 CI 配置。源码级守卫按「每个 `<table>` 的**父元素**是否带 `overflow-x-auto`」判定,**不能**用全局 `overflow-x-auto` 计数 1:1 断言——`DocSidebar.vue:25` 已有一处用在 `<nav>` 上(实测基线 = 1 而非 0),全局计数会写错。 |
| D-I | 打磨项取舍 | 纳入 P9B-1/P9B-2/P9B-4、P11-N5/N6;**不纳入** P9B-3(已修)、P11 其余次要 notes(纯留档,无可动手改动) | 逐条判据见 §3.5。关键:P9B-2 与 P9B-4 都改既有断言覆盖的行为,须先证明**既有断言不破**再动手——P9B-2 的 `a11y.spec.ts:61` 在未评分态(`rated=false`)下断言 `评 3 星`,动态标签只在「已评且 i === score」时改写,故该断言路径不变;P9B-4 的六处 `toast-announce` 断言均不涉及「手动关掉最新一条」,新语义是**追加**而非改写。 |
---
## 3. 实现
### 3.1 `crearte/src/app/components/AppHeader.vue`(D-A / D-G / 缺口 E)
当前 `:101-102`:
```vue
<details v-else ref="detailsRef" class="relative ml-auto sm:ml-0" @toggle="menuOpen = ($event.target as HTMLDetailsElement).open">
<summary ref="summaryRef" class="list-none cursor-pointer select-none border-2 border-ink bg-surface px-2 py-1 text-xs font-bold [&::-webkit-details-marker]:hidden">{{ user.display_name }} ▾</summary>
```
改为(`<details>` 一行**不动**,仅改 `<summary>` 及其内容):
```vue
<summary
ref="summaryRef"
:title="user.display_name"
class="flex max-w-[6rem] min-w-0 list-none cursor-pointer select-none items-center gap-1 border-2 border-ink bg-surface px-2 py-1 text-xs font-bold [&::-webkit-details-marker]:hidden"
>
<span class="min-w-0 truncate">{{ user.display_name }}</span>
<span class="shrink-0" aria-hidden="true">▾</span>
</summary>
```
`ref="summaryRef"` 必须保留(`onDocKeydown` 的 Esc 回焦依赖它,见 `:26` `summaryRef.value?.focus()`)。`truncate` = `overflow:hidden` + `text-overflow:ellipsis` + `white-space:nowrap`,配合 `min-w-0` 才能在 flex 子项上生效。
### 3.2 `crearte/src/app/views/{AdminUsersView,AdminView,AdminAuditView}.vue`(D-B / D-C / D-D)
五处统一模式。**带 `v-else` 的三处**(`AdminUsersView.vue:115`、`AdminView.vue:150`、`AdminAuditView.vue:60`)——`v-else` 上移:
```vue
<!-- 改前 -->
<table v-else class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
…
</table>
<!-- 改后 -->
<div v-else class="relative overflow-x-auto pr-1 pb-1">
<table class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
…
</table>
</div>
```
**不带 `v-else` 的两处**(`AdminView.vue:173`、`AdminView.vue:220`)——仅包裹:
```vue
<div class="relative overflow-x-auto pr-1 pb-1">
<table class="w-full border-2 border-ink bg-surface text-sm shadow-hard">
…
</table>
</div>
```
表格开闭行号(供核对,改动会移动后续行号,以内容锚点为准):`AdminUsersView.vue` 115→161;`AdminView.vue` 150→165、173→214、220→239;`AdminAuditView.vue` 60→82。表格**内部**(`<thead>`/`<tbody>`/`<th scope>`/`data-testid`)一字不动——P9-B 的 `scope="col"` 与 sr-only「操作」必须原样保留(`a11y.spec.ts:52` 断言 `headers.nth(5)` 可访问名为 `操作`)。
### 3.3 `crearte/src/app/components/ResultMeta.vue`(D-E)
`:22` 单行改动(全文件仅此一处 `relative shrink-0`,唯一匹配):
```vue
<!-- 改前 --> <div class="relative shrink-0">
<!-- 改后 --> <div class="relative min-w-0">
```
该文件共 **4 处** `shrink-0`(元素级核准),只改 `:22` 这一处,**其余三处不动**:`:18` 计数 `<p class="shrink-0 text-[0.9375rem] font-extrabold" aria-live="polite">`、`:20` `<PhArrowsDownUp … class="hidden shrink-0 text-ink-soft sm:block" />`、`:43`「筛选」按钮 `class="lift flex shrink-0 items-center gap-1.5 …"`。spike 7 的四候选对照实测:只有移除 select 包裹层的 `shrink-0`(候选 B)能把 `/games` @320px 的 +3px 归零;针对计数 `<p>`(候选 C)与筛选按钮(候选 D)的改动均**无效**(over 仍 +3px),故不动它们。
### 3.4 `crearte/src/app/views/AccountView.vue`(D-F)
`:190` 单行改动:
```vue
<!-- 改前 --> <dd class="text-sm font-bold">{{ user.display_name }}</dd>
<!-- 改后 --> <dd class="min-w-0 text-sm font-bold wrap-anywhere">{{ user.display_name }}</dd>
```
同文件 `:194` 的用户名 `dd` 与其他 `dd` **不动**(用户名有 `maxlength="39"`,实测不溢出)。
### 3.5 打磨项(跨仓,各自独立)
**(a) P9B-1 — `crearte/src/app/router/index.test.ts:111`**
当前弱断言:
```ts
await expect(router.push('/docs')).resolves.not.toThrow()
```
它依赖 vue-router 5.3.1「`afterEach` 抛错不被吞」的语义,升级后若改为吞抛则**恒真**(假绿)。改为直接单测被测函数本身,绕开 router 版本语义:
```ts
// 直接验证 focusMain 在 #main 缺失时静默 no-op(不经 router,故不依赖
// vue-router「afterEach 抛错不被吞」的版本语义——那会让断言退化为恒真)
expect(() => focusMain()).not.toThrow()
```
并从 `@/router` 补 `focusMain` 到既有 import。保留其后的 `expect(document.title).toBe('文档 · crearte 创艺')`(标题职责仍经 router 验证)。
**(b) P9B-2 — `crearte/src/app/components/GameReactions.vue:105`**
当前每颗星静态 `:aria-label="`评 ${i} 星`"`,读屏用户听「评 4 星」无法预知「再点同一颗星会撤评」(该控件支持点当前分取消)。改为按状态动态:
```vue
:aria-label="rated && i === score ? `已评 ${i} 星,点击取消评分` : `评 ${i} 星`"
```
`rated`/`score` 均为既有 ref(`:16-17`,由 `apply(view)` 从服务端 `ReactionView` 全量替换,`:44-51`)。**既有断言不破**:`e2e/a11y.spec.ts:61` 在 `seedSession(page,'user')` + 夹具无个人评分下走 `rated=false` 分支,可访问名仍是 `评 3 星`。新增单测须覆盖两态(未评 → `评 N 星`;已评 N → `已评 N 星,点击取消评分`;已评 M≠N → `评 N 星`)。**不改** `role="group"` 的 `:aria-label`(`:98`,P9-B 已定稿)与星形字形 `aria-hidden`。
**(c) P9B-4 — `crearte/src/app/components/ToastHost.vue:22-28`**
当前 watch 源是**数组末尾条目的 id**:
```ts
watch(
() => toasts.value[toasts.value.length - 1]?.id,
(id) => {
announcement.value =
id === undefined ? '' : (toasts.value[toasts.value.length - 1]?.text ?? '')
}
)
```
饱和态(`MAX_VISIBLE=3`)下 push 会「逐最旧 + append」,长度 3→3 不变而末尾 id 变,故 P9-B 特意以 id 为源(这个选择是对的,**保留**)。副作用是:**手动关掉最新一条**时末尾 id 回退到较旧条 → watch 触发 → 播报区**重念一条仍在屏上的旧消息**(自动过期不触发,因为过期的是最旧条)。修法:只在 id **单调变新**时播报,并记住已播报的最大 id:
```ts
const announcement = ref('')
// 已播报的最大 id:useToast 的 nextId 单调递增,故「比它大」即「新消息」。
// 手动关掉最新一条会让数组末尾 id 回退到较旧条,若不比较大小就会重念一条
// 仍在屏上的旧消息(P9B-打磨4)。
let announcedId = 0
watch(
() => toasts.value[toasts.value.length - 1]?.id,
(id) => {
if (id === undefined) {
// 全部消失:清空播报,但不重置 announcedId(后续新消息 id 必然更大)
announcement.value = ''
return
}
if (id <= announcedId) return
announcedId = id
announcement.value = toasts.value[toasts.value.length - 1]?.text ?? ''
}
)
```
**六处既有断言均不破**(逐一核对 `ToastHost.test.ts`):`:49-53` 常驻空播报(初始 `announcement=''`);`:61` push 后等于该消息(id 1 > 0);`:74-78` 消失后清空(`id===undefined` 分支);`:98` 饱和态播报第 4 条(id 4 > 3);`:106` 播报节点内零交互控件(结构未变)。新增单测须覆盖「三条并存 → 关掉最新 → 播报文字**不变**(不重念旧消息)」。注意 `announcedId` 是模块内闭包变量,而 `toasts` 是 `useToast` 的模块级单例 ref——测试间须用既有 `__resetToasts()`(`useToast.ts:38`)隔离;若 `announcedId` 残留导致跨测试污染,改为把它也挂到 store 侧或在 `__resetToasts` 里一并复位(实现者按实测选择,并在测试注释里写明)。
**(d) P11-N5 — `crearte-server/src/internal/config/config_test.go`**
P11 T3 加了归一化,但「错误消息回显归一化后的值」无钉桩。在 `TestApplyLogging` 的 bogus 段(`:264` 附近,`LOG_FORMAT` bogus 之后)补:
```go
// 归一化后的错误消息须回显归一化值(P11-N5):运维看到 " BOGUS " 原样
// 会以为是空格问题,回显 "bogus" 才指向真正的白名单不匹配。
t.Setenv("LOG_LEVEL", " BOGUS ")
err := applyLogging(&cfg)
if err == nil {
t.Fatal("invalid LOG_LEVEL must fail after normalization")
}
if !strings.Contains(err.Error(), "bogus") {
t.Errorf("error = %q, want it to echo the normalized value %q", err, "bogus")
}
if strings.Contains(err.Error(), " BOGUS ") {
t.Errorf("error = %q, must not echo the raw un-normalized value", err)
}
```
须先 `grep -n "\"strings\"" internal/config/config_test.go` 确认 import;缺失则补。先读 `applyLogging` 现有错误文案,确认它确实回显归一化值(P11 T3 报告称是)——若实测不回显,则本项从「补测试」变为「补实现 + 测试」,须在报告里说明。
> **勘误(2026-10-02,终审 N-F1)**:上方片段的第一条断言写的是裸子串 `strings.Contains(err.Error(), "bogus")`。审查 note SD-N1 指出它有理论盲区:若实现改为回显**小写但未 trim** 的 `" bogus "`,裸子串断言与下方大小写敏感的原样形式检查**会同时放过**。落地的 `config_test.go`(server `fa39d27`)已收紧为 `%q` 渲染出的**带引号形式** `` `"bogus"` ``,一次钉住 trim 与 lower 两个性质。终审已用 mutation 独立证实两条断言对「回显原始 env」同时开火。**不要按上方片段把它弱化回裸子串。**
**(e) P11-N6 — `crearte-deploy/.github/workflows/validate.yml`**
在 `compose` job 的「compose parses under every profile」step 之后新增一个 step,把 P11 D-A′ 的**空默认**变成机器守卫(终审 N6:现无任何自动化测试能抓住 compose 文件被改回 subnet 信任):
```yaml
- name: TRUSTED_PROXIES defaults empty (P11 D-A' guard)
env:
POSTGRES_PASSWORD: "***"
MINIO_ROOT_USER: ci
MINIO_ROOT_PASSWORD: "***"
AUTH_TOKEN_SECRET: "***"
BUNDLE_KEK_k1: ci-not-a-real-secret
run: |
set -euo pipefail
# 空默认是安全默认:信任整个 compose 网段会把 docker 网桥网关划进信任范围,
# 使客户端预置的 X-Forwarded-For 被采纳(限流可自选桶绕过)。
# 详见 README「客户端 IP 解析与限流」。
rendered="$(docker compose --profile prod config)"
# 未设 .env 时必须解析为空字符串(config 输出形如 TRUSTED_PROXIES: "")
echo "$rendered" | grep -q 'TRUSTED_PROXIES: ""' \
|| { echo 'TRUSTED_PROXIES must default to empty — see README' >&2; exit 1; }
# 且不得出现网段信任(172.28.0.0/24 或任何 COMPOSE_SUBNET 派生)
if echo "$rendered" | grep -Eq 'TRUSTED_PROXIES: "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+'; then
echo 'TRUSTED_PROXIES must not default to a CIDR (docker SNAT makes the bridge gateway trusted)' >&2
exit 1
fi
# 覆盖仍须生效(管线未断)
TRUSTED_PROXIES=10.0.0.0/8 docker compose --profile prod config \
| grep -q 'TRUSTED_PROXIES: "10.0.0.0/8"' \
|| { echo 'TRUSTED_PROXIES override pipeline broken' >&2; exit 1; }
```
> **勘误(2026-10-02,审查 SD-D1 定案)**:上方字面片段的断言②③模式写的是**带引号**形式(`TRUSTED_PROXIES: "[0-9]+…`、`TRUSTED_PROXIES: "10.0.0.0/8"`),但 compose 实测只对**空串**加引号,非空标量**裸渲染**(`TRUSTED_PROXIES: 10.0.0.0/8`)。照抄字面片段会同时产出:②**恒不匹配的死断言**(改回 CIDR 默认也放行 = 假信心)与③**恒红的假警**(正向 CI 必炸)。落地的 `validate.yml`(deploy `17748bf`)已改为兼容裸标量的 ` *[0-9]` / ` *10\.0\.0\.0/8` 并渲染到文件复用,三态实跑验证(正向绿 / 注入 CIDR 红 / 删注入行红)。**不要按字面片段「修回去」。**
注意 `paths:` 过滤器已含 `docker-compose.yml` 与 `.github/workflows/validate.yml`,无需改触发条件。**本地无法跑 GitHub Actions**,故本项的验证方式是:把 `run:` 块内容作为脚本在本地对 `docker compose --profile prod config` 实跑一遍(含覆盖分支与「故意改坏 → 应失败」的反向验证),并在报告里贴输出。反向验证必须做——只证明「当前通过」不证明守卫有牙(P11 mutation 抽查同理)。
---
## 4. 边界(不做的事)
- **不动 D4**(nav 逐字换行):外观级、无溢出、修法在 320px 与缺口 A 争空间,需独立设计决策。
- **不引入汉堡菜单 / 不改 nav 结构**:勘查已证伪其必要性。
- **不动 CSP / HSTS / TLS**:P11 已明确挂账为独立批(CSP 需设计——游玩子域经 iframe+SW 加载用户作品;HSTS 待 TLS)。
- **不动后端任何行为**:`maxDisplayNameLen=60` 是既有合法契约,本批不改校验规则(改短会破坏已有账号数据)。缺口 A 是**前端渲染**未防御合法输入,修在前端。
- **不改 `sr-only` 的 Tailwind 定义或 P9-B 的 `scope="col"` 结构**:D-B 的 `relative` 是在**包裹层**上补包含块,不动 sr-only 本身(它是全仓通用工具类,改它波及面不可控)。
- **不加 `overflow-x-hidden` 到 body/html**:那会把溢出**藏起来**而非修掉,且会裁掉 `sticky` 页头与 `shadow-hard`。缺口必须真消除(实测 `scrollWidth == clientWidth`),不是视觉遮盖。
- **不动 `AdminView.vue` 的三表内部结构**(列数、`data-testid`、按钮文案):只加包裹层。
---
## 5. 测试计划
### T1(AppHeader,缺口 A/E)
1. 既有 `app/components/AppHeader.test.ts` 六个测试全绿(它们用 `w.get('summary')` / `w.get('details')` / `w.get('details a')`,与 summary **内部**结构无关;`:23` 的 mock `display_name: 'tester'`)。
2. 新增单测:60 字 ASCII 名下 ① `<summary>` 有 `title` 属性且值为全名;② 名字 span 带 `truncate`、`▾` span 带 `aria-hidden="true"`;③ summary 的可访问名不含 `▾`。
3. e2e:`e2e/responsive.spec.ts`(见 T4)覆盖 320/375px × 60 字名 × 6 路由零溢出 + 菜单不逃逸。
### T2(三视图五表格,缺口 D)
1. 既有 `e2e/a11y.spec.ts:50/52` 的 `columnheader` 断言全绿(`headers` 计数仍 6、`nth(5)` 可访问名仍 `操作`)——`getByRole('columnheader')` 不受包裹层影响,须实跑确认。
2. 既有 `e2e/admin-flow.spec.ts` 全绿(`queue-sub-p1` 等 `data-testid` 定位不变)。
3. 新增源码级守卫 `app/lib/tableOverflow.test.ts`:扫 `app/**/*.vue`,对每个 `<table` 起始标签,向上取**同一文件文本内**最近的开标签父元素,断言其 `class` 含 `overflow-x-auto`;违规报 `文件:行号: 行内容`。沿用 `tableScope.test.ts` 的 `walk()`/`candidates()`/跨行起始标签截取范式。**不得**用全局 `overflow-x-auto` 计数断言(`DocSidebar.vue:25` 的 `<nav>` 是合法既存用例,基线 = 1)。
4. e2e:短数据下 `/admin/users`、`/admin`、`/admin/audit` 在 320/375px 零溢出,且表格**仍可横向滚动到**(断言包裹层 `scrollWidth > clientWidth` 时内容可达——修掉溢出不能靠藏内容)。
### T3(ResultMeta + AccountView,缺口 B/C)
1. 既有 `e2e/author-page.noauth.spec.ts:12` 的 `${count} 款作品` 断言全绿(只改类名,不动文本)。
2. 既有 `app/views/CatalogView.test.ts` 全绿。
3. e2e:`/games` @320px 零溢出;`/account` 在 60 字名下 320/375/768px 零溢出。(**归因更正**:spike 17 2×2 消融实测,768px 的 +40px 由缺口 A 页头驱动、非本项 dd——回退 dd @768 仍 `over=+0`;dd 修复的牙在 320/375px,回退 dd @320 → `scrollWidth=592`。故 768 腿钉的是**页头回归**,320/375 腿才是本项 dd 的牙口。)
### T4(机器守卫,D-H)
1. 新增 `e2e/responsive.spec.ts`,**必须进 CI 主腿**(`playwright.config.ts` `testIgnore: /noauth\.spec\.ts/` 不含它):
- 视口 320 / 375;名字形态 短名(`安`)/ 60 字 ASCII / 60 字 CJK;登录态 登出 / user / admin。
- 路由 `/`、`/games`、`/docs`、`/creator`、`/login`、`/register`、`/account`、`/admin/users`、`/admin`、`/admin/audit`(后四个需 admin seed + 最小 mock,沿用 `a11y.spec.ts:26` 与 `admin-flow.spec.ts:30-56` 的端点范式;表格由 `v-else` 门控,**mock 必须返回至少一条数据**否则 `<thead>` 不渲染、守卫空跑)。
- 每格断言 `document.documentElement.scrollWidth <= clientWidth + 1`。
- 另断言:60 字名下 `header details > div` 菜单 `right <= innerWidth`(缺口 E 钉桩);`<summary>` 的 `▾` 仍**可见**(防止将来有人用「整体 truncate」把 caret 吞掉——spike 15 实测那条路 `caret=HIDDEN`)。
- `seedSession` 的 `display_name` 硬编码为 `role`(`helpers.ts:37`),长名场景需**扩展可选参数**(`seedSession(page, role, displayName?)`,默认值保持 `role` 以免动既有 21 个 spec)或在 `responsive.spec.ts` 内自带 seed——二选一,实现者定,但**不得**改既有调用点的行为。
- 组合数控制:不必跑满 3×3×10 全矩阵(会拖慢 CI)。至少覆盖 {320,375} × {60字ASCII, 短名} × {全部 10 路由} + {375} × {60字CJK} × {`/`, `/account`, `/admin/users`}。总时长目标 ≤ 90s(参考:既有主 e2e 81 测试 1.1min)。
2. 守卫**必须有牙**:实现者须在本地临时 revert 任一修复(如把 `relative` 去掉、或把 `max-w-[6rem]` 去掉),确认 `responsive.spec.ts` / `tableOverflow.test.ts` 变红,再恢复;报告里贴红/绿两次输出。这是 P11 mutation 抽查纪律的落地。
### T5(打磨项)
1. (a) `router/index.test.ts` 全绿,且新断言不经 router(版本无关)。
2. (b) `GameReactions.test.ts` 新增两态可访问名断言;既有 `e2e/a11y.spec.ts:61` 不破(实跑确认)。
3. (c) `ToastHost.test.ts` 既有六处断言全绿 + 新增「关掉最新条不重念旧消息」;`__resetToasts()` 隔离实测有效。
4. (d) server `go test ./internal/config/ -run TestApplyLogging -v` 绿;`gofmt -l .` 空、`go vet ./...` 0。
5. (e) deploy 守卫脚本本地实跑三态:当前通过 / `TRUSTED_PROXIES=172.28.0.0/24` 注入 `.env` 时**失败** / 覆盖 `10.0.0.0/8` 时通过。四 profile `config -q` 仍全绿。
### 全量回归(合并前必跑)
- crearte:`npm run test`(vitest,基线 **626** + 本批新增)、`npm run typecheck`、`npm run build`、`npm run build:runtime`、`npm run e2e`(基线 **80+1skip** + 新增 responsive)、`npm run e2e:noauth`(基线 **4**)。**脚本名先查 `package.json`**——P11 曾把 `test`/`typecheck` 写成 `test:unit`/`type-check` 配 `--if-present` 导致假绿。
- server:容器化 `gofmt -l .` / `go vet ./...` / `go build ./...` / `go test ./... -count=1`(基线 11 包 ok)。
- deploy:四 profile `config -q` + 卷名集合与基线逐字相同。
- 管道后取退出码一律 `set -o pipefail` 或不管道(P11 三度踩坑)。
---
## 6. CHANGELOG 与版本
- `crearte`:**0.25.0**(Fixed 段记缺口 A/B/C/D/E 四缺陷 + 下拉菜单逃逸;Added 段记两层机器守卫;Changed 段记打磨三项 P9B-1/2/4)。
- `crearte-server`:**0.17.1**(Tests 段记 P11-N5 错误消息回显钉桩;纯测试,patch 级)。
- `crearte-deploy`:**0.7.1**(CI 段记 P11-N6 `TRUSTED_PROXIES` 空默认机器守卫;纯 workflow,patch 级)。
- wrapper:**0.3.5**(Done 段记 P12 交付;ROADMAP P12 行 + 文档索引表补 spec/plan 两行;同时把「移动端页头/汉堡菜单」挂账标注为**已证伪删除**、D4 标注为 **park 待设计决策**)。
条目格式沿用 AGENTS.md:同条目英文行紧跟中文行(无空行),不同条目间空行。
---
## 7. 证据存档
`crearte/.superpowers/sdd-p12/`(`.gitignore` 已含 `.superpowers/`,不入库):
- `FINDINGS-survey.md` — **16 轮 spike 的实测输出固化**:四轮基线审计与「移动端页头」证伪、D1a 发现与范围扩张(三渲染点)、D3 独立性证明(短数据)、修法候选对照(含两个实验缺陷:Q1 误抓 sr-only `<h2>`、Q2 被 D1 污染)、残差归因三轮(矛盾 → 逐层探测 → **烧蚀定案 sr-only span**)、H1/H2 机制验证 + H3 24 格全绿矩阵、D4 七候选全灭 → park、D1a 精确形态定案(Vue 单文本节点导致 V2–V4 失效 → flex F3 胜出)。每个数字都有出处。
- 本 spec §1/§2 的所有实测值均引自该文件;spike 源文件跑完即删(工作树 `porcelain=0` 已核),不入库。