agentcss 0.11.12 → 0.11.14

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.
@@ -241,6 +241,15 @@
241
241
  两栏的断言必须**互斥**且各自成立 —— 能同时成立的两条断言,
242
242
  什么也证明不了。这两个属性不影响渲染,但缺了它们,
243
243
  这块对照就退化成"看着像错的",`demo-claim` 门禁会报红。
244
+
245
+ 反例栏里还得有**标本**:真渲染出来的控件或内容。只有一段
246
+ `<p class="small">` 描述"别这么做",不算反例 —— 读者看不见那个"错"。
247
+ 摆不出来的(运行时行为 / 读屏输出 / 这页没有图表实例)写在块上:
248
+ `<div class="cmp__pane cmp__bad" data-prose="为什么摆不出来">`
249
+ 单栏的 `box bad` 块没有成对结构,**在块上**写清为什么:
250
+ `<div class="box bad" data-claim-skip="为什么写不出互斥断言">`
251
+ 合法的两种典型:两栏在视觉上必然相同(差别只在无障碍树里)、
252
+ 差别是对比度(那件事由 `contrast.py` 单独测)。理由会被逐条打印。
244
253
  ========================================================================= */
245
254
  .page .cmp {
246
255
  display: grid;
@@ -63,7 +63,7 @@
63
63
  </p>
64
64
 
65
65
  <h2 class="rule-top">反例</h2>
66
- <div class="box bad">
66
+ <div class="box bad" data-claim-skip="两栏画出来的都是同一批色块,尺寸与位置完全相同 —— 差别是暗色下 2.72:1 与 6.39:1 的对比度,而 demo-claim 没有对比度指标(那件事由 contrast.py 单独测,本页正文引的就是它的数)">
67
67
  <h3>反例:饱和底 + 白字</h3>
68
68
  <div class="row">
69
69
  <span class="badge badge--solid-success">已披露</span>
@@ -79,13 +79,21 @@
79
79
  </p>
80
80
  </div>
81
81
 
82
- <div class="box bad">
82
+ <div class="box bad" data-claim-skip="可点的正例本该是一个 a / button,但本库 badge 门禁要求所有可点元素命中区 ≥44px,而徽标本体只有 24px 高 —— 摆一个过得了门的正例,要么改组件,要么给演示塞内联样式把高度撑起来(那是拿样式骗门禁)。⇒ 配不成对的原因不是量不了,是组件本身还没解决这个冲突,见 CHANGELOG「已知冲突」">
83
83
  <h3>反例:用 badge 装可点的东西</h3>
84
+ <div class="row">
85
+ <div class="badge badge--info">待复核</div>
86
+ <span class="small">← 它和上面的 badge 长得一模一样,但 Tab 一遍就会发现焦点跳过了它</span>
87
+ </div>
84
88
  <p class="small">
85
89
  <code>&lt;div class="badge badge--info" onclick="..."&gt;待复核&lt;/div&gt;</code>
86
90
  —— div 加 onclick 对读屏是<strong>「没有角色的按钮」</strong>:
87
91
  读不出来它是能点的,键盘也 Tab 不到。
88
92
  要能点就用 <code>&lt;button&gt;</code> 或 <code>&lt;a&gt;</code>,样式照抄 <code>.badge</code>。
93
+ <br><br>
94
+ ⚠️ <strong>但这句建议目前有个坑</strong>:换成 button/a 之后,
95
+ 本库 badge 门禁会判「命中区 54×24 &lt; 44px」—— 徽标本体的高度不够。
96
+ <strong>这个问题还没解决</strong>,不是演示写错了。
89
97
  </p>
90
98
  </div>
91
99
 
@@ -186,22 +186,39 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
186
186
  </p>
187
187
  </div>
188
188
 
189
- <h2 class="rule-bottom">反例:用 display:none 隐藏原生控件</h2>
190
- <div class="box bad">
191
- <h3>反例:把原生勾选框 <code>display:none</code> 藏掉</h3>
192
- <div class="choice choice--checkbox">
193
- <input class="choice__input" type="checkbox" style="display:none" id="bad1">
194
- <label class="choice__label" for="bad1">
195
- <span class="choice__mark"></span>
196
- <span>我看起来正常,但 Tab 到不了这里</span>
197
- </label>
189
+ <h2 class="rule-bottom">对照:把原生勾选框藏起来的两种写法</h2>
190
+ <div class="cmp">
191
+ <div class="cmp__pane cmp__bad" data-bad="input|focus|=0">
192
+ <h3>❌ display:none:看不见,也 Tab 不到</h3>
193
+ <div class="choice choice--checkbox">
194
+ <input class="choice__input" type="checkbox" style="display:none" id="bad1">
195
+ <label class="choice__label" for="bad1">
196
+ <span class="choice__mark"></span>
197
+ <span>我看起来正常,但 Tab 到不了这里</span>
198
+ </label>
199
+ </div>
200
+ <p class="cmp__why">
201
+ <strong>按一次 Tab 就知道:焦点直接跳过它。</strong>
202
+ <code>display:none</code> 把这个 input 从 tab 顺序里删掉了,
203
+ 键盘用户彻底到不了 —— 而鼠标点仍然有效,所以这个 bug 用鼠标永远测不出来。
204
+ </p>
205
+ </div>
206
+
207
+ <div class="cmp__pane cmp__good" data-good="input|focus|=1">
208
+ <h3>✅ opacity:0 + 绝对定位:看不见,但还在 Tab 顺序里</h3>
209
+ <div class="choice choice--checkbox">
210
+ <input class="choice__input" type="checkbox" id="good1">
211
+ <label class="choice__label" for="good1">
212
+ <span class="choice__mark"></span>
213
+ <span>同一行字,看起来一模一样,但 Tab 停得住</span>
214
+ </label>
215
+ </div>
216
+ <p class="cmp__why">
217
+ 本库的 input 是 <code>opacity:0</code> 铺满整个 label:
218
+ 你看得见的是 <code>.choice__mark</code>,焦点环也画在它身上
219
+ (<code>:focus-visible + .choice__mark</code>)。
220
+ </p>
198
221
  </div>
199
- <p class="small" style="margin-top:var(--sp-3)">
200
- <strong>用 Tab 试试——焦点会直接跳过它。</strong>
201
- <code>display:none</code> 把它从 tab 顺序里移除了,键盘用户彻底到不了这里。
202
- 鼠标点击还正常,所以这种 bug 用鼠标测不出来。<br><br>
203
- 本库的 input 用的是 <code>opacity:0</code> + 绝对定位:<strong>看不见,但仍在 tab 顺序里</strong>。
204
- </p>
205
222
  </div>
206
223
 
207
224
  <h2 class="rule-bottom">焦点环必须画在看得见的地方</h2>
@@ -60,8 +60,13 @@
60
60
  </div>
61
61
 
62
62
  <h2>反例</h2>
63
- <div class="box bad">
63
+ <div class="box bad" data-claim-skip="两栏都会画出一条 1px 的线,高度与宽度完全相同 —— 差别是 1.26:1 与 3.42:1 的对比度,而 demo-claim 没有对比度指标(那件事由 contrast.py / component-contrast.py 单独测)">
64
64
  <h3>反例:语义分隔用装饰色</h3>
65
+ <div style="max-width:32em">
66
+ <p class="small" style="margin:0">基本信息</p>
67
+ <hr class="separator separator--tight">
68
+ <p class="small" style="margin:0">高级设置</p>
69
+ </div>
65
70
  <p class="small">
66
71
  分隔"基本信息"与"高级设置"——这是<strong>两类不同的东西</strong>,
67
72
  但如果用 <code>--border-decor</code>(1.26:1),
@@ -71,13 +76,33 @@
71
76
  </p>
72
77
  </div>
73
78
 
74
- <div class="box bad">
75
- <h3>反例:用 div + border-bottom 代替 hr</h3>
76
- <p class="small">
77
- <code>&lt;div class="line"&gt;</code> 对读屏是<strong>什么都没有</strong>。
78
- 语义分隔必须用 <code>&lt;hr&gt;</code>(或 <code>role="separator"</code>),
79
- 装饰性的才可以用纯 div。
80
- </p>
79
+ <div class="cmp">
80
+ <div class="cmp__pane cmp__bad" data-bad="hr|count|=0">
81
+ <h3>❌ div + border-bottom</h3>
82
+ <div>
83
+ <p class="small" style="margin:0">基本信息</p>
84
+ <div style="border-top:1px solid var(--border-decor); margin:var(--sp-4) 0"></div>
85
+ <p class="small" style="margin:0">高级设置</p>
86
+ </div>
87
+ <p class="cmp__why">
88
+ 线画得一模一样 —— 连颜色都对齐了。但读屏走到这里是
89
+ <strong>两段独立的文字,中间什么都没有</strong>:
90
+ 它不知道这两段被分成了一组。
91
+ </p>
92
+ </div>
93
+
94
+ <div class="cmp__pane cmp__good" data-good="hr|count|=1">
95
+ <h3>✅ hr(或 role="separator")</h3>
96
+ <div>
97
+ <p class="small" style="margin:0">基本信息</p>
98
+ <hr class="separator separator--tight">
99
+ <p class="small" style="margin:0">高级设置</p>
100
+ </div>
101
+ <p class="cmp__why">
102
+ 同一条线,读屏会念「分隔符」。<strong>0 个 <code>&lt;hr&gt;</code> 与 1 个</strong>,
103
+ 就是"看得见"和"念得出"的全部差别。
104
+ </p>
105
+ </div>
81
106
  </div>
82
107
  </main>
83
108
  </body>
@@ -82,18 +82,42 @@
82
82
  </div>
83
83
 
84
84
  <h2 class="rule-top">反例</h2>
85
- <div class="box bad">
86
- <h3>反例:用 div + role="switch",自己管所有状态</h3>
87
- <p class="small">
88
- 常见写法是 <code>&lt;div role="switch" tabindex="0" onclick="..."&gt;</code>,
89
- 然后手写 <code>aria-checked</code> 切换。这需要自己处理:
90
- Space 键、焦点样式、状态播报。<strong>三件事漏一件就坏</strong>,
91
- 而且测试工具(axe)只看得见 DOM,看不见你的 JS 有没有绑对。
92
- </p>
85
+ <div class="cmp">
86
+ <div class="cmp__pane cmp__bad" data-bad="input|count|=0">
87
+ <h3>❌ div + role="switch",自己管所有状态</h3>
88
+ <div class="switch" role="switch" aria-checked="false" tabindex="0">
89
+ <span class="switch__track"><span class="switch__thumb"></span></span>
90
+ <span class="switch__label">开启动价推送</span>
91
+ </div>
92
+ <p class="cmp__why">
93
+ <strong>看起来和下面那个一模一样 —— 这正是坑所在。</strong>
94
+ 常见写法是 <code>&lt;div role="switch" tabindex="0" onclick="…"&gt;</code>
95
+ 再手写 <code>aria-checked</code> 切换,于是 Space 键、焦点样式、状态播报
96
+ 三件事都得自己管,<strong>漏一件就坏</strong>。
97
+ 而 axe 只看得见 DOM,看不见你的 JS 有没有绑对。
98
+ </p>
99
+ </div>
100
+
101
+ <div class="cmp__pane cmp__good" data-good="input|count|=1">
102
+ <h3>✅ 原生 checkbox + role="switch"</h3>
103
+ <span class="switch">
104
+ <input class="switch__input" type="checkbox" role="switch" id="s6">
105
+ <span class="switch__track"><span class="switch__thumb"></span></span>
106
+ <label class="switch__label" for="s6">开启动价推送</label>
107
+ </span>
108
+ <p class="cmp__why">
109
+ 同一个外观,但键盘响应、焦点环、状态播报<strong>全部由浏览器给</strong> ——
110
+ 少写的那部分不是"省事",是"少了一个会忘的地方"。
111
+ </p>
112
+ </div>
93
113
  </div>
94
114
 
95
- <div class="box bad">
115
+ <div class="box bad" data-claim-skip="两栏在视觉上必须完全相同 —— 那正是这一块要教的:差别只在无障碍树里(sr-only 的 label 仍在树中,display:none 的不在),可见部分都只有滑块那么大,写不出互斥断言。证据在 10-review/a11y-tree/switch.txt">
96
116
  <h3>反例:给 label 加 display:none</h3>
117
+ <div class="switch">
118
+ <span class="switch__track"><span class="switch__thumb"></span></span>
119
+ <label class="switch__label" style="display:none">自动续订</label>
120
+ </div>
97
121
  <p class="small">
98
122
  为了"只留滑块"而把 <code>&lt;label&gt;</code> 隐藏掉,
99
123
  开关就<strong>失去了可点击的文字区域</strong>,也失去了语义名字 ——
@@ -199,23 +199,76 @@ p { margin: 0 0 var(--sp-3); }
199
199
  </p>
200
200
  </div>
201
201
 
202
- <h2 class="rule-top">反例</h2>
203
- <div class="box bad">
204
- <h3>反例:用 div 当 trigger</h3>
205
- <p class="small">
206
- <code>&lt;div class="accordion__trigger"&gt;</code> 键盘上 Tab 不到、
207
- 也不响应 Enter/Space。<strong>APG 要求 header 里是
208
- <code>role="button"</code> 的元素</strong>,用原生
209
- <code>&lt;button&gt;</code> 最省事也最可靠。
210
- </p>
202
+ <h2 class="rule-top">对照:两处最容易写错的地方</h2>
203
+ <div class="cmp">
204
+ <div class="cmp__pane cmp__bad" data-bad="button|count|=0">
205
+ <h3>❌ 用 div 当 trigger</h3>
206
+ <div class="accordion">
207
+ <div class="accordion__item">
208
+ <div class="accordion__heading">
209
+ <div class="accordion__trigger">
210
+ <span class="accordion__label">数据源与口径</span>
211
+ <span class="accordion__marker" aria-hidden="true"></span>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+ <p class="cmp__why">
217
+ <code>&lt;div class="accordion__trigger"&gt;</code> 键盘上 Tab 不到、
218
+ 也不响应 Enter/Space —— 它<strong>一个 button 都不是</strong>。
219
+ APG 要求 header 里是 <code>role="button"</code> 的元素,
220
+ 用原生 <code>&lt;button&gt;</code> 最省事也最可靠。
221
+ </p>
222
+ </div>
223
+
224
+ <div class="cmp__pane cmp__good" data-good="button|count|=1">
225
+ <h3>✅ heading 包住原生 button</h3>
226
+ <div class="accordion">
227
+ <div class="accordion__item">
228
+ <h3 class="accordion__heading">
229
+ <button class="accordion__trigger" aria-expanded="false">
230
+ <span class="accordion__label">数据源与口径</span>
231
+ <span class="accordion__marker" aria-hidden="true"></span>
232
+ </button>
233
+ </h3>
234
+ </div>
235
+ </div>
236
+ <p class="cmp__why">
237
+ 同一个外观,一个按钮元素。Enter / Space 由浏览器负责 ——
238
+ 本组件的 JS <strong>一行 keydown 都没写</strong>,写了反而会双触发。
239
+ </p>
240
+ </div>
211
241
  </div>
212
- <div class="box bad">
213
- <h3>反例:折叠时只用 max-height: 0</h3>
214
- <p class="small">
215
- 内容在无障碍树里<strong>仍然存在</strong> —— 读屏会念出「折叠」区域里的全部文字,
216
- 键盘也能 Tab 进去。<strong>必须用 <code>hidden</code> 或
217
- <code>display:none</code></strong>,让它真正离开无障碍树。
218
- </p>
242
+
243
+ <div class="cmp">
244
+ <div class="cmp__pane cmp__bad" data-bad="a|focus|=1">
245
+ <h3>❌ 折叠时只用 max-height: 0</h3>
246
+ <div id="acc-bad" style="max-height:0; overflow:hidden">
247
+ <p class="small" style="margin:0 0 var(--sp-2)">这一整段文字,读屏此刻仍然念得出来。</p>
248
+ <a href="#acc-bad">里面的这个链接,Tab 也仍然进得去</a>
249
+ </div>
250
+ <p class="cmp__why">
251
+ <strong>这一栏看起来和右边一样空 —— 差别不在视觉上,在第 1 次按 Tab 之后。</strong>
252
+ 这块的高度被压成了 <code>0</code>,你<strong>看不见它</strong>,
253
+ 但按一下 Tab,焦点会停在里面的链接上;读屏也会把那句"仍然念得出来"念完。
254
+ 内容在无障碍树里<strong>仍然活着</strong>,只是视觉上消失。
255
+ </p>
256
+ </div>
257
+
258
+ <div class="cmp__pane cmp__good" data-good="a|focus|=0">
259
+ <h3>✅ hidden / display:none</h3>
260
+ <p class="small" style="margin:0 0 var(--sp-2)">
261
+ (这一栏故意是空的:内容被 <code>hidden</code> 之后连占位都不留,这正是重点。)
262
+ </p>
263
+ <div hidden>
264
+ <p class="small" style="margin:0 0 var(--sp-2)">这一整段文字,读屏此刻仍然念得出来。</p>
265
+ <a href="#acc-good">里面的这个链接,Tab 也仍然进得去</a>
266
+ </div>
267
+ <p class="cmp__why">
268
+ 同样看不见,但内容<strong>真的离开了无障碍树</strong>:
269
+ Tab 到不了、读屏也不念。折叠面板收起时必须这样。
270
+ </p>
271
+ </div>
219
272
  </div>
220
273
  </main>
221
274
  <script src="accordion.js"></script>
@@ -55,7 +55,7 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
55
55
  </p>
56
56
 
57
57
  <div class="grid2" style="margin-top:var(--sp-4)">
58
- <div class="box bad">
58
+ <div class="box bad" data-claim-skip="差别是「什么时候报错」—— 时序。两个输入框在任何一个静态时刻的几何与文本都相同,只有交互过程中才分得出来,而 demo-claim 量的是静态 DOM">
59
59
  <h3>反例:oninput 立即报错</h3>
60
60
  <div class="field">
61
61
  <label class="field__label" for="naive">金额</label>
@@ -126,11 +126,11 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
126
126
 
127
127
  <h2 class="rule-bottom">三、错误文案:给建议,不给判断</h2>
128
128
  <div class="grid2">
129
- <div class="box bad">
129
+ <div class="box bad" data-claim-skip="两栏都是三条错误文案(uniq 都是 3、字号行高全同),差别只在措辞本身 —— 机器判不了哪句更清楚,只能人读">
130
130
  <h3>反例</h3>
131
- <p style="font-size:var(--fs-sm); color:var(--danger); margin:0 0 var(--sp-2)">格式错误</p>
132
- <p style="font-size:var(--fs-sm); margin:0 0 var(--sp-2)">非法输入</p>
133
- <p style="font-size:var(--fs-sm); margin:0">值不合法</p>
131
+ <p class="naive-error">格式错误</p>
132
+ <p class="naive-error">非法输入</p>
133
+ <p class="naive-error" style="margin-bottom:0">值不合法</p>
134
134
  <p class="small" style="margin-top:var(--sp-3)">
135
135
  WCAG 3.3.3 要求给修正建议。这三句用户读完还是不知道该改成什么。
136
136
  </p>
@@ -92,7 +92,7 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
92
92
  <strong>点遮罩不关闭</strong>。误点遮罩的代价太大。
93
93
  </p>
94
94
  </div>
95
- <div class="box bad">
95
+ <div class="box bad" data-prose="焦点落在哪颗按钮上、点遮罩关不关,都是运行时行为:要摆出「错的那一版」得真的渲染一个会抢焦点的对话框,而演示页里放一个真 modal 会盖住整页">
96
96
  <h3 style="margin:0 0 var(--sp-3); font-size:var(--fs-sm); font-weight:500">常见做法</h3>
97
97
  <p class="small" style="margin:0">
98
98
  焦点默认落在"删除"上,用户看清标题之前就按了 Enter。<br><br>
@@ -111,9 +111,15 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
111
111
 
112
112
  <h2 class="rule-bottom">用错比做丑严重得多</h2>
113
113
  <div class="two">
114
- <div class="box bad aside-col">
114
+ <div class="box bad aside-col" data-claim-skip="左栏是一个 toast 标本,右栏是一条判断规则(没有人能数的话)—— 两栏根本不是同一种东西,没有可比的量;而且「该 toast 还是 dialog」取决于要不要用户先确认,没有阈值可划">
115
115
  <h3 style="margin:0 0 var(--sp-3); font-size:var(--fs-sm); font-weight:500; color:var(--danger)">
116
116
  ❌ 错用</h3>
117
+ <div class="toast toast--error" role="status" style="margin:0 0 var(--sp-3)">
118
+ <div class="toast__body">
119
+ <div class="toast__title">文件已删除</div>
120
+ <div class="toast__desc">3 秒后自动消失,没有撤销</div>
121
+ </div>
122
+ </div>
117
123
  <p class="small" style="margin:0 0 var(--sp-2)">
118
124
  <strong>把"确认删除"做成 toast</strong><br>
119
125
  用户可能根本没看见就把文件删了,而且没法撤销。
@@ -102,23 +102,82 @@
102
102
  </p>
103
103
  </div>
104
104
 
105
- <h2 class="rule-top">反例</h2>
106
- <div class="box bad">
107
- <h3>反例:用 div 当 tab</h3>
108
- <p class="small">
109
- <code>&lt;div role="tab"&gt;</code> 键盘上根本<strong>Tab 不到</strong>
110
- (除非手写 <code>tabindex</code>),也不会响应 Enter/Space。
111
- APG 明确要求 tab 是 <code>&lt;button role="tab"&gt;</code> ——
112
- 用原生 button,键盘行为免费且必然正确。
113
- </p>
105
+ <h2 class="rule-top">对照:两处最容易写错的地方</h2>
106
+ <div class="cmp">
107
+ <div class="cmp__pane cmp__bad" data-bad="[role=tab]|focus|=0">
108
+ <h3>❌ 用 div 当 tab</h3>
109
+ <div class="tabs">
110
+ <div class="tabs__list" role="tablist" aria-label="错例:div 当 tab">
111
+ <div class="tabs__tab" role="tab" aria-selected="true">基本信息</div>
112
+ <div class="tabs__tab" role="tab" aria-selected="false">高级设置</div>
113
+ </div>
114
+ </div>
115
+ <p class="cmp__why">
116
+ 这两个 tab <strong>看起来完全正常</strong>,但
117
+ <code>&lt;div role="tab"&gt;</code> 根本 Tab 不到(除非手写
118
+ <code>tabindex</code>),也不响应 Enter/Space。
119
+ APG 要求 tab 是 <code>&lt;button role="tab"&gt;</code>。
120
+ </p>
121
+ </div>
122
+
123
+ <div class="cmp__pane cmp__good" data-good="[role=tab]|focus|=2">
124
+ <h3>✅ button role="tab"</h3>
125
+ <div class="tabs">
126
+ <div class="tabs__list" role="tablist" aria-label="正例:button 当 tab">
127
+ <button class="tabs__tab" role="tab" aria-selected="true">基本信息</button>
128
+ <button class="tabs__tab" role="tab" aria-selected="false">高级设置</button>
129
+ </div>
130
+ </div>
131
+ <p class="cmp__why">
132
+ 同一个外观,键盘行为免费且必然正确。
133
+ (这里只演示"能不能聚焦",roving 见下面那块。)
134
+ </p>
135
+ </div>
114
136
  </div>
115
- <div class="box bad">
116
- <h3>反例:每个 tab 都写 tabindex="0"</h3>
117
- <p class="small">
118
- 看起来"都能 Tab 到",但一排 5 个 tab 就要按 <strong>5 次 Tab</strong>
119
- 才能进内容区。Tab 键应该是<strong>进出</strong>控件的,
120
- 组内移动是方向键的活。
121
- </p>
137
+
138
+ <div class="cmp">
139
+ <div class="cmp__pane cmp__bad" data-bad="[role=tab]|focus|=3">
140
+ <h3>❌ 每个 tab 都写 tabindex="0"</h3>
141
+ <div class="tabs">
142
+ <div class="tabs__list" role="tablist" aria-label="错例:三个 tab 全是 0">
143
+ <button class="tabs__tab" role="tab" tabindex="0" aria-selected="true">基本信息</button>
144
+ <button class="tabs__tab" role="tab" tabindex="0" aria-selected="false">高级设置</button>
145
+ <button class="tabs__tab" role="tab" tabindex="0" aria-selected="false">日志</button>
146
+ </div>
147
+ </div>
148
+ <p class="cmp__why">
149
+ 看起来"都能 Tab 到",但一排 3 个 tab 就要按 <strong>3 次 Tab</strong>
150
+ 才能进内容区。Tab 键应该是<strong>进出</strong>控件的,
151
+ 组内移动是方向键的活。
152
+ </p>
153
+ </div>
154
+
155
+ <div class="cmp__pane cmp__good" data-good="[role=tab]|focus|=1">
156
+ <h3>✅ roving tabindex:整组只有一个 0</h3>
157
+ <div class="tabs" data-tabs>
158
+ <div class="tabs__list" role="tablist" aria-orientation="horizontal" aria-label="正例:roving">
159
+ <button class="tabs__tab" role="tab" id="rt1" aria-controls="rp1"
160
+ aria-selected="true">基本信息</button>
161
+ <button class="tabs__tab" role="tab" id="rt2" aria-controls="rp2"
162
+ aria-selected="false">高级设置</button>
163
+ <button class="tabs__tab" role="tab" id="rt3" aria-controls="rp3"
164
+ aria-selected="false">日志</button>
165
+ </div>
166
+ <div class="tabs__panel" role="tabpanel" id="rp1" aria-labelledby="rt1">
167
+ <p>一次 Tab 就进来,方向键在组内移动。</p>
168
+ </div>
169
+ <div class="tabs__panel" role="tabpanel" id="rp2" aria-labelledby="rt2" hidden>
170
+ <p>方向键到这里。</p>
171
+ </div>
172
+ <div class="tabs__panel" role="tabpanel" id="rp3" aria-labelledby="rt3" hidden>
173
+ <p>方向键到这里。</p>
174
+ </div>
175
+ </div>
176
+ <p class="cmp__why">
177
+ 那个 <code>0</code> 跟着选中项走 —— 由 <code>roving.js</code> 在切换时搬,
178
+ 不是初始化时设一次。只设一次是最常见的 bug。
179
+ </p>
180
+ </div>
122
181
  </div>
123
182
  </main>
124
183
  <script src="tabs.js"></script>
@@ -147,7 +147,7 @@ td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabu
147
147
  </p>
148
148
 
149
149
  <h2 class="rule-top">4 · 明确禁止(这些是"看起来专业但会骗人"的)</h2>
150
- <div class="box bad">
150
+ <div class="box bad" data-claim-skip="这一块是「不要做」清单本身(一张表),不是某个组件的错写法 —— 要给它配正例栏,得先真的渲染一张 3D 图与一张 2D 图做对照,而本页没有图表实例可摆;表格尺寸量出来的是排版,不是观点">
151
151
  <table>
152
152
  <tr><th>不要做</th><th>为什么</th></tr>
153
153
  <tr><td class="no">3D 图表</td><td>透视扭曲会<b>误导读数</b>(Tufte 的核心结论)</td></tr>
@@ -107,7 +107,7 @@
107
107
  </div>
108
108
 
109
109
  <h2 class="rule-top">🔴 无障碍:它没有可读的文本等价物</h2>
110
- <div class="box bad">
110
+ <div class="box bad" data-prose="这一块说的是原因(为什么刻意不加 role=img),反例是读屏念出来的那句话,不是一个看得见的元素 —— 图形本身在下面那块里已经摆出来了">
111
111
  <p>
112
112
  纯 SVG 路径对读屏<strong>没有意义</strong> ——
113
113
  读屏只会说"图像",不会说"最近上涨 2.3%"。
@@ -123,7 +123,7 @@
123
123
  </div>
124
124
 
125
125
  <h2 class="rule-top">反例</h2>
126
- <div class="box bad">
126
+ <div class="box bad" data-claim-skip="两栏画的是同一条走势线,差别只在旁边有没有一个数字 —— 而「该不该配数字」本页上文明写取决于数值重不重要(纯装饰小图可以不配),没有一条能划的界线;断言只能去数一个我自己编出来的选择器,那证明的是我编的标记,不是这件事">
127
127
  <h3>反例:只放 sparkline,没有数字</h3>
128
128
  <p class="small">
129
129
  视觉上"很有信息",但读屏用户<strong>什么都不知道</strong> ——
@@ -131,7 +131,7 @@
131
131
  </p>
132
132
  <span class="sparkline" data-spark="12,18,15,24,22,31,28" data-spark-height="40"></span>
133
133
  </div>
134
- <div class="box bad">
134
+ <div class="box bad" data-prose="「动画有没有被 reduced-motion 关掉」是媒体查询在运行时决定的行为,同一个 sparkline 在两种设置下长得一模一样 —— 静态块摆不出「错的那一版」">
135
135
  <h3>反例:动画不管 reduced-motion</h3>
136
136
  <p class="small">
137
137
  前庭功能障碍者会因动画眩晕(WCAG 2.3.3)。
package/CHANGELOG.md CHANGED
@@ -16,6 +16,189 @@
16
16
 
17
17
  (无)
18
18
 
19
+ ## [0.11.14] - 2026-10-29
20
+
21
+ **「键盘到不了」这句话,现在能量了。**
22
+
23
+ 0.11.13 结束时,10 个页面仍是单栏反例。其中 5 页的登记里写着一句诚实话:
24
+ 「两块都可量,但这一页仍是单栏结构,升级成 cmp 要重排整页 —— 本轮没做,
25
+ 已知欠账」。这一版把那 5 页还掉了,并且在做的时候发现:**那句话有一半不准**
26
+ —— 那些"可量"的差别是「按 Tab 能不能停上去」,而当时的指标表里
27
+ 根本没有能量它的东西。剩下的只有高度、宽度、字号这些代理量,
28
+ 拿来判"键盘到不了"是**间接**的(高度 0 暗示焦点没了,但那只是暗示)。
29
+
30
+ ### 新指标 `focus`
31
+
32
+ `data-bad="input|focus|=0"` —— 数的是**真的能 Tab 到**的元素个数。
33
+ 实现是「`focus()` 成功 **且** `tabIndex >= 0`」,两个信号缺一不可,
34
+ 两种错都实测到了:
35
+
36
+ | 元素 | tabIndex | 聚焦得成 | Tab 到得了 |
37
+ |---|---|---|---|
38
+ | `a[href]` | 0 | ✅ | ✅ |
39
+ | `a`(没有 href) | 0 | ❌ | ❌ ← tabIndex 会撒谎 |
40
+ | `input[disabled]` | 0 | ❌ | ❌ ← tabIndex 会撒谎 |
41
+ | `div[tabindex="-1"]` | -1 | ✅ | ❌ ← 能聚焦 ≠ 能 Tab 到 |
42
+
43
+ 第二条是这一版第一次跑就踩到的:只判 `tabIndex >= 0`,把没有 href 的 `<a>`
44
+ 算成可聚焦(自检当场红了);只判 `focus()`,把 roving tabindex 里
45
+ `-1` 的那几项算成能 Tab 到(tabs 的正例量出 3,应该是 1)。
46
+ 两条都留在自检里,各占一条用例。
47
+
48
+ ### 7 个块从单栏改成成对
49
+
50
+ | 页面 | 反例栏 | 正例栏 | 量的 |
51
+ |---|---|---|---|
52
+ | `choice` | `display:none` 的 input | `opacity:0` + 绝对定位 | `focus` 0 vs 1 |
53
+ | `separator` | `div` + border-top | `<hr>` | `hr` 个数 0 vs 1 |
54
+ | `switch` | `div role="switch"` | 原生 checkbox | `input` 个数 0 vs 1 |
55
+ | `accordion` | `div` 当 trigger | heading 包 button | `button` 个数 0 vs 1 |
56
+ | `accordion` | `max-height:0` | `hidden` | 链接 `focus` 1 vs 0 |
57
+ | `tabs` | `div role="tab"` | `button role="tab"` | `focus` 0 vs 2 |
58
+ | `tabs` | 三个 `tabindex="0"` | roving(只有一个 0) | `focus` 3 vs 1 |
59
+
60
+ ### 已知冲突:可点的徽标过不了自己的命中区门禁
61
+
62
+ `badge` 本来也要改成成对(`div` 装可点 vs `a` 装可点),做到一半被
63
+ `badge` 组件门禁拦住了:
64
+
65
+ ```
66
+ FAIL ③ 命中区全部 ≥44px:A.badge badge--inf 视觉 54×24 命中 54×24
67
+ ```
68
+
69
+ 这不是演示写错 —— **是组件自身的矛盾**:README 说「要能点就用 button/a,
70
+ 样式照抄 `.badge`」,而徽标本体只有 24px 高,换上正确元素之后
71
+ 命中区就不达标(WCAG 2.5.5 要 44px)。也就是说,
72
+ **本库目前没有任何一种写法能同时满足「可点的徽标」和「44px 命中区」**。
73
+
74
+ 这一版**没有**用内联样式把演示撑到 44px 蒙过门禁 —— 那是拿样式骗判据。
75
+ 徽标照旧是单栏,块上写明"配不成对的原因不是量不了,是组件还没解决"。
76
+ 真要修得动 `.badge--clickable` 的垂直内边距(或干脆在文档里禁止可点徽标),
77
+ 那是破坏性视觉变更,单独一版做。
78
+
79
+ ### 判据改成**按块**登记(原来按页)
80
+
81
+ 单栏块的理由从页面注释 `<!-- demo-claim:skip -->` 搬到块上
82
+ `data-claim-skip="理由"`。原因不是审美,是这一版恰好会造出"混合页":
83
+
84
+ ```
85
+ separator:一块改成了 cmp(hr vs div),另一块(对比度)改不了
86
+ switch :一块改成了 cmp(div vs 原生),另一块(label)改不了
87
+ ```
88
+
89
+ 按页判的话,只要加**一个** `cmp` 块,同页剩下的单栏反例就全部"洗白"了 ——
90
+ 门禁不看了,也不需要理由。所以先把这个洞堵上再改内容,否则改完就是假绿。
91
+ 页面级注释现在会**报错**,不忽略:两套机制并存比没有机制更坏。
92
+
93
+ 顺带:写了 `data-prose`(连标本都摆不出来)的块,不再要求写
94
+ `data-claim-skip` —— 一条声明只覆盖一个问题,别让人把同一句话说两遍。
95
+
96
+ ### 覆盖
97
+
98
+ - 对照块 **24 → 31**(30 页),每块两栏互斥且各自成立
99
+ - 单栏块登记 **9 个**(逐块,理由是"视觉上必然相同"或"差别是时序/措辞/对比度")
100
+ - `data-prose` 声明 3 个(不变)
101
+ - `demo-claim` 自检 **32 → 48 条**(29 必须红 + 19 必须绿)
102
+
103
+ 判据**证不了**的那些也没藏着:`switch` 的「label 被藏掉」两栏视觉必然相同,
104
+ 差别只在无障碍树里 —— 那一块登记为"写不出互斥断言",证据指向
105
+ `10-review/a11y-tree/switch.txt`,不是"看着差不多就算了"。
106
+
107
+ ## [0.11.13] - 2026-10-29
108
+
109
+ **先得有东西可看,才谈得上「两栏不一样」。**
110
+
111
+ 0.11.12 让 24 个对照块各写了一条可证伪的数字断言。但那套判据有个前提:
112
+ 块里得有标本。发布后把全部 34 个 demo 页扫了一遍 —— **13 个反例块里
113
+ 一个标本都没有**,只有 `<p class="small">` 在**描述**一个反例。
114
+
115
+ 最刺眼的一处:`02-primitives/separator` 的标题写着
116
+ 「反例:用 div + border-bottom 代替 hr」,块里只有一段话说
117
+ "`<div class="line">` 对读屏什么都没有"—— **那个 div 根本没被渲染**。
118
+ 读者在这一屏里学不到任何看得见的东西。`switch` / `accordion` / `tabs`
119
+ 各两处、`badge`、`sparkline`、`form-validation` 同形。
120
+
121
+ `demo-counterexample-gate.py` 对它们全绿:它判的是"块在、没被藏、
122
+ 块后有标题",一段纯文字全都满足。这是比"反例不成立"更早一层的漏洞 ——
123
+ **没有反例**。
124
+
125
+ ### 新增的两条判据(`05-audit/demo-claim-gate.js`,同一道门禁)
126
+
127
+ 1. **反例块里必须有标本** —— 真渲染出来的控件或内容。判据看标签语义:
128
+ 原生控件/绘图元素算;`h3 p code pre strong` 这些"天生是字"的标签,
129
+ 带**非排版类**时才算(`p.rep-p` 是内容本身,`p.small` 是描述它的文字)。
130
+ 摆不出来的写在块上:`data-prose="为什么摆不出来"`(理由 ≥8 字,
131
+ 逐条打印,可以被反驳)。
132
+ 2. **单栏页必须逐页登记** —— 只有 `box bad`、没有 `cmp` 的页,页内要写
133
+ `<!-- demo-claim:skip 理由 -->`。以前只在末尾打一行"10 页未覆盖",
134
+ 现在每页的理由都打出来,并且**要求区分"量不了"和"可量但没做"**。
135
+ 既有 `cmp` 又写 `skip` 也算矛盾,同样红。
136
+
137
+ 自检从 10 条加打到 32 条(21 条必须红 + 11 条必须绿)。
138
+
139
+ ### 判据本身走过的两条错路(都留在代码注释与 `internal/ERRORS.md` 里)
140
+
141
+ - 第一版按「类名是否定义在组件样式里」判标本 → `sparkline.css` 里
142
+ `.row > .sparkline` 这种后代选择器把排版类收进了类名表(假绿);
143
+ 而 `separator` 那个 `<div class="line">` 的类定义在 **demo 自己的
144
+ `<style>`** 里,真标本被判成没标本(假红)。
145
+ - 第二版按「有没有边框/底色」判 → `tooltip`(`div.tt-field`)、
146
+ `tree`(`div.tree__node`)、`report`(`<p class="rep-p">`)三处
147
+ **纯文字构成的真标本**被冤枉。这一库有大量反例的差别就在字号、
148
+ 字重、行高、颜色上,这些东西不产生边框。
149
+
150
+ ### 补上的 10 处标本(另有 3 块实在摆不出来,写了 `data-prose`)
151
+
152
+ `separator` ×2(真渲染 `<hr>` 与 `div` 分隔线)、`switch` ×2(假开关与
153
+ 丢了 label 的开关)、`accordion` ×2(div trigger、`max-height:0` 里仍可
154
+ Tab 的链接)、`tabs` ×2(div 当 tab、三个 `tabindex="0"`)、`badge` ×1
155
+ (长得一样但 Tab 不到的 div)、`overlay` ×1(一条「文件已删除」的真 toast)。
156
+
157
+ ### 登记的 10 页(不假装解决)
158
+
159
+ - **可量但本轮没做(5 页)**:`choice` / `separator` / `switch` /
160
+ `accordion` / `tabs` —— 差别用 `count` / `h` 就能写成互斥断言
161
+ (`<hr>` 个数 0 vs 1、`input` 个数 0 vs 1…),缺的是把页面重排成 `cmp`。
162
+ **这是欠账,不是边界**,理由里原话写着。
163
+ - **量不了(5 页)**:`badge`(对比度与焦点可达性,`demo-claim` 没有这两个
164
+ 指标)、`form-validation`(时序与措辞)、`overlay`(运行时焦点落点)、
165
+ `echarts-adapter`(没有图表实例可摆)、`sparkline`(读屏输出与
166
+ `reduced-motion`)。
167
+
168
+ ### 契约变化(一处,如实记录)
169
+
170
+ `ai/components.json` 里 `switch` 的 `aria.attrs` 从 `[]` 变成 `["checked"]`。
171
+ 原因是新加的反例真的写了 `aria-checked`(那一块讲的就是"手写这个属性"),
172
+ 而抽取器只取 `aria-` 后面的名字(与 `expanded` 同约定)。原生
173
+ `<input role="switch">` 并不需要它 —— 这条是**反例带进契约**的。
174
+ 选择保留并写在这里,而不是悄悄让它躺在 diff 里(`internal/ERRORS.md` E122)。
175
+
176
+ ### 门禁
177
+
178
+ 条数不变(197 道,本轮没有新增门禁文件,只是同一道门禁多了两条判据)。
179
+
180
+ 两处**门禁自身**的改动:
181
+
182
+ - `a11y-ax` 从 10 页扩到 **11 页**,新增 `accordion`(`button + expanded +
183
+ 受控 region` 这一组关系,与 `tablist` 的 `selected/controls` 不同源;
184
+ 而 `accordion-contract.js` 查的是"属性写得对不对",查不到"读屏实际念出什么")。
185
+ 加进去之后顺带证实:`max-height:0` 那块里被裁掉的 `<a>` **仍在无障碍树里**
186
+ (快照里是 `link "里面的这个链接,Tab 也仍然进得去"`),反例不是嘴上说的。
187
+ - 三页的朗读序列快照更新(`switch` / `tabs` / `overlay`)—— 都是因为新增了
188
+ 真标本:假开关现在会被念成 `switch "开启动价推送"`,假 tablist 会被念成
189
+ `tablist "基本信息 高级设置"`,`「文件已删除」` 那条 toast 会被念成
190
+ `status [live=polite atomic=true]`。三处都逐条核过,不是照单全收。
191
+
192
+ ### 自己踩的坑(`internal/ERRORS.md` E119–E123)
193
+
194
+ - **E119/E120**:判"有没有标本"的两条错路(类名定义表 / 边框底色),
195
+ 两头都有误判实例,都留在代码注释里。
196
+ - **E121**:自检结论把"必须绿"的用例算进了"反向控制"总数 —— 数字对、
197
+ 措辞撒谎。
198
+ - **E122**:反例把 `aria-checked` 带进了对外契约(见上一节)。
199
+ - **E123**:给反例的 `div` 顺手补了 `aria-expanded`,`axe` 报
200
+ `aria-allowed-attr` —— 反例里写"正确答案"会让反例变成半成品正例。
201
+
19
202
  ## [0.11.12] - 2026-10-29
20
203
 
21
204
  **反例从「看得见」走到「量得出」**。0.11.11 把 24 个对照块摆了出来,
package/CONTRIBUTING.md CHANGED
@@ -120,7 +120,24 @@ fix: <一句话说清改了什么、为什么>
120
120
  `nav` 的反例长标题根本没换行(`.toc__link` 自带 `nowrap`,只是被裁掉),
121
121
  `list` 同形,`dropdown` 的反例写了 `display:flex` 绕开了真实的 24px 坑。
122
122
  只判"块在不在"时,这三处全是绿的。
123
- ⇒ **写不出断言的块,说明那个反例量化不了,那就别摆出来。**
123
+ ⇒ **写不出断言的块,先看第 8 条**:要么补标本让它可量,要么写明为什么量不了。
124
+ 8. **反例块里必须有标本;摆不出来就写清理由。**
125
+ `box bad` / `cmp__bad` 里出现"标题 + 一段说明"这种形态,`demo-claim` 会红。
126
+ 这不是假想的洁癖:0.11.12 发布后扫了全部 34 个 demo 页,**13 个反例块
127
+ 一个标本都没有** —— `separator` 标题写着"用 div + border-bottom 代替 hr",
128
+ 块里连那个 div 都没有,只有一段话在描述它。读者学不到任何看得见的东西。
129
+ 摆不出可见标本的(运行时行为、读屏输出、读者要自己按 Tab 才能发现、
130
+ 页面上没有图表实例),写在块上:
131
+ `data-prose="为什么摆不出来"`。理由至少 8 个字,会被逐条打印出来,可以被反驳。
132
+ 只有 `box bad` 单栏、配不出 `cmp` 结构的块,要在**块上**写
133
+ `data-claim-skip="为什么写不出互斥断言"`。
134
+ **理由要区分"量不了"和"可量但没做"**:后者是欠账,写明白,别混进前者
135
+ —— 一句"量化不了"掩盖一处没做的活,比不写更糟。
136
+ 9. **能配成对的,必须配成对。** `demo-claim` 里有一个指标(`focus`:
137
+ "按 Tab 能不能停上去")就是为"键盘到不了"这类反例加的 ——
138
+ `choice` / `switch` / `accordion` / `tabs` 四页的单栏反例,
139
+ 0.11.14 全部改成了成对结构,因为它们**本来就可量**,差的只是有人去重排。
140
+ ⇒ 写"量不了"之前先问一句:**是新指标的事,还是我没做?**
124
141
 
125
142
  ## 提 PR 之前
126
143
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
3
  "generatedBy": "05-audit/gen-ai-contract.py",
4
- "libraryVersion": "0.11.12",
4
+ "libraryVersion": "0.11.14",
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 下的专属门禁 ⇒ 还没被机器验证过,不要用",
@@ -1725,7 +1725,9 @@
1725
1725
  "roles": [
1726
1726
  "switch"
1727
1727
  ],
1728
- "attrs": []
1728
+ "attrs": [
1729
+ "checked"
1730
+ ]
1729
1731
  },
1730
1732
  "usesOtherNamespaces": [],
1731
1733
  "global": null,
package/dist/COSTS.md CHANGED
@@ -49,7 +49,7 @@
49
49
  ## 三、全库
50
50
 
51
51
  - 全部组件都用上(min,含 tokens 一份):**60.0 KB**
52
- - 源码版(带注释)全库 gzip:290.9 KB
52
+ - 源码版(带注释)全库 gzip:291.2 KB
53
53
 
54
54
  > ⚠️ 源码版那个数字**不是**要付的钱:它包含我们的注释(全库 CSS 有 66% 是注释,字节口径)。
55
55
  > 它的用途只有一个 —— 让人看清"dist 到底省了多少"。
package/index.html CHANGED
@@ -92,7 +92,7 @@ li a:active { background: var(--surface-sunken); }
92
92
  </p>
93
93
  </main>
94
94
  <footer class="wrap">
95
- <p class="note">0.11.12 · 零第三方运行时依赖</p>
95
+ <p class="note">0.11.14 · 零第三方运行时依赖</p>
96
96
  </footer>
97
97
  </body>
98
98
  </html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcss",
3
- "version": "0.11.12",
3
+ "version": "0.11.14",
4
4
  "description": "移动 Web 组件库:CSS + 可选行为脚本,零第三方运行时依赖。样式用 bundler 引入,交互行为用 script 标签引入。",
5
5
  "keywords": [
6
6
  "css",