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.
33 KiB
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 下「作品」渲染为
作/品(linkh=45、navH=45vs 行高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:
<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> 及其内容):
<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 上移:
<!-- 改前 -->
<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)——仅包裹:
<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,唯一匹配):
<!-- 改前 --> <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 单行改动:
<!-- 改前 --> <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
当前弱断言:
await expect(router.push('/docs')).resolves.not.toThrow()
它依赖 vue-router 5.3.1「afterEach 抛错不被吞」的语义,升级后若改为吞抛则恒真(假绿)。改为直接单测被测函数本身,绕开 router 版本语义:
// 直接验证 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 星」无法预知「再点同一颗星会撤评」(该控件支持点当前分取消)。改为按状态动态:
: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:
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:
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 之后)补:
// 归一化后的错误消息须回显归一化值(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(serverfa39d27)已收紧为%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 信任):
- 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(deploy17748bf)已改为兼容裸标量的*[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)
- 既有
app/components/AppHeader.test.ts六个测试全绿(它们用w.get('summary')/w.get('details')/w.get('details a'),与 summary 内部结构无关;:23的 mockdisplay_name: 'tester')。 - 新增单测:60 字 ASCII 名下 ①
<summary>有title属性且值为全名;② 名字 span 带truncate、▾span 带aria-hidden="true";③ summary 的可访问名不含▾。 - e2e:
e2e/responsive.spec.ts(见 T4)覆盖 320/375px × 60 字名 × 6 路由零溢出 + 菜单不逃逸。
T2(三视图五表格,缺口 D)
- 既有
e2e/a11y.spec.ts:50/52的columnheader断言全绿(headers计数仍 6、nth(5)可访问名仍操作)——getByRole('columnheader')不受包裹层影响,须实跑确认。 - 既有
e2e/admin-flow.spec.ts全绿(queue-sub-p1等data-testid定位不变)。 - 新增源码级守卫
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)。 - e2e:短数据下
/admin/users、/admin、/admin/audit在 320/375px 零溢出,且表格仍可横向滚动到(断言包裹层scrollWidth > clientWidth时内容可达——修掉溢出不能靠藏内容)。
T3(ResultMeta + AccountView,缺口 B/C)
- 既有
e2e/author-page.noauth.spec.ts:12的${count} 款作品断言全绿(只改类名,不动文本)。 - 既有
app/views/CatalogView.test.ts全绿。 - 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)
- 新增
e2e/responsive.spec.ts,必须进 CI 主腿(playwright.config.tstestIgnore: /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)。
- 视口 320 / 375;名字形态 短名(
- 守卫必须有牙:实现者须在本地临时 revert 任一修复(如把
relative去掉、或把max-w-[6rem]去掉),确认responsive.spec.ts/tableOverflow.test.ts变红,再恢复;报告里贴红/绿两次输出。这是 P11 mutation 抽查纪律的落地。
T5(打磨项)
- (a)
router/index.test.ts全绿,且新断言不经 router(版本无关)。 - (b)
GameReactions.test.ts新增两态可访问名断言;既有e2e/a11y.spec.ts:61不破(实跑确认)。 - (c)
ToastHost.test.ts既有六处断言全绿 + 新增「关掉最新条不重念旧消息」;__resetToasts()隔离实测有效。 - (d) server
go test ./internal/config/ -run TestApplyLogging -v绿;gofmt -l .空、go vet ./...0。 - (e) deploy 守卫脚本本地实跑三态:当前通过 /
TRUSTED_PROXIES=172.28.0.0/24注入.env时失败 / 覆盖10.0.0.0/8时通过。四 profileconfig -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-N6TRUSTED_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已核),不入库。