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.
This commit is contained in:
2026-10-02 21:16:43 +08:00
parent 708f95eb3d
commit b9e59aee83
4 changed files with 430 additions and 2 deletions
@@ -0,0 +1,361 @@
# 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` 已核),不入库。