agentcss 0.27.0 → 0.27.1

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.
@@ -86,6 +86,17 @@
86
86
  max(env(safe-area-inset-left, 0px), var(--sp-4));
87
87
  }
88
88
 
89
+ /* 纸面视口高度不等于一屏,屏幕安全区与末尾留白也不属于正文。
90
+ 实测长金融报告:仅去掉 padding-block-end,8 页(末页全空白)→ 7 页,
91
+ 3689 个非空白字符保持不变;只去掉首端内边距或 min-height 都不能修它。
92
+ 保留首端留白与左右内边距,避免顺手改变整份文档的分页位置。 */
93
+ @media print {
94
+ .page {
95
+ min-height: 0;
96
+ padding-block-end: 0;
97
+ }
98
+ }
99
+
89
100
  /* ============================================================================
90
101
  1b. 盒模型(`border-box`)—— 只在 .page 范围内生效
91
102
  ----------------------------------------------------------------------------
@@ -170,3 +170,12 @@
170
170
  from { opacity: 0; }
171
171
  to { opacity: 1; }
172
172
  }
173
+
174
+ /* Ctrl+P 可能落在淡入首帧;纸不是时间线。本块必须排在动画块之后(同特异性靠顺序)。 */
175
+ @media print {
176
+ .tabs__panel--enter {
177
+ animation: none;
178
+ transition: none;
179
+ opacity: 1;
180
+ }
181
+ }
package/AGENTS.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  - **Contract (machine-readable):** `ai/components.json` — 40 components with root class, slots, variants, states, ARIA, file paths, and **`printPolicy`** (what happens to this component on paper: `omit` / `expand` / `reveal` / `keep-together` / `allow-break`, plus a `pagination` flag). Query it; never guess class names — and never assume a component prints as it looks on screen.
4
4
  - **Known failure modes:** `ai/pitfalls.json`, or `npx agentcss ai pitfalls --json --component=<id>`.
5
- - **Print / PDF:** `npx agentcss ai pdf <page.html> --out page.pdf [--paper=a4|letter|a5] [--margin="18mm 15mm"] [--max-pages=N]`. `@page` belongs to the exporter, never to the library. To hold a document to paper standards use `ai check <doc.json> --medium=paper` — it then requires `pageBudget` and refuses `pagination:"server"` components.
5
+ - **Print / PDF:** `npx agentcss ai pdf <page.html> --out page.pdf [--paper=a3|a4|a5|letter|legal] [--margin="18mm 15mm"] [--max-pages=N] [--wait-ms=800] [--ready-selector="#report-ready"]`. Wait for load + fonts; use a visible ready selector for asynchronous content. A timeout or browser failure never replaces an existing PDF. `@page` belongs to the exporter, never to the library. For paper validation use `ai check <doc.json> --medium=paper` — it requires `pageBudget` and refuses `pagination:"server"` components.
6
6
  - **Query:** `npx agentcss ai components --json [--maturity=stable]` · `ai tokens --json [--set=dark]` · `ai tree --json`.
7
7
  - **Validate, then branch on the exit code:** `npx agentcss ai validate <file> --schema=components --json`. Every subcommand accepts `--json`; failures print exactly one document `{"ok":false,"command","error":{"kind","message"}}`. Codes are one table for the whole CLI: `0` pass · `1` what you checked is bad (a verdict) · `2` bad flag or value — unknown flag, unknown enum value, empty value and a repeated flag all land here · `3` environment missing (no browser / no `node`) · `4` render failed · `5` over budget · `6` tool bug. A typo can never degrade into "empty result, exit 0".
8
8
  - **Check the page you actually wrote:** `npx agentcss ai check <page.html> [--recipe=report/finance]`. Static — no network, no browser. It reports class names the library does not define, stylesheets that were never linked (naming the file that defines the class), missing `img alt` / table name / `th scope`, skipped heading levels, and the per-layout obligations in `ai/recipes.json`. `npx agentcss ai scaffold <recipe> --out <page.html>` gives a minimal shell that already passes.
package/CHANGELOG.md CHANGED
@@ -18,6 +18,29 @@
18
18
 
19
19
  ## [Unreleased]
20
20
 
21
+ ## [0.27.1] - 2026-11-06
22
+
23
+ ### Added
24
+
25
+ - **`ai pdf` 有了就绪契约:`--wait-ms`(0–60000,默认 800)与
26
+ `--ready-selector=<CSS>`。** 导出顺序固定为:页面 `load` → `document.fonts.ready`
27
+ → 可选可见元素 → 额外等待 → 固定动画终态 → `Page.printToPDF`。
28
+ 选择器在打印媒体下必须可见且有尺寸 —— 只存在但 `display:none` 不算就绪。
29
+ 整个导出默认 60 秒总预算,超时是明确失败(退出码 4),不是"等一下大概就好了"。
30
+
31
+ ### Fixed
32
+
33
+ - **PDF 导出不再把旧文件当成本次成功。** 导出改走共享零依赖 CDP 管道
34
+ (`ai/cdp.js`,截图与 PDF 共用)。浏览器只写暂存产物,先验 `%PDF-` 文件头与页树,
35
+ 再原子替换最终文件;浏览器失败、超时、非法参数或暂存产物无效时,已有 PDF
36
+ 逐字节保留,结论明确为失败。未捕获 JS 异常随 `jsErrors` 返回。
37
+ 超页数预算仍是另一种结果:保留完整的新 PDF 并返回 `over-budget`,绝不静默截断。
38
+ - **纸张不再吞下淡入面板或单独多出空白尾页。** Tabs 在 print 媒体下取消动画/过渡
39
+ 并让 `opacity` 落到 1;`.page` 在 print 媒体下去掉屏幕最小高度与末尾内边距。
40
+ 真实金融长文由 8 页(两页空白)修为 7 页,且每页都有文字。新增延迟内容、旧文件哈希
41
+ 保护、有界超时与真实长文逐页内容、崩溃结论形状与临时文件清理判据(E14–E20)。
42
+ - **修正 AI 入口文档漂移。** 暗色查询统一为 `--set=dark`;区分 `check` 的 HTML/JSON
43
+ 输入与 `diff` 的 JSON 输入;零模型 Benchmark 记录明确为 `3 / UNVERIFIED`。
21
44
 
22
45
  ## [0.27.0] - 2026-11-06
23
46
 
package/README.md CHANGED
@@ -333,10 +333,11 @@ Every component carries a contract covering focus order, keyboard operation, ARI
333
333
 
334
334
  ```bash
335
335
  npm ci
336
- npm run gate # full gate run
337
- npm run gate:fast # fast layer
338
- npm test # unit and behaviour-core tests
339
- npm run dist # rebuild dist/
336
+ npm test # unit and behaviour-core tests
337
+ npm run gate:pre-commit # ~7s: the five checks a one-line change can break
338
+ npm run gate:fast # static layer, no browser needed
339
+ npm run gate # full run: browsers, visual baselines, pack closure
340
+ npm run dist # rebuild dist/
340
341
  ```
341
342
 
342
343
  ## License
package/README.zh-CN.md CHANGED
@@ -421,7 +421,7 @@ python -m http.server 8000 # 在仓库根起静态服务
421
421
 
422
422
  ```bash
423
423
  python -m http.server 8000 & # 🔴 必须先起静态服务:一部分门禁要真浏览器渲染
424
- bash 05-audit/check-all.sh # 全量门禁约 130 项,本机约 8 分钟
424
+ bash 05-audit/check-all.sh # 全量门禁;条数以本轮输出为准,本机约 10 分钟
425
425
  ```
426
426
 
427
427
  每一项的输出是 `PASS` / `FAIL` / `SKIP`,末尾给出失败条数与退出码(全绿为 0)。
@@ -518,14 +518,15 @@ bash 05-audit/check-all.sh # 全量门禁约 130 项,本机约 8 分钟
518
518
 
519
519
  ```bash
520
520
  node ai/cli.js components --maturity=stable # 列出达到 stable 的组件
521
- node ai/cli.js tokens --mode=dark # 列出暗色令牌
522
- node ai/cli.js check doc.json # 校验一份**契约文档**(读 JSON)
521
+ node ai/cli.js tokens --set=dark # 列出暗色令牌
522
+ node ai/cli.js check doc.json # 校验契约计划(读 JSON)
523
+ node ai/cli.js check page.html # 静态检查真实页面(读 HTML)
523
524
  node ai/cli.js diff a.json b.json # 两份契约文档按语义 id 比对
524
525
  ```
525
526
 
526
- > ⚠️ `check` / `diff` 吃的是 **JSON 契约文档**,**不解析原始 HTML**。
527
- > 想校验一个真实页面,请先用契约格式描述它,或直接跑 `npm run gate`
528
- > (那里有真浏览器,能验的是渲染结果而不是源文件文本)。
527
+ > `check` 按计划与产物分流:JSON 查节点、组件档位和打印义务;HTML 查类名、
528
+ > 样式表链接与页面结构。两者都是静态检查,不启动浏览器。`diff` 只吃 JSON;
529
+ > 真浏览器的渲染回归由 `npm run gate` 负责。
529
530
 
530
531
  - `ai/components.json` / `ai/tokens.json` 由源码**生成**(`--check` 已接门禁)
531
532
  ⇒ 改了源码忘记重新生成就红。真值只有一份,不靠"记得同步"。
@@ -556,16 +557,19 @@ node ai/cli.js diff a.json b.json # 两份契约文档按语义 id
556
557
  ## 开发
557
558
 
558
559
  ```bash
559
- npm ci # 按锁文件安装
560
- npm test # 单元测试(约 100ms)
561
- npm run test:types # 类型契约:正例必过 + 反例必挂
562
- npm run gate:fast # 静态检查(约 45 秒)
563
- npm run gate # 全量检查(含浏览器)
560
+ npm ci # 按锁文件安装
561
+ npm test # 单元测试(约 1 秒)
562
+ npm run test:types # 类型契约:正例必过 + 反例必挂
563
+ npm run gate:pre-commit # 提交前档:5 道最容易被一行改动打破的判据(约 7 秒)
564
+ npm run gate:fast # 静态层:不装浏览器也能跑(约 3.5 分钟)
565
+ npm run gate # 全量:含浏览器、视觉基线与打包闭包(约 10 分钟)
564
566
  npm run ai -- check path/to/doc.json # 机器可读契约的命令行(读 JSON 文档)
565
567
  npm run benchmark # 兼容性基准
566
568
  ```
567
569
 
568
- 改代码时跑 `gate:fast` 即可;**提交前**再跑一次 `gate`。
570
+ 三档是**递进**关系,不是三选一:改代码时 `gate:pre-commit` 随手跑,
571
+ 推送前 `gate:fast`,发版前 `gate`。**提交前档绿 ≠ 可发版** ——
572
+ 它只覆盖"改一行就可能破"的那几道。
569
573
 
570
574
  类型声明在 `types/index.d.ts`。改动它之前先看 `API.md` 的「稳定程度约定」。
571
575
 
package/START-HERE.md CHANGED
@@ -47,7 +47,7 @@
47
47
  | 进度条 / 环 | `02-primitives/progress/progress.css` | 228 | — | 有耗时过程要给进度就要 |
48
48
  | 浮层气泡 | `02-primitives/popover/popover.css` | 187 | **必需 345** (`popover.js`) | 要一段信息贴着触发元素浮出来就要 |
49
49
  | 日期区间 | `02-primitives/date-range/date-range.css` | 206 | **必需 332** (`date-range.js`) | 要选起止日期就要 |
50
- | 标签页 | `03-patterns/tabs/tabs.css` | 172 | **必需 316** (`tabs.js`) | 同层级内容切换就要 |
50
+ | 标签页 | `03-patterns/tabs/tabs.css` | 181 | **必需 316** (`tabs.js`) | 同层级内容切换就要 |
51
51
  | 折叠面板 | `03-patterns/accordion/accordion.css` | 453 | **必需 389** (`accordion.js`) | 长表单分段就要 |
52
52
  | 动作菜单 | `03-patterns/dropdown/dropdown.css` | 135 | **必需 575** (`dropdown.js`) | 要一组动作(新建 / 导出 / 删除)就要 |
53
53
  | 文字提示 | `03-patterns/tooltip/tooltip.css` | 89 | **必需 299** (`tooltip.js`) | 图标按钮或缩写需要一句话解释就要 |
package/ai/START-HERE.md CHANGED
@@ -58,7 +58,7 @@
58
58
  | 组件清单与成熟度 | `node ai/cli.js components --maturity=stable` |
59
59
  | 某个组件踩过的坑 | `node ai/cli.js pitfalls --component=<id>`(数据在 `ai/pitfalls.json`) |
60
60
  | 写文案 / 做演示 / 发页面时的坑 | `node ai/cli.js pitfalls --scope=writing`(数据在 `ai/pitfalls-writing.json`,不挂组件) |
61
- | 令牌名与取值 | `node ai/cli.js tokens --mode=dark`、`node ai/cli.js tree`(分层视图) |
61
+ | 令牌名与取值 | `node ai/cli.js tokens --set=dark`、`node ai/cli.js tree`(分层视图) |
62
62
  | 组件真实长什么样 | 该组件目录下的 `demo.html` |
63
63
  | 英文一页速览 | `llms.txt` / `llms-full.txt`(发现层;本文件是中文详版) |
64
64
  | 页面怎么搭 | `04-recipes/` 下的示例;要写报告/长文就先 `node ai/cli.js scaffold list` |
package/ai/cdp.js ADDED
@@ -0,0 +1,152 @@
1
+ /*
2
+ * cdp.js — 零依赖 Chrome DevTools Protocol 管道客户端。
3
+ *
4
+ * shot 与 pdf 共用这一份实现。调试走 fd 3/4,以 NUL 分隔 JSON;不开放端口,
5
+ * 不连接使用者的浏览器,也不引入 Puppeteer 运行时依赖。
6
+ */
7
+ 'use strict';
8
+
9
+ var { spawn } = require('child_process');
10
+
11
+ /** 启动一个隔离的 headless Chrome,并返回有界的 CDP 请求接口。 */
12
+ function connect(chromePath, profile) {
13
+ var args = [
14
+ '--headless=new',
15
+ '--disable-gpu',
16
+ '--disable-extensions',
17
+ '--no-first-run',
18
+ '--no-default-browser-check',
19
+ '--hide-scrollbars',
20
+ '--remote-debugging-pipe',
21
+ '--user-data-dir=' + profile,
22
+ 'about:blank'
23
+ ];
24
+ /* 只有明确以 root 在容器中运行时才允许调用方关闭沙箱。 */
25
+ if (process.env.AGENTCSS_NO_SANDBOX === '1') args.unshift('--no-sandbox');
26
+ var child = spawn(chromePath, args, {
27
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
28
+ windowsHide: true
29
+ });
30
+
31
+ var buf = Buffer.alloc(0);
32
+ var pending = Object.create(null);
33
+ var waiters = [];
34
+ var listeners = [];
35
+ var nextId = 0;
36
+ var dead = null;
37
+ var stderr = '';
38
+
39
+ function failAll(err) {
40
+ dead = dead || err;
41
+ Object.keys(pending).forEach(function (k) {
42
+ clearTimeout(pending[k].timer);
43
+ pending[k].reject(dead);
44
+ });
45
+ pending = Object.create(null);
46
+ waiters.splice(0).forEach(function (w) {
47
+ clearTimeout(w.timer);
48
+ w.reject(dead);
49
+ });
50
+ }
51
+
52
+ var commandPipe = child.stdio[3];
53
+ var eventPipe = child.stdio[4];
54
+ if (!commandPipe || !eventPipe) {
55
+ failAll(new Error('无法建立 CDP 管道(Chrome 没能启动)'));
56
+ }
57
+ /* 🔴 必须有 error 监听。浏览器起不来时管道会以 EPIPE/ECONNRESET 结束,
58
+ 而 Node 的流在没有 error 监听时**直接抛出** —— 那就成了"本工具崩了",
59
+ 和"这份 PDF 没导出来"是两件事,也让调用方看不到结构化结论。 */
60
+ if (commandPipe) commandPipe.on('error', function (e) { failAll(e); });
61
+ if (eventPipe) eventPipe.on('error', function (e) { failAll(e); });
62
+
63
+ function dispatch(msg) {
64
+ if (msg.id !== undefined && pending[msg.id]) {
65
+ var p = pending[msg.id];
66
+ delete pending[msg.id];
67
+ clearTimeout(p.timer);
68
+ if (msg.error) p.reject(new Error(p.method + ':' + (msg.error.message || JSON.stringify(msg.error))));
69
+ else p.resolve(msg.result);
70
+ return;
71
+ }
72
+ if (!msg.method) return;
73
+ for (var L = 0; L < listeners.length; L++) {
74
+ if (listeners[L].method === msg.method) listeners[L].fn(msg.params, msg.sessionId);
75
+ }
76
+ for (var i = waiters.length - 1; i >= 0; i--) {
77
+ var w = waiters[i];
78
+ if (w.method === msg.method && (!w.sessionId || w.sessionId === msg.sessionId)) {
79
+ waiters.splice(i, 1);
80
+ clearTimeout(w.timer);
81
+ w.resolve(msg.params);
82
+ }
83
+ }
84
+ }
85
+
86
+ if (eventPipe) eventPipe.on('data', function (chunk) {
87
+ buf = Buffer.concat([buf, chunk]);
88
+ var i;
89
+ while ((i = buf.indexOf(0)) >= 0) {
90
+ var raw = buf.subarray(0, i).toString('utf8');
91
+ buf = buf.subarray(i + 1);
92
+ try { dispatch(JSON.parse(raw)); } catch (e) { /* 忽略不完整的非 JSON 浏览器输出 */ }
93
+ }
94
+ });
95
+ child.stdio[1].on('data', function () { /* Chrome 常规 stdout 不属于协议 */ });
96
+ child.stdio[2].on('data', function (chunk) {
97
+ stderr = (stderr + chunk.toString('utf8')).slice(-4000);
98
+ });
99
+ child.on('error', function (e) { failAll(e); });
100
+ child.on('exit', function (code) {
101
+ failAll(new Error('Chrome 提前退出(退出码 ' + code + ')'));
102
+ });
103
+
104
+ function send(method, params, sessionId, timeoutMs) {
105
+ return new Promise(function (resolve, reject) {
106
+ if (dead) return reject(dead);
107
+ var id = ++nextId;
108
+ var limit = timeoutMs || 15000;
109
+ var timer = setTimeout(function () {
110
+ delete pending[id];
111
+ reject(new Error(method + ' 超时(' + limit + 'ms 没有回应)'));
112
+ }, limit);
113
+ pending[id] = { resolve: resolve, reject: reject, timer: timer, method: method };
114
+ try {
115
+ var message = { id: id, method: method, params: params || {} };
116
+ /* 顶层命令不能带 sessionId:null;Chrome 只接受“字段不存在”或字符串。 */
117
+ if (sessionId !== undefined && sessionId !== null) message.sessionId = sessionId;
118
+ commandPipe.write(JSON.stringify(message) + '\0');
119
+ } catch (e) {
120
+ clearTimeout(timer);
121
+ delete pending[id];
122
+ reject(e);
123
+ }
124
+ });
125
+ }
126
+
127
+ function waitEvent(method, sessionId, timeoutMs) {
128
+ return new Promise(function (resolve, reject) {
129
+ var limit = timeoutMs || 15000;
130
+ var timer = setTimeout(function () {
131
+ var at = waiters.findIndex(function (w) {
132
+ return w.method === method && w.sessionId === sessionId;
133
+ });
134
+ if (at >= 0) waiters.splice(at, 1);
135
+ reject(new Error('等 ' + method + ' 超时(' + limit + 'ms)'));
136
+ }, limit);
137
+ waiters.push({ method: method, sessionId: sessionId,
138
+ resolve: resolve, reject: reject, timer: timer });
139
+ });
140
+ }
141
+
142
+ return {
143
+ send: send,
144
+ waitEvent: waitEvent,
145
+ on: function (method, fn) { listeners.push({ method: method, fn: fn }); },
146
+ close: function () { try { child.kill(); } catch (e) { /* 已退出 */ } },
147
+ stderr: function () { return stderr; },
148
+ child: child
149
+ };
150
+ }
151
+
152
+ module.exports = { connect: connect };
package/ai/cli.js CHANGED
@@ -907,7 +907,7 @@ function cmdPdf(args) {
907
907
  if (!src) {
908
908
  bad('用法:agentcss pdf <页面.html|URL> --out <file.pdf> '
909
909
  + '[--paper=a4|letter|a5|a3|legal] [--margin="18mm 15mm"] '
910
- + '[--max-pages=N] [--chrome=<path>] [--json]');
910
+ + '[--max-pages=N] [--wait-ms=800] [--ready-selector=<CSS>] [--chrome=<path>] [--json]');
911
911
  }
912
912
  var remote = /^https?:\/\//i.test(src);
913
913
  var outFile = flag('out', null);
@@ -923,6 +923,13 @@ function cmdPdf(args) {
923
923
  if (budget !== null && !/^\d+$/.test(String(budget))) {
924
924
  bad('--max-pages 必须是 ≥1 的整数,收到 ' + JSON.stringify(String(budget)));
925
925
  }
926
+ var waitRaw = flag('wait-ms', null);
927
+ if (waitRaw === true) bad('--wait-ms 后面要跟 0–60000 的整数毫秒');
928
+ if (typeof waitRaw === 'string' && !/^\d+$/.test(waitRaw)) {
929
+ bad('--wait-ms 必须是 0–60000 的整数,收到 ' + JSON.stringify(waitRaw));
930
+ }
931
+ var ready = flag('ready-selector', null);
932
+ if (ready === true || ready === '') bad('--ready-selector 必须是非空 CSS 选择器');
926
933
  var chrome = flag('chrome', null);
927
934
  if (chrome === true) bad('--chrome 后面要跟可执行文件路径');
928
935
 
@@ -932,6 +939,8 @@ function cmdPdf(args) {
932
939
  paper: flag('paper', pdfExport.DEFAULT_PAPER),
933
940
  margin: flag('margin', pdfExport.DEFAULT_MARGIN),
934
941
  budget: budget === null ? null : Number(budget),
942
+ waitMs: waitRaw === null ? undefined : Number(waitRaw),
943
+ readySelector: ready,
935
944
  chrome: chrome
936
945
  });
937
946
 
@@ -951,6 +960,9 @@ function cmdPdf(args) {
951
960
  bytes: r.bytes || null,
952
961
  injected: r.injected === undefined ? null : r.injected,
953
962
  chrome: r.chrome || null,
963
+ waitMs: r.waitMs === undefined ? null : r.waitMs,
964
+ readySelector: r.readySelector || null,
965
+ jsErrors: r.jsErrors || [],
954
966
  ms: r.ms || null
955
967
  };
956
968
  if (!r.ok) {
@@ -973,6 +985,11 @@ function cmdPdf(args) {
973
985
  process.stdout.write(' ' + r.pages + ' 页 · ' + report.paperSize + ' · '
974
986
  + (r.margin ? '边距 ' + r.margin : '边距由页面决定')
975
987
  + ' · ' + r.bytes + ' 字节 · ' + r.ms + ' ms\n');
988
+ process.stdout.write(' 就绪:加载与字体完成 + 额外等待 ' + r.waitMs + ' ms'
989
+ + (r.readySelector ? ' + 可见元素 ' + r.readySelector : '') + '\n');
990
+ if (r.jsErrors && r.jsErrors.length) {
991
+ process.stdout.write(' ⚠️ 页面有 ' + r.jsErrors.length + ' 条未捕获 JS 异常,请核对内容\n');
992
+ }
976
993
  process.stdout.write(' 预算 ' + (r.budget === null ? '未设(无界)' : r.budget + ' 页')
977
994
  + ' · ' + (r.injected
978
995
  ? '纸幅 ' + r.paper
@@ -1178,7 +1195,7 @@ var FLAGS = {
1178
1195
  profile: [],
1179
1196
  capabilities: [],
1180
1197
  pitfalls: ['component', 'scope', 'tag'],
1181
- pdf: ['out', 'paper', 'margin', 'max-pages', 'chrome'],
1198
+ pdf: ['out', 'paper', 'margin', 'max-pages', 'wait-ms', 'ready-selector', 'chrome'],
1182
1199
  scaffold: ['out', 'css-prefix'],
1183
1200
  shot: ['out', 'width', 'height', 'full', 'scale', 'wait', 'chrome']
1184
1201
  };
@@ -1231,8 +1248,9 @@ function usage() {
1231
1248
  ' ai profile [--json] 列出三档及其差异\n' +
1232
1249
  ' ai capabilities [--json] 列出可协商的能力\n' +
1233
1250
  ' ai pdf <页面.html|URL> --out <file.pdf> [--paper=a4|letter|a5|a3|legal]\n' +
1234
- ' [--margin="18mm 15mm"] [--max-pages=N] [--chrome=<path>] [--json]\n' +
1235
- ' HTML → PDF。零依赖(用你机器上已装的 Chrome),@page 由导出方给。\n' +
1251
+ ' [--margin="18mm 15mm"] [--max-pages=N] [--wait-ms=800]\n' +
1252
+ ' [--ready-selector=<CSS>] [--chrome=<path>] [--json]\n' +
1253
+ ' 等加载、字体与就绪元素后导出;失败不改写旧 PDF。零依赖,@page 由导出方给。\n' +
1236
1254
  ' ai scaffold <版式> --out <page.html> [--css-prefix=<前缀>] [--json]\n' +
1237
1255
  ' 生成最小壳(正确的骨架 + 必有件位置),不含内容。`list` 看有哪些版式。\n' +
1238
1256
  ' ai shot <页面.html|URL> --out <shot-{width}.png> [--width=390,1440] [--height=900]\n' +
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": "1.0.0",
3
3
  "generatedBy": "05-audit/gen-ai-contract.py",
4
- "libraryVersion": "0.27.0",
4
+ "libraryVersion": "0.27.1",
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,177 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * pdf-renderer.js — exportPdf 的异步子进程;不是公共 CLI。
4
+ * 从 stdin 接收一份 JSON 配置,从 stdout 返回一份 JSON 结论。
5
+ * 浏览器只写工作目录里的暂存 PDF,绝不接触调用方的最终输出路径。
6
+ */
7
+ 'use strict';
8
+
9
+ var fs = require('fs');
10
+ var cdp = require('./cdp.js');
11
+
12
+ var DEFAULT_WAIT = 800;
13
+ var READY_INTERVAL = 50;
14
+
15
+ function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
16
+ function timeoutError(message) {
17
+ var e = new Error(message);
18
+ e.code = 'timeout';
19
+ return e;
20
+ }
21
+
22
+ /* 在 print 媒体下等待一个可见元素。元素只是存在但 display:none 不算就绪。 */
23
+ function readyExpression(selector) {
24
+ return '(function(){try{var e=document.querySelector(' + JSON.stringify(selector) + ');'
25
+ + 'if(!e)return {ok:true,ready:false};var s=getComputedStyle(e);'
26
+ + 'return {ok:true,ready:s.display!=="none"&&s.visibility!=="hidden"&&e.getClientRects().length>0};'
27
+ + '}catch(e){return {ok:false,error:String(e)}}})()';
28
+ }
29
+
30
+ async function render(o) {
31
+ var started = Date.now();
32
+ var budget = o.timeoutMs || 60000;
33
+ var deadline = started + budget;
34
+ function left() { return Math.max(1, deadline - Date.now()); }
35
+ function checkTime(what) {
36
+ if (Date.now() >= deadline) throw timeoutError(what + '超出时间预算 ' + budget + 'ms');
37
+ }
38
+
39
+ var browser = cdp.connect(o.chrome, o.profile);
40
+ var jsErrors = [];
41
+ var timedOut = false;
42
+ var timer = setTimeout(function () {
43
+ timedOut = true;
44
+ browser.close();
45
+ }, budget);
46
+
47
+ try {
48
+ var target = await browser.send('Target.createTarget', { url: 'about:blank' }, null, left());
49
+ var attached = await browser.send('Target.attachToTarget', {
50
+ targetId: target.targetId, flatten: true
51
+ }, null, left());
52
+ var sid = attached.sessionId;
53
+ await browser.send('Page.enable', {}, sid, left());
54
+ await browser.send('Runtime.enable', {}, sid, left());
55
+ browser.on('Runtime.exceptionThrown', function (p) {
56
+ if (jsErrors.length >= 20) return;
57
+ var d = (p && p.exceptionDetails) || {};
58
+ jsErrors.push(String((d.exception && d.exception.description) || d.text || '未捕获异常').split('\n')[0]);
59
+ });
60
+
61
+ var loaded = browser.waitEvent('Page.loadEventFired', sid, left());
62
+ var nav = await browser.send('Page.navigate', { url: o.target }, sid, left());
63
+ if (nav && nav.errorText) {
64
+ var loadError = new Error('页面加载失败(' + nav.errorText + '):' + o.src);
65
+ loadError.code = 'render';
66
+ throw loadError;
67
+ }
68
+ await loaded;
69
+ checkTime('页面加载');
70
+
71
+ /* 先固定打印环境,再判断 ready:print-only 元素也能作为就绪标志。 */
72
+ await browser.send('Emulation.setEmulatedMedia', {
73
+ media: 'print',
74
+ features: [{ name: 'prefers-reduced-motion', value: 'reduce' }]
75
+ }, sid, left());
76
+ var fonts = await browser.send('Runtime.evaluate', {
77
+ expression: 'document.fonts&&document.fonts.ready?document.fonts.ready.then(function(){return true}):true',
78
+ awaitPromise: true, returnByValue: true
79
+ }, sid, left());
80
+ if (fonts.exceptionDetails) {
81
+ throw new Error('等待字体失败:' + (fonts.exceptionDetails.text || 'document.fonts.ready rejected'));
82
+ }
83
+ checkTime('字体加载');
84
+
85
+ if (o.readySelector !== null && o.readySelector !== undefined) {
86
+ for (;;) {
87
+ checkTime('等待 ready-selector ' + JSON.stringify(o.readySelector));
88
+ var ready = await browser.send('Runtime.evaluate', {
89
+ expression: readyExpression(o.readySelector), returnByValue: true
90
+ }, sid, left());
91
+ if (!ready.result || !ready.result.value || !ready.result.value.ok) {
92
+ var invalid = new Error('--ready-selector 不是合法 CSS 选择器:' + o.readySelector);
93
+ invalid.code = 'usage';
94
+ throw invalid;
95
+ }
96
+ if (ready.result.value.ready) break;
97
+ await sleep(Math.min(READY_INTERVAL, left()));
98
+ }
99
+ }
100
+
101
+ var waitMs = o.waitMs === undefined ? DEFAULT_WAIT : o.waitMs;
102
+ if (waitMs) {
103
+ var room = left();
104
+ /* 剩余预算装不下这次等待时,先耗尽它再报 timeout —— 这样报错里的
105
+ "等的是什么 / 还剩多少"和实际发生的事一致,而不是只睡一半就下结论。 */
106
+ if (waitMs > room) {
107
+ await sleep(room);
108
+ throw timeoutError('额外等待 ' + waitMs + 'ms 超出剩余预算 ' + room + 'ms');
109
+ }
110
+ await sleep(waitMs);
111
+ }
112
+
113
+ /* 有限动画落到终态,无限动画取消;同时关掉打印过渡,避免首帧 opacity:0。 */
114
+ var animations = await browser.send('Runtime.evaluate', {
115
+ expression: '(function(){var n=0;document.getAnimations().forEach(function(a){try{'
116
+ + 'var t=a.effect&&a.effect.getComputedTiming?a.effect.getComputedTiming():null;'
117
+ + 'if(t&&isFinite(t.iterations)){a.finish()}else{a.cancel()}n++}catch(e){try{a.cancel()}catch(_){}}});'
118
+ + 'var s=document.createElement("style");s.setAttribute("data-agentcss-print-motion","");'
119
+ + 's.textContent="@media print{*,*::before,*::after{transition:none!important}}";'
120
+ + 'document.head.appendChild(s);return n})()',
121
+ returnByValue: true
122
+ }, sid, left());
123
+ if (animations.exceptionDetails) throw new Error('固定打印动画失败');
124
+
125
+ var printed = await browser.send('Page.printToPDF', {
126
+ printBackground: true,
127
+ preferCSSPageSize: true,
128
+ displayHeaderFooter: false,
129
+ marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0
130
+ }, sid, left());
131
+ checkTime('PDF 生成');
132
+ var buf = Buffer.from(printed.data || '', 'base64');
133
+ if (buf.length < 5 || buf.subarray(0, 5).toString('latin1') !== '%PDF-') {
134
+ throw new Error('浏览器返回的不是 PDF(' + buf.length + ' 字节)');
135
+ }
136
+ fs.writeFileSync(o.stagedPdf, buf);
137
+ return {
138
+ ok: true, stagedPdf: o.stagedPdf, bytes: buf.length, jsErrors: jsErrors,
139
+ waitMs: waitMs, readySelector: o.readySelector || null,
140
+ animations: animations.result ? animations.result.value : null,
141
+ ms: Date.now() - started
142
+ };
143
+ } catch (e) {
144
+ return {
145
+ ok: false, code: timedOut ? 'timeout' : (e.code === 'usage' || e.code === 'timeout' ? e.code : 'render'),
146
+ message: timedOut ? 'PDF 导出超出时间预算 ' + budget + 'ms(页面加载、字体或 ready-selector 未完成)'
147
+ : String(e && e.message || e),
148
+ jsErrors: jsErrors, ms: Date.now() - started,
149
+ stderr: browser.stderr().slice(-800)
150
+ };
151
+ } finally {
152
+ clearTimeout(timer);
153
+ browser.close();
154
+ /* Windows 上 Chrome 刚退出时仍可能持有 profile 文件句柄。 */
155
+ await sleep(150);
156
+ try { fs.rmSync(o.profile, { recursive: true, force: true, maxRetries: 3 }); }
157
+ catch (e) { /* 父进程还会清理整个暂存目录 */ }
158
+ }
159
+ }
160
+
161
+ var input = '';
162
+ process.stdin.setEncoding('utf8');
163
+ process.stdin.on('data', function (s) { input += s; });
164
+ process.stdin.on('end', function () {
165
+ var config;
166
+ try { config = JSON.parse(input); }
167
+ catch (e) {
168
+ process.stdout.write(JSON.stringify({ ok: false, code: 'render', message: '导出子进程收到非法配置' }));
169
+ return;
170
+ }
171
+ render(config).then(function (result) {
172
+ process.stdout.write(JSON.stringify(result));
173
+ }).catch(function (e) {
174
+ process.stdout.write(JSON.stringify({ ok: false, code: 'render',
175
+ message: String(e && e.message || e) }));
176
+ });
177
+ });
package/ai/pdf.js CHANGED
@@ -16,14 +16,14 @@
16
16
  * 所以它只能由"产出这份文件的人"给 —— 这正是本文件存在的理由。
17
17
  *
18
18
  * ============================================================================
19
- * 🔴 为什么用 Chrome 的命令行,而不是 puppeteer
19
+ * 🔴 为什么走共享 CDP 管道,而不是 Puppeteer 或 Chrome 一次性命令行
20
20
  * ============================================================================
21
- * 本库的承诺是**零运行时依赖**(package.json 里 dependencies 是空的)。
22
- * 为了一个"5% 的人才会用"的导出功能塞一个 ~300 MB 的浏览器包,
23
- * 等于让 95% 的人为 5% 的人付安装代价 —— 那是这条承诺唯一的实质内容。
21
+ * 本库保持**零运行时依赖**,使用机器上已装好的 Chrome/Chromium/Edge。
22
+ * `cdp.js` 复用截图已有的调试管道:能等加载、字体和指定元素,再 printToPDF。
23
+ * 一次性 `--print-to-pdf` 无法表达就绪条件,也无法可靠区分新旧产物。
24
24
  *
25
- * 所以:`--print-to-pdf` 走使用者**已经装好的** Chrome/Chromium/Edge。
26
- * 找不到就**明确报错**(`no-chrome`),绝不静默降级成一个空文件。
25
+ * 浏览器只写同目录暂存文件;确认本次成功后才原子替换最终 PDF。任何失败都
26
+ * 明确返回错误并保留旧文件,绝不把上一次产物当成本次成功。
27
27
  *
28
28
  * ============================================================================
29
29
  * ⚠️ 一条必须写下来的观测差异
@@ -202,6 +202,26 @@ function paperSpec(name) {
202
202
  return PAPERS[k] ? { key: k, ...PAPERS[k] } : null;
203
203
  }
204
204
 
205
+ /* 硬杀(Ctrl+C / 断电 / 被任务管理器结束)会留下 `.agentcss-print-*.html`:
206
+ finally 没机会跑。下次导出前顺手清掉**同目录**里超过 6 小时的残留。
207
+ 门槛设长是为了不碰另一个正在导出的进程留下的文件(单次导出是秒级)。 */
208
+ const STALE_TMP_MS = 6 * 3600 * 1000;
209
+
210
+ function sweepStaleTemplates(dir) {
211
+ let names;
212
+ try { names = fs.readdirSync(dir); } catch (e) { return 0; }
213
+ var removed = 0;
214
+ const cutoff = Date.now() - STALE_TMP_MS;
215
+ for (const n of names) {
216
+ if (!/^\.agentcss-print-.*\.html$/.test(n)) continue;
217
+ const p = path.join(dir, n);
218
+ try {
219
+ if (fs.statSync(p).mtimeMs < cutoff) { fs.unlinkSync(p); removed += 1; }
220
+ } catch (e) { /* 别处正在用 / 已删 */ }
221
+ }
222
+ return removed;
223
+ }
224
+
205
225
  /** 注入到 `<head>` 末尾(**必须在使用者样式之后**,否则同特异性被覆盖)。 */
206
226
  function injectSheet(html, css) {
207
227
  const style = '<style id="agentcss-paper" data-agentcss="paper">' + css + '</style>';
@@ -230,7 +250,9 @@ function isRemote(src) {
230
250
  * @param {string} [o.margin] CSS margin 简写(默认 18mm 15mm)
231
251
  * @param {number} [o.budget] 页数上限;超了返回 over-budget
232
252
  * @param {string} [o.chrome] Chrome 可执行文件
233
- * @param {number} [o.timeoutMs]
253
+ * @param {number} [o.waitMs] 就绪后的额外等待,0–60000(默认 800)
254
+ * @param {string} [o.readySelector] 等待一个打印媒体下可见的 CSS 元素
255
+ * @param {number} [o.timeoutMs] 总预算,1000–600000(默认 60000)
234
256
  * @returns {object} `{ok:true, ...}` 或 `{ok:false, code, message, ...}`
235
257
  */
236
258
  function exportPdf(o) {
@@ -254,107 +276,129 @@ function exportPdf(o) {
254
276
  if (o.budget !== undefined && o.budget !== null) {
255
277
  const b = Number(o.budget);
256
278
  if (!Number.isInteger(b) || b < 1) {
257
- /* ⚠️ 用 String() 而不是 JSON.stringify:`JSON.stringify(NaN)` 是 `"null"`,
258
- 于是 `--max-pages=abc` 会报「收到 null」—— 使用者据这句话会去查一个
259
- 根本不存在的 null(实测踩过)。 */
260
279
  return { ok: false, code: 'usage',
261
280
  message: '--max-pages 必须是 ≥1 的整数,收到 ' + String(o.budget) };
262
281
  }
263
282
  }
283
+ const waitMs = o.waitMs === undefined ? 800 : Number(o.waitMs);
284
+ if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 60000) {
285
+ return { ok: false, code: 'usage',
286
+ message: '--wait-ms 必须是 0–60000 的整数,收到 ' + String(o.waitMs) };
287
+ }
288
+ if (o.readySelector !== undefined && o.readySelector !== null
289
+ && (typeof o.readySelector !== 'string' || !o.readySelector.trim())) {
290
+ return { ok: false, code: 'usage', message: '--ready-selector 必须是非空 CSS 选择器' };
291
+ }
292
+ const timeoutMs = o.timeoutMs === undefined ? 60000 : Number(o.timeoutMs);
293
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) {
294
+ return { ok: false, code: 'usage', message: 'timeoutMs 必须是 1000–600000 的整数' };
295
+ }
264
296
 
265
297
  const chrome = resolveChrome(o.chrome);
266
298
  if (chrome.error) return { ok: false, code: 'no-chrome', message: chrome.error };
267
299
 
268
300
  const outAbs = path.resolve(o.out);
269
301
  fs.mkdirSync(path.dirname(outAbs), { recursive: true });
302
+ const staging = fs.mkdtempSync(path.join(path.dirname(outAbs), '.agentcss-pdf-'));
303
+ const profile = path.join(staging, 'profile');
304
+ fs.mkdirSync(profile);
305
+ const stagedPdf = path.join(staging, 'output.pdf');
270
306
 
271
307
  /* 本地文件才注入 @page:远程页面我们拿不到它的字节,硬注入等于假装改了它。 */
272
308
  let target = o.src;
273
309
  let tmpHtml = null;
274
- if (!isRemote(o.src)) {
275
- const srcAbs = path.resolve(o.src);
276
- if (!fs.existsSync(srcAbs)) {
277
- return { ok: false, code: 'usage', message: '找不到输入文件:' + srcAbs };
310
+ let rendered = null;
311
+ let buf = null;
312
+ let info = null;
313
+ try {
314
+ if (!isRemote(o.src)) {
315
+ const srcAbs = path.resolve(o.src);
316
+ if (!fs.existsSync(srcAbs)) {
317
+ return { ok: false, code: 'usage', message: '找不到输入文件:' + srcAbs };
318
+ }
319
+ const html = fs.readFileSync(srcAbs, 'utf8');
320
+ sweepStaleTemplates(path.dirname(srcAbs));
321
+ /* 与输入同目录,页面里的相对样式、字体和图片才不会失效。 */
322
+ tmpHtml = path.join(path.dirname(srcAbs),
323
+ '.agentcss-print-' + process.pid + '-' + Date.now().toString(36) + '.html');
324
+ fs.writeFileSync(tmpHtml, injectSheet(html, cssFor(spec, margin)), 'utf8');
325
+ target = pathToFileURL(tmpHtml).href;
278
326
  }
279
- const html = fs.readFileSync(srcAbs, 'utf8');
280
- /* ⚠️ 临时文件必须落在**同一个目录**:换成系统临时目录,页面里的
281
- `./tokens.css` / `/02-primitives/...` 会全部 404,而产物看起来"只是没样式"。 */
282
- tmpHtml = path.join(path.dirname(srcAbs),
283
- '.agentcss-print-' + process.pid + '-' + Date.now().toString(36) + '.html');
284
- fs.writeFileSync(tmpHtml, injectSheet(html, cssFor(spec, margin)), 'utf8');
285
- target = pathToFileURL(tmpHtml).href;
286
- }
287
-
288
- const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'agentcss-chrome-'));
289
- const args = [
290
- '--headless=new',
291
- '--disable-gpu',
292
- '--disable-extensions',
293
- '--no-first-run',
294
- '--no-default-browser-check',
295
- /* 每次一个新 profile:使用者可能正开着 Chrome,共用 profile 会抢锁 */
296
- '--user-data-dir=' + profile,
297
- /* 页眉页脚(日期 / 标题 / 1/2)默认是开的,必须显式关掉 */
298
- '--no-pdf-header-footer',
299
- '--print-to-pdf-no-header',
300
- '--print-to-pdf=' + outAbs,
301
- target,
302
- ];
303
- /* 容器里以 root 跑才需要关沙箱 —— 默认不开,也不替使用者决定 */
304
- if (process.env.AGENTCSS_NO_SANDBOX === '1') args.unshift('--no-sandbox');
305
327
 
306
- let res;
307
- try {
308
- res = spawnSync(chrome.path, args, {
309
- timeout: o.timeoutMs || 60000,
310
- encoding: 'utf8',
311
- windowsHide: true,
312
- stdio: ['ignore', 'pipe', 'pipe'],
328
+ /* 公共 API 保持同步;异步 CDP 只在独立 Node 子进程中运行。 */
329
+ const res = spawnSync(process.execPath, [path.join(__dirname, 'pdf-renderer.js')], {
330
+ input: JSON.stringify({
331
+ src: o.src, target: target, chrome: chrome.path, profile: profile,
332
+ stagedPdf: stagedPdf, waitMs: waitMs,
333
+ readySelector: o.readySelector === undefined ? null : o.readySelector,
334
+ timeoutMs: timeoutMs,
335
+ }),
336
+ timeout: timeoutMs + 10000,
337
+ encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024,
338
+ stdio: ['pipe', 'pipe', 'pipe'],
313
339
  });
340
+ if (res.error && res.error.code === 'ETIMEDOUT') {
341
+ return { ok: false, code: 'timeout', out: outAbs, ms: Date.now() - t0,
342
+ message: 'PDF 导出子进程没有按时退出;最终文件未被改写' };
343
+ }
344
+ if (res.error || res.status !== 0) {
345
+ return { ok: false, code: 'render', out: outAbs, ms: Date.now() - t0,
346
+ message: 'PDF 导出子进程失败:' + String(res.error || ('退出码 ' + res.status))
347
+ + (res.stderr ? '\n ' + String(res.stderr).slice(-800) : '') };
348
+ }
349
+ try { rendered = JSON.parse(res.stdout); } catch (e) { /* 下方按工具失败处理 */ }
350
+ if (!rendered) {
351
+ return { ok: false, code: 'render', out: outAbs, ms: Date.now() - t0,
352
+ message: 'PDF 导出子进程没有返回合法结果;最终文件未被改写' };
353
+ }
354
+ if (!rendered.ok) {
355
+ /* 子进程的 stagedPdf 在 finally 里已被删掉 —— 不要把它交出去,
356
+ 否则调用方会拿到一个"看着像产物、其实不存在"的路径。 */
357
+ const { stagedPdf: _staged, ...rest } = rendered;
358
+ return { ...rest, out: outAbs, chrome: chrome.path, ms: Date.now() - t0 };
359
+ }
360
+ if (!fs.existsSync(stagedPdf)) {
361
+ return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
362
+ message: '浏览器报告成功但没有生成本次暂存 PDF;最终文件未被改写' };
363
+ }
364
+
365
+ /* 文件头与页树都先在暂存文件上验证,通过后才接触最终路径。 */
366
+ buf = fs.readFileSync(stagedPdf);
367
+ if (buf.length < 5 || buf.slice(0, 5).toString('latin1') !== '%PDF-') {
368
+ return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
369
+ message: '本次暂存产物不是 PDF(' + buf.length + ' 字节);最终文件未被改写' };
370
+ }
371
+ info = pages.inspect(buf);
372
+ if (info.pages < 1) {
373
+ return { ok: false, code: 'unreadable', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
374
+ message: '暂存 PDF 没有可读页对象;最终文件未被改写' };
375
+ }
376
+ try { fs.renameSync(stagedPdf, outAbs); } catch (e) {
377
+ return { ok: false, code: 'render', out: outAbs, chrome: chrome.path, ms: Date.now() - t0,
378
+ message: '新 PDF 已生成,但无法替换输出文件:' + e.message + '。旧文件未被主动改写' };
379
+ }
314
380
  } finally {
315
- /* 清理必须**同步**做完:CLI 随后就 process.exit,挂一个 setTimeout
316
- 等于每次导出都在系统临时目录里留一个 Chrome profile(实测漏了 6 个才发现)。
317
- 删不掉不算错(Windows 上偶发 EBUSY),但不能因为删不掉就不删。 */
318
381
  if (tmpHtml) { try { fs.unlinkSync(tmpHtml); } catch (e) { /* 已删 */ } }
319
- try { fs.rmSync(profile, { recursive: true, force: true, maxRetries: 3 }); }
320
- catch (e) { /* 交给系统清理 */ }
321
- }
322
-
323
- const ms = Date.now() - t0;
324
- const stderr = String((res && res.stderr) || '').slice(-800);
325
- if (res && res.error && res.error.code === 'ETIMEDOUT') {
326
- return { ok: false, code: 'timeout', message: 'Chrome 在 '
327
- + Math.round((o.timeoutMs || 60000) / 1000) + 's 内没有结束(页面可能有死循环或等待网络)', ms };
328
- }
329
- if (!fs.existsSync(outAbs)) {
330
- return { ok: false, code: 'render', ms,
331
- message: 'Chrome 没有产出文件(退出码 ' + (res ? res.status : '?') + ')'
332
- + (stderr ? '\n ' + stderr.trim().split('\n').slice(-4).join('\n ') : '') };
382
+ try { fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3 }); }
383
+ catch (e) { /* Windows 偶发 EBUSY,交给系统清理 */ }
333
384
  }
334
-
335
- const buf = fs.readFileSync(outAbs);
336
- if (buf.length < 5 || buf.slice(0, 5).toString('latin1') !== '%PDF-') {
337
- return { ok: false, code: 'render',
338
- message: '产出的文件不是 PDF(' + buf.length + ' 字节)—— 通常是页面加载失败', ms };
339
- }
340
-
341
- const info = pages.inspect(buf);
342
385
  const injected = !isRemote(o.src);
343
386
  const base = {
344
- out: outAbs, bytes: buf.length, pdf: buf, ms,
345
- /* 远程页面没注入 @page ⇒ `paper` 只能是 null:报 "a4" 是在陈述一个
346
- 没有发生的事实(实测远程导出是 612×792)。真实纸幅看 `mediaBox`。 */
387
+ out: outAbs, bytes: buf.length, pdf: buf, ms: Date.now() - t0,
347
388
  paper: injected ? spec.key : null,
348
389
  margin: injected ? margin : null, chrome: chrome.path,
349
390
  pages: info.pages, mediaBox: info.mediaBox,
350
- injected: injected,
391
+ injected: injected, waitMs: waitMs,
392
+ readySelector: o.readySelector || null,
393
+ jsErrors: rendered.jsErrors || [],
351
394
  };
352
395
 
353
- /* 页数两套说法不一致 ⇒ 不判预算,也不假装通过 */
354
- if (!info.agree && o.budget !== undefined && o.budget !== null) {
396
+ /* 两套读法不一致时,连"这份 PDF 有几页"都不可信 —— 无论有没有预算。
397
+ 只在设了预算时才检查,会让"没设预算"的调用方拿到一个可能是错页数的 ok:true。 */
398
+ if (!info.agree) {
355
399
  return { ...base, ok: false, code: 'unreadable',
356
400
  message: 'PDF 里 `/Type /Page` 数出 ' + info.pages + ' 页、页树 `/Count` 说 '
357
- + info.countFromTree + ' 页 —— 两套说法不一致,无法判定预算' };
401
+ + info.countFromTree + ' 页 —— 两套说法不一致,页数不可信' };
358
402
  }
359
403
 
360
404
  if (o.budget !== undefined && o.budget !== null && info.pages > o.budget) {
@@ -393,4 +437,5 @@ module.exports = {
393
437
  /* 只为可测性导出:浏览器发现逻辑必须在"没有系统 Chrome"的机器上也能被验证,
394
438
  否则它只会在 CI 上坏、而开发机永远绿。 */
395
439
  chromeCandidates,
440
+ sweepStaleTemplates,
396
441
  };
package/ai/shot.js CHANGED
@@ -34,9 +34,9 @@
34
34
  var fs = require('fs');
35
35
  var os = require('os');
36
36
  var path = require('path');
37
- var { spawn } = require('child_process');
38
37
  var { pathToFileURL } = require('url');
39
38
  var pdf = require('./pdf.js');
39
+ var connect = require('./cdp.js').connect;
40
40
 
41
41
  var DEFAULT_WIDTHS = [1440];
42
42
  var DEFAULT_HEIGHT = 900;
@@ -45,114 +45,6 @@ var DEFAULT_WAIT = 800;
45
45
  截断**不静默**:结果里带 truncatedAt,人/Agent 能看见。 */
46
46
  var MAX_FULL_HEIGHT = 10000;
47
47
 
48
- /** 极简 CDP 客户端:一个子进程、两个 fd、以 \0 分隔的 JSON。 */
49
- function connect(chromePath, profile) {
50
- var child = spawn(chromePath, [
51
- '--headless=new',
52
- '--disable-gpu',
53
- '--disable-extensions',
54
- '--no-first-run',
55
- '--no-default-browser-check',
56
- /* 滚动条会占掉 15px 视口,且它本身不是页面内容 */
57
- '--hide-scrollbars',
58
- '--remote-debugging-pipe',
59
- '--user-data-dir=' + profile,
60
- 'about:blank'
61
- ], {
62
- /* fd 0/1/2 给普通输出,3 = 命令(Chrome 读),4 = 事件(Chrome 写) */
63
- stdio: ['ignore', 'pipe', 'pipe', 'pipe', 'pipe'],
64
- windowsHide: true
65
- });
66
-
67
- var buf = Buffer.alloc(0);
68
- var pending = Object.create(null);
69
- var waiters = [];
70
- var listeners = [];
71
- var nextId = 0;
72
- var dead = null;
73
-
74
- function dispatch(msg) {
75
- if (msg.id !== undefined && pending[msg.id]) {
76
- var p = pending[msg.id];
77
- delete pending[msg.id];
78
- clearTimeout(p.timer);
79
- if (msg.error) p.reject(new Error(p.method + ':' + (msg.error.message || JSON.stringify(msg.error))));
80
- else p.resolve(msg.result);
81
- return;
82
- }
83
- if (!msg.method) return;
84
- for (var L = 0; L < listeners.length; L++) {
85
- if (listeners[L].method === msg.method) listeners[L].fn(msg.params, msg.sessionId);
86
- }
87
- for (var i = waiters.length - 1; i >= 0; i--) {
88
- var w = waiters[i];
89
- if (w.method === msg.method && (!w.sessionId || w.sessionId === msg.sessionId)) {
90
- waiters.splice(i, 1);
91
- clearTimeout(w.timer);
92
- w.resolve(msg.params);
93
- }
94
- }
95
- }
96
-
97
- child.stdio[4].on('data', function (chunk) {
98
- buf = Buffer.concat([buf, chunk]);
99
- var i;
100
- while ((i = buf.indexOf(0)) >= 0) {
101
- var raw = buf.subarray(0, i).toString('utf8');
102
- buf = buf.subarray(i + 1);
103
- var msg = null;
104
- try { msg = JSON.parse(raw); } catch (e) { continue; }
105
- dispatch(msg);
106
- }
107
- });
108
- child.stdio[1].on('data', function () { /* 吞掉:Chrome 的正常输出没有信息量 */ });
109
- child.stdio[2].on('data', function () { /* 同上;错误通过 CDP 的 error 字段回来 */ });
110
- child.on('error', function (e) { dead = e; });
111
- child.on('exit', function (code) {
112
- dead = dead || new Error('Chrome 提前退出(退出码 ' + code + ')');
113
- Object.keys(pending).forEach(function (k) {
114
- clearTimeout(pending[k].timer);
115
- pending[k].reject(dead);
116
- });
117
- pending = Object.create(null);
118
- waiters.splice(0).forEach(function (w) { clearTimeout(w.timer); w.reject(dead); });
119
- });
120
-
121
- function send(method, params, sessionId, timeoutMs) {
122
- return new Promise(function (resolve, reject) {
123
- if (dead) return reject(dead);
124
- var id = ++nextId;
125
- var timer = setTimeout(function () {
126
- delete pending[id];
127
- reject(new Error(method + ' 超时(' + (timeoutMs || 15000) + 'ms 没有回应)'));
128
- }, timeoutMs || 15000);
129
- pending[id] = { resolve: resolve, reject: reject, timer: timer, method: method };
130
- child.stdio[3].write(JSON.stringify({
131
- id: id, method: method, params: params || {}, sessionId: sessionId
132
- }) + '\0');
133
- });
134
- }
135
-
136
- function waitEvent(method, sessionId, timeoutMs) {
137
- return new Promise(function (resolve, reject) {
138
- var timer = setTimeout(function () {
139
- var at = waiters.findIndex(function (w) { return w.method === method && w.sessionId === sessionId; });
140
- if (at >= 0) waiters.splice(at, 1);
141
- reject(new Error('等 ' + method + ' 超时(' + (timeoutMs || 15000) + 'ms)'));
142
- }, timeoutMs || 15000);
143
- waiters.push({ method: method, sessionId: sessionId, resolve: resolve, reject: reject, timer: timer });
144
- });
145
- }
146
-
147
- return {
148
- send: send, waitEvent: waitEvent,
149
- /* 常驻监听:异常/控制台消息可能在任意时刻到达,用一次性 waiter 会漏掉第二条起的所有 */
150
- on: function (method, fn) { listeners.push({ method: method, fn: fn }); },
151
- close: function () { try { child.kill(); } catch (e) { /* 已退出 */ } },
152
- child: child
153
- };
154
- }
155
-
156
48
  /** 页面在某个宽度下的量:溢出多少、谁溢出、有多高。 */
157
49
  var PROBE_JS = [
158
50
  '(function(){',
@@ -1 +1 @@
1
- .page {margin:0;min-height:100vh;min-height:100dvh;background:var(--paper);color:var(--text-primary);font-family:var(--sans);font-size:var(--fs-base);line-height:var(--lh-base);-webkit-text-size-adjust:100%;padding-block:calc(env(safe-area-inset-top, 0px) + var(--sp-6)) calc(env(safe-area-inset-bottom, 0px) + var(--sp-6));padding-inline:max(env(safe-area-inset-right, 0px), var(--sp-4)) max(env(safe-area-inset-left, 0px), var(--sp-4));}.page *, .page *::before, .page *::after {box-sizing:border-box;}.page .wrap {margin-inline:auto;max-width:min(var(--measure-page), 100%);}.page .wrap--prose {max-width:min(var(--measure-prose), 100%);}.page h1 {font-family:var(--sans);font-size:var(--fs-2xl);font-weight:500;letter-spacing:var(--tracking-title);line-height:var(--lh-tight);margin:0 0 var(--sp-3);max-width:var(--measure-title);}.page h2 {font-size:var(--fs-lg);margin:var(--sp-7) 0 var(--sp-3);}.page .rule-top {border-top:1px solid var(--border-decor);padding-top:var(--sp-4);}.page .rule-bottom {border-bottom:1px solid var(--border-decor);padding-bottom:var(--sp-2);margin-bottom:var(--sp-4);}.page code {font-family:var(--mono);font-size:0.9em;background:var(--surface-sunken);padding:1px 4px;border-radius:var(--r-xs);}:where(.page) pre {overflow-x:auto;}.page .box {background:var(--surface);border:1px solid var(--border-decor);border-radius:var(--r-md);padding:var(--sp-5);}.page .box.bad {background:var(--surface-sunken);}.page .cmp {display:grid;grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);gap:var(--sp-4);}.page .cmp--wide-first {grid-template-columns:minmax(0, 1.55fr) minmax(0, 1fr);}@media (max-width:640px) {.page .cmp, .page .cmp--wide-first, .page .cmp--clip {grid-template-columns:minmax(0, 1fr);}}.page .cmp__pane {padding:var(--sp-4);border:1px solid var(--border-decor);border-radius:var(--r-md);background:var(--surface-sunken);min-width:0;}.page .cmp--clip .cmp__pane {overflow:hidden;}.page .cmp__pane > h3 {margin:0 0 var(--sp-3);font-size:var(--fs-sm);font-weight:500;}.page .cmp__bad > h3 {color:var(--danger);}.page .cmp__good > h3 {color:var(--success);}.page .cmp__why {margin:var(--sp-3) 0 0;font-size:var(--fs-xs);line-height:1.6;color:var(--text-secondary);}
1
+ .page {margin:0;min-height:100vh;min-height:100dvh;background:var(--paper);color:var(--text-primary);font-family:var(--sans);font-size:var(--fs-base);line-height:var(--lh-base);-webkit-text-size-adjust:100%;padding-block:calc(env(safe-area-inset-top, 0px) + var(--sp-6)) calc(env(safe-area-inset-bottom, 0px) + var(--sp-6));padding-inline:max(env(safe-area-inset-right, 0px), var(--sp-4)) max(env(safe-area-inset-left, 0px), var(--sp-4));}@media print {.page {min-height:0;padding-block-end:0;}}.page *, .page *::before, .page *::after {box-sizing:border-box;}.page .wrap {margin-inline:auto;max-width:min(var(--measure-page), 100%);}.page .wrap--prose {max-width:min(var(--measure-prose), 100%);}.page h1 {font-family:var(--sans);font-size:var(--fs-2xl);font-weight:500;letter-spacing:var(--tracking-title);line-height:var(--lh-tight);margin:0 0 var(--sp-3);max-width:var(--measure-title);}.page h2 {font-size:var(--fs-lg);margin:var(--sp-7) 0 var(--sp-3);}.page .rule-top {border-top:1px solid var(--border-decor);padding-top:var(--sp-4);}.page .rule-bottom {border-bottom:1px solid var(--border-decor);padding-bottom:var(--sp-2);margin-bottom:var(--sp-4);}.page code {font-family:var(--mono);font-size:0.9em;background:var(--surface-sunken);padding:1px 4px;border-radius:var(--r-xs);}:where(.page) pre {overflow-x:auto;}.page .box {background:var(--surface);border:1px solid var(--border-decor);border-radius:var(--r-md);padding:var(--sp-5);}.page .box.bad {background:var(--surface-sunken);}.page .cmp {display:grid;grid-template-columns:minmax(0, 1fr) minmax(0, 1fr);gap:var(--sp-4);}.page .cmp--wide-first {grid-template-columns:minmax(0, 1.55fr) minmax(0, 1fr);}@media (max-width:640px) {.page .cmp, .page .cmp--wide-first, .page .cmp--clip {grid-template-columns:minmax(0, 1fr);}}.page .cmp__pane {padding:var(--sp-4);border:1px solid var(--border-decor);border-radius:var(--r-md);background:var(--surface-sunken);min-width:0;}.page .cmp--clip .cmp__pane {overflow:hidden;}.page .cmp__pane > h3 {margin:0 0 var(--sp-3);font-size:var(--fs-sm);font-weight:500;}.page .cmp__bad > h3 {color:var(--danger);}.page .cmp__good > h3 {color:var(--success);}.page .cmp__why {margin:var(--sp-3) 0 0;font-size:var(--fs-xs);line-height:1.6;color:var(--text-secondary);}
@@ -1 +1 @@
1
- .tabs {display:flex;flex-direction:column;gap:var(--sp-4);}.tabs--manual .tabs__tab[aria-selected="true"] {color:var(--text-secondary);font-weight:400;}@media (hover:hover) and (pointer:fine) {.tabs--manual .tabs__tab[aria-selected="true"]:hover {color:var(--text-primary);}}.tabs__list {display:flex;gap:var(--sp-1);border-bottom:1px solid var(--border-decor);position:relative;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}.tabs__list::-webkit-scrollbar {display:none;}.tabs--vertical {flex-direction:row;gap:var(--sp-4);}.tabs--vertical .tabs__list {flex-direction:column;border-bottom:0;border-inline-end:1px solid var(--border-decor);}.tabs__tab {appearance:none;background:none;border:0;margin-bottom:-1px;padding:var(--sp-3) var(--sp-4);font:inherit;font-size:var(--fs-base);color:var(--text-secondary);cursor:pointer;border-bottom:2px solid transparent;transition:color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);white-space:nowrap;flex:0 0 auto;}.tabs--vertical .tabs__tab {margin-bottom:0;margin-inline-end:-1px;border-bottom:0;border-inline-end:2px solid transparent;text-align:start;}.tabs__tab[aria-selected="true"] {color:var(--text-primary);font-weight:600;border-bottom-color:var(--accent);}.tabs--vertical .tabs__tab[aria-selected="true"] {border-inline-end-color:var(--accent);}@media (hover:hover) and (pointer:fine) {.tabs__tab:not([aria-disabled="true"]):hover {color:var(--text-primary);background:var(--surface-sunken);}}.tabs__tab[aria-disabled="true"] {color:var(--text-tertiary);cursor:not-allowed;}.tabs__tab:focus-visible {outline:2px solid var(--accent);outline-offset:-2px;border-radius:var(--r-sm) var(--r-sm) 0 0;}.tabs__panel {padding:var(--sp-4) 0;}.tabs__panel[hidden] {display:none;}@media (prefers-reduced-motion:no-preference) {.tabs__panel--enter {animation:tabs-fade var(--dur-base) var(--ease-out);}}@keyframes tabs-fade {from {opacity:0;}to {opacity:1;}}
1
+ .tabs {display:flex;flex-direction:column;gap:var(--sp-4);}.tabs--manual .tabs__tab[aria-selected="true"] {color:var(--text-secondary);font-weight:400;}@media (hover:hover) and (pointer:fine) {.tabs--manual .tabs__tab[aria-selected="true"]:hover {color:var(--text-primary);}}.tabs__list {display:flex;gap:var(--sp-1);border-bottom:1px solid var(--border-decor);position:relative;overflow-x:auto;overflow-y:hidden;scrollbar-width:none;}.tabs__list::-webkit-scrollbar {display:none;}.tabs--vertical {flex-direction:row;gap:var(--sp-4);}.tabs--vertical .tabs__list {flex-direction:column;border-bottom:0;border-inline-end:1px solid var(--border-decor);}.tabs__tab {appearance:none;background:none;border:0;margin-bottom:-1px;padding:var(--sp-3) var(--sp-4);font:inherit;font-size:var(--fs-base);color:var(--text-secondary);cursor:pointer;border-bottom:2px solid transparent;transition:color var(--dur-fast) var(--ease-out), border-color var(--dur-fast) var(--ease-out);white-space:nowrap;flex:0 0 auto;}.tabs--vertical .tabs__tab {margin-bottom:0;margin-inline-end:-1px;border-bottom:0;border-inline-end:2px solid transparent;text-align:start;}.tabs__tab[aria-selected="true"] {color:var(--text-primary);font-weight:600;border-bottom-color:var(--accent);}.tabs--vertical .tabs__tab[aria-selected="true"] {border-inline-end-color:var(--accent);}@media (hover:hover) and (pointer:fine) {.tabs__tab:not([aria-disabled="true"]):hover {color:var(--text-primary);background:var(--surface-sunken);}}.tabs__tab[aria-disabled="true"] {color:var(--text-tertiary);cursor:not-allowed;}.tabs__tab:focus-visible {outline:2px solid var(--accent);outline-offset:-2px;border-radius:var(--r-sm) var(--r-sm) 0 0;}.tabs__panel {padding:var(--sp-4) 0;}.tabs__panel[hidden] {display:none;}@media (prefers-reduced-motion:no-preference) {.tabs__panel--enter {animation:tabs-fade var(--dur-base) var(--ease-out);}}@keyframes tabs-fade {from {opacity:0;}to {opacity:1;}}@media print {.tabs__panel--enter {animation:none;transition:none;opacity:1;}}
package/dist/COSTS.md CHANGED
@@ -46,14 +46,14 @@
46
46
  | pattern | `pagination` | 2.9 KB | 0.8 KB | 2.8 KB | **6.5 KB** | 32.7 KB | −26.3 KB |
47
47
  | pattern | `reveal` | 2.9 KB | 0.3 KB | 1.2 KB | **4.4 KB** | 26.4 KB | −22.0 KB |
48
48
  | pattern | `states` | 2.9 KB | 0.9 KB | — | **3.8 KB** | 26.3 KB | −22.5 KB |
49
- | pattern | `tabs` | 2.9 KB | 0.7 KB | 2.0 KB | **5.6 KB** | 29.1 KB | −23.5 KB |
49
+ | pattern | `tabs` | 2.9 KB | 0.7 KB | 2.0 KB | **5.7 KB** | 29.2 KB | −23.5 KB |
50
50
  | pattern | `tooltip` | 2.9 KB | 0.5 KB | 2.0 KB | **5.4 KB** | 28.7 KB | −23.3 KB |
51
51
  | pattern | `tree` | 2.9 KB | 0.6 KB | 3.1 KB | **6.6 KB** | 30.3 KB | −23.7 KB |
52
52
 
53
53
  ## 三、全库
54
54
 
55
55
  - 全部组件都用上(min,含 tokens 一份):**72.6 KB**
56
- - 源码版(带注释)全库 gzip:386.4 KB
56
+ - 源码版(带注释)全库 gzip:386.7 KB
57
57
 
58
58
  > ⚠️ 源码版那个数字**不是**要付的钱:它包含我们的注释(全库 CSS 有 69% 是注释,字节口径)。
59
59
  > 它的用途只有一个 —— 让人看清"dist 到底省了多少"。
@@ -5,7 +5,7 @@
5
5
 
6
6
  | 项目 | 值 |
7
7
  |---|---|
8
- | 声明适用版本 | agentcss `0.27.0` |
8
+ | 声明适用版本 | agentcss `0.27.1` |
9
9
  | 声明日期 | 2026-11-06 |
10
10
  | 复核节奏 | 每次发版前重跑(`npm run gate` 内含全部证据) |
11
11
  | 范围 | 本库交付的 CSS / JS、全部 `demo.html`(组件页 + 图表页 + 组合页)、以及文档站生成的页面 |
package/docs/PRINT.md CHANGED
@@ -258,9 +258,9 @@ node 05-audit/print-gate.js --pdf 某文件.pdf
258
258
  | `print-content-audit.js` | 该印的在不在?(屏幕可见 → 纸上消失?) | 它只在浏览器里量,不读 PDF |
259
259
  | `agentcss pdf` + `pdf-export-gate.js` | **导出成文件**这条路能用吗?(§八) | 它不做视觉比对 |
260
260
 
261
- 前四个判的是"浏览器里按 Ctrl+P",最后一个判的是"导出一份文件" ——
262
- **这是两条不同的渲染路径**(CDP `page.pdf()` vs Chrome 命令行 `--print-to-pdf`),
263
- `@page` 的来源与底色默认都不同。所以前者全绿推不出后者能用,两边各有各的判据。
261
+ 前四个判的是"浏览器里按 Ctrl+P",最后一个判的是"导出一份文件"。两者都走 CDP,
262
+ 但 `@page` 来源、就绪条件、底色参数与输出保护不同;打印策略全绿推不出导出链没有
263
+ 旧文件假成功或异步漏内容,所以两边各有各的判据。
264
264
 
265
265
  `printPolicy` 是**有限枚举**,每个值对应一组能在浏览器里量的断言:
266
266
 
@@ -352,20 +352,36 @@ node 05-audit/print-policy-gate.js --selftest # 反向控制
352
352
  npx agentcss pdf report.html --out report.pdf # 默认 A4 / 18mm 15mm
353
353
  npx agentcss pdf report.html --out report.pdf --paper=letter --margin="20mm"
354
354
  npx agentcss pdf report.html --out report.pdf --max-pages=8
355
+ npx agentcss pdf report.html --out report.pdf --ready-selector="#report-ready" --wait-ms=0
355
356
  ```
356
357
 
357
358
  `--paper` 支持 `a4` / `letter` / `a5` / `a3` / `legal`。边距是 CSS 简写
358
359
  (`18mm`、`"18mm 15mm"`、四段都认)。这两个参数靠**注入一条 `@page` 规则**实现,
359
360
  注入位置在使用者样式之后 —— 否则同特异性会被盖掉(同一个病)。
360
361
 
361
- 零运行时依赖:用使用者机器上**已装好的** Chrome / Chromium / Edge。
362
- 找不到就明确报错(退出码 3),不静默降级成空文件。指定位置:
362
+ 零运行时依赖:用使用者机器上**已装好的** Chrome / Chromium / Edge,经共享 CDP
363
+ 管道调用 `Page.printToPDF`,不打包 Puppeteer。找不到浏览器明确报错(退出码 3):
363
364
 
364
365
  ```bash
365
366
  npx agentcss pdf report.html --out report.pdf --chrome="C:/Program Files/Google/Chrome/Application/chrome.exe"
366
367
  # 或环境变量 AGENTCSS_CHROME / CHROME_PATH
367
368
  ```
368
369
 
370
+ #### 就绪、失败与旧文件保护
371
+
372
+ 导出顺序是:页面 `load` → `document.fonts.ready` → 可选可见元素 → 额外等待 →
373
+ 固定动画终态 → PDF。`--wait-ms` 默认 `800`,可设 `0`,上限 `60000`;它只是额外缓冲,
374
+ **不是任意异步数据已经完成的证明**。动态报告应在数据与图表都渲染完后显示一个元素,
375
+ 再传 `--ready-selector="#report-ready"`;只存在但 `display:none`、不可见或没有尺寸不算就绪。
376
+
377
+ 整个导出默认有 60 秒总预算,超时退出码为 `4`。非法 CSS 选择器、空值与非法等待
378
+ 都是用法错误(退出码 `2`)。未捕获的页面 JS 异常会放进 JSON 的 `jsErrors` 供复核,
379
+ 普通 `console.error` 不会擅自让导出失败。
380
+
381
+ 浏览器只写暂存 PDF;确认本次成功后才原子替换输出文件。浏览器崩溃、就绪超时、
382
+ 非法选择器或产物校验失败时,**已有 PDF 保持不变,结论明确为失败**。超页数预算不同:
383
+ 完整的新 PDF 会保留,退出码 `5`,由作者决定如何拆改,绝不静默截断。
384
+
369
385
  ### 8.2 页数预算:超了报错,不替你截断
370
386
 
371
387
  ```bash
@@ -422,8 +438,8 @@ npx agentcss ai check doc.json --medium=paper
422
438
  ### 8.5 这条路谁守着
423
439
 
424
440
  ```bash
425
- node 05-audit/pdf-export-gate.js # E1–E12 + 三条反向,约 11s,不需要静态服务
426
- node 05-audit/pdf-export-gate.js --selftest # 只跑"必须红"的几条,约 0.3s
441
+ node 05-audit/pdf-export-gate.js # E1–E20,不需要静态服务,约 40s
442
+ node 05-audit/pdf-export-gate.js --selftest # 旧文件保护 / 超时 / 崩溃结论 / 非法参数
427
443
  ```
428
444
 
429
445
  | 判据 | 它问什么 | 实测抓到过什么 |
@@ -436,15 +452,27 @@ node 05-audit/pdf-export-gate.js --selftest # 只跑"必须红"的几条,约 0
436
452
  | E7 | `--margin` 真的影响页数吗 | 不注入 `@page` 时 60mm 与 10mm 页数相同 |
437
453
  | E8/E9″ | 没有浏览器时说人话吗;报错回显原始输入吗 | 退出码不对 / `abc` 被报成 `null` |
438
454
  | E11 | 产物本身合格吗(字体链 / 每页有文本) | 交给 `print-gate --pdf` 的分析器判,不重复实现解析 |
455
+ | E13 | 页树 `/Count` 与 `/Type /Page` 数一致吗 | 分段页树按"第一个"取 ⇒ 50 页读成 8 页 |
456
+ | E14 | ready-selector 等到的是内容,不只是耗时吗 | 延迟新增行的文字 runs 必须增加(48 → 143) |
457
+ | E15 | 真实金融长文每页都有内容吗 | 透明面板与全空白尾页;现为 7 页、每页有文字 |
458
+ | E16 | 浏览器失败会冒用旧 PDF 吗 | 旧文件 SHA-256 必须不变,结论必须失败 |
459
+ | E17 | 就绪条件永不出现会无限等吗 | 有界 timeout,且不碰旧文件 |
460
+ | E18 | 新参数写错会静默按默认值导出吗 | 非法 wait / 空值 / 非法 CSS 都是退出码 2 |
461
+ | E19 | 浏览器起不来时给的是结论还是堆栈 | 去掉管道 error 监听 ⇒ 进程以 EPIPE 崩溃 |
462
+ | E20 | 强杀留下的临时 HTML 会一直堆着吗 | 下次导出清掉 6 小时前的残留,刚生成的不碰 |
439
463
 
440
464
  🔴 E3 与 E7 是同一件事的两面:`@page` 注入是这套东西唯一的技术前提。
441
465
  把它改坏,门禁必须红 —— 实测过:注释掉注入那行,E3 报
442
466
  `a4=612×792pt`(回落成 Letter),E7 报 `10mm=2 页 / 60mm=2 页`。
443
467
 
468
+ E14–E20 则保证“导出链说成功”与“这份产物真是本次、完整、可用”不会脱节。
469
+
444
470
  ### 8.6 不覆盖什么
445
471
 
446
472
  - **远程 URL 不注入 `@page`**:拿不到远程页面的字节,硬注入等于假装改了它。
447
473
  这类导出会明确打印"(远程页面:未注入 @page)",纸幅由页面自己的 CSS 决定。
448
- - **视觉正确**:底色位置、边框粗细、图片裁切 —— 要栅格化比对,没做。
449
- - **页眉页脚 / 页码**:`--no-pdf-header-footer` 已关掉 Chrome 的默认页眉页脚,
450
- 但本工具不生成自己的。需要页码请用 `@page` + `counter(page)` 自己写。
474
+ - **跨机器像素基线**:库内不把字体光栅化差异当成回归;正式交付仍需对实际 PDF
475
+ 逐页栅格化,检查空白、裁切、边框与图表,并做一次实体打印打样。
476
+ - **页眉页脚 / 页码**:导出器显式 `displayHeaderFooter:false`,本工具不生成自己的。
477
+ 需要页码时用浏览器打印对话框的页眉页脚选项,或在自有 CDP/Puppeteer 层提供
478
+ `headerTemplate` / `footerTemplate`;不要假设 `@page + counter(page)` 在 Chrome 可用。
package/index.html CHANGED
@@ -97,7 +97,7 @@ li a:active { background: var(--surface-sunken); }
97
97
  </p>
98
98
  </main>
99
99
  <footer class="wrap">
100
- <p class="note">0.27.0 · 零第三方运行时依赖</p>
100
+ <p class="note">0.27.1 · 零第三方运行时依赖</p>
101
101
  </footer>
102
102
  </body>
103
103
  </html>
package/llms-full.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # agentcss — full reference (one file)
2
2
 
3
- > Generated from `ai/components.json` + `ai/pitfalls*.json` + `ai/behaviors.json` at version **0.27.0**.
3
+ > Generated from `ai/components.json` + `ai/pitfalls*.json` + `ai/behaviors.json` at version **0.27.1**.
4
4
  > Do not edit by hand: run `python 05-audit/gen-llms.py` (freshness is gated).
5
5
  > Short version: `llms.txt`. Machine contract: `ai/*.json`.
6
6
 
@@ -20,7 +20,8 @@ npx agentcss ai check <doc.json> --profile=strict --json # the plan
20
20
  npx agentcss ai check <page.html> --json # the artifact
21
21
  npx agentcss ai scaffold report/finance --out report.html # skeleton only
22
22
  npx agentcss ai shot <page.html> --out shot-{width}.png --width=390,1440 --full
23
- npx agentcss ai pdf <page.html> --out page.pdf # zero deps: your Chrome
23
+ npx agentcss ai pdf <page.html> --out page.pdf --wait-ms=800 # zero deps: your Chrome
24
+ [--ready-selector="#report-ready"] # wait for async content
24
25
  ```
25
26
 
26
27
  Every subcommand accepts `--json`. On failure it prints *one* JSON document —
@@ -56,7 +57,7 @@ and it reports horizontal overflow plus JS errors.
56
57
  | `pagination` | `pagination` | pattern | stable | 7.7 KB | `pagination.js` | 分页控件 | — |
57
58
  | `reveal` | `reveal` | pattern | stable | 4.0 KB | `reveal.js` | 入场动效:一个 data-reveal 属性六个方向,无 JS 时内容全可见 | — |
58
59
  | `states` | `state` | pattern | stable | 10.9 KB | — | 空/加载/错误/成功四种整块状态 | `empty-state-no-decoration` `text-injection-xss` |
59
- | `tabs` | `tabs` | pattern | stable | 5.9 KB | `tabs.js` | 选项卡切换 | — |
60
+ | `tabs` | `tabs` | pattern | stable | 6.1 KB | `tabs.js` | 选项卡切换 | — |
60
61
  | `tooltip` | `tooltip` | pattern | stable | 3.5 KB | `tooltip.js` | 悬停/聚焦提示 | `popover-measure-after-render` `fixed-element-inventory` |
61
62
  | `tree` | `tree` | pattern | stable | 4.0 KB | `tree.js` | 可展开树形结构 | — |
62
63
  | `badge` | `badge` | primitive | stable | 6.1 KB | — | 状态标签:计数、分类、结果标记 | — |
package/llms.txt CHANGED
@@ -25,7 +25,9 @@ npx agentcss ai check page.html --json # check the page you actual
25
25
  npx agentcss ai scaffold report/finance --out report.html # minimal shell (skeleton, no content)
26
26
  npx agentcss ai shot page.html --out shot-{width}.png --width=390,1440 --full
27
27
  # PNG at exact viewport widths
28
- npx agentcss ai pdf page.html --out page.pdf --max-pages=8 # HTML → PDF (zero deps: uses your Chrome)
28
+ npx agentcss ai pdf page.html --out page.pdf --max-pages=8 --ready-selector="#report-ready"
29
+ # waits for load + fonts + visible async content;
30
+ # zero deps; failed renders preserve an old PDF
29
31
  ```
30
32
 
31
33
  `check <doc.json>` and `check <page.html>` answer **different** questions: the JSON one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentcss",
3
- "version": "0.27.0",
3
+ "version": "0.27.1",
4
4
  "description": "移动 Web 组件库:CSS + 可选行为脚本,零第三方运行时依赖。样式用 bundler 引入,交互行为用 script 标签引入。",
5
5
  "keywords": [
6
6
  "css",