状态:已接受(2026-09-11,批 0-D 落地) 关联:前端重设计规格(§4 / §5 / §8.6.1 / §10 / §11)· ADR-032 · ui-ux-system.md · 批 0-D 实施计划 · 批次台账
.superpowers/sdd/2026-09-11-frontend-redesign-batch0d-primitives/progress.md
ADR-032 交付了 token 层(色阶 / 字阶 / z-index 标尺 / 图标),但样式仍然只能逐文件改:实测 2,256 处内联 style={{、
仅 4 处 className、:hover / :active 在活代码里 0 处、:focus* / :disabled 各 0、aria-disabled 0、
禁用视觉降级全站 1 处、role="dialog" / aria-modal / createPortal 各 0、@keyframes 0 个、
prefers-reduced-motion 在 app/src 里 0 处(recon §2 / §3 / §8 / §10)。
后果有三条: ① 批 4 的迁移(20 弹层 / 44 空态 / 85 加载 / 196 错误行 / 4 toast / 2 confirm / 上千处内联样式)没有靶子 —— 无处可迁; ② 批 6 的动效(规格 §8「活的纸」)没有接缝 —— 单属性 120ms 的回执、进出场 200/160、可中断可反向,都需要一个 "下次只改一处"的挂载点,否则每加一个动效都要回头改每一处调用点; ③ 交互态与无障碍能力(键盘焦点、禁令、屏幕阅读器语义)在仓内没有先例,不建立即无从复用。
用户对动效纲领的答复是「我希望我在对其进行交互时,它是活的」(规格 §8.6.1)—— 它把重心放在用户动作的那一刻, 因此原语必须内置交互态,而不只是"长得对"。
新建子域 app/src/ui/primitives/,沿用 app/src/ui/icons/ 的「子域 + 单一 index.ts 导出面」范式。
规格 §1(L1 裁决 6)的 10 类原语中,9 类在本批落地(第 10 类 z-index 标尺已在批 0-A 交付,本批只消费):
| # | 原语 | 本批落地的文件(实测行数 · countLines() 口径) |
吸收的现状病灶 |
|---|---|---|---|
| 1 | Text |
Text.tsx 89 · Text.css 46 · Text.test.tsx 130 |
fontSize 1274 行/143 文件 · 弱化灰 256 行/100 文件 |
| 2 | Surface |
Surface.tsx 97 · Surface.css 73 · Surface.test.tsx 168 |
borderRadius 584 行/134 文件(4 值混用)· boxShadow 18 个不同字面值 |
| 3 | Button |
Button.tsx 132 · Button.css 92 · Button.test.tsx 286 |
<button> 510 行/121 文件全是内联样式 · 按钮样式常量 86 行/60 文件 |
| 4 | Modal |
Modal.tsx 188 · Modal.css 81 · Modal.test.tsx 296 · Modal.exit.test.tsx 91 |
28 个手写弹层(其中 11 个早退式卸载) |
| 5 | ConfirmDialog |
ConfirmDialog.tsx 192 · ConfirmDialog.css 58 · ConfirmDialog.test.tsx 297 |
23 处命令式确认(window.confirm 在 WebView2 下可能静默返回 false) |
| 6 | Toast |
Toast.tsx 257 · Toast.css 76 · Toast.test.tsx 249 · Toast.interrupt.test.tsx 167 · Toast.style.test.ts 132 · Toast.placement.test.tsx 123 |
4 套自绘 toast,全部只有进、没有出 |
| 7 | EmptyState |
EmptyState.tsx 170 · EmptyState.css 91 · EmptyState.test.tsx 276 · EmptyState.align.test.tsx 102 |
40 行/28 文件的「暂无…」灰字,5 套空态,首启无主行动按钮 |
| 8 | Loading / Skeleton / Probe |
Loading.tsx 105 · Loading.css 79 · Loading.test.tsx 238 |
85 处/30 文件的手写灰字,全站 0 骨架屏 |
| 9 | StatusLine |
StatusLine.tsx 94 · StatusLine.css 42 · StatusLine.test.tsx 267 |
196 处/76 文件,三种红并存,错误常在列表最底部 |
| — | 共享内核 | usePresence.ts 200 · usePresence.test.tsx 282 · usePresence.node.test.ts 71 · useFocusTrap.ts 122 · useFocusTrap.test.tsx 236 · ime.ts 18 · ime.test.ts 32 |
卸载时机 / 焦点陷阱 / IME 组合态 |
| — | 接缝与守卫 | motion.css 74 · index.ts 45 · style-seams.test.ts 287 · style-contract.test.ts 233 · motion-coverage.test.ts 147 |
动效变量与 reduced-motion 块 · 导出面 · 机器判据见 style-seams / style-contract / motion-coverage 三个测试文件 |
行数口径(唯一有效):
countLines(),即[System.IO.File]::ReadAllLines(path, UTF8).Count—— 含空行的全部行数(禁用Get-Content/Measure-Object -Line/ 数0x0A字节)。 权威来源:逐文件数值以机器生成的 行数豁免登记 为准(node scripts/line-limits.mjs --write生成、--full校验,不要手改其数字);本表只是人读摘要。 注:本表所列原语全部 ≤300 行,在登记表内属人工追加的记录块(--write只重算 >300 行的登记区)⇒ 两份不一致时按上句口径在 HEAD 重新实测,再同步两份。
导出面:primitives/index.ts 是唯一公共入口(批 4 之后全站从这里 import);组内互引用走相对文件路径
(./Text),不 import barrel —— 避免循环依赖。index.ts 另承担 import "./motion.css";:导入本层即带上
动效变量与 reduced-motion 块;深导入单个原语不会带走 reduced-motion 块(该边界写在 index.ts 的 @ai-context 里)。
本批全部 42 个文件逐个 ≤300 行(最大 297),无需行数豁免登记。
🔻 批 5 收口就地加注 · 导出面新增第 10 个原语
ViewSwitcher(C1 = C14①;2026-09-12,上面原文一字未改):
- 落点与形态:
app/src/ui/primitives/ViewSwitcher.tsx(135 行,预算 ≤150)+ViewSwitcher.css(63 行,预算 ≤80)+ViewSwitcher.test.tsx(225 行 / 14 passed);已进ui/primitives/index.tsbarrel(该文件 45 → 48 行,+2 导出 + 注释)并在style-contract.test.ts的枚举里(该文件 233 → 296 行;用例 6 → 10,只新增枚举块 + 它自己的判据,CONTRACTS的 12 条一字未动、删除行 = 0)。--dur-micro接缝:本原语覆盖 §8.6.1 第 3 条的响应层接缝(--ed-dur-micro= 120 ms),零行内style(W1–W3/W4 判据;W4 的形态按批 5 裁决 1 强化为「每段className逐字等于ed-btn ed-btn--segment」+「根/段无style属性」,理由是aria-pressed是状态的唯一权威,再加状态类会造出第二份权威)。⚠️ 上表「接缝与守卫」行的行数是批 0-D 时点值:index.ts45 → 48 ·style-contract.test.ts233 → 296(motion.css74 不变 ·style-seams.test.ts287 ·motion-coverage.test.ts147)。⇒ 该行按本加注读;本 ADR §1 的「9 类原语」口径不变(ViewSwitcher是第 10 个落地文件、属 §1 的「原语层第 10 类」延伸,不重排编号)。- 首屏账(诚实代价,见 §4 的批 5 加注):
ViewSwitcher进 barrel 后首屏 JS Δ = 0 B、CSS +604 B —— 计划 C14① 的「首屏 Δ 不是 0」被实测否证(机理:rollup 把它 tree-shake 进懒 chunk)。- 出处:批 5 计划 §收口回写 §四(C14 行)·
tmp/t18/measurements.md§9 C1。
🔻 批 6 收口就地加注 · 上表行数的「三副本同步」(R33.3 / R63.2;2026-09-13,上表与上面两段加注的原文一字未改):上表是同一张 42 文件表的三份副本之一(另两份 = 规格 §5 的表 + 行数豁免登记 的人工记录块),三份在批 6 收口时均已漂移。⇒ 本 ADR 不再抄第三份数值清单(那等于再造一份会漂的副本):权威读数 = 规格 §5 的批 6 加注(逐值 HEAD 实测 + 19 项漂移逐条)与豁免表人工块(T35 二次刷新)。本表只需记住 6 个关键差:
motion.css74 → 335 ·Text.css46 → 66 ·index.ts45 → 48 ·style-seams.test.ts287 → 299 ·style-contract.test.ts233 → 296 ·motion-coverage.test.ts147 → 186;42 个文件全部 ≤300 对源文件成立(最大 299),唯一超 300 的是motion.css335(.css不在行数门禁的SOURCE_EXT视野内)。⚠️ 口径缺口:三处副本的<!-- line-count-src … -->锚点全仓 0 个读者(「门禁按files=重测」今日无实现)⇒ 同步只能靠人工。
这是规格 §7.1 四部件依赖方向在原语层上的落法,也是本 ADR 的核心约束:
- 原语不得 import
components/·pages/·hooks/里的任何东西(本批实测:0 处违反)。 - 原语之间只有单向组合:
EmptyState→Button+Text+icons;Modal→Button+Text+useFocusTrap+usePresence;ConfirmDialog→Modal;Toast→Button+Text+usePresence;StatusLine→Text。 叶子原语不知道自己被谁组合 —— 故ConfirmDialog复用Modal的容器/层叠/进出场/焦点陷阱/ESC,自己只加 「初始焦点重定向到取消」这一件事(17 行,不是第二套陷阱)。 - 反向依赖的具体危害:一旦原语 import 了某个业务组件,批 4 的迁移就会把业务耦合拖进 L1,且"换实现只改一个文件"失效。
- 颜色与尺寸一律经 CSS 变量消费 token;
primitives/**/*.css内零颜色字面量(色值只允许出现在app/scripts/gen-tokens.mjs与产物app/src/ui/tokens.css)。 - z-index 一律走 TS 标尺
zIndex("modal" | "modalNested" | "toast"),不写裸数字 (规格 §4.2①:标尺是 TS 模块而不是 CSS 变量,写成变量会重新打开「谁都能随手写个数字」的口子)。 - 陷阱记录在案:
varRef()/cssVar()的入参类型是ColorTokenName(16 个颜色名的字面量联合), 阴影与字阶变量没有门面函数(varRef("shadow-2")编译期报错)⇒ 它们只在 CSS 里以var()消费, 本批不改tokens.ts的门面签名。 - 三条机器判据(
style-seams.test.ts):零颜色字面量(先剥注释)· 动效位移 ≤ 8px · CSS 类 ↔ 规则一致。
:hover / :active / :focus-visible / @keyframes / @media (prefers-reduced-motion) 都无法内联表达 ——
而 §8.6.1 的四条硬约束恰恰要求这些能力。故本批全站首次大规模使用 className(改造前 4 处),
类名规约:.ed-<原语> 基类 + .ed-<原语>--<档> 修饰类 + .ed-<原语>-<部位> / .ed-<原语>__<部位> 子元素类。
style 仍是纯透传(供调用点布局与批 6 的动效接缝使用),但视觉权威在类:
调用点若用 style 覆盖四态的底色/位移,等于把"一处改对所有地方"重新打散。
行内 style 覆盖 CSS 类(motion.css 文件头 ⑤ 记有同一句):批 6 若用 style 直驱属性值,
类上声明的 transition 仍然在(过渡由类规则声明),但终值以 style 为准;反之要让类规则重新当家,
必须先把 style 摘掉。
本批建立的交互态能力(改造前均为 0)::hover 三档各异 · :active 按下微陷 1px · :focus-visible 2px 焦点环
(首个落在 Surface.css,Button.css 是第二个)· :disabled 与 [aria-disabled="true"] 的统一视觉降级 ·
cursor: not-allowed · :disabled 移出 Tab 序而 busy 保留焦点(aria-disabled + aria-busy)。
⚠️ 批 4 收口就地加注 · §4 的「行内 style 禁令」自何时起有真实输入(2026-09-12;上面原文一字未改):
- 本条禁令(「调用点不得用行内
style覆盖类的底/圆角/边框」)在批 0-D 落笔时对真实代码是空真的 —— 当时全仓<Surface>0 个。批 4 的69489f9f(refactor(ui): migrate card borders to surface)第一次真正使用Surface:14 个<Surface>开标签,机器判据(surfaceRatchet.test.ts⑦)实测覆盖命中 0;同一提交把FROZEN_SURFACE_TAG_TOTAL由 0 → 14(T17-A 冻结的「零」是故意的空真登记,一迁移就会红)。⇒ 本条禁令自69489f9f起有真实输入。- 已知边界(诚实登记,不得读成「判据完备」):⑦ 的文本级启发式只认
style={{ … }}字面对象 ⇒style={S}/style={pick()}这类间接写法是假阴(写在overridesSurface()的文档注释里)。批 4 迁移的 14 处全是直接形态,故对本批读数无影响 —— 但下游若用变量间接给style,棘轮会漏。- 同源的两条原语缺口(批 4 实测,登记为 follow-up 而非本批动作):①
SurfaceProps无 DOM 属性透传(onClick/id/aria-*/data-*/dangerouslySetInnerHTML/ref)⇒ 至少挡住 3 处已点名 + 未来 21 处;②Surface基类必出底色、无「只出边框」档 ⇒ 挡住 28 处透明边框容器。两条都属「批 5 与视图层一并裁」(B22 第 3 条)。🔻 批 5 收口就地加注 · 类名命名空间(C2 = R-2)与上面两条
Surface缺口的去向(D12)(2026-09-12,上面全部原文与加注一字未改):
- R-2:
ViewSwitcher复用既有ed-btn基座命名空间 —— 容器.ed-btn-group+ 段.ed-btn--segment(段控件本质是成组的按钮)。这不是随手取名,是「既有断言 0 改动」的关键:若另立.ed-switcher*命名空间,motion-coverage.test.ts与style-seams.test.ts的既有计数/名单会需要改动;复用ed-btn后,批 5 对既有断言的改动数保持 0(唯一例外是style-contract.test.ts的只增枚举块)。- 诚实代价(如实登记,不许省略):语义折中 —— 一个「视图切换器」的类名落在按钮基座命名空间里;读到
.ed-btn--segment的人会先以为是Button的一个档位。换来的收益:Button的variant枚举与Button.css零改动(C16 登记 2 的条件④逐字要求「若实现中发现必须改Button的variant枚举或Button.css语义 ⇒ STOP 报控制方」—— 实际未触发)。且 §4 的「行内 style 禁令」在本原语上是真的:零行内style(判据 W4 +style-contract②)。- 产物级前后对照锚(可复跑):字面量
ed-btn--segment在批 5 前的 dist 里 0 命中(T1 台账逐字NOT-FOUND,tmp/t1/lazy-chunks.md:168)⇒ 批 5 后命中registry-C-RvXIfE.js(懒 chunk) ⇒ 该锚已从 0 变为命中 ✅。- 上面两条
Surface缺口(① DOM 属性透传 +3 处 · ② 透明容器 +28 处)→ 两条都转批 7(D12 逐字)。理由:两条都是原语能力扩展(与本 ADR §1 的「只加属性级槽」同族,B16 先例),批 5 已有一个原语(ViewSwitcher)要新建;且 ② 若接受,surfaceRatchet的两条冻结值必须抬高(「只许降」的棘轮倒退 ⇒ 需要一次显式裁决)。读数:+3(DOM 属性透传解锁的已点名处数)·+28(透明边框容器处数,来自task-4-report.md:208-212的分桶:边框 240 → 整圈 172 = 控件 96 · NM14 14 · 锚定菜单 4 · 交互 4 · 条件底色 4 · 透明容器 28 · 有底色容器 22)。- 出处:批 5 计划 §收口回写 §五/§七 ·
tmp/t18/measurements.md§9 C2 与 D6。
- 相位协议:
enter(已挂载的起点态,等一帧让过渡起步)→entered(终态)→exit(退场中), 由消费方写到[data-phase]上,Modal/ConfirmDialog/Toast共用同一协议。 - 卸载时机归
usePresence:transitionend(只认本节点自身的过渡)+ 超时兜底(exitMs + 80ms)—— reduced-motion 下永远不会有transitionend,只等事件就是"关不掉的弹层";matchMedia自带守卫 (vitest 全局 node 环境与 jsdom 30 都没有window.matchMedia)。可中断可反向:退场途中open回true⇒ 清计时器、直回"entered"(不重放进场)。 - 时长变量名与数值(
motion.css定义一次,消费方写var(--ed-dur-x, <同值字面量>)):--ed-dur-micro 120ms·--ed-dur-overlay-in 200ms/--ed-dur-overlay-out 160ms(出场比进场快)·--ed-dur-toast-in 180ms/--ed-dur-toast-out 140ms·--ed-dur-skeleton 1200ms·--ed-dur-card 220ms·--ed-dur-reveal 500ms·--ed-dur-page 150ms·--ed-ease cubic-bezier(0.2,0,0,1)。 - ★ 批 6 的接管机制(本批存在的理由):
--ed-dur-*/--ed-ease的真源属批 6(0-A 计划自审已推迟)。 本批把它们按规格 §8.4 的名字与数值先落在motion.css一处,所有引用点都写成var(--名, 同值字面量)⇒ 批 6 落地真源时整块删除即可生效,无需改任何规则; 同理@media (prefers-reduced-motion)只写这一处(motion.css),新原语只要根类进名单就自动被覆盖。 - ★ 三方同值纪律(T9 评审 I-3 的通用化):JS 侧的兜底窗口(
usePresence的exitMs,写在组件的const EXIT_MS)与 CSS 侧的过渡时长必须是同一个数,且与motion.css的 token 定值相等 —— 漂移时 不会有任何报错:JS 早了 = 过渡被截断(看不见出场),JS 晚了 = 节点多残留一截(那段时间还能被点到)。 机器判据:style-contract.test.ts的「退场时长三方对拍」(Modal.tsx+Modal.css/Toast.tsx+Toast.css各一行)。 - reduced-motion 判据 = 基类名单 + 动画落点名单,不是"全类集合 ⊇":
.ed-modal-head/body/foot、.ed-confirm-seal/-impacts/-keep、.ed-empty__title、.ed-toast-action这类子元素类与基类同在一个元素上,已被同一条规则覆盖(逐字枚举只会假红); 但animation声明的选择器原文必须逐字进名单(含伪元素) ——animation-duration/animation-iteration-count不是可继承属性,T11 评审用 headless Chromium(--force-prefers-reduced-motion)实测坐实:.ed-skeleton元素是0.001s / 1,而.ed-skeleton::after仍是1.2s / infinite / ed-skeleton-shimmer⇒ 计划验收「让两者都静止」 当时未达成。裁决:名单里补.ed-skeleton::after(伪元素)与.ed-empty-enter(钩子类),理由是这一块是 "让一切都静止"的唯一收口处,漏掉伪元素是系统性洞(未来任何原语在伪元素上做动画都会再踩);把微光挪到元素自身 只能修这一个实例。判据落在motion-coverage.test.ts(抽取primitives/*.css里每个animation/animation-name声明的选择器原文,逐个要求出现在名单里;反向再断言名单无死条目)。 - 分桶裁定(控制方 2026-09-11,回应 T12 评审 I-2):
StatusLine的接缝是一次性浮现transition(无循环动画), keyframes 桶只留Loading/Skeleton/Probe(骨架微光 / 探针)—— 依据 §8.6.1 第 1 条「环境层永远不抢注意力」: 状态行是信息不是「呼吸物」,循环动效会让它在长列表里变成噪音;"闲置时也有生命感"由Probe承担。 规格 §8.6.1 第 3 条已按此改写。 - 本批
@keyframes的先后(口径订正):本批第一个@keyframes是EmptyState.css的ed-empty-in(提交0e5e78ab,00:06:10),Loading.css的ed-skeleton-shimmer/ed-probe-swing在其后(174b1893,00:06:41)。Loading.css原写的「全仓第一个@keyframes」与事实相反,已由 T11 在44de78d5就地订正 (该文件现写明不宣称任何"首个/第一")—— 此处保留正确口径,避免后续批次再据错误断言推理。
点击是即时的,而退场是异步的:open=false 后弹层仍挂载 160ms(Modal 的 Modal.css;Toast 140ms),
那段时间里面板还在屏上 —— 用户"手一抖"的第二次点击会真的落到按钮上。批 4 迁移 28 个手写弹层后,
这正是删除 / 级联删除的误触面。
契约(T7 落地,83710e27):
.ed-modal-overlay[data-phase="exit"]与.ed-modal[data-phase="exit"]各加pointer-events: none(只加在退场相位,进场相位不动,也不用!important);ConfirmDialog侧另有两条文件内缓解(T8,ef240816):两颗按钮busy={busy || !open}(busy已实测拦下 click,且不设原生disabled⇒ 退场期焦点与 Tab 序稳定),以及Modal的关闭意图 (遮罩 / 头部关闭钮 —— 那条路busy到不了)按open门控。ESC 无需处理:Modal的 ESC 监听本就以open为门控。- 这是原语层的系统级修法:一次覆盖所有 Modal 消费者(含批 4 迁移的 28 个)。
批 4 的验收线是 role="dialog" 20/20(规格 §11 第 2 条)。原语层把三条改造前各 0 命中的契约从 0 变成 1:
createPortal(挂 document.body,含 typeof document === "undefined" 早退守卫)· role="dialog" + aria-modal +
aria-labelledby(指向 title 的节点文本,不硬编码 useId)· 焦点陷阱 + 打开聚焦首元素 + 关闭归还焦点。
新增契约(本批实测得出,批 4 必须遵守):Modal 是唯一持有 Portal、焦点陷阱与 ESC 栈的实现,
消费者不得自建第二套 —— ConfirmDialog 已按此改写(无 createPortal / 无 zIndex / 无文档级键盘监听,
有源码与 CSS 扫描断言钉住),批 4 的 28 个手写弹层迁移后同样不得保留自己的遮罩与 window ESC 监听。
⚠️ 批 4 收口就地加注 · 本条契约的「适用对象」与三口径并列(2026-09-12;上面这段原文一字未改):
- 适用对象 = 对话框类(
role="dialog"那一类),不是「一切手写浮层」。 上句「批 4 的 28 个手写弹层」是本 ADR 落笔时(批 0-D / 批 4 开工前)的合计口径;批 4 的实际执行按控制方 B1/B2 分成了「迁」与「不迁」两支:
- 迁(验收口径 = 20):
role="dialog"20/20 —— 20 个文件全部只经原语(barrel 含Modal∧ 无深导入 ∧ 无自建遮罩 ∧ 无keydown∧ 无裸 z-index),role="dialog"的源码命中只出现在ui/primitives/Modal.tsx与已登记例外;构成A14 + B4 + E2钉在dialogMigration.e.test.ts的DIALOG_20。- 不迁(14):11 个锚定菜单(
BrowserChrome·GroupRowContextMenu·GroupSidebarRow·NoteHeaderActions·NoteListBatchMenu·NoteMoveToGroupMenu·NoteRowContextMenu·RouteInfoPopover·SessionRowContextMenu·chat/ChatLaunchMenu·note-selection/SelectionActionMenu)只落popover(200);3 个采集/预览覆盖层(CaptureOverlayPanel·ImagePreviewOverlay·ScreenSelectOverlay)保留裸值并逐条带理由。这 14 个仍须登记在案(不许从账本里消失),且不得 import 原语层(两条硬守卫:dialogMigration.e.test.ts② +buttonMigration.test.ts④)。- 🔻 批 4 收口后更正(收口评审 I-7,2026-09-12;上句原文一字未改):上句列出的「11 个锚定菜单」里有 3 个不是
NON_MIGRATED_14的成员 ——BrowserChrome·GroupSidebarRow·NoteHeaderActions(三者同时不在ADR033_28与CROSS_LINE_34;BrowserChrome在.e里只作TABLE4_ALIAS,且有一条机器断言CROSS_LINE_34.includes(TABLE4_ALIAS) === false逐字写着「别名不是成员」);同时缺席 3 个真成员 ——NoteEditView·NoteLinkToSystem·RichEditorView(三者均非*Overlay,属那 11 的锚定/透明层)。机器真源(dialogMigration.e.test.ts:115-122的NON_MIGRATED_14去掉 3 个*Overlay后逐字):GroupRowContextMenu·NoteEditView·NoteLinkToSystem·NoteListBatchMenu·NoteMoveToGroupMenu·NoteRowContextMenu·RichEditorView·RouteInfoPopover·SessionRowContextMenu·chat/ChatLaunchMenu·note-selection/SelectionActionMenu。复现:node .superpowers/sdd/2026-09-12-frontend-redesign-batch4-primitives/tmp/docfix/d4-membership.mjs。规格 §11-2 例外表已同步同一更正(含逐行成员资格表)。「3 个采集/预览覆盖层」与「14 条总数 = 34 − 20」不受影响。- 三口径并列(互不替代):20(本 ADR 上文「20/20」的验收口径 = 对话框类)· 28(本 ADR 这句的同行口径)· 34(跨行容忍口径:把
position:fixed与inset:0分写两行的形态也算进来)。等式:34 − 20 = 14 = 11 锚定菜单 + 3 覆盖层;28 ⊆ 34 ∧ |28| = 28 ∧ 28 == 34 − 6(机器判据在dialogMigration.e.test.ts)。- 本条的 ESC 残留纪律仍然有效且已结清登记:上句「5 处属批 4 迁移时必须一并处理的残留」——批 4 的处置是逐条登记、不修(B1:改它们 = 改菜单退出语义,属批 5/8):
note-selection/SelectionActionMenu.tsx:78的window捕获相监听 + 4 处元素级onKeyDown(GroupSidebarRow/LinkEntityPicker/SessionDetailHeader/SessionListRow)。⇒ 本 ADR 的「必须一并处理」应读作「必须一并登记并给出不修的理由」,不是「批 4 必须改掉它们」。- §7 的 z-index 面:批 4 已完成整段迁移 —— 裸数字 58 处 / 43 文件 / 17 值 → 3 处 / 3 文件 / 3 值(全部是上述 B2 例外);六档标尺 6 个值(
raised 10/panel 100/popover 200/modal 300/modalNested 400/toast 500);有意值收敛(50/51/60/999/1000/1100/1150 → modal(300)·30–61 → popover(200)·900 → panel(100))是可见观感变化,已在规格 §11-2 与docs/versions/v0.22.md批 4 节 durable 登记。
两条实现口径(踩过坑,勿再犯):
- ESC 的"最内层"判定必须用 React 树深度(
ModalDepthContext):实测朴素入栈序(React effect 自底向上) 与 DOM 序(portal 插入序会反转)都会把外层当成栈顶;最内层stopPropagation()只能拦住document/window的冒泡相监听(仓内 15 处),拦不住SelectionActionMenu.tsx的window捕获相与 4 处元素级 ReactonKeyDown—— 这 5 处属批 4 迁移时必须一并处理的残留。 - 遮罩点击走
mousedown+target === currentTarget(用click会误伤"面板内按下、遮罩上松开")。
ime.ts(isImeComposing:isComposing 或 keyCode === 229)本批只建不接 —— 需要 IME 守卫的是调用点的
「Enter 提交」(规格 §5.2 第三条),批 4/5 在每个提交处消费它。
--ed-shadow-1/2定值(改造前 38 文件各写一份、18 个互不相同的boxShadow值):- 亮档低层
--ed-shadow-1=0 1px 2px rgba(28,25,23,.06), 0 4px 12px rgba(28,25,23,.08)(菜单 / 浮层 / 小卡) - 亮档高层
--ed-shadow-2=0 2px 4px rgba(28,25,23,.06), 0 12px 32px rgba(28,25,23,.14)(Modal / 浮窗) - 暗档不用投影,改白色反相描边
0 0 0 1px rgba(255,255,255,.06)(沿用ui-ux-system.md既有做法: 暗底上的黑投影不可见,描边才是"抬高"的可读信号) - 命名关系:
ui-ux-system.md原有的--ed-shadow-card被--ed-shadow-1取代(该 token 实测 0 处代码引用, 故不留兼容别名,只改文档)。
- 亮档低层
- 18 个字阶 CSS 变量(6 档 ×
size/line/weight)交付为--ed-type-<n>-{size,line,weight}(n自 1 起); 第 3 档的无单位行高1.9统一为29.5px(15.5 × 1.9 = 29.45,取一位小数;无单位与 px 并存会让 CSS 无法统一消费)。 ui/tokens.css入口接线(app/src/main.tsx:5的import "./ui/tokens.css";):此前tokens.css从未被 import (实测全仓只有note-mark.css被入口引入)⇒ 不接线的话,本批原语里每一处var(--ed-shadow-2)/var(--ed-dur-micro)在运行时根本没有值(未定义变量不报错),原语等于没生效。该文件只定义:root/[data-theme="dark"]的自定义属性、 无任何选择器 ⇒ 零视觉变化。防回归断言落在tokens.drift.test.ts。--due亮档第二次对比度修正:#A05F10→#9F5E10。 触发底是剪报底纹(--ed-mark-clip #F4F1E9)这第三种底:#A05F10在其上仅 4.4950:1,低于正文 4.5:1 线。 求解规则 = 保持色相、RGB 三通道按同一系数f缩放(hex(round(ch × f))),取满足「① 纸#FBFAF8≥4.5 且 ② 面#FFFFFF≥4.5 且 ③ 剪报底#F4F1E9≥4.55」的最小加深量 ⇒f = 0.994。 三底实测(WCAG 2.1):剪报底 4.5571 / 纸 4.9309 / 面 5.1436;暗档#E0A44B不动(剪报底 7.4863 本就达标)。 余量 0.05 是硬要求:#A05F10正是"卡在 4.4950"的反例 —— hex 量化与将来底色微调都会吃掉没有余量的值。 旧值#A05F10在剪报底上不达标的反例守门落在app/src/ui/contrast.test.ts。 (ADR-032 的「后果」一节记的是第一次修正#B26A12 → #A05F10,属决策历史,刻意不改。)--ed-ink-4禁止用于剪报底纹(不新增前景 token):剪报底纹只改背景,文字仍用所在层级的--ed-ink-*; §4.3 的四档阈值是按阅读面定的,换底就必须逐对实测 ——ink-4在剪报底上亮档 2.8489 / 暗档 3.0480, 连 3:1 的过渡态例外都不满足(它的例外只在阅读面上成立:面对 3.22:1)。 ⇒ 规则(写死):剪报底纹上只用ink-3及更深(ink-3/ink-2/ink-1)。 反例守门:contrast.test.ts(toBeLessThan(3))+ui-ux-system.md的同款条款 + 本 ADR。- 危险/错误语义 = 不新增 token:
--ed-stamp是全站唯一非中性色,规格 §4.1 原文写明「绝不用于按钮」⇒StatusLine的error用--ed-stamp作文字色(warn → --ed-due·ok → --ed-ok·info → --ed-ink-3); 该原语不渲染任何按钮(action是纯插槽),于是"不能有按钮底色"成为结构保证而非纪律;ConfirmDialog的破坏性确认按钮保持中性(variant="secondary",没有danger变体), 危险信号由印章标记(该 token 只作color+border)+ 级联影响清单承载;- 反例守卫(
style-seams.test.ts):primitives/**/*.css里凡属性名以background开头且值含var(--ed-stamp)即失败;另断言Button.css内该 token 出现 0 次(含注释 —— 按钮连这个名字都不写)。
本批只交付"被迁移的靶子":20 弹层 / 44 空态 / 85 加载 / 196 错误行 / 4 toast / 2 confirm / 上千处内联 style
的替换全属批 4(规格 §10)。界面外观零变化是设计意图(规格 §10「批 0–1 期间界面几乎不变」),
故类规则只作用于 .ed-* 元素,而现存组件没有这些类。
z-index 的迁移纪律(0-A 交接第 3 条):必须按叠放段整段推进,不许零散替换 —— 叠放顺序是渲染的, 而不是被设计的;段内混用标尺值与裸数字会让"谁在上面"变得不可推理。
- 正面:批 4 有了迁移靶子,批 6 有了动效接缝(
[data-phase]三态 + 一套时长变量 + 一处 reduced-motion 块); 三条全仓新契约(createPortal/role="dialog"+aria-modal/ 焦点陷阱与归还)从 0 变成 1; 交互态从零基础建立;样式首次被机器判据守住:style-seams/style-contract/motion-coverage三个测试文件(见下"合规性验证")。此处不写判据条数 —— 条数在历次修订中漂移过(同一份 ADR 内曾同时 出现三个数字),写死会让后续批次误以为还有判据没落地;以三个守卫文件的断言为准。 - 负面 / 代价:
① 迁移期会出现「内联
style+ 新原语类」两种写法并存的窗口(与 token 迁移期同性质); ② 原语层是第三层,组件树多了一层间接(换来的是"一处改对所有地方"); ③motion.css的时长变量块是临时真源,批 6 必须整块删除并并入app/scripts/gen-tokens.mjs(已登记)。 - 风险:
①
ink-4的 3:1 例外被误用(§4.3 三条规则 + 剪报底禁令 + 反例守门兜底); ② reduced-motion 名单漏项(由"基类名单双向一致"判据兜底); ③busy态的焦点环被同规则的opacity: .55衰减到 55% —— 本批唯一"视觉降级压过无障碍"处, 已登记给批 6 一并处理; ④ConfirmDialogProps无tier⇒ 弹层内再弹拿不到modalNested(登记为批 4 观察项)。 ✅ 批 4 Task 2 已结清(2026-09-12;就地加注,上面的原决策文字保留):实测≥3 处结构性嵌套 (InterviewDialog → GoalPlanApprovalDialog·NoteAiDialog → RefineLaunchDialog/RefineWorkbench·RouteInfoPopover → GroupDeleteConfirm/ModelCardCreateDialog)⇒ 按 B6 阈值「≥3 共用 ⇒ 改原语」加ConfirmDialogProps.tier?: ModalTier(默认"modal")并透传给Modal;不新增档位 (ModalTier仍两档 ⇒style-contract的全枚举表不动)。判据 =ConfirmDialog.tier.test.tsx(7 条)。⚠️ 依据更正(批 4 T11,2026-09-12;上一段那半句「按 B6 阈值 ≥3」原文保留,但不再作为依据): 那半句是错的 —— 上面那三对「结构性嵌套」全是自绘弹层、不含<ConfirmDialog>;实测 「弹层内再弹的ConfirmDialog消费者」= 0 个(T11 落地前全仓ui/primitivesimport 数 = 0 ⇒tier无任何消费者;T11 的 8 处迁移全在页面/面板顶层,也不传tier)⇒ ≥3 主判据不成立。 真实依据 = B6 的特殊条款(原文结构上无法表达):本缺口的「改调用点」一支不可表达 (调用点拿不到modalNested)⇒ 走例外通道加一个具名、有文档的 prop。 代码不变(标准透传、默认"modal"、不新增档位、不碰style-contract全枚举、未违反 §4); 与 ⑤ 的阈值判据的关系:特殊条款是 ≥3 之外的唯一例外通道,本行即该通道的实例。 同批同源缺口:Modal的 body 滚动锁(20 个弹层共用 ⇒ 改原语)= 引用计数 + 原值快照、 门控presence.mounted(不是open—— 退场 160ms 内面板还在屏上);判据 =Modal.scroll-lock.test.tsx(6 条)。⚠️ 该观察项的另一半「未做:body 滚动锁」逐字住在规格docs/superpowers/specs/2026-09-11-frontend-redesign-design.md:274,ADR 正文无此句 ⇒ 只在本行结清并指向规格。 ⑤ 原语「结构上表达不了」的两处能力缺口已在批 4 Task 3 按 B6 特殊条款结清(2026-09-12, 实施单元 T3;本行是就地加注,上面的原决策文字一律保留):Toast的位置:App.tsx的 AI toast 要贴导航条下方(top: calc(var(--ed-nav-h) + 8px)), 而--ed-nav-h是壳层 token(ui/tokens.css)—— L1 原语结构上读不到它(§2 依赖方向禁止反向)。 ⇒ 加一个具名档位ToastProps.placement: "viewport" | "belowNav"(默认"viewport"= 基类形态), 实现走类.ed-toast--below-nav(Toast.css,top消费var(--ed-nav-h, 56px)+bottom: auto), 绝不用行内style覆盖类语义(§4 逐字禁止)。EmptyState的对齐:44 处空态(33 文件)里的内联排版在原语里无处安放(本组件边界① 逐字 「不加className/style」)⇒ 加一个受控排版档位EmptyStateProps.align: "center" | "start"(默认"center"= 基类形态),实现走类.ed-empty--start(align-items+text-align)。 不放开裸className——fontSize/墨度仍归Text字阶、空气仍归compact。- 阈值判据(批 4 起沿用):同一缺口 ≥3 个调用点共用 ⇒ 改原语;<3 ⇒ 改调用点;
「原语结构上无法表达」是唯一的例外通道,且例外只许加一个具名、有文档的 prop。
两处判据分别落在
Toast.placement.test.tsx/EmptyState.align.test.tsx(含"不许行内style" 与"不许写死数值"的反例样本),取值联合 ↔ CSS 类的全枚举锚在style-contract.test.ts。 - 已知未覆盖面(诚实登记):
belowNav档只定纵向锚点,从App.tsx今日的right: 16迁过来 仍有 2px 横向差异(§10 已承认「观感从批 4 开始变」);存量 44 处空态的fontSize(实测 35 处) 与内联灰(38 处)不在这两档的能力内,由Text/compact吸收,逐处迁移时若仍表达不了 ⇒ 按上面 的阈值判据登记后再议(不许就地加第三个口子)。
- 原语扁平直放
app/src/ui/:否决。9 类 × 3 文件会把现为「5 个扁平模块 + 1 个子目录」的ui/冲垮, 且失去icons/已证明的单一导出面。 - 交互态用「
onMouseEnter+ 局部 state」:否决。那正是现状 13 个文件的写法,会让批 6 被迫逐处改, 且写不出:focus-visible与@keyframes。 - 一个
primitives.css承载全部原语样式:否决。会让 T3–T12 串行改写同一文件,与「文件随职责走」和并行纪律冲突。 - 字阶一次性改名为语义档(
--ed-text-body等):否决。控制方裁决要求数值命名法(--ed-type-<n>-*), 与--ed-ink-1..4/--ed-space-*一致。 - 为危险/错误语义新增 token:否决。现有色阶已够用,新增 token 会把"哪一种红才算错"的问题重新打开。
- 本批连调用点一起迁移:否决。迁移是 20+44+85+196 处的大面,与"外观零变化"的批次纪律冲突,且会掩盖原语自身的缺陷。
| 断言 | 落点 |
|---|---|
| 原语 CSS 零颜色字面量(剥注释后判,注释里的旧色值不算犯规) | style-seams.test.ts「守卫①」 |
--ed-stamp 不作底色 + Button.css 内计数为 0 |
style-seams.test.ts「守卫②」(含口径锚:印章仍以 color/border 消费它) |
reduced-motion 覆盖率:基类名单逐一覆盖 + 动画落点(含伪元素)逐字覆盖 + 名单无死条目 + 5 个非原语 .ed-* 名排除 |
motion-coverage.test.ts |
每个 primitives/*.css 被其宿主模块 import(删掉那行 import 时其余用例仍全绿 —— T3 评审实测的洞) |
style-contract.test.ts |
13 个取值联合 ↔ CSS 类规则的全枚举锚(Record<Union, …> 编译期双向 + 档数运行期冗余;ToastPlacement / EmptyStateAlign 由批 4 Task 3 补入,原 11 = 10 联合 + PresencePhase 三态) |
style-contract.test.ts |
退场时长三方对拍:EXIT_MS == 组件 CSS 的 var(--ed-dur-*, <n>ms) 兜底 == motion.css 定值 |
style-contract.test.ts |
| 位移 ≤ 8px · token 兜底值 == 真源 · 类名 ↔ 规则 | style-seams.test.ts(T3/T4 既有节) |
ui/tokens.css 入口接线未被回退 |
tokens.drift.test.ts |
| 裸数字 z-index 只许减少 | ui/zIndex.guard.test.ts |
- ADR-034 = L2 壳层与列契约(按规格 §10 推定为批 3 落地时)。
- ADR-035 = L4 动效纲领与引擎(推定为批 6 接入 GSAP 时)。
- ADR-010 修订为退役 —— ✅ 批 1 已落(2026-09-11 裁决 / 2026-09-12 落地),见其「退役修订」节(规格 §10 批 1 明写)。
⚠️ 以上三条归属是计划者按批次内容推定,控制方若另有安排以控制方为准。docs/tech-debt/的 TD-2026-08-31-C(App.css死样式)由批 0-D Task 13 实质闭环(该目录未入库,故只在此记录闭环关系); TD-2026-09-11-AG(structuredBlocks.ts整模块无生产调用方)仍 open,属批 1/4。- 动效 token(
--ed-dur-*/--ed-ease)的真源、GSAP、四层动效与三档强度:批 6。
🔻 批 5 收口就地加注 · 「视图层契约」在批 5 的落地与未做项(C3 = C4 = D4;2026-09-12,上面原文一字未改):
- 本 ADR 标题里的「视图层契约」在批 5 第一次有真实载体:
app/src/views/registry.ts(ViewSpec+load+viewsFor,109 行)·app/src/views/useViewMemory.ts(70 行,顶格)· 会话 5 视图 + 笔记 2 视图 · 两个宿主(session-detail/SessionViewHost.tsx136 行 /notes/NotesReadingColumn.tsx290 行)· 守卫views/architecture.guard.test.ts(260 行 / 17 用例)+views/architecture.slots.test.ts(100 行 / 2 用例)。§2 的依赖方向由 A1/A2/A3 三组判据守住(views/**首屏可达 0 · 注册表运行时导入者恰 2 ·views/**对@tauri-apps裸 0/声明级 0,阳性对照双侧命中)。- 编辑态切视图的
flushSave契约(本 ADR §1 的StatusLine与 §7 的「不用第二套机制」同向):catch⇒ 不切视图 + 保持编辑态 + 就近渲染一行StatusLine kind="error"(不用 toast —— 它已登记belowNav堆叠缺陷,且就近提示role="alert"可测)。判据 F1–F4(NotesReadingColumn.views.test.tsx11/11 passed),F4 断言「阻断全过程 0 个 toast 节点」并证明选择器有牙;反向对照 T14-M1(把catch改成继续切换)⇒ 唯一红 = F1。- 显式登记未做(两条,各自带归属):① 不改接口 ——
flushSave的返回类型未升级为Promise<boolean>(NoteEditHandle原样)⇒ 批 8 接口卫生;②RichEditorView内的 Ctrl+E / 完成按钮路径仍不阻断(今天就是 fire-and-forget,不是本批引入的回归,C4② 逐字禁止顺手扩大面)⇒ 批 8。另:RichEditorView内部若要显示保存错误做不到(它自带statusstate 保持原样)⇒ 批 7/8。- 行数代价:
components/notes/NotesReadingColumn.tsx166 → 290 行(预算 ≤260 ⇒ 超 30,已登记;口径:预算是估算,绑定约束是 ≤300 + 零新增豁免登记)。- ADR-035 的归属不变:L4 动效纲领与引擎仍顺延批 6;批 5 未新增任何 ADR(
docs/adr/实盘最高仍是 ADR-034,docs/adr/README.md索引本批零改动)。- 出处:批 5 计划 §收口回写 §四/§七 ·
tmp/t18/measurements.md§9 C3 与 D6。