agentcss 0.11.8 → 0.11.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/02-primitives/badge/README.md +14 -0
  2. package/02-primitives/button/button.css +7 -3
  3. package/02-primitives/card/README.md +1 -1
  4. package/02-primitives/combobox/README.md +15 -0
  5. package/02-primitives/date-range/README.md +11 -0
  6. package/02-primitives/popover/README.md +12 -1
  7. package/02-primitives/progress/README.md +4 -0
  8. package/02-primitives/select/README.md +12 -0
  9. package/02-primitives/separator/README.md +3 -1
  10. package/02-primitives/switch/README.md +14 -0
  11. package/03-patterns/accordion/README.md +17 -0
  12. package/03-patterns/drawer/README.md +12 -0
  13. package/03-patterns/dropdown/README.md +15 -0
  14. package/03-patterns/overlay/README.md +3 -3
  15. package/03-patterns/pagination/README.md +14 -0
  16. package/03-patterns/reveal/README.md +15 -0
  17. package/03-patterns/states/README.md +1 -1
  18. package/03-patterns/tabs/README.md +15 -0
  19. package/03-patterns/tooltip/README.md +13 -0
  20. package/03-patterns/tree/README.md +16 -0
  21. package/03-patterns/tree/demo.html +9 -1
  22. package/03-patterns/tree/tree.css +9 -0
  23. package/03-patterns/tree/tree.js +12 -4
  24. package/04-recipes/analysis-report/README.md +16 -0
  25. package/04-recipes/data-showcase/README.md +11 -0
  26. package/04-recipes/detail-page/README.md +13 -0
  27. package/04-recipes/report/README.md +13 -0
  28. package/04-recipes/table/README.md +16 -0
  29. package/CHANGELOG.md +105 -0
  30. package/CONTRIBUTING.md +43 -4
  31. package/README.zh-CN.md +3 -1
  32. package/START-HERE.md +2 -2
  33. package/SUPPORT.md +2 -1
  34. package/ai/components.json +3 -2
  35. package/dist/02-primitives/button/button.css +1 -1
  36. package/dist/03-patterns/tree/tree.css +1 -1
  37. package/dist/03-patterns/tree/tree.js +3 -3
  38. package/dist/COSTS.md +3 -3
  39. package/docs/A11Y-MANUAL-PROTOCOL.md +86 -0
  40. package/docs/ACCESSIBILITY.md +143 -66
  41. package/index.html +1 -1
  42. package/package.json +1 -1
@@ -65,6 +65,20 @@
65
65
 
66
66
  高度 24px(默认)· 字号 12px —— 达 WCAG 2.2 的 24×24 最小目标。
67
67
 
68
+
69
+ ## 反例
70
+
71
+ - ❌ **用 `badge--danger` 当按钮** —— badge 不可点;要可点必须换成 `<button>` / `<a>` 并加 `badge--clickable`,
72
+ `div` + `onClick` 对读屏是"没有角色的按钮"(`badge.css` 里写明了这一条)
73
+ - ❌ **只用颜色区分状态、不加文字** —— WCAG 1.4.1 要求形状或文字参与区分;
74
+ 纯色块在灰度打印和色觉障碍下不传递任何信息。所以本库是「淡底 + 深字」而不是「饱和底 + 白字」
75
+ - ❌ **用 badge 装数字角标** —— 那是另一类设计:数字一变化读屏未必会念,用户不知道"3 变成 4"是发生了什么事
76
+ - ❌ **给不可点的 badge 写 hover 过渡** —— 光标和过渡都在暗示"我能点",用户去点,什么也不会发生
77
+ - ❌ **列表里每一行都挂 `badge--solid-warning`** —— 饱和底在长列表里会盖过正文;`solid` 那三档只留给"这一行必须被看见"的少数情况
78
+
79
+ ---
80
+
81
+
68
82
  ## 验证
69
83
 
70
84
  - `component-contrast.py`:5 组字/底组合两套配色全过
@@ -58,9 +58,13 @@
58
58
  user-select: none;
59
59
  text-decoration: none;
60
60
 
61
- /* 🔴 兜底:即使内容异常长也不许溢出边界 */
62
- overflow: hidden;
63
- max-width: 100%;
61
+ /* 🔴 0.11.9 改:原来是 `overflow: hidden; max-width: 100%`("内容再长也不
62
+ 许溢出")。副作用:`overflow: hidden` 把 flex/grid 项的**自动最小尺寸**
63
+ 变成 0 ⇒ 窄容器里按钮被压得比自己的文字还窄 ⇒ 文字被裁。
64
+ 实测 200% 字号下按钮 200px、文字 232px,裁掉 32px(WCAG 1.4.4 违规)。
65
+ 而它要防的"label 与 spinner 重叠"已由 grid 叠放解决(同一格不会重叠)。
66
+ ⇒ 换成 `min-width: max-content`:按钮不窄于自己的文字。 */
67
+ min-width: max-content;
64
68
 
65
69
  /* 动效只用 token 里的那一组 —— 一源原则 */
66
70
  transition: background-color var(--dur-fast) var(--ease-out),
@@ -125,7 +125,7 @@ hover 只改 **`transform`** 和 **`box-shadow`**,这两个属性不触发回
125
125
  ## 反例 —— 什么时候不该用
126
126
 
127
127
  - ❌ **一排卡片全用 `--elevated`** —— 阴影互相打架,整页发灰。列表用 `--flat`
128
- - ❌ **hover 抬起 8px 以上** —— 游戏感
128
+ - ❌ **hover 抬起 8px 以上** —— 鼠标扫过列表时整页都在晃,注意力从内容跑到动画上;悬停只需要"轻微浮起"这一点点反馈
129
129
  - ❌ **整卡套 `div onclick`** —— 键盘不可达,见上
130
130
  - ❌ **给链接本身再画焦点环** —— 会变成两个框
131
131
  - ❌ **用 JS 截断描述文字** —— 会留下半个词或半个字,用 `-webkit-line-clamp`
@@ -73,6 +73,21 @@ if (!list.id) list.id = 'combo-list-' + seq;
73
73
  |---|---|
74
74
  | `dismissable:manual` | 点外部关闭是手写的,**不是统一机制** |
75
75
 
76
+ ## 反例
77
+
78
+ - ❌ **标签只能点小叉删除、不支持 Backspace** —— 键盘用户删不掉已选项,
79
+ 这是标签输入的事实标准交互(`combobox.js` 里 Backspace 删最后一个是刻意实现的)
80
+ - ❌ **过滤结果不高亮命中片段** —— 用户看到的是"顺序乱了",而不是"因为匹配才留下";
81
+ `combobox.js` 高亮命中片段就是为了回答"为什么这条匹配"
82
+ - ❌ **把焦点环画在 `combo__input` 上** —— 环画在 `combo__field:focus-within` 上;
83
+ 两处都画会出现双层框
84
+ - ❌ **`combo__tag-del` 只做视觉上的小叉、不补命中区** —— 命中区要 44px;手指点不中,等于删不掉
85
+ - ❌ **禁用时只让输入框变灰、`combo__tag-del` 还能点** —— 看着能点,点下去要么没反应要么出事;
86
+ 禁用态要把删除按钮一起变灰
87
+ - ❌ **在自定义匹配函数里做异步过滤** —— 它会被逐项同步调用,返回 Promise 等同于"全部不匹配"
88
+
89
+ ---
90
+
76
91
  ## 门禁
77
92
 
78
93
  - `05-audit/combobox-check.js`
@@ -95,6 +95,17 @@ if (msg) {
95
95
  契约里 `behaviorDebt` 为空 —— 清除、校验、preset 接线都有门禁覆盖
96
96
  (`05-audit/date-range-check.js`)。
97
97
 
98
+ ## 反例
99
+
100
+ - ❌ **不给 `drange__clear`** —— 日期填错要能一键清空;没有它,用户得逐格删六个数字
101
+ - ❌ **错误态只改颜色、不加图标** —— WCAG 1.4.1;本库的 `drange__error` 是「边框 + 图标」,颜色只是辅助
102
+ - ❌ **禁用态用 `opacity`** —— 透明度会把文字对比度一起拉低,禁用不等于允许不可读(`date-range.css` 里改用中性色)
103
+ - ❌ **自建日历替代 `<input type="date">`** —— 移动端原生选择器是系统级的:读屏、键盘、本地化都免费;
104
+ 自建的那一套要全部重做,而且一定做得更差
105
+ - ❌ **三个预设按钮只做鼠标点击** —— `drange__preset` 必须能 Tab 到,快捷选择对键盘用户才是"快捷"
106
+
107
+ ---
108
+
98
109
  ## 门禁
99
110
 
100
111
  `05-audit/date-range-check.js`
@@ -77,4 +77,15 @@ Popover.current; // 当前开着的实例(没有则null)
77
77
  ## 动画
78
78
 
79
79
  尊重 `prefers-reduced-motion` —— 用户在系统里关了动效,
80
- 组件的过渡也要跟着关。
80
+ 组件的过渡也要跟着关。
81
+
82
+ ## 反例
83
+
84
+ - ❌ **给 popover 加 focus trap** —— 不做 trap 是刻意的:它没有"确认/取消"来放行,
85
+ trap 住了人出不去。焦点**进入**浮层(键盘用户要能直接操作)就够了
86
+ - ❌ **关闭态只用 `pointer-events: none`** —— Tab 仍能走进去,焦点会跑到看不见的地方;
87
+ 必须是 `inert`(`popover.js` 的 `open()` 里写明了这一条)
88
+ - ❌ **同时开两个 popover** —— 两个叠着,用户不知道该关哪个,也不知道点空白处关的是哪一个
89
+ - ❌ **把箭头塞进带 `overflow: hidden` 的容器** —— 箭头会被裁掉;所以本组件不用 overflow 方式隐藏
90
+
91
+ ---
@@ -75,6 +75,10 @@ role="progressbar" aria-busy="true" aria-label="正在处理"
75
75
  - ❌ **代替"还剩多久"** ⇒ 百分比给的是"比例",不是"时间"
76
76
  - ❌ **环形用在极小尺寸**(< 32px)⇒ 环太细,读不出进度
77
77
  - ❌ **在一个页面里同时出现 3 个以上进度条** ⇒ 用户不知道该看哪个
78
+ - ❌ **用 `display: none` 隐藏进度条** ⇒ 会留下 `::after` 参与合成的空盒,位置还占着;改宽度或改状态,别改可见性
79
+ - ❌ **进度到 100% 就自动变绿** ⇒ 颜色的语义是"结果"不是"数值",100% 也可能是失败;`progress--success` 按业务给
80
+ - ❌ **把超过 100 的值喂给 `progress-ring`** ⇒ 弧长按百分比算,超过就是绕圈或错位
81
+ - ❌ **用 `progress-steps` 表达连续百分比** ⇒ 它按离散阶段设计(`progress-steps__seg--done` / `--current`),用户会以为"每段就是 25%"
78
82
 
79
83
  ## 相关
80
84
 
@@ -90,6 +90,18 @@ const sel = Select.create(元素, { multiple: false, value: 'b', label: '选项'
90
90
 
91
91
  接统一行为层前,别当`stable` 用。
92
92
 
93
+ ## 反例
94
+
95
+ - ❌ **不实现类型搜索** —— 原生 select 支持连续打字跳转;自建却丢掉这个,是**功能倒退**。
96
+ `select.js` 里做了 500ms 缓冲(超时清空,否则 "abc" 永远搜不到)
97
+ - ❌ **把焦点放进 `select__list`** —— 焦点必须常驻 `select__btn`,"当前在第几项"靠
98
+ `aria-activedescendant` 表达;焦点进了 list,读屏会开始念"列表"而不是"选项"
99
+ - ❌ **`select__opt` 命中区不足 44px** —— 本库按 AAA 要求做命中区;选项挤在一起,触屏必点错
100
+ - ❌ **把 `select__value--placeholder` 当已选值** —— 占位不是值:表单提交时它是空的,
101
+ 而用户以为自己选了
102
+
103
+ ---
104
+
93
105
  ## 门禁
94
106
 
95
107
  `05-audit/select-check.js` —— 覆盖键盘契约与 ARIA 属性同步。
@@ -47,7 +47,7 @@
47
47
 
48
48
  **这就是"不要一档打天下"的实测依据。**
49
49
 
50
- ## 两个反例
50
+ ## 反例
51
51
 
52
52
  **① 语义分隔用装饰色** —— 分隔"基本信息"与"高级设置"是两类不同的东西,
53
53
  但 1.26:1 的线在低视力与暗色下几乎看不见,而读屏只念"分隔符"、
@@ -57,6 +57,8 @@
57
57
  **什么都没有**。语义分隔必须用 `<hr>` 或 `role="separator"`,
58
58
  装饰性的才可以用纯 div。
59
59
 
60
+ - ❌ **自己用 `box-shadow` 画分隔线** —— 1px 的线在部分浏览器上会取整成 2px;本库用 border 就是为了这个(`separator.css` 里写了原因)
61
+
60
62
  ## 细节
61
63
 
62
64
  用 `border` 而不是 `box-shadow` 画线 —— 1px 的线用 box-shadow
@@ -63,3 +63,17 @@ Space 键、焦点样式、状态播报 —— **三件事漏一件就坏**,
63
63
  - 滑块位移随状态变化 · axe 0 违规 / 24 条通过
64
64
 
65
65
  判别力验证:去掉 `role` 与 `label` ⇒ 4 项立即被抓。
66
+
67
+ ## 反例
68
+
69
+ - ❌ **让 Enter 也能切换** —— APG 的 Switch 只认 Space。Enter 会**双重切换**:
70
+ 读屏自己会发一次,你的处理器再切一次,用户看到的是"按了没反应"
71
+ - ❌ **用 `aria-label` 代替 `<label for>`** —— 视觉标签点了没反应,用户以为坏了。
72
+ 标签不可见时用 `switch__label--sr`:看得见可以没有,听得到必须有
73
+ - ❌ **把 switch 当 checkbox 用** —— switch 的语义是"**立刻生效**",checkbox 是"随表单一起提交"。
74
+ 用开关做"我已阅读条款",用户勾完就以为已经生效了
75
+ - ❌ **给开关加 `error` 或 `readonly`** —— 开关没有中间态可校验;readonly 对开关等于 disabled(用户改不了),
76
+ 留着它只会让复用者以为"这个能校验"
77
+ - ❌ **只把小滑块做成可点区域** —— `switch__input` 要铺满整块;只点滑块,触屏上漏点率很高
78
+
79
+ ---
@@ -119,3 +119,20 @@ APG 警告:
119
119
  - axe 0 违规 / 29 条通过
120
120
 
121
121
  判别力验证:注入 keydown 双触发 ⇒ **2 项立即被抓**。
122
+
123
+ ## 反例
124
+
125
+ - ❌ **给折叠标题加方向键 / Home / End** —— APG 的 Accordion 只要求 Tab + Enter/Space。
126
+ 加了会和页面滚动抢方向键;本组件**刻意不监听 `keydown`**,这是设计不是遗漏
127
+ - ❌ **用 `max-height` 做展开动画** —— 你必须猜内容有多高:猜小了裁字,猜大了结尾拖一段空白。
128
+ 本库用 `grid-template-rows` 从 `0fr` 到 `1fr`,不需要知道高度
129
+ - ❌ **收起时直接 `display: none`** —— 关闭前必须**先量到高度再收**,否则量到 0,
130
+ 动画"啪"一下消失(`accordion.js` 里那条注释说的就是这个)
131
+ - ❌ **给 `accordion__panel` 加 `overflow: hidden`** —— 会裁掉里面控件的焦点环;
132
+ 收的过程交给 grid 的 `0fr` 完成
133
+ - ❌ **每一项都套 `role="region"`** —— APG 有 ≤6 的地标上限,超过就是"地标泛滥",
134
+ 读屏的地标列表反而失去导航价值
135
+ - ❌ **把 `--acc-h` 挪进全局令牌表** —— 它只对本组件有意义(`accordion.css` 里写了为什么不进 `tokens.css`),
136
+ 进全局表会让所有人以为那是个通用尺寸
137
+
138
+ ---
@@ -74,6 +74,18 @@ Drawer.open({ place: 'right' }); // 默认就是 right
74
74
  |---|---|
75
75
  | `dismissable:manual` | 点遮罩关闭是手写的,不是统一机制 |
76
76
 
77
+ ## 反例
78
+
79
+ - ❌ **打开抽屉时不给背景加 `inert` / `aria-hidden`** —— 抽屉是**模态**:读屏会把抽屉与背景
80
+ 一起念出来("双重朗读"),用户听到两套内容叠在一起
81
+ - ❌ **省掉焦点陷阱** —— Tab 会跑到抽屉外面,键盘用户在看不见的内容上操作,还以为抽屉坏了
82
+ - ❌ **`drawer__close` 做得很小** —— 它是独立目标,要 44px。抽屉滑进来之后,用户第一件事就是找关闭
83
+ - ❌ **在插入 DOM 之后才记录 `document.activeElement`** —— 记到的是新节点,
84
+ 关闭时焦点"归还"到一个已经不存在的位置,等于没还
85
+ - ❌ **嵌套抽屉不用锁计数** —— 内层关掉时会把外层的滚动锁一起解开,外层还在,背景却能滚了
86
+
87
+ ---
88
+
77
89
  ## 门禁
78
90
 
79
91
  `05-audit/drawer-check.js`
@@ -73,6 +73,21 @@ menu.setAttribute('role', 'menu');
73
73
  | `focus-return:manual` | 关闭后归位是手写的,不是统一机制 |
74
74
  | `typeahead:gap` | **没有类型搜索** —— APG 的 Menu Button 不硬性要求,但列表长时体验缺失 |
75
75
 
76
+ ## 反例
77
+
78
+ - ❌ **菜单里一部分项 `aria-hidden`、一部分不设** —— APG 要求**全设或全不设**。
79
+ 半吊子会让读屏报"1 项中的 0 项"这种不可理解的计数
80
+ - ❌ **禁用项不给 `tabindex="-1"`** —— Tab 会停在点不动的项上,用户以为菜单坏了
81
+ - ❌ **Esc 关闭后焦点不还给 `dd__btn`** —— 焦点掉到 `<body>`,键盘用户要从页面开头重新 Tab(APG 明确要求归还)
82
+ - ❌ **给某个 `dd__item` 设 `tabindex="0"`** —— `role="menu"` 靠方向键步进,多一个 0
83
+ 就多出一个 Tab 停靠点,整个菜单的导航模型就混了
84
+ - ❌ **先写 `aria-controls` 再确保 `dd__menu` 有 id** —— 顺序反了会引用空字符串,
85
+ 读屏拿不到"这个按钮控制哪个菜单"(`dropdown.js` 里为此调过顺序)
86
+ - ❌ **用 `opacity: 0` 或 `visibility` 隐藏 `dd__menu`** —— 关闭态要用 `hidden`:
87
+ 它同时移出无障碍树并不可聚焦,另两种做法都还留着 Tab 停靠点
88
+
89
+ ---
90
+
76
91
  ## 门禁
77
92
 
78
93
  `05-audit/dropdown-check.js`
@@ -184,13 +184,13 @@ grep -n '=>\|\bconst\b\|\blet\b' 03-patterns/overlay/overlay.js # 应无输出
184
184
  ## 反例
185
185
 
186
186
  - ❌ **toast 抢焦点** —— 通知不该打断当前操作
187
- - ❌ **用 toast 做需要确认的操作** —— 见上
188
- - ❌ **dialog 不困住焦点** —— 混乱
187
+ - ❌ **用 toast 做需要确认的操作** —— toast 里没有按钮、也拿不到焦点:用户要么没看见,要么看见了却点不到
188
+ - ❌ **dialog 不困住焦点** —— Tab 会跑到背后的页面上,读屏用户一路读下去,读到的是 dialog 外面的内容
189
189
  - ❌ **dialog 关闭后焦点不回去** —— 键盘用户要重新 Tab
190
190
  - ❌ **锁滚动不补偿滚动条宽度** —— 页面"闪"一下
191
191
  - ❌ **错误 toast 3 秒消失** —— 还没读完就走了
192
192
  - ❌ **危险操作初始焦点在"删除"上** —— 条件反射误触
193
193
  - ❌ **危险操作点遮罩即关闭** —— 手滑就完了
194
- - ❌ **dialog 大幅缩放/回弹** —— 廉价感
194
+ - ❌ **dialog 大幅缩放/回弹** —— 大面积位移让前庭敏感的用户不适,而且把注意力从内容移到了动画上
195
195
  - ❌ **backdrop 加 `backdrop-filter: blur`** —— 旧 WebView 掉帧
196
196
  - ❌ **用自定义 div 替代 `<dialog>` 且不需要自定义动画** —— 原生的 focus trap 更可靠
@@ -109,6 +109,20 @@ if (q > 0) this.page = Math.min(q, this.pages);
109
109
 
110
110
  `ai/components.json` 里 `pagination` 的 `behaviorDebt` 为空。
111
111
 
112
+ ## 反例
113
+
114
+ - ❌ **把 `role="navigation"` 直接放在 `pagination__list` 上** —— 会**覆盖 `ul` 的列表语义**,
115
+ 读屏不再报告"共 N 项"。要包在 `<nav aria-label>` 里
116
+ - ❌ **用 `aria-selected` 标当前页** —— 那是 tab / option / row 的语义;当前页用 `aria-current="page"`,
117
+ 用错了读屏会说"已选中",而用户以为自己只是停在第 3 页
118
+ - ❌ **客户端翻页不放 `pagination__live`** —— 页码变了却没有任何播报,读屏用户不知道"内容已经换了"。
119
+ 而且这个 live 区必须**常驻 DOM**:新插入的节点很多读屏不播
120
+ - ❌ **页码无限铺开** —— 槽位一多就撑破窄屏(USWDS 的上限是 7)。该用 `pagination__ellipsis` 收中间
121
+ - ❌ **没跳过页码也显示省略号** —— 用户会以为中间少了页,去点它,什么也不会发生
122
+ - ❌ **禁用态用纯透明表示** —— 半透明会让人以为"再点一下就行";本库是让它"退到背景里"
123
+
124
+ ---
125
+
112
126
  ## 门禁
113
127
 
114
128
  `05-audit/pagination-check.js`
@@ -89,3 +89,18 @@ CSS transition 被打断时是 **retarget**:从当前值继续插值,不重
89
89
  这条结论立即失效 —— 那时必须重新量,并且很可能需要守卫。
90
90
 
91
91
  ⚠️ 这属于**负面结论入档**:写下来是为了下次不要再"看到别人有就照抄一遍"。
92
+
93
+ ## 反例
94
+
95
+ - ❌ **引了脚本就指望内容被藏起来** —— 默认**不接管**:只有显式调 `enable()` 才会把目标设成 `opacity: 0`。
96
+ 隐藏是有代价的决定,必须由页面自己做出
97
+ - ❌ **藏了但没有兜底** —— 任何一步失败都会留下"看不见的内容",最坏是整页空白。
98
+ 所以隐藏**只在** `<html data-reveal-ready>` 存在时生效,没有它内容就是全可见
99
+ - ❌ **位移超过 8px** —— 内容"飞进来"会打断阅读节奏;本库的位移上限就是 8px
100
+ - ❌ **自己再写一套 keyframes** —— 缓动、时长、延迟会各写一套,最后没人能回答
101
+ "为什么这个方向和那个方向节奏不一样"。本库是**一条规则撑六种表现**
102
+ - ❌ **时长写裸数值** —— 只走令牌;`05-audit/motion.py` 会查裸时长与自编曲线
103
+ - ❌ **reduced-motion 时把动画一刀切归零** —— 降级是**分级**的:关掉位移不等于关掉一切,
104
+ 透明度的过渡仍然可以留
105
+
106
+ ---
@@ -76,7 +76,7 @@ fetch(...).then(r => { clearTimeout(t); showResult(r); });
76
76
 
77
77
  - ❌ **empty 和 loading 用同一个样式** —— 用户分不清是"在加载"还是"真没有"
78
78
  - ❌ **empty 态不放动作按钮** —— 把用户卡死
79
- - ❌ **error 态没有重试出口** —— 死路
79
+ - ❌ **error 态没有重试出口** —— 用户唯一的动作只剩刷新页面,而刷新会把已经填好的内容一起丢掉
80
80
  - ❌ **结构未知时用骨架屏** —— 撒谎,且内容到位时跳动
81
81
  - ❌ **请求 <300ms 也显示骨架屏** —— 闪一下更糟
82
82
  - ❌ **把普通操作的成功做成整页 success** —— 成功是过渡态,用 button 的 `data-state="success"`(1.2s 自动消失)
@@ -82,3 +82,18 @@ demo 的面板里放了按钮,`Tab` 序列是
82
82
  - axe 0 违规 / 30 条通过
83
83
 
84
84
  判别力验证:破坏 roving(全部设成 `0`)⇒ **5 项立即被抓**。
85
+
86
+ ## 反例
87
+
88
+ - ❌ **每个 `tabs__tab` 都给 `tabindex="0"`** —— Tab 键要逐个穿过一整排;
89
+ roving tabindex 只留**一个** 0,其余 -1
90
+ - ❌ **`tabindex="0"` 只在初始化时设一次** —— 这是本组件最常见的 bug:用户切到第 3 个 tab 后,
91
+ 再按 Tab 又回到第 1 个。那个唯一的 0 必须**跟着选中项走**
92
+ - ❌ **禁用项也设 `tabindex="0"`** —— Tab 会停在不可用的项上,用户按了没反应,以为页面卡了
93
+ - ❌ **automatic 模式下 `preventDefault`** —— 会与 button 的默认行为打架,变成"按一次跳两格";
94
+ 不拦,交给默认行为
95
+ - ❌ **把 `tabs--manual` 当纯样式类用** —— 它是**行为开关**:`tabs.js` 读它决定方向键要不要切面板。
96
+ 只加类不改语义,用户会以为方向键坏了
97
+ - ❌ **`overflow-y` 不写 `hidden`** —— 一个轴设 `auto` 会让另一轴也自动化,选中态的 `border-bottom` 被裁掉
98
+
99
+ ---
@@ -90,6 +90,19 @@ JS 只切`data-state`,显隐与动画全交给 CSS。
90
90
 
91
91
  要接统一行为层,得先清这两条债。
92
92
 
93
+ ## 反例
94
+
95
+ - ❌ **把必须知道的信息只放在 tooltip 里** —— 触屏没有 hover,键盘用户也未必停留。
96
+ 判据很硬:tooltip 的文字**不能**与触发元素的可见文字/`aria-label` 重复 ——
97
+ 一旦重复,说明它承载了本该可见的信息
98
+ - ❌ **只做 hover、不做 focus** —— 键盘用户永远看不到(WCAG 1.4.13 要求 focus 也能出)
99
+ - ❌ **Esc 关不掉** —— WCAG 1.4.13 的硬性要求:hover/focus 出现的内容必须可以关掉
100
+ - ❌ **用 `display: none` 隐藏气泡** —— 读屏读不到;淡入用透明度
101
+ - ❌ **`role="tooltip"` 不给 `aria-label`** —— 光靠内容拿不到可访问名,读屏只会念"提示"(0.8.9 补的)
102
+ - ❌ **触屏只靠 hover 触发** —— 触屏上 hover 会"粘住"不褪;触屏必须走 JS 的 click / focus
103
+
104
+ ---
105
+
93
106
  ## 门禁
94
107
 
95
108
  `05-audit/tooltip-check.js`(契约 `gates` 里登记的唯一一道)——
@@ -89,6 +89,22 @@ const t = Tree.create(元素, { label: '目录' });
89
89
 
90
90
  `ai/components.json` 里 `tree` 的 `behaviorDebt` 为空。
91
91
 
92
+ ## 反例
93
+
94
+ - ❌ **↑↓ 只移动焦点、←→ 只展开不移动焦点** —— APG 是**两段式**:↑↓ 移动焦点,←→ 展开/收起
95
+ **并带着焦点走**。只做一半,键盘用户会觉得"按了没反应"
96
+ - ❌ **每个 `tree__item` 都 `tabindex="0"`** —— 整棵树只占**一个** Tab 位(roving tabindex);
97
+ 否则穿过 200 个节点要按 200 次 Tab
98
+ - ❌ **只靠旋转箭头表示展开/收起** —— 必须有**三态形状线索**(`tree__marker`):
99
+ 旋转只能看出"变过",看不出"现在是哪一态"
100
+ - ❌ **RTL 下收起箭头不翻向** —— 阿拉伯语用户看到的是一个指错方向的符号;
101
+ `tree.css` 里那条 `[dir="rtl"]` 就是为此
102
+ - ❌ **用 `margin` 做层级缩进** —— 缩进用 padding:`margin` 会把点击区一起缩窄,深层的节点点不中
103
+ - ❌ **把 ←→ 交给"左右移动"处理** —— 树里 ←→ 是展开/收起(APG 两段式),
104
+ 当成左右移动会被方向键库先吃掉
105
+
106
+ ---
107
+
92
108
  ## 门禁
93
109
 
94
110
  `05-audit/tree-check.js`
@@ -107,8 +107,16 @@
107
107
  '</ul>';
108
108
 
109
109
  if (collapsed) {
110
+ /* 🔴 判据必须查 `.tree__item`(类名),不能查 `role="treeitem"`。
111
+ 原因:这段跑在 `Tree.create()` **之前**,而 `role="treeitem"` 正是
112
+ `create()` 补上去的 —— 上面手写的 `<li>` 只有类名、没有 role。
113
+ ⇒ 按 role 查时,每一个 `g.parentElement.getAttribute('role')` 都是
114
+ `null`,条件恒假,**一个都没收起**,
115
+ 而页面小标题写着「2 · 默认全部收起」⇒ 演示与实物相反。
116
+ (0.11.9 由 rtl-render 的方向性字形探针发现:
117
+ 探针找 `.tree__item[aria-expanded="false"]` 一个都找不到。) */
110
118
  document.querySelectorAll('#' + id + ' [role="group"]').forEach(function (g) {
111
- if (g.parentElement.getAttribute && g.parentElement.getAttribute('role') === 'treeitem') {
119
+ if (g.parentElement.classList.contains('tree__item')) {
112
120
  g.hidden = true;
113
121
  g.parentElement.setAttribute('aria-expanded', 'false');
114
122
  }
@@ -87,6 +87,15 @@
87
87
  content: "\00B7"; /* · 叶子节点 */
88
88
  }
89
89
 
90
+ /* ---------- 书写方向(RTL)----------
91
+ ⚠️ 逻辑属性只能翻「框的位置」,翻不了**字形**:▼/▶ 是文字符号,不是边框
92
+ 画出来的三角(select 的 caret 用 border,天然对称,不用管)。
93
+ ⇒ RTL 里"收起"的箭头必须指向左,否则阿拉伯语用户看到的是一个指错方向的
94
+ 箭头 —— 这种错不报错、也不溢出,只能靠一条显式规则或一张基线图发现。 */
95
+ [dir="rtl"] .tree__item[aria-expanded="false"] > .tree__node .tree__marker::before {
96
+ content: "\25C0"; /* ◀ */
97
+ }
98
+
90
99
  /* ---------- 层级缩进(用左边框引导线)---------- */
91
100
  .tree ul ul {
92
101
  padding-inline-start: var(--sp-5);
@@ -357,10 +357,18 @@
357
357
  if (it.ul && it.li.getAttribute('aria-expanded') === 'true') {
358
358
  setExpanded(it, false); /* 先收起,不动焦点 */
359
359
  } else {
360
- /* 找到父节点并聚焦它(← 在收起状态下要"往回走")*/
361
- for (var i = cur; i > 0; i--) {
362
- var cand = visible()[i];
363
- if (it.li.contains(cand.li)) { focusAt(i); return; }
360
+ /* 找到父节点并聚焦它(← 在收起状态下要"往回走")
361
+ 🔴 0.11.9 修:循环原来从 `cur` **本身**开始,而
362
+ `it.li.contains(it.li)` 恒为 true ⇒ 第一次迭代就 focusAt(cur)
363
+ 然后 return ⇒「已收起时按 ← 回到父节点」**从来没生效过**,
364
+ 焦点原地不动,看起来像"按键没反应"。
365
+
366
+ ⚠️ 它一直没被发现,是因为 tree-check 那条断言在 demo 出现
367
+ 真实的收起节点之前一直走"跳过"分支 —— 跳过不算通过。
368
+ 现在从 cur - 1 往回找第一个包含本节点的可见项 = 最近的祖先。 */
369
+ var vis = visible();
370
+ for (var i = cur - 1; i >= 0; i--) {
371
+ if (vis[i].li.contains(it.li)) { focusAt(i); return; }
364
372
  }
365
373
  }
366
374
  return;
@@ -78,3 +78,19 @@ python -m http.server 8000 # 仓库根
78
78
 
79
79
  页面里的实验数据**全部是演示用示例数据**,不是真实实验结果。
80
80
  留出这条是因为:「长得像真实数据」的示例最容易被误引用。
81
+
82
+ ## 反例
83
+
84
+ - ❌ **栅格列写 `1fr` 而不是 `minmax(0, 1fr)`** —— 长表格、长代码会把列撑开,整页横向溢出。
85
+ 这是 Grid 的经典陷阱,`analysis-report.css` 里两处都写成 `minmax(0, 1fr)`
86
+ - ❌ **先做桌面并排、再用媒体查询缩回单栏** —— 默认就该是单栏,并排是宽屏**才发生**的事。
87
+ 反过来做,窄屏上得到的是"缩小的桌面版"
88
+ - ❌ **`position: sticky` 的辅助栏不配 `align-self: start`** —— 在 grid 里 sticky 不生效,
89
+ 那一栏会跟着页面一起滚走
90
+ - ❌ **再另造一个"侧栏卡片"** —— 直接复用 `card.css`;重复造会在换主题时多一个不一致的源头
91
+ - ❌ **用 inline style 写字号和颜色** —— inline 的优先级高于一切样式表(INVARIANT I-4),
92
+ 换肤时改不动,只能一路 `!important`
93
+ - ❌ **把组件 CSS 引在骨架之前** —— 顺序反了就要靠 `!important` 抢优先级;
94
+ "骨架在组件之前"这条是有门禁守着的
95
+
96
+ ---
@@ -43,3 +43,14 @@ python -m http.server 8000
43
43
 
44
44
  页面内所有数值均为**示例数据**,不构成任何业务或科研结论。
45
45
  留这句是因为「长得像真实数据」的示例最容易被误引用。
46
+
47
+ ## 反例
48
+
49
+ - ❌ **把这四个当成"同一种图表的四种皮肤"** —— 它们共享令牌与容器,但**不共享**坐标规则与数据格式,
50
+ 是四个用途。换皮肤换不来正确的表达
51
+ - ❌ **用 `table` 回答"涨还是跌"** —— 表格给的是具体值,趋势要看形状;
52
+ 那是 `sparkline` 的活,用表看趋势要一行行读数字
53
+ - ❌ **用完整坐标系画三五个点** —— 坐标轴、刻度、网格是为"值落在哪、误差多大"服务的;
54
+ 几个点的走向用迷你趋势图就够,画坐标系反而让人以为有精度
55
+
56
+ ---
@@ -91,3 +91,16 @@ python 05-audit/no-data-gate.py # 无数据态三条规则
91
91
  一旦把某个业务的字段写进通用库,库的边界就没了。
92
92
  - **不做图表**:指标卡只放数字。要图请用 `09-assets/` 的图表组件。
93
93
  - **不做标签页 / 折叠**:那是 `03-patterns/` 的组件,这里只给骨架。
94
+
95
+ ## 反例
96
+
97
+ - ❌ **缺的字段写成 `0`** —— `0` 是一个真实的值,用户会拿它去算同比。缺就是缺:
98
+ 写「未披露」+ 弱化色
99
+ - ❌ **缺的字段直接把节点删掉** —— 三个变体的 DOM 结构必须完全一致(节点数量与嵌套),
100
+ 删节点会让同一类页面越改越不像,最后没人能说清"这页该长什么样"
101
+ - ❌ **无数据时隐藏整块** —— 不隐藏、不冒充零,**必须写清原因**。
102
+ 空白让人以为是加载失败,反而会去刷新
103
+ - ❌ **三个变体各写一套类名** —— 允许变的是文案、数值、说明块;类名与语义标签不许变
104
+ - ❌ **换肤时改类名** —— 色对挂在 `section` 上,改类名等于换了一套结构,之后没法对比
105
+
106
+ ---
@@ -174,3 +174,16 @@ bash 05-audit/check-all.sh # 全量
174
174
  全部读**计算值/几何**而不是 CSS 文本 —— 因为"写了 ≠ 生效"在本系统上翻过车:
175
175
  `.rep h2 / .rep h3`(特异度 0,1,1)曾经压过 `.rep__title`(0,1,0),
176
176
  使 `--rep-title-fs` 静默失效、三套版式的标题被钉死在同一个字号。
177
+
178
+ ## 反例
179
+
180
+ - ❌ **拿它做分页 PDF / 印刷分页** —— 页边距、页眉页脚、页码、跨页控制**一律没有**,
181
+ 也不假装有。需要印刷排版,用专门的工具链
182
+ - ❌ **靠改选择器换版式** —— 换版式只改那六个变量(`--rep-measure` / `--rep-body-fs` /
183
+ `--rep-title-fs` / `--rep-lead-fs` / `--rep-body-lh` / `--rep-sec-num`),不改选择器
184
+ - ❌ **再发明第七种字体角色** —— 六个角色(眉题 / 元信息 / 图表说明 / 来源 / 编者注 / 风险披露)
185
+ 是**不多不少**的;多一种,读者就要重新学一遍"这行字是什么身份"
186
+ - ❌ **把它当 CMS 或模板引擎** —— 没有内容管理、没有模板继承、没有构建步骤,只有 CSS
187
+ - ❌ **自己写表格样式** —— 复用 `table.css`;两份表格样式在换主题时必然分叉
188
+
189
+ ---
@@ -101,3 +101,19 @@ var COLS = [
101
101
  python -m http.server 8000
102
102
  # http://127.0.0.1:8000/04-recipes/table/demo.html
103
103
  ```
104
+
105
+ ## 反例
106
+
107
+ - ❌ **靠数值大小猜语义** —— 不能用"小于 1 就乘 100"。`0.25` 是比例还是普通测量值,
108
+ 只能由列定义的 `format` 说
109
+ - ❌ **给"量"的正数加 `+`** —— 营收、成本、毛利率这类量没有 `+`;只有 `signed-percent`
110
+ 这类"变化量"才加。满屏 `+` 会让读者以为每个数都在变
111
+ - ❌ **负数用连字符 `-`** —— 用真减号 `−`;连字符在等宽数字里对不齐,也容易被当成项目符号
112
+ - ❌ **排序状态放在按钮上** —— 要放 `<th aria-sort="ascending|descending">`;
113
+ 放在按钮上读屏不会报告"这一列正按什么排序"
114
+ - ❌ **正负只靠红绿** —— 颜色之外必须有 `+` / `−` 符号(WCAG 1.4.1)
115
+ - ❌ **粘性表头配 `border-collapse: collapse`** —— `separate` 是 sticky 生效的前提,
116
+ 用 collapse 表头不会粘住
117
+ - ❌ **拿它当虚拟滚动表格用** —— 本组件不做虚拟滚动;超过 200 行请配分页
118
+
119
+ ---
package/CHANGELOG.md CHANGED
@@ -16,6 +16,111 @@
16
16
 
17
17
  (无)
18
18
 
19
+ ## [0.11.10] - 2026-10-29
20
+
21
+ ### Added
22
+
23
+ - **每个组件现在都写清了「什么时候不该用」**。此前 33 个组件里只有 11 个有反例章,
24
+ 其余只写"✅ 这么用"—— 一份只写正例的文档,读者学不到边界。新增 20 章、112 条
25
+ (全库现有 195 条),每条的依据都能在本库源码里指到具体位置:
26
+ `button.css` 的一条注释、`combobox.js` 的一个分支、某道门禁守的一条规则。
27
+ - `05-audit/do-dont-gate.py` —— 反例章的门禁。它**不判"理由对不对"**(那是判断题),
28
+ 只判两件机器能抓的事:章节在、条目够;以及**反例里提到的本库标识符必须真实存在**
29
+ (修饰符类名 / CSS 变量 / 文件路径)。编造的反例恰恰会在这里露馅:一个人可以凭想象
30
+ 写出流畅的理由,但写不出一个真实存在的类名。9 条自检,其中 5 条必须红。
31
+
32
+ ### Changed
33
+
34
+ - 章节标题按**语义**识别,不按字面:`## 反例` / `## 两个反例` / `## 反模式` /
35
+ `## 何时**不该**用` 都算。要求统一标题的后果,是让人为了过门禁去改文案。
36
+ - 已有章节里 6 条"只有结论、没有理由"的条目补全了理由
37
+ (`overlay` 的「见上」「混乱」「廉价感」、`states` 的「死路」、`card` 的「游戏感」)。
38
+ - `progress` 新增的四条并入原有的「何时不该用」,没有另开一章 ——
39
+ 同一个意思写两遍,读者会在两处看到两套说法。
40
+
41
+ ## [0.11.9] - 2026-10-29
42
+
43
+ **把「我们说做到的」和「能证明做到的」对齐**。这一版没有新组件,改的全是
44
+ 「话」与「证据」之间的落差 —— 每一处落差在改了之后都能被一条命令复现。
45
+
46
+ ### Added
47
+
48
+ - **无障碍一致性声明**(`docs/ACCESSIBILITY.md` 重写)。此前这一页写的是
49
+ 「我们验了什么」,读者真正要的是「你按哪一版标准、哪几条达标、哪几条不达标」。
50
+ 现在是 **51 行 WCAG 2.2 AA 逐条声明**(✅ 27 · ◐ 7 · ✗ 0 · — 10),
51
+ 四态各有定义:✅ 有自动化判据守着、◐ 部分达成(附缺口)、
52
+ ✗ 未达成(本版为 0)、— 不适用(附不适用的理由)。
53
+ 每一行都给出可复现的命令或判据名 —— 没有证据的行不许写 ✅。
54
+ - **真机读屏协议**(`docs/A11Y-MANUAL-PROTOCOL.md`):20 分钟、两条路径
55
+ (NVDA + Chrome / VoiceOver + Safari)、6 个页面 × 5 件事。结果表**全部写着
56
+ 「未执行」** —— 这一页记录的是边界,不是进度。自动化能给的是可访问性树,
57
+ 给不了「读屏器念出来到底顺不顺」,而后者才是用户的实际体验。
58
+ - **组件文档内嵌可直接操作的示例**(35 页)。此前每页只有 2–4 个链接指向
59
+ `demo.html`:想知道按钮长什么样,得跳出去。现在是 same-origin iframe 活标本 ——
60
+ 自动撑高(多档采样,不靠猜高度)、跟随明暗主题、脚本可交互;
61
+ `file://` 打开时退化为固定高度可滚动(跨源拿不到内部高度,这是浏览器的规矩,
62
+ 不是没做)。
63
+ - **无障碍树快照判据**(`a11y-ax`):10 个页面、575 行朗读序列,逐行 diff;
64
+ 同时硬判 WCAG 4.1.2 —— 每个可交互控件必须有非空 accessible name。
65
+ 这是声明里「名称/角色/值」那一行唯一的证据来源。
66
+ - **缩放 / 重排 / 焦点遮挡判据**(`zoom-reflow`):38 页,覆盖 1.4.4 文本 200%、
67
+ 1.4.10 320px 重排、2.4.11 焦点不被吸顶栏遮挡。
68
+ - **RTL 渲染判据**(`rtl-render`):37 页。判三件事 —— RTL 下页面不横滚、
69
+ 把正文换成阿拉伯语 / 希伯来语并膨胀到 1.3 倍仍不横滚、方向性字形真的镜像
70
+ (收起箭头 ▶→◀ 必须翻;展开箭头 ▼ 与叶子标记 · **必须不翻**)。
71
+ - **RTL 视觉基线** 13 页(`--dir rtl`):覆盖「镜像错了但不溢出不改字形」这一类
72
+ 自动化看不见的坏法(抽屉从错误的一侧滑入、栅格左右互换、上/下一页顺序反了)。
73
+ - **CI 字体安装脚本**(`05-audit/ci-fonts.sh`):ubuntu runner 默认没有 CJK /
74
+ 阿拉伯 / 希伯来字体,不装的话视觉基线会录成一堆方框 —— 而"方框"也是有像素的,
75
+ 比对会"通过",直到真回归也看不出来。脚本装完还会点名验一遍三类字体各至少一条。
76
+ - **失败时上传视觉证据**(两个工作流):`_current-*` 与 `_diff-*` 打成 artifact。
77
+ 没有它,CI 上的 `FAIL … 7.6%` 只能靠猜 —— 本机看不到那台机器的渲染结果。
78
+
79
+ ### Fixed
80
+
81
+ - **按钮在 200% 字号下被裁字**。`overflow: hidden` 会把 flex/grid 项的自动最小尺寸
82
+ 变成 0,而它当初要防的重叠已由 grid 叠放解决。改为 `min-width: max-content`。
83
+ - **`tree` 演示第 2 例「默认全部收起」实际是全部展开**。收起逻辑查
84
+ `role="treeitem"`,而那个 `role` 正是 `Tree.create()` 之后才补上的 ——
85
+ 判据跑在它之前,条件恒假。改为查 `.tree__item` 类名。
86
+ - **`tree` 的折叠箭头在 RTL 下不镜像**。全库此前**没有任何一条 `[dir="rtl"]` 规则**:
87
+ 逻辑属性只翻「框」,翻不了「字形」,而 ▶ 是文字符号不是边框画出来的三角。
88
+ - **视觉回归不再依赖 Pillow**。缺 Pillow 时那道门禁此前直接跳过,而本仓库自己的
89
+ 规矩是「跳过不是通过」。比对改为零依赖 PNG 解码(zlib + 5 种行过滤),
90
+ 并与 Pillow 做过逐字节交叉验证(393×1744 的基线图,解码结果完全相同)。
91
+ - **`404.html` 的站点根路径被判据当成了文件系统路径**。`/frontend-lib/01-tokens/*.css`
92
+ 是 Pages 的站点根写法(404 页会在任意层级被服务,只能写绝对路径),
93
+ 而判据把它交给 `os.path.join` —— 前导 `/` 会让 `join` 丢掉前面的参数,
94
+ 于是变成 `/frontend-lib/...`。这台机器的仓库**正好**在 `E:\frontend-lib`,
95
+ Windows 就把它解析到真实文件上,**本机永远绿**;换到 Linux 立刻红 3 处。
96
+ 改为显式的站点根映射。教训不是"路径写错了",是**环境顺手掩盖了判据缺陷**。
97
+ - **`SUPPORT.md` 指向一个不入库的文件**。原文写「证据文件:`10-review/engine-evidence.json`」,
98
+ 而那一行在 `.gitignore` 里(生成物、每台机器不同)⇒ 读者点开是 404,
99
+ npm 包里也没有。改为指向**生成它的脚本**,并说明产物本就不该入库。
100
+ 本机能过只是因为磁盘上恰好留着上次生成的产物。
101
+ - **门禁失败时的诊断会给出错误结论**。跑批器原来用
102
+ `grep '^\s*\[' | head -6` 显示失败输出:`head -6` 把 13 节的 release 门禁
103
+ 截成 `[1]`…`[6]`,**看起来像前六节全挂了**(实际挂的是第 10 节);
104
+ `grep` 只认节标题,把 `· 文件 原因` 这种明细行全丢掉 ——
105
+ `wiring` 报了 3 处、日志里一处都看不见。改为原样回显失败门禁的完整输出
106
+ (超过 160 行才截断,且保留末尾 40 行 —— 崩溃栈在那里)。
107
+ - **视觉基线改为按平台分集**(`_baseline-<平台>[-dark|-rtl]`)。本库不含
108
+ `@font-face`,字体走系统栈,同一份 HTML 换到另一个系统整页像素差 7~11% ——
109
+ 与回归无关。基线目录名现在带平台,各平台各存一套;某平台没有基线集时
110
+ 门禁**直接判红**并说明去哪弄,而不是拿另一台机器的渲染当标准。
111
+
112
+ ### Changed
113
+
114
+ - **发版改为 tag 触发**(`.github/workflows/release.yml`):`v*` tag → 校验
115
+ tag 与版本一致 → 跑发布前门禁 → `npm publish --provenance` → 从 CHANGELOG
116
+ 对应小节建 Release(小节为空则拒绝)。`--provenance` 让 npm 给包签上
117
+ 「这确实由这个仓库、这条工作流构建」,本地手工发版发不出这个签名。
118
+ - `CONTRIBUTING.md` 的环境依赖改成两份实话:Node + Chromium(行为与渲染类判据)、
119
+ Pillow(仅交叉验证用,不再是运行时依赖)。
120
+ - 发版工作流多了两条实话:发布前**先装 CI 字体**(基线锚在字体上),
121
+ 以及 `workflow_dispatch` 只用于**补录某平台基线**、不会顺手发版。
122
+
123
+
19
124
  ## [0.11.8] - 2026-10-29
20
125
 
21
126
  **补上文档站最大的真空**:令牌用法、焦点环、动效时长、暗色切换、逻辑属性这些
package/CONTRIBUTING.md CHANGED
@@ -8,14 +8,47 @@ npm run gate # 全量:含浏览器类
8
8
  npm run gate:fast # 只跑静态层,不用装浏览器
9
9
  ```
10
10
 
11
- 全量是 **183 道门禁**(本机约 12 分钟)。
11
+ 全量是 **192 道门禁**(本机约 12 分钟)。
12
12
 
13
- 两道环境依赖要自己装,否则对应的门禁会**跳过**(跳过不算通过):
13
+ 环境依赖只有一套(Node + Chromium),缺了对应的门禁会**跳过**(跳过不算通过):
14
14
 
15
15
  | 依赖 | 少了会怎样 | 装法 |
16
16
  |---|---|---|
17
- | Node + Chromium | `unit` / `responsive` / `firefox` 与全部行为脚本跳过 | 按 `package.json` 的 `engines` 装 Node,浏览器由脚本自己拉起 |
18
- | Pillow | `visual` 与 `visual-dark` 跳过 ⇒ **视觉回归一次都不跑** | `pip install pillow`(或用 `PY_PIL=<解释器>` 指一个装了 Pillow 的) |
17
+ | **Node ≥ 22 + Chromium** | `unit` / `responsive` / `firefox` 与全部行为、渲染类门禁(无障碍 / RTL / 键盘 / 视觉基线 / 活标本)**跳过** | 按 `package.json` 的 `engines` 装 Node;浏览器由 `05-audit/browser.js` 自己拉起(`puppeteer-core` 已在 devDependencies) |
18
+
19
+ Python 侧**没有第三方依赖**了:视觉比对的 PNG 解码是本仓库自己的
20
+ `05-audit/pngdiff.py`(zlib + 行过滤,与 Pillow 做过逐字节对齐)。
21
+ Pillow 只在你想复核那次对齐时才有用,装了不加分、不装也不跳过。
22
+
23
+ RTL 有**两套**证据,需要分别跑、分别更新:
24
+
25
+ ```bash
26
+ node 05-audit/rtl-render.js # 渲染判据(37 页)
27
+ python 05-audit/shot-baseline.py --check --dir rtl # 视觉基线(13 页)
28
+ ```
29
+
30
+ **视觉基线按平台分集**:目录名是 `_baseline-<平台>[-<scheme>][-rtl]`,
31
+ `<平台>` 取 `sys.platform`(`win32` / `linux` / `darwin`)。原因是本库
32
+ **不含 `@font-face`**,字体走系统栈 —— 同一份 HTML 换到另一个系统,
33
+ 字宽、行高、换行位置全变,整页像素差 7~11%,跟"回归"一点关系都没有。
34
+ 所以:
35
+
36
+ - 本机没有**当前平台**的基线集 ⇒ 这门门禁**直接判红**(不是跳过):
37
+ 没有基线就没人能说"观感没变"。用 `--record` 在你**有权代表的那台机器上**
38
+ 录一套,连同 `_stamp.json` 一起提交;
39
+ - **不要**在 CI 上随手 `--record`:那等于把 CI 的渲染当成标准,
40
+ 以后真回归反而看不出来;
41
+ - Linux 那套的字体由 `05-audit/ci-fonts.sh` 统一安装(两个工作流都调它,
42
+ 不各抄一份 apt 清单)。字体版本升级会让 CI 红而本机绿 —— 确认是字体变化,
43
+ 就重录 Linux 那套;这也是"基线锚在渲染环境上"的固有代价。
44
+
45
+ **加新组件时请注意**:若它含有 **方向性字形**(箭头、指示符)、
46
+ **左右不对称的槽位/边框/滑入方向**、或**顺序有语义**(上一页/下一页、首列),
47
+ 就必须把它的 demo 加进 `shot-baseline.py` 的 `RTL_PAGES`。
48
+ RTL 视觉基线**故意不是全量**(纯文本页在 RTL 下与 LTR 是同一套盒模型,
49
+ 多录不增加判据),但反过来 —— 命中上面三条却没进表的页面,
50
+ 它的 RTL 观感**没有任何门禁看着**:`rtl-render` 只抓溢出与字形,
51
+ `rtl-check` 只查物理属性,两者都看不见"镜像错了"。
19
52
 
20
53
  ⚠️ 上面的数字写在**代码块外**是有意的:`doc-facts-gate` 会核对
21
54
  「文档里的门禁数」与 `check-all.sh` 的真实注册数,而**代码块内的内容被豁免**
@@ -72,6 +105,12 @@ fix: <一句话说清改了什么、为什么>
72
105
  两者的差别就是问题的形状(这正是 E41 的发现方式)。
73
106
  5. **豁免要写清理由,并列举式。** 「这份代码的使用者控制不了它」是唯一的豁免理由;
74
107
  新增豁免必须显式列出来,否则会被抓到 —— 宁可多报一次,也不要默默放过。
108
+ 6. **新组件必须写「反例」章。** 至少 3 条,每条说清「不该怎么用 + 为什么」。
109
+ 反例里提到的类名、CSS 变量、文件名必须**真实存在** —— `do-dont` 门禁会逐个核。
110
+ 这条不是为了凑数:只写"✅ 这么用"的文档,读者学不到边界;
111
+ 而边界恰恰是组件库最该交付的东西。标题怎么写都行
112
+ (`## 反例` / `## 反模式` / `## 何时不该用` 都认),内容不能省。
113
+ **不要为了过门禁改文案**:判据认的是"这段话在讲不该怎么用",不是你用哪个标题。
75
114
 
76
115
  ## 提 PR 之前
77
116
 
package/README.zh-CN.md CHANGED
@@ -388,7 +388,9 @@ bash 05-audit/check-all.sh # 全量门禁约 130 项,本机约 8 分钟
388
388
  ### 经过实际验证的
389
389
 
390
390
  - 桌面 Chrome / Edge / Firefox,跑过全量门禁(含真浏览器渲染与无障碍扫描)。
391
- - 视觉基线在 `10-review/shots/_baseline/`,由 `shot-baseline` 门禁逐张比对。
391
+ - 视觉基线在 `10-review/shots/_baseline-<平台>/`(本库字体走系统栈,
392
+ 渲染结果跟操作系统绑定,所以**按平台各存一套**),由 `shot-baseline` 门禁逐张比对。
393
+
392
394
  - 每个组件的键盘路径 / ARIA / 对比度都有契约守着(`05-audit/` 下百余道检查)。
393
395
  - 数据语义有机械断言:例如科学绘图的「缺失值不被当作零」、
394
396
  表格的「不按数值大小猜百分比」,都有**反例对照**证明判据有效。
package/START-HERE.md CHANGED
@@ -34,7 +34,7 @@
34
34
 
35
35
  | 组件 | 文件 | 行数 | JS | 什么时候要 |
36
36
  |---|---|---|---|---|
37
- | 按钮 | `02-primitives/button/button.css` | 353 | — | 几乎总要 |
37
+ | 按钮 | `02-primitives/button/button.css` | 357 | — | 几乎总要 |
38
38
  | 输入框 | `02-primitives/input/input.css` | 239 | — | 有表单就要 |
39
39
  | 标签 / 徽章 | `02-primitives/badge/badge.css` | 141 | — | 有状态标记就要("已披露""停牌") |
40
40
  | 卡片 | `02-primitives/card/card.css` | 246 | — | 有分组内容就要 |
@@ -52,7 +52,7 @@
52
52
  | 动作菜单(beta) | `03-patterns/dropdown/dropdown.css` | 135 | **必需 451** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
53
53
  | 文字提示 | `03-patterns/tooltip/tooltip.css` | 89 | **必需 288** (`tooltip.js`) | 图标按钮或缩写需要一句话解释就要 |
54
54
  | 分页 | `03-patterns/pagination/pagination.css` | 195 | **必需 395** (`pagination.js`) | 数据多到要翻页就要 |
55
- | 树形 | `03-patterns/tree/tree.css` | 113 | **必需 456** (`tree.js`) | 层级数据(文件 / 组织 / 分类)要展开就要 |
55
+ | 树形 | `03-patterns/tree/tree.css` | 122 | **必需 464** (`tree.js`) | 层级数据(文件 / 组织 / 分类)要展开就要 |
56
56
  | 侧边抽屉(beta) | `03-patterns/drawer/drawer.css` | 129 | **必需 258** (`drawer.js`) | 要从侧边滑出面板就要 |
57
57
  | 弹窗 / 吐司(beta) | `03-patterns/overlay/overlay.css` | 319 | **必需 563** (`overlay.js`) | 需要弹层(吐司 / 模态)就要 |
58
58
  | 状态(空 / 加载 / 错 / 成功) | `03-patterns/states/states.css` | 252 | — | **凡是会异步取数就要** |
package/SUPPORT.md CHANGED
@@ -24,7 +24,8 @@
24
24
 
25
25
  ## 自动化覆盖的环境
26
26
 
27
- 证据文件:`10-review/engine-evidence.json`(由 `05-audit/engine-gate.js` 生成,可复跑)。
27
+ 下面这张表由 `05-audit/engine-gate.js` 在本机跑出来:`node 05-audit/engine-gate.js`。
28
+ 产物是**你这台机器**的结果,不入库,也不该入库 —— 别人的环境不是你的环境。
28
29
 
29
30
  | 平台 | 引擎 | 状态 | 覆盖内容 |
30
31
  |---|---|---|---|
@@ -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.8",
4
+ "libraryVersion": "0.11.10",
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 下的专属门禁 ⇒ 还没被机器验证过,不要用",
@@ -990,7 +990,8 @@
990
990
  },
991
991
  "aria": {
992
992
  "roles": [
993
- "group"
993
+ "group",
994
+ "treeitem"
994
995
  ],
995
996
  "attrs": [
996
997
  "expanded",
@@ -1 +1 @@
1
- .btn {display:inline-grid;grid-template-areas:"stack";place-items:center;font-family:inherit;font-size:var(--fs-sm);font-weight:500;line-height:1;white-space:nowrap;min-height:44px;padding:0 var(--sp-5);border:1px solid transparent;border-radius:var(--r-sm);cursor:pointer;user-select:none;text-decoration:none;overflow:hidden;max-width:100%;transition:background-color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);}.btn.btn--sm {position:relative;min-height:var(--sz-touch-sm, 32px);padding:0 var(--sp-3);font-size:var(--fs-xs);}.btn.btn--sm::after {content:"";position:absolute;inset-inline-start:0;inset-inline-end:0;top:50%;height:var(--sz-touch, 44px);transform:translateY(-50%);}.btn.btn--md {min-height:44px;padding:0 var(--sp-5);}.btn.btn--lg {min-height:52px;padding:0 var(--sp-6);font-size:var(--fs-base);}.btn--primary {background:var(--accent);color:var(--text-on-accent);}@media (hover:hover) and (pointer:fine) {.btn--primary:hover {background:var(--accent-hover);}}.btn--secondary {background:var(--surface);color:var(--text-primary);border-color:var(--border-control);}@media (hover:hover) and (pointer:fine) {.btn--secondary:hover {background:var(--surface-sunken);border-color:var(--text-tertiary);}}.btn--ghost {background:transparent;color:var(--text-secondary);}@media (hover:hover) and (pointer:fine) {.btn--ghost:hover {background:var(--surface-sunken);color:var(--text-primary);}}.btn--danger {background:var(--danger);color:var(--text-on-accent);}@media (hover:hover) and (pointer:fine) {.btn--danger:hover {background:var(--danger-hover);}}.btn:active {transform:translateY(var(--lift-sm));}.btn:focus-visible {outline:2px solid var(--accent);outline-offset:2px;}.btn:disabled, .btn[aria-disabled="true"] {background:var(--surface-sunken);color:var(--text-tertiary);border-color:var(--border-decor);cursor:not-allowed;}@media (hover:hover) and (pointer:fine) {.btn:disabled:hover, .btn[aria-disabled="true"]:hover {background:var(--surface-sunken);transform:none;}}.btn:disabled:active, .btn[aria-disabled="true"]:active {transform:none;}.btn[aria-busy="true"] {cursor:progress;pointer-events:none;}.btn__label {grid-area:stack;min-width:0;}.btn[aria-busy="true"] .btn__label {opacity:0;}.btn__spinner {grid-area:stack;opacity:0;width:16px;height:16px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:btn-spin 700ms linear infinite;}.btn[aria-busy="true"] .btn__spinner {opacity:1;}@keyframes btn-spin {to {transform:rotate(360deg);}}@media (prefers-reduced-motion:reduce) {.btn__spinner {animation-duration:1.2s !important;}}.btn[data-state="success"] {background:var(--success);color:var(--text-on-accent);}.btn[data-state="success"] .btn__label {opacity:1;}.btn[data-state="success"] .btn__spinner {opacity:0;}.btn--icon {width:44px;padding:0;}.btn--icon.btn--sm {width:32px;}.btn--icon.btn--lg {width:52px;}.btn--block {display:flex;width:100%;}
1
+ .btn {display:inline-grid;grid-template-areas:"stack";place-items:center;font-family:inherit;font-size:var(--fs-sm);font-weight:500;line-height:1;white-space:nowrap;min-height:44px;padding:0 var(--sp-5);border:1px solid transparent;border-radius:var(--r-sm);cursor:pointer;user-select:none;text-decoration:none;min-width:max-content;transition:background-color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out), color var(--dur-fast) var(--ease-out), transform var(--dur-fast) var(--ease-out);}.btn.btn--sm {position:relative;min-height:var(--sz-touch-sm, 32px);padding:0 var(--sp-3);font-size:var(--fs-xs);}.btn.btn--sm::after {content:"";position:absolute;inset-inline-start:0;inset-inline-end:0;top:50%;height:var(--sz-touch, 44px);transform:translateY(-50%);}.btn.btn--md {min-height:44px;padding:0 var(--sp-5);}.btn.btn--lg {min-height:52px;padding:0 var(--sp-6);font-size:var(--fs-base);}.btn--primary {background:var(--accent);color:var(--text-on-accent);}@media (hover:hover) and (pointer:fine) {.btn--primary:hover {background:var(--accent-hover);}}.btn--secondary {background:var(--surface);color:var(--text-primary);border-color:var(--border-control);}@media (hover:hover) and (pointer:fine) {.btn--secondary:hover {background:var(--surface-sunken);border-color:var(--text-tertiary);}}.btn--ghost {background:transparent;color:var(--text-secondary);}@media (hover:hover) and (pointer:fine) {.btn--ghost:hover {background:var(--surface-sunken);color:var(--text-primary);}}.btn--danger {background:var(--danger);color:var(--text-on-accent);}@media (hover:hover) and (pointer:fine) {.btn--danger:hover {background:var(--danger-hover);}}.btn:active {transform:translateY(var(--lift-sm));}.btn:focus-visible {outline:2px solid var(--accent);outline-offset:2px;}.btn:disabled, .btn[aria-disabled="true"] {background:var(--surface-sunken);color:var(--text-tertiary);border-color:var(--border-decor);cursor:not-allowed;}@media (hover:hover) and (pointer:fine) {.btn:disabled:hover, .btn[aria-disabled="true"]:hover {background:var(--surface-sunken);transform:none;}}.btn:disabled:active, .btn[aria-disabled="true"]:active {transform:none;}.btn[aria-busy="true"] {cursor:progress;pointer-events:none;}.btn__label {grid-area:stack;min-width:0;}.btn[aria-busy="true"] .btn__label {opacity:0;}.btn__spinner {grid-area:stack;opacity:0;width:16px;height:16px;border:2px solid currentColor;border-top-color:transparent;border-radius:50%;animation:btn-spin 700ms linear infinite;}.btn[aria-busy="true"] .btn__spinner {opacity:1;}@keyframes btn-spin {to {transform:rotate(360deg);}}@media (prefers-reduced-motion:reduce) {.btn__spinner {animation-duration:1.2s !important;}}.btn[data-state="success"] {background:var(--success);color:var(--text-on-accent);}.btn[data-state="success"] .btn__label {opacity:1;}.btn[data-state="success"] .btn__spinner {opacity:0;}.btn--icon {width:44px;padding:0;}.btn--icon.btn--sm {width:32px;}.btn--icon.btn--lg {width:52px;}.btn--block {display:flex;width:100%;}
@@ -1 +1 @@
1
- .tree {font-size:var(--fs-sm);color:var(--text-primary);outline:none;}.tree ul {margin:0;padding:0;list-style:none;}.tree__item {position:relative;}.tree__node {display:flex;align-items:center;gap:var(--sp-2);min-height:32px;padding:0 var(--sp-2);border-radius:var(--r-xs);cursor:pointer;user-select:none;}.tree__node:focus-visible {outline:var(--focus-ring-width) solid var(--focus-ring-color);outline-offset:calc(-1 * var(--focus-ring-width));}@media (hover:hover) and (pointer:fine) {.tree__node:hover {background:var(--surface-sunken);}}.tree__node[aria-selected="true"] {background:var(--accent-soft);color:var(--text-primary);font-weight:500;}.tree__marker {flex:0 0 auto;width:1em;font-size:.8em;color:var(--text-tertiary);text-align:center;}.tree__item[aria-expanded="true"] > .tree__node .tree__marker::before {content:"\25BC";}.tree__item[aria-expanded="false"] > .tree__node .tree__marker::before {content:"\25B6";}.tree__item:not([aria-expanded]) > .tree__node .tree__marker::before {content:"\00B7";}.tree ul ul {padding-inline-start:var(--sp-5);border-inline-start:1px solid var(--border-decor);margin-inline-start:var(--sp-4);}.tree__label {flex:1 1 auto;}.tree__count {flex:0 0 auto;padding:0 var(--sp-1);font-size:var(--fs-xs);font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--text-tertiary);background:var(--surface-sunken);border-radius:var(--r-pill);}@media (prefers-reduced-motion:reduce) {.tree__marker {transition:none;}}
1
+ .tree {font-size:var(--fs-sm);color:var(--text-primary);outline:none;}.tree ul {margin:0;padding:0;list-style:none;}.tree__item {position:relative;}.tree__node {display:flex;align-items:center;gap:var(--sp-2);min-height:32px;padding:0 var(--sp-2);border-radius:var(--r-xs);cursor:pointer;user-select:none;}.tree__node:focus-visible {outline:var(--focus-ring-width) solid var(--focus-ring-color);outline-offset:calc(-1 * var(--focus-ring-width));}@media (hover:hover) and (pointer:fine) {.tree__node:hover {background:var(--surface-sunken);}}.tree__node[aria-selected="true"] {background:var(--accent-soft);color:var(--text-primary);font-weight:500;}.tree__marker {flex:0 0 auto;width:1em;font-size:.8em;color:var(--text-tertiary);text-align:center;}.tree__item[aria-expanded="true"] > .tree__node .tree__marker::before {content:"\25BC";}.tree__item[aria-expanded="false"] > .tree__node .tree__marker::before {content:"\25B6";}.tree__item:not([aria-expanded]) > .tree__node .tree__marker::before {content:"\00B7";}[dir="rtl"] .tree__item[aria-expanded="false"] > .tree__node .tree__marker::before {content:"\25C0";}.tree ul ul {padding-inline-start:var(--sp-5);border-inline-start:1px solid var(--border-decor);margin-inline-start:var(--sp-4);}.tree__label {flex:1 1 auto;}.tree__count {flex:0 0 auto;padding:0 var(--sp-1);font-size:var(--fs-xs);font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--text-tertiary);background:var(--surface-sunken);border-radius:var(--r-pill);}@media (prefers-reduced-motion:reduce) {.tree__marker {transition:none;}}
@@ -297,9 +297,9 @@ if (it.ul && it.li.getAttribute('aria-expanded') === 'true') {
297
297
  setExpanded(it, false);
298
298
  } else {
299
299
 
300
- for (var i = cur; i > 0; i--) {
301
- var cand = visible()[i];
302
- if (it.li.contains(cand.li)) { focusAt(i); return; }
300
+ var vis = visible();
301
+ for (var i = cur - 1; i >= 0; i--) {
302
+ if (vis[i].li.contains(it.li)) { focusAt(i); return; }
303
303
  }
304
304
  }
305
305
  return;
package/dist/COSTS.md CHANGED
@@ -19,7 +19,7 @@
19
19
  | 档 | 组件 | tokens(必付) | CSS | JS | **合计** | 源码版合计 | 差 |
20
20
  |---|---|---:|---:|---:|---:|---:|---:|
21
21
  | primitive | `badge` | 2.6 KB | 0.5 KB | — | **3.1 KB** | 20.8 KB | −17.7 KB |
22
- | primitive | `button` | 2.6 KB | 1.0 KB | — | **3.6 KB** | 24.2 KB | −20.6 KB |
22
+ | primitive | `button` | 2.6 KB | 1.0 KB | — | **3.6 KB** | 24.4 KB | −20.8 KB |
23
23
  | primitive | `card` | 2.6 KB | 0.9 KB | — | **3.6 KB** | 21.6 KB | −18.0 KB |
24
24
  | primitive | `choice` | 2.6 KB | 1.1 KB | — | **3.7 KB** | 23.4 KB | −19.7 KB |
25
25
  | primitive | `combobox` | 2.6 KB | 1.0 KB | 5.4 KB | **9.0 KB** | 27.7 KB | −18.8 KB |
@@ -44,12 +44,12 @@
44
44
  | pattern | `states` | 2.6 KB | 0.8 KB | — | **3.5 KB** | 22.1 KB | −18.6 KB |
45
45
  | pattern | `tabs` | 2.6 KB | 0.7 KB | 2.0 KB | **5.3 KB** | 25.0 KB | −19.7 KB |
46
46
  | pattern | `tooltip` | 2.6 KB | 0.5 KB | 1.9 KB | **5.0 KB** | 24.7 KB | −19.7 KB |
47
- | pattern | `tree` | 2.6 KB | 0.6 KB | 2.9 KB | **6.2 KB** | 25.6 KB | −19.5 KB |
47
+ | pattern | `tree` | 2.6 KB | 0.6 KB | 2.9 KB | **6.2 KB** | 26.2 KB | −20.1 KB |
48
48
 
49
49
  ## 三、全库
50
50
 
51
51
  - 全部组件都用上(min,含 tokens 一份):**60.0 KB**
52
- - 源码版(带注释)全库 gzip:288.2 KB
52
+ - 源码版(带注释)全库 gzip:289.0 KB
53
53
 
54
54
  > ⚠️ 源码版那个数字**不是**要付的钱:它包含我们的注释(全库 CSS 有 66% 是注释,字节口径)。
55
55
  > 它的用途只有一个 —— 让人看清"dist 到底省了多少"。
@@ -0,0 +1,86 @@
1
+ # 无障碍人工测试协议(读屏)
2
+
3
+ **为什么需要这份东西**:自动检查(axe / 可访问性树)能证明"每个控件有名字、顺序稳定",
4
+ 但证明不了"念出来是否**合理**"。这一页把这件事变成**可执行、可复现、可填表**的动作,
5
+ 时长约 20 分钟。
6
+
7
+ **当前状态:未执行。** 下面那张结果表是空的 —— 这不是省略,是事实。
8
+ 跑完并填上之后,[无障碍一致性声明](ACCESSIBILITY.md) 里"没有真机读屏记录"那一条才能划掉。
9
+
10
+ ---
11
+
12
+ ## 一 · 准备
13
+
14
+ | 路径 | 系统 | 读屏 | 浏览器 |
15
+ |---|---|---|---|
16
+ | A | Windows 10/11 | [NVDA](https://www.nvaccess.org/download/)(免费) | Chrome 或 Edge |
17
+ | B | macOS | VoiceOver(系统自带,`Cmd + F5`) | Safari |
18
+
19
+ 被测页面(本地起一个静态服务,仓库根目录执行):
20
+
21
+ ```bash
22
+ python3 -m http.server 8000 --bind 127.0.0.1
23
+ # 然后访问 http://127.0.0.1:8000/02-primitives/button/demo.html
24
+ ```
25
+
26
+ 要测的 6 个页面(一个组件一类交互语义,覆盖主要风险):
27
+
28
+ | # | 页面 | 它想证明什么 |
29
+ |---|---|---|
30
+ | 1 | `02-primitives/button/demo.html` | 按钮的名字念得出、禁用与加载态念得对 |
31
+ | 2 | `02-primitives/input/demo.html` | 标签与错误说明能被一起念出来 |
32
+ | 3 | `02-primitives/choice/demo.html` | 单选 / 复选的分组关系念得对 |
33
+ | 4 | `03-patterns/tabs/demo.html` | 标签页的选中态与面板归属念得对 |
34
+ | 5 | `03-patterns/overlay/demo.html` | 对话框打开时焦点进入、关闭后归还 |
35
+ | 6 | `04-recipes/table/demo.html` | 表头与单元格的关联念得出来 |
36
+
37
+ ---
38
+
39
+ ## 二 · 每页只做这 5 件事(NVDA 键位在括号里)
40
+
41
+ 1. **从页面顶部按 Tab 走一遍**,听到的每个控件都要有**说得通的名字**。
42
+ 听到 "button"、"未标记"、"undefined"、只有图标名 ⇒ ❌ 记下来。
43
+ 2. **用读屏的"下一个标题"跳(NVDA `H`)**,标题层级应当是 1 → 2 → 3,不跳级。
44
+ 3. **把焦点放到一个禁用控件上**,它应当被念成"不可用"(而不是干脆跳过或念成正常按钮)。
45
+ 4. **触发表单错误**(页面里带校验的那块),错误文字应当被**紧跟控件**念出,
46
+ 不是只在屏幕右上角飘一条。
47
+ 5. **打开一个浮层(对话框 / 抽屉)**:焦点应进入浮层内部,`Esc` 关闭后
48
+ 焦点应回到**打开它的那个按钮**。
49
+
50
+ > 判读标准只有一条:**不看屏幕,只听**。听到的内容能不能让你知道该做什么。
51
+ > 一旦你需要看屏幕才能确定发生了什么,那一处就是缺陷。
52
+
53
+ ---
54
+
55
+ ## 三 · 结果表(填这里)
56
+
57
+ | 页面 | ① 名字 | ② 标题层级 | ③ 禁用态 | ④ 错误说明 | ⑤ 浮层焦点 | 备注(听到了什么) |
58
+ |---|---|---|---|---|---|---|
59
+ | button | 未执行 | 未执行 | 未执行 | — | — | |
60
+ | input | 未执行 | 未执行 | 未执行 | 未执行 | — | |
61
+ | choice | 未执行 | 未执行 | 未执行 | — | — | |
62
+ | tabs | 未执行 | 未执行 | — | — | — | |
63
+ | overlay | 未执行 | 未执行 | — | — | 未执行 | |
64
+ | table | 未执行 | 未执行 | — | — | — | |
65
+
66
+ 状态只填三种:**通过** / **失败(附听到的内容)** / **未执行**。
67
+ "大概没问题"不属于这三种,不要写。
68
+
69
+ 填写人 / 日期 / 系统与读屏版本:`____ / ____ / ____`
70
+
71
+ ---
72
+
73
+ ## 四 · 跑完之后
74
+
75
+ 1. 失败项开 issue,标题用 `a11y: <控件> <现象>`,正文贴上"听到的内容"与"期望的内容"。
76
+ 2. 把结果表更新进本页,并在 [无障碍一致性声明](ACCESSIBILITY.md) 第二节把
77
+ "人工(尚未执行)"改成实际结论。
78
+ 3. 声明里第 3 节「已知缺口」第 1 条据此划掉 —— **只有在这一步之后**才允许改。
79
+
80
+ ---
81
+
82
+ ## 五 · 为什么这份协议要写进仓库
83
+
84
+ 因为"我们打算做读屏测试"和"我们做过读屏测试"在仓库里长得一模一样 ——
85
+ 除非有一张**带状态列的表**。这张表现在全部写着"未执行",
86
+ 它记录的不是进度,而是**边界**:本库目前能承诺的东西到哪为止。
@@ -1,76 +1,153 @@
1
- # 无障碍
2
-
3
- 目标等级是 **WCAG 2.2 AA**。本文说清三件事:**验过什么、怎么验的、还差什么**。
4
- 没说到的,就当作没验过 —— 这份文件的用处是让人不必猜。
5
-
6
- ## 一 · 已验证的范围
7
-
8
- 最近一次运行(`node 05-audit/a11y-scan.js`):
9
-
10
- | 项目 | 手段 | 结果 |
1
+ # 无障碍一致性声明
2
+
3
+ **目标等级:WCAG 2.2 Level AA。** 本文按 **成功准则(Success Criterion)逐条**说明
4
+ 状态、证据来源与缺口 —— 不是"我们很重视无障碍",而是"哪一条过、哪一条没过、凭什么"。
5
+
6
+ | 项目 | 值 |
7
+ |---|---|
8
+ | 声明适用版本 | agentcss `0.11.9` |
9
+ | 声明日期 | 2026-10-29 |
10
+ | 复核节奏 | 每次发版前重跑(`npm run gate` 内含全部证据) |
11
+ | 范围 | 本库交付的 CSS / JS、35 个组件的 `demo.html`、以及文档站生成的页面 |
12
+ | 不在范围 | 使用者拿这些组件拼出来的业务页面(那是你的无障碍责任,不是库的) |
13
+
14
+ 判读方式只有一句:**表里写「通过」的,下面都给出了可复现的命令;写「部分」或「未验」的,
15
+ 就是没做到,不要按"应该没问题"理解。**
16
+
17
+ ---
18
+
19
+ ## 一 · 逐条状态
20
+
21
+ 图例:**✅ 通过**(有判据且为绿)· **◐ 部分**(做了但有明确边界)· **✗ 未验**(没有证据)·
22
+ **— 不适用**(本库不含该内容,理由写在括号里)
23
+
24
+ ### 1. 可感知
25
+
26
+ | SC | 名称 | 状态 | 证据 / 边界 |
27
+ |---|---|---|---|
28
+ | 1.1.1 | 非文本内容 | ◐ | 图标一律 `aria-hidden`,语义由文字或 `aria-label` 承担;图表类组件要求调用方给替代文本,**库替不了使用者** |
29
+ | 1.3.1 | 信息与关系 | ✅ | `a11y-scan`(axe)+ 页面结构判据:单一 `h1`、`main`、`lang`、标题不跳级 |
30
+ | 1.3.2 | 有意义的顺序 | ✅ | DOM 顺序 = 视觉顺序,无 `order` / 绝对定位重排 |
31
+ | 1.3.3 | 感官特性 | ✅ | 说明文字不以"左边的按钮""圆形图标"指代 |
32
+ | 1.3.4 | 方向 | ✅ | 无 `orientation` 锁定,横竖屏均可用 |
33
+ | 1.3.5 | 输入目的识别 | — | 库不生成字段的 `autocomplete` 语义,由调用方按场景决定 |
34
+ | 1.4.1 | 颜色的使用 | ✅ | 状态均带文字或图标,不单靠颜色区分 |
35
+ | 1.4.3 | 对比度(最低) | ✅ | `contrast.py` 从令牌实算:正文 ≥ 4.5:1、控件 ≥ 3:1 |
36
+ | 1.4.4 | 调整文本大小 | ✅ | `zoom-reflow.js`:根字号拉到 200% 后,页面不横滚、文字不被裁 |
37
+ | 1.4.5 | 文本的图像 | — | 不使用文字截图 |
38
+ | 1.4.10 | 重排 | ✅ | `zoom-reflow.js`:320 CSS px 宽下 `<html>` 无横向滚动(数据表格允许内部横滚,属 WCAG 豁免的二维内容) |
39
+ | 1.4.11 | 非文本对比度 | ✅ | 控件边框 / 焦点环 / 图表色阶单独实算 |
40
+ | 1.4.12 | 文本间距 | ◐ | 组件不写死行高与段距(都用令牌),但**未**按"行高 1.5 倍 / 段距 2 倍"的极端注入逐组件验证 |
41
+ | 1.4.13 | 内容悬停或聚焦 | ◐ | tooltip 可 Esc 关闭、可悬停保持;**未**验证"可选中提示内文字"这一分支 |
42
+ | 2.1.1 | 键盘 | ✅ | `keyboard-nav.js`:Tab 顺序、Enter/Space、Esc 关闭 |
43
+ | 2.1.2 | 无键盘陷阱 | ✅ | `keyboard-nav.js`:浮层关闭后焦点可继续移动,不困在层内 |
44
+ | 2.1.4 | 字符快捷键 | — | 库不注册单字符快捷键 |
45
+ | 2.2.1 | 计时可调 | ◐ | toast 自动消失时长可配但有上限;**未**提供"暂停计时"控件 |
46
+ | 2.2.2 | 暂停、停止、隐藏 | ✅ | `motion.py --reduced-audit`:`prefers-reduced-motion` 下位移关闭,spinner 例外已登记 |
47
+ | 2.3.1 | 三次闪烁 | — | 无大于 3Hz 的闪烁内容 |
48
+ | 2.4.1 | 跳过区块 | ✅ | 文档站每页有跳转链接;组件自身不设地标 |
49
+ | 2.4.2 | 页面标题 | ✅ | 文档站逐页判 `<title>` 非空且与 `h1` 一致 |
50
+ | 2.4.3 | 焦点顺序 | ✅ | 同 2.1.1;无 `tabindex > 0` |
51
+ | 2.4.4 | 链接目的 | ✅ | 链接文字自身可读,无"点这里" |
52
+ | 2.4.6 | 标题和标签 | ✅ | 表单控件均有可见 `<label>`;`a11y-ax` 判每个可交互控件有非空名称 |
53
+ | 2.4.7 | 焦点可见 | ✅ | `focus-ring-check.js`:`:focus-visible` 有 2px 焦点环,非 `outline: none` |
54
+ | 2.4.11 | 焦点不被遮挡(2.2 新增 AA) | ✅ | `zoom-reflow.js`:焦点矩形与吸顶 / 吸底元素的重叠 ≥ 90% 即判失败 |
55
+ | 2.4.13 | 焦点外观(AAA,仅记录) | ◐ | 焦点环厚度与对比度达标,但未按 AAA 逐组件出数字 |
56
+ | 2.5.1 | 指针手势 | — | 无多点 / 路径手势 |
57
+ | 2.5.3 | 标签中的名称 | ◐ | 可见文字与 accessible name 一致;**未**做"语音输入可命中"的实测 |
58
+ | 2.5.5 | 目标大小(AAA,仅记录) | ✅ | 44×44 热区(`a11y.py` + 行为脚本) |
59
+ | 2.5.7 | 拖动移动(2.2 AA) | — | 无拖动型交互(滑块用键盘 / 点击调整) |
60
+ | 2.5.8 | 目标大小最小(2.2 AA) | ✅ | 最小 24×24,实测见 `a11y.py` |
61
+ | 3.1.1 | 语言 | ✅ | 文档站与 demo 均有 `lang`,双语页各自声明 |
62
+ | 3.1.2 | 部分语言 | — | 中文页面内无成段外语 |
63
+ | 3.2.1 | 焦点时 | ✅ | 焦点不触发上下文切换(无 focus 即跳转 / 即提交) |
64
+ | 3.2.2 | 输入时 | ✅ | 输入不自动提交;`select` 改变不跳页 |
65
+ | 3.2.6 | 一致的帮助(2.2 A) | — | 库无全局帮助入口 |
66
+ | 3.3.1 | 错误识别 | ✅ | `form-validation` 用文字说明错误,不只变色 |
67
+ | 3.3.2 | 标签或说明 | ✅ | 必填项有可见标记 + `required` |
68
+ | 3.3.7 | 冗余输入(2.2 A) | — | 由业务表单决定,库不承载 |
69
+ | 3.3.8 | 可访问的认证(2.2 AA) | — | 库不含登录流程 |
70
+ | 4.1.2 | 名称、角色、值 | ✅ | `a11y-ax.js`:10 个代表页的可访问性树逐节点判,每个可交互控件有非空名称;朗读序列存快照防回归 |
71
+ | 4.1.3 | 状态消息 | ◐ | toast / 表单错误用 `role="status"` / `alert`;**未**在真实读屏里验证播报时机 |
72
+
73
+ **统计:✅ 27 条 · ◐ 7 条 · ✗ 0 条 · — 12 条。**
74
+
75
+ ---
76
+
77
+ ## 二 · 证据是怎么来的(以及它盖不住什么)
78
+
79
+ ### 自动化(可复跑,命令都在 `05-audit/`)
80
+
81
+ | 手段 | 覆盖 | 最近结果 |
11
82
  |---|---|---|
12
- | 自动审计 | `axe-core`,跑 18 个页面(组件 demo + 文档页 + 长文页) | **0 类违规 / 991 条规则通过**;另有 **9 处**标记「待人工」 |
13
- | 「0 违规」可信吗 | 反向控制:注入 5 类典型违规,看它抓不抓得到 | 5 类全部抓到。**没有反向控制的 0 违规不算证据** |
14
- | 对比度(浅色) | `contrast.py` 从 `tokens.css` 读实色值逐对计算 | 正文 ≥ 4.5:1、控件边界 ≥ 3:1、装饰线不强制 |
15
- | 对比度(暗色) | `dark-contrast.js` 单独算一遍 | 同上;暗色不是浅色取反,独立验收 |
16
- | 组件级对比度 | `component-contrast.py` | 每个组件自己的配色再算一次 |
17
- | 焦点可见 | `focus-ring-check.js` | 有焦点环,且不是 `outline: none` 代替品 |
18
- | 焦点归还 | `focus-return-check.js` | 关闭弹层/抽屉后焦点回到触发元素 |
19
- | 键盘可达 | `keyboard-nav.js` | Tab 顺序、Esc 关闭、不困在浮层里 |
20
- | 触控目标 | `a11y.py` + 行为脚本 | 热区 44×44(WCAG 2.5.5) |
21
- | 减少动效 | `motion.py --reduced-audit` | 位移关、真实进度留(3 处登记例外)、双向核对 |
22
- | 页面结构 | 文档站门禁逐页判 | 每页有 `lang`、单一 `h1`、`main`、跳转链接、无重复 `id` |
23
-
24
- 目标尺寸、对比度阈值、动效分档这些**规则本身**写在
25
- [全局约定](CONVENTIONS.md) 里,这里只报结论。
26
-
27
- ## 二 · 已知缺口
28
-
29
- 这一节比上一节重要。以下都是**没做到**的,不是"应该没问题":
30
-
31
- 1. **没有真机读屏记录。** NVDA、VoiceOver、TalkBack 一个都没录过。
32
- axe 只覆盖 WCAG 问题的约 30–40%,它查不出"alt 写得有没有意义""阅读顺序对不对"
33
- "屏幕阅读器实际听到的是什么"—— 那几项只能用耳朵听。
34
- 所以本库目前能说的是「**自动检查能覆盖的部分是干净的**」,不能说得更多。
35
- 2. **RTL 只有静态检查。** 交付 CSS 里物理方向属性 0 处、全部逻辑属性(`rtl-check.js` 守),
36
- 但没有一张 `dir="rtl"` 的渲染基线,也没跑过阿拉伯语/希伯来语的文字膨胀案例。
37
- 3. **强制色板(forced-colors)只覆盖了一部分。** 6 个 CSS 写了
38
- `forced-colors` / `prefers-contrast` 的处理,不是全部组件。
39
- Windows 高对比度模式下的表现没有逐组件验证。
40
- 4. **Android 系统 WebView 未验证。** 真机矩阵里它是 ❌(见
41
- [浏览器支持矩阵](../SUPPORT.md)),已验的是微信内置内核、iOS/macOS Safari 等 7 行。
42
- 5. **没有 VPAT / ACR 正式符合性报告。** 采购流程里要的那种文档,本库没有出。
43
- 6. **9 处「待人工」还没有人工结论。** 自动审计把它们标出来了(需要人判断,
44
- 比如图表的替代文本是否描述了数据),目前仍挂着。
45
-
46
- ## 三 · 怎么自己验
83
+ | `axe-core`(`a11y-scan.js`) | 38 页(35 组件 demo + 文档站) | **0 类违规**,991 条规则通过 |
84
+ | 反向控制(注入 5 类典型违规) | 证明上一条不是"没查" | 5 类全部抓到 |
85
+ | `a11y-ax.js` 可访问性树 | 10 个交互语义各异的页面 | 0 个无名控件;朗读序列存 `10-review/a11y-tree/` |
86
+ | `contrast.py` / `dark-contrast.js` | 亮 / 暗两套,逐对实算 | 正文 ≥ 4.5:1 |
87
+ | `zoom-reflow.js` | 38 页 × 3 条 AA 硬项 | 320px 不横滚 · 200% 不裁字 · 焦点不被遮挡 |
88
+ | `focus-ring-check` / `focus-return-check` / `keyboard-nav` | 焦点可见、焦点归还、键盘路径 | 全绿 |
89
+
90
+ ### 人工(**尚未执行** —— 这是本声明最大的空白)
91
+
92
+ 自动化的天花板必须说清楚:**axe 只能覆盖约 30–40% 的 WCAG 问题**,而且它听不见。
93
+ 以下三件事本库**没有**任何一条真实记录:
94
+
95
+ 1. **真机读屏**(NVDA / VoiceOver / TalkBack)——一条录音都没有。
96
+ 2. **真实键盘 + 读屏组合下的阅读顺序**是否符合预期(`a11y-ax` 只证明"顺序稳定",
97
+ 不证明"顺序正确"——后者需要人判断)。
98
+ 3. **9 处「待人工」结论**(例如图表的替代文本是否真的描述了数据)。
99
+
100
+ 为了把这块从"没有"变成"可执行",协议写在
101
+ [无障碍人工测试协议](A11Y-MANUAL-PROTOCOL.md):两条路径、约 20 分钟、结果直接填表。
102
+ **在你或志愿者跑完之前,本声明只代表"自动检查覆盖的部分是干净的"。**
103
+
104
+ ---
105
+
106
+ ## 三 · 已知缺口(按影响排序)
107
+
108
+ 1. **没有真机读屏记录**(同上)。这是与 Carbon / Spectrum 那类一致性报告差距最大的一处。
109
+ 2. **没有 VPAT / ACR 正式报告**。采购流程要的那种文档本库没有出;
110
+ 上面那张 SC 表可以当它的替代品,但它不是法律意义上的 ACR。
111
+ 3. **强制色板(forced-colors)只覆盖 6 个 CSS**,Windows 高对比度模式未逐组件验证。
112
+ 4. **RTL 已进渲染判据与视觉基线,但没进真机**:
113
+ `rtl-render.js` 判 37 页(RTL 不横滚 / 阿·希文字膨胀 1.3× 不破相 /
114
+ 方向性字形镜像),`shot-baseline.py --dir rtl` 有 13 张视觉基线
115
+ (覆盖"镜像错了但不溢出不改字形"那一类)。
116
+ **仍然缺的是**:母语 RTL 使用者的实际阅读体验、iOS/Android 真机 RTL 截图,
117
+ 以及「页面加载后再切 `dir`」时组件内部状态是否需要重算。
118
+ 最后这条的现状是:CSS 会立刻响应(树形箭头、栅格、滑入方向都靠 CSS),
119
+ 但凡是 JS 在挂载时把方向读进变量的地方都不会重算 —— 本库目前没有这种写法,
120
+ 这一点靠 `rtl-render` 的"挂载后再改 dir"路径间接守着。
121
+ 5. **1.4.12 文本间距**未按极端注入验证;**1.4.13** 未验证"提示内文字可选中"。
122
+ 6. **Android 系统 WebView 未验证**,真机矩阵里它是 ❌(见[浏览器支持矩阵](../SUPPORT.md))。
123
+ 7. **`prefers-contrast: more` 未单独验收**(只做了 `forced-colors`)。
124
+
125
+ ---
126
+
127
+ ## 四 · 自己验一遍
47
128
 
48
129
  ```bash
49
- # 单个 demo 页的可访问性(结构、语言、标题层级、跳转链接…)
50
- python3 05-audit/a11y.py 02-primitives/button/demo.html
51
-
52
- # axe 审计(跑全部已登记的页面)
53
- node 05-audit/a11y-scan.js
54
-
55
- # 证明上面那个"0 违规"不是因为没查
56
- node 05-audit/_a11y-reverse-control.js
57
-
58
- # 亮色 / 暗色两套对比度
59
- python3 05-audit/contrast.py
60
- node 05-audit/dark-contrast.js
61
-
62
- # 键盘与焦点
63
- node 05-audit/keyboard-nav.js
64
- node 05-audit/focus-ring-check.js
65
- node 05-audit/focus-return-check.js
130
+ # 一条命令跑完全部无障碍相关判据(含反向控制)
131
+ npm run gate:fast # 静态层:结构 / 契约 / 文档事实
132
+ node 05-audit/a11y-scan.js # axe(需要本地静态服务 :8000)
133
+ node 05-audit/a11y-ax.js # 可访问性树 + 名称判据
134
+ node 05-audit/zoom-reflow.js # 200% / 320px / 焦点遮挡
135
+ node 05-audit/rtl-render.js # RTL 渲染 + 阿/希膨胀 + 字形镜像
136
+ node 05-audit/a11y-ax.js --selftest # 证明上面那条判据抓得到问题
137
+ node 05-audit/zoom-reflow.js --selftest # 同上
138
+ node 05-audit/rtl-render.js --selftest # 同上
139
+ python 05-audit/shot-baseline.py --check --dir rtl # RTL 视觉基线
66
140
  ```
67
141
 
68
142
  `python3 05-audit/a11y.py --dir .` 会连 `node_modules` 一起扫,
69
143
  那里面的第三方 HTML 会报一堆与你无关的问题 —— 门禁里用的是逐目录调用,别用 `--dir .`。
70
144
 
71
- ## 四 · 报告问题
145
+ ---
72
146
 
73
- 无障碍问题按**缺陷**处理,不按"改进建议"处理:请开 issue 并写清
147
+ ## 五 · 报告问题
148
+
149
+ 无障碍问题按**缺陷**处理,不按"改进建议"处理。开 issue 写清三件事:
74
150
  **控件 + 输入方式(键盘 / 读屏 / 触摸)+ 期望行为**。
75
- 已知缺口里列的那些(读屏实测、RTL 渲染、高对比度)如果能提供你机器上的
76
- 实际结果,比任何自动化都值钱。
151
+
152
+ 如果你能提供自己机器上的读屏结果(读到了什么、和预期差在哪),
153
+ 它的价值高于本页任何一条自动化结论 —— 那正是第 2 节里空着的那一块。
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.8 · 零第三方运行时依赖</p>
95
+ <p class="note">0.11.10 · 零第三方运行时依赖</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.8",
3
+ "version": "0.11.10",
4
4
  "description": "移动 Web 组件库:CSS + 可选行为脚本,零第三方运行时依赖。样式用 bundler 引入,交互行为用 script 标签引入。",
5
5
  "keywords": [
6
6
  "css",