diff --git a/web/src/scripts/main.ts b/web/src/scripts/main.ts index df2dbcb..a14a094 100644 --- a/web/src/scripts/main.ts +++ b/web/src/scripts/main.ts @@ -33,25 +33,64 @@ function bindForms(): void { }); } +/** + * Disable a button + swap its label for a spinner while an async handler + * runs, then restore both. Mirrors the pattern used inside the evaluate + * form (lib/evaluate.ts) so the UX is consistent across the page. + */ +async function runWithBusyState( + btn: HTMLButtonElement, + busyLabel: string, + handler: () => void | Promise, +): Promise { + const originalLabel = btn.innerHTML; + btn.disabled = true; + btn.classList.add("is-busy"); + btn.innerHTML = `${busyLabel}`; + try { + await handler(); + } finally { + btn.classList.remove("is-busy"); + btn.disabled = false; + btn.innerHTML = originalLabel; + } +} + +interface ActionConfig { + handler: () => void | Promise; + /** Async actions render a busy state. Sync ones fire and forget. */ + busy?: boolean; + /** Label to show inside the button while it's running. */ + busyLabel?: string; +} + function bindActionButtons(): void { - const map: Record void | Promise> = { - "load-demo": loadDemo, - "refresh-policies": loadPolicies, - "refresh-timeline": loadTimeline, - "preset-safe-transfer": presetSafeTransfer, - "preset-over-cap": presetOverCap, - "preset-forbidden-approve": presetForbiddenApprove, - "preset-unknown-destination": presetUnknownDest, - "modal-copy": copyJsonModal, - "modal-close": () => closeJsonModal(), + const map: Record = { + // Async actions hit the API and should show a busy state. The Quick + // Demo path posts a policy that the server then anchors on 0G storage, + // which can take 5-30s on Galileo — the loader keeps the UI honest. + "load-demo": { handler: loadDemo, busy: true, busyLabel: "Loading demo" }, + "refresh-policies": { handler: loadPolicies, busy: true, busyLabel: "Refreshing" }, + "refresh-timeline": { handler: loadTimeline, busy: true, busyLabel: "Refreshing" }, + // Synchronous form-fill helpers. No await, no busy state. + "preset-safe-transfer": { handler: presetSafeTransfer }, + "preset-over-cap": { handler: presetOverCap }, + "preset-forbidden-approve": { handler: presetForbiddenApprove }, + "preset-unknown-destination": { handler: presetUnknownDest }, + "modal-copy": { handler: copyJsonModal }, + "modal-close": { handler: () => closeJsonModal() }, }; - for (const [action, handler] of Object.entries(map)) { + for (const [action, cfg] of Object.entries(map)) { document .querySelectorAll(`[data-action="${action}"]`) .forEach((el) => { el.addEventListener("click", (e) => { e.preventDefault(); - void handler(); + if (cfg.busy) { + void runWithBusyState(el, cfg.busyLabel ?? "Working", cfg.handler); + } else { + void cfg.handler(); + } }); }); } diff --git a/web/src/styles/global.css b/web/src/styles/global.css index fcddeea..ed219ae 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -268,18 +268,39 @@ body::after { content: none; } +/* Color-adaptive spinner: inherits the button's text color via currentColor + * so it looks right inside both .btn-primary (dark on lime) and .btn-ghost + * (light on dark). Three quarters of the ring is currentColor; the fourth + * is transparent — the rotation reads as a spinning gap. */ .btn-spinner { display: inline-block; width: 0.85rem; height: 0.85rem; border-radius: 50%; - border: 2px solid rgba(12, 13, 12, 0.25); - border-top-color: var(--ink-bg); + border: 2px solid currentColor; + border-top-color: transparent; + opacity: 0.9; animation: btn-spin 0.7s linear infinite; } @keyframes btn-spin { to { transform: rotate(360deg); } } +/* The busy state injects a `` next to the label + * span. .btn-ghost was previously `display: inline-block` which leaves both + * spans flush against each other and visibly collides the spinner with the + * leading "L" of "LOADING DEMO". Match the .btn-primary inline-flex + gap + * shape so the spinner gets proper breathing room from the text. */ +.btn-ghost { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; +} +.btn-ghost:disabled, +.btn-ghost.is-busy { + opacity: 0.7; + cursor: progress; +} .btn-primary::after { content: "→"; font-family: var(--serif);