agentcss 0.0.0-stage → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/01-tokens/README.md +236 -0
  2. package/01-tokens/behavior/dismissable.js +132 -0
  3. package/01-tokens/behavior/emit.js +76 -0
  4. package/01-tokens/behavior/focus-return.js +81 -0
  5. package/01-tokens/behavior/roving.js +152 -0
  6. package/01-tokens/behavior/typeahead.js +124 -0
  7. package/01-tokens/focus-ring-demo.html +133 -0
  8. package/01-tokens/focus-ring.css +102 -0
  9. package/01-tokens/page.css +244 -0
  10. package/01-tokens/polyfill.js +116 -0
  11. package/01-tokens/responsive-demo.html +171 -0
  12. package/01-tokens/scrollbar-demo.html +194 -0
  13. package/01-tokens/scrollbar.css +179 -0
  14. package/01-tokens/theme-toggle.js +103 -0
  15. package/01-tokens/tokens.css +972 -0
  16. package/01-tokens/typography.css +156 -0
  17. package/02-primitives/badge/README.md +71 -0
  18. package/02-primitives/badge/badge.css +141 -0
  19. package/02-primitives/badge/demo.html +101 -0
  20. package/02-primitives/button/README.md +99 -0
  21. package/02-primitives/button/button.css +353 -0
  22. package/02-primitives/button/demo.html +200 -0
  23. package/02-primitives/card/README.md +145 -0
  24. package/02-primitives/card/card.css +246 -0
  25. package/02-primitives/card/demo.html +170 -0
  26. package/02-primitives/choice/README.md +161 -0
  27. package/02-primitives/choice/choice.css +395 -0
  28. package/02-primitives/choice/demo.html +277 -0
  29. package/02-primitives/combobox/README.md +79 -0
  30. package/02-primitives/combobox/combobox.css +223 -0
  31. package/02-primitives/combobox/combobox.js +485 -0
  32. package/02-primitives/combobox/demo.html +109 -0
  33. package/02-primitives/date-range/README.md +100 -0
  34. package/02-primitives/date-range/date-range.css +206 -0
  35. package/02-primitives/date-range/date-range.js +301 -0
  36. package/02-primitives/date-range/demo.html +104 -0
  37. package/02-primitives/input/README.md +170 -0
  38. package/02-primitives/input/demo.html +246 -0
  39. package/02-primitives/input/input.css +239 -0
  40. package/02-primitives/popover/README.md +80 -0
  41. package/02-primitives/popover/demo.html +150 -0
  42. package/02-primitives/popover/popover.css +187 -0
  43. package/02-primitives/popover/popover.js +331 -0
  44. package/02-primitives/progress/README.md +82 -0
  45. package/02-primitives/progress/demo.html +154 -0
  46. package/02-primitives/progress/progress.css +172 -0
  47. package/02-primitives/select/README.md +95 -0
  48. package/02-primitives/select/demo.html +121 -0
  49. package/02-primitives/select/select.css +151 -0
  50. package/02-primitives/select/select.js +549 -0
  51. package/02-primitives/separator/README.md +63 -0
  52. package/02-primitives/separator/demo.html +84 -0
  53. package/02-primitives/separator/separator.css +73 -0
  54. package/02-primitives/skeleton/README.md +69 -0
  55. package/02-primitives/skeleton/demo.html +110 -0
  56. package/02-primitives/skeleton/skeleton.css +96 -0
  57. package/02-primitives/switch/README.md +65 -0
  58. package/02-primitives/switch/demo.html +115 -0
  59. package/02-primitives/switch/switch.css +370 -0
  60. package/03-patterns/accordion/README.md +121 -0
  61. package/03-patterns/accordion/accordion.css +453 -0
  62. package/03-patterns/accordion/accordion.js +369 -0
  63. package/03-patterns/accordion/demo.html +223 -0
  64. package/03-patterns/content/README.md +103 -0
  65. package/03-patterns/content/content.css +286 -0
  66. package/03-patterns/content/demo.html +88 -0
  67. package/03-patterns/drawer/README.md +79 -0
  68. package/03-patterns/drawer/demo.html +101 -0
  69. package/03-patterns/drawer/drawer.css +129 -0
  70. package/03-patterns/drawer/drawer.js +258 -0
  71. package/03-patterns/dropdown/README.md +78 -0
  72. package/03-patterns/dropdown/demo.html +101 -0
  73. package/03-patterns/dropdown/dropdown.css +135 -0
  74. package/03-patterns/dropdown/dropdown.js +451 -0
  75. package/03-patterns/form-validation/README.md +147 -0
  76. package/03-patterns/form-validation/demo.html +280 -0
  77. package/03-patterns/form-validation/form-validation.css +263 -0
  78. package/03-patterns/list/README.md +150 -0
  79. package/03-patterns/list/demo.html +225 -0
  80. package/03-patterns/list/flip.js +201 -0
  81. package/03-patterns/list/list.css +162 -0
  82. package/03-patterns/nav/README.md +225 -0
  83. package/03-patterns/nav/demo.html +131 -0
  84. package/03-patterns/nav/nav.css +314 -0
  85. package/03-patterns/nav/nav.js +430 -0
  86. package/03-patterns/nav/toc.js +111 -0
  87. package/03-patterns/overlay/README.md +196 -0
  88. package/03-patterns/overlay/demo.html +241 -0
  89. package/03-patterns/overlay/overlay.css +319 -0
  90. package/03-patterns/overlay/overlay.js +563 -0
  91. package/03-patterns/pagination/README.md +114 -0
  92. package/03-patterns/pagination/demo.html +165 -0
  93. package/03-patterns/pagination/pagination.css +195 -0
  94. package/03-patterns/pagination/pagination.js +395 -0
  95. package/03-patterns/reveal/README.md +91 -0
  96. package/03-patterns/reveal/demo.html +106 -0
  97. package/03-patterns/reveal/reveal.css +93 -0
  98. package/03-patterns/reveal/reveal.js +172 -0
  99. package/03-patterns/states/README.md +141 -0
  100. package/03-patterns/states/demo.html +240 -0
  101. package/03-patterns/states/states.css +252 -0
  102. package/03-patterns/tabs/README.md +84 -0
  103. package/03-patterns/tabs/demo.html +139 -0
  104. package/03-patterns/tabs/tabs.css +172 -0
  105. package/03-patterns/tabs/tabs.js +307 -0
  106. package/03-patterns/tooltip/README.md +96 -0
  107. package/03-patterns/tooltip/demo.html +78 -0
  108. package/03-patterns/tooltip/tooltip.css +89 -0
  109. package/03-patterns/tooltip/tooltip.js +288 -0
  110. package/03-patterns/tree/README.md +94 -0
  111. package/03-patterns/tree/demo.html +138 -0
  112. package/03-patterns/tree/tree.css +113 -0
  113. package/03-patterns/tree/tree.js +456 -0
  114. package/04-recipes/analysis-report/README.md +80 -0
  115. package/04-recipes/analysis-report/analysis-report.css +187 -0
  116. package/04-recipes/analysis-report/demo.html +274 -0
  117. package/04-recipes/data-showcase/README.md +45 -0
  118. package/04-recipes/data-showcase/demo.html +247 -0
  119. package/04-recipes/detail-page/README.md +93 -0
  120. package/04-recipes/detail-page/demo.html +55 -0
  121. package/04-recipes/detail-page/detail-page.css +190 -0
  122. package/04-recipes/detail-page/full.html +60 -0
  123. package/04-recipes/detail-page/null.html +60 -0
  124. package/04-recipes/detail-page/partial.html +60 -0
  125. package/04-recipes/detail-page/verify-skeleton.py +19 -0
  126. package/04-recipes/longform/README.md +93 -0
  127. package/04-recipes/longform/agc-memory.svg +4 -0
  128. package/04-recipes/longform/content.html +610 -0
  129. package/04-recipes/longform/content.md +51 -0
  130. package/04-recipes/longform/longform.html +627 -0
  131. package/04-recipes/report/README.md +176 -0
  132. package/04-recipes/report/demo.html +355 -0
  133. package/04-recipes/report/report.css +364 -0
  134. package/04-recipes/table/README.md +103 -0
  135. package/04-recipes/table/demo.html +455 -0
  136. package/04-recipes/table/table.css +372 -0
  137. package/04-recipes/tierA-tokens.css +189 -0
  138. package/09-assets/README.md +74 -0
  139. package/09-assets/bar/README.md +94 -0
  140. package/09-assets/bar/bar.css +166 -0
  141. package/09-assets/bar/bar.js +320 -0
  142. package/09-assets/bar/demo.html +176 -0
  143. package/09-assets/echarts-adapter/demo.html +192 -0
  144. package/09-assets/echarts-adapter/echarts-adapter.js +277 -0
  145. package/09-assets/model-viewer/README.md +155 -0
  146. package/09-assets/model-viewer/demo.html +105 -0
  147. package/09-assets/model-viewer/model-viewer.css +143 -0
  148. package/09-assets/model-viewer/model-viewer.js +423 -0
  149. package/09-assets/scientific-plot/README.md +94 -0
  150. package/09-assets/scientific-plot/demo.html +252 -0
  151. package/09-assets/scientific-plot/scientific-plot.css +141 -0
  152. package/09-assets/scientific-plot/scientific-plot.js +610 -0
  153. package/09-assets/sparkline/README.md +153 -0
  154. package/09-assets/sparkline/demo.html +163 -0
  155. package/09-assets/sparkline/sparkline.css +158 -0
  156. package/09-assets/sparkline/sparkline.js +508 -0
  157. package/API.md +332 -0
  158. package/CHANGELOG.md +3116 -0
  159. package/CODE_OF_CONDUCT.md +64 -0
  160. package/CONTRIBUTING.md +89 -0
  161. package/ERRORS.md +1658 -0
  162. package/LICENSE +21 -0
  163. package/README.md +491 -2
  164. package/SECURITY.md +72 -0
  165. package/START-HERE.md +164 -0
  166. package/SUPPORT.md +109 -0
  167. package/adapters/presentation/README.md +113 -0
  168. package/adapters/presentation/check.js +101 -0
  169. package/adapters/presentation/deck.schema.json +74 -0
  170. package/adapters/presentation/to-doc.js +138 -0
  171. package/ai/START-HERE.md +104 -0
  172. package/ai/behaviors.json +380 -0
  173. package/ai/behaviors.schema.json +61 -0
  174. package/ai/capabilities.json +56 -0
  175. package/ai/cli.js +387 -0
  176. package/ai/components.json +1944 -0
  177. package/ai/components.meta.json +168 -0
  178. package/ai/components.schema.json +220 -0
  179. package/ai/contract.schema.json +147 -0
  180. package/ai/deprecations.json +55 -0
  181. package/ai/deprecations.schema.json +85 -0
  182. package/ai/motion-policy.json +22 -0
  183. package/ai/patch.js +323 -0
  184. package/ai/patch.schema.json +63 -0
  185. package/ai/profiles.json +92 -0
  186. package/ai/tokens.audience.json +55 -0
  187. package/ai/tokens.audience.schema.json +73 -0
  188. package/ai/tokens.json +762 -0
  189. package/ai/tokens.schema.json +58 -0
  190. package/ai/tokens.tree.json +752 -0
  191. package/ai/tokens.tree.schema.json +56 -0
  192. package/ai/validate.js +383 -0
  193. package/dist/01-tokens/behavior/dismissable.js +60 -0
  194. package/dist/01-tokens/behavior/emit.js +21 -0
  195. package/dist/01-tokens/behavior/focus-return.js +31 -0
  196. package/dist/01-tokens/behavior/roving.js +77 -0
  197. package/dist/01-tokens/behavior/typeahead.js +61 -0
  198. package/dist/01-tokens/focus-ring.css +1 -0
  199. package/dist/01-tokens/page.css +1 -0
  200. package/dist/01-tokens/polyfill.js +78 -0
  201. package/dist/01-tokens/scrollbar.css +1 -0
  202. package/dist/01-tokens/theme-toggle.js +61 -0
  203. package/dist/01-tokens/tokens.css +1 -0
  204. package/dist/01-tokens/typography.css +1 -0
  205. package/dist/02-primitives/badge/badge.css +1 -0
  206. package/dist/02-primitives/button/button.css +1 -0
  207. package/dist/02-primitives/card/card.css +1 -0
  208. package/dist/02-primitives/choice/choice.css +1 -0
  209. package/dist/02-primitives/combobox/combobox.css +1 -0
  210. package/dist/02-primitives/combobox/combobox.js +439 -0
  211. package/dist/02-primitives/date-range/date-range.css +1 -0
  212. package/dist/02-primitives/date-range/date-range.js +267 -0
  213. package/dist/02-primitives/input/input.css +1 -0
  214. package/dist/02-primitives/popover/popover.css +1 -0
  215. package/dist/02-primitives/popover/popover.js +257 -0
  216. package/dist/02-primitives/progress/progress.css +1 -0
  217. package/dist/02-primitives/select/select.css +1 -0
  218. package/dist/02-primitives/select/select.js +449 -0
  219. package/dist/02-primitives/separator/separator.css +1 -0
  220. package/dist/02-primitives/skeleton/skeleton.css +1 -0
  221. package/dist/02-primitives/switch/switch.css +1 -0
  222. package/dist/03-patterns/accordion/accordion.css +1 -0
  223. package/dist/03-patterns/accordion/accordion.js +219 -0
  224. package/dist/03-patterns/content/content.css +1 -0
  225. package/dist/03-patterns/drawer/drawer.css +1 -0
  226. package/dist/03-patterns/drawer/drawer.js +229 -0
  227. package/dist/03-patterns/dropdown/dropdown.css +1 -0
  228. package/dist/03-patterns/dropdown/dropdown.js +365 -0
  229. package/dist/03-patterns/form-validation/form-validation.css +1 -0
  230. package/dist/03-patterns/list/flip.js +117 -0
  231. package/dist/03-patterns/list/list.css +1 -0
  232. package/dist/03-patterns/nav/nav.css +1 -0
  233. package/dist/03-patterns/nav/nav.js +322 -0
  234. package/dist/03-patterns/nav/toc.js +76 -0
  235. package/dist/03-patterns/overlay/overlay.css +1 -0
  236. package/dist/03-patterns/overlay/overlay.js +398 -0
  237. package/dist/03-patterns/pagination/pagination.css +1 -0
  238. package/dist/03-patterns/pagination/pagination.js +273 -0
  239. package/dist/03-patterns/reveal/reveal.css +1 -0
  240. package/dist/03-patterns/reveal/reveal.js +138 -0
  241. package/dist/03-patterns/states/states.css +1 -0
  242. package/dist/03-patterns/tabs/tabs.css +1 -0
  243. package/dist/03-patterns/tabs/tabs.js +242 -0
  244. package/dist/03-patterns/tooltip/tooltip.css +1 -0
  245. package/dist/03-patterns/tooltip/tooltip.js +213 -0
  246. package/dist/03-patterns/tree/tree.css +1 -0
  247. package/dist/03-patterns/tree/tree.js +383 -0
  248. package/dist/04-recipes/analysis-report/analysis-report.css +1 -0
  249. package/dist/04-recipes/detail-page/detail-page.css +1 -0
  250. package/dist/04-recipes/report/report.css +1 -0
  251. package/dist/04-recipes/table/table.css +1 -0
  252. package/dist/04-recipes/tierA-tokens.css +1 -0
  253. package/dist/09-assets/bar/bar.css +1 -0
  254. package/dist/09-assets/bar/bar.js +222 -0
  255. package/dist/09-assets/echarts-adapter/echarts-adapter.js +185 -0
  256. package/dist/09-assets/model-viewer/model-viewer.css +1 -0
  257. package/dist/09-assets/model-viewer/model-viewer.js +320 -0
  258. package/dist/09-assets/scientific-plot/scientific-plot.css +1 -0
  259. package/dist/09-assets/scientific-plot/scientific-plot.js +568 -0
  260. package/dist/09-assets/sparkline/sparkline.css +1 -0
  261. package/dist/09-assets/sparkline/sparkline.js +265 -0
  262. package/dist/COSTS.md +56 -0
  263. package/docs/AI-CONTRACT.md +197 -0
  264. package/docs/BACKLOG.md +127 -0
  265. package/docs/BENCHMARK.md +252 -0
  266. package/docs/GUIDANCE.md +154 -0
  267. package/docs/INVARIANT.md +372 -0
  268. package/docs/LAYER-BASELINE.md +158 -0
  269. package/docs/RELEASING.md +275 -0
  270. package/docs/RFC-THEME-SCOPE.md +141 -0
  271. package/docs/ROADMAP.md +182 -0
  272. package/index.html +82 -0
  273. package/package.json +107 -4
  274. package/types/index.d.ts +310 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,3116 @@
1
+ # Changelog
2
+
3
+ 本文件记录本库每个版本的变化。格式遵循
4
+ [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
5
+ 版本号遵循[语义化版本](https://semver.org/lang/zh-CN/)。
6
+
7
+ ## 版本号约定
8
+
9
+ 本库的版本按**语义化版本**(`主.次.修`):
10
+
11
+ - **主版本**:令牌或定义**不兼容**变更(下游必须改代码)
12
+ - **次版本**:新增组件 / 新增能力(下游不改代码也能升级)
13
+ - **修订号**:修 bug、修文案(下游无需任何改动)
14
+
15
+ ## [Unreleased]
16
+
17
+ (本轮无未发布改动 —— 0.11.2 的条目见下方。)
18
+
19
+ ## [0.11.2] - 2026-10-28
20
+
21
+ ### Added · `03-patterns/nav/nav.js` —— 抽屉的行为层(2026-10-28)
22
+
23
+ `nav.css` 早就定义了抽屉的三个状态(`list`/`scrim` 的 `data-state`、
24
+ `toggle` 的 `aria-expanded`),却从没说**谁来切**。于是 demo 抄一份、示例页再抄一份
25
+ ——两个副本各有各的漏。现在这一层在库里,430 行(含三个共用核),
26
+ `data-nav` 自动初始化,复用 `emit` / `focus-return` / `dismissable` 三块核。
27
+
28
+ 抽出来的时候抓到了**旧 demo 里那个从没被发现的 bug**:
29
+
30
+ > 旧代码把 `aria-hidden="true"` 打在了 body 的**每个子元素**上,跳过的是
31
+ > list / scrim / toggle 三个元素。但 `<header class="nav">` 是 list 的**祖先**,
32
+ > 不在跳过名单里 ⇒ 整块 header 被 aria-hidden 包住,**抽屉自己也跟着对读屏消失**。
33
+
34
+ 它"看起来完全正常":截图正常、像素比对正常、Tab 也正常。只有用读屏打开菜单才会发现
35
+ 里面一个字都读不出来。新实现的判据是「跳过**包含**导航自身的节点」。
36
+
37
+ 另外两条与旧实现不同的地方:
38
+ - **断点不写数字**:判据是"汉堡按钮还在不在"(`offsetParent !== null`),
39
+ 不是 `innerWidth > 768`。CSS 改一个断点值,写死数字的 JS 会静默错位。
40
+ - **遮罩不算"内部"**:写进 `inside` 的话,点遮罩不会关闭(实测过一次),
41
+ 而那正是遮罩唯一的存在理由。
42
+
43
+ ### Added · 宽图 / 宽表通道 `.rep__fig--wide` / `.rep__wide`(2026-10-28)
44
+
45
+ `04-recipes/report/README.md` 从 0.8.x 就写着「大图请放进正文流(`figure.rep__fig--wide`)」,
46
+ 但那条规则**从来没有生效过**:
47
+
48
+ ```css
49
+ .rep__body { max-width: var(--rep-measure); } /* ← 父容器被压住 */
50
+ .rep--magazine .rep__fig--wide { max-width: none; } /* ← 子元素再宽也越不过它 */
51
+ ```
52
+
53
+ 实测:金融版正文列 30em@14px = 420px,1800 宽的折线图被压到 420px,
54
+ 图里 12px 的刻度字缩成 6px(`3×6` 像素)。示例页最初的"解决办法"是把
55
+ `--rep-measure` 改成 50em —— 那等于**用"整页正文放宽"换"图能画宽"**,
56
+ 读者要在数字和文字之间来回看,行长一宽这件事就变累了。
57
+
58
+ 现在的做法是把行长**从列容器移到内容块**上(这也是 `tokens.css` 里 `h1/p` 的既有写法):
59
+
60
+ ```css
61
+ .rep__body { max-width: none; }
62
+ .rep__body p, .rep__body .rep__fig, .rep__body .table, … { max-width: var(--rep-measure); }
63
+ .rep__body .rep__fig--wide, .rep__body .rep__wide { max-width: none; }
64
+ ```
65
+
66
+ ⚠️ 两个坑:
67
+ 1. 宽通道规则必须写成 `.rep__body .rep__wide`(0,2,0)。写成裸类(0,1,0)会被
68
+ 上面那条 0,2,0 压住 —— **加了 class 却没有效果**,比没有这个 class 更难查。
69
+ 实测过一次(图量出来还是 420px)。
70
+ 2. 只把 `max-width` 施加在 **font-size = 基准**的块上:`30em` 里的 em 跟着元素自身
71
+ 字号走,套到 `h2`(1.5rem)上会静默变成 720px。
72
+
73
+ 三个 profile 共用同一个开关。既有 demo 的视觉基线**逐页比对为零漂移**
74
+ (393 宽下正文列本来就等于容器宽,这个功能只在桌面宽度有区别)。
75
+
76
+ ### Added · 两道浏览器门禁(177 道)
77
+
78
+ - **`nav-drawer.js`** —— 44 条判据 × 两个页面:初始化、三个状态、锁滚动、
79
+ 背景 inert、连续 8 次 Tab 的焦点陷阱、Esc 后焦点归位、点遮罩、变宽自关、
80
+ JS 报错。`kbd` 那条是"只报告",抓不到"抽屉开了但读屏读不到"这种硬伤。
81
+ - **`rep-wide-channel.js`** —— 13 条判据,**在 1440 宽度下量数字**。
82
+ 为什么必须单独一道:视觉基线只拍 393 宽(`VIEWPORT = (393, 852)`),
83
+ 而宽通道**只在桌面宽度才有区别** ⇒ 它的回归没有任何门禁看得见。
84
+ ⚠️ 第一版判据写错了两条(「正文比列窄 40px」「图内文字宽 > 18px」),
85
+ 在 report demo 和单字符刻度上假红 —— 判据错,不是实现错,已改正并记进文件头。
86
+
87
+ ### Fixed · 门禁注册表的"一个组件只有一个 JS"假设(2026-10-28)
88
+
89
+ `nav` 有了第二个 JS 之后,`fix-start-here.py` 的分类校验**静默失效**了:
90
+ 它取"目录里唯一的 .js",两个文件时退化成 `n_js=0, js_name=None`,
91
+ 于是表格显示「—」、校验第一行 `continue` —— 校验脚本报"全部通过",
92
+ 而事实是它什么都没查。这比它要防的 bug 更隐蔽。
93
+
94
+ - `fix-start-here.py`:rows 带出目录里**所有** .js,分类表按**文件**核对
95
+ (漏登记 / 陈旧登记 / 必需与可选判据各查一遍)。
96
+ nav 现在显示成 `必需 430 (nav.js) · 可选 111 (toc.js,自动生成目录)`。
97
+ - `gen-ai-contract.py`:新增 `files.jsAlso`。否则第二个文件在 AI 契约里
98
+ **彻底消失** —— 而 AI 消费者读的就是这份契约,看不见等于不存在。
99
+
100
+ ### Fixed · 四道门禁的"量具错"(2026-10-28)
101
+
102
+ `nav.js` 落地后有四道门禁报红,**没有一道是实现坏了**——全是量具的假设太窄。
103
+ 这一类必须分开记,因为"改实现去迎合量具"和"改量具去描述实现"看起来都像在修 bug。
104
+
105
+ | 门禁 | 报的是什么 | 真相 |
106
+ |---|---|---|
107
+ | `behavior-matrix` | 4 种行为 × `nav` 没登记 | **实现没登记**,这次是真账(补 core / core / n/a / n/a) |
108
+ | `autoinit` | 「表里出现了库里没有的全局 `Toc`」 | 契约只记 `global` 一个字段 ⇒ 第二个全局在量具眼里不存在 |
109
+ | `api-form` | 「声明了浏览器里不存在的 global:`Nav`」 | 探针表 `TARGETS` 是硬编码的,新全局没人查 |
110
+ | `api-form` | 「`Nav` 文档写了实现没有的成员」 | 判据只在 `construct` 时才并入 `protoMethods` |
111
+ | `report` | 「三套 profile 的行长没拉开档次(null em)」 | 行长搬到了内容块上,判据还在读列容器 `max-width: none` |
112
+
113
+ 四处修法:
114
+
115
+ 1. **契约补 `globalAlso`**(`gen-ai-contract.py`):同目录的第二个 JS 若导出另一个
116
+ 全局,一并记进契约。`Toc` 从"库里没有"变回"库里有,只是不自动初始化"。
117
+ ⇒ 修法**不是**把 `Toc` 从那张表里删掉——那是拿真实信息换绿灯。
118
+ 2. **探针表漏登记单独报**(`api-form-gate.py`):新增 `probe_targets()` 从探针源码
119
+ 读真表,把"探针没登记"和"页面里探不到"分成两种红、两句提示。
120
+ 混在一句话里,人就会去改错地方(而"改错地方"的结果正好是删契约)。
121
+ 3. **`attach` / `create` 形态也算原型方法**(`api-form-gate.py`):一个实例能调的
122
+ 方法都在原型上,与它是怎么被创建出来的无关。旧判据让所有"文档提到实例方法"的
123
+ attach 组件必然假红。
124
+ 4. **成功消息里不许硬编码数量**(`behavior-matrix-gate.py`):原先打印
125
+ 「OK 18 个组件」,实际 21 个——「数字对不上」和「数字对得上」在终端里长得一样。
126
+ 5. **行长判据改读正文块**(`report-check.js`):它读 `.rep__body` 的
127
+ `max-width`,而那个容器现在是 `none`(行长搬到内容块上了)⇒ 换算出来是
128
+ `null em`。改成读 `.rep__body p`,容器只作兜底。实测三档变成
129
+ `42em > 34em > 30em`,并用变异探针(把金融版 30em 改成 42em)验证判据仍会红。
130
+
131
+ 顺带被第 3 条抓出一条**真文档错**:`Nav` 那一行原本写着实例方法 `toggle`,
132
+ 而原型上只有 `isOpen` / `open` / `close` / `destroy`。不是判据过严,是我记错了。
133
+
134
+ `API.md` 另补两处:全局表新增 `Nav`(attach 形态),自动初始化表新增
135
+ `Nav`(是 · 扫 `[data-nav]`)。同时把「18 个全局里只有 5 个是 create」
136
+ 改成实测的「22 个全局里只有 7 个是 create」——那句话已经陈旧了三个版本。
137
+
138
+ ### Added · 勾稽门禁 `finance-example-ties.py`(2026-10-27)
139
+
140
+ 示例页第一次交付时,**33 条勾稽错了 6 条**,而它通过了全部既有门禁:
141
+
142
+ | 错在哪 | 差多少 |
143
+ |---|---|
144
+ | 利润表加不出营业利润(中间科目被省掉) | 79.4 亿元 |
145
+ | 资产负债表少了「少数股东权益」一行 | 49.5 亿元 |
146
+ | 五年表研发费用 2.1 亿 vs 同一页利润表 12.1 亿 | 6 倍 |
147
+ | KPI 的 ROE 34.6% 与资产负债表的权益对不上 | 8.4pp |
148
+ | 市盈率 22.4 / 市净率 7.8 / 股息率 2.9% 三数互斥 | — |
149
+ | 摘要里「251 亿元来自销量」在分部数据里找不到出处 | — |
150
+
151
+ 没有一道门禁在看"数对不对" —— HTML 校验、对比度、像素回归全部通过。
152
+ **读者判断一页财务报告可不可信,第一件事是拿计算器加一遍。**
153
+ 加不上,后面写得再好也没人看。
154
+
155
+ 新门禁把这件事变成 30 条独立判据(利润表四段、BS 三向、CF、ROE 跨表、
156
+ 五年表逐年、分部图逐年、费用率图 vs 表、估值四数),容差按页面显示精度给
157
+ (整数列 ±0.51、一位小数 ±0.051),不是"大概对就行"。
158
+ 估值三个数**从页面上读**而不是在脚本里写死 —— 写死就成了自证。
159
+
160
+ ⚠️ 它自己也被变异探针验过:8 个故意改坏的点(含 ±0.2 亿的漂移、
161
+ 删整行、改行名)**全部被判红**。门禁的价值是它能抓到的缺陷,不是它跑过多少次。
162
+
163
+ ### Added · 组合示例页 `examples/finance-report/`(2026-10-27)
164
+
165
+ 一张 A 股公司分析页,把库当素材拼成真实产品的一页:`detail-kpi` 指标区(带
166
+ sparkline 迷你趋势)+ `rep--finance` 报告版式 + `Bar` 分部堆叠柱 +
167
+ `scientific-plot` 费用率折线 + `tabs` 三张报表 + `table--financial` 五年指标 +
168
+ `accordion` 口径说明 + `nav` 应用外壳与移动端抽屉 + 明暗主题切换。
169
+
170
+ 它的作用不是"再多一个 demo",而是**组件组合起来是否成立**的活证据 ——
171
+ 单个组件都对、拼起来错位,正是组件库最典型的失败方式。
172
+ 已纳入视觉基线(受管页 40 → 41)。
173
+
174
+ ### Fixed · 三个"默认环境看不见"的缺陷
175
+
176
+ **① 导航在「减少动态效果」下整块飞出屏幕**(最严重)
177
+ `nav.css` 的 reduced-motion 段把移动端抽屉的"收起态位移"
178
+ (`translateX(100%)`)写在了**无断点**的作用域里。后果:任何在系统里开启
179
+ 「减少动态效果」的用户,**桌面端顶部导航整体移出视口右侧**,菜单消失且无报错。
180
+ 实测 1440 视口下 `scrollWidth` 1440 → 1512,正好是列表自身宽度。
181
+ 修法:那两条移进 `@media (prefers-reduced-motion: reduce) and (max-width: 48rem)`。
182
+ ⚠️ 基线截图是 393 宽视口拍的,抽屉本来就该收起 ⇒ **视觉门禁永远看不见这个 bug**。
183
+
184
+ **② 财务表的列头与数字不同侧**
185
+ `<th>` 默认 `text-align: center`,而 `.num` 是 `end` ⇒ 列头「2021」居中、
186
+ 下面的「1,095」靠右,两者不在一条竖线上(五年表实测错开约半个列宽)。
187
+ 新增 `.table--financial thead th:not(:first-child) { text-align: end }`。
188
+ ⚠️ 只改 financial:三线表的表头居中是学科惯例,两者语义不同。
189
+
190
+ **③ 窄容器里柱状图的刻度字缩到 3×6 像素**
191
+ `bar.js` 把 `viewBox` 宽写死 640,注释还写着"靠 CSS width:100% 自适应" ——
192
+ 那句话是错的:`width:100%` 只是**等比缩放整张 SVG**,而图中的刻度字是绝对值
193
+ (10px),跟着一起缩。393 视口(容器 334px)实测缩放 0.52 ⇒ 字号渲染成 3×6px。
194
+ 修法:新增 `opt.width`(默认 640,下限 240),布局本来就按 `plotW` 算的,
195
+ 传入真实容器宽后按容器重排、字号保持 10px。
196
+
197
+ ### Fixed · `visual-coverage-gate` 只认 `demo.html`
198
+
199
+ 判据把入口文件名写死成 `demo.html`,`examples/` 下用 `index.html` 的示例页
200
+ 要么扫不到(漏覆盖)、要么被当成"幽灵条目"判红。两种都是判据写错。
201
+ 改成 `ENTRY_NAMES` 常量一处定义,并把 `examples/` 纳入视野。
202
+ 放宽视野后立刻暴露一个既有缺口:`10-review/ios/` 从来没有基线
203
+ (它是给人在 iPhone Safari 上逐条核对的验收清单,已登记豁免并写明理由)。
204
+
205
+ ### Merged · 两条外部修复分支(2026-10-27)
206
+
207
+ - `fix/overlay-aria-fallback`:旧 WebView 没有 `inert` 时补 `aria-hidden` + 交互隔离
208
+ (focusin 拉回焦点、指针事件全 capture 拦截);两条契约分开断言,
209
+ 原有两处"不支持就跳过"改成机制感知谓词。
210
+ - `fix/package-closure-and-gate-blindspots`:包内闭包门禁(`pack-closure`)+
211
+ 消费者侧解析(`entry-resolve`)+ 星号语义改问真 Node(`_pkg-target`)+
212
+ `pkg-name` / `motion-live` / `orphan` / `switch-geom` / `tap-target` / `theme-scope`。
213
+
214
+ ### Fixed · 根入口的两半修复合并后自相矛盾
215
+
216
+ 同一个缺陷(`exports["."]` 指向从未发布的 `./index.html`)两边各修了一半:
217
+ 本机侧**删掉声明**(只留 `types`),另一台机器**把文件真正打进包**。
218
+ 合并后变成"文件在包里、声明已删" ⇒ `pack-smoke` 的消费者侧解析判红
219
+ (`require`/`import` 都 `ERR_PACKAGE_PATH_NOT_EXPORTED`)。
220
+
221
+ 处置:既然 `index.html` 现在确实随包发布,就**恢复声明**(`"default": "./index.html"`),
222
+ 而不是留一个既解析不到、又没说明为什么的 `exports["."]`。
223
+ ⇒ 根入口的语义是"落地页",库本身仍无 JS 运行时入口(`main` 依旧不设)。
224
+
225
+ ### Added · 「哪些组件会自动初始化」写成契约(2026-10-27 · P3 的正确修法)
226
+
227
+ 交接单的 P3 写的是「九个 create-only 组件的 `DOMContentLoaded` 计数全为 0
228
+ ⇒ 自动初始化静默失效」。**前半句实测正确,后半句是错的**:
229
+ 它们不是"该有却没有",而是**本来就不该有**(自动初始化拿不到 `options`,
230
+ 给一个默认实例比不初始化更坏)。
231
+
232
+ 真问题是**这个区别没人写下来**。后果很具体:把 `Accordion` 的经验套到
233
+ `Select` 上 —— 结构渲染正常、点了没反应、console 一个字都没有。
234
+
235
+ ⇒ `API.md` 新增「自动初始化」表(19 个全局逐个写明 是 / 条件 / 否),
236
+ 并由新门禁 `autoinit-gate` 守三处一致:
237
+
238
+ 1. 任何含 `DOMContentLoaded` 的库脚本必须有**且只有一条** `@autoinit:` 标记
239
+ 2. 标记里的选择器必须在文件里**真的用到**(先把标记行剔掉再查 —— 否则
240
+ "我写什么就有什么",判据自指)
241
+ 3. `API.md` 那张表与 `ai/components.json` 的全局集合双向一致
242
+
243
+ **没有改任何组件行为**:这是文档 + 判据的补丁,零回归风险。
244
+ 7 个破坏样本全部判对(含"表说不会但实现偷偷加了"这个最现实的漂移方向)。
245
+
246
+ ### Fixed · 元门禁自己也会红:`selftest-all` 的两处结构性问题(2026-10-27)
247
+
248
+ `MODE=fast` 全跑时报 `selftest-all FAIL`,追下去不是代码坏了,是**判据的
249
+ 前提没人造**:
250
+
251
+ - 它内部有 `motion-concurrency.js --selftest`,那一条要连 `127.0.0.1:8000`;
252
+ 而 fast 档是纯静态层、**故意不起服务**(不抢用户的端口)。
253
+ - ⇒ 该自检打印「跑不起来:ERR_CONNECTION_REFUSED」,被当成"环境问题",
254
+ 而**环境问题一旦能被当成借口,红灯就永远可以解释掉**。
255
+
256
+ 两处改法:
257
+
258
+ 1. **前提自己造**:`selftest-all` 现在自己确保 `:8000` 在跑 ——
259
+ 先探测并做 **canary 字节比对**(确认服务的是**这棵树**,不是别人的副本,
260
+ 这是 `check-all.sh` 已经踩过的坑),不一致就红并说明;自己起的服务退出时关掉。
261
+ 2. **判定抽成 `verdict()` 并加自检**:5 个合成样本,其中一条是
262
+ **「RC=0 但输出说跑不起来 ⇒ 必须判红」**(判据 ④)。
263
+ 理由是「跑不起来」不能以任何形式被记成「通过」(INVARIANT I-10)。
264
+
265
+ 顺带补掉 P2 的欠账:`dead-class-gate.py` 早有 `--selftest`,但**从没被任何
266
+ 脚本跑过**("门禁死了也不会有人知道"),现接进 `behavior-reverse.sh`。
267
+
268
+ 门禁数 172 → **174**(新增 `selftest-all-rev` 与 `autoinit`)。
269
+ `CONTRIBUTING.md` 里两处过时数字同时修正(一处被 `doc-facts` 抓到,
270
+ 一处是散文标题里的 `159 道` —— 已改成不写数字,免得再变成第二个真源)。
271
+
272
+ ---
273
+
274
+ ## [0.11.0] - 2026-10-25
275
+
276
+ ### Added · 主题的**容器级**作用域(P1)
277
+
278
+ 以前暗色只有两条入口,都在根上(`@media` + `:root[data-theme]`)⇒ **一页只能一种主题**,
279
+ 做不了"深色 hero + 浅色正文",也做不了文档站里的反色示例。现在:
280
+
281
+ ```html
282
+ <div data-theme="dark"> <!-- 这块区域整体用暗色 -->
283
+ <button class="btn btn--primary">暗色按钮</button>
284
+ <div data-theme="light"> <!-- 再嵌回去,也成立 -->
285
+ <button class="btn btn--primary">浅色按钮</button>
286
+ </div>
287
+ </div>
288
+ ```
289
+
290
+ - 选择器去掉 `:root` 限定:`:root[data-theme="dark"], [data-theme="dark"]`
291
+ (保留第一项 ⇒ 根上的特异性不降级,旧行为逐字等价)。
292
+ - 新增 `[data-theme="light"]` **回退段**:`var()` 是继承的,CSS 没有"取消声明、
293
+ 回到上一层"的语法,所以"暗容器里再要浅色"只能把浅色 L1 重写一遍。
294
+ 它由 `theme-sync.py` 生成、逐条校验 —— 不允许手写(I-7:第二份真值必然漂移)。
295
+ - 🔴 **首跑实测到的坑(这条值得单独记)**:只覆盖 `--raw-*` 是**没用的**。
296
+ `--paper: var(--raw-paper)` 在**声明它的元素**(`:root`)上就求值完了,
297
+ 后代继承的是结果 `#f6f7f8`,不是那个 `var()` 表达式。
298
+ ⇒ 每个作用域必须**连 L2 别名一起重声明**,才能在该元素上重新求值。
299
+ 验证前后的实测:暗区 `--paper` 从 `#f6f7f8` 变成 `#14171A`,按钮底色
300
+ 从 `rgb(27,77,143)` 变成 `rgb(122,169,222)`。
301
+ - 新增门禁 `05-audit/theme-scope-gate.js`(真浏览器):作用域解析 / 组件跟随 /
302
+ 嵌套回退 / 作用域内对比度 / 焦点环加强版 / 静态禁止组件按系统偏好分支。
303
+ 反向控制改的是**真 CSS 文本**:改回 `:root` 限定 ⇒ 红;删掉浅色回退段 ⇒ 红。
304
+ - `focus-ring.css` 的暗色选择器去掉 `:root`(否则局部暗区里的焦点环拿到的是浅色版)。
305
+
306
+ ### Changed · 组件不再按 `prefers-color-scheme` 分支
307
+
308
+ 主题能从容器来之后,组件里的 media 查询只反映**系统**偏好 ⇒ 局部暗区里的组件
309
+ 会拿到另一套配色,与周围不自洽。
310
+
311
+ - `tooltip`:原来的暗色 media 分支改成一对令牌 `--tooltip-bg/fg/border`
312
+ (文字/底 16.60:1 浅色、11.93:1 暗色,均 AAA)。描边用 `inset` 环而不是
313
+ `border` —— 浅色下与底色同色(不可见、零布局影响),暗色下正好贴在原边线上。
314
+ - `badge`:那段暗色 media 块与基础规则**逐字相同**(`--text-on-solid` 早已令牌化),
315
+ 是死代码,删除。
316
+
317
+ ### Removed · `tooltip-check` 里一条**绑在实现形态**上的判据
318
+
319
+ 原判据「暗色下气泡反色」查的是 `tooltip.css` 里**有没有 `@media (prefers-color-scheme: dark)`
320
+ 段**。改成令牌驱动之后这段被删,判据立刻报"🔴 无暗色段",而**实际暗色表现一点没变**
321
+ (实测对比度仍 11.93:1、底色仍从 rgb(21,24,28) 切到 rgb(35,40,48))。
322
+
323
+ ⇒ 删掉这个代理判据:结果由同文件 ④ 用实测值守着,「组件不许按系统偏好分支」由
324
+ `theme-scope-gate` 静态守着(它管全部 27 个组件,不只这一个)。
325
+ 这是 `INVARIANT I-15`(判据绑在内容/结果上才稳定)的又一次实例。
326
+
327
+ ### Verified
328
+
329
+ - 明暗两套视觉基线 **40/40 页逐像素一致** ⇒ 这次改动对既有页面**零视觉影响**,
330
+ 只增加了新的能力(`P1` 的价值不在改观感,在改能力)。
331
+ - `MODE=full`:**171 道门禁全过、0 跳过**。
332
+
333
+ ### Added · `orphan-gate`(05-audit 里不许有"没人跑"的脚本)
334
+
335
+ - 判据:每个 `05-audit/*.{py,js,mjs,sh}` 必须属于四类之一 ——
336
+ 被 `check-all.sh` / `package.json` / workflow 引用、被别的脚本 import、
337
+ `_` 前缀的工具模块、或前 40 行里写了 `MANUAL-ONLY: <理由>` 的人工工具。
338
+ 四类都不是 ⇒ 红。
339
+ - 反向控制 5 例(含"只被 CHANGELOG 提过"不算有主 —— 第一版就是那里判错的)。
340
+ - **首跑抓到 11 个,其中两个不是"垃圾"而是"丢了的东西"**:
341
+ - `tap-target.js` —— WCAG 2.5.8(24×24)/ 2.5.5(44×44)命中区检查,跑 8 个页面,
342
+ 实现完整但**从来没被调用**。已接线。
343
+ - `switch-geometry.js` —— `ai/components.json` 一直把它列为 `switch` 的门禁,
344
+ 而它第一行就 `ReferenceError: Cannot access 'REPO' before initialization`,
345
+ **一次都没跑起来过**。修好并接线(顺带证明滑块两端间隙 2/20、行程 20px 正确)。
346
+ - 其余:4 个标为 `MANUAL-ONLY`(各带具体理由)、5 个移进 `_attic/`(带墓碑说明)。
347
+
348
+ ### Fixed · `core-boundary-gate` 每跑一次留下一个临时目录
349
+
350
+ - `tempfile.mkdtemp(prefix='fl-coreboundary-')` 两处,**没有任何清理**
351
+ ⇒ `%TEMP%` 里攒了 **369 个**。已用 `atexit.register(shutil.rmtree, …)` 兜住
352
+ (不包 `try/finally` 是因为该函数有 5 条提前 `return`,重排缩进的风险大于收益)。
353
+ - 验证:修前跑一次 +1,修后跑 `main` + `--selftest` 各一次,增量 **0**。
354
+
355
+ ### Added · `05-audit/README.md`(门禁目录的唯一入口)
356
+
357
+ - 四类脚本约定、参数约定的坑(传错只扫 0 个文件却判 PASS)、新增门禁的六条清单。
358
+
359
+ ### Added · `motion-live` 门禁(动效的**运行时**判据)
360
+
361
+ - 背景:现有三道动效门禁全是静态的 —— 查"有没有 `prefers-reduced-motion` 块"、
362
+ "时长是不是走令牌"。**没有一条量过运行时的动画对象。**
363
+ - 三条判据(都不靠阈值调参):
364
+ ① 状态切换**必须产生动画对象**(切显示/切隐藏各 ≥ 1;没有动画 = 值瞬移);
365
+ ② 快速重复触发时**同一属性并发 ≤ 1**(按属性计,不按动画对象总数 —— 一个切换本来就产生 2 个对象);
366
+ ③ `prefers-reduced-motion: reduce` 下**动画 0 个、opacity 恒为 1**。
367
+ - 反向控制不是喂合成数据,而是**在真实页面注入 `*{transition:none}`** ⇒ 判据 ① 必须变红。
368
+ - 实测结论:三条全过。**huxx 那条「动画进行中不要打断」我们不需要**
369
+ —— 它是给 JS 动画(会重启)说的,我们的 CSS transition 是 retarget。
370
+ 反过来加守卫会让离场元素"多亮一下",已撤回。结论写进 `03-patterns/reveal/README.md`
371
+ 的负面结论区(含重开条件)。
372
+
373
+ ### Added · `pkg-resolve` 门禁(发布物的**真实解析**)
374
+
375
+ - 为什么不是又一道存在性检查:`tsc-gate` 走 `type-tests` 的 `paths` 别名,
376
+ 验的是"这个 d.ts 语法对不对";`pack-smoke` 验的是"文件在不在包里"。
377
+ **没有一道回答"使用者那句 `import { Select } from 'agentcss'` 到底能不能成"。**
378
+ - 做法:按 `files` 白名单把真实发布物摆成 `node_modules/<name>/` 的样子,
379
+ 让 `tsc --strict --skipLibCheck false --moduleResolution bundler` 从**包名**解析一次。
380
+ - 判据 A:`npm pack --dry-run --json` 的清单与复制出的文件数一致(用 npm 自己的答案当真源);
381
+ B:tsc 退出码 0 且输出无 `error TS`;
382
+ C:`exports` 每一条落点都存在(**精确目标查文件、通配目标查目录非空**)。
383
+ - 反向控制两条:删掉 `exports["."].types` ⇒ 必须报 TS2307;
384
+ 把 `./tokens.css` 指向不存在的文件 ⇒ 判据 C 必须红。
385
+ - **首跑就抓到一个真缺陷**(见下)。
386
+
387
+ ### Fixed · 发布物的三处真缺陷
388
+
389
+ 1. **`exports["."]` 指向 `./index.html`,而它不在 `files` 白名单里** ⇒
390
+ 包装完之后,包根导出指向一个**不存在的文件**。已改为只保留 `types` 条件
391
+ (根路径只用于 `import type`,运行时不导出任何东西是符合预期的)。
392
+ 2. **`pack-smoke` 的条件式 exports 盲区**:它只遍历 `typeof v === 'string'` 的条目,
393
+ 而 `exports["."]` 是 `{types, default}` 这种对象形态 ⇒ **整条被跳过**。
394
+ 已改成递归展开所有叶子。
395
+ 3. **`pack-smoke` 的通配检查多了一层 `dirname`**:`./dist/02-primitives/*` 被检查成了
396
+ `dist/` 而不是 `dist/02-primitives/` ⇒ 目标子目录不存在也能过。已去掉那层 `dirname`。
397
+
398
+ ### 已知未修(下一轮)
399
+
400
+ - `Select` 的公开返回值**没有** `destroy()`(`select.js:541` 只返回 `open/close/value`,
401
+ 另两处 `destroy` 是内部微行为核的)。它因此是六个交互组件里唯一不支持卸载的 ——
402
+ 类型里没写 `destroy()` 是**对的**,缺的是运行时。补它会扩 API,留给 P5 单独做。
403
+
404
+ ---
405
+
406
+ ## [0.10.0] - 2026-10-25
407
+
408
+ > ⚠️ 定版说明:本节原来挂在 `[Unreleased]` 下,还带着一句「0.9.0 的内容已定版,见下一节」
409
+ > —— **那句是错的**。`v0.9.0` 标签停在 `d29a151`(10-17),而下面这些内容
410
+ > (reveal / detail-page / 视觉守卫 / 动效分级 / pkg-name)都是之后做的,
411
+ > 一共 13 个提交。它们属于 **0.10.0**:期间新增了两个组件,按语义化版本是**次版本**。
412
+
413
+ ### Added · `pkg-name` 门禁(包名一致性)
414
+
415
+ - 背景:包名从 `frontend-lib` 改为 `agentcss`,但类型测试仍用**旧名**做
416
+ `paths` 映射 ⇒ 类型门禁验的是旧名,用户真会写的 `from 'agentcss'`
417
+ 一次都没被编译过。
418
+ - 四条判据:① tsconfig 的 `paths` 键必须只有包名;② 类型测试的 import
419
+ 说明符必须是包名;③ 代码/CLI 里不许把**仓库名**当包名用(仓库名从
420
+ `repository.url` 推出,改名时判据自动跟着变);④ README / START-HERE
421
+ 的**代码块内**安装命令必须是本包或 `github:` 形式。
422
+ - 判据 ④ 第一版写成"所有文档都查",当场误报 `RELEASING.md` 的历史记录
423
+ ("当时写的是 npm install frontend-lib")⇒ 收紧为只查 README/START-HERE
424
+ 且只在 fenced 代码块内(E36「判据第一版过宽」的同类修正)。
425
+ - 自带 `--selftest`:6 个变异体 + 1 条**良性对照**(正文引旧命令必须仍然绿)。
426
+ - 顺带把 `type-tests` 的包名、`types/index.d.ts` 的用法示例、`ai/cli.js`
427
+ 的用法串统一到 `agentcss`;`package.json` 补上 `bin`(`agentcss ai …`
428
+ 终于能当命令用,此前只有 `node ai/cli.js`)。
429
+
430
+ ### Fixed · reveal 的白屏兜底(huxx 第 5 条落地)
431
+
432
+ - **真实缺陷**:`enable()` 先把 `data-reveal-ready` 打到 `<html>` 上,
433
+ **再** `new IntersectionObserver`。构造函数一旦抛错(部分 WebView 有此
434
+ 问题),隐藏态已经生效,而 `revealAll()` 永远跑不到 ⇒ **整页空白**。
435
+ - 修法两条:① 构造 + 注册整体放进 `try`,`catch` 里 `revealAll()` 全显;
436
+ ② 加 2 秒看门狗,只补「**已经在视口内**却还没显示」的元素
437
+ —— 不直接全显,否则折叠线以下的内容会提前播完,reveal 退化成一次性淡入。
438
+ - `reveal-contract` 新增判据「白屏有兜底」(try 包住 / catch 全显 /
439
+ 看门狗 / 真补元素 四条同时成立),并配变异体 ⇒ 11 条判据 + 12 条自检。
440
+
441
+ ---
442
+
443
+ ### Added · reveal(入场动效)
444
+
445
+ - 新组件 `03-patterns/reveal`:一个 `data-reveal` 属性撑六个方向,
446
+ 六方向共用一套 `transform`(只改三个变量,不写 keyframes)。
447
+ - 位移上限 8px、时长只走 `--dur-*`、错峰 40ms 封顶 6 个、不做出场动画。
448
+ - **无 JS 全可见**:隐藏只在 `html[data-reveal-ready]` 下生效,引擎默认不接管。
449
+ - 与参考实现的两处偏离:默认只播一次(重播需 `data-reveal-repeat`);
450
+ 引擎需显式 `data-reveal-auto` 或 `Reveal.enable()` 才接管。
451
+ - 专属门禁 `reveal-contract`(10 条 + 11 条自检)。
452
+
453
+ ### Added · detail-page(实体详情页骨架)
454
+
455
+ - 新配方 `04-recipes/detail-page`:字段齐全 / 部分缺失 / 无数据三态**同构**。
456
+ - 无数据是一等状态:不隐藏、不用 0 冒充、必须写清原因。
457
+ - `detail-page-contract`(标签序列逐项比对 + 视觉覆盖核对)、
458
+ `no-data-gate`(无数据态三规则)。
459
+
460
+ ### Added · 视觉与动效的两道守卫
461
+
462
+ - `visual-coverage-gate`:视野内的 `demo.html` 必须都在 shot 基线清单里
463
+ (首跑即抓到 `04-recipes/report` 从无基线)。
464
+ - `visual-stamp-gate`:基线指纹必须等于当前代码指纹;不再只是 WARN。
465
+ - `motion-policy.json` + `motion.py --reduced-audit`:减少动效从"一刀切"
466
+ 升级为可检查的策略(保留清单 + 未登记保留即红)。
467
+
468
+ ### Changed · 视觉基线
469
+
470
+ - 重录浅色/暗色基线到当前代码(原先浅色录于 `6f045ff`、暗色录于 `4962b2c`,
471
+ 而 HEAD 已到 `208782f` —— 两页红着过了 5 天)。
472
+ - `shot_name` 支持同目录多页(此前只按目录命名,四个变体互相覆盖成一张图)。
473
+
474
+ ### Fixed · 三处门禁自身缺陷
475
+
476
+ - `coverage-gate` / `admission-gate` 只认 `*-check.js` / `*-contract.js`
477
+ ⇒ 用 Python 写的契约不被承认,明明有判据却报"无契约"。
478
+ - `shot-baseline` 同目录多页互相覆盖(见上)。
479
+ - `api-form-gate` 的诊断提示只怀疑"生成器抓错了",漏了"探针表没登记该页面"。
480
+
481
+ ### Fixed · 首次使用的四个文档/配置缺口(实测得出)
482
+
483
+ 用「装 tarball → 只读文档 → 写一个真实移动端页面」的方式跑了一遍,撞到四处静默失效:
484
+
485
+ - **`.input` 这个类不存在**(真实是 `.field__input`,且必须包在 `.field` 里)。
486
+ 写了不报错、不警告,只是拿到浏览器默认样式(高度 21px vs 组件 44px)。
487
+ → `input` README 顶部加了醒目警告 + 最小结构。
488
+ - **`.card` 必须包 `.card__body`**(自身 `padding: 0`,内边距在子元素上),
489
+ 而原 README 只有设计论证、没有用法。→ 补 `## 用法`(含"直接放内容会贴边"的说明)。
490
+ - **`.card` 是 flex column ⇒ 行内小元素被拉伸**(实测状态标签从 54px 拉到 355px)。
491
+ → 在 card README 写明原因与两种解法。
492
+ - **`page.css` 没有 exports 入口**:`import 'agentcss/page.css'` 报
493
+ `ERR_PACKAGE_PATH_NOT_EXPORTED`,只能写 `agentcss/dist/01-tokens/page.css`,
494
+ 而它是所有组件的依赖。→ `exports` 补 `"./page.css"`。
495
+
496
+ 另:**README 补「组件一览」**(33 个,按 primitives / patterns / recipes / assets 分组,
497
+ 含根类名与用途)。此前用户只能翻目录才知道有什么。
498
+
499
+ > 这四条都不是"功能坏了",是**信息没到位**。判据能保证"结构对了不会坏",
500
+ > 保证不了"用户知道结构是什么"。
501
+
502
+ ### Fixed · 主入口从未进过 tarball(外部审计第 1、3 条)
503
+
504
+ `exports["."].default` 指向 `./index.html`,而 `files` 白名单里没有它。
505
+ 三个盲点**同时**假绿,于是这件事活了 11 个版本。
506
+
507
+ - `package.json` 的 `files` 补 `index.html`。实测证据:修复前 `npm pack`
508
+ 出 **269 个文件、`index.html` 0 个**,而 `pack-smoke` 照常打
509
+ 「OK exports 的每个入口都真实存在」。
510
+ - **新增门禁 `pack-closure`(fast 层,约 1.5 秒)**:拿
511
+ `npm pack --dry-run --json` 的**权威清单**,递归判定 `exports` 的每一种形态
512
+ (字符串 / 条件对象 / 嵌套条件 / 数组回退链 / 通配),外加随包 HTML 的本地
513
+ 引用、文档里给使用者的 `node_modules/<包名>/…` 路径。
514
+ 外部审计第 3 条要的正是这一项。
515
+ - `pack-smoke` 第 4 步改调 `pack-closure --dir`,判**真装完之后**的树;
516
+ 原先自己那份 exports 判定删掉 —— E31:同一判定写两遍必然漂移。
517
+ - `release-gate` [7] 明确写出"本节只判仓库内存在,包内清单由 pack-closure 判";
518
+ `_walk_targets` 改成递归(原来只认条件对象第一层的字符串,
519
+ 嵌套条件与数组回退链被静默漏掉 —— 同一个"判据只认一种形态"的病)。
520
+ - `a11y-scan` 增加假绿守卫:清单里有 N 页、**成功加载 0 页**时判失败,
521
+ 不再按"零违规"返回成功。
522
+ - 判据自检 11 条,含"注释里的 `<script src>` 不算引用"、
523
+ "目标目录只含子目录也算非空"这两个真踩过的坑。
524
+
525
+ ## [0.9.0] - 2026-10-17
526
+
527
+ ### Added
528
+ - **`eol-gate.py`:行尾门禁(D4 的防守,不只是搬运)**。
529
+ D4 当时做的是"把现有 CRLF 转成 LF",**没有堵住生成器会翻回去** ——
530
+ 实测复发三次:`theme-sync` 写 tokens.css、`make-tiersnippet` 写 tierA、
531
+ `shot-baseline` 写 `_stamp.json`,根因都是 `open(..., "w")` 在 Windows 上
532
+ 默认把换行写成 CRLF。
533
+
534
+ 判据两条:`git ls-files --eol` 的**索引侧**与**工作区侧**都不得是
535
+ `crlf` / `mixed`(二进制 `-text` 与空文件 `none` 不计)。
536
+ 零视野、git 不可用一律 **fail-closed**。
537
+
538
+ 反向控制**不依赖"仓库里恰好有个坏文件"**(E50 记过:那种前提会随仓库
539
+ 状态漂移)—— 用伪造的 `--eol` 输出测 9 种情况(索引侧脏/只有工作区脏/
540
+ mixed/二进制/空文件/混在正常文件里/带 attributes/多空格对齐/不可解析)。
541
+
542
+ - **颜色令牌分两层(D5)+ `layer-tokens.py` 结构门禁**。`:root` 里 30 个颜色字面量
543
+ 搬进 L1 区块(`--raw-paper: #f6f7f8;`),语义令牌改成 `var(--raw-paper)`;
544
+ **暗色段只覆盖 L1** —— 语义层是引用会自然跟随 ⇒「暗色段漏写某个语义令牌」
545
+ 在结构上不可能发生。
546
+
547
+ L1 **不进对外契约**:`ai/tokens.json`、`ai/tokens.tree.json`、`tierA-tokens.css`
548
+ 全部在导出前解析成字面值 ⇒ 三个产物**与分层前逐字节相同**(`git diff` 为空)。
549
+
550
+ 新门禁五条判据:`:root` 里不许再出现「语义名 = 颜色字面量」/L1 必须被引用/
551
+ 引用的 L1 必须存在/暗色段只许覆盖 L1/L1 与 L2 必须 1:1。反向控制 5 条全过。
552
+
553
+ - **`_css.mask_comments()`**:把注释替换成**等长空白**(保留换行),偏移量与原文一致。
554
+ 定位 CSS 结构前必须先遮蔽 —— `strip_comments` 会改长度,之后所有下标都错位;
555
+ 而不遮蔽就会命中注释里的示例(见 Fixed 第一条)。
556
+
557
+ - **`visual-dark`:暗色视觉回归**。此前 `visual` 34 页逐像素**只拍浅色** ——
558
+ 暗色下"整页看不清 / 某块浮层糊掉"这类回归**没有任何门禁能抓**
559
+ (`token-resolved` 覆盖的是令牌解析值,覆盖不到阴影叠加、渐变、伪元素、
560
+ 图片资源在暗色下的实际观感)。现在两套基线并列,暗色走 `--scheme dark`。
561
+ ⚠️ 截图时**两条暗色路径一起设**:`emulateMediaFeatures(prefers-color-scheme: dark)`
562
+ 走 `@media` 那段,`data-theme="dark"` 走 `[data-theme]` 那段 ——
563
+ 它们是两份代码,只设一个等于只测一半。
564
+ 反向控制:把暗色 `--paper` 由 `#14171A` 改到 `#2F353C` ⇒ 暗色 34 页全红、浅色仍 34/34 绿。
565
+ - 视觉门禁的解释器选择改为「`$PY` 没装 Pillow 时可用 `PY_PIL=<解释器>` 指定」,
566
+ 并在输出里**写明**是哪个解释器在跑 —— 否则"这道门禁跳过了"只能靠猜。
567
+
568
+ - **`05-audit/token-resolved-gate.js` —— 暗色的「解析值」门禁**(W19)。
569
+ 此前明暗有四道判据(`theme-sync` 比文本、`theme-css` 比渲染但只探 2 个令牌、
570
+ `token-tree` 比声明文本),仍漏一类:
571
+ **声明合法、两条路径文本一致,但浏览器根本没让它生效。**
572
+ 本库实测撞过两次(`--lift-*` 被未闭合注释吞掉;裸声明写在 `@media { }` 体内被丢弃
573
+ ⇒ 暗色静默回落浅色)。门禁取 107 个令牌 × 4 种明暗组合的 `getComputedStyle` 结果,
574
+ 判五条:暗色两条路径等价 / 浅色两条路径等价 / **声明即生效** / 无空值 / 与基线逐条一致。
575
+ 4 条反向控制,其中「声明全在但两条路径都不生效」那条刻意**不能**靠删声明来模拟 ——
576
+ 那会让判据的输入集合变空,不红只是因为没东西可查。
577
+
578
+ - **`10-review/webview/capability-probe.html` —— 真机能力探针**(W10 / W12)。
579
+ 自包含单文件(不引本库 CSS、不引外部资源):拷到手机上直接打开就能用。
580
+ 15 项能力**全部来自源码实际用法**(`:has()` / `clamp()` / `popover` / `flex gap` …),
581
+ 其中 4 项是**行为实测**而不是 `CSS.supports()` 的声明式回答 ——
582
+ `@layer` 用"塞一张样式表看 CSSOM 里还剩几条规则 + 层内颜色是否真的应用"来判。
583
+ 结果以 JSON 输出,直接贴进 `docs/LAYER-BASELINE.md`。
584
+
585
+ - **`docs/LAYER-BASELINE.md`** —— 切 `@layer` 之前的基线落档处。
586
+ 含测试方法(三条路径)、判定规则("每一台都 15/15 才能无条件切")、
587
+ 以及一张 8 列记录表。**当前 0 行数据 —— 状态是 UNVERIFIED,不是通过。**
588
+ 刻意不写任何引擎版本号:没实测过的版本号会被下一个人当成事实引用。
589
+
590
+ - **`05-audit/webview-probe-gate.py`** —— 探针与源码用法的**双向**漂移门禁。
591
+ 库新增 `:has()` 而探针没加 ⇒ 红("基线全绿"覆盖不到它);
592
+ 探针测了库里已不用的能力 ⇒ 也红(每台真机都填不满,人就会开始挑着填)。
593
+ 另查:探针必须自包含、基线表不许有 `TBD` / `?` 这类占位符、
594
+ 标 `VERIFIED` 的行必须带**完整 UA**("iPhone 15"不是 UA,别人复核不了)。
595
+
596
+ - **`05-audit/webview-probe-check.js`** —— 探针页在 Chromium 里真跑一次。
597
+ 静态判据只能证明"清单写全了",证明不了探测函数没写坏;
598
+ 探测器集体失灵时静态判据会全绿,而每台真机都会得到"全不支持"。
599
+ 反向控制:把所有 `supports()` 改成恒 `false` ⇒ 5 秒内被抓。
600
+
601
+ - **`benchmark/records.js` + `benchmark/models/reference.json`** —— 跑批记录校验(W21)。
602
+ 记录 v2 格式(`schemaVersion` / `modelVersion` / `recordedAt` / `attemptsPerTask` /
603
+ `attempt` 编号),八条判据。参考记录由 `--write-reference` 从 TASKS 生成,
604
+ `--check-records` 要求它 **10/10** —— 它验的是"记录 → 评分"这条管线本身。
605
+
606
+ - **`mutation-leak-gate.py`** —— 变异串泄漏检测。
607
+ 反向控制的机制是「原地改写源码 → 跑门禁 → 还原」,
608
+ 中断时源码会留在被变异的状态,而**没有任何报错**、门禁照样绿、
609
+ 下一次 `git add -A` 就提交了。这道门禁每轮核对一遍。
610
+ 配 3 条反向控制(会红 / 不误报)。
611
+
612
+ - **`purge-css.js` + `purge-css-selftest.js`** —— 「按用到生成」出口。
613
+ 扫页面 DOM,只输出命中的 CSS 规则。只删**确定未命中**的;
614
+ `unknown`(querySelector 抛错)/ `dynamic`(伪类)/ `@keyframes` 一律保留。
615
+
616
+ - **`09-assets` 进了 `dist` 构建** —— `build-dist.py` 的 `SRC_DIRS` 漏了它,
617
+ 四支资产 CSS 从来没有过产物,于是 `exports` 的 `./assets/*` 只能指向
618
+ 带 64% 注释的源码目录。现在 min/stripped 各 39 支(原 35)。
619
+
620
+
621
+ - `rewrite-residue-gate.py`:抓「批量替换脚本留下的拼接痕迹」
622
+ (`REPO + REPO +` / `X + 'a' + 'b'` 这类只可能来自坏正则的写法),带反向控制。
623
+ - `SECURITY.md` 与 `CONTRIBUTING.md`。
624
+
625
+ ### Fixed
626
+ - **`shot-baseline.py` 写 `_stamp.json` 用 CRLF**(D4 第三次复发)。
627
+ 截图基线录制器的三处 `io.open(..., "w")` 都没指定 `newline`,
628
+ `_stamp.json` 因此带 CRLF 进了库。已修,并把该文件转回 LF。
629
+ 根本防止见 Added 里的 `eol-gate.py`。
630
+
631
+ - **`theme-sync.py` 定位到了注释里举的 `@media` 例子 ⇒ 生成 0 个令牌却判"一致"**。
632
+ `RE_MEDIA_DARK` 命中注释里那段(讲"为什么不用 `light-dark()`"的示例),
633
+ 再靠 `[^{]*` 吃到后面一个**空规则块** ⇒ `[data-theme="dark"]` 被生成成空块,
634
+ 而 `--check` 比对"两段一致",两边都是空 ⇒ **0 == 0 通过**。
635
+ 已改为先 `mask_comments()` 再定位(5 处)。这类"输入集合塌缩成空集"的假绿
636
+ 比直接报错难发现得多 —— 判据里凡是"两个集合必须一致"的,都得同时断言集合非空。
637
+
638
+ - **`token-tree-gate` 的 ⑤b 读的是 CSSOM 规则文本,不是解析值**。
639
+ 分层后 CSSOM 里 `--paper` 就是 `var(--raw-paper)`(自定义属性保留原始 token 流),
640
+ 与导出的 `#f6f7f8` 永远对不上。
641
+ 探针新增 `resolvedLight` / `resolvedDark`(`getComputedStyle` 会真的做替换),
642
+ ⑤b 改用它;导出值是 `var()` 的别名令牌(`--popover-p: var(--sp-3)`)跳过值比对 ——
643
+ 别名关系由 ②③ 与类型判据负责。
644
+
645
+ - **`contrast.py` 暗色模式只读 `@media` 之后那一段**。分层后暗色段只有 L1,
646
+ 语义令牌(`--paper`)全被报"缺" ⇒ 4 组对比度**静默漏测**。
647
+ 改成按 **CSS 层叠语义**取全文(后出现的覆盖前面出现的)。
648
+
649
+ - **`tierA-tokens.css` 里全是"看不见的空行"**。`_strip_comments` 把注释替换成
650
+ 等长空格(为了保下标),生成器把那些行原样写出去了 ⇒ 17.6 KB 里绝大部分是空白。
651
+ 现在丢掉"纯空白但非空"的残行并折叠连续空行:**17,664 → 4,971 字节(−72%)**,
652
+ 令牌数不变(浅色 99 + 暗色 30)。
653
+
654
+ - **两个生成器写文件默认 CRLF**(`theme-sync` / `make-tiersnippet`)。
655
+ D4 已经把全库统一成 LF,而生成器每跑一次就翻回 CRLF —— 两道工序互相拆台。
656
+ 已全部显式 `newline='
657
+ '`(见 `ERRORS.md` E48)。
658
+
659
+ - **探针页的「复制结果」在手机上必然失败** —— 它只用 `navigator.clipboard`,
660
+ 而那个 API **只在安全上下文存在**(https / localhost / file://)。
661
+ 手机访问 `http://<内网地址>:端口` 时它是 `undefined` ⇒ 按钮直接弹"复制失败"。
662
+ 我交付前的验证用的是 `file://` 与 `127.0.0.1` —— **两个都是安全上下文**,
663
+ 于是测成了"成功"。⇒ 已登记 `ERRORS.md` E47。
664
+ 现在:主路径改成**同源 POST「发送到电脑」**(不碰剪贴板、不依赖安全上下文),
665
+ 剪贴板降为备用并补 `document.execCommand('copy')` 兜底;
666
+ 前两级都失败时用 `Range` **替用户全选**文本 —— 否则"长按复制"只会选中一个词,
667
+ 那句提示等于没说。
668
+
669
+ - **`--record <名字>` 指向不存在的文件时只 WARN 就 RC=0** ——
670
+ 等于"声称跑了批,其实没跑"。现在 RC=2,并明说这是 UNVERIFIED。
671
+ - **空记录算出"通过率 0/0"仍 RC=0** —— `runs || []` 的后果。现在判据 ③ 直接判红。
672
+ - **模型的坏补丁会让整份跑批崩掉** —— 给 T03 喂空 `ops`,`expect()` 读
673
+ `nodes.b.children` 抛 `TypeError`。`patch.apply` 与 `expect` 已包 try/catch,
674
+ 异常计为"这次没过"(外部输入必须容忍乱写)。
675
+ - **`selftest-all.py` 只扫 `.py`** —— JS 门禁的自检从来没被它发现过。
676
+ 改成按扩展名认解释器;判据落在**调用点**(`argv.indexOf('--selftest')`),
677
+ 不是文档里出现过这个词就算。26 → 37 个自检。
678
+ - **`cascade-layer-gate` 的「切层必须有基线档」只判文件存在** ——
679
+ 基线文档一被创建就变绿,而里面一行实测都没有。
680
+ 已收紧成"至少一行 `VERIFIED`",并把自检的前提改成**显式注入**而不是读磁盘。
681
+ - **`benchmark` 三条门禁被误判成 component 层**(命令含 `node` 就判组件层)
682
+ —— 快跑时全被跳过。它们是纯 Node,已标 `@static`。
683
+
684
+ ### Changed
685
+ - **`dist/` 的 CSS 只交付一份,且内容是压缩版**(D1)。
686
+ 此前 `dist/` 同时产出 `x.css`(去注释)与 `x.min.css`(压缩),而源码目录也在包里
687
+ ⇒ 同一批 CSS 在包内出现**三次**(未压缩 564.4 kB,占解包 19.3%)。
688
+ 现在 `dist/x.css` **就是压缩版**,`x.min.css` 不再产出。
689
+ - 路径不变:`import '…/button.css'` 照旧可用
690
+ - 写死 `…/button.min.css` 的地方,去掉 `.min` 即可
691
+ - 要可读版仍走 `./src/*`(源码,注释齐全)
692
+
693
+ ⚠️ **收益有两个口径,差 24 倍,如实写在这里**:
694
+ **解包** 632 → 427 KB(−205 KB,−32%);**tarball** 650.3 → 641.8 KB(−8.5 KB,−1.3%)。
695
+ 原因是 tar.gz 是单一压缩流,两份近乎相同的 CSS 会被高效压掉。
696
+ 此前"占解包 19.3%"的说法没错,但它**不等于**下载体积能省 19%。
697
+
698
+ 验收:`dist-parity` 39 个产物与源码 CSSOM 逐条一致;
699
+ 明暗两套视觉回归 34 / 34 全一致(压缩后**连像素都没变**);
700
+ `pack-smoke` 从 tarball 实装、`exports` 每个入口都真实存在;
701
+ 暗色对比度 1237 个文字元素 0 个不足。
702
+
703
+ - 门禁 **143 → 150 道**(`token-resolved` / `webview-probe` / `webview-probe-run` /
704
+ `benchmark-records` / `benchmark-records-rev` / `dist-js-parity-rev` / `visual-dark`)。
705
+ - `benchmark/results/` 不入库:`models/` 是证据,`results/` 是它的重算产物。
706
+ - **`docs/LAYER-BASELINE.md` 已有 5 行真机数据,结论是"不能无条件切层"**:
707
+ iOS Safari 15/15 · iOS 微信 15/15 · iOS 企微 15/15 · Android 微信 XWEB 14/15 ·
708
+ **Android 百度浏览器 11/15 —— 缺 `@layer`(Chromium 97)**。
709
+ `@layer` 不支持时**整层规则会被丢弃**(不是"层序不对",是"那一层的样式没了"),
710
+ ⇒ 只能 `@supports at-rule(@layer)` 双写,或者不切。
711
+ ⚠️ 基线表只回答"支不支持",不替产品回答"值不值得为它放弃切层"。
712
+ - 基线判据被数据纠正过一次:原来把"缺任何一项库在用的能力"都算成不能切层,
713
+ 于是微信缺一个 `scrollbar-width` 就会被判死 —— 而那个缺失与层叠毫无关系。
714
+ 现拆成「阻塞切层(只认 `@layer` 本身)」与「不阻塞但需逐条判定有无回退」。
715
+ - **修正一处探针的误映射**:CAPS 表原来写 `("popover", "Popover API")`,
716
+ 而 `popover` 在源码里命中的全是**自研类名 `.popover`**(配 `data-open`),
717
+ 不是原生 Popover API —— 实测 grep `showPopover(` / `popover=` / `[popover]` 全无命中。
718
+ ⇒ 该项与"库能不能用"无关,已从 CAPS 移到 ALLOW 并写明理由。
719
+ - 探针页的「发送到电脑」加了 `navigator.sendBeacon` 兼容通道 ——
720
+ 部分内置 WebView 对 `fetch` 有额外限制,`sendBeacon` 走另一条投递路径。
721
+
722
+
723
+ ---
724
+
725
+ - **`git tag` 纪律**:每个版本打 **annotated tag**,并由 `release-gate`
726
+ 的 `[10] git tag 锚点` 判据守着(核对 tag 指向的提交里 `VERSION` 就是那个版本 ——
727
+ 只查"tag 存在"的话,给任意提交打个 tag 也能过)。
728
+ 实测:本库此前 **0 个 tag**,`v0.8.8` 是补打的,锚点取 `cf385fe`
729
+ (`git log -S'"version": "0.8.8"' -- package.json` 的唯一命中)。
730
+ `v0.8.8` 之前的版本**不补打**:那些提交不在可达历史里(历史被 squash 过),
731
+ 按日期猜锚点会造出**假锚点**,比没有锚点更糟。
732
+
733
+ - **`_changelog.py`** —— CHANGELOG 版本标题的**唯一解析器**。
734
+ 此前 `bump-version.py` 与 `doc-facts-gate.py` 各写了一份正则(E31:
735
+ 同一个判定写两遍必然漂移),而它们**确实漂了**:见下面 `Fixed` 的第一条。
736
+
737
+ - **`tag-balance.py` 与 `wiring-check.py` 接进门禁**。
738
+ 两者此前是**零引用的孤儿**(外部审计 P1-8)—— 按本库自己的话
739
+ 「代码说自己会检查,但没有任何门禁跑它」不算门禁。
740
+ · `tag-balance`:HTML 标签配平。接进来当场抓到上面那 3 个真问题。
741
+ · `wiring-check`:属性用了、配套的类名/脚本引了吗、引的文件真的在吗。
742
+
743
+ - **仓库治理面**:`.github/CODEOWNERS`、`PULL_REQUEST_TEMPLATE.md`、
744
+ `ISSUE_TEMPLATE/`(bug / feature)、根级 `CODE_OF_CONDUCT.md`、
745
+ `.gitattributes`(文本统一 LF)。
746
+ ⇒ `.gitattributes` 的理由:本库大量判据做**字节比对**
747
+ (dist-parity 比 CSSOM、pack-smoke 比 tarball、visual 比截图),
748
+ 而 Windows 上 `core.autocrlf=true` 会让同一份内容在磁盘是 CRLF、索引是 LF
749
+ ⇒ 门禁报的"不一致"可能纯粹来自行尾。
750
+
751
+
752
+ ### Changed
753
+ - **包体 −29.8%**:`09-assets/model-viewer/vendor/`(589.3 kB 的 `three.min.js`
754
+ 等第三方整包)与 `models/*.glb` 不再进包。
755
+ 该组件 README 自己就写着「**不把 three.js 打包成本库依赖**…
756
+ three.js 本身由主项目负责」——之前与自述冲突。
757
+ 实测 tarball 899.1 → 631.3 kB、unpacked 2917.0 → 2015.1 kB。
758
+
759
+ - **`css-imports` 与 `dead-class` 从「只报告」升为真门禁**。
760
+ 两者此前挂在 `run_report` 下 ⇒ **永远 PASS**(退出码恒为 0),
761
+ 而 `css-imports` 抓到过真问题(某页用 `.btn--primary` 却没引
762
+ `button.css` ⇒ 暗色下 **2.45:1**,实测值)。一道"永远 PASS"的门禁等于没有门禁。
763
+ 升级前提是误报降到 0:
764
+ - `css-imports`:新增判据「本页内联 `<style>` 里自己定义的 class
765
+ 不算缺引用」⇒ 实测误报 2 → **0**(36 页)
766
+ - `dead-class`:新增判据「README 类名表里出现过的 class
767
+ 是已文档化的能力,不是死类」⇒ 实测真死 **0** 个
768
+
769
+ - **`doc-facts-gate` 新增「源码体积 / 注释占比」判据** + 零文件守卫。
770
+ 同一件事有两个相差 11.7 个百分点的口径(字节 64.3% vs 字符 52.6%),
771
+ 现在输出里写明是**字节口径**。
772
+
773
+ - **`dist-cost-gate` 里两处硬编码的 `64%` 改为实算**。
774
+
775
+
776
+ - **`exports` 默认指向去注释后的产物**。原来 `import 'agentcss/tokens.css'`
777
+ 拿到的是源码版(39.6 KB,其中 31.6 KB 是注释)⇒ 打包工具会把这些字节原样传下去。
778
+ 现在默认给 `dist/` 版(8.0 KB),源码版走 `agentcss/src/*`。
779
+ - **`exports` 补上 `"."` 与 types 条件**。已用真实 `tsc`(`moduleResolution: nodenext`)
780
+ 实测:补之前 `import type {} from 'agentcss'` 报 TS2307 找不到模块,
781
+ 补之后通过。
782
+ - **CI 的「静态门禁」不再手挑清单**,改为调用 `MODE=fast bash 05-audit/check-all.sh`。
783
+ 原来那里手挑了 10 道,而 check-all.sh 注册了 135 道
784
+ ⇒ 上一轮修好的 7 道假绿门禁与新增的 `rewrite-residue-gate` **在 CI 上一道都没跑过**。
785
+
786
+ - **`MODE=component` 真正实现了**。它此前**只写在文档里**、
787
+ 代码里没有任何地方读它 ⇒ 实际等价于 `full`("文档说做了、实际没接")。
788
+ 三个分层现在是:`fast` 只跑静态层 / `component` 只跑组件层 / `full` 全量。
789
+
790
+ - **分层判定改成三级**,不再只看命令文本:
791
+ ① 命令行标记 `@component` / `@static`(同一个脚本分两种层时用)
792
+ ② 命令文本含 `node` / `puppeteer` / `playwright` / `with-server`
793
+ ③ **脚本自己声明**:文件头有 `# LAYER: component`
794
+ 理由:原来只有 ② ⇒ 两个重型门禁漏网 —— `beh-reverse`(`bash …`
795
+ 但内部起浏览器)与 `pack-smoke`(`bash …` 但内部跑 `npm pack` + 真装一次)
796
+ 的命令文本里都没有 `node` ⇒ `MODE=fast` 照样跑它们(各几十秒,"秒级档"名不副实)。
797
+ 反之 `accent-gate` / `selftest` 需要 `:8000` 却因为文本里没有 `node`
798
+ 被判成静态层 ⇒ fast(纯静态、不起服务)里一路 FAIL(实测)。
799
+
800
+ - **CHANGELOG 改成 Keep a Changelog 的标准形状**:
801
+ 版本标题从自创的(破折号连接)改成 Keep a Changelog 的方括号形式
802
+ `[版本号] - YYYY-MM-DD`;
803
+ 去掉重复的第二个 H1(`# 更新日志`);`[Unreleased]` 段的小节归并去重;
804
+ 版本段内部的 2 个 H2 降级为 H3(其中 `0.4.0 的第二部分:…` 曾被解析器
805
+ **误认成版本条目**)。由 `release-gate` 的 `[11] CHANGELOG 结构` 判据守着。
806
+
807
+
808
+ ### Fixed
809
+ - **反向控制的变异串泄漏进了正式源码** ——
810
+ `03-patterns/overlay/overlay.js` 里 `unlockScroll()` 的
811
+ `removeProperty('--overlay-scrollbar-width')` 被换成了 `void 0;`
812
+ (那正是 `overlay-host-reverse.py` 里 MUTANTS[3] 的变异串)。
813
+ ⇒ 弹层关闭后 `<html>` 上残留 `--overlay-scrollbar-width`,
814
+ 宿主写成 `right: var(--overlay-scrollbar-width, 0px)` 的 `position: fixed`
815
+ 元素会**首次开弹层后永久偏移一个滚动条宽度**,不复位、不报错。
816
+ ⇒ 已修,并新增 `mutation-leak-gate.py` 防止复发(见 Added)。
817
+
818
+ - **`tokens.css` 有一条注释没闭合**(第 288 行开、294 行才闭)
819
+ ⇒ `--lift-sm/md/lg` 三条令牌与它后面 60 多行注释被浏览器整段当注释吃掉。
820
+
821
+
822
+ - **`doc-facts-gate --selftest`**:新增的零文件守卫在 selftest 的 fixture
823
+ 环境(故意不建 `.css`)里误报 ⇒ 两条反向控制失败。
824
+ ⇒ 加 `FL_SELFTEST` 让路并在 `finally` 清掉。
825
+ **教训:新增判据后必须跑一次 `--selftest`。**
826
+
827
+ - **`dead-class-gate` 的参数解析**:原来读 `sys.argv[1]` 当路径,
828
+ 而 `--selftest` / `--strict` 恰好在那个位置 ⇒ `os.path.abspath('--selftest')`
829
+ ⇒ root 指向不存在的目录 ⇒ **什么都没扫到 ⇒ 判「没有真死类」**。
830
+ 这类"参数解析 bug 伪装成判据失效"也会伪装成假绿。
831
+
832
+ - **`release-gate` 的 `files` 判据**原来没区分**包含项**与**排除项**,
833
+ 会把 `!path` 也算进"必须存在"⇒ 惩罚一个正确的排除项。
834
+ 现在排除项检查的是「它指向的东西确实存在」(否则这条排除毫无意义)。
835
+
836
+ 本次补上闭合符,并给 `token-parse-gate` 加判据 A'
837
+ (判据 A 只查「孤立的 `*/`」,查不出「未闭合的 `/*`」—— 已实测确认它对原文件不报)。
838
+
839
+ - **3 个 demo 的 HTML 标签不配平**(`<main>` 被写成了 `</div>` 闭合、
840
+ `form-validation` 的 `<main class="wrap">">` 多出一个 `">` 会当文本渲染出来、
841
+ `content` 的 `<main>` 根本没闭合)。
842
+ 浏览器会**自动纠正**,所以页面"看起来正常"、不留痕迹 ——
843
+ axe 与静态类名门禁都抓不到,只有标签配平能。
844
+ ⚠️ 这是接进 `tag-balance` 那道门禁之后当场抓到的。
845
+
846
+ - **反向控制在被中断时会留下污染**(P0-1 的根本解法):
847
+ `overlay-host-reverse.py` / `combobox-instance-reverse.py` 在 `finally` 里
848
+ 还原源码,但进程被杀时来不及跑 ⇒ 源码留在**被变异的状态**。
849
+ ⇒ 新增**就地还原校验**:写完立刻比对 sha256,不一致就当场喊 + 再写一次 +
850
+ 判失败(用 sha256 而不是比长度:变异串与原串**可能等长**,那时长度校验完全失效)。
851
+
852
+ - **`bump-version.py` 读不到 CHANGELOG 的版本号**(改格式后暴露):
853
+ 它的老正则 `^##\s+(\d+\.\d+\.\d+)` 只认自创格式,改格式后**一个都匹配不上**
854
+ ⇒ 往下扫到了正文里的 `0.4.0 的第二部分:…` ⇒ 读出「CHANGELOG = 0.4.0」
855
+ 与 `package.json` 的 0.8.8 不符 ⇒ `version-sync` 红。
856
+ ⇒ 改用共享解析器(并修掉"正文 H2 被当版本标题"这个一直存在的洞)。
857
+ 同类问题还波及:
858
+ · `doc-facts-gate` 的 F5(版本条目必须带日期)失效
859
+ · `doc-facts-gate` 的 `version_at()` 恒返回 None ⇒
860
+ 整段 CHANGELOG 检查**静默空转**(判据还在跑,什么都没查)
861
+
862
+ - **`check-all.sh` 的结论行打在中段**:`全部通过。` / `N 道门禁失败。`
863
+ 原本在脚本中段,而它后面还有约 20 道门禁 ⇒ 日志里会先打「全部通过。」
864
+ **再接着出现 FAIL 行** —— 读到的人不会往下看。
865
+ ⇒ 结论与「本轮:执行 N · 跳过 M」一起移到 `exit $fail` 之前。
866
+
867
+ - **`check-all.sh` 的跳过计数漏算**:`MODE=fast` 的汇总写「执行 125 道」,
868
+ 而实际是 142 道(68 PASS + 2 FAIL + 72 SKIP)。原因:跳过分两种来源
869
+ (分层跳过 / 环境缺失),计数只加在其中一处。
870
+ ⇒ 计数移进 `skip()`,一处覆盖两种。
871
+
872
+ - **`tag-balance.py` 的两个洞**(接进门禁前先修的):
873
+ ① 报的行号是**"剥离后文本"的坐标**(它把 script/style/注释挖掉时把换行也吃了)
874
+ ⇒ 报「期望 `</main>`(开在 L15)」,而 L15 是 CSS 规则 ⇒ 人定位不到。
875
+ ② **剥注释与挖空的顺序反了** ⇒ 注释里那句
876
+ `<!-- 这里**没有** <script src="three.min.js"> … -->` 被当成真的 `<script>` 开始,
877
+ 非贪婪匹配到**几十行之后**的真 `</script>` ⇒ 中间的真实标签被整体挖掉 ⇒
878
+ 报出指向错误位置的**假红**(`model-viewer` 就是这么被误报的)。
879
+
880
+ - **`wiring-check.py` 的两个洞**:
881
+ ① 扫描范围是 `**/*.html` ⇒ 把 `node_modules/` 里的第三方页面也扫进来,
882
+ 报出一堆 `/assets/index-xxx.js 引用的文件不存在`(那是别人包的产物)⇒ 假红淹没真问题;
883
+ ② 属性提取用全文本正则 ⇒ 把 `<code>&lt;script src="…"&gt;</code>` 这种
884
+ **转义示例**当成真引用 ⇒ 报「引用的文件不存在: echarts.min.js」
885
+ (那个文件本来就不该存在 —— 示例说的是"主项目自己引")。
886
+ ⇒ 改成只在**真标签**内部取属性。
887
+
888
+ - **`doc-facts-gate` 的门禁数判据抓不到一种写法**:它只认 `N 道门禁`,
889
+ 而 `CONTRIBUTING.md` 写的是「全量门禁:135 道」⇒ **那处数字一直过期而门禁看不见**。
890
+ ⇒ 补 `门禁[::]\s*(\d+)\s*道` 模式。
891
+ 同时把该文件里的数字从**代码块内**挪到正文(代码块被判据豁免,
892
+ ⇒ 写在块里等于没人守)。
893
+
894
+ - **`release-gate` 的 tag 判据会把「工具坏了」报成「事实不满足」**:
895
+ 第一版 `_git()` 把异常吞成 `''`,而 `subprocess` **没 import**
896
+ ⇒ 判据说「没有 v0.8.8 这个 tag」,而 tag 明明在。
897
+ ⇒ 改成区分「git 不可用(判据视野已空,报红并说明)」与「tag 真的不存在」。
898
+
899
+
900
+ ### Security
901
+
902
+ - `SECURITY.md` 的「支持的版本」一节此前写着「还没有 git tag」
903
+ ⇒ 与事实不符,已改;并补上 tag 锚点的口径与不补打历史版本的理由。
904
+
905
+ ## [0.8.8] - 2026-10-14
906
+
907
+ ### ① 给核加「分级 Esc」能力 ⇒ combobox 收编,manual 只剩 2 条
908
+
909
+ **问题**:combobox 的 Esc 是**三级的**(① 关列表 ② 清输入 ③ 放弃),
910
+ 而核的 `onKey` 是「无条件 `preventDefault + stopPropagation`,再调 `onDismiss`」
911
+ ⇒ 组件**无法表达"这次我没关"**。上一版我因此判定"不收编"。
912
+
913
+ **改动**:给核加**返回值语义** —— `onDismiss` 返回 `false` ⇒
914
+ 核**不干预**默认行为(不抢 `preventDefault`、不 `stopPropagation`),
915
+ 于是 Esc 能继续传给外层浮层。
916
+
917
+ ```js
918
+ onDismiss: function (ev, reason) {
919
+ if (reason === 'outside') { close(); return true; }
920
+ if (!list.hidden) { close(); return true; } /* ① 关列表 */
921
+ if (input.value) { input.value = ''; paintList(); return true; } /* ② 清输入 */
922
+ return false; /* ③ 放弃 ⇒ 核不干预 */
923
+ }
924
+ ```
925
+
926
+ **向后兼容已验证**:现有组件的 `onDismiss` 都**没有返回值**(`undefined`),
927
+ `undefined !== false` ⇒ 行为完全不变(单测里钉住了这一条)。
928
+
929
+ 反向控制:把返回值判断去掉(恢复旧行为)⇒ **2 条单测立刻变红**。
930
+ 契约门禁「Esc 先关列表再清输入」实测通过。
931
+
932
+ ### ② 发布自动化:`bump-version.py`
933
+
934
+ 版本号出现在**四处**(VERSION / package.json / package-lock ×2 / CHANGELOG),
935
+ `release-gate.py` 盯着它们一致 ⇒ 手改就是在破坏本库"我说什么就是什么"的卖点。
936
+
937
+ ```
938
+ python3 05-audit/bump-version.py 0.8.9 # 改四处 + 建 CHANGELOG 骨架
939
+ python3 05-audit/bump-version.py --check # 只查一致(已接进门禁)
940
+ ```
941
+
942
+ **它的自检不是"跑完不报错"**:改完会**读回四处逐个比对**,
943
+ 不一致就指出**是哪一处**,再让 `release-gate.py` 自己核一遍。
944
+
945
+ 反向控制:故意把 `package-lock` 根包版本改旧 ⇒ `--check` 精确点名那一处。
946
+
947
+ ### 收编后的数字
948
+
949
+ ```
950
+ core 16(was 15) · manual 2(was 3) · gap 2 · n/a 56
951
+ stable 29(was 28) · beta 4(was 5)
952
+ ```
953
+
954
+ 剩下 2 条 manual 是**明确不该收**的:`drawer` / `overlay(dialog)` 是模态弹层,
955
+ 点外部**不该关闭**;核天然处理 outside ⇒ 收编等于引入错误行为。
956
+
957
+ ---
958
+
959
+ ## [0.8.7] - 2026-10-14
960
+
961
+ ### 🔴 给5 个微行为核补单元测试(补全库最大的一块质量缺口)
962
+
963
+ 此前 5 个核共 **551 行**(dismissable 118 / roving 152 / typeahead 124 /
964
+ emit 76 / focus-return 81),**一条单测都没有** —— 逻辑正确性只能靠
965
+ 浏览器门禁间接推断。现补 **47 条单测 + 一个 60 行的极小 DOM 桩**。
966
+
967
+ ```
968
+ before: 115 行单测 / 只覆盖 date-range 一个组件
969
+ after: 47 +7 条单测 / 5 个核全覆盖 · 跑 0.7 秒
970
+ ```
971
+
972
+ **为什么自己写 DOM 桩而不用 jsdom**:本库卖点之一是「零运行时依赖」,
973
+ 5 个核真正用到的 DOM API 只有 11 个 ⇒ 60 行手写桩足够。
974
+ 桩的**能力边界已写在文件头**:不冒泡、不支持捕获阶段 ⇒
975
+ 「Esc 只关最内层」这类仍必须由浏览器门禁证明,不越界下结论。
976
+
977
+ **⭐ 三条反向控制证明这些测试有鉴别力**(不是装饰品):
978
+
979
+ | 故意破坏 | 被抓 |
980
+ |---|---|
981
+ | `focus-return` restore 后不清空记忆 | 1 条红 |
982
+ | `dismissable` Esc 不再 stopPropagation | 1 条红 |
983
+ | `roving` 的循环导航失效 | 2 条红 |
984
+
985
+ ### 🔴 写这个文件时我犯的错(ERRORS E40)
986
+
987
+ **第一版测试是照想象写的**,凭空捏造了 API:
988
+ `flEmitOn()`(emit 根本没这个)、`t.key('a')`(真名 `type()`)、
989
+ `r.key({...})`(真名 `step(i,d)` / `rove(元素)`)⇒ 43 条里 21 条假失败。
990
+
991
+ 更糟的是**中途我在打补丁时越修越少**(43→22→16→14),
992
+ 因为用正则批量改代码、自己又引入新错误。**停下来实测真实行为才查清。**
993
+
994
+ ⇒ 纪律:
995
+ 1. **测任何东西之前先读它的真实签名**(`sed -n '/^var flX/,/^ function/p'`)
996
+ 2. 批量正则改测试代码是**高危**的—— 改完必须先看数量变化是否符合预期
997
+ 3. 测试失败时**先怀疑自己的调用姿势**,而不是先怀疑被测代码
998
+
999
+ 顺带记两个由实测才发现的真实契约(此前无人写过):
1000
+ - `roving` 的 `axis` **默认 'x'** ⇒ 默认配置下 ArrowDown/Up **不响应**
1001
+ - `typeahead.destroy()` **只停定时器**,不阻止 `type()`(核里没有"已销毁"标志)
1002
+
1003
+ ---
1004
+
1005
+ ## [0.8.6] - 2026-10-14
1006
+
1007
+ ### 行为债收编:manual 9 → 3,`tooltip` / `select` 升 stable
1008
+
1009
+ `behaviors.json` 的 budget 棘轮是「只能降不能升」,本轮把 4 条 manual 收进微行为核:
1010
+
1011
+ | 组件 | 收编内容 |
1012
+ |---|---|
1013
+ | `tooltip` | dismissable + focus-return |
1014
+ | `select` | focus-return + dismissable |
1015
+ | `dropdown` | focus-return |
1016
+ | `overlay` | focus-return —— **顺带修掉一个真 bug** |
1017
+
1018
+ ### 🔴 收编顺带修掉的真 bug(E38)
1019
+
1020
+ `overlay` 的 dialog 关闭时**焦点从来没还过**,而门禁「关闭后焦点归位」一直显示 OK:
1021
+
1022
+ ```js
1023
+ if (focusIsMine && myTrigger && document.contains(myTrigger)) { … }
1024
+ // ↑ focusIsMine 在这个文件里根本不存在 ⇒ 条件恒假 ⇒ 永不执行
1025
+ ```
1026
+
1027
+ JS 没有编译期检查,拼错的变量名静默为 `undefined`。门禁那条判据量的是
1028
+ 「关掉后焦点在不在触发器」——它**本该红**,实际绿是因为那条路径压根没跑到。
1029
+ 反向控制确认判据有牙:去掉 `restore()` 后门禁确实报红(焦点停在 BODY)。
1030
+
1031
+ ### 🔴 剩下的 3 条 manual:**决定不收**,并写明理由
1032
+
1033
+ | 组件 | 不收的理由 |
1034
+ |---|---|
1035
+ | `combobox` | Esc 是**两级**的(先关列表、再清输入),核的 `escOn` 表达不了;且它用 `root.contains` 判定外部,本来就是正确写法 ⇒ 收编收益为 0 |
1036
+ | `drawer` | 模态弹层,只需 Esc;要核支持"忽略 outside"得**改核**而不是接线 |
1037
+ | `overlay` | dialog 是模态,**点外部不该关闭**;核天然处理 outside ⇒ 收编等于引入错误行为 |
1038
+
1039
+ ⇒ **「收编率」不是 KPI,行为正确才是**(ERRORS E37)。
1040
+
1041
+ ### 收编后的数字
1042
+
1043
+ ```
1044
+ core 15(was 9) · manual 3(was 9) · gap 2 · n/a 56
1045
+ stable 28(was 26) · beta 5(was 7)
1046
+ ```
1047
+
1048
+ ---
1049
+
1050
+ ## [0.8.5] - 2026-10-14
1051
+
1052
+ ### 🔴 列头的单位写法跟着学科规范改(用户指出)
1053
+
1054
+ **问题**:单位是「列名 + 小一号灰字」裸写,且三个分组表头三种写法
1055
+ (`室温(25 °C)` 全角括号 / `时效 100 °C` 无括号 / 窄屏被折行),
1056
+ 看起来累 —— 每列的起点都不同,竖着读数列时视线反复横跳。
1057
+
1058
+ **依据**(不是"我觉得",五处权威口径一致):
1059
+
1060
+ | 来源 | 要求 |
1061
+ |---|---|
1062
+ | GB/T 7713.2-2022《学术论文编写规则》 | 「量的符号或名称/单位符号」的标准化形式 |
1063
+ | CY/T 170—2019《学术出版规范 表格》 | 同上;且表身单元格内不宜带单位 |
1064
+ | Science Author Guidelines | unit of measure **in parentheses** |
1065
+ | IEEE Standards Style Manual 14.3.1 | provided **in parentheses** or preceded by a solidus |
1066
+
1067
+ **改动**
1068
+ - `抗拉强度 MPa` → `抗拉强度 (MPa)`;`伸长率 %` → `伸长率 (%)`
1069
+ - 单位改为**同字号**(原来小一号 ⇒ 视觉上变成"另一行的小注",
1070
+ 而规范里单位与列名同字号,只用括号区分)
1071
+ - 分组表头统一成 `室温 (25 °C)` / `时效 (100 °C)` / `时效 (150 °C)`
1072
+ - **组件负责加括号**,不要求调用方记得写(要求调用方做,迟早漏一处)
1073
+
1074
+ **新门禁 ⑨**:列头单位必须被括号包住(反向控制:去掉一处括号 ⇒ 立刻点名三个)。
1075
+
1076
+ 顺带修了判据⑧:它比对的是**整段字符串**,改成列头写 `(万元)` 后永远判红。
1077
+ ⇒ 改为**剥掉括号再比对单位本身** —— 这条的本意是"两处说的是同一个单位"。
1078
+
1079
+ ---
1080
+
1081
+ ## [0.8.3] - 2026-10-13
1082
+
1083
+ 补两块**基础缺口**。都是"目录里有、契约与门禁里没有"那类病 ——
1084
+ 与 0.8.1「根 START-HERE 漏 11 个组件」同源,这两次漏得更整层。
1085
+
1086
+ ### ⑩ `09-assets` 整层纳入契约(29 → 33 组件)
1087
+
1088
+ `sparkline` / `bar` / `scientific-plot` / `model-viewer` 一个都不在契约里
1089
+ ⇒ **AI 入口查"这库有没有图表",得到的答案是"没有"**。
1090
+
1091
+ - 新增 `asset` tier;`byTier` 统计改读 `ALL_TIERS`(原来写死三档,
1092
+ 少一档 ⇒ 那一档计数恒为 0 且没人会发现)
1093
+ - meta 的 `rootClass` / `purpose` 均从源码读出
1094
+ (⚠️ `scientific-plot` 的根类是 `.splot`,不是 `.scientific-plot`)
1095
+
1096
+ ### 门禁探测修洞:`bar` 曾被记成"没有专属门禁"
1097
+
1098
+ 原探测只认「脚本文件名以组件名开头」⇒ `bar` 拿不到 `chart-check.js`
1099
+ ⇒ 成熟度掉到 **alpha**,而那个脚本第 70 行明确列了 `09-assets/bar`。
1100
+
1101
+ 新增第二种认法:脚本用 `const XXX_DIRS = [...]` 这样的**清单变量**声明覆盖谁。
1102
+
1103
+ > 🔴 只认清单变量,不认"文件里任何位置出现过该路径"——
1104
+ > `const BAR_URL = '.../09-assets/bar/demo.html'` 这类**用到的地址**
1105
+ > 会让判据永久为真、门禁被架空。
1106
+ > 中间还排除掉本脚本自己(注释里正好写着这个样例,文件名又含 `contract`)。
1107
+
1108
+ ### 人类入口 26 → 33 组件,且改为契约驱动
1109
+
1110
+ 3 个 `recipe`(table / analysis-report / report)与 4 个 `asset` 之前一个都没有。
1111
+ `ORDER` 原来手写 26 个 id、层目录写死两层 ⇒ 目录一变就漂移。
1112
+ 现在两者都从契约读,**契约读不到直接报错**,不静默退回旧清单。
1113
+
1114
+ ### 新组件准入门禁加两条(已验证有牙)
1115
+
1116
+ | 判据 | 抓什么 |
1117
+ |---|---|
1118
+ | ⑨ 每个组件必须有 `README.md` | 10 个组件长期"有 demo 无 README",合计 96 个顶层公开类无人解释 |
1119
+ | ⑩ 每个组件必须在契约里登记 | 治本:抓「目录里有、契约里没有」——本轮第三次犯的同一个病 |
1120
+
1121
+ ⑨ 只保证"不缺席",不保证"写得好"——后者靠 review 与 `doc-facts`。
1122
+
1123
+ ### 补齐 10 份 README
1124
+
1125
+ 每份都是**先读源码再写、写完逐条核实**,不是按 BEM 直觉套模板。
1126
+ 核实过程中抓出并改掉十处我写错的,例如:
1127
+ `date-range` 的标记是 `data-dr-*` 不是 `data-drange-*`;
1128
+ `dropdown` 的前缀是 `dd__` 不是 `dropdown__`;
1129
+ `popover` 的 API 是 `attach()` 不是 `create()`、关闭态用 `inert` 不是 `hidden`;
1130
+ `pagination` 的配置全从 DOM 属性读、`create` 是单参数。
1131
+
1132
+ 四份 `beta` 组件如实列出 `behaviorDebt`,不写得比契约更好看。
1133
+
1134
+ ---
1135
+
1136
+ ## [0.8.2] - 2026-10-10
1137
+
1138
+ 外部评审(VIZ-REPORT-01)指出**图表与表格正确性**仅 5.0/10,指控四项。
1139
+ 本轮逐条**先取证后修**,并新增**结果级判据**(读浏览器几何/计算值,
1140
+ 而不是读 CSS 文本)。新增 `04-recipes/report` 报告版式系统 v1。
1141
+
1142
+ ### P0-1 · 柱状图声称堆叠、实际重叠覆盖(`09-assets/bar/bar.js`)
1143
+
1144
+ **症状**:`draw()` 在**每个系列开始时**重置 `base` 数组,且所有系列的柱都用
1145
+ 同一个横坐标与同一柱宽 ⇒ 多系列不是堆叠,是**后画的盖住先画的**。
1146
+
1147
+ **取证**(改前,真实浏览器):三根柱的底边全在同一 y=`158.0` —— 完全重合。
1148
+
1149
+ **修**:`base` 提到系列循环之外;新增 `topVal`(堆叠 = 该列合计,分组 = 该列最高);
1150
+ 新增 `opt.mode`(`'stack'` / `'group'`,默认 stack);分组宽度与堆叠宽度分开算;
1151
+ 新增**图例**(默认 `系列数 ≥ 2 且都有名字`时出现)。
1152
+ 返回值新增 `mode / tops / legend`,便于被断言。
1153
+
1154
+ **改后实测**:24 根柱,`顶段底边 = 152.6`、`底段顶边 = 152.6`(相接,重叠 0)。
1155
+
1156
+ ### P0-2 · 科学绘图所有系列同色(`09-assets/scientific-plot/scientific-plot.js`)
1157
+
1158
+ **症状**:渲染函数里 `var colorIdx = 0` **遍历中不递增** ⇒ 全部系列用 `s0`。
1159
+
1160
+ **修**(⚠️ 没有采用"循环末尾 `colorIdx++`"那种改法 —— 那会让**同一材料的
1161
+ 区间带与曲线拿到不同颜色**):改为**逻辑系列身份**
1162
+ (`s.id` → 名字 → 顺序,三级兜底),带与线**共享同一身份**,因此必然同色;
1163
+ 新增图例、数据层 `clipPath` 裁剪、无效点(null/NaN/≤0 对数)计数与页面明示。
1164
+
1165
+ **改后实测**:三组材料三种颜色 —— `rgb(27,77,143)` / `rgb(192,57,43)` /
1166
+ `rgb(47,107,58)`,且**每组带与线同色**。
1167
+
1168
+ ### P1-1 · 表格排序箭头不变 + 动态文本未转义(`04-recipes/table/`)
1169
+
1170
+ **症状 ①**:`.table__sort[aria-sort=…]` 只管按钮自己,而 `demo.html` 把
1171
+ `aria-sort` 放在外层 `<th>` 上 ⇒ 数据已排序,箭头仍是 `↕`。
1172
+ **修**:选择器改为**两条并存**(`th[aria-sort] .table__sort::after` 与
1173
+ `.table__sort[aria-sort]::after`),因为两种写法都合法。
1174
+ **改后实测**:点第一下箭头 `↑`、第二下 `↓`,数据顺序同步反转。
1175
+
1176
+ **症状 ②**:demo 用 `innerHTML` 拼动态文本。
1177
+ **修**:新增 `esc()`(含 `'` → `&#39;`),全部动态文本走它;并加一节
1178
+ **转义自证区**(`<b>` / `<a href>` / 事件属性三种载荷,渲染后均应为 0 个真实标签)。
1179
+
1180
+ ### P1-2 · 缺"结果是否正确表达数据"的验证
1181
+
1182
+ 新增**结果级判据**(不是结构级)**共 28 条**:
1183
+
1184
+ | 文件 | 新增 |
1185
+ |---|---|
1186
+ | `05-audit/chart-check.js` | +4(堆叠几何 / tops 口径 / 柱高对得上合计 / 超量程被裁)|
1187
+ | `05-audit/scientific-plot-check.js` | +4(多系列可分 / 带线同色 / 图例同色 / 数据层裁剪)|
1188
+ | `05-audit/table-check.js` | +3(箭头与数据同步 / 动态文本不生成 HTML / 三种预设不合并)|
1189
+ | `05-audit/report-check.js` | 新建 17 条 |
1190
+
1191
+ 全部读 `getBoundingClientRect` / `getComputedStyle`,**不读源码文本**。
1192
+ 并逐条做过**反向控制**(把修复前的原缺陷注入回去 ⇒ 必须变红):
1193
+
1194
+ - A/B 那 11 条:**10 次注入,11 条全部变红**(RC-A1 一次覆盖 2 条),
1195
+ 还原后按 SHA-256 校验字节一致 —— 已跑,10/10
1196
+ - report 那 17 条:13 次注入(RC-1…RC-13),13/13
1197
+ - 泄漏判据(terms):11 条正例反例,11/11
1198
+
1199
+ ### 同一轮顺带修的两处门禁口径
1200
+
1201
+ - **两道泄漏门禁各有一套词表** ⇒ 同一份内容 `leak-scan` 报「无敏感信息」、
1202
+ `outsider-audit` 报「17 处」。⇒ 词表收进 `05-audit/terms.py` **单一来源**,
1203
+ 两边都 import;并把 5 处自指的「工单」改写成技术表述。
1204
+ 修完:局扫 501 文件 **OK**;外人视角 17 处 → 3 处。
1205
+ - **那 3 处是 `package.json` 里的仓库自指地址**(repository / homepage / bugs)。
1206
+ 原判据按**文件**豁免 package.json —— 那等于给整份文件开洞。
1207
+ ⇒ 改成按**出现位置**判定(`terms.hits_outside_repo_field`):
1208
+ 账号名只在自指地址里 ⇒ 放过;出现在别的字段(如 `author.name`)⇒ **照抓**。
1209
+ 反向控制 7 条全过(含"同一行既有自指链接又有裸账号名"这种混合形态)。
1210
+ ⇒ 推上去之后**再扫一遍远端**(447 个文本文件):**零泄漏** —— 17 → 3 → **0**。
1211
+ - `check-all.sh` 的 `PY` 写死成 `python` ⇒ 默认解释器没装 Pillow 时
1212
+ `visual` **永远 SKIP**(跳过不是通过,外人看不出自己少跑了一道)。
1213
+ ⇒ 改为 `PY="${PY:-python}"`,可外部指定。
1214
+
1215
+ ### 未验证(不冒充已验)
1216
+
1217
+ - **CSS counter 生成的具体编号数字**(是不是 1、2、3 连着排):
1218
+ `::before` 的 `content` 不在 DOM、也不在无障碍树,`accessibility.snapshot()`
1219
+ 实测抓不到。判据降级为"counter 名与 increment 用对 + 生成框真的占了像素",
1220
+ **具体数字列为未验证**(已写进 `04-recipes/report/README.md` 已知限制)。
1221
+ - **打印效果**:只做了"不更糟"(`break-inside: avoid` 等),
1222
+ **分页 PDF / 印刷分页明确不做**,仓库里出现 `@page` 门禁即红。
1223
+ - 本机默认 Python 无 Pillow ⇒ 门禁里的 `visual` 记 **SKIP**;
1224
+ 用带 Pillow 的解释器另跑一次:`34 / 34 个页面观感一致`。
1225
+
1226
+ ### 新增 · `04-recipes/report` 报告版式系统 v1
1227
+
1228
+ 共享基础(六个可变量 + 六种字体角色 + 页面网格 + 五级间距 + 图表说明/来源脚注)
1229
+ + 三种版式:`rep--magazine` / `rep--technical` / `rep--finance`。
1230
+ **零运行时依赖**,只换一个类即可换版式取舍。
1231
+
1232
+ **开发中实测抓到的三个真缺陷**(都已修,且都有判据守着):
1233
+
1234
+ 1. `.rep h2 / .rep h3`(0,1,1)压过 `.rep__title`(0,1,0)
1235
+ ⇒ `--rep-title-fs` 静默失效,三套版式标题被钉死成同一个 24px。
1236
+ **修**:`.rep .rep__title`(0,2,0)。
1237
+ 2. 编号规则写在 `h2::before`,而实例的小节是 `h3` ⇒ **一节都没编号**。
1238
+ **修**:改为 `.rep--technical .rep__sec > h3::before`,
1239
+ 并区分"该编号的有 / 不该编号的没有"。
1240
+ 3. 边注沿用库默认 `--measure-aside`(32em=576px),而正文 + 边注共享
1241
+ 832px 页宽 ⇒ **正文列被挤到 220px ≈ 12em**(一行 12 字),页面看起来却完全正常。
1242
+ **修**:`.rep` 内就地收窄为 14em ⇒ 正文恢复 30em;
1243
+ 并新增判据"宽屏下正文列不得窄于 22em"。
1244
+
1245
+ **明确不做(写进 README 与代码注释,并加反向判据)**:
1246
+ 分页 PDF / 印刷分页(`@page`、页眉页脚、页码、跨页控制)、自动目录、
1247
+ 脚注尾注、交叉引用、Markdown 输入、CMS、多语言排版。
1248
+ `report.css` 一旦出现 `@page` 或 `break-after: page`,门禁即报红。
1249
+
1250
+ ---
1251
+
1252
+ ## [0.8.1] - 2026-10-10
1253
+
1254
+ 修的是**对外文档的失实**,以及让它再次发生的两个洞。
1255
+ **没有改任何 CSS / JS 运行时代码、令牌、公开类名** —— 下游无需改动即可升级。
1256
+
1257
+ ### 文档失实
1258
+
1259
+ **1 · 根 `START-HERE.md` 的组件表只列了 15 个,实际少了一大半**
1260
+
1261
+ 漏掉的是:下拉选择 `select`、可搜索选择 `combobox`、日期区间 `date-range`、
1262
+ 浮层 `popover`、进度条 `progress`、加载骨架 `skeleton`、动作菜单 `dropdown`、
1263
+ 文字提示 `tooltip`、分页 `pagination`、树形 `tree`、侧边抽屉 `drawer`。
1264
+
1265
+ ⇒ 后果不是"文档少写几行",而是**复用者在入口看不到它们,会以为这个库没有**。
1266
+ 现在这张表由 `05-audit/fix-start-here.py` 生成:组件、CSS 行数、JS 行数、
1267
+ beta 标注(取自 `ai/components.json`)、页面示例清单,全部是数出来的。
1268
+
1269
+ **2 · 同一份文件自相矛盾**
1270
+
1271
+ 第 39 行的组件表里列着徽章,文末却把它写成「没有」。
1272
+ 现在「有 / 没有」那一节的每一行都对着目录数过,并写明脚本管不了散文。
1273
+
1274
+ **3 · `09-assets/README.md` 标题写着「素材(图标)」**
1275
+
1276
+ 而这一层早就是 5 个图表能力(sparkline / bar / scientific-plot /
1277
+ model-viewer / echarts-adapter),正文一个字没提;且正文是对话腔
1278
+ ("图标本体我拿不到,请给我")。⇒ 改为面向使用者,
1279
+ 目录清单与 AI 入口同源、由同一份脚本生成。
1280
+
1281
+ **4 · 补上 `ERRORS.md`**
1282
+
1283
+ 收敛计划里写明"教训要写进 ERRORS.md",而这个文件一直不存在。
1284
+ 现在 14 条,每条「症状 → 根因 → 纠正规则」,规则都能照着执行。
1285
+
1286
+ **5 · `docs/ROADMAP.md` 两处打架**
1287
+
1288
+ 第一节 6 行写「待办 W3 / W7 / W8 / W9」,第三节里同样编号已标完成。
1289
+ ⇒ 统一为「已完成(Wx)」,并在表下注明核对过一次。
1290
+
1291
+ ### 补上的两个洞(都是门禁漏扫,不是文档漏写)
1292
+
1293
+ - **`doc-facts-gate` 漏扫一整类**:判「文档说某组件不存在」的正则只认 ASCII 名,
1294
+ 中文名(徽章 / 表格 / 分页 …)全漏 ⇒ 上面第 2 条就是这样躲过门禁的。
1295
+ 已补中文名判据:别名从 `fix-start-here.py` 读(不抄第二份),
1296
+ 名字与否定词之间必须有分隔符(`|` / ❌),避免误报普通句子。
1297
+ - **`release-gate` 不管文档有没有进包**:README 写着「`START-HERE.md`(根目录)
1298
+ 是给人类复用者的三步清单」,而 npm 的 `files` 白名单里没有它。
1299
+ 已补判据,并把 `START-HERE.md` / `API.md` / `ERRORS.md` / `SUPPORT.md` /
1300
+ `CHANGELOG.md` / `docs/` 加进白名单 —— 已用 `npm pack --dry-run` 实装确认。
1301
+
1302
+ ## [0.8.0] - 2026-10-10
1303
+
1304
+ 新增一个科学绘图模块、把表格从"能看"推到"能比",并给 AI 使用入口减重。
1305
+
1306
+ ⚠️ **本版本有一次破坏性变更**:移除了 `.state--delayed`(0.7.7 已登记、
1307
+ 0.8.0 到期兑现)。只用了这一个类的下游需要自己补两行 CSS,见下方"移除"一节。
1308
+ 除此之外新增内容都不需要改代码即可升级。
1309
+
1310
+ ### 新增 `09-assets/scientific-plot`(二维科学绘图)
1311
+
1312
+ 原生 SVG、零运行时依赖、ES5。补的是 BI 图表给不了的那一类需求:
1313
+ **数值坐标轴 + 不确定性表达**。
1314
+
1315
+ - 线性 / 对数坐标(对数轴 domain ≤ 0 **直接抛错**,不静默修正)
1316
+ - 轴名与单位、刻度、网格、参考线、点标注
1317
+ - 折线 / 散点 / 函数曲线(**采样**,绝不 `eval` 表达式)/ 误差棒 / 置信区间带
1318
+ - **缺失值 ≠ 零**:`null` 处断开,不跨缺失连线
1319
+ - 不计算任何统计量,也不宣称显著性:区间与误差由调用方给
1320
+ - SVG 带 `role="img"` 与自动生成的 `aria-label`
1321
+
1322
+ ### `04-recipes/table`:列级格式 + 三种预设
1323
+
1324
+ 修掉两个真缺陷:
1325
+
1326
+ | 缺陷 | 症状 | 改法 |
1327
+ |---|---|---|
1328
+ | 按数值大小猜百分比 | `Math.abs(v) < 1` 就乘 100 加 `%` ⇒ `0.25` 是比例还是测量值全凭大小猜 | 由列定义的 `format` 决定:`decimal` 绝不乘 100 |
1329
+ | 对所有正数加 `+` | 营收、成本、毛利率也被标上 `+` | 正负号只按列语义 —— 只有 `signed-percent` 才加 |
1330
+ | **单位与数值量级不符** | 列头与表题都写「万元」,而茅台营收写成 `1,505,600` ⇒ 那是 150.6 亿元,比真实量级**小 10 倍**。读者会照着列头换算,算错了不报错、只会记错 | 现值按公开年报的亿元口径换算回万元(茅台 `15,056,000` 万元 = 1505.6 亿);表题注明"非真实财报,量级参照公开年报",并标明毛利率/同比/ROE 是示意值 |
1331
+ | 单位在两处各写一遍 | 表题写「金额单位:万元」,列头又写 `unit: '万元'` ⇒ 两处会漂移;把 `unit` 删掉还会变成"没声明单位" | 新增判据:表题与列头的单位写法必须一致,且**一页里找不到声明单位的表就判红**(跳过=假绿) |
1332
+
1333
+ ⚠️ 诚实边界:**数值量级与单位是否相符,门禁查不出来** ——
1334
+ 那需要外部真值(真实财报),本库没有也不该有。
1335
+ 上面那一行是**人工核对时发现的**,不是门禁发现的。
1336
+ 新加的判据只钉死"两处单位写法一致",别因为门禁绿了就以为单位一定对。
1337
+
1338
+ 新增三种预设(财务 / 学术三线表 / 统计结果),多级表头(`scope="colgroup"`)、
1339
+ 表题与脚注、行表头 `scope="row"`。
1340
+
1341
+ ### 新增 `04-recipes/analysis-report` 与 `04-recipes/data-showcase`
1342
+
1343
+ - `analysis-report`:正文 + 辅助栏**并排**,窄屏自然退回单栏(不是缩小桌面版)。
1344
+ - `data-showcase`:四种数据表达方式(迷你趋势 / 科学坐标图 / 学术表格 / 统计结果表)
1345
+ 各自回答什么问题的对照页。
1346
+
1347
+ 两页的入口都叫 `demo.html`(与其它组件目录一致),局部样式随目录名:
1348
+ `analysis-report/analysis-report.css`。
1349
+
1350
+ 三份新契约(科学绘图 / 组合型报告 / 数据展示对照)已接入全量门禁。
1351
+ 判据量的是**几何与语义**,不是读 CSS 文本:宽屏是否真的并排、窄屏是否真的退回单栏、
1352
+ 宽表是否在容器内滚动、表头有没有 `scope`、图有没有图注与文本等价信息;
1353
+ 科学绘图那份验的是**数据诚实性**:`null` 处是否断开、对数轴非法域是否抛错、
1354
+ 有没有 `eval`、声明的单位有没有上轴。
1355
+
1356
+ ### AI 使用入口减重(Capability-First)
1357
+
1358
+ - 新增 `ai/START-HERE.md`(约 90 行):只做导航,不复制契约与组件文档。
1359
+ - README 加"怎么用它 —— 有选择地用":本库是**可选**资产,不是所有页面的统一规范;
1360
+ 组件不适用就用别的;改已有网站时宿主体系优先;非网页输出形态不适用本库布局规则。
1361
+ - `docs/GUIDANCE.md` 的 G-4 / G-6 明确**适用范围**,不再暗示所有 AI 任务都应无条件遵循。
1362
+ - `docs/INVARIANT.md` 复核 I-2 / I-5 / I-8 / I-9:保留客观事实,
1363
+ 去掉被推广成普遍禁令的推论(详见文件内各条的"作用域 / ⚠️"段落)。
1364
+ - `docs/INVARIANT.md` 开头补「🔴 先读这条:写在这里,**不等于**它就是事实」:
1365
+ 明确**位置不代表正确性**,并给出两条判据(描述的是机制还是某类做法?
1366
+ 作用域写清楚了吗?)。此前这份文件只说明"三层怎么分",
1367
+ 没说过"这里的条目也可以被证明是错的" ⇒ 读者容易把它当免检金牌。
1368
+ - 修正 README 里错误的 CLI 示例:`ai check` 吃的是 **JSON 契约文档**,**不解析原始 HTML**。
1369
+ - README 补"**怎么自己验证**":此前只写了怎么跑示例,没写怎么跑门禁
1370
+ —— 而门禁需要先起 8000 端口的静态服务,不知道这点的人会看到一堆莫名其妙的失败。
1371
+ 同时写清两件容易误解的事:① `SKIP` 是真话不是通过(缺 Pillow / 缺 `npm ci` 时);
1372
+ ② **环境不全时门禁不会给假绿**,它会直接报「基线跑不起来…多半是 8000 端口没有静态服务」。
1373
+
1374
+ #### 第二次核对:入口里那些"说得像真的"的数字
1375
+
1376
+ 这一节记的不是新功能,是**交付前回到仓库逐条核了一遍**。逐个核 `ai/START-HERE.md` 里提到的
1377
+ 每一处资源真的存在、每一个数真的对得上,查出 6 处与仓库实况不符:
1378
+
1379
+ | 入口里写的 | 实际 | 后果 |
1380
+ |---|---|---|
1381
+ | 27 个组件 / 20 stable | **28 / 21 stable** | 数字是手抄的,加一个组件就错 |
1382
+ | `04-recipes/` 只列了 longform、table | 还有 **`analysis-report`**、**`data-showcase`** | 🔴 最严重的一条:按入口走的人**看不到本版最能说明组合能力的那个范例** |
1383
+ | `09-assets/` 只列了 sparkline、echarts-adapter | 还有 `bar`、`model-viewer`、**`scientific-plot`** | 同上,新资产在入口里不存在 |
1384
+ | `05-audit/` 下 112 道检查 | 实测 116 处注册、一次全量打印 130 行结果 | 数字本身就是漂移源 |
1385
+ | 令牌的第二份视图写作 `ai/tree` | 真实命令是 **`node ai/cli.js tree`** | 照抄路径 ⇒ 文件不存在 |
1386
+ | 成熟度判据写作 `components.meta.json` | 真名 **`ai/components.meta.json`** | 相对路径含糊 |
1387
+
1388
+ 改法不是把数改对就算了 —— 那只是把漂移**推迟到下一次改代码**。
1389
+ 现在 `ai/START-HERE.md` 里这段由 `05-audit/fix-start-here.py` **生成**
1390
+ (值取自 `ai/components.json` 与实际目录),并在既有 `start-here` 门禁里比对:
1391
+
1392
+ - 组件数 / 成熟度分布 ← 读契约,不手抄;
1393
+ - `04-recipes/`、`09-assets/` 目录 ← 实际列表,**新增目录若没补一句说明就直接红**
1394
+ (不静默漏掉,链子里少一环是最难发现的错)。
1395
+
1396
+ 反向控制已跑:加一个未登记目录 ⇒ EXIT=1;把手写的 27 改回去 ⇒ `已漂移` 红。
1397
+ **没有新增门禁** —— 这是既有 `start-here` 那一道的判定范围变宽了。
1398
+
1399
+ ### 移除:`.state--delayed`(兑现 0.7.7 的登记)
1400
+
1401
+ | | |
1402
+ |---|---|
1403
+ | 登记于 | 0.7.7(`ai/deprecations.json`,`removeIn: 0.8.0`)|
1404
+ | 为什么删 | 它承诺"延迟显现",但全库**没有任何 JS** 会设它依赖的 `data-state="shown"` ⇒ 使用者必须自己接,而当时文档没写。读起来像本库已实现 —— 误会发生在读者脑子里,代码不会报错 |
1405
+ | 替代品 | **没有**。要延迟显现就自己写两行,见 `03-patterns/states/README.md` 的「移除记录」 |
1406
+
1407
+ 这不是门禁逼出来的机械动作:`deprecation-gate.py` 只拦两件事 ——
1408
+ "没登记就删"和"登记了不兑现"。既然 0.7.7 公开公告了窗口,到 0.8.0 就该删。
1409
+
1410
+ 移除面:`states.css` 的两条规则、`states/README.md` 的钩子表、
1411
+ `overlay/README.md` 的提及、`API.md` 迁移表(改为标注已移除)。
1412
+ `ai/surface.baseline.json` **保留**这个名字 —— 基线只增不减,它是"它曾经存在过"的证据。
1413
+
1414
+ ### 修复
1415
+
1416
+ - **两个新示例页里的 inline style 收进样式表**。辅助栏那句"本报告不承诺"和
1417
+ `data-showcase` 的图注原本写成 `style="font-size:var(--fs-sm);…"`。
1418
+ 值本身合规(全是令牌),但 **inline 的优先级高于一切样式表**(I-4),
1419
+ 想要覆盖它的下游只能再加 `!important` —— 一个教人"例子就这么写"的范例不该这么示范。
1420
+ 已分别落到 `analysis-report.css` 与本页 `<style>`,视觉无变化。
1421
+ - 🔴 **sparkline 的"关掉动画就没有图"**(这一条影响面最大,值得单独说)。
1422
+ `.spark-anim` 的基态写成了 `stroke-dashoffset: var(--spark-len, 1000)`
1423
+ ——**整条线被推出可见区**,靠 `animation: spark-draw … forwards` 才变可见。
1424
+ 于是「动画被取消 ⇒ 线整条消失」。
1425
+
1426
+ 咬人的地方是**视觉门禁**:`shot-baseline.py` 为了冻结动画再截图,会注入
1427
+ `animation:none !important` ⇒ sparkline 的线整条不见。
1428
+ 什么时候不见取决于 ResizeObserver 回调与截图的先后(`.spark-anim` 只在首绘时加,
1429
+ 紧接着的重绘不加)⇒ 表现为**偶发假红**:同一份代码,5 次里红 1 次。
1430
+ 更糟的是:录基线那次若正好落在"看不见"的态,**"线不见了"就被录成了正常**
1431
+ —— 门禁从此对这个组件是盲的。本次核对时确认旧基线正是这种状态。
1432
+
1433
+ 改法:把它倒过来 —— **基态 `stroke-dashoffset: 0`(可见)**,
1434
+ 动画的 `from` 才是"完全偏移"。取消动画最多是"少一次动效",
1435
+ 绝不会变成"少一张图"(对第三方 CSS 重置 / 打印 / 降级渲染同样成立)。
1436
+
1437
+ 同时确认一句**错的注释**:`--spark-len` 全库**没有任何 JS 去写它**,
1438
+ 兜底值 1000 是实际生效值,而且它是**承重**的
1439
+ (dasharray 必须 ≥ 路径长度)。`css-validity-gate.js` 里"由 JS 在运行时写"的说法已改正。
1440
+
1441
+ 新判据(`sparkline-contract.js`):**在与视觉门禁逐字相同的冻结条件下,
1442
+ 折线必须仍然可见**(computed `offset = 0`);并配一条对照
1443
+ (默认下 `animation-name` 必须仍是 `spark-draw`,防止有人为了过判据把动画删掉)。
1444
+ 反向控制:把 CSS 退回旧写法 ⇒ 该条立刻红,且只有它红。
1445
+ - `05-audit/shot-baseline.py`:截图前原来"猜一个 300ms"(`t.wait || 300`),
1446
+ 机器忙时可能不够字体/图片解码完 ⇒ **偶发假红**。换成确定条件:
1447
+ `document.fonts.ready` → 等所有 `<img>` 的 load/error → 再等两帧。
1448
+ 34 页 settle 从 10.5s 降到 1.0s,总耗时 21.8s → 10.6s:**更快且不再竞态**。
1449
+ - **`dist` 里的孤儿产物**:把 `analysis-report/report.css` 重命名为 `analysis-report.css`
1450
+ 之后,重建 dist 会产出新名字但**不删旧名字** ⇒ dist 里两个文件内容一样、
1451
+ 而 `report.css` 的源码**已经不存在**(纯幽灵,下载的人不知道该用哪个)。
1452
+ 已删掉旧的两个文件,并在既有的 `dist-fresh` 门禁里补上"产物→源码"方向的判据:
1453
+ dist 里**没有任何源码产出它**的文件 ⇒ 红。
1454
+ (此前这道门禁只查"源码→产物",也就是只防漏建、不防漏删。)
1455
+ 反向控制:往 dist 塞一个无主文件 ⇒ 必须报孤儿(已验证会红)。
1456
+ - `05-audit/deprecation-gate.py --selftest`:突变对象写的是 `planned[0]`,
1457
+ 并**假设它当前还在公开面里**。0.8.0 兑现承诺删掉 `state--delayed` 之后,
1458
+ ③④⑤ 三个突变体同时失效 —— ④ 变成 **0 条**(该红的没红,最坏的一种),
1459
+ ⑤ 直接 `list.remove` 抛异常把自检**崩在半路**,后面 4 项根本没跑。
1460
+ 改为从"公开面 ∩ 基线"里挑一个当下真实存在、且不在登记册里的名字当突变对象;
1461
+ 突变一律 append 合成条目,不去改真实登记(否则隔离断言分不清谁红的)。
1462
+ 已用 5 组突变(摘掉 ③④⑤⑦⑨ 各一条判据)验证:每次都只红对应那一条。
1463
+ - `05-audit/combobox-instance-reverse.py`:9 个变异体里有 3 个"目标串找不到"
1464
+ ⇒ 反向控制静默失效。根因是变异串写 `\n` 而源文件在 Windows 上是纯 CRLF
1465
+ (INVARIANT I-15 实证 A 的同类)。改为**适配变异串的换行符**,文件一个字节不动。
1466
+ 修后 9/9 变异体全部被抓住。
1467
+
1468
+ ## [0.7.12] - 2026-10-10
1469
+
1470
+ ### Combobox:四条实例级缺陷(外部评审 N7–N10)
1471
+
1472
+ 评审指出的问题都在**实例**层面 —— demo 页面上完全查不出来,
1473
+ 所以新写了一道**自己造实例**的门禁(`combobox-instance-check.js`,15 条判据)。
1474
+
1475
+ | 缺陷 | 症状 | 改法 |
1476
+ |---|---|---|
1477
+ | `max` 不是真上限(N7) | 只在 `commitActive()` 里"加完发现满了才关列表" ⇒ 点击路径与公开 `add()` 都能绕过 | 上限收进 `addTag()` **单一入口**,三条路全走它 |
1478
+ | `disabled` 从未生效(N7) | 渲染只写 `role/data-value/aria-selected`,而 `setActive()` 却在**读** `aria-disabled` ⇒ 那段"跳过禁用项"是在读一个永远不存在的属性 | 渲染写出 `aria-disabled`;游标跳过;**全禁用时不设 `aria-activedescendant`**(APG:没有可选项就没有"当前项");点击 / Enter / 公开 `add()` 全拒绝 |
1479
+ | 没有 `destroy()`(N8) | 注册了 **document 级** click 监听却没人摘 ⇒ 实例销毁后监听还活着,每个死实例都握着一份 DOM 引用 | 监听器改成**具名函数** + 新增 `destroy()`(幂等),逐个摘除 |
1480
+ | 自动 id 会撞(N10) | `uid` 只在 `!list.id` 时递增 ⇒ 调用方自己给了 list id 时,多个实例全叫 `combo-input-0`(实测三个实例撞成同一个) | 序号与"list 有没有 id"脱钩,进来就领一个 |
1481
+ | `closest()` 兜底是死代码(N9) | `e.target.closest ? closest(...) : null` ⇒ 没有原生 `closest` 时直接返回 `null`,**兜底永远跑不到** —— 而那正是写兜底要救的老 WebView | 直接调 `closest()`(它内部自己判)。`combobox` 与 `date-range` 同一处,一起改 |
1482
+
1483
+ `types/index.d.ts` 同步:补 `destroy()`;删掉那句"它**没有** destroy()"(现在是假的);
1484
+ `max` 的注释从"超出会关闭下拉"改成**硬上限**。
1485
+
1486
+ ### 顺带修掉一个"门禁自己看不见"的洞
1487
+
1488
+ **乱码门禁漏了一整类文件**:`EXTS` 里没有 `.ts` ⇒ `types/*.d.ts` 从头到尾没被扫过,
1489
+ 而里面真有一处乱码(`thisQ(本季度)` 的"本"烂成了替换字符),门禁却一直报"✅ 无乱码"。
1490
+ ⇒ 补上 `.ts` / `.py` / `.sh` / `.yml` / `.mjs`;**全仓 444 个文件重扫**,
1491
+ 抓到 2 处真乱码(另一处在 `api-contract-gate.py` 的注释里,从首个提交就是坏的,按语境重建)。
1492
+ 新增 `--selftest`(4 条反向控制,含"干净的 .ts 不许报")。
1493
+ ⚠️ 教训:扩展名**白名单**这种写法本身就危险 —— 没列进去的类型永远不会被看一眼。
1494
+
1495
+ ### 体积基线重录(有意增长)
1496
+
1497
+ 源码 4.6 → 5.4 KB(+19.4%,超过 5% 容差)。新增 `destroy` / `disabled` 全链路 /
1498
+ `max` 单一入口 / 唯一 id 都是真实代码,不是注释堆的:
1499
+ **实际交付的去注释产物 gzip 只 +856 B**(新写的解释性注释已精简过一轮 ——
1500
+ 理由留在变更记录里,不占交付体积)。
1501
+
1502
+ ## [0.7.11] - 2026-10-09
1503
+
1504
+ > 把全仓 .md 里**会随版本漂移的说法**对齐到机器算出来的真值,
1505
+ > 并把「谁来保证它不再漂」这件事交给门禁。
1506
+ >
1507
+ > ⚠️ 过程中撞到的都比"文档写错了"严重,三条都在本节:
1508
+ > ① `unit` 门禁**挂了整套照样 EXIT=0**(写了 `FAILED`,结尾判的是 `fail`);
1509
+ > ② `doc-facts-gate` 只查 4 份文档 ⇒ `docs/` 里的错数漂了很久**没人看**;
1510
+ > ③ 它报的门禁数 **125 是凑巧对上的**(漏 2 条、重名多算 2 条,刚好抵消)。
1511
+
1512
+ ### 🔴 修:`unit` 门禁失败不进总结(活着的假绿)
1513
+
1514
+ `check-all.sh` 里 `unit` 失败时加的是 `FAILED=$((FAILED+1))`,
1515
+ 而结尾是 `exit $fail` —— **两个不同的变量**。
1516
+ 实测取证:让单元测试挂掉,日志打 `unit FAIL`,整套门禁仍然 `EXIT=0`。
1517
+
1518
+ ⇒ 统一用 `fail`;并顺手把 `unit` 交给 `run()`,以后不会再长出第二个计数器。
1519
+
1520
+ ### 🔴 修:`doc-facts-gate` 的三个洞(数字漂了没人管)
1521
+
1522
+ | 洞 | 症状 | 改法 |
1523
+ |---|---|---|
1524
+ | 视野只有 4 份文档 | `docs/INVARIANT.md` 写「跑完全套 117 道」「本版组件数写 26」,真值早就是 128 / 27,**这道门禁根本没看那份文档** | 视野扩到 35 份受版本控制的 `.md`;并打印"扫了几份",**扫到 0 份直接判失败** |
1525
+ | 只报数字、不核名单 | 报 125,与实测一致,看着全对 —— 其实是漏了 `css-imports` / `unit`(-2)、`switch` / `states` 两条命令共用同一标签(+2)**刚好抵消**。对的数字、错的名单,比算错更危险:它会替错数背书 | 改成返回**名单**,并强制「名字必须唯一」(`run` 一条 + `skip` 一条不算重名,那是 if/else 两支) |
1526
+ | 组件数用目录法 | 数出 31(把 `09-assets/` 的 4 个资源/适配器算成了组件),而权威是 `ai/components.json` 的 27 | 组件数改取契约;目录法只用来判"某个组件有没有" |
1527
+
1528
+ 顺带:门禁**注册数必须与机器无关**。以前缺环境时是裸 `echo "... SKIP"`,
1529
+ 那行不在名单里 ⇒ 有 node 的机器比没有的多 3 条。
1530
+ ⇒ 新增 `skip <名字> <原因>`,三种登记方式(`run` / `run_report` / `skip`)都算。
1531
+
1532
+ ### 🔴 修:两条门禁的产出被销毁 / 失败被吞掉
1533
+
1534
+ - `css-imports` 写成 `...py >/dev/null 2>&1 && echo PASS || true`
1535
+ ⇒ 它的**唯一产出**(报告)被丢进 `/dev/null`,脚本崩了也被 `|| true` 吞掉
1536
+ ⇒ 既不算门禁、也不报告、也不失败。
1537
+ - 同类:`dead-class` 走 `| sed`,管道退出码恒为 sed 的 0。
1538
+
1539
+ ⇒ 新增 `run_report`:发现项**打出来**但不判失败(误报率偏高是既定取舍),
1540
+ 脚本自身出错仍然判失败,并且**计入注册数**。
1541
+
1542
+ ### 修:文档里的错说法
1543
+
1544
+ | 位置 | 原来说 | 现在 |
1545
+ |---|---|---|
1546
+ | `docs/INVARIANT.md` | 「跑完全套 **117** 道」(旧值,真值当时已 128) | 不写死数字,指向 `doc-facts-gate.py` 实算 |
1547
+ | `docs/INVARIANT.md` | 「本版有 **26** 个组件」 | 那行是「不写在这里」表格里的**示例** ⇒ 改成 `N 个组件`,从此不参与漂移 |
1548
+ | `docs/ROADMAP.md` | 「27 个组件成熟度**全部升到 stable**」 | 契约里是 20 stable / 7 beta(M8 故意留下的鉴别力)⇒ 按四条判据重判重写 |
1549
+ | `SUPPORT.md` | 教使用者跑 `grep -c 'run "'` 查门禁数 | 那命令会给出 **99**(漏掉 for 循环里的 `run "$g"` 和 `skip`)⇒ 删掉并写明为什么 |
1550
+
1551
+ ### 新增反向控制
1552
+
1553
+ `doc-facts-gate.py --selftest` —— 在临时仓库里注入 6 种错说法,
1554
+ 每条都必须被抓到(含**豁免**验证:代码块里的数字不许当成事实声明;
1555
+ **视野**验证:一份文档都没有 ⇒ 不许通过)。已接进 `behavior-reverse.sh`。
1556
+
1557
+ ---
1558
+
1559
+ ## [0.7.10] - 2026-10-09
1560
+
1561
+ > 外部评审里 **Overlay 那组**("弹层会不会弄坏宿主页面")的整改。
1562
+ > ⚠️ 改的过程中撞见两件比原指控更严重的事,都记在本节:
1563
+ > ① 我第一版写的判据**量错了对象**,怎么改代码都不可能过;
1564
+ > ② 一直在用的 `scrollbar-gutter: stable` 其实是**按下葫芦浮起瓢**,
1565
+ > 实测推翻后换掉了。
1566
+ > 新增门禁 `overlay-host-check` + 常驻反向控制 `overlay-host-reverse`。
1567
+
1568
+ ### 修:关闭弹层会清空宿主自己写的 `padding-right`
1569
+
1570
+ `unlockScroll()` 无条件执行 `body.style.paddingRight = savedPaddingRight`,
1571
+ 而在支持 `scrollbar-gutter` 的浏览器上 `savedPaddingRight` **从头到尾是空串**
1572
+ ⇒ 宿主自己的内联 `padding-right` 被一键清空。
1573
+
1574
+ ⇒ 拆成两件事记账:**原值** + **这次到底改没改**,
1575
+ 只恢复本组件真的改过的属性 —— 没碰过的东西一个字节都不许写回。
1576
+
1577
+ ### 修:嵌套弹层**非栈顶关闭**时,背景提前解锁
1578
+
1579
+ 原来的记账是「所有权」:只有自己设过 `inert = true` 的节点才负责恢复。
1580
+ 它**假设弹层按打开的逆序关闭**。一旦不是(A 开 → B 开 → 先关 A):
1581
+
1582
+ ```
1583
+ B 还开着,背景却能点了 —— 用户看到的是「弹窗明明还在,页面却能点」
1584
+ ```
1585
+
1586
+ ⇒ 改成**引用计数**:只要还有弹层需要锁,背景就不许解锁,与关闭顺序无关。
1587
+
1588
+ ### 修:焦点归还认错了人
1589
+
1590
+ 原来从**全局**栈弹栈顶元素。嵌套场景先关外层时,会把焦点从
1591
+ 内层**还开着**的弹层里抢走。
1592
+ ⇒ 归还目标绑到**实例**(打开时记下当时的 `document.activeElement`):
1593
+ 关内层还内层、关外层还外层;且只在焦点仍在自己内部时才还。
1594
+
1595
+ ### 实测推翻:`scrollbar-gutter: stable` 按下葫芦浮起瓢
1596
+
1597
+ 原实现靠它防"弹窗打开时整页往右跳"。开着真实滚动条量了三种写法
1598
+ (视口 393、滚动条 15px):
1599
+
1600
+ | 写法 | 流式内容 | `100vw` 元素 | 代价 |
1601
+ |---|---|---|---|
1602
+ | 无条件 `html { scrollbar-gutter: stable }` | 稳 | 稳 | **导入即生效**:一个弹层没开过,宿主 `100vw` 也从 393 被压到 378 |
1603
+ | 状态级 `html[data-scroll-locked] { … }` | 稳 | **跳 393→378** | 抖动只是从流式内容转移到了 `100vw` 元素上 |
1604
+ | (基线)只 `overflow: hidden` 不补偿 | **跳 378→393** | 稳 | — |
1605
+
1606
+ ⇒ 它要稳就必须**一直**占位,而"一直"与"组件只影响自己"冲突。
1607
+ ⇒ 放弃它,统一走 **JS 叠加补偿**(基准取 `getComputedStyle`),
1608
+ 实测流式内容与 `100vw` **两者都不动**。
1609
+ ⇒ 顺带删掉一条**自动化里永远走不到**的分支:三个引擎全都支持
1610
+ `scrollbar-gutter` ⇒ 那条"降级路径"从来没被执行过。
1611
+
1612
+ **`position: fixed` 的元素怎么办**:它们相对**视口**定位,body 的 padding
1613
+ 管不到 ⇒ 把滚动条宽度公开成 `--overlay-scrollbar-width`(仅锁定期间存在),
1614
+ 宿主自己补 `right: var(--overlay-scrollbar-width, 0px)`。
1615
+
1616
+ ### 🔴 判据写错对象:一次"怎么改都过不了"的红
1617
+
1618
+ 新判据第一版量的是 `documentElement.clientWidth`,结果永远红。
1619
+ 实测 6 种锁滚动写法后发现:**任何一种** `overflow: hidden` 锁滚动
1620
+ 都会让它 378 → 393(唯一"不变"的那一种根本没锁住滚动)。
1621
+
1622
+ ⇒ 那不是代码缺陷,是**判据失真**。改量真正看得见的两件事:
1623
+ 流式内容宽度、`100vw` 元素宽度。两者都不许变。
1624
+
1625
+ ### 顺手修掉两处"判据自己有洞"
1626
+
1627
+ - **`bleed-gate` 的死代码**:`TAGS` 里没有 `html` / `body`,
1628
+ ⇒ `RESET_OK` 中那条 `html|body` 分支**永远进不去**。补上后它立刻抓到了
1629
+ `overlay.css` 里那条无条件的 `html` 规则(也就是上一节的病根)。
1630
+ - **`clicktest` 的双重假绿**:「弹窗打开时页面无横向位移」量的是
1631
+ `body.getBoundingClientRect().left` —— body 左边永远贴着视口,恒为 0;
1632
+ 加上 headless 默认 `--hide-scrollbars` ⇒ 滚动条宽度恒为 0 ⇒ 补偿 0 等于没补偿。
1633
+ ⇒ 改量 body **内容盒宽度**,并给 clicktest 打开真实滚动条。
1634
+ 变异验证:去掉补偿后 1147 → 1162(红),有补偿 1147 → 1147(绿)。
1635
+
1636
+ ### 新增
1637
+
1638
+ - `05-audit/overlay-host-check.js` —— 弹层**对宿主页面**的影响
1639
+ (宿主样式保全 / 补偿叠加 / 引用计数 / 焦点归位 / 不横向跳 / 宽度公开)。
1640
+ 它不替代 `overlay-check.js`:弹层可以完全符合 APG,同时把宿主的
1641
+ `padding-right` 清掉 —— 两件事不能互相担保。
1642
+ - `05-audit/overlay-host-reverse.py` —— 常驻反向控制(4 个变异体逐个打补丁重跑,
1643
+ 有一个跑绿就是假绿)。接进 `behavior-reverse.sh`,**每次都跑**。
1644
+ - 运行时变量 `--overlay-scrollbar-width`(仅锁定期间)。
1645
+
1646
+ ### 视觉基线重录(3 张图,全部是有意改动)
1647
+
1648
+ ⚠️ 这次重录是在**远端干净树**里跑出来的:本地那次 `visual` 因环境被判 SKIP
1649
+ (同一台机器上判定不稳定)⇒ 本地全绿**其实没查这一项**。差异三张:
1650
+
1651
+ | 页面 | 基线 → 现在 | 为什么 |
1652
+ |---|---|---|
1653
+ | `02-primitives_popover` | 483 → 393 宽 | 修掉了 popover 横向溢出(视口只有 393,原来撑到 483) |
1654
+ | `03-patterns_overlay` | 378 → 393 宽 | 删掉无条件 `html{scrollbar-gutter:stable}`:那 15px 是它**在没开弹层时也在占位**的证据 |
1655
+ | `10-review_composition` | 378 → 393 宽 | 同上(该页引入了 overlay) |
1656
+
1657
+ 🔴 顺带记一条差点踩进去的坑:曾以为差异是"Chromium 启动器从 puppeteer 换成
1658
+ Playwright(引擎矩阵那一版),headless 默认 `--hide-scrollbars` ⇒ 宽了 15px",
1659
+ 于是给截图脚本加了 `showScrollbars` —— 结果 **28 张集体变动**。
1660
+ 实测基线图宽度分布后才发现:31 张里 28 张本来就是 393(隐藏滚动条),
1661
+ 只有那 2 张 378 是 `scrollbar-gutter` 占的位。
1662
+ ⇒ 量具没换,是产品真的变了。已把这段写进 `shot-baseline.py` 注释,
1663
+ 免得后来者再"修"一次。
1664
+
1665
+ ### 体积基线重录(有意增长)
1666
+
1667
+ 源码 `03-patterns/overlay/overlay.js` 12456 → 15291 B(+22.8%)超出 5% 容差,
1668
+ 其中绝大部分是本次新增的"为什么"注释(dist 会剥掉注释)。
1669
+ 实际交付体积:去注释版 gzip 1948 → 2183 B(**+235 B**),`overlay.min.css` gzip −3 B。
1670
+ ⇒ 按门禁提示重录基线,数字记在这里备查。
1671
+
1672
+ ---
1673
+
1674
+ ## [0.7.9] - 2026-10-09
1675
+
1676
+ > 依据两份外部严格工程评审。⚠️ 评审的 12 项指控**逐条对着源码核过**:
1677
+ > 9+4 项成立(已修),2 项不成立(未改,理由见下),另外发现 3 项评审没提的新缺陷。
1678
+ > ⚠️ 本版本起,门禁注册数由 `doc-facts-gate` 自动核对,文档里写错就红。
1679
+
1680
+ ### 修:CI 工作流的 YAML 语法错误(评审 P0-1)
1681
+
1682
+ `.github/workflows/gate.yml` 里躺着一行:
1683
+
1684
+ ```yaml
1685
+ timeout-minutes: 6# 兜底:卡住就掐掉,不烧额度
1686
+ ```
1687
+
1688
+ YAML 规范要求注释标记 `#` 必须与前面的内容由**空白**分隔。少了那个空格,
1689
+ `timeout-minutes` 的值就变成字符串 `"6# 兜底:…"`,而不是整数 `6`。
1690
+
1691
+ ⚠️ 危害不在"格式不好看",而在于它**伪装成了一句注释**:
1692
+ 人读的时候它是「6 分钟 + 一句说明」,机器读的时候它是一个非法整数
1693
+ ⇒ **读者与机器的理解分叉了,而分叉点肉眼不可见。**
1694
+
1695
+ ⇒ 加一道门禁 `05-audit/workflow-gate.js`,用**真 YAML 解析器**(不是正则)判四条:
1696
+ YAML 合法性(报错带行号)/ `timeout-minutes` 必须是整数 /
1697
+ `run` 引用的脚本必须真实存在 / 每个 job 必须先 `npm ci`。
1698
+ ⚠️ 第三条的理由:CI 上写错一个脚本名,最坏情况是那道门禁**从未被执行过**,
1699
+ 而 workflow 依然绿 —— 与"门禁没接进链"是同一类假绿。
1700
+ 反向控制 8 条全绿;其中两条专门防**误报**:
1701
+ · 动态脚本名 `"05-audit/${c}-check.js"` 不许被判成引用了 `check.js`
1702
+ · 正常 workflow 必须全绿
1703
+ 🔴 判据踩过的坑(写下来因为它极难发现):先剥 `${…}` 再提取路径是**错的** ——
1704
+ 剥完留下的空格会让 `check.js` 重新变成独立 token。
1705
+ 正确规则是「路径必须是**独立参数**」(前一个字符只能是空白/引号/行首)。
1706
+
1707
+ ### 修:浏览器矩阵**没有真的切换引擎**(评审 P0-1,这一版最严重的一条)
1708
+
1709
+ 矩阵声明了 chromium / webkit / firefox 三个引擎,
1710
+ 但 `matrix.engine` **只用在 `npx playwright install` 那一步**,
1711
+ 从来没传给测试启动器 —— `05-audit/browser.js` 恒用 `puppeteer-core` +
1712
+ 硬编码的 Chromium 路径。⇒ **三个作业跑的都是 Chromium**。
1713
+
1714
+ ⚠️ 这比"没做跨浏览器"更坏:它产生了一份**看起来存在**的证据。
1715
+ 评审拿它当覆盖率,使用者拿它当兼容承诺,而它一次都没测过
1716
+ (与 I-10「报告通过却什么也没查」同一类失效)。
1717
+
1718
+ ⇒ 三处改动:
1719
+ 1. `browser.js` 支持 `launch({ engine })` 与 `FL_ENGINE` 环境变量;
1720
+ 写错引擎名**立刻报错**(不许静默退回 —— 退回就等于没选)。
1721
+ 缺 playwright 也报错,**绝不退回 Chromium**。
1722
+ 2. 新增 `05-audit/engine-probe.js`:每个作业**自己站起来报 UA**,
1723
+ 并校验 UA 属于声明的引擎族(评审要的验收标准)。
1724
+ 🔴 Chromium 的 UA 里**也含** `AppleWebKit`(它本是 WebKit 分叉)
1725
+ ⇒ 只判子串会把 Chromium 认成 WebKit —— 而那正是原来那个 bug 的形态。
1726
+ 3. 新增 `05-audit/engine-gate.js`:逐个引擎启动,要求
1727
+ **跑起来的引擎之间 UA 两两不同** ⇒ 证明不是"同一个浏览器跑了三遍"。
1728
+ 装不上某个引擎 ⇒ **SKIP**(环境),绝不报"通过"。
1729
+
1730
+ 适配层用 Proxy 直通 + 只为真缺的三个方法写实现
1731
+ (`setViewport` / `setCacheEnabled` / `emulateMediaFeatures`):
1732
+ ⇒ 覆盖面由"我想到了多少"变成"Playwright 本来就有多少"。
1733
+ `setViewport` 带 `isMobile` 时 Playwright 只能在**建 context 时**设,
1734
+ ⇒ Proxy 内部换引用重建,调用方手里的对象**不需要更新**。
1735
+
1736
+ **实测(本地三个引擎全部真跑起来,UA 两两不同):**
1737
+
1738
+ | 引擎 | 版本 | 矩阵 8 个组件契约(select/combobox/date-range/overlay/tree/dropdown/popover/tooltip) |
1739
+ |---|---|---|
1740
+ | chromium | Chrome/154 | **8/8 通过** |
1741
+ | webkit | 26.6 | **8/8 通过** |
1742
+ | firefox | 155.0 | **8/8 通过** |
1743
+
1744
+ ⚠️ 诚实边界:UA 不同只证明**真的启动了三个不同的浏览器**,
1745
+ 8/8 通过也只证明这 8 个组件的**契约判据**在三引擎上成立 ——
1746
+ 不等于 iOS WKWebView / Android WebView 真机(软键盘、安全区、
1747
+ `100vh`、页面回弹、VoiceOver 一项都没覆盖)。真机仍是 ❌,见 `SUPPORT.md`。
1748
+
1749
+ ### 修:popover 在移动端真的横向溢出(开真引擎后**新发现**,评审没提)
1750
+
1751
+ 开 WebKit 后 popover 契约红了一条:`483 ≤ 393`。
1752
+ 逐元素量下来两引擎**布局完全一致**,差别只在 `window.innerWidth`:
1753
+
1754
+ | 引擎 | scrollWidth | innerWidth | 结论 |
1755
+ |---|---|---|---|
1756
+ | chromium | 483 | **483** | 判据通过 —— 因为**它把视口也量成了 483** |
1757
+ | webkit | 483 | 393 | 判据红 |
1758
+
1759
+ ⇒ **溢出是真的,Chromium 只是用"视口被内容撑大"的方式把它量成了没溢出。**
1760
+ 这正是评审那句"错误的矩阵会掩盖真问题"的实例。
1761
+
1762
+ 修法是让四个方向的演示**竖排**(`.row--stack`)——
1763
+ `end` 方向浮层挂在锚点右侧(`inset-inline-start: 100%`),横排时必然顶出视口。
1764
+ ⚠️ 这不是"改测试让它过":溢出是真的;竖排后演示成立。
1765
+ 但**组件本身不会碰撞翻转**这件事没有变 ⇒ 已写进 `SUPPORT.md` 的「已知限制」。
1766
+
1767
+ ### 修:锁文件版本漂移(评审 P0-2)
1768
+
1769
+ `package.json` = 0.7.7,`package-lock.json` 根包 = **0.3.0**。
1770
+
1771
+ ⚠️ 这不一定让 `npm ci` 失败 —— 正因为**不一定**失败,它才会一直没人发现:
1772
+ 锁文件里那个版本号是"我上一次 npm install 时的包版本",
1773
+ 它漂移时没有任何一处会报错,直到有人去读它。
1774
+
1775
+ ⇒ 扩展**既有**的 `release-gate.py` 判据 ①(不新建门禁,避免重复造):
1776
+ 现在要求 `VERSION == package.json == CHANGELOG == package-lock.json`
1777
+ (lockfile 的 `version` 与 `packages[""].version` 两处都查,连带 name)。
1778
+ 突变体证明它是活的:把锁文件改回 0.3.0 ⇒ EXIT=1。
1779
+
1780
+ ### 修:`SUPPORT.md` 里三句假话(这一版**新发现**,评审没提)
1781
+
1782
+ | 原文 | 事实 |
1783
+ |---|---|
1784
+ | 「桌面 Firefox **✅ 已验证**」 | 一次都没跑过 |
1785
+ | 「iOS Safari:自动化跑的是桌面 Firefox」 | 跑的是 **Chromium** |
1786
+ | 「共 60+ 项检查」 | 注册数早就不是 60+ |
1787
+
1788
+ ⚠️ 这比"没验证"更糟:它给了一个**错误的**证据来源。
1789
+ ⇒ 重写整页:自动化覆盖现在按引擎列出并指向证据文件
1790
+ `10-review/engine-evidence.json`;门禁数量不再写死(自动算,写错就红)。
1791
+
1792
+ ### 报告里**不成立**的两条(未改,记录理由)
1793
+
1794
+ - 「宣传里用固定的『34 契约 + 55 门禁』」—— 全仓 grep **无此表述**;
1795
+ 数量一律指向脚本生成的数字。报告这条针对的是不存在的内容。
1796
+ - 「106 个检查脚本 ⇒ 治理过度」—— 106 是**文件名匹配**数,
1797
+ 不等于每次 CI 执行 106 道;门禁执行条数已由 `doc-facts-gate` 自动算。
1798
+ **按文件名估算治理体量本身就不是有效度量。**
1799
+
1800
+ ### 加:两道门禁 + 一道探针
1801
+
1802
+ - `05-audit/workflow-gate.js`(4 条判据 / 8 条反向控制)
1803
+ - `05-audit/engine-gate.js`(5 条判据 / 4 条反向控制)
1804
+ - `05-audit/engine-probe.js`(每作业自证 / 4 条反向控制)
1805
+
1806
+ ### 加:一个 devDependency
1807
+
1808
+ `yaml`(仅供门禁解析 workflow 用)。**运行时仍是零依赖** ——
1809
+ `dependencies` 依然为空,这个包只在 `devDependencies` 里。
1810
+ 缺它时 `workflow-gate` 显式 SKIP,不退化成正则。
1811
+
1812
+ ---
1813
+
1814
+ ## [0.7.8] - 2026-10-09
1815
+
1816
+ ### 修:`doc-facts` 的门禁计数偏小 22 条(报 100,实际注册 122)
1817
+
1818
+ `real_gate_count()` 用的是 `^run\s+"`,**两个盲区**:
1819
+
1820
+ | 盲区 | 漏掉的东西 |
1821
+ |---|---|
1822
+ | 只锚行首、不认缩进 ⇒ `for g in …; do run "$g" …` 这种**循环展开**的一条都不算 | 16 条 |
1823
+ | 不认缩进的普通 `run` | 5 条(rtl / kbd / responsive / visual / tsc) |
1824
+
1825
+ ⇒ 它报 100,而 `check-all.sh` 里实际注册 **122** 道门禁。
1826
+
1827
+ ⚠️ 一个自称"实际注册条数"的函数报出偏小的数,比不报更危险:
1828
+ 文档照它写就是错的,而门禁还会替这个错数**背书**(I-10 的近亲)。
1829
+
1830
+ ### 🔴 中途差点被自己的修正骗过去
1831
+
1832
+ 第一版修正只加了循环展开,算出来 **121**,与"把 `run` 换成只报数的桩、真跑一遍"
1833
+ 实测的 **121 一模一样** ⇒ 看起来完全对。
1834
+
1835
+ 但按**名单**逐条核对才发现是两个错**刚好抵消**:
1836
+
1837
+ - `run "visual"` 在 `if 有 Pillow` 分支里 ⇒ 注册了,本机没执行(−1)
1838
+ - `PW_PATH="..." run "firefox"` 前面挂着**环境变量前缀** ⇒ 正则没算进去(+1)
1839
+
1840
+ ⇒ **对的数字、错的名单**。只核对总数永远发现不了,只有比对名单才抓得到。
1841
+ 修正后:注册 **122** / 本机执行 **121**,差值恰好是 `visual`,名单无交叉遗漏。
1842
+
1843
+ ⚠️ 诚实边界:`visual` 要 Pillow、`tsc` 要 typescript、`responsive` / `firefox` 要 node
1844
+ ⇒ "这一台机器上跑了几条"会小于等于注册数。文档里写门禁总数按**注册数**写。
1845
+
1846
+ ### 修:废弃门禁的反向控制**写死了版本号**(版本一推进就静默失效)
1847
+
1848
+ `deprecation-gate.py --selftest` 里的突变体原本写死 `removeIn='0.7.8'`。
1849
+ 在 0.7.7 时它是"未来版本"(只触发 ③),升到 0.7.8 就变成"**已到期**"
1850
+ ⇒ ④ 连带触发 ⇒ 隔离断言报「实际 2 条」。
1851
+
1852
+ ⚠️ 这种失效方式特别坏:它表现为**隔离断言红**,看起来像"判据坏了",
1853
+ 容易把人引去改一个没坏的判据 —— 和 0.7.6 那次 `gate-fixture` 的归因错乱是同一类病。
1854
+
1855
+ ⇒ 突变体的版本号改为**从当前版本推导**(`this_minor()` / `same_minor_future()` /
1856
+ `next_minor()` / `past_minor()`)。已在 0.7.8 / 0.7.9 / 0.7.30 三个版本下验证全绿。
1857
+
1858
+ ℹ️ 另外确认了一件**正确的事**:把版本推到 0.9.3(已过 `state--delayed` 承诺的 0.8.0)
1859
+ 自检会红,且报的是「承诺了却没删」—— 那正是它该说的话,不是自检坏了。
1860
+
1861
+ 本版本门禁注册数:122 道门禁(本机实际执行 121,差的那条是 `visual`)。
1862
+ ⚠️ 这句话是**故意写在这里**的:它会被 `doc-facts` 门禁拿去和 `check-all.sh`
1863
+ 实际注册数比对 ⇒ 上面那个数字要是改错,`doc-facts` 会红。
1864
+
1865
+ ---
1866
+
1867
+ ## [0.7.7] - 2026-10-09
1868
+
1869
+ ### 加:废弃治理 —— 移除公开名字从此要走流程(M9)
1870
+
1871
+ **此前,移除一个公开名字(令牌 / 类名)不受任何门禁约束。**
1872
+
1873
+ 0.7.4 移除了 `--measure`,只在更新日志里记了一笔 ⇒ 没有一处机器可读的地方说清:
1874
+
1875
+ | 下游需要知道 | 此前 | 现在 |
1876
+ |---|---|---|
1877
+ | 什么时候开始算废弃 | 人肉记忆 | `deprecatedIn` |
1878
+ | 什么时候**允许**真的删 | 没有约定 | `removeIn`(必须跨 ≥1 个次版本) |
1879
+ | 删了之后用什么替代 | 自己翻 | `replacement`(且门禁证明它真存在) |
1880
+
1881
+ 对标 Primer 的 Deprecated / Removed 两档:废弃要有告警、有替代方案、提前公告、
1882
+ 并用 codemod 或 lint 阻断继续使用。
1883
+
1884
+ **新建 `ai/deprecations.json`(人工持有的登记册)**,两个桶:
1885
+
1886
+ - `planned` —— 走流程的:先登记,到 `removeIn` 才允许删。判据**双向**看住:
1887
+ 到期没删红(承诺了没兑现)、提前删也红(窗口被跳过)。
1888
+ - `retroactive` —— 事后补登(已经删了才来登记)。它是**历史欠账**,不是流程;
1889
+ 数量写死在 `budget.retroactive` 当棘轮 ⇒ 想再加一条必须改预算,
1890
+ 而改预算是一次看得见的、要写理由的改动。目前只有 1 条:`--measure`(0.7.4)。
1891
+
1892
+ **新建 `ai/surface.baseline.json`(公开面基线,334 个名字)**,🔴 **只增不减**。
1893
+ 理由:如果 `--update` 会顺手把消失的名字从基线里抹掉,那"先删代码、再跑一次
1894
+ `--update`"就能让"未登记的移除"永远不红 ⇒ 这道门禁等于没有。
1895
+ 只增不减 ⇒ 历史抹不掉 ⇒ 判据永远有牙。
1896
+
1897
+ ### 加:一道门禁 `05-audit/deprecation-gate.py`(九条判据)
1898
+
1899
+ | # | 判据 |
1900
+ |---|---|
1901
+ | ① | 条目字段齐备,`kind` ∈ token / class |
1902
+ | ② | `deprecatedIn` 不得是未来版本(不许"预告") |
1903
+ | ③ | `removeIn` 必须比 `deprecatedIn` 晚**至少一个次版本**(迁移窗口) |
1904
+ | ④ | 已到期 ⇒ 名字**必须已经**消失(兑现) |
1905
+ | ⑤ | 未到期 ⇒ 名字**必须还在**(不许提前删) |
1906
+ | ⑥ | `replacement` 若非空 ⇒ 它指向的东西必须真的存在 |
1907
+ | ⑦ | 基线里有、现在没了 ⇒ 必须有已到期的登记(不许无流程移除) |
1908
+ | ⑧ | `retroactive` 条数 ≤ budget(棘轮) |
1909
+ | ⑨ | 已到期且已消失的登记名 ⇒ 必须在基线里(证明它**曾经存在**,不是打错字) |
1910
+
1911
+ 反向控制 10 项全绿,其中 5 条带**隔离断言**(必须只红了目标那一条)
1912
+ ⇒ 证明突变体真打到被测判据,不是被别的判据顺带抓到。
1913
+
1914
+ ⚠️ 库里 `planned` 只有 1 条时,④⑤ 的实例是靠**自检的合成条目**保证的,
1915
+ 不是靠"恰好有废弃项"。哪天 `planned` 空了,判据也不会变成永远绿的死判据
1916
+ (自检每次都跑,见 `behavior-reverse.sh`)。
1917
+
1918
+ ### 废弃:`state--delayed`(0.7.7 登记,0.8.0 起允许移除)
1919
+
1920
+ 这个类承诺了一个「延迟显现」的机制,但全库**没有任何 JS** 会去设置它所依赖的
1921
+ `data-state="shown"` ⇒ 使用者必须自己接,而文档没写。
1922
+ 它读起来像本库已经实现了 —— 这类名字最危险,因为误会发生在读者脑子里,代码不会报错。
1923
+
1924
+ 没有替代品:想要延迟显现,自己给元素加 `data-state="shown"` 即可
1925
+ (`states.css` 里那两行就是全部实现)。
1926
+
1927
+ ⚠️ 这是一次**产品判断**,不是机械结论 —— 若你认为它该留,删掉 `planned` 里那一条即可,
1928
+ 门禁不会拦(它只拦"没登记就删"和"登记了不兑现")。
1929
+
1930
+ ### 改:文档与元数据
1931
+
1932
+ - `docs/AI-CONTRACT.md` —— 补废弃登记册与基线的说明,并写明**诚实边界**:
1933
+ 我们没有 codemod、也没有运行时告警 ⇒ 这个"窗口"只是版本号承诺,不是技术阻断
1934
+ - `ai/deprecations.schema.json` —— 新 schema;已接进 `ai/cli.js validate`
1935
+ 与 `05-audit/ai-layer-gate.js`(schema 份数 7 → 8)
1936
+ ⇒ 正例通过、5 个反例(kind 写错 / 版本号不是三段 / 缺 reason /
1937
+ budget 写成字符串 / replacement 写成数字)全部被拒
1938
+
1939
+ ---
1940
+
1941
+ ## [0.7.6] - 2026-10-09
1942
+
1943
+ ### 修:成熟度阶梯**没有鉴别力**(M8)
1944
+
1945
+ 改之前的实测:**27 个组件全是 `stable`**。
1946
+
1947
+ `ladder`(alpha / beta / stable)在这种分布下等于不存在:它不区分任何两个组件,
1948
+ 使用者想知道"这个组件稳不稳"只能自己去翻源码;更糟的是它给出一种
1949
+ "都被验证过"的错觉 —— 而实际上它们良莠不齐。
1950
+
1951
+ 对标 Primer:六档阶梯(Experimental → Alpha → Beta → Stable → Deprecated →
1952
+ Removed),**每档都有硬判据**。判据的存在感,就是 ladder 的鉴别力。
1953
+
1954
+ ⇒ 补上第 4 条判据:**行为欠账**。行为矩阵里还有 `manual`(手写副本未收编)
1955
+ 或 `gap`(该做没做)的组件,不许算 `stable` —— 因为那种组件"改一处不会全库
1956
+ 跟着变",正是 I-14 的病。
1957
+
1958
+ 结果:**stable 20 / beta 7**(accordion · combobox · drawer · dropdown ·
1959
+ overlay · select · tooltip)。
1960
+
1961
+ | 档位 | 判据 |
1962
+ |---|---|
1963
+ | `alpha` | 缺 `demo.html` 或缺 `05-audit` 下的专属门禁 |
1964
+ | `beta` | 前两条都有,但状态还在 `.is-*` 类上(违反 I-8)**或**还有行为欠账 |
1965
+ | `stable` | 四条全过 |
1966
+
1967
+ ⚠️ 副作用(**有意的**):`strict` profile 只放行 `stable` ⇒ 这 7 个组件在
1968
+ `strict` 下**不可用**。要用就切 `standard`,并接受它们可能还有手写副本。
1969
+
1970
+ ### 加:一道门禁 `05-audit/maturity-gate.py`
1971
+
1972
+ 五条判据:
1973
+
1974
+ 1. 每个组件的档位必须等于**独立重算**的结果(本文件自己扫源码,不读生成器的结论)
1975
+ 2. 档位必须在 ladder 里
1976
+ 3. 🔴 **ladder 必须有鉴别力**:不得所有组件都落在同一档 —— 这条判据存在的全部理由
1977
+ 4. `maturityReasons.behaviorDebt` 必须与 `ai/behaviors.json` 实测一致(双向)
1978
+ 5. `maturityReasons` 里四条判据字段必须与独立重算一致(双向)
1979
+
1980
+ ⚠️ 第 3 条的可达路径值得写下来,免得将来以为它永远绿:
1981
+ 它统计的是**重算**档位的分布,**不是**契约里写的值 ⇒ 手写把 `maturity` 全改成
1982
+ `stable` 是碰不到它的(那会被第 1 条抓走)。真正让它红的是:有人在
1983
+ `ai/behaviors.json` 里把 `manual` / `gap` 洗成 `core`,或把第 4 条判据改松
1984
+ ⇒ 27 个组件重算全 `stable` ⇒ ladder 归零。
1985
+ 反向控制里就是照这条路径造的突变体,并额外断言**只红了第 3 条这一条**
1986
+ (证明突变体真打到被测代码,不是被 ①④ 顺带抓到)。
1987
+
1988
+ 反向控制 8 项全绿。已接进 `check-all.sh` 静态段与 `behavior-reverse.sh`。
1989
+
1990
+ ### 修:门禁反向控制的**归因错乱**(连带修掉一个假绿)
1991
+
1992
+ `05-audit/gate-selfcheck-fixtures.py` 用**全库退出码**判定每一条样本。
1993
+ ⇒ 仓库里任何一处不相干的命中,都会让 4 条「正常内容应**不**被抓」被报成
1994
+ 「**误报**了」。
1995
+
1996
+ 实测(本次收口撞上的):我在更新日志里写了一个 `leak-scan` 明令禁止的**内部流程词**
1997
+ (`terms.PROCESS_WORDS` 里的那个,字面就不复述了 —— 写了它这道门禁会红)
1998
+ ⇒ 全库报 1 处 ⇒ 那 4 条样本集体被冤枉,而它们一条都没误报。
1999
+ ⚠️ 这种报告是**误导性**的:它会把人引去改一个根本没坏的判据。
2000
+
2001
+ ⇒ 改成**归因到被注入的那一个文件**(看命中名单里有没有它),并加两道护栏:
2002
+
2003
+ - 扫到 **0 个文件**时退出码同样是 0 ⇒ 那 4 条会**全部假绿通过**。
2004
+ 现在要求"确实扫到了文件"才算"未被误报"。
2005
+ - 新增 `_check_attribution()`:在**别处确有命中**的前提下,真跑一条
2006
+ 「正常内容」样本 ⇒ 谁把判定退回"只看退出码",它立刻红。
2007
+ 已用突变体验证:退回旧逻辑 ⇒ EXIT=1,报的正是"判据没归因到单个文件"。
2008
+
2009
+ ### 改:文档与元数据
2010
+
2011
+ - `docs/AI-CONTRACT.md` —— 写明 `maturity` 是**算出来的**(不是拍的),
2012
+ 以及 `strict` 只放行 `stable` 的影响
2013
+ - `ai/components.meta.json` —— `maturityCriteria` 改为四条,并加
2014
+ `$whyFourth`(为什么补第四条)与 `$strictImpact`(strict 下的影响)
2015
+
2016
+ ---
2017
+
2018
+ ## [0.7.5] - 2026-10-09
2019
+
2020
+ ### 修:六道门禁在没有 `.git` 的树里**安静地假绿**(M5 远端复跑暴露)
2021
+
2022
+ `05-audit/_common.py` 的 `scannable_files()` 取文件清单只走 `git ls-files`。
2023
+ **没有 git**(zip / tarball 解压出来的树、或机器上没装 git)⇒ 返回 **0 个文件**:
2024
+
2025
+ | 门禁 | 表现 | 危险程度 |
2026
+ |---|---|---|
2027
+ | `api-doc` | 报「实现里挂载的全局对象:**0 个**」 | 红 —— 还算吵 |
2028
+ | `leak-scan` | 四个已知样本**一个都没抓到**,报"无敏感信息" | 🔴 **假绿**,最安静的那种 |
2029
+
2030
+ ⚠️ 这正是 I-10("报告通过却什么也没查")的又一种形态,
2031
+ 而且它自己的注释就写着"视野必须是磁盘上要交付的东西,**不是 git 索引**"。
2032
+
2033
+ ⇒ 加兜底:没有 git 时退回文件系统遍历(跳过产物目录 + 认 `.gitignore`)。
2034
+ `.gitignore` 匹配器只认三种写法(目录名 / `*.ext` / 整条路径)并支持 `!` 否定,
2035
+ 能力边界写在代码注释里 —— 它不是要实现 git,只是不想扫到 0 个。
2036
+
2037
+ **两种视野必须一致**(多一个少一个都不行:少了漏检、多了把生成产物扫进来):
2038
+ 实测 git 视野 463 个文件、兜底视野 **463 个**,逐路径相同。
2039
+ (中间踩了两次:不认 `.gitignore` ⇒ 兜底多扫 4 个刚生成的 PNG;
2040
+ 不认 `!` ⇒ 又把已入库的 31 个基线截图排除掉。)
2041
+
2042
+ 反向控制加进 `gate-selfcheck-fixtures.py`(该门禁已在 check-all 里每次都跑),
2043
+ 并用突变验证它会红:把兜底删掉 ⇒ EXIT=1
2044
+ 「无 git 时兜底没枚举出 a.js(返回 [])⇒ 假绿的根源」。
2045
+
2046
+ > 发现路径:这次 `git clone` 被 443 挡住,改用 GitHub tarball(同样来自远端、
2047
+ > 但没有 `.git`)跑全套门禁 ⇒ 立刻红。换一条验证通道,捅出了另一类洞。
2048
+
2049
+ ## [0.7.4] - 2026-10-09
2050
+
2051
+ ### 加:第二种令牌导出 `ai/tokens.tree.json`(M5)
2052
+
2053
+ 已有的 `ai/tokens.json` 是 DTCG 形状:键名带 `--`、字段带 `$`(`$value`/`$type`)。
2054
+ 而 **Style Dictionary 与 Figma Tokens(Tokens Studio)认的是不带 `$` 的
2055
+ `value`/`type**,并且要按类别分层。`$value` 与 `value` 是**互不兼容**的两套约定,
2056
+ 同一个文件喂不进两类工具 ⇒ 两种各出一份,`index` 段把两种键名互相对应
2057
+ (`--paper` ↔ `color/paper`)。
2058
+
2059
+ 实测:浅色 107 / 暗色 30 · 16 个类别(color / space / radius / fontSize /
2060
+ lineHeight / letterSpacing / fontFamily / measure / duration / easing / zIndex /
2061
+ breakpoint / shadow / lift / size / number)· 18.7 KB。
2062
+
2063
+ `ai/cli.js` 新增 `tree` 子命令(`--set` / `--cat`),两种导出都能从命令行查到。
2064
+
2065
+ ### 🔴 这根桩为什么必须是浏览器
2066
+
2067
+ `tokens.tree.json` 是**生成物**。生成物跟"再生成一遍"比,等于自己跟自己对答案
2068
+ (0.7.1 真踩过,I-10 实证 9)。⇒ `token-tree-gate.py` 判据 ⑤ 把每一个名字、
2069
+ 每一个值拿到真浏览器里,用 **Chrome 自己的 CSS 解析器(CSSOM)**重读一遍再比。
2070
+ 这根桩与本库任何一条正则无关。
2071
+
2072
+ **事故真值实测**:在 `tokens.css` 里插一个孤儿注释收尾符 —— 正则**照样读得到**
2073
+ 那条声明,而浏览器把它当成坏声明丢了 ⇒ ⑤b 立刻红(`--paper` 值对不上浏览器)。
2074
+ 这正是 `token-parse-gate.py` 里记过的 bug A 那一类:只有浏览器看得见。
2075
+
2076
+ ### 分类规则不是猜的
2077
+
2078
+ 令牌的**名字**决定类别、**值**决定类型,两边必须互相印证,对不上就红。
2079
+ 这套交叉校验当场抓出了一个我自己的错:
2080
+
2081
+ - `--lift-*` 我一直当成"阴影阶梯"(登记册里也这么写),
2082
+ 实测它是 `translateY()` 的**位移量**(1px / 2px / 8px)—— 已两处改正。
2083
+
2084
+ 落在四张分类表**之外**的新令牌会让生成器直接报错(不许默默归到兜底类别)。
2085
+
2086
+ ### 归一化(两个只有比过才知道的坑)
2087
+
2088
+ - CSSOM **保留**自定义属性值的原始空白(不折叠),而值中间的**注释也保留**
2089
+ (开头的会丢)⇒ 比对前两边都要剥注释 + 折叠空白。
2090
+ - 折叠空白**不能动引号内的**:`'SF Mono'` 里的空格是有意义的。
2091
+
2092
+ > ⚠️ 数字更正:0.7.3 的提交说明里写"109 道门禁",**写错了** ——
2093
+ > 那是我按日志里的 `PASS` 行数数的,不是门禁的注册条数。
2094
+ > 本库唯一的规范计数是 `bash 05-audit/check-all.sh` 里注册的条数
2095
+ > (`doc-facts-gate.py` 盯着这个数):**当前 98 道**。
2096
+ > 记在这里,是因为"报告里的数字必须能被复算"这条对本文档同样适用。
2097
+
2098
+ ### 反向控制(11 条,全部实测会红)
2099
+
2100
+ 7 条判据突变(index 删/编造/指向不存在的叶子 / 类型越界 / dark 引入新令牌 /
2101
+ 改错值 / 编造名字)+ 生成器拒绝归类表外令牌 + 两条归一化(引号保护 / 剥注释)。
2102
+
2103
+ ## [0.7.3] - 2026-10-09
2104
+
2105
+ ### 加:每个令牌都要登记「给谁用的」(M4)
2106
+
2107
+ 「声明了 108 个令牌、其中 9 个从没被 `var()` 引用」这个数字早就量出来过,
2108
+ 但它只是**一句观察**,不是判据 ⇒ 从来没人被它拦住。
2109
+
2110
+ 而这 9 个根本不是同一种东西 —— 在「只数孤儿个数」的视角下却完全一样:
2111
+
2112
+ | 令牌 | 真相 | 判定 |
2113
+ |---|---|---|
2114
+ | `--bp-sm` / `--bp-lg` / `--bp-xl` | 断点**物理上不可能**被 `var()` 引用(进不了 media query 条件) | `reference`:「没人引用」是本分,不是病 |
2115
+ | `--fs-xl` | 字号阶梯的一档 | `public`:阶梯允许有未使用的档位 |
2116
+ | `--lh-loose` / `--measure-aside` / `--tracking-normal` | 同上,但**文档里查不到** | `public` + 补进文档 |
2117
+ | `--ease-drawer` | 写着「抽屉式滑入(iOS 观感)」……而抽屉一直在用 `--ease-out` | 🔴 **真缺陷**:造它的人以为接上了 |
2118
+ | `--measure` | 被 `--measure-page` / `--measure-prose` 取代后的遗留 | 🔴 **真缺陷**:死重量 |
2119
+
2120
+ ⇒ 新增**手写**真值源 `ai/tokens.audience.json`(为什么必须手写:见 0.7.1 那条
2121
+ 「生成器自己跟自己对答案」)+ 门禁 `05-audit/token-audience-gate.py`。
2122
+
2123
+ **现状(实测)**:107 个令牌全部登记受众(public 72 · internal 31 · reference 4),
2124
+ 孤儿 **9 → 7**,棘轮写死 7,只能降。
2125
+
2126
+ ### 修:上面那两个真缺陷
2127
+
2128
+ - `03-patterns/drawer/drawer.css` 的四条进出场动画改用 `--ease-drawer`
2129
+ (此前用通用的 `--ease-out`,令牌从 0.4 起就是为抽屉造的,一直没接上)。
2130
+ - 移除 `--measure`(无引用、无文档、已被两个带语义后缀的版心令牌取代)。
2131
+ 同步清掉 `04-recipes/longform/` 两个单文件配方页里的内联副本。
2132
+
2133
+ ### 判据自己的两处「不够结实」(都已修,并写进 I-10)
2134
+
2135
+ 1. **第一版判据 ⑤ 写成「孤儿 + public 必须在文档里出现过」⇒ 假绿。**
2136
+ 把抽屉的 bug 放回去实测:只有棘轮兜住,⑤ 一声不吭 —— 因为
2137
+ `--ease-drawer` 被 README 里一句"已接回抽屉组件"顺带提到就算"有文档"了。
2138
+ ⇒ 改成**受审名单**(README 里那一节)双向校验:名单里的必须真存在、
2139
+ 真没人引用;反过来公开孤儿必须在名单里。
2140
+ 2. **Markdown 水平线 `---` 会被当成令牌名。** 名单解析改以字母开头匹配。
2141
+
2142
+ ### 反向控制(15 条,全部实测会红)
2143
+
2144
+ 8 条判据突变(删族 / 登记不存在的令牌 / 非法 audience / 孤儿登记成 internal /
2145
+ 公开孤儿不在名单 / 名单混入非孤儿 / 名单混入不存在的令牌 / 棘轮没同步 /
2146
+ 单条登记不写理由)+ 4 条扫描器本身(边界 `--measure` 不被 `--measure-page`
2147
+ 顶替 / 正向 / 空文档 / 只取该节)。
2148
+
2149
+ 外加**事故真值**两次:把抽屉改回 `--ease-out` ⇒ EXIT=1;把 `--measure`
2150
+ 加回 `tokens.css` ⇒ EXIT=1。
2151
+
2152
+ ## [0.7.2] - 2026-10-09
2153
+
2154
+ ### 修:两个「承诺了不存在的接口」
2155
+
2156
+ 它们和 0.4.0 那次 `is-*` 状态类是**同一种病**:说有、实现里没有,
2157
+ 照抄的人会在运行时拿到 `TypeError`,而没有任何门禁在比对这两者。
2158
+ 区别只是上次在 `API.md`,这次在**源码注释**里。
2159
+
2160
+ | 出处 | 承诺了什么 | 实际情况 |
2161
+ |---|---|---|
2162
+ | `03-patterns/pagination/pagination.js` 文件头用法示例 | `pg.on('change', fn)` | `Pagination` 上只有 `create` / `update` / `pagesOf` ⇒ `pg.on is not a function` |
2163
+ | `03-patterns/tabs/tabs.js` 自动初始化 | `data-on-change` 属性 | 把**字符串**塞进了要求函数的位置 ⇒ `typeof onChange === 'function'` 永远不成立 ⇒ 这个属性**从第一天起就没生效过**,而且不报错 |
2164
+
2165
+ ⇒ 两处都改成走 `fl-change` 事件(0.7.1 加的统一事件模型,正是为此存在的)。
2166
+
2167
+ ### 加:两道判据,专门盯这一类
2168
+
2169
+ **`api-form` 判据 5 —— 源码注释里承诺的 API 必须真实存在**
2170
+
2171
+ 扫组件注释里的 `<全局>.<方法>()` 与 `<实例>.<方法>()`,逐个到
2172
+ `global-api-probe.js` 的**浏览器实测**成员里核对(静态成员 ∪ 原型方法)。
2173
+
2174
+ ⚠️ **认实例别名是必需的**:真那一例是 `var pg = Pagination.create(...)` 然后
2175
+ 写 `pg.on(...)`。只认 `Pagination.on(` 的话,这条判据**恰好在它存在的理由上失效**。
2176
+
2177
+ ⚠️ 反向控制里**连扫描器本身一起验**(`promises_in` 是纯函数,可喂合成文本):
2178
+ 第一版扫描器的别名正则用了**非捕获组** ⇒ `group(2)` 直接 `IndexError`,
2179
+ 而自检**没抓到** —— 因为它只验了判定函数,根本没跑扫描器。
2180
+ ⇒ 又一条「判据没跑到被测代码」(记在 `docs/INVARIANT.md` I-10 实证 10)。
2181
+
2182
+ **`emit` 判据 ⑩ —— 不许用 HTML 属性当回调(`data-on-*`)**
2183
+
2184
+ HTML 属性里放不出函数,所以 `data-on-*` 形式的回调**物理上只能是死代码**。
2185
+ 要收通知就用 `fl-*` 事件。
2186
+
2187
+ ⚠️ 只扫代码、先剥注释:第一版扫原始行,把 tabs.js 里**解释这段历史的注释**
2188
+ 也命中了(注释里引用了那句旧代码)。判据要管的是"代码真的去读它"。
2189
+
2190
+ 两道判据都已用**事故真值**证明会红(把 `pg.on` / `data-on-change` 放回原位,
2191
+ 实测 `EXIT=1`),不是"写完就绿"。
2192
+
2193
+ ---
2194
+
2195
+ ## [0.7.1] - 2026-10-09
2196
+
2197
+ ### 加:统一事件模型 —— 组件**开始真的发 DOM 事件**(emit 核)
2198
+
2199
+ 在此之前,本库**一个 DOM 事件都不发**,只有构造时传进来的回调
2200
+ (`onChange` / `onSelect` / `onOpen` / `onClose`)。
2201
+
2202
+ 对陌生人来说这是最隐蔽的一类坑:
2203
+
2204
+ ```js
2205
+ root.addEventListener('change', fn); // 写得很自然
2206
+ // ⇒ 永远不触发,而且**没有任何报错**。
2207
+ ```
2208
+
2209
+ 他不会去翻文档找"本库只支持回调",只会认定"这个库是坏的"。
2210
+
2211
+ ⚠️ 而且就算**随手补上**事件,也有两个坑是只有跑起来才看得见的:
2212
+
2213
+ | 坑 | 为什么静态扫不出来 |
2214
+ |---|---|
2215
+ | 事件名不加前缀 | 组件内部有原生 `<input>` / `<button>`,它们的 `change` / `click` 会**冒泡上来** ⇒ 调用方收到自己没订阅过的东西 |
2216
+ | `new CustomEvent(...)` | 老 WebView **没有这个构造函数**,它在**用户交互的路径上**抛异常 —— 本地 Chrome 永远测不出来,真机上表现为"点了没反应" |
2217
+
2218
+ ⇒ 抽成工具核 `01-tokens/behavior/emit.js`,**兜底逻辑全库只有一份**。
2219
+
2220
+ 统一约定(十个组件一致):`fl-` 前缀 · `bubbles: true` · `cancelable: false` ·
2221
+ 派发在**组件根**(⇒ `e.target` 就是组件根)· `e.detail.value` 带值。
2222
+
2223
+ | 组件 | 新增事件 |
2224
+ |---|---|
2225
+ | `Select` | `fl-change` `fl-open` `fl-close` |
2226
+ | `Combobox` | `fl-change` |
2227
+ | `DateRange` | `fl-change` |
2228
+ | `Accordion` | `fl-change` |
2229
+ | `Tabs` | `fl-change` |
2230
+ | `Tree` | `fl-select` |
2231
+ | `Dropdown` | `fl-select` `fl-open` `fl-close` |
2232
+ | `Pagination` | `fl-change` |
2233
+ | `Popover` | `fl-open` `fl-close` |
2234
+ | `Drawer` | `fl-close` |
2235
+
2236
+ ⚠️ 委托时**必须认 `e.target`**:事件会冒泡,嵌套组件会在同一个祖先上给你
2237
+ 两个 `fl-change`。事件名不能代替身份。
2238
+
2239
+ ### 加:门禁 `emit`(九条,含老 WebView 兜底的现场验证)
2240
+
2241
+ ⑦ 这条不是"顺便测一下":老分支平时**永远走不到**,写错了本地一行报错都没有。
2242
+ ⇒ 判据**现场把 `window.CustomEvent` 摘掉**,强制走 `createEvent` +
2243
+ `initCustomEvent` 那条路,断言事件照样送到、`type` / `detail` / `bubbles` 一致。
2244
+
2245
+ ⑨ 唯一入口:组件源码里**不许私自**造事件。
2246
+ 判据先把注入块整段摘掉再看块外手写部分(否则使用点里必然命中——那是注入进来的);
2247
+ 只扫源码不扫 dist(dist 压缩后注释被剥掉,扫它必假红)。
2248
+
2249
+ 反向控制:6 个突变 + ⑨ 的三向验证(该抓的抓到 / 注入块内不误报 / 真仓库 0 命中)。
2250
+
2251
+ ### 成本(实测,不是估的)
2252
+
2253
+ | | 原始 | gzip |
2254
+ |---|---|---|
2255
+ | 注入块(每个使用点一份) | +731 B | +411 B |
2256
+ | 10 个组件合计 | **+9.7 KB**(各 +880…+1059 B) | **+3.4 KB**(各 +291…+387 B) |
2257
+ | 全库(含 dist 与核文件) | +11.8 KB | +5.1 KB(+2.62%) |
2258
+
2259
+ 体积基线已按实测值重录。
2260
+
2261
+ ### 修
2262
+
2263
+ - 🔴 **我自己引入的一次静默回归**(实测,不是推演):
2264
+ 注入 emit 核之后,`gen-ai-contract.py` 抽全局名的正则 `window\.([A-Z]\w*)\s*=`
2265
+ 把核里 `typeof window.CustomEvent === 'function'` 的 **`===` 第一个 `=`**
2266
+ 当成了赋值 ⇒ **10 个组件的 `global` 全变成 `CustomEvent`**。
2267
+
2268
+ ⚠️ 最恶劣的一点:**它没让任何门禁变红**。
2269
+ 干净克隆跑完**当时全套**门禁 EXIT=0 —— 因为那道门禁的判据是
2270
+ 「契约 == 生成器重跑一遍」,而生成器**正是**写错的那个(自己跟自己对答案)。
2271
+
2272
+ ⇒ 三处修:正则改 `=(?!=)`;抽取前先摘掉 `BEHAVIOR INJECT` 块(核不是组件源码);
2273
+ 新增判据「`ai/components.json` 声明的 `global` 必须在**浏览器实测**里存在」
2274
+ (`api-form` 判据 4 + 反向控制,用 `CustomEvent` 当事故真值反例)。
2275
+ ⇒ 顺带:`global` 从此也认 `global.X =`(IIFE 参数写法),
2276
+ 此前 `select` / `dropdown` / `tree` / `combobox` / `date-range` / `popover`
2277
+ 的 `global` 一直是 `null` —— 契约在骗人,它们明明都有全局对象。
2278
+ ⇒ 记录在 `docs/INVARIANT.md` I-10 实证 9。
2279
+ - `Tree` 的 `destroy()` 此前没有销毁 typeahead(留下一个已无意义的定时器),已补上。
2280
+ - `docs`:`API.md` 新增「事件」一节(事件一览 + 五条统一约定 + 委托示例)。
2281
+
2282
+ ---
2283
+
2284
+ ## [0.7.0] - 2026-10-08
2285
+
2286
+ ### 加:行为覆盖矩阵 `ai/behaviors.json` + 门禁(M3)
2287
+
2288
+ 四个核抽完之后,还有一件**没人管**的事:**哪些组件必须有哪种行为**此前只写在人的脑子里。
2289
+ 后果是三件分不清的事:
2290
+
2291
+ | 分不清 | 后果 |
2292
+ |---|---|
2293
+ | 谁还没收编? | 只能逐个 grep,改完没人知道还剩几处 |
2294
+ | 谁不适用? | "不适用"和"忘了做"看起来一模一样 |
2295
+ | 新加一个组件时要不要 Esc? | 没人提醒 |
2296
+
2297
+ ⇒ 现在这张表是**机器可读 + 强制完整**的:`18 个组件 × 4 种行为 = 72 格,一格都不能少`。
2298
+
2299
+ 四种状态(不是三选一,第四种是故意留的):
2300
+
2301
+ | 状态 | 含义 | 门禁怎么核 |
2302
+ |---|---|---|
2303
+ | `core` | 已收编到核 | 去源码里查:**有注入块且真的调用了核**(只注入不调用 ⇒ 红) |
2304
+ | `manual` | 有手写实现,未收编 | 必须写清现状 |
2305
+ | `gap` | **应当有但根本没实现** | 必须写理由,计入缺口预算 |
2306
+ | `n/a` | 不适用 | 必须写理由 |
2307
+
2308
+ ⚠️ `gap` 这一档是**故意加的**:没有它,"应做未做"只能往 `n/a` 里塞 ——
2309
+ 那就成了"用不适用来掩盖没做"。这四个状态里,只有 `gap` 是**承认缺口**的。
2310
+
2311
+ **棘轮**:`manual` 与 `gap` 的数量写死在 `budget` 里,门禁要求**两边相等** ⇒
2312
+ 只能降不能升;降了要把上限一起改小(不许偷偷升回去)。
2313
+
2314
+ ### 当前这张表实测出来的数(不是估计)
2315
+
2316
+ | 状态 | 数量 |
2317
+ |---|---|
2318
+ | `core`(已收编) | **9** |
2319
+ | `manual`(手写待收编) | **9** —— dismissable 5(select / combobox / drawer / overlay / tooltip)+ focus-return 4(select / dropdown / overlay / tooltip) |
2320
+ | `gap`(应做未做) | **2** —— accordion 的方向键可选增强、dropdown 的按首字母跳菜单项 |
2321
+ | `n/a`(不适用,均已写明理由) | **52** |
2322
+
2323
+ ⇒ 也就是说:**核抽完了,但还有 9 处手写实现没收编、2 处明确承认的缺口**。
2324
+ 这两个数现在**被门禁盯着、只会往下降**,不再是"大概做完了"。
2325
+
2326
+ ### 加:两道门禁
2327
+
2328
+ | 门禁 | 判据 | 反向控制 |
2329
+ |---|---|---|
2330
+ | `beh-matrix` | 六条:每格必须登记 / 状态合法 / `core` 必须真用核 / 非 core 必须写理由 / 数量与 budget 一致(棘轮) / 不存在的组件或行为名 ⇒ 红 | 七个反例(声明 core 但源码没有 / 删掉登记 / n/a 无理由 / 未知行为 scrollLock / budget 不一致 / 状态写成 maybe / 注入了却没调用核)⇒ **每个都被抓到** |
2331
+ | `beh-reverse` | 把四个核 + 矩阵的反向控制**每次都跑一遍** | —— |
2332
+
2333
+ ⚠️ 为什么要单开 `beh-reverse`:突变体是**硬编码正则**,源码一改形状它就匹配不上
2334
+ ⇒ 那个"反例"根本没生效,而门禁报告**照样是 PASS**(今天一天踩了两次)。
2335
+ ⇒ 反向控制不接进 `check-all` 就等于没有:**掉了牙它得自己会叫**。
2336
+
2337
+ ### 加:`ai/behaviors.schema.json`
2338
+
2339
+ 与 `components.json` / `tokens.json` 同样受 `ai-layer-gate` 的 schema 校验。
2340
+ ⚠️ 写的时候 `propertyNames` 被校验器白名单拦下了 —— 白名单是真的会拦的,
2341
+ 于是键名合法性改由 `beh-matrix` 的第 ⑥ 条守(用得上、但校验器还没实现的关键字不许进 schema)。
2342
+
2343
+ ## [0.6.2] - 2026-10-08
2344
+
2345
+ ### 加:第四个微行为核 `01-tokens/behavior/typeahead.js`(打字定位)
2346
+
2347
+ 迁移前全库只有两处实现,而它们**各做了一半**:
2348
+
2349
+ | 实现 | 会累积前缀("co" ⇒ components) | 连打同一字符会循环 |
2350
+ |---|---|---|
2351
+ | select | ✅ | ❌ 连按两次同一个字母,游标**不动** |
2352
+ | tree | ❌ 打 "co" 只会先跳到 c 项再跳到 o 项 | ✅ |
2353
+
2354
+ ⇒ 两处缺的**不是同一半**,"完整"这件事此前没有任何地方定义过,也没有门禁在守。
2355
+
2356
+ 核一次定死(照 APG typeahead 约定):单字符跳到下一个匹配项(可绕回)/
2357
+ 快速连打累积前缀 / 连打同一字符在匹配项间循环 / 停顿超过 500ms 清空缓冲 /
2358
+ 跳过禁用项 / 不匹配则不动也不报错。
2359
+
2360
+ 已迁移:`select`、`tree`。
2361
+
2362
+ **不适用**:`combobox` —— 它有输入框,**打字即过滤**,不是"打首字母跳到某一项",
2363
+ 把两者混为一谈会让用户每打一个字就被拽走焦点。
2364
+
2365
+ ### 加:两道门禁
2366
+
2367
+ | 门禁 | 判据 | 反向控制 |
2368
+ |---|---|---|
2369
+ | `typeahead` | 真实浏览器七条:跳转 / 连打循环 / 绕回 / 累积前缀 / 不匹配不动 / 跳过禁用项 / 超时清空 | 改坏四处(从当前项开始找 / 去掉连打特判 / 去掉超时清空 / 禁用项也参与)⇒ **每处都被抓到** |
2370
+ | `tree-check`(加两条) | 连打 b ⇒ Button.js → base.css(**循环**);打 "ba" ⇒ base.css(**累积**) | 去掉核里的连打特判 ⇒ 循环那条红(实测) |
2371
+
2372
+ ⚠️ 顺带承认一条**我自己的判据没那么结实**:给 select 加的"二次打开后仍命中"那条,
2373
+ 实测**守不住** `close()` 里的缓冲清空 —— 把那行删掉它依然绿(核自带的 500ms 超时
2374
+ 会兜住)。⇒ 已把注释改成它真正守得住的东西("组件有没有真的接上核",
2375
+ 实测:断掉接线 ⇒ 红)。**没被反向控制证明过的声称,不许写进判据注释**。
2376
+
2377
+ ### 体积账(如实记)
2378
+
2379
+ | 项 | 实测 |
2380
+ |---|---|
2381
+ | typeahead 注入块 | 纯代码 **1733 B** |
2382
+ | `select.js` | gzip 4884 → **5838 B(+954 B)** |
2383
+ | `tree.js` | gzip 5060 → **5772 B(+712 B)** |
2384
+ | 全库 gzip | 190.7 → **194.7 KB(+4.0 KB / +2.1%)** |
2385
+
2386
+ ⚠️ 全库这个数比"使用者的成本"大:新增的 `01-tokens/behavior/typeahead.js`
2387
+ 自己 gzip 2360 B(文件头注释占大头),但**它不进页面** ——
2388
+ 使用者复制的是组件文件,里面只有 1733 B 的注入块。
2389
+ ⇒ 真实增量是**该组件 gzip +0.7 ~ +0.95 KB**。
2390
+
2391
+ | 四个核 | 注入块纯代码 |
2392
+ |---|---|
2393
+ | dismissable | 1681 B |
2394
+ | focus-return | 927 B |
2395
+ | roving | 2463 B |
2396
+ | typeahead | **1733 B** |
2397
+
2398
+ ### 未做(下一批继续)
2399
+
2400
+ 机器可读的行为覆盖矩阵(把"哪些组件必须有哪种行为、不适用的写明理由"变成门禁)。
2401
+
2402
+ ## [0.6.1] - 2026-10-08
2403
+
2404
+ ### 加:第三个微行为核 `01-tokens/behavior/roving.js`(方向键游走)
2405
+
2406
+ "一组项里焦点怎么走" —— 迁移前手写在 **tabs / tree / dropdown / select / combobox** 里。
2407
+
2408
+ 危险的不是"写了很多次",是**每一处自己决定边界行为**:
2409
+
2410
+ | 边界 | 迁移前的实测差异 |
2411
+ |---|---|
2412
+ | 走到最后一个再按一次 | tabs 用取模绕回;tree 与 dropdown 用 `if` 夹逼(步长大于 1 时结果不同) |
2413
+ | 禁用项 | tabs 跳过;dropdown 的候选集根本不含禁用项;select 另有一套"按方向找下一个可用" |
2414
+ | Home / End | tabs、tree、dropdown 有;select、combobox 有;pagination 是**翻页**不是移动 |
2415
+ | 纵向 / 横向 | 有的只认 ↑↓,有的只认 ←→,有的两种都认 |
2416
+
2417
+ ⇒ 同一个"按 ↓"在不同组件里有不同结果。这种不一致**没有门禁会报**,只有键盘用户会遇到。
2418
+
2419
+ 核一次定死:取模循环(对负数、对步长大于列表长度都对)、`items`(全集,管 tabindex)
2420
+ 与 `movable`(可用子集,管移动)分开、Home/End、`axis` 决定认哪一对方向键。
2421
+
2422
+ **已迁移**:`tabs`、`tree`、`dropdown`。
2423
+
2424
+ ### 一个必须写下来的边界:不是所有"↑↓"都是游走
2425
+
2426
+ | 组件 | 判定 | 理由 |
2427
+ |---|---|---|
2428
+ | `dropdown` | **部分适用** | 要的是同一套步进/绕回/Home·End,但 APG 菜单的菜单项按规范**全部 `tabindex="-1"`**(焦点由程序移动,菜单项永远不占 Tab 位,Tab 的作用是**关闭菜单**)⇒ 核用 `tabindex: false` 只接管步进,不碰 tabindex |
2429
+ | `select` / `combobox` | **不适用** | 用 `aria-activedescendant` 模式:**焦点常驻按钮**,↑↓ 不移动 DOM 焦点 ⇒ 核的键盘绑定(靠 `e.target` 在不在候选集里判断)根本收不到 |
2430
+ | `pagination` | **不适用** | ←→ / Home·End 是**翻页命令**,不是在一组项里移动焦点 |
2431
+
2432
+ ⇒ 这三处的"不适用"不是偷懒留下的口子,是**有理由的显式判定**;
2433
+ 下一步会用机器可读的行为覆盖矩阵把它们登记下来并要求写明理由。
2434
+
2435
+ ### 顺带修掉的两处"门禁自己不结实"
2436
+
2437
+ 1. **`tree-check` 新加的那条判据第一版是假绿**。
2438
+ 它只数**初始状态**里有几个 `tabindex="0"` ⇒ 把 tree 里"移动前把其余置 -1"那行删掉后,
2439
+ 它**依然全绿**(I-10 实证 8)。
2440
+ ⇒ 改成:判据**自己先走一遍键盘**(移动 → 收起 → 再移动 → 再展开)再数。
2441
+ 改完的实测:原样 1 个 0,删掉那行 ⇒ **7 个 0**,判据红了。
2442
+ 2. **注入块里的注释是要被复制进每个使用点的字节**。
2443
+ 初版还多暴露了一个 `wrap()`("越界就夹回边界"),实测**零个使用点**调用
2444
+ ⇒ 它是被注入进 3 个组件的死代码 ⇒ 删掉,长解释移到真值源文件头。
2445
+ 注入块 2801 B → **2463 B**(gzip 1232 → 1081 B)。
2446
+
2447
+ ### 加:一道门禁
2448
+
2449
+ | 门禁 | 判据 | 反向控制 |
2450
+ |---|---|---|
2451
+ | `roving` | 真实浏览器十条:绕回 / `loop=false` 时不动 / Home·End / 跳过禁用项 / `rove()` 让全集只有一个 0 / 取模对大步长也对 / destroy 后不响应 / **`tabindex:false` 时一个 tabindex 都不写** / 纵向只认 ↑↓ / `axis` 隔离 | 把核改坏四处(取模改 if 夹逼 / `rove` 只管可移动项 / 去掉 Home·End / 忽略 `tabindex:false`)⇒ **每处都必须被抓到** |
2452
+
2453
+ 第 ⑨ 条是补出来的:核悄悄给菜单项写 `tabindex="0"` 会让菜单多出一个 Tab 停靠点,
2454
+ "Tab 用来关闭菜单"这条契约当场失效 —— 而 `dropdown-check` 里那条断言会先红,
2455
+ 看起来像"dropdown 坏了",其实是核越界。
2456
+
2457
+ ### 体积账(如实记)
2458
+
2459
+ | 项 | 数字 |
2460
+ |---|---|
2461
+ | roving 注入块 | 纯代码 **2463 B**,gzip ≈ **1.05 KB** / 每个使用点 |
2462
+ | 三个核合计 | dismissable 1681 B / focus-return 927 B / roving 2463 B |
2463
+ | 全库 gzip | 178.3(抽核前)→ 181.9(dismissable)→ 184.2(focus-return)→ **190.7 KB(roving,+3.6%)** |
2464
+
2465
+ ⚠️ 全库这个数会吓人,但**它不是使用者的成本**:一个页面只引**一个**组件,
2466
+ 所以真实增量是"该组件 gzip +1.05 KB",不是 +6.5 KB。基线已重录,归因就是这一条。
2467
+
2468
+ ### 未做(下一批继续)
2469
+
2470
+ 第四个核 typeahead(首字母跳转),以及机器可读的行为覆盖矩阵。
2471
+
2472
+ ## [0.6.0] - 2026-10-08
2473
+
2474
+ ### 加:微行为核 —— 同一件事不再写 N 份(M2 · 对标 Radix 的行为原语)
2475
+
2476
+ Radix 把"关得掉 / 焦点归位 / 方向键游走 / 首字母跳转"做成**四个原语**,
2477
+ 组件引用它们,而不是各自重写。我们此前是后者:
2478
+
2479
+ | 行为 | 迁移前(14 个组件 JS 实测) |
2480
+ |---|---|
2481
+ | Esc 关闭 | 手写 **10 处**,分布在 7 个文件 |
2482
+ | 点击外部关闭 | 手写 **7 处**,分布在 7 个文件 |
2483
+ | 焦点保存→归位 | 手写 **6 处**,分布在 5 个文件 |
2484
+ | roving tabindex | 手写 **33 处**,分布在 9 个文件 |
2485
+ | 首字母跳转(typeahead) | **0 处** —— 全库根本没有 |
2486
+
2487
+ (复算方式:对本库 14 个组件 JS 做逐文件的正则计数,见 `05-audit/` 下的测量脚本)
2488
+
2489
+ ⭐ 同一件事写 7 份 ⇒ 改一处要改 7 处 ⇒ 必然漏(I-14)。
2490
+ 更难看的是**漏了没人知道**:popover 曾因 `contains` 没把触发元素算进去,
2491
+ 导致"点触发器刚打开就被关掉" —— 这种 bug 会在每个手写副本里各犯一遍。
2492
+
2493
+ ### 本批落地的第一个核:`01-tokens/behavior/dismissable.js`
2494
+
2495
+ 管两件事:① Esc 关闭 ② 点击浮层之外关闭。不管焦点归位、滚动锁、动画
2496
+ (一个核只做一件事,否则它就变成另一个"什么都管"的大文件)。
2497
+
2498
+ **为什么是"生成进使用点"而不是"抽个公共文件让大家引"**
2499
+
2500
+ JS 用 `<script>` 加载时**没有** `@import` 的等价物,于是共享一段 JS 只有三条路:
2501
+
2502
+ | 方案 | 后果 |
2503
+ |---|---|
2504
+ | A. 抽公共文件,组件运行时依赖 | 使用者复制了组件却漏复制核 ⇒ **行为静默消失**(Esc 突然关不掉,不报任何错) |
2505
+ | B. 有核就用、没核就用自己的副本 | 同一份逻辑仍然写 N 份,I-14 的病一点没治 |
2506
+ | **C. 真值源一份 + 生成进使用点 + 门禁证明跟随** ← 采用 | 使用者永远只复制一个文件;"改一处"在物理上成立 |
2507
+
2508
+ C 与 CSS 页面骨架(page.css)是同一个形状,只是 CSS 靠 `@import` 天然满足第二点,
2509
+ JS 要靠 `05-audit/gen-behavior.py` 注入。
2510
+
2511
+ **代价(如实写)**:注入块是纯代码 1731 B(注释留在真值源里,不重复注入),
2512
+ 每个使用点 gzip 多约 0.8 KB;全库 gzip 178.3 → **181.9 KB(+2.01%)**。
2513
+ 体积基线已重录,归因就是这一条。
2514
+
2515
+ ### 已迁移
2516
+
2517
+ - `popover`:删掉手写的两处 Esc 分支 + 全局外部点击监听
2518
+ - `dropdown`:删掉手写的 Esc 分支 + document 点击监听
2519
+ (⚠️ 顺带修掉一个隐患:若两处都留着,同一元素上的两个监听器会**各关一次**,
2520
+ `stopPropagation` 挡不住同级的另一个监听器 —— 那是 `stopImmediatePropagation` 的活)
2521
+
2522
+ ### 加:三道门禁
2523
+
2524
+ | 门禁 | 判据 | 反向控制 |
2525
+ |---|---|---|
2526
+ | `behavior` | 每个使用点里的核与真值源**逐行全等**;注入了就必须真的调用它 | 改真值源一个字 ⇒ 红;篡改使用点里的块 ⇒ 红;注入幂等 |
2527
+ | `dismissable` | 真实浏览器里七条:嵌套时一次 Esc **只关最内层** / 再按一次关外层 / 点外部只关最内层 / 点内部不关 / destroy 后 Esc 不再触发 / destroy 后点外部也不再关到它 / `when()` 为 false 不响应 | 把核改坏三处(去 stopPropagation / 栈顶改栈底 / destroy 不摘栈)⇒ **每处都必须被抓到** |
2528
+ | `admission`(改) | 原判据只认 `if (k === 'Escape'…)` **这一种写法** ⇒ 换种写法(哪怕更对)就违规。改为:走核的交给核自己的浏览器门禁(突变体比正则强) | — |
2529
+
2530
+ 第 ⑦ 条是补出来的:只测 Esc 抓不到"destroy 忘了摘栈" —— 因为 Esc 监听器确实被摘了,
2531
+ 而外部点击走的是共享的 document 监听器、只查栈 ⇒ 仍会关到已销毁的层。
2532
+
2533
+ ### 加:第二个核 `focus-return`(焦点归位)
2534
+
2535
+ "记住当前焦点、关闭时还回去"——实测手写了 **6 处,分布在 5 个文件**。
2536
+
2537
+ 它看起来只有两行,但每个手写副本都要各自回答三个降级问题,
2538
+ 而实测**5 个组件答出了 5 种不同答案**:
2539
+
2540
+ | 问题 | 答错的后果 |
2541
+ |---|---|
2542
+ | 要还原的元素已经**不在文档里**了怎么办? | `focus()` 不报错、但**焦点不动** ⇒ 用户以为还回去了,其实掉在 body 上 |
2543
+ | 焦点本来就在 `body` 上,要不要记? | 记了等于没记,还原时也不会比"不动"更好 |
2544
+ | 还原失败(元素不可聚焦)要不要抛错? | 抛了就是打断用户;不抛就要能看出来 |
2545
+
2546
+ ⇒ 键盘用户要从页面开头重新 Tab,而开发者完全看不出来(不报错、不告警)。
2547
+
2548
+ 核一次答完这三个问题,并用真实浏览器六条判据钉死(含"第二次 restore 不该又把焦点拽回去"
2549
+ —— 那会打断用户**已经主动移走**的焦点)。
2550
+
2551
+ 已迁移:`popover`(fallback 给触发器:打开它的元素若被删了,焦点至少回到触发器)、
2552
+ `drawer`(不给 fallback:元素没了就维持现状,**不假装还回去了**)。
2553
+
2554
+ ### 顺带修的一处:磁盘写满时浏览器门禁会误报红
2555
+
2556
+ 本库实测:C 盘 476G 用满(E 盘还有 421G),Chrome 启动失败并报
2557
+ "磁盘空间不足 / 无法创建 ProcessSingleton" ⇒ 浏览器门禁全红,**而代码一个字都没问题**。
2558
+
2559
+ ⇒ `05-audit/browser.js` 支持用 `FL_TMPDIR` 把临时 profile 指到别的盘。
2560
+ **不设就与原来完全一样**,不影响任何默认环境(含陌生人克隆后的第一次运行)。
2561
+
2562
+ ### 未做(下一批继续)
2563
+
2564
+ focus-return / roving / typeahead 三个核,以及 `ai/behaviors.json` 行为覆盖矩阵(M3)。
2565
+
2566
+ ## [0.5.1] - 2026-10-08
2567
+
2568
+ ### 修:三道门禁在陌生人机器上是坏的(M1 收口时干净克隆复跑暴露)
2569
+
2570
+ `0.5.0` 提交后,`git clone` 到一台干净机器上跑全量门禁:**107 通过 / 3 失败**
2571
+ —— `dist-fresh`、`bleed`、`legacy-api`。三道都是**本仓库里绿、别人克隆里红**。
2572
+
2573
+ 这是本库最难查的一类缺陷:维护者本地永远复现不了,只有陌生人一上来就撞上。
2574
+ (同一类问题已第四次出现,见 `docs/INVARIANT.md` I-10 实证 6 / 实证 7 与新增的 I-15。)
2575
+
2576
+ | 门禁 | 症状 | 根因 | 修法 |
2577
+ |---|---|---|---|
2578
+ | `dist-fresh` | 内容一字未改,17 个文件全报"不新鲜" | 散列按**磁盘字节**算。源码换行符是混合状态(LF 17 个 / CRLF 32 个),克隆时签出自动转换把那 17 个改成了 CRLF | 散列先归一换行再算 |
2579
+ | `bleed` | 产物目录里的同源 CSS 被判"裸标签选择器" | 例外按**路径前缀**判,产物目录那一侧没被豁免 | 判例前把产物路径映射回源路径 |
2580
+ | `legacy-api` | 产物目录里的兼容层被判"直接用了新 API" | 豁免按**路径**判,产物副本没被豁免 | 同上 |
2581
+
2582
+ 第二、三条尤其值得记:**同一份内容,源码侧放行、产物侧报错** ⇒ 错的不是产物,
2583
+ 是判据把规则写在了"位置"上而不是"内容"上。
2584
+
2585
+ 四条反向控制(证明修完之后判据仍然有鉴别力,不是被放宽了):
2586
+
2587
+ 1. 往产物目录的组件 CSS 里塞裸标签 ⇒ 仍红
2588
+ 2. 往产物目录的兼容层以外的 JS 里塞新 API ⇒ 仍红
2589
+ 3. 真改一个源码文件的内容 ⇒ `dist-fresh` 仍红
2590
+ 4. **只换换行符(LF⇄CRLF)⇒ 仍绿**(这条已写进 `build-dist.py --selftest` 常驻,
2591
+ 防止以后有人把判据改回按字节算)
2592
+
2593
+ ### 修:六道门禁看不见"还没进索引的新文件"(假绿)
2594
+
2595
+ 上一版的本地绿灯是**假的**:产物目录当时还没 `git add`,
2596
+ 而六道门禁取文件清单用的 `git ls-files` ⇒ 只返回**已跟踪**文件 ⇒ 一个都没看见。
2597
+
2598
+ 未跟踪文件恰恰是最需要被看的(那是刚写完的新代码),却被排除在视野外。
2599
+
2600
+ - 改:共享模块新增 `scannable_files()` = 已跟踪 ∪ 未跟踪且未被忽略
2601
+ (`repo-hygiene` 不动 —— 它的正确语义正是"只查索引")。
2602
+ - 反向控制:新建一个**未跟踪**的违规文件,旧视野 **0 命中**、新视野必须抓住
2603
+ —— 两道门禁实测都抓住(清理后恢复绿)。
2604
+
2605
+ ### 加:不变式 I-15「判据绑在内容上才稳定」
2606
+
2607
+ 把上面两条的共性固化:**同一份内容在两个环境下给出两个结论 ⇒ 判据一定绑错了东西。**
2608
+ 并附一条推论 —— 凡出现"内容相同却判定不同",先怀疑判据绑错了东西,而不是改内容去凑判据。
2609
+
2610
+ > 本版对**使用者的界面零影响**:改的是门禁与构建脚本,源码 CSS/JS 一字节未动。
2611
+
2612
+ ## [0.5.0] - 2026-10-08
2613
+
2614
+ ### 加:`dist/` —— 源码与交付物分离(M1 · 对标 Pico / Open Props)
2615
+
2616
+ 实测:全库 CSS 原始 323.9 KB,**其中 207.5 KB 是注释(64%)**;
2617
+ `01-tokens/tokens.css` 源码版 gzip 15.8 KB ⇒ 使用者下载的是我们的推理过程。
2618
+
2619
+ Pico 在 README 上按产物逐个公布体积;Open Props 的 CDN 清单区分源版与 min 版;
2620
+ Web Awesome 从 `dist/` 交付。三家都是 **src 可读、dist 可 ship**,只有我们把二者混成一个文件。
2621
+
2622
+ ⭐ 更要紧的一条理由来自我们自己:`.gitignore` 里早就写着
2623
+ 「内部工作文档不进仓库 —— 交付面只放提交物」(决策记录 / 复盘 / 作战计划三类目录全在名单里)。
2624
+ ⇒ **这条纪律我们已经执行了,只是只对 markdown 执行了。** M1 把它扩到 CSS。
2625
+
2626
+ - `dist/**/*.min.css`:去注释 + 压缩(`tokens` 15.8 KB → **2.1 KB**,省 86%)
2627
+ - `dist/**/*.css`:只去注释、保留格式(便于 diff / 阅读)
2628
+ - `dist/**/*.js`:只去注释,**不压缩** —— 手写 JS 压缩器有破坏 ES5 语义的风险,
2629
+ 收益不抵风险,这一点如实写进 README 与 manifest,不假装 min 过
2630
+ - `dist/manifest.json`:每个产物记录来源路径、源 sha256、原始/gzip 字节
2631
+ - `dist/COSTS.md`:**采纳一个组件的真实成本**(tokens 必付 + 组件 CSS + 可选 JS)
2632
+
2633
+ ### 加:三道门禁
2634
+
2635
+ - **`dist-fresh`**:源码 sha256 变了而 dist 没重建 ⇒ 红(防"交付的是旧产物")。
2636
+ - **`dist-parity`**:把 dist 与源码各自喂给浏览器 CSSOM,必须解析出
2637
+ **同一份规则集**(条数 + 每条 cssText)。判据交给浏览器,不写第二个手写正则去验证第一个。
2638
+ 反向控制:在内存里改一个 dist 产物的色值 ⇒ 立刻红(其余 65 个不误报)。
2639
+ - **`dist-cost`**:`COSTS.md` 上每个数字都必须能用同一脚本复算 ⇒ 手改一个数字就红。
2640
+
2641
+ ### 这条工作里被门禁逼出来的三个真东西
2642
+
2643
+ 1. **逗号后面的空格不能压**。CSSOM 序列化 `transition` 这类逗号列表时会**原样保留源码空白**,
2644
+ 压成 `,border-color` 虽然解析结果一样,但 cssText 不同 ⇒ parity 红。
2645
+ 2. **`+` 两侧的空格不能压**。`calc(100% + 1px)` 压成 `calc(100%+1px)` 是**非法**的
2646
+ ⇒ 整条声明被丢弃,界面静默少一块。同理不碰 `/`(`font: 12px/1.5`)与 `-`(负号)。
2647
+ ⇒ 压缩器的标点白名单从 `{}:;,>+~` 收紧到 `{}:;`,是**被判据逼出来**的,不是拍的。
2648
+ 3. **判据自己也会写反**。第一版反向控制断言"产物里不许出现 `/*`",
2649
+ 而 `content: "/* 不是注释 */"` 是**值的一部分**,删了才是 bug。
2650
+ ⇒ 修正为同时管两头(真注释要删、字符串里的要留)。
2651
+
2652
+ > 体积:`dist/` 不计入 `size-baseline`(它是源文件的导出物,计入会双倍计数);
2653
+ > 源文件一字节没改,size-budget 不受影响。
2654
+
2655
+ ## [0.4.2] - 2026-10-07
2656
+
2657
+ ### 修掉根因:这个库**根本没有"全局"**
2658
+
2659
+ 事故原话:「只要稍微去修改一下全局的,比方说有 14 个页面要改,那么立刻崩。」
2660
+
2661
+ 复盘不是"模型笨",是**页面骨架被 33 个页面各抄了一份**在内联 `<style>` 里。
2662
+ 脚本数过 39 个页面(数字都能复跑):
2663
+
2664
+ | 选择器 | 出现在 | 写法种数 |
2665
+ |---|---|---|
2666
+ | `h1` | 36 页 | 8 |
2667
+ | `h2` | 35 页 | 11 |
2668
+ | `.wrap` | 33 页 | 16 ← 列宽从 680px 到 900px 有 13 个不同数字 |
2669
+ | `body` | 32 页 | 9 |
2670
+ | `code` | 24 页 | 9 |
2671
+ | `.lede` | 21 页 | 7 ← typography.css 里**早就有了**,页面还在重抄 |
2672
+ | `.note` | 21 页 | 10 |
2673
+ | `.box` | 13 页 | 2 |
2674
+
2675
+ ⇒ "改全局"在物理上不成立:没有一处可改,只有 33 处要改。
2676
+ 机械化证据:`git worktree` 取出改动前的版本,在**同一把尺子**下量"改一个全局令牌,
2677
+ 这一页跟不跟着变" —— **36 处不跟随**(每页都写死了自己的列宽)。
2678
+
2679
+ ### 加:`01-tokens/page.css`(页面骨架,单一真值源)
2680
+
2681
+ - 新增 `--measure-page: 52rem`(实测众数 820px,取干净的 rem 值)、
2682
+ `--tracking-title: -0.02em` 两个令牌。
2683
+ - 骨架全部挂在 `.page` 下(`<body class="page">` 才生效)——
2684
+ 裸标签选择器的作用域是整个文档,会污染宿主页面;
2685
+ `05-audit/bleed-gate.py` 就是拦这个的,骨架必须同样遵守。
2686
+ - 收编 19 个选择器:`body` / `.wrap(+--prose)` / `h1` / `h2` /
2687
+ `.rule-top` / `.rule-bottom` / `code` / `.box(+.bad)` /
2688
+ `.lede` / `.small` / `.note(+--warn)` / `.rule` / `.num` / `.num-lg` 等。
2689
+ - 39 个页面迁移:`<body class="page">` + 引 page.css + 删掉自己那份骨架,
2690
+ **共删 231 条重复规则**。
2691
+ - `.note` 收敛到实测众数(21 页里 20 页当"灰底说明块"用),
2692
+ warning 语义显式化为 `.note--warn`(只有 responsive-demo 用)。
2693
+ - `.lede` 从 `--fs-lg` 降到 `--fs-md`:原来与 `h2` 同档,导语抢了小节标题的层级。
2694
+ - `h1` 取"讲究版"(字重 500 + 收紧字距 + `--measure-title` 限宽,list/nav/states 三页在用),
2695
+ 不是数量最多的"没写完版"(19 页,缺的正是这三件事)。
2696
+ - `h2` 的两种东西分开:光秃秃的小节标题留在骨架里,
2697
+ 带分隔线的用 `.rule-top` / `.rule-bottom` 修饰类
2698
+ (原来 13 页用上边线、6 页用下边线,各写各的)。
2699
+
2700
+ ### 加:两道门禁
2701
+
2702
+ - **`05-audit/shell-gate.py`** —— 骨架收编了的选择器,页面一律不许再声明。
2703
+ 不拦的话,`.page h1`(0,1,1)会**静默盖掉**页面的 `h1`(0,0,1),
2704
+ 页面那句变成死代码:不报错、看不出来,只在你以为改了的时候没改。
2705
+ 自检含反向控制(坏样本 9 个选择器必须全被抓)+ 过宽检查(页面自己的
2706
+ `.row` / `.demo-grid` 必须放行)。
2707
+ - **`05-audit/skeleton-lever-check.js`** —— 在真浏览器里量三个全局杠杆
2708
+ (`--measure-page` / `--fs-2xl` / `--sp-6`),**每一页**都必须跟着变:
2709
+ **106 次命中 / 0 次未命中**(改动前是 33 命中 / 36 未命中)。
2710
+ 反向控制两条:不引骨架的宿主样板(`examples/react-vite`)必须纹丝不动;
2711
+ 页面局部 class 不该被骨架令牌牵动。
2712
+ 顺带量窄屏横向溢出(320 / 393px)。
2713
+
2714
+ ### 修:窄屏横向溢出(新尺子量出来的既有缺口)
2715
+
2716
+ - `date-range.css` 的 `.drange__input` **漏了 `box-sizing: border-box`** ——
2717
+ `input` 的 UA 盒模型是 content-box,`width: 100%` 只是内容宽,
2718
+ 加上 padding 12×2 + border 1×2 实际盒宽比容器多 **26px**
2719
+ ⇒ 393px 视口下 `scrollWidth 403 > 393`。
2720
+ 全库 `box-sizing` 只出现过 5 次,都是某处临时补的 ⇒ 谁记得谁没事,谁忘谁溢出。
2721
+ 修法两条:组件自己补 `border-box`(组件正确性不能依赖宿主引了骨架);
2722
+ 骨架里加 `.page * { box-sizing: border-box }`(只挂 `.page`,不做全局 reset)。
2723
+ - `input/demo` 的 `minmax(320px, 1fr)` → `minmax(min(320px, 100%), 1fr)`
2724
+ (窄于 320px 时轨道不缩 ⇒ 溢出 21px)。
2725
+ - `pagination.css` 的 `.pagination__list` 补 `flex-wrap: wrap`
2726
+ (父级 `.pagination` 早就写了,页码列表自己没写 ⇒ 溢出 7px)。
2727
+ - `scrollbar-demo` 的两张表加横向滚动容器(等宽标识符不可断行 ⇒ 溢出 21px)。
2728
+ - `model-viewer` 同因(溢出 9px)已随列宽收敛消失。
2729
+
2730
+ ⚠️ **一处已知未修**:`popover/demo.html` 的 `--end` / `--bottom` 演示。
2731
+ 浮层是绝对定位、按锚点**外侧**弹出(最小 200px),窄屏上锚点右侧没有 200px 空间,
2732
+ 而本库刻意不做 JS 落点检测(定位靠 CSS、不靠测量)
2733
+ ⇒ 关闭状态的浮层仍占着滚动溢出区,把整页撑出横向滚动。
2734
+ 门禁里以**显式例外**打印(每次都印,不静默);修法见脚本注释。
2735
+
2736
+ ### 补:呈现适配层的五条判据**此前一次都没被执行过**
2737
+
2738
+ `adapters/presentation/check.js` 声称守五条判据(版面必须已登记 / 单页元素 ≤ 12 /
2739
+ 非封面页必须有标题 / 标题层级不跳级 / 禁 `.slide-N` 这类页面级选择器),
2740
+ 而 `core-boundary` 只验了 `to-doc.js` 的往返 ⇒ **`check.js` 从来没被任何门禁跑过**。
2741
+ 读 README 的人会以为有人在看,实际没人看 —— 这是 I-10 的第 5 个实证,
2742
+ 也是最难发现的一类:它连"通过"报告都不产出。
2743
+
2744
+ - 新增 `05-audit/presentation-gate.py`(已接入 `check-all.sh`):
2745
+ 合规 deck(`fixtures/deck-ok.json`)必须过;违规 deck(`fixtures/deck-bad.json`)
2746
+ 必须红,且**五条判据各命中一次**(少一条就红 ⇒ 判据被删/改文案立刻暴露);
2747
+ `check.js` 的 `MAX_PER_SLIDE` 必须等于 `deck.schema.json` 的 `nodes.maxItems`
2748
+ (两个真值源,改一处忘一处就成了"文档说 12、代码放 20")。
2749
+ - 反向控制:把上限放宽到 999 / 去掉 `.slide-` 正则 / 把 schema 改成 8
2750
+ ⇒ 门禁必须认出对应判据已失效。
2751
+
2752
+ ⚠️ 本版**没有**新增任何 PPT 能力:没有凭空造适配层,
2753
+ `adapters/presentation/` 是既有的、只是第一次被真的跑起来。
2754
+
2755
+ ### 修:tooltip 的左右方位**根本没有小三角**,而且落点算错了
2756
+
2757
+ 两处都是**静默**的:不报错、门禁不红,只是"看着不太对"。
2758
+
2759
+ 1. **类名对不上**:`tooltip.js` 的 `place='left'|'right'` 拼出 `tooltip--left` /
2760
+ `--right`,而 `tooltip.css` 与契约 `ai/components.json` 里只有
2761
+ `--inset-inline-start` / `--inset-inline-end` ⇒ 这两个方位的 `::after`
2762
+ 四条边全是 transparent ⇒ **没有小三角**。演示页 `p-left` / `p-right` 正在用。
2763
+ 修:`place` 按书写方向换算(物理左在 LTR 是内联起点、在 RTL 是内联终点),
2764
+ 并新增 `start` / `end` 两个逻辑方位。
2765
+ 2. **量的时候元素还没进 DOM**:`show()` 先读 `getBoundingClientRect()` 再
2766
+ `appendChild` ⇒ 游离元素拿到全 0。实测后果:
2767
+ `top` 方位的气泡与锚点**完全重叠**(tip.top 398 == anchor.top 398,
2768
+ 正确值应是 364),`left` / `right` 上下偏半个身位。
2769
+ 修:先挂上去、藏起来量,量完再显示。顺带给 `top` 补了水平居中
2770
+ (原来只有 `bottom` 居中,`top` 的 left 是静态位置,实测偏 16px)。
2771
+ 3. **RTL 尖角指反**:CSS 里 `--inset-inline-start` 配的是物理
2772
+ `border-left-color`。小三角是"给哪条边上色、尖角就指向反方向",
2773
+ RTL 下左边变成内联终点 ⇒ 尖角指向气泡外侧。改成
2774
+ `border-inline-start-color` / `border-inline-end-color`。
2775
+
2776
+ 配套:
2777
+ - `tooltip-check.js` 新增判据⑥:在真浏览器里量 `::after` 的四条边 ——
2778
+ LTR/RTL × left/right 四个组合必须**各恰好一条边有色,且 RTL 落在相反的物理边**
2779
+ (修之前是 0 条)。判据写完后先跑一次确认它会红,才动手改代码。
2780
+ - `rtl-check.js` 的物理属性词表补上 `border-left-color` / `border-right-color`
2781
+ —— 原来只收 margin/padding/left/right/text-align,**边框上色边漏了**,
2782
+ 这正是它能静默到现在的原因。已反向控制(注入 1 处 ⇒ 门禁报 1 处)。
2783
+
2784
+ > 体积:`tooltip.js` 1.7 → 2.6 KB,`tooltip.css` 1.5 → 1.7 KB ⇒ 单文件容差(5%)
2785
+ > 超了。成因可归因:新增 `resolve()` 方位换算 + 逻辑定位 + 两条"为什么"注释。
2786
+ > 全库 gzip 177.2 → 178.3 KB(+0.62%,预算内)⇒ 已重录基线。
2787
+
2788
+ ### 补:`check-all.sh` 的静态服务加 canary("量另一棵树"第三次复发)
2789
+
2790
+ 实测第三次栽在同一个坑上,这次是**运行器自己**:
2791
+
2792
+ 1. 我在**源仓库目录**手动起过一个 `http.server` 忘了关;
2793
+ 2. 随后在一个**克隆副本**里跑全量验收;
2794
+ 3. 运行器探测到 8000 已通 ⇒ 打印「复用已在跑的服务」⇒
2795
+ 100 多道浏览器门禁量的全是**源仓库**。
2796
+
2797
+ 症状极具迷惑性:**107 道 PASS,只有 `selftest` 红** ——
2798
+ 因为它要现生成临时文件,在别人的根目录下 404。若不是它红,这就是一次完美的假绿。
2799
+
2800
+ 修:拉 `index.html` 与本地**字节比对**(不是看返回码)。不一致 ⇒ `exit 2`,
2801
+ 并明确写出"继续跑下去量的就不是这份代码"。
2802
+ 反向控制:另起一棵假树占住 8000 ⇒ 运行器报「另一棵树」并退出 2(已实测)。
2803
+
2804
+ ⭐ 三次复发的共同点:**"服务在跑"不等于"在跑这一份代码"**。
2805
+ 判据必须是**字节**,不能是**连通性**。
2806
+
2807
+ ### 为什么可以重录视觉基线
2808
+
2809
+ 31 页大小全部变化(页面高度 ±30 ~ ±150px)。变化**来源已逐条归因**:
2810
+ 标题阶梯、导语字号、列宽、页面框内边距、以及上面那条盒模型修复。
2811
+ 判据不是"看着像",而是:`skeleton-lever-check` 证明三个杠杆每页都生效、
2812
+ 横向无溢出;`shell-gate` 证明没有第二份骨架定义;
2813
+ 余下 60+ 道行为契约全绿。此前 0.4.1 已证明过"改输入→输出完全没动"
2814
+ 是门禁在量另一棵树,那次之后截图门禁改用 OS 分配端口 + canary 字节比对。
2815
+
2816
+ ---
2817
+
2818
+ ## [0.4.1] - 2026-10-07
2819
+
2820
+ ### 修掉一个 19 处、静默了很久的 bug:`border-inset-inline-*`
2821
+
2822
+ 全库 19 处写的是 `border-inset-inline-start` / `-end` ——
2823
+ **这不是一个存在的 CSS 属性**(正确的是 `border-inline-start` / `-end`)。
2824
+ 机械验证:`CSS.supports()` 两种形式 + `element.style.setProperty()` 三处一致返回
2825
+ `false`;而 `border-inline-start` 三处都接受(带 `var()` 也成立)。
2826
+
2827
+ 后果:这些边框**从来没有被渲染过**。而配套的
2828
+ `border-left-color: var(--accent)` 只是给一条 **0 宽、style:none** 的边上色
2829
+ ⇒ 目录当前项指示条、引用块竖线、树形导引线、列表 hover 线、纵向选项卡选中线
2830
+ **全部看不见**。
2831
+
2832
+ 受影响 10 个组件:nav / content / tree / list / tabs / accordion / drawer /
2833
+ popover / date-range / model-viewer。
2834
+
2835
+ ⚠️ 为什么过去没被发现:写错属性名**不报错、不影响解析**,
2836
+ lint / 注释平衡 / 令牌解析全认为这一行正常;33 道行为契约验的是
2837
+ "能不能点、焦点在哪",**验不了观感**;唯一能发现它的是视觉回归,
2838
+ 而视觉回归当时正在假绿(见下一节)。
2839
+
2840
+ 修复:一律改用逻辑属性,配套的 `border-*-color` 也一并改
2841
+ ⇒ RTL 下不会着错边(物理 `border-left-color` 不跟着翻转)。
2842
+
2843
+ 顺带修掉同类一处:`scrollbar.css` 写了 `border-width: 2px solid transparent`
2844
+ (`border-width` 只吃宽度,那是 `border` 简写的值)⇒ 整条被丢弃。
2845
+ 改成 `border` 简写,并补 `background-clip: content-box`
2846
+ (否则底色画到透明边框底下,看上去**完全没变细**)。
2847
+
2848
+ ### 新增门禁 `css-validity`(判据来自浏览器自己)
2849
+
2850
+ 每条声明交给 `CSS.supports(prop, value)` 判定 ——
2851
+ **不手写属性白名单**:手写的那份是第二份真相源,必然漂移(INVARIANT I-7)。
2852
+
2853
+ 三档,避免误伤:无前缀却不认 ⇒ **FAIL**;`-webkit-` 不认 ⇒ **WARN**
2854
+ (实测 `-webkit-overflow-scrolling` 是 Safari 专有,Chromium 从未实现);
2855
+ 他厂前缀(-moz/-ms/-o)⇒ 跳过并计数(本机无从判定,不假装有结论)。
2856
+
2857
+ 同一道门禁还查 **`var()` 悬空引用**:引用了全库从未定义、且**没有兜底**的
2858
+ 自定义属性 ⇒ 该声明在计算值阶段被整条丢弃(同一种静默失效)。
2859
+ 有兜底的不算错 —— 那是刻意留的扩展位(`--spark-c` 等 5 处由 JS 或使用者写)。
2860
+
2861
+ 自检 9 条,含反向控制(坏夹具必须判红、好夹具必须判绿)。
2862
+
2863
+ ### 修掉三处**门禁自身**的失效(比上面的 bug 更危险)
2864
+
2865
+ - **视觉回归可能在测另一棵树**(本次就是这样被发现的:CSS 修好后
2866
+ diff 数字**一字未变**)。`shot-baseline.py` 用固定端口 8000 起静态服务,
2867
+ 端口被占时子进程立刻静默退出,`Popen` **不报错**、父进程毫无察觉
2868
+ ⇒ 所有截图来自那个残留进程。实测本机挂着一个残留 http.server,
2869
+ 服务的是**另一个目录**。
2870
+ ⇒ 改用**操作系统分配端口**(bind 0,物理上不可能撞车)+ canary 校验
2871
+ (拉一个文件与磁盘字节比对,确认连上的是本仓库)。
2872
+ - **「基线是否过期」漏掉 5 个文件**:指纹 glob 是 `0*/*/*.css`(三段),
2873
+ 匹配不到 `01-tokens/tokens.css` 这类二级路径 ⇒ **只改令牌时指纹纹丝不动**,
2874
+ diff 出现了却没人知道为什么。⇒ 改为遍历库目录,并把会改变渲染的
2875
+ JS / HTML 一并纳入(改了 tree.js 的 DOM,观感一样会变)。
2876
+ - **8 个组件根本没有视觉基线**:tabs / accordion / popover / progress /
2877
+ skeleton / pagination / model-viewer / sparkline 有 CSS、有 demo,
2878
+ 却不在受管页面里 ⇒ 改坏了无人报警(tabs.css 这次恰好在改动范围内)。
2879
+ 已补齐到 **31 页**。
2880
+
2881
+ ### 基线已按「有意变更」流程重录(31 页,录完即测 31/31 一致)
2882
+
2883
+ 重录前做过三方比对核实不是回归:
2884
+
2885
+ | 页面 | 基线 vs 基线所属提交的代码 | 该提交的代码 vs 现在 |
2886
+ |---|---|---|
2887
+ | card / button / overlay | 不一致(2299 / 7473 / 286 px) | **0 px** |
2888
+
2889
+ ⇒ 这三页**当前代码与基线所属提交逐像素完全一致**,也就是说它们从没变过,
2890
+ 是那份**基线 PNG 本身录于更早的状态**;states / form-validation 的差异
2891
+ 来自有意改的演示文案(`.is-success` → `data-state="success"`、
2892
+ `.is-dirty` → `data-dirty`);list 的左侧指示条是
2893
+ 「曾经正常 → 被写坏 → 本次修好」,所以现在反而更接近旧基线。
2894
+
2895
+ 基线戳现在也记录录制时的 `head`,下次能直接对上"这份基线属于哪份代码"
2896
+ (这次就是因为只能看出"不一样"、答不出"差在哪",才查了很久)。
2897
+
2898
+ ### 体积
2899
+
2900
+ 无新增 CSS,仅修正既有声明 ⇒ 体积无变化(基线重录不影响产物)。
2901
+
2902
+ ---
2903
+
2904
+ ## [0.4.0] - 2026-10-07
2905
+
2906
+ > 0.3.0 之后的所有工作一次性发布(0.3.1 未对外发布,故不单列一节)。
2907
+ > 本次含 **2 处不兼容变更**,见最末一节。
2908
+
2909
+ ### 修掉两个**真浏览器实测**才看得见的 bug
2910
+
2911
+ 建立机器可读令牌索引(`ai/tokens.json`)时顺带发现的,
2912
+ 两个都**不报错、页面能显示、门禁全绿**,但功能已经坏了:
2913
+
2914
+ - **`--switch-on` 在浅色模式下是空的**
2915
+ 原因:`tokens.css` 里一段注释**缺开头**,第二行只有一个收尾符 ⇒
2916
+ CSS 的错误恢复把紧跟其后的 `--switch-on` 当成"坏声明"跳过了。
2917
+ ⇒ 开关组件在浅色下没有轨道色。
2918
+ - **`--surface-raised` 只在暗色段定义**
2919
+ ⇒ `background: var(--surface-raised)` 在浅色下整条失效、计算值**完全透明**。
2920
+ 实测受影响组件 6 个:combobox / popover / select / dropdown / tooltip / table。
2921
+ ⚠️ 这条在 `10-review/ios/index.html` 里**早就记过**,但当时只在那一页绕开
2922
+ (换个令牌),**没修根因** ⇒ 其余 5 个组件一直在踩。
2923
+
2924
+ ### 新增:机器可读契约(对标 Open Props / Primer)
2925
+
2926
+ - `ai/components.json` —— 27 个组件的**结构化契约**:
2927
+ 插槽(BEM 元素名即插槽名)、变体、状态、成熟度、专属门禁、ARIA
2928
+ - `ai/tokens.json` —— 106 个浅色 + 30 个暗色令牌,
2929
+ `$value` / `$type` 按 W3C Design Tokens 草案形状,可被 Figma / Style Dictionary 直接消费
2930
+ - 两者均由 `05-audit/gen-ai-contract.py` **从源码生成**,`--check` 已接门禁
2931
+ ⇒ 改了源码忘记重新生成就红(真值只有一份:CSS)
2932
+
2933
+ ### 组件成熟度(对标 Primer 的 Alpha/Beta/Stable)
2934
+
2935
+ 成熟度**算出来**的,不是拍的:
2936
+
2937
+ - `stable`:有 demo + 专属门禁 + 状态全部用 `data-*` 表达
2938
+ - `beta`:仍有用 `is-*` class 表达的状态 ⇒ 违反不变式 I-8
2939
+ (状态不在 DOM 上,JS 改坏就静默错乱)
2940
+
2941
+ ⇒ 约束力不再一刀切:模型变强时放宽的是 beta 区,stable 区一个字不动。
2942
+
2943
+ ⭐ **本次状态收敛完成后,27 个组件全部达到 `stable`** ——
2944
+ 成熟度是**算出来的**(`ai/components.json` 由 `gen-ai-contract.py` 生成),
2945
+ 不是谁把 beta 改成了 stable。之前的 11 个 beta,卡的就是 I-8 这一条。
2946
+
2947
+ ### 新增门禁(4 道)
2948
+
2949
+ - `theme-sync`:`theme-toggle.js` 与 `tokens.css` 同步
2950
+ 🔴 **这条本来就该在,但一直没接进 check-all.sh**,而脚本自己的文档还写着
2951
+ 「--check 已接进门禁 ⇒ 漂移会变红」⇒ 于是它真的漂移了(28 vs 27 个暗色令牌),
2952
+ 门禁一声不响。**文档说做了、实际没接,比没做更危险。**
2953
+ 顺带把挑令牌的**白名单**换成**差集规则**(暗色值与浅色不同就复制)——
2954
+ 白名单永远还会漏,`--skeleton-bg` / `--text-on-solid` 就是这么丢的。
2955
+ - `ai-contract`:机器可读契约与源码一致
2956
+ - `token-parse`:孤儿注释收尾符 / `var()` 引用缺失 / 浏览器复核关键令牌
2957
+ - `comment-balance` **重写**:原来是**数 `/*` 和 `*/` 的个数**,
2958
+ 实测放过了一个真 bug(一处缺开头 + 一处嵌套,两个错误方向相反正好抵消)
2959
+ ⇒ 修好之后它反而报红 —— **它在惩罚修复**。改成真扫描器(跳过字符串与 JS 正则)。
2960
+
2961
+ ---
2962
+
2963
+ ### 0.4.0 的第二部分:规范分层、状态收敛、纯 CSS 暗色
2964
+
2965
+ ### 🔴 规范分成三层(Invariant / Contract / Guidance)
2966
+
2967
+ 起因是一个真实的两难:**今天的约束会变成明天的天花板。**
2968
+ 约束写死 ⇒ 模型变强了也放不开;写活 ⇒ 没有基准,每次都重新吵一遍。
2969
+
2970
+ 拆法:
2971
+
2972
+ | 层 | 文件 | 性质 | 改它需要 |
2973
+ |---|---|---|---|
2974
+ | **不变式** | `docs/INVARIANT.md` | 客观事实,永不变 | 物理定律变了 |
2975
+ | **契约** | `ai/*.json` + `API.md` | 约定,随版本变 | 发个版本 |
2976
+ | **建议** | `docs/GUIDANCE.md` | 工作建议,可整份推翻 | 什么都不需要 |
2977
+
2978
+ ⇒ 放宽约束时动的是 Guidance;Invariant 一个字不动。
2979
+ `05-audit/invariant-gate.py` 守着第一层的边界(出现组件名 / 令牌名 /
2980
+ 版本号 / 档位名 / 建议性措辞 ⇒ 红)。
2981
+
2982
+ 配套:
2983
+
2984
+ - `docs/BENCHMARK.md` —— 对标 Pico / Radix / Open Props / Web Awesome / Primer,
2985
+ 每一条写清"学什么、不学什么、**为什么不学**"
2986
+ - `docs/GUIDANCE.md` —— 5 条建议,**每条都写明"什么时候可以删掉它"**
2987
+
2988
+ ### 修掉一个只在**真暗色**下才暴露的链接 bug
2989
+
2990
+ `03-patterns/content` 的链接规则写的是 `.prose > a`(**直接子元素**)
2991
+ ⇒ 正文里的链接几乎都在 `<p>` 里,一个都没被选中,落到浏览器默认蓝
2992
+ `rgb(0,0,238)`,暗色下对比度 **1.91:1**(需 4.5)。
2993
+
2994
+ 更糟的是旁边还有一条"补救"规则 `.content a` —— 而 `.content` 这个类名
2995
+ 在全库 **0 处出现**(本组件的根类是 `.prose`)⇒ 整段是死的,
2996
+ 且它的注释还写着「.content 是本组件的根」。
2997
+ ⇒ 于是"链接已经处理过"这个判断一直是假的。
2998
+
2999
+ 亮色下默认蓝看着"还能看",所以一直没人发现。
3000
+ 是 `dark-contrast` 在真暗色下实测抓出来的。
3001
+
3002
+ ### 🔴 状态表达统一为 `data-*`(0.4.0 不兼容变更之一)
3003
+
3004
+ 发现的是一个**已经随发布版出去的 bug**,不只是风格问题:
3005
+
3006
+ 1. CSS 里长期同时写着两套:
3007
+ `.combo__field.is-disabled` **和** `.combo__field[data-state="disabled"]`,
3008
+ 但 JS **只加前者** ⇒ `API.md` 明明写着"请改用 `data-state`",
3009
+ 而那个分支**从来不会被设上** —— 承诺了一个不工作的 API。
3010
+ 2. 一次半自动改写把 `.a.is-disabled .b` 拆成
3011
+ `.a.is-disabled, .a[data-state="disabled"] .b` ⇒ 前半截**丢了后代选择器**,
3012
+ 规则落到容器自己身上,真正该变灰的 `.b` 再也没有这条样式。
3013
+ 受影响:`combobox` 的禁用态输入文字与标签删除按钮、`button` 的 success 态
3014
+ label/spinner 反转。
3015
+
3016
+ ⇒ 收敛为一套:`data-state`(状态枚举)/ `data-dirty`(正交标记)/
3017
+ `data-scroll-locked`(`<body>` 锁滚动)。`is-*` 选择器全部删除。
3018
+ `API.md` 里给了逐字替换的迁移表。
3019
+
3020
+ ### 🔴 强制暗色改为纯 CSS(0.4.0 不兼容变更之二)
3021
+
3022
+ 过去:JS 往 `:root` 写 **30 条 inline 属性**。
3023
+ 代价:inline style 压过**一切**样式表 ⇒ 使用者改 `--surface` 只能加 `!important`
3024
+ (不变式 I-4)。为了一个开关,把整个配色层抬到了样式表之上。
3025
+
3026
+ 现在:CSS 自己表达,JS 只翻属性。
3027
+
3028
+ ```css
3029
+ @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }
3030
+ :root[data-theme="dark"] { … } /* 由 theme-sync.py 生成 */
3031
+ ```
3032
+
3033
+ - 真值仍然只有一份(`@media` 段),`[data-theme]` 段由
3034
+ `05-audit/theme-sync.py` **生成**,`--check` 已接门禁 ⇒ 漂移会红
3035
+ - `theme-toggle.js` **不再含任何色值**(门禁逐字检查:hex / rgb / hsl 一律红)
3036
+ - 新增 `theme-css` 门禁:真浏览器里跑四种组合,且**不加载 `theme-toggle.js`**
3037
+ ⇒ 证明"没有 JS 也成立"
3038
+
3039
+ ### 机器可读契约的配套能力
3040
+
3041
+ - `ai/validate.js` + `ai/cli.js` —— 零依赖校验器与命令行
3042
+ (`ai check / validate / diff / patch / components / tokens / profile / capabilities`)
3043
+ - **Profiles** `creative / standard / strict` —— 门槛不同。
3044
+ 门禁证明它有鉴别力:**同一份文档在 strict 下的报错数必须多于 creative**
3045
+ - **Patch API** —— 对**语义 ID** 的 create/update/move/delete/replace;
3046
+ `move` 只改挂载关系、不碰内容;非法操作 ⇒ 文档**字节级不变**
3047
+ - **能力协商** `ai/capabilities.json` —— 按声明的能力给可用操作集,
3048
+ **不按模型名分支**
3049
+ - **前向兼容** `extensions: {}` —— 旧版校验器遇到未知扩展只忽略,不判非法
3050
+ - `adapters/presentation/` —— 呈现形态(演示文稿)语义**不进核心**,
3051
+ 走适配层;`core-boundary` 门禁守着核心里 0 命中
3052
+ - `benchmark/` —— 10 类编辑意图的兼容性基准骨架(**当前没有任何模型实测记录**,
3053
+ 这是诚实的现状,不是成果)
3054
+
3055
+ ### 新增门禁(6 道)
3056
+
3057
+ `ai-layer` · `invariant` · `benchmark` · `core-boundary`
3058
+ · `cascade-layer` · `theme-css`(浏览器)
3059
+
3060
+ 外加两处**门禁自身的判别力修复**:
3061
+
3062
+ - `leak-scan` 的「工作日期戳」原来只查**行首带注释标记**的行 ⇒
3063
+ 块注释里的缩进行一条都查不到(实测漏了 4 处,都已随发布版出去)。
3064
+ 改成跨行跟踪块注释状态 ⇒ 立即又抓出 5 处,现已清零。
3065
+ - `invariant-gate` 的自检原来没测「档位名」这一条 ——
3066
+ 而它恰恰是唯一在真实运行中触发过的判据。已补。
3067
+
3068
+ ### 体积
3069
+
3070
+ - 令牌多了一份 30 条的 `[data-theme="dark"]` 段 ⇒ `tokens.css` +4.7%
3071
+ - 总体积 gzip **+0.4 KB(+0.23%)**
3072
+ - 已按"有意变更"流程重录基线,不是把阈值调大
3073
+
3074
+ ---
3075
+
3076
+ ## [0.3.0] - 2026-10-06
3077
+
3078
+ ### 新增
3079
+
3080
+ - **暗色模式**(`prefers-color-scheme: dark`):21 个令牌,
3081
+ 全部按 WCAG 公式计算,**不是把浅色反过来**——
3082
+ `--accent` 在暗色下**变亮**(深底上深色看不见)、
3083
+ `--text-on-accent` **翻转**为深字(按钮变亮 ⇒ 白底白字)。
3084
+ `05-audit/dark-gate.py` 守住文字 AA 与分层可见。
3085
+ - **i18n 度量令牌** `--measure-*`:按 `:lang()` 切换单位
3086
+ (CJK 用 `em`、拉丁用 `ch`、RTL 与连字语言各一套)。
3087
+ 实测中/英/日/阿四语言,标题均 ≤ 2 行。
3088
+ - **统一检查入口** `bash 05-audit/check-all.sh`(75 道门禁一条命令,
3089
+ `MODE=fast` 只跑跨平台安全的静态检查)
3090
+ - **自停服务器** `bash 05-audit/with-server.sh <命令>`——
3091
+ 起服务器、跑命令、**一定停掉**(trap 绑退出路径)
3092
+ - **结构依赖清单** `05-audit/struct-deps.sh`(改 HTML 结构前先跑)
3093
+ - 组件零依赖;验证工具(puppeteer-core)装在库外
3094
+
3095
+ ### 新增门禁(8 道)
3096
+
3097
+ `hover-gate` · `measure-gate` · `license-gate` · `numeric-gate`
3098
+ · `dark-gate` · `hardcode-gate` · `reuse-check` · `accent-gate`
3099
+
3100
+ 每道都做过**判别力验证**(构造已知失败,确认工具抓得到)。
3101
+
3102
+ ### 修复
3103
+
3104
+ - **勾选框点不动 / 框是空的**:改结构后 7 处 `+` 选择器失效(自白勾在白底上不可见)
3105
+ - **错误提示永远在**:`.form--has-summary` 把空元素也显示了
3106
+ - **中文标题被挤成三行**:`13ch` 实际只能放 6 个汉字
3107
+ - **左侧竖条 / 等宽并排 / 大面积饱和色**:去 AI 味
3108
+ - **文档与实现脱节**:同步 4 处色值、撤掉 5 条"看起来有道理但做不到"的假原则
3109
+ - **版权**:清掉 2 处逐字外部引用,登记 5 条外部方法论来源
3110
+
3111
+ ### 已知边界
3112
+
3113
+ - 浏览器只在 Chrome / Edge(Chromium)验过
3114
+ - 屏幕阅读器未做真人验证(只能验结构层)
3115
+ - 移动端只有 Chrome 模拟,无真机
3116
+