OXYGENHUB

Reports: the timeline, play, compare, and the frame interface 95b525b0b977

A report's page gets a timeline of its runs on the bookmark: a stop per
run, arrow keys to step, Play at 1 to 10 s a run (carried in the URL),
and Compare against any other run, with every metric's two values and
difference and the two pages side by side. Inside the frame,
report.run() and report.history("file") are answered by the hub page
over postMessage, history reading that data file from each run.

refs #127
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-11 23:01 UTC
Signature
✔ signed by Andy McCall
Hash
95b525b0b977ed1a639e28a01dd3321709fa1f99312b6d446a53369ad764a510
Depends on
42fa95540468 50d93fa443dc c40c2d3edb51 c8df33a6829f d23464c0ac60 f3e5e2bc78e9
Depended on by
fac6853c4e46 a7aa76ac52d7 3b1bc27fe805 16626c0d69c0 20b8fe85888b
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.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
17 atoms, 0 tree operations, 7 files

Browse the tree at this change

modified assets/hub.js +57 −0
…
74 unchanged lines
75 75
    });
76 76
  });
77 77
78 +
  /* Report frames ask for runs (#127, RP4): report.run() is the run on
79 +
     screen, report.history(dataset) that file of data/ across the runs,
80 +
     oldest first. Answered only to the frame itself, from the files of
81 +
     this report's own runs. */
82 +
  if (frames.length) window.addEventListener("message", function (e) {
83 +
    frames.forEach(function (f) {
84 +
      if (e.source !== f.contentWindow) return;
85 +
      var ask = e.data && e.data.oxygenhubAsk;
86 +
      if (!ask) return;
87 +
      var runs = []; try { runs = JSON.parse(f.dataset.reportRuns || "[]"); } catch (_) {}
88 +
      var current = Number(f.dataset.reportCurrent);
89 +
      var reply = function (body) { f.contentWindow.postMessage({ oxygenhubAnswer: { n: ask.n, value: body.value, error: body.error } }, "*"); };
90 +
      var meta = function (r) { return { run: r.run, bookmark: r.bookmark, created_ms: r.created_ms, summary: r.summary, current: r.id === current }; };
91 +
      if (ask.what === "run") {
92 +
        var r = runs.filter(function (x) { return x.id === current; })[0];
93 +
        reply({ value: r ? meta(r) : null });
94 +
      } else if (ask.what === "history" && typeof ask.dataset === "string" && /^[A-Za-z0-9._+-]+(\/[A-Za-z0-9._+-]+)*$/.test(ask.dataset)) {
95 +
        Promise.all(runs.map(function (r) {
96 +
          return fetch(r.files + "/data/" + ask.dataset).then(function (res) { return res.ok ? res.text() : null; }).catch(function () { return null; })
97 +
            .then(function (text) { var m = meta(r); m.text = text; return m; });
98 +
        })).then(function (all) { reply({ value: all.filter(function (x) { return x.text !== null; }) }); });
99 +
      } else {
100 +
        reply({ error: "ask for run, or history with a file name under data/" });
101 +
      }
102 +
    });
103 +
  });
104 +
105 +
  /* The report timeline (#127, RP4): arrow keys step through the runs,
106 +
     Play steps on by itself at the chosen speed, carried in the URL. */
107 +
  var tl = document.querySelector("[data-timeline]");
108 +
  if (tl) {
109 +
    var stops = Array.prototype.slice.call(tl.querySelectorAll("[data-tl-stop]"));
110 +
    var at = stops.findIndex(function (a) { return a.classList.contains("on"); });
111 +
    var go = function (i, play) {
112 +
      if (i < 0 || i >= stops.length) return;
113 +
      var u = new URL(stops[i].href, location.href);
114 +
      if (play) u.searchParams.set("play", play); else u.searchParams.delete("play");
115 +
      location.href = u.toString();
116 +
    };
117 +
    document.addEventListener("keydown", function (e) {
118 +
      if (e.target.closest("input, select, textarea, [contenteditable]")) return;
119 +
      if (e.key === "ArrowLeft") { e.preventDefault(); go(at - 1, tl.dataset.play); }
120 +
      if (e.key === "ArrowRight") { e.preventDefault(); go(at + 1, tl.dataset.play); }
121 +
    });
122 +
    var speed = tl.querySelector("[data-tl-speed]"), button = tl.querySelector("[data-tl-play]");
123 +
    if (button) button.addEventListener("click", function () {
124 +
      if (tl.dataset.play) { go(at, null); return; }
125 +
      go(at >= stops.length - 1 ? 0 : at + 1, speed ? speed.value : "2");
126 +
    });
127 +
    if (speed) speed.addEventListener("change", function () { if (tl.dataset.play) go(at, speed.value); });
128 +
    if (tl.dataset.play) {
129 +
      var next = at + 1;
130 +
      if (next < stops.length) setTimeout(function () { go(next, tl.dataset.play); }, Number(tl.dataset.play) * 1000);
131 +
      else setTimeout(function () { go(at, null); }, Number(tl.dataset.play) * 1000);
132 +
    }
133 +
  }
134 +
78 135
  /* Theme switch in the header: dark, light, auto (follows the system); kept in the theme cookie. */
79 136
  var toggle = document.querySelector("[data-theme-toggle]");
80 137
  if (toggle) {
…
452 unchanged lines
modified assets/report-frame.js +20 −0
…
20 unchanged lines
21 21
  window.addEventListener("load", send);
22 22
  if (window.ResizeObserver) new ResizeObserver(send).observe(root);
23 23
  else setInterval(send, 500);
24 +
  // The frame interface (RP4): report.run() is the run on screen, and
25 +
  // report.history("frames.csv") that file of data/ across the report's
26 +
  // runs on its bookmark, oldest first, each { run, created_ms, current,
27 +
  // text }. Both are promises; outside the hub there is no report object.
28 +
  var asked = 0, waiting = {};
29 +
  window.addEventListener("message", function (e) {
30 +
    var a = e.source === window.parent && e.data && e.data.oxygenhubAnswer;
31 +
    if (!a || !waiting[a.n]) return;
32 +
    var w = waiting[a.n]; delete waiting[a.n];
33 +
    if (a.error) w.reject(new Error(a.error)); else w.resolve(a.value);
34 +
  });
35 +
  var ask = function (what, dataset) {
36 +
    return new Promise(function (resolve, reject) {
37 +
      var n = ++asked;
38 +
      waiting[n] = { resolve: resolve, reject: reject };
39 +
      window.parent.postMessage({ oxygenhubAsk: { n: n, what: what, dataset: dataset } }, "*");
40 +
      setTimeout(function () { if (waiting[n]) { delete waiting[n]; reject(new Error("no answer from the hub")); } }, 30000);
41 +
    });
42 +
  };
43 +
  window.report = { run: function () { return ask("run"); }, history: function (dataset) { return ask("history", dataset); } };
24 44
  window.parent.postMessage({ oxygenhubReport: { ready: true } }, "*");
25 45
})();
modified crates/hub/src/web/reports.rs +121 −1
…
20 unchanged lines
21 21
    bookmark: Option<String>,
22 22
    /// One report, by id.
23 23
    id: Option<i64>,
24 +
    /// Another run of the same report to compare with, by id (#127, RP4).
25 +
    compare: Option<i64>,
26 +
    /// Playing: seconds per run, carried from stop to stop.
27 +
    play: Option<u32>,
24 28
}
25 29
30 +
/// A stop on the timeline: one run of the report on its bookmark.
31 +
pub struct Stop {
32 +
    pub id: i64,
33 +
    pub href: String,
34 +
    pub label: String,
35 +
    pub tip: String,
36 +
    pub current: bool,
37 +
    pub compared: bool,
38 +
}
39 +
40 +
/// A metric shown against another run.
41 +
pub struct Compared {
42 +
    pub key: String,
43 +
    pub here: String,
44 +
    pub there: String,
45 +
    pub unit: String,
46 +
    /// here − there, `+1.5`; empty when equal or missing.
47 +
    pub delta: String,
48 +
    pub trend: &'static str,
49 +
}
50 +
26 51
/// A metric as shown: its value, and its change against the run before.
27 52
pub struct MetricView {
28 53
    pub key: String,
…
66 unchanged lines
95 120
    /// Where the frame loads the report's page from, when it has one.
96 121
    frame: Option<String>,
97 122
    files_base: String,
123 +
    /// Oldest first, the run shown marked.
124 +
    timeline: Vec<Stop>,
125 +
    /// The run compared with, its metrics against this one's, and its frame.
126 +
    compare: Option<(Report, Vec<Compared>, Option<String>)>,
127 +
    /// Seconds per run while playing.
128 +
    play: Option<u32>,
129 +
    /// The runs, oldest first, as JSON for the frame interface
130 +
    /// (`report.run()`, `report.history()`).
131 +
    runs_json: String,
98 132
}
99 133
100 134
/// A number as a person would write it: no trailing zeros.
…
66 unchanged lines
167 201
                threshold: threshold.join(", "),
168 202
                failing,
169 203
                spark: spark(&values),
204 +
            }
205 +
        })
206 +
        .collect()
207 +
}
208 +
209 +
/// The metrics of this run against another's, every key either has.
210 +
fn compared(here: &[Metric], there: &[Metric]) -> Vec<Compared> {
211 +
    let mut keys: Vec<&str> = here.iter().chain(there).map(|m| m.key.as_str()).collect();
212 +
    keys.sort();
213 +
    keys.dedup();
214 +
    keys.into_iter()
215 +
        .map(|k| {
216 +
            let (a, b) = (here.iter().find(|m| m.key == k), there.iter().find(|m| m.key == k));
217 +
            let (delta, trend) = match (a, b) {
218 +
                (Some(a), Some(b)) if (a.value - b.value).abs() > f64::EPSILON => {
219 +
                    let up = a.value > b.value;
220 +
                    let trend = match a.better.as_str() {
221 +
                        "higher" if up => "better",
222 +
                        "higher" => "worse",
223 +
                        "lower" if up => "worse",
224 +
                        "lower" => "better",
225 +
                        _ => "same",
226 +
                    };
227 +
                    (format!("{}{}", if up { "+" } else { "" }, num(a.value - b.value)), trend)
228 +
                }
229 +
                _ => (String::new(), "same"),
230 +
            };
231 +
            Compared {
232 +
                key: k.to_string(),
233 +
                here: a.map(|m| num(m.value)).unwrap_or_else(|| "–".into()),
234 +
                there: b.map(|m| num(m.value)).unwrap_or_else(|| "–".into()),
235 +
                unit: a.or(b).map(|m| m.unit.clone()).unwrap_or_default(),
236 +
                delta,
237 +
                trend,
170 238
            }
171 239
        })
172 240
        .collect()
…
96 unchanged lines
269 337
    for r in runs.iter().skip(at).rev() {
270 338
        series.push(reports::metrics(&app.db, r.id).await.map_err(e)?);
271 339
    }
272 -
    let files_base = format!("/{owner}/{name}/reports/files/{}/{}", report.id, reports::file_mac(&app, report.id));
340 +
    let base_of = |id: i64| format!("/{owner}/{name}/reports/files/{id}/{}", reports::file_mac(&app, id));
341 +
    let files_base = base_of(report.id);
273 342
    let frame = reports::file(&app.db, report.id, "index.html").await.map_err(e)?.map(|_| format!("{files_base}/index.html"));
343 +
    let label = |r: &Report| r.run_number.map(|n| format!("run #{n}")).unwrap_or_else(|| "upload".into());
344 +
    let page = format!("/{owner}/{name}/reports/{rname}");
345 +
    let keep = |id: i64| {
346 +
        let mut url = format!("{page}?id={id}");
347 +
        if let Some(c) = q.compare.filter(|c| *c != id) {
348 +
            url.push_str(&format!("&compare={c}"));
349 +
        }
350 +
        if let Some(p) = q.play {
351 +
            url.push_str(&format!("&play={p}"));
352 +
        }
353 +
        url
354 +
    };
355 +
    let timeline: Vec<Stop> = runs
356 +
        .iter()
357 +
        .rev()
358 +
        .map(|r| Stop {
359 +
            id: r.id,
360 +
            href: keep(r.id),
361 +
            label: label(r),
362 +
            tip: format!("{}{}", label(r), if r.summary.is_empty() { String::new() } else { format!(": {}", r.summary) }),
363 +
            current: r.id == report.id,
364 +
            compared: Some(r.id) == q.compare,
365 +
        })
366 +
        .collect();
367 +
    // Another run to compare with (RP4): every metric, both values, the
368 +
    // difference; and its page beside this one's.
369 +
    let compare = match q.compare.filter(|c| *c != report.id) {
370 +
        Some(cid) => match reports::get(&app.db, row.id, cid).await.map_err(e)?.filter(|r| r.name == rname) {
371 +
            Some(other) => {
372 +
                let theirs = reports::metrics(&app.db, other.id).await.map_err(e)?;
373 +
                let rows = compared(&now, &theirs);
374 +
                let f =
375 +
                    reports::file(&app.db, other.id, "index.html").await.map_err(e)?.map(|_| format!("{}/index.html", base_of(other.id)));
376 +
                Some((other, rows, f))
377 +
            }
378 +
            None => None,
379 +
        },
380 +
        None => None,
381 +
    };
382 +
    let runs_json = serde_json::to_string(
383 +
        &runs
384 +
            .iter()
385 +
            .rev()
386 +
            .map(|r| serde_json::json!({ "id": r.id, "run": r.run_number, "bookmark": r.bookmark, "created_ms": r.created_ms, "summary": r.summary, "files": base_of(r.id), "current": r.id == report.id }))
387 +
            .collect::<Vec<_>>(),
388 +
    )
389 +
    .unwrap_or_default();
274 390
    let repo = RepoShell::new(&app, &row, "reports").with_role(role);
275 391
    Ok(Page(ReportPage {
276 392
        shell,
…
5 unchanged lines
282 398
        frame,
283 399
        files_base,
284 400
        report,
401 +
        timeline,
402 +
        compare,
403 +
        play: q.play.map(|p| p.clamp(1, 60)),
404 +
        runs_json,
285 405
    }))
286 406
}
287 407
…
106 unchanged lines
modified crates/hub/tests/flow.rs +27 −0
…
1056 unchanged lines
1057 1057
    let data = src.replace("/index.html", "/data/frames.csv");
1058 1058
    assert_eq!(get_bytes(&h.app, &data).await, (StatusCode::OK, data_bytes.clone()));
1059 1059
    // A link with another report's MAC, or a file it lacks, finds nothing.
1060 +
    // A second run of the report: the timeline has two stops, and the
1061 +
    // first can be compared with it (RP4).
1062 +
    let first: i64 = sqlx::query_scalar("SELECT id FROM reports WHERE name = 'frame-timing'").fetch_one(&h.app.db).await.unwrap();
1063 +
    let second: i64 = sqlx::query_scalar(
1064 +
        "INSERT INTO reports (repo_id, name, kind, title, summary, context, run_id, job_id, run_number, job, bookmark, state, manifest, files, bytes, created_ms) \
1065 +
         SELECT repo_id, name, kind, title, 'Median 18.0 ms', context, run_id, NULL, 2, job, bookmark, state, manifest, files, bytes, created_ms + 1000 FROM reports WHERE id = ? RETURNING id",
1066 +
    )
1067 +
    .bind(first)
1068 +
    .fetch_one(&h.app.db)
1069 +
    .await
1070 +
    .unwrap();
1071 +
    sqlx::query(
1072 +
        "INSERT INTO report_metrics (report_id, key, value, unit, better, fail_above) VALUES (?, 'median_ms', 18.0, 'ms', 'lower', 20)",
1073 +
    )
1074 +
    .bind(second)
1075 +
    .execute(&h.app.db)
1076 +
    .await
1077 +
    .unwrap();
1078 +
    let (_, page) = call(&h.app, "GET", "/andy/demo/reports/frame-timing", None, None).await;
1079 +
    assert_eq!(page.matches("data-tl-stop").count(), 2, "a stop per run");
1080 +
    assert!(page.contains("Median 18.0 ms"), "the newest run is shown");
1081 +
    let (_, page) = call(&h.app, "GET", &format!("/andy/demo/reports/frame-timing?id={first}&compare={second}&play=3"), None, None).await;
1082 +
    assert!(page.contains("This run against run #2") && page.contains("-1.6"), "every metric against the other run");
1083 +
    let stop = page.split("data-tl-stop").next().unwrap_or("").rsplit("href=").next().unwrap_or("").to_string();
1084 +
    assert!(page.contains("data-play=\"3\""), "playing");
1085 +
    assert!(stop.contains("play") && stop.contains('3'), "playing carries on from stop to stop: {stop}");
1086 +
    assert!(page.contains("data-report-runs="), "the frame interface knows the runs");
1060 1087
    let forged = src.replacen("/files/", "/files/9", 1);
1061 1088
    assert_eq!(get_bytes(&h.app, &forged).await.0, StatusCode::NOT_FOUND);
1062 1089
    assert_eq!(get_bytes(&h.app, &src.replace("/index.html", "/secret.txt")).await.0, StatusCode::NOT_FOUND);
…
1450 unchanged lines
modified docs/reports.md +8 −2
…
204 unchanged lines
205 205
sandboxed (`allow-scripts` only) rather than on a second origin: it
206 206
carries no session, so its files are served under a per-report MAC in the
207 207
link, with `Content-Security-Policy: sandbox allow-scripts` and nothing
208 -
beyond this hub to load. Retention (§3.3), the timeline (3) and `oh
209 -
report` (4) follow.
208 +
beyond this hub to load. The timeline (3) is built too: one stop per
209 +
run on the bookmark, arrow keys to step, Play at 1 to 10 s a run (carried
210 +
in the URL as `play`), Compare against any other run (`compare`) with
211 +
every metric's difference and the two pages side by side, and the frame
212 +
interface: inside the frame, `report.run()` and `report.history("file")`
213 +
answer from the hub page by postMessage, `history` reading that file of
214 +
`data/` from each run's capability URL. Retention (§3.3) and `oh report`
215 +
(4) follow.
210 216
211 217
212 218
1. Storage and index: the `report` output kind in OxygenFlow, the runner's
…
5 unchanged lines
modified templates/report.html +37 −3
…
14 unchanged lines
15 15
  </div>
16 16
</section>
17 17
18 -
{% if !metrics.is_empty() %}
18 +
{% if timeline.len() > 1 %}
19 +
<section class="rep-timeline" data-timeline{% if let Some(p) = play %} data-play="{{ p }}"{% endif %}>
20 +
  <div class="rep-tl-bar">
21 +
    <button type="button" class="rep-tl-btn" data-tl-play aria-label="{% if play.is_some() %}Stop playing{% else %}Play through the runs{% endif %}">{% if play.is_some() %}■ Stop{% else %}▶ Play{% endif %}</button>
22 +
    <label class="small mute">every <select data-tl-speed aria-label="Seconds per run">{% for s in [1u32, 2, 3, 5, 10] %}<option value="{{ s }}"{% if play.unwrap_or(2) == *s %} selected{% endif %}>{{ s }} s</option>{% endfor %}</select></label>
23 +
    <form class="inline small" method="get" action="{{ repo.url }}/reports/{{ report.name }}">
24 +
      <input type="hidden" name="id" value="{{ report.id }}">
25 +
      <label class="mute">compare with <select name="compare" onchange="this.form.submit()" aria-label="Run to compare with"><option value="">none</option>{% for t in timeline %}{% if !t.current %}<option value="{{ t.id }}"{% if t.compared %} selected{% endif %}>{{ t.label }}</option>{% endif %}{% endfor %}</select></label>
26 +
      <noscript><button>Compare</button></noscript>
27 +
    </form>
28 +
    <span class="small mute rep-tl-keys">← → step through runs</span>
29 +
  </div>
30 +
  <ol class="rep-tl-stops">
31 +
    {% for t in timeline %}<li><a href="{{ t.href }}" class="rep-tl-stop{% if t.current %} on{% endif %}{% if t.compared %} cmp{% endif %}" title="{{ t.tip }}"{% if t.current %} aria-current="true"{% endif %} data-tl-stop><span class="sr">{{ t.label }}</span></a></li>{% endfor %}
32 +
  </ol>
33 +
  <div class="rep-tl-ends small mute"><span>{% if let Some(f) = timeline.first() %}{{ f.label }}{% endif %}</span><span>{% if let Some(l) = timeline.last() %}{{ l.label }}{% endif %}</span></div>
34 +
</section>
35 +
{% endif %}
36 +
37 +
{% if let Some((other, rows, _)) = compare %}
38 +
<section class="panel">
39 +
  <h2 class="rep-cmp-h">This run against {% if let Some(n) = other.run_number %}run #{{ n }}{% else %}an upload{% endif %} <a class="small" href="{{ repo.url }}/reports/{{ report.name }}?id={{ report.id }}">stop comparing</a></h2>
40 +
  <table class="rep-table">
41 +
    <thead><tr><th>Metric</th><th class="num">{% if let Some(n) = report.run_number %}run #{{ n }}{% else %}this{% endif %}</th><th class="num">{% if let Some(n) = other.run_number %}run #{{ n }}{% else %}that{% endif %}</th><th class="num">Difference</th></tr></thead>
42 +
    <tbody>
43 +
    {% for c in rows %}
44 +
      <tr><td><code>{{ c.key }}</code></td><td class="num"><strong>{{ c.here }}</strong>{% if !c.unit.is_empty() %} <small class="mute">{{ c.unit }}</small>{% endif %}</td><td class="num">{{ c.there }}</td><td class="num">{% if !c.delta.is_empty() %}<span class="rep-delta {{ c.trend }}">{{ c.delta }}</span>{% else %}<span class="mute">–</span>{% endif %}</td></tr>
45 +
    {% endfor %}
46 +
    </tbody>
47 +
  </table>
48 +
</section>
49 +
{% endif %}
50 +
51 +
{% if !metrics.is_empty() && compare.is_none() %}
19 52
<section class="panel">
20 53
  <table class="rep-table">
21 54
    <thead><tr><th>Metric</th><th class="num">Value</th><th class="num">Change</th><th>Across runs</th><th>Threshold</th></tr></thead>
…
13 unchanged lines
35 68
{% endif %}
36 69
37 70
{% if let Some(src) = frame %}
38 -
<section class="rep-frame-wrap">
39 -
  <iframe class="rep-frame" src="{{ src }}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="{{ report.title }}"></iframe>
71 +
<section class="rep-frame-wrap{% if let Some((_, _, Some(_))) = compare %} rep-side{% endif %}">
72 +
  <iframe class="rep-frame" src="{{ src }}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="{{ report.title }}" data-report-runs="{{ runs_json }}" data-report-current="{{ report.id }}"></iframe>
73 +
  {% if let Some((other, _, Some(osrc))) = compare %}<iframe class="rep-frame" src="{{ osrc }}" sandbox="allow-scripts" referrerpolicy="no-referrer" loading="lazy" title="{{ report.title }}, {% if let Some(n) = other.run_number %}run #{{ n }}{% else %}upload{% endif %}" data-report-runs="{{ runs_json }}" data-report-current="{{ other.id }}"></iframe>{% endif %}
40 74
  <p class="small mute">The report's own page, in a sandbox: its scripts run with no access to your session or this hub. <a href="{{ src }}" target="_blank" rel="noopener">Open it alone</a>.</p>
41 75
</section>
42 76
{% endif %}
…
18 unchanged lines
modified templates/style.css +18 −0
…
952 unchanged lines
953 953
.rep-runs { list-style: none; padding: 0; margin: 0; }
954 954
.rep-runs li { padding: .25em 0; }
955 955
.rep-runs li.on a { font-weight: 700; }
956 +
957 +
/* Reports timeline (oxygenhub#127, RP4) */
958 +
.rep-timeline { margin: 1em 0; padding: .8em 1em; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); }
959 +
.rep-tl-bar { display: flex; flex-wrap: wrap; align-items: center; gap: .6em 1.2em; margin-bottom: .7em; }
960 +
.rep-tl-btn { font: inherit; font-weight: 600; padding: .3em .9em; border-radius: 999px; border: 1px solid var(--acc); color: var(--acc); background: transparent; cursor: pointer; }
961 +
.rep-tl-btn:hover { background: color-mix(in srgb, var(--acc) 12%, transparent); }
962 +
.rep-tl-keys { margin-left: auto; }
963 +
.rep-tl-stops { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: 2px; }
964 +
.rep-tl-stops li { flex: 1; min-width: 4px; }
965 +
.rep-tl-stop { display: block; height: 14px; border-radius: 3px; background: color-mix(in srgb, var(--acc) 22%, var(--soft)); }
966 +
.rep-tl-stop:hover { background: color-mix(in srgb, var(--acc) 55%, var(--soft)); }
967 +
.rep-tl-stop.on { background: var(--acc); height: 22px; }
968 +
.rep-tl-stop.cmp { background: var(--warn); height: 22px; }
969 +
.rep-tl-ends { display: flex; justify-content: space-between; margin-top: .3em; }
970 +
.rep-cmp-h { display: flex; align-items: baseline; gap: 1em; font-size: 1.05em; margin: 0 0 .6em; }
971 +
.rep-frame-wrap.rep-side { display: grid; grid-template-columns: 1fr 1fr; gap: 1em; }
972 +
.rep-frame-wrap.rep-side > p { grid-column: 1 / -1; }
973 +
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }