OXYGENHUB

Release trust page: badges of what is signed and attested, a graph drawn at its own size bf5101d394c0

A summary row of badges (tag signature, changes signed or sealed, assets
built and attested by OxygenFlow); in the graph a lock, ! or a cross on
every box with the reason underneath and the details on hover; the
builds column only when something was built; asset headings badged.
The SVG has its own width and height instead of stretching to the page,
and github.css's node rule is scoped to the history graph's shapes, so
it no longer strokes the trust graph's text. Test.

closes #79
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-02 12:40 UTC
Signature
✔ signed by Andy McCall
Hash
bf5101d394c0cae673a730f03a02d7e075a61b751a400c000c72073e4f00b30d
Depends on
34f40eb4b205 42fa95540468 749d9a5fcd9f b5668108487f d23464c0ac60 df940d75cd3d
Depended on by
6ff755541a62 2291f8cc9c96
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
In tags
v0.3.1 v0.4.0 v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
30 atoms, 0 tree operations, 6 files

Browse the tree at this change

modified CHANGELOG.md +2 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **A clearer release trust page (#79).** A row of badges at the top says whether the tag is signed and by whom, how many changes are signed or sealed, and how many assets an attested OxygenFlow build produced. The graph is drawn at its own size (it filled the screen), drops the builds column when nothing was built, and marks every box: a lock where the claim is signed and checked, `!` where it is not (unsigned, uploaded by hand), `✘` where it fails, with the reason underneath and the details on hover. Each asset's heading carries its badge too. The GitHub look no longer strokes the graph's text.
8 +
7 9
## 0.3.0 (2026-10-02)
8 10
9 11
- **Edit a proposal and comment on one from `oh` (#76).** `PATCH /api/v1/repos/{owner}/{name}/proposals/{n}` changes the title or description, for the author or a maintainer as on the page; `oh proposal edit N [-t] [-b | -F FILE]`, `oh proposal comment N -b/-F`, and `oh proposal create -F FILE` ("-" reads stdin).
…
29 unchanged lines
modified assets/github.css +4 −2
…
162 unchanged lines
163 163
164 164
/* History: GitHub's commit-list quiet. */
165 165
:root[data-look="github"] .lane { stroke: var(--line); }
166 -
:root[data-look="github"] .node { fill: var(--panel); stroke: var(--mute); }
167 -
:root[data-look="github"] .node.head { fill: var(--acc); stroke: var(--acc); }
166 +
/* The history and lanes graphs' dots and cards only: other graphs (the
167 +
   release trust graph) use .node on groups holding text. */
168 +
:root[data-look="github"] :is(circle, rect).node { fill: var(--panel); stroke: var(--mute); }
169 +
:root[data-look="github"] :is(circle, rect).node.head { fill: var(--acc); stroke: var(--acc); }
168 170
:root[data-look="github"] .chg .title { font-weight: 600; }
modified crates/hub/src/web/release_trust.rs +156 −53
…
113 unchanged lines
114 114
    }
115 115
}
116 116
117 +
/// One claim of the trust summary: `pass`, `warn` or `fail`, and what it says.
118 +
pub struct TrustBadge {
119 +
    pub class: &'static str,
120 +
    pub text: String,
121 +
}
122 +
117 123
#[derive(Clone, Debug, Serialize)]
118 124
pub struct ReleaseTrust {
119 125
    pub repository: String,
…
20 unchanged lines
140 146
            Verdict::Warn => "warn",
141 147
            Verdict::Fail => "fail",
142 148
        }
149 +
    }
150 +
151 +
    /// The page's trust summary, one badge per claim: the tag's signature,
152 +
    /// how many changes are authenticated, and how many assets an attested
153 +
    /// OxygenFlow build produced (oxygenhub#79).
154 +
    pub fn summary(&self) -> Vec<TrustBadge> {
155 +
        let tag = match self.tag_signature.as_str() {
156 +
            "valid" => ("pass", format!("Tag signed by {}", self.tag_signer.as_ref().map(|k| k.name.as_str()).unwrap_or("its signer"))),
157 +
            "unsigned" => ("warn", "Tag unsigned".to_string()),
158 +
            other => ("fail", format!("Tag signature {other}")),
159 +
        };
160 +
        let authenticated: usize = self.authors.iter().map(|a| a.signed + a.sealed).sum();
161 +
        let bad: usize = self.authors.iter().map(|a| a.invalid + a.revoked).sum();
162 +
        let changes = (
163 +
            if bad > 0 {
164 +
                "fail"
165 +
            } else if authenticated < self.changes {
166 +
                "warn"
167 +
            } else {
168 +
                "pass"
169 +
            },
170 +
            format!("{authenticated} of {} changes signed or sealed", self.changes),
171 +
        );
172 +
        let attested =
173 +
            self.assets.iter().filter(|a| a.build.as_ref().is_some_and(|b| b.receipt.is_some() && b.receipt_problem.is_none())).count();
174 +
        let assets = (
175 +
            if attested == self.assets.len() { "pass" } else { "warn" },
176 +
            if self.assets.is_empty() {
177 +
                "No assets".to_string()
178 +
            } else {
179 +
                format!("{attested} of {} assets built and attested by OxygenFlow", self.assets.len())
180 +
            },
181 +
        );
182 +
        [tag, changes, assets].into_iter().map(|(class, text)| TrustBadge { class, text }).collect()
143 183
    }
144 184
145 185
    pub fn verdict(&self) -> Verdict {
…
298 unchanged lines
444 484
    out
445 485
}
446 486
447 -
/// The report as a graph: authors, the tagged state, the builds and the
448 -
/// assets, in four columns, as inline SVG.
487 +
/// The report as a graph: authors, the tagged state, the builds (when any
488 +
/// job built an asset) and the assets, as inline SVG drawn at its own size.
489 +
/// Each node carries a badge: a lock where the claim is signed and checked,
490 +
/// `!` where it is not (unsigned, uploaded by hand), `✘` where it fails;
491 +
/// its second line says why, and its title says it in full (oxygenhub#79).
449 492
pub fn graph(t: &ReleaseTrust) -> String {
450 -
    const W: f64 = 180.0;
451 -
    const H: f64 = 34.0;
452 -
    const GAP: f64 = 12.0;
453 -
    const COL: f64 = 230.0;
493 +
    const W: f64 = 220.0;
494 +
    const H: f64 = 40.0;
495 +
    const GAP: f64 = 10.0;
496 +
    const COL: f64 = W + 48.0;
497 +
    struct Node {
498 +
        col: usize,
499 +
        row: usize,
500 +
        title: String,
501 +
        sub: String,
502 +
        class: &'static str,
503 +
        tip: String,
504 +
    }
454 505
    let esc = crate::render::escape;
455 -
    let mut nodes: Vec<(f64, f64, String, String, &str)> = Vec::new();
506 +
    let mut nodes: Vec<Node> = Vec::new();
456 507
    let mut edges: Vec<(usize, usize, bool)> = Vec::new();
457 -
    let y = |i: usize| 10.0 + i as f64 * (H + GAP);
508 +
    let any_build = t.assets.iter().any(|a| a.build.is_some());
509 +
    let asset_col = if any_build { 3 } else { 2 };
458 510
    let authors: Vec<usize> = t
459 511
        .authors
460 512
        .iter()
…
1 unchanged lines
462 514
        .enumerate()
463 515
        .map(|(i, a)| {
464 516
            let name = if a.who.name.is_empty() { a.who.key[..12].to_string() } else { a.who.name.clone() };
465 -
            let ok = a.invalid + a.revoked == 0 && a.unsigned == 0;
466 -
            nodes.push((
467 -
                0.0,
468 -
                y(i),
469 -
                name,
470 -
                format!("{} changes", a.changes),
471 -
                if ok {
472 -
                    "pass"
473 -
                } else if a.invalid + a.revoked > 0 {
474 -
                    "fail"
475 -
                } else {
476 -
                    "warn"
477 -
                },
478 -
            ));
517 +
            let (class, why) = if a.invalid + a.revoked > 0 {
518 +
                ("fail", format!("{} invalid or revoked", a.invalid + a.revoked))
519 +
            } else if a.unsigned > 0 {
520 +
                ("warn", format!("{} of {} unsigned", a.unsigned, a.changes))
521 +
            } else {
522 +
                ("pass", "all signed".to_string())
523 +
            };
524 +
            let linked = if a.who.account.is_some() { "key linked to an account" } else { "key not linked to an account" };
525 +
            nodes.push(Node {
526 +
                col: 0,
527 +
                row: i,
528 +
                title: name.clone(),
529 +
                sub: format!("{} change{} · {why}", a.changes, if a.changes == 1 { "" } else { "s" }),
530 +
                class,
531 +
                tip: format!("{name}: {} changes, {} signed, {} sealed, {} unsigned; {linked}", a.changes, a.signed, a.sealed, a.unsigned),
532 +
            });
479 533
            nodes.len() - 1
480 534
        })
481 535
        .collect();
482 -
    let tag_class = match t.tag_signature.as_str() {
483 -
        "valid" => "pass",
484 -
        "unsigned" => "warn",
485 -
        _ => "fail",
536 +
    let signer = t.tag_signer.as_ref().map(|k| k.name.clone()).unwrap_or_default();
537 +
    let (tag_class, tag_sub) = match t.tag_signature.as_str() {
538 +
        "valid" => ("pass", format!("signed by {signer}")),
539 +
        "unsigned" => ("warn", "unsigned".to_string()),
540 +
        other => ("fail", other.to_string()),
486 541
    };
487 -
    nodes.push((COL, y(0), format!("tag {}", t.tag), format!("{} change{}", t.changes, if t.changes == 1 { "" } else { "s" }), tag_class));
542 +
    nodes.push(Node {
543 +
        col: 1,
544 +
        row: 0,
545 +
        title: format!("tag {}", t.tag),
546 +
        sub: tag_sub.clone(),
547 +
        class: tag_class,
548 +
        tip: format!("tag {}: {} changes; {tag_sub}", t.tag, t.changes),
549 +
    });
488 550
    let tag = nodes.len() - 1;
489 551
    for a in authors {
490 552
        edges.push((a, tag, true));
491 553
    }
492 554
    let mut builds: HashMap<(i64, String), usize> = HashMap::new();
555 +
    let mut build_rows = 0;
493 556
    for (i, a) in t.assets.iter().enumerate() {
494 557
        let build = a.build.as_ref().map(|b| {
495 558
            *builds.entry((b.run, b.job.clone())).or_insert_with(|| {
496 -
                let class = match (&b.receipt, &b.receipt_problem) {
497 -
                    (Some(_), None) => "pass",
498 -
                    (Some(_), Some(_)) => "fail",
499 -
                    _ => "warn",
559 +
                let runner = b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_default();
560 +
                let (class, why) = match (&b.receipt, &b.receipt_problem) {
561 +
                    (Some(_), None) => ("pass", format!("receipt verified · {runner}")),
562 +
                    (Some(_), Some(p)) => ("fail", p.clone()),
563 +
                    _ => ("warn", "no signed receipt".to_string()),
500 564
                };
501 -
                let row = nodes.iter().filter(|n| n.0 == 2.0 * COL).count();
502 -
                nodes.push((
503 -
                    2.0 * COL,
504 -
                    y(row),
505 -
                    format!("run #{} {}", b.run, b.job),
506 -
                    b.runner.as_ref().map(|r| r.name.clone()).unwrap_or_else(|| "unsigned".into()),
565 +
                nodes.push(Node {
566 +
                    col: 2,
567 +
                    row: build_rows,
568 +
                    title: format!("run #{} {}", b.run, b.job),
569 +
                    sub: why.clone(),
507 570
                    class,
508 -
                ));
571 +
                    tip: format!(
572 +
                        "run #{} job {}: {why}{}",
573 +
                        b.run,
574 +
                        b.job,
575 +
                        if b.same_state_as_tag { ", from the tagged state" } else { ", from another state than the tag's" }
576 +
                    ),
577 +
                });
578 +
                build_rows += 1;
509 579
                edges.push((tag, nodes.len() - 1, b.same_state_as_tag));
510 580
                nodes.len() - 1
511 581
            })
512 582
        });
513 -
        let class = if a.build.is_some() { "pass" } else { "warn" };
514 -
        nodes.push((3.0 * COL, y(i), a.name.clone(), crate::render::bytes(a.size as u64), class));
583 +
        let size = crate::render::bytes(a.size as u64);
584 +
        let (class, why) = match &a.build {
585 +
            Some(b) if b.receipt.is_some() && b.receipt_problem.is_none() => ("pass", "attested build"),
586 +
            Some(_) => ("warn", "built, not attested"),
587 +
            None => ("warn", "uploaded by hand"),
588 +
        };
589 +
        nodes.push(Node {
590 +
            col: asset_col,
591 +
            row: i,
592 +
            title: a.name.clone(),
593 +
            sub: format!("{size} · {why}"),
594 +
            class,
595 +
            tip: format!("{}: {size}, {why}", a.name),
596 +
        });
515 597
        if let Some(b) = build {
516 598
            edges.push((b, nodes.len() - 1, true));
517 599
        }
518 600
    }
519 -
    let rows = nodes.iter().map(|n| n.1).fold(0.0, f64::max) + H + 10.0;
520 -
    let mut svg =
521 -
        format!(r#"<svg class="trust-graph" viewBox="0 0 {} {rows}" role="img" aria-label="Release trust graph">"#, 3.0 * COL + W + 2.0);
601 +
    let x = |n: &Node| 1.0 + n.col as f64 * COL;
602 +
    let y = |n: &Node| 1.0 + n.row as f64 * (H + GAP);
603 +
    let width = 2.0 + asset_col as f64 * COL + W;
604 +
    let height = nodes.iter().map(y).fold(0.0, f64::max) + H + 2.0;
605 +
    let mut svg = format!(
606 +
        r#"<svg class="trust-graph" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="Release trust graph">"#
607 +
    );
522 608
    for (a, b, solid) in &edges {
523 -
        let (x1, y1) = (nodes[*a].0 + W, nodes[*a].1 + H / 2.0);
524 -
        let (x2, y2) = (nodes[*b].0, nodes[*b].1 + H / 2.0);
609 +
        let (x1, y1) = (x(&nodes[*a]) + W, y(&nodes[*a]) + H / 2.0);
610 +
        let (x2, y2) = (x(&nodes[*b]), y(&nodes[*b]) + H / 2.0);
525 611
        let mx = (x1 + x2) / 2.0;
526 612
        svg.push_str(&format!(
527 613
            r#"<path class="edge{}" d="M{x1},{y1} C{mx},{y1} {mx},{y2} {x2},{y2}"/>"#,
528 614
            if *solid { "" } else { " dashed" }
529 615
        ));
530 616
    }
531 -
    for (x, y, title, sub, class) in &nodes {
617 +
    for n in &nodes {
618 +
        let (nx, ny) = (x(n), y(n));
619 +
        // The badge, in a circle at the node's right.
620 +
        let (cx, cy) = (nx + W - 16.0, ny + H / 2.0);
621 +
        let badge = match n.class {
622 +
            // A padlock: the shackle, then the body.
623 +
            "pass" => format!(
624 +
                r#"<path class="shackle" d="M{},{} v-3 a3.5,3.5 0 0 1 7,0 v3"/><rect class="lock" x="{}" y="{}" width="10" height="7.5" rx="1.5"/>"#,
625 +
                cx - 3.5,
626 +
                cy - 0.5,
627 +
                cx - 5.0,
628 +
                cy - 0.5
629 +
            ),
630 +
            "warn" => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">!</text>"#, cy + 4.0),
631 +
            _ => format!(r#"<text class="mark" x="{cx}" y="{}" text-anchor="middle">✘</text>"#, cy + 4.0),
632 +
        };
532 633
        svg.push_str(&format!(
533 -
            r#"<g class="node {class}"><rect x="{x}" y="{y}" width="{W}" height="{H}" rx="4"/><text x="{}" y="{}" class="t">{}</text><text x="{}" y="{}" class="s">{}</text></g>"#,
534 -
            x + 8.0,
535 -
            y + 14.0,
536 -
            esc(&truncate(title, 26)),
537 -
            x + 8.0,
538 -
            y + 27.0,
539 -
            esc(&truncate(sub, 30)),
634 +
            r#"<g class="node {}"><title>{}</title><rect x="{nx}" y="{ny}" width="{W}" height="{H}" rx="6"/><circle class="badge" cx="{cx}" cy="{cy}" r="10"/>{badge}<text x="{}" y="{}" class="t">{}</text><text x="{}" y="{}" class="s">{}</text></g>"#,
635 +
            n.class,
636 +
            esc(&n.tip),
637 +
            nx + 10.0,
638 +
            ny + 17.0,
639 +
            esc(&truncate(&n.title, 28)),
640 +
            nx + 10.0,
641 +
            ny + 31.0,
642 +
            esc(&truncate(&n.sub, 32)),
540 643
        ));
541 644
    }
542 645
    svg.push_str("</svg>");
…
52 unchanged lines
modified crates/hub/tests/flow.rs +12 −0
…
988 unchanged lines
989 989
        "tool: pinned environment",
990 990
        "notes.txt: no build found",
991 991
        "<svg class=\"trust-graph\"",
992 +
        // oxygenhub#79: a summary of badges, badges in the graph, and the
993 +
        // graph drawn at its own size rather than stretched to the page.
994 +
        "Tag signed by",
995 +
        "1 of 1 changes signed or sealed",
996 +
        "1 of 2 assets built and attested by OxygenFlow",
997 +
        "attested build",
998 +
        "uploaded by hand",
999 +
        "receipt verified",
1000 +
        "class=\"shackle\"",
1001 +
        "<title>notes.txt:",
992 1002
    ] {
993 1003
        assert!(page.contains(want), "missing {want:?}");
994 1004
    }
1005 +
    let svg = &page[page.find("<svg class=\"trust-graph\"").unwrap()..];
1006 +
    assert!(svg[..200].contains(" width=\"") && svg[..200].contains(" height=\""), "the graph has its own size: {}", &svg[..200]);
995 1007
    let (_, releases) = call(&h.app, "GET", "/andy/demo/releases", None, None).await;
996 1008
    assert!(releases.contains("/andy/demo/releases/trust/v1"), "the release links its audit");
997 1009
…
435 unchanged lines
modified templates/release_trust.html +4 −2
…
8 unchanged lines
9 9
<a class="go small" href="?format=json" download>Export JSON</a></div>
10 10
<p class="small mute">Who wrote what this release holds, how it is authenticated, what built each asset and from what, with every claim audited below.{% if let Some(p) = t.since %} Changes are counted since the previous release, {{ p }}.{% endif %} The export carries the signed receipts: check each with <code>oxy receipt verify</code>.</p>
11 11
12 +
<ul class="plain trust-summary">{% for b in t.summary() %}<li class="tb tb-{{ b.class }}"><span class="tb-mark" aria-hidden="true">{% if b.class == "pass" %}<svg viewBox="0 0 16 16" width="14" height="14"><path d="M5 7V5a3 3 0 0 1 6 0v2" fill="none" stroke="currentColor" stroke-width="1.6"/><rect x="3.5" y="7" width="9" height="7" rx="1.5" fill="currentColor"/></svg>{% else if b.class == "warn" %}!{% else %}✘{% endif %}</span>{{ b.text }}</li>{% endfor %}</ul>
13 +
12 14
<section class="panel audit audit-{{ t.verdict_class() }}">
13 15
  <h2>Audit <span class="small mute">{{ t.audit.len()|plural("check", "checks") }}</span></h2>
14 16
  <ul class="plain checks">{% for c in t.audit %}<li class="{{ c.class() }}"><span class="glyph">{{ c.glyph() }}</span> <strong>{{ c.title }}</strong>{% if !c.detail.is_empty() %} <span class="small mute">{{ c.detail }}</span>{% endif %}</li>{% endfor %}</ul>
…
1 unchanged lines
16 18
17 19
<section class="panel">
18 20
  <h2>Graph</h2>
19 -
  <p class="small mute">Authors → the tagged state → the jobs that built the assets → the assets. A dashed line is a build from another state than the tag's.</p>
21 +
  <p class="small mute">Authors → the tagged state → the jobs that built the assets → the assets. A lock marks what is signed and checked, <strong>!</strong> what is not (unsigned, or uploaded by hand), <strong>✘</strong> what fails. A dashed line is a build from another state than the tag's. Hover a box for the details.</p>
20 22
  <div class="trust-graph-wrap">{{ graph|safe }}</div>
21 23
</section>
22 24
…
17 unchanged lines
40 42
  <h2>Assets</h2>
41 43
  {% if t.assets.is_empty() %}<p class="mute">This release has no assets.</p>{% endif %}
42 44
  {% for a in t.assets %}
43 -
  <h3><a href="{{ a.url }}">{{ a.name }}</a> <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.blake3 }}">{{ a.blake3|short }}</code></span></h3>
45 +
  <h3><a href="{{ a.url }}">{{ a.name }}</a> {% if let Some(b) = a.build %}{% if b.receipt.is_some() && b.receipt_problem.is_none() %}<span class="tb tb-pass tb-sm">attested build</span>{% else %}<span class="tb tb-warn tb-sm">built, not attested</span>{% endif %}{% else %}<span class="tb tb-warn tb-sm">uploaded by hand</span>{% endif %} <span class="small mute">{{ a.size|bytes }} · blake3 <code title="{{ a.blake3 }}">{{ a.blake3|short }}</code></span></h3>
44 46
  {% if let Some(b) = a.build %}
45 47
  <dl class="facts">
46 48
    <dt>Built by</dt><dd><a href="{{ b.run_url }}">run #{{ b.run }}</a> job <strong>{{ b.job }}</strong>{% if let Some(c) = b.exit_code %}, exit {{ c }}{% endif %}</dd>
…
11 unchanged lines
modified templates/style.css +18 −1
…
578 unchanged lines
579 579
.audit-warn { border-left: 3px solid var(--warn); }
580 580
.audit-pass { border-left: 3px solid var(--ok); }
581 581
.trust-graph-wrap { overflow-x: auto; }
582 -
svg.trust-graph { min-width: 720px; width: 100%; height: auto; }
582 +
svg.trust-graph { max-width: 100%; height: auto; display: block; }
583 +
svg.trust-graph .node .badge { fill: var(--bg); stroke: none; }
584 +
svg.trust-graph .node.pass .badge { fill: color-mix(in srgb, var(--ok) 15%, var(--panel)); }
585 +
svg.trust-graph .node.warn .badge { fill: color-mix(in srgb, var(--warn) 18%, var(--panel)); }
586 +
svg.trust-graph .node.fail .badge { fill: color-mix(in srgb, var(--bad) 15%, var(--panel)); }
587 +
svg.trust-graph .node .lock { fill: var(--ok); stroke: none; }
588 +
svg.trust-graph .node .shackle { fill: none; stroke: var(--ok); stroke-width: 1.6; }
589 +
svg.trust-graph .node.warn .mark { fill: var(--warn); font-weight: 700; font-size: 13px; }
590 +
svg.trust-graph .node.fail .mark { fill: var(--bad); font-weight: 700; font-size: 12px; }
591 +
svg.trust-graph text.t { font-weight: 600; }
592 +
/* Trust badges: the page's summary, and the assets' headings. */
593 +
.trust-summary { display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 16px; }
594 +
.tb { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); font-size: 13px; font-weight: 500; }
595 +
.tb-sm { padding: 1px 8px; font-size: 12px; vertical-align: middle; }
596 +
.tb-mark { display: inline-flex; font-weight: 700; }
597 +
.tb-pass { color: var(--ok); border-color: color-mix(in srgb, var(--ok) 45%, var(--line)); background: color-mix(in srgb, var(--ok) 8%, transparent); }
598 +
.tb-warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 45%, var(--line)); background: color-mix(in srgb, var(--warn) 8%, transparent); }
599 +
.tb-fail { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 45%, var(--line)); background: color-mix(in srgb, var(--bad) 8%, transparent); }
583 600
svg.trust-graph .edge { fill: none; stroke: var(--line); stroke-width: 1.5; }
584 601
svg.trust-graph .edge.dashed { stroke-dasharray: 5 4; stroke: var(--warn); }
585 602
svg.trust-graph .node rect { fill: var(--panel); stroke: var(--line); }
…
62 unchanged lines