agentcss 0.11.13 → 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.
@@ -246,8 +246,10 @@
246
246
  `<p class="small">` 描述"别这么做",不算反例 —— 读者看不见那个"错"。
247
247
  摆不出来的(运行时行为 / 读屏输出 / 这页没有图表实例)写在块上:
248
248
  `<div class="cmp__pane cmp__bad" data-prose="为什么摆不出来">`
249
- 单栏的 `box bad` 页没有成对结构,页内要写
250
- `<!-- demo-claim:skip 为什么没有成对断言 -->`
249
+ 单栏的 `box bad` 块没有成对结构,**在块上**写清为什么:
250
+ `<div class="box bad" data-claim-skip="为什么写不出互斥断言">`
251
+ 合法的两种典型:两栏在视觉上必然相同(差别只在无障碍树里)、
252
+ 差别是对比度(那件事由 `contrast.py` 单独测)。理由会被逐条打印。
251
253
  ========================================================================= */
252
254
  .page .cmp {
253
255
  display: grid;
@@ -17,7 +17,6 @@
17
17
  </head>
18
18
  <body class="page page">
19
19
  <main class="wrap">
20
- <!-- demo-claim:skip 反例的差别是「饱和底白字的对比度」和「div 能不能被 Tab」:前者 demo-claim 没有对比度指标(contrast.py 单独测),后者没有焦点指标 -->
21
20
  <h1>badge · 状态标记</h1>
22
21
  <p class="lede">
23
22
  badge 承载<strong>一个短状态词</strong>("已停牌""审计中")。
@@ -64,7 +63,7 @@
64
63
  </p>
65
64
 
66
65
  <h2 class="rule-top">反例</h2>
67
- <div class="box bad">
66
+ <div class="box bad" data-claim-skip="两栏画出来的都是同一批色块,尺寸与位置完全相同 —— 差别是暗色下 2.72:1 与 6.39:1 的对比度,而 demo-claim 没有对比度指标(那件事由 contrast.py 单独测,本页正文引的就是它的数)">
68
67
  <h3>反例:饱和底 + 白字</h3>
69
68
  <div class="row">
70
69
  <span class="badge badge--solid-success">已披露</span>
@@ -80,7 +79,7 @@
80
79
  </p>
81
80
  </div>
82
81
 
83
- <div class="box bad">
82
+ <div class="box bad" data-claim-skip="可点的正例本该是一个 a / button,但本库 badge 门禁要求所有可点元素命中区 ≥44px,而徽标本体只有 24px 高 —— 摆一个过得了门的正例,要么改组件,要么给演示塞内联样式把高度撑起来(那是拿样式骗门禁)。⇒ 配不成对的原因不是量不了,是组件本身还没解决这个冲突,见 CHANGELOG「已知冲突」">
84
83
  <h3>反例:用 badge 装可点的东西</h3>
85
84
  <div class="row">
86
85
  <div class="badge badge--info">待复核</div>
@@ -91,6 +90,10 @@
91
90
  —— div 加 onclick 对读屏是<strong>「没有角色的按钮」</strong>:
92
91
  读不出来它是能点的,键盘也 Tab 不到。
93
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>,不是演示写错了。
94
97
  </p>
95
98
  </div>
96
99
 
@@ -32,7 +32,6 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
32
32
  </head>
33
33
  <body class="page page">
34
34
  <main class="wrap">
35
- <!-- demo-claim:skip 可量(display:none 的 input 高度是 0,opacity:0 的不是),但这一页仍是单栏结构,升级成 cmp 要重排整页 —— 本轮没做,已知欠账 -->
36
35
 
37
36
  <h1>checkbox / radio / select</h1>
38
37
  <p class="lede">
@@ -187,22 +186,39 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
187
186
  </p>
188
187
  </div>
189
188
 
190
- <h2 class="rule-bottom">反例:用 display:none 隐藏原生控件</h2>
191
- <div class="box bad">
192
- <h3>反例:把原生勾选框 <code>display:none</code> 藏掉</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>
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>
199
221
  </div>
200
- <p class="small" style="margin-top:var(--sp-3)">
201
- <strong>用 Tab 试试——焦点会直接跳过它。</strong>
202
- <code>display:none</code> 把它从 tab 顺序里移除了,键盘用户彻底到不了这里。
203
- 鼠标点击还正常,所以这种 bug 用鼠标测不出来。<br><br>
204
- 本库的 input 用的是 <code>opacity:0</code> + 绝对定位:<strong>看不见,但仍在 tab 顺序里</strong>。
205
- </p>
206
222
  </div>
207
223
 
208
224
  <h2 class="rule-bottom">焦点环必须画在看得见的地方</h2>
@@ -15,7 +15,6 @@
15
15
  </head>
16
16
  <body class="page page">
17
17
  <main class="wrap">
18
- <!-- demo-claim:skip 两块都可量(<hr> 的个数 0 vs 1),但这一页仍是单栏结构,升级成 cmp 要重排整页 —— 本轮没做,已知欠账 -->
19
18
  <h1>separator · 分隔线</h1>
20
19
  <p class="lede">
21
20
  分隔线有两种,<strong>必须分清</strong>:装饰性的只分开内容块,
@@ -61,7 +60,7 @@
61
60
  </div>
62
61
 
63
62
  <h2>反例</h2>
64
- <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 单独测)">
65
64
  <h3>反例:语义分隔用装饰色</h3>
66
65
  <div style="max-width:32em">
67
66
  <p class="small" style="margin:0">基本信息</p>
@@ -77,14 +76,33 @@
77
76
  </p>
78
77
  </div>
79
78
 
80
- <div class="box bad">
81
- <h3>反例:用 div + border-bottom 代替 hr</h3>
82
- <div style="border-bottom:1px solid var(--border-decor); margin:var(--sp-4) 0"></div>
83
- <p class="small">
84
- <code>&lt;div class="line"&gt;</code> 对读屏是<strong>什么都没有</strong>。
85
- 语义分隔必须用 <code>&lt;hr&gt;</code>(或 <code>role="separator"</code>),
86
- 装饰性的才可以用纯 div。
87
- </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>
88
106
  </div>
89
107
  </main>
90
108
  </body>
@@ -18,7 +18,6 @@
18
18
  </head>
19
19
  <body class="page page">
20
20
  <main class="wrap">
21
- <!-- demo-claim:skip 两块都可量(input 个数 0 vs 1、label 的高度),但这一页仍是单栏结构,升级成 cmp 要重排整页 —— 本轮没做,已知欠账 -->
22
21
  <h1>switch · 开关</h1>
23
22
  <p class="lede">
24
23
  开关用于<strong>立即生效的设置项</strong>("开/关通知")。
@@ -83,22 +82,37 @@
83
82
  </div>
84
83
 
85
84
  <h2 class="rule-top">反例</h2>
86
- <div class="box bad">
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>
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>
91
112
  </div>
92
- <p class="small">
93
- 它<strong>看起来和上面那个一模一样</strong> —— 这正是坑所在:
94
- 常见写法是 <code>&lt;div role="switch" tabindex="0" onclick="..."&gt;</code>,
95
- 然后手写 <code>aria-checked</code> 切换。这需要自己处理:
96
- Space 键、焦点样式、状态播报。<strong>三件事漏一件就坏</strong>,
97
- 而且测试工具(axe)只看得见 DOM,看不见你的 JS 有没有绑对。
98
- </p>
99
113
  </div>
100
114
 
101
- <div class="box bad">
115
+ <div class="box bad" data-claim-skip="两栏在视觉上必须完全相同 —— 那正是这一块要教的:差别只在无障碍树里(sr-only 的 label 仍在树中,display:none 的不在),可见部分都只有滑块那么大,写不出互斥断言。证据在 10-review/a11y-tree/switch.txt">
102
116
  <h3>反例:给 label 加 display:none</h3>
103
117
  <div class="switch">
104
118
  <span class="switch__track"><span class="switch__thumb"></span></span>
@@ -15,7 +15,6 @@ p { margin: 0 0 var(--sp-3); }
15
15
  </head>
16
16
  <body class="page page">
17
17
  <main class="wrap">
18
- <!-- demo-claim:skip 两块都可量(button 的个数、面板里链接的高度),但这一页仍是单栏结构,升级成 cmp 要重排整页 —— 本轮没做,已知欠账 -->
19
18
  <h1>accordion · 折叠面板</h1>
20
19
  <p class="lede">
21
20
  用于把长表单或长内容分成几段。<strong>默认只开一个</strong>(同一个问题的不同答案),
@@ -200,36 +199,76 @@ p { margin: 0 0 var(--sp-3); }
200
199
  </p>
201
200
  </div>
202
201
 
203
- <h2 class="rule-top">反例</h2>
204
- <div class="box bad">
205
- <h3>反例:用 div 当 trigger</h3>
206
- <div class="accordion">
207
- <div class="accordion__item">
208
- <div class="accordion__heading">
209
- <div class="accordion__trigger">用 div 写的标题(Tab 不到,也念不出状态)</div>
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>
210
214
  </div>
211
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>
212
240
  </div>
213
- <p class="small">
214
- <code>&lt;div class="accordion__trigger"&gt;</code> 键盘上 Tab 不到、
215
- 也不响应 Enter/Space。<strong>APG 要求 header 里是
216
- <code>role="button"</code> 的元素</strong>,用原生
217
- <code>&lt;button&gt;</code> 最省事也最可靠。
218
- </p>
219
241
  </div>
220
- <div class="box bad">
221
- <h3>反例:折叠时只用 max-height: 0</h3>
222
- <div id="acc-bad" style="max-height:0; overflow:hidden">
223
- <p class="small" style="margin:0 0 var(--sp-2)">这一整段文字,读屏此刻仍然念得出来。</p>
224
- <a href="#acc-bad">里面的这个链接,Tab 也仍然进得去</a>
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>
225
271
  </div>
226
- <p class="small">
227
- 上面那块的高度被压成了 <code>0</code>,你<strong>看不见它</strong> ——
228
- 但按一下 Tab,焦点会停在里面的链接上;读屏也会把那句"仍然念得出来"念完。
229
- 内容在无障碍树里<strong>仍然存在</strong>,只是视觉上消失:
230
- <strong>必须用 <code>hidden</code> 或 <code>display:none</code></strong>,
231
- 让它真正离开无障碍树。
232
- </p>
233
272
  </div>
234
273
  </main>
235
274
  <script src="accordion.js"></script>
@@ -42,7 +42,6 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
42
42
  </head>
43
43
  <body class="page page">
44
44
  <main class="wrap">
45
- <!-- demo-claim:skip 两块反例的差别都是时序(什么时候报错)和措辞(文案怎么说),没有可量的元素属性 -->
46
45
 
47
46
  <h1>表单校验</h1>
48
47
  <p class="lede">
@@ -56,7 +55,7 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
56
55
  </p>
57
56
 
58
57
  <div class="grid2" style="margin-top:var(--sp-4)">
59
- <div class="box bad">
58
+ <div class="box bad" data-claim-skip="差别是「什么时候报错」—— 时序。两个输入框在任何一个静态时刻的几何与文本都相同,只有交互过程中才分得出来,而 demo-claim 量的是静态 DOM">
60
59
  <h3>反例:oninput 立即报错</h3>
61
60
  <div class="field">
62
61
  <label class="field__label" for="naive">金额</label>
@@ -127,7 +126,7 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
127
126
 
128
127
  <h2 class="rule-bottom">三、错误文案:给建议,不给判断</h2>
129
128
  <div class="grid2">
130
- <div class="box bad">
129
+ <div class="box bad" data-claim-skip="两栏都是三条错误文案(uniq 都是 3、字号行高全同),差别只在措辞本身 —— 机器判不了哪句更清楚,只能人读">
131
130
  <h3>反例</h3>
132
131
  <p class="naive-error">格式错误</p>
133
132
  <p class="naive-error">非法输入</p>
@@ -42,7 +42,6 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
42
42
  </head>
43
43
  <body class="page page">
44
44
  <main class="wrap">
45
- <!-- demo-claim:skip 焦点落点与「点遮罩关不关」都是运行时行为,静态块里摆不出「错的那一版」 -->
46
45
 
47
46
  <h1>Toast vs Dialog</h1>
48
47
  <p class="lede">
@@ -112,7 +111,7 @@ p.small { font-size: var(--fs-xs); color: var(--text-tertiary); line-height: 1.6
112
111
 
113
112
  <h2 class="rule-bottom">用错比做丑严重得多</h2>
114
113
  <div class="two">
115
- <div class="box bad aside-col">
114
+ <div class="box bad aside-col" data-claim-skip="左栏是一个 toast 标本,右栏是一条判断规则(没有人能数的话)—— 两栏根本不是同一种东西,没有可比的量;而且「该 toast 还是 dialog」取决于要不要用户先确认,没有阈值可划">
116
115
  <h3 style="margin:0 0 var(--sp-3); font-size:var(--fs-sm); font-weight:500; color:var(--danger)">
117
116
  ❌ 错用</h3>
118
117
  <div class="toast toast--error" role="status" style="margin:0 0 var(--sp-3)">
@@ -17,7 +17,6 @@
17
17
  </head>
18
18
  <body class="page page">
19
19
  <main class="wrap">
20
- <!-- demo-claim:skip 两块都可量(button 的个数、tabindex="0" 的个数),但这一页仍是单栏结构,升级成 cmp 要重排整页 —— 本轮没做,已知欠账 -->
21
20
  <h1>tabs · 标签页</h1>
22
21
  <p class="lede">
23
22
  标签页用于在同一位置切换<strong>同层级的几块内容</strong>。
@@ -103,37 +102,82 @@
103
102
  </p>
104
103
  </div>
105
104
 
106
- <h2 class="rule-top">反例</h2>
107
- <div class="box bad">
108
- <h3>反例:用 div 当 tab</h3>
109
- <div class="tabs">
110
- <div class="tabs__list" role="tablist">
111
- <div class="tabs__tab" role="tab" aria-selected="true">基本信息</div>
112
- <div class="tabs__tab" role="tab" aria-selected="false">高级设置</div>
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>
113
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>
114
135
  </div>
115
- <p class="small">
116
- 上面这两个 tab <strong>看起来完全正常</strong>,但
117
- <code>&lt;div role="tab"&gt;</code> 键盘上根本<strong>Tab 不到</strong>
118
- (除非手写 <code>tabindex</code>),也不会响应 Enter/Space。
119
- APG 明确要求 tab 是 <code>&lt;button role="tab"&gt;</code> ——
120
- 用原生 button,键盘行为免费且必然正确。
121
- </p>
122
136
  </div>
123
- <div class="box bad">
124
- <h3>反例:每个 tab 都写 tabindex="0"</h3>
125
- <div class="tabs">
126
- <div class="tabs__list" role="tablist">
127
- <button class="tabs__tab" role="tab" tabindex="0" aria-selected="true">基本信息</button>
128
- <button class="tabs__tab" role="tab" tabindex="0">高级设置</button>
129
- <button class="tabs__tab" role="tab" tabindex="0">日志</button>
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>
130
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>
131
180
  </div>
132
- <p class="small">
133
- 看起来"都能 Tab 到",但一排 3 个 tab 就要按 <strong>3 次 Tab</strong>
134
- 才能进内容区(真到 5 个 tab 的页面上只会更糟)。Tab 键应该是<strong>进出</strong>控件的,
135
- 组内移动是方向键的活。
136
- </p>
137
181
  </div>
138
182
  </main>
139
183
  <script src="tabs.js"></script>
@@ -35,7 +35,6 @@ td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabu
35
35
  </head>
36
36
  <body class="page page">
37
37
  <div class="wrap">
38
- <!-- demo-claim:skip 反例是一张「不要做」清单表;3D、双 Y 轴这些错法都要真的渲染一张图才立得住,本页没有图表实例可摆 -->
39
38
  <h1>图表规范</h1>
40
39
  <p class="lede">
41
40
  <strong>下次画图照这里做就行</strong>,不用再反复微调。
@@ -148,7 +147,7 @@ td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabu
148
147
  </p>
149
148
 
150
149
  <h2 class="rule-top">4 · 明确禁止(这些是"看起来专业但会骗人"的)</h2>
151
- <div class="box bad">
150
+ <div class="box bad" data-claim-skip="这一块是「不要做」清单本身(一张表),不是某个组件的错写法 —— 要给它配正例栏,得先真的渲染一张 3D 图与一张 2D 图做对照,而本页没有图表实例可摆;表格尺寸量出来的是排版,不是观点">
152
151
  <table>
153
152
  <tr><th>不要做</th><th>为什么</th></tr>
154
153
  <tr><td class="no">3D 图表</td><td>透视扭曲会<b>误导读数</b>(Tufte 的核心结论)</td></tr>
@@ -34,7 +34,6 @@
34
34
  </head>
35
35
  <body class="page page">
36
36
  <main class="wrap">
37
- <!-- demo-claim:skip 两块反例(读屏念什么、reduced-motion 动画)都是运行时或读屏输出,没有可量的元素属性 -->
38
37
  <h1>sparkline · 迷你趋势图</h1>
39
38
  <p class="lede">
40
39
  在一行里显示"最近走势"。它是<strong>辅助</strong>不是图表 ——
@@ -124,7 +123,7 @@
124
123
  </div>
125
124
 
126
125
  <h2 class="rule-top">反例</h2>
127
- <div class="box bad">
126
+ <div class="box bad" data-claim-skip="两栏画的是同一条走势线,差别只在旁边有没有一个数字 —— 而「该不该配数字」本页上文明写取决于数值重不重要(纯装饰小图可以不配),没有一条能划的界线;断言只能去数一个我自己编出来的选择器,那证明的是我编的标记,不是这件事">
128
127
  <h3>反例:只放 sparkline,没有数字</h3>
129
128
  <p class="small">
130
129
  视觉上"很有信息",但读屏用户<strong>什么都不知道</strong> ——
package/CHANGELOG.md CHANGED
@@ -16,6 +16,94 @@
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
+
19
107
  ## [0.11.13] - 2026-10-29
20
108
 
21
109
  **先得有东西可看,才谈得上「两栏不一样」。**
package/CONTRIBUTING.md CHANGED
@@ -129,10 +129,15 @@ fix: <一句话说清改了什么、为什么>
129
129
  摆不出可见标本的(运行时行为、读屏输出、读者要自己按 Tab 才能发现、
130
130
  页面上没有图表实例),写在块上:
131
131
  `data-prose="为什么摆不出来"`。理由至少 8 个字,会被逐条打印出来,可以被反驳。
132
- 只有 `box bad` 单栏、没有 `cmp` 结构的页,还要在页面里写
133
- `<!-- demo-claim:skip 为什么没有成对断言 -->`。
132
+ 只有 `box bad` 单栏、配不出 `cmp` 结构的块,要在**块上**写
133
+ `data-claim-skip="为什么写不出互斥断言"`。
134
134
  **理由要区分"量不了"和"可量但没做"**:后者是欠账,写明白,别混进前者
135
135
  —— 一句"量化不了"掩盖一处没做的活,比不写更糟。
136
+ 9. **能配成对的,必须配成对。** `demo-claim` 里有一个指标(`focus`:
137
+ "按 Tab 能不能停上去")就是为"键盘到不了"这类反例加的 ——
138
+ `choice` / `switch` / `accordion` / `tabs` 四页的单栏反例,
139
+ 0.11.14 全部改成了成对结构,因为它们**本来就可量**,差的只是有人去重排。
140
+ ⇒ 写"量不了"之前先问一句:**是新指标的事,还是我没做?**
136
141
 
137
142
  ## 提 PR 之前
138
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.13",
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 下的专属门禁 ⇒ 还没被机器验证过,不要用",
package/dist/COSTS.md CHANGED
@@ -49,7 +49,7 @@
49
49
  ## 三、全库
50
50
 
51
51
  - 全部组件都用上(min,含 tokens 一份):**60.0 KB**
52
- - 源码版(带注释)全库 gzip:291.1 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.13 · 零第三方运行时依赖</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.13",
3
+ "version": "0.11.14",
4
4
  "description": "移动 Web 组件库:CSS + 可选行为脚本,零第三方运行时依赖。样式用 bundler 引入,交互行为用 script 标签引入。",
5
5
  "keywords": [
6
6
  "css",