OXYGENHUB

No inline code in pages, so the CSP can be enforced (#227) a7aa76ac52d7

Every inline script and handler moved out of the templates:
- the look and theme set before first paint: assets/early.js, still in
  <head> and blocking;
- the pulse chart: assets/pulse.js;
- the backdrop's start: assets/backdrop-boot.js, a module that reads
  the assets' version from its own address;
- the mail panel's field toggle: hub.js;
- 25 confirm/autosubmit/select-on-focus handlers: data-confirm,
  data-autosubmit and data-select, which hub.js handles with delegated
  listeners (a confirm on a form's submit, or on a click elsewhere).

A test fails if a template gains an inline <script> (JSON data blocks
aside) or an on…= attribute. The policy stays report-only for now: the
issue says to watch for `csp would block` in the log for a few days,
then flip ENFORCE in headers.rs.

Refs #227
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 7f72273d5ef7
When
· 2026-10-18 01:17 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
a7aa76ac52d71a6d221ff315025cbcb5076097311c84fa9ec54c5d455402760e
Depends on
0a3b244b748f 107eaba52c9d 119fd17d213f 145306044896 16626c0d69c0 17ca6cddc534 19bb31ff1f5c 206c1fc1b06a 2a0c09c09716 3556d08e17f9 36e5a9c15a84 3f4b0ea6eabf 40dd3f86379a 42fa95540468 47e374020b59 4f6925337848 554ccacfc123 66fd9d6b1452 745d225de344 7974adb0be66 7b17d570d7ee 83d5052bf76c 8e820b4ae875 8fb35df7d66e 95b525b0b977 9756c67d3aec a2fbad481dd7 b1e92a6233ee b6b7e4cf6b24 c0095389312b c8df33a6829f c987c86513df cd46e56b1709 d23464c0ac60 d98a93c10a0d da8f7826a645 de505df5cf8d e1ec9fbf538e e271a37b629a e71846aa07b9 f3e5e2bc78e9 f6346d875ee5 ffb67a81188c
Depended on by
0bc95135954d 84e3c09656b2 d366306dae7d 166f6e9bf4f1 c7067c7bda56
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
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
55 atoms, 4 tree operations, 27 files

Browse the tree at this change

added assets/backdrop-boot.js +35 −0
1 +
/* Starts the page's backdrop (docs/backgrounds.md); a module of its own for the CSP (#227). The assets' version comes from this file's own address. */
2 +
const v = new URL(import.meta.url).search;
3 +
const canvas = document.getElementById("backdrop");
4 +
const chosen = document.cookie.match(/(?:^|; )backdrop=(moving|still|off)/);
5 +
const touch = matchMedia("(pointer: coarse)").matches && !matchMedia("(pointer: fine)").matches;
6 +
const pick = chosen ? chosen[1] : (touch || (navigator.connection && navigator.connection.saveData) ? "still" : "moving");
7 +
if (pick === "off") {
8 +
  canvas.remove();
9 +
} else {
10 +
  try {
11 +
    const bd = await import(`/static/backdrop/backdrop.js${v}`);
12 +
    await bd.default({ module_or_path: `/static/backdrop/backdrop_bg.wasm${v}` });
13 +
    const data = JSON.parse(document.getElementById("backdrop-data").textContent);
14 +
    bd.start(canvas, data, pick === "still");
15 +
    // Live (§5): a sky that names its repositories (`mine` or `hub`) asks
16 +
    // every 30 s, while the page is shown, what happened since.
17 +
    if (data.live) {
18 +
      let since = data.now;
19 +
      const poll = async () => {
20 +
        if (document.hidden) return;
21 +
        try {
22 +
          const r = await fetch(`/sky/arrivals?scene=${data.live}&since=${since}`, { headers: { accept: "application/json" } });
23 +
          if (!r.ok) return;
24 +
          const got = await r.json();
25 +
          since = got.now;
26 +
          if (got.events.length) bd.arrive(got);
27 +
        } catch (e) {}
28 +
      };
29 +
      setInterval(poll, 30000);
30 +
      document.addEventListener("visibilitychange", poll);
31 +
    }
32 +
  } catch (e) {
33 +
    canvas.remove();
34 +
  }
35 +
}
added assets/early.js +3 −0
1 +
/* Runs before first paint, from <head> (oxygenhub#227: no inline scripts). */
2 +
/* The look (oxygenhub#74): GitHub-like by default (assets/github.css, every rule scoped to data-look="github"); ?look=classic keeps the RetroPlay look for this browser, ?look=github returns to the default. */(function(){var q=new URLSearchParams(location.search).get("look");if(q==="classic")document.cookie="look=classic; path=/; max-age=31536000; SameSite=Lax";else if(q==="github"||q==="default")document.cookie="look=; path=/; max-age=0; SameSite=Lax";if(!/(?:^|; )look=classic/.test(document.cookie))document.documentElement.dataset.look="github";})();
3 +
/* Theme before first paint: the theme cookie (light, dark or auto; light when unset), auto following the system. */(function(){var d=document.documentElement,m=document.cookie.match(/(?:^|; )theme=(light|dark|auto)/),p=m?m[1]:"light",q=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");function a(){d.dataset.theme=p==="auto"?(q&&q.matches?"light":"dark"):p;}d.dataset.themePref=p;a();if(q&&q.addEventListener)q.addEventListener("change",function(){if(d.dataset.themePref==="auto")d.dataset.theme=q.matches?"light":"dark";});})();
added assets/pulse.js +117 −0
1 +
/* The pulse chart (oxygenhub#217), drawn from #pulse-data; moved out of the page for the CSP (#227). */
2 +
(() => {
3 +
"use strict";
4 +
const data = JSON.parse(document.getElementById("pulse-data").textContent);
5 +
const stage = document.getElementById("pulse-stage"), card = document.getElementById("pulse-card");
6 +
const NS = "http://www.w3.org/2000/svg", reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
7 +
const el = (t, a, p) => { const e = document.createElementNS(NS, t); for (const k in a) e.setAttribute(k, a[k]); if (p) p.appendChild(e); return e; };
8 +
const PAL = 7, col = r => r < PAL ? `var(--pu${r + 1})` : "var(--pu8)";
9 +
document.getElementById("pulse-title").textContent = `Last ${data.months} months`;
10 +
const days = data.days.map((d, i) => ({ t: d[0], via: d[1], by: d[2], date: new Date(data.start + i * 864e5) }));
11 +
const N = days.length, LAST = N - 1;
12 +
const W = 1000, H = 300, L = 40, R = 14, T = 26, B = 58, base = H - B, X = i => L + i * ((W - L - R) / LAST);
13 +
const s = el("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Changes pushed each day over the last year" });
14 +
stage.prepend(s);
15 +
const defs = el("defs", {}, s);
16 +
const grad = el("linearGradient", { id: "pu-grad", x1: L, x2: W - R, y1: 0, y2: 0, gradientUnits: "userSpaceOnUse" }, defs);
17 +
const fade = el("linearGradient", { id: "pu-fade", x1: 0, x2: 0, y1: 0, y2: 1 }, defs);
18 +
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": ".5" }, fade); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": "0" }, fade);
19 +
const fm = el("mask", { id: "pu-fm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs); el("rect", { width: W, height: H, fill: "url(#pu-fade)" }, fm);
20 +
const glow = el("filter", { id: "pu-glow", x: "-5%", y: "-30%", width: "110%", height: "160%" }, defs);
21 +
el("feGaussianBlur", { stdDeviation: "2.6", result: "b" }, glow); const mg = el("feMerge", {}, glow); el("feMergeNode", { in: "b" }, mg); el("feMergeNode", { in: "SourceGraphic" }, mg);
22 +
const spot = el("radialGradient", { id: "pu-spot", cx: "0", cy: "0", r: "1", gradientUnits: "userSpaceOnUse" }, defs);
23 +
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": "1" }, spot); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": ".4" }, spot);
24 +
const sm = el("mask", { id: "pu-sm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs);
25 +
const spotRect = el("rect", { width: W, height: H, fill: "#fff" }, sm);
26 +
const clip = el("clipPath", { id: "pu-clip" }, defs), clipRect = el("rect", { width: reduce ? W : 0, height: H }, clip);
27 +
const grid = el("g", {}, s), ylab = [];
28 +
for (let k = 0; k <= 4; k++) { const y = T + (base - T) * k / 4; el("line", { x1: L, x2: W - R, y1: y, y2: y, stroke: "var(--line)", "stroke-opacity": ".5" }, grid); ylab.push(el("text", { x: L - 6, y: y + 4, "text-anchor": "end", class: "pu-axis" }, grid)); }
29 +
days.forEach((d, i) => { if (N > 100 ? d.date.getUTCDate() !== 1 : (d.date.getUTCDate() !== 1 && d.date.getUTCDate() !== 15)) return; el("line", { x1: X(i), x2: X(i), y1: base, y2: base + 5, stroke: "var(--line)" }, grid); const t = el("text", { x: X(i) + 3, y: base + 18, class: "pu-axis" }, grid); t.textContent = d.date.toLocaleDateString("en-GB", N > 100 ? { month: "short", timeZone: "UTC" } : { day: "numeric", month: "short", timeZone: "UTC" }); });
30 +
const plot = el("g", { mask: "url(#pu-sm)", "clip-path": "url(#pu-clip)" }, s);
31 +
const area = el("path", { fill: "url(#pu-grad)", mask: "url(#pu-fm)" }, plot);
32 +
const line = el("path", { fill: "none", stroke: "url(#pu-grad)", "stroke-width": 2.2, "stroke-linejoin": "round", filter: "url(#pu-glow)" }, plot);
33 +
const viaLine = el("path", { fill: "none", stroke: "var(--pu-agent)", "stroke-width": 1.3, "stroke-dasharray": "1 3", "stroke-linecap": "round" }, plot);
34 +
const strip = el("g", {}, plot), peakG = el("g", {}, s);
35 +
const beat = el("circle", { r: 4.5, fill: "var(--panel)", stroke: "var(--acc)", "stroke-width": 2, filter: "url(#pu-glow)", opacity: 0 }, s);
36 +
const scan = el("line", { y1: T - 4, y2: base, stroke: "var(--ink)", "stroke-dasharray": "2 3", opacity: 0 }, s);
37 +
const dot = el("circle", { r: 5, fill: "var(--panel)", "stroke-width": 2.5, opacity: 0 }, s);
38 +
let only = -1, ys = [];
39 +
const val = d => only < 0 ? d.t : (d.by.find(p => p[0] === only) || [0, 0])[1];
40 +
const top = d => d.by.length ? d.by.reduce((a, b) => b[1] > a[1] ? b : a)[0] : 0;
41 +
const smooth = pts => pts.reduce((acc, [x, y], i) => { if (!i) return `M${x},${y}`; const [x0, y0] = pts[i - 1], cx = (x0 + x) / 2; return acc + `C${cx},${y0} ${cx},${y} ${x},${y}`; }, "");
42 +
function build() {
43 +
  const v = days.map(val), max = Math.max(1, ...v);
44 +
  ys = v.map(n => base - (base - T) * n / max);
45 +
  const d = smooth(ys.map((y, i) => [X(i), y]));
46 +
  line.setAttribute("d", d); area.setAttribute("d", d + `L${X(LAST)},${base}L${X(0)},${base}Z`);
47 +
  viaLine.setAttribute("d", smooth(days.map((x, i) => [X(i), base - (base - T) * (only < 0 ? x.via : Math.round(val(x) * x.via / (x.t || 1))) / max])));
48 +
  grad.textContent = "";
49 +
  for (let i = 0; i <= LAST; i += Math.max(1, Math.round(N / 52))) el("stop", { offset: (i / LAST).toFixed(4), style: `stop-color:${col(only < 0 ? top(days[i]) : only)}` }, grad);
50 +
  strip.textContent = "";
51 +
  days.forEach((x, i) => { if (!val(x)) return; el("rect", { x: X(i) - .9, y: base + 28, width: 1.8, height: 9, rx: .9, style: `fill:${col(only < 0 ? top(x) : only)}` }, strip); });
52 +
  ylab.forEach((t, k) => { t.textContent = Math.round(max * (4 - k) / 4); });
53 +
  peakG.textContent = "";
54 +
  if (only < 0) {
55 +
    // At most four labels, the biggest days first, none crowding another;
56 +
    // the other releases are marked without words.
57 +
    const peaks = (data.peaks || []).filter(([i]) => i >= 0 && i <= LAST).sort((a, b) => ys[a[0]] - ys[b[0]]);
58 +
    const labelled = [];
59 +
    peaks.forEach(([i, label]) => {
60 +
      const y = ys[i], free = labelled.length < 4 && labelled.every(j => Math.abs(X(j) - X(i)) > 120);
61 +
      el("circle", { cx: X(i), cy: y, r: free ? 3.2 : 2.2, fill: "var(--ink)" }, peakG);
62 +
      if (!free) return;
63 +
      labelled.push(i);
64 +
      el("circle", { cx: X(i), cy: y, r: 4, fill: "none", stroke: "var(--ink)", class: "pu-ring" }, peakG);
65 +
      const right = X(i) < W - 160;
66 +
      const t = el("text", { x: X(i) + (right ? 8 : -8), y: Math.max(T - 8, y - 8), "text-anchor": right ? "start" : "end", class: "pu-peak" }, peakG); t.textContent = label;
67 +
    });
68 +
  }
69 +
}
70 +
build();
71 +
// Legend: a repository's own year.
72 +
const lg = document.getElementById("pulse-legend");
73 +
const btn = (label, c, idx) => { const b = document.createElement("button"); b.type = "button"; b.className = "pu-chip"; b.setAttribute("aria-pressed", String(idx === -1)); b.innerHTML = `<i style="background:${c}"></i>`; b.append(label); b.addEventListener("click", () => { only = idx; [...lg.children].forEach(c => c.setAttribute("aria-pressed", "false")); b.setAttribute("aria-pressed", "true"); build(); }); lg.appendChild(b); };
74 +
btn("all", "var(--ink)", -1);
75 +
data.repos.slice(0, PAL).forEach((n, i) => btn(n.split("/").pop(), col(i), i));
76 +
// The total counts up; the line draws itself in; then a heartbeat while idle.
77 +
const totalEl = document.getElementById("pulse-total"), total = Number(totalEl.dataset.total);
78 +
if (!reduce) { const t0 = performance.now(); const tick = now => { const p = Math.min(1, (now - t0) / 900); totalEl.textContent = Math.round(total * (1 - Math.pow(1 - p, 3))).toLocaleString("en-GB"); if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }
79 +
else totalEl.textContent = total.toLocaleString("en-GB");
80 +
let idle = true, timer = 0;
81 +
function heartbeat() {
82 +
  if (reduce) return;
83 +
  clearTimeout(timer);
84 +
  timer = setTimeout(function run() {
85 +
    if (!idle || document.hidden) { timer = setTimeout(run, 2000); return; }
86 +
    const len = line.getTotalLength(), t0 = performance.now();
87 +
    const go = now => { const p = Math.min(1, (now - t0) / 2600), pt = line.getPointAtLength(len * p); beat.setAttribute("cx", pt.x); beat.setAttribute("cy", pt.y); beat.setAttribute("opacity", idle ? String(Math.sin(p * Math.PI)) : "0"); if (p < 1 && idle) requestAnimationFrame(go); else { beat.setAttribute("opacity", "0"); timer = setTimeout(run, 4500); } };
88 +
    requestAnimationFrame(go);
89 +
  }, 800);
90 +
}
91 +
if (!reduce) {
92 +
  const t0 = performance.now();
93 +
  let shown = false;
94 +
  const reveal = now => { if (shown) return; const p = Math.min(1, (now - t0) / 1600); clipRect.setAttribute("width", L + (W - L) * (1 - Math.pow(1 - p, 3))); if (p < 1) requestAnimationFrame(reveal); };
95 +
  requestAnimationFrame(reveal);
96 +
  setTimeout(() => { shown = true; clipRect.setAttribute("width", W); heartbeat(); }, 1700);
97 +
}
98 +
// Pointer: spotlight, scan line and the day's card.
99 +
const fmt = d => d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
100 +
s.addEventListener("pointermove", e => {
101 +
  idle = false; beat.setAttribute("opacity", "0");
102 +
  const r = s.getBoundingClientRect(), x = (e.clientX - r.left) * (W / r.width);
103 +
  const i = Math.max(0, Math.min(LAST, Math.round((x - L) / ((W - L - R) / LAST)))), d = days[i];
104 +
  spotRect.setAttribute("fill", "url(#pu-spot)"); spot.setAttribute("gradientTransform", `translate(${X(i)} ${ys[i]}) scale(160 240)`);
105 +
  scan.setAttribute("x1", X(i)); scan.setAttribute("x2", X(i)); scan.setAttribute("opacity", ".45");
106 +
  dot.setAttribute("cx", X(i)); dot.setAttribute("cy", ys[i]); dot.style.stroke = col(only < 0 ? top(d) : only); dot.setAttribute("opacity", "1");
107 +
  const n = val(d), share = d.t ? Math.round(100 * d.via / d.t) : 0;
108 +
  const bar = d.t ? d.by.map(([k, m]) => `<i style="width:${100 * m / d.t}%;background:${col(k)}"></i>`).join("") : "";
109 +
  const repos = d.by.slice().sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, m]) => `<div class="row"><span>${(data.repos[k] || "").split("/").pop()}</span><span>${m}</span></div>`).join("");
110 +
  card.innerHTML = `<b>${fmt(d.date)}</b><div class="row"><span>changes</span><span>${n}</span></div><div class="row"><span>via agents</span><span>${share}%</span></div>${only < 0 ? repos : ""}${bar ? `<div class="bar">${bar}</div>` : ""}`;
111 +
  card.style.opacity = "1";
112 +
  const px = e.clientX - r.left, cw = card.offsetWidth;
113 +
  card.style.left = Math.max(0, Math.min(r.width - cw, px + (px > r.width * .6 ? -cw - 14 : 14))) + "px";
114 +
});
115 +
s.addEventListener("pointerleave", () => { idle = true; spotRect.setAttribute("fill", "#fff"); scan.setAttribute("opacity", "0"); dot.setAttribute("opacity", "0"); card.style.opacity = "0"; });
116 +
document.addEventListener("visibilitychange", () => { if (!document.hidden) heartbeat(); });
117 +
})();
added crates/hub/tests/csp.rs +35 −0
1 +
//! The pages carry no inline code (oxygenhub#227), so the
2 +
//! Content-Security-Policy's `script-src 'self'` can be enforced: no
3 +
//! `<script>` without `src` (JSON data blocks aside) and no `on…=` handler
4 +
//! attributes in any template. Scripts belong in assets/ (hub.js reads
5 +
//! `data-confirm`, `data-autosubmit`, `data-select`).
6 +
7 +
#[test]
8 +
fn templates_carry_no_inline_scripts_or_handlers() {
9 +
    let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates");
10 +
    let handler = |s: &str| {
11 +
        s.match_indices(" on").any(|(i, _)| {
12 +
            let rest = &s[i + 3..];
13 +
            let name: String = rest.chars().take_while(|c| c.is_ascii_lowercase()).collect();
14 +
            !name.is_empty() && rest[name.len()..].starts_with("=\"")
15 +
        })
16 +
    };
17 +
    let mut found = Vec::new();
18 +
    for e in std::fs::read_dir(&dir).unwrap() {
19 +
        let path = e.unwrap().path();
20 +
        if path.extension().and_then(|x| x.to_str()) != Some("html") {
21 +
            continue;
22 +
        }
23 +
        let text = std::fs::read_to_string(&path).unwrap();
24 +
        for (n, line) in text.lines().enumerate() {
25 +
            let inline_script = line.match_indices("<script").any(|(i, _)| {
26 +
                let tag = &line[i..line[i..].find('>').map_or(line.len(), |e| i + e)];
27 +
                !tag.contains("src=") && !tag.contains("application/json")
28 +
            });
29 +
            if inline_script || handler(line) {
30 +
                found.push(format!("{}:{}: {}", path.file_name().unwrap().to_string_lossy(), n + 1, line.trim()));
31 +
            }
32 +
        }
33 +
    }
34 +
    assert!(found.is_empty(), "inline code the CSP would block:\n{}", found.join("\n"));
35 +
}
modified assets/hub.js +39 −0
…
818 unchanged lines
819 819
        .then(function () { buttons.forEach(function (b) { b.disabled = false; }); });
820 820
    });
821 821
  });
822 +
  /* Declared in data- attributes, so pages carry no inline handlers and
823 +
     the Content-Security-Policy can be enforced (oxygenhub#227):
824 +
     data-confirm asks before a click or a form's submit goes ahead;
825 +
     data-autosubmit sends a select's form when it changes; data-select
826 +
     selects a field's text when it is focused or clicked. */
827 +
  document.addEventListener("click", function (e) {
828 +
    var el = e.target.closest && e.target.closest("[data-confirm]");
829 +
    if (el && el.tagName !== "FORM" && !window.confirm(el.getAttribute("data-confirm"))) {
830 +
      e.preventDefault();
831 +
      e.stopImmediatePropagation();
832 +
    }
833 +
  }, true);
834 +
  document.addEventListener("submit", function (e) {
835 +
    var f = e.target;
836 +
    if (f.hasAttribute && f.hasAttribute("data-confirm") && !window.confirm(f.getAttribute("data-confirm"))) e.preventDefault();
837 +
  }, true);
838 +
  document.addEventListener("change", function (e) {
839 +
    var el = e.target;
840 +
    if (el.hasAttribute && el.hasAttribute("data-autosubmit") && el.form) el.form.submit();
841 +
  });
842 +
  function selectAll(e) {
843 +
    var el = e.target;
844 +
    if (el.hasAttribute && el.hasAttribute("data-select") && el.select) el.select();
845 +
  }
846 +
  document.addEventListener("focusin", selectAll);
847 +
  document.addEventListener("click", selectAll);
848 +
849 +
  /* Mail panel (oxygenhub#181): show the fields the chosen service asks. */
850 +
  (function () {
851 +
    var sel = document.querySelector("[data-mail-service]");
852 +
    if (!sel) return;
853 +
    var show = function () {
854 +
      document.querySelectorAll(".mail-panel [data-for]").forEach(function (el) {
855 +
        el.hidden = el.getAttribute("data-for").split(" ").indexOf(sel.value) < 0;
856 +
      });
857 +
    };
858 +
    sel.addEventListener("change", show);
859 +
    show();
860 +
  })();
822 861
})();
modified crates/hub/src/web/statics.rs +18 −1
…
18 unchanged lines
19 19
        for a in ASSETS.iter().filter(|a| {
20 20
            matches!(
21 21
                a.path,
22 -
                "github.css" | "hub.js" | "report-frame.js" | "report.css" | "backdrop/backdrop.js" | "backdrop/backdrop_bg.wasm"
22 +
                "github.css"
23 +
                    | "hub.js"
24 +
                    | "early.js"
25 +
                    | "pulse.js"
26 +
                    | "backdrop-boot.js"
27 +
                    | "report-frame.js"
28 +
                    | "report.css"
29 +
                    | "backdrop/backdrop.js"
30 +
                    | "backdrop/backdrop_bg.wasm"
23 31
            )
24 32
        }) {
25 33
            h.update(a.bytes);
…
20 unchanged lines
46 54
    },
47 55
    Asset { path: "github.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../../../../assets/github.css") },
48 56
    Asset { path: "hub.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/hub.js") },
57 +
    // What pages ran inline before the CSP was enforced (#227): the look
58 +
    // and theme before first paint, the pulse chart, the backdrop's start.
59 +
    Asset { path: "early.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/early.js") },
60 +
    Asset { path: "pulse.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/pulse.js") },
61 +
    Asset {
62 +
        path: "backdrop-boot.js",
63 +
        mime: "text/javascript; charset=utf-8",
64 +
        bytes: include_bytes!("../../../../assets/backdrop-boot.js"),
65 +
    },
49 66
    // For report frames (#127): the size and theme bridge the hub adds to
50 67
    // a report's pages, and the stylesheet a report may link.
51 68
    Asset { path: "report-frame.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../../../../assets/report-frame.js") },
…
65 unchanged lines
modified templates/admin.html +0 −14
…
92 unchanged lines
93 93
    <div data-for="resend ses mailgun smtp url"><label for="mailfrom">From</label><input type="text" id="mailfrom" name="from" value="{{ mail_from }}" placeholder="OxygenHub &lt;[email protected]&gt;"></div>
94 94
    <button type="submit">SAVE</button>
95 95
  </form>
96 -
  <script>
97 -
  /* Mail panel (oxygenhub#181): show the fields the chosen service asks. */
98 -
  (function () {
99 -
    var sel = document.querySelector("[data-mail-service]");
100 -
    if (!sel) return;
101 -
    var show = function () {
102 -
      document.querySelectorAll(".mail-panel [data-for]").forEach(function (el) {
103 -
        el.hidden = el.getAttribute("data-for").split(" ").indexOf(sel.value) < 0;
104 -
      });
105 -
    };
106 -
    sel.addEventListener("change", show);
107 -
    show();
108 -
  })();
109 -
  </script>
110 96
  <form class="card" method="post" action="/admin/mail/test"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit"{% if !mail_enabled || my_email.is_empty() %} disabled{% endif %}>SEND A TEST{% if !my_email.is_empty() %} TO {{ my_email }}{% endif %}</button></form>
111 97
  <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} Off turns it off. Passwords and keys stay on the server and are never shown again. Every service is SMTP underneath, so any provider works: <code>smtps://USER:PASSWORD@HOST:465</code> for TLS from the start, or <code>smtp://USER:PASSWORD@HOST:587</code>, which the hub sends with STARTTLS required (add <code>?tls=opportunistic</code> or <code>?tls=none</code> to say otherwise; a relay on this machine needs none).</p>
112 98
</section>
…
23 unchanged lines
modified templates/admin_runners.html +1 −1
…
32 unchanged lines
33 33
    <td class="small">{% if let Some(t) = r.last_seen_ms %}<time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% else %}<span class="mute">never</span>{% endif %}</td>
34 34
    <td><form class="tiny" method="post" action="/admin/runners/{{ r.id }}/state"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
35 35
      {% if r.state == "active" %}<button name="state" value="draining" title="Finish current jobs, take no new ones">drain</button> <button name="state" value="disabled" title="Refuse its requests at once">disable</button>{% else %}<button name="state" value="active">resume</button>{% endif %}</form>
36 -
      <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working; its jobs keep its name.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form></td>
36 +
      <form class="tiny" method="post" action="/admin/runners/{{ r.id }}/remove" data-confirm="Remove {{ r.name }}? Its credential stops working; its jobs keep its name."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form></td>
37 37
  </tr>
38 38
  {% endfor %}
39 39
  </table></div>
…
29 unchanged lines
modified templates/assistants_panel.html +1 −1
…
22 unchanged lines
23 23
      {% if o.on() %}<span class="tag head">on</span>{% else if o.off_above %}<span class="tag dim" title="An assistant of this name is turned off at a level above">off above</span>{% else %}<span class="tag dim">off</span>{% endif %}
24 24
    </div>
25 25
    <p class="small mute"><code>{{ r.endpoint }}</code> · token {% if r.has_token() %}set{% else %}none{% endif %} · context {{ r.budget }} characters</p>
26 -
    <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="test" title="Send a one-line message and show the answer or the error">TEST CONNECTION</button> <button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete {{ r.name }}?')">delete</button></form>
26 +
    <form class="tiny" method="post" action="{{ base }}/assistants"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><button name="action" value="test" title="Send a one-line message and show the answer or the error">TEST CONNECTION</button> <button name="action" value="toggle">{% if r.enabled %}turn off{% else %}turn on{% endif %}</button> <button name="action" value="delete" class="danger-btn" data-confirm="Delete {{ r.name }}?">delete</button></form>
27 27
    <details class="hook-edit"><summary class="small">edit</summary>
28 28
      <form class="card" method="post" action="{{ base }}/assistants">
29 29
        <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.id }}"><input type="hidden" name="action" value="update">
…
52 unchanged lines
modified templates/backdrop.html +1 −35
…
4 unchanged lines
5 5
   leaves no backdrop and nothing else changed. A sky with `live` polls
6 6
   `/sky/arrivals` for what happened since and adds it. #}
7 7
<canvas id="backdrop" aria-hidden="true"></canvas>
8 -
<script type="module">
9 -
const canvas = document.getElementById("backdrop");
10 -
const chosen = document.cookie.match(/(?:^|; )backdrop=(moving|still|off)/);
11 -
const touch = matchMedia("(pointer: coarse)").matches && !matchMedia("(pointer: fine)").matches;
12 -
const pick = chosen ? chosen[1] : (touch || (navigator.connection && navigator.connection.saveData) ? "still" : "moving");
13 -
if (pick === "off") {
14 -
  canvas.remove();
15 -
} else {
16 -
  try {
17 -
    const bd = await import("/static/backdrop/backdrop.js?v={{ shell.assets() }}");
18 -
    await bd.default({ module_or_path: "/static/backdrop/backdrop_bg.wasm?v={{ shell.assets() }}" });
19 -
    const data = JSON.parse(document.getElementById("backdrop-data").textContent);
20 -
    bd.start(canvas, data, pick === "still");
21 -
    // Live (§5): a sky that names its repositories (`mine` or `hub`) asks
22 -
    // every 30 s, while the page is shown, what happened since.
23 -
    if (data.live) {
24 -
      let since = data.now;
25 -
      const poll = async () => {
26 -
        if (document.hidden) return;
27 -
        try {
28 -
          const r = await fetch(`/sky/arrivals?scene=${data.live}&since=${since}`, { headers: { accept: "application/json" } });
29 -
          if (!r.ok) return;
30 -
          const got = await r.json();
31 -
          since = got.now;
32 -
          if (got.events.length) bd.arrive(got);
33 -
        } catch (e) {}
34 -
      };
35 -
      setInterval(poll, 30000);
36 -
      document.addEventListener("visibilitychange", poll);
37 -
    }
38 -
  } catch (e) {
39 -
    canvas.remove();
40 -
  }
41 -
}
42 -
</script>
8 +
<script type="module" src="/static/backdrop-boot.js?v={{ shell.assets() }}"></script>
modified templates/base.html +1 −2
…
6 unchanged lines
7 7
<link rel="stylesheet" href="/style.css?v={{ shell.assets() }}">
8 8
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
9 9
<link rel="stylesheet" href="/static/github.css?v={{ shell.assets() }}">
10 -
<script>/* The look (oxygenhub#74): GitHub-like by default (assets/github.css, every rule scoped to data-look="github"); ?look=classic keeps the RetroPlay look for this browser, ?look=github returns to the default. */(function(){var q=new URLSearchParams(location.search).get("look");if(q==="classic")document.cookie="look=classic; path=/; max-age=31536000; SameSite=Lax";else if(q==="github"||q==="default")document.cookie="look=; path=/; max-age=0; SameSite=Lax";if(!/(?:^|; )look=classic/.test(document.cookie))document.documentElement.dataset.look="github";})();</script>
11 -
<script>/* Theme before first paint: the theme cookie (light, dark or auto; light when unset), auto following the system. */(function(){var d=document.documentElement,m=document.cookie.match(/(?:^|; )theme=(light|dark|auto)/),p=m?m[1]:"light",q=window.matchMedia&&matchMedia("(prefers-color-scheme: light)");function a(){d.dataset.theme=p==="auto"?(q&&q.matches?"light":"dark"):p;}d.dataset.themePref=p;a();if(q&&q.addEventListener)q.addEventListener("change",function(){if(d.dataset.themePref==="auto")d.dataset.theme=q.matches?"light":"dark";});})();</script>
10 +
<script src="/static/early.js?v={{ shell.assets() }}"></script>
12 11
</head>
13 12
<body{% block bodyattrs %}{% endblock %}>
14 13
<a class="skip" href="#main">Skip to content</a>
…
49 unchanged lines
modified templates/bookmarks.html +1 −1
…
15 unchanged lines
16 16
  <td>{{ b.changes }}</td>
17 17
  <td><time title="{{ b.latest_ms|when }}">{{ b.latest_ms|ago }}</time></td>
18 18
  <td class="small">{% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b.name }}"><button type="submit" class="small" title="Run the pipeline of {{ b.name }}'s current state">Run pipeline</button></form> · {% endif %}<a href="{{ repo.url }}/changes/{{ b.name }}">history</a>{% if !b.default %} · <a href="{{ repo.url }}/compare/{{ repo.default_bookmark }}...{{ b.name }}">compare with {{ repo.default_bookmark }}</a>{% if can_delete %}
19 -
    <form class="inline" method="post" action="{{ repo.url }}/bookmarks/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b.name }}"><button type="submit" class="small danger-btn" onclick="return confirm('Delete the bookmark {{ b.name }}? Its changes stay while anything else reaches them.')">DELETE</button></form>{% endif %}{% endif %}</td>
19 +
    <form class="inline" method="post" action="{{ repo.url }}/bookmarks/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b.name }}"><button type="submit" class="small danger-btn" data-confirm="Delete the bookmark {{ b.name }}? Its changes stay while anything else reaches them.">DELETE</button></form>{% endif %}{% endif %}</td>
20 20
</tr>
21 21
{% endfor %}
22 22
</table></div>
…
1 unchanged lines
modified templates/coverage.html +1 −1
…
7 unchanged lines
8 8
  <div class="cov-hero-head">
9 9
    <div>
10 10
      <h1>Coverage</h1>
11 -
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/coverage"><label for="cov-bm" class="mute">bookmark</label> <select id="cov-bm" name="bookmark" onchange="this.form.submit()">{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ bookmark }}</strong>{% endif %}
11 +
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/coverage"><label for="cov-bm" class="mute">bookmark</label> <select id="cov-bm" name="bookmark" data-autosubmit>{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ bookmark }}</strong>{% endif %}
12 12
        · {% if r.uploaded() %}<span class="tag warn" title="Uploaded with oh coverage upload: measured off the hub, not by a Flow job">uploaded by {{ r.uploader }}</span>{% else %}<a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a>{% endif %} · <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></div>
13 13
    </div>
14 14
    <div class="cov-score {{ self.class_of(r.percent()) }}">
…
85 unchanged lines
modified templates/edit.html +1 −1
…
21 unchanged lines
22 22
  <p class="small mute">The change is authored by your linked key and signed by this hub's key as your delegate; the repository's policy decides whether it may land.</p>
23 23
  <div class="choice">
24 24
    <button type="submit"{% if delegation.is_some() %} disabled{% endif %}>SAVE</button>
25 -
    {% if !new_file %}<button type="submit" name="delete" value="1" class="danger-btn"{% if delegation.is_some() %} disabled{% endif %} onclick="return confirm('Delete {{ path }}?')">DELETE FILE</button>{% endif %}
25 +
    {% if !new_file %}<button type="submit" name="delete" value="1" class="danger-btn"{% if delegation.is_some() %} disabled{% endif %} data-confirm="Delete {{ path }}?">DELETE FILE</button>{% endif %}
26 26
    <a class="go" href="{{ repo.url }}/{% if new_file %}tree/{{ r }}{% else %}file/{{ r }}/{{ path }}{% endif %}">CANCEL</a>
27 27
  </div>
28 28
</form>
…
1 unchanged lines
modified templates/flow_run.html +2 −2
…
10 unchanged lines
11 11
  {% if can_rerun %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/rerun"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small">RE-RUN</button></form>{% endif %}
12 12
  {% if !run.priority.is_empty() %}<p class="small"><span class="tag tag-urgent" title="Runners take it before any other">{{ run.priority }}</span> {{ run.priority_note }}</p>{% endif %}
13 13
  {% if can_expedite %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/expedite" title="Runners take an urgent run's jobs before any other's"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="text" name="reason" size="22" maxlength="200" placeholder="why it is urgent" required> <button type="submit" class="small">EXPEDITE</button></form>{% endif %}
14 -
  {% if can_cancel %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/cancel" onsubmit="return confirm('Cancel run #{{ run.number }}? Jobs still running are stopped.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small danger-btn">CANCEL</button></form>{% endif %}
14 +
  {% if can_cancel %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/cancel" data-confirm="Cancel run #{{ run.number }}? Jobs still running are stopped."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" class="small danger-btn">CANCEL</button></form>{% endif %}
15 15
</div>
16 16
{% if let Some(q) = queue %}
17 17
<section class="panel"><div class="strip">Waiting for a runner: {{ q.place().unwrap_or_default() }}.{% if !q.running.is_empty() %} Running now: {{ q.running.join(", ") }}.{% endif %}{% if !q.ahead.is_empty() %} Queued ahead: {{ q.ahead.join(", ") }}.{% endif %}{% if q.hidden > 0 %} Also ahead or running: {{ q.hidden }} job{% if q.hidden != 1 %}s{% endif %} in private repositories.{% endif %}</div></section>
…
6 unchanged lines
24 24
  <ul class="plain">{% for a in approvals %}<li class="approval st-{{ a.state }}"><strong>{{ a.job }}</strong>
25 25
    {% if a.state == "waiting" %}waits for approval by someone with the <em>{{ a.by }}</em> role{% if a.others_only %}, other than {{ run.actor }}{% endif %}.
26 26
      {% if a.sealed %}<p class="small">It takes an approval signed with your own key. In a checkout of {{ repo.owner }}/{{ repo.name }}:</p>
27 -
      <div class="clone"><input type="text" readonly value="{{ a.command }}" onfocus="this.select()" aria-label="The command that approves it"><button type="button" class="small" data-copy="{{ a.command }}">copy</button></div>{% endif %}
27 +
      <div class="clone"><input type="text" readonly value="{{ a.command }}" data-select aria-label="The command that approves it"><button type="button" class="small" data-copy="{{ a.command }}">copy</button></div>{% endif %}
28 28
      {% if a.may_decide() %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/jobs/{{ a.job }}/decide"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% if !a.sealed %}<button type="submit" name="action" value="approve" class="small">APPROVE</button> {% endif %}<button type="submit" name="action" value="reject" class="small danger-btn">REJECT</button></form>
29 29
      {% else %}<span class="small mute">{{ a.why_not() }}</span>{% endif %}
30 30
    {% else if a.state == "approved" %}approved by {{ a.who }} <time title="{{ a.when_ms|when }}">{{ a.when_ms|ago }}</time>{% if !a.seal.is_empty() %}, <span class="sig sig-valid">◈</span> sealed with their key (approval <code title="{{ a.seal }}">{{ a.seal|short }}</code>){% else if a.sealed %}{% endif %}.
…
39 unchanged lines
modified templates/org_settings.html +1 −1
…
86 unchanged lines
87 87
  <div class="table"><table>
88 88
  <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Machine</th><th></th></tr>
89 89
  {% for (r, serves, own) in runners %}<tr><td><strong>{{ r.name }}</strong></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td class="small">{{ serves }}</td><td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }}{% endif %}</td>
90 -
  <td>{% if *own %}<form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %}
90 +
  <td>{% if *own %}<form class="tiny" method="post" action="/orgs/{{ org.name }}/settings/runners/{{ r.id }}/remove" data-confirm="Remove {{ r.name }}? Its credential stops working."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %}
91 91
  </table></div>{% endif %}
92 92
  <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form>
93 93
</section>
…
7 unchanged lines
modified templates/overview.html +2 −2
…
27 unchanged lines
28 28
        <summary class="go small primary" aria-haspopup="true"><span aria-hidden="true">&lt;&gt;</span> Code</summary>
29 29
        <div class="clone-panel" role="group" aria-label="Clone or download this repository">
30 30
          <label for="clone-cmd" class="small mute">Clone with oxy</label>
31 -
          <div class="clone"><input id="clone-cmd" type="text" readonly value="oxy clone {{ repo.clone_url }}" onfocus="this.select()" onclick="this.select()"><button type="button" class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div>
31 +
          <div class="clone"><input id="clone-cmd" type="text" readonly value="oxy clone {{ repo.clone_url }}" data-select><button type="button" class="small" data-copy="oxy clone {{ repo.clone_url }}">copy</button></div>
32 32
          <p class="small mute">The URL alone: <code>{{ repo.clone_url }}</code></p>
33 33
          {% if let Some(git) = repo.git_url %}
34 34
          <label for="git-clone-cmd" class="small mute">Clone with Git (a read-only mirror)</label>
35 -
          <div class="clone"><input id="git-clone-cmd" type="text" readonly value="git clone {{ git }}" onfocus="this.select()" onclick="this.select()"><button type="button" class="small" data-copy="git clone {{ git }}">copy</button></div>
35 +
          <div class="clone"><input id="git-clone-cmd" type="text" readonly value="git clone {{ git }}" data-select><button type="button" class="small" data-copy="git clone {{ git }}">copy</button></div>
36 36
          <p class="small mute">For tools that only speak Git. Push with oxy: the mirror refuses pushes.</p>
37 37
          {% endif %}
38 38
          {% if quickstart.is_none() %}
…
80 unchanged lines
modified templates/project.html +3 −3
…
36 unchanged lines
37 37
  </details>
38 38
  <details class="add-card"><summary class="small">column</summary>
39 39
    <form method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="column" value="{{ col.column.id }}">
40 -
      <input type="text" name="name" value="{{ col.column.name }}" aria-label="Column name" maxlength="40"><button name="action" value="rename_column" class="small">RENAME</button> <button name="action" value="remove_column" class="small danger-btn" onclick="return confirm('Remove this column? Its cards move to the first column.')">REMOVE</button>
40 +
      <input type="text" name="name" value="{{ col.column.name }}" aria-label="Column name" maxlength="40"><button name="action" value="rename_column" class="small">RENAME</button> <button name="action" value="remove_column" class="small danger-btn" data-confirm="Remove this column? Its cards move to the first column.">REMOVE</button>
41 41
    </form>
42 42
  </details>
43 43
  {% endif %}
…
9 unchanged lines
53 53
  <label for="ptitle">Title</label><input type="text" id="ptitle" name="title" value="{{ project.title }}" maxlength="100">
54 54
  <label for="pdesc">Description</label><input type="text" id="pdesc" name="description" value="{{ project.description }}">
55 55
  <div class="choice"><label><input type="checkbox" name="private" value="1"{% if !project.public %} checked{% endif %}> private</label> <label><input type="checkbox" name="closed" value="1"{% if project.closed %} checked{% endif %}> closed (read-only)</label></div>
56 -
  <button name="action" value="settings">SAVE</button> <button name="action" value="delete" class="danger-btn" onclick="return confirm('Delete this project? Issues and proposals are not touched.')">DELETE PROJECT</button>
56 +
  <button name="action" value="settings">SAVE</button> <button name="action" value="delete" class="danger-btn" data-confirm="Delete this project? Issues and proposals are not touched.">DELETE PROJECT</button>
57 57
</form>
58 58
{% if !transfer_to.is_empty() %}
59 59
<form class="card" method="post" action="/projects/{{ project.owner }}/{{ project.number }}"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
1 unchanged lines
61 61
  <p class="small mute">Move this project to another owner. It takes the next number there, with its columns and cards; this address goes on redirecting to it.</p>
62 62
  <label for="pto">New owner</label>
63 63
  <select id="pto" name="to">{% for o in transfer_to %}<option value="{{ o }}">{{ o }}</option>{% endfor %}</select>
64 -
  <button name="action" value="transfer" onclick="return confirm('Transfer this project?')">TRANSFER</button>
64 +
  <button name="action" value="transfer" data-confirm="Transfer this project?">TRANSFER</button>
65 65
</form>
66 66
{% endif %}
67 67
</details>
…
2 unchanged lines
modified templates/proposal.html +1 −1
…
188 unchanged lines
189 189
    {% if requirements.required_approvals == 0 && approvals.is_empty() %}<p class="small warn-text">No approval yet, and this repository's <a href="{{ repo.url }}/settings">settings</a> require none.</p>{% endif %}
190 190
    {% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %}
191 191
    {% if can_land %}
192 -
      {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"{% if approvals.is_empty() %} onsubmit="return confirm('Land #{{ p.number }} without any approval?')"{% endif %}><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" data-busy="LANDING">LAND</button></form>
192 +
      {% if hub_can_land %}<form method="post" action="{{ repo.url }}/proposal/{{ p.number }}/land"{% if approvals.is_empty() %} data-confirm="Land #{{ p.number }} without any approval?"{% endif %}><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" data-busy="LANDING">LAND</button></form>
193 193
      {% else %}<p class="small">This repository has a trust policy, so land from a client with your key:</p><p class="secret">oxy proposal land {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</p>{% endif %}
194 194
    {% endif %}
195 195
  {% endif %}
…
58 unchanged lines
modified templates/pulse.html +1 −118
…
7 unchanged lines
8 8
  {% if pulse_total == 0 %}<p class="empty small">No changes pushed in the last year.</p>{% else %}
9 9
  <div class="pulse-stage" id="pulse-stage"><div class="pulse-card" id="pulse-card" aria-live="polite"></div></div>
10 10
  <script type="application/json" id="pulse-data">{{ pulse|safe }}</script>
11 -
  <script>
12 -
(() => {
13 -
"use strict";
14 -
const data = JSON.parse(document.getElementById("pulse-data").textContent);
15 -
const stage = document.getElementById("pulse-stage"), card = document.getElementById("pulse-card");
16 -
const NS = "http://www.w3.org/2000/svg", reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
17 -
const el = (t, a, p) => { const e = document.createElementNS(NS, t); for (const k in a) e.setAttribute(k, a[k]); if (p) p.appendChild(e); return e; };
18 -
const PAL = 7, col = r => r < PAL ? `var(--pu${r + 1})` : "var(--pu8)";
19 -
document.getElementById("pulse-title").textContent = `Last ${data.months} months`;
20 -
const days = data.days.map((d, i) => ({ t: d[0], via: d[1], by: d[2], date: new Date(data.start + i * 864e5) }));
21 -
const N = days.length, LAST = N - 1;
22 -
const W = 1000, H = 300, L = 40, R = 14, T = 26, B = 58, base = H - B, X = i => L + i * ((W - L - R) / LAST);
23 -
const s = el("svg", { viewBox: `0 0 ${W} ${H}`, role: "img", "aria-label": "Changes pushed each day over the last year" });
24 -
stage.prepend(s);
25 -
const defs = el("defs", {}, s);
26 -
const grad = el("linearGradient", { id: "pu-grad", x1: L, x2: W - R, y1: 0, y2: 0, gradientUnits: "userSpaceOnUse" }, defs);
27 -
const fade = el("linearGradient", { id: "pu-fade", x1: 0, x2: 0, y1: 0, y2: 1 }, defs);
28 -
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": ".5" }, fade); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": "0" }, fade);
29 -
const fm = el("mask", { id: "pu-fm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs); el("rect", { width: W, height: H, fill: "url(#pu-fade)" }, fm);
30 -
const glow = el("filter", { id: "pu-glow", x: "-5%", y: "-30%", width: "110%", height: "160%" }, defs);
31 -
el("feGaussianBlur", { stdDeviation: "2.6", result: "b" }, glow); const mg = el("feMerge", {}, glow); el("feMergeNode", { in: "b" }, mg); el("feMergeNode", { in: "SourceGraphic" }, mg);
32 -
const spot = el("radialGradient", { id: "pu-spot", cx: "0", cy: "0", r: "1", gradientUnits: "userSpaceOnUse" }, defs);
33 -
el("stop", { offset: "0", "stop-color": "#fff", "stop-opacity": "1" }, spot); el("stop", { offset: "1", "stop-color": "#fff", "stop-opacity": ".4" }, spot);
34 -
const sm = el("mask", { id: "pu-sm", maskUnits: "userSpaceOnUse", x: 0, y: 0, width: W, height: H }, defs);
35 -
const spotRect = el("rect", { width: W, height: H, fill: "#fff" }, sm);
36 -
const clip = el("clipPath", { id: "pu-clip" }, defs), clipRect = el("rect", { width: reduce ? W : 0, height: H }, clip);
37 -
const grid = el("g", {}, s), ylab = [];
38 -
for (let k = 0; k <= 4; k++) { const y = T + (base - T) * k / 4; el("line", { x1: L, x2: W - R, y1: y, y2: y, stroke: "var(--line)", "stroke-opacity": ".5" }, grid); ylab.push(el("text", { x: L - 6, y: y + 4, "text-anchor": "end", class: "pu-axis" }, grid)); }
39 -
days.forEach((d, i) => { if (N > 100 ? d.date.getUTCDate() !== 1 : (d.date.getUTCDate() !== 1 && d.date.getUTCDate() !== 15)) return; el("line", { x1: X(i), x2: X(i), y1: base, y2: base + 5, stroke: "var(--line)" }, grid); const t = el("text", { x: X(i) + 3, y: base + 18, class: "pu-axis" }, grid); t.textContent = d.date.toLocaleDateString("en-GB", N > 100 ? { month: "short", timeZone: "UTC" } : { day: "numeric", month: "short", timeZone: "UTC" }); });
40 -
const plot = el("g", { mask: "url(#pu-sm)", "clip-path": "url(#pu-clip)" }, s);
41 -
const area = el("path", { fill: "url(#pu-grad)", mask: "url(#pu-fm)" }, plot);
42 -
const line = el("path", { fill: "none", stroke: "url(#pu-grad)", "stroke-width": 2.2, "stroke-linejoin": "round", filter: "url(#pu-glow)" }, plot);
43 -
const viaLine = el("path", { fill: "none", stroke: "var(--pu-agent)", "stroke-width": 1.3, "stroke-dasharray": "1 3", "stroke-linecap": "round" }, plot);
44 -
const strip = el("g", {}, plot), peakG = el("g", {}, s);
45 -
const beat = el("circle", { r: 4.5, fill: "var(--panel)", stroke: "var(--acc)", "stroke-width": 2, filter: "url(#pu-glow)", opacity: 0 }, s);
46 -
const scan = el("line", { y1: T - 4, y2: base, stroke: "var(--ink)", "stroke-dasharray": "2 3", opacity: 0 }, s);
47 -
const dot = el("circle", { r: 5, fill: "var(--panel)", "stroke-width": 2.5, opacity: 0 }, s);
48 -
let only = -1, ys = [];
49 -
const val = d => only < 0 ? d.t : (d.by.find(p => p[0] === only) || [0, 0])[1];
50 -
const top = d => d.by.length ? d.by.reduce((a, b) => b[1] > a[1] ? b : a)[0] : 0;
51 -
const smooth = pts => pts.reduce((acc, [x, y], i) => { if (!i) return `M${x},${y}`; const [x0, y0] = pts[i - 1], cx = (x0 + x) / 2; return acc + `C${cx},${y0} ${cx},${y} ${x},${y}`; }, "");
52 -
function build() {
53 -
  const v = days.map(val), max = Math.max(1, ...v);
54 -
  ys = v.map(n => base - (base - T) * n / max);
55 -
  const d = smooth(ys.map((y, i) => [X(i), y]));
56 -
  line.setAttribute("d", d); area.setAttribute("d", d + `L${X(LAST)},${base}L${X(0)},${base}Z`);
57 -
  viaLine.setAttribute("d", smooth(days.map((x, i) => [X(i), base - (base - T) * (only < 0 ? x.via : Math.round(val(x) * x.via / (x.t || 1))) / max])));
58 -
  grad.textContent = "";
59 -
  for (let i = 0; i <= LAST; i += Math.max(1, Math.round(N / 52))) el("stop", { offset: (i / LAST).toFixed(4), style: `stop-color:${col(only < 0 ? top(days[i]) : only)}` }, grad);
60 -
  strip.textContent = "";
61 -
  days.forEach((x, i) => { if (!val(x)) return; el("rect", { x: X(i) - .9, y: base + 28, width: 1.8, height: 9, rx: .9, style: `fill:${col(only < 0 ? top(x) : only)}` }, strip); });
62 -
  ylab.forEach((t, k) => { t.textContent = Math.round(max * (4 - k) / 4); });
63 -
  peakG.textContent = "";
64 -
  if (only < 0) {
65 -
    // At most four labels, the biggest days first, none crowding another;
66 -
    // the other releases are marked without words.
67 -
    const peaks = (data.peaks || []).filter(([i]) => i >= 0 && i <= LAST).sort((a, b) => ys[a[0]] - ys[b[0]]);
68 -
    const labelled = [];
69 -
    peaks.forEach(([i, label]) => {
70 -
      const y = ys[i], free = labelled.length < 4 && labelled.every(j => Math.abs(X(j) - X(i)) > 120);
71 -
      el("circle", { cx: X(i), cy: y, r: free ? 3.2 : 2.2, fill: "var(--ink)" }, peakG);
72 -
      if (!free) return;
73 -
      labelled.push(i);
74 -
      el("circle", { cx: X(i), cy: y, r: 4, fill: "none", stroke: "var(--ink)", class: "pu-ring" }, peakG);
75 -
      const right = X(i) < W - 160;
76 -
      const t = el("text", { x: X(i) + (right ? 8 : -8), y: Math.max(T - 8, y - 8), "text-anchor": right ? "start" : "end", class: "pu-peak" }, peakG); t.textContent = label;
77 -
    });
78 -
  }
79 -
}
80 -
build();
81 -
// Legend: a repository's own year.
82 -
const lg = document.getElementById("pulse-legend");
83 -
const btn = (label, c, idx) => { const b = document.createElement("button"); b.type = "button"; b.className = "pu-chip"; b.setAttribute("aria-pressed", String(idx === -1)); b.innerHTML = `<i style="background:${c}"></i>`; b.append(label); b.addEventListener("click", () => { only = idx; [...lg.children].forEach(c => c.setAttribute("aria-pressed", "false")); b.setAttribute("aria-pressed", "true"); build(); }); lg.appendChild(b); };
84 -
btn("all", "var(--ink)", -1);
85 -
data.repos.slice(0, PAL).forEach((n, i) => btn(n.split("/").pop(), col(i), i));
86 -
// The total counts up; the line draws itself in; then a heartbeat while idle.
87 -
const totalEl = document.getElementById("pulse-total"), total = Number(totalEl.dataset.total);
88 -
if (!reduce) { const t0 = performance.now(); const tick = now => { const p = Math.min(1, (now - t0) / 900); totalEl.textContent = Math.round(total * (1 - Math.pow(1 - p, 3))).toLocaleString("en-GB"); if (p < 1) requestAnimationFrame(tick); }; requestAnimationFrame(tick); }
89 -
else totalEl.textContent = total.toLocaleString("en-GB");
90 -
let idle = true, timer = 0;
91 -
function heartbeat() {
92 -
  if (reduce) return;
93 -
  clearTimeout(timer);
94 -
  timer = setTimeout(function run() {
95 -
    if (!idle || document.hidden) { timer = setTimeout(run, 2000); return; }
96 -
    const len = line.getTotalLength(), t0 = performance.now();
97 -
    const go = now => { const p = Math.min(1, (now - t0) / 2600), pt = line.getPointAtLength(len * p); beat.setAttribute("cx", pt.x); beat.setAttribute("cy", pt.y); beat.setAttribute("opacity", idle ? String(Math.sin(p * Math.PI)) : "0"); if (p < 1 && idle) requestAnimationFrame(go); else { beat.setAttribute("opacity", "0"); timer = setTimeout(run, 4500); } };
98 -
    requestAnimationFrame(go);
99 -
  }, 800);
100 -
}
101 -
if (!reduce) {
102 -
  const t0 = performance.now();
103 -
  let shown = false;
104 -
  const reveal = now => { if (shown) return; const p = Math.min(1, (now - t0) / 1600); clipRect.setAttribute("width", L + (W - L) * (1 - Math.pow(1 - p, 3))); if (p < 1) requestAnimationFrame(reveal); };
105 -
  requestAnimationFrame(reveal);
106 -
  setTimeout(() => { shown = true; clipRect.setAttribute("width", W); heartbeat(); }, 1700);
107 -
}
108 -
// Pointer: spotlight, scan line and the day's card.
109 -
const fmt = d => d.toLocaleDateString("en-GB", { weekday: "short", day: "numeric", month: "short", year: "numeric", timeZone: "UTC" });
110 -
s.addEventListener("pointermove", e => {
111 -
  idle = false; beat.setAttribute("opacity", "0");
112 -
  const r = s.getBoundingClientRect(), x = (e.clientX - r.left) * (W / r.width);
113 -
  const i = Math.max(0, Math.min(LAST, Math.round((x - L) / ((W - L - R) / LAST)))), d = days[i];
114 -
  spotRect.setAttribute("fill", "url(#pu-spot)"); spot.setAttribute("gradientTransform", `translate(${X(i)} ${ys[i]}) scale(160 240)`);
115 -
  scan.setAttribute("x1", X(i)); scan.setAttribute("x2", X(i)); scan.setAttribute("opacity", ".45");
116 -
  dot.setAttribute("cx", X(i)); dot.setAttribute("cy", ys[i]); dot.style.stroke = col(only < 0 ? top(d) : only); dot.setAttribute("opacity", "1");
117 -
  const n = val(d), share = d.t ? Math.round(100 * d.via / d.t) : 0;
118 -
  const bar = d.t ? d.by.map(([k, m]) => `<i style="width:${100 * m / d.t}%;background:${col(k)}"></i>`).join("") : "";
119 -
  const repos = d.by.slice().sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, m]) => `<div class="row"><span>${(data.repos[k] || "").split("/").pop()}</span><span>${m}</span></div>`).join("");
120 -
  card.innerHTML = `<b>${fmt(d.date)}</b><div class="row"><span>changes</span><span>${n}</span></div><div class="row"><span>via agents</span><span>${share}%</span></div>${only < 0 ? repos : ""}${bar ? `<div class="bar">${bar}</div>` : ""}`;
121 -
  card.style.opacity = "1";
122 -
  const px = e.clientX - r.left, cw = card.offsetWidth;
123 -
  card.style.left = Math.max(0, Math.min(r.width - cw, px + (px > r.width * .6 ? -cw - 14 : 14))) + "px";
124 -
});
125 -
s.addEventListener("pointerleave", () => { idle = true; spotRect.setAttribute("fill", "#fff"); scan.setAttribute("opacity", "0"); dot.setAttribute("opacity", "0"); card.style.opacity = "0"; });
126 -
document.addEventListener("visibilitychange", () => { if (!document.hidden) heartbeat(); });
127 -
})();
128 -
  </script>
11 +
  <script src="/static/pulse.js?v={{ shell.assets() }}" defer></script>
129 12
  {% endif %}
130 13
</section>
modified templates/release_new.html +1 −1
…
7 unchanged lines
8 8
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
9 9
{% if tags.is_empty() %}<p class="empty">This repository has no tags. <code>oxy tag create v1.0 -m "First release"</code> and push, then come back.</p>{% else %}
10 10
<form class="card wide" method="get" action="{{ repo.url }}/releases/new">
11 -
  <div class="row"><div><label for="tag">Tag</label><select id="tag" name="tag" onchange="this.form.submit()">{% for t in tags %}<option value="{{ t }}"{% if *t == tag %} selected{% endif %}>{{ t }}</option>{% endfor %}</select></div></div>
11 +
  <div class="row"><div><label for="tag">Tag</label><select id="tag" name="tag" data-autosubmit>{% for t in tags %}<option value="{{ t }}"{% if *t == tag %} selected{% endif %}>{{ t }}</option>{% endfor %}</select></div></div>
12 12
</form>
13 13
<form class="card wide" method="post" action="{{ repo.url }}/releases/new">
14 14
  <input type="hidden" name="_csrf" value="{{ shell.csrf }}">
…
8 unchanged lines
modified templates/releases.html +1 −1
…
33 unchanged lines
34 34
    <label for="title">Title</label><input type="text" id="title" name="title" value="{{ v.r.title }}">
35 35
    <label for="notes">Notes</label><textarea id="notes" name="notes" class="md" rows="8">{{ v.r.notes }}</textarea>
36 36
    <div class="choice"><label><input type="checkbox" name="prerelease" value="1"{% if v.r.prerelease %} checked{% endif %}> pre-release</label></div>
37 -
    <div class="choice"><button type="submit" name="action" value="save">SAVE</button> <button type="submit" name="action" value="delete" class="danger-btn" onclick="return confirm('Delete this release? The tag stays.')">DELETE RELEASE</button></div>
37 +
    <div class="choice"><button type="submit" name="action" value="save">SAVE</button> <button type="submit" name="action" value="delete" class="danger-btn" data-confirm="Delete this release? The tag stays.">DELETE RELEASE</button></div>
38 38
  </form>
39 39
  {% endif %}
40 40
</section>
…
2 unchanged lines
modified templates/repo_settings.html +1 −1
…
147 unchanged lines
148 148
  <div class="table"><table>
149 149
  <tr><th>Runner</th><th>Status</th><th>Serves</th><th>Machine</th><th></th></tr>
150 150
  {% for (r, serves, own) in runners %}<tr><td><strong>{{ r.name }}</strong></td><td><span class="runner-status st-{{ r.status() }}">{{ r.status() }}</span></td><td class="small">{{ serves }}</td><td class="small">{% if r.arch.is_empty() %}<span class="mute">unknown</span>{% else %}{{ r.arch }} · {{ r.os }}{% endif %}</td>
151 -
  <td>{% if *own %}<form class="tiny" method="post" action="{{ repo.url }}/settings/runners/{{ r.id }}/remove" onsubmit="return confirm('Remove {{ r.name }}? Its credential stops working.')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %}
151 +
  <td>{% if *own %}<form class="tiny" method="post" action="{{ repo.url }}/settings/runners/{{ r.id }}/remove" data-confirm="Remove {{ r.name }}? Its credential stops working."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="danger-btn">remove</button></form>{% endif %}</td></tr>{% endfor %}
152 152
  </table></div>{% endif %}
153 153
  <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form>
154 154
</section>
…
103 unchanged lines
modified templates/report.html +2 −2
…
7 unchanged lines
8 8
    <div>
9 9
      <div class="small"><a href="{{ repo.url }}/reports">Reports</a> /</div>
10 10
      <h1>{{ report.title }} <span class="tag rep-kind rep-{{ report.kind }}">{{ report.kind }}</span></h1>
11 -
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/reports/{{ report.name }}"><label for="rep-bm" class="mute">bookmark</label> <select id="rep-bm" name="bookmark" onchange="this.form.submit()">{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == report.bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ report.bookmark }}</strong>{% endif %}
11 +
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/reports/{{ report.name }}"><label for="rep-bm" class="mute">bookmark</label> <select id="rep-bm" name="bookmark" data-autosubmit>{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == report.bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ report.bookmark }}</strong>{% endif %}
12 12
        {% if let Some(n) = report.run_number %}· <a href="{{ repo.url }}/flow/{{ n }}">run #{{ n }}</a>{% endif %} · job {{ report.job }} · <time title="{{ report.created_ms|when_i }}">{{ report.created_ms|ago_i }}</time> · {{ report.files }} files, {{ report.bytes|bytes }} · <a href="{{ files_base }}/report.toml" target="_blank" rel="noopener">report.toml</a></div>
13 13
      {% if !report.summary.is_empty() %}<p class="rep-summary">{{ report.summary }}</p>{% endif %}
14 14
    </div>
…
7 unchanged lines
22 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 23
    <form class="inline small" method="get" action="{{ repo.url }}/reports/{{ report.name }}">
24 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>
25 +
      <label class="mute">compare with <select name="compare" data-autosubmit 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 26
      <noscript><button>Compare</button></noscript>
27 27
    </form>
28 28
    <span class="small mute rep-tl-keys">← → step through runs</span>
…
67 unchanged lines
modified templates/tests.html +1 −1
…
7 unchanged lines
8 8
  <div class="cov-hero-head">
9 9
    <div>
10 10
      <h1>Tests</h1>
11 -
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/tests"><label for="t-bm" class="mute">bookmark</label> <select id="t-bm" name="bookmark" onchange="this.form.submit()">{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ bookmark }}</strong>{% endif %}
11 +
      <div class="cov-sub">{% if bookmarks.len() > 1 %}<form class="inline" method="get" action="{{ repo.url }}/tests"><label for="t-bm" class="mute">bookmark</label> <select id="t-bm" name="bookmark" data-autosubmit>{% for b in bookmarks %}<option value="{{ b }}"{% if b.as_str() == bookmark.as_str() %} selected{% endif %}>{{ b }}</option>{% endfor %}</select> <noscript><button>Show</button></noscript></form>{% else %}<span class="mute">on</span> <strong>{{ bookmark }}</strong>{% endif %}
12 12
        · <a href="{{ repo.url }}/flow/{{ r.run_number }}">run #{{ r.run_number }}</a> · job {{ r.job }} · <time title="{{ r.created_ms|when_i }}">{{ r.created_ms|ago_i }}</time></div>
13 13
    </div>
14 14
    <div class="cov-score {{ self.class_of(r) }}">
…
91 unchanged lines
modified templates/webhooks_panel.html +1 −1
…
13 unchanged lines
14 14
    <p class="small mute">events <code>{{ r.hook.events }}</code> ·
15 15
      {% if let Some((ok, text)) = r.last_result() %}last delivery {% if ok %}<span class="sig sig-valid">✔</span>{% else %}<span class="sig sig-invalid">✘</span>{% endif %} {{ text }}{% else %}nothing delivered yet{% endif %}
16 16
      · <a href="{{ base }}/hooks/{{ r.hook.id }}/deliveries">deliveries</a></p>
17 -
    <form class="tiny" method="post" action="{{ base }}/hooks"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.hook.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if r.hook.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete" onclick="return confirm('Delete this webhook?')">delete</button></form>
17 +
    <form class="tiny" method="post" action="{{ base }}/hooks"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.hook.id }}"><button name="action" value="ping">ping</button> <button name="action" value="toggle">{% if r.hook.active %}disable{% else %}enable{% endif %}</button> <button name="action" value="delete" data-confirm="Delete this webhook?">delete</button></form>
18 18
    <details class="hook-edit"><summary class="small">edit</summary>
19 19
      <form class="card" method="post" action="{{ base }}/hooks">
20 20
        <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="id" value="{{ r.hook.id }}"><input type="hidden" name="action" value="update">
…
34 unchanged lines
modified templates/wiki.html +2 −2
…
19 unchanged lines
20 20
<div>
21 21
  {% if let Some(h) = html %}
22 22
  <article class="doc wiki-doc card-doc">{{ h|safe }}</article>
23 -
  {% if can_edit %}<p class="small mute wiki-foot"><form class="tiny inline" method="post" action="{{ repo.url }}/wiki/{{ slug }}/delete" onsubmit="return confirm('Delete the page {{ title }}?')"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">Delete this page</button></form></p>{% endif %}
23 +
  {% if can_edit %}<p class="small mute wiki-foot"><form class="tiny inline" method="post" action="{{ repo.url }}/wiki/{{ slug }}/delete" data-confirm="Delete the page {{ title }}?"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button class="linkish">Delete this page</button></form></p>{% endif %}
24 24
  {% else if any %}
25 25
  <p class="empty">There is no page called <strong>{{ title }}</strong> yet.{% if can_edit %} <a href="{{ repo.url }}/wiki/_new?title={{ title }}">Create it</a>.{% endif %}</p>
26 26
  {% else %}
…
18 unchanged lines
45 45
  </section>{% endif %}
46 46
  {% if let Some(s) = sidebar %}<section class="side-sec"><div class="doc wiki-side">{{ s|safe }}</div></section>{% endif %}
47 47
  <section class="side-sec"><div class="side-head"><h3>Clone this wiki</h3></div>
48 -
    <div class="clone"><input type="text" readonly value="oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki" onfocus="this.select()" onclick="this.select()" aria-label="Clone command"><button type="button" class="small" data-copy="oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki">copy</button></div>
48 +
    <div class="clone"><input type="text" readonly value="oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki" data-select aria-label="Clone command"><button type="button" class="small" data-copy="oxy clone {{ shell.base_url }}/{{ repo.owner }}/{{ repo.name }}.wiki">copy</button></div>
49 49
  </section>
50 50
</aside>
51 51
</div>
…
2 unchanged lines