agentcss 0.25.4 → 0.25.5

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.
@@ -224,11 +224,13 @@
224
224
  var q = input.value.trim();
225
225
  shown = filter(q).slice(0, 50);
226
226
  if (!shown.length) {
227
+ /* @html-sink escaped — 插值只有 q,且过了 esc() */
227
228
  list.innerHTML = '<li class="combo__empty" role="presentation">' +
228
229
  (q ? '没有匹配「' + esc(q) + '」的选项' : '暂无可选') + '</li>';
229
230
  return;
230
231
  }
231
232
  var ql = q.toLowerCase();
233
+ /* @html-sink escaped — label/value 全部过 esc();list.id / i / picked 是内部量 */
232
234
  list.innerHTML = shown.map(function (o, i) {
233
235
  var picked = tags.indexOf(o.value) >= 0;
234
236
  var label = esc(o.label);
@@ -253,6 +255,7 @@
253
255
  /* ---------- 标签 ---------- */
254
256
  function paintTags() {
255
257
  if (!tagBox) return;
258
+ /* @html-sink escaped — o.label 与 v 都过 esc() */
256
259
  tagBox.innerHTML = tags.map(function (v) {
257
260
  var o = ALL.filter(function (x) { return x.value === v; })[0] || { label: v };
258
261
  /* ⭐ 删除按钮有 aria-label(否则读屏只念"×")*/
@@ -172,7 +172,15 @@
172
172
  }
173
173
 
174
174
  function paintSummary(s) {
175
- if (summary) summary.innerHTML = s ? ('区间 <b>' + s + '</b>') : '';
175
+ /* 🔴 不拼 innerHTML(E299):`s` 来自两个 <input> 的 value,
176
+ 而 `type=date` 在 Chromium 97 旧内核会回退成文本输入 ⇒ value 可为任意串。 */
177
+ if (!summary) return;
178
+ summary.textContent = '';
179
+ if (!s) return;
180
+ summary.appendChild(document.createTextNode('区间 '));
181
+ var b = document.createElement('b');
182
+ b.textContent = s;
183
+ summary.appendChild(b);
176
184
  }
177
185
 
178
186
  function emit() {
@@ -213,6 +221,7 @@
213
221
  }
214
222
  var box = root.querySelector('[data-dr-presets]');
215
223
  if (!box) return;
224
+ /* @html-sink internal — 只有固定标签与 p.key(本文件常量) */
216
225
  box.innerHTML = presets.map(function (p) {
217
226
  return '<button class="drange__preset" type="button" aria-pressed="false" ' +
218
227
  'data-dr-preset="' + p.key + '">' + p.label + '</button>';
@@ -181,6 +181,8 @@
181
181
  (加了就是模态,语义就错了)。 */
182
182
  box.setAttribute('role', 'dialog');
183
183
  box.setAttribute('aria-label', opts.label || '');
184
+ /* @html-sink raw-html — `opts.html` 是有意的 HTML 入口,调用方负责净化;
185
+ 登记在 SECURITY.md「HTML 入口(sink)」。 */
184
186
  box.innerHTML =
185
187
  '<span class="popover__arrow" aria-hidden="true"></span>' +
186
188
  '<div class="popover__body">' + (opts.html || '') + '</div>';
@@ -247,6 +247,8 @@
247
247
 
248
248
  var body = document.createElement('div');
249
249
  body.className = 'drawer__body';
250
+ /* @html-sink raw-html — `opt.content` 是有意的 HTML 入口,调用方负责净化;
251
+ 登记在 SECURITY.md「HTML 入口(sink)」。 */
250
252
  if (typeof opt.content === 'string') body.innerHTML = opt.content;
251
253
  else if (opt.content) body.appendChild(opt.content);
252
254
 
@@ -175,6 +175,7 @@
175
175
  btn.className = 'shell__menu';
176
176
  btn.setAttribute('aria-label', o.label || ('打开' + title));
177
177
  btn.setAttribute('aria-haspopup', 'dialog');
178
+ /* @html-sink internal — ICON 是本文件的固定 SVG 常量,无插值 */
178
179
  btn.innerHTML = ICON;
179
180
  btn.addEventListener('click', function () { self.open(); });
180
181
  topbar.insertBefore(btn, topbar.firstChild);
@@ -116,8 +116,16 @@
116
116
  this.ui.appendChild(this.btn);
117
117
 
118
118
  this.hint = el('p', 'mv__hint');
119
- this.hint.innerHTML = '模型 <code>' + (this.src.split('/').pop() || '—') +
120
- '</code> 会<b>点击后才下载</b>,首屏不占流量。';
119
+ /* 🔴 文件名来自 `src`,可能是调用方拼出来的 URL(E299)。
120
+ 原来用 innerHTML 直接拼 ⇒ `<img onerror>` 这类值会被解析成节点。
121
+ 改成 textContent 组装:`<code>` 与 `<b>` 仍然存在,但文本永远只是文本。 */
122
+ var fname = el('code', null, this.src.split('/').pop() || '—');
123
+ var strong = el('b', null, '点击后才下载');
124
+ this.hint.appendChild(document.createTextNode('模型 '));
125
+ this.hint.appendChild(fname);
126
+ this.hint.appendChild(document.createTextNode(' 会'));
127
+ this.hint.appendChild(strong);
128
+ this.hint.appendChild(document.createTextNode(',首屏不占流量。'));
121
129
  this.ui.appendChild(this.hint);
122
130
 
123
131
  if (!hasWebGL()) {
@@ -351,6 +359,7 @@
351
359
  self.ui.innerHTML = '';
352
360
  var cap = el('p', 'mv__cap');
353
361
  var dt = Math.round(((window.performance && performance.now) ? performance.now() : Date.now()) - t0);
362
+ /* @html-sink internal — 插值只有 fmtKB() 的返回值与换算后的毫秒数 */
354
363
  cap.innerHTML = '模型 <b>' + fmtKB(bytes || self.lastBytes || 0) + '</b> · 引擎就绪耗时 <b>' + dt + 'ms</b>' +
355
364
  (controls ? ' · 拖动可旋转,滚轮/双指可缩放' : ' · 未加载控制器,无法拖动');
356
365
  self.node.appendChild(cap);
@@ -543,6 +543,7 @@
543
543
  legendHtml = '<ul class="splot__legend">' + items.join('') + '</ul>';
544
544
  }
545
545
 
546
+ /* @html-sink internal — svg 由本文件生成,图例名字已过 esc() */
546
547
  el.innerHTML = svg + legendHtml;
547
548
  return {
548
549
  svg: el.firstChild,
package/CHANGELOG.md CHANGED
@@ -16,6 +16,89 @@
16
16
 
17
17
  (无)
18
18
 
19
+ ## [0.25.5] - 2026-11-04
20
+
21
+ ### 新增:`tokens.core.css`(只有变量)与 `base.reset.css`(只有底座)
22
+
23
+ `tokens.css` 一直同时干两件事:定义变量,以及**改整个宿主文档**的三条规则
24
+ (全局 `:focus-visible`、`a/button/label…` 的 `-webkit-tap-highlight-color`、
25
+ `prefers-reduced-motion` 里对 `*` 的 `!important`)。README 的最小示例却把这套组合
26
+ 描述成"零副作用"——嵌进已有站点的人会发现宿主的焦点环与点按反馈被换掉了,
27
+ 而看不出是哪一行。
28
+
29
+ 冻结期不能改 `tokens.css` 的语义,所以只做加法:
30
+
31
+ | 新入口 | 含什么 | 改宿主吗 |
32
+ |---|---|---|
33
+ | `agentcss/tokens.core.css` | 只有变量(含暗色、按语言的字体与字距) | 不会 |
34
+ | `agentcss/base.reset.css` | 只有那三条 | 会 |
35
+ | `agentcss/tokens.css` | 旧路,一个字节没动 | 会(同上) |
36
+
37
+ 两个新文件是**生成物**(`05-audit/split-tokens.py` 从 `tokens.css` 切出来),
38
+ `token-split` 门禁逐字节比对 —— 同一事实不留第二份手写来源(I-7)。
39
+
40
+ **计算样式实测**(light / dark 各一次,同一页面只换引入文件):
41
+
42
+ | | `--paper` | 焦点环 | 点按高亮 |
43
+ |---|---|---|---|
44
+ | core | `#f6f7f8` / `#14171A` | `auto 1px`(浏览器默认) | 保留 `0.18` |
45
+ | tokens.css | `#f6f7f8` / `#14171A` | `solid 2px`(库的) | 被关掉 `0` |
46
+
47
+ 判据 5 条 + 反向控制 4 条。核心一条:**每条顶层规则必须归入 core / reset / library**,
48
+ 归不了类就红 —— 将来有人往 `tokens.css` 加第四条全局规则,会被逼着做一次显式决定。
49
+
50
+ 过程中自己踩的两个坑,都写进了判据:
51
+ - 按选择器前缀归类 ⇒ 暗色令牌在 `@media screen and (prefers-color-scheme: dark)` 里,
52
+ 被判成"库自己的东西",core 里没有暗色;**计算样式实测 dark 下仍返回 `#f6f7f8`**。
53
+ 改成递归判"块里是不是只有自定义属性"。
54
+ - `_inner()` 在原文里找第一个 `{`,而块首注释里就有 `{`(举例 `.wrap { max-width }`)
55
+ ⇒ 切到注释中间,后面全错。索引必须从遮蔽层取。
56
+
57
+ ### 新增:每个 HTML 注入点都要说得清装的是什么(`xss-sink-gate`)
58
+
59
+ 全库 21 处 `.innerHTML` / `.insertAdjacentHTML`,此前没有任何机制阻止第 22 处悄悄进来,
60
+ `SECURITY.md` 只写了 `combobox` 一处、并称"没有门禁守着"。现在分四类
61
+ (清空 12 / 已转义 3 / 内部量 4 / 有意入口 2),非"赋空串"的必须在紧邻上一行写
62
+ `/* @html-sink <kind> — <理由> */`。
63
+
64
+ 两处**真缺陷**已修:
65
+ - `model-viewer` 把 `src` 的文件名拼进 `innerHTML`。文件名可以是
66
+ `<img src=x onerror=…>`。改成 `textContent` 组装。载荷实测:注入串只以
67
+ `&lt;img` 出现,`injectedImg=false`、`flag=false`。
68
+ - `date-range` 把 `<input>` 的 `value` 拼进 `innerHTML`,而 `type=date` 在
69
+ Chromium 97 旧内核会回退成文本输入 ⇒ `value` 可为任意串。改成节点组装(摘要文本不变,
70
+ 组件契约门禁仍断言「区间 2026-01-01 → 2026-01-31(31 天)」)。
71
+
72
+ `raw-html` 两处(`popover` 的 `opts.html`、`drawer` 的 `opt.content`)登记在
73
+ `SECURITY.md` 的「HTML 入口」,与代码**双向**核对(陈旧登记也红)。
74
+
75
+ **这道门禁证明不了什么**:它不证明 `esc()` 的实现对,也不覆盖全部数据流。
76
+ 它证明的是"新增未声明的注入点必须显式改标记或改文档"。
77
+
78
+ ### 修正:文档里三处正在说谎的地方
79
+
80
+ - `CONVENTIONS.md` 写「`!important` 现有 10 处(无障碍 9 + `[hidden]` 1)」,
81
+ 门禁实测 **13 处**:多出来的 2 处是 `@media print` —— 文档连**类别**都没列。
82
+ 按文档去审计的人会把这两处正当的写成"说不清为什么要赢"。改成类别表 + 「要数字跑门禁」。
83
+ - `README` 说「`components.json` 里标 `beta` 的那几个组件」,实际 **40/40 stable**。
84
+ 改成讲清四条机械判据,并写明 `stable` 量的是机制完备度、不是"久经生产"。
85
+ - `README` 安装段还留着「发布成功之后这一行才会换上」的历史段落(上一句已写已发布);
86
+ 源码复制那段编号从 ② 改 ③。
87
+
88
+ ### 体积账本:去掉重复计数,不放宽上限
89
+
90
+ `tokens.core.css` / `base.reset.css` 是 `tokens.css` 的切片,消费者只下载其中一个。
91
+ 三个文件一起计入会把同一批声明算三遍 ⇒ `perf-gate` 排除这两个**已知切片**并在输出里
92
+ 打印说明;`ceiling` / `granted` 一个都没动。
93
+
94
+ ### 验证
95
+
96
+ - 快速层 **120 道 / 0 FAIL**;打包冒烟(含消费者侧文档路径解析)通过。
97
+ - 视觉基线重录,但 **109 张 PNG 逐字节未变**,只有 `_stamp.json` 更新。
98
+ - 反向控制:`xss-sink` 12 条、`token-split` 4 条,每条都证明"该红时真的红"。
99
+
100
+ ---
101
+
19
102
  ## [0.25.4] - 2026-11-03
20
103
 
21
104
  **修订版:把「类型里看不见的那 8 个全局」补上,并让这件事以后有人管。
package/CONTRIBUTING.md CHANGED
@@ -29,7 +29,7 @@ npm run gate # 全量:含浏览器类
29
29
  npm run gate:fast # 只跑静态层,不用装浏览器
30
30
  ```
31
31
 
32
- 全量是 **235 道门禁**(默认 `min(核数, 6)` 路并发,实测 583 秒 / 退出码 0;
32
+ 全量是 **239 道门禁**(默认 `min(核数, 6)` 路并发,实测 583 秒 / 退出码 0;
33
33
  单道耗时累加 1658 秒,并发效率约 2.8×。组件层要起浏览器,还要逐张比 **109 张**视觉截图 ——
34
34
  47 浅色 + 47 暗色 + 15 RTL,按平台分集,只比本平台那套)。
35
35
  只想确认没改坏静态契约的话用 `gate:fast`,几秒到几分钟。
package/README.md CHANGED
@@ -75,12 +75,29 @@ npm install github:owain323/frontend-lib
75
75
 
76
76
  **1 · Bring in the styles** (with your bundler)
77
77
 
78
+ First decide whether you want the **base layer**. It is the only part of this library that touches the host page.
79
+
80
+ | File | Contains | Touches the host? |
81
+ |---|---|---|
82
+ | `agentcss/tokens.core.css` | CSS custom properties only (incl. dark and per-language overrides) | **No** |
83
+ | `agentcss/base.reset.css` | Global focus ring, `a/button/label…` tap highlight, reduced-motion | Yes (three rules) |
84
+ | `agentcss/tokens.css` | Both of the above combined (the old path, byte-for-byte unchanged) | Yes (same three) |
85
+
78
86
  ```js
87
+ // Dropping into an existing site: variables only, host focus ring and tap feedback untouched
88
+ import 'agentcss/tokens.core.css';
89
+
90
+ // Building a page from scratch: one import (equivalent to tokens.core.css + base.reset.css)
79
91
  import 'agentcss/tokens.css';
92
+
80
93
  import 'agentcss/typography.css';
81
94
  import 'agentcss/primitives/button/button.css';
82
95
  ```
83
96
 
97
+ The three base-layer rules, spelled out: a global `:focus-visible` ring; `-webkit-tap-highlight-color: transparent` on bare links/buttons/labels; and a `prefers-reduced-motion` block that overrides `*, *::before, *::after` with `!important`.
98
+
99
+ > Both files are **generated** from `01-tokens/tokens.css` by `05-audit/split-tokens.py` and checked byte-for-byte by a gate. Edit the token source, then run `python 05-audit/split-tokens.py --write`.
100
+
84
101
  **2 · For interactive components, load the behaviour script**
85
102
 
86
103
  Behaviour scripts are IIFEs that attach to `window` (e.g. `window.Overlay`).
@@ -291,7 +308,7 @@ Colour comes from tokens only. Component CSS never writes `@media (prefers-color
291
308
 
292
309
  ## Accessibility
293
310
 
294
- Every component carries a contract covering focus order, keyboard operation, ARIA state and contrast; the checks run in real browsers rather than only reading source text. Components that fail their own contract are marked `beta` rather than described as stable.
311
+ Every component carries a contract covering focus order, keyboard operation, ARIA state and contrast; the checks run in real browsers rather than only reading source text. Maturity is computed from four mechanical criteria (demo + dedicated gate + state on `data-*` + zero behaviour debt), not assigned by hand. All 40 currently pass, so there is no `beta` component — that measures **mechanism completeness**, not how many production projects have shipped it.
295
312
 
296
313
  ## Development
297
314
 
package/README.zh-CN.md CHANGED
@@ -74,15 +74,10 @@ npm install github:owain323/frontend-lib
74
74
  > registry 上没有可安装版本,靠 GitHub Release 附件分发(当时的分发路径记在
75
75
  > `CHANGELOG.md` 的 0.11.2 一节)。自 0.11.2 起这条通路已恢复。
76
76
 
77
- **② 源码复制**(不想装依赖):把 `01-tokens/` 和需要的组件目录复制进你的项目。
77
+ **③ 源码复制**(不想装依赖):把 `01-tokens/` 和需要的组件目录复制进你的项目。
78
78
  ⚠️ 复制 `dist/` 里的版本,**别复制源码版** —— 源码版有一半以上是给人和 AI 读的注释
79
79
  (理由见下面「体积」一节)。
80
80
 
81
- > 为什么这里不提前写 `npm install agentcss`:
82
- > 写了而 registry 上还没有,照做的人会拿到 404,然后以为是自己的环境问题 ——
83
- > 这与本库曾经踩过的「README 教人装、装到的是别人 2017 年的包」是同一类坑。
84
- > 发布成功之后这一行才会换上。
85
-
86
81
  ## 组件一览
87
82
 
88
83
  共 **40** 个(stable 40 · 全部通过四条判据)。每个组件目录下有 `README.md`(为什么这么做)和 `demo.html`(能直接抄的结构)。
@@ -152,12 +147,37 @@ npm install github:owain323/frontend-lib
152
147
 
153
148
  **1 · 引入样式**(用你的打包器)
154
149
 
150
+ 先做一个决定:**要不要底座层**。这是全库唯一会改到宿主页面的地方。
151
+
152
+ | 文件 | 含什么 | 会改宿主吗 |
153
+ |---|---|---|
154
+ | `agentcss/tokens.core.css` | 只有 CSS 变量(含暗色、按语言的字体与字距覆盖) | **不会** |
155
+ | `agentcss/base.reset.css` | 全局焦点环、`a/button/label…` 的点按高亮、减少动效 | 会(三条,见下) |
156
+ | `agentcss/tokens.css` | 上面两者的合集(旧路,行为一个字节都没变) | 会(同上三条) |
157
+
155
158
  ```js
159
+ // 嵌进已有站点:只要变量,不动宿主的焦点环与点按反馈
160
+ import 'agentcss/tokens.core.css';
161
+
162
+ // 从零搭页面:一条搞定(等价于 tokens.core.css + base.reset.css)
156
163
  import 'agentcss/tokens.css';
164
+
157
165
  import 'agentcss/typography.css';
158
166
  import 'agentcss/primitives/button/button.css';
159
167
  ```
160
168
 
169
+ **底座层具体改什么**(这三条作用于宿主,不只是本库组件):
170
+
171
+ 1. `:focus-visible` —— 所有可聚焦元素拿到本库的焦点环(2px `--accent`);
172
+ 2. `a, button, label, summary, input[type=checkbox]…` —— 原生点按高亮被关掉
173
+ (`-webkit-tap-highlight-color: transparent`);
174
+ 3. `@media (prefers-reduced-motion: reduce)` —— 对 `*, *::before, *::after`
175
+ 压掉动画与过渡(带 `!important`)。
176
+
177
+ > 这两个文件是**生成物**:由 `05-audit/split-tokens.py` 从 `tokens.css` 切出来,
178
+ > 门禁逐字节核对。想改令牌只有一个地方 —— `01-tokens/tokens.css`,
179
+ > 改完跑 `python 05-audit/split-tokens.py --write`。
180
+
161
181
  **2 · 需要交互组件时**,引入行为脚本
162
182
 
163
183
  行为脚本是 IIFE,挂到 `window` 上(如 `window.Overlay`)。
@@ -185,11 +205,11 @@ import 'agentcss/patterns/overlay/overlay.js';
185
205
 
186
206
  ```
187
207
  dist/01-tokens/tokens.css ← 拷这个
188
- 01-tokens/tokens.css ← 源码(带 31.6 KB 注释),用于阅读
208
+ 01-tokens/tokens.css ← 源码(带全部注释),用于阅读
189
209
  ```
190
210
 
191
- - **别拷源码目录**除非你要读它:全库 CSS 有 68% 是注释(字节口径;字符口径会低报 12 个百分点),`page.css` 一个文件
192
- 17.5 KB 里 15.1 KB 是注释。
211
+ - **别拷源码目录**除非你要读它:全库 CSS 里注释占了多数字节(精确比例见
212
+ `dist/COSTS.md` —— 那里是现算的,不手抄),`page.css` 单个尤其明显。
193
213
  - **JS 也拷 `dist/`**:那一份去了注释但没压缩(理由见「体积」一节)。
194
214
 
195
215
  ---
@@ -256,10 +276,11 @@ dist/01-tokens/tokens.css ← 拷这个
256
276
  ## 体积:默认给你的已经是干净产物
257
277
 
258
278
  源码目录(`01-tokens/` `02-primitives/` …)里的 CSS **带着全部注释** ——
259
- 实测全库 CSS 有 **约七成是注释**(精确体积由 `dist/COSTS.md` 现算),`page.css` 单个就有 86%。
279
+ 注释占了多数字节(精确体积由 `dist/COSTS.md` 现算,这里是故意不手抄的),
280
+ `page.css` 单个更高。
260
281
 
261
282
  那些注释是写给你看的取舍理由(为什么这么定、踩过什么坑),不是写给你浏览器看的。
262
- **注释让你看代码时处处撞墙;从源码目录引入的话,构建工具还会把 68% 的字节原样打进产物。**
283
+ **注释让你看代码时处处撞墙;从源码目录引入的话,构建工具还会把那部分字节原样打进产物。**
263
284
 
264
285
  **一个冻结的上限。** 整套库受一份账本约束([`05-audit/budget.json`](05-audit/budget.json)):
265
286
  硬天花板 + 一份「常规新增组件不用改任何数字」的储备 + 一个只在体积下降时收紧的棘轮。
@@ -528,9 +549,13 @@ npm run benchmark # 兼容性基准
528
549
 
529
550
  改代码时跑 `gate:fast` 即可;**提交前**再跑一次 `gate`。
530
551
 
531
- 类型声明在 `types/index.d.ts`。改动它之前先看 `API.md` 的「稳定程度约定」,
532
- 以及 `ai/components.json` 里标 `beta` 的那几个组件 ——
533
- 它们定稿之前,那几部分不适合作为长期依赖引入。
552
+ 类型声明在 `types/index.d.ts`。改动它之前先看 `API.md` 的「稳定程度约定」。
553
+
554
+ 成熟度不是人写的,是 `ai/components.json` 里**四条机械判据**算出来的
555
+ (有 demo + 有专属门禁 + 状态全在 `data-*` 上 + 行为零欠账)。
556
+ 当前 40 个全部满足,所以**没有 beta 组件** —— 但这四条量的是**机制完备度**,
557
+ 不是"在多少个生产项目里跑过"。把 `stable` 读成"久经生产"是误读;
558
+ 真实采用证据在 `SUPPORT.md` 与 `docs/LAYER-BASELINE.md` 里,按行查。
534
559
 
535
560
  ## 许可
536
561
 
package/SECURITY.md CHANGED
@@ -25,20 +25,39 @@
25
25
  - **构建期依赖**:`package-lock.json` 里是 devDependencies(typescript、puppeteer-core、
26
26
  axe-core 等),只在开发和跑门禁时用。它们**不会被使用者安装**。
27
27
  - **真实面**:
28
- - **`combobox` 用 `innerHTML` 渲染,但变量都过了转义** ——
29
- 实测约 235 / 240 / 264 三处,插值全部走本文件内的 `esc()`
30
- (转义 `& < > "`)。
31
- ⇒ 结论:**传不可信内容进 combobox 是安全的**。
32
- 但这条正确性**只靠这一处 `esc()` 调用撑着,没有门禁守着**
33
- (`bleed-gate` 查的是内部机制泄漏,不是 XSS)——
34
- 将来谁插一个新字段忘了 `esc()`,没有任何东西会拦住他。这在路线图上。
28
+ - **HTML 注入点 —— 现在有门禁守着**。全库共 21 处 `.innerHTML` /
29
+ `.insertAdjacentHTML`,按"装的是什么"分四类:清空型(赋空串)、
30
+ escaped(插值过 `esc()`)、internal(只有常量/内部量)、raw-html(有意的
31
+ HTML 入口)。每个非清空型注入点都必须在紧邻上一行写
32
+ `/* @html-sink <kind> — <理由> */`,否则 `xss-sink-gate.py` 红。
33
+ ⇒ 这不能证明 `esc()` 一定对,但它能保证:**新增一个未声明的注入点,
34
+ 必须显式改标记或改文档**,不会静默进来。
35
35
  - **CSS 注入** —— 把不可信内容插进 `class` 属性时同理(本库不读 `class` 里的值,
36
36
  所以这不是可利用路径,只是习惯问题)。
37
37
  - **DOM 监听泄漏** —— 组件反复挂载/卸载时可能累积监听(部分组件的 `destroy()`
38
38
  正在补齐中,见 CHANGELOG)
39
39
 
40
- ⇒ 也就是说:**本库不太可能成为供应链攻击的一环**,
41
- combobox 那处 `innerHTML` 当前是安全的(已转义),但缺一道门禁兜底。
40
+ ⇒ 也就是说:**本库不太可能成为供应链攻击的一环**。
41
+
42
+ ## HTML 入口(sink)
43
+
44
+ 下面两处会把字符串**当 HTML 解析**。它们是**有意的 API**,不是疏漏 ——
45
+ `popover` 要能放任意块级内容,`drawer` 要能放整页内容,只收文本就废了。
46
+
47
+ | 文件 | 入口 | 谁负责净化 |
48
+ |---|---|---|
49
+ | `02-primitives/popover/popover.js` | `opts.html` | 调用方 |
50
+ | `03-patterns/drawer/drawer.js` | `opt.content` | 调用方 |
51
+
52
+ **为什么本库不做净化**:净化需要一个 HTML 解析器或一份允许列表实现,而本库的目标是
53
+ 零运行时依赖 + Chromium 97 可用。给一个"看起来安全"的半吊子净化,比明确说
54
+ "这是调用方的责任"更危险。
55
+
56
+ 调用方该怎么做:内容来自用户输入时,先净化(如 DOMPurify)再传;只想放纯文本时,
57
+ 用 `textContent` 或先转义,不要走这两个参数。
58
+
59
+ > 这张表与代码**双向**核对:登记了却没有对应注入点 ⇒ 红(防陈旧登记);
60
+ > 有 raw-html 注入点却没登记 ⇒ 红。判据在 `05-audit/xss-sink-gate.py`。
42
61
 
43
62
  ## 支持的版本
44
63
 
package/START-HERE.md CHANGED
@@ -40,20 +40,20 @@
40
40
  | 卡片 | `02-primitives/card/card.css` | 283 | — | 有分组内容就要 |
41
41
  | 单选 / 多选 | `02-primitives/choice/choice.css` | 434 | — | 单选 / 多选就要 |
42
42
  | 下拉选择 | `02-primitives/select/select.css` | 151 | **必需 631** (`select.js`) | 原生 select 的外观或交互不够用就要 |
43
- | 可搜索选择 | `02-primitives/combobox/combobox.css` | 223 | **必需 477** (`combobox.js`) | 选项多到要搜索,或要选多个并留成标签就要 |
43
+ | 可搜索选择 | `02-primitives/combobox/combobox.css` | 223 | **必需 480** (`combobox.js`) | 选项多到要搜索,或要选多个并留成标签就要 |
44
44
  | 开关 | `02-primitives/switch/switch.css` | 429 | — | 有"立即生效的设置"就要 |
45
45
  | 分隔线 | `02-primitives/separator/separator.css` | 73 | — | 要分开内容块就要 |
46
46
  | 加载骨架 | `02-primitives/skeleton/skeleton.css` | 114 | — | 数据还没到、先把版式占住就要 |
47
47
  | 进度条 / 环 | `02-primitives/progress/progress.css` | 228 | — | 有耗时过程要给进度就要 |
48
- | 浮层气泡 | `02-primitives/popover/popover.css` | 187 | **必需 323** (`popover.js`) | 要一段信息贴着触发元素浮出来就要 |
49
- | 日期区间 | `02-primitives/date-range/date-range.css` | 206 | **必需 301** (`date-range.js`) | 要选起止日期就要 |
48
+ | 浮层气泡 | `02-primitives/popover/popover.css` | 187 | **必需 325** (`popover.js`) | 要一段信息贴着触发元素浮出来就要 |
49
+ | 日期区间 | `02-primitives/date-range/date-range.css` | 206 | **必需 310** (`date-range.js`) | 要选起止日期就要 |
50
50
  | 标签页 | `03-patterns/tabs/tabs.css` | 172 | **必需 316** (`tabs.js`) | 同层级内容切换就要 |
51
51
  | 折叠面板 | `03-patterns/accordion/accordion.css` | 453 | **必需 389** (`accordion.js`) | 长表单分段就要 |
52
52
  | 动作菜单 | `03-patterns/dropdown/dropdown.css` | 135 | **必需 555** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
53
53
  | 文字提示 | `03-patterns/tooltip/tooltip.css` | 89 | **必需 284** (`tooltip.js`) | 图标按钮或缩写需要一句话解释就要 |
54
54
  | 分页 | `03-patterns/pagination/pagination.css` | 195 | **必需 401** (`pagination.js`) | 数据多到要翻页就要 |
55
55
  | 树形 | `03-patterns/tree/tree.css` | 122 | **必需 464** (`tree.js`) | 层级数据(文件 / 组织 / 分类)要展开就要 |
56
- | 侧边抽屉 | `03-patterns/drawer/drawer.css` | 145 | **必需 376** (`drawer.js`) | 要从侧边滑出面板就要 |
56
+ | 侧边抽屉 | `03-patterns/drawer/drawer.css` | 145 | **必需 378** (`drawer.js`) | 要从侧边滑出面板就要 |
57
57
  | 弹窗 / 吐司 | `03-patterns/overlay/overlay.css` | 338 | **必需 697** (`overlay.js`) | 需要弹层(吐司 / 模态)就要 |
58
58
  | 状态(空 / 加载 / 错 / 成功) | `03-patterns/states/states.css` | 269 | — | **凡是会异步取数就要** |
59
59
  | 表单校验 | `03-patterns/form-validation/form-validation.css` | 274 | — | 提交前要校验就要 |
@@ -66,13 +66,13 @@
66
66
  | upload | `02-primitives/upload/upload.css` | 490 | 可选 676 (`upload.js`,拖放 / 文件列表 / 校验;没有它基础形态照常工作) | |
67
67
  | reveal | `03-patterns/reveal/reveal.css` | 93 | 可选 175 (`reveal.js`,入场动效,纯增强;没有它内容直接可见) | |
68
68
  | analysis-report | `04-recipes/analysis-report/analysis-report.css` | 226 | — | |
69
- | app-shell | `04-recipes/app-shell/app-shell.css` | 588 | **必需 288** (`shell-nav.js`) | |
69
+ | app-shell | `04-recipes/app-shell/app-shell.css` | 588 | **必需 289** (`shell-nav.js`) | |
70
70
  | detail-page | `04-recipes/detail-page/detail-page.css` | 190 | — | |
71
71
  | report | `04-recipes/report/report.css` | 369 | — | |
72
72
  | table | `04-recipes/table/table.css` | 409 | — | |
73
73
  | bar | `09-assets/bar/bar.css` | 181 | **必需 324** (`bar.js`) | |
74
- | model-viewer | `09-assets/model-viewer/model-viewer.css` | 143 | **必需 423** (`model-viewer.js`) | |
75
- | scientific-plot | `09-assets/scientific-plot/scientific-plot.css` | 141 | **必需 603** (`scientific-plot.js`) | |
74
+ | model-viewer | `09-assets/model-viewer/model-viewer.css` | 143 | **必需 432** (`model-viewer.js`) | |
75
+ | scientific-plot | `09-assets/scientific-plot/scientific-plot.css` | 141 | **必需 604** (`scientific-plot.js`) | |
76
76
  | sparkline | `09-assets/sparkline/sparkline.css` | 158 | **必需 508** (`sparkline.js`) | |
77
77
 
78
78
  ### 🔴 这些组件的 JS 是**必需的**(不是增强)
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
3
  "generatedBy": "05-audit/gen-ai-contract.py",
4
- "libraryVersion": "0.25.4",
4
+ "libraryVersion": "0.25.5",
5
5
  "$comment": "机器可读的组件契约。slots = BEM 元素名(在本库里元素名就是插槽名),states.enum = 组件会在 DOM 上写出的 data-* 取值,states.legacyClass = 仍未收敛到 data 属性的历史状态类(INVARIANT I-8 未达标)。printPolicy = 这个组件在纸上会发生什么(空数组 = 原样保留);pagination = 内容是否被分页裁过(server 不许与 expand 同时出现,否则展开的只是当前一页)。",
6
6
  "maturityCriteria": {
7
7
  "alpha": "缺 demo.html 或缺 05-audit 下的专属门禁 ⇒ 还没被机器验证过,不要用",
@@ -0,0 +1 @@
1
+ @media (prefers-reduced-motion:reduce) {*, *::before, *::after {animation-duration:0.01ms !important;animation-iteration-count:1 !important;transition-duration:0.01ms !important;scroll-behavior:auto !important;}}:focus-visible {outline:2px solid var(--accent);outline-offset:2px;border-radius:var(--r-sm);}a, button, label, summary, input[type="checkbox"], input[type="radio"], input[type="button"], input[type="submit"], [role="button"], [role="switch"], [role="tab"], [role="option"], .choice, .switch, .btn, .accordion__trigger, .tabs__tab, .card, .list__row {-webkit-tap-highlight-color:transparent;}
@@ -0,0 +1 @@
1
+ :root {--raw-paper:#f6f7f8;--raw-surface:#ffffff;--raw-surface-sunken:#f1f3f5;--raw-surface-raised:#ffffff;--raw-scrollbar-thumb:#7A808A;--raw-scrollbar-thumb-hov:#6E747E;--raw-text-primary:#15181c;--raw-text-secondary:#4f555e;--raw-text-tertiary:#606770;--raw-text-on-accent:#ffffff;--raw-text-on-solid:#ffffff;--raw-skeleton-bg:#e9ecef;--raw-skeleton-shimmer:rgba(255, 255, 255, 0.72);--raw-border-control:#868b94;--raw-border-decor:#e2e5e9;--raw-border-decor-str:#c8cdd4;--raw-danger:#c0392b;--raw-danger-hover:#8f1e17;--raw-scrim:rgba(21, 24, 28, 0.4);--raw-success:#2f6b3a;--raw-warning:#8a5a00;--raw-info:#1d5f8a;--raw-danger-bg:#fdeeec;--raw-success-bg:#eaf2e6;--raw-warning-bg:#fbf1dd;--raw-info-bg:#e8f1f8;--raw-dir-up:#c0392b;--raw-dir-down:#2f6b3a;--raw-accent:#1b4d8f;--raw-switch-on:#1b4d8f;--raw-accent-hover:#163a6d;--raw-accent-soft:#e6eef7;--raw-tooltip-bg:#15181c;--raw-tooltip-fg:#f6f7f8;--raw-tooltip-border:#15181c;--paper:var(--raw-paper);--surface:var(--raw-surface);--surface-sunken:var(--raw-surface-sunken);--surface-raised:var(--raw-surface-raised);--scrollbar-size:10px;--scrollbar-size-thin:6px;--scrollbar-thumb:var(--raw-scrollbar-thumb);--scrollbar-thumb-hov:var(--raw-scrollbar-thumb-hov);--scrollbar-radius:5px;--text-primary:var(--raw-text-primary);--text-secondary:var(--raw-text-secondary);--text-tertiary:var(--raw-text-tertiary);--text-on-accent:var(--raw-text-on-accent);--text-on-solid:var(--raw-text-on-solid);--skeleton-bg:var(--raw-skeleton-bg);--skeleton-shimmer:var(--raw-skeleton-shimmer);--skeleton-h:1em;--skeleton-size:40px;--skeleton-duration:1.4s;--ease-out:cubic-bezier(0.23, 1, 0.32, 1);--progress-h:6px;--progress-dur:240ms;--r-full:999px;--ring-size:64px;--ring-thickness:8px;--progress:0%;--progress-num:0;--z-popover:60;--popover-min-w:200px;--popover-max-w:320px;--popover-p:var(--sp-3);--popover-gap:8px;--popover-dur:140ms;--border-control:var(--raw-border-control);--border-decor:var(--raw-border-decor);--border-decor-str:var(--raw-border-decor-str);--danger:var(--raw-danger);--danger-hover:var(--raw-danger-hover);--scrim:var(--raw-scrim);--success:var(--raw-success);--warning:var(--raw-warning);--info:var(--raw-info);--danger-bg:var(--raw-danger-bg);--success-bg:var(--raw-success-bg);--warning-bg:var(--raw-warning-bg);--info-bg:var(--raw-info-bg);--dir-up:var(--raw-dir-up);--dir-down:var(--raw-dir-down);--accent:var(--raw-accent);--switch-on:var(--raw-switch-on);--accent-hover:var(--raw-accent-hover);--accent-soft:var(--raw-accent-soft);--tooltip-bg:var(--raw-tooltip-bg);--tooltip-fg:var(--raw-tooltip-fg);--tooltip-border:var(--raw-tooltip-border);--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:clamp(1rem, 0.95rem + 0.3vw, 1.25rem);--sp-5:clamp(1.25rem, 1.15rem + 0.7vw, 1.75rem);--sp-6:clamp(1.5rem, 1.3rem + 1.2vw, 2.25rem);--sp-7:clamp(2rem, 1.7rem + 1.8vw, 3.5rem);--sp-8:clamp(2.5rem, 2.1rem + 2.4vw, 4.5rem);--bp-sm:30rem;--bp-md:48rem;--bp-lg:64rem;--bp-xl:80rem;--fs-xs:0.75rem;--fs-sm:0.875rem;--fs-base:clamp(1rem, 0.95rem + 0.3vw, 1.125rem);--fs-md:clamp(1.125rem, 1.05rem + 0.5vw, 1.3125rem);--fs-lg:clamp(1.3125rem, 1.2rem + 0.7vw, 1.5rem);--fs-xl:clamp(1.5rem, 1.3rem + 1.2vw, 1.875rem);--fs-2xl:clamp(1.75rem, 1.45rem + 1.8vw, 2.5rem);--lh-tight:1.25;--lh-base:1.7;--lh-loose:1.85;--measure-page:52rem;--tracking-title:-0.02em;--r-sm:6px;--r-xs:3px;--r-md:10px;--r-lg:14px;--r-pill:999px;--shadow-1:0 1px 2px rgba(31, 29, 25, 0.05), 0 1px 3px rgba(31, 29, 25, 0.06);--shadow-2:0 2px 4px rgba(31, 29, 25, 0.05), 0 6px 16px rgba(31, 29, 25, 0.08);--dur-fast:120ms;--dur-base:180ms;--dur-slow:280ms;--ease-out:cubic-bezier(0.23, 1, 0.32, 1);--ease-in-out:cubic-bezier(0.77, 0, 0.175, 1);--ease-drawer:cubic-bezier(0.32, 0.72, 0, 1);--lift-sm:1px;--lift-md:2px;--lift-lg:8px;--z-base:0;--z-sticky:10;--z-header:20;--z-raised:5;--z-overlay:30;--z-modal:40;--z-toast:50;--sz-touch:44px;--sz-touch-sm:32px;}:root {--measure-prose:34em;--measure-title:20em;--measure-lede:32em;--measure-pull:30em;--measure-aside:32em;}:root:lang(en), :root:lang(en-US), :root:lang(en-GB), :root:lang(fr), :root:lang(de), :root:lang(es), :root:lang(pt), :root:lang(ru), :root:lang(el), :root:lang(tr), :root:lang(vi) {--measure-prose:68ch;--measure-title:34ch;--measure-lede:62ch;--measure-pull:58ch;--measure-aside:56ch;}:root:lang(ar) {--measure-prose:60ch;--measure-title:30ch;--measure-lede:56ch;--measure-pull:52ch;--measure-aside:50ch;}:root:lang(hi), :root:lang(th), :root:lang(km), :root:lang(my) {--measure-prose:56ch;--measure-title:28ch;--measure-lede:52ch;--measure-pull:48ch;--measure-aside:46ch;}:root:lang(zh), :root:lang(zh-CN), :root:lang(zh-TW), :root:lang(ja), :root:lang(ko) {--measure-prose:34em;--measure-title:20em;--measure-lede:32em;--measure-pull:30em;--measure-aside:32em;}@media screen and (prefers-color-scheme:dark) {:root:not([data-theme="light"]) {--raw-paper:#14171A;--raw-surface:#1C2024;--raw-surface-raised:#232830;--raw-surface-sunken:#1A1D21;--raw-scrollbar-thumb:#6E747E;--raw-scrollbar-thumb-hov:#7D828A;--raw-border-decor:#2A3038;--raw-border-decor-str:#3A424C;--raw-border-control:#8A9099;--raw-text-primary:#E4E7EA;--raw-text-secondary:#AEB6C0;--raw-text-tertiary:#9BA3AD;--raw-text-on-accent:#0D1114;--raw-text-on-solid:#0D1114;--raw-skeleton-bg:#2E343C;--raw-skeleton-shimmer:rgba(255, 255, 255, 0.06);--raw-danger:#E07A6E;--raw-danger-hover:#F09A90;--raw-danger-bg:#341D1A;--raw-success:#6FAA7A;--raw-success-bg:#16241A;--raw-dir-up:#E07A6E;--raw-dir-down:#6FAA7A;--raw-warning:#C99A3F;--raw-warning-bg:#242A31;--raw-info:#5B9BC4;--raw-info-bg:#13242F;--raw-accent:#7AA9DE;--raw-switch-on:#3E6EA8;--raw-accent-hover:#8FB8E6;--raw-accent-soft:#1C2C3D;--raw-tooltip-bg:#232830;--raw-tooltip-fg:#E4E7EA;--raw-tooltip-border:#2A3038;--raw-scrim:rgba(0, 0, 0, 0.62);}}[data-theme="light"] {--raw-accent:#1b4d8f;--raw-accent-hover:#163a6d;--raw-accent-soft:#e6eef7;--raw-border-control:#868b94;--raw-border-decor:#e2e5e9;--raw-border-decor-str:#c8cdd4;--raw-danger:#c0392b;--raw-danger-bg:#fdeeec;--raw-danger-hover:#8f1e17;--raw-dir-down:#2f6b3a;--raw-dir-up:#c0392b;--raw-info:#1d5f8a;--raw-info-bg:#e8f1f8;--raw-paper:#f6f7f8;--raw-scrim:rgba(21, 24, 28, 0.4);--raw-scrollbar-thumb:#7A808A;--raw-scrollbar-thumb-hov:#6E747E;--raw-skeleton-bg:#e9ecef;--raw-skeleton-shimmer:rgba(255, 255, 255, 0.72);--raw-success:#2f6b3a;--raw-success-bg:#eaf2e6;--raw-surface:#ffffff;--raw-surface-raised:#ffffff;--raw-surface-sunken:#f1f3f5;--raw-switch-on:#1b4d8f;--raw-text-on-accent:#ffffff;--raw-text-on-solid:#ffffff;--raw-text-primary:#15181c;--raw-text-secondary:#4f555e;--raw-text-tertiary:#606770;--raw-tooltip-bg:#15181c;--raw-tooltip-border:#15181c;--raw-tooltip-fg:#f6f7f8;--raw-warning:#8a5a00;--raw-warning-bg:#fbf1dd;--accent:var(--raw-accent);--accent-hover:var(--raw-accent-hover);--accent-soft:var(--raw-accent-soft);--border-control:var(--raw-border-control);--border-decor:var(--raw-border-decor);--border-decor-str:var(--raw-border-decor-str);--danger:var(--raw-danger);--danger-bg:var(--raw-danger-bg);--danger-hover:var(--raw-danger-hover);--dir-down:var(--raw-dir-down);--dir-up:var(--raw-dir-up);--info:var(--raw-info);--info-bg:var(--raw-info-bg);--paper:var(--raw-paper);--scrim:var(--raw-scrim);--scrollbar-thumb:var(--raw-scrollbar-thumb);--scrollbar-thumb-hov:var(--raw-scrollbar-thumb-hov);--skeleton-bg:var(--raw-skeleton-bg);--skeleton-shimmer:var(--raw-skeleton-shimmer);--success:var(--raw-success);--success-bg:var(--raw-success-bg);--surface:var(--raw-surface);--surface-raised:var(--raw-surface-raised);--surface-sunken:var(--raw-surface-sunken);--switch-on:var(--raw-switch-on);--text-on-accent:var(--raw-text-on-accent);--text-on-solid:var(--raw-text-on-solid);--text-primary:var(--raw-text-primary);--text-secondary:var(--raw-text-secondary);--text-tertiary:var(--raw-text-tertiary);--tooltip-bg:var(--raw-tooltip-bg);--tooltip-border:var(--raw-tooltip-border);--tooltip-fg:var(--raw-tooltip-fg);--warning:var(--raw-warning);--warning-bg:var(--raw-warning-bg);}@media screen {:root[data-theme="dark"], [data-theme="dark"] {--raw-accent:#7AA9DE;--raw-accent-hover:#8FB8E6;--raw-accent-soft:#1C2C3D;--raw-border-control:#8A9099;--raw-border-decor:#2A3038;--raw-border-decor-str:#3A424C;--raw-danger:#E07A6E;--raw-danger-bg:#341D1A;--raw-danger-hover:#F09A90;--raw-dir-down:#6FAA7A;--raw-dir-up:#E07A6E;--raw-info:#5B9BC4;--raw-info-bg:#13242F;--raw-paper:#14171A;--raw-scrim:rgba(0, 0, 0, 0.62);--raw-scrollbar-thumb:#6E747E;--raw-scrollbar-thumb-hov:#7D828A;--raw-skeleton-bg:#2E343C;--raw-skeleton-shimmer:rgba(255, 255, 255, 0.06);--raw-success:#6FAA7A;--raw-success-bg:#16241A;--raw-surface:#1C2024;--raw-surface-raised:#232830;--raw-surface-sunken:#1A1D21;--raw-switch-on:#3E6EA8;--raw-text-on-accent:#0D1114;--raw-text-on-solid:#0D1114;--raw-text-primary:#E4E7EA;--raw-text-secondary:#AEB6C0;--raw-text-tertiary:#9BA3AD;--raw-tooltip-bg:#232830;--raw-tooltip-border:#2A3038;--raw-tooltip-fg:#E4E7EA;--raw-warning:#C99A3F;--raw-warning-bg:#242A31;--accent:var(--raw-accent);--accent-hover:var(--raw-accent-hover);--accent-soft:var(--raw-accent-soft);--border-control:var(--raw-border-control);--border-decor:var(--raw-border-decor);--border-decor-str:var(--raw-border-decor-str);--danger:var(--raw-danger);--danger-bg:var(--raw-danger-bg);--danger-hover:var(--raw-danger-hover);--dir-down:var(--raw-dir-down);--dir-up:var(--raw-dir-up);--info:var(--raw-info);--info-bg:var(--raw-info-bg);--paper:var(--raw-paper);--scrim:var(--raw-scrim);--scrollbar-thumb:var(--raw-scrollbar-thumb);--scrollbar-thumb-hov:var(--raw-scrollbar-thumb-hov);--skeleton-bg:var(--raw-skeleton-bg);--skeleton-shimmer:var(--raw-skeleton-shimmer);--success:var(--raw-success);--success-bg:var(--raw-success-bg);--surface:var(--raw-surface);--surface-raised:var(--raw-surface-raised);--surface-sunken:var(--raw-surface-sunken);--switch-on:var(--raw-switch-on);--text-on-accent:var(--raw-text-on-accent);--text-on-solid:var(--raw-text-on-solid);--text-primary:var(--raw-text-primary);--text-secondary:var(--raw-text-secondary);--text-tertiary:var(--raw-text-tertiary);--tooltip-bg:var(--raw-tooltip-bg);--tooltip-border:var(--raw-tooltip-border);--tooltip-fg:var(--raw-tooltip-fg);--warning:var(--raw-warning);--warning-bg:var(--raw-warning-bg);}}:root {--sans:-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans SC', 'Noto Sans CJK SC', 'Geeza Pro', 'Segoe UI Historic', sans-serif;--mono:ui-monospace, 'SF Mono', 'Cascadia Mono', 'Segoe UI Mono', 'Roboto Mono', Menlo, Consolas, 'Sarasa Mono SC', 'Microsoft YaHei Mono', monospace;--serif:'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia, 'Songti SC', 'SimSun', 'Noto Serif CJK SC', 'Source Han Serif SC', serif;--tracking-normal:0;}:root:lang(ar), :root:lang(fa), :root:lang(he), :root:lang(ur) {--tracking-normal:0.01em;}
@@ -188,11 +188,13 @@ return String(s).replace(/[&<>"]/g, function (c) {
188
188
  var q = input.value.trim();
189
189
  shown = filter(q).slice(0, 50);
190
190
  if (!shown.length) {
191
+ /* @html-sink escaped — 插值只有 q,且过了 esc() */
191
192
  list.innerHTML = '<li class="combo__empty" role="presentation">' +
192
193
  (q ? '没有匹配「' + esc(q) + '」的选项' : '暂无可选') + '</li>';
193
194
  return;
194
195
  }
195
196
  var ql = q.toLowerCase();
197
+ /* @html-sink escaped — label/value 全部过 esc();list.id / i / picked 是内部量 */
196
198
  list.innerHTML = shown.map(function (o, i) {
197
199
  var picked = tags.indexOf(o.value) >= 0;
198
200
  var label = esc(o.label);
@@ -217,6 +219,7 @@ return String(s).replace(/[&<>"]/g, function (c) {
217
219
  /* ---------- 标签 ---------- */
218
220
  function paintTags() {
219
221
  if (!tagBox) return;
222
+ /* @html-sink escaped — o.label 与 v 都过 esc() */
220
223
  tagBox.innerHTML = tags.map(function (v) {
221
224
  var o = ALL.filter(function (x) { return x.value === v; })[0] || { label: v };
222
225
  /* ⭐ 删除按钮有 aria-label(否则读屏只念"×")*/
@@ -146,7 +146,14 @@ return true;
146
146
  }
147
147
 
148
148
  function paintSummary(s) {
149
- if (summary) summary.innerHTML = s ? ('区间 <b>' + s + '</b>') : '';
149
+
150
+ if (!summary) return;
151
+ summary.textContent = '';
152
+ if (!s) return;
153
+ summary.appendChild(document.createTextNode('区间 '));
154
+ var b = document.createElement('b');
155
+ b.textContent = s;
156
+ summary.appendChild(b);
150
157
  }
151
158
 
152
159
  function emit() {
@@ -186,6 +193,7 @@ presets = presets.filter(function (x) { return opt.presets.indexOf(x.key) >= 0;
186
193
  }
187
194
  var box = root.querySelector('[data-dr-presets]');
188
195
  if (!box) return;
196
+
189
197
  box.innerHTML = presets.map(function (p) {
190
198
  return '<button class="drange__preset" type="button" aria-pressed="false" ' +
191
199
  'data-dr-preset="'+ p.key + '">' + p.label + '</button>';
@@ -148,6 +148,7 @@ box.setAttribute('inert', '');
148
148
 
149
149
  box.setAttribute('role', 'dialog');
150
150
  box.setAttribute('aria-label', opts.label || '');
151
+
151
152
  box.innerHTML =
152
153
  '<span class="popover__arrow" aria-hidden="true"></span>'+
153
154
  '<div class="popover__body">'+ (opts.html || '') + '</div>';
@@ -210,6 +210,7 @@ head.appendChild(closeBtn);
210
210
 
211
211
  var body = document.createElement('div');
212
212
  body.className = 'drawer__body';
213
+
213
214
  if (typeof opt.content === 'string') body.innerHTML = opt.content;
214
215
  else if (opt.content) body.appendChild(opt.content);
215
216
 
@@ -75,6 +75,7 @@ btn.type = 'button';
75
75
  btn.className = 'shell__menu';
76
76
  btn.setAttribute('aria-label', o.label || ('打开' + title));
77
77
  btn.setAttribute('aria-haspopup', 'dialog');
78
+
78
79
  btn.innerHTML = ICON;
79
80
  btn.addEventListener('click', function () { self.open(); });
80
81
  topbar.insertBefore(btn, topbar.firstChild);
@@ -68,8 +68,14 @@ this.btn.addEventListener('click', function () { self.load(); });
68
68
  this.ui.appendChild(this.btn);
69
69
 
70
70
  this.hint = el('p', 'mv__hint');
71
- this.hint.innerHTML = '模型 <code>' + (this.src.split('/').pop() || '—') +
72
- '</code> 会<b>点击后才下载</b>,首屏不占流量。';
71
+
72
+ var fname = el('code', null, this.src.split('/').pop() || '—');
73
+ var strong = el('b', null, '点击后才下载');
74
+ this.hint.appendChild(document.createTextNode('模型 '));
75
+ this.hint.appendChild(fname);
76
+ this.hint.appendChild(document.createTextNode(' 会'));
77
+ this.hint.appendChild(strong);
78
+ this.hint.appendChild(document.createTextNode(',首屏不占流量。'));
73
79
  this.ui.appendChild(this.hint);
74
80
 
75
81
  if (!hasWebGL()) {
@@ -260,6 +266,7 @@ self.raf = requestAnimationFrame(loop);
260
266
  self.ui.innerHTML = '';
261
267
  var cap = el('p', 'mv__cap');
262
268
  var dt = Math.round(((window.performance && performance.now) ? performance.now() : Date.now()) - t0);
269
+
263
270
  cap.innerHTML = '模型 <b>' + fmtKB(bytes || self.lastBytes || 0) + '</b> · 引擎就绪耗时 <b>' + dt + 'ms</b>' +
264
271
  (controls ? ' · 拖动可旋转,滚轮/双指可缩放' : ' · 未加载控制器,无法拖动');
265
272
  self.node.appendChild(cap);
@@ -501,6 +501,7 @@ return String(s == null ? '' : s)
501
501
  legendHtml = '<ul class="splot__legend">' + items.join('') + '</ul>';
502
502
  }
503
503
 
504
+ /* @html-sink internal — svg 由本文件生成,图例名字已过 esc() */
504
505
  el.innerHTML = svg + legendHtml;
505
506
  return {
506
507
  svg: el.firstChild,