@shield-agent/kya 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,10 +3,12 @@
3
3
  */
4
4
  import { assertNoSecrets, clip, stripEscapes } from "../dash/render.js";
5
5
  import { clipMultiline, DIFF_MAX_TOTAL_CHARS } from "../diff-preview.js";
6
+ import { DOMAIN_LABELS, domainOrder } from "../certify/render.js";
6
7
  import { productLabel, readTrail, readTrailSince } from "../trail.js";
7
8
  import { loadCertifyCard, loadIdentity, loadOrrCard, loadSandboxes, loadShowbackCard, loadWiredHosts, } from "./enrich.js";
8
9
  import { SHOWBACK_DISCLAIMER, } from "../showback/cost-per-task.js";
9
10
  import { computeDashboard } from "./dashboard.js";
11
+ import { receiptCss } from "./receipt-css.js";
10
12
  function esc(s) {
11
13
  return stripEscapes(s)
12
14
  .replace(/&/g, "&")
@@ -264,8 +266,8 @@ function projectMeta(project) {
264
266
  function renderFeed(events, nowMs, live) {
265
267
  if (events.length === 0) {
266
268
  const hint = live
267
- ? "No events yet. Wrap a tool call and this page will refresh."
268
- : "No events yet. Run a wrap, then regenerate this receipt.";
269
+ ? "No events yet — evaluate a tool call with <code>kya wrap</code> and this page will refresh."
270
+ : "No events yet — evaluate a tool call with <code>kya wrap</code>, then regenerate this receipt.";
269
271
  return `<p class="empty">${hint}</p>`;
270
272
  }
271
273
  const sorted = [...events].sort((a, b) => b.ts.localeCompare(a.ts));
@@ -541,39 +543,191 @@ function orrPanel(orr, nowMs) {
541
543
  <p class="mute small">scorecards: ${orr.scorecards.pass} pass · ${orr.scorecards.fail} fail · ${orr.scorecards.partial} partial · ${orr.scorecards.notEvaluated} n/e${when}</p>
542
544
  </section>`;
543
545
  }
544
- function certifyPanel(certify) {
545
- if (!certify)
546
- return "";
547
- const chips = [
548
- ["Pass", certify.pass],
549
- ["Gap", certify.gap],
550
- ["Insufficient", certify.insufficientEvidence],
551
- ["Attested", certify.attested],
552
- ]
553
- .map(([label, n]) => `<span class="stat">${label}<b>${n}</b></span>`)
554
- .join("");
546
+ function usdText(usd) {
547
+ return usd === null ? "USD n/a" : `~$${usd.toFixed(2)}`;
548
+ }
549
+ /** KPI tile: big tabular number over a small caps label. The tone class is
550
+ * only applied above zero — a zero count never reads as a signal. */
551
+ function kpiTile(label, value, cls = "") {
552
+ const v = typeof value === "number" ? value : esc(value);
553
+ const toned = typeof value === "number" && value > 0 && cls ? ` ${cls}` : "";
554
+ return `<div class="kpi"><span class="kpi-num${toned}">${v}</span><span class="kpi-lab">${label}</span></div>`;
555
+ }
556
+ /**
557
+ * Hero Certify panel: purpose-built rich card for the live Agent Trust
558
+ * Baseline. The section carries the result state (`hero-certify pass|gap|
559
+ * none`) for the accent border; an absent card is a fail-closed neutral
560
+ * state that must never read as a pass.
561
+ */
562
+ function heroCertifyPanel(certify) {
563
+ if (!certify) {
564
+ return `<section class="panel hero-certify none" aria-label="Certify">
565
+ <h2>Certify — Agent Trust Baseline</h2>
566
+ <div class="orrline">
567
+ <span class="pill">not evaluated</span>
568
+ </div>
569
+ <p class="mute small">No live evaluation available — wrap tool calls and run kya certify to build the baseline.</p>
570
+ </section>`;
571
+ }
572
+ const state = certify.result === "pass" ? "pass" : "gap";
573
+ // Status legend: one muted line under the count tiles so the four numbers
574
+ // read as definitions, not bare counters.
575
+ const legend = `<p class="legend">gap = requirement failing — your work plan · insufficient = not enough local evidence to evaluate (never counts as pass) · attested = your signed statement, unverified</p>`;
576
+ // Attested summary: the hero's gap rows are gap-status only, so attestations
577
+ // surface as one line naming the first attested requirement (+N more) with
578
+ // its attestation text.
579
+ const firstAttested = certify.requirements.find((r) => r.status === "attested");
580
+ const attestedLine = certify.attested === 0
581
+ ? ""
582
+ : firstAttested
583
+ ? `<p class="att-line">attested: <code>${esc(firstAttested.id)}</code>${certify.attested > 1 ? ` (+${certify.attested - 1} more)` : ""} — "${esc(clip(firstAttested.attestation?.text ?? firstAttested.evidence, 100))}"</p>`
584
+ : `<p class="att-line">attested: ${certify.attested} — your signed statement, unverified</p>`;
585
+ // topGaps arrives severity-ordered from the loader; cap defensively at 5.
586
+ // Each row is id — title + severity chip, with the redacted evidence
587
+ // one-liner beneath (clipped so the hero stays compact).
555
588
  const gaps = certify.topGaps.length === 0
556
589
  ? ""
557
590
  : `<ul class="rows">
558
591
  ${certify.topGaps
559
- .map((g) => ` <li><code>${esc(g.id)}</code> — ${esc(g.severity)}</li>`)
592
+ .slice(0, 5)
593
+ .map((g) => ` <li><code>${esc(g.id)}</code> — ${esc(g.title)} <span class="sev ${esc(g.severity)}">${esc(g.severity)}</span>${g.evidence ? `<span class="gap-ev">${esc(clip(g.evidence, 140))}</span>` : ""}</li>`)
560
594
  .join("\n")}
561
595
  </ul>`;
562
- // Pill tones reuse the ORR panel's classes: pass is green, gap is amber.
563
- return `<section class="panel" aria-label="Certify">
596
+ // Pill tones reuse the ORR palette: pass is green, gap is amber.
597
+ return `<section class="panel hero-certify ${state}" aria-label="Certify">
564
598
  <h2>Certify — Agent Trust Baseline</h2>
565
599
  <div class="orrline">
566
- <span class="pill orr-${certify.result === "pass" ? "green" : "amber"}">${esc(certify.result)}</span>
600
+ <span class="pill orr-${state === "pass" ? "green" : "amber"}">${esc(certify.result)}</span>
567
601
  </div>
568
- <div class="stats">${chips}</div>
602
+ <div class="kpi-row">
603
+ ${kpiTile("Pass", certify.pass, "ok")}
604
+ ${kpiTile("Gap", certify.gap, "warn")}
605
+ ${kpiTile("Insufficient", certify.insufficientEvidence, "mute")}
606
+ ${kpiTile("Attested", certify.attested, "mute")}
607
+ </div>
608
+ ${legend}
609
+ ${attestedLine}
569
610
  ${gaps}
570
- <p class="mute small">live evaluation — window ${certify.windowDays}d, ${certify.trailEvents} trail events · kya certify for the full gap report + signed bundle</p>
611
+ <p class="mute small">live evaluation — window ${certify.windowDays}d, ${certify.trailEvents} trail events · the Certify tab has the full live requirement table · kya certify for the gap report + signed bundle</p>
571
612
  </section>`;
572
613
  }
573
- function usdText(usd) {
574
- return usd === null ? "USD n/a" : `~$${usd.toFixed(2)}`;
614
+ /** Detail-table pill tones, reusing the existing pill/tone system: pass is
615
+ * green, gap is amber, insufficient evidence keeps the neutral mute default,
616
+ * attested gets the neutral-foreground .att tone. */
617
+ const DETAIL_PILL_TONE = {
618
+ pass: "ok",
619
+ gap: "orr-amber",
620
+ insufficient_evidence: "",
621
+ attested: "att",
622
+ };
623
+ const CERTIFY_DETAIL_TITLE_CLIP = 120;
624
+ const CERTIFY_DETAIL_CLIP = 160;
625
+ /**
626
+ * Certify tab body: the full live requirement table — every evaluated
627
+ * requirement grouped by domain, domains in catalog order (the card's
628
+ * requirements arrive in catalog order, so first-seen order IS the catalog
629
+ * order). Each domain gets a heading row with mini-counts, then one row per
630
+ * requirement: status pill, id, title, and a muted evidence line beneath —
631
+ * or the attestation text for attested rows. Empty input yields "" so the
632
+ * caller falls back to the zone-empty hint (fail-closed: no table without
633
+ * data).
634
+ */
635
+ function certifyDetailPanel(requirements) {
636
+ if (requirements.length === 0)
637
+ return "";
638
+ const sections = domainOrder(requirements)
639
+ .map((domain) => {
640
+ const rows = requirements.filter((r) => r.domain === domain);
641
+ const counts = { pass: 0, gap: 0, insufficient: 0, attested: 0 };
642
+ for (const r of rows) {
643
+ if (r.status === "pass")
644
+ counts.pass++;
645
+ else if (r.status === "gap")
646
+ counts.gap++;
647
+ else if (r.status === "attested")
648
+ counts.attested++;
649
+ else
650
+ counts.insufficient++;
651
+ }
652
+ const items = rows
653
+ .map((r) => {
654
+ const tone = DETAIL_PILL_TONE[r.status];
655
+ const pill = `<span class="pill${tone ? ` ${tone}` : ""}">${esc(r.status.replace(/_/g, " "))}</span>`;
656
+ const detail = r.attestation
657
+ ? `<span class="att">attested ${esc(r.attestation.at)} — "${esc(clip(r.attestation.text, CERTIFY_DETAIL_CLIP))}"</span>`
658
+ : r.evidence
659
+ ? `<span class="evi">${esc(clip(r.evidence, CERTIFY_DETAIL_CLIP))}</span>`
660
+ : "";
661
+ return ` <li>${pill} <code>${esc(r.id)}</code> — ${esc(clip(r.title, CERTIFY_DETAIL_TITLE_CLIP))}${detail}</li>`;
662
+ })
663
+ .join("\n");
664
+ const label = DOMAIN_LABELS[domain] ?? domain;
665
+ return ` <section class="dom" aria-label="${esc(label)}">
666
+ <div class="dom-head"><h3>${esc(label)}</h3><span class="cnt">${counts.pass} pass · ${counts.gap} gap · ${counts.insufficient} insufficient · ${counts.attested} attested</span></div>
667
+ <ul class="rows">
668
+ ${items}
669
+ </ul>
670
+ </section>`;
671
+ })
672
+ .join("\n");
673
+ return `<section class="panel certify-detail" aria-label="Certify detail">
674
+ <h2>Every requirement — live evaluation</h2>
675
+ ${sections}
676
+ </section>`;
677
+ }
678
+ /** Hero KPI cards: compact, non-interactive rollups (filters live in the
679
+ * Activity zone's chip bar). Numbers keep tabular-nums via .mix-num/.kpi-num. */
680
+ function verdictsHeroCard(c, total) {
681
+ // Base 100% = total events, matching dashboard.ts's verdictMix contract:
682
+ // verdict is a free-form string from untrusted JSONL, so an event may land
683
+ // in no bucket (bogus verdict) while still counting in `never` — only the
684
+ // raw event count keeps every bar inside the base the Overview card uses.
685
+ const pct = (n) => (total > 0 ? Math.round((n / total) * 100) : 0);
686
+ const mixRow = (label, n, fill) => `<div class="mix-row"><span class="mix-lab">${label}</span><span class="mix-bar"><span class="mix-fill ${fill}" style="width:${pct(n)}%"></span></span><span class="mix-num">${n}</span></div>`;
687
+ return `<section class="panel" aria-label="Verdicts">
688
+ <h2>Verdicts</h2>
689
+ <div class="mix">
690
+ ${mixRow("Allow", c.allow, "ok")}
691
+ ${mixRow("Deny", c.deny, "bad")}
692
+ ${mixRow("Review", c.require, "warn")}
693
+ ${mixRow("Never", c.never, "bad")}
694
+ </div>
695
+ </section>`;
696
+ }
697
+ const SPARK_BARS = 7;
698
+ const SPARK_H = 24;
699
+ /**
700
+ * Inline 7-bar sparkline (no JS): the last 7 activity buckets, left-padded
701
+ * with zeros, heights normalized so the max bucket is full height. Empty
702
+ * input renders a flat baseline — heights are guarded, never NaN/Infinity.
703
+ */
704
+ function activitySparkline(activity) {
705
+ const counts = activity.buckets.slice(-SPARK_BARS).map((b) => b.count);
706
+ while (counts.length < SPARK_BARS)
707
+ counts.unshift(0);
708
+ const max = Math.max(0, ...counts);
709
+ const rects = counts
710
+ .map((n, i) => {
711
+ const h = max > 0 ? Math.round((n / max) * SPARK_H) : 0;
712
+ return `<rect x="${i * 10}" y="${SPARK_H - h}" width="8" height="${h}" rx="1"></rect>`;
713
+ })
714
+ .join("");
715
+ const unit = activity.granularity === "hour" ? "hours" : "days";
716
+ return `<svg class="spark" viewBox="0 0 68 ${SPARK_H}" role="img" aria-label="Activity, last ${SPARK_BARS} ${unit}">${rects}<line class="base" x1="0" y1="${SPARK_H - 0.5}" x2="68" y2="${SPARK_H - 0.5}"></line></svg>`;
575
717
  }
576
- function showbackPanel(report) {
718
+ function activityHeroCard(agg, db, total) {
719
+ return `<section class="panel" aria-label="Activity">
720
+ <h2>Activity</h2>
721
+ <div class="kpi-row">
722
+ ${kpiTile("Events", total)}
723
+ </div>
724
+ ${activitySparkline(db.activity)}
725
+ <p class="mute small">observe ${agg.modes.observe} · hold ${agg.modes.hold} · offline ${agg.modes.offline} · IDE ${agg.planes.ide} · runtime ${agg.planes.runtime}</p>
726
+ </section>`;
727
+ }
728
+ /** Hero-styled showback: same fields as the old panel (tokens in/out, est.
729
+ * USD, top runs, disclaimer), omitted entirely when no report exists. */
730
+ function showbackHeroCard(report) {
577
731
  if (!report)
578
732
  return "";
579
733
  const topRuns = [...report.perRun]
@@ -588,7 +742,11 @@ ${topRuns
588
742
  </ul>`;
589
743
  return `<section class="panel" aria-label="Showback">
590
744
  <h2>Showback (observe only)</h2>
591
- <p class="line">${report.totalTokensIn} tokens in · ${report.totalTokensOut} tokens out · ${esc(usdText(report.estimatedUsd))}</p>
745
+ <div class="kpi-row">
746
+ ${kpiTile("Tokens in", report.totalTokensIn)}
747
+ ${kpiTile("Tokens out", report.totalTokensOut)}
748
+ ${kpiTile("Est. USD", usdText(report.estimatedUsd))}
749
+ </div>
592
750
  ${runs}
593
751
  <p class="mute small">${esc(SHOWBACK_DISCLAIMER)}</p>
594
752
  </section>`;
@@ -644,6 +802,12 @@ export function renderReceiptHtml(model) {
644
802
  // must never resolve via Object.prototype. The id lets tests extract just
645
803
  // this script. KNOWN is space-delimited so `indexOf(' '+g+' ')` doubles as a
646
804
  // prototype-safe whitelist ('constructor' etc. never match).
805
+ // Filter state persists in the ?f= QUERY param, not the hash: the hash is
806
+ // owned by the pure-CSS :target tabs (#overview/#certify/#activity/#system), so a
807
+ // hash-based filter would hide the Activity zone on every chip toggle and
808
+ // every tab click would wipe the filter. Legacy #f= hashes are still parsed
809
+ // on load (read-only); the next save writes the ?f= form and drops the
810
+ // legacy fragment.
647
811
  const filterScript = `<script id="kya-filters">
648
812
  (function(){
649
813
  var feed = document.getElementById('feed');
@@ -654,9 +818,20 @@ export function renderReceiptHtml(model) {
654
818
  var chips = document.querySelectorAll('[data-fgroup]');
655
819
  function parse(){
656
820
  state = Object.create(null);
657
- var h = location.hash;
658
- if (h.indexOf('#f=') !== 0) return;
659
- h.slice(3).split(',').forEach(function(seg){
821
+ var raw = null;
822
+ var s = location.search;
823
+ if (s.indexOf('?') === 0) {
824
+ var params = s.slice(1).split('&');
825
+ for (var i = 0; i < params.length; i++) {
826
+ if (params[i].indexOf('f=') === 0) { raw = params[i].slice(2); break; }
827
+ }
828
+ }
829
+ if (raw === null) {
830
+ var legacy = location.hash;
831
+ if (legacy.indexOf('#f=') === 0) raw = legacy.slice(3);
832
+ }
833
+ if (raw === null) return;
834
+ raw.split(',').forEach(function(seg){
660
835
  var i = seg.indexOf(':');
661
836
  if (i < 1) return;
662
837
  var g = seg.slice(0, i);
@@ -672,8 +847,22 @@ export function renderReceiptHtml(model) {
672
847
  Object.keys(state).forEach(function(g){
673
848
  Object.keys(state[g]).forEach(function(v){ parts.push(g + ':' + encodeURIComponent(v)); });
674
849
  });
675
- var h = parts.length ? '#f=' + parts.join(',') : '';
676
- history.replaceState(null, '', location.pathname + location.search + h);
850
+ // Rebuild the query rather than overwrite it: the live page carries its
851
+ // loopback token as ?t= and must not lose it on a chip toggle.
852
+ var kept = [];
853
+ var s = location.search;
854
+ if (s.indexOf('?') === 0) {
855
+ var params = s.slice(1).split('&');
856
+ for (var i = 0; i < params.length; i++) {
857
+ if (params[i] && params[i].indexOf('f=') !== 0) kept.push(params[i]);
858
+ }
859
+ }
860
+ if (parts.length) kept.push('f=' + parts.join(','));
861
+ var q = kept.length ? '?' + kept.join('&') : '';
862
+ // The tab hash rides along untouched; a legacy #f= fragment is not a tab
863
+ // hash and is dropped now that the state lives in the query.
864
+ var h = location.hash.indexOf('#f=') === 0 ? '' : location.hash;
865
+ history.replaceState(null, '', location.pathname + q + h);
677
866
  }
678
867
  function active(){
679
868
  return Object.keys(state).some(function(g){ return Object.keys(state[g]).length > 0; });
@@ -721,406 +910,76 @@ export function renderReceiptHtml(model) {
721
910
  })();
722
911
  </script>`;
723
912
  const livePill = model.live ? `<span class="live" title="Watching trail.jsonl">Live</span>` : "";
913
+ // Zones never render as bare whitespace: with no panels they get a hint.
914
+ const zoneBody = (parts, empty) => parts.some((p) => p.length > 0)
915
+ ? parts.filter(Boolean).join("\n ")
916
+ : `<p class="mute small zone-empty">${empty}</p>`;
917
+ const overviewBody = zoneBody([dashboardPanel(dashboard), sessionsPanel(agg, nowMs), reasonsPanel(agg)], "No activity in this window yet — analytics appear once events land.");
918
+ const certifyBody = zoneBody([model.certify ? certifyDetailPanel(model.certify.requirements) : ""], "No live evaluation yet — wrap tool calls and run kya certify to build the baseline.");
919
+ const systemBody = zoneBody([
920
+ wiredHostsPanel(model.wiredHosts),
921
+ sandboxesPanel(model.sandboxes, nowMs),
922
+ orrPanel(model.orr, nowMs),
923
+ toolsHtml,
924
+ ], "No system state yet — wire a host, configure a sandbox, or run an ORR.");
724
925
  const html = `<!DOCTYPE html>
725
926
  <html lang="en">
726
927
  <head>
727
928
  <meta charset="utf-8"/>
728
929
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
729
930
  <title>${esc(model.title)} - ${esc(model.rangeLabel)}</title>
730
- <style>
731
- :root {
732
- color-scheme: dark;
733
- --bg: #0a0c10;
734
- --fg: #eef2f7;
735
- --mute: #8b95a8;
736
- --line: #1c2330;
737
- --card: #10141c;
738
- --ok: #3ecf8e;
739
- --bad: #ff5d5d;
740
- --warn: #f0b429;
741
- --rail: #2a3344;
742
- --day: #6b7280;
743
- }
744
- @media (prefers-color-scheme: light) {
745
- :root {
746
- color-scheme: light;
747
- --bg: #f3f5f8;
748
- --fg: #0f172a;
749
- --mute: #64748b;
750
- --line: #e2e8f0;
751
- --card: #ffffff;
752
- --ok: #059669;
753
- --bad: #dc2626;
754
- --warn: #d97706;
755
- --rail: #cbd5e1;
756
- --day: #64748b;
757
- }
758
- }
759
- * { box-sizing: border-box; }
760
- body {
761
- margin: 0;
762
- font-family: "IBM Plex Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
763
- background: var(--bg);
764
- color: var(--fg);
765
- line-height: 1.4;
766
- }
767
- main { max-width: 42rem; margin: 0 auto; padding: 1.35rem 1rem 3rem; }
768
- header.bar {
769
- position: sticky; top: 0; z-index: 3;
770
- background: color-mix(in srgb, var(--bg) 88%, transparent);
771
- backdrop-filter: blur(10px);
772
- padding: 0.85rem 0 0.75rem;
773
- border-bottom: 1px solid var(--line);
774
- margin-bottom: 1rem;
775
- }
776
- .title-row {
777
- display: flex; align-items: baseline; justify-content: space-between;
778
- gap: 0.75rem; flex-wrap: wrap;
779
- }
780
- h1 {
781
- margin: 0; font-size: 1.2rem; font-weight: 600;
782
- letter-spacing: -0.025em;
783
- }
784
- .range { color: var(--mute); font-size: 0.85rem; display: inline-flex; align-items: center; gap: 0.5rem; }
785
- .live {
786
- display: inline-flex; align-items: center; gap: 0.35rem;
787
- font-size: 0.7rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase;
788
- color: var(--ok); border: 1px solid color-mix(in srgb, var(--ok) 40%, transparent);
789
- border-radius: 999px; padding: 0.12rem 0.5rem;
790
- }
791
- .live::before {
792
- content: ""; width: 0.4rem; height: 0.4rem; border-radius: 50%;
793
- background: var(--ok);
794
- box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 22%, transparent);
795
- animation: pulse 1.6s ease-in-out infinite;
796
- }
797
- .live.off { color: var(--mute); border-color: var(--line); }
798
- .live.off::before { background: var(--mute); box-shadow: none; animation: none; }
799
- @keyframes pulse {
800
- 0%, 100% { opacity: 1; }
801
- 50% { opacity: 0.45; }
802
- }
803
- @media (prefers-reduced-motion: reduce) {
804
- .live::before { animation: none; }
805
- }
806
- .stats {
807
- display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.7rem;
808
- }
809
- .stat {
810
- font-size: 0.75rem; font-variant-numeric: tabular-nums;
811
- color: var(--mute); background: var(--card);
812
- border: 1px solid var(--line); border-radius: 999px;
813
- padding: 0.2rem 0.6rem;
814
- }
815
- .stat b { color: var(--fg); font-weight: 650; margin-left: 0.25rem; }
816
- .stat.bad b { color: var(--bad); }
817
- .stat.warn b { color: var(--warn); }
818
- .stat.ok b { color: var(--ok); }
819
- button.stat {
820
- font-family: inherit; cursor: pointer;
821
- appearance: none; -webkit-appearance: none;
822
- }
823
- button.stat:hover { border-color: var(--mute); }
824
- .stat[aria-pressed="true"] {
825
- color: var(--fg);
826
- border-color: color-mix(in srgb, var(--ok) 55%, var(--line));
827
- background: color-mix(in srgb, var(--ok) 12%, var(--card));
828
- }
829
- .stat[hidden] { display: none; }
830
- .clear-filters { color: var(--mute); font-size: 0.72rem; }
831
- /* Analytics dashboard: 2×2 card grid, collapses to one column on narrow. */
832
- .db-grid {
833
- display: grid; grid-template-columns: 1fr 1fr; gap: 0.9rem 1.1rem;
834
- }
835
- @media (max-width: 700px) {
836
- .db-grid { grid-template-columns: 1fr; }
837
- }
838
- .db-card { min-width: 0; }
839
- .db-card h3 {
840
- margin: 0 0 0.4rem; font-size: 0.68rem; font-weight: 700;
841
- letter-spacing: 0.06em; text-transform: uppercase; color: var(--mute);
842
- }
843
- .db-card h3 .mute { text-transform: none; letter-spacing: 0.02em; font-weight: 600; }
844
- .db-card .sub {
845
- margin: 0.35rem 0 0.15rem; font-size: 0.66rem; font-weight: 700;
846
- letter-spacing: 0.05em; text-transform: uppercase; color: var(--day);
847
- }
848
- /* Filter-linked bar row: block variant of .stat's hover/active contract. */
849
- .db-item {
850
- display: grid; grid-template-columns: 6.5rem 1fr 2.4rem;
851
- gap: 0.5rem; align-items: center; width: 100%;
852
- font-family: inherit; font-size: 0.76rem; font-variant-numeric: tabular-nums;
853
- color: var(--mute); text-align: left;
854
- background: transparent; border: 1px solid transparent; border-radius: 8px;
855
- padding: 0.14rem 0.3rem; cursor: pointer;
856
- appearance: none; -webkit-appearance: none;
857
- }
858
- .db-item:hover { border-color: var(--line); background: color-mix(in srgb, var(--bg) 55%, var(--card)); }
859
- .db-item[aria-pressed="true"] {
860
- color: var(--fg);
861
- border-color: color-mix(in srgb, var(--ok) 55%, var(--line));
862
- background: color-mix(in srgb, var(--ok) 12%, var(--card));
863
- }
864
- .db-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
865
- .db-bar {
866
- height: 0.55rem; border-radius: 4px; overflow: hidden;
867
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
868
- }
869
- .db-fill { display: block; height: 100%; border-radius: 4px; }
870
- .db-fill.ok { background: var(--ok); }
871
- .db-fill.warn { background: var(--warn); }
872
- .db-fill.bad { background: var(--bad); }
873
- .db-num { text-align: right; color: var(--fg); }
874
- /* Vertical mini-bar timeline (not clickable — no time filter exists). */
875
- .db-tl { display: flex; align-items: stretch; gap: 2px; height: 4.6rem; }
876
- .db-tl .col {
877
- flex: 1 1 0; min-width: 0; display: flex; flex-direction: column;
878
- align-items: center; justify-content: flex-end; gap: 0.15rem;
879
- }
880
- .db-vbar {
881
- width: 100%; min-height: 2px; border-radius: 2px 2px 0 0;
882
- background: color-mix(in srgb, var(--ok) 75%, var(--mute));
883
- }
884
- .db-tl .lab {
885
- font-size: 0.52rem; color: var(--mute); white-space: nowrap;
886
- overflow: hidden; max-width: 100%; font-variant-numeric: tabular-nums;
887
- }
888
- /* utility: must beat .ev's display:grid (same specificity, later rule would win) */
889
- .filtered-out { display: none !important; }
890
- .identity {
891
- margin-top: 0.45rem; color: var(--mute); font-size: 0.78rem;
892
- word-break: break-word;
893
- }
894
- .modeb {
895
- display: inline-block; font-size: 0.6rem; font-weight: 700;
896
- color: var(--mute); border: 1px solid var(--line); border-radius: 4px;
897
- padding: 0 0.28rem; line-height: 1.1rem; vertical-align: baseline;
898
- }
899
- .panel {
900
- background: var(--card); border: 1px solid var(--line);
901
- border-radius: 12px; padding: 0.7rem 0.9rem 0.8rem; margin-bottom: 1rem;
902
- }
903
- .panel h2 {
904
- margin: 0 0 0.5rem; font-size: 0.72rem; font-weight: 700;
905
- letter-spacing: 0.07em; text-transform: uppercase; color: var(--day);
906
- }
907
- .panel .sub { margin: 0 0 0.45rem; color: var(--mute); font-size: 0.8rem; }
908
- .panel .line { margin: 0.25rem 0; font-size: 0.84rem; word-break: break-word; }
909
- .panel .mute { color: var(--mute); }
910
- .panel .small { font-size: 0.74rem; }
911
- .rows {
912
- list-style: none; margin: 0; padding: 0;
913
- display: flex; flex-direction: column; gap: 0.3rem;
914
- font-size: 0.82rem;
915
- }
916
- .rows li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.45rem; min-width: 0; }
917
- .rows li.mute { color: var(--mute); }
918
- .rows .sid, .rows code {
919
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
920
- font-size: 0.78rem; word-break: break-all;
921
- }
922
- .rows .cnt { color: var(--mute); font-size: 0.76rem; }
923
- .rows .rel { margin-left: auto; color: var(--mute); font-size: 0.74rem; white-space: nowrap; }
924
- .rows .hint {
925
- font-size: 0.72rem; border-bottom: 1px dotted var(--mute); cursor: help;
926
- }
927
- .wdot {
928
- width: 0.5rem; height: 0.5rem; border-radius: 50%; flex: none;
929
- align-self: center; background: var(--mute);
930
- }
931
- .wdot.allow { background: var(--ok); }
932
- .wdot.hold { background: var(--warn); }
933
- .wdot.deny, .wdot.never { background: var(--bad); }
934
- .chiprow { display: flex; flex-wrap: wrap; gap: 0.35rem; }
935
- .chip {
936
- font-size: 0.76rem; color: var(--mute);
937
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
938
- border: 1px solid var(--line); border-radius: 999px; padding: 0.15rem 0.55rem;
939
- }
940
- .chip code {
941
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace;
942
- color: var(--fg); font-size: 0.74rem;
943
- }
944
- .tbl { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
945
- .tbl th {
946
- text-align: left; font-size: 0.66rem; font-weight: 700; letter-spacing: 0.06em;
947
- text-transform: uppercase; color: var(--mute); padding: 0.15rem 0.5rem 0.3rem 0;
948
- }
949
- .tbl td { padding: 0.2rem 0.5rem 0.2rem 0; border-top: 1px solid var(--line); }
950
- .tbl code {
951
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace; font-size: 0.76rem;
952
- }
953
- .pill {
954
- display: inline-block; font-size: 0.7rem; font-weight: 700;
955
- letter-spacing: 0.04em; text-transform: uppercase;
956
- border-radius: 999px; padding: 0.1rem 0.55rem;
957
- border: 1px solid var(--line); color: var(--mute);
958
- }
959
- .pill.ok { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
960
- .pill.orr-green { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
961
- .pill.orr-amber { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
962
- .pill.orr-red { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
963
- .orrline { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.55rem; }
964
- .orrline .disp { font-size: 0.84rem; font-weight: 600; }
965
- .alert {
966
- display: flex; flex-wrap: wrap; gap: 0.45rem 0.75rem; align-items: baseline;
967
- background: color-mix(in srgb, var(--bad) 12%, var(--card));
968
- border: 1px solid color-mix(in srgb, var(--bad) 35%, var(--line));
969
- border-radius: 10px; padding: 0.65rem 0.85rem; margin-bottom: 1rem;
970
- font-size: 0.85rem;
971
- }
972
- .alert strong { color: var(--bad); font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.04em; }
973
- .alert code {
974
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
975
- font-size: 0.78rem;
976
- }
977
- .feed {
978
- background: var(--card);
979
- border: 1px solid var(--line);
980
- border-radius: 12px;
981
- overflow: hidden;
982
- }
983
- .day {
984
- padding: 0.55rem 0.9rem 0.35rem;
985
- font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em;
986
- text-transform: uppercase; color: var(--day);
987
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
988
- border-bottom: 1px solid var(--line);
989
- }
990
- .ev {
991
- display: grid; grid-template-columns: 1.1rem 1fr; gap: 0;
992
- border-bottom: 1px solid var(--line);
993
- }
994
- .ev:last-child { border-bottom: none; }
995
- .rail {
996
- position: relative;
997
- display: flex; justify-content: center;
998
- padding-top: 1.05rem;
999
- }
1000
- .rail::before {
1001
- content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
1002
- width: 1px; background: var(--rail); transform: translateX(-50%);
1003
- }
1004
- .tick {
1005
- width: 0.55rem; height: 0.55rem; border-radius: 50%;
1006
- background: var(--mute); position: relative; z-index: 1;
1007
- box-shadow: 0 0 0 3px var(--card);
1008
- }
1009
- .ev.ok .tick { background: var(--ok); }
1010
- .ev.bad .tick { background: var(--bad); }
1011
- .ev.warn .tick { background: var(--warn); }
1012
- .ev.never { background: color-mix(in srgb, var(--bad) 7%, transparent); }
1013
- .main { padding: 0.7rem 0.85rem 0.7rem 0.35rem; min-width: 0; }
1014
- .top {
1015
- display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35rem 0.55rem;
1016
- }
1017
- @media (max-width: 480px) {
1018
- .when { flex-basis: 100%; margin-left: 0; margin-top: 0.1rem; }
1019
- }
1020
- .verdict {
1021
- font-size: 0.68rem; font-weight: 750; letter-spacing: 0.05em;
1022
- min-width: 2.6rem;
1023
- }
1024
- .ev.ok .verdict { color: var(--ok); }
1025
- .ev.bad .verdict { color: var(--bad); }
1026
- .ev.warn .verdict { color: var(--warn); }
1027
- .tool {
1028
- font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
1029
- font-size: 0.84rem; word-break: break-word; flex: 1 1 auto;
1030
- }
1031
- .when {
1032
- margin-left: auto; color: var(--mute); font-size: 0.72rem;
1033
- font-variant-numeric: tabular-nums; white-space: nowrap;
1034
- }
1035
- .when .rel { opacity: 0.85; margin-left: 0.35rem; }
1036
- .summary {
1037
- margin-top: 0.2rem;
1038
- font-size: 0.84rem;
1039
- color: var(--fg);
1040
- word-break: break-word;
1041
- }
1042
- details.diff {
1043
- margin-top: 0.35rem;
1044
- border: 1px solid var(--line);
1045
- border-radius: 8px;
1046
- background: color-mix(in srgb, var(--bg) 55%, var(--card));
1047
- overflow: hidden;
1048
- }
1049
- details.diff > summary {
1050
- cursor: pointer;
1051
- list-style: none;
1052
- padding: 0.35rem 0.55rem;
1053
- font-size: 0.72rem;
1054
- font-weight: 650;
1055
- letter-spacing: 0.04em;
1056
- text-transform: uppercase;
1057
- color: var(--mute);
1058
- }
1059
- details.diff > summary::-webkit-details-marker { display: none; }
1060
- details.diff pre {
1061
- margin: 0;
1062
- padding: 0.45rem 0.55rem 0.6rem;
1063
- border-top: 1px solid var(--line);
1064
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace;
1065
- font-size: 0.72rem;
1066
- line-height: 1.35;
1067
- white-space: pre-wrap;
1068
- word-break: break-word;
1069
- color: var(--fg);
1070
- }
1071
- .meta {
1072
- margin-top: 0.18rem; color: var(--mute); font-size: 0.76rem;
1073
- display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: baseline;
1074
- }
1075
- .dot { opacity: 0.55; }
1076
- .empty { margin: 0; padding: 1.25rem 1rem; color: var(--mute); font-size: 0.9rem; }
1077
- .tools {
1078
- margin-top: 1.1rem; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
1079
- }
1080
- .tools .label {
1081
- font-size: 0.68rem; font-weight: 700; letter-spacing: 0.06em;
1082
- text-transform: uppercase; color: var(--mute); margin-right: 0.25rem;
1083
- }
1084
- .tools code {
1085
- font-family: "IBM Plex Mono", ui-monospace, Menlo, monospace;
1086
- font-size: 0.72rem; color: var(--mute);
1087
- background: var(--card); border: 1px solid var(--line);
1088
- border-radius: 6px; padding: 0.18rem 0.4rem;
1089
- }
1090
- </style>
931
+ <style>${receiptCss()}</style>
1091
932
  </head>
1092
933
  <body>
1093
934
  <main>
1094
- <header class="bar">
1095
- <div class="title-row">
1096
- <h1>${esc(model.title)}</h1>
1097
- <div class="range">${esc(model.rangeLabel)}${livePill}</div>
1098
- </div>
1099
- ${identityLine(model.identity)}
1100
- <div class="stats" aria-label="Counts">
1101
- <button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
1102
- <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
1103
- <button type="button" class="stat warn" data-fgroup="verdict" data-fvalue="REQUIRE_APPROVE" aria-pressed="false" title="Filter: Review">Review<b>${c.require}</b></button>
1104
- <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
1105
- ${chips.modes}${chips.planes}
1106
- <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>
1107
- </div>
1108
- ${chips.products}
1109
- ${chips.projects}
1110
- </header>
935
+ <div class="topstick">
936
+ <header class="bar">
937
+ <div class="title-row">
938
+ <h1>${esc(model.title)}</h1>
939
+ <div class="range">${esc(model.rangeLabel)}${livePill}</div>
940
+ </div>
941
+ ${identityLine(model.identity)}
942
+ </header>
943
+ <nav class="tabs" aria-label="Report sections">
944
+ <a class="tab" id="tab-overview" href="#overview">Overview</a>
945
+ <a class="tab" id="tab-certify" href="#certify">Certify</a>
946
+ <a class="tab" id="tab-activity" href="#activity">Activity</a>
947
+ <a class="tab" id="tab-system" href="#system">System</a>
948
+ </nav>
949
+ </div>
1111
950
  ${blockedBanner}
1112
- ${dashboardPanel(dashboard)}
1113
- <section class="feed" id="feed" aria-label="Activity feed">
951
+ <div class="hero">
952
+ ${heroCertifyPanel(model.certify)}
953
+ ${verdictsHeroCard(c, events.length)}
954
+ ${activityHeroCard(agg, dashboard, events.length)}
955
+ ${showbackHeroCard(model.showback)}
956
+ </div>
957
+ <section class="zone" id="overview" aria-labelledby="tab-overview">
958
+ ${overviewBody}
959
+ </section>
960
+ <section class="zone" id="certify" aria-labelledby="tab-certify">
961
+ ${certifyBody}
962
+ </section>
963
+ <section class="zone" id="activity" aria-labelledby="tab-activity">
964
+ <div class="feed-head">
965
+ <div class="stats" aria-label="Counts">
966
+ <button type="button" class="stat ok" data-fgroup="verdict" data-fvalue="ALLOW" aria-pressed="false" title="Filter: Allow">Allow<b>${c.allow}</b></button>
967
+ <button type="button" class="stat bad" data-fgroup="verdict" data-fvalue="DENY" aria-pressed="false" title="Filter: Deny">Deny<b>${c.deny}</b></button>
968
+ <button type="button" class="stat warn" data-fgroup="verdict" data-fvalue="REQUIRE_APPROVE" aria-pressed="false" title="Filter: Review">Review<b>${c.require}</b></button>
969
+ <button type="button" class="stat" data-fgroup="never" data-fvalue="1" aria-pressed="false" title="Filter: Never">Never<b>${c.never}</b></button>
970
+ ${chips.modes}${chips.planes}
971
+ <button type="button" class="stat clear-filters" id="clear-filters" hidden>Clear filters ×</button>
972
+ </div>
973
+ ${chips.products}
974
+ ${chips.projects}
975
+ </div>
976
+ <section class="feed" id="feed" aria-label="Activity feed">
1114
977
  ${renderFeed(events, nowMs, model.live)}
978
+ </section>
979
+ </section>
980
+ <section class="zone" id="system" aria-labelledby="tab-system">
981
+ ${systemBody}
1115
982
  </section>
1116
- ${sessionsPanel(agg, nowMs)}
1117
- ${reasonsPanel(agg)}
1118
- ${wiredHostsPanel(model.wiredHosts)}
1119
- ${sandboxesPanel(model.sandboxes, nowMs)}
1120
- ${orrPanel(model.orr, nowMs)}
1121
- ${certifyPanel(model.certify)}
1122
- ${showbackPanel(model.showback)}
1123
- ${toolsHtml}
1124
983
  </main>
1125
984
  ${filterScript}
1126
985
  ${liveScript}
@@ -1170,6 +1029,17 @@ export function renderReceiptMarkdown(model) {
1170
1029
  if (agg.projects.length >= 2) {
1171
1030
  lines.push("## Projects", ...agg.projects.map((p) => `- ${mdText(p.label)} × ${p.count}`), "");
1172
1031
  }
1032
+ if (model.certify) {
1033
+ const c = model.certify;
1034
+ lines.push("## Certify", `result: ${c.result} · counts: ${c.pass} pass · ${c.gap} gap · ${c.insufficientEvidence} insufficient · ${c.attested} attested`);
1035
+ if (c.topGaps.length > 0) {
1036
+ lines.push("Top gaps:");
1037
+ for (const g of c.topGaps) {
1038
+ lines.push(`- \`${mdInline(g.id)}\` — ${mdText(g.title)} (${mdText(g.severity)})`);
1039
+ }
1040
+ }
1041
+ lines.push(`live evaluation — window ${c.windowDays}d, ${c.trailEvents} trail events · the Certify tab has the full live requirement table · kya certify for the gap report + signed bundle`, "");
1042
+ }
1173
1043
  const db = computeDashboard(model.events);
1174
1044
  if (db.verdictMix.total > 0) {
1175
1045
  const pct = (n) => Math.round((n / db.verdictMix.total) * 100);
@@ -1259,17 +1129,6 @@ export function renderReceiptMarkdown(model) {
1259
1129
  lines.push(`Most urgent fix: ${mdText(orr.mostUrgentFix)}`);
1260
1130
  lines.push(`scorecards: ${orr.scorecards.pass} pass · ${orr.scorecards.fail} fail · ${orr.scorecards.partial} partial · ${orr.scorecards.notEvaluated} n/e${orr.generatedAt ? ` · ${relativeTime(orr.generatedAt, nowMs)}` : ""}`, "");
1261
1131
  }
1262
- if (model.certify) {
1263
- const c = model.certify;
1264
- lines.push("## Certify", `result: ${c.result} · counts: ${c.pass} pass · ${c.gap} gap · ${c.insufficientEvidence} insufficient · ${c.attested} attested`);
1265
- if (c.topGaps.length > 0) {
1266
- lines.push("Top gaps:");
1267
- for (const g of c.topGaps) {
1268
- lines.push(`- \`${mdInline(g.id)}\` — ${mdText(g.severity)}`);
1269
- }
1270
- }
1271
- lines.push(`live evaluation — window ${c.windowDays}d, ${c.trailEvents} trail events · kya certify for the full gap report + signed bundle`, "");
1272
- }
1273
1132
  if (model.showback) {
1274
1133
  const sb = model.showback;
1275
1134
  lines.push("## Showback (observe only)", `${sb.totalTokensIn} tokens in · ${sb.totalTokensOut} tokens out · ${usdText(sb.estimatedUsd)}`);