eleven / ocho Public

home: assemble the delete step before showing it — no more popping pieces (#469)

The stern panel used to appear instantly and then jump twice: the
destroy zone landing when ownership confirmed, the last-admin note
flashing in when the space answered. Now both checks run first and the
panel reveals fully assembled. A fast answer (the usual case) shows no
loader at all; past 150ms the existing build-spin appears with
'Checking with the space…', and at 2.5s we reveal with whatever has
settled — a straggling positive still applies live, so the hold is
never lost to the cap.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8c94bba012e310da9952a5d8ade562377e0f47f6 parent 8aeb003
2 files changed, +59 −21
  • home/web/pages/index.html +8 −0
  • home/web/static/home.js +51 −21
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 1eb115f..8bbd5f7 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -196,6 +196,7 @@
.btn-danger:disabled { opacity: .4; cursor: default; }
.btn-danger:focus-visible { outline: 2px solid var(--danger); outline-offset: 2px; }
#archive-stern .wide-quiet { margin-top: 10px; }
+ #archive-checking { padding: 34px 0 12px; }
/* The per-row archive: a box glyph that reveals on hover, like the drag grip.
Reversible, so it stays neutral (not danger-red). */
.row-archive {
@@ -456,6 +457,13 @@
<p class="create-sub">Hidden from your list. Bring one back anytime, or remove it for good.</p>
<div id="archive-list"></div>
</div>
+ <!-- Shown while the delete step's checks run (ownership + last-admin), so
+ the stern panel appears fully assembled instead of pieces popping in.
+ Only appears at all when the answers take more than a beat (home.js). -->
+ <div id="archive-checking" class="hidden">
+ <div class="build-spin" aria-hidden="true"></div>
+ <p class="create-sub">Checking with the space…</p>
+ </div>
<div id="archive-stern" class="hidden">
<h2 class="create-title danger-ink">Delete your access?</h2>
<p class="create-sub stern-copy">Deleting <b id="stern-name"></b> removes your only
diff --git a/home/web/static/home.js b/home/web/static/home.js
index b6f1889..e396d33 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -702,6 +702,7 @@ function openArchive() {
archiving = null;
$("vault").classList.add("hidden");
$("archive-stern").classList.add("hidden");
+ $("archive-checking").classList.add("hidden");
$("archive-main").classList.remove("hidden");
$("archive").classList.remove("hidden");
archiveStatus("");
@@ -718,6 +719,7 @@ function closeArchive() {
function closeArchiveStern() {
archiving = null;
$("archive-stern").classList.add("hidden");
+ $("archive-checking").classList.add("hidden");
$("archive-main").classList.remove("hidden");
archiveStatus("");
}
@@ -778,12 +780,21 @@ function unarchiveSpace(s) {
persistVault(archiveStatus);
}
+// The stern step assembles BEFORE it shows: both checks (ownership → destroy
+// zone, last-admin → hold) run first, so pieces never pop into a visible panel.
+// A fast answer shows no loader at all; past STERN_LOADER_DELAY_MS the spinner
+// appears, and at STERN_CHECK_CAP_MS we reveal with whatever has settled — a
+// straggler still applies live (rare, and correctness beats stillness).
+const STERN_LOADER_DELAY_MS = 150;
+const STERN_CHECK_CAP_MS = 2500;
+const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
+
// openArchiveDelete opens the stern permanent-delete step for one archived space.
// "Delete your access" (drop the saved entry) is offered unless the space reports
// this is its ONLY admin key (checkSoleAdmin); for a space THIS account created,
// the deeper "destroy for everyone" is revealed too (ownership checked against
// the provisioner ledger; the server re-enforces it on delete).
-function openArchiveDelete(s) {
+async function openArchiveDelete(s) {
archiving = s;
sternSoleAdmin = false;
sternDestroyable = false;
@@ -797,22 +808,39 @@ function openArchiveDelete(s) {
$("destroy-zone").classList.add("hidden");
$("destroy-forever").disabled = true;
$("archive-main").classList.add("hidden");
- $("archive-stern").classList.remove("hidden");
+ $("archive-stern").classList.add("hidden");
+ $("archive-checking").classList.add("hidden");
archiveStatus("");
- if (spacesEnabled) {
- apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
- .then((r) => {
- const mine = (r.spaces || []).some((sp) => sp.host === host);
- if (mine && archiving === s) {
- sternDestroyable = true;
- $("destroy-zone").classList.remove("hidden");
- prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule)
- updateSternHold(); // the note steers to destroy once it's on the table
- }
- })
- .catch(() => { /* can't confirm ownership — don't offer the destroy */ });
+
+ const checks = Promise.allSettled([checkOwnership(s, host), checkSoleAdmin(s)]);
+ const fast = await Promise.race([checks.then(() => true), sleep(STERN_LOADER_DELAY_MS).then(() => false)]);
+ if (archiving !== s) return; // backed out mid-check
+ if (!fast) {
+ $("archive-checking").classList.remove("hidden");
+ await Promise.race([checks, sleep(STERN_CHECK_CAP_MS)]);
+ if (archiving !== s) return;
+ $("archive-checking").classList.add("hidden");
}
- checkSoleAdmin(s);
+ updateSternHold();
+ $("archive-stern").classList.remove("hidden");
+}
+
+// checkOwnership learns whether THIS account created the space (the provisioner
+// ledger), which puts "destroy for everyone" on the table. Rendering happens in
+// updateSternHold — this only sets the flag, so it composes with the pre-reveal
+// assembly above and still applies live if it straggles past the cap.
+function checkOwnership(s, host) {
+ if (!spacesEnabled) return Promise.resolve();
+ return apiJSON("/api/spaces", { headers: { Authorization: "Bearer " + state.token } })
+ .then((r) => {
+ const mine = (r.spaces || []).some((sp) => sp.host === host);
+ if (mine && archiving === s) {
+ sternDestroyable = true;
+ prefetchLogin(); // the destroy ceremony needs fresh options (Safari gesture rule)
+ updateSternHold();
+ }
+ })
+ .catch(() => { /* can't confirm ownership — don't offer the destroy */ });
}
// checkSoleAdmin asks the space itself — with the saved session token, straight
@@ -823,10 +851,10 @@ function openArchiveDelete(s) {
// on a positive answer.
function checkSoleAdmin(s) {
const token = s.credentials && s.credentials.token;
- if (!token) return;
+ if (!token) return Promise.resolve();
let url;
- try { url = new URL("/api/me?token=" + encodeURIComponent(token), s.url); } catch { return; }
- fetch(url, { signal: AbortSignal.timeout(8000) })
+ try { url = new URL("/api/me?token=" + encodeURIComponent(token), s.url); } catch { return Promise.resolve(); }
+ return fetch(url, { signal: AbortSignal.timeout(8000) })
.then((r) => (r.ok ? r.json() : null))
.then((me) => {
if (soleAdminHold(me) && archiving === s) {
@@ -837,10 +865,12 @@ function checkSoleAdmin(s) {
.catch(() => { /* no answer — no hold */ });
}
-// updateSternHold renders the last-admin hold: "Delete your access" goes (and
-// stays) off, and the note says why and what to do instead — destroy the space
-// below when this account created it, otherwise hand admin to someone else first.
+// updateSternHold renders what the checks learned: the destroy zone when this
+// account owns the space, and the last-admin hold — "Delete your access" goes
+// (and stays) off, with the note saying why and what to do instead: destroy the
+// space below, or hand admin to someone else first.
function updateSternHold() {
+ $("destroy-zone").classList.toggle("hidden", !sternDestroyable);
const note = $("sole-admin-note");
if (!sternSoleAdmin) { note.classList.add("hidden"); return; }
note.textContent = sternDestroyable