dsh-showcase 0.1.1 → 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.
@@ -1,3 +1,8 @@
1
+ import { renderDijiangDocument } from "./dijiang-layout.js";
2
+ import { findComparisonPair, formatReportDuration } from "./report-content.js";
3
+
4
+ const POSTER_RECORD_LIMIT = 20;
5
+
1
6
  function escapeHtml(value) {
2
7
  return String(value ?? "").replace(/[&<>"']/g, (character) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[character]);
3
8
  }
@@ -501,9 +506,80 @@ function manifestEmptyMarkup(kind, title, detail) {
501
506
  ].join("");
502
507
  }
503
508
 
509
+ function fishDetailLedgerMarkup(data, text) {
510
+ const en = data.locale === "en";
511
+ const files = (Array.isArray(data.gitFiles) ? data.gitFiles : []).slice(0, POSTER_RECORD_LIMIT);
512
+ const totals = {
513
+ additions: data.additions == null ? files.reduce((sum, file) => sum + (Number(file.additions) || 0), 0) : Number(data.additions) || 0,
514
+ deletions: data.deletions == null ? files.reduce((sum, file) => sum + (Number(file.deletions) || 0), 0) : Number(data.deletions) || 0,
515
+ };
516
+ const screenshots = posterScreenshotItems(data);
517
+ const pair = findComparisonPair(screenshots);
518
+ const redactionDetails = Array.isArray(data.redactionDetails) ? data.redactionDetails : [];
519
+ const copy = en ? {
520
+ label: "Evidence detail ledger",
521
+ summary: "Delivery summary",
522
+ duration: "Duration",
523
+ delta: "Line changes",
524
+ captures: "Captures",
525
+ diff: "Line diff",
526
+ diffMissing: "No line diff retained in this report.",
527
+ diffBinary: "Binary file; no text diff.",
528
+ diffUntracked: "Untracked file; not included in the Git patch.",
529
+ diffOmitted: "Patch omitted by capture limits.",
530
+ diffTruncated: "Only part of the diff is retained.",
531
+ privacy: "Privacy review",
532
+ noPrivacy: "No redaction breakdown recorded.",
533
+ compare: "Before / after comparison",
534
+ before: "Before",
535
+ after: "After",
536
+ noCompare: "No matching before and after viewport pair.",
537
+ } : {
538
+ label: "证据明细台账",
539
+ summary: "交付摘要",
540
+ duration: "任务耗时",
541
+ delta: "增删总量",
542
+ captures: "截图记录",
543
+ diff: "逐行差异",
544
+ diffMissing: "报告未保留逐行 Diff。",
545
+ diffBinary: "二进制文件,不展示文本 Diff。",
546
+ diffUntracked: "未跟踪文件,尚未纳入 Git 差异。",
547
+ diffOmitted: "受采集大小或数量限制,此文件未保留 Diff。",
548
+ diffTruncated: "仅保留部分 Diff,内容已截断。",
549
+ privacy: "隐私审查",
550
+ noPrivacy: "未记录脱敏分类明细。",
551
+ compare: "改版前后对比",
552
+ before: "改版前",
553
+ after: "改版后",
554
+ noCompare: "尚无相同页面、相同视口的改版前后截图。",
555
+ };
556
+ const diffPanels = files.length
557
+ ? files.map((file, index) => {
558
+ const unavailable = file.diffUnavailable === "binary"
559
+ ? copy.diffBinary
560
+ : file.diffUnavailable === "untracked"
561
+ ? copy.diffUntracked
562
+ : ["oversize", "limit"].includes(file.diffUnavailable)
563
+ ? copy.diffOmitted
564
+ : copy.diffMissing;
565
+ const diff = file.diff === undefined
566
+ ? '<p class="fish-detail-empty">' + escapeHtml(unavailable) + '</p>'
567
+ : '<pre class="fish-detail-diff">' + escapeHtml(file.diff) + '</pre>';
568
+ return '<details class="fish-detail-file"' + (index === 0 ? ' open' : '') + '><summary><span>' + escapeHtml(file.path) + '</span><b>+' + (Number(file.additions) || 0) + ' / -' + (Number(file.deletions) || 0) + '</b></summary>' + diff + (file.diffTruncated ? '<small>' + escapeHtml(copy.diffTruncated) + '</small>' : '') + '</details>';
569
+ }).join("")
570
+ : '<p class="fish-detail-empty">' + escapeHtml(copy.diffMissing) + '</p>';
571
+ const privacy = redactionDetails.length
572
+ ? redactionDetails.map((item) => '<li><span>' + escapeHtml(item.name) + '</span><b>' + (Number(item.count) || 0) + '</b></li>').join("")
573
+ : '<li class="fish-detail-empty">' + escapeHtml(copy.noPrivacy) + '</li>';
574
+ const comparison = pair
575
+ ? '<div class="fish-detail-comparison" data-fish-comparison><header><b>' + escapeHtml(copy.compare) + '</b><small>' + escapeHtml(pair.after.label || pair.before.label || "") + '</small></header><div><figure><img src="data:' + escapeHtml(pair.before.mimeType) + ';base64,' + pair.before.image + '" alt="' + escapeHtml(copy.before) + '"><figcaption>' + escapeHtml(copy.before) + '</figcaption></figure><figure><img src="data:' + escapeHtml(pair.after.mimeType) + ';base64,' + pair.after.image + '" alt="' + escapeHtml(copy.after) + '"><figcaption>' + escapeHtml(copy.after) + '</figcaption></figure></div></div>'
576
+ : '<div class="fish-detail-comparison fish-detail-comparison--empty"><header><b>' + escapeHtml(copy.compare) + '</b></header><p>' + escapeHtml(copy.noCompare) + '</p></div>';
577
+ return '<section class="fish-detail-ledger" aria-label="' + escapeHtml(copy.label) + '"><header class="fish-detail-ledger__head"><span>' + escapeHtml(copy.label) + '</span><b>' + escapeHtml(copy.summary) + '</b></header><dl class="fish-detail-overview"><div><dt>' + escapeHtml(copy.duration) + '</dt><dd>' + escapeHtml(formatReportDuration(data.durationMs, data.locale)) + '</dd></div><div><dt>' + escapeHtml(copy.delta) + '</dt><dd><strong>+' + totals.additions + '</strong><i>−' + totals.deletions + '</i></dd></div><div><dt>' + escapeHtml(copy.captures) + '</dt><dd>' + screenshots.length + '</dd></div></dl><div class="fish-detail-columns"><section><h3>' + escapeHtml(copy.diff) + '</h3>' + diffPanels + '</section><section><h3>' + escapeHtml(copy.privacy) + '</h3><ul class="fish-detail-privacy">' + privacy + '</ul></section></div>' + comparison + '</section>';
578
+ }
579
+
504
580
  function deliveryManifestMarkup(data, text) {
505
581
  const status = statusPresentation(data, text);
506
- const rawGitFiles = (Array.isArray(data.gitFiles) ? data.gitFiles : []).slice(0, 5);
582
+ const rawGitFiles = (Array.isArray(data.gitFiles) ? data.gitFiles : []).slice(0, POSTER_RECORD_LIMIT);
507
583
  const untrackedDirectories = rawGitFiles.filter((file) => String(file.path ?? "").endsWith("/") && !(Number(file.additions) || Number(file.deletions)));
508
584
  const changedGitFiles = rawGitFiles.filter((file) => !untrackedDirectories.includes(file));
509
585
  const inferredGitState = String(data.gitRange ?? "").includes("NO_GIT") ? "unavailable" : rawGitFiles.length ? "changed" : "clean";
@@ -523,15 +599,19 @@ function deliveryManifestMarkup(data, text) {
523
599
  : changedGitItems || manifestEmptyMarkup("git-details-missing", text.gitChangedMissingTitle, text.gitChangedMissingDetail);
524
600
  const gitMeta = gitState === "unavailable" ? text.gitUnavailableMeta : String(data.gitRange ?? "");
525
601
 
526
- const rawReceipts = (Array.isArray(data.testReceipts) ? data.testReceipts : []).slice(0, 3);
602
+ const rawReceipts = (Array.isArray(data.testReceipts) ? data.testReceipts : []).slice(0, POSTER_RECORD_LIMIT);
527
603
  const inferredTestState = rawReceipts.length ? "configured" : "unconfigured";
528
604
  const testState = ["unconfigured", "failed", "configured"].includes(data.testState) ? data.testState : inferredTestState;
529
605
  const receiptItems = rawReceipts.map((receipt) => {
530
606
  const receiptState = receiptPresentation(receipt, text);
531
607
  const exitCode = Number(receipt.exitCode) || 0;
532
608
  const ariaLabel = String(receipt.command ?? "") + ": " + receiptState.label + ", " + text.exit + " " + exitCode;
609
+ const output = String(receipt.output ?? "");
610
+ const outputDetails = output
611
+ ? '<details class="manifest-receipt-output"><summary>' + escapeHtml(data.locale === "en" ? "View output" : "查看输出") + '</summary><pre>' + escapeHtml(output) + '</pre>' + (receipt.outputTruncated ? '<small>' + escapeHtml(data.locale === "en" ? "Output truncated." : "输出已截断。") + '</small>' : '') + '</details>'
612
+ : '';
533
613
  return [
534
- '<li data-status="', receiptState.key, '" aria-label="', escapeHtml(ariaLabel), '"><span>', escapeHtml(receipt.command), '</span><b><span aria-hidden="true">', escapeHtml(receiptState.symbol), '</span> ', escapeHtml(receiptState.label), ' · ', escapeHtml(receipt.duration), ' · ', escapeHtml(text.exit), ' ', exitCode, '</b></li>',
614
+ '<li data-status="', receiptState.key, '" aria-label="', escapeHtml(ariaLabel), '"><span>', escapeHtml(receipt.command), '</span><b><span aria-hidden="true">', escapeHtml(receiptState.symbol), '</span> ', escapeHtml(receiptState.label), ' · ', escapeHtml(receipt.duration), ' · ', escapeHtml(text.exit), ' ', exitCode, '</b>', outputDetails, '</li>',
535
615
  ].join("");
536
616
  }).join("");
537
617
  const receipts = receiptItems || (testState === "unconfigured"
@@ -575,7 +655,8 @@ function deliveryManifestMarkup(data, text) {
575
655
  '<div class="manifest-grid"><article class="manifest-block" data-block-state="', gitState, '"><div class="manifest-block__head"><span>', escapeHtml(text.manifestChanges), '</span><small>', escapeHtml(gitMeta), '</small></div><ul data-empty="', changedGitItems ? 'false' : 'true', '">', gitItems, '</ul></article>',
576
656
  '<article class="manifest-block" data-block-state="', testState, '"><div class="manifest-block__head"><span>', escapeHtml(text.manifestReceipts), '</span><small>', escapeHtml(testMeta), '</small></div><ul data-empty="', rawReceipts.length ? 'false' : 'true', '">', receipts, '</ul></article></div>',
577
657
  '<div class="manifest-scope"><small>', escapeHtml(text.manifestScope), '</small><div>', scope, '</div></div>',
578
- '<footer class="manifest-output" data-status="', status.key, '" data-output-state="', outputState, '"><div class="manifest-output__meta"><div class="manifest-output__label"><span>', escapeHtml(text.manifestOutput), '</span><small data-status="', status.key, '" aria-label="', escapeHtml(statusAriaLabel(data.locale, outputLine ? status.output : text.outputsMissingTitle)), '">', escapeHtml(outputLine ? status.output : text.outputsMissingTitle), '</small></div><small class="manifest-output__privacy">', Number(data.redactionCount) || 0, ' ', escapeHtml(text.manifestRedactions), ' / ', escapeHtml(text.manifestLocal), '</small></div><div class="manifest-output__path">', outputSteps, '</div></footer></section>',
658
+ '<footer class="manifest-output" data-status="', status.key, '" data-output-state="', outputState, '"><div class="manifest-output__meta"><div class="manifest-output__label"><span>', escapeHtml(text.manifestOutput), '</span><small data-status="', status.key, '" aria-label="', escapeHtml(statusAriaLabel(data.locale, outputLine ? status.output : text.outputsMissingTitle)), '">', escapeHtml(outputLine ? status.output : text.outputsMissingTitle), '</small></div><small class="manifest-output__privacy">', Number(data.redactionCount) || 0, ' ', escapeHtml(text.manifestRedactions), ' / ', escapeHtml(text.manifestLocal), '</small></div><div class="manifest-output__path">', outputSteps, '</div></footer>',
659
+ fishDetailLedgerMarkup(data, text), '</section>',
579
660
  ].join("");
580
661
  }
581
662
 
@@ -780,7 +861,7 @@ function dijiangLoaderMarkup(text, stages, locale, projectName, statusKey = "com
780
861
  '<div class="field-loader-stages" aria-hidden="true">', stageItems, '</div><span class="field-loader-anchor" aria-hidden="true"></span>',
781
862
  '<span class="field-loader-release" aria-hidden="true"><i></i><i></i><i></i><i></i><i></i></span></footer></section>',
782
863
  '<div class="field-loader-scan" aria-hidden="true"></div>',
783
- '<script>(function(){var node=document.querySelector(".loader");if(!node)return;var done=false;var root=document.documentElement;root.dataset.loaderReady="false";node.setAttribute("aria-busy","true");var started=performance.now();var duration=2750;var fill=node.querySelector("[data-loader-fill]");var percent=node.querySelector("[data-loader-percent]");var status=node.querySelector("[data-loader-status]");var stages=[].slice.call(node.querySelectorAll("[data-loader-stage]"));var locale=node.getAttribute("data-loader-locale");var outcome=node.getAttribute("data-loader-outcome")||"completed";var finalMessage=locale==="en"?({completed:"DELIVERY READY",partial:"REVIEW REQUIRED",failed:"DELIVERY BLOCKED"})[outcome]:({completed:"交付链路就绪",partial:"等待复核",failed:"交付链路阻断"})[outcome];var messages=locale==="en"?["LINK BEACON","VERIFY INDEX","BUILD TOPOLOGY","SYNC EVIDENCE",finalMessage]:["接入帝江号信标","校验证据索引","重建交付拓扑","同步主题证据",finalMessage];function tick(now){var raw=Math.min(1,(now-started)/duration);var messageIndex=raw<.2?0:raw<.42?1:raw<.65?2:raw<.97?3:4;if(fill)fill.style.transform="scaleX("+raw+")";if(percent)percent.textContent=String(Math.round(raw*100)).padStart(2,"0")+"%";if(status)status.textContent=messages[messageIndex];stages.forEach(function(stage,index){var start=index*.18;var finish=index===4?.96:(index+1)*.18;stage.classList.toggle("is-live",raw>=start);stage.classList.toggle("is-done",raw>=finish)});if(raw<1)requestAnimationFrame(tick)}function remove(){if(done)return;done=true;node.dataset.loaderState="complete";node.setAttribute("aria-busy","false");root.dataset.loaderReady="true";node.remove()}function fallbackDelay(){var style=getComputedStyle(node);var animationDuration=parseFloat(style.animationDuration)||0;var animationDelay=parseFloat(style.animationDelay)||0;return Math.max(5000,Math.ceil((animationDuration+animationDelay)*1000)+600)}requestAnimationFrame(tick);node.addEventListener("animationend",function(event){if(event.target===node)remove()},{once:true});setTimeout(remove,fallbackDelay())})()</script>',
864
+ '<script>(function(){var node=document.querySelector(".loader");if(!node)return;var done=false;var root=document.documentElement;root.dataset.loaderReady="false";node.setAttribute("aria-busy","true");var started=performance.now();var duration=2750;var fill=node.querySelector("[data-loader-fill]");var percent=node.querySelector("[data-loader-percent]");var status=node.querySelector("[data-loader-status]");var stages=[].slice.call(node.querySelectorAll("[data-loader-stage]"));var locale=node.getAttribute("data-loader-locale");var outcome=node.getAttribute("data-loader-outcome")||"completed";var finalMessage=locale==="en"?({completed:"DELIVERY READY",partial:"REVIEW REQUIRED",failed:"DELIVERY BLOCKED"})[outcome]:({completed:"交付链路就绪",partial:"等待复核",failed:"交付链路阻断"})[outcome];var messages=locale==="en"?["LINK BEACON","VERIFY INDEX","PREPARE RECEIPTS","SYNC EVIDENCE",finalMessage]:["接入帝江号信标","校验证据索引","整理验证回执","同步主题证据",finalMessage];function tick(now){if(done)return;var raw=Math.min(1,(now-started)/duration);var messageIndex=raw<.2?0:raw<.42?1:raw<.65?2:raw<.97?3:4;if(fill)fill.style.transform="scaleX("+raw+")";if(percent)percent.textContent=String(Math.round(raw*100)).padStart(2,"0")+"%";if(status)status.textContent=messages[messageIndex];stages.forEach(function(stage,index){var start=index*.18;var finish=index===4?.96:(index+1)*.18;stage.classList.toggle("is-live",raw>=start);stage.classList.toggle("is-done",raw>=finish)});if(raw<1)requestAnimationFrame(tick)}function remove(){if(done)return;done=true;node.dataset.loaderState="complete";node.setAttribute("aria-busy","false");root.dataset.loaderReady="true";node.remove()}function fallbackDelay(){var style=getComputedStyle(node);var animationDuration=parseFloat(style.animationDuration)||0;var animationDelay=parseFloat(style.animationDelay)||0;return Math.max(5000,Math.ceil((animationDuration+animationDelay)*1000)+600)}requestAnimationFrame(tick);function onExit(event){if(event.target===node){node.removeEventListener("animationend",onExit);remove()}}node.addEventListener("animationend",onExit);setTimeout(remove,fallbackDelay())})()</script>',
784
865
  '</div>',
785
866
  ].join("");
786
867
  }
@@ -939,23 +1020,11 @@ const dijiangLoaderCompactCss = `
939
1020
  function createDijiangPoster(data) {
940
1021
  const text = posterText(data.locale);
941
1022
  const status = statusPresentation(data, text);
942
- const posterCss = dijiangCss.replace("data:image/webp;base64,__DIJIANG_IMAGE__", "about:blank");
943
- const surfaceCss = data.surfaceImage
944
- ? `.field-blueprint{background-image:linear-gradient(rgba(13,17,20,.36),rgba(13,17,20,.58)),url('${data.surfaceImage}');background-position:center;background-size:auto,cover}`
945
- : "";
946
- return [
947
- '<!doctype html><html lang="', data.locale, '"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>', escapeHtml(text.title), '</title><style>', sharedCss, evidenceIntegrityCss, posterCss, dijiangCompactCss, surfaceCss, dijiangTopologyCss, dijiangMaterialCss, dijiangLoaderCompactCss, manifestStateCss, dijiangReportContentCss, statusCss, '</style></head><body>', dijiangLoaderMarkup(text, data.stages, data.locale, data.projectName, status.key),
948
- '<article class="poster dijiang-poster" data-theme="frontier-signal" data-status="', status.key, '"><div class="field-topband"></div><div class="field-sheet"></div>', dijiangContoursMarkup("field-contours"),
949
- dijiangSlashedWordMarkup("field-topmark"), '<div class="field-safety-slab" aria-hidden="true"></div>', dijiangBlueprintMarkup(), '<div class="field-copy-panel" aria-hidden="true"></div>',
950
- '<div class="mast"><span>DSH / ', escapeHtml(text.title), '</span><span>0017 / ', escapeHtml(text.local), '</span></div>',
951
- '<div class="copy"><div class="kicker">', escapeHtml(text.fieldTheme), ' / ', escapeHtml(status.fieldKicker), '</div><h1 class="title">', escapeHtml(data.projectName), '</h1><p class="task">', escapeHtml(data.task), '</p><div class="verified" data-status="', status.key, '" role="status" aria-label="', escapeHtml(statusAriaLabel(data.locale, status.badge)), '"><span aria-hidden="true">', escapeHtml(status.symbol), '</span> ', escapeHtml(status.badge), '</div><div class="field-calibration" aria-hidden="true"><i></i><i></i><i></i></div></div>', dijiangReadoutMarkup(data, status),
952
- '<div class="stamp" data-status="', status.key, '" aria-label="', escapeHtml(statusAriaLabel(data.locale, status.deliverable)), '">', escapeHtml(status.deliverable), '</div><div class="metrics">', posterMetricsMarkup(data, text), '</div>',
953
- '<div class="rail">', stageMarkup(data.stages), '</div><div class="footer mono">', escapeHtml(posterFooterText(data, text)), '</div></article>',
954
- currentRouteMarkup(data.stages, text),
955
- deliveryManifestMarkup(data, text),
956
- evidencePageMarkup(data, text),
957
- '</body></html>',
958
- ].join("");
1023
+ return renderDijiangDocument(data, text, status, {
1024
+ instrument: dijiangLoaderInstrumentMarkup(),
1025
+ metrics: posterMetricsMarkup(data, text),
1026
+ integrity: evidenceIntegrityMarkup(data, text),
1027
+ });
959
1028
  }
960
1029
 
961
1030
  function createFieldPoster(data) {
@@ -987,6 +1056,9 @@ function createFieldPoster(data) {
987
1056
 
988
1057
  const fishManifestHardeningCss = "@media(max-width:35rem){.delivery-manifest{min-width:0;overflow:hidden}.delivery-manifest .manifest-block li{align-items:flex-start;flex-wrap:wrap;gap:.2rem .45rem}.delivery-manifest .manifest-block li span{flex:1 1 9rem;max-width:100%;overflow:hidden;overflow-wrap:anywhere;white-space:normal;text-overflow:clip}.delivery-manifest .manifest-block li b{flex:0 1 auto;max-width:100%;white-space:normal;overflow-wrap:anywhere}.delivery-manifest .manifest-output,.delivery-manifest .manifest-output__meta,.delivery-manifest .manifest-output__label,.delivery-manifest .manifest-output__path{width:100%;min-width:0;max-width:100%}.delivery-manifest .manifest-output{overflow:hidden}.delivery-manifest .manifest-output__meta{display:grid;grid-template-columns:minmax(0,1fr);gap:.3rem}.delivery-manifest .manifest-output__label{display:grid;grid-template-columns:minmax(0,1fr);gap:.15rem}.delivery-manifest .manifest-output__label span,.delivery-manifest .manifest-output__label small,.delivery-manifest .manifest-output__privacy{min-width:0;max-width:100%;overflow:hidden;overflow-wrap:anywhere;white-space:normal}.delivery-manifest .manifest-output__path{display:grid!important;grid-template-columns:minmax(0,1fr)!important;gap:.4rem}.delivery-manifest .manifest-output__path span{display:grid;grid-template-columns:auto minmax(0,1fr);width:100%;min-width:0;max-width:100%;padding:0;overflow:visible}.delivery-manifest .manifest-output__path strong{min-width:0;max-width:100%;overflow:hidden;overflow-wrap:anywhere;white-space:normal;text-overflow:clip}.delivery-manifest .manifest-output__privacy{text-align:left;line-height:1.35}}";
989
1058
 
1059
+ const fishReceiptCss = ".manifest-receipt-output{flex:1 1 100%;min-width:0;margin-top:.2rem}.manifest-receipt-output summary{cursor:pointer;list-style:none;color:#0757cf;font:800 .7rem/1.35 'Cascadia Mono','Microsoft YaHei UI',monospace}.manifest-receipt-output summary::-webkit-details-marker{display:none}.manifest-receipt-output summary:before{content:'+';display:inline-block;width:1.2em}.manifest-receipt-output[open] summary:before{content:'−'}.manifest-receipt-output pre{max-height:12rem;margin:.35rem 0 0;padding:.55rem;background:rgba(7,87,207,.08);color:#10245c;white-space:pre-wrap;overflow:auto;overflow-wrap:anywhere;font:700 .7rem/1.45 'Cascadia Mono','Microsoft YaHei UI',monospace}.manifest-receipt-output>small{display:block;margin-top:.25rem;color:#2865ad;font:700 .65rem/1.3 'Cascadia Mono','Microsoft YaHei UI',monospace}";
1060
+ const fishDetailCss = ".fish-detail-ledger{position:relative;z-index:1;margin:clamp(.7rem,1vw,1rem) 0 0;padding:clamp(.75rem,1.1vw,1rem);color:#10245c;background:rgba(255,255,255,.76);border-top:.14rem solid #0757cf}.fish-detail-ledger__head{display:flex;align-items:baseline;justify-content:space-between;gap:1rem;padding-bottom:.5rem;border-bottom:.12rem solid #8bd8f8}.fish-detail-ledger__head span{color:#0757cf;font:900 .75rem/1.2 'Cascadia Mono','Microsoft YaHei UI',monospace;text-transform:uppercase}.fish-detail-ledger__head b{font-size:clamp(1rem,1.2vw,1.25rem)}.fish-detail-overview{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.5rem;margin:.7rem 0 0}.fish-detail-overview div{min-width:0;padding:.55rem .65rem;background:#eaf7ff;border-left:.2rem solid #48bfe9}.fish-detail-overview dt{color:#2865ad;font:800 .65rem/1.2 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-overview dd{margin:.2rem 0 0;font:900 clamp(.9rem,1vw,1.1rem)/1.2 'Bahnschrift','Microsoft YaHei UI',sans-serif;overflow-wrap:anywhere}.fish-detail-overview dd strong{color:#087f77}.fish-detail-overview dd i{margin-left:.45rem;color:#d44a62;font-style:normal}.fish-detail-columns{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(10rem,.65fr);gap:clamp(.75rem,1.2vw,1.2rem);margin-top:1rem}.fish-detail-columns>section{min-width:0}.fish-detail-columns h3{margin:0 0 .4rem;color:#0757cf;font:900 .85rem/1.2 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-file{border-top:1px solid rgba(7,87,207,.2)}.fish-detail-file:last-child{border-bottom:1px solid rgba(7,87,207,.2)}.fish-detail-file summary{display:flex;align-items:baseline;justify-content:space-between;gap:.7rem;min-width:0;padding:.42rem 0;cursor:pointer;color:#1b3470;font:800 .76rem/1.25 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-file summary span{min-width:0;overflow-wrap:anywhere}.fish-detail-file summary b{flex:0 0 auto;color:#0757cf;white-space:nowrap}.fish-detail-diff{max-height:12rem;margin:0 0 .4rem;padding:.55rem;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;color:#10245c;background:#eef7ff;border-left:.2rem solid #48bfe9;font:700 .68rem/1.4 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-file>small{display:block;margin:0 0 .4rem;color:#2865ad;font:700 .65rem/1.3 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-empty{margin:.35rem 0;color:#2865ad;font:700 .7rem/1.4 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-privacy{display:grid;gap:.35rem;margin:0;padding:0;list-style:none}.fish-detail-privacy li{display:flex;justify-content:space-between;gap:.6rem;padding:.4rem .5rem;background:#eaf7ff;border-bottom:1px solid rgba(7,87,207,.17);font:800 .72rem/1.25 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-privacy b{color:#087f77}.fish-detail-comparison{margin-top:1rem;padding-top:.7rem;border-top:.12rem solid #8bd8f8}.fish-detail-comparison header{display:flex;align-items:baseline;justify-content:space-between;gap:.7rem}.fish-detail-comparison header b{color:#0757cf;font:900 .85rem/1.2 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-comparison header small{color:#2865ad;font:700 .68rem/1.2 'Cascadia Mono','Microsoft YaHei UI',monospace;overflow-wrap:anywhere}.fish-detail-comparison>div{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.6rem;margin-top:.55rem}.fish-detail-comparison figure{min-width:0;margin:0}.fish-detail-comparison img{display:block;width:100%;aspect-ratio:16/9;object-fit:cover;border:1px solid #8bd8f8;background:#eaf7ff}.fish-detail-comparison figcaption{margin-top:.25rem;color:#2865ad;font:800 .66rem/1.2 'Cascadia Mono','Microsoft YaHei UI',monospace}.fish-detail-comparison--empty p{margin:.5rem 0 0;color:#2865ad;font:700 .7rem/1.35 'Cascadia Mono','Microsoft YaHei UI',monospace}@media(max-width:56.249rem){.fish-detail-overview{grid-template-columns:1fr}.fish-detail-columns{grid-template-columns:1fr;gap:1rem}.fish-detail-comparison>div{grid-template-columns:1fr}.fish-detail-ledger__head{align-items:flex-start;flex-direction:column;gap:.25rem}}";
1061
+
990
1062
  const manifestStateCss = [
991
1063
  ".manifest-block ul[data-empty='true']{align-content:stretch}.manifest-block:last-child ul[data-empty='true']:after{display:none}.manifest-block .manifest-empty{display:block;min-height:4.25rem;padding:clamp(.55rem,.75vw,.75rem);border:.08rem solid rgba(7,87,207,.2);background:rgba(255,255,255,.42)}.manifest-block .manifest-empty>span{display:grid;gap:.22rem;max-width:100%;overflow:visible;white-space:normal}.manifest-block .manifest-empty strong{color:#0757cf;font-family:'Bahnschrift','Microsoft YaHei UI',sans-serif;font-size:clamp(.875rem,.98vw,1rem);font-weight:900;line-height:1.2}.manifest-block .manifest-empty small{color:#2865ad;font-size:clamp(.6875rem,.76vw,.8125rem);font-weight:750;line-height:1.35;white-space:normal}.manifest-empty[data-empty-state='git-unavailable']{border-color:rgba(8,127,119,.35);background:rgba(220,250,247,.54)}.manifest-empty[data-empty-state='tests-unconfigured']{border-color:rgba(255,210,74,.58);background:rgba(255,246,207,.6)}",
992
1064
  ".manifest-output[data-output-state='empty'] .manifest-output__path{grid-template-columns:1fr}.manifest-output__empty{display:grid;gap:.2rem;min-width:0;padding:.55rem .65rem;border:.08rem solid rgba(7,87,207,.2);background:rgba(234,247,255,.74)}.manifest-output__empty strong{color:#0757cf;font:900 clamp(.8125rem,.9vw,.9375rem) 'Bahnschrift','Microsoft YaHei UI',sans-serif;white-space:normal}.manifest-output__empty small{color:#2865ad;font:750 clamp(.6875rem,.76vw,.8125rem)/1.35 'Cascadia Mono','Microsoft YaHei UI',monospace;white-space:normal}",
@@ -1033,7 +1105,7 @@ function createFishPoster(data) {
1033
1105
  "@keyframes current-drift{to{transform:translate3d(7vw,1.2vh,0) scale(1.03)}}@keyframes ripple-spin{to{transform:rotate(360deg)}}@keyframes portrait-tide{to{transform:translate3d(1.2vw,-.55vh,0) scale(1.015);opacity:.76}}@keyframes portrait-current{to{transform:translate3d(7%,0,0)}}@keyframes whale-drift{to{translate:7vw -2vh;rotate:4deg}}@keyframes light-sweep{from{transform:translate3d(-6vw,0,0) rotate(16deg)}to{transform:translate3d(10vw,0,0) rotate(16deg)}}@keyframes bubble-rise{0%{opacity:0;translate:0 0;scale:.65}12%{opacity:.8}78%{opacity:.38}100%{opacity:0;translate:2vw -108vh;scale:1.35}}@keyframes sparkle{50%{transform:scale(1.35) rotate(45deg);opacity:.55}}@keyframes route-swell{to{stroke-dashoffset:-176}}@keyframes evidence-current{to{transform:translate3d(12%,-4%,0) scale(1.06)}}@keyframes evidence-scan{to{transform:translateX(78vw) skewX(-10deg)}}@media(max-height:48rem) and (min-width:56.25rem){.poster-layout{padding-block:1.5rem .8rem;grid-template-rows:auto auto auto minmax(10rem,1fr) auto;row-gap:.42rem}.title{font-size:clamp(3.25rem,4.9vw,4.35rem)}.task{font-size:clamp(1rem,1.25vw,1.1875rem);line-height:1.3}.verified{margin-top:.62rem;padding:.38rem .65rem}.portrait-field{min-height:14rem}.current-route{min-height:3.7rem}.route-proofs{height:calc(100% - 3.3rem)}.metric{padding:.45rem .7rem}.route-evidence{padding:.7rem .85rem}}@media(max-width:56.249rem){.poster{height:auto;min-height:0}.poster-layout{width:auto;height:auto;margin:0;padding:4.75rem 1rem 1.25rem;grid-template-columns:1fr;grid-template-areas:'copy' 'portrait' 'route' 'metrics' 'evidence' 'footer';grid-template-rows:auto 15rem auto auto auto auto;row-gap:.85rem}.copy{max-width:100%}.title{font-size:clamp(3rem,12vw,4.25rem)}.task{font-size:1.0625rem}.portrait-field{min-height:0;height:15rem}.portrait-field:before{inset:4% 0 0 10%}.portrait-field .art{inset:0;width:100%;height:100%;object-position:50% 0%}.portrait-field:after{bottom:14%}.current-route{min-height:12.25rem;padding:.2rem 0 0}.current-route header{align-items:flex-start;flex-direction:column;gap:.2rem}.route-swell{display:none}.route-nodes{min-height:0;grid-template-columns:1fr;gap:.25rem;padding:.65rem 0 .1rem 1.1rem;border-left:.18rem solid #48bfe9}.route-nodes span,.route-nodes span:nth-child(2),.route-nodes span:nth-child(4){display:grid;grid-template-columns:auto 1fr;align-items:center;column-gap:.65rem;transform:none}.route-nodes em{font-size:1rem}.metrics{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:.45rem}.metric{min-width:0;display:grid;align-content:center;gap:.15rem;padding:.55rem .6rem}.metric b{font-size:1.6rem}.metric small{font-size:.8125rem;line-height:1.15}.route-evidence{min-height:0;padding:.85rem}.route-evidence header{align-items:flex-start;flex-direction:column;gap:.3rem}.route-proofs{height:auto;display:grid;grid-template-columns:1fr;gap:.8rem;padding-top:.8rem}.route-proof{min-height:0}.route-proof__frame{aspect-ratio:16/10}.route-proof:nth-child(2) .route-proof__frame{aspect-ratio:4/3}.route-proof:nth-child(3) .route-proof__frame{aspect-ratio:10/11}.route-proof:nth-child(2) .route-proof__frame,.route-proof:nth-child(3) .route-proof__frame{transform:none}.footer{padding-top:.15rem}.whale-one{right:13%;top:37%;width:17vw}.whale-two{right:4%;top:64%;width:13vw}.whale-three{display:none}}@media(min-width:35rem) and (max-width:56.249rem){.poster-layout{padding-inline:2rem;grid-template-rows:auto 22rem auto auto auto auto}.portrait-field{height:22rem}.current-route{min-height:12rem}.route-proofs{grid-template-columns:minmax(0,1.15fr) minmax(0,.85fr)}.route-proof:first-child{grid-column:1/-1}.route-proof:first-child .route-proof__frame{aspect-ratio:16/8}.route-proof:nth-child(2) .route-proof__frame{aspect-ratio:4/3}.route-proof:nth-child(3) .route-proof__frame{aspect-ratio:4/5}}@media(prefers-reduced-motion:reduce){.loader{animation-duration:.01ms}.current,.ripple,.light,.whale,.spark,.bubbles i,.portrait-field:after,.portrait-field .art,.route-evidence:after{animation:current-breathe 7s ease-in-out infinite alternate}.route-swell path{animation:none;stroke-dashoffset:0}@keyframes current-breathe{to{opacity:.72}}}",
1034
1106
  ].join("");
1035
1107
  return [
1036
- '<!doctype html><html lang="', data.locale, '"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>', escapeHtml(text.title), '</title><style>', sharedCss, evidenceIntegrityCss, css, fishManifestHardeningCss, manifestStateCss, fishGlassCss, fishVisualHardeningCss, statusCss, '</style></head><body>', loadingMarkup(text, "fish", status.key, data.projectName),
1108
+ '<!doctype html><html lang="', data.locale, '"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>', escapeHtml(text.title), '</title><style>', sharedCss, evidenceIntegrityCss, css, fishManifestHardeningCss, fishReceiptCss, fishDetailCss, manifestStateCss, fishGlassCss, fishVisualHardeningCss, statusCss, '</style></head><body>', loadingMarkup(text, "fish", status.key, data.projectName),
1037
1109
  '<article class="poster" data-theme="blue-big-fish" data-status="', status.key, '"><div class="current current-one"></div><div class="current current-two"></div><div class="current current-three"></div><div class="ripple ripple-one"></div><div class="ripple ripple-two"></div><div class="light light-one"></div><div class="light light-two"></div>', bubbleMarkup(), whaleSchoolMarkup(), '<i class="spark s1"></i><i class="spark s2"></i><i class="spark s3"></i>',
1038
1110
  '<div class="poster-layout"><section class="copy"><div class="kicker">', escapeHtml(text.fishTheme), ' / ', escapeHtml(status.fishKicker), '</div><h1 class="title">', escapeHtml(data.projectName), '</h1><p class="task">', escapeHtml(data.task), '</p><div class="verified" data-status="', status.key, '" role="status" aria-label="', escapeHtml(statusAriaLabel(data.locale, status.badge)), '"><span aria-hidden="true">', escapeHtml(status.symbol), '</span> ', escapeHtml(status.summary), '</div></section>',
1039
1111
  '<aside class="portrait-field" aria-hidden="true"><img class="art" src="data:image/webp;base64,', data.image, '" alt=""></aside>',
@@ -1045,5 +1117,26 @@ function createFishPoster(data) {
1045
1117
 
1046
1118
  export function createStyledPosterHtml(data) {
1047
1119
  const normalizedData = normalizePosterData(data);
1048
- return normalizedData.theme === "blue-big-fish" ? createFishPoster(normalizedData) : createDijiangPoster(normalizedData);
1120
+ if (normalizedData.theme !== "blue-big-fish") return createDijiangPoster(normalizedData);
1121
+ const html = createFishPoster(normalizedData);
1122
+ const warnings = Array.isArray(data?.freshnessWarnings) ? data.freshnessWarnings : [];
1123
+ const notice = warnings.length ? `<aside class="evidence-warnings" role="status"><strong>${normalizedData.locale === "en" ? "Evidence needs review" : "证据需要复核"}</strong><ul>${warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join("")}</ul></aside>` : "";
1124
+ return html.replace("</style>", `${responsiveEvidenceCss}</style>`).replace('<article class="poster', `${notice}<article class="poster`);
1049
1125
  }
1126
+
1127
+ const responsiveEvidenceCss = `
1128
+ .evidence-warnings{position:relative;padding:1rem 5%;background:#fff2cc;color:#402f0a;border-bottom:2px solid #c79708;font:1rem/1.5 sans-serif;overflow-wrap:anywhere}.evidence-warnings ul{margin:.4rem 0 0;padding-left:1.3rem}
1129
+ .poster{height:auto;min-height:min(100svh,72rem)}
1130
+ .poster .title{font-size:3.5rem;line-height:1.04;overflow-wrap:anywhere}.poster .task{font-size:1.125rem;line-height:1.5}
1131
+ .poster-layout{height:auto;min-height:inherit;grid-template-rows:auto auto auto auto auto}
1132
+ .poster-layout .current-route{min-height:7rem;height:auto;overflow:visible}.poster-layout .route-nodes{min-height:4.5rem}
1133
+ .delivery-manifest{grid-template-rows:auto auto auto auto}.manifest-block:last-child ul:after{content:none}
1134
+ .poster-layout .delivery-manifest{overflow:hidden}.poster-layout .manifest-grid{min-height:0}
1135
+ .poster-layout .current-route>svg{top:25%;height:70%}
1136
+ .dijiang-poster{height:auto;min-height:min(100svh,72rem);display:grid;grid-template-columns:minmax(0,42%) minmax(0,1fr);grid-template-areas:'copy scene' 'metrics scene' 'rail rail' 'footer footer';grid-template-rows:minmax(24rem,auto) auto auto auto;gap:1.25rem 3%;padding:6rem 5.1% 1rem;align-content:start}
1137
+ .dijiang-poster>.copy{position:relative;inset:auto;grid-area:copy;align-self:start;width:auto;max-width:100%;padding:1rem;background:#f8f9f6}.dijiang-poster>.field-copy-panel{display:none}
1138
+ .dijiang-poster>.metrics{position:relative;inset:auto;grid-area:metrics;width:100%;margin:0}.dijiang-poster>.rail{position:relative;inset:auto;grid-area:rail;width:auto}.dijiang-poster>.footer{position:relative;inset:auto;grid-area:footer;width:auto;margin:0}
1139
+ .dijiang-poster>.field-blueprint{top:7rem;bottom:12rem;left:51%;right:4%;width:auto;min-height:18rem}.dijiang-poster>.field-readout{left:65%;top:40%;width:25%;max-width:20rem}
1140
+ @media(max-width:56.249rem){.poster .title{font-size:2.5rem}.poster .task{font-size:1rem}.poster-layout{grid-template-rows:auto 17.5rem auto auto auto auto}.poster-layout .current-route{min-height:0}.poster-layout .route-nodes{min-height:0}.dijiang-poster{grid-template-columns:1fr;grid-template-areas:'copy' 'metrics' 'rail' 'footer';grid-template-rows:auto auto auto auto;gap:1rem;padding:6rem 1rem 1.25rem;min-height:100svh}.dijiang-poster>.field-blueprint,.dijiang-poster>.field-readout{display:none}.dijiang-poster>.copy{margin:0;width:100%}.dijiang-poster>.rail{grid-template-columns:repeat(5,minmax(0,1fr))}.dijiang-poster>.footer{font-size:.75rem;overflow-wrap:anywhere}}
1141
+ @media print{.loader{display:none!important}*,*:before,*:after{animation:none!important}.poster{break-after:page}}
1142
+ `;
@@ -0,0 +1,5 @@
1
+ export function projectFile(projectPath: string, value: string): Promise<string>;
2
+ export function readBoundedFile(path: string, maxBytes: number, signal?: AbortSignal): Promise<Buffer>;
3
+ export function parseJson(source: string | Buffer): unknown;
4
+ export function atomicProjectWrite(projectPath: string, value: string, content: string): Promise<void>;
5
+ export function withProjectLock<T>(projectPath: string, action: () => Promise<T>, signal?: AbortSignal): Promise<T>;
@@ -0,0 +1,82 @@
1
+ import { mkdir, open, realpath, rename, unlink } from "node:fs/promises";
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { randomUUID } from "node:crypto";
5
+
6
+ export async function projectFile(projectPath, value) {
7
+ const root = await realpath(resolve(projectPath));
8
+ const candidate = resolve(root, value);
9
+ const suffix = [];
10
+ let probe = candidate;
11
+ while (true) {
12
+ try { probe = resolve(await realpath(probe), ...suffix.reverse()); break; }
13
+ catch (error) {
14
+ if (error.code !== "ENOENT") throw error;
15
+ suffix.push(basename(probe));
16
+ const parent = dirname(probe);
17
+ if (parent === probe) throw error;
18
+ probe = parent;
19
+ }
20
+ }
21
+ for (const path of [candidate, probe]) {
22
+ const rel = relative(root, path);
23
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new Error("Path must resolve inside projectPath.");
24
+ }
25
+ return candidate;
26
+ }
27
+
28
+ export async function readBoundedFile(path, maxBytes, signal) {
29
+ signal?.throwIfAborted();
30
+ const file = await open(path, "r");
31
+ try {
32
+ const details = await file.stat();
33
+ if (!details.isFile() || details.size > maxBytes) throw new Error(`File exceeds the ${maxBytes} byte safety limit or is not a regular file.`);
34
+ // Detect growth after stat without an unbounded readFile.
35
+ const bytes = Buffer.alloc(Math.min(details.size + 1, maxBytes + 1));
36
+ let count = 0;
37
+ while (count < bytes.length) {
38
+ signal?.throwIfAborted();
39
+ const { bytesRead } = await file.read(bytes, count, bytes.length - count, null);
40
+ if (!bytesRead) break;
41
+ count += bytesRead;
42
+ }
43
+ if (count > details.size || count > maxBytes) throw new Error("File changed during bounded read; retry capture.");
44
+ return bytes.subarray(0, count);
45
+ } finally { await file.close(); }
46
+ }
47
+
48
+ export function parseJson(source) { return JSON.parse(String(source).replace(/^\uFEFF/, "")); }
49
+
50
+ export async function atomicProjectWrite(projectPath, value, content) {
51
+ const path = await projectFile(projectPath, value);
52
+ await mkdir(dirname(path), { recursive: true });
53
+ const temporary = `${path}.tmp-${randomUUID()}`;
54
+ try {
55
+ const file = await open(await projectFile(projectPath, temporary), "wx", 0o600);
56
+ try { await file.writeFile(content, "utf8"); } finally { await file.close(); }
57
+ await projectFile(projectPath, value);
58
+ await rename(temporary, path);
59
+ } finally { await unlink(temporary).catch((error) => { if (error.code !== "ENOENT") throw error; }); }
60
+ }
61
+
62
+ export async function withProjectLock(projectPath, action, signal) {
63
+ const directory = await projectFile(projectPath, ".showcase");
64
+ await mkdir(directory, { recursive: true });
65
+ const path = await projectFile(projectPath, ".showcase/.write.lock");
66
+ const deadline = Date.now() + 30_000;
67
+ let lock;
68
+ while (!lock) {
69
+ signal?.throwIfAborted();
70
+ try { lock = await open(path, "wx", 0o600); }
71
+ catch (error) {
72
+ if (error.code !== "EEXIST") throw error;
73
+ if (Date.now() >= deadline) throw new Error("Project output is locked by another capture/generation. If its process crashed, verify it has stopped before removing .showcase/.write.lock.");
74
+ await delay(30, undefined, { signal });
75
+ }
76
+ }
77
+ try {
78
+ await lock.writeFile(JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
79
+ signal?.throwIfAborted();
80
+ return await action();
81
+ } finally { await lock.close(); await unlink(path); }
82
+ }
@@ -0,0 +1,26 @@
1
+ const rules = [
2
+ ["private-key", /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, "[REDACTED_PRIVATE_KEY]"],
3
+ ["authorization", /\b(authorization[\"']?\s*[:=]\s*[\"']?)(?:(?:bearer|basic)\s+)?[^\s,;\"'}]+/gi, "$1[REDACTED]"],
4
+ ["cookie", /\b((?:set-cookie|cookie)[\"']?\s*[:=]\s*)[^\r\n]+/gi, "$1[REDACTED_COOKIE]"],
5
+ ["token", /\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|token|password|secret)[\"']?\s*[:=]\s*(?:\"[^\"]*\"|'[^']*'|[^\s,;}]+)/gi, "[REDACTED_TOKEN]"],
6
+ ["provider-key", /\b(?:sk-(?:proj-|ant-|or-v1-)?[A-Za-z0-9_-]{16,}|AIza[0-9A-Za-z_-]{20,}|xai-[A-Za-z0-9_-]{16,}|pplx-[A-Za-z0-9_-]{16,}|hf_[A-Za-z0-9_-]{16,})\b/g, "[REDACTED_KEY]"],
7
+ ["github-token", /\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g, "[REDACTED_GITHUB_TOKEN]"],
8
+ ["url-credentials", /\b(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, "$1[REDACTED]@"],
9
+ ["windows-path", /\b[A-Za-z]:[\\/][^\s<>:"|?*]+/g, "[REDACTED_PATH]"],
10
+ ["unix-path", /(?<![\w/])\/(?:Users|home|mnt|var|tmp)\/[^\s/:]+(?:\/[^\s:]+)*/g, "[REDACTED_PATH]"],
11
+ ];
12
+
13
+ export function redact(text) {
14
+ const replacements = {};
15
+ let redacted = String(text);
16
+ for (const [name, expression, replacement] of rules) {
17
+ redacted = redacted.replace(expression, (match) => {
18
+ replacements[name] = (replacements[name] ?? 0) + 1;
19
+ return match.replace(expression, replacement);
20
+ });
21
+ }
22
+ return { text: redacted, summary: {
23
+ originalLength: text.length, redactedLength: redacted.length, replacements,
24
+ totalReplacements: Object.values(replacements).reduce((sum, count) => sum + count, 0),
25
+ } };
26
+ }
@@ -0,0 +1,67 @@
1
+ import { redact } from "./redaction.js";
2
+
3
+ export function formatReportDuration(milliseconds, locale = "zh-CN") {
4
+ if (!Number.isFinite(milliseconds) || milliseconds < 0) return locale === "en" ? "Not recorded" : "未记录";
5
+ const seconds = Math.floor(milliseconds / 1000);
6
+ const minutes = Math.floor(seconds / 60);
7
+ const hours = Math.floor(minutes / 60);
8
+ return locale === "en"
9
+ ? `${hours ? `${hours}h ` : ""}${minutes % 60}m ${seconds % 60}s`
10
+ : `${hours ? `${hours} 小时 ` : ""}${minutes % 60} 分 ${seconds % 60} 秒`;
11
+ }
12
+
13
+ // Pair only the same page and viewport. Never align unrelated captures by position.
14
+ export function findComparisonPair(shots) {
15
+ for (const after of shots) {
16
+ if (after.kind !== "after" || after.captureMode === "region") continue;
17
+ const before = shots.find((shot) => {
18
+ if (shot.kind !== "before" || shot.captureMode === "region") return false;
19
+ if (after.theme && shot.theme && after.theme !== shot.theme) return false;
20
+ const a = after.viewport, b = shot.viewport;
21
+ if (!a || !b || a.width !== b.width || a.height !== b.height) return false;
22
+ if ((after.captureMode ?? "viewport") !== (shot.captureMode ?? "viewport")) return false;
23
+ if (after.imageWidth && shot.imageWidth && after.imageWidth * shot.imageHeight !== shot.imageWidth * after.imageHeight) return false;
24
+ if (after.comparisonId || shot.comparisonId) return Boolean(after.comparisonId && after.comparisonId === shot.comparisonId);
25
+ return Boolean(after.url && shot.url && after.url.split("#")[0] === shot.url.split("#")[0]);
26
+ });
27
+ if (before) return { before, after };
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ export function sanitizeReportExport(report) {
33
+ const fields = ["version", "generatedAt", "project", "task", "git", "tests", "screenshots", "redaction", "warnings", "demo", "evidenceReview"];
34
+ const sanitize = (value) => {
35
+ if (typeof value === "string") return redact(value).text;
36
+ if (Array.isArray(value)) return value.map(sanitize);
37
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [redact(key).text, sanitize(item)]));
38
+ return value;
39
+ };
40
+ return Object.fromEntries(fields.filter((key) => report?.[key] !== undefined).map((key) => [key, sanitize(report[key])]));
41
+ }
42
+
43
+ export function reportMarkdown(report, locale = "zh-CN") {
44
+ const en = locale === "en";
45
+ const md = (value) => String(value ?? "").replace(/[\\`*_[\]<>]/g, "\\$&").replace(/[\r\n]+/g, " ");
46
+ const files = report.git?.files ?? [];
47
+ const tests = report.tests ?? [];
48
+ return [
49
+ `# ${md(report.project?.name || (en ? "Delivery report" : "交付报告"))}`,
50
+ "", ...(report.demo ? [en ? "> Demo data, not a verified project delivery." : "> 演示数据,不代表当前项目实测交付结果。", ""] : []),
51
+ md(report.task?.goal), "",
52
+ `- ${en ? "Status" : "状态"}: ${md(report.task?.status)}`,
53
+ `- ${en ? "Duration" : "耗时"}: ${formatReportDuration(report.task?.durationMs, locale)}`,
54
+ `- ${en ? "Revision" : "比较范围"}: ${md(report.git?.baseRef)}..${md(report.git?.headRef)}`,
55
+ "", `## ${en ? "Changes" : "变更清单"}`,
56
+ ...files.map((file) => `- ${md(file.path)}: +${Number(file.additions) || 0} / -${Number(file.deletions) || 0}`),
57
+ "", `## ${en ? "Verification" : "验证回执"}`,
58
+ ...tests.map((test) => `- ${md(test.command)}: ${md(test.status)} (exit ${Number(test.exitCode) || 0})`),
59
+ "", `## ${en ? "Privacy review" : "隐私审查"}`,
60
+ ...Object.entries(report.redaction?.replacements ?? {}).map(([name, count]) => `- ${md(name)}: ${Number(count) || 0}`),
61
+ "", `## ${en ? "Review queue" : "复核事项"}`, ...(report.warnings ?? []).map((warning) => `- ${md(warning)}`), "",
62
+ ].join("\n");
63
+ }
64
+
65
+ export function serializeInlineJson(value) {
66
+ return JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
67
+ }
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
 
3
3
  const isoDate = z.string().datetime();
4
4
 
5
- const taskSchema = z.object({
5
+ export const taskSchema = z.object({
6
6
  id: z.string().min(1),
7
7
  goal: z.string().min(1),
8
8
  status: z.enum(["completed", "partial", "failed"]),
@@ -11,15 +11,21 @@ const taskSchema = z.object({
11
11
  durationMs: z.number().int().nonnegative(),
12
12
  });
13
13
 
14
- const gitChangeSchema = z.object({
14
+ export const gitChangeSchema = z.object({
15
15
  baseRef: z.string().min(1).optional(),
16
16
  headRef: z.string().min(1).optional(),
17
+ baseCommit: z.string().optional(),
18
+ headCommit: z.string().optional(),
19
+ fingerprint: z.string().optional(),
17
20
  files: z.array(z.object({
18
21
  path: z.string().min(1),
19
22
  status: z.enum(["added", "modified", "deleted", "renamed", "untracked"]),
20
23
  additions: z.number().int().nonnegative(),
21
24
  deletions: z.number().int().nonnegative(),
22
25
  previousPath: z.string().min(1).optional(),
26
+ diff: z.string().max(65_536).optional(),
27
+ diffTruncated: z.boolean().optional(),
28
+ diffUnavailable: z.enum(["untracked", "binary", "oversize", "limit", "unavailable"]).optional(),
23
29
  })),
24
30
  summary: z.object({
25
31
  changedFiles: z.number().int().nonnegative(),
@@ -28,7 +34,7 @@ const gitChangeSchema = z.object({
28
34
  }),
29
35
  });
30
36
 
31
- const testReceiptSchema = z.object({
37
+ export const testReceiptSchema = z.object({
32
38
  id: z.string().min(1).optional().default("test"),
33
39
  command: z.string().min(1),
34
40
  startedAt: isoDate.optional().default("1970-01-01T00:00:00.000Z"),
@@ -36,9 +42,10 @@ const testReceiptSchema = z.object({
36
42
  exitCode: z.number().int(),
37
43
  status: z.enum(["passed", "failed", "skipped"]),
38
44
  output: z.string().optional().default(""),
45
+ outputTruncated: z.boolean().optional(),
39
46
  });
40
47
 
41
- const screenshotEvidenceSchema = z.object({
48
+ export const screenshotEvidenceSchema = z.object({
42
49
  id: z.string().min(1).optional().default("capture"),
43
50
  label: z.string().min(1).optional().default("capture"),
44
51
  // Accept the two legacy URL/theme aliases so reports created before the
@@ -53,18 +60,20 @@ const screenshotEvidenceSchema = z.object({
53
60
  imagePath: z.string().min(1),
54
61
  capturedAt: isoDate.optional().default("1970-01-01T00:00:00.000Z"),
55
62
  kind: z.enum(["after", "before"]).optional().default("after"),
63
+ deviceScaleFactor: z.number().positive().max(8).optional(),
64
+ captureMode: z.enum(["viewport", "full-page", "region"]).optional(),
65
+ sourceFingerprint: z.string().optional(),
66
+ comparisonId: z.string().min(1).max(128).optional(),
56
67
  });
57
68
 
58
- const redactionSummarySchema = z.object({
69
+ export const redactionSummarySchema = z.object({
59
70
  originalLength: z.number().int().nonnegative().optional().default(0),
60
71
  redactedLength: z.number().int().nonnegative().optional().default(0),
61
72
  replacements: z.record(z.string(), z.number().int().nonnegative()).default({}),
62
73
  totalReplacements: z.number().int().nonnegative().default(0),
63
74
  });
64
75
 
65
- // This is the runtime mirror of src/core/report-schema.ts. It lives beside
66
- // the plugin because the published plugin is plain ESM JavaScript and cannot
67
- // import the source-only TypeScript module at runtime.
76
+ // One runtime schema for the published plugin, source CLI and browser fixtures.
68
77
  export const reportSchema = z.object({
69
78
  version: z.literal(1),
70
79
  generatedAt: isoDate,
@@ -74,6 +83,7 @@ export const reportSchema = z.object({
74
83
  tests: z.array(testReceiptSchema),
75
84
  screenshots: z.array(screenshotEvidenceSchema),
76
85
  redaction: redactionSummarySchema,
86
+ warnings: z.array(z.string()).optional(),
77
87
  }).passthrough();
78
88
 
79
89
  export function formatReportIssues(error) {