agentcss 0.25.6 → 0.27.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.
Files changed (81) hide show
  1. package/01-tokens/README.md +1 -1
  2. package/01-tokens/behavior/labels.js +44 -0
  3. package/01-tokens/responsive-demo.html +1 -1
  4. package/01-tokens/tokens.core.css +1 -1
  5. package/01-tokens/tokens.css +2 -2
  6. package/02-primitives/combobox/combobox.js +26 -3
  7. package/02-primitives/date-range/date-range.js +28 -6
  8. package/02-primitives/popover/popover.js +22 -2
  9. package/02-primitives/select/select.js +23 -2
  10. package/02-primitives/timeline/demo.html +1 -1
  11. package/02-primitives/upload/upload.js +50 -17
  12. package/03-patterns/content/content.css +13 -1
  13. package/03-patterns/content/demo.html +1 -1
  14. package/03-patterns/drawer/demo.html +1 -1
  15. package/03-patterns/drawer/drawer.js +44 -6
  16. package/03-patterns/dropdown/dropdown.js +21 -1
  17. package/03-patterns/overlay/overlay.css +1 -1
  18. package/03-patterns/overlay/overlay.js +39 -2
  19. package/03-patterns/pagination/pagination.js +37 -10
  20. package/03-patterns/states/states.css +1 -1
  21. package/03-patterns/tooltip/tooltip.js +17 -2
  22. package/03-patterns/tree/tree.js +21 -1
  23. package/04-recipes/analysis-report/demo.html +1 -1
  24. package/04-recipes/app-shell/README.md +1 -1
  25. package/04-recipes/app-shell/demo.html +1 -1
  26. package/04-recipes/data-showcase/demo.html +2 -2
  27. package/04-recipes/longform/content.html +993 -78
  28. package/04-recipes/longform/longform.html +3 -3
  29. package/04-recipes/report/demo.html +3 -3
  30. package/04-recipes/table/README.md +23 -1
  31. package/04-recipes/table/demo.html +7 -7
  32. package/09-assets/bar/bar.css +1 -1
  33. package/09-assets/bar/bar.js +28 -4
  34. package/09-assets/model-viewer/model-viewer.js +45 -13
  35. package/09-assets/scientific-plot/demo.html +6 -6
  36. package/AGENTS.md +3 -1
  37. package/API.md +32 -0
  38. package/CHANGELOG.md +174 -5
  39. package/CONTRIBUTING.md +44 -3
  40. package/README.md +19 -0
  41. package/README.zh-CN.md +18 -0
  42. package/SECURITY.md +16 -4
  43. package/START-HERE.md +25 -18
  44. package/ai/START-HERE.md +40 -6
  45. package/ai/check-html.js +658 -0
  46. package/ai/cli.js +557 -67
  47. package/ai/components.json +9 -4
  48. package/ai/pdf.js +52 -10
  49. package/ai/recipes.json +86 -0
  50. package/ai/scaffold.js +195 -0
  51. package/ai/shot.js +345 -0
  52. package/ai/validate.js +65 -6
  53. package/dist/01-tokens/behavior/labels.js +11 -0
  54. package/dist/02-primitives/combobox/combobox.js +25 -3
  55. package/dist/02-primitives/date-range/date-range.js +26 -5
  56. package/dist/02-primitives/popover/popover.js +14 -1
  57. package/dist/02-primitives/select/select.js +22 -2
  58. package/dist/02-primitives/upload/upload.js +47 -17
  59. package/dist/03-patterns/content/content.css +1 -1
  60. package/dist/03-patterns/drawer/drawer.js +32 -4
  61. package/dist/03-patterns/dropdown/dropdown.js +20 -1
  62. package/dist/03-patterns/overlay/overlay.js +25 -1
  63. package/dist/03-patterns/pagination/pagination.js +34 -10
  64. package/dist/03-patterns/tooltip/tooltip.js +14 -2
  65. package/dist/03-patterns/tree/tree.js +20 -1
  66. package/dist/09-assets/bar/bar.js +23 -4
  67. package/dist/09-assets/model-viewer/model-viewer.js +38 -12
  68. package/dist/COSTS.md +20 -20
  69. package/docs/ACCESSIBILITY.md +3 -3
  70. package/docs/ARCHITECTURE.md +116 -0
  71. package/docs/L10N.md +92 -0
  72. package/docs/LAYER-BASELINE.md +2 -2
  73. package/docs/PRINT.md +3 -3
  74. package/docs/RELEASE-PROCESS.md +59 -3
  75. package/docs/STABILITY.md +1 -1
  76. package/docs/VERIFY-RELEASE.md +24 -2
  77. package/index.html +3 -3
  78. package/llms-full.txt +18 -4
  79. package/llms.txt +26 -5
  80. package/package.json +4 -2
  81. package/types/index.d.ts +218 -13
@@ -257,7 +257,7 @@ node ai/cli.js tree [--set=light|dark] [--cat=color] # 第二种
257
257
  (`contrast` / `gen-ai-contract` / `gen-tokens-tree` / `token-audience` /
258
258
  `theme-sync`)。真实事故:一条 `@layer` 让六个工具同时读错,且都没有报错。
259
259
  3. 选择器是 `:root[data-market="intl"]` (0,2,0),**不是** `:where(...)` (0) ——
260
- `:where()` 抢不过 `:root` 的 (0,1,0),写了等于没写(E135)。
260
+ `:where()` 抢不过 `:root` 的 (0,1,0),写了等于没写。
261
261
 
262
262
  > 精简档(`tierA-tokens.css`)**不含**这段交换:它是一份 `<style>` 内的
263
263
  > 令牌快照,没有配套文件可引。要国际口径就自己补一条规则 ——
@@ -0,0 +1,44 @@
1
+ /**
2
+ * labels.js — 「文案覆盖合并」工具核
3
+ *
4
+ * ==========================================================================
5
+ * 🔴 为什么要有这个核
6
+ * --------------------------------------------------------------------------
7
+ * 0.26.0 给组件加 `labels`(读屏 / 可见文案覆盖)时,最初的写法是
8
+ * 在 9 个组件里各贴一份合并函数:
9
+ *
10
+ * for (k in defaults) { out[k] = src[k] == null ? defaults[k] : src[k]; }
11
+ *
12
+ * 九份一模一样的循环,只差 `defaults` 的字面量。这与 I-14 是同一条病:
13
+ * **同一份逻辑写 N 份**。后果不是"难看",是**修正只到一处**:
14
+ * · 以后要支持"传 null 表示删掉这条文案"→ 改 1 处、漏 8 处;
15
+ * · 要支持嵌套命名空间 → 同上;
16
+ * · 某处写成 `src[k] || defaults[k]`,空字符串就再也覆盖不掉 ——
17
+ * 而这种偏差在 9 份副本里肉眼几乎看不出来。
18
+ *
19
+ * ⇒ 真值源一份(本文件),生成进使用点,`gen-behavior --check` 证明跟随。
20
+ * 使用点仍然**各自保留自己的 `defaults` 字面量** —— 那是组件的事实,
21
+ * 不是可以共享的代码。
22
+ *
23
+ * ==========================================================================
24
+ * 语义(三句话,必须与实现逐字对应)
25
+ * --------------------------------------------------------------------------
26
+ * ① `undefined` / `null` ⇒ 用默认值("没传"和"显式取消"等价);
27
+ * ② 其他任何值(含 `''`、`0`、`false`)⇒ **原样采用** ——
28
+ * 用 `||` 判断会把空字符串吞掉,那是错误的;
29
+ * ③ 只认 `defaults` 里有的键 —— 拼错的键不会悄悄生效(避免"传了却没反应")。
30
+ * ==========================================================================
31
+ */
32
+ /* ==== BEHAVIOR CORE BEGIN: labels ==== */
33
+ var flLabels = function (defaults, src) {
34
+ src = src || {};
35
+ var out = {};
36
+ var k;
37
+ for (k in defaults) {
38
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
39
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
40
+ }
41
+ }
42
+ return out;
43
+ };
44
+ /* ==== BEHAVIOR CORE END: labels ==== */
@@ -76,7 +76,7 @@ p { margin: 0 0 var(--sp-4); }
76
76
  </div>
77
77
  <p style="margin-top:var(--sp-4)">
78
78
  判据很简单:<strong>这个值变了会不会导致"控件大小跟着变"?会 → 不要流体。</strong>
79
- `--fs-xs` / `--fs-sm`(UI 文本)和 `--sp-1`~`--sp-3`(小间距)都属这一类。
79
+ <code>--fs-xs</code> / <code>--fs-sm</code>(UI 文本)和 <code>--sp-1</code>~<code>--sp-3</code>(小间距)都属这一类。
80
80
  </p>
81
81
 
82
82
  <h2>三、该用断点的:布局结构</h2>
@@ -884,7 +884,7 @@
884
884
  —— 遍历所有声明、后者覆盖前者、**不看选择器**。
885
885
  把一段**条件性**的交换规则放在这里,它们会把它当成默认值叠上去,
886
886
  于是报告里「暗色 涨」显示成绿色,而浏览器里其实是对的 —— 门禁冤枉代码,
887
- 比漏判更难查(实测,见 E137)。
887
+ 比漏判更难查(实测)。
888
888
  ⇒ 本文件只放「无条件声明」与「暗色媒体段」,条件规则一律外置。
889
889
  ========================================================================= */
890
890
 
@@ -926,7 +926,7 @@
926
926
  —— 遍历所有声明、后者覆盖前者、**不看选择器**。
927
927
  把一段**条件性**的交换规则放在这里,它们会把它当成默认值叠上去,
928
928
  于是报告里「暗色 涨」显示成绿色,而浏览器里其实是对的 —— 门禁冤枉代码,
929
- 比漏判更难查(实测,见 E137)。
929
+ 比漏判更难查(实测)。
930
930
  ⇒ 本文件只放「无条件声明」与「暗色媒体段」,条件规则一律外置。
931
931
  ========================================================================= */
932
932
 
@@ -1117,7 +1117,7 @@ input[type="submit"],
1117
1117
  /* ⚠️ 模态的遮罩规则**不在这里**,在 03-patterns/overlay/overlay.css。
1118
1118
  原因不是洁癖:`.dialog-backdrop { position: static }` 写在这里会被
1119
1119
  overlay.css 里同特异性的 `position: fixed` 盖掉 —— 规则写了、不生效、
1120
- 不报错(E254 的同一个形状,2026-11-02 由 print-policy-gate 的 L3 抓到)。 */
1120
+ 不报错(同一个形状,2026-11-02 由 print-policy-gate 的 L3 抓到)。 */
1121
1121
 
1122
1122
  /* 不跨页切断。代价:高于半页的卡片会被整体推到下一页 ⇒ 页数可能变多。 */
1123
1123
  tr, img, figure, svg,
@@ -145,6 +145,29 @@
145
145
  var uid = 0;
146
146
 
147
147
  function create(root, opt) {
148
+ /* 读屏文案默认值(F2 / 0.26.0)。不传 `labels` 时输出逐字不变。 */
149
+ var DEFAULT_LABELS = {
150
+ suggest: '建议',
151
+ input: '输入',
152
+ noMatch: function (q) { return '没有匹配「' + q + '」的选项'; },
153
+ empty: '暂无可选'
154
+ };
155
+
156
+ /* ==== BEHAVIOR INJECT BEGIN: labels ==== */
157
+ var flLabels = function (defaults, src) {
158
+ src = src || {};
159
+ var out = {};
160
+ var k;
161
+ for (k in defaults) {
162
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
163
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
164
+ }
165
+ }
166
+ return out;
167
+ };
168
+ /* ==== BEHAVIOR INJECT END: labels ==== */
169
+ var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
170
+
148
171
  opt = opt || {};
149
172
  var input = root.querySelector('[data-combo-input]');
150
173
  var list = root.querySelector('[data-combo-list]');
@@ -182,14 +205,14 @@
182
205
  var shown = []; /* 当前过滤后的选项 */
183
206
 
184
207
  list.setAttribute('role', 'listbox');
185
- list.setAttribute('aria-label', opt.label || '建议');
208
+ list.setAttribute('aria-label', opt.label || L.suggest);
186
209
  input.setAttribute('role', 'combobox');
187
210
  input.setAttribute('aria-autocomplete', 'list');
188
211
  input.setAttribute('aria-expanded', 'false');
189
212
  input.setAttribute('aria-controls', list.id);
190
213
  if (!input.id) input.id = 'combo-input-' + seq;
191
214
  if (!input.getAttribute('aria-label') && !root.querySelector('label[for="' + input.id + '"]')) {
192
- input.setAttribute('aria-label', opt.label || '输入');
215
+ input.setAttribute('aria-label', opt.label || L.input);
193
216
  }
194
217
 
195
218
  /* ---------- 过滤(含高亮命中片段)---------- */
@@ -226,7 +249,7 @@
226
249
  if (!shown.length) {
227
250
  /* @html-sink escaped — 插值只有 q,且过了 esc() */
228
251
  list.innerHTML = '<li class="combo__empty" role="presentation">' +
229
- (q ? '没有匹配「' + esc(q) + '」的选项' : '暂无可选') + '</li>';
252
+ (q ? L.noMatch(esc(q)) : L.empty) + '</li>';
230
253
  return;
231
254
  }
232
255
  var ql = q.toLowerCase();
@@ -119,6 +119,28 @@
119
119
  }
120
120
 
121
121
  function create(root, opt) {
122
+ /* 校验文案默认值(F2 / 0.26.0)。不传 `labels` 时输出逐字不变。 */
123
+ /* ==== BEHAVIOR INJECT BEGIN: labels ==== */
124
+ var flLabels = function (defaults, src) {
125
+ src = src || {};
126
+ var out = {};
127
+ var k;
128
+ for (k in defaults) {
129
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
130
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
131
+ }
132
+ }
133
+ return out;
134
+ };
135
+ /* ==== BEHAVIOR INJECT END: labels ==== */
136
+ var DEFAULT_LABELS = {
137
+ requiredEnd: '请填写结束日期。',
138
+ requiredStart: '请填写开始日期。',
139
+ order: function (f, t) { return '开始日期不能晚于结束日期(当前:' + f + ' → ' + t + ')。'; },
140
+ min: function (min) { return '开始日期不能早于 ' + min + '。'; },
141
+ max: function (max) { return '结束日期不能晚于 ' + max + '。'; }
142
+ };
143
+ var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
122
144
  opt = opt || {};
123
145
  var from = root.querySelector('[data-dr-from]');
124
146
  var to = root.querySelector('[data-dr-to]');
@@ -158,21 +180,21 @@
158
180
  function validate() {
159
181
  var f = from.value, t = to.value;
160
182
  if (!f && !t) { setError(''); paintSummary(''); return true; }
161
- if (f && !t) { setError('请填写结束日期。'); return false; }
162
- if (!f && t) { setError('请填写开始日期。'); return false; }
183
+ if (f && !t) { setError(L.requiredEnd); return false; }
184
+ if (!f && t) { setError(L.requiredStart); return false; }
163
185
  if (f > t) {
164
- setError('开始日期不能晚于结束日期(当前:' + f + ' → ' + t + ')。');
186
+ setError(L.order(f, t));
165
187
  return false;
166
188
  }
167
- if (opt.min && f < opt.min) { setError('开始日期不能早于 ' + opt.min + '。'); return false; }
168
- if (opt.max && t > opt.max) { setError('结束日期不能晚于 ' + opt.max + '。'); return false; }
189
+ if (opt.min && f < opt.min) { setError(L.min(opt.min)); return false; }
190
+ if (opt.max && t > opt.max) { setError(L.max(opt.max)); return false; }
169
191
  setError('');
170
192
  paintSummary(f + ' → ' + t + '(' + days(f, t) + ' 天)');
171
193
  return true;
172
194
  }
173
195
 
174
196
  function paintSummary(s) {
175
- /* 🔴 不拼 innerHTML(E299):`s` 来自两个 <input> 的 value,
197
+ /* 🔴 不拼 innerHTML:`s` 来自两个 <input> 的 value,
176
198
  而 `type=date` 在 Chromium 97 旧内核会回退成文本输入 ⇒ value 可为任意串。 */
177
199
  if (!summary) return;
178
200
  summary.textContent = '';
@@ -181,11 +181,12 @@
181
181
  (加了就是模态,语义就错了)。 */
182
182
  box.setAttribute('role', 'dialog');
183
183
  box.setAttribute('aria-label', opts.label || '');
184
+ var bodyHtml = sanitizeHtml(opts.html || '', opts.sanitize);
184
185
  /* @html-sink raw-html — `opts.html` 是有意的 HTML 入口,调用方负责净化;
185
- 登记在 SECURITY.md「HTML 入口(sink)」。 */
186
+ 登记在 SECURITY.md「HTML 入口(sink)」。`sanitize` / `setSanitizer` 可挂钩。 */
186
187
  box.innerHTML =
187
188
  '<span class="popover__arrow" aria-hidden="true"></span>' +
188
- '<div class="popover__body">' + (opts.html || '') + '</div>';
189
+ '<div class="popover__body">' + bodyHtml + '</div>';
189
190
 
190
191
  /* 箭头要指向触发元素的中心,而不是浮层的中心 */
191
192
  positionArrow(box, trigger);
@@ -294,6 +295,24 @@
294
295
  return api;
295
296
  }
296
297
 
298
+ /* HTML 净化钩子(F6 / 0.26.0)。
299
+ 🔴 默认**不改变任何行为**:没传 `sanitize` 也没有 `setSanitizer()` 时,
300
+ `opts.html` 仍然按"调用方已净化"处理(与 0.25.x 一致)。
301
+ ⇒ 想接入 DOMPurify 这类库:
302
+ Popover.setSanitizer(function (html) { return DOMPurify.sanitize(html); });
303
+ 或逐次传 `opts.sanitize` 覆盖(逐次优先于模块级)。
304
+ ⚠️ 钩子只处理**字符串入口**;传 Node 的入口本来就绕过了 HTML 解析。 */
305
+ var SANITIZER = null;
306
+
307
+ function setSanitizer(fn) {
308
+ SANITIZER = (typeof fn === 'function') ? fn : null;
309
+ }
310
+
311
+ function sanitizeHtml(html, perCall) {
312
+ var fn = (typeof perCall === 'function') ? perCall : SANITIZER;
313
+ return fn ? String(fn(String(html))) : String(html);
314
+ }
315
+
297
316
  /* 箭头对准触发元素中心(浮层可能比触发元素宽) */
298
317
  function positionArrow(box, trigger) {
299
318
  var arrow = box.querySelector('.popover__arrow');
@@ -321,5 +340,6 @@
321
340
  get current() { return current; },
322
341
  };
323
342
 
343
+ Popover.setSanitizer = setSanitizer;
324
344
  global.Popover = Popover;
325
345
  })(typeof window !== 'undefined' ? window : globalThis);
@@ -246,6 +246,27 @@
246
246
  var uid = 0;
247
247
 
248
248
  function create(root, opt) {
249
+ /* 读屏文案默认值(F2 / 0.26.0)。不传 `labels` 时输出逐字不变。 */
250
+ var DEFAULT_LABELS = {
251
+ option: '选项',
252
+ placeholder: '请选择'
253
+ };
254
+
255
+ /* ==== BEHAVIOR INJECT BEGIN: labels ==== */
256
+ var flLabels = function (defaults, src) {
257
+ src = src || {};
258
+ var out = {};
259
+ var k;
260
+ for (k in defaults) {
261
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
262
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
263
+ }
264
+ }
265
+ return out;
266
+ };
267
+ /* ==== BEHAVIOR INJECT END: labels ==== */
268
+ var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
269
+
249
270
  opt = opt || {};
250
271
  var btn = root.querySelector('[data-select-btn]');
251
272
  var list = root.querySelector('[data-select-list]');
@@ -299,7 +320,7 @@
299
320
 
300
321
  list.setAttribute('role', 'listbox');
301
322
  if (isMulti) list.setAttribute('aria-multiselectable', 'true');
302
- list.setAttribute('aria-label', opt.label || '选项');
323
+ list.setAttribute('aria-label', opt.label || L.option);
303
324
  btn.setAttribute('aria-haspopup', 'listbox');
304
325
  btn.setAttribute('aria-expanded', 'false');
305
326
  btn.setAttribute('aria-controls', list.id);
@@ -344,7 +365,7 @@
344
365
  for (var i = 0; i < options.length; i++) {
345
366
  if (hasValue(valueOfOpt(options[i]))) texts.push(optText(options[i]));
346
367
  }
347
- return texts.length ? texts.join('、') : (opt.placeholder || '请选择');
368
+ return texts.length ? texts.join('、') : (opt.placeholder || L.placeholder);
348
369
  }
349
370
  /* ⚠️ 空列表也会被调用(初始化 / 清空后重绘):必须能接住 undefined,
350
371
  老实现直接 o.querySelector 会抛 "Cannot read properties of undefined"。 */
@@ -90,7 +90,7 @@
90
90
  </div>
91
91
  <p class="small">
92
92
  <code>timeline--compact</code> 只做两件事:条目间距从 <code>--sp-4</code> 收到
93
- <code>--sp-2</code>,时间挪回标题同一行。**它不改字号** ——
93
+ <code>--sp-2</code>,时间挪回标题同一行。<strong>它不改字号</strong> ——
94
94
  在侧栏里把字缩小,等于承认这块内容不重要,而事实往往相反。
95
95
  </p>
96
96
 
@@ -80,8 +80,42 @@
80
80
  remove: 'M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6'
81
81
  };
82
82
 
83
- var STATE_TEXT = { pending: '待上传', done: '已完成', error: '失败' };
83
+ /* 读屏 / 状态文案默认值(F2 / 0.26.0)。
84
+ 值可以是字符串或函数;不传 `labels` 时输出与之前逐字相同。 */
85
+ var DEFAULT_LABELS = {
86
+ pending: '待上传',
87
+ done: '已完成',
88
+ error: '失败',
89
+ remove: function (name) { return '移除 ' + name; },
90
+ uploading: function (pct) { return '上传中 ' + Math.round(pct) + '%'; },
91
+ countMax: function (n) { return '最多 ' + n + ' 个文件'; },
92
+ sizeMax: function (limit) { return '超过 ' + limit; },
93
+ type: '类型不支持',
94
+ folder: '请拖入文件,不支持文件夹',
95
+ none: function (reason) { return reason ? '未添加任何文件:' + reason : '未添加任何文件'; },
96
+ noDrop: '当前浏览器不支持拖放,请点「选择文件」',
97
+ noDropShort: '当前浏览器不支持拖放,请点选择文件',
98
+ summary: function (added, total, rejected) {
99
+ var s = '已添加 ' + added + ' 个文件,共 ' + total + ' 个';
100
+ if (rejected) s += ';' + rejected + ' 个未通过校验';
101
+ return s;
102
+ },
103
+ removed: function (name, rest) { return '已移除 ' + name + ',还剩 ' + rest + ' 个'; }
104
+ };
84
105
 
106
+ /* ==== BEHAVIOR INJECT BEGIN: labels ==== */
107
+ var flLabels = function (defaults, src) {
108
+ src = src || {};
109
+ var out = {};
110
+ var k;
111
+ for (k in defaults) {
112
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
113
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
114
+ }
115
+ }
116
+ return out;
117
+ };
118
+ /* ==== BEHAVIOR INJECT END: labels ==== */
85
119
  /* ---------------------------------------------------------------- 工具 */
86
120
 
87
121
  /**
@@ -183,6 +217,7 @@
183
217
  var opt = opts || {};
184
218
 
185
219
  this.root = root;
220
+ this.labels = flLabels(DEFAULT_LABELS, opt && opt.labels);
186
221
  this.input = root.querySelector('.upload__input');
187
222
  this.list = root.querySelector('.upload__list');
188
223
  this.status = root.querySelector('.upload__status');
@@ -299,15 +334,15 @@
299
334
  for (var i = 0; i < incoming.length; i++) {
300
335
  var f = incoming[i];
301
336
  if (maxFiles && keep.length >= maxFiles) {
302
- rejected.push({ file: f, reason: 'count', text: '最多 ' + maxFiles + ' 个文件' });
337
+ rejected.push({ file: f, reason: 'count', text: this.labels.countMax(maxFiles) });
303
338
  continue;
304
339
  }
305
340
  if (this.maxSize && f.size > this.maxSize) {
306
- rejected.push({ file: f, reason: 'size', text: '超过 ' + humanSize(this.maxSize) });
341
+ rejected.push({ file: f, reason: 'size', text: this.labels.sizeMax(humanSize(this.maxSize)) });
307
342
  continue;
308
343
  }
309
344
  if (!matchesAccept(f, accept)) {
310
- rejected.push({ file: f, reason: 'type', text: '类型不支持' });
345
+ rejected.push({ file: f, reason: 'type', text: this.labels.type });
311
346
  continue;
312
347
  }
313
348
  keep.push(f);
@@ -336,7 +371,7 @@
336
371
  if (dirCount) {
337
372
  r.rejected.push({
338
373
  file: { name: dirCount + ' 个文件夹', size: 0 },
339
- reason: 'dir', text: '请拖入文件,不支持文件夹'
374
+ reason: 'dir', text: this.labels.folder
340
375
  });
341
376
  }
342
377
 
@@ -344,7 +379,7 @@
344
379
  /* 一个都没收:仍要把拒收项画出来,否则用户"拖了没反应" */
345
380
  this.rejected = r.rejected;
346
381
  this.render();
347
- this.announce(r.rejected.length ? '未添加任何文件:' + r.rejected[0].text : '未添加任何文件');
382
+ this.announce(this.labels.none(r.rejected.length ? r.rejected[0].text : ''));
348
383
  flEmit(this.root, 'fl-upload', {
349
384
  files: currentFiles(this.input), added: 0, rejected: r.rejected
350
385
  });
@@ -354,9 +389,9 @@
354
389
  if (!writeFiles(this.input, r.keep)) {
355
390
  /* 见文件头:宁可明说不可用,也不能装作收下了 */
356
391
  this.rejected = [{ file: r.keep[0], reason: 'unsupported',
357
- text: '当前浏览器不支持拖放,请点「选择文件」' }];
392
+ text: this.labels.noDrop }];
358
393
  this.render();
359
- this.announce('当前浏览器不支持拖放,请点选择文件');
394
+ this.announce(this.labels.noDropShort);
360
395
  return false;
361
396
  }
362
397
 
@@ -441,7 +476,7 @@
441
476
  /* 状态词给读屏(图标是 aria-hidden)—— 也是色盲用户的第二条线索 */
442
477
  var sr = document.createElement('span');
443
478
  sr.className = 'upload__status';
444
- sr.textContent = STATE_TEXT[state] || '';
479
+ sr.textContent = this.labels[state] || '';
445
480
  mark.appendChild(sr);
446
481
 
447
482
  var name = document.createElement('span');
@@ -477,12 +512,12 @@
477
512
  var rm = document.createElement('button');
478
513
  rm.className = 'upload__remove';
479
514
  rm.type = 'button';
480
- rm.setAttribute('aria-label', '移除 ' + (file.name || ''));
515
+ rm.setAttribute('aria-label', this.labels.remove(file.name || ''));
481
516
  rm.appendChild(iconEl('remove'));
482
517
  rm.addEventListener('click', function () { self.removeAt(index); });
483
518
  li.appendChild(rm);
484
519
 
485
- if (pct != null && pct >= 0) meta.textContent = '上传中 ' + Math.round(pct) + '%';
520
+ if (pct != null && pct >= 0) meta.textContent = self.labels.uploading(pct);
486
521
  }
487
522
 
488
523
  return li;
@@ -536,7 +571,7 @@
536
571
  mark.appendChild(iconEl('done'));
537
572
  var sr = document.createElement('span');
538
573
  sr.className = 'upload__status';
539
- sr.textContent = STATE_TEXT.done;
574
+ sr.textContent = this.labels.done;
540
575
  mark.appendChild(sr);
541
576
  }
542
577
  if (meta && f) meta.textContent = humanSize(f.size);
@@ -557,7 +592,7 @@
557
592
  }
558
593
  this.paintProgress(bar, pct);
559
594
  /* 进度也写成文本:**信息不能只在图形里**,否则强制色/色盲下丢失 */
560
- if (meta) meta.textContent = '上传中 ' + Math.round(pct) + '%';
595
+ if (meta) meta.textContent = this.labels.uploading(pct);
561
596
  },
562
597
 
563
598
  /* ================================================================ 移除 */
@@ -579,7 +614,7 @@
579
614
 
580
615
  this.rebind(); /* 索引整体前移,旧闭包里的 i 已过期 */
581
616
  this.syncCount();
582
- this.announce('已移除 ' + (removed.name || '文件') + ',还剩 ' + rest.length + ' 个');
617
+ this.announce(this.labels.removed(removed.name || '文件', rest.length));
583
618
  flEmit(this.root, 'fl-upload', {
584
619
  files: rest, added: 0, removed: removed, rejected: []
585
620
  });
@@ -620,9 +655,7 @@
620
655
 
621
656
  summary: function (added, rejected) {
622
657
  var total = currentFiles(this.input).length;
623
- var s = '已添加 ' + added + ' 个文件,共 ' + total + ' 个';
624
- if (rejected) s += ';' + rejected + ' 个未通过校验';
625
- return s;
658
+ return this.labels.summary(added, total, rejected);
626
659
  },
627
660
 
628
661
  /* ================================================================ 销毁 */
@@ -180,7 +180,19 @@
180
180
  height: 0; /* 🔴 hr 默认有 inset 边框,必须清掉 */
181
181
  }
182
182
 
183
- .prose > img {
183
+ /* 🔴 必须是**后代**选择器,不是 `> img` —— 和下面 `.prose a` 同一条教训。
184
+ *
185
+ * Markdown / CommonMark 渲染图片出来是 `<p><img></p>`,图片的父节点是 `<p>`,
186
+ * 不是 `.prose` 的直接子代 ⇒ `> img` **一条都匹配不上**,
187
+ * 于是 `max-width:100%` 从未生效。
188
+ * 实测(外部审计 F1):`04-recipes/longform/content.html` 里
189
+ * `<p><img width="600">`,375px 溢出 246px、320px 溢出 301px。
190
+ * 对"给用户和 AI 照抄的配方页"来说,这个缺陷会被原样复制出去。
191
+ *
192
+ * `> a` → `a` 早在 0.9 就踩过一次(段落里的链接落到浏览器默认蓝,
193
+ * 暗色对比度 1.91:1)。同一个坑换了标签又踩一次,所以这次把范围写宽:
194
+ * 正文里任何位置的图片都受同一条约束。 */
195
+ .prose img {
184
196
  max-width: 100%;
185
197
  height: auto;
186
198
  display: block;
@@ -79,7 +79,7 @@
79
79
  </dl>
80
80
 
81
81
  <h2>4 · 代码与链接</h2>
82
- <pre><code>const f = (x) =&gt; x * 2; // 纯 ES5 时代写法</code></pre>
82
+ <pre tabindex="0"><code>const f = (x) =&gt; x * 2; // 纯 ES5 时代写法</code></pre>
83
83
  <p>详细口径见 <a href="#main">内部数据字典</a>。</p>
84
84
 
85
85
  <h2>5 · 待办清单</h2>
@@ -74,7 +74,7 @@ ul.demo-list li { margin: var(--sp-1) 0; }
74
74
  <span class="dr-bar__title">筛选</span>
75
75
  <button class="dr-tiny" type="button" tabindex="-1" aria-label="关闭">×</button>
76
76
  </div>
77
- <p class="cmp__why">关闭是**独立目标**:抽屉滑进来之后,用户第一件事就是找它。24px 的叉在触屏上要瞄准,点偏了就点到了背后的内容。</p>
77
+ <p class="cmp__why">关闭是<strong>独立目标</strong>:抽屉滑进来之后,用户第一件事就是找它。24px 的叉在触屏上要瞄准,点偏了就点到了背后的内容。</p>
78
78
  </div>
79
79
  <div class="cmp__pane cmp__good" data-good="button.drawer__close|h|>=44">
80
80
  <h3>✅ 本库:<code>drawer__close</code> 44px</h3>
@@ -201,8 +201,44 @@
201
201
  bottom: 'drawer--bottom'
202
202
  };
203
203
 
204
+
205
+ /* HTML 净化钩子(F6 / 0.26.0)。
206
+ 🔴 默认**不改变任何行为**:没传 `sanitize` 也没有 `setSanitizer()` 时,
207
+ `opt.content` 仍然按"调用方已净化"处理(与 0.25.x 一致)。
208
+ ⇒ 想接入 DOMPurify 这类库:
209
+ Drawer.setSanitizer(function (html) { return DOMPurify.sanitize(html); });
210
+ 或逐次传 `opt.sanitize` 覆盖(逐次优先于模块级)。
211
+ ⚠️ 钩子只处理**字符串入口**;传 Node 的入口本来就绕过了 HTML 解析。 */
212
+ var SANITIZER = null;
213
+
214
+ function setSanitizer(fn) {
215
+ SANITIZER = (typeof fn === 'function') ? fn : null;
216
+ }
217
+
218
+ function sanitizeHtml(html, perCall) {
219
+ var fn = (typeof perCall === 'function') ? perCall : SANITIZER;
220
+ return fn ? String(fn(String(html))) : String(html);
221
+ }
222
+
223
+ /* 读屏文案默认值(F2 / 0.26.0)。不传 `labels` 时行为逐字不变。 */
224
+ var DEFAULT_LABELS = { dialog: '抽屉', close: '关闭' };
225
+
226
+ /* ==== BEHAVIOR INJECT BEGIN: labels ==== */
227
+ var flLabels = function (defaults, src) {
228
+ src = src || {};
229
+ var out = {};
230
+ var k;
231
+ for (k in defaults) {
232
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
233
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
234
+ }
235
+ }
236
+ return out;
237
+ };
238
+ /* ==== BEHAVIOR INJECT END: labels ==== */
204
239
  function open(opt) {
205
240
  opt = opt || {};
241
+ var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
206
242
  var place = opt.place || 'right';
207
243
  var placeCls = PLACE_CLASS[place];
208
244
  if (!placeCls) placeCls = PLACE_CLASS.right;
@@ -226,7 +262,7 @@
226
262
  d.setAttribute('aria-modal', 'true');
227
263
  if (!d.id) d.id = 'drawer-' + (new Date()).getTime();
228
264
  if (opt.title) d.setAttribute('aria-labelledby', d.id + '-title');
229
- else d.setAttribute('aria-label', opt.label || '抽屉');
265
+ else d.setAttribute('aria-label', opt.label || L.dialog);
230
266
  d.tabIndex = -1; /* 焦点陷阱的兜底落点 */
231
267
 
232
268
  var head = document.createElement('div');
@@ -240,16 +276,18 @@
240
276
  导致上面所有 close.addEventListener 绑在函数上(静默失败)。 */
241
277
  closeBtn.className = 'drawer__close';
242
278
  closeBtn.type = 'button';
243
- closeBtn.setAttribute('aria-label', '关闭');
279
+ closeBtn.setAttribute('aria-label', L.close);
244
280
  closeBtn.textContent = '\u00D7';
245
281
  head.appendChild(h);
246
282
  head.appendChild(closeBtn);
247
283
 
248
284
  var body = document.createElement('div');
249
285
  body.className = 'drawer__body';
250
- /* @html-sink raw-html — `opt.content` 是有意的 HTML 入口,调用方负责净化;
251
- 登记在 SECURITY.md「HTML 入口(sink)」。 */
252
- if (typeof opt.content === 'string') body.innerHTML = opt.content;
286
+ if (typeof opt.content === 'string') {
287
+ /* @html-sink raw-html — `opt.content` 是有意的 HTML 入口,调用方负责净化;
288
+ 登记在 SECURITY.md「HTML 入口(sink)」。`sanitize` / `setSanitizer` 可挂钩。 */
289
+ body.innerHTML = sanitizeHtml(opt.content, opt.sanitize);
290
+ }
253
291
  else if (opt.content) body.appendChild(opt.content);
254
292
 
255
293
  d.appendChild(head);
@@ -374,5 +412,5 @@
374
412
  return { el: d, close: close };
375
413
  }
376
414
 
377
- global.Drawer = { open: open };
415
+ global.Drawer = { open: open, setSanitizer: setSanitizer };
378
416
  })(typeof window !== 'undefined' ? window : globalThis);
@@ -330,6 +330,26 @@
330
330
  }
331
331
 
332
332
  function create(root, opt) {
333
+ /* 读屏文案默认值(F2 / 0.26.0)。不传 `labels` 时输出逐字不变。 */
334
+ var DEFAULT_LABELS = {
335
+ menu: '菜单'
336
+ };
337
+
338
+ /* ==== BEHAVIOR INJECT BEGIN: labels ==== */
339
+ var flLabels = function (defaults, src) {
340
+ src = src || {};
341
+ var out = {};
342
+ var k;
343
+ for (k in defaults) {
344
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
345
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
346
+ }
347
+ }
348
+ return out;
349
+ };
350
+ /* ==== BEHAVIOR INJECT END: labels ==== */
351
+ var L = flLabels(DEFAULT_LABELS, opt && opt.labels);
352
+
333
353
  opt = opt || {};
334
354
  var btn = root.querySelector('[data-dd-btn]') || root.querySelector('button');
335
355
  var menu = root.querySelector('[data-dd-menu]');
@@ -344,7 +364,7 @@
344
364
  /* APG:菜单要么全 aria-hidden,要么全不设 —— 不能半吊子 */
345
365
  menu.setAttribute('role', 'menu');
346
366
  if (!menu.getAttribute('aria-label') && !menu.getAttribute('aria-labelledby')) {
347
- menu.setAttribute('aria-label', opt.label || '菜单');
367
+ menu.setAttribute('aria-label', opt.label || L.menu);
348
368
  }
349
369
  Array.prototype.forEach.call(menu.querySelectorAll('li'), function (li) {
350
370
  /* 🔴 修一个**静默失效**(实测发现):
@@ -289,7 +289,7 @@ body[data-scroll-locked] { overflow: hidden; }
289
289
  隐藏遮罩会把弹窗里的字一起吞掉(静默截断)。实测:屏幕开着 dialog 时
290
290
  打印,遮罩是 `position: fixed; inset: 0; rgba(21,24,28,.4)`,整页 40% 黑。
291
291
  ⚠️ 必须写在这个文件里:写进 tokens.css 会被这里同特异性的 `position: fixed`
292
- 盖掉 —— 规则写了、不生效、不报错(E254;由 print-policy-gate L3 抓到)。
292
+ 盖掉 —— 规则写了、不生效、不报错(由 print-policy-gate L3 抓到)。
293
293
  ========================================================================= */
294
294
  @media print {
295
295
  .dialog-backdrop {