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

33 KiB
Raw Blame History

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:

        <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(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 信任):

      - 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 已核),不入库。