dsh-showcase 0.1.2 → 0.1.3

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.
package/CHANGELOG.md CHANGED
@@ -4,8 +4,29 @@
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ### Fixed
8
+
9
+ - Markdown-only 摘要遇到项目外部 symlink 时静默跳过越界记录,不再把外部视口标签写入文档;海报生成仍严格拒绝越界路径。
10
+ - GitHub Actions 改用兼容 Node 24 的官方 Action 版本,避免旧运行时弃用提醒。
11
+ - 浏览器拒绝本地存储或偏好 JSON 损坏时,离线报告保留导出时的明暗、强调色和动效设置;无效设置不会覆盖有效值。
12
+ - 手机端显示设置的操作区域至少为 44px,并保留矮屏滚动边界。
13
+ - 两套主题统一保留测试回执的具体输出;蓝色大肥鱼主题新增可展开的命令输出与截断提示,不再只显示命令摘要。
14
+
15
+ - CLI 与插件共享报告校验和脱敏,覆盖命令、任务说明及多种凭据格式;损坏的旧报告不再被静默覆盖。
16
+ - 使用项目级跨进程写锁与真实路径检查,防止并发产物混写及 junction 越界写入;保留回滚失败时的备份。
17
+ - 修复 Git 重命名、monorepo 子目录、历史比较和 detached HEAD;记录提交 SHA 与工作区指纹,测试后的变更不再遗漏。
18
+ - 状态以实际回执为准;校验截图尺寸、时效、Before/After 和 DPR,并在 HTML 中展示复核警告。
19
+ - 长文本海报与矮屏布局改为内容自适应;HTML 内嵌主题素材,数据与 Markdown 导出实际下载文件。
20
+ - 设置连接失败后可以重试;测试输出保留头尾并标注截断,支持 UTF-8 BOM 配置与混合技术栈检测。
21
+ - 移除未使用的 jsdom,约束 Zod 主版本,更新 Vitest 安全补丁,并扩展 Windows/Linux CI 矩阵。
22
+
7
23
  ### Added
8
24
 
25
+ - 依据终末地官网实际样式校正社区主题:白色 / 中性灰 / 硬黑分区、`#fffa00` 信号黄、局部地形等高线、灰色导航选中态;加载页使用 `#141414` 深底、旋转仪表与黄色横向扫幕,保留可持久化的明暗和动效设置。
26
+ - 复核信息收紧到报告顶部,变更与测试前置,单张 / 双张截图按记录数量适配;保留鱼主题与旧版终末地。
27
+
28
+ - 终末地补齐任务摘要、可切换逐行 Diff、同页同视口前后截图对比、脱敏分类及 JSON / Markdown 下载;演示截图独立采集,不复用鱼主题图片。
29
+ - CLI 可保留经过脱敏的有限文本补丁;旧报告继续兼容,二进制、未跟踪、超限和缺失补丁均有明确说明。
9
30
  - 终末地帝江号与蓝色大肥鱼两套独立海报主题。
10
31
  - DSH 插件设置、Markdown 摘要和可选的自包含 HTML 海报。
11
32
  - 跨项目源码发现、证据时效检查、路径约束和极端输入保护。
@@ -14,6 +35,12 @@
14
35
 
15
36
  ### Changed
16
37
 
38
+ - 加强终末地首屏主视觉:扩大公告式标题、提高等高线存在感,加入黑色结构面与斜切信号黄带,让工业编辑风格形成明确视觉峰值。
39
+ - 终末地交付报告收敛状态色为黑 / 灰 / 信号黄,减少红绿仪表盘噪音;桌面标题、摘要和分区增加留白,移动端阶段导航改为文档流内的吸顶栏,避免遮挡 Diff 与回执。
40
+ - 加强终末地主屏层次:将交付中继融入黑灰标题带,局部复用等高线素材,使用深色命令输出与浅色文件清单,并补充导航、回执和按钮的交互反馈。
41
+ - 终末地演示与插件 HTML 共用交付工作台:单条阶段导航、实际界面证据、变更档案、可展开回执和交付中继;无截图时显示证据缺口,不再展示装饰性拓扑。
42
+ - 增加路径复制、原图下载与自包含 HTML 下载;保留旧版终末地对照入口,不改变蓝色大肥鱼的视觉布局。
43
+ - 修复加载仪表旋转中心和进度编号重叠,保留厚进度条与黄色斜切扫幕;主题纹理加载失败不再阻断演示报告。
17
44
  - 重写 GitHub 首页,明确安装、使用、隐私、素材和发布边界。
18
45
  - 强化终末地帝江号加载器的机械校准环、厚进度轨和黄色切换幕。
19
46
 
package/README.md CHANGED
@@ -21,8 +21,10 @@ dsh-showcase 是一个本地运行的 DSH 插件。它读取目标项目的 Git
21
21
  <details>
22
22
  <summary>终末地帝江号(机械工业 / 等高线 / 黄黑校准)</summary>
23
23
 
24
- <p><img src="docs/assets/dijiang-desktop-latest.png" alt="终末地帝江号桌面 1K 预览,1024 x 640" width="680"></p>
25
- <p><img src="docs/assets/dijiang-mobile-latest.png" alt="终末地帝江号移动端 1K 预览,455 x 1024" width="220"></p>
24
+ <p><img src="docs/assets/dijiang-desktop-latest.png" alt="终末地帝江号当前桌面首屏,1000 x 900" width="680"></p>
25
+ <p><img src="docs/assets/dijiang-mobile-latest.png" alt="终末地帝江号当前移动端首屏,455 x 1024" width="220"></p>
26
+
27
+ 终末地主题采用白、浅灰与硬黑分区,搭配信号黄、局部等高线和细分隔线。左侧阶段导航在手机上收至底部;变更档案与验证回执前置,截图保留完整画面。深色加载页以旋转仪表和信号色横向扫幕进入报告。右上角可切换明暗、谷地黄 / 武陵青、动效及背景帧率上限。这些是社区主题设计,预览中的演示数据不代表目标项目已通过验证。
26
28
  </details>
27
29
 
28
30
  <details>
@@ -45,7 +47,7 @@ dsh-showcase 是一个本地运行的 DSH 插件。它读取目标项目的 Git
45
47
 
46
48
  | 你现在要做的事 | dsh-showcase 的输出 |
47
49
  | --- | --- |
48
- | 向别人说明这次改了什么 | Git 文件清单、增删行统计和当前引用 |
50
+ | 向别人说明这次改了什么 | Git 文件清单、增删行统计、当前引用;终末地报告可切换文件查看保留的逐行 Diff |
49
51
  | 证明测试确实运行过 | 测试命令、退出码、耗时和脱敏后的输出 |
50
52
  | 检查桌面和移动端界面 | 最多 3 张经过路径、时间和主题校验的截图 |
51
53
  | 给交付结果做归档 | Markdown 摘要和可独立打开的 HTML 海报 |
@@ -53,6 +55,10 @@ dsh-showcase 是一个本地运行的 DSH 插件。它读取目标项目的 Git
53
55
 
54
56
  支持前端、后端、CLI、Node.js、Python、Rust、Go、Java、库和静态 HTML 项目。
55
57
 
58
+ 终末地 HTML 还包含任务耗时、增删总量、脱敏分类明细,以及 HTML / JSON / Markdown 下载。同页同视口的 Before / After 截图可用滑块对比。JSON 是脱敏后的报告数据,不包含图片二进制;HTML 内嵌图片,可以离线查看和操作。
59
+
60
+ `capture` 会为前 20 个变更文件尝试采集文本 Diff,每份最多保留 16,000 个字符并标注截断。二进制、未跟踪、超大或无法读取的文件只保留明确的缺失说明;旧报告没有 Diff 时不会生成假差异。补丁仍需人工复核后再对外分享。
61
+
56
62
  工作流只有一条:
57
63
 
58
64
  ```text
@@ -264,12 +270,15 @@ $showcaseRepo = "C:\tools\dsh-showcase"
264
270
 
265
271
  | 检测到的文件 | 默认测试 |
266
272
  | --- | --- |
267
- | `package.json` 中存在 `scripts.test` | `npm test` |
273
+ | `package.json` 中存在 `scripts.test` | 按锁文件选择 `pnpm test`、`yarn test`、`bun run test`,否则 `npm test` |
268
274
  | `pyproject.toml`、`pytest.ini`、`setup.cfg` 或 `requirements.txt` | `pytest -q` |
269
275
  | `Cargo.toml` | `cargo test` |
270
276
  | `go.mod` | `go test ./...` |
271
277
  | `pom.xml` | `mvn test` |
272
278
  | `gradlew.bat` | `gradlew.bat test` |
279
+ | POSIX 系统的 `gradlew` | `./gradlew test` |
280
+
281
+ 混合技术栈会保留各语言的默认命令。自动检测只是初始建议,运行前请检查 `.showcase/config.json`,使用项目实际支持的、非 watch 模式的测试命令。
273
282
 
274
283
  测试也可以单独设置超时:
275
284
 
@@ -289,7 +298,7 @@ $showcaseRepo = "C:\tools\dsh-showcase"
289
298
 
290
299
  ## 截图证据
291
300
 
292
- CLI 会保留 `report.json` 中已经存在的截图记录,但不会自动启动浏览器截图。
301
+ CLI 会保留 `report.json` 中已经存在的截图记录,但不会自动启动浏览器截图,也不会把旧截图时间更新成当前时间。建议先保存代码,再截图,最后运行 `capture` 并生成摘要。
293
302
 
294
303
  前端项目可以用 Playwright、浏览器验收工具或自己的截图脚本,把图片保存在目标项目内,再把记录加入 `report.json.screenshots`:
295
304
 
@@ -297,7 +306,6 @@ CLI 会保留 `report.json` 中已经存在的截图记录,但不会自动启
297
306
  {
298
307
  "id": "desktop-after",
299
308
  "label": "桌面完成态",
300
- "theme": "frontier-signal",
301
309
  "viewport": {
302
310
  "name": "desktop",
303
311
  "width": 1440,
@@ -306,6 +314,8 @@ CLI 会保留 `report.json` 中已经存在的截图记录,但不会自动启
306
314
  "imagePath": "evidence/desktop-after.png",
307
315
  "capturedAt": "2026-08-20T10:00:00.000Z",
308
316
  "kind": "after",
317
+ "deviceScaleFactor": 1,
318
+ "captureMode": "viewport",
309
319
  "url": "http://localhost:4173/"
310
320
  }
311
321
  ~~~
@@ -313,22 +323,43 @@ CLI 会保留 `report.json` 中已经存在的截图记录,但不会自动启
313
323
  截图规则:
314
324
 
315
325
  - `imagePath` 必须位于目标项目内部;
316
- - `theme` 使用 `frontier-signal` 或 `blue-big-fish`;
317
- - 截图主题必须与 DSH 插件当前主题匹配;
326
+ - 普通项目无需填写 `theme`,同一份证据可使用任意海报主题展示;
327
+ - 只有明确区分本项目两套视觉版本时才填写 `theme`,跨主题记录会被隔离;
318
328
  - 缺失、过期、跨主题或不可读的截图不会被当成有效证据;
329
+ - `capturedAt` 应为真实采集时间,不能晚于报告,也不能早于截图文件或已发现源码的修改时间(容差 2 秒);
330
+ - `viewport` 是浏览器 CSS 像素,`deviceScaleFactor` 是截图设备像素比;整屏用 `viewport`、整页用 `full-page`、局部裁剪用 `region`;
331
+ - 实际图片尺寸必须与视口、像素比匹配。局部裁剪和 `before` 图片会单独标注,不能代替改版后整屏验收;
332
+ - 可选 `sourceFingerprint` 记录截图时的 Git 工作区指纹,汇总时会与采集报告比对;
319
333
  - 没有可视界面的项目可以不提供截图。
320
334
 
321
- 截图不是“随便放一张图片”:它必须属于目标项目、采集时间不能晚于报告、格式和尺寸必须可读,而且主题要和当前海报一致。跨主题截图会保留在报告里,但会从当前海报隔离出去。
335
+ 截图不是“随便放一张图片”:路径、格式、像素尺寸和时间都需要通过检查。缺少截图的视觉项目会标为待复核;检查说明同时出现在 Markdown 与 HTML。插件生成的海报最多嵌入 3 张有效截图,优先保留可配对的 Before / After,再补其他视口。
336
+
337
+ 前后对比要求页面 URL、视口尺寸、采集模式及图片比例一致,不能使用局部裁剪。当旧版与新版地址不同但确实是同一页面时,可以给两条记录设置相同的 `comparisonId`。没有可用配对就显示缺失状态。仓库终末地演示页附带 3 张当前视口截图和 1 张本轮修改前截图,不占用目标项目的采集记录。
322
338
 
323
339
  `frontier-signal` 是“终末地帝江号”的内部兼容键,不是第三套主题;普通用户只需要在 DSH 设置中选择中文主题名。
324
340
 
325
341
  ## 工具参数
326
342
 
343
+ ### 换到其他项目
344
+
345
+ 主题只改变交付报告,不会改写目标项目的界面,也不会把本仓库的示例截图、文件名或测试结果带过去。项目名、任务、Diff、测试回执和截图都来自目标项目自己的 `.showcase/report.json`。
346
+
347
+ | 目标项目 | 报告如何展示 |
348
+ | --- | --- |
349
+ | 有界面的前端或静态站点 | 展示该项目的截图、文件改动与命令结果;缺少有效截图时提示复核 |
350
+ | 后端、CLI 或库 | 保留变更与验证回执,明确说明截图非必需 |
351
+ | 没有 Git 的目录 | 使用本地文件模式,不编造提交范围或 Diff,状态保留待复核 |
352
+ | 测试失败或证据缺失 | 展示失败日志或缺失原因,不沿用演示页的成功结果 |
353
+
354
+ 在每个目标项目中分别执行 `init`,检查 `.showcase/config.json` 的任务和验证命令,再执行 `capture`;DSH 工具的 `projectPath` 指向这个目标项目。CLI 会根据常见项目标记提供默认命令,但测试环境和依赖需要在本机可运行;自定义构建系统、单体仓库子项目应填写自己的命令。插件不会自动证明任意项目已通过测试,也不会自动拍摄截图。
355
+
356
+ ### 调用参数
357
+
327
358
  工具名:`showcase_layout_summary`
328
359
 
329
360
  | 参数 | 必填 | 默认值或用途 |
330
361
  | --- | --- | --- |
331
- | `projectPath` | 是 | 目标项目根目录 |
362
+ | `projectPath` | 是 | 目标项目根目录;相对路径基于 DSH 当前会话工作目录,无法获知会话目录时须传绝对路径 |
332
363
  | `reportPath` | 否 | `.showcase/report.json` |
333
364
  | `outputPath` | 否 | `.showcase/layout-summary.md` |
334
365
  | `posterPath` | 否 | `.showcase/layout-poster.html` |
@@ -363,6 +394,14 @@ CLI 会保留 `report.json` 中已经存在的截图记录,但不会自动启
363
394
 
364
395
  检查文件是否存在、路径是否位于目标项目内、`capturedAt` 是否早于图片修改时间,以及截图主题是否匹配。
365
396
 
397
+ ### 测试失败了,为什么原报告写着 completed?
398
+
399
+ 汇总会重新核对回执:非零退出码或失败测试优先标记失败;跳过测试、缺失视觉证据、时效警告会降为待复核。测试执行期间产生的新文件或修改会进入最终 Git 清单,并提示重新验证。
400
+
401
+ ### 提示输出被锁定怎么办?
402
+
403
+ 采集与产物生成使用 `.showcase/.write.lock` 串行写入,避免多个会话互相覆盖。正常完成或取消会释放锁;等待超过 30 秒会报错,不会抢占正在工作的进程。只有确认锁内记录的进程已经退出、没有其他采集任务时,才手动删除遗留锁再重试。损坏的既有 `report.json` 也不会被自动覆盖,应先备份并修正它。
404
+
366
405
  ### 可以用于非前端项目吗?
367
406
 
368
407
  可以。后端、CLI 和库项目不要求截图,仍可生成 Git 与测试回执。
@@ -371,19 +410,25 @@ CLI 会保留 `report.json` 中已经存在的截图记录,但不会自动启
371
410
 
372
411
  - 所有默认产物只写入目标项目的 `.showcase/`;
373
412
  - 没有远程上传步骤;
374
- - 常见 Token、Authorization、GitHub Token 格式和本机绝对路径会尝试脱敏;
413
+ - 测试输出、命令和任务文案会尝试脱敏,包括常见 API Key、Basic/Bearer Authorization、Cookie 与 JSON Token;
375
414
  - 报告最大 2 MiB,单张截图最大 8 MiB、尺寸不超过 16384 px;
376
415
  - 每张海报最多嵌入 3 张有效截图;
377
416
  - 脱敏不是绝对保证,公开分享前仍需人工检查。
417
+ - 路径限制会检查真实路径,拒绝通过符号链接或 Windows junction 写出项目边界;这不是执行任意测试命令的安全沙箱,只运行你信任的项目和配置。
418
+ - 证据检查不是数字签名或完整浏览器渲染验证。源码自动发现最多 4000 个文件、8 层目录,不能替代完整依赖图分析;截图和人工验收仍由使用者负责。
378
419
 
379
420
  ## 从源码开发
380
421
 
422
+ 构建工具要求 Node.js 20.19+、22.12+ 或 24+,建议使用 Node.js 24。安装到 DSH 的插件运行时要求仍为 Node.js 20+。
423
+
381
424
  ~~~powershell
382
425
  npm ci
383
426
  npm run check
384
427
  npm run dev
385
428
  ~~~
386
429
 
430
+ 本地预览:`/?theme=dijiang` 为当前终末地主题,`/?theme=fish` 为蓝色大肥鱼。终末地演示页与插件导出共用渲染器;旧版保留在 `/?theme=dijiang&legacy=1` 供视觉对照,不用于新报告导出。
431
+
387
432
  主要目录:
388
433
 
389
434
  ~~~text
@@ -391,7 +436,7 @@ src/ CLI、报告 schema、Git、命令和脱敏逻辑
391
436
  plugin/ DSH 插件、设置面板、摘要和海报生成器
392
437
  tests/ 单元、跨项目、极端输入和产物测试
393
438
  docs/ 使用、上传、发布和预览素材
394
- .github/workflows/ Node 20/22 CI
439
+ .github/workflows/ Windows/Linux,Node 20/22/24 CI
395
440
  ~~~
396
441
 
397
442
  维护文档:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-showcase",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Local, verifiable delivery evidence reports for coding-agent work.",
5
5
  "type": "module",
6
6
  "main": "./plugin/client-host.js",
@@ -58,7 +58,14 @@
58
58
  "plugin/layout-summary.d.ts",
59
59
  "plugin/layout-summary.js",
60
60
  "plugin/poster-html.js",
61
+ "plugin/dijiang-layout.js",
62
+ "plugin/dijiang-style.js",
63
+ "plugin/dijiang-motion.js",
64
+ "plugin/report-content.js",
61
65
  "plugin/report-schema.js",
66
+ "plugin/project-io.js",
67
+ "plugin/project-io.d.ts",
68
+ "plugin/redaction.js",
62
69
  "plugin/assets/dijiang-survey-surface.webp",
63
70
  "plugin/assets/whale-girl-keyvisual.webp",
64
71
  "cordis.patch.yml",
@@ -84,7 +91,7 @@
84
91
  "dependencies": {
85
92
  "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
86
93
  "@deepseek-ai/schemastery": "^3.18.1",
87
- "zod": "latest"
94
+ "zod": "^4.4.3"
88
95
  },
89
96
  "peerDependencies": {
90
97
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
@@ -97,13 +104,12 @@
97
104
  "@types/react": "latest",
98
105
  "@types/react-dom": "latest",
99
106
  "@vitejs/plugin-react": "latest",
100
- "jsdom": "latest",
101
107
  "lucide-react": "latest",
102
108
  "react": "latest",
103
109
  "react-dom": "latest",
104
110
  "tsx": "latest",
105
111
  "typescript": "latest",
106
112
  "vite": "latest",
107
- "vitest": "latest"
113
+ "vitest": "^4.1.11"
108
114
  }
109
115
  }
package/plugin/client.js CHANGED
@@ -32,6 +32,7 @@ const messages = {
32
32
  failed: "保存失败,请重试。",
33
33
  loading: "正在读取插件设置…",
34
34
  unavailable: "插件设置服务当前不可用。",
35
+ retry: "重新连接",
35
36
  },
36
37
  en: {
37
38
  title: "Layout evidence artifacts",
@@ -51,6 +52,7 @@ const messages = {
51
52
  failed: "Could not save the setting. Try again.",
52
53
  loading: "Loading plugin settings…",
53
54
  unavailable: "Plugin settings are currently unavailable.",
55
+ retry: "Reconnect",
54
56
  },
55
57
  };
56
58
 
@@ -128,13 +130,15 @@ function createThemeCard(rpc) {
128
130
  const [generatePoster, setGeneratePoster] = useState(false);
129
131
  const [loadState, setLoadState] = useState("loading");
130
132
  const [saveState, setSaveState] = useState("idle");
133
+ const [loadAttempt, setLoadAttempt] = useState(0);
131
134
  const mountedRef = useRef(true);
132
135
  const savingRef = useRef(false);
133
136
  const choiceRefs = useRef({});
134
137
  useEffect(() => {
135
138
  let active = true;
136
139
  mountedRef.current = true;
137
- rpc.call(RPC_CHANNEL, "get", {}).then((result) => {
140
+ setLoadState("loading");
141
+ Promise.resolve().then(() => rpc.call(RPC_CHANNEL, "get", {})).then((result) => {
138
142
  if (!active) return;
139
143
  const value = result.ok ? result.value : undefined;
140
144
  const validTheme = value?.theme === "frontier-signal" || value?.theme === "blue-big-fish";
@@ -153,7 +157,7 @@ function createThemeCard(rpc) {
153
157
  active = false;
154
158
  mountedRef.current = false;
155
159
  };
156
- }, []);
160
+ }, [loadAttempt]);
157
161
 
158
162
  if (loadState !== "ready") {
159
163
  const loading = loadState === "loading";
@@ -168,7 +172,12 @@ function createThemeCard(rpc) {
168
172
  className: "dsh-showcase-settings__description",
169
173
  role: "status",
170
174
  "aria-live": "polite",
171
- }, t(loadState))),
175
+ }, t(loadState)),
176
+ !loading && React.createElement("button", {
177
+ type: "button",
178
+ className: "dsh-showcase-settings__choice",
179
+ onClick: () => setLoadAttempt((attempt) => attempt + 1),
180
+ }, t("retry"))),
172
181
  loading && React.createElement("div", { className: "dsh-showcase-settings__body", "aria-hidden": true },
173
182
  React.createElement("div", { className: "dsh-showcase-settings__loading-choices" },
174
183
  ...THEME_VALUES.map((value) => React.createElement("div", {
@@ -0,0 +1,161 @@
1
+ import { findComparisonPair, formatReportDuration, reportMarkdown, sanitizeReportExport, serializeInlineJson } from "./report-content.js";
2
+ import { dijiangConsoleCss } from "./dijiang-style.js";
3
+ import { renderDijiangLoader, dijiangMotionScript } from "./dijiang-motion.js";
4
+
5
+ const escape = (value) => String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
6
+
7
+ const lucideLicense = "ISC License\n\nCopyright (c) 2026 Lucide Icons and Contributors\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHOR DISCLAIMS ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n\n---\n\nThe following Lucide icons are derived from the Feather project:\n\nairplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out\n\nThe MIT License (MIT) (for the icons listed above)\n\nCopyright (c) 2013-present Cole Bemis\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.";
8
+
9
+ // Static Lucide icons keep exported reports self-contained without a React runtime.
10
+ const icons = {
11
+ "arrow": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-arrow-up-right\" aria-hidden=\"true\"><path d=\"M7 7h10v10\"></path><path d=\"M7 17 17 7\"></path></svg>",
12
+ "download": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-download\" aria-hidden=\"true\"><path d=\"M12 15V3\"></path><path d=\"M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4\"></path><path d=\"m7 10 5 5 5-5\"></path></svg>",
13
+ "copy": "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"lucide lucide-copy\" aria-hidden=\"true\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"></rect><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"></path></svg>"
14
+ };
15
+ const isBase64 = (value) => typeof value === "string" && value.length > 0 && value.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(value);
16
+
17
+ export function renderDijiangDocument(data, text, status, parts) {
18
+ const en = data.locale === "en";
19
+ const copy = en ? {
20
+ station: "DELIVERY RELAY", archive: "Evidence archive", changes: "Change register", checks: "Verification receipts", capture: "Visual evidence", output: "Local artifacts", overview: "Delivery report", sample: "DEMO DATA", snapshot: "REPORT SNAPSHOT", warnings: "Review queue", noWarnings: "No review warnings recorded", source: "Report", download: "Download HTML", privacy: "Privacy review", verified: "Retained verification output", count: "Recorded", emptyOutput: "No command output recorded.", full: "Download original capture", copyPath: "Copy path", copied: "Copied", copyFailed: "Copy unavailable; select the path below.", remaining: "More records are available in the source report.", local: "LOCAL ONLY", scope: "Verification scope", jump: "Delivery stages", skip: "Skip to evidence", untimed: "Time not recorded",
21
+ } : {
22
+ station: "交付中继", archive: "证据档案", changes: "变更档案", checks: "验证回执", capture: "界面证据", output: "本地产物", overview: "交付报告", sample: "演示数据", snapshot: "报告快照", warnings: "复核队列", noWarnings: "未记录额外复核事项", source: "采集报告", download: "下载 HTML", privacy: "隐私审查", verified: "已保留的验证输出", count: "记录", emptyOutput: "未记录命令输出。", full: "下载原始截图", copyPath: "复制路径", copied: "已复制", copyFailed: "复制不可用,请选择下方路径文本。", remaining: "其余记录见采集报告。", local: "仅本地生成", scope: "凭据覆盖", jump: "交付阶段", skip: "跳到证据", untimed: "未记录时间",
23
+ };
24
+ const files = Array.isArray(data.gitFiles) ? data.gitFiles : [];
25
+ const tests = Array.isArray(data.testReceipts) ? data.testReceipts : [];
26
+ const screenshots = Array.isArray(data.posterScreenshots) ? data.posterScreenshots.filter((shot) => isBase64(shot?.image) && /^image\/(png|jpeg|webp)$/.test(shot.mimeType)) : [];
27
+ const warnings = Array.isArray(data.freshnessWarnings) ? data.freshnessWarnings : [];
28
+ const gitState = data.gitState ?? (files.length ? "changed" : "clean");
29
+ const testState = data.testState ?? (tests.length ? "configured" : "unconfigured");
30
+ const detailCopy = en ? {
31
+ summary: "Delivery summary", duration: "Duration", delta: "Line changes", views: "Captures", diff: "Line diff",
32
+ missing: "No line diff retained in this report.", binary: "Binary file; no text diff.", untracked: "Untracked file; not included in the Git patch.",
33
+ omitted: "Patch omitted by capture limits.", truncated: "Only part of the output is retained.",
34
+ before: "Before", after: "After", compare: "Before / after", position: "Comparison position",
35
+ noPair: "No before and after captures of the same page and viewport.", privacyEmpty: "No redaction breakdown recorded.",
36
+ json: "Download JSON", markdown: "Download Markdown",
37
+ } : {
38
+ summary: "交付摘要", duration: "任务耗时", delta: "增删总量", views: "截图记录", diff: "逐行差异",
39
+ missing: "报告未保留逐行 Diff。", binary: "二进制文件,不展示文本 Diff。", untracked: "未跟踪文件,尚未纳入 Git 差异。",
40
+ omitted: "受采集大小或数量限制,此文件未保留 Diff。", truncated: "仅保留部分输出,内容已截断。",
41
+ before: "改版前", after: "改版后", compare: "前后对比", position: "对比位置",
42
+ noPair: "尚无相同页面、相同视口的改版前后截图。", privacyEmpty: "未记录脱敏分类明细。",
43
+ json: "下载 JSON", markdown: "下载 Markdown",
44
+ };
45
+ const redactionDetails = Array.isArray(data.redactionDetails) ? data.redactionDetails : [];
46
+ const exportReport = sanitizeReportExport(data.reportData ?? {
47
+ version: 1, generatedAt: data.generatedAt, demo: Boolean(data.demo), project: { name: data.projectName },
48
+ task: { goal: data.task, status: status.key, durationMs: data.durationMs },
49
+ git: { files, summary: { changedFiles: data.fileCount, additions: data.additions, deletions: data.deletions } },
50
+ tests, screenshots: screenshots.map(({ image, ...shot }) => shot),
51
+ redaction: { totalReplacements: data.redactionCount, replacements: Object.fromEntries(redactionDetails.map(item => [item.name, item.count])) },
52
+ warnings,
53
+ });
54
+ const exportPayload = serializeInlineJson({ report: exportReport, markdown: data.summaryMarkdown ?? reportMarkdown(exportReport, data.locale) });
55
+ const totals = { additions: data.additions ?? files.reduce((n, f) => n + (Number(f.additions) || 0), 0), deletions: data.deletions ?? files.reduce((n, f) => n + (Number(f.deletions) || 0), 0) };
56
+ const overview = '<section class="ed-section ed-overview" id="ed-overview"><header class="ed-section-head"><h2>' + detailCopy.summary + '</h2></header><dl><div><dt>' + detailCopy.duration + '</dt><dd>' + formatReportDuration(data.durationMs, data.locale) + '</dd></div><div><dt>' + detailCopy.delta + '</dt><dd class="ed-delta"><b>+' + totals.additions + '</b><i>−' + totals.deletions + '</i></dd></div><div><dt>' + detailCopy.views + '</dt><dd>' + screenshots.length + '</dd></div></dl></section>';
57
+ const diffPanels = files.map((file, index) => {
58
+ const reason = file.diffUnavailable === "binary" ? detailCopy.binary : file.diffUnavailable === "untracked" ? detailCopy.untracked : ["oversize", "limit"].includes(file.diffUnavailable) ? detailCopy.omitted : detailCopy.missing;
59
+ const lines = file.diff === undefined ? '<p class="ed-diff-missing">' + reason + '</p>' : '<pre class="ed-diff-code">' + String(file.diff).split(/\r?\n/).map(line => '<code class="ed-diff-line ' + (line.startsWith("@@") ? "hunk" : line.startsWith("+") && !line.startsWith("+++") ? "addition" : line.startsWith("-") && !line.startsWith("---") ? "deletion" : "") + '">' + escape(line) + '\n</code>').join("") + '</pre>';
60
+ return '<section class="ed-diff-panel" id="ed-diff-' + index + '"' + (index ? ' hidden' : '') + ' aria-label="' + escape(file.path) + ' Diff"><header><h3>' + detailCopy.diff + '</h3><code>' + escape(file.previousPath ? file.previousPath + " → " + file.path : file.path) + '</code></header>' + lines + (file.diffTruncated ? '<p class="ed-more">' + detailCopy.truncated + '</p>' : '') + '</section>';
61
+ }).join("");
62
+ const privacy = '<section class="ed-section ed-privacy" id="ed-privacy"><header class="ed-section-head"><h2>' + copy.privacy + '</h2><span>' + (Number(data.redactionCount) || 0) + ' ' + text.redactions + '</span></header>' + (redactionDetails.length ? '<dl>' + redactionDetails.map(item => '<div><dt>' + escape(item.name) + '</dt><dd>' + (Number(item.count) || 0) + '</dd></div>').join("") + '</dl>' : '<p class="ed-more">' + detailCopy.privacyEmpty + '</p>') + '</section>';
63
+ const pair = findComparisonPair(screenshots);
64
+ const comparison = '<div class="ed-comparison"><h3>' + detailCopy.compare + '</h3>' + (pair ? '<div class="ed-comparison-stage" data-ed-comparison data-before="ed-shot-' + screenshots.indexOf(pair.before) + '" data-after="ed-shot-' + screenshots.indexOf(pair.after) + '"><span class="ed-compare-label">' + detailCopy.before + '</span><span class="ed-compare-label ed-compare-label--after">' + detailCopy.after + '</span><div class="ed-compare-before" data-ed-derived></div><div class="ed-compare-after" data-ed-derived></div><i class="ed-compare-handle" aria-hidden="true"></i></div><label class="ed-compare-control">' + detailCopy.position + '<input type="range" min="0" max="100" value="50" step="1" data-ed-compare-range aria-label="' + detailCopy.position + '"><output>50%</output></label>' : '<p class="ed-more">' + detailCopy.noPair + '</p>') + '</div>';
65
+ const outputs = [[text.manifestReport, data.reportPath], [text.manifestSummary, data.summaryPath], [text.manifestPoster, data.posterPath]].filter(([, path]) => path);
66
+ const targets = ["ed-task", "ed-changes", "ed-tests", "ed-captures", "ed-outputs"];
67
+ const empty = (kind, title, detail) => `<div class="ed-empty" data-empty-state="${kind}"><strong>${escape(title)}</strong><p>${escape(detail)}</p></div>`;
68
+ const fileRows = gitState === "unavailable"
69
+ ? empty("git-unavailable", text.gitUnavailableTitle, text.gitUnavailableDetail)
70
+ : files.length ? `<ol class="ed-files">${files.map((file, index) => `<li><button type="button" data-ed-file="${index}" aria-pressed="${index === 0}" aria-controls="ed-diff-${index}"><span class="ed-index">${String(index + 1).padStart(2, "0")}</span><code>${escape(file.path)}</code><span class="ed-delta"><b>+${Number(file.additions) || 0}</b><i>−${Number(file.deletions) || 0}</i></span></button></li>`).join("")}</ol>`
71
+ : gitState === "clean" ? empty("git-clean", text.gitCleanTitle, text.gitCleanDetail) : empty("git-details-missing", text.gitChangedMissingTitle, text.gitChangedMissingDetail);
72
+ const testRows = tests.length ? `<ul class="ed-receipts">${tests.map((test, index) => {
73
+ const key = test.exitCode !== 0 ? "failed" : ["passed", "failed", "skipped"].includes(test.status) ? test.status : "passed";
74
+ const presentation = text.status[key];
75
+ return `<li data-status="${key}"><details ${index === 0 ? "open" : ""}><summary><span class="ed-receipt-mark" aria-label="${escape(presentation.receipt)}">${presentation.symbol}</span><code>${escape(test.command)}</code><span class="ed-receipt-result">${escape(presentation.receipt)}<small>${escape(test.duration || "")} · ${escape(text.exit)} ${Number(test.exitCode) || 0}</small></span></summary><pre>${escape(test.output || copy.emptyOutput)}</pre>${test.outputTruncated ? `<p class="ed-more">${detailCopy.truncated}</p>` : ""}</details></li>`;
76
+ }).join("")}</ul>` : empty(testState === "unconfigured" ? "tests-unconfigured" : "test-receipts-missing", testState === "unconfigured" ? text.testsUnconfiguredTitle : text.testsMissingTitle, testState === "unconfigured" ? text.testsUnconfiguredDetail : text.testsMissingDetail);
77
+ const excluded = (data.isolatedEvidenceViewports?.length ?? 0) + (data.unclassifiedEvidenceViewports?.length ?? 0) + (data.invalidEvidenceViewports?.length ?? 0);
78
+ const captures = screenshots.length ? `<div class="ed-captures">${screenshots.map((shot, index) => `<details ${shot.kind !== "before" || index === 0 ? "open" : ""}><summary><span>${String(index + 1).padStart(2, "0")}</span>${escape(shot.label)}</summary><figure><div class="ed-shot"><img id="ed-shot-${index}" src="data:${shot.mimeType};base64,${shot.image}" alt="${escape(shot.label)}" loading="${index === 0 ? "eager" : "lazy"}"></div><figcaption>${escape(shot.label)}<button type="button" data-ed-capture="ed-shot-${index}" title="${escape(copy.full)}">${icons.download}${escape(copy.full)}</button></figcaption></figure></details>`).join("")}</div>${excluded ? parts.integrity : ""}` : parts.integrity;
79
+ const captureSection = `<section class="ed-section ed-visual evidence-page" id="ed-captures" aria-label="${escape(copy.capture)}"><header class="ed-section-head"><h2>${escape(copy.capture)}</h2><span>${String(screenshots.length).padStart(2, "0")} / ${String(screenshots.length + excluded).padStart(2, "0")} ${escape(data.visualProject === false && !screenshots.length ? text.captureOptionalMeta : text.captureMatched)}</span></header>${captures}${screenshots.length ? comparison : ""}</section>`;
80
+ const statusAria = `${en ? "Status: " : "状态:"}${status.badge}`;
81
+ const settingsIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sliders-horizontal" aria-hidden="true"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg>';
82
+ const preferences = `<details class="ed-settings"><summary aria-label="${en ? "Display settings" : "显示设置"}" title="${en ? "Display settings" : "显示设置"}">${settingsIcon}</summary><div class="ed-preferences"><h2>${en ? "Display settings" : "显示设置"}</h2>
83
+ <fieldset><legend>${en ? "Appearance" : "明暗"}</legend><div class="ed-options"><label><input type="radio" name="ed-palette" value="light" data-ed-pref="palette" checked><span>${en ? "Light" : "亮色"}</span></label><label><input type="radio" name="ed-palette" value="dark" data-ed-pref="palette"><span>${en ? "Dark" : "暗色"}</span></label></div></fieldset>
84
+ <fieldset><legend>${en ? "Signal color" : "信号色"}</legend><div class="ed-options"><label><input type="radio" name="ed-accent" value="yellow" data-ed-pref="accent" checked><span><i class="ed-swatch ed-swatch--yellow"></i>${en ? "Valley yellow" : "谷地黄"}</span></label><label><input type="radio" name="ed-accent" value="cyan" data-ed-pref="accent"><span><i class="ed-swatch ed-swatch--cyan"></i>${en ? "Wuling cyan" : "武陵青"}</span></label></div></fieldset>
85
+ <label class="ed-option-row">${en ? "Full motion" : "完整动效"}<input type="checkbox" data-ed-pref="motion" checked></label>
86
+ <label class="ed-option-row">${en ? "Moving contours" : "动态等高线"}<input type="checkbox" data-ed-pref="background" checked></label>
87
+ <label class="ed-option-row">${en ? "Background frame cap" : "背景帧率上限"}<select data-ed-pref="fps"><option value="24">24 FPS</option><option value="60">60 FPS</option><option value="120">120 FPS</option></select></label>
88
+ <label class="ed-option-row">${en ? "Background speed" : "背景速度"}<input type="range" min="0.5" max="2" step="0.5" value="1" data-ed-pref="speed"><output data-ed-speed-output>1x</output></label>
89
+ </div></details>`;
90
+ const station = `<section class="ed-station field-readout" aria-label="${escape(copy.station)}"><header><span>${escape(copy.station)}</span><small>DIJIANG / LOCAL</small></header><p class="ed-station-code">${escape(status.fieldKicker)}</p><div class="ed-verdict" role="status" aria-label="${escape(statusAria)}"><span aria-hidden="true">${escape(status.symbol)}</span><strong>${escape(status.badge)}</strong></div><p class="ed-outcome" aria-label="${escape((en ? "Status: " : "状态:") + status.deliverable)}">${escape(status.deliverable)}</p><div class="ed-metrics">${parts.metrics}</div><footer><span>${escape(copy.privacy)}</span><b>${Number(data.redactionCount) || 0} ${escape(text.redactions)}</b></footer></section>`;
91
+ const reportMeta = `<dl class="ed-meta"><div><dt>${en ? "REVISION" : "比较范围"}</dt><dd>${escape(gitState === "unavailable" ? text.gitUnavailableMeta : data.gitRange || text.noRecords)}</dd></div><div><dt>${en ? "CAPTURED" : "报告时间"}</dt><dd>${escape(data.generatedAt || copy.untimed)}</dd></div></dl>`;
92
+ const body = `<a class="ed-skip" href="#ed-evidence">${escape(copy.skip)}</a>
93
+ <article class="poster dijiang-console" data-theme="frontier-signal" data-status="${status.key}">
94
+ <header class="ed-mast"><a class="ed-brand" href="#ed-task"><b>DS</b><span>${escape(text.fieldTheme)}<small>DSH / DIJIANG</small></span></a><div class="ed-mast-meta"><span>${escape(copy.local)}</span><span>${escape(data.demo ? copy.sample : copy.snapshot)}</span>${preferences}</div></header>
95
+ <nav class="ed-stages current-route" aria-label="${escape(copy.jump)}">${data.stages.map((stage, index) => `<a href="#${targets[index]}" ${index === 0 ? 'aria-current="location"' : ""}><b>${String(index + 1).padStart(2, "0")}</b><span>${escape(stage)}</span><i aria-hidden="true">${icons.arrow}</i></a>`).join("")}</nav>
96
+ <div class="ed-bridge"><canvas class="ed-contours" aria-hidden="true"></canvas><div class="ed-shell">
97
+ <header class="ed-heading" id="ed-task"><div><p class="ed-heading-code">${escape(copy.overview)} <span>// ${escape(status.fieldKicker)}</span></p><h1>${escape(data.projectName)}</h1><p class="ed-task">${escape(data.task)}</p></div>${station}</header>
98
+ </div></div>
99
+ <div class="ed-shell">
100
+ <div class="ed-workspace" id="ed-evidence">
101
+ <aside class="ed-side">
102
+ <section class="ed-review" aria-label="${escape(copy.warnings)}"><header><h2>${escape(copy.warnings)}</h2><span>${String(warnings.length).padStart(2, "0")}</span></header>${warnings.length ? `<ul>${warnings.map((warning) => `<li>${escape(warning)}</li>`).join("")}</ul>` : `<p>${escape(copy.noWarnings)}</p>`}</section>
103
+ ${reportMeta}
104
+ <div class="ed-scope"><span>${escape(copy.scope)}</span><p>${escape(text.scopeGit)} · ${escape(text.scopeTests)} · ${escape(data.visualProject === false ? text.scopeScreensOptional : text.scopeScreens)}</p></div>
105
+ </aside>
106
+ <div class="ed-main">
107
+ ${overview}
108
+ <section class="route-evidence delivery-manifest ed-ledger" data-status="${status.key}" data-git-state="${escape(gitState)}" data-test-state="${escape(testState)}" data-output-state="${outputs.length ? "available" : "empty"}" aria-label="${escape(copy.archive)}">
109
+ <div class="ed-section" id="ed-changes"><header class="ed-section-head"><h2>${escape(copy.changes)}</h2><span>${String(Number(data.fileCount) || files.length).padStart(2, "0")} ${escape(copy.count)}</span></header>${fileRows}${diffPanels}${data.fileCount > files.length ? `<p class="ed-more">${escape(copy.remaining)}</p>` : ""}</div>
110
+ <div class="ed-section" id="ed-tests"><header class="ed-section-head"><h2>${escape(copy.checks)}</h2><span>${Number(data.passedTests) || 0} / ${Number(data.testCount) || 0}</span></header>${testRows}${data.testCount > tests.length ? `<p class="ed-more">${escape(copy.remaining)}</p>` : ""}</div>
111
+ </section>
112
+ ${captureSection}
113
+ ${privacy}
114
+ <section class="ed-section ed-outputs" id="ed-outputs"><header class="ed-section-head"><h2>${escape(copy.output)}</h2><div class="ed-export-actions"><button type="button" data-ed-download title="${escape(copy.download)}">${icons.download}${escape(copy.download)}</button><button type="button" data-ed-export="json" aria-label="${detailCopy.json}" title="${detailCopy.json}">${icons.download}JSON</button><button type="button" data-ed-export="md" aria-label="${detailCopy.markdown}" title="${detailCopy.markdown}">${icons.download}Markdown</button></div></header>${outputs.length ? `<dl>${outputs.map(([label, path]) => `<div><dt>${escape(label)}</dt><dd><code>${escape(path)}</code><button type="button" data-ed-copy="${escape(path)}" aria-label="${escape(copy.copyPath)}: ${escape(path)}" title="${escape(copy.copyPath)}">${icons.copy}</button></dd></div>`).join("")}</dl>` : empty("outputs-missing", text.outputsMissingTitle, text.outputsMissingDetail)}<p class="ed-notice" role="status" aria-live="polite"></p></section>
115
+ </div>
116
+ </div><footer class="ed-footer"><span>${escape(text.footer)}</span><span>DSH / ${escape(copy.archive)}</span></footer>
117
+ </div>
118
+ </article>`;
119
+ return `<!doctype html><html lang="${data.locale}" data-dijiang-motion="full"><head><!--${lucideLicense}--><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(data.projectName)} · ${escape(text.fieldTheme)}</title><style>${dijiangConsoleCss}</style></head><body>${renderDijiangLoader(data.projectName, en, parts.instrument)}${body}<script id="ed-export-data" type="application/json">${exportPayload}</script><script>${dijiangMotionScript}</script><script>${dijiangConsoleScript}</script></body></html>`;
120
+ }
121
+
122
+ const dijiangConsoleScript = `(()=>{
123
+ const root=document.documentElement;
124
+ const links=[...document.querySelectorAll('.ed-stages a')];
125
+ const mark=()=>{links.forEach(a=>{if(a.hash===location.hash)a.setAttribute('aria-current','location');else a.removeAttribute('aria-current')})};
126
+ addEventListener('hashchange',mark);if(location.hash)mark();
127
+ const notice=document.querySelector('.ed-notice');
128
+ const en=root.lang==='en';
129
+ document.querySelectorAll('[data-ed-file]').forEach(button=>button.addEventListener('click',()=>{
130
+ document.querySelectorAll('[data-ed-file]').forEach(item=>item.setAttribute('aria-pressed',String(item===button)));
131
+ document.querySelectorAll('.ed-diff-panel').forEach(panel=>panel.hidden=panel.id!==button.getAttribute('aria-controls'));
132
+ }));
133
+ const comparison=document.querySelector('[data-ed-comparison]');
134
+ if(comparison){
135
+ for(const kind of ['before','after']){
136
+ const source=document.getElementById(comparison.dataset[kind]);
137
+ if(source){const image=document.createElement('img');image.src=source.src;image.alt=source.alt;comparison.querySelector('.ed-compare-'+kind).append(image)}
138
+ }
139
+ const slider=document.querySelector('[data-ed-compare-range]');
140
+ const update=()=>{comparison.querySelector('.ed-compare-after').style.clipPath='inset(0 0 0 '+slider.value+'%)';comparison.querySelector('.ed-compare-handle').style.left=slider.value+'%';slider.nextElementSibling.value=slider.value+'%'};
141
+ slider.addEventListener('input',update);update();
142
+ }
143
+ document.querySelectorAll('[data-ed-export]').forEach(button=>button.addEventListener('click',()=>{
144
+ const payload=JSON.parse(document.getElementById('ed-export-data').textContent);
145
+ const json=button.dataset.edExport==='json';
146
+ const url=URL.createObjectURL(new Blob([json?JSON.stringify(payload.report,null,2):payload.markdown],{type:json?'application/json':'text/markdown'}));
147
+ const anchor=document.createElement('a');anchor.href=url;anchor.download=(payload.report.demo?'delivery-demo-report':'delivery-report')+(json?'.json':'.md');anchor.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
148
+ }));
149
+ document.querySelectorAll('[data-ed-copy]').forEach(button=>button.addEventListener('click',async()=>{
150
+ try{await navigator.clipboard.writeText(button.dataset.edCopy);notice.textContent=en?'Copied':'已复制'}catch{notice.textContent=en?'Copy unavailable; select the path text.':'复制不可用,请选择路径文本。'}
151
+ }));
152
+ document.querySelectorAll('[data-ed-capture]').forEach(button=>button.addEventListener('click',()=>{
153
+ const image=document.getElementById(button.dataset.edCapture);if(!image)return;
154
+ const anchor=document.createElement('a');anchor.href=image.src;anchor.download=button.dataset.edCapture+'.'+(image.src.match(/^data:image\\/(png|jpeg|webp)/)?.[1]||'png');anchor.click();
155
+ }));
156
+ document.querySelector('[data-ed-download]')?.addEventListener('click',()=>{
157
+ const clone=document.documentElement.cloneNode(true);clone.querySelector('.loader')?.remove();clone.dataset.loaderReady='true';clone.querySelectorAll('[data-ed-derived]').forEach(node=>node.replaceChildren());
158
+ const url=URL.createObjectURL(new Blob(['<!doctype html>'+clone.outerHTML],{type:'text/html'}));
159
+ const anchor=document.createElement('a');anchor.href=url;anchor.download='dijiang-delivery.html';anchor.click();setTimeout(()=>URL.revokeObjectURL(url),1000);
160
+ });
161
+ })()`;