agentcss 0.25.6 → 0.26.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 (56) hide show
  1. package/01-tokens/behavior/labels.js +44 -0
  2. package/02-primitives/combobox/combobox.js +26 -3
  3. package/02-primitives/date-range/date-range.js +27 -5
  4. package/02-primitives/popover/popover.js +22 -2
  5. package/02-primitives/select/select.js +23 -2
  6. package/02-primitives/upload/upload.js +50 -17
  7. package/03-patterns/content/content.css +13 -1
  8. package/03-patterns/content/demo.html +1 -1
  9. package/03-patterns/drawer/drawer.js +44 -6
  10. package/03-patterns/dropdown/dropdown.js +21 -1
  11. package/03-patterns/overlay/overlay.js +39 -2
  12. package/03-patterns/pagination/pagination.js +37 -10
  13. package/03-patterns/tooltip/tooltip.js +17 -2
  14. package/03-patterns/tree/tree.js +21 -1
  15. package/04-recipes/analysis-report/demo.html +1 -1
  16. package/04-recipes/data-showcase/demo.html +2 -2
  17. package/04-recipes/longform/content.html +989 -74
  18. package/04-recipes/longform/longform.html +3 -3
  19. package/04-recipes/report/demo.html +1 -1
  20. package/04-recipes/table/README.md +22 -0
  21. package/04-recipes/table/demo.html +6 -6
  22. package/09-assets/bar/bar.js +28 -4
  23. package/09-assets/model-viewer/model-viewer.js +44 -12
  24. package/09-assets/scientific-plot/demo.html +6 -6
  25. package/API.md +32 -0
  26. package/CHANGELOG.md +71 -0
  27. package/README.md +17 -0
  28. package/README.zh-CN.md +16 -0
  29. package/SECURITY.md +16 -4
  30. package/START-HERE.md +14 -14
  31. package/ai/components.json +9 -4
  32. package/ai/pdf.js +50 -8
  33. package/dist/01-tokens/behavior/labels.js +11 -0
  34. package/dist/02-primitives/combobox/combobox.js +25 -3
  35. package/dist/02-primitives/date-range/date-range.js +26 -5
  36. package/dist/02-primitives/popover/popover.js +14 -1
  37. package/dist/02-primitives/select/select.js +22 -2
  38. package/dist/02-primitives/upload/upload.js +47 -17
  39. package/dist/03-patterns/content/content.css +1 -1
  40. package/dist/03-patterns/drawer/drawer.js +32 -4
  41. package/dist/03-patterns/dropdown/dropdown.js +20 -1
  42. package/dist/03-patterns/overlay/overlay.js +25 -1
  43. package/dist/03-patterns/pagination/pagination.js +34 -10
  44. package/dist/03-patterns/tooltip/tooltip.js +14 -2
  45. package/dist/03-patterns/tree/tree.js +20 -1
  46. package/dist/09-assets/bar/bar.js +23 -4
  47. package/dist/09-assets/model-viewer/model-viewer.js +38 -12
  48. package/dist/COSTS.md +14 -14
  49. package/docs/ACCESSIBILITY.md +2 -2
  50. package/docs/L10N.md +92 -0
  51. package/docs/RELEASE-PROCESS.md +7 -1
  52. package/docs/VERIFY-RELEASE.md +7 -2
  53. package/index.html +1 -1
  54. package/llms-full.txt +2 -2
  55. package/package.json +3 -2
  56. package/types/index.d.ts +218 -13
@@ -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 ==== */
@@ -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,14 +180,14 @@
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;
@@ -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"。 */
@@ -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>
@@ -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
  /* 🔴 修一个**静默失效**(实测发现):
@@ -10,8 +10,9 @@
10
10
  demo 可以用现代语法(跑在用户浏览器上),库代码不行。
11
11
 
12
12
  API:
13
- Overlay.toast({ title, desc, variant, duration })
13
+ Overlay.toast({ title, desc, variant, duration, maxVisible, labels })
14
14
  Overlay.dialog({ title, desc, actions, danger, initialFocus })
15
+ labels: 读屏文案覆盖(默认中文,不传时行为不变)
15
16
  → 返回 { el, close }
16
17
  ========================================================================= */
17
18
  (function (global) {
@@ -265,8 +266,36 @@
265
266
  warning: 'toast--warning'
266
267
  };
267
268
 
269
+ /* 读屏文案的**默认值**与合并规则(F2 / 0.26.0)。
270
+ 🔴 只覆盖"会被读屏念出来或用户看得见"的文案;`throw new Error(...)`
271
+ 这类是给调用方看的开发期信息,不在这里 —— 它们不经过 UI。
272
+ 值可以是字符串,也可以是函数(动态文案,如 `第 N 页`)。
273
+ 不传 `labels` 时行为与之前**逐字相同**(默认中文)。 */
274
+ var DEFAULT_LABELS = {
275
+ closeToast: '关闭提示'
276
+ };
277
+
278
+ /* ==== BEHAVIOR INJECT BEGIN: labels ==== */
279
+ var flLabels = function (defaults, src) {
280
+ src = src || {};
281
+ var out = {};
282
+ var k;
283
+ for (k in defaults) {
284
+ if (Object.prototype.hasOwnProperty.call(defaults, k)) {
285
+ out[k] = (src[k] === undefined || src[k] === null) ? defaults[k] : src[k];
286
+ }
287
+ }
288
+ return out;
289
+ };
290
+ /* ==== BEHAVIOR INJECT END: labels ==== */
291
+ /* 同屏最多几条 toast。超了就把**最旧的**直接摘掉 ——
292
+ 否则 20 条并发提示会把屏幕铺满,且最后一条永远读不到。
293
+ 0 / 负数表示不限制(调用方自己负责)。 */
294
+ var TOAST_LIMIT = 4;
295
+
268
296
  function toast(opts) {
269
297
  opts = opts || {};
298
+ var L = flLabels(DEFAULT_LABELS, opts && opts.labels);
270
299
 
271
300
  var region = document.querySelector('.toast-region');
272
301
  if (!region) {
@@ -314,7 +343,7 @@
314
343
  var closeBtn = document.createElement('button');
315
344
  closeBtn.className = 'toast__close';
316
345
  closeBtn.type = 'button';
317
- closeBtn.setAttribute('aria-label', '关闭提示');
346
+ closeBtn.setAttribute('aria-label', L.closeToast);
318
347
  closeBtn.textContent = '×';
319
348
  el.appendChild(closeBtn);
320
349
 
@@ -331,6 +360,14 @@
331
360
  }
332
361
 
333
362
  closeBtn.addEventListener('click', close);
363
+ /* 同屏上限:先摘最旧的,再挂新的。
364
+ 🔴 摘的时候**不走 close()** —— 它带 220ms 退场动画,
365
+ 动画期间节点仍在 DOM 里,children.length 不减 ⇒ 这里会空转。
366
+ 被摘掉的那条已经在屏幕外了,动画没有意义。 */
367
+ var max = (opts.maxVisible === undefined) ? TOAST_LIMIT : opts.maxVisible;
368
+ while (max > 0 && region.children.length >= max) {
369
+ region.removeChild(region.firstChild);
370
+ }
334
371
  region.appendChild(el);
335
372
 
336
373
  var timerId = auto > 0 ? setTimeout(close, auto) : null;