agentcss 0.13.1 → 0.14.0

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.
package/CHANGELOG.md CHANGED
@@ -16,6 +16,155 @@
16
16
 
17
17
  (无)
18
18
 
19
+ ## [0.14.0] - 2026-10-30
20
+
21
+ ### 行为 · drawer / overlay 的「关得掉」收编进核(beta → stable)
22
+
23
+ `ai/behaviors.json` 里这两格写着 `manual` 已经很久了,理由也很诚实:0.8.6 评估过,
24
+ 但核当时只有"点外部一律关",而这两个都是**模态** —— 收编等于给它们引入错误行为。
25
+ 于是 Esc 各留一份手写实现,I-14 的病留在原地。
26
+
27
+ 这次补的是核缺的那一半:`outside: false`(模态层**不进栈**)。
28
+ 不进栈的后果写进了核文件头:模态层不再参与"点外部关最内层"的仲裁 ——
29
+ 它压根不该被这条规则选中;Esc 走元素监听器,与栈无关。
30
+
31
+ - `drawer`:删掉手写的 Esc 分支与 `if (e.target === host) close()`;
32
+ `inside` 只放抽屉本体,遮罩**不在** inside 里(放进去就等于声明"点遮罩算内部")。
33
+ - `overlay`:删掉手写的 Esc 分支;点遮罩关闭仍是它自己的语义
34
+ (带 `danger` / `closeOnBackdrop` 两个前置条件),没并进核。
35
+
36
+ 判据跟着补,且都不是"改完再看一眼":
37
+
38
+ - `dismissable-check` ⑧ 模态点外部不关、⑧b Esc 仍关得掉(两半分开断 ——
39
+ 只断"不关"的话,把关闭路径整条删掉也能过)、⑨ 模态不进栈(模态里开菜单,
40
+ 点外面只关菜单)。自检突变从 3 个加到 **5 个**,新增的两个专打 `outside`。
41
+ - `drawer-check` ⑧ 点抽屉内部不关、⑨ 点遮罩关闭。
42
+ - `overlay-check` ⑤c `body` 上的外部点击不关模态。
43
+ - 反向实验:把遮罩写进 drawer 的 `inside` ⇒ ⑨ 红;把 overlay 的 `outside` 改回
44
+ `true` ⇒ ⑤c 红。
45
+
46
+ 成熟度:**stable 36 / beta 1**(原 33 / 4),`strict` 档位下不可用的组件从 4 个降到 1 个
47
+ (只剩 `dropdown` 的 `typeahead:gap`)。`budget.manual` 棘轮从 2 收到 **0**。
48
+
49
+ ### 判据 · accordion 那格 `roving:gap` 是**写错了**,不是没做
50
+
51
+ 同一轮里核对 APG 原文才发现:`ai/behaviors.json` 里 accordion 的 roving 写着
52
+ "APG 把方向键列为**可选**增强,本库未实现" —— 这句话不成立。APG Accordion 的
53
+ Keyboard Interaction 只有 `Enter`/`Space`、`Tab`、`Shift+Tab` 三项,
54
+ **没有**方向键 / Home / End,也没有"可选"字样(w3.org 原文,2026-10-30 取)。
55
+
56
+ 而组件 README 第 68 行早就写了同一个结论,还多给了一条更硬的理由:
57
+ 移动端的方向键就是**页面滚动键**,抢过来会直接和滚动打架。
58
+ ⇒ 矩阵没跟上组件自己的文档,让 accordion 白背了一个 beta。
59
+
60
+ 改成 `n/a`(附核对日期与链接),不是"为了升 stable 而改判据"——同一轮里
61
+ `dropdown` 的 `typeahead:gap` **保留**:APG Menu 明确把"按字符跳项"列为
62
+ `(Optional)`,本库确实没做。两格按同一条标准判:矩阵记的是**规范要求什么**,
63
+ 不是我们想不想做。
64
+
65
+ **体积代价**:两个组件各多背一份核 ⇒ gzip 总体积 **+0.49%**(257.2 → 258.5 KB),
66
+ `drawer.js` 单文件 gzip 3.4 → 4.5 KB(+32.7%)。这是「注入而非运行时依赖」的既定代价,
67
+ `size-baseline` 已按显式重录记下。顺带把注入块里 429 字节的长注释挪回核文件头
68
+ (8 个使用点都背一份),把原本 +1.53% 压回 +0.49%。
69
+
70
+ ### 判据 · 「打开」的三条输入路径现在有一张矩阵
71
+
72
+ 起因不是新 bug,是**判据的覆盖面**:`dropdown` 的菜单开了、焦点留在按钮上,
73
+ 而当时 212 道门禁全绿 —— 因为那条判据只按了 `↓`。读屏在浏览模式按 Enter,
74
+ 浏览器把它变成**一次点击**,走的是另一条分支。
75
+
76
+ 新增 `05-audit/opener-path-check.js`:`dropdown` / `select` / `popover` / `overlay` /
77
+ `drawer` 五个"点一下就开"的组件 × 三条路径(键盘 / 鼠标点击 / 读屏合成点击),
78
+ 每格断言「打开 + 焦点到位」,并额外判**三条路径结果必须一致**——不一致本身就是缺陷,
79
+ 哪怕每种结果单独看都能自圆其说。
80
+
81
+ 「焦点到位」按组件各自的合法形态断:菜单 / 对话框 / 气泡是**焦点移进弹层**;
82
+ select-only combobox 是**焦点留在按钮 + `aria-activedescendant` 指向选中项**。
83
+ 后者是这一轮实测出来的:拿"DOM 焦点必须进列表"去要求它,会把自己判成缺陷。
84
+
85
+ `--selftest` 用一张故意写坏的夹具证明它分得清三条路:只监听 `keydown` 的实现在
86
+ 键盘上绿、在另两条上红;只监听 `pointerdown` 的只在鼠标上绿;开得了但不动焦点的
87
+ 三条全红,且**"没打开"与"打开了没移焦"分开判**。
88
+
89
+ 结论:五个组件 15 格全绿 —— 这一类没有新缺陷,变的是判据,不是实现。
90
+
91
+ ### 文档 · select 的「焦点落在选中项」说清了是哪一种焦点
92
+
93
+ 原文只写"落在选中项(不是第一项,也不是按钮)"。实际实现是
94
+ `aria-activedescendant` 指过去、DOM 焦点留在按钮上 —— 这是 APG 对
95
+ select-only combobox 的合法形态,但按字面拿 `document.activeElement` 去核对会判成缺陷。
96
+ 已补一段说明「活动项 ≠ DOM 焦点」,并指出判据是按哪一种断的。
97
+
98
+ ### 文档 · 无障碍人工测试补上「标题层级」这一格
99
+
100
+ 九个页面的 `H` 键逐级跳从"未执行"变成实测结论:NVDA 念出的 **107/107 条**级别与页面
101
+ 结构逐条一致,九页走到底都念「往下没有标题」。证据是读屏交给语音合成器的原文,不是
102
+ 人耳旁听 —— 与第一轮同样的口径。
103
+
104
+ 同一轮把机器侧的结构扫描从 9 页扩到**全站 96 页 / 887 个标题**:每页恰好一个 `h1`、
105
+ 跳级 0 处、空名 0 个,层级只到 3 级(`h1×96 · h2×581 · h3×210`),DOM 里的标题数与
106
+ 无障碍树里的 heading 节点数逐页相等。
107
+
108
+ 三条 axe 规则(`heading-order` / `page-has-heading-one` / `empty-heading`)各做了一次
109
+ 破坏实验,确认真的会红;外加一条反向对照(合法的 `h3 → h4` 仍然全绿),
110
+ 证明它们不是"见到 `h4` 就报"。实验表在
111
+ [docs/A11Y-MANUAL-PROTOCOL.md](docs/A11Y-MANUAL-PROTOCOL.md)。
112
+
113
+ ### 文档 · 协议页补了三个实操坑,都是这一轮踩出来的
114
+
115
+ 1. 读屏按 **URL** 记忆浏览光标,复用同一个 URL 会从上次的位置继续 ⇒ 看起来像"本页没有标题"。
116
+ 2. 加载后按 `↑` **不一定**读到 `h1`(有时落在导语段落上)⇒ 用 `H` 再 `SHIFT+H` 取。
117
+ 3. 末端那句「往下没有标题」是**证据**不是失败。
118
+
119
+ ### 更正 · 一句话当时没有证据
120
+
121
+ 协议页原来写"九个页面都跑过 Tab 遍历……标题层级本轮没做"。前半句没错,但
122
+ "**从页面顶部**按 Tab 走一遍"里的"从页面顶部"没有证据:脚本里那行 `CTRL+HOME` 走的是
123
+ 一个不支持 `HOME` 的按键通道,一直返回 `rc=1`,等于空操作。
124
+ Tab 遍历本身走焦点顺序、不依赖浏览光标,所以结论不受影响;这句话已改成它真正证明的东西。
125
+
126
+ ## [0.13.2] - 2026-10-30
127
+
128
+ ### 修复 · 下拉菜单打开时,焦点没有交给第一项
129
+
130
+ 读屏用户在浏览模式下按 Enter,NVDA 会把这次按键变成**一次点击**。而原先只有键盘
131
+ 分支里会聚焦首项,click 分支只负责打开 ⇒ 菜单开了、焦点还留在按钮上,菜单项
132
+ 永远进不去。真机实测(NVDA + Edge):修复前焦点元素是那个按钮,修复后落在第一项。
133
+
134
+ 修法是把聚焦收进 `open()`,两条路径共用。
135
+
136
+ ### 修复 · 表格列头把排序符号念了出来
137
+
138
+ 列头按钮的可访问名里混着 `↕`,读屏念成「公司 · ↕ · 按钮」。
139
+ 来源是 `.table__sort::after { content: "↕" }` —— Chromium 把伪元素文本算进了
140
+ 可访问名。改为 `content: ""` + `mask` 图像画箭头:仍是 ↕ / ↑ / ↓ 三种形状、颜色走
141
+ `currentColor`,**标记结构不用改**(外部集成方无需动 HTML)。
142
+
143
+ 尺寸不是零差异:箭头 5×12 → 4×10 px(393 视口实测),墨量反而更足(3.7 → 4.9),
144
+ 垂直居中也更准;但它的盒子宽了 2px,同一表内右对齐各列最多右移 6px。
145
+
146
+ 🔴 2026-10-30 更正:这条原本写着「不要改用 `content: "↕" / ""` —— Firefox 不支持」。
147
+ **那句是错的**:三个引擎实测都接受该语法,也都把 ↕ 从可访问名里去掉了。
148
+ 真正的理由是**老引擎**——该语法要 Chromium 108 / Firefox 128 / Safari 17.4,
149
+ 不认的引擎会丢掉整条声明、箭头彻底消失,而支持矩阵里有 Chromium 97(Android 百度浏览器)。
150
+ 实测矩阵见 [docs/A11Y-MANUAL-PROTOCOL.md](docs/A11Y-MANUAL-PROTOCOL.md)。
151
+
152
+ ### 判据 · 两处都补了能抓住它的检查
153
+
154
+ - `dropdown-check` 新增「点击打开也聚焦首项」。原来只测 `↓` 这条键盘路径,
155
+ 所以上面那个缺陷在 111 道门禁全绿的情况下一直存在(用改前的代码做过对照:
156
+ `↓` 绿、`↑↓` 绿、点击红)。
157
+ - `table-check` 的排序判据改为读 `mask` 的自定义属性;把降序箭头改成升序形状的
158
+ 破坏实验能当场判红,说明它仍在看结果而不是结构。
159
+
160
+ ### 文档 · 无障碍人工测试协议补上实测结果
161
+
162
+ 九个页面跑了一轮 NVDA 朗读(证据是读屏交给语音合成器的原文,不是人耳旁听),
163
+ 结果表从「全部未执行」变成实测结论;`H` 键标题层级与 macOS VoiceOver 仍为空白,
164
+ 表里如实写着「未执行」。
165
+
166
+ ---
167
+
19
168
  ## [0.13.1] - 2026-10-30
20
169
 
21
170
  **补录 Linux 三套视觉基线。0.13.0 的 tag 因这处过期未通过发布门禁,包没有发出去
package/CONTRIBUTING.md CHANGED
@@ -8,7 +8,7 @@ npm run gate # 全量:含浏览器类
8
8
  npm run gate:fast # 只跑静态层,不用装浏览器
9
9
  ```
10
10
 
11
- 全量是 **212 道门禁**(4 路并发实测约 9 分钟 / 退出码 0;单道耗时累加 1691 秒,
11
+ 全量是 **214 道门禁**(4 路并发实测约 9 分钟 / 退出码 0;单道耗时累加 1691 秒,
12
12
  组件层要起浏览器,还要逐张比 96 张视觉截图。0.11.15 起默认 4 路并发)。
13
13
  只想确认没改坏静态契约的话用 `gate:fast`,几秒到几分钟。
14
14
 
package/README.zh-CN.md CHANGED
@@ -83,7 +83,7 @@ npm install github:owain323/frontend-lib
83
83
 
84
84
  ## 组件一览
85
85
 
86
- 共 **37** 个(stable 33 / beta 4)。每个组件目录下有 `README.md`(为什么这么做)和 `demo.html`(能直接抄的结构)。
86
+ 共 **37** 个(stable 36 / beta 1)。每个组件目录下有 `README.md`(为什么这么做)和 `demo.html`(能直接抄的结构)。
87
87
 
88
88
  ### primitives · 基础件(15)
89
89
 
package/START-HERE.md CHANGED
@@ -39,26 +39,26 @@
39
39
  | 标签 / 徽章 | `02-primitives/badge/badge.css` | 141 | — | 有状态标记就要("已披露""停牌") |
40
40
  | 卡片 | `02-primitives/card/card.css` | 246 | — | 有分组内容就要 |
41
41
  | 单选 / 多选 | `02-primitives/choice/choice.css` | 395 | — | 单选 / 多选就要 |
42
- | 下拉选择 | `02-primitives/select/select.css` | 151 | **必需 549** (`select.js`) | 原生 select 的外观或交互不够用就要 |
43
- | 可搜索选择 | `02-primitives/combobox/combobox.css` | 223 | **必需 485** (`combobox.js`) | 选项多到要搜索,或要选多个并留成标签就要 |
42
+ | 下拉选择 | `02-primitives/select/select.css` | 151 | **必需 541** (`select.js`) | 原生 select 的外观或交互不够用就要 |
43
+ | 可搜索选择 | `02-primitives/combobox/combobox.css` | 223 | **必需 477** (`combobox.js`) | 选项多到要搜索,或要选多个并留成标签就要 |
44
44
  | 开关 | `02-primitives/switch/switch.css` | 370 | — | 有"立即生效的设置"就要 |
45
45
  | 分隔线 | `02-primitives/separator/separator.css` | 73 | — | 要分开内容块就要 |
46
46
  | 加载骨架 | `02-primitives/skeleton/skeleton.css` | 96 | — | 数据还没到、先把版式占住就要 |
47
47
  | 进度条 / 环 | `02-primitives/progress/progress.css` | 172 | — | 有耗时过程要给进度就要 |
48
- | 浮层气泡 | `02-primitives/popover/popover.css` | 187 | **必需 331** (`popover.js`) | 要一段信息贴着触发元素浮出来就要 |
48
+ | 浮层气泡 | `02-primitives/popover/popover.css` | 187 | **必需 323** (`popover.js`) | 要一段信息贴着触发元素浮出来就要 |
49
49
  | 日期区间 | `02-primitives/date-range/date-range.css` | 206 | **必需 301** (`date-range.js`) | 要选起止日期就要 |
50
50
  | 标签页 | `03-patterns/tabs/tabs.css` | 172 | **必需 316** (`tabs.js`) | 同层级内容切换就要 |
51
- | 折叠面板(beta) | `03-patterns/accordion/accordion.css` | 453 | **必需 389** (`accordion.js`) | 长表单分段就要 |
52
- | 动作菜单(beta) | `03-patterns/dropdown/dropdown.css` | 135 | **必需 451** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
53
- | 文字提示 | `03-patterns/tooltip/tooltip.css` | 89 | **必需 288** (`tooltip.js`) | 图标按钮或缩写需要一句话解释就要 |
51
+ | 折叠面板 | `03-patterns/accordion/accordion.css` | 453 | **必需 389** (`accordion.js`) | 长表单分段就要 |
52
+ | 动作菜单(beta) | `03-patterns/dropdown/dropdown.css` | 135 | **必需 448** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
53
+ | 文字提示 | `03-patterns/tooltip/tooltip.css` | 89 | **必需 280** (`tooltip.js`) | 图标按钮或缩写需要一句话解释就要 |
54
54
  | 分页 | `03-patterns/pagination/pagination.css` | 195 | **必需 401** (`pagination.js`) | 数据多到要翻页就要 |
55
55
  | 树形 | `03-patterns/tree/tree.css` | 122 | **必需 464** (`tree.js`) | 层级数据(文件 / 组织 / 分类)要展开就要 |
56
- | 侧边抽屉(beta) | `03-patterns/drawer/drawer.css` | 129 | **必需 258** (`drawer.js`) | 要从侧边滑出面板就要 |
57
- | 弹窗 / 吐司(beta) | `03-patterns/overlay/overlay.css` | 319 | **必需 569** (`overlay.js`) | 需要弹层(吐司 / 模态)就要 |
56
+ | 侧边抽屉 | `03-patterns/drawer/drawer.css` | 129 | **必需 336** (`drawer.js`) | 要从侧边滑出面板就要 |
57
+ | 弹窗 / 吐司 | `03-patterns/overlay/overlay.css` | 319 | **必需 638** (`overlay.js`) | 需要弹层(吐司 / 模态)就要 |
58
58
  | 状态(空 / 加载 / 错 / 成功) | `03-patterns/states/states.css` | 252 | — | **凡是会异步取数就要** |
59
59
  | 表单校验 | `03-patterns/form-validation/form-validation.css` | 263 | — | 提交前要校验就要 |
60
60
  | 列表增删 | `03-patterns/list/list.css` | 162 | 可选 201 (`flip.js`,FLIP 增删动画,纯增强) | 列表会动态增删就要 |
61
- | 导航 / 目录 | `03-patterns/nav/nav.css` | 314 | **必需 433** (`nav.js`) · 可选 111 (`toc.js`,自动生成目录,纯增强) | 顶部要导航,或长文要目录就要 |
61
+ | 导航 / 目录 | `03-patterns/nav/nav.css` | 314 | **必需 425** (`nav.js`) · 可选 111 (`toc.js`,自动生成目录,纯增强) | 顶部要导航,或长文要目录就要 |
62
62
  | 长文排版 | `03-patterns/content/content.css` | 286 | — | 写文档 / 长文就要 |
63
63
  | delta | `02-primitives/delta/delta.css` | 113 | — | |
64
64
  | metric | `02-primitives/metric/metric.css` | 157 | — | |
@@ -66,7 +66,7 @@
66
66
  | analysis-report | `04-recipes/analysis-report/analysis-report.css` | 187 | — | |
67
67
  | detail-page | `04-recipes/detail-page/detail-page.css` | 190 | — | |
68
68
  | report | `04-recipes/report/report.css` | 364 | — | |
69
- | table | `04-recipes/table/table.css` | 372 | — | |
69
+ | table | `04-recipes/table/table.css` | 387 | — | |
70
70
  | bar | `09-assets/bar/bar.css` | 166 | **必需 320** (`bar.js`) | |
71
71
  | model-viewer | `09-assets/model-viewer/model-viewer.css` | 143 | **必需 423** (`model-viewer.js`) | |
72
72
  | scientific-plot | `09-assets/scientific-plot/scientific-plot.css` | 141 | **必需 610** (`scientific-plot.js`) | |
package/SUPPORT.md CHANGED
@@ -11,6 +11,12 @@
11
11
  > ⇒ 这比"没验证"更糟:它给了一个**错误的**证据来源,
12
12
  > 读者会以为"至少 Firefox 跑过了"。
13
13
  > 现在引擎是真的切换了,并且每个作业都要**自己报 UA** 自证(见下)。
14
+ >
15
+ > 🔴 2026-10-30 更正(反方向):
16
+ > 这一页曾把桌面 Firefox 标成 ⚠️,理由是「没装 firefox 时门禁显式 SKIP」。
17
+ > **那句推测是错的**:CI 的 `浏览器矩阵(firefox)` 作业一直在跑,而且是绿的
18
+ > (UA 自报 `X11; Linux x86_64; rv:155.0 Gecko/20100101 Firefox/155.0`)。
19
+ > ⚠️ 与 ❌ 都会低估覆盖面,和 ✅ 高估一样是失真。
14
20
 
15
21
  ## 状态定义
16
22
 
@@ -31,7 +37,7 @@
31
37
  |---|---|---|---|
32
38
  | 桌面 | Chromium | ✅ | 全部组件契约、暗色对比度、响应式三档、视觉回归 |
33
39
  | 桌面 | WebKit | ✅ | nightly 矩阵的组件契约(select / combobox / date-range / overlay / tree / dropdown / popover / tooltip) |
34
- | 桌面 | Firefox | ⚠️ | 矩阵里**已声明并自证**,但本机/CI 需要先 `npx playwright install firefox`;没装时门禁显式 SKIP,不假装通过 |
40
+ | 桌面 | Firefox | ✅ | 与 WebKit 同一批组件契约(select / combobox / date-range / overlay / tree / dropdown / popover / tooltip),CI 作业自报 Gecko UA;另外 8 个组件(accordion / drawer / table / tabs / choice / input / overlay / dropdown)用 `FL_ENGINE=firefox` 在本机复跑同样全绿 |
35
41
 
36
42
  ### 🔴 怎么知道"跑的真的是这个引擎"
37
43
 
package/ai/START-HERE.md CHANGED
@@ -30,7 +30,7 @@
30
30
  > 它的值直接从 `ai/components.json` 与目录实际内容读出来 —— **不信上面的说法,信这个**。
31
31
 
32
32
  <!-- ==== AI-INVENTORY-BEGIN ==== -->
33
- - 组件 **37** 个(`beta` 4 · `stable` 33)
33
+ - 组件 **37** 个(`beta` 1 · `stable` 36)
34
34
  - `04-recipes/`(页面级示例):`analysis-report`(组合型研究报告:正文 + 辅助栏并排,窄屏退回单栏) · `data-showcase`(四种数据表达方式对照:我到底该用哪个) · `detail-page`(实体详情页骨架:字段齐全 / 部分缺失 / 无数据,三态同构(DOM 不变)) · `longform`(单栏长文排版与阅读节奏) · `report`(报告版式系统 v1:共享基础 + 三种版式取舍(杂志 / 科研技术 / 金融研究)) · `table`(专业数据表格:列级格式与单位,三种用途预设)
35
35
  - `09-assets/`(图表与可视化):`bar`(柱状图,含图表规范的裁剪铁律) · `echarts-adapter`(把令牌喂给 ECharts,按需引入;本库不含 ECharts) · `model-viewer`(惰性加载的 3D 模型查看器) · `scientific-plot`(二维科学绘图:坐标轴 / 误差棒 / 置信区间带 / 对数轴) · `sparkline`(迷你趋势线,没有坐标轴的走势提示)
36
36
  <!-- ==== AI-INVENTORY-END ==== -->
package/ai/behaviors.json CHANGED
@@ -28,8 +28,8 @@
28
28
  }
29
29
  },
30
30
  "budget": {
31
- "manual": 2,
32
- "gap": 2
31
+ "manual": 0,
32
+ "gap": 1
33
33
  },
34
34
  "matrix": {
35
35
  "dismissable": {
@@ -54,8 +54,8 @@
54
54
  "展开/收起是内容显隐,没有'关闭'语义;Esc 不参与"
55
55
  ],
56
56
  "drawer": [
57
- "manual",
58
- "0.8.6 评估后**决定不收编**:drawer 已经是**模态**弹层,它的 dismissable 只含 Esc;点遮罩关闭是它自己的语义(overlay 负责遮罩)。核的 onDocClick 会把「点遮罩」当成 outside 触发,而 drawer 想忽略非Esc 的关闭原因 ⇒ 需要给核加\"忽略 outside\"的开关,属于改核而不是接线。暂不做。"
57
+ "core",
58
+ "0.14.0 收编(推翻 0.8.6 的\"暂不做\"):核新增 `outside` 开关,模态层不进栈,于是\"点遮罩关闭\"仍然成立而\"点其它地方\"不会被判成 outside。删掉手写的 Esc 分支与 `if (e.target === host) close()`;inside 只放抽屉本体,遮罩**不在** inside 里(放进去就等于声明\"点遮罩算内部\")。判据 drawer-check ⑧(点内部不关)/ ⑨(点遮罩关)。"
59
59
  ],
60
60
  "dropdown": [
61
61
  "core",
@@ -74,8 +74,8 @@
74
74
  "0.11.2 收编:抽屉的「Esc 关闭」直接走核。⚠️ 遮罩**不能**写进 inside —— 写进去就等于声明\"点遮罩算内部\",那一句\"点遮罩关闭\"的注释会立刻变成谎话(实测过,关不掉)"
75
75
  ],
76
76
  "overlay": [
77
- "manual",
78
- "0.8.6 评估后**决定不收编**:dialog 是模态,**点外部不该关闭**(那是 modal 的定义)。核的 onDocClick 天然会处理 outside ⇒ 收编等于给 dialog 引入错误行为。overlay 只需 Esc 那一半,Esc 已在组件内正确实现(不依赖全局栈)。"
77
+ "core",
78
+ "0.14.0 收编(推翻 0.8.6 的\"不收编\"):dialog 是模态,点外部不关 —— 这正是核新开关 `outside:false` 的语义(模态层不进栈)。Esc 走核;点遮罩带 danger / closeOnBackdrop 两个前置条件,仍是组件自己的语义,没并进核。判据 overlay-check ⑤c(body 上的外部点击不关)/ ⑤b(点遮罩关且焦点归位)。"
79
79
  ],
80
80
  "pagination": [
81
81
  "n/a",
@@ -222,8 +222,8 @@
222
222
  "aria-activedescendant 模式:焦点常驻按钮"
223
223
  ],
224
224
  "accordion": [
225
- "gap",
226
- "APG 把方向键列为**可选**增强,本库未实现;要做就应走 roving 核,不该再手写一份"
225
+ "n/a",
226
+ "**判据纠正(2026-10-30 核对 APG 原文)**:此前这里写 `gap`,理由是\"APG 把方向键列为可选增强,本库未实现\"。核对后这条不成立 —— APG Accordion 的 Keyboard Interaction 只有 `Enter`/`Space`、`Tab`、`Shift+Tab` 三项,**没有**方向键 / Home / End,也没有\"可选\"字样(w3.org/WAI/ARIA/apg/patterns/accordion,2026-10-30 取)。加上本库是移动端组件:方向键就是页面滚动键,抢过来会直接和滚动打架。⇒ 不适用,不是缺口。组件 README 第 68 行早就写了同一个结论,是矩阵没跟上。"
227
227
  ],
228
228
  "drawer": [
229
229
  "n/a",
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
3
  "generatedBy": "05-audit/gen-ai-contract.py",
4
- "libraryVersion": "0.13.1",
4
+ "libraryVersion": "0.14.0",
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 下的专属门禁 ⇒ 还没被机器验证过,不要用",
8
8
  "beta": "有 demo + 专属门禁,但**下面至少有一条没过**:① 状态仍用 .is-* class 表达(违反 INVARIANT I-8);② 该收编到微行为核的行为还是**手写副本**(manual)或**根本没做**(gap) ⇒ 改一处不会全库跟着变(I-14 的病)",
9
9
  "stable": "以上四条全过:有 demo + 专属门禁 + 状态全在 data-* 属性上 + 行为零欠账",
10
10
  "$note": "成熟度是【计算出来的】,不是拍的。手写把某个组件改成 stable 而它还有 manual / gap ⇒ 门禁会红。",
11
- "$whyFourth": "🔴 为什么第四条判据不能省:用前三条时多数组件会**全是 stable** ⇒ ladder 没有任何鉴别力(对标 Primer 六档阶梯,每档都有硬判据)。补上「行为欠账」这一条后:**stable 33 / beta 4**(本行由生成器算出,不要手改)。",
12
- "$strictImpact": "⚠️ strict 档位只放行 stable ⇒ 4 个组件在 strict 下不可用:accordion / drawer / dropdown / overlay。放宽 strict 会让「未收编的手写行为」进入生产路径。(本行由生成器算出,不要手改)"
11
+ "$whyFourth": "🔴 为什么第四条判据不能省:用前三条时多数组件会**全是 stable** ⇒ ladder 没有任何鉴别力(对标 Primer 六档阶梯,每档都有硬判据)。补上「行为欠账」这一条后:**stable 36 / beta 1**(本行由生成器算出,不要手改)。",
12
+ "$strictImpact": "⚠️ strict 档位只放行 stable ⇒ 1 个组件在 strict 下不可用:dropdown。放宽 strict 会让「未收编的手写行为」进入生产路径。(本行由生成器算出,不要手改)"
13
13
  },
14
14
  "summary": {
15
15
  "total": 37,
16
16
  "byMaturity": {
17
- "stable": 33,
18
- "beta": 4
17
+ "stable": 36,
18
+ "beta": 1
19
19
  },
20
20
  "byTier": {
21
21
  "primitive": 15,
@@ -244,14 +244,12 @@
244
244
  "tier": "pattern",
245
245
  "purpose": "可折叠分组",
246
246
  "rootClass": "accordion",
247
- "maturity": "beta",
247
+ "maturity": "stable",
248
248
  "maturityReasons": {
249
249
  "hasDemo": true,
250
250
  "hasDedicatedGate": true,
251
251
  "statesOnDomAttributes": true,
252
- "behaviorDebt": [
253
- "roving:gap"
254
- ]
252
+ "behaviorDebt": []
255
253
  },
256
254
  "gates": [
257
255
  "accordion-contract.js"
@@ -346,14 +344,12 @@
346
344
  "tier": "pattern",
347
345
  "purpose": "侧边抽屉,含遮罩",
348
346
  "rootClass": "drawer",
349
- "maturity": "beta",
347
+ "maturity": "stable",
350
348
  "maturityReasons": {
351
349
  "hasDemo": true,
352
350
  "hasDedicatedGate": true,
353
351
  "statesOnDomAttributes": true,
354
- "behaviorDebt": [
355
- "dismissable:manual"
356
- ]
352
+ "behaviorDebt": []
357
353
  },
358
354
  "gates": [
359
355
  "drawer-check.js"
@@ -636,14 +632,12 @@
636
632
  "tier": "pattern",
637
633
  "purpose": "吐司提示与模态遮罩",
638
634
  "rootClass": "toast",
639
- "maturity": "beta",
635
+ "maturity": "stable",
640
636
  "maturityReasons": {
641
637
  "hasDemo": true,
642
638
  "hasDedicatedGate": true,
643
639
  "statesOnDomAttributes": true,
644
- "behaviorDebt": [
645
- "dismissable:manual"
646
- ]
640
+ "behaviorDebt": []
647
641
  },
648
642
  "gates": [
649
643
  "overlay-check.js",
@@ -2,6 +2,7 @@ var flDismissable = function (opts) {
2
2
  var opt = opts || {};
3
3
  var escOn = opt.escOn || [];
4
4
  var inside = opt.inside || [];
5
+ var outside = opt.outside !== false;
5
6
  var when = opt.when || function () { return true; };
6
7
  var onDismiss = opt.onDismiss || function () {};
7
8
 
@@ -42,7 +43,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
42
43
  for (var j = 0; j < escOn.length; j++) {
43
44
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
44
45
  }
45
- shared.stack.push(rec);
46
+
47
+ if (outside) shared.stack.push(rec);
46
48
  if (!shared.bound) {
47
49
  shared.bound = true;
48
50
  document.addEventListener('click', onDocClick, true);
@@ -5,6 +5,7 @@ var flDismissable = function (opts) {
5
5
  var opt = opts || {};
6
6
  var escOn = opt.escOn || [];
7
7
  var inside = opt.inside || [];
8
+ var outside = opt.outside !== false;
8
9
  var when = opt.when || function () { return true; };
9
10
  var onDismiss = opt.onDismiss || function () {};
10
11
 
@@ -45,7 +46,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
45
46
  for (var j = 0; j < escOn.length; j++) {
46
47
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
47
48
  }
48
- shared.stack.push(rec);
49
+
50
+ if (outside) shared.stack.push(rec);
49
51
  if (!shared.bound) {
50
52
  shared.bound = true;
51
53
  document.addEventListener('click', onDocClick, true);
@@ -5,6 +5,7 @@ var flDismissable = function (opts) {
5
5
  var opt = opts || {};
6
6
  var escOn = opt.escOn || [];
7
7
  var inside = opt.inside || [];
8
+ var outside = opt.outside !== false;
8
9
  var when = opt.when || function () { return true; };
9
10
  var onDismiss = opt.onDismiss || function () {};
10
11
 
@@ -45,7 +46,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
45
46
  for (var j = 0; j < escOn.length; j++) {
46
47
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
47
48
  }
48
- shared.stack.push(rec);
49
+
50
+ if (outside) shared.stack.push(rec);
49
51
  if (!shared.bound) {
50
52
  shared.bound = true;
51
53
  document.addEventListener('click', onDocClick, true);
@@ -5,6 +5,7 @@ var flDismissable = function (opts) {
5
5
  var opt = opts || {};
6
6
  var escOn = opt.escOn || [];
7
7
  var inside = opt.inside || [];
8
+ var outside = opt.outside !== false;
8
9
  var when = opt.when || function () { return true; };
9
10
  var onDismiss = opt.onDismiss || function () {};
10
11
 
@@ -45,7 +46,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
45
46
  for (var j = 0; j < escOn.length; j++) {
46
47
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
47
48
  }
48
- shared.stack.push(rec);
49
+
50
+ if (outside) shared.stack.push(rec);
49
51
  if (!shared.bound) {
50
52
  shared.bound = true;
51
53
  document.addEventListener('click', onDocClick, true);
@@ -55,6 +55,69 @@ clear: function () { saved = null; },
55
55
  };
56
56
  };
57
57
 
58
+ var flDismissable = function (opts) {
59
+ var opt = opts || {};
60
+ var escOn = opt.escOn || [];
61
+ var inside = opt.inside || [];
62
+ var outside = opt.outside !== false;
63
+ var when = opt.when || function () { return true; };
64
+ var onDismiss = opt.onDismiss || function () {};
65
+
66
+ var shared = window.__flDismiss || (window.__flDismiss = { stack: [], bound: false });
67
+
68
+ function isInside(node) {
69
+ if (!node) return false;
70
+ for (var i = 0; i < inside.length; i++) {
71
+ var el = inside[i];
72
+ if (!el || !el.contains) continue;
73
+ if (el === node || el.contains(node)) return true;
74
+ }
75
+ return false;
76
+ }
77
+
78
+ function onKey(e) {
79
+ if (!when()) return;
80
+ var k = e.key;
81
+ if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
82
+
83
+ var closed = onDismiss(e, 'esc');
84
+ if (closed === false) return;
85
+ if (e.preventDefault) e.preventDefault();
86
+ if (e.stopPropagation) e.stopPropagation();
87
+ }
88
+
89
+ function onDocClick(e) {
90
+ var top = null;
91
+ for (var i = shared.stack.length - 1; i >= 0; i--) {
92
+ if (shared.stack[i].when()) { top = shared.stack[i]; break; }
93
+ }
94
+ if (!top || top.isInside(e.target)) return;
95
+ top.onDismiss(e, 'outside');
96
+ }
97
+
98
+ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
99
+
100
+ for (var j = 0; j < escOn.length; j++) {
101
+ if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
102
+ }
103
+
104
+ if (outside) shared.stack.push(rec);
105
+ if (!shared.bound) {
106
+ shared.bound = true;
107
+ document.addEventListener('click', onDocClick, true);
108
+ }
109
+
110
+ return {
111
+ destroy: function () {
112
+ for (var j = 0; j < escOn.length; j++) {
113
+ if (escOn[j]) escOn[j].removeEventListener('keydown', onKey);
114
+ }
115
+ var k = shared.stack.indexOf(rec);
116
+ if (k >= 0) shared.stack.splice(k, 1);
117
+ },
118
+ };
119
+ };
120
+
58
121
  var FOCUSABLE = [
59
122
  'a[href]',
60
123
  'button:not([disabled])',
@@ -176,11 +239,15 @@ else (f[0] || d).focus();
176
239
  (f[0] || d).focus();
177
240
  }
178
241
 
179
- function onKey(e) {
180
- var k = e.key;
242
+ var dm = flDismissable({
243
+ escOn: [d],
244
+ inside: [d],
245
+ when: function () { return !closed; },
246
+ onDismiss: function () { close(); },
247
+ });
181
248
 
182
- if (k === 'Escape' || k === 'Esc') { e.preventDefault(); close(); return; }
183
- if (k !== 'Tab') return;
249
+ function onKey(e) {
250
+ if (e.key !== 'Tab') return;
184
251
 
185
252
  var list = [].slice.call(d.querySelectorAll(FOCUSABLE))
186
253
  .filter(function (x) { return x.offsetParent !== null; });
@@ -192,10 +259,8 @@ e.preventDefault(); last.focus();
192
259
  e.preventDefault(); first.focus();
193
260
  }
194
261
  }
195
- function onBackdropClick(e) { if (e.target === host) close(); }
196
262
 
197
263
  d.addEventListener('keydown', onKey);
198
- host.addEventListener('click', onBackdropClick);
199
264
  closeBtn.addEventListener('click', function () { close(); });
200
265
 
201
266
  var closed = false;
@@ -204,7 +269,8 @@ if (closed) return;
204
269
  closed = true;
205
270
  d.setAttribute('data-state', 'leaving');
206
271
  d.removeEventListener('keydown', onKey);
207
- host.removeEventListener('click', onBackdropClick);
272
+
273
+ dm.destroy();
208
274
  setBackgroundInert(false);
209
275
  unlockScroll();
210
276
 
@@ -137,6 +137,7 @@ var flDismissable = function (opts) {
137
137
  var opt = opts || {};
138
138
  var escOn = opt.escOn || [];
139
139
  var inside = opt.inside || [];
140
+ var outside = opt.outside !== false;
140
141
  var when = opt.when || function () { return true; };
141
142
  var onDismiss = opt.onDismiss || function () {};
142
143
 
@@ -177,7 +178,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
177
178
  for (var j = 0; j < escOn.length; j++) {
178
179
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
179
180
  }
180
- shared.stack.push(rec);
181
+
182
+ if (outside) shared.stack.push(rec);
181
183
  if (!shared.bound) {
182
184
  shared.bound = true;
183
185
  document.addEventListener('click', onDocClick, true);
@@ -265,6 +267,8 @@ fr.save();
265
267
  menu.hidden = false;
266
268
  btn.setAttribute('aria-expanded', 'true');
267
269
  active = -1;
270
+
271
+ focusAt(0);
268
272
  flEmit(root, 'fl-open', null);
269
273
  if (opt.onOpen) opt.onOpen();
270
274
  }
@@ -331,7 +335,6 @@ if (e.target === btn) {
331
335
  if (k === 'ArrowDown' || k === 'Enter' || k === ' ' || k === 'Spacebar') {
332
336
  e.preventDefault();
333
337
  open();
334
- focusAt(0);
335
338
  }
336
339
  return;
337
340
  }
@@ -59,6 +59,7 @@ var flDismissable = function (opts) {
59
59
  var opt = opts || {};
60
60
  var escOn = opt.escOn || [];
61
61
  var inside = opt.inside || [];
62
+ var outside = opt.outside !== false;
62
63
  var when = opt.when || function () { return true; };
63
64
  var onDismiss = opt.onDismiss || function () {};
64
65
 
@@ -99,7 +100,8 @@ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
99
100
  for (var j = 0; j < escOn.length; j++) {
100
101
  if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
101
102
  }
102
- shared.stack.push(rec);
103
+
104
+ if (outside) shared.stack.push(rec);
103
105
  if (!shared.bound) {
104
106
  shared.bound = true;
105
107
  document.addEventListener('click', onDocClick, true);