agentcss 0.25.4 → 0.25.6

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,147 @@
16
16
 
17
17
  (无)
18
18
 
19
+ ## [0.25.6] - 2026-11-04
20
+
21
+ 这一版**没有改组件、令牌、CSS 或运行时 JS**。改的是「装到手里的包可不可信」:
22
+ 文档链接、依赖来源、包里不该有的临时文件,以及几道"报告通过却什么都没检查"
23
+ 的发布门禁。从 `0.25.5` 升级不需要改代码。
24
+
25
+ ### Security
26
+
27
+ - 依赖锁文件里有一处 `yaml` 仍指向第三方镜像 `registry.npmmirror.com`,
28
+ 而 `npm ci` 按 `resolved` 直取,会绕开仓库钉住的官方源。已改回
29
+ `registry.npmjs.org`,并核对官方 tarball 的 sha512 与锁文件 `integrity` 一致。
30
+ 新增 `lockfile-source-gate`:每个 `resolved` 的 host 都要在白名单里,
31
+ 镜像源、缺 integrity、非 https 都会红。
32
+ - `package.json` 的 `files` 排除项补上临时文件后缀与门禁探针名。
33
+ 之前反向控制留在源码目录里的 `.bak` 备份会被目录级白名单一起收进包;
34
+ 实测加排除前探针进包、加后不进。
35
+ - HTML 注入点的盘点门禁原本只扫五个目录,现在扫描面由 `files` 白名单推导,
36
+ 新增目录自动纳入;已复核 `esm/`、`adapters/` 里没有注入点。
37
+ 两处把 `src` / `value` 拼进 `innerHTML` 的组件改成 DOM 节点组装。
38
+
39
+ ### Fixed
40
+
41
+ - 包内四份文档(README、README.zh-CN、SUPPORT、docs/LAYER-BASELINE)里
42
+ 有八个相对链接指向仓库文件,而这些文件不在发布包里 —— 装了包的人点开就是死链。
43
+ 改为指向公开仓库的同一份文件;新增包视角链接门禁,用
44
+ `npm pack --dry-run --json` 的真实清单逐条验,不再只从仓库视角看。
45
+ - 打印门禁在并发下偶发误报:活标本 iframe 的样式表已挂上、框高却还是初始值,
46
+ 打印版面被裁掉一半,判据把环境抖动报成内容回归。现在要求文档、样式、字体
47
+ 都就绪且**框高装得下内容**,并在切到打印媒体后重新判定。
48
+ - 文档事实门禁抓到 `CONTRIBUTING.md` 写死了门禁条数;改为让条数只由门禁实算。
49
+ `package.json` 里三条 scripts 缩进错乱,以及 README 里几处历史措辞,一并修正。
50
+ - `release-gate` 曾把新加的通配排除判成「指向不存在的东西」;
51
+ 现在字面排除继续查存在,glob 排除只分类、不按当前有没有文件判。
52
+ - 一条写盘的反向控制漏标 `@serial`,与 `dist-fresh` 并发时报出「孤儿产物」。
53
+ 已补标记,并把那个临时探针也挡在包外。
54
+
55
+ ### Changed · 门禁自证(不进 npm 包)
56
+
57
+ 这些是仓库里的门禁与内部台账,装包的人拿不到,但决定了「全绿」值不值得信:
58
+
59
+ - `doc-imports.js` 原来读**仓库**的文档、却在**消费者**侧解析,验错了对象;
60
+ 而且一份文档都没扫到、或抽出来的全是占位符时照样通过。改为读已安装包里的
61
+ 文档,补零视野守卫、显式承诺清单与显式豁免(`CHANGELOG.md` 里记着历史坏路径,
62
+ 不能当成给读者照抄的入口)、未登记文档自动报红,并补了反向控制。
63
+ 实测:往包内 README 注入一条坏路径,旧判据绿、新判据红。
64
+ - `entry-resolve.js` 在 `exports` 不产生任何说明符时打印 `0/0` 却返回通过,
65
+ 还输出「两套条件都能解析到真实文件」这句假结论。现在零输入即红,
66
+ 并补了覆盖传递闭包、import 条件、通配展开的反向控制。
67
+ - `pack-smoke.sh` 把门禁输出用 `tail` 截成两行,扫描规模那行看不见了;
68
+ 现在保留统计行,看到 0 就能怀疑。
69
+ - 内部路线图与待办台账里几处仍写着「尚未发版 / 待远端验证」的活跃状态,
70
+ 按真值更正,历史段落只加注记、不改写。
71
+
72
+ 上述每一项都跑了正向、反向控制与真实发布包验证;发布前全量门禁 0 FAIL。
73
+ 新增的失误与纠正规则登记在内部 `ERRORS.md`(不随包分发)。
74
+
75
+ ---
76
+
77
+ ## [0.25.5] - 2026-11-04
78
+
79
+ ### 新增:`tokens.core.css`(只有变量)与 `base.reset.css`(只有底座)
80
+
81
+ `tokens.css` 一直同时干两件事:定义变量,以及**改整个宿主文档**的三条规则
82
+ (全局 `:focus-visible`、`a/button/label…` 的 `-webkit-tap-highlight-color`、
83
+ `prefers-reduced-motion` 里对 `*` 的 `!important`)。README 的最小示例却把这套组合
84
+ 描述成"零副作用"——嵌进已有站点的人会发现宿主的焦点环与点按反馈被换掉了,
85
+ 而看不出是哪一行。
86
+
87
+ 冻结期不能改 `tokens.css` 的语义,所以只做加法:
88
+
89
+ | 新入口 | 含什么 | 改宿主吗 |
90
+ |---|---|---|
91
+ | `agentcss/tokens.core.css` | 只有变量(含暗色、按语言的字体与字距) | 不会 |
92
+ | `agentcss/base.reset.css` | 只有那三条 | 会 |
93
+ | `agentcss/tokens.css` | 旧路,一个字节没动 | 会(同上) |
94
+
95
+ 两个新文件是**生成物**(`05-audit/split-tokens.py` 从 `tokens.css` 切出来),
96
+ `token-split` 门禁逐字节比对 —— 同一事实不留第二份手写来源(I-7)。
97
+
98
+ **计算样式实测**(light / dark 各一次,同一页面只换引入文件):
99
+
100
+ | | `--paper` | 焦点环 | 点按高亮 |
101
+ |---|---|---|---|
102
+ | core | `#f6f7f8` / `#14171A` | `auto 1px`(浏览器默认) | 保留 `0.18` |
103
+ | tokens.css | `#f6f7f8` / `#14171A` | `solid 2px`(库的) | 被关掉 `0` |
104
+
105
+ 判据 5 条 + 反向控制 4 条。核心一条:**每条顶层规则必须归入 core / reset / library**,
106
+ 归不了类就红 —— 将来有人往 `tokens.css` 加第四条全局规则,会被逼着做一次显式决定。
107
+
108
+ 过程中自己踩的两个坑,都写进了判据:
109
+ - 按选择器前缀归类 ⇒ 暗色令牌在 `@media screen and (prefers-color-scheme: dark)` 里,
110
+ 被判成"库自己的东西",core 里没有暗色;**计算样式实测 dark 下仍返回 `#f6f7f8`**。
111
+ 改成递归判"块里是不是只有自定义属性"。
112
+ - `_inner()` 在原文里找第一个 `{`,而块首注释里就有 `{`(举例 `.wrap { max-width }`)
113
+ ⇒ 切到注释中间,后面全错。索引必须从遮蔽层取。
114
+
115
+ ### 新增:每个 HTML 注入点都要说得清装的是什么(`xss-sink-gate`)
116
+
117
+ 全库 21 处 `.innerHTML` / `.insertAdjacentHTML`,此前没有任何机制阻止第 22 处悄悄进来,
118
+ `SECURITY.md` 只写了 `combobox` 一处、并称"没有门禁守着"。现在分四类
119
+ (清空 12 / 已转义 3 / 内部量 4 / 有意入口 2),非"赋空串"的必须在紧邻上一行写
120
+ `/* @html-sink <kind> — <理由> */`。
121
+
122
+ 两处**真缺陷**已修:
123
+ - `model-viewer` 把 `src` 的文件名拼进 `innerHTML`。文件名可以是
124
+ `<img src=x onerror=…>`。改成 `textContent` 组装。载荷实测:注入串只以
125
+ `&lt;img` 出现,`injectedImg=false`、`flag=false`。
126
+ - `date-range` 把 `<input>` 的 `value` 拼进 `innerHTML`,而 `type=date` 在
127
+ Chromium 97 旧内核会回退成文本输入 ⇒ `value` 可为任意串。改成节点组装(摘要文本不变,
128
+ 组件契约门禁仍断言「区间 2026-01-01 → 2026-01-31(31 天)」)。
129
+
130
+ `raw-html` 两处(`popover` 的 `opts.html`、`drawer` 的 `opt.content`)登记在
131
+ `SECURITY.md` 的「HTML 入口」,与代码**双向**核对(陈旧登记也红)。
132
+
133
+ **这道门禁证明不了什么**:它不证明 `esc()` 的实现对,也不覆盖全部数据流。
134
+ 它证明的是"新增未声明的注入点必须显式改标记或改文档"。
135
+
136
+ ### 修正:文档里三处正在说谎的地方
137
+
138
+ - `CONVENTIONS.md` 写「`!important` 现有 10 处(无障碍 9 + `[hidden]` 1)」,
139
+ 门禁实测 **13 处**:多出来的 2 处是 `@media print` —— 文档连**类别**都没列。
140
+ 按文档去审计的人会把这两处正当的写成"说不清为什么要赢"。改成类别表 + 「要数字跑门禁」。
141
+ - `README` 说「`components.json` 里标 `beta` 的那几个组件」,实际 **40/40 stable**。
142
+ 改成讲清四条机械判据,并写明 `stable` 量的是机制完备度、不是"久经生产"。
143
+ - `README` 安装段还留着「发布成功之后这一行才会换上」的历史段落(上一句已写已发布);
144
+ 源码复制那段编号从 ② 改 ③。
145
+
146
+ ### 体积账本:去掉重复计数,不放宽上限
147
+
148
+ `tokens.core.css` / `base.reset.css` 是 `tokens.css` 的切片,消费者只下载其中一个。
149
+ 三个文件一起计入会把同一批声明算三遍 ⇒ `perf-gate` 排除这两个**已知切片**并在输出里
150
+ 打印说明;`ceiling` / `granted` 一个都没动。
151
+
152
+ ### 验证
153
+
154
+ - 快速层 **120 道 / 0 FAIL**;打包冒烟(含消费者侧文档路径解析)通过。
155
+ - 视觉基线重录,但 **109 张 PNG 逐字节未变**,只有 `_stamp.json` 更新。
156
+ - 反向控制:`xss-sink` 12 条、`token-split` 4 条,每条都证明"该红时真的红"。
157
+
158
+ ---
159
+
19
160
  ## [0.25.4] - 2026-11-03
20
161
 
21
162
  **修订版:把「类型里看不见的那 8 个全局」补上,并让这件事以后有人管。
package/CONTRIBUTING.md CHANGED
@@ -29,9 +29,10 @@ npm run gate # 全量:含浏览器类
29
29
  npm run gate:fast # 只跑静态层,不用装浏览器
30
30
  ```
31
31
 
32
- 全量是 **235 道门禁**(默认 `min(核数, 6)` 路并发,实测 583 秒 / 退出码 0;
33
- 单道耗时累加 1658 秒,并发效率约 2.8×。组件层要起浏览器,还要逐张比 **109 张**视觉截图 ——
34
- 47 浅色 + 47 暗色 + 15 RTL,按平台分集,只比本平台那套)。
32
+ 全量的门禁条数由 `05-audit/check-all.sh` 实算,**不在文档里写死** —— 新增一道门禁
33
+ 就会变,写下来的第二天就是第二个会漂的真值(I-7)。默认 `min(核数, 6)` 路并发;
34
+ 组件层要起浏览器,还要逐张比浅色 / 暗色 / RTL 三套视觉基线(按平台分集,
35
+ 只比本平台那套)。条数、耗时、并发效率一律以**本轮门禁打印的结果**为准。
35
36
  只想确认没改坏静态契约的话用 `gate:fast`,几秒到几分钟。
36
37
 
37
38
  ### 全量为什么**不**在 CI 上跑(这不是缺口,是结论)
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`).
@@ -252,7 +269,7 @@ Two builds ship:
252
269
  The source tree keeps every comment — that is documentation, not payload.
253
270
 
254
271
  **One frozen ceiling.** The whole library is held to a single budget recorded in
255
- [`05-audit/budget.json`](05-audit/budget.json): a hard ceiling, a reserve that absorbs
272
+ [`05-audit/budget.json`](https://github.com/owain323/frontend-lib/blob/master/05-audit/budget.json): a hard ceiling, a reserve that absorbs
256
273
  normal additions *without anyone editing a number*, and a ratchet that tightens the
257
274
  budget when the library shrinks. The rules — and what would have to be true to raise
258
275
  the ceiling — are in [`docs/BUDGET.md`](docs/BUDGET.md).
@@ -273,7 +290,7 @@ not — is in [`docs/STABILITY.md`](docs/STABILITY.md).
273
290
  - **Tested once** (`🔍`) means exactly that: one manual run on a real device, with a full UA and a per-capability result, but **no automated check watching it**. A browser update will not turn anything red. It never counts as verified, and the two never add up.
274
291
  - **Not claimed** means exactly that. A row that has not been tested is not written as supported.
275
292
 
276
- Mobile WebView support is where that distinction bites. Seven entry points have been probed by hand — per-capability results in [`10-review/webview/RESULTS.md`](10-review/webview/RESULTS.md) — while Android System WebView still has **no data at all**. None of the seven was checked for soft-keyboard, safe-area, scroll-bounce or 200 % text behaviour: "15/15 capabilities" is not "the page works on a phone".
293
+ Mobile WebView support is where that distinction bites. Seven entry points have been probed by hand — per-capability results in [`10-review/webview/RESULTS.md`](https://github.com/owain323/frontend-lib/blob/master/10-review/webview/RESULTS.md) — while Android System WebView still has **no data at all**. None of the seven was checked for soft-keyboard, safe-area, scroll-bounce or 200 % text behaviour: "15/15 capabilities" is not "the page works on a phone".
277
294
 
278
295
  ## Theming
279
296
 
@@ -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,12 +276,13 @@ 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
- **一个冻结的上限。** 整套库受一份账本约束([`05-audit/budget.json`](05-audit/budget.json)):
285
+ **一个冻结的上限。** 整套库受一份账本约束([`05-audit/budget.json`](https://github.com/owain323/frontend-lib/blob/master/05-audit/budget.json)):
265
286
  硬天花板 + 一份「常规新增组件不用改任何数字」的储备 + 一个只在体积下降时收紧的棘轮。
266
287
  规则、以及「什么情况才允许抬天花板」,在 [`docs/BUDGET.md`](docs/BUDGET.md)。
267
288
 
@@ -435,7 +456,7 @@ bash 05-audit/check-all.sh # 全量门禁约 130 项,本机约 8 分钟
435
456
  `🔍` 是「在真实设备上人工试过一次,之后没人再看」——
436
457
  浏览器升级只会被前者发现。逐项结果、完整 UA 与复核方法见
437
458
  [`docs/LAYER-BASELINE.md`](docs/LAYER-BASELINE.md) 与
438
- [`10-review/webview/RESULTS.md`](10-review/webview/RESULTS.md)。
459
+ [`10-review/webview/RESULTS.md`](https://github.com/owain323/frontend-lib/blob/master/10-review/webview/RESULTS.md)。
439
460
 
440
461
  ⚠️ 定位里写「移动 Web」指的是**设计取向**(触控优先、命中区不小于 44px),
441
462
  不代表已被自动化覆盖,更不代表页面在真机上就能用:
@@ -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 是**必需的**(不是增强)
package/SUPPORT.md CHANGED
@@ -97,9 +97,9 @@ grep 一条都数不到),还有 `skip "…"`(这台机器跑不了的门
97
97
 
98
98
  ### 试过一次的(🔍 人工实测,无回归保护)
99
99
 
100
- 7 行数据来自在真实设备上打开 [`capability-probe.html`](10-review/webview/capability-probe.html)。
100
+ 7 行数据来自在真实设备上打开 [`capability-probe.html`](https://github.com/owain323/frontend-lib/blob/master/10-review/webview/capability-probe.html)。
101
101
  完整 UA 与逐项结论在 [`docs/LAYER-BASELINE.md`](docs/LAYER-BASELINE.md);
102
- 15 项能力分别是什么、怎么自己复跑一遍,在 [`10-review/webview/RESULTS.md`](10-review/webview/RESULTS.md)。
102
+ 15 项能力分别是什么、怎么自己复跑一遍,在 [`10-review/webview/RESULTS.md`](https://github.com/owain323/frontend-lib/blob/master/10-review/webview/RESULTS.md)。
103
103
  本节 7 行**全部是 `🔍`** —— 没有一行有自动化守着。
104
104
 
105
105
  | 入口 | UA 自述的环境 | 实测 | 缺什么 |
@@ -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.6",
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(否则读屏只念"×")*/