feat: simplify assurance reports with progressive disclosure

This commit is contained in:
thanhnv
2026-07-29 11:55:49 +07:00
parent a7b1c604d5
commit 1d6384d23c
14 changed files with 431 additions and 123 deletions
+1
View File
@@ -77,6 +77,7 @@ o6.txt
o7.txt
t6.txt
t7.txt
tmp/design-qa/
# TS build cache
*.tsbuildinfo
+1 -1
View File
@@ -1 +1 @@
1.0.6
1.0.7
+78
View File
@@ -0,0 +1,78 @@
# CASAN report redesign — Design QA
## Comparison target
- Source visual truth:
- `/Users/thanhnguyen/Downloads/hình ảnh (1).png` — 1844 × 862 px.
- `/Users/thanhnguyen/Downloads/hình ảnh (2).png` — 1796 × 795 px.
- Rendered implementation:
- `tmp/design-qa/h6-default-final.png` — 866 × 2165 px.
- Product state: Core Local Assurance Viewer, H6, stale/insufficient telemetry,
six successful runs, technical disclosures collapsed.
- Browser viewport: 866 × 1043 CSS px, DPR 2. The browser capture is stored at
CSS-pixel width; the reference screens are wider desktop compositions, so
comparison is by hierarchy, density and visualization pattern rather than
pixel-identical scaling.
- Responsive verification: 390 × 844 CSS px, document `scrollWidth` 390 px.
## Full-view comparison evidence
The two reference reports and the final implementation were opened together in
one comparison input. CASAN intentionally preserves the references' strongest
patterns—headline metrics, measured distributions, explicit provenance and
forensic detail—while moving from an all-dark engineering sheet to the existing
CASAN light decision workspace.
## Required fidelity surfaces
- Fonts and typography: the references use dense monospace throughout. CASAN
intentionally uses the existing system sans hierarchy for decision content
and reserves monospace for IDs, timestamps and evidence values. Headings,
labels and body copy wrap without clipping.
- Spacing and layout rhythm: four executive metrics, decision findings, outcome
distribution and evidence quality form a consistent vertical sequence.
Technical content is removed from the default reading path.
- Colors and tokens: navy, cyan, green, amber and red follow the existing CASAN
semantic palette. Every colored state also has a visible label or value.
- Image and asset fidelity: neither reference contains raster product imagery.
No placeholder image, generated asset or replacement illustration is needed.
- Copy and content: all values and warnings come from canonical report data.
Missing token/cost values remain unavailable and no maturity claim is inferred.
## Focused-region comparison
The final full-page capture is readable at original resolution, including the
executive metrics, decision findings, outcome bar, coverage bars and collapsed
technical disclosure. A separate crop was not needed.
## Interaction and accessibility checks
- Filter and technical sections are native `details`/`summary` controls.
- Technical telemetry becomes visible when expanded and hidden when collapsed.
- Responsive navigation opens from one labeled button and closes with Escape.
- At 390 px the document has no horizontal overflow.
- Reduced-motion CSS remains present.
- Browser console warning/error check returned no entries.
## Comparison history
### Iteration 1
- Finding: **P2 — tablet navigation consumed too much report width** at the
default 866 px browser viewport.
- Evidence: `tmp/design-qa/h6-default.png`.
- Fix: raised the local viewer navigation-collapse breakpoint from 820 px to
960 px.
### Iteration 2
- Post-fix evidence: `tmp/design-qa/h6-default-final.png`.
- Result: the report uses the full tablet canvas, the menu remains keyboard
operable, all content is readable, and no actionable P0/P1/P2 mismatch remains.
## Follow-up polish
- A multi-run trend chart can be added only after the report contract exposes a
truthful ordered series. It must not be inferred from incomplete snapshots.
final result: passed
@@ -78,6 +78,25 @@ Control Plane. `casan view` remains the predictable Core single-project viewer.
The UI shell itself is non-sensitive and may load without a token. All project
metadata, evidence and exports require the session token.
## Presentation and disclosure policy
Reports use two information layers:
1. the executive layer shows verdict, governed volume, reliability, latency,
evidence quality, decision findings and measured distributions;
2. the technical layer preserves filters, provider counters, canonical paths,
event history, sanitized manifests and raw breakdown tables behind native
disclosure controls.
The technical layer is collapsed by default in interactive viewers. It remains
available for investigation and is included in print-ready export appendices.
This is progressive disclosure, not evidence deletion.
Every visual must be derived from the report contract. Missing token, cost,
freshness or provenance values remain unavailable; the UI must not invent a
zero, trend, governance verdict or maturity claim. Charts include visible
labels and values so meaning does not depend on color alone.
## Production acceptance gate
The Core viewer is releasable only when all of these pass:
@@ -32,6 +32,18 @@ function coverage(label: string, value: number, detail: string): string {
</div>`;
}
function distributionRows(report: H6Report): string {
const maximum = Math.max(...report.details.by_status.map((row) => row.count), 1);
if (report.details.by_status.length === 0) return '<p class="empty">No measured outcome distribution is available.</p>';
return `<ol class="distribution">${report.details.by_status.map((row) => {
const value = Math.max(row.count, 0);
const width = value === 0 ? 0 : Math.max((value / maximum) * 100, 3);
const tone = row.status === 'failed' ? 'fail' : row.status === 'degraded' ? 'attention' : 'pass';
const share = report.summary.runs ? Math.round((value / report.summary.runs) * 100) : 0;
return `<li><div><span><strong>${escapeHtml(row.status.replaceAll('_', ' '))}</strong><small>${share}% of selected runs</small></span><b>${number(value)}</b></div><i><em class="${tone}" style="width:${width}%"></em></i></li>`;
}).join('')}</ol>`;
}
export function renderH6ReportHtml(report: H6Report): string {
const verdictClass = report.verdict === 'pass' ? 'pass' : report.verdict === 'fail' ? 'fail' : report.verdict === 'attention' ? 'attention' : 'neutral';
const scope = [
@@ -59,6 +71,7 @@ export function renderH6ReportHtml(report: H6Report): string {
const warnings = report.data_quality.warnings.length
? report.data_quality.warnings.map((warning) => `<li>${escapeHtml(warning)}</li>`).join('')
: '<li class="positive">All required sources are present and no estimation warning was detected.</li>';
const outcomes = distributionRows(report);
return `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
@@ -73,10 +86,12 @@ export function renderH6ReportHtml(report: H6Report): string {
.section{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.section-head{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;border-bottom:1px solid #f2f4f7;padding:17px 20px}.kicker{color:#087e8b;font-size:9px;font-weight:900;letter-spacing:.16em;text-transform:uppercase}.section-head h2{margin:3px 0 0;font-size:16px;letter-spacing:-.01em}.section-head p{max-width:580px;margin:3px 0 0;color:var(--muted);font-size:10px}.section-body{padding:18px 20px}
.findings{display:grid;gap:10px}.finding{display:grid;grid-template-columns:38px minmax(0,1fr);gap:12px;border:1px solid #eaecf0;border-left:4px solid var(--cyan);border-radius:10px;padding:12px}.finding.warning{border-left-color:var(--amber);background:#fffcf5}.finding.critical{border-left-color:var(--red);background:#fffbfa}.finding.success{border-left-color:var(--green);background:#f6fef9}.finding-index{color:#98a2b3;font:15px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head{display:flex;align-items:center;justify-content:space-between;gap:12px}.finding-head strong{font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.finding-head code{color:#667085;font-size:8px}.finding p{margin:5px 0 0;color:#475467;font-size:11px}
.quality-grid{display:grid;grid-template-columns:.75fr 1.25fr;gap:20px}.coverage+.coverage{margin-top:20px}.coverage-head{display:flex;justify-content:space-between;gap:12px}.coverage-head span{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.coverage-head strong{font:13px ui-monospace,SFMono-Regular,Menlo,monospace}.track{height:6px;margin-top:7px;overflow:hidden;border-radius:99px;background:#f2f4f7}.track i{display:block;height:100%;border-radius:99px;background:#d0d5dd}.track i.good{background:var(--green)}.track i.warn{background:var(--amber)}.coverage small{display:block;margin-top:6px;color:#98a2b3}.disclosure{border:1px solid #eaecf0;border-radius:12px;background:#f9fafb;padding:14px}.disclosure strong{font-size:10px;text-transform:uppercase;letter-spacing:.1em}.disclosure ul{margin:10px 0 0;padding-left:17px;color:#475467;font-size:10px}.disclosure li+li{margin-top:6px}.disclosure .positive{color:#027a48}
.distribution{display:grid;gap:15px;margin:0;padding:0;list-style:none}.distribution li>div{display:flex;align-items:end;justify-content:space-between;gap:16px}.distribution span strong,.distribution span small{display:block}.distribution span strong{font-size:11px;text-transform:capitalize}.distribution span small{margin-top:3px;color:var(--muted);font-size:9px}.distribution b{font:10px ui-monospace,SFMono-Regular,Menlo,monospace}.distribution i{display:block;overflow:hidden;height:7px;margin-top:7px;border-radius:99px;background:#eaecf0}.distribution em{display:block;height:100%;border-radius:inherit;background:var(--cyan)}.distribution em.pass{background:var(--green)}.distribution em.attention{background:var(--amber)}.distribution em.fail{background:var(--red)}
.technical{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.technical>summary{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:17px 20px;cursor:pointer;list-style:none}.technical>summary::-webkit-details-marker{display:none}.technical>summary strong,.technical>summary small{display:block}.technical>summary strong{font-size:13px}.technical>summary small{margin-top:3px;color:var(--muted);font-size:10px}.technical>summary b{color:var(--soft);font-size:18px;font-weight:400}.technical[open]>summary b{transform:rotate(45deg)}.technical-body{border-top:1px solid #f2f4f7;padding:18px 20px}.technical .summary{margin:0 0 16px}.technical .section{box-shadow:none}
.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;font-size:10px}th{border-block:1px solid #eaecf0;background:#f9fafb;padding:9px 10px;color:#667085;font-size:8px;letter-spacing:.1em;text-align:left;text-transform:uppercase}td{border-bottom:1px solid #f2f4f7;padding:10px;vertical-align:top}tr:last-child td{border-bottom:0}.numeric{text-align:right;font:9px ui-monospace,SFMono-Regular,Menlo,monospace}.negative{color:#b42318;font-weight:700}td code{display:block;max-width:420px;color:#98a2b3;font-size:8px;word-break:break-all}.status{display:inline-flex;border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;text-transform:uppercase}.status.fresh{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.status.stale{border-color:#fedf89;background:#fffaeb;color:#b54708}.status.missing{border-color:#fecdca;background:#fef3f2;color:#b42318}.empty{padding:30px;color:#98a2b3;text-align:center}
.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-foot{display:block}.document-meta{margin-top:18px;text-align:left}.summary{grid-template-columns:repeat(2,1fr)}.quality-grid{grid-template-columns:1fr}.finding-head{display:block}.finding-head code{display:block;margin-top:4px}}
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.section,.metric,.finding{break-inside:avoid}.cover h1{font-size:30px}}
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section,.technical{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.section,.metric,.finding{break-inside:avoid}.cover h1{font-size:30px}.technical>summary b{display:none}.technical-body{display:block!important}}
</style></head><body><main>
<header class="cover">
<div class="eyebrow">CASAN · H6 AgentOps assurance</div>
@@ -93,31 +108,33 @@ export function renderH6ReportHtml(report: H6Report): string {
<div class="metric"><span>Failure rate</span><strong>${report.summary.failure_rate_pct}%</strong><small>${number(report.summary.failed)} failed · ${number(report.summary.alerts)} alerts</small></div>
<div class="metric"><span>P95 latency</span><strong>${number(report.summary.latency_ms.p95)} ms</strong><small>P50 ${number(report.summary.latency_ms.p50)} ms</small></div>
<div class="metric"><span>Data quality</span><strong>${escapeHtml(report.data_quality.status.replaceAll('_', ' '))}</strong><small>Coverage disclosed below</small></div>
<div class="metric"><span>Provider tokens</span><strong>${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : '<span class="unavailable">Unavailable</span>'}</strong><small>${report.summary.coverage.token_pct}% record coverage</small></div>
<div class="metric"><span>Actual provider cost</span><strong>${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : '<span class="unavailable">Unavailable</span>'}</strong><small>${number(report.summary.provider_calls)} provider calls</small></div>
<div class="metric"><span>Cost attribution</span><strong>${report.summary.coverage.cost_pct}%</strong><small>${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records</small></div>
<div class="metric"><span>Retries</span><strong>${number(report.summary.retries)}</strong><small>Observed reroute attempts</small></div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Decision brief</div><h2>What requires attention</h2><p>Threshold findings are operational review signals, not maturity claims.</p></div><span class="status ${verdictClass}">${escapeHtml(report.verdict)}</span></div>
<div class="section-body"><div class="findings">${findings}</div></div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Outcome mix</div><h2>Run distribution</h2><p>Canonical runtime status values, normalized against the largest observed group.</p></div><span class="status">${number(report.summary.runs)} total</span></div>
<div class="section-body">${outcomes}</div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Evidence quality</div><h2>Coverage and disclosure</h2><p>Unavailable telemetry remains null and is never rendered as zero.</p></div><span class="status">${escapeHtml(report.data_quality.status)}</span></div>
<div class="section-body"><div class="quality-grid"><div>${coverage('Token attribution', report.summary.coverage.token_pct, `${report.summary.coverage.token_records}/${report.summary.coverage.runtime_records} runtime records`)}${coverage('Cost attribution', report.summary.coverage.cost_pct, `${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records`)}</div><div class="disclosure"><strong>Disclosure notes</strong><ul>${warnings}</ul></div></div></div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Source integrity</div><h2>Evidence provenance</h2><p>Freshness and presence are evaluated independently for every canonical source.</p></div></div>
<div class="table-wrap"><table><thead><tr><th>Source</th><th>State</th><th class="numeric">Age</th><th class="numeric">Records</th><th>Canonical path</th></tr></thead><tbody>${sourceRows}</tbody></table></div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Runtime</div><h2>Execution profile by step</h2></div></div>
<div class="table-wrap"><table><thead><tr><th>Step</th><th class="numeric">Runs</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_step)}</tbody></table></div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Provider</div><h2>Model usage and provenance</h2><p>Only provider-reported token and actual cost values are shown.</p></div></div>
<div class="table-wrap"><table><thead><tr><th>Provider · model</th><th class="numeric">Calls</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_provider)}</tbody></table></div>
</section>
<details class="technical">
<summary><span><strong>Technical evidence and breakdowns</strong><small>Provider counters, canonical paths and raw tables are preserved for audit.</small></span><b>+</b></summary>
<div class="technical-body">
<section class="summary">
<div class="metric"><span>Provider tokens</span><strong>${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : '<span class="unavailable">Unavailable</span>'}</strong><small>${report.summary.coverage.token_pct}% record coverage</small></div>
<div class="metric"><span>Actual provider cost</span><strong>${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : '<span class="unavailable">Unavailable</span>'}</strong><small>${number(report.summary.provider_calls)} provider calls</small></div>
<div class="metric"><span>Cost attribution</span><strong>${report.summary.coverage.cost_pct}%</strong><small>${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records</small></div>
<div class="metric"><span>Retries</span><strong>${number(report.summary.retries)}</strong><small>Observed reroute attempts</small></div>
</section>
<section class="section"><div class="section-head"><div><div class="kicker">Source integrity</div><h2>Evidence provenance</h2></div></div><div class="table-wrap"><table><thead><tr><th>Source</th><th>State</th><th class="numeric">Age</th><th class="numeric">Records</th><th>Canonical path</th></tr></thead><tbody>${sourceRows}</tbody></table></div></section>
<section class="section"><div class="section-head"><div><div class="kicker">Runtime</div><h2>Execution profile by step</h2></div></div><div class="table-wrap"><table><thead><tr><th>Step</th><th class="numeric">Runs</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_step)}</tbody></table></div></section>
<section class="section"><div class="section-head"><div><div class="kicker">Provider</div><h2>Model usage and provenance</h2></div></div><div class="table-wrap"><table><thead><tr><th>Provider · model</th><th class="numeric">Calls</th><th class="numeric">Failures</th><th class="numeric">Avg latency</th><th class="numeric">Tokens</th><th class="numeric">Cost</th></tr></thead><tbody>${breakdownRows(report.details.by_provider)}</tbody></table></div></section>
</div>
</details>
<footer class="footer"><span>Generated from CASAN runtime evidence. No maturity score or telemetry value is hard-coded in this document.</span><span>Contract v${report.schema_version} · ${escapeHtml(report.report_id)}</span></footer>
</main></body></html>`;
}
@@ -60,8 +60,9 @@ main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relat
.spine{position:relative;display:grid;grid-template-columns:repeat(7,minmax(0,1fr));padding:24px 18px 20px}.spine:before{position:absolute;left:8%;right:8%;top:43px;height:1px;background:var(--line);content:""}.spine-node{position:relative;text-align:center}.spine-dot{display:flex;width:38px;height:38px;margin:0 auto;align-items:center;justify-content:center;border:2px solid #d0d5dd;border-radius:99px;background:#fff;color:#667085;font-weight:900;box-shadow:0 0 0 5px #fff}.spine-node.pass .spine-dot{border-color:#6ce9a6;color:#027a48}.spine-node.warn .spine-dot{border-color:#fec84b;color:#b54708}.spine-node.fail .spine-dot{border-color:#fda29b;color:#b42318}.spine-node strong{display:block;margin-top:8px;color:#98a2b3;font-size:9px;letter-spacing:.13em}.spine-node small{display:block;margin-top:3px;overflow:hidden;color:#344054;font-size:9px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}
.controls{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;padding:18px}.gate{display:grid;grid-template-columns:48px minmax(0,1fr);overflow:hidden;border:1px solid var(--line);border-radius:14px;background:#fff;break-inside:avoid}.gate-index{display:flex;align-items:flex-start;justify-content:center;border-right:1px solid var(--line);padding-top:18px;color:#98a2b3;font-size:12px;font-weight:900}.gate.pass{border-top:3px solid var(--green)}.gate.warn{border-top:3px solid var(--amber)}.gate.fail{border-top:3px solid var(--red)}.gate-copy{padding:15px}.gate-heading{display:flex;align-items:center;justify-content:space-between;gap:10px}.gate-heading h3{margin:0;font-size:14px}.badge{border:1px solid #d0d5dd;border-radius:999px;padding:3px 7px;color:#475467;font-size:8px;font-weight:900;letter-spacing:.08em;text-transform:uppercase}.badge.pass{border-color:#a6f4c5;background:#ecfdf3;color:#027a48}.badge.warn{border-color:#fedf89;background:#fffaeb;color:#b54708}.badge.fail{border-color:#fecdca;background:#fef3f2;color:#b42318}.gate-description{min-height:34px;margin:6px 0;color:var(--muted);font-size:10px}.decision{margin-top:10px;border-left:3px solid #d0d5dd;background:#f9fafb;padding:9px 10px}.decision span,.evidence-title{display:block;color:#667085;font-size:8px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.decision strong{display:block;margin-top:4px;font-size:10px;font-weight:600}.decision small{display:block;margin-top:4px;color:#98a2b3;font:8px ui-monospace,SFMono-Regular,Menlo,monospace}.evidence-title{margin-top:12px}.manifest{margin:6px 0 0;border:1px solid #eaecf0;border-radius:8px}.manifest div{display:grid;grid-template-columns:110px minmax(0,1fr);border-bottom:1px solid #eaecf0}.manifest div:last-child{border-bottom:0}.manifest dt,.manifest dd{margin:0;padding:6px 7px;font:8px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;word-break:break-all}.manifest dt{background:#f9fafb;color:#667085;font-weight:700}.manifest dd{color:#344054}.empty-evidence{margin-top:6px;border:1px dashed #d0d5dd;border-radius:8px;padding:10px;color:#98a2b3;font-size:9px;text-align:center}
.certification{display:grid;grid-template-columns:180px minmax(0,1fr);gap:20px;padding:19px 20px}.certification label{color:#667085;font-size:9px;font-weight:900;letter-spacing:.12em;text-transform:uppercase}.certification strong{display:block;margin-top:5px;font-size:15px}.certification p{margin:0;color:#475467;font-size:11px}.footer{display:flex;justify-content:space-between;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:12px;color:#98a2b3;font-size:9px}
.technical{margin-top:16px;border:1px solid var(--line);border-radius:17px;background:var(--paper);box-shadow:0 8px 22px rgba(16,24,40,.04)}.technical>summary{display:flex;align-items:center;justify-content:space-between;gap:18px;padding:17px 20px;cursor:pointer;list-style:none}.technical>summary::-webkit-details-marker{display:none}.technical>summary strong,.technical>summary small{display:block}.technical>summary strong{font-size:13px}.technical>summary small{margin-top:3px;color:var(--muted);font-size:10px}.technical>summary b{color:var(--soft);font-size:18px;font-weight:400}.technical[open]>summary b{transform:rotate(45deg)}.technical-body{border-top:1px solid #f2f4f7;padding:18px}.technical .summary{margin:0 0 16px}.technical .section{box-shadow:none}
@media(max-width:820px){main{padding:18px 12px 44px}.cover{padding:24px}.cover h1{font-size:28px}.cover-grid{grid-template-columns:1fr}.summary{grid-template-columns:repeat(2,1fr)}.spine{min-width:720px}.spine-wrap{overflow:auto}.controls{grid-template-columns:1fr}.document-meta{grid-template-columns:1fr 1fr}.certification{grid-template-columns:1fr}}
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.spine{padding-inline:8px}.controls{grid-template-columns:repeat(2,1fr);padding:12px}.section,.metric,.gate{break-inside:avoid}.footer{position:relative}.cover h1{font-size:30px}}
@page{size:A4;margin:12mm}@media print{html,body{background:#fff}main{max-width:none;padding:0}.cover,.metric,.section,.technical{box-shadow:none}.cover{-webkit-print-color-adjust:exact;print-color-adjust:exact}.summary{grid-template-columns:repeat(4,1fr)}.spine{padding-inline:8px}.controls{grid-template-columns:repeat(2,1fr);padding:12px}.section,.metric,.gate{break-inside:avoid}.footer{position:relative}.cover h1{font-size:30px}.technical>summary b{display:none}.technical-body{display:block!important}}
</style></head><body><main>
<header class="cover">
<div class="eyebrow">CASAN · independent assurance dossier</div>
@@ -82,23 +83,27 @@ main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relat
<div class="metric"><span>Evidence source</span><strong>${evidenceState}</strong><small>Trace + graph reconstruction</small></div>
<div class="metric"><span>H6 quality</span><strong>${escapeHtml(report.summary.telemetry_quality)}</strong><small>Telemetry disclosure status</small></div>
<div class="metric"><span>Duration</span><strong>${report.summary.duration_ms === null ? 'Unavailable' : `${report.summary.duration_ms} ms`}</strong><small>End-to-end governed run</small></div>
<div class="metric"><span>Tool calls</span><strong>${report.summary.tool_calls}</strong><small>Observed side-effect attempts</small></div>
<div class="metric"><span>Failures</span><strong>${report.summary.failures}</strong><small>Recorded execution failures</small></div>
<div class="metric"><span>Token usage</span><strong>${tokenValue}</strong><small>Never inferred as zero</small></div>
<div class="metric"><span>Actual cost</span><strong>${costValue}</strong><small>Provider attribution only</small></div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Control sequence</div><h2>Live assurance rail · H1 → H7</h2><p>The evidence spine shows the terminal state of every governed control.</p></div><span class="badge ${badgeClass(report.verdict)}">${escapeHtml(report.verdict)}</span></div>
<div class="spine-wrap"><div class="spine">${spine}</div></div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Control dossiers</div><h2>Decision and evidence detail</h2><p>Every field below is sanitized before persistence. Raw prompts and tool output are excluded.</p></div></div>
<div class="controls">${gates}</div>
</section>
<section class="section">
<div class="section-head"><div><div class="kicker">Certification</div><h2>Assurance conclusion</h2></div></div>
<div class="certification"><div><label>Strength</label><strong>${escapeHtml(report.certification.strength ?? 'unknown')}</strong></div><p>${escapeHtml(report.certification.reasons.join(' · ') || 'No certification reason recorded.')}</p></div>
</section>
<details class="technical">
<summary><span><strong>Technical evidence and control dossiers</strong><small>Execution counters and sanitized field manifests are preserved for audit.</small></span><b>+</b></summary>
<div class="technical-body">
<section class="summary">
<div class="metric"><span>Tool calls</span><strong>${report.summary.tool_calls}</strong><small>Observed side-effect attempts</small></div>
<div class="metric"><span>Failures</span><strong>${report.summary.failures}</strong><small>Recorded execution failures</small></div>
<div class="metric"><span>Token usage</span><strong>${tokenValue}</strong><small>Never inferred as zero</small></div>
<div class="metric"><span>Actual cost</span><strong>${costValue}</strong><small>Provider attribution only</small></div>
</section>
<section class="section"><div class="section-head"><div><div class="kicker">Control dossiers</div><h2>Decision and evidence detail</h2><p>Every field below is sanitized before persistence. Raw prompts and tool output are excluded.</p></div></div><div class="controls">${gates}</div></section>
</div>
</details>
<footer class="footer"><span>Generated from CASAN runtime evidence. Unavailable values remain null.</span><span>Schema v${report.schema_version} · ${escapeHtml(report.trace_id)}</span></footer>
</main></body></html>`;
}
@@ -145,6 +145,8 @@ test('HTML export is standalone, escaped and contains no hard-coded maturity sco
assert.doesNotMatch(html, /Average\s+\d|\/100|218 core tests/i);
assert.match(html, /No maturity score or telemetry value is hard-coded/);
assert.match(html, /Operational evidence, ready for a decision/);
assert.match(html, /Run distribution/);
assert.match(html, /<details class="technical">/);
assert.match(html, /Evidence provenance/);
assert.match(html, /@page\{size:A4/);
});
@@ -192,6 +194,7 @@ test('per-run assurance export carries H1-H7 and truthful H6 availability', () =
assert.match(html, /Live assurance rail · H1 → H7/);
assert.match(html, /Evidence fields/);
assert.match(html, /Decision and evidence detail/);
assert.match(html, /<details class="technical">/);
assert.match(html, /@page\{size:A4/);
assert.match(html, /Unavailable/);
assert.doesNotMatch(html, /\$0(?:\.0+)?/);
@@ -172,5 +172,80 @@ export function CoverageBar({
);
}
export function DistributionBars({
label,
rows,
}: {
label: string;
rows: Array<{
label: string;
value: number;
detail?: ReactNode;
tone?: Tone;
}>;
}) {
const maximum = Math.max(...rows.map((row) => row.value), 1);
return (
<div role="img" aria-label={label}>
<ol className="space-y-4">
{rows.map((row) => {
const bounded = Math.max(row.value, 0);
const width = bounded === 0 ? 0 : Math.max((bounded / maximum) * 100, 3);
const tone = row.tone ?? 'info';
return (
<li key={row.label}>
<div className="flex items-end justify-between gap-4">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-slate-800">{row.label}</p>
{row.detail && <div className="mt-1 text-[11px] text-slate-500">{row.detail}</div>}
</div>
<span className="shrink-0 font-mono text-xs font-semibold text-slate-900">{integerLabel(bounded)}</span>
</div>
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-100" aria-hidden="true">
<div className={`h-full rounded-full ${TONE[tone].line}`} style={{ width: `${width}%` }} />
</div>
</li>
);
})}
</ol>
{rows.length === 0 && <p className="text-sm text-slate-500">No measured distribution is available.</p>}
</div>
);
}
function integerLabel(value: number): string {
return new Intl.NumberFormat('en-US').format(value);
}
export function DisclosurePanel({
summary,
description,
badge,
children,
open = false,
}: {
summary: string;
description: string;
badge?: ReactNode;
children: ReactNode;
open?: boolean;
}) {
return (
<details open={open} className="group overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-[0_10px_28px_rgba(15,23,42,0.04)]">
<summary className="flex min-h-16 cursor-pointer list-none items-center justify-between gap-4 px-5 py-4 outline-none transition hover:bg-slate-50 focus-visible:ring-4 focus-visible:ring-inset focus-visible:ring-cyan-100 [&::-webkit-details-marker]:hidden sm:px-6">
<div>
<p className="text-sm font-semibold text-slate-900">{summary}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{description}</p>
</div>
<div className="flex shrink-0 items-center gap-3">
{badge}
<span className="select-none text-lg text-slate-400 transition-transform group-open:rotate-45" aria-hidden="true">+</span>
</div>
</summary>
<div className="border-t border-slate-100 p-5 sm:p-6">{children}</div>
</details>
);
}
export const reportActionPrimary = 'inline-flex min-h-11 items-center justify-center rounded-xl bg-white px-4 py-2.5 text-xs font-bold text-slate-950 shadow-sm transition hover:-translate-y-px hover:bg-cyan-50 focus:outline-none focus:ring-2 focus:ring-cyan-300';
export const reportActionSecondary = 'inline-flex min-h-11 items-center justify-center rounded-xl border border-slate-700 bg-slate-900/70 px-4 py-2.5 text-xs font-bold text-slate-200 transition hover:-translate-y-px hover:border-slate-500 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-cyan-400';
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api, runReportExportUrl, type HarnessGateNode, type HarnessTraceGraph } from '../../lib/api';
import { ReportPanel } from '../report/ReportPrimitives';
import { DisclosurePanel, ReportPanel } from '../report/ReportPrimitives';
import { StatusBadge } from '../ui/Card';
import { AssuranceRail } from './AssuranceRail';
@@ -35,8 +35,10 @@ function GateDossier({ node }: { node: HarnessGateNode }) {
</div>
<p className="mt-3 text-sm font-medium leading-6 text-slate-800">{node.reason || 'No decision reason was emitted.'}</p>
</div>
<div className="rounded-2xl border border-slate-200 bg-white p-4">
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-slate-500">Event history</p>
<DisclosurePanel
summary="Event history"
description={`${node.events.length} sanitized lifecycle events · hidden by default`}
>
<div className="mt-3 space-y-3">
{node.events.slice(-4).reverse().map((event, index) => (
<div key={`${event.timestamp}-${index}`} className="grid grid-cols-[0.65rem_minmax(0,1fr)] gap-3">
@@ -55,33 +57,30 @@ function GateDossier({ node }: { node: HarnessGateNode }) {
))}
{node.events.length === 0 && <p className="text-sm text-slate-500">No individual event was emitted for this control.</p>}
</div>
</div>
</DisclosurePanel>
</div>
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950 text-slate-100 shadow-[0_18px_50px_rgba(15,23,42,0.18)]">
<div className="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-cyan-300">Safe evidence manifest</p>
<p className="mt-1 text-xs text-slate-500">Sanitized fields persisted by the harness</p>
</div>
<span className="rounded-full border border-slate-700 bg-slate-900 px-2.5 py-1 font-mono text-[10px] text-slate-400">{entries.length} fields</span>
</div>
<DisclosurePanel
summary="Safe evidence manifest"
description="Sanitized fields persisted by the harness · hidden by default"
badge={<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 font-mono text-[10px] text-slate-500">{entries.length} fields</span>}
>
{entries.length > 0 ? (
<dl className="divide-y divide-slate-800">
<dl className="-m-5 divide-y divide-slate-100 sm:-m-6">
{entries.map(([key, value]) => (
<div key={key} className="grid gap-1 px-5 py-3.5 sm:grid-cols-[10rem_minmax(0,1fr)] sm:gap-4">
<dt className="font-mono text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-500">{key}</dt>
<dd className="break-all font-mono text-xs leading-5 text-slate-200"><EvidenceValue value={value} /></dd>
<dd className="break-all font-mono text-xs leading-5 text-slate-700"><EvidenceValue value={value} /></dd>
</div>
))}
</dl>
) : (
<div className="px-5 py-12 text-center">
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-slate-700 text-slate-500">∅</div>
<p className="mt-3 text-sm text-slate-400">No safe evidence fields are available yet.</p>
<div className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 text-slate-400">∅</div>
<p className="mt-3 text-sm text-slate-500">No safe evidence fields are available yet.</p>
</div>
)}
</div>
</DisclosurePanel>
</div>
);
}
@@ -3,6 +3,8 @@ import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
CoverageBar,
DisclosurePanel,
DistributionBars,
ReportHero,
ReportPanel,
SignalMetric,
@@ -140,11 +142,10 @@ export function H6ReportPage() {
) : undefined}
/>
<ReportPanel
eyebrow="Scope"
title="Evidence boundary"
description="Filters are applied server-side to the screen, JSON contract and print-ready dossier."
right={Object.values(draft).some(Boolean) ? <span className="rounded-full bg-cyan-50 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-cyan-700">Filtered view</span> : <span className="text-xs text-slate-400">All available evidence</span>}
<DisclosurePanel
summary="Filter the evidence boundary"
description="Project, date and trace filters stay out of the executive view until they are needed."
badge={Object.values(draft).some(Boolean) ? <span className="rounded-full bg-cyan-50 px-3 py-1 text-[10px] font-black uppercase tracking-[0.12em] text-cyan-700">Filtered</span> : <span className="text-xs text-slate-400">All evidence</span>}
>
<form onSubmit={applyFilters} className="grid gap-4 md:grid-cols-2 xl:grid-cols-[1fr_1fr_1fr_1.45fr_auto] xl:items-end">
<FilterField label="Project">
@@ -168,7 +169,7 @@ export function H6ReportPage() {
<button type="button" onClick={clearFilters} className="min-h-11 rounded-xl border border-slate-300 bg-white px-4 py-2.5 text-sm font-bold text-slate-600 transition hover:bg-slate-50">Clear</button>
</div>
</form>
</ReportPanel>
</DisclosurePanel>
{report.isLoading && <LoadingReport />}
{report.isError && (
@@ -187,24 +188,17 @@ export function H6ReportPage() {
<SignalMetric label="Telemetry integrity" value={data.data_quality.status.replaceAll('_', ' ')} detail={`${data.summary.coverage.token_pct}% token · ${data.summary.coverage.cost_pct}% cost coverage`} tone={reportTone(data.data_quality.status)} primary />
</section>
<section className="grid gap-3 rounded-2xl border border-slate-200 bg-white p-4 shadow-[0_12px_32px_rgba(15,23,42,0.04)] sm:grid-cols-2 lg:grid-cols-4">
<div className="border-b border-slate-100 p-2 sm:border-b-0 sm:border-r">
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Provider tokens</p>
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{data.summary.coverage.token_records > 0 ? integer(data.summary.tokens.provider_total ?? data.summary.tokens.total ?? 0) : 'Unavailable'}</p>
</div>
<div className="border-b border-slate-100 p-2 sm:border-b-0 lg:border-r">
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Actual cost</p>
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{data.summary.cost_usd.provider_actual !== null ? money(data.summary.cost_usd.provider_actual) : 'Unavailable'}</p>
</div>
<div className="border-b border-slate-100 p-2 sm:border-b-0 sm:border-r">
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Provider calls</p>
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{integer(data.summary.provider_calls)}</p>
</div>
<div className="p-2">
<p className="text-[10px] font-black uppercase tracking-[0.14em] text-slate-400">Retries</p>
<p className="mt-2 font-mono text-lg font-semibold text-slate-900">{integer(data.summary.retries)}</p>
</div>
</section>
<DisclosurePanel
summary="Provider telemetry"
description="Token, cost, call and retry counters are technical evidence, hidden by default."
>
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<SignalMetric label="Provider tokens" value={data.summary.coverage.token_records > 0 ? integer(data.summary.tokens.provider_total ?? data.summary.tokens.total ?? 0) : 'Unavailable'} detail={`${data.summary.coverage.token_pct}% record coverage`} tone={data.summary.coverage.token_records > 0 ? 'info' : 'warning'} />
<SignalMetric label="Actual cost" value={data.summary.cost_usd.provider_actual !== null ? money(data.summary.cost_usd.provider_actual) : 'Unavailable'} detail={`${data.summary.coverage.cost_pct}% record coverage`} tone={data.summary.cost_usd.provider_actual !== null ? 'info' : 'warning'} />
<SignalMetric label="Provider calls" value={integer(data.summary.provider_calls)} detail="Provider-attributed requests" />
<SignalMetric label="Retries" value={integer(data.summary.retries)} detail="Observed reroute attempts" tone={data.summary.retries > 0 ? 'warning' : 'neutral'} />
</section>
</DisclosurePanel>
<div className="grid gap-5 xl:grid-cols-[1.18fr_0.82fr]">
<ReportPanel
@@ -236,37 +230,20 @@ export function H6ReportPage() {
</ReportPanel>
<ReportPanel
eyebrow="Source integrity"
title="Evidence provenance"
description="Freshness is evaluated independently for every canonical source."
right={<StatusBadge value={data.freshness.status} />}
eyebrow="Outcome mix"
title="Run distribution"
description="Counts are grouped from canonical runtime status values; the longest bar is the largest observed group."
right={<span className="font-mono text-xs text-slate-400">{integer(data.summary.runs)} total</span>}
>
<div className="space-y-3">
{data.evidence_sources.map((source) => {
const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh';
return (
<article key={source.source} className="rounded-xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h3 className="text-sm font-semibold capitalize text-slate-900">{source.source}</h3>
<p className="mt-1 font-mono text-[10px] text-slate-400">{source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}</p>
</div>
<StatusBadge value={state} />
</div>
<p className="mt-3 break-all border-t border-slate-100 pt-3 font-mono text-[10px] leading-4 text-slate-400">{source.path}</p>
</article>
);
})}
</div>
</ReportPanel>
</div>
<div className="grid gap-5 xl:grid-cols-2">
<ReportPanel eyebrow="Runtime" title="Execution profile by step" description="Latency, failure and attribution by governed lifecycle step.">
<BreakdownTable rows={data.details.by_step} subject="Step" />
</ReportPanel>
<ReportPanel eyebrow="Provider" title="Model usage and provenance" description="Only provider-reported token and actual cost values are shown.">
<BreakdownTable rows={data.details.by_provider} subject="Provider · model" />
<DistributionBars
label="Governed run outcome distribution"
rows={data.details.by_status.map((row) => ({
label: row.status.replaceAll('_', ' '),
value: row.count,
detail: `${data.summary.runs ? Math.round((row.count / data.summary.runs) * 100) : 0}% of selected runs`,
tone: row.status === 'failed' ? 'danger' as const : row.status === 'degraded' ? 'warning' as const : 'success' as const,
}))}
/>
</ReportPanel>
</div>
@@ -297,17 +274,47 @@ export function H6ReportPage() {
</div>
</ReportPanel>
<div className="rounded-2xl border border-slate-800 bg-slate-950 p-5 text-white shadow-[0_18px_48px_rgba(15,23,42,0.16)] sm:flex sm:items-center sm:justify-between sm:gap-6 sm:p-6">
<div>
<p className="text-[10px] font-black uppercase tracking-[0.18em] text-cyan-300">Independent evidence package</p>
<h2 className="mt-2 text-lg font-semibold">Same contract. Two review formats.</h2>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-400">JSON is machine-auditable. HTML is a self-contained, print-ready assurance dossier generated only when requested.</p>
<DisclosurePanel
summary="Technical evidence and breakdowns"
description="Canonical paths, step tables and provider attribution are preserved for audit without crowding the decision view."
badge={<StatusBadge value={data.freshness.status} />}
>
<div className="space-y-6">
<section>
<div className="mb-4">
<p className="text-[10px] font-black uppercase tracking-[0.16em] text-cyan-700">Source integrity</p>
<h3 className="mt-1 text-base font-semibold text-slate-900">Evidence provenance</h3>
</div>
<div className="grid gap-3 lg:grid-cols-2">
{data.evidence_sources.map((source) => {
const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh';
return (
<article key={source.source} className="rounded-xl border border-slate-200 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<h4 className="text-sm font-semibold capitalize text-slate-900">{source.source}</h4>
<p className="mt-1 font-mono text-[10px] text-slate-400">{source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}</p>
</div>
<StatusBadge value={state} />
</div>
<p className="mt-3 break-all border-t border-slate-100 pt-3 font-mono text-[10px] leading-4 text-slate-400">{source.path}</p>
</article>
);
})}
</div>
</section>
<div className="grid gap-5 xl:grid-cols-2">
<div>
<h3 className="mb-3 text-sm font-semibold text-slate-900">Execution profile by step</h3>
<BreakdownTable rows={data.details.by_step} subject="Step" />
</div>
<div>
<h3 className="mb-3 text-sm font-semibold text-slate-900">Model usage and provenance</h3>
<BreakdownTable rows={data.details.by_provider} subject="Provider · model" />
</div>
</div>
</div>
<div className="mt-5 flex shrink-0 flex-wrap gap-2 sm:mt-0">
<a href={h6ReportExportUrl(query, 'json')} className={reportActionSecondary}>Download JSON</a>
<a href={h6ReportExportUrl(query, 'html')} className={reportActionPrimary}>Download HTML</a>
</div>
</div>
</DisclosurePanel>
</>
)}
</div>
@@ -137,6 +137,28 @@ main { max-width: 1500px; margin: 0 auto; padding: 28px clamp(20px,4vw,48px) 64p
.panel h2 { margin: 6px 0 0; font-size: 19px; letter-spacing: -.025em; }
.panel-description { max-width: 720px; margin: 7px 0 0; color: var(--muted); font-size: 12px; line-height: 1.6; }
.panel-body { border-top: 1px solid #f2f4f7; padding: 22px 24px 24px; }
.disclosure-panel {
overflow: hidden; border: 1px solid var(--line); border-radius: 18px; background: white;
box-shadow: 0 10px 28px rgba(16,24,40,.04);
}
.disclosure-panel > summary {
display: flex; min-height: 66px; align-items: center; justify-content: space-between; gap: 18px;
padding: 15px 20px; list-style: none; cursor: pointer; transition: background .16s ease;
}
.disclosure-panel > summary::-webkit-details-marker { display: none; }
.disclosure-panel > summary:hover { background: #f9fafb; }
.disclosure-panel > summary strong, .disclosure-panel > summary small { display: block; }
.disclosure-panel > summary strong { color: #344054; font-size: 13px; }
.disclosure-panel > summary small { margin-top: 5px; color: var(--muted); font-size: 11px; line-height: 1.45; }
.disclosure-side { display: flex; flex-shrink: 0; align-items: center; gap: 10px; }
.disclosure-side > b { color: var(--faint); font-size: 20px; font-weight: 400; transition: transform .16s ease; }
.disclosure-panel[open] .disclosure-side > b { transform: rotate(45deg); }
.disclosure-body { border-top: 1px solid #f2f4f7; padding: 20px; }
.disclosure-body > dl { margin: -20px; }
.disclosure-panel .manifest-row { border-bottom: 1px solid #f2f4f7; }
.disclosure-panel .manifest-row:last-child { border-bottom: 0; }
.disclosure-panel .manifest-row dt { color: var(--faint); font: 750 9px ui-monospace,monospace; letter-spacing: .07em; text-transform: uppercase; }
.disclosure-panel .manifest-row dd { margin: 0; overflow-wrap: anywhere; color: #344054; font: 11px/1.5 ui-monospace,monospace; }
.two-col { display: grid; grid-template-columns: minmax(0,1.2fr) minmax(330px,.8fr); gap: 18px; }
.rail-wrap { overflow-x: auto; padding-bottom: 4px; }
.evidence-rail { display: grid; grid-template-columns: repeat(7,minmax(116px,1fr)); min-width: 840px; gap: 7px; }
@@ -203,6 +225,20 @@ tbody tr:hover { background: #f0fdfa; }
.source h3 { margin: 0; font-size: 12px; text-transform: capitalize; }
.source-meta, .source-path { color: var(--faint); font: 9px/1.5 ui-monospace,monospace; }
.source-meta { margin-top: 5px; }.source-path { overflow-wrap:anywhere; border-top: 1px solid #f2f4f7; margin-top: 12px; padding-top: 10px; }
.distribution { display: grid; gap: 17px; margin: 0; padding: 0; list-style: none; }
.distribution-head { display: flex; align-items: end; justify-content: space-between; gap: 16px; }
.distribution-head span strong, .distribution-head span small { display: block; }
.distribution-head span strong { color: #344054; font-size: 12px; text-transform: capitalize; }
.distribution-head span small { margin-top: 4px; color: var(--muted); font-size: 10px; }
.distribution-head > b { color: var(--ink); font: 700 11px ui-monospace,monospace; }
.distribution-track { overflow: hidden; height: 8px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
.distribution-track i { display: block; height: 100%; border-radius: inherit; background: var(--cyan); }
.distribution-track i.success { background: #12b76a; }
.distribution-track i.warning { background: #f79009; }
.distribution-track i.danger { background: #f04438; }
.metric-grid.compact .metric-card { min-height: 116px; }
.technical-section { margin-top: 24px; border-top: 1px solid #f2f4f7; padding-top: 22px; }
.technical-section h3 { margin: 6px 0 14px; font-size: 16px; }
.coverage { display: grid; gap: 18px; }
.coverage-head { display: flex; justify-content: space-between; gap: 12px; font-size: 11px; }
.coverage-track { overflow: hidden; height: 7px; margin-top: 8px; border-radius: 99px; background: #eaecf0; }
@@ -241,7 +277,7 @@ tbody tr:hover { background: #f0fdfa; }
.filters { grid-template-columns: 1fr 1fr; }
.filter-actions { grid-column: 1/-1; }
}
@media (max-width: 820px) {
@media (max-width: 960px) {
.sidebar { transform: translateX(-100%); transition: transform .2s ease; box-shadow: 18px 0 50px rgba(16,24,40,.28); }
.sidebar.open { transform: translateX(0); }
.shell { margin-left: 0; }
@@ -69,6 +69,21 @@
<div class="panel-body">${body}</div></article>`;
}
function disclosure(summary, description, body, badge = "") {
return `<details class="disclosure-panel"><summary><span><strong>${esc(summary)}</strong><small>${esc(description)}</small></span>
<span class="disclosure-side">${badge}<b aria-hidden="true">+</b></span></summary><div class="disclosure-body">${body}</div></details>`;
}
function distribution(label, rows) {
const maximum = Math.max(...rows.map((row) => Number(row.value) || 0), 1);
return `<ol class="distribution" role="img" aria-label="${esc(label)}">${rows.map((row) => {
const value = Math.max(Number(row.value) || 0, 0);
const width = value === 0 ? 0 : Math.max((value / maximum) * 100, 3);
return `<li><div class="distribution-head"><span><strong>${esc(row.label)}</strong><small>${esc(row.detail || "")}</small></span><b>${esc(number(value))}</b></div>
<div class="distribution-track" aria-hidden="true"><i class="${slug(row.tone || "info")}" style="width:${width}%"></i></div></li>`;
}).join("")}</ol>`;
}
function empty(title, detail) {
return `<div class="empty"><div class="empty-mark">∅</div><strong>${esc(title)}</strong><p>${esc(detail)}</p></div>`;
}
@@ -130,10 +145,9 @@
${metric("Observed failures", number(failures), `${recent.length ? Math.round((failures / recent.length) * 100) : 0}% of loaded records`)}
${metric("Evidence gates", latestTrace ? "H1 → H7" : "Waiting", latestTrace ? "Control-by-control reconstruction available" : "Evidence appears after a prompt finalizes")}
</section>` +
panel(
"Product boundary",
"Core is visual by default",
"The local viewer covers the complete single-project review workflow; centralized operations remain a Platform capability.",
disclosure(
"About this local report",
"Edition boundaries and implementation notes are hidden until needed.",
`<div class="two-col">
<div class="notice success"><strong>Included in Core</strong><br>Latest run, history, interactive H1–H7 evidence, H6 AgentOps, and on-demand JSON/HTML export.</div>
<div class="notice warning"><strong>Platform boundary</strong><br>Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.</div>
@@ -200,9 +214,9 @@
<div class="dossier">
<div class="decision-card"><div><p class="eyebrow">Selected control</p><h2>${esc(selected.title)}</h2><p class="panel-description">${esc(selected.description)}</p></div>
<div class="inset"><span class="inset-label">Latest decision · ${esc(time(selected.updated_at))}</span><p>${esc(selected.reason)}</p></div>
<div class="inset"><span class="inset-label">Event history</span>${timeline}</div>
${disclosure("Event history", `${events.length} recent lifecycle events · hidden by default`, timeline)}
</div>
<div class="manifest"><div class="manifest-head"><div><p class="eyebrow">Safe evidence manifest</p><div class="metric-detail">Sanitized fields persisted by the harness</div></div>${pill(`${entries.length} fields`)}</div>${manifest}</div>
${disclosure("Safe evidence manifest", "Sanitized fields persisted by the harness · hidden by default", manifest, pill(`${entries.length} fields`))}
</div>`,
actions,
);
@@ -225,6 +239,12 @@
}).join("");
const breakdown = (rows, subject) => `<div class="table-scroll"><table><thead><tr><th>${esc(subject)}</th><th class="right">Runs</th><th class="right">Failures</th><th class="right">Avg latency</th><th class="right">Tokens</th><th class="right">Cost</th></tr></thead>
<tbody>${rows.length ? rows.map((row) => `<tr><td><strong>${esc(row.key)}</strong></td><td class="right mono">${row.runs}</td><td class="right mono">${row.failures}</td><td class="right mono">${number(row.latency_avg_ms)} ms</td><td class="right mono">${number(row.tokens)}</td><td class="right mono">${money(row.cost_usd)}</td></tr>`).join("") : `<tr><td colspan="6">${empty("No matching records", `No ${subject.toLowerCase()} evidence is available.`)}</td></tr>`}</tbody></table></div>`;
const outcomes = distribution("Governed run outcome distribution", data.details.by_status.map((row) => ({
label: String(row.status).replaceAll("_", " "),
value: row.count,
detail: `${data.summary.runs ? Math.round((row.count / data.summary.runs) * 100) : 0}% of selected runs`,
tone: row.status === "failed" ? "danger" : row.status === "degraded" ? "warning" : "success",
})));
$("#h6-view").innerHTML =
hero({
eyebrow: "H6 · AgentOps assurance",
@@ -234,7 +254,7 @@
meta: [data.report_id, `Generated ${time(data.generated_at)}`, `Freshness ${data.freshness.status}`],
actions,
}) +
panel("Scope", "Evidence boundary", "Filters apply to the screen and both export formats.", h6Filters(data)) +
disclosure("Filter the evidence boundary", "Project, date and trace filters stay out of the executive view until needed.", h6Filters(data)) +
`<section class="metric-grid">
${metric("Governed runs", number(data.summary.runs), `${data.summary.success} successful · ${data.summary.degraded} degraded`)}
${metric("Failure rate", `${data.summary.failure_rate_pct}%`, `${data.summary.failed} failed · ${data.summary.alerts} alerts`)}
@@ -243,17 +263,29 @@
</section>` +
`<div class="two-col">
${panel("Decision brief", "What requires attention", "Threshold findings are review signals, not maturity claims.", findings, pill(data.verdict))}
${panel("Source integrity", "Evidence provenance", "Freshness is evaluated independently for each canonical source.", `<div class="source-grid">${sources}</div>`, pill(data.freshness.status))}
</div>` +
`<div class="breakdown-grid">
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
${panel("Outcome mix", "Run distribution", "Canonical runtime status values, normalized against the largest observed group.", outcomes, `<span class="mono metric-detail">${number(data.summary.runs)} total</span>`)}
</div>` +
panel("Evidence quality", "Coverage and disclosure", "Unavailable telemetry remains null. Coverage gaps stay visible.", `<div class="two-col">
<div class="coverage">${coverage("Token attribution", data.summary.coverage.token_pct, `${data.summary.coverage.token_records}/${data.summary.coverage.runtime_records} records`)}
${coverage("Cost attribution", data.summary.coverage.cost_pct, `${data.summary.coverage.cost_records}/${data.summary.coverage.runtime_records} records`)}</div>
<div class="inset"><span class="inset-label">Disclosure notes</span>${data.data_quality.warnings.length ? `<ul>${data.data_quality.warnings.map((warning) => `<li class="metric-detail">${esc(warning)}</li>`).join("")}</ul>` : `<p class="success-copy">All required sources are present with no estimation warning.</p>`}</div>
</div>`, pill(data.data_quality.status));
</div>`, pill(data.data_quality.status)) +
disclosure(
"Technical evidence and breakdowns",
"Provider counters, canonical paths and raw tables are preserved for audit without crowding the decision view.",
`<section class="metric-grid compact">
${metric("Provider tokens", data.summary.coverage.token_records > 0 ? number(data.summary.tokens.provider_total ?? data.summary.tokens.total) : "Unavailable", `${data.summary.coverage.token_pct}% record coverage`)}
${metric("Actual cost", money(data.summary.cost_usd.provider_actual), `${data.summary.coverage.cost_pct}% record coverage`)}
${metric("Provider calls", number(data.summary.provider_calls), "Provider-attributed requests")}
${metric("Retries", number(data.summary.retries), "Observed reroute attempts")}
</section>
<div class="technical-section"><p class="eyebrow">Source integrity</p><h3>Evidence provenance</h3><div class="source-grid">${sources}</div></div>
<div class="breakdown-grid technical-section">
${panel("Runtime", "Execution profile by step", "Latency, failure and attribution by governed lifecycle step.", breakdown(data.details.by_step, "Step"))}
${panel("Provider", "Model usage and provenance", "Only provider-reported token and actual cost values are shown.", breakdown(data.details.by_provider, "Provider · model"))}
</div>`,
pill(data.freshness.status),
);
bindH6Filters();
}
@@ -656,6 +656,31 @@ def report_html(report: dict[str, Any], kind: str) -> str:
("Data quality", report.get("data_quality", {}).get("status")),
]
findings = report.get("findings", [])
statuses = report.get("details", {}).get("by_status", [])
maximum = max(
[float(item.get("count", 0) or 0) for item in statuses] or [1]
)
total_runs = float(summary.get("runs", 0) or 0)
outcome_rows = "".join(
'<li><div><span><strong>%s</strong><small>%s%% of selected runs</small></span><b>%s</b></div>'
'<i><em class="%s" style="width:%s%%"></em></i></li>' % (
escape(_text(item.get("status"), "unknown").replace("_", " ")),
round((float(item.get("count", 0) or 0) / total_runs) * 100)
if total_runs else 0,
escape(str(int(float(item.get("count", 0) or 0)))),
"fail" if item.get("status") == "failed" else
"attention" if item.get("status") == "degraded" else "pass",
max((float(item.get("count", 0) or 0) / maximum) * 100, 3)
if float(item.get("count", 0) or 0) else 0,
)
for item in statuses
)
outcomes = (
'<section class="panel"><h2>Run distribution</h2>'
'<p class="sub">Canonical runtime status values, normalized against the largest observed group.</p>'
f'<ol class="distribution">{outcome_rows}</ol></section>'
if outcome_rows else ""
)
else:
summary = report.get("summary", {})
metrics = [
@@ -668,6 +693,7 @@ def report_html(report: dict[str, Any], kind: str) -> str:
{"code": gate.get("title"), "severity": gate.get("status"), "message": gate.get("reason")}
for gate in report.get("gates", [])
]
outcomes = ""
cards = "".join(
f'<div class="metric"><span>{escape(str(label))}</span><strong>{escape(str(value if value is not None else "Unavailable"))}</strong></div>'
for label, value in metrics
@@ -692,10 +718,11 @@ main{{max-width:1060px;margin:auto;padding:44px 24px 72px}}header{{border-radius
.grid{{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0}}.metric,.panel{{border:1px solid var(--line);border-radius:18px;background:white;padding:20px;box-shadow:0 10px 30px #10182808}}
.metric span{{display:block;color:var(--muted)}}.metric strong{{display:block;margin-top:16px;font-size:24px;letter-spacing:-.03em}}.panel{{margin-top:18px;padding:26px}}h2{{margin:0 0 6px;font-size:20px}}.sub{{margin:0 0 20px;color:var(--muted);font-size:14px}}
.finding{{border-left:4px solid var(--cyan);border-radius:10px;background:#f8fafc;padding:14px 16px;margin-top:10px}}.finding div{{display:flex;justify-content:space-between;gap:16px}}code{{font-weight:800}}em{{color:var(--muted);font-size:11px;text-transform:uppercase}}.finding p{{margin:8px 0 0;color:#344054;font-size:14px;line-height:1.55}}.clear{{border-radius:12px;background:#ecfdf3;color:var(--green);padding:16px}}
.distribution{{display:grid;gap:16px;margin:20px 0 0;padding:0;list-style:none}}.distribution li>div{{display:flex;align-items:end;justify-content:space-between;gap:16px}}.distribution span strong,.distribution span small{{display:block}}.distribution span strong{{font-size:13px;text-transform:capitalize}}.distribution span small{{margin-top:4px;color:var(--muted);font-size:11px}}.distribution b{{font:700 12px ui-monospace,monospace}}.distribution i{{display:block;overflow:hidden;height:8px;margin-top:8px;border-radius:99px;background:#eaecf0}}.distribution em{{display:block;height:100%;border-radius:inherit;background:var(--cyan)}}.distribution em.pass{{background:#12b76a}}.distribution em.attention{{background:#f79009}}.distribution em.fail{{background:#f04438}}
details{{margin-top:18px}}summary{{cursor:pointer;font-weight:700}}pre{{overflow:auto;max-height:720px;border-radius:14px;background:#101828;color:#d0d5dd;padding:20px;font:11px/1.6 ui-monospace,monospace}}
footer{{margin-top:22px;color:var(--muted);font-size:11px}}@media(max-width:760px){{h1{{font-size:32px}}.grid{{grid-template-columns:1fr 1fr}}header{{padding:28px}}}}@media print{{body{{background:white}}main{{max-width:none;padding:0}}header,.panel,.metric{{box-shadow:none}}details{{break-before:page}}}}
</style></head><body><main><header><div class="eyebrow">CASAN · Independent assurance dossier</div><h1>{escape(str(title))}</h1>
<div class="meta"><span>{escape(str(report.get("report_id", "")))}</span><span>Generated {escape(str(report.get("generated_at", "")))}</span></div><div class="badge">Verdict · {escape(verdict)}</div></header>
<section class="grid">{cards}</section><section class="panel"><h2>Decision evidence</h2><p class="sub">Threshold findings and control outcomes from canonical local evidence.</p>{rows}</section>
<section class="grid">{cards}</section><section class="panel"><h2>Decision evidence</h2><p class="sub">Threshold findings and control outcomes from canonical local evidence.</p>{rows}</section>{outcomes}
<section class="panel"><h2>Machine-auditable contract</h2><p class="sub">The complete sanitized report payload used to render this dossier.</p><details><summary>Open JSON evidence</summary><pre>{contract}</pre></details></section>
<footer>Generated on demand by CASAN Core Local Assurance Viewer · read-only · offline-capable</footer></main></body></html>"""
@@ -139,6 +139,15 @@ class LocalReportTests(unittest.TestCase):
self.assertIn("&lt;script&gt;alert(1)&lt;/script&gt;", html)
self.assertIn("Machine-auditable contract", html)
def test_h6_html_visualizes_outcomes_and_hides_raw_contract(self):
self.write_run()
report = REPORT.h6_report(self.root, run="trace-1")
html = REPORT.report_html(report, "h6")
self.assertIn("Run distribution", html)
self.assertIn('class="distribution"', html)
self.assertIn("<details><summary>Open JSON evidence</summary>", html)
self.assertNotIn("None</strong>", html)
def test_unsafe_trace_id_never_reads_outside_project(self):
report = REPORT.run_report(self.root, "../../etc/passwd")
self.assertEqual(report["verdict"], "not_found")