agentcss 0.0.0-stage → 0.11.3

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 (274) hide show
  1. package/01-tokens/README.md +236 -0
  2. package/01-tokens/behavior/dismissable.js +132 -0
  3. package/01-tokens/behavior/emit.js +76 -0
  4. package/01-tokens/behavior/focus-return.js +81 -0
  5. package/01-tokens/behavior/roving.js +152 -0
  6. package/01-tokens/behavior/typeahead.js +124 -0
  7. package/01-tokens/focus-ring-demo.html +133 -0
  8. package/01-tokens/focus-ring.css +102 -0
  9. package/01-tokens/page.css +244 -0
  10. package/01-tokens/polyfill.js +116 -0
  11. package/01-tokens/responsive-demo.html +171 -0
  12. package/01-tokens/scrollbar-demo.html +194 -0
  13. package/01-tokens/scrollbar.css +179 -0
  14. package/01-tokens/theme-toggle.js +103 -0
  15. package/01-tokens/tokens.css +972 -0
  16. package/01-tokens/typography.css +156 -0
  17. package/02-primitives/badge/README.md +71 -0
  18. package/02-primitives/badge/badge.css +141 -0
  19. package/02-primitives/badge/demo.html +101 -0
  20. package/02-primitives/button/README.md +99 -0
  21. package/02-primitives/button/button.css +353 -0
  22. package/02-primitives/button/demo.html +200 -0
  23. package/02-primitives/card/README.md +145 -0
  24. package/02-primitives/card/card.css +246 -0
  25. package/02-primitives/card/demo.html +170 -0
  26. package/02-primitives/choice/README.md +161 -0
  27. package/02-primitives/choice/choice.css +395 -0
  28. package/02-primitives/choice/demo.html +277 -0
  29. package/02-primitives/combobox/README.md +79 -0
  30. package/02-primitives/combobox/combobox.css +223 -0
  31. package/02-primitives/combobox/combobox.js +485 -0
  32. package/02-primitives/combobox/demo.html +109 -0
  33. package/02-primitives/date-range/README.md +100 -0
  34. package/02-primitives/date-range/date-range.css +206 -0
  35. package/02-primitives/date-range/date-range.js +301 -0
  36. package/02-primitives/date-range/demo.html +104 -0
  37. package/02-primitives/input/README.md +170 -0
  38. package/02-primitives/input/demo.html +246 -0
  39. package/02-primitives/input/input.css +239 -0
  40. package/02-primitives/popover/README.md +80 -0
  41. package/02-primitives/popover/demo.html +150 -0
  42. package/02-primitives/popover/popover.css +187 -0
  43. package/02-primitives/popover/popover.js +331 -0
  44. package/02-primitives/progress/README.md +82 -0
  45. package/02-primitives/progress/demo.html +154 -0
  46. package/02-primitives/progress/progress.css +172 -0
  47. package/02-primitives/select/README.md +95 -0
  48. package/02-primitives/select/demo.html +121 -0
  49. package/02-primitives/select/select.css +151 -0
  50. package/02-primitives/select/select.js +549 -0
  51. package/02-primitives/separator/README.md +63 -0
  52. package/02-primitives/separator/demo.html +84 -0
  53. package/02-primitives/separator/separator.css +73 -0
  54. package/02-primitives/skeleton/README.md +69 -0
  55. package/02-primitives/skeleton/demo.html +110 -0
  56. package/02-primitives/skeleton/skeleton.css +96 -0
  57. package/02-primitives/switch/README.md +65 -0
  58. package/02-primitives/switch/demo.html +115 -0
  59. package/02-primitives/switch/switch.css +370 -0
  60. package/03-patterns/accordion/README.md +121 -0
  61. package/03-patterns/accordion/accordion.css +453 -0
  62. package/03-patterns/accordion/accordion.js +369 -0
  63. package/03-patterns/accordion/demo.html +223 -0
  64. package/03-patterns/content/README.md +103 -0
  65. package/03-patterns/content/content.css +286 -0
  66. package/03-patterns/content/demo.html +88 -0
  67. package/03-patterns/drawer/README.md +79 -0
  68. package/03-patterns/drawer/demo.html +101 -0
  69. package/03-patterns/drawer/drawer.css +129 -0
  70. package/03-patterns/drawer/drawer.js +258 -0
  71. package/03-patterns/dropdown/README.md +78 -0
  72. package/03-patterns/dropdown/demo.html +101 -0
  73. package/03-patterns/dropdown/dropdown.css +135 -0
  74. package/03-patterns/dropdown/dropdown.js +451 -0
  75. package/03-patterns/form-validation/README.md +147 -0
  76. package/03-patterns/form-validation/demo.html +280 -0
  77. package/03-patterns/form-validation/form-validation.css +263 -0
  78. package/03-patterns/list/README.md +150 -0
  79. package/03-patterns/list/demo.html +225 -0
  80. package/03-patterns/list/flip.js +201 -0
  81. package/03-patterns/list/list.css +162 -0
  82. package/03-patterns/nav/README.md +225 -0
  83. package/03-patterns/nav/demo.html +131 -0
  84. package/03-patterns/nav/nav.css +314 -0
  85. package/03-patterns/nav/nav.js +430 -0
  86. package/03-patterns/nav/toc.js +111 -0
  87. package/03-patterns/overlay/README.md +196 -0
  88. package/03-patterns/overlay/demo.html +241 -0
  89. package/03-patterns/overlay/overlay.css +319 -0
  90. package/03-patterns/overlay/overlay.js +563 -0
  91. package/03-patterns/pagination/README.md +114 -0
  92. package/03-patterns/pagination/demo.html +165 -0
  93. package/03-patterns/pagination/pagination.css +195 -0
  94. package/03-patterns/pagination/pagination.js +395 -0
  95. package/03-patterns/reveal/README.md +91 -0
  96. package/03-patterns/reveal/demo.html +106 -0
  97. package/03-patterns/reveal/reveal.css +93 -0
  98. package/03-patterns/reveal/reveal.js +172 -0
  99. package/03-patterns/states/README.md +141 -0
  100. package/03-patterns/states/demo.html +240 -0
  101. package/03-patterns/states/states.css +252 -0
  102. package/03-patterns/tabs/README.md +84 -0
  103. package/03-patterns/tabs/demo.html +139 -0
  104. package/03-patterns/tabs/tabs.css +172 -0
  105. package/03-patterns/tabs/tabs.js +307 -0
  106. package/03-patterns/tooltip/README.md +96 -0
  107. package/03-patterns/tooltip/demo.html +78 -0
  108. package/03-patterns/tooltip/tooltip.css +89 -0
  109. package/03-patterns/tooltip/tooltip.js +288 -0
  110. package/03-patterns/tree/README.md +94 -0
  111. package/03-patterns/tree/demo.html +138 -0
  112. package/03-patterns/tree/tree.css +113 -0
  113. package/03-patterns/tree/tree.js +456 -0
  114. package/04-recipes/analysis-report/README.md +80 -0
  115. package/04-recipes/analysis-report/analysis-report.css +187 -0
  116. package/04-recipes/analysis-report/demo.html +274 -0
  117. package/04-recipes/data-showcase/README.md +45 -0
  118. package/04-recipes/data-showcase/demo.html +247 -0
  119. package/04-recipes/detail-page/README.md +93 -0
  120. package/04-recipes/detail-page/demo.html +55 -0
  121. package/04-recipes/detail-page/detail-page.css +190 -0
  122. package/04-recipes/detail-page/full.html +60 -0
  123. package/04-recipes/detail-page/null.html +60 -0
  124. package/04-recipes/detail-page/partial.html +60 -0
  125. package/04-recipes/detail-page/verify-skeleton.py +19 -0
  126. package/04-recipes/longform/README.md +93 -0
  127. package/04-recipes/longform/agc-memory.svg +4 -0
  128. package/04-recipes/longform/content.html +610 -0
  129. package/04-recipes/longform/content.md +51 -0
  130. package/04-recipes/longform/longform.html +627 -0
  131. package/04-recipes/report/README.md +176 -0
  132. package/04-recipes/report/demo.html +355 -0
  133. package/04-recipes/report/report.css +364 -0
  134. package/04-recipes/table/README.md +103 -0
  135. package/04-recipes/table/demo.html +455 -0
  136. package/04-recipes/table/table.css +372 -0
  137. package/04-recipes/tierA-tokens.css +189 -0
  138. package/09-assets/README.md +74 -0
  139. package/09-assets/bar/README.md +94 -0
  140. package/09-assets/bar/bar.css +166 -0
  141. package/09-assets/bar/bar.js +320 -0
  142. package/09-assets/bar/demo.html +176 -0
  143. package/09-assets/echarts-adapter/demo.html +192 -0
  144. package/09-assets/echarts-adapter/echarts-adapter.js +277 -0
  145. package/09-assets/model-viewer/README.md +155 -0
  146. package/09-assets/model-viewer/demo.html +105 -0
  147. package/09-assets/model-viewer/model-viewer.css +143 -0
  148. package/09-assets/model-viewer/model-viewer.js +423 -0
  149. package/09-assets/scientific-plot/README.md +94 -0
  150. package/09-assets/scientific-plot/demo.html +252 -0
  151. package/09-assets/scientific-plot/scientific-plot.css +141 -0
  152. package/09-assets/scientific-plot/scientific-plot.js +610 -0
  153. package/09-assets/sparkline/README.md +153 -0
  154. package/09-assets/sparkline/demo.html +163 -0
  155. package/09-assets/sparkline/sparkline.css +158 -0
  156. package/09-assets/sparkline/sparkline.js +508 -0
  157. package/API.md +332 -0
  158. package/CHANGELOG.md +3138 -0
  159. package/CODE_OF_CONDUCT.md +64 -0
  160. package/CONTRIBUTING.md +89 -0
  161. package/ERRORS.md +1658 -0
  162. package/LICENSE +21 -0
  163. package/README.md +497 -2
  164. package/SECURITY.md +72 -0
  165. package/START-HERE.md +164 -0
  166. package/SUPPORT.md +109 -0
  167. package/adapters/presentation/README.md +113 -0
  168. package/adapters/presentation/check.js +101 -0
  169. package/adapters/presentation/deck.schema.json +74 -0
  170. package/adapters/presentation/to-doc.js +138 -0
  171. package/ai/START-HERE.md +104 -0
  172. package/ai/behaviors.json +380 -0
  173. package/ai/behaviors.schema.json +61 -0
  174. package/ai/capabilities.json +56 -0
  175. package/ai/cli.js +387 -0
  176. package/ai/components.json +1944 -0
  177. package/ai/components.meta.json +168 -0
  178. package/ai/components.schema.json +220 -0
  179. package/ai/contract.schema.json +147 -0
  180. package/ai/deprecations.json +55 -0
  181. package/ai/deprecations.schema.json +85 -0
  182. package/ai/motion-policy.json +22 -0
  183. package/ai/patch.js +323 -0
  184. package/ai/patch.schema.json +63 -0
  185. package/ai/profiles.json +92 -0
  186. package/ai/tokens.audience.json +55 -0
  187. package/ai/tokens.audience.schema.json +73 -0
  188. package/ai/tokens.json +762 -0
  189. package/ai/tokens.schema.json +58 -0
  190. package/ai/tokens.tree.json +752 -0
  191. package/ai/tokens.tree.schema.json +56 -0
  192. package/ai/validate.js +383 -0
  193. package/dist/01-tokens/behavior/dismissable.js +60 -0
  194. package/dist/01-tokens/behavior/emit.js +21 -0
  195. package/dist/01-tokens/behavior/focus-return.js +31 -0
  196. package/dist/01-tokens/behavior/roving.js +77 -0
  197. package/dist/01-tokens/behavior/typeahead.js +61 -0
  198. package/dist/01-tokens/focus-ring.css +1 -0
  199. package/dist/01-tokens/page.css +1 -0
  200. package/dist/01-tokens/polyfill.js +78 -0
  201. package/dist/01-tokens/scrollbar.css +1 -0
  202. package/dist/01-tokens/theme-toggle.js +61 -0
  203. package/dist/01-tokens/tokens.css +1 -0
  204. package/dist/01-tokens/typography.css +1 -0
  205. package/dist/02-primitives/badge/badge.css +1 -0
  206. package/dist/02-primitives/button/button.css +1 -0
  207. package/dist/02-primitives/card/card.css +1 -0
  208. package/dist/02-primitives/choice/choice.css +1 -0
  209. package/dist/02-primitives/combobox/combobox.css +1 -0
  210. package/dist/02-primitives/combobox/combobox.js +439 -0
  211. package/dist/02-primitives/date-range/date-range.css +1 -0
  212. package/dist/02-primitives/date-range/date-range.js +267 -0
  213. package/dist/02-primitives/input/input.css +1 -0
  214. package/dist/02-primitives/popover/popover.css +1 -0
  215. package/dist/02-primitives/popover/popover.js +257 -0
  216. package/dist/02-primitives/progress/progress.css +1 -0
  217. package/dist/02-primitives/select/select.css +1 -0
  218. package/dist/02-primitives/select/select.js +449 -0
  219. package/dist/02-primitives/separator/separator.css +1 -0
  220. package/dist/02-primitives/skeleton/skeleton.css +1 -0
  221. package/dist/02-primitives/switch/switch.css +1 -0
  222. package/dist/03-patterns/accordion/accordion.css +1 -0
  223. package/dist/03-patterns/accordion/accordion.js +219 -0
  224. package/dist/03-patterns/content/content.css +1 -0
  225. package/dist/03-patterns/drawer/drawer.css +1 -0
  226. package/dist/03-patterns/drawer/drawer.js +229 -0
  227. package/dist/03-patterns/dropdown/dropdown.css +1 -0
  228. package/dist/03-patterns/dropdown/dropdown.js +365 -0
  229. package/dist/03-patterns/form-validation/form-validation.css +1 -0
  230. package/dist/03-patterns/list/flip.js +117 -0
  231. package/dist/03-patterns/list/list.css +1 -0
  232. package/dist/03-patterns/nav/nav.css +1 -0
  233. package/dist/03-patterns/nav/nav.js +322 -0
  234. package/dist/03-patterns/nav/toc.js +76 -0
  235. package/dist/03-patterns/overlay/overlay.css +1 -0
  236. package/dist/03-patterns/overlay/overlay.js +398 -0
  237. package/dist/03-patterns/pagination/pagination.css +1 -0
  238. package/dist/03-patterns/pagination/pagination.js +273 -0
  239. package/dist/03-patterns/reveal/reveal.css +1 -0
  240. package/dist/03-patterns/reveal/reveal.js +138 -0
  241. package/dist/03-patterns/states/states.css +1 -0
  242. package/dist/03-patterns/tabs/tabs.css +1 -0
  243. package/dist/03-patterns/tabs/tabs.js +242 -0
  244. package/dist/03-patterns/tooltip/tooltip.css +1 -0
  245. package/dist/03-patterns/tooltip/tooltip.js +213 -0
  246. package/dist/03-patterns/tree/tree.css +1 -0
  247. package/dist/03-patterns/tree/tree.js +383 -0
  248. package/dist/04-recipes/analysis-report/analysis-report.css +1 -0
  249. package/dist/04-recipes/detail-page/detail-page.css +1 -0
  250. package/dist/04-recipes/report/report.css +1 -0
  251. package/dist/04-recipes/table/table.css +1 -0
  252. package/dist/04-recipes/tierA-tokens.css +1 -0
  253. package/dist/09-assets/bar/bar.css +1 -0
  254. package/dist/09-assets/bar/bar.js +222 -0
  255. package/dist/09-assets/echarts-adapter/echarts-adapter.js +185 -0
  256. package/dist/09-assets/model-viewer/model-viewer.css +1 -0
  257. package/dist/09-assets/model-viewer/model-viewer.js +320 -0
  258. package/dist/09-assets/scientific-plot/scientific-plot.css +1 -0
  259. package/dist/09-assets/scientific-plot/scientific-plot.js +568 -0
  260. package/dist/09-assets/sparkline/sparkline.css +1 -0
  261. package/dist/09-assets/sparkline/sparkline.js +265 -0
  262. package/dist/COSTS.md +56 -0
  263. package/docs/AI-CONTRACT.md +197 -0
  264. package/docs/BACKLOG.md +127 -0
  265. package/docs/BENCHMARK.md +252 -0
  266. package/docs/GUIDANCE.md +154 -0
  267. package/docs/INVARIANT.md +372 -0
  268. package/docs/LAYER-BASELINE.md +158 -0
  269. package/docs/RELEASING.md +311 -0
  270. package/docs/RFC-THEME-SCOPE.md +141 -0
  271. package/docs/ROADMAP.md +182 -0
  272. package/index.html +82 -0
  273. package/package.json +107 -4
  274. package/types/index.d.ts +310 -0
@@ -0,0 +1,236 @@
1
+ # tokens —— 设计令牌
2
+
3
+ | 字段 | 内容 |
4
+ |---|---|
5
+ | **来源** | 原创。流体公式与 WCAG 判据来自公开资料,见下 |
6
+ | **实测** | `05-audit/contrast.py` 22/22 |
7
+ | **适用** | 精简档 / B / C |
8
+ | **规范** | 见本文件「流体与断点」一节|
9
+
10
+ ---
11
+
12
+ ## 三组令牌,三条不同的规则
13
+
14
+ | 组 | 规则 | 为什么 |
15
+ |---|---|---|
16
+ | **色** | **全部必须过对比度门禁** | 无对比度的颜色等于没有 |
17
+ | **字号** | 标题/正文流体,**UI 文本固定** | UI 字号参与控件尺寸,流体化会抖动 |
18
+ | **间距** | 大值流体,小值固定 | 同上 |
19
+
20
+ 🔴 每档的取值与理由都写在下面,**改之前先读这一节**。
21
+
22
+ ---
23
+
24
+ ## 明暗与**局部换肤**
25
+
26
+ 整页明暗有三条路,都不需要 JS:
27
+
28
+ | 做法 | 效果 |
29
+ |---|---|
30
+ | 什么都不做 | 跟随系统(`prefers-color-scheme`) |
31
+ | `<html data-theme="dark">` | 强制暗(系统是浅色也暗) |
32
+ | `<html data-theme="light">` | 强制亮(系统是暗色也亮) |
33
+
34
+ **任何容器**也可以单独指定,用来做"深色 hero + 浅色正文"、文档站里的反色示例:
35
+
36
+ ```html
37
+ <div data-theme="dark"> <!-- 这块区域整体切到暗色 -->
38
+ <button class="btn btn--primary">暗色按钮</button>
39
+
40
+ <div data-theme="light"> <!-- 嵌回去也成立 -->
41
+ <button class="btn btn--primary">浅色按钮</button>
42
+ </div>
43
+ </div>
44
+ ```
45
+
46
+ ⚠️ 三条边界,都是有意的:
47
+
48
+ 1. **容器必须显式写 `data-theme`**。不做"没写就跟随系统"——那需要把令牌写到
49
+ **每一个元素**上(`*:not([data-theme])`),后果是使用者写在 `body` 上的覆盖
50
+ 失效,且长页面上每个元素各存 30 个自定义属性。
51
+ 2. **容器自己不会涂底色**。`[data-theme]` 只切换令牌;要不要给这块区域加背景,
52
+ 由你决定(通常 `background: var(--paper)`)。
53
+ 3. **`color-scheme` 目前只由 `date-range` 局部设置**,根上没有。所以暗色下
54
+ 原生滚动条/日期弹层不会跟着变 —— 这是已知未决项,见
55
+ `docs/RFC-THEME-SCOPE.md` 第六节(它会让既有 40 页视觉基线全变,需要单独一轮)。
56
+
57
+ 组件的明暗差异**全部由令牌表达**,组件 CSS 里不许出现
58
+ `@media (prefers-color-scheme: dark)` —— 主题能从容器来之后,那种写法只反映系统偏好,
59
+ 局部暗区里的组件会拿到另一套配色。这条由 `theme-scope-gate` 静态守着。
60
+
61
+ ---
62
+
63
+ ## 色(全部实测达标)
64
+
65
+ | 令牌 | 值 | 对比度 |
66
+ |---|---|---|
67
+ | `--paper` | `#f6f7f8` | 底色 |
68
+ | `--text-primary` | `#15181c` | **17.37 AAA** |
69
+ | `--text-secondary` | `#4f555e` | **7.33 AAA** |
70
+ | `--text-tertiary` | `#6b7280` | **4.72 AA** |
71
+ | `--accent` | `#1b4d8f` | **8.18 AAA** |
72
+ | `--danger` | `#c0392b` | **6.26 AA** |
73
+ | `--border-control` | `#868b94` | **3.56**(UI 边界需 ≥3.0)|
74
+
75
+ 🔴 **暖褐色已被移除**。理由见 `05-品味规范.md`:
76
+ 暖纸感是我第一轮凭感觉定的,没有依据。**颜色应该服务于内容,不该自己表演。**
77
+
78
+ ---
79
+
80
+ ## 字号:流体与固定的分工
81
+
82
+ ```css
83
+ --fs-xs: 0.75rem; /* 12px 固定:辅助文字 */
84
+ --fs-sm: 0.875rem; /* 14px 固定:UI 文本 */
85
+ --fs-base: clamp(1rem, 0.95rem + 0.3vw, 1.125rem); /* 16 → 18px */
86
+ --fs-md: clamp(1.125rem, 1.05rem + 0.5vw, 1.3125rem); /* 18 → 21px */
87
+ --fs-lg: clamp(1.3125rem, 1.2rem + 0.7vw, 1.5rem); /* 21 → 24px */
88
+ --fs-xl: clamp(1.5rem, 1.3rem + 1.2vw, 1.875rem); /* 24 → 30px */
89
+ --fs-2xl: clamp(1.75rem, 1.45rem + 1.8vw, 2.5rem); /* 28 → 40px */
90
+ ```
91
+
92
+ ### 两条硬规则
93
+
94
+ **1. `clamp()` 的上下限必须用 rem,绝不用 px。**
95
+ px 上下限不尊重"用户把浏览器字号调大"的设置——下限会锁死,放到 200% 也涨不上去。
96
+
97
+ **2. UI 文本(`--fs-xs` / `--fs-sm`)不流体。**
98
+ 它们是**控件尺寸的一部分**。流体会让按钮和输入框在不同屏幕下大小不一、布局抖动。
99
+
100
+ ### 验收判据(WCAG 1.4.4)
101
+
102
+ > 只要 **MAX ≤ 2.5 × MIN**,就一定通过 resize-text 检查。
103
+
104
+ 本库最大比值 = 2.5 ÷ 1.75 = **1.43**,安全。
105
+
106
+ ### 🔴 硬下限:表单字号 ≥ 16px
107
+
108
+ `input` 的字号**不能**低于 16px。iOS Safari 在小于 16px 时会自动放大整个页面
109
+ **且不缩回**。这是硬约束,不是审美。
110
+
111
+ ---
112
+
113
+ ## 间距:大值流体
114
+
115
+ ```css
116
+ --sp-1: 4px; --sp-2: 8px; --sp-3: 12px; /* 固定 */
117
+ --sp-4: clamp(1rem, 0.95rem + 0.3vw, 1.25rem); /* 16 → 20px */
118
+ --sp-5: clamp(1.25rem, 1.15rem + 0.7vw, 1.75rem); /* 20 → 28px */
119
+ --sp-6: clamp(1.5rem, 1.3rem + 1.2vw, 2.25rem); /* 24 → 36px */
120
+ --sp-7: clamp(2rem, 1.7rem + 1.8vw, 3.5rem); /* 32 → 56px */
121
+ --sp-8: clamp(2.5rem, 2.1rem + 2.4vw, 4.5rem); /* 40 → 72px */
122
+ ```
123
+
124
+ **判据**:
125
+ > 这个值变了会不会导致"控件大小跟着变"?会 → **不要**流体。
126
+
127
+ ---
128
+
129
+ ## 断点令牌
130
+
131
+ | 令牌 | 值 | 用途 |
132
+ |---|---|---|
133
+ | `--bp-sm` | 30rem (480px) | 大手机 / 小平板 |
134
+ | `--bp-md` | 48rem (768px) | 平板 |
135
+ | `--bp-lg` | 64rem (1024px) | 小笔记本 |
136
+ | `--bp-xl` | 80rem (1280px) | 桌面 |
137
+
138
+ 🔴 **断点用 rem 不用 px** —— 用户调大字号时断点跟着变,这才是对的。
139
+
140
+ 🔴 **CSS 变量不能用在 media query 条件里**,所以写的时候仍要字面值
141
+ (`@media (min-width: 48rem)`,不是 `var(--bp-md)`)。
142
+ 令牌的作用是:给流体计算提供锚点、给脚本查询、让"我们有几个断点"有唯一答案。
143
+
144
+ ---
145
+
146
+ ## 两份机器可读导出(给不同胃口的工具)
147
+
148
+ | 文件 | 形状 | 喂给谁 |
149
+ |---|---|---|
150
+ | `ai/tokens.json` | DTCG 草案:键名带 `--`,字段带 `$`(`$value` / `$type`) | 想**读**令牌的人 / 认 DTCG 的工具 |
151
+ | `ai/tokens.tree.json` | 分层:键名不带 `--`,字段不带 `$`(`value` / `type`) | **Style Dictionary / Figma Tokens(Tokens Studio)** |
152
+
153
+ 为什么要两份:**`$value` 和 `value` 是不兼容的两套约定**,
154
+ 同一个文件喂不进两类工具。`tokens.tree.json` 的 `index` 段把两种键名互相对应
155
+ (`--paper` ↔ `color/paper`)。
156
+
157
+ 命令行可直接查:
158
+
159
+ ```bash
160
+ node ai/cli.js tokens [--set=light|dark] # 第一种
161
+ node ai/cli.js tree [--set=light|dark] [--cat=color] # 第二种
162
+ ```
163
+
164
+ ⚠️ 第二份是**生成物**,它的正确性不是靠"再生成一遍"保证的 ——
165
+ `token-tree-gate.py` 判据 ⑤ 会把每一个名字、每一个值拿到**真浏览器**里,
166
+ 用 Chrome 自己的 CSS 解析器(CSSOM)重新读一遍再比。
167
+
168
+ ---
169
+
170
+ ## 库里没引用、但承诺给你用的令牌
171
+
172
+ 下面这些令牌**本库的组件一个都没用**(`token-audience-gate.py` 会一直盯着这个数:
173
+ 现在 5 个,只能降不能升)。它们不是死代码 —— 是**阶梯的档位**和**给脚本读的常量**,
174
+ 你写自己的页面时可以直接引用。
175
+
176
+ | 令牌 | 值 | 为什么库里没人用 |
177
+ |---|---|---|
178
+ | `--tracking-normal` | `0` | 字距阶梯的"常规"档:它是**默认值**,写了等于没写 |
179
+ | `--bp-sm` / `--bp-lg` / `--bp-xl` | 见上表 | 断点**物理上不可能**被 `var()` 引用(进不了 media query 条件) |
180
+
181
+ > 2026-10-10(第二次):新增的 `04-recipes/report` 在本层内把边注重定义为 14em,
182
+ > 因此不再引用本层的"边注"档 ⇒ 那个档位回到这张表里。
183
+ > 棘轮数仍为 5(同一轮里既有回收也有放出,净额不变)。
184
+ > ⚠️ 这不是"放水":门禁把它挑出来的依据是它**真的**没人引用了。
185
+ >
186
+ > 2026-10-10:行高阶梯的"松"档与字号阶梯的 XL 档原先在这张表里。
187
+ > 新增的 `04-recipes/analysis-report`(代码块行高)与
188
+ > `04-recipes/data-showcase`(KPI 数值字号)开始引用它们 ⇒ 它们**不再**是孤儿。
189
+ > 按双向校验规则从名单移除,棘轮数 7 → 5(棘轮只能降,本次是降)。
190
+ >
191
+ > ⚠️ 这一节里的**任何** `--` 开头字符串都会被 `token-audience-gate.py` 当作名单项,
192
+ > 所以上面这句只能用中文描述、不能直接写令牌名。
193
+
194
+ ⚠️ 这张表是**双向校验**的:表里的令牌必须真的存在、真的没人引用;
195
+ 反过来,一个没人引用的公开令牌如果不在表里,门禁都会拦。
196
+ (0.7.3 就据此挖出两个真缺陷:一个历史遗留的版心令牌被移除,
197
+ 一个"为抽屉造的缓动曲线"其实从没接到抽屉上。)
198
+
199
+ ---
200
+
201
+ ## 页面骨架 `page.css`(选配)
202
+
203
+ `tokens.css` 管**值**,`typography.css` 管**辅助文字**,`page.css` 管**页面框**:
204
+ 页面底色/字色/安全区内边距、内容列宽、标题阶梯、行内代码、说明块、演示块。
205
+
206
+ **只在写了 `class="page"` 的页面上生效**(不写就不生效,对宿主零副作用)。
207
+
208
+ 要改全局观感,改这几个令牌就够了 —— 不要在页面里再写一遍:
209
+
210
+ | 令牌 | 值 | 管什么 |
211
+ |---|---|---|
212
+ | `--measure-page` | `52rem` | 内容列宽(所有 `.wrap` 一起变) |
213
+ | `--tracking-title` | `-0.02em` | 标题字距 |
214
+ | `--fs-2xl` | 见字号表 | 一级标题字号 |
215
+ | `--sp-6` / `--sp-4` | 见间距表 | 页面框的上下 / 左右内边距 |
216
+ | `--measure-prose` | 见下 | `.wrap--prose`(长文窄列) |
217
+
218
+ `--measure-page` 的来历:收编之前 33 个页面写了 16 种列宽
219
+ (680 / 720 / 760 / 800 / 820 / 860 / 900 px,以及 42 / 44 / 46 / 52 rem 和 72ch),
220
+ 彼此**没有语义差别**,纯粹是每次手敲随手写的。取众数 820px,落成干净的 `52rem`。
221
+
222
+ ⚠️ 页面里重写 `body` / `h1` / `h2` / `.wrap` / `code` 等
223
+ **不会报错但也不会生效**(被 `.page` 前缀的定义以更高特异性静默盖掉,
224
+ 成为死声明)⇒ `05-audit/shell-gate.py` 会拦住它。
225
+
226
+ ## 精简档 的使用方式
227
+
228
+ 复制 **`04-recipes/tierA-tokens.css`** 进 `<style>`(**5.1 KB**,含浅色 102 + 暗色 33 个令牌,零外链零构建)。
229
+
230
+ 那一份是由 `tokens.css` **自动抽取**的(`python 05-audit/make-tiersnippet.py`),
231
+ 所以不会与本文件漂移;改了这个文件请重新生成。
232
+
233
+ > 🔴 更正:这一行原先写的是「复制 `tokens.css` 里 `[核心]` 段,约 2 KB」,
234
+ > **但 `[核心]` 这个区块从来不存在** —— 是个指向空处的假承诺。
235
+
236
+ `responsive-demo.html` 可以拖窄窗口直接看出哪些在流体、哪些没有。
@@ -0,0 +1,132 @@
1
+ /**
2
+ * dismissable.js — 「关得掉」微行为核
3
+ *
4
+ * ==========================================================================
5
+ * 🔴 为什么要抽成核(数字是实测的,复算命令见本项目更新日志)
6
+ * --------------------------------------------------------------------------
7
+ * 抽之前,14 个组件 JS 里:
8
+ * · Esc 关闭 手写了 10 处,分布在 7 个文件
9
+ * · 点击外部关闭 手写了 7 处,分布在 7 个文件
10
+ *
11
+ * 同一件事写 7 份 ⇒ 改一处要改 7 处 ⇒ 必然漏(见 I-14)。
12
+ * 更难看的是**漏了没人知道**:popover 曾因为 `contains` 没把触发元素算进去,
13
+ * 导致"点触发器刚打开就被关掉" —— 这种 bug 会在每个手写副本里各犯一遍。
14
+ *
15
+ * ==========================================================================
16
+ * 边界:只管"关得掉",不管别的
17
+ * --------------------------------------------------------------------------
18
+ * ① Esc 关闭
19
+ * ② 点击浮层之外关闭
20
+ * 不管:焦点归位(focus-return 核)、滚动锁、动画。
21
+ * 一个核只做一件事 —— 否则它就变成另一个"什么都管"的大文件。
22
+ *
23
+ * ==========================================================================
24
+ * 用法
25
+ * --------------------------------------------------------------------------
26
+ * var d = flDismissable({
27
+ * escOn: [box, trigger], // 在这些元素上听 Esc
28
+ * inside: [box, anchor], // 点在这些里面 ⇒ 不算"外部"
29
+ * when: function () { return opened; }, // 只有开着才响应
30
+ * onDismiss: function (ev, reason) { close(); } // reason: 'esc' | 'outside'
31
+ * });
32
+ * d.destroy(); // 必须成对调用,否则监听器与栈都会泄漏
33
+ *
34
+ * ==========================================================================
35
+ * ⚠️ 三个必须知道的实现细节(都是实测踩出来的,改之前先读)
36
+ * --------------------------------------------------------------------------
37
+ * ① **Esc 挂在元素上(冒泡阶段),不是挂在 document 上**:
38
+ * 冒泡从最内层往外走 ⇒ 最内层先收到 ⇒ 它 stopPropagation 之后
39
+ * 外层的 dialog 就收不到 ⇒ 一次 Esc 只关最内层。
40
+ * (挂在 document 捕获阶段会**先**触发 ⇒ 分不清内外层 ⇒ 两层一起关)
41
+ *
42
+ * ② **外部点击必须挂在 document 捕获阶段**:
43
+ * 组件自己的 click 先跑、再轮到"是不是外部"的判定,
44
+ * 否则点触发器会被判成外部点击 ⇒ 刚打开就关掉(popover 踩过)。
45
+ *
46
+ * ③ **栈必须挂在 window 上,不能写在闭包里**:
47
+ * 本核会被 05-audit/gen-behavior.py 整块注入进多个组件文件,
48
+ * 每个组件拿到的是**各自的副本**(各自闭包)。
49
+ * 栈若写在闭包里 ⇒ 两个组件各维护一个栈 ⇒ 嵌套时 ① 的语义失效。
50
+ *
51
+ * 栈的取舍(如实写明):同一时刻**只允许一个非模态浮层打开**
52
+ * (本库既有约定,popover 的 open() 会先关掉别的),
53
+ * 在此前提下"取最后一个活跃的记录"与"取最后打开的那个"等价。
54
+ *
55
+ * ==========================================================================
56
+ * 约束:ES5 · 零依赖 · 可被整块注入 · 不污染除 __flDismiss 外的任何全局
57
+ * ==========================================================================
58
+ */
59
+ /* ==== BEHAVIOR CORE BEGIN: dismissable ==== */
60
+ var flDismissable = function (opts) {
61
+ var opt = opts || {};
62
+ var escOn = opt.escOn || [];
63
+ var inside = opt.inside || [];
64
+ var when = opt.when || function () { return true; };
65
+ var onDismiss = opt.onDismiss || function () {};
66
+
67
+ var shared = window.__flDismiss || (window.__flDismiss = { stack: [], bound: false });
68
+
69
+ function isInside(node) {
70
+ if (!node) return false;
71
+ for (var i = 0; i < inside.length; i++) {
72
+ var el = inside[i];
73
+ if (!el || !el.contains) continue;
74
+ if (el === node || el.contains(node)) return true;
75
+ }
76
+ return false;
77
+ }
78
+
79
+ function onKey(e) {
80
+ if (!when()) return;
81
+ var k = e.key;
82
+ if (k !== 'Escape' && k !== 'Esc' && e.keyCode !== 27) return;
83
+ /* 🔴 为什么 onDismiss 在前、preventDefault/stopPropagation 在后
84
+ (0.8.8 改):**分级 Esc** 需要核让组件有机会说"这次我没关"。
85
+ combobox 的 Esc 是三级的:
86
+ ① 列表开着 → 关列表
87
+ ② 列表关了、输入有字 → 清空输入
88
+ ③ 都没了 → 什么都不做(此时**不该**阻止默认行为,
89
+ 让 Esc 能继续传给外层浮层)
90
+ 而原实现是「无条件 preventDefault + stopPropagation,再调 onDismiss」
91
+ ⇒ 组件根本无法表达"这次我没关"。
92
+ ⇒ 改成:先问onDismiss,它返回 false 就**不干预**默认行为。
93
+ ⚠️ 顺序安全性:stopPropagation 只需在**本次监听器执行期间**被调用即可
94
+ 阻止冒泡到外层,放在 onDismiss 之后仍然有效。
95
+ */
96
+ var closed = onDismiss(e, 'esc');
97
+ if (closed === false) return;
98
+ if (e.preventDefault) e.preventDefault();
99
+ if (e.stopPropagation) e.stopPropagation();
100
+ }
101
+
102
+ function onDocClick(e) {
103
+ var top = null;
104
+ for (var i = shared.stack.length - 1; i >= 0; i--) {
105
+ if (shared.stack[i].when()) { top = shared.stack[i]; break; }
106
+ }
107
+ if (!top || top.isInside(e.target)) return;
108
+ top.onDismiss(e, 'outside');
109
+ }
110
+
111
+ var rec = { when: when, isInside: isInside, onDismiss: onDismiss };
112
+
113
+ for (var j = 0; j < escOn.length; j++) {
114
+ if (escOn[j]) escOn[j].addEventListener('keydown', onKey);
115
+ }
116
+ shared.stack.push(rec);
117
+ if (!shared.bound) {
118
+ shared.bound = true;
119
+ document.addEventListener('click', onDocClick, true);
120
+ }
121
+
122
+ return {
123
+ destroy: function () {
124
+ for (var j = 0; j < escOn.length; j++) {
125
+ if (escOn[j]) escOn[j].removeEventListener('keydown', onKey);
126
+ }
127
+ var k = shared.stack.indexOf(rec);
128
+ if (k >= 0) shared.stack.splice(k, 1);
129
+ },
130
+ };
131
+ };
132
+ /* ==== BEHAVIOR CORE END: dismissable ==== */
@@ -0,0 +1,76 @@
1
+ /**
2
+ * emit.js — 「统一事件发射」工具核
3
+ *
4
+ * ==========================================================================
5
+ * 🔴 为什么要抽成核(不是因为懒,是因为这里有一处**必须只有一份**的兜底)
6
+ * --------------------------------------------------------------------------
7
+ * 老 WebView(以及部分精简档内核)**没有 `CustomEvent` 构造函数**,
8
+ * 只有 `document.createEvent('CustomEvent')` + `initCustomEvent()`。
9
+ *
10
+ * ⚠️ 这个坑的恶劣之处:
11
+ * · `new CustomEvent(...)` 在老环境里**抛异常**,而且是在**用户交互的路径上**抛
12
+ * (点一下按钮才炸),本地 Chrome 永远测不出来;
13
+ * · 若每个组件各写一次兜底,8 个组件里迟早有一个只写了新写法
14
+ * ⇒ 那个组件在真机上"点了没反应",而且控制台不一定有线索。
15
+ *
16
+ * ⇒ 兜底逻辑只能有一份,由门禁保证每个使用点逐行一致。
17
+ *
18
+ * ==========================================================================
19
+ * 事件模型(统一约定,不是各组件各起各的)
20
+ * --------------------------------------------------------------------------
21
+ * ① 事件名一律 `fl-` 前缀。
22
+ * 为什么:组件内部有原生 `<input>` / `<button>`,它们的 `change` / `click`
23
+ * 会**冒泡上来**;不加前缀 ⇒ 调用方 `el.addEventListener('change', ...)`
24
+ * 会收到自己没订阅过的东西。
25
+ * ② 一律 `bubbles: true`:调用方可以在**祖先**上做事件委托。
26
+ * ③ 一律 `cancelable: false`:本库的事件是**通知**,不是"可撤销的动作"。
27
+ * (真要拦就用回调里 return false,别把两件事混在事件上。)
28
+ * ④ 一律在**组件根元素**上派发,不在内部子元素上。
29
+ * ⑤ `detail` 统一带 `value`(有值的组件),组件自己的字段另加。
30
+ * ⑥ 委托时**必须认 `e.target`**:事件会冒泡,嵌套组件(如 tabs 里放
31
+ * select)会在同一个祖先上收到两个 `fl-change`;靠 `e.target` 认
32
+ * 组件根,事件名不能代替身份。
33
+ * ⑦ 顺序:**先派发事件,再调回调**。回调是调用方写的,可能抛异常;
34
+ * 事件先落地 ⇒ 回调炸了也不会连累挂在祖先上的其它订阅者。
35
+
36
+ * ==========================================================================
37
+ * 用法
38
+ * --------------------------------------------------------------------------
39
+ * flEmit(root, 'fl-change', { value: v });
40
+ * // 名字里已带 fl- 就不重复加;不带就自动补 —— 防止漏写前缀
41
+ *
42
+ * ⚠️ 它**不是"行为核"**(不像 Esc 关闭那样是用户能感知的行为),
43
+ * 所以不进 `ai/behaviors.json` 的行为矩阵;它是**工具核**。
44
+ *
45
+ * ==========================================================================
46
+ * ⚠️ 注入块里的注释会**被复制进每个使用点**(gzip 也要算)
47
+ * --------------------------------------------------------------------------
48
+ * ⇒ 长解释一律写在**本文件头**(BEGIN 标记之外),注入块内只留一行提示。
49
+ *
50
+ * ==========================================================================
51
+ * 约束:ES5 · 零依赖 · 可被整块注入 · 不新增任何全局
52
+ * ==========================================================================
53
+ */
54
+ /* ==== BEHAVIOR CORE BEGIN: emit ==== */
55
+ var flEmit = function (el, name, detail) {
56
+ if (!el || !el.dispatchEvent) return null;
57
+ var type = name.indexOf('fl-') === 0 ? name : 'fl-' + name;
58
+ var ev = null;
59
+ /* 老 WebView 没有 CustomEvent 构造函数 ⇒ 兜底走 createEvent(理由见文件头)*/
60
+ if (typeof window.CustomEvent === 'function') {
61
+ try {
62
+ ev = new window.CustomEvent(type, {
63
+ detail: detail || null, bubbles: true, cancelable: false
64
+ });
65
+ } catch (e) { ev = null; }
66
+ }
67
+ if (!ev) {
68
+ try {
69
+ ev = document.createEvent('CustomEvent');
70
+ ev.initCustomEvent(type, true, false, detail || null);
71
+ } catch (e2) { return null; }
72
+ }
73
+ el.dispatchEvent(ev);
74
+ return ev;
75
+ };
76
+ /* ==== BEHAVIOR CORE END: emit ==== */
@@ -0,0 +1,81 @@
1
+ /**
2
+ * focus-return.js — 「焦点归位」微行为核
3
+ *
4
+ * ==========================================================================
5
+ * 🔴 为什么要抽成核(14 个组件 JS 实测)
6
+ * --------------------------------------------------------------------------
7
+ * "记住当前焦点,关闭时还回去"被手写了 **6 处**,分布在 5 个文件
8
+ * (drawer / overlay / popover / dropdown / pagination)。
9
+ *
10
+ * 它看起来只有两行(存 activeElement、再 focus 回去),
11
+ * 但每一处手写副本都要各自回答三个问题,而实测**每处答得都不一样**:
12
+ *
13
+ * ① 要记的元素已经**不在文档里**了怎么办?(列表项被删了)
14
+ * ② 焦点本来就在 body 上,要不要记?
15
+ * ③ 还原失败(元素不可聚焦)要不要抛错?
16
+ *
17
+ * ⇒ 于是 5 个组件有 5 种降级行为,其中至少一种是**静默把焦点丢在 body 上**
18
+ * (键盘用户要从页面开头重新 Tab,而开发者完全看不出来)。
19
+ *
20
+ * ==========================================================================
21
+ * 边界
22
+ * --------------------------------------------------------------------------
23
+ * 只管"记住 / 归还"这一件事。
24
+ * 不管:焦点陷阱(那是 dialog 的事)、初始焦点该落在哪、滚动锁。
25
+ *
26
+ * ==========================================================================
27
+ * 用法
28
+ * --------------------------------------------------------------------------
29
+ * var fr = flFocusReturn({ fallback: trigger }); // 原元素没了时的退路
30
+ * fr.save(); // 打开浮层**之前**调
31
+ * ...
32
+ * fr.restore(); // 关闭时调;还回去了返回 true
33
+ *
34
+ * ==========================================================================
35
+ * ⚠️ 三个必须知道的实现细节(都是实测定下来的)
36
+ * --------------------------------------------------------------------------
37
+ * ① **body 不当作目标**。焦点默认就在 body 上,记它等于没记,
38
+ * 还原时也不会比"不动"更好。⇒ save() 记 null,restore() 退到 fallback。
39
+ * ② **还原前必须 document.contains 判一次**。元素被移除后 focus() 不报错,
40
+ * 但**焦点不会动** ⇒ 用户以为还回去了,其实焦点掉在 body 上(静默失效)。
41
+ * ③ **restore() 之后清空**。否则第二次 restore 会把焦点又拽回同一个元素,
42
+ * 打断用户**已经主动移走**的焦点。
43
+ *
44
+ * ==========================================================================
45
+ * 约束:ES5 · 零依赖 · 可被整块注入 · 不新增任何全局
46
+ * ==========================================================================
47
+ */
48
+ /* ==== BEHAVIOR CORE BEGIN: focus-return ==== */
49
+ var flFocusReturn = function (opts) {
50
+ var opt = opts || {};
51
+ var saved = null;
52
+
53
+ return {
54
+ save: function () {
55
+ var a = document.activeElement;
56
+ saved = (a && a !== document.body && a.focus) ? a : null;
57
+ return saved;
58
+ },
59
+
60
+ restore: function () {
61
+ var target = saved;
62
+ /* ⚠️ 见文档 ②:不判 contains 的话,元素被移除后 focus() 不报错
63
+ 但焦点不动 ⇒ 用户以为还回去了,其实掉在 body 上。 */
64
+ if (!target || !document.contains(target)) target = opt.fallback || null;
65
+ if (!target) { saved = null; return false; }
66
+ try {
67
+ target.focus();
68
+ } catch (e) {
69
+ saved = null;
70
+ return false;
71
+ }
72
+ var ok = document.activeElement === target;
73
+ saved = null; /* 见文档 ③ */
74
+ return ok;
75
+ },
76
+
77
+ target: function () { return saved; },
78
+ clear: function () { saved = null; },
79
+ };
80
+ };
81
+ /* ==== BEHAVIOR CORE END: focus-return ==== */
@@ -0,0 +1,152 @@
1
+ /**
2
+ * roving.js — 「方向键游走」微行为核(roving tabindex)
3
+ *
4
+ * ==========================================================================
5
+ * 🔴 为什么要抽成核(14 个组件 JS 实测)
6
+ * --------------------------------------------------------------------------
7
+ * `tabindex` 被手写了 **33 处**,分布在 9 个文件,是数量最多的一项。
8
+ *
9
+ * 其中真正危险的不是"写了很多次",而是**每一处自己决定边界行为**:
10
+ *
11
+ * · 走到最后一个再按一次 ⇒ 有的绕回第一个,有的**停住**(同一库两种手感)
12
+ * · 禁用项 ⇒ 有的跳过,有的**停在上面不动**(用户以为卡了)
13
+ * · Home / End ⇒ 有的实现,有的**没实现**
14
+ * · 纵向 / 横向 ⇒ 有的只认 ↑↓,有的只认 ←→,有的两种都认
15
+ *
16
+ * ⇒ 同一个"按 ↓"在不同组件里有不同结果。这种不一致**没有门禁会报**,
17
+ * 只有键盘用户会遇到。
18
+ *
19
+ * ==========================================================================
20
+ * 边界
21
+ * --------------------------------------------------------------------------
22
+ * 只管"一组项里焦点怎么走"。
23
+ * 不管:展开/收起(那是 tree 自己的语义)、选中与激活的差别
24
+ * (APG 里 automatic 与 manual 的差别由组件决定 —— 本核只告诉它"移到第几项")。
25
+ *
26
+ * ==========================================================================
27
+ * 用法
28
+ * --------------------------------------------------------------------------
29
+ * var roving = flRoving({
30
+ * container: listEl, // 在哪里听 keydown
31
+ * items: function () { return allTabs; }, // tabindex 全集(含禁用项)
32
+ * movable: function () { return enabledTabs; }, // 可移动子集(省略则用 items)
33
+ * nodeOf: function (x) { return x.node; }, // 项 → DOM 节点(省略则项就是节点)
34
+ * axis: 'y', // 'x' ←→(默认) / 'y' ↑↓
35
+ * loop: true, // 默认绕回
36
+ * homeEnd: true, // 默认支持 Home / End
37
+ * tabindex: true, // 默认接管 tabindex;false = 只接管步进
38
+ * onMove: function (i, item, reason) { ... } // reason: step / home / end
39
+ * });
40
+ * roving.rove(targetNode); // 把 tabindex="0" 给目标,其余 -1
41
+ * roving.destroy(); // 摘掉 keydown
42
+ *
43
+ * ==========================================================================
44
+ * ⚠️ 四个必须知道的实现细节
45
+ * --------------------------------------------------------------------------
46
+ * ① **items 与 movable 必须分开**。tabindex 要落在**全集**上(禁用项也得是 -1,
47
+ * 否则 Tab 会停到禁用项上),但移动只在**可用子集**里进行。
48
+ * 早期写法只用一个列表 ⇒ 禁用项要么能被方向键走到、要么 Tab 能停上去。
49
+ * ② **循环用取模,不要用 if 夹逼**。`((i + d) % n + n) % n` 对负数也对,
50
+ * 而 `if (i < 0) i = n - 1` 在 d 的绝对值大于 n 时会错。
51
+ * ③ **核只负责 preventDefault + stopPropagation**,不替组件决定"移动后做什么"
52
+ * (激活还是只移焦点)—— 那是 APG 里 automatic / manual 的分工。
53
+ * ④ **"步进"与"roving tabindex"是两件事**,用 `tabindex: false` 分开。
54
+ * APG 菜单的菜单项按规范**全部 tabindex=-1**(焦点由程序移动,
55
+ * 菜单项永远不占 Tab 位)⇒ 它要的是同一套步进/绕回/Home·End,
56
+ * 但**不能**让核把某一项改成 0(会破坏"Tab 用来关闭菜单"这条契约)。
57
+ *
58
+ * ==========================================================================
59
+ * ⚠️ 注入块里的注释会**被复制进每个使用点**(真金白银,gzip 也要算)
60
+ * --------------------------------------------------------------------------
61
+ * ⇒ 长解释一律写在**本文件头**(BEGIN 标记之外),注入块内只留一行提示。
62
+ * ⇒ 同理:只暴露**有人用**的 API。初版多暴露了一个 `wrap()`
63
+ * ("越界就夹回边界"),实测零个使用点调用 ⇒ 它是被注入进 3 个组件的
64
+ * 死代码,每个组件白背几百字节 ⇒ 删掉。真需要时从版本历史里找回来,
65
+ * 别让使用点为"可能会用到"买单。
66
+ *
67
+ * ==========================================================================
68
+ * 约束:ES5 · 零依赖 · 可被整块注入 · 不新增任何全局
69
+ * ==========================================================================
70
+ */
71
+ /* ==== BEHAVIOR CORE BEGIN: roving ==== */
72
+ var flRoving = function (opts) {
73
+ var opt = opts || {};
74
+ var container = opt.container;
75
+ var all = opt.items || function () { return []; };
76
+ var movable = opt.movable || all;
77
+ var nodeOf = opt.nodeOf || function (x) { return x; };
78
+ var axis = opt.axis || 'x';
79
+ var loop = opt.loop !== false;
80
+ var homeEnd = opt.homeEnd !== false;
81
+ /* ④ tabindex: false ⇒ 只要步进,不要 roving tabindex(理由见文件头)*/
82
+ var roveTab = opt.tabindex !== false;
83
+ var onMove = opt.onMove || function () {};
84
+
85
+ /* ① tabindex 落在**全集**上:禁用项也必须是 -1,否则 Tab 会停上去 */
86
+ function rove(target) {
87
+ if (!roveTab) return target;
88
+ var l = all() || [];
89
+ for (var i = 0; i < l.length; i++) {
90
+ var n = nodeOf(l[i]);
91
+ if (!n || !n.setAttribute) continue;
92
+ n.setAttribute('tabindex', n === target ? '0' : '-1');
93
+ }
94
+ return target;
95
+ }
96
+
97
+ /* ② 取模循环:对负数、对 |d| > n 都对 */
98
+ function step(i, d) {
99
+ var n = (movable() || []).length;
100
+ if (!n) return -1;
101
+ if (loop) return ((i + d) % n + n) % n;
102
+ var t = i + d;
103
+ return (t < 0 || t >= n) ? -1 : t;
104
+ }
105
+
106
+ function indexOfNode(node) {
107
+ var l = movable() || [];
108
+ for (var i = 0; i < l.length; i++) {
109
+ if (nodeOf(l[i]) === node) return i;
110
+ }
111
+ return -1;
112
+ }
113
+
114
+ function onKey(e) {
115
+ var l = movable() || [];
116
+ if (!l.length) return;
117
+ var i = indexOfNode(e.target);
118
+ if (i < 0) return;
119
+ var k = e.key;
120
+ var next = -1;
121
+ var reason = 'step';
122
+
123
+ if (axis === 'y') {
124
+ if (k === 'ArrowDown') next = step(i, 1);
125
+ else if (k === 'ArrowUp') next = step(i, -1);
126
+ } else {
127
+ if (k === 'ArrowRight') next = step(i, 1);
128
+ else if (k === 'ArrowLeft') next = step(i, -1);
129
+ }
130
+ if (homeEnd && k === 'Home') { next = 0; reason = 'home'; }
131
+ else if (homeEnd && k === 'End') { next = l.length - 1; reason = 'end'; }
132
+
133
+ if (next < 0 || next >= l.length) return;
134
+ /* ③ 只拦事件,不替组件决定移动后做什么 */
135
+ if (e.preventDefault) e.preventDefault();
136
+ if (e.stopPropagation) e.stopPropagation();
137
+ onMove(next, l[next], reason);
138
+ }
139
+
140
+ if (container) container.addEventListener('keydown', onKey);
141
+
142
+ /* ⚠️ 只暴露有人用的东西(理由见文件头)*/
143
+ return {
144
+ rove: rove,
145
+ step: step,
146
+ indexOf: indexOfNode,
147
+ destroy: function () {
148
+ if (container) container.removeEventListener('keydown', onKey);
149
+ },
150
+ };
151
+ };
152
+ /* ==== BEHAVIOR CORE END: roving ==== */