OXYGENHUB

Reports: cards with a kind icon and health, frames that fit and share the hub's look f3e5e2bc78e9

The Reports tab's cards get an icon per kind, a tone and badge from their
thresholds (met, crossed, or none), metric tiles, and Coverage and Tests
with their latest figure. A report's HTML pages get a small frame script
that reports their height, so the frame grows to fit instead of
scrolling, and passes the hub's theme on; /static/report.css gives a
report the hub's fonts and colour tokens to use instead of its own.
Static assets are readable across origins for the sandboxed frame.

refs #127
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 14:31 UTC
Signature
✔ signed by Andy McCall
Hash
f3e5e2bc78e936e8d327871445ab4baeb5a2de73081514efa21f26a11c47d4aa
Depends on
145306044896 42fa95540468 50d93fa443dc 8475176972e8 8e820b4ae875 c40c2d3edb51 c8df33a6829f d23464c0ac60
Depended on by
a7aa76ac52d7 17ca6cddc534 95b525b0b977 40dd3f86379a
In bookmarks
api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check main oh-comment-body-file proposal-list-fast
In tags
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
33 atoms, 2 tree operations, 9 files

Browse the tree at this change

added assets/report-frame.js +25 −0
1 +
/* OxygenHub report frame (oxygenhub#127): the hub adds this to a report's
2 +
   HTML pages. It tells the page around the frame how tall the report is,
3 +
   so the frame grows to fit instead of scrolling, and takes the hub's
4 +
   theme (data-theme="dark" or "light" on <html>) for report.css. It sends
5 +
   nothing but sizes, and reads nothing but the theme. */
6 +
(function () {
7 +
  if (window.parent === window) return;
8 +
  var root = document.documentElement, last = 0;
9 +
  var send = function () {
10 +
    var h = Math.ceil(Math.max(root.scrollHeight, document.body ? document.body.scrollHeight : 0));
11 +
    if (h && h !== last) {
12 +
      last = h;
13 +
      window.parent.postMessage({ oxygenhubReport: { height: h } }, "*");
14 +
    }
15 +
  };
16 +
  window.addEventListener("message", function (e) {
17 +
    if (e.source !== window.parent) return;
18 +
    var t = e.data && e.data.oxygenhubTheme;
19 +
    if (t === "dark" || t === "light") root.dataset.theme = t;
20 +
  });
21 +
  window.addEventListener("load", send);
22 +
  if (window.ResizeObserver) new ResizeObserver(send).observe(root);
23 +
  else setInterval(send, 500);
24 +
  window.parent.postMessage({ oxygenhubReport: { ready: true } }, "*");
25 +
})();
added assets/report.css +33 −0
1 +
/* OxygenHub report stylesheet (oxygenhub#127). A report's page links it
2 +
   to look like the hub around it, with no fonts or colours of its own:
3 +
4 +
     <link rel="stylesheet" href="/static/report.css">
5 +
6 +
   It sets the hub's fonts (Barlow; IBM Plex Mono for code) and its colour
7 +
   tokens, following the hub's theme as the frame script passes it on:
8 +
   --bg, --panel, --soft, --ink, --mute, --line, --acc, --acc-2, --ok,
9 +
   --bad, --warn, and --m1..--m5 for chart series. The page's background
10 +
   is the hub's panel. */
11 +
@font-face { font-family: "Barlow"; font-weight: 400; font-display: swap; src: url(/static/fonts/Barlow-400.woff2) format("woff2"); }
12 +
@font-face { font-family: "Barlow"; font-weight: 500; font-display: swap; src: url(/static/fonts/Barlow-500.woff2) format("woff2"); }
13 +
@font-face { font-family: "Barlow"; font-weight: 600; font-display: swap; src: url(/static/fonts/Barlow-600.woff2) format("woff2"); }
14 +
:root {
15 +
  color-scheme: dark;
16 +
  --bg:#070c18; --panel:#0e1526; --soft:#141d33; --ink:#e6ecf7; --mute:#8592ad; --line:#1f2b47;
17 +
  --acc:#00e0f0; --acc-2:#0072ff; --ok:#5fe3a1; --bad:#ff5e7a; --warn:#b48cff;
18 +
  --m1:#00f0ff; --m2:#d6dcea; --m3:#5aa8ff; --m4:#0072ff; --m5:#8592ad;
19 +
}
20 +
:root[data-theme="light"] {
21 +
  color-scheme: light;
22 +
  --bg:#f4f7fc; --panel:#ffffff; --soft:#e9eef8; --ink:#1b2333; --mute:#5f6a81; --line:#d6dded;
23 +
  --acc:#0064e0; --acc-2:#00b8d4; --ok:#1a7850; --bad:#c62d47; --warn:#6b3fd1;
24 +
  --m1:#0090a8; --m2:#7a8290; --m3:#2f6fcf; --m4:#0055c0; --m5:#8a8f9c;
25 +
}
26 +
html { background: var(--panel); }
27 +
body { margin: 0; padding: 16px; background: var(--panel); color: var(--ink); font: 15px/1.45 "Barlow", system-ui, sans-serif; }
28 +
code, pre, kbd { font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }
29 +
a { color: var(--acc); }
30 +
h1, h2, h3 { font-weight: 600; line-height: 1.2; }
31 +
table { border-collapse: collapse; }
32 +
th, td { border-bottom: 1px solid var(--line); padding: .35em .6em; text-align: left; }
33 +
th { color: var(--mute); font-weight: 500; }
modified assets/hub.js +21 −0
…
54 unchanged lines
55 55
    });
56 56
  });
57 57
58 +
  /* Report frames (#127): a report's page says how tall it is, and the
59 +
     frame grows to fit; the frame is told the theme, at start and on a
60 +
     change. Only messages from the frame itself are taken. */
61 +
  var frames = document.querySelectorAll("iframe.rep-frame");
62 +
  var themeToFrames = function () {
63 +
    frames.forEach(function (f) { if (f.contentWindow) f.contentWindow.postMessage({ oxygenhubTheme: document.documentElement.dataset.theme || "dark" }, "*"); });
64 +
  };
65 +
  if (frames.length) window.addEventListener("message", function (e) {
66 +
    frames.forEach(function (f) {
67 +
      if (e.source !== f.contentWindow) return;
68 +
      var r = e.data && e.data.oxygenhubReport;
69 +
      if (!r) return;
70 +
      if (r.ready) themeToFrames();
71 +
      if (typeof r.height === "number" && r.height > 0) {
72 +
        f.style.height = Math.min(Math.max(r.height, 120), 40000) + "px";
73 +
        f.classList.add("sized");
74 +
      }
75 +
    });
76 +
  });
77 +
58 78
  /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */
59 79
  var toggle = document.querySelector("[data-theme-toggle]");
60 80
  if (toggle) {
…
11 unchanged lines
72 92
      root.dataset.themePref = p;
73 93
      root.dataset.theme = p === "auto" ? (window.matchMedia && matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark") : p;
74 94
      show();
95 +
      themeToFrames();
75 96
    });
76 97
  }
77 98
…
434 unchanged lines
modified crates/hub/src/web/reports.rs +80 −4
…
43 unchanged lines
44 44
pub struct Item {
45 45
    pub report: Report,
46 46
    pub metrics: Vec<MetricView>,
47 +
    /// `good`, `bad` or `neutral`: its thresholds met, one crossed, or none.
48 +
    pub health: &'static str,
49 +
    /// What the badge says: "2 thresholds met", "1 over its threshold".
50 +
    pub badge: String,
47 51
}
48 52
53 +
/// The Coverage or Tests entry on the Reports tab: its latest figure.
54 +
pub struct Builtin {
55 +
    pub big: String,
56 +
    pub sub: String,
57 +
    pub health: &'static str,
58 +
    pub when_ms: i64,
59 +
    pub bookmark: String,
60 +
}
61 +
62 +
/// The health and badge of a report from its metrics' thresholds.
63 +
fn health_of(metrics: &[MetricView]) -> (&'static str, String) {
64 +
    let with = metrics.iter().filter(|m| !m.threshold.is_empty()).count();
65 +
    let failing = metrics.iter().filter(|m| m.failing).count();
66 +
    match (with, failing) {
67 +
        (0, _) => ("neutral", format!("{} metric{}", metrics.len(), if metrics.len() == 1 { "" } else { "s" })),
68 +
        (_, 0) => ("good", format!("{with} threshold{} met", if with == 1 { "" } else { "s" })),
69 +
        (_, f) => ("bad", format!("{f} over {}", if f == 1 { "its threshold" } else { "their thresholds" })),
70 +
    }
71 +
}
72 +
49 73
#[derive(Template)]
50 74
#[template(path = "reports.html")]
51 75
pub struct ReportsPage {
…
1 unchanged lines
53 77
    repo: RepoShell,
54 78
    bookmark: String,
55 79
    items: Vec<Item>,
56 -
    coverage: bool,
57 -
    tests: bool,
80 +
    coverage: Option<Builtin>,
81 +
    tests: Option<Builtin>,
58 82
}
59 83
60 84
#[derive(Template)]
…
108 unchanged lines
169 193
            Some(p) => reports::metrics(&app.db, p.id).await.map_err(e)?,
170 194
            None => vec![],
171 195
        };
172 -
        items.push(Item { metrics: views(&now, &before, &[]), report: r });
196 +
        let metrics = views(&now, &before, &[]);
197 +
        let (health, badge) = health_of(&metrics);
198 +
        items.push(Item { metrics, report: r, health, badge });
173 199
    }
174 200
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
175 -
    Ok(Page(ReportsPage { shell, coverage: repo.coverage, tests: repo.tests, repo, bookmark: bookmark.unwrap_or_default(), items }))
201 +
    // Coverage and Tests: their latest, on the default bookmark when it has
202 +
    // one, else wherever was newest.
203 +
    let pick = |list: Vec<String>| bookmark.clone().or_else(|| list.iter().find(|b| **b == row.default_bookmark).or(list.first()).cloned());
204 +
    let coverage = match pick(crate::coverage::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.coverage) {
205 +
        Some(b) => crate::coverage::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|c| Builtin {
206 +
            big: c.percent().map(|p| format!("{p:.1}%")).unwrap_or_else(|| "–".into()),
207 +
            sub: format!("{} of {} lines covered", c.lines_hit, c.lines_found),
208 +
            health: match c.percent() {
209 +
                Some(p) if p >= 75.0 => "good",
210 +
                Some(p) if p < 50.0 => "bad",
211 +
                _ => "neutral",
212 +
            },
213 +
            when_ms: c.created_ms,
214 +
            bookmark: c.bookmark.clone(),
215 +
        }),
216 +
        None => None,
217 +
    };
218 +
    let tests = match pick(crate::junit::bookmarks(&app.db, row.id).await.map_err(e)?).filter(|_| repo.tests) {
219 +
        Some(b) => crate::junit::on_bookmark(&app.db, row.id, &b, 1).await.map_err(e)?.into_iter().next().map(|t| Builtin {
220 +
            big: if t.broken() > 0 { format!("{} failing", t.broken()) } else { format!("{} passed", t.passed) },
221 +
            sub: t.summary(),
222 +
            health: if t.broken() > 0 { "bad" } else { "good" },
223 +
            when_ms: t.created_ms,
224 +
            bookmark: t.bookmark.clone(),
225 +
        }),
226 +
        None => None,
227 +
    };
228 +
    Ok(Page(ReportsPage { shell, coverage, tests, repo, bookmark: bookmark.unwrap_or_default(), items }))
176 229
}
177 230
178 231
/// `GET /{owner}/{name}/reports/{report}`: one report, the newest on its
…
51 unchanged lines
230 283
        files_base,
231 284
        report,
232 285
    }))
286 +
}
287 +
288 +
/// `page` with the report frame script added (#127).
289 +
fn with_frame_script(page: Vec<u8>, version: &str) -> Vec<u8> {
290 +
    let tag = format!(r#"<script src="/static/report-frame.js?v={version}"></script>"#);
291 +
    let lower = page.to_ascii_lowercase();
292 +
    let find = |needle: &[u8]| lower.windows(needle.len()).position(|w| w == needle);
293 +
    let at = find(b"</head>").or_else(|| find(b"<body")).unwrap_or(page.len());
294 +
    let mut out = Vec::with_capacity(page.len() + tag.len());
295 +
    out.extend_from_slice(&page[..at]);
296 +
    out.extend_from_slice(tag.as_bytes());
297 +
    out.extend_from_slice(&page[at..]);
298 +
    out
233 299
}
234 300
235 301
/// A file's type, by its extension.
…
36 unchanged lines
272 338
        Ok(b) => b,
273 339
        Err(_) => return not_found(),
274 340
    };
341 +
    // A page gets the frame script, which sizes the frame to it and passes
342 +
    // the hub's theme on: before </head>, else <body>, else at the end.
343 +
    let bytes =
344 +
        if content_type(&path).starts_with("text/html") { with_frame_script(bytes, super::statics::assets_version()) } else { bytes };
275 345
    let origin = app.config.base_url().trim_end_matches('/').to_string();
276 346
    let csp = format!(
277 347
        "sandbox allow-scripts; default-src 'none'; script-src 'self' {origin} 'unsafe-inline' 'unsafe-eval'; \
…
35 unchanged lines
313 383
        assert_eq!(num(16.40), "16.4");
314 384
        assert!(spark(&[1.0, 3.0, 2.0]).contains("polyline"));
315 385
        assert!(spark(&[1.0]).is_empty());
386 +
        let page = with_frame_script(b"<!doctype html><html><HEAD><title>t</title></HEAD><body>x</body></html>".to_vec(), "v1");
387 +
        let page = String::from_utf8(page).unwrap();
388 +
        assert!(page.starts_with("<!doctype html>"), "the doctype stays first");
389 +
        assert!(page.contains(r#"<script src="/static/report-frame.js?v=v1"></script></HEAD>"#), "{page}");
390 +
        let bare = String::from_utf8(with_frame_script(b"<p>hi</p>".to_vec(), "v1")).unwrap();
391 +
        assert!(bare.ends_with("</script>"), "{bare}");
316 392
    }
317 393
}
modified crates/hub/src/web/statics.rs +16 −4
…
15 unchanged lines
16 16
    V.get_or_init(|| {
17 17
        let mut h = blake3::Hasher::new();
18 18
        h.update(CSS.as_bytes());
19 -
        for a in ASSETS.iter().filter(|a| a.path == "github.css" || a.path == "hub.js") {
19 +
        for a in ASSETS.iter().filter(|a| matches!(a.path, "github.css" | "hub.js" | "report-frame.js" | "report.css")) {
20 20
            h.update(a.bytes);
21 21
        }
22 22
        h.finalize().to_hex()[..12].to_string()
…
18 unchanged lines
41 41
    },
42 42
    Asset { path: "github.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../../../../assets/github.css") },
43 43
    Asset { path: "hub.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/hub.js") },
44 +
    // For report frames (#127): the size and theme bridge the hub adds to
45 +
    // a report's pages, and the stylesheet a report may link.
46 +
    Asset { path: "report-frame.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/report-frame.js") },
47 +
    Asset { path: "report.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../../../../assets/report.css") },
44 48
    Asset {
45 49
        path: "vendor/editor.js",
46 50
        mime: "text/javascript; charset=utf-8",
…
31 unchanged lines
78 82
79 83
pub async fn asset(Path(path): Path<String>) -> Response {
80 84
    match ASSETS.iter().find(|a| a.path == path) {
81 -
        Some(a) => {
82 -
            ([(header::CONTENT_TYPE, a.mime), (header::CACHE_CONTROL, "public, max-age=31536000, immutable")], a.bytes).into_response()
83 -
        }
85 +
        // Readable across origins: a report's sandboxed frame has an
86 +
        // opaque origin, and loads fonts in CORS mode (#127).
87 +
        Some(a) => (
88 +
            [
89 +
                (header::CONTENT_TYPE, a.mime),
90 +
                (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
91 +
                (header::ACCESS_CONTROL_ALLOW_ORIGIN, "*"),
92 +
            ],
93 +
            a.bytes,
94 +
        )
95 +
            .into_response(),
84 96
        None => StatusCode::NOT_FOUND.into_response(),
85 97
    }
86 98
}
modified crates/hub/tests/flow.rs +6 −1
…
1047 unchanged lines
1048 1048
    let csp = res.headers()[header::CONTENT_SECURITY_POLICY].to_str().unwrap().to_string();
1049 1049
    assert!(csp.starts_with("sandbox allow-scripts;") && csp.contains("default-src 'none'"), "{csp}");
1050 1050
    assert_eq!(res.headers()[header::ACCESS_CONTROL_ALLOW_ORIGIN], "*");
1051 -
    assert_eq!(res.into_body().collect().await.unwrap().to_bytes().as_ref(), page_bytes.as_slice());
1051 +
    // The page as kept, with the frame script that sizes the frame added.
1052 +
    let served = res.into_body().collect().await.unwrap().to_bytes().to_vec();
1053 +
    assert!(served.starts_with(&page_bytes), "the report's page");
1054 +
    assert!(String::from_utf8_lossy(&served).contains("/static/report-frame.js"), "with the frame script");
1055 +
    let (st, _) = get_bytes(&h.app, "/static/report.css").await;
1056 +
    assert_eq!(st, StatusCode::OK, "the stylesheet a report may link");
1052 1057
    let data = src.replace("/index.html", "/data/frames.csv");
1053 1058
    assert_eq!(get_bytes(&h.app, &data).await, (StatusCode::OK, data_bytes.clone()));
1054 1059
    // A link with another report's MAC, or a file it lacks, finds nothing.
…
1453 unchanged lines
modified docs/reports.md +22 −0
…
173 unchanged lines
174 174
   job's output.
175 175
3. Push. The report appears on the Reports tab after the run.
176 176
177 +
### Fitting the hub
178 +
179 +
- **Height:** the hub adds a small script to a report's HTML pages
180 +
  (`/static/report-frame.js`) that tells the page around the frame how
181 +
  tall the report is, so the frame grows to fit and the report never
182 +
  scrolls inside it. Lay the page out by its content: nothing sized to the
183 +
  viewport (`100vh`, `height: 100%` on `html`/`body`), or it grows with
184 +
  the frame.
185 +
- **Look:** link the hub's stylesheet, and drop any fonts or colours of the
186 +
  report's own:
187 +
188 +
  ```html
189 +
  <link rel="stylesheet" href="/static/report.css">
190 +
  ```
191 +
192 +
  It sets the hub's fonts and colour tokens (`--bg`, `--panel`, `--soft`,
193 +
  `--ink`, `--mute`, `--line`, `--acc`, `--ok`, `--bad`, `--warn`,
194 +
  `--m1`..`--m5` for series), dark or light as the hub is, switching when
195 +
  the viewer switches. Charts read them with
196 +
  `getComputedStyle(document.documentElement).getPropertyValue("--m1")`
197 +
  and redraw on a `data-theme` change if they cache colours.
198 +
177 199
## 8. Order of work
178 200
179 201
*Status, 2026-10-11:* storage and index (1) are deployed; the Reports tab,
…
16 unchanged lines
modified templates/reports.html +34 −19
…
1 unchanged lines
2 2
{% import "repo_tabs.html" as m %}
3 3
{% block title %}Reports · {{ repo.owner }}/{{ repo.name }}{% endblock %}
4 4
{% block tabs %}{% call m::tabs(repo, "reports") %}{% endblock %}
5 +
{% macro icon(kind) %}<span class="rep-icon rep-{{ kind }}" aria-hidden="true"><svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">{% if kind == "performance" %}<path d="M4 17a8 8 0 1 1 16 0"/><path d="M12 17l4.5-6"/><circle cx="12" cy="17" r="1.2" fill="currentColor"/>{% else if kind == "coverage" %}<path d="M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"/><path d="M8.5 12l2.5 2.5 4.5-5"/>{% else if kind == "tests" %}<path d="M9 3h6"/><path d="M10 3v6l-5 9a2 2 0 0 0 1.8 3h10.4a2 2 0 0 0 1.8-3l-5-9V3"/><path d="M7.5 15h9"/>{% else %}<path d="M5 20V11"/><path d="M12 20V4"/><path d="M19 20v-6"/>{% endif %}</svg></span>{% endmacro %}
5 6
{% block content %}
6 -
<h1>Reports</h1>
7 -
<p class="mute small">What this repository's Flow jobs measure, newest run first{% if !bookmark.is_empty() %}, on <strong>{{ bookmark }}</strong> (<a href="{{ repo.url }}/reports">all bookmarks</a>){% endif %}. Each metric shows its change against the run before on the same bookmark.</p>
7 +
<div class="rep-top">
8 +
  <h1>Reports</h1>
9 +
  <p class="mute small">What this repository's Flow jobs measure, newest run first{% if !bookmark.is_empty() %}, on <strong>{{ bookmark }}</strong> (<a href="{{ repo.url }}/reports">all bookmarks</a>){% endif %}. Changes are against the run before on the same bookmark.</p>
10 +
</div>
8 11
<div class="rep-list">
9 12
{% for i in items %}
10 -
  <a class="panel rep-card" href="{{ repo.url }}/reports/{{ i.report.name }}?id={{ i.report.id }}">
13 +
  <article class="rep-card rep-{{ i.health }}">
11 14
    <div class="rep-head">
12 -
      <span class="tag rep-kind rep-{{ i.report.kind }}">{{ i.report.kind }}</span>
13 -
      <strong class="rep-title">{{ i.report.title }}</strong>
15 +
      {% call icon(i.report.kind) %}
16 +
      <div class="rep-name">
17 +
        <a class="rep-link" href="{{ repo.url }}/reports/{{ i.report.name }}?id={{ i.report.id }}">{{ i.report.title }}</a>
18 +
        <span class="rep-kindname">{{ i.report.kind }}</span>
19 +
      </div>
20 +
      <span class="rep-badge">{% if i.health == "good" %}✓ {% else if i.health == "bad" %}✗ {% endif %}{{ i.badge }}</span>
14 21
    </div>
15 22
    {% if !i.report.summary.is_empty() %}<p class="rep-summary">{{ i.report.summary }}</p>{% endif %}
16 -
    {% if !i.metrics.is_empty() %}<ul class="rep-metrics">{% for v in i.metrics %}<li class="{% if v.failing %}rep-failing{% endif %}"><span class="k">{{ v.key }}</span> <span class="v">{{ v.value }}{% if !v.unit.is_empty() %} <small>{{ v.unit }}</small>{% endif %}</span>{% if !v.delta.is_empty() %} <span class="rep-delta {{ v.trend }}">{{ v.delta }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
17 -
    <div class="small mute">{% if !i.report.bookmark.is_empty() %}on {{ i.report.bookmark }} · {% endif %}{% if let Some(n) = i.report.run_number %}run #{{ n }} · {% endif %}job {{ i.report.job }} · <time title="{{ i.report.created_ms|when_i }}">{{ i.report.created_ms|ago_i }}</time></div>
18 -
  </a>
23 +
    {% if !i.metrics.is_empty() %}<ul class="rep-tiles">{% for v in i.metrics %}<li class="{% if v.failing %}rep-failing{% endif %}"><span class="v">{{ v.value }}{% if !v.unit.is_empty() %}<small> {{ v.unit }}</small>{% endif %}</span><span class="k">{{ v.key }}</span>{% if !v.delta.is_empty() %}<span class="rep-delta {{ v.trend }}">{{ v.delta }}</span>{% endif %}</li>{% endfor %}</ul>{% endif %}
24 +
    <div class="rep-foot">{% if !i.report.bookmark.is_empty() %}<span class="lbl lbl-bm">{{ i.report.bookmark }}</span>{% endif %}{% if let Some(n) = i.report.run_number %} run #{{ n }} ·{% endif %} {{ i.report.job }} · <time title="{{ i.report.created_ms|when_i }}">{{ i.report.created_ms|ago_i }}</time></div>
25 +
  </article>
19 26
{% endfor %}
20 -
{% if coverage %}
21 -
  <a class="panel rep-card" href="{{ repo.url }}/coverage">
22 -
    <div class="rep-head"><span class="tag rep-kind rep-coverage">coverage</span> <strong class="rep-title">Coverage</strong></div>
23 -
    <p class="rep-summary">Line coverage from the jobs' <code>coverage</code> artifacts, by file and across runs.</p>
24 -
  </a>
27 +
{% if let Some(c) = coverage %}
28 +
  <article class="rep-card rep-{{ c.health }}">
29 +
    <div class="rep-head">
30 +
      {% call icon("coverage") %}
31 +
      <div class="rep-name"><a class="rep-link" href="{{ repo.url }}/coverage">Coverage</a><span class="rep-kindname">coverage</span></div>
32 +
    </div>
33 +
    <div class="rep-hero"><span class="rep-big">{{ c.big }}</span><span class="mute">{{ c.sub }}</span></div>
34 +
    <div class="rep-foot"><span class="lbl lbl-bm">{{ c.bookmark }}</span> from the jobs' <code>coverage</code> artifacts · <time title="{{ c.when_ms|when_i }}">{{ c.when_ms|ago_i }}</time></div>
35 +
  </article>
25 36
{% endif %}
26 -
{% if tests %}
27 -
  <a class="panel rep-card" href="{{ repo.url }}/tests">
28 -
    <div class="rep-head"><span class="tag rep-kind rep-tests">tests</span> <strong class="rep-title">Tests</strong></div>
29 -
    <p class="rep-summary">Test results from the jobs' <code>junit</code> artifacts, each case and its history.</p>
30 -
  </a>
37 +
{% if let Some(t) = tests %}
38 +
  <article class="rep-card rep-{{ t.health }}">
39 +
    <div class="rep-head">
40 +
      {% call icon("tests") %}
41 +
      <div class="rep-name"><a class="rep-link" href="{{ repo.url }}/tests">Tests</a><span class="rep-kindname">tests</span></div>
42 +
    </div>
43 +
    <div class="rep-hero"><span class="rep-big">{{ t.big }}</span><span class="mute">{{ t.sub }}</span></div>
44 +
    <div class="rep-foot"><span class="lbl lbl-bm">{{ t.bookmark }}</span> from the jobs' <code>junit</code> artifacts · <time title="{{ t.when_ms|when_i }}">{{ t.when_ms|ago_i }}</time></div>
45 +
  </article>
31 46
{% endif %}
32 47
</div>
33 -
{% if items.is_empty() && !coverage && !tests %}<p class="empty">No reports yet. A Flow job keeps one with <code>report "NAME" { path "/src/out/report" }</code> in its output; see the OxygenFlow wiki's Reports page.</p>{% endif %}
48 +
{% if items.is_empty() && coverage.is_none() && tests.is_none() %}<p class="empty">No reports yet. A Flow job keeps one with <code>report "NAME" { path "/src/out/report" }</code> in its output; see the OxygenFlow wiki's Reports page.</p>{% endif %}
34 49
{% endblock %}
modified templates/style.css +27 −11
…
909 unchanged lines
910 910
.proj-list .empty { border: 0; margin: 0; }
911 911
912 912
/* Reports (oxygenhub#127) */
913 -
.rep-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(22em, 1fr)); gap: 1em; margin-top: 1em; }
914 -
.rep-card { display: flex; flex-direction: column; gap: .5em; color: var(--ink); text-decoration: none; padding: 1em; }
915 -
.rep-card:hover { border-color: var(--acc); }
916 -
.rep-head { display: flex; align-items: center; gap: .5em; }
917 -
.rep-title { font-size: 1.05em; }
913 +
.rep-top h1 { margin-bottom: .2em; }
914 +
.rep-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(21em, 1fr)); gap: 1em; margin-top: 1.2em; }
915 +
.rep-card { --tone: var(--mute); position: relative; display: flex; flex-direction: column; gap: .7em; padding: 1.1em 1.2em 1em; border: 1px solid var(--line); border-top: 3px solid var(--tone); border-radius: 12px;
916 +
  background: linear-gradient(180deg, color-mix(in srgb, var(--tone) 9%, var(--panel)), var(--panel) 70%); transition: border-color .15s, transform .15s, box-shadow .15s; }
917 +
.rep-card:hover { transform: translateY(-1px); box-shadow: 0 6px 18px color-mix(in srgb, var(--tone) 18%, transparent); border-color: color-mix(in srgb, var(--tone) 55%, var(--line)); }
918 +
.rep-card.rep-good { --tone: var(--ok); } .rep-card.rep-bad { --tone: var(--bad); } .rep-card.rep-neutral { --tone: var(--acc); }
919 +
.rep-head { display: flex; align-items: center; gap: .7em; }
920 +
.rep-icon { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: var(--kind); background: color-mix(in srgb, var(--kind) 14%, transparent); }
921 +
.rep-icon.rep-performance { --kind: var(--acc); } .rep-icon.rep-coverage { --kind: var(--ok); } .rep-icon.rep-tests { --kind: var(--warn); } .rep-icon.rep-custom { --kind: var(--mute); }
922 +
.rep-name { display: flex; flex-direction: column; min-width: 0; flex: 1; }
923 +
.rep-link { color: var(--ink); font-weight: 600; font-size: 1.08em; }
924 +
.rep-link::after { content: ""; position: absolute; inset: 0; border-radius: 12px; }
925 +
.rep-kindname { color: var(--mute); font-size: .8em; text-transform: uppercase; letter-spacing: .06em; }
926 +
.rep-badge { flex: none; font-size: .8em; font-weight: 600; padding: .2em .65em; border-radius: 999px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
927 +
.rep-summary { margin: 0; color: var(--ink); }
928 +
.rep-tiles { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5em; }
929 +
.rep-tiles li { display: flex; flex-direction: column; padding: .5em .65em; border-radius: 8px; background: var(--soft); min-width: 0; }
930 +
.rep-tiles .v { font-size: 1.15em; font-weight: 600; font-variant-numeric: tabular-nums; }
931 +
.rep-tiles .v small { font-weight: 400; color: var(--mute); font-size: .7em; }
932 +
.rep-tiles .k { font-size: .78em; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
933 +
.rep-tiles li.rep-failing { box-shadow: inset 3px 0 0 var(--bad); }
934 +
.rep-hero { display: flex; flex-direction: column; gap: .1em; }
935 +
.rep-big { font-size: 2em; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--tone); line-height: 1.1; }
936 +
.rep-foot { margin-top: auto; font-size: .82em; color: var(--mute); display: flex; flex-wrap: wrap; align-items: center; gap: .35em; }
918 937
.rep-kind { text-transform: lowercase; }
919 938
.rep-performance { color: var(--acc); } .rep-coverage { color: var(--ok); } .rep-tests { color: var(--warn); } .rep-custom { color: var(--mute); }
920 -
.rep-summary { margin: .2em 0; }
921 -
.rep-metrics { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: .4em 1.2em; }
922 -
.rep-metrics .k { color: var(--mute); font-size: .85em; }
923 -
.rep-metrics .v { font-weight: 600; font-variant-numeric: tabular-nums; }
924 -
.rep-delta { font-size: .85em; font-weight: 600; font-variant-numeric: tabular-nums; }
939 +
.rep-delta { font-size: .8em; font-weight: 600; font-variant-numeric: tabular-nums; }
925 940
.rep-delta.better { color: var(--ok); } .rep-delta.worse { color: var(--bad); } .rep-delta.same { color: var(--mute); }
926 941
.rep-failing .v, .rep-failing strong { color: var(--bad); }
927 942
.rep-table { width: 100%; border-collapse: collapse; }
…
2 unchanged lines
930 945
.rep-spark polyline { fill: none; stroke: var(--acc); stroke-width: 1.5; }
931 946
.rep-spark circle { fill: var(--acc); }
932 947
.rep-frame-wrap { margin: 1em 0; }
933 -
.rep-frame { width: 100%; height: 80vh; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
948 +
.rep-frame { display: block; width: 100%; height: 70vh; border: 1px solid var(--line); border-radius: 10px; background: var(--panel); }
949 +
.rep-frame.sized { overflow: hidden; }
934 950
.rep-context { display: grid; grid-template-columns: max-content 1fr; gap: .3em 1em; margin: 0; }
935 951
.rep-context dt { color: var(--mute); }
936 952
.rep-context dd { margin: 0; font-variant-numeric: tabular-nums; }
…
3 unchanged lines