agentcss 0.12.0 → 0.13.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.
@@ -317,6 +317,12 @@
317
317
  menu.hidden = false;
318
318
  btn.setAttribute('aria-expanded', 'true');
319
319
  active = -1;
320
+ /* ⭐ 聚焦首项必须放在 open() 里,不能只写在键盘分支。
321
+ 读屏用户在浏览模式下按 Enter,NVDA 会把它变成**一次点击**,
322
+ 走的是 click 分支 ⇒ 键盘分支里的 `focusAt(0)` 根本没机会执行,
323
+ 焦点留在按钮上,菜单项永远进不去(真机用 NVDA + Edge 实测过)。
324
+ APG 的菜单按钮也是这样:打开即把焦点交给第一项。 */
325
+ focusAt(0);
320
326
  flEmit(root, 'fl-open', null);
321
327
  if (opt.onOpen) opt.onOpen();
322
328
  }
@@ -391,12 +397,11 @@
391
397
  root.addEventListener('keydown', function (e) {
392
398
  var k = e.key;
393
399
 
394
- /* 按钮上:↓ / Enter / Space 打开并聚焦首项 */
400
+ /* 按钮上:↓ / Enter / Space 打开(聚焦首项已由 open() 负责) */
395
401
  if (e.target === btn) {
396
402
  if (k === 'ArrowDown' || k === 'Enter' || k === ' ' || k === 'Spacebar') {
397
403
  e.preventDefault();
398
404
  open();
399
- focusAt(0);
400
405
  }
401
406
  return;
402
407
  }
@@ -438,10 +438,16 @@
438
438
  box.addEventListener('keydown', function (e) {
439
439
  if (e.key === 'Escape') {
440
440
  e.preventDefault();
441
- /* 🔴 必须 stopPropagation:弹层可嵌套。
442
- * 不拦住的话,内层的 Esc 会**冒泡到外层**,
443
- * 一次按键把两层全关掉 —— 键盘用户会以为应用崩了。
444
- * (本库在 popover 上踩过同一个坑,这里一并修好。) */
441
+ /* ⚠️ 防御性 stopPropagation —— 保留,但**当前结构下它不改变任何行为**。
442
+ * 等价突变体实测:删掉这一行,一次 Esc 仍然只关最内层。
443
+ * 原因:两个 `.dialog-backdrop` 是 `body` 的**兄弟**节点,外层 box 不是
444
+ * 内层的祖先,所以内层按钮的 keydown 冒泡路径是
445
+ * button → .dialog → .dialog-backdrop → body
446
+ * —— 根本到不了外层的 `.dialog`(实测 `outer.contains(inner) === false`)。
447
+ * 原注释写「不拦住会一次关两层」,那是**假设了 DOM 嵌套**,与实现不符。
448
+ * 保留理由:若将来改成「内层放进外层 box」的真嵌套,这行就必需。
449
+ * 判据 `pattern-behavior-check.js ⑬` 验证的是**行为**(只关最内层),
450
+ * 它抓不到删除本行的突变 —— 那是等价突变体,不是判据失效。 */
445
451
  e.stopPropagation();
446
452
  close();
447
453
  return;
@@ -102,7 +102,7 @@ python -m http.server 8000
102
102
  # http://127.0.0.1:8000/04-recipes/table/demo.html
103
103
  ```
104
104
 
105
- ## 配色:与本库 `list` 的约定**不同**(已知,未统一)
105
+ ## 配色:与本库 `list` 的约定**不同**(故意的,不是笔误)
106
106
 
107
107
  | 组件 | 类名 | 约定 |
108
108
  |---|---|---|
@@ -114,14 +114,49 @@ python -m http.server 8000
114
114
  - 表格里多是**量**(营收、毛利率、净利润)—— 负数是"红字",会计惯例;
115
115
  - 列表里多是**涨跌**(涨跌幅、日变动)—— A 股惯例是涨红跌绿。
116
116
 
117
- ⚠️ **真正的麻烦在这里**:把「涨跌幅」这一列塞进 `table` 时,两种约定会打架 ——
118
- 负的涨跌幅被渲染成红色,读者第一眼读成"涨"。目前**没有解**:
117
+ ### 涨跌幅放进表格:解题方式(0.12.1 已定)
119
118
 
120
- - 加一套 `num--up` / `num--down`(市场约定)是候选,但那会让 `table` 有四组颜色类;
121
- - 或者由调用方显式指定语义(`data-polarity="market"`),但那要改契约。
119
+ 把「涨跌幅」这一列塞进 `table` 时,两种约定会打架 ——
120
+ 负的涨跌幅被渲染成红色,读者第一眼读成"涨"。这个问题曾经挂在 README 里写「目前没有解」,
121
+ 现在有了,而且**不是**当初列的两个候选之一:
122
122
 
123
- 在决定之前,这一条就写在这儿 —— 一个库里有两种配色约定而不说明,
124
- 比约定本身选错更糟。
123
+ > **市场涨跌不进 `table` 的颜色体系,而是嵌 `delta` 原子。**
124
+
125
+ ```html
126
+ <td class="num"><span class="delta delta--up">3.21%</span></td>
127
+ <td class="num"><span class="delta delta--down">1.05%</span></td>
128
+ <td class="num"><span class="delta delta--flat">0.00%</span></td>
129
+ ```
130
+
131
+ | | `num--pos` / `num--neg` | 嵌 `delta` |
132
+ |---|---|---|
133
+ | 语义 | 会计正负(多了 / 少了) | 市场涨跌(涨了 / 跌了) |
134
+ | 颜色来源 | `--success` / `--danger` | `--dir-up` / `--dir-down` |
135
+ | 随 `[data-market]` 切换 | **不切** | **切** |
136
+ | 符号 | `::before` 的 `+` / `−` | `delta` 自己的 `+` / `−` / 无 |
137
+
138
+ 为什么否掉当初那两个候选:
139
+
140
+ - `num--up` / `num--down` 会让 `table` 有**四组**颜色类,调用方每次都要记"这列用哪组";
141
+ - `data-polarity="market"` 是把语义塞进一个属性,表格得替数据判断"这列是涨跌",
142
+ 而它并不知道 —— 调用方才知道。
143
+
144
+ ⇒ 方向是**独立语义**,所以它就该是独立原子。表格只负责对齐(`td.num`),不负责判色。
145
+
146
+ ⚠️ **不要两套一起用**:`<td class="num num--pos"><span class="delta delta--up">`
147
+ 会让同一格同时受两套颜色支配,谁赢取决于选择器权重 —— 那是最难查的一类 bug。
148
+ `table-check` 的判据 ⑬ 会挡住给 `table.css` 添 `num--up` / `num--down` 的做法。
149
+
150
+ ### 零值:两边都不许有符号(0.12.1 修,E145)
151
+
152
+ `format: 'signed-percent'` 的正号由类名触发,而旧实现判的是 `v < 0` ⇒
153
+ `v === 0` 落进 else 分支得到 `num--pos`,渲染成「**+0.0%**」——
154
+ 一个没变化的数被念成"涨了"。四舍五入处同样复现:`0.0004` 在 1 位精度下也印成「+0.0%」。
155
+
156
+ ⇒ 规则只有一条:**符号必须跟打印出来的数字同号**。
157
+ 先算出将要打印的数值,再按它定符号;等于 0 既不加 `+` 也不加 `−`
158
+ (这与 `delta--flat` 是同一个契约)。`table-check` 判据 ⑫ 守着它,
159
+ 并且要求页面上**必须存在**零值样本 —— 删掉零值行不能算通过。
125
160
 
126
161
 
127
162
  ## 反例
@@ -137,5 +172,8 @@ python -m http.server 8000
137
172
  - ❌ **粘性表头配 `border-collapse: collapse`** —— `separate` 是 sticky 生效的前提,
138
173
  用 collapse 表头不会粘住
139
174
  - ❌ **拿它当虚拟滚动表格用** —— 本组件不做虚拟滚动;超过 200 行请配分页
175
+ - ❌ **给表格再加一套「市场涨跌」的颜色类** —— 那是第三组方向类,
176
+ 加上会计的正负就是四组;市场涨跌嵌 `delta`(判据 ⑬ 会红)
177
+ - ❌ **零值带 `+`** —— 「+0.0%」= 把持平念成上涨;先算打印值再定符号(判据 ⑫ 会红)
140
178
 
141
179
  ---
@@ -9,7 +9,11 @@
9
9
  <link rel="stylesheet" href="../../01-tokens/typography.css">
10
10
  <link rel="stylesheet" href="../../01-tokens/focus-ring.css">
11
11
  <link rel="stylesheet" href="../../01-tokens/page.css">
12
+ <!-- 市场口径切换:只有引了它,[data-market="intl"] 才生效。
13
+ 不引则属性被完全忽略(可选文件,见 01-tokens/market.css 的文件头)。 -->
14
+ <link rel="stylesheet" href="../../01-tokens/market.css">
12
15
  <link rel="stylesheet" href="../../02-primitives/button/button.css">
16
+ <link rel="stylesheet" href="../../02-primitives/delta/delta.css">
13
17
  <link rel="stylesheet" href="table.css">
14
18
  <style>
15
19
  .toolbar { display: flex; gap: var(--sp-2); margin: 0 0 var(--sp-3); flex-wrap: wrap; }
@@ -92,6 +96,30 @@
92
96
  </p>
93
97
  <div id="escproof"></div>
94
98
 
99
+ <!-- ========================================================================
100
+ 两种方向语义:会计正负 vs 市场涨跌(0.12.1)
101
+ ===================================================================== -->
102
+ <h2 class="rule-top">6 · 同一张表里的两种方向语义</h2>
103
+ <p class="note">
104
+ 「同比 +16.3%」和「近 20 日 +3.21%」看起来都是"涨",但它们是<strong>两件事</strong>:
105
+ 前者是会计口径(正负就是加减),后者是市场口径(红涨绿跌,国际口径正好相反)。
106
+ <br>⇒ 会计正负用 <code>num--pos</code> / <code>num--neg</code>;
107
+ 市场涨跌用嵌进单元格的 <code>delta</code>,
108
+ <strong>不给表格加第三组方向类</strong>(<code>num--up</code> / <code>num--down</code> 故意不存在)。
109
+ <br>下面两块数据完全相同,只有外层 <code>data-market</code> 不同 ——
110
+ <strong>会计那一列一个字都不变,市场那一列整体翻转</strong>。
111
+ <br>⚠️ 在同一张表里,会计的「+16.3%」是绿、市场的「+3.21%」是红 ——
112
+ 看起来互相矛盾,<strong>这恰恰是它们必须分开命名的原因</strong>:
113
+ 一个说"多了多少",一个说"涨了多少",共用一个颜色令牌就会给出矛盾信号。
114
+ </p>
115
+ <div id="polarity"></div>
116
+ <p class="table__foot">
117
+ 注:<strong>零值两边都不带符号</strong> —— 会计列不写 <code>+</code>,
118
+ 市场列用 <code>delta--flat</code>。
119
+ 「0.0%」被读成「涨了 0.0%」是本库明确反对的一类错误
120
+ (四舍五入到 0 也算 0,所以 0.0004 在 1 位精度下同样不带符号)。
121
+ </p>
122
+
95
123
 
96
124
 
97
125
  <h2 class="rule-bottom">对比:错写法 vs 本库写法</h2>
@@ -187,17 +215,29 @@
187
215
  }
188
216
  if (f === 'text') return { text: String(v), cls: '' };
189
217
 
190
- var abs = Math.abs(Number(v));
218
+ var n = Number(v);
219
+ var abs = Math.abs(n);
220
+ var isPct = (f === 'percent' || f === 'signed-percent');
191
221
  var text;
192
222
  if (f === 'currency' || f === 'integer') text = grouped(abs, col.precision);
193
- else if (f === 'percent' || f === 'signed-percent') text = fixed(abs * 100, col.precision) + '%';
223
+ else if (isPct) text = fixed(abs * 100, col.precision) + '%';
194
224
  else text = fixed(abs, col.precision);
195
225
 
196
226
  /* 符号:负数总是有 −(数值语义 + 无障碍第二线索);
197
- 正数只有 signed-* 才带 + —— 这就是"按列语义处理" */
227
+ 正数只有 signed-* 才带 + —— 这就是"按列语义处理"。
228
+
229
+ 🔴 但符号必须**跟打印出来的数字同号**(0.12.1 修,E145):
230
+ 旧实现判的是 `v < 0`,于是 `v === 0` 掉进 else 分支拿到 `num--pos`,
231
+ 渲染成「+0.0%」—— 一个没变化的数被念成"涨了"。
232
+ 同一条错还会在**四舍五入**处复现:0.0004 在 1 位精度下打印成
233
+ `0.0%`,若仍按原始值判正负,同样得到「+0.0%」。
234
+ ⇒ 唯一的正确判法:先算出**将要打印的数值**,再按它定符号。
235
+ `= 0` 既不加 + 也不加 −(与 delta 的 `delta--flat` 同一个契约)。 */
236
+ var shown = Number(fixed(isPct ? abs * 100 : abs, col.precision));
198
237
  var cls = 'num';
199
- if (Number(v) < 0) cls += ' num--neg';
200
- else if (f === 'signed-percent' || f === 'signed-decimal') cls += ' num--pos';
238
+ if (shown !== 0 && n < 0) cls += ' num--neg';
239
+ else if (shown !== 0 && n > 0 &&
240
+ (f === 'signed-percent' || f === 'signed-decimal')) cls += ' num--pos';
201
241
 
202
242
  return { text: text, cls: cls };
203
243
  }
@@ -488,6 +528,53 @@
488
528
  '<p class="table__foot">复查方式:用浏览器看这一节的单元格 —— ' +
489
529
  '第二、三列应当<b>原样显示</b>尖括号文本,而不是把它们变成样式。</p>';
490
530
  document.getElementById('escproof').innerHTML = e2;
531
+
532
+ /* --------------------------------------- 预设六:两种方向语义(0.12.1) */
533
+
534
+ /* 市场涨跌:方向交给 delta,表格自己不判色。
535
+ 🔴 为什么不给 table 加 `num--up` / `num--down`:
536
+ 那会让表格有四组颜色类(pos / neg / up / down),调用方每次都要记
537
+ 「这一列用哪组」。方向该由**数据语义**决定,
538
+ 不该由「它被放进了表格」决定 —— 所以它是独立原子,不是表格的变体。
539
+
540
+ 🔴 符号规则与 cell() 完全一致:按**打印出来的数字**判方向。
541
+ 四舍五入到 0 的一律算持平,否则 0.00004 会印成「+0.00%」——
542
+ 同一类错误在会计列与市场列各犯一次,就等于没修。 */
543
+ function deltaCell(v) {
544
+ var n = Number(v);
545
+ var shown = Number(fixed(Math.abs(n) * 100, 2));
546
+ var dir = shown === 0 ? 'flat' : (n > 0 ? 'up' : 'down');
547
+ return '<span class="delta delta--' + dir + '">' +
548
+ esc(fixed(Math.abs(n) * 100, 2)) + '%</span>';
549
+ }
550
+
551
+ var POL_DATA = [
552
+ { name: '营业收入', acct: 0.163, mkt: 0.0321 },
553
+ { name: '营业成本', acct: -0.008, mkt: -0.0105 },
554
+ { name: '其他收益', acct: 0.000, mkt: 0.0000 } /* 零值:两边都不许出符号 */
555
+ ];
556
+
557
+ function polarityTable() {
558
+ var h = '<div class="table"><table>' +
559
+ '<caption class="table__cap">同一份数据 · 会计口径不随市场切换,市场口径整体翻转</caption>' +
560
+ '<thead><tr><th scope="col">科目</th>' +
561
+ '<th scope="col">同比<span class="table__unit">(会计)</span></th>' +
562
+ '<th scope="col">涨跌<span class="table__unit">(市场)</span></th>' +
563
+ '</tr></thead><tbody>';
564
+ POL_DATA.forEach(function (r) {
565
+ var a = cell(r.acct, { format: 'signed-percent', precision: 1 });
566
+ h += '<tr><th scope="row">' + esc(r.name) + '</th>' +
567
+ '<td class="' + a.cls + '">' + esc(a.text) + '</td>' +
568
+ '<td class="num">' + deltaCell(r.mkt) + '</td></tr>';
569
+ });
570
+ return h + '</tbody></table></div>';
571
+ }
572
+
573
+ document.getElementById('polarity').innerHTML =
574
+ '<p class="note">A 股口径 <code>[data-market="cn"]</code>(默认,红涨绿跌)</p>' +
575
+ '<div data-market="cn">' + polarityTable() + '</div>' +
576
+ '<p class="note">国际口径 <code>[data-market="intl"]</code> —— 只有市场那一列翻转</p>' +
577
+ '<div data-market="intl">' + polarityTable() + '</div>';
491
578
  })();
492
579
  </script>
493
580
  </body>
@@ -72,10 +72,21 @@
72
72
  outline-offset: var(--focus-ring-offset);
73
73
  }
74
74
 
75
+ /* ⭐ 排序指示符改用 mask 画,不再用文字字形。
76
+ 原因:Chromium 会把 ::after 的 `content` 文本算进**可访问名**,读屏于是念出
77
+ 「公司 · ↕ · 按钮」—— 这个符号对用户毫无意义,而且自动检查抓不到(名字非空即过)。
78
+ 换成图像后字形不进无障碍树;三态形状、`opacity`、暗色跟随(currentColor)全不变。
79
+ ⚠️ 不要改回 `content: "↕" / ""` 这种替代文本语法:Firefox 不支持,箭头会直接消失。 */
75
80
  .table__sort::after {
76
- content: "↕"; /* 未排序:上下双向箭头 */
81
+ content: ""; /* 空内容 ⇒ 不进可访问名 */
82
+ inline-size: .85em;
83
+ block-size: .85em;
84
+ flex: none;
85
+ background-color: currentColor; /* 跟着文字颜色走,暗色自动适配 */
77
86
  opacity: .45;
78
- font-size: .9em;
87
+ -webkit-mask: var(--sort-glyph) center / contain no-repeat;
88
+ mask: var(--sort-glyph) center / contain no-repeat;
89
+ --sort-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1.4 5.2 4.6h1.7v2.6h2.2V4.6h1.7z'/%3E%3Cpath d='M8 14.6 10.8 11.4h-1.7V8.8H6.9v2.6H5.2z'/%3E%3C/svg%3E");
79
90
  }
80
91
 
81
92
  /* 🔴🔴 排序指示符:三态必须**同时**用形状 + 颜色表达(WCAG 1.4.1)
@@ -95,13 +106,13 @@
95
106
  ⇒ 两条选择器并存,任何一种写法都能把箭头点亮。 */
96
107
  .table th[aria-sort="ascending"] .table__sort::after,
97
108
  .table__sort[aria-sort="ascending"]::after {
98
- content: "↑";
109
+ --sort-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1.6 4.6 6h2.1v8h2.6V6h2.1z'/%3E%3C/svg%3E");
99
110
  opacity: 1;
100
111
  }
101
112
 
102
113
  .table th[aria-sort="descending"] .table__sort::after,
103
114
  .table__sort[aria-sort="descending"]::after {
104
- content: "↓";
115
+ --sort-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 14.4 11.4 10h-2.1V2H6.7v8H4.6z'/%3E%3C/svg%3E");
105
116
  opacity: 1;
106
117
  }
107
118
 
package/CHANGELOG.md CHANGED
@@ -16,6 +16,201 @@
16
16
 
17
17
  (无)
18
18
 
19
+ ## [0.13.2] - 2026-10-30
20
+
21
+ ### 修复 · 下拉菜单打开时,焦点没有交给第一项
22
+
23
+ 读屏用户在浏览模式下按 Enter,NVDA 会把这次按键变成**一次点击**。而原先只有键盘
24
+ 分支里会聚焦首项,click 分支只负责打开 ⇒ 菜单开了、焦点还留在按钮上,菜单项
25
+ 永远进不去。真机实测(NVDA + Edge):修复前焦点元素是那个按钮,修复后落在第一项。
26
+
27
+ 修法是把聚焦收进 `open()`,两条路径共用。
28
+
29
+ ### 修复 · 表格列头把排序符号念了出来
30
+
31
+ 列头按钮的可访问名里混着 `↕`,读屏念成「公司 · ↕ · 按钮」。
32
+ 来源是 `.table__sort::after { content: "↕" }` —— Chromium 把伪元素文本算进了
33
+ 可访问名。改为 `content: ""` + `mask` 图像画箭头:三态形状不变、颜色走
34
+ `currentColor`,**标记结构不用改**(外部集成方无需动 HTML)。
35
+
36
+ ⚠️ 不要改用 `content: "↕" / ""` 那种替代文本语法 —— Firefox 不支持,箭头会消失。
37
+
38
+ ### 判据 · 两处都补了能抓住它的检查
39
+
40
+ - `dropdown-check` 新增「点击打开也聚焦首项」。原来只测 `↓` 这条键盘路径,
41
+ 所以上面那个缺陷在 111 道门禁全绿的情况下一直存在(用改前的代码做过对照:
42
+ `↓` 绿、`↑↓` 绿、点击红)。
43
+ - `table-check` 的排序判据改为读 `mask` 的自定义属性;把降序箭头改成升序形状的
44
+ 破坏实验能当场判红,说明它仍在看结果而不是结构。
45
+
46
+ ### 文档 · 无障碍人工测试协议补上实测结果
47
+
48
+ 九个页面跑了一轮 NVDA 朗读(证据是读屏交给语音合成器的原文,不是人耳旁听),
49
+ 结果表从「全部未执行」变成实测结论;`H` 键标题层级与 macOS VoiceOver 仍为空白,
50
+ 表里如实写着「未执行」。
51
+
52
+ ---
53
+
54
+ ## [0.13.1] - 2026-10-30
55
+
56
+ **补录 Linux 三套视觉基线。0.13.0 的 tag 因这处过期未通过发布门禁,包没有发出去
57
+ —— npm 上不存在 0.13.0。本版本内容 = 0.13.0 + 基线对齐。**
58
+
59
+ 0.12.1 改了 `04-recipes/table/demo.html`(新增第 6 节两栏对照,页面高度
60
+ `4540` → `5653`),当时只重录了 Windows 的三套基线。Linux 三套仍指向旧代码,
61
+ 而**基线与指纹戳是按平台分集的** —— 本机的 `visual-stamp` 只核本平台,
62
+ 于是「212 道全绿」在这件事上的保证范围**仅限 Windows**,Linux 那三套要到
63
+ CI 上才暴露。
64
+
65
+ 重录后逐张核对:三套各**只有** `04-recipes_table.png` 变化(418 KB → 556 KB),
66
+ 其余 `43 / 43 / 14` 张**逐字节相同**。这同时证明渲染环境没有漂移,
67
+ 变化只来自表格页新增的那一节。
68
+
69
+ `CONTRIBUTING.md` 补了基线纪律:改 demo 的**结构**(页面高度会变)⇒
70
+ 所有已有平台的基线都要重录,不只本机;Linux 那套走
71
+ Actions → release → Run workflow → 勾 `shoot_baseline`,
72
+ **不要**在本机 `--record`(那录的是 Windows 的渲染,等于污染基准)。
73
+
74
+ ## [0.13.0] - 2026-10-30
75
+
76
+ **门禁体系从「两态」变成「三态」,并补上四道真能红的门禁。
77
+ `SKIP` 从此是一个独立的、不计入通过了的结果 —— 在此之前,
78
+ 「我没跑」只能靠 `return 0` 表达,于是它被显示成 PASS。**
79
+
80
+ 组件 CSS 产物本轮只动了一处注释,所以对**使用者**而言这是个空版本;
81
+ 真正的变化在「你怎么知道它是对的」这条链上。
82
+
83
+ ### 新增能力
84
+
85
+ - **`node ai/cli.js assets`** —— 列出**全部**可复用资产(49 项),
86
+ 不再只覆盖「有独立 CSS 的组件目录」。此前 `longform`(单文件长文示例页)、
87
+ `data-showcase`、`echarts-adapter`、`01-tokens/page.css`(页面骨架)、
88
+ `01-tokens/behavior/`(三个共享微行为核)对机器不可见 ——
89
+ 而 AI 不知道有 `page.css` 就会自己写布局,不知道有 `behavior/` 就会重写
90
+ 「Esc 关闭 + 焦点归还」。清单**从目录扫描生成**,不维护第二份手工列表。
91
+ - **`benchmark/REAL-PAGE-TASKS.md`** —— P01–P10 十个页面级生成任务,
92
+ 每个给出「需求 → 必须用到的库能力 → 自动判据 → 参考结果」,配 A/B 对照协议、
93
+ 记录格式与统计口径。定位是**效果验证**,与既有的 T01–T10(契约层)分开报告。
94
+ - **`benchmark/score-page.js`** —— 对**任意** HTML 打分的 5 条静态判据。
95
+ 既有的 `a11y-ax` / `responsive-check` / `zoom-reflow` 页面清单是内置页常量,
96
+ 指不到新生成的文件 ⇒ 不能拿它们给生成页打分,这是另写一个的原因。
97
+ 库类覆盖率阈值 `0.6` 是拿参考页实测 `0.696` 校准的,反向控制 3 条全绿。
98
+
99
+ ### 修掉的真缺陷
100
+
101
+ - **门禁跑完不退出(必然死锁)** `check-all.sh` 的主等待是裸 `wait`,
102
+ 而它会等**本 shell 起的全部子进程** —— 包括脚本自己启动的静态服务,
103
+ 那个服务永不退出 ⇒ 208 道跑完后进程无限挂起,只能人工 kill。
104
+ 实测复现:`sleep 30 & wait` 超时 `124`;加 `disown` 后 **0.076 秒**返回。
105
+ ⇒ 起服务后 `disown`。
106
+ - **临时目录只增不减(21.2 GB)** `FL_TMPDIR` 只 `mkdir -p`、从不清理;
107
+ 更隐蔽的是第 565 行的 `trap ... EXIT` **覆盖**了第 171 行的清理 trap
108
+ (bash 的 trap 是替换,不是追加)⇒ 浏览器 profile 一个都删不掉。
109
+ ⇒ 合并成唯一出口,并且只清理本脚本自己设的默认目录(用户显式指定的不碰)。
110
+ 清理时另揪出 8 个孤儿 headless 浏览器仍占着 profile 目录。
111
+ - **两道门禁把「没跑」说成「通过」** `engine-gate` 在可用引擎 < 2 时 `return 0`;
112
+ `benchmark --check-records` 把参考解法数成「模型记录」。两者都被汇总显示为 PASS。
113
+ ⇒ `check-all.sh` 引入第三态 **`rc=3` = 未判定**,单独计数;
114
+ 有未判定项时**不再打印「已执行的全部通过」**这种容易被读成全绿的措辞。
115
+ (没用 `2`:已有 9 道门禁用 `exit(2)` 表示「参数错 / 前置不满足」,
116
+ 把 2 定义成跳过会把这些真错误静默吞掉。)
117
+ - **资产发现的三处遗漏**(详见上节「新增能力」的 `ai assets` 条目)。
118
+
119
+ ### 新增门禁(4 道,均带反向控制)
120
+
121
+ | 门禁 | 它守什么 | 反向控制 |
122
+ |---|---|---|
123
+ | `assets-catalog` | 磁盘目录 / 契约 / CLI 三方对账 | 注入空目录 ⇒ 红;`_` 前缀目录 ⇒ 不算资产 |
124
+ | `zero-dep` | 「零第三方运行时依赖」这条对外承诺 | 往 `dependencies` 注入 `lodash` ⇒ 红 |
125
+ | `pattern-behavior` | **组件有没有真的接上行为核**(13 条) | 关掉遮罩关闭 / 不归还焦点 ⇒ 红 |
126
+ | `benchmark-score` | 评分器自身的判据合成 | 3 条破坏样本 ⇒ 红 |
127
+
128
+ `zero-dep` 值得单说:README 第一句就是「零依赖」,而此前**没有任何门禁读
129
+ `dependencies`**。注入 `lodash` 后 `doc-facts` / `dist:check` / `npm test`
130
+ **三道全部 RC=0** —— 核心承诺被违反时全库不会发现。
131
+
132
+ `pattern-behavior` 与既有的 `dismissable-check` / `focus-return-check` 分工不同:
133
+ 后者测**核本身**(把核注入空白页直接调),前者测**接线**(通过组件真实 API
134
+ 开一个抽屉,验焦点是否进入、Esc 是否只关最内层、点遮罩是否符合该组件自己的语义)。
135
+
136
+ ### 文档对齐
137
+
138
+ - `CONTRIBUTING.md`:补「全量为什么**不**在 CI 上跑」的完整理由与责任边界;
139
+ 补 `rc=3` 三态表;补「失败时先读 `internal/tmp/selftest-fail-*.log`」。
140
+ - `docs/A11Y-MANUAL-PROTOCOL.md`:结果表补 `accordion` / `drawer` / `dropdown`
141
+ 三行 —— 三个 beta 组件此前一个都不在表内,而「beta 升 stable」的判据之一
142
+ 就是行为零欠账,人工读屏是唯一的用户侧证据。**状态仍写「未执行」**:
143
+ 加行不等于做过。
144
+
145
+ ### 未改(诚实登记)
146
+
147
+ - **真实模型跑批仍是 0 条**。`benchmark --check-records` 现在会显示 **SKIP**,
148
+ 这不是故障,是它在说「还没有真实记录」。转述时不要把它说成通过。
149
+ - **人工读屏仍未执行**:需要一台装读屏软件的机器与人耳,已在协议里标 `NOT_TESTED`。
150
+ - 一处**等价突变体**:`overlay.js` 里删除 `stopPropagation` 抓不到红。实测
151
+ `outer.contains(inner) === false`(两个 backdrop 是兄弟节点),拦不拦行为一样。
152
+ 保留该行(未来若改成真嵌套就必需),但注释已改为说明**真实机制**,
153
+ 而不是原先那句把假设写成事实的「不拦会一次关两层」。
154
+
155
+ ## [0.12.1] - 2026-10-29
156
+
157
+ **把 `table` 与 `delta` 接上:市场涨跌以 `delta` 嵌进单元格,
158
+ 表格不再需要第四组颜色类;顺带修掉「零值被印成正号」。**
159
+
160
+ 这是 0.12.0 留下的那条自认缺口(`table/README.md` 原话:「目前**没有解**」)。
161
+ 解法不是当初列的两个候选(加 `num--up/down` 或 `data-polarity`),而是第三条:
162
+ **方向是独立语义,所以它继续待在独立原子里,表格只负责对齐。**
163
+
164
+ ### 修掉的真缺陷
165
+
166
+ - **E145 · 零值被印成正号** `format: 'signed-percent'` 的正号由类名触发,
167
+ 而旧实现判的是 `v < 0` ⇒ `v === 0` 掉进 else 得到 `num--pos`,渲染成「**+0.0%**」——
168
+ 一个没变化的数被念成"涨了"。四舍五入处同样复现:`0.0004` 在 1 位精度下也印成「+0.0%」。
169
+ ⇒ 改成「先算打印值,再按它定符号」,与 `delta--flat` 同一个契约。
170
+ (**不是门禁发现的** —— 门禁只验它写了判据的东西。这条是有人顺着 demo 源码
171
+ 读下来才看见的,记在这里提醒自己:判据的边界就是视野的边界。)
172
+
173
+ ### 本轮流程上踩的坑(详见 `internal/ERRORS.md` E146–E148)
174
+
175
+ - **E146** 新判据 F7 的豁免条件第一版用 `or` 连接「版本号」与「过去时语汇」,
176
+ 结果 `docs/ACCESSIBILITY.md` 表格行里的 WCAG 编号「**4.1.2**」被当成版本号
177
+ ⇒ 整行豁免 ⇒ **判据对它自己最该管的那一行完全失效**。破坏实验才发现。
178
+ - **E147** 同一份文档的「适用版本 `0.11.9`」与「38 个 demo 页」跨了三个版本没人改。
179
+ 这类数字只有加了 F7/F8 才第一次被机器看见。
180
+ - **E148** 我在本文件初稿里写了一条**没发生过的缺陷**("table demo 写了 `data-market`
181
+ 却没引 `market.css`")。核对 `git show HEAD:` 发现改前那个 demo 里 `data-market`
182
+ 出现 **0 次** —— 是我把"我要新增的东西"误记成"原来就有的漏洞"。已删。
183
+
184
+ ### 新增
185
+
186
+ - `04-recipes/table/demo.html` 第 6 节:**同一张表里的两种方向语义**。
187
+ 两块数据逐字相同,只有外层 `data-market` 不同 —— 会计列一个字不变,市场列整体翻转。
188
+ 零值那一行两边都不带符号。
189
+ - `table-check.js` 三条判据(该门禁的专属判据 14 → 17 条):
190
+ - ⑫ 符号与打印值同号(**且要求页面上真的存在零值样本**,删掉零值行不能算通过)
191
+ - ⑬ 市场涨跌用 `delta` + `table.css` 不许出现 `num--up/down` + intl 只翻市场列
192
+ - ⑭ 393px 下单元格内的 `delta` 不溢出(量几何,不看声明)
193
+ 三次破坏实验确认判别力:退回旧符号逻辑 ⇒ ⑫ 红;给 `table.css` 加 `num--up` ⇒ ⑬ 红;
194
+ 把 intl 块改成 cn ⇒ ⑬ 红。
195
+ - `doc-facts-gate` 新增 F7:文档里的「全部 N 个 demo 页」必须等于磁盘份数。
196
+ 它第一次跑就抓到 `CONTRIBUTING.md` 的旧数字,也抓到本门禁自己的一处设计错误
197
+ (拿 WCAG 编号「4.1.2」当版本号 ⇒ 整行被豁免 ⇒ 判据对目标文件完全失效)。
198
+ - `doc-facts-gate` 新增 F8:「无障碍声明适用版本」必须等于 `VERSION`。
199
+
200
+ ### 文档对齐
201
+
202
+ - `docs/ACCESSIBILITY.md`:适用版本 `0.11.9` → `0.12.1`;
203
+ 覆盖数 38 → **40**(a11y-ax)/ 39(axe)/ 40(zoom-reflow)——
204
+ 三个工具各自口径不同,原先把它们写成同一个 38 是错的。
205
+ - `04-recipes/table/README.md`:「未来没有解」改写为解法与取舍,并补零值规则。
206
+
207
+ ### 未改(诚实登记)
208
+
209
+ - `axe` 的 991 条规则通过数、`zoom-reflow` 的 3 条 AA 硬项结果都是上一轮实测值,
210
+ 本版没有重跑这两个工具(只重跑了 `a11y-ax`)。数字沿用,但**不声称本轮复核过**。
211
+
212
+ ---
213
+
19
214
  ## [0.12.0] - 2026-10-29
20
215
 
21
216
  **新增两个组件:`delta`(变化量)与 `metric`(指标块)。这是「数值展示层」的第一批。**
package/CONTRIBUTING.md CHANGED
@@ -8,10 +8,48 @@ npm run gate # 全量:含浏览器类
8
8
  npm run gate:fast # 只跑静态层,不用装浏览器
9
9
  ```
10
10
 
11
- 全量是 **208 道门禁**(本机约 5–7 分钟:组件层要起浏览器,还要逐张比 96 张视觉截图;
12
- 0.11.15 起默认 4 路并发执行,之前串行要 15–20 分钟)。
11
+ 全量是 **212 道门禁**(4 路并发实测约 9 分钟 / 退出码 0;单道耗时累加 1691 秒,
12
+ 组件层要起浏览器,还要逐张比 96 张视觉截图。0.11.15 起默认 4 路并发)。
13
13
  只想确认没改坏静态契约的话用 `gate:fast`,几秒到几分钟。
14
14
 
15
+ ### 全量为什么**不**在 CI 上跑(这不是缺口,是结论)
16
+
17
+ `.github/workflows/gate.yml` 分三层:`quick`(`MODE=fast`,静态 + 视觉)、
18
+ `contract`(API 契约,需 Chromium)、`browsers`(nightly,三引擎矩阵)。
19
+ **没有任何一处是 `MODE=full`**,而且不该有 —— 实测在 Linux runner 上跑全量会
20
+ FAIL 10+ 道(字体渲染不同 ⇒ 视觉 diff 必然不一致;路径/编码不同 ⇒ 体积类误报;
21
+ shallow clone ⇒ 依赖 git 状态的判据语义不同;浏览器版本不同 ⇒ 截图基线不匹配)。
22
+
23
+ **一个长期红的 CI 比没有 CI 更糟**:大家会习惯性忽略它,真正的新问题也一起被忽略。
24
+ 所以边界是:
25
+
26
+ - **CI 负责**:跨平台可靠的那部分(静态契约、类型、API 形态、单引擎行为)
27
+ - **发版人负责**:发版前在**本机目标平台**跑一次 `bash 05-audit/check-all.sh`
28
+
29
+ `browsers` job 已覆盖三引擎的组件行为契约,但它**不替代**本机全量:
30
+ 它不跑视觉基线(基线按平台分集)、不跑 RTL/响应式/无障碍全量。
31
+
32
+ ### 失败时先读哪里
33
+
34
+ `selftest-all` 报红时,除屏幕上那 4 行摘要,**完整子输出会落盘**到
35
+ `internal/tmp/selftest-fail-<脚本名>.log`(`internal/` 已被忽略,不进仓库)。
36
+ 不可复现的红最需要这个 —— 摘要往往不够定位。
37
+
38
+ 只想验一条反向控制时,用 `SKIP_SELFTESTS="a.py,b.py" python3 05-audit/selftest-all.py`
39
+ (列表里没写到的都会跑;被跳过的会在末尾逐条列出理由)。
40
+
41
+ ### 门禁退出码的三种含义(写新门禁必读)
42
+
43
+ | rc | 含义 | 汇总里显示 | 计入失败 |
44
+ |---|---|---|---|
45
+ | `0` | 通过 | `PASS` | 否 |
46
+ | `1` | 失败 | `FAIL`,并原样打印门禁自己的话 | 是 |
47
+ | `3` | **跳过 / 未判定**(环境不具备、数据为空,但本身无错) | `SKIP`,末尾单独数「未判定 N 道」 | 否 |
48
+
49
+ **`2` 不是跳过**:已有 9 个门禁用它表示「参数错 / 前置条件不满足」,往 2 上加语义会把真错误
50
+ 静默降级成跳过。**更不要 `return 0` 冒充跳过**——`rc=0` 天然等于通过,那正是假绿
51
+ (见 `internal/ERRORS.md` 的 E152 / E153)。有未判定项时,汇总**不会**打印「全部通过」。
52
+
15
53
  环境依赖只有一套(Node + Chromium),缺了对应的门禁会**跳过**(跳过不算通过):
16
54
 
17
55
  | 依赖 | 少了会怎样 | 装法 |
@@ -44,6 +82,20 @@ python 05-audit/shot-baseline.py --check --dir rtl # 视觉基线(13 页)
44
82
  不各抄一份 apt 清单)。字体版本升级会让 CI 红而本机绿 —— 确认是字体变化,
45
83
  就重录 Linux 那套;这也是"基线锚在渲染环境上"的固有代价。
46
84
 
85
+ **改了 demo 的结构 ⇒ 所有已有平台的基线都要重录,不只是你本机那套。**
86
+
87
+ 基线连同 `_stamp.json` 是**按平台分集**的:你重录 Windows 那套,
88
+ 本机的 `visual-stamp` 立刻绿;而 Linux 那套的戳仍指向旧 head,
89
+ 它在 CI 上红,**本机全量永远看不见**。判据很简单:**页面高度会不会变**——
90
+ 增删章节、改布局、改示例数据条数都会变;改文案、改注释不会。
91
+ `visual` 报「尺寸变了 (393, 4540) → (393, 5653)」就是该重录的信号,
92
+ 不是"轻微像素差",别当噪音忽略。
93
+
94
+ Linux 那套**不要**在本机 `--record`(录出来的是 Windows 的渲染,等于污染基准)。
95
+ 正确通道是 Actions → release → Run workflow → 勾 `shoot_baseline`:
96
+ 它在 ubuntu + `ci-fonts.sh` 的同一套环境里录,产物只出 artifact、不落库、不发布。
97
+ 下载 `baseline-Linux` 覆盖 `10-review/shots/_baseline-linux*`,**逐张看过**再提交。
98
+
47
99
  **加新组件时请注意**:若它含有 **方向性字形**(箭头、指示符)、
48
100
  **左右不对称的槽位/边框/滑入方向**、或**顺序有语义**(上一页/下一页、首列),
49
101
  就必须把它的 demo 加进 `shot-baseline.py` 的 `RTL_PAGES`。
package/START-HERE.md CHANGED
@@ -49,12 +49,12 @@
49
49
  | 日期区间 | `02-primitives/date-range/date-range.css` | 206 | **必需 301** (`date-range.js`) | 要选起止日期就要 |
50
50
  | 标签页 | `03-patterns/tabs/tabs.css` | 172 | **必需 316** (`tabs.js`) | 同层级内容切换就要 |
51
51
  | 折叠面板(beta) | `03-patterns/accordion/accordion.css` | 453 | **必需 389** (`accordion.js`) | 长表单分段就要 |
52
- | 动作菜单(beta) | `03-patterns/dropdown/dropdown.css` | 135 | **必需 451** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
52
+ | 动作菜单(beta) | `03-patterns/dropdown/dropdown.css` | 135 | **必需 456** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
53
53
  | 文字提示 | `03-patterns/tooltip/tooltip.css` | 89 | **必需 288** (`tooltip.js`) | 图标按钮或缩写需要一句话解释就要 |
54
54
  | 分页 | `03-patterns/pagination/pagination.css` | 195 | **必需 401** (`pagination.js`) | 数据多到要翻页就要 |
55
55
  | 树形 | `03-patterns/tree/tree.css` | 122 | **必需 464** (`tree.js`) | 层级数据(文件 / 组织 / 分类)要展开就要 |
56
56
  | 侧边抽屉(beta) | `03-patterns/drawer/drawer.css` | 129 | **必需 258** (`drawer.js`) | 要从侧边滑出面板就要 |
57
- | 弹窗 / 吐司(beta) | `03-patterns/overlay/overlay.css` | 319 | **必需 563** (`overlay.js`) | 需要弹层(吐司 / 模态)就要 |
57
+ | 弹窗 / 吐司(beta) | `03-patterns/overlay/overlay.css` | 319 | **必需 569** (`overlay.js`) | 需要弹层(吐司 / 模态)就要 |
58
58
  | 状态(空 / 加载 / 错 / 成功) | `03-patterns/states/states.css` | 252 | — | **凡是会异步取数就要** |
59
59
  | 表单校验 | `03-patterns/form-validation/form-validation.css` | 263 | — | 提交前要校验就要 |
60
60
  | 列表增删 | `03-patterns/list/list.css` | 162 | 可选 201 (`flip.js`,FLIP 增删动画,纯增强) | 列表会动态增删就要 |
@@ -66,7 +66,7 @@
66
66
  | analysis-report | `04-recipes/analysis-report/analysis-report.css` | 187 | — | |
67
67
  | detail-page | `04-recipes/detail-page/detail-page.css` | 190 | — | |
68
68
  | report | `04-recipes/report/report.css` | 364 | — | |
69
- | table | `04-recipes/table/table.css` | 372 | — | |
69
+ | table | `04-recipes/table/table.css` | 383 | — | |
70
70
  | bar | `09-assets/bar/bar.css` | 166 | **必需 320** (`bar.js`) | |
71
71
  | model-viewer | `09-assets/model-viewer/model-viewer.css` | 143 | **必需 423** (`model-viewer.js`) | |
72
72
  | scientific-plot | `09-assets/scientific-plot/scientific-plot.css` | 141 | **必需 610** (`scientific-plot.js`) | |
package/ai/cli.js CHANGED
@@ -256,6 +256,153 @@ function cmdComponents(args) {
256
256
  process.exit(0);
257
257
  }
258
258
 
259
+ // 🔴 ai assets —— 组件之外那批资产的机器可读目录(WO-03)
260
+ // ---------------------------------------------------------------------------
261
+ // 为什么要有这条:
262
+ // `ai components` 只认 `components.json`,而它只收「有独立 CSS 的目录」⇒ 37 个。
263
+ // 磁盘上还有 5 项不是组件、但 AI 不知道就会自己重写的东西:
264
+ // · 01-tokens/page.css 页面骨架与响应式两栏
265
+ // · 01-tokens/behavior/ 微行为核(Esc 关闭 / 焦点归还 / 方向键游走)
266
+ // · 04-recipes/longform 单文件长文示例页(连 demo.html 都没有)
267
+ // · 04-recipes/data-showcase
268
+ // · 09-assets/echarts-adapter 依赖宿主已有 ECharts(本库不含)
269
+ // 后果很具体:不知道 page.css 就自己写布局,不知道 behavior/ 就手写
270
+ // dismissable —— 后者正是 4 个 beta 组件被判 beta 的原因(dismissable:manual)。
271
+ //
272
+ // ⚠️ 本命令的清单**从磁盘扫描生成**,禁止手工维护第二份事实。
273
+ // `05-audit/assets-catalog-gate.py` 校验「磁盘目录数 == 本命令输出数」,
274
+ // 两边一旦漂移就变红。
275
+ var ROOT = path.join(AI, '..');
276
+ var ASSET_LAYERS = ['02-primitives', '03-patterns', '04-recipes', '09-assets'];
277
+
278
+ function readFirstUsefulLine(file) {
279
+ try {
280
+ var s = fs.readFileSync(file, 'utf8').split('\n');
281
+ for (var i = 0; i < s.length && i < 90; i++) {
282
+ var t = s[i].trim();
283
+ if (!t) continue;
284
+ // 块注释边界与装饰线:跳过,不是描述
285
+ if (/^\/\*+[=\s*-]*$/.test(t)) continue;
286
+ if (/^[=\s-]*\*+\/$/.test(t)) continue;
287
+ if (/^\*+[=\s*-]*$/.test(t)) continue;
288
+ if (/^={3,}/.test(t) || /^-{3,}/.test(t)) continue;
289
+ // 注释内的内容行:去掉前导 * 再判断
290
+ if (t.charAt(0) === '*') { t = t.replace(/^\*+\s*/, '').trim(); if (!t) continue; }
291
+ if (t.length > 2 && t.slice(-2) === '*/') { t = t.slice(0, -2).trim(); if (!t) continue; }
292
+ if (t.charAt(0) === '#') { t = t.replace(/^#+\s*/, '').trim(); if (t) return t.slice(0, 72); continue; }
293
+ // Markdown 表格:只认「用途」这类带说明的字段,其余(来源/门禁/文件)跳过
294
+ var m = t.match(/^\|\s*\*\*([^*]+)\*\*\s*\|\s*([^|]+)\|/);
295
+ if (m) {
296
+ if (m[1].indexOf('用途') >= 0 || m[1].indexOf('定位') >= 0) return m[2].trim().slice(0, 72);
297
+ continue;
298
+ }
299
+ if (t.charAt(0) === '|') continue;
300
+ // 无中文的「a / b / c」是路径面包屑,不是描述
301
+ if (!/[\u4e00-\u9fa5]/.test(t) && t.indexOf(' / ') >= 0) continue;
302
+ if (!/[\u4e00-\u9fa5]/.test(t) && /\.(css|js|html)$/.test(t)) continue;
303
+ return t.slice(0, 72);
304
+ }
305
+ } catch (e) { /* 读不到就当没描述,不抛 */ }
306
+ return '';
307
+ }
308
+
309
+ function describeAsset(dir, name, purpose) {
310
+ // 在契约里的,直接用契约的 purpose(比 README 标题精确)
311
+ if (purpose) return purpose;
312
+ var d = path.join(ROOT, dir, name);
313
+ var rd = path.join(d, 'README.md');
314
+ if (fs.existsSync(rd)) {
315
+ var txt = fs.readFileSync(rd, 'utf8');
316
+ var m = txt.match(/\|\s*\*\*用途\*\*\s*\|\s*([^|\n]+)/);
317
+ if (m) return m[1].trim().slice(0, 72);
318
+ return readFirstUsefulLine(rd);
319
+ }
320
+ // 没有 README:回退到目录内第一个脚本的头部注释
321
+ var f2 = fs.readdirSync(d).filter(function (f) {
322
+ return /\.(js|css)$/.test(f) && f.indexOf('demo') !== 0;
323
+ });
324
+ if (f2.length) return readFirstUsefulLine(path.join(d, f2[0]));
325
+ return '';
326
+ }
327
+
328
+ // 类型判定必须机械可判,不查名单:
329
+ // 在组件契约里 → component
330
+ // 09-assets 下的目录 → adapter(都是「消费外部能力」的适配层)
331
+ // 有 CSS 但不在契约 → component(意味着契约漏了它,门禁会报)
332
+ // 无 CSS 有 demo.html → recipe(示例页)
333
+ // 其余 → example(单文件示例)
334
+ function classifyAsset(layer, name, inContract, files) {
335
+ if (inContract[name]) return 'component';
336
+ if (layer === '09-assets') return 'adapter';
337
+ if (files.some(function (f) { return /\.css$/.test(f); })) return 'component';
338
+ if (files.indexOf('demo.html') >= 0) return 'recipe';
339
+ return 'example';
340
+ }
341
+
342
+ function collectAssets() {
343
+ var doc = readJSON(path.join(AI, 'components.json'));
344
+ var inContract = {};
345
+ var purposeOf = {};
346
+ doc.components.forEach(function (c) {
347
+ inContract[c.id] = true;
348
+ purposeOf[c.id] = c.purpose || '';
349
+ });
350
+
351
+ var rows = [];
352
+ ASSET_LAYERS.forEach(function (layer) {
353
+ var abs = path.join(ROOT, layer);
354
+ if (!fs.existsSync(abs)) return;
355
+ fs.readdirSync(abs).sort().forEach(function (n) {
356
+ // 下划线/点开头是临时目录与隐藏目录,不是资产(扫描口径,不是白名单)
357
+ if (n.charAt(0) === '_' || n.charAt(0) === '.') return;
358
+ var p = path.join(abs, n);
359
+ if (!fs.statSync(p).isDirectory()) return;
360
+ var files = fs.readdirSync(p);
361
+ rows.push({
362
+ id: n, layer: layer, type: classifyAsset(layer, n, inContract, files),
363
+ demo: files.indexOf('demo.html') >= 0, contract: !!inContract[n],
364
+ desc: describeAsset(layer, n, purposeOf[n])
365
+ });
366
+ });
367
+ });
368
+
369
+ // 01-tokens 层:同样靠扫描,不写死名字。
370
+ // behavior/ 目录 → behavior;*.css → base;*.js → behavior;*.html 是 demo,跳过
371
+ var tk = path.join(ROOT, '01-tokens');
372
+ if (fs.existsSync(tk)) {
373
+ fs.readdirSync(tk).sort().forEach(function (n) {
374
+ if (n.charAt(0) === '_' || n.charAt(0) === '.') return;
375
+ var p = path.join(tk, n);
376
+ var st = fs.statSync(p);
377
+ if (st.isDirectory()) {
378
+ rows.push({ id: n, layer: '01-tokens', type: 'behavior', demo: false,
379
+ contract: false, desc: describeAsset('01-tokens', n, '') });
380
+ return;
381
+ }
382
+ if (/\.html$/.test(n)) return;
383
+ var t = /\.css$/.test(n) ? 'base' : /\.js$/.test(n) ? 'behavior' : null;
384
+ if (!t) return;
385
+ rows.push({ id: n, layer: '01-tokens', type: t, demo: false, contract: false,
386
+ desc: readFirstUsefulLine(p) });
387
+ });
388
+ }
389
+ return rows;
390
+ }
391
+
392
+ function cmdAssets(args) {
393
+ var rows = collectAssets();
394
+ if (flag('json', false)) { out(rows); process.exit(0); }
395
+ var miss = rows.filter(function (r) { return !r.contract; });
396
+ process.stdout.write(' === 全部资产(' + rows.length + ' 项)===\n');
397
+ process.stdout.write(' 契约内 ' + (rows.length - miss.length) + ' 项 · 契约外 ' + miss.length + ' 项\n\n');
398
+ rows.forEach(function (r) {
399
+ process.stdout.write(' ' + (r.contract ? '✅' : '○') + ' ' + pad(r.id, 22) +
400
+ '[' + r.type + ']' + (r.demo ? ' demo' : ' ') + ' ' + r.desc + '\n');
401
+ });
402
+ process.stdout.write('\n ✅ 在组件契约内(可 ai components 查详情) ○ 契约外(需看本节)\n');
403
+ process.exit(0);
404
+ }
405
+
259
406
  function cmdTokens(args) {
260
407
  var doc = readJSON(path.join(AI, 'tokens.json'));
261
408
  var set = flag('set', null);
@@ -351,6 +498,7 @@ var CMDS = {
351
498
  diff: cmdDiff,
352
499
  patch: cmdPatch,
353
500
  components: cmdComponents,
501
+ assets: cmdAssets,
354
502
  tokens: cmdTokens,
355
503
  tree: cmdTree,
356
504
  profile: cmdProfile,
@@ -367,6 +515,7 @@ function main() {
367
515
  ' ai diff <改动前.json> <改动后.json> 按语义 id 比对\n' +
368
516
  ' ai patch <doc.json> <patch.json> [-o out.json]\n' +
369
517
  ' ai components [--maturity=stable|beta|alpha]\n' +
518
+ ' ai assets [--json] 组件之外的资产:布局 / 微行为核 / 示例页 / 适配器\n' +
370
519
  ' ai tokens [--set=light|dark]\n' +
371
520
  ' ai tree [--set=light|dark] [--cat=color]\n' +
372
521
  ' ai profile 列出三档及其差异\n' +
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
3
  "generatedBy": "05-audit/gen-ai-contract.py",
4
- "libraryVersion": "0.12.0",
4
+ "libraryVersion": "0.13.2",
5
5
  "$comment": "机器可读的组件契约。slots = BEM 元素名(在本库里元素名就是插槽名),states.enum = 组件会在 DOM 上写出的 data-* 取值,states.legacyClass = 仍未收敛到 data 属性的历史状态类(INVARIANT I-8 未达标)。",
6
6
  "maturityCriteria": {
7
7
  "alpha": "缺 demo.html 或缺 05-audit 下的专属门禁 ⇒ 还没被机器验证过,不要用",
@@ -265,6 +265,8 @@ fr.save();
265
265
  menu.hidden = false;
266
266
  btn.setAttribute('aria-expanded', 'true');
267
267
  active = -1;
268
+
269
+ focusAt(0);
268
270
  flEmit(root, 'fl-open', null);
269
271
  if (opt.onOpen) opt.onOpen();
270
272
  }
@@ -331,7 +333,6 @@ if (e.target === btn) {
331
333
  if (k === 'ArrowDown' || k === 'Enter' || k === ' ' || k === 'Spacebar') {
332
334
  e.preventDefault();
333
335
  open();
334
- focusAt(0);
335
336
  }
336
337
  return;
337
338
  }
@@ -1 +1 @@
1
- .table {position:relative;overflow:auto;max-height:var(--table-max-h, 70vh);background:var(--surface);border:1px solid var(--border-decor);border-radius:var(--r-md);}.table table {width:100%;border-collapse:separate;border-spacing:0;font-size:var(--fs-sm);}.table thead th {position:sticky;top:0;z-index:var(--z-sticky);background:var(--surface-raised);color:var(--text-secondary);font-weight:600;text-align:start;white-space:nowrap;padding:var(--sp-2) var(--sp-3);border-bottom:1px solid var(--border-decor);}.table__sort {display:inline-flex;align-items:center;gap:var(--sp-1);min-height:32px;padding:0 var(--sp-1);margin:0 calc(-1 * var(--sp-1));background:none;border:0;border-radius:var(--r-xs, 3px);color:inherit;font:inherit;cursor:pointer;}.table__sort:focus-visible {outline:var(--focus-ring-width) solid var(--focus-ring-color);outline-offset:var(--focus-ring-offset);}.table__sort::after {content:"↕";opacity:.45;font-size:.9em;}.table th[aria-sort="ascending"] .table__sort::after, .table__sort[aria-sort="ascending"]::after {content:"↑";opacity:1;}.table th[aria-sort="descending"] .table__sort::after, .table__sort[aria-sort="descending"]::after {content:"↓";opacity:1;}.table tbody td, .table tbody th {padding:var(--sp-2) var(--sp-3);text-align:start;border-bottom:1px solid var(--border-decor);vertical-align:top;}.table tbody tr:last-child td {border-bottom:0;}@media (hover:hover) and (pointer:fine) {.table tbody tr:hover {background:var(--surface-sunken);}}.table .num {text-align:end;font-variant-numeric:tabular-nums;font-family:var(--mono);white-space:nowrap;}.table .num--pos {color:var(--success);}.table .num--neg {color:var(--danger);}.table .num--pos::before {content:"+";}.table .num--neg::before {content:"−";}.table__empty, .table__error, .table__loading {padding:var(--sp-7) var(--sp-4);text-align:center;color:var(--text-secondary);font-size:var(--fs-sm);}.table__empty {background:var(--surface-sunken);}.table__error {background:var(--surface-sunken);color:var(--danger);}.table__loading {color:var(--text-tertiary);}.table__bar {height:8px;margin:var(--sp-2) auto;max-width:220px;background:var(--surface-sunken);border-radius:999px;overflow:hidden;}@media (prefers-reduced-motion:no-preference) {.table__bar::after {content:"";display:block;height:100%;width:40%;background:var(--accent-soft);border-radius:999px;animation:table-shimmer 1.2s var(--ease-in-out, ease-in-out) infinite;}@keyframes table-shimmer {0% {transform:translateX(-100%);}100% {transform:translateX(250%);}}}.table__empty .btn, .table__error .btn {margin-top:var(--sp-3);}.table--compact {font-size:var(--fs-xs);}.table--compact thead th, .table--compact tbody td {padding:var(--sp-1) var(--sp-2);}.table__cap {caption-side:top;text-align:start;padding:var(--sp-2) var(--sp-3);font-size:var(--fs-xs);color:var(--text-tertiary);}.table__foot {margin:var(--sp-2) 0 0;padding:0 var(--sp-1);font-size:var(--fs-xs);color:var(--text-tertiary);line-height:1.6;}.table__foot ol {margin:0;padding-inline-start:var(--sp-4);}.table thead tr + tr th {top:2.4em;font-weight:500;font-size:var(--fs-xs);color:var(--text-tertiary);border-bottom:1px solid var(--border-decor);}.table thead th[colspan] {text-align:center;border-inline-start:1px solid var(--border-decor);}.table thead th[colspan]:first-child {border-inline-start:0;}.table--financial thead th {white-space:nowrap;}.table--financial tbody th {white-space:nowrap;}.table--financial thead th:not(:first-child) {text-align:end;}.table__unit {font-weight:400;font-size:inherit;color:var(--text-secondary);margin-inline-start:0.35em;white-space:nowrap;}.table .num--na, .table .cell--na {color:var(--text-tertiary);text-align:center;}.table--academic {border:0;border-radius:0;background:transparent;max-height:none;}.table--academic table {background:transparent;}.table--academic thead th {background:transparent;color:var(--text-primary);font-weight:600;border-top:2px solid var(--border-decor-str);border-bottom:1px solid var(--border-decor);position:static;}.table--academic thead tr + tr th {font-weight:400;border-top:0;border-bottom:1px solid var(--border-decor);}.table--academic tbody td, .table--academic tbody th {border-bottom:0;}.table--academic tbody tr:last-child td, .table--academic tbody tr:last-child th {border-bottom:1px solid var(--border-decor);}@media (hover:hover) and (pointer:fine) {.table--academic tbody tr:hover {background:transparent;}}.table--stats .stat__se {color:var(--text-tertiary);font-size:var(--fs-xs);}.table--stats .stat__p {font-family:var(--mono);font-variant-numeric:tabular-nums;}.table--stats .stat__p--sig {font-weight:700;color:var(--text-primary);}.table--stats .stat__ci {font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap;}@media print {.table {max-height:none;overflow:visible;}.table thead th {position:static;}.table--academic thead th {border-top:2px solid var(--text-primary);}}
1
+ .table {position:relative;overflow:auto;max-height:var(--table-max-h, 70vh);background:var(--surface);border:1px solid var(--border-decor);border-radius:var(--r-md);}.table table {width:100%;border-collapse:separate;border-spacing:0;font-size:var(--fs-sm);}.table thead th {position:sticky;top:0;z-index:var(--z-sticky);background:var(--surface-raised);color:var(--text-secondary);font-weight:600;text-align:start;white-space:nowrap;padding:var(--sp-2) var(--sp-3);border-bottom:1px solid var(--border-decor);}.table__sort {display:inline-flex;align-items:center;gap:var(--sp-1);min-height:32px;padding:0 var(--sp-1);margin:0 calc(-1 * var(--sp-1));background:none;border:0;border-radius:var(--r-xs, 3px);color:inherit;font:inherit;cursor:pointer;}.table__sort:focus-visible {outline:var(--focus-ring-width) solid var(--focus-ring-color);outline-offset:var(--focus-ring-offset);}.table__sort::after {content:"";inline-size:.85em;block-size:.85em;flex:none;background-color:currentColor;opacity:.45;-webkit-mask:var(--sort-glyph) center / contain no-repeat;mask:var(--sort-glyph) center / contain no-repeat;--sort-glyph:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1.4 5.2 4.6h1.7v2.6h2.2V4.6h1.7z'/%3E%3Cpath d='M8 14.6 10.8 11.4h-1.7V8.8H6.9v2.6H5.2z'/%3E%3C/svg%3E");}.table th[aria-sort="ascending"] .table__sort::after, .table__sort[aria-sort="ascending"]::after {--sort-glyph:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 1.6 4.6 6h2.1v8h2.6V6h2.1z'/%3E%3C/svg%3E");opacity:1;}.table th[aria-sort="descending"] .table__sort::after, .table__sort[aria-sort="descending"]::after {--sort-glyph:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M8 14.4 11.4 10h-2.1V2H6.7v8H4.6z'/%3E%3C/svg%3E");opacity:1;}.table tbody td, .table tbody th {padding:var(--sp-2) var(--sp-3);text-align:start;border-bottom:1px solid var(--border-decor);vertical-align:top;}.table tbody tr:last-child td {border-bottom:0;}@media (hover:hover) and (pointer:fine) {.table tbody tr:hover {background:var(--surface-sunken);}}.table .num {text-align:end;font-variant-numeric:tabular-nums;font-family:var(--mono);white-space:nowrap;}.table .num--pos {color:var(--success);}.table .num--neg {color:var(--danger);}.table .num--pos::before {content:"+";}.table .num--neg::before {content:"−";}.table__empty, .table__error, .table__loading {padding:var(--sp-7) var(--sp-4);text-align:center;color:var(--text-secondary);font-size:var(--fs-sm);}.table__empty {background:var(--surface-sunken);}.table__error {background:var(--surface-sunken);color:var(--danger);}.table__loading {color:var(--text-tertiary);}.table__bar {height:8px;margin:var(--sp-2) auto;max-width:220px;background:var(--surface-sunken);border-radius:999px;overflow:hidden;}@media (prefers-reduced-motion:no-preference) {.table__bar::after {content:"";display:block;height:100%;width:40%;background:var(--accent-soft);border-radius:999px;animation:table-shimmer 1.2s var(--ease-in-out, ease-in-out) infinite;}@keyframes table-shimmer {0% {transform:translateX(-100%);}100% {transform:translateX(250%);}}}.table__empty .btn, .table__error .btn {margin-top:var(--sp-3);}.table--compact {font-size:var(--fs-xs);}.table--compact thead th, .table--compact tbody td {padding:var(--sp-1) var(--sp-2);}.table__cap {caption-side:top;text-align:start;padding:var(--sp-2) var(--sp-3);font-size:var(--fs-xs);color:var(--text-tertiary);}.table__foot {margin:var(--sp-2) 0 0;padding:0 var(--sp-1);font-size:var(--fs-xs);color:var(--text-tertiary);line-height:1.6;}.table__foot ol {margin:0;padding-inline-start:var(--sp-4);}.table thead tr + tr th {top:2.4em;font-weight:500;font-size:var(--fs-xs);color:var(--text-tertiary);border-bottom:1px solid var(--border-decor);}.table thead th[colspan] {text-align:center;border-inline-start:1px solid var(--border-decor);}.table thead th[colspan]:first-child {border-inline-start:0;}.table--financial thead th {white-space:nowrap;}.table--financial tbody th {white-space:nowrap;}.table--financial thead th:not(:first-child) {text-align:end;}.table__unit {font-weight:400;font-size:inherit;color:var(--text-secondary);margin-inline-start:0.35em;white-space:nowrap;}.table .num--na, .table .cell--na {color:var(--text-tertiary);text-align:center;}.table--academic {border:0;border-radius:0;background:transparent;max-height:none;}.table--academic table {background:transparent;}.table--academic thead th {background:transparent;color:var(--text-primary);font-weight:600;border-top:2px solid var(--border-decor-str);border-bottom:1px solid var(--border-decor);position:static;}.table--academic thead tr + tr th {font-weight:400;border-top:0;border-bottom:1px solid var(--border-decor);}.table--academic tbody td, .table--academic tbody th {border-bottom:0;}.table--academic tbody tr:last-child td, .table--academic tbody tr:last-child th {border-bottom:1px solid var(--border-decor);}@media (hover:hover) and (pointer:fine) {.table--academic tbody tr:hover {background:transparent;}}.table--stats .stat__se {color:var(--text-tertiary);font-size:var(--fs-xs);}.table--stats .stat__p {font-family:var(--mono);font-variant-numeric:tabular-nums;}.table--stats .stat__p--sig {font-weight:700;color:var(--text-primary);}.table--stats .stat__ci {font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap;}@media print {.table {max-height:none;overflow:visible;}.table thead th {position:static;}.table--academic thead th {border-top:2px solid var(--text-primary);}}
package/dist/COSTS.md CHANGED
@@ -36,11 +36,11 @@
36
36
  | pattern | `accordion` | 2.7 KB | 1.3 KB | 1.8 KB | **5.8 KB** | 34.1 KB | −28.3 KB |
37
37
  | pattern | `content` | 2.7 KB | 1.0 KB | — | **3.7 KB** | 24.2 KB | −20.5 KB |
38
38
  | pattern | `drawer` | 2.7 KB | 0.8 KB | 2.0 KB | **5.4 KB** | 25.0 KB | −19.6 KB |
39
- | pattern | `dropdown` | 2.7 KB | 0.8 KB | 2.9 KB | **6.4 KB** | 27.9 KB | −21.5 KB |
39
+ | pattern | `dropdown` | 2.7 KB | 0.8 KB | 2.9 KB | **6.4 KB** | 28.1 KB | −21.7 KB |
40
40
  | pattern | `form-validation` | 2.7 KB | 0.8 KB | — | **3.5 KB** | 24.1 KB | −20.7 KB |
41
41
  | pattern | `list` | 2.7 KB | 0.6 KB | — | **3.3 KB** | 22.0 KB | −18.7 KB |
42
42
  | pattern | `nav` | 2.7 KB | 1.2 KB | 2.6 KB | **6.5 KB** | 30.9 KB | −24.4 KB |
43
- | pattern | `overlay` | 2.7 KB | 1.1 KB | 2.8 KB | **6.7 KB** | 33.6 KB | −26.9 KB |
43
+ | pattern | `overlay` | 2.7 KB | 1.1 KB | 2.8 KB | **6.7 KB** | 33.9 KB | −27.2 KB |
44
44
  | pattern | `pagination` | 2.7 KB | 0.8 KB | 2.6 KB | **6.0 KB** | 30.1 KB | −24.1 KB |
45
45
  | pattern | `reveal` | 2.7 KB | 0.3 KB | 1.2 KB | **4.2 KB** | 24.2 KB | −20.0 KB |
46
46
  | pattern | `states` | 2.7 KB | 0.8 KB | — | **3.5 KB** | 23.6 KB | −20.1 KB |
@@ -51,7 +51,7 @@
51
51
  ## 三、全库
52
52
 
53
53
  - 全部组件都用上(min,含 tokens 一份):**61.2 KB**
54
- - 源码版(带注释)全库 gzip:302.0 KB
54
+ - 源码版(带注释)全库 gzip:303.0 KB
55
55
 
56
56
  > ⚠️ 源码版那个数字**不是**要付的钱:它包含我们的注释(全库 CSS 有 67% 是注释,字节口径)。
57
57
  > 它的用途只有一个 —— 让人看清"dist 到底省了多少"。
@@ -4,8 +4,13 @@
4
4
  但证明不了"念出来是否**合理**"。这一页把这件事变成**可执行、可复现、可填表**的动作,
5
5
  时长约 20 分钟。
6
6
 
7
- **当前状态:未执行。** 下面那张结果表是空的 —— 这不是省略,是事实。
8
- 跑完并填上之后,[无障碍一致性声明](ACCESSIBILITY.md) 里"没有真机读屏记录"那一条才能划掉。
7
+ **当前状态:已执行一轮(2026-10-30)。** 九个页面都跑过 Tab 遍历,浮层焦点在
8
+ `overlay` / `drawer` 上验到,**标题层级(`H` 键逐级跳)本轮没做**,结果表里如实写着
9
+ "未执行"。
10
+
11
+ 证据形态要说清楚:**不是人耳旁听**,是让 NVDA 把朗读日志写到文件,逐条取它交给语音
12
+ 合成器的原始文本(`IO - speech.speech.speak`)。这比人耳更可复现、可存档,但它证明的
13
+ 是"NVDA 念了什么",不覆盖"听起来是否别扭"。
9
14
 
10
15
  ---
11
16
 
@@ -56,17 +61,43 @@ python3 -m http.server 8000 --bind 127.0.0.1
56
61
 
57
62
  | 页面 | ① 名字 | ② 标题层级 | ③ 禁用态 | ④ 错误说明 | ⑤ 浮层焦点 | 备注(听到了什么) |
58
63
  |---|---|---|---|---|---|---|
59
- | button | 未执行 | 未执行 | 未执行 | — | — | |
60
- | input | 未执行 | 未执行 | 未执行 | 未执行 | — | |
61
- | choice | 未执行 | 未执行 | 未执行 | — | — | |
62
- | tabs | 未执行 | 未执行 | — | — | — | |
63
- | overlay | 未执行 | 未执行 | — | — | 未执行 | |
64
- | table | 未执行 | 未执行 | — | — | — | |
64
+ | button | 通过 | 未执行 | 未执行 | — | — | 12 个按钮全部念出名字;加载态念「忙碌中」 |
65
+ | input | 通过 | 未执行 | 未执行 | 通过 | — | 只读念「只读」;错误念「错误的输入 · 请输入有效金额」,紧跟控件 |
66
+ | choice | 通过 | 未执行 | — | — | — | 「全选(下面 3 项)」念「部分选中」;单选念「时间范围 · 分组 · 已选中 · 3 之 1 项」 |
67
+ | tabs | 通过 | 未执行 | — | — | — | 念「选项卡组 · 选项卡 · 已选中 · 2 之 1 项」;错例组三个 `tabindex=0` 各停一次,正例组只停一次 —— 对照成立 |
68
+ | overlay | 通过 | 通过(对话框标题念成 2 级) | — | — | 通过 | 打开后焦点进对话框(取消 / 确认标记),Esc 后回到「普通确认」,再 Tab 落到「删除这份报告」 |
69
+ | table | 通过 | 未执行 | — | — | — | 表格念「共 7 行 7 列 · 1 行 1 列」;🔴 **列头按钮把 `↕` 念了出来**(见下) |
70
+ | **accordion** | 通过 | 通过(标题念成 3 级) | 通过 | — | — | 「公司基本信息 · 按钮 · 已展开 · 标题 3 级」→ 回车念「已折叠」→ 再回车念「已展开」;`aria-disabled` 那栏念「不可用」 |
71
+ | **drawer** | 通过 | 未执行 | — | — | 通过 | 打开念「从left进来的抽屉 · 对话框」;Tab 在「取消 → 确定 → 关闭」之间循环;Esc 后回到「左侧 · 按钮」 |
72
+ | **dropdown** | 通过 | 未执行 | — | — | 通过(修复后) | 按钮念「菜单按钮 · 已折叠 · 子菜单」,展开念「已展开 · 菜单」。**修复前**:按 Enter 打开后焦点留在按钮上(读屏在浏览模式下把 Enter 变成点击,走 click 分支),修复后真机复测焦点落在「✓ 按日期」。方向键仍无法在项间移动 —— 极简 APG 对照页表现相同,属读屏浏览模式限制 |
73
+
74
+ > **为什么后三行是补的**:`accordion` / `drawer` / `dropdown` 这三个都是 beta 组件
75
+ > (`ai/components.json` 当前记 4 个 beta),而它们升 stable 的判据之一就是「行为零欠账」
76
+ > ——人工读屏是这件事**唯一的用户侧证据**。
77
+ > 只覆盖 `overlay` 一个浮层,等于对其他浮层的真实表现没有任何记录。
78
+
79
+ **本轮跑出来并已修掉的一条真缺陷**:`table` 的列头按钮被念成「公司 · `↕` · 按钮」——
80
+ 那个 `↕` 来自 `table.css` 里 `.table__sort::after` 的 `content`,Chromium 把伪元素文本算进了
81
+ 可访问名。**自动检查抓不到它**(可访问名"非空"就算过),212 道门禁里也没有一道看这个。
82
+
83
+ 改法:把字形换成 `mask` 图像(`content` 留空),三态形状仍是 `↕ / ↑ / ↓`,
84
+ 颜色靠 `currentColor` 跟着主题走,标记结构一个字符都不用动。
85
+ ⚠️ 不走 `content: "↕" / ""` 那条路 —— 替代文本语法 Firefox 不支持,箭头会直接消失。
86
+ 改完用 NVDA 复听:表头只剩「公司 · 按钮」;`table-check` 的排序判据也跟着改成
87
+ 读 `mask` 的自定义属性(破坏实验验证过:把降序箭头改成升序形状,判据当场红)。
88
+
89
+ **`dropdown` 那行要连着对照读**:同样的操作在极简 APG 对照页上表现一致(↓ 被吃),
90
+ 所以"方向键读不出菜单项"不是本库特有。但另一件事是**真缺陷**:用 NVDA 在浏览模式下
91
+ 按 Enter,它会把这次按键变成一次点击 ⇒ 走的是 click 分支,而本库原来只在键盘分支里
92
+ 聚焦首项 ⇒ 菜单打开了、焦点还在按钮上,菜单项永远进不去。
93
+ 自动门禁当时是绿的,因为它只测了 `↓` 这条键盘路径。
94
+ **修法与判据**:把聚焦首项收进 `open()`(两条路径共用),并补一条"点击打开也聚焦首项"。
65
95
 
66
96
  状态只填三种:**通过** / **失败(附听到的内容)** / **未执行**。
67
97
  "大概没问题"不属于这三种,不要写。
68
98
 
69
- 填写人 / 日期 / 系统与读屏版本:`____ / ____ / ____`
99
+ 填写人 / 日期 / 系统与读屏版本:
100
+ `脚本驱动 NVDA(朗读取自 speech 日志原文,非人耳旁听)/ 2026-10-30 / Windows 10 22H2 19045 · NVDA 2026.3 · Edge`
70
101
 
71
102
  ---
72
103
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  | 项目 | 值 |
7
7
  |---|---|
8
- | 声明适用版本 | agentcss `0.11.9` |
8
+ | 声明适用版本 | agentcss `0.13.2` |
9
9
  | 声明日期 | 2026-10-29 |
10
10
  | 复核节奏 | 每次发版前重跑(`npm run gate` 内含全部证据) |
11
11
  | 范围 | 本库交付的 CSS / JS、全部 `demo.html`(组件页 + 图表页 + 组合页)、以及文档站生成的页面 |
@@ -67,7 +67,7 @@
67
67
  | 3.3.2 | 标签或说明 | ✅ | 必填项有可见标记 + `required` |
68
68
  | 3.3.7 | 冗余输入(2.2 A) | — | 由业务表单决定,库不承载 |
69
69
  | 3.3.8 | 可访问的认证(2.2 AA) | — | 库不含登录流程 |
70
- | 4.1.2 | 名称、角色、值 | ✅ | `a11y-ax.js`:**全部 38 个 demo 页**的可访问性树逐节点判,每个可交互控件有非空名称;朗读序列逐页存快照防回归 |
70
+ | 4.1.2 | 名称、角色、值 | ✅ | `a11y-ax.js`:**全部 40 个 demo 页**的可访问性树逐节点判,每个可交互控件有非空名称;朗读序列逐页存快照防回归 |
71
71
  | 4.1.3 | 状态消息 | ◐ | toast / 表单错误用 `role="status"` / `alert`;**未**在真实读屏里验证播报时机 |
72
72
 
73
73
  **统计:✅ 27 条 · ◐ 7 条 · ✗ 0 条 · — 12 条。**
@@ -80,32 +80,41 @@
80
80
 
81
81
  | 手段 | 覆盖 | 最近结果 |
82
82
  |---|---|---|
83
- | `axe-core`(`a11y-scan.js`) | 38 页(35 组件 demo + 文档站) | **0 类违规**,991 条规则通过 |
83
+ | `axe-core`(`a11y-scan.js`) | 39 页(37 组件 demo + 2 个非组件页) | **0 类违规**,991 条规则通过 |
84
84
  | 反向控制(注入 5 类典型违规) | 证明上一条不是"没查" | 5 类全部抓到 |
85
- | `a11y-ax.js` 可访问性树 | **全部 `demo.html`(现 38 个)**;清单与磁盘不一致即红 | 0 个无名控件;38 份朗读序列快照在 `10-review/a11y-tree/` |
85
+ | `a11y-ax.js` 可访问性树 | **全部 `demo.html`(现 40 个)**;清单与磁盘不一致即红 | 0 个无名控件;40 份朗读序列快照在 `10-review/a11y-tree/` |
86
86
  | `contrast.py` / `dark-contrast.js` | 亮 / 暗两套,逐对实算 | 正文 ≥ 4.5:1 |
87
- | `zoom-reflow.js` | 38 页 × 3 条 AA 硬项 | 320px 不横滚 · 200% 不裁字 · 焦点不被遮挡 |
87
+ | `zoom-reflow.js` | 40 页 × 3 条 AA 硬项 | 320px 不横滚 · 200% 不裁字 · 焦点不被遮挡 |
88
88
  | `focus-ring-check` / `focus-return-check` / `keyboard-nav` | 焦点可见、焦点归还、键盘路径 | 全绿 |
89
89
 
90
- ### 人工(**尚未执行** —— 这是本声明最大的空白)
90
+ ### 人工(**已执行一轮,仍缺人耳与第二平台**)
91
91
 
92
92
  自动化的天花板必须说清楚:**axe 只能覆盖约 30–40% 的 WCAG 问题**,而且它听不见。
93
- 以下三件事本库**没有**任何一条真实记录:
94
93
 
95
- 1. **真机读屏**(NVDA / VoiceOver / TalkBack)——一条录音都没有。
96
- 2. **真实键盘 + 读屏组合下的阅读顺序**是否符合预期(`a11y-ax` 只证明"顺序稳定",
97
- 不证明"顺序正确"——后者需要人判断)。
98
- 3. **9 处「待人工」结论**(例如图表的替代文本是否真的描述了数据)。
94
+ 2026-10-30 用 NVDA 2026.3 + Edge(Windows 10 22H2)跑了一轮:把 NVDA 的朗读日志写到
95
+ 文件,逐条取它交给语音合成器的原文。九页结果见
96
+ [无障碍人工测试协议](A11Y-MANUAL-PROTOCOL.md) 第三节。
99
97
 
100
- 为了把这块从"没有"变成"可执行",协议写在
101
- [无障碍人工测试协议](A11Y-MANUAL-PROTOCOL.md):两条路径、约 20 分钟、结果直接填表。
102
- **在你或志愿者跑完之前,本声明只代表"自动检查覆盖的部分是干净的"。**
98
+ **它证明的是"NVDA 念了什么",不等于"人听着顺不顺"。** 仍然缺:
99
+
100
+ 1. **人耳旁听** —— 证据是日志文本;语气、打断、重复播报这类问题不在其中。
101
+ 2. **macOS VoiceOver / Android TalkBack** —— 一条记录都没有。
102
+ 3. **标题层级(`H` 键逐级跳)** —— 本轮没做,协议表里写着"未执行"。
103
+ 4. **9 处「待人工」结论**(例如图表的替代文本是否真的描述了数据)。
104
+
105
+ 这一轮还确认并修掉了一条真缺陷:`table` 的列头按钮会把排序符号 `↕` 念出来
106
+ (来源是 `table.css` 的 `::after` 伪元素文本,Chromium 把它算进了可访问名)。
107
+ 自动检查抓不到它 —— 可访问名"非空"就算过。
108
+ 修法是把字形换成 `mask` 图像(`content` 留空),视觉与三态形状不变;
109
+ 改完用 NVDA 复听,表头只剩「公司 · 按钮」。详见协议页第三节。
103
110
 
104
111
  ---
105
112
 
106
113
  ## 三 · 已知缺口(按影响排序)
107
114
 
108
- 1. **没有真机读屏记录**(同上)。这是与 Carbon / Spectrum 那类一致性报告差距最大的一处。
115
+ 1. **没有人耳旁听与第二平台记录**:2026-10-30 有一轮 NVDA 脚本实录(见协议页),
116
+ 但 VoiceOver / TalkBack 仍为空白,语气与播报时机也没人判断过。
117
+ 这是与 Carbon / Spectrum 那类一致性报告差距最大的一处。
109
118
  2. **没有 VPAT / ACR 正式报告**。采购流程要的那种文档本库没有出;
110
119
  上面那张 SC 表可以当它的替代品,但它不是法律意义上的 ACR。
111
120
  3. **强制色板(forced-colors)只覆盖 6 个 CSS**,Windows 高对比度模式未逐组件验证。
@@ -194,6 +194,13 @@
194
194
  - **方向必须有非颜色的载体**:`delta` 用 `+` / `−`(U+2212)符号,
195
195
  且符号必须真的进可访问性树(CSS `::before` 生成的字符不在元素 name 里,
196
196
  但会作为独立 StaticText 出现 —— 换空串/背景图/`visibility:hidden` 就会没)。
197
+ - **容器不许再加一套方向类**。表格里只有会计正负(`num--pos` / `num--neg`),
198
+ 市场涨跌一律嵌 `<span class="delta delta--up">`
199
+ —— 否则 `table` 会有四组颜色类,调用方每列都要记"这列用哪组"。
200
+ 表格只管对齐(`td.num`),不管判色。
201
+ - **零值不带符号**:符号必须跟**打印出来的数字**同号。
202
+ `0` 与「四舍五入到 0」(如精度 1 下的 `0.0004`)都不加 `+` / `−`
203
+ —— 「+0.0%」等于把持平念成上涨。会计列与市场列是同一条规则。
197
204
 
198
205
  ## 十一 · 每条规则由什么挡住
199
206
 
@@ -213,6 +220,7 @@
213
220
  | 触控目标 44px | `a11y.py`(静态) + 行为脚本(运行时) |
214
221
  | 页面骨架只有一份(改令牌能牵动全部页面) | `skeleton-lever-check.js` |
215
222
  | 涨跌方向色对、口径可切、符号进可访问性树 | `delta-check.js` |
223
+ | 表格里的方向语义(涨跌嵌 `delta`、零值不带符号、口径只翻市场列) | `table-check.js` ⑫⑬⑭ |
216
224
  | 指标缺数是显式状态、数字等宽对齐 | `metric-check.js` |
217
225
  | 文档里的数字与实际一致(组件数/门禁数/精简档令牌数) | `doc-facts-gate.py` |
218
226
 
package/index.html CHANGED
@@ -94,7 +94,7 @@ li a:active { background: var(--surface-sunken); }
94
94
  </p>
95
95
  </main>
96
96
  <footer class="wrap">
97
- <p class="note">0.12.0 · 零第三方运行时依赖</p>
97
+ <p class="note">0.13.2 · 零第三方运行时依赖</p>
98
98
  </footer>
99
99
  </body>
100
100
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcss",
3
- "version": "0.12.0",
3
+ "version": "0.13.2",
4
4
  "description": "移动 Web 组件库:CSS + 可选行为脚本,零第三方运行时依赖。样式用 bundler 引入,交互行为用 script 标签引入。",
5
5
  "keywords": [
6
6
  "css",