OXYGENHUB

Web: show that the hub is working while a request is slow 16626c0d69c0

A pressed button says what it is doing with the CLI's spinner (LANDING on
a proposal) and ignores a second press. A navigation that takes more than
300 ms spins the Oxygen mark's rings, in the header and beside the pointer
(below the header on touch screens), with a progress cursor. Going back to
a page the browser kept clears it all.

closes #142
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-12 10:09 UTC
Signature
✔ signed by Andy McCall
Hash
16626c0d69c027dfa6d64937ae54a2bf68026bc886eab483a326c55299547e14
Depends on
0b20f1ec4007 155cab142b63 4f37afcc3583 95b525b0b977 d23464c0ac60 de505df5cf8d
Depended on by
668b2ad7495c 03bfb945f5c1 a7aa76ac52d7 6ca0b971ff07 19355e2283ab
In bookmarks
api-unknown-fields cancel-landed-runs close-proposal-140 diffusion-pin-bookmark draft-ready 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
7 atoms, 0 tree operations, 3 files

Browse the tree at this change

modified assets/hub.js +84 −0
…
547 unchanged lines
548 548
    }
549 549
  } catch (err) { /* no storage: nothing to restore */ }
550 550
551 +
  /* Working (#142): on a repository whose store is remote, a land or a
552 +
     page can take seconds. A pressed button says what it is doing, with
553 +
     the CLI's spinner, and ignores a second press; a navigation that takes
554 +
     more than a moment spins the Oxygen mark's rings, in the header and
555 +
     beside the pointer. A quick page shows none of it. */
556 +
  var SPIN = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
557 +
  var waitTimer = 0, spinTimer = 0, pointer = null, mark = null;
558 +
  document.addEventListener("pointermove", function (e) {
559 +
    pointer = e.pointerType === "mouse" ? { x: e.clientX, y: e.clientY } : null;
560 +
    if (mark && pointer) place();
561 +
  }, { passive: true });
562 +
  function place() {
563 +
    if (pointer) { mark.style.left = pointer.x + 14 + "px"; mark.style.top = pointer.y + 14 + "px"; mark.classList.remove("corner"); }
564 +
    else mark.classList.add("corner");
565 +
  }
566 +
  function waiting() {
567 +
    clearTimeout(waitTimer);
568 +
    waitTimer = setTimeout(function () {
569 +
      if (!mark) {
570 +
        var logo = document.querySelector("header .brand svg");
571 +
        mark = document.createElement("div");
572 +
        mark.className = "oxy-wait-mark";
573 +
        mark.setAttribute("role", "status");
574 +
        mark.setAttribute("aria-label", "Working");
575 +
        if (logo) mark.appendChild(logo.cloneNode(true));
576 +
        document.body.appendChild(mark);
577 +
      }
578 +
      place();
579 +
      document.documentElement.classList.add("oxy-wait");
580 +
    }, 300);
581 +
  }
582 +
  function busy(button, form) {
583 +
    if (!button) return;
584 +
    var label = button.getAttribute("data-busy") || button.textContent.trim();
585 +
    button.dataset.idle = button.innerHTML;
586 +
    button.style.minWidth = button.offsetWidth + "px";
587 +
    button.textContent = label + " ";
588 +
    var glyph = document.createElement("span");
589 +
    glyph.className = "oxy-spin";
590 +
    glyph.setAttribute("aria-hidden", "true");
591 +
    glyph.textContent = SPIN[0];
592 +
    button.appendChild(glyph);
593 +
    button.setAttribute("aria-busy", "true");
594 +
    var i = 0;
595 +
    clearInterval(spinTimer);
596 +
    spinTimer = setInterval(function () { glyph.textContent = SPIN[++i % SPIN.length]; }, 80);
597 +
    /* Disabled only after the form's data is gathered, so the button's
598 +
       own name and value still go with it. */
599 +
    setTimeout(function () { button.disabled = true; }, 0);
600 +
    form.dataset.sending = "1";
601 +
  }
602 +
  document.addEventListener("submit", function (e) {
603 +
    var form = e.target;
604 +
    if (form.dataset.sending) { e.preventDefault(); return; }
605 +
    if (e.defaultPrevented || (form.target && form.target !== "_self")) return;
606 +
    if ((form.getAttribute("method") || "get").toLowerCase() === "post") {
607 +
      busy(e.submitter || form.querySelector("button[type=submit], button:not([type])"), form);
608 +
    }
609 +
    waiting();
610 +
  });
611 +
  document.addEventListener("click", function (e) {
612 +
    if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
613 +
    var a = e.target.closest ? e.target.closest("a[href]") : null;
614 +
    if (!a || a.hasAttribute("download") || (a.target && a.target !== "_self")) return;
615 +
    var url = new URL(a.href, location.href);
616 +
    if (url.origin !== location.origin) return;
617 +
    if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
618 +
    waiting();
619 +
  });
620 +
  /* Back to a page the browser kept: it is not working any more. */
621 +
  window.addEventListener("pageshow", function (e) {
622 +
    if (!e.persisted) return;
623 +
    clearTimeout(waitTimer);
624 +
    clearInterval(spinTimer);
625 +
    document.documentElement.classList.remove("oxy-wait");
626 +
    document.querySelectorAll("[aria-busy=true][data-idle]").forEach(function (b) {
627 +
      b.innerHTML = b.dataset.idle;
628 +
      b.disabled = false;
629 +
      b.removeAttribute("aria-busy");
630 +
      b.style.minWidth = "";
631 +
      if (b.form) delete b.form.dataset.sending;
632 +
    });
633 +
  });
634 +
551 635
  /* AI assistants (#109): a provider fills the name, endpoint and API;
552 636
     List models asks the endpoint what it offers and fills the model
553 637
     field's suggestions. Without this script, both are typed by hand. */
…
36 unchanged lines
modified templates/proposal.html +5 −5
…
124 unchanged lines
125 125
    <div class="tl-card outcome-card">
126 126
      <div><strong>Proposal landed</strong>
127 127
      <p class="small">{% if !p.landed_by.is_empty() %}<a href="/{{ p.landed_by }}">{{ p.landed_by }}</a> landed{% else %}Landed{% endif %} {{ changes.len()|plural("change", "changes") }} into <a class="lbl lbl-bm" href="{{ repo.url }}/tree/{{ p.target }}">{{ p.target }}</a>{% if p.landed_ms > 0 %} <time title="{{ p.landed_ms|when_i }}">{{ p.landed_ms|ago_i }}</time>{% endif %}.{% if let Some(b) = deletable_source %} The <code>{{ b }}</code> bookmark can be safely deleted.{% endif %}</p></div>
128 -
      {% if let Some(b) = deletable_source %}<form method="post" action="{{ repo.url }}/bookmarks/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b }}"><button type="submit">Delete bookmark</button></form>{% endif %}
128 +
      {% if let Some(b) = deletable_source %}<form method="post" action="{{ repo.url }}/bookmarks/delete"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="bookmark" value="{{ b }}"><button type="submit" data-busy="Deleting">Delete bookmark</button></form>{% endif %}
129 129
    </div>
130 130
  </div>
131 131
  {% else if p.state == "closed" %}
…
15 unchanged lines
147 147
          {% if can_edit %}
148 148
          {% if p.state == "draft" %}<button type="submit" form="pstate" name="action" value="ready" formnovalidate>Ready for review</button>{% endif %}
149 149
          {% if p.state == "open" %}<button type="submit" form="pstate" name="action" value="draft" formnovalidate>Back to draft</button>{% endif %}
150 -
          {% if p.is_open() %}<button type="submit" form="pstate" name="action" value="close" class="danger-btn" formnovalidate>Close proposal</button>{% endif %}
151 -
          {% if p.state == "closed" %}<button type="submit" form="pstate" name="action" value="reopen" formnovalidate>Reopen proposal</button>{% endif %}
150 +
          {% if p.is_open() %}<button type="submit" form="pstate" name="action" value="close" class="danger-btn" formnovalidate data-busy="Closing">Close proposal</button>{% endif %}
151 +
          {% if p.state == "closed" %}<button type="submit" form="pstate" name="action" value="reopen" formnovalidate data-busy="Reopening">Reopen proposal</button>{% endif %}
152 152
          {% endif %}
153 153
          <button type="submit" class="primary-btn">Comment</button>
154 154
        </div>
…
13 unchanged lines
168 168
          <label><input type="radio" name="verdict" value="approve"><span>Approve</span></label>
169 169
          <label><input type="radio" name="verdict" value="changes"><span>Request changes</span></label>
170 170
        </div>
171 -
        <button type="submit" class="primary-btn">Submit review</button>
171 +
        <button type="submit" class="primary-btn" data-busy="Submitting">Submit review</button>
172 172
      </div>
173 173
      <p class="compose-note">An approval counts once you seal the changes from a client: <code>oxy seal --proposal {{ shell.base_url }}{{ repo.url }}/proposal/{{ p.number }}</code></p>
174 174
    </form>
…
12 unchanged lines
187 187
    {% 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 %}
188 188
    {% else %}<ul class="plain small">{% for b in blockers %}<li><span class="sig sig-invalid">✘</span> {{ b }}</li>{% endfor %}</ul>{% endif %}
189 189
    {% if can_land %}
190 -
      {% 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">LAND</button></form>
190 +
      {% 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>
191 191
      {% 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 %}
192 192
    {% endif %}
193 193
  {% endif %}
…
57 unchanged lines
modified templates/style.css +16 −0
…
970 unchanged lines
971 971
.rep-frame-wrap.rep-side { display: grid; grid-template-columns: 1fr 1fr; gap: 1em; }
972 972
.rep-frame-wrap.rep-side > p { grid-column: 1 / -1; }
973 973
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
974 +
975 +
/* Working (#142): a pressed button's spinner, and the Oxygen mark's rings
976 +
   spinning while a slow page loads (hub.js adds .oxy-wait after 300 ms). */
977 +
button[aria-busy=true] { cursor: progress; opacity: 1; }
978 +
.oxy-spin { display: inline-block; width: 1em; text-align: center; }
979 +
.oxy-wait, .oxy-wait * { cursor: progress !important; }
980 +
.oxy-wait-mark { display: none; position: fixed; z-index: 1000; pointer-events: none; padding: 4px 6px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: 0 2px 8px rgba(0, 0, 0, .18); line-height: 0; }
981 +
.oxy-wait-mark svg { width: 36px; height: 18px; }
982 +
.oxy-wait-mark.corner { left: auto !important; top: calc(env(safe-area-inset-top, 0px) + 68px) !important; right: 16px; }
983 +
.oxy-wait .oxy-wait-mark { display: block; }
984 +
.oxy-wait header .brand svg circle[r="10"], .oxy-wait-mark svg circle[r="10"] { stroke-dasharray: 44 18.8; animation: oxy-ring .9s linear infinite; }
985 +
.oxy-wait header .brand svg circle[cx="11"][r="10"], .oxy-wait-mark svg circle[cx="11"][r="10"] { animation-direction: reverse; }
986 +
@keyframes oxy-ring { to { stroke-dashoffset: -62.8; } }
987 +
@media (prefers-reduced-motion: reduce) {
988 +
  .oxy-wait header .brand svg circle[r="10"], .oxy-wait-mark svg circle[r="10"] { animation: none; stroke-dasharray: none; }
989 +
}