diff --git a/.gitignore b/.gitignore index 290ab2e..0e0abfb 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,7 @@ o6.txt o7.txt t6.txt t7.txt +tmp/design-qa/ # TS build cache *.tsbuildinfo diff --git a/VERSION b/VERSION index af0b7dd..238d6e8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.6 +1.0.7 diff --git a/design-qa.md b/design-qa.md new file mode 100644 index 0000000..0928ec4 --- /dev/null +++ b/design-qa.md @@ -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 diff --git a/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md b/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md index 45c3aa5..95919e2 100644 --- a/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md +++ b/docs/packaging/CORE_LOCAL_ASSURANCE_VIEWER.md @@ -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: diff --git a/packages/casan-control-panel/backend/src/reports/h6-report.html.ts b/packages/casan-control-panel/backend/src/reports/h6-report.html.ts index 206ed4b..5519ce5 100644 --- a/packages/casan-control-panel/backend/src/reports/h6-report.html.ts +++ b/packages/casan-control-panel/backend/src/reports/h6-report.html.ts @@ -32,6 +32,18 @@ function coverage(label: string, value: number, detail: string): string { `; } +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 '

No measured outcome distribution is available.

'; + return `
    ${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 `
  1. ${escapeHtml(row.status.replaceAll('_', ' '))}${share}% of selected runs${number(value)}
  2. `; + }).join('')}
`; +} + 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) => `
  • ${escapeHtml(warning)}
  • `).join('') : '
  • All required sources are present and no estimation warning was detected.
  • '; + const outcomes = distributionRows(report); return ` @@ -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}}
    CASAN · H6 AgentOps assurance
    @@ -93,31 +108,33 @@ export function renderH6ReportHtml(report: H6Report): string {
    Failure rate${report.summary.failure_rate_pct}%${number(report.summary.failed)} failed · ${number(report.summary.alerts)} alerts
    P95 latency${number(report.summary.latency_ms.p95)} msP50 ${number(report.summary.latency_ms.p50)} ms
    Data quality${escapeHtml(report.data_quality.status.replaceAll('_', ' '))}Coverage disclosed below
    -
    Provider tokens${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : 'Unavailable'}${report.summary.coverage.token_pct}% record coverage
    -
    Actual provider cost${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : 'Unavailable'}${number(report.summary.provider_calls)} provider calls
    -
    Cost attribution${report.summary.coverage.cost_pct}%${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records
    -
    Retries${number(report.summary.retries)}Observed reroute attempts
    Decision brief

    What requires attention

    Threshold findings are operational review signals, not maturity claims.

    ${escapeHtml(report.verdict)}
    ${findings}
    +
    +
    Outcome mix

    Run distribution

    Canonical runtime status values, normalized against the largest observed group.

    ${number(report.summary.runs)} total
    +
    ${outcomes}
    +
    Evidence quality

    Coverage and disclosure

    Unavailable telemetry remains null and is never rendered as zero.

    ${escapeHtml(report.data_quality.status)}
    ${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`)}
    Disclosure notes
      ${warnings}
    -
    -
    Source integrity

    Evidence provenance

    Freshness and presence are evaluated independently for every canonical source.

    -
    ${sourceRows}
    SourceStateAgeRecordsCanonical path
    -
    -
    -
    Runtime

    Execution profile by step

    -
    ${breakdownRows(report.details.by_step)}
    StepRunsFailuresAvg latencyTokensCost
    -
    -
    -
    Provider

    Model usage and provenance

    Only provider-reported token and actual cost values are shown.

    -
    ${breakdownRows(report.details.by_provider)}
    Provider · modelCallsFailuresAvg latencyTokensCost
    -
    +
    + Technical evidence and breakdownsProvider counters, canonical paths and raw tables are preserved for audit.+ +
    +
    +
    Provider tokens${report.summary.coverage.token_records > 0 ? number(report.summary.tokens.provider_total ?? report.summary.tokens.total ?? 0) : 'Unavailable'}${report.summary.coverage.token_pct}% record coverage
    +
    Actual provider cost${report.summary.cost_usd.provider_actual !== null ? money(report.summary.cost_usd.provider_actual) : 'Unavailable'}${number(report.summary.provider_calls)} provider calls
    +
    Cost attribution${report.summary.coverage.cost_pct}%${report.summary.coverage.cost_records}/${report.summary.coverage.runtime_records} runtime records
    +
    Retries${number(report.summary.retries)}Observed reroute attempts
    +
    +
    Source integrity

    Evidence provenance

    ${sourceRows}
    SourceStateAgeRecordsCanonical path
    +
    Runtime

    Execution profile by step

    ${breakdownRows(report.details.by_step)}
    StepRunsFailuresAvg latencyTokensCost
    +
    Provider

    Model usage and provenance

    ${breakdownRows(report.details.by_provider)}
    Provider · modelCallsFailuresAvg latencyTokensCost
    +
    +
    `; } diff --git a/packages/casan-control-panel/backend/src/reports/run-report.html.ts b/packages/casan-control-panel/backend/src/reports/run-report.html.ts index 4a137c4..e1c837d 100644 --- a/packages/casan-control-panel/backend/src/reports/run-report.html.ts +++ b/packages/casan-control-panel/backend/src/reports/run-report.html.ts @@ -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}}
    CASAN · independent assurance dossier
    @@ -82,23 +83,27 @@ main{max-width:1180px;margin:0 auto;padding:38px 24px 64px}.cover{position:relat
    Evidence source${evidenceState}Trace + graph reconstruction
    H6 quality${escapeHtml(report.summary.telemetry_quality)}Telemetry disclosure status
    Duration${report.summary.duration_ms === null ? 'Unavailable' : `${report.summary.duration_ms} ms`}End-to-end governed run
    -
    Tool calls${report.summary.tool_calls}Observed side-effect attempts
    -
    Failures${report.summary.failures}Recorded execution failures
    -
    Token usage${tokenValue}Never inferred as zero
    -
    Actual cost${costValue}Provider attribution only
    Control sequence

    Live assurance rail · H1 → H7

    The evidence spine shows the terminal state of every governed control.

    ${escapeHtml(report.verdict)}
    ${spine}
    -
    -
    Control dossiers

    Decision and evidence detail

    Every field below is sanitized before persistence. Raw prompts and tool output are excluded.

    -
    ${gates}
    -
    Certification

    Assurance conclusion

    ${escapeHtml(report.certification.strength ?? 'unknown')}

    ${escapeHtml(report.certification.reasons.join(' · ') || 'No certification reason recorded.')}

    +
    + Technical evidence and control dossiersExecution counters and sanitized field manifests are preserved for audit.+ +
    +
    +
    Tool calls${report.summary.tool_calls}Observed side-effect attempts
    +
    Failures${report.summary.failures}Recorded execution failures
    +
    Token usage${tokenValue}Never inferred as zero
    +
    Actual cost${costValue}Provider attribution only
    +
    +
    Control dossiers

    Decision and evidence detail

    Every field below is sanitized before persistence. Raw prompts and tool output are excluded.

    ${gates}
    +
    +
    `; } diff --git a/packages/casan-control-panel/backend/test/reports.test.ts b/packages/casan-control-panel/backend/test/reports.test.ts index d5834e6..fc74cf4 100644 --- a/packages/casan-control-panel/backend/test/reports.test.ts +++ b/packages/casan-control-panel/backend/test/reports.test.ts @@ -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, /
    /); 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, /
    /); assert.match(html, /@page\{size:A4/); assert.match(html, /Unavailable/); assert.doesNotMatch(html, /\$0(?:\.0+)?/); diff --git a/packages/casan-control-panel/frontend/src/components/report/ReportPrimitives.tsx b/packages/casan-control-panel/frontend/src/components/report/ReportPrimitives.tsx index b456862..e9c6fd9 100644 --- a/packages/casan-control-panel/frontend/src/components/report/ReportPrimitives.tsx +++ b/packages/casan-control-panel/frontend/src/components/report/ReportPrimitives.tsx @@ -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 ( +
    +
      + {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 ( +
    1. +
      +
      +

      {row.label}

      + {row.detail &&
      {row.detail}
      } +
      + {integerLabel(bounded)} +
      +
    2. + ); + })} +
    + {rows.length === 0 &&

    No measured distribution is available.

    } +
    + ); +} + +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 ( +
    + +
    +

    {summary}

    +

    {description}

    +
    +
    + {badge} + +
    +
    +
    {children}
    +
    + ); +} + 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'; diff --git a/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx b/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx index a0793fd..0dd9f08 100644 --- a/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx +++ b/packages/casan-control-panel/frontend/src/components/trace/TraceExplorer.tsx @@ -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 }) {

    {node.reason || 'No decision reason was emitted.'}

    -
    -

    Event history

    +
    -
    -
    -
    -

    Safe evidence manifest

    -

    Sanitized fields persisted by the harness

    -
    - {entries.length} fields -
    + {entries.length} fields} + > {entries.length > 0 ? ( -
    +
    {entries.map(([key, value]) => (
    {key}
    -
    +
    ))}
    ) : (
    -
    ∅
    -

    No safe evidence fields are available yet.

    +
    ∅
    +

    No safe evidence fields are available yet.

    )} -
    + ); } diff --git a/packages/casan-control-panel/frontend/src/pages/H6Report.tsx b/packages/casan-control-panel/frontend/src/pages/H6Report.tsx index b1dcd02..abc363e 100644 --- a/packages/casan-control-panel/frontend/src/pages/H6Report.tsx +++ b/packages/casan-control-panel/frontend/src/pages/H6Report.tsx @@ -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} /> - Filtered view : All available evidence} + Filtered : All evidence} >
    @@ -168,7 +169,7 @@ export function H6ReportPage() { -
    + {report.isLoading && } {report.isError && ( @@ -187,24 +188,17 @@ export function H6ReportPage() { -
    -
    -

    Provider tokens

    -

    {data.summary.coverage.token_records > 0 ? integer(data.summary.tokens.provider_total ?? data.summary.tokens.total ?? 0) : 'Unavailable'}

    -
    -
    -

    Actual cost

    -

    {data.summary.cost_usd.provider_actual !== null ? money(data.summary.cost_usd.provider_actual) : 'Unavailable'}

    -
    -
    -

    Provider calls

    -

    {integer(data.summary.provider_calls)}

    -
    -
    -

    Retries

    -

    {integer(data.summary.retries)}

    -
    -
    + +
    + 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'} /> + + + 0 ? 'warning' : 'neutral'} /> +
    +
    } + eyebrow="Outcome mix" + title="Run distribution" + description="Counts are grouped from canonical runtime status values; the longest bar is the largest observed group." + right={{integer(data.summary.runs)} total} > -
    - {data.evidence_sources.map((source) => { - const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh'; - return ( -
    -
    -
    -

    {source.source}

    -

    {source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}

    -
    - -
    -

    {source.path}

    -
    - ); - })} -
    -
    -
    - -
    - - - - - + ({ + 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, + }))} + />
    @@ -297,17 +274,47 @@ export function H6ReportPage() { -
    -
    -

    Independent evidence package

    -

    Same contract. Two review formats.

    -

    JSON is machine-auditable. HTML is a self-contained, print-ready assurance dossier generated only when requested.

    + } + > +
    +
    +
    +

    Source integrity

    +

    Evidence provenance

    +
    +
    + {data.evidence_sources.map((source) => { + const state = !source.present ? 'missing' : source.stale ? 'stale' : 'fresh'; + return ( +
    +
    +
    +

    {source.source}

    +

    {source.records} records · {source.age_s === null ? 'no timestamp' : `${integer(source.age_s)}s old`}

    +
    + +
    +

    {source.path}

    +
    + ); + })} +
    +
    +
    +
    +

    Execution profile by step

    + +
    +
    +

    Model usage and provenance

    + +
    +
    - -
    + )}
    diff --git a/packages/casan-harness/assets/local-viewer/app.css b/packages/casan-harness/assets/local-viewer/app.css index c488e01..a316467 100644 --- a/packages/casan-harness/assets/local-viewer/app.css +++ b/packages/casan-harness/assets/local-viewer/app.css @@ -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; } diff --git a/packages/casan-harness/assets/local-viewer/app.js b/packages/casan-harness/assets/local-viewer/app.js index 4dec9af..ac7cf68 100644 --- a/packages/casan-harness/assets/local-viewer/app.js +++ b/packages/casan-harness/assets/local-viewer/app.js @@ -69,6 +69,21 @@
    ${body}
    `; } + function disclosure(summary, description, body, badge = "") { + return `
    ${esc(summary)}${esc(description)} + ${badge}
    ${body}
    `; + } + + function distribution(label, rows) { + const maximum = Math.max(...rows.map((row) => Number(row.value) || 0), 1); + return ``; + } + function empty(title, detail) { return `
    ∅
    ${esc(title)}

    ${esc(detail)}

    `; } @@ -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")} ` + - 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.", `
    Included in Core
    Latest run, history, interactive H1–H7 evidence, H6 AgentOps, and on-demand JSON/HTML export.
    Platform boundary
    Multi-project fleet views, centralized RBAC, shared approvals, ingestion and organization-wide retention.
    @@ -200,9 +214,9 @@

    Selected control

    ${esc(selected.title)}

    ${esc(selected.description)}

    Latest decision · ${esc(time(selected.updated_at))}

    ${esc(selected.reason)}

    -
    Event history${timeline}
    + ${disclosure("Event history", `${events.length} recent lifecycle events · hidden by default`, timeline)}
    -

    Safe evidence manifest

    Sanitized fields persisted by the harness
    ${pill(`${entries.length} fields`)}
    ${manifest}
    + ${disclosure("Safe evidence manifest", "Sanitized fields persisted by the harness · hidden by default", manifest, pill(`${entries.length} fields`))}
    `, actions, ); @@ -225,6 +239,12 @@ }).join(""); const breakdown = (rows, subject) => `
    ${rows.length ? rows.map((row) => ``).join("") : ``}
    ${esc(subject)}RunsFailuresAvg latencyTokensCost
    ${esc(row.key)}${row.runs}${row.failures}${number(row.latency_avg_ms)} ms${number(row.tokens)}${money(row.cost_usd)}
    ${empty("No matching records", `No ${subject.toLowerCase()} evidence is available.`)}
    `; + 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)) + `
    ${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 @@
    ` + `
    ${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.", `
    ${sources}
    `, pill(data.freshness.status))} -
    ` + - `
    - ${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, `${number(data.summary.runs)} total`)}
    ` + panel("Evidence quality", "Coverage and disclosure", "Unavailable telemetry remains null. Coverage gaps stay visible.", `
    ${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`)}
    Disclosure notes${data.data_quality.warnings.length ? `
      ${data.data_quality.warnings.map((warning) => `
    • ${esc(warning)}
    • `).join("")}
    ` : `

    All required sources are present with no estimation warning.

    `}
    -
    `, pill(data.data_quality.status)); +
    `, 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.", + `
    + ${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")} +
    +

    Source integrity

    Evidence provenance

    ${sources}
    +
    + ${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"))} +
    `, + pill(data.freshness.status), + ); bindH6Filters(); } diff --git a/packages/casan-harness/scripts/python/local_report.py b/packages/casan-harness/scripts/python/local_report.py index 4df9fdf..f8af72f 100755 --- a/packages/casan-harness/scripts/python/local_report.py +++ b/packages/casan-harness/scripts/python/local_report.py @@ -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( + '
  • %s%s%% of selected runs%s
    ' + '
  • ' % ( + 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 = ( + '

    Run distribution

    ' + '

    Canonical runtime status values, normalized against the largest observed group.

    ' + f'
      {outcome_rows}
    ' + 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'
    {escape(str(label))}{escape(str(value if value is not None else "Unavailable"))}
    ' 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}}}}
    CASAN · Independent assurance dossier

    {escape(str(title))}

    {escape(str(report.get("report_id", "")))}Generated {escape(str(report.get("generated_at", "")))}
    Verdict · {escape(verdict)}
    -
    {cards}

    Decision evidence

    Threshold findings and control outcomes from canonical local evidence.

    {rows}
    +
    {cards}

    Decision evidence

    Threshold findings and control outcomes from canonical local evidence.

    {rows}
    {outcomes}

    Machine-auditable contract

    The complete sanitized report payload used to render this dossier.

    Open JSON evidence
    {contract}
    Generated on demand by CASAN Core Local Assurance Viewer · read-only · offline-capable
    """ diff --git a/packages/casan-harness/tests/local-viewer-tests.py b/packages/casan-harness/tests/local-viewer-tests.py index 21510a8..9263cc4 100755 --- a/packages/casan-harness/tests/local-viewer-tests.py +++ b/packages/casan-harness/tests/local-viewer-tests.py @@ -139,6 +139,15 @@ class LocalReportTests(unittest.TestCase): self.assertIn("<script>alert(1)</script>", 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("
    Open JSON evidence", html) + self.assertNotIn("None", 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")