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 | + | } |