eleven / messenger Public

home: compose the sign-in card — last-used first, else platform default, "Other ways to sign in" (#1112) (#1124)

* home: compose the sign-in card — last-used first, else platform default, other ways (#1112)

The card mechanics half of #1112, built with the live doors (Apple, email,
passkey); providers slot in as they ship.

- composeCard: ONE primary door — the localStorage last-used marker when it
  names a live door, else the platform default (Apple platforms → Apple;
  Android/ChromeOS → Google and Windows → Microsoft, both dark for now so
  they fall through to email-first; unknown → email-first) — with the email
  door always beneath it (unless it IS the primary) and a quiet "Other ways
  to sign in" link. Demoted doors don't render on the main card.
- The all-ways step (#otherways): every door on one card, same screen-swap
  pattern as the email door, each row wired to the SAME handler as the main
  card's version; clearly-commented slots where Google/Microsoft/GitHub join.
- DOORS registry: a dark door ships by flipping one flag + adding its button
  to the commented slot — no restructuring.
- The Apple stage-clear now keys off .loading (not an id), so the pending
  choreography works from either surface; #newhere follows the visible
  sign-in surface, so the passkey-failure flow works from wherever the door
  was invoked; the email door's Back returns to whichever surface opened it.
- Greeter/cookie/reassure rules hold: the all-ways step hides #signed-out
  exactly like the email door does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* home: the card never paints uncomposed — the composing veil

The default markup got a frame before composeCard() rearranged the
doors (the last-used flash). The pre-paint gate now always raises a
composing veil; module init lifts it after composition with a
near-instant reveal. The CSP hash self-heals for the edited gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e3074146e199839b26ba4912f2c95e3602b947cd parent e0c41bb
3 files changed, +241 −40
  • changelog/2026-07-18-sign-in-card-remembers.md +1 −0
  • home/web/pages/index.html +70 −20
  • home/web/static/home.js +170 −20
diff --git a/changelog/2026-07-18-sign-in-card-remembers.md b/changelog/2026-07-18-sign-in-card-remembers.md
new file mode 100644
index 0000000..d32a97a
--- /dev/null
+++ b/changelog/2026-07-18-sign-in-card-remembers.md
@@ -0,0 +1 @@
+🤖 The Eleven home sign-in card now leads with the way you signed in last time — one clear button, with email always right beneath it. On a device that hasn't signed in before, the card picks the door that fits it (Apple devices lead with Apple), and a quiet "Other ways to sign in" link lists every method.
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 598e065..ab2f558 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -10,9 +10,9 @@
<link rel="apple-touch-icon" href="/static/icon-512.png">
<link rel="stylesheet" href="/static/home.css">
<style>
- /* The landing is a single centered card: the Eleven mark, the three doors
- (Apple, email, a quiet passkey link), and a reassuring line. Your spaces
- appear only once you've signed in. */
+ /* The landing is a single centered card: the Eleven mark, the sign-in
+ doors (composed per browser — see composeCard in home.js), and a
+ reassuring line. Your spaces appear only once you've signed in. */
/* Center the card + hint as one group. The shared body is a stretch grid
(fine for the single-card ceremony pages); with two children here it would
otherwise push them to opposite ends of the viewport. */
@@ -27,10 +27,12 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
font-weight: 500; font-size: 26px; letter-spacing: -0.02em;
}
- /* The three doors: Apple and email lead — both find-or-create, one button
- each — and the passkey path demotes to a quiet grey link beneath them.
- The Apple button is Apple's standard black look, hand-built (no Apple JS
- SDK — the CSP pins scripts to self); the hairline keeps it from
+ /* The doors (#1112): home.js composes the card — ONE primary door (the
+ last-used method, else the platform default), the email door beneath it
+ (unless it IS the primary), and the quiet "Other ways to sign in" link
+ to the all-ways step; demoted doors don't render on the main card at
+ all. The Apple button is Apple's standard black look, hand-built (no
+ Apple JS SDK — the CSP pins scripts to self); the hairline keeps it from
dissolving into a dark panel. */
.apple-btn {
display: flex; align-items: center; justify-content: center; gap: 8px;
@@ -38,6 +40,10 @@
border: 1px solid light-dark(#000, rgba(255,255,255,.28));
position: relative; /* anchors the loading ring */
}
+ /* This inline sheet loads after home.css, so .apple-btn's display beats the
+ global .hidden at equal specificity — restate the hide here (the same
+ trap .icon-add.hidden answers below). Demoted doors hide this way. */
+ .apple-btn.hidden { display: none; }
/* A working button wears a ring (Apple hand-over, Send code): the wait
needs a face even when the server is instant. White reads on both the
black and accent buttons — the only wearers. Reduced motion keeps a
@@ -53,11 +59,16 @@
@keyframes btn-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { button.loading::after { animation: none; } }
/* Tapping Apple clears the stage: everything but the spinning button fades
- away, so the wait for the sheet is just the button at work. The smoke
- cover (html.siwa-wait, below with the loading chrome) follows on a
- timer — we can't hear the overlay appear. */
- #signed-out .actions > *, #newhere { transition: opacity .3s ease, transform .3s ease; }
- main.card.apple-pending #signed-out .actions > :not(#apple-signin),
+ away, so the wait for the sheet is just the button at work. The pressed
+ button wears .loading, and the door can be on the main card OR the
+ all-ways step, so the fade is "everything that isn't the working button"
+ on either surface. The smoke cover (html.siwa-wait, below with the
+ loading chrome) follows on a timer — we can't hear the overlay appear. */
+ #signed-out .actions > *, #otherways .actions > *,
+ #otherways .icon-back, #otherways .create-title, #newhere { transition: opacity .3s ease, transform .3s ease; }
+ main.card.apple-pending :is(#signed-out, #otherways) .actions > :not(.loading),
+ main.card.apple-pending #otherways .icon-back,
+ main.card.apple-pending #otherways .create-title,
main.card.apple-pending #newhere {
opacity: 0; transform: translateY(4px); pointer-events: none;
}
@@ -65,9 +76,12 @@
body:has(main.card.apple-pending) .cookie-ask,
body:has(main.card.apple-pending) .demo-card { opacity: 0; pointer-events: none; }
@media (prefers-reduced-motion: reduce) {
- #signed-out .actions > *, #newhere { transition: none; }
+ #signed-out .actions > *, #otherways .actions > *,
+ #otherways .icon-back, #otherways .create-title, #newhere { transition: none; }
}
.apple-btn svg { display: block; flex: none; }
+ /* The quiet in-card link style: the demoted passkey door wears it, and so
+ does the "Other ways to sign in" link (#1112) — one vocabulary. */
.passkey-link {
background: none; border: none; padding: 6px 0; font: inherit;
font-size: 14px; font-weight: 600; color: var(--muted);
@@ -78,7 +92,7 @@
/* The email door, a card step of its own: heading, an address row, the
no-spam note, then the code row once a code is on its way (home.js
drives the two steps). */
- #email-door h2 { margin-bottom: 14px; }
+ #email-door h2, #otherways h2 { margin-bottom: 14px; }
#email-flow { display: flex; flex-direction: column; gap: 8px; }
#email-flow form { display: flex; gap: 8px; }
#email-flow form.hidden { display: none; }
@@ -612,7 +626,11 @@
/* Hide the content (card, its outside-the-card reassurance, and the demo
card) beneath the loader so nothing flashes; all fade back in when
booting drops. */
- html.booting main.card, html.booting .reassure, html.booting .demo-card, html.booting .cookie-ask { opacity: 0; }
+ html.booting main.card, html.booting .reassure, html.booting .demo-card, html.booting .cookie-ask,
+ html.composing main.card, html.composing .reassure, html.composing .demo-card, html.composing .cookie-ask { opacity: 0; }
+ /* The composing lift is quick — a plain visitor shouldn't feel a fade. */
+ html.compose-reveal main.card, html.compose-reveal .reassure,
+ html.compose-reveal .demo-card, html.compose-reveal .cookie-ask { transition-duration: .12s; }
main.card, .reassure, .demo-card, .cookie-ask { transition: opacity .5s ease; }
/* cover-out keeps the chrome displayed (booting has already dropped) so the
fade actually plays; the timeout in finishHomeLoader then parks it. */
@@ -654,6 +672,10 @@
// brand only lands once the sign-in has confirmed. Signed-out visitors
// skip it all; home.js lifts the cover on every path it was raised for.
try {
+ // The card never paints uncomposed: composeCard() decides its doors
+ // (last-used/platform), so the default markup must not get a frame.
+ // home.js lifts this at the end of module init on every path.
+ document.documentElement.classList.add("composing");
if (location.hash.indexOf("#siwa=") === 0)
document.documentElement.classList.add("booting", "siwa-wait");
else if ((sessionStorage.getItem("home_token") && sessionStorage.getItem("home_dek")) ||
@@ -692,15 +714,20 @@
<div class="brand-word">Eleven</div>
</div>
- <!-- The signed-out doors; hidden once you're in (see showVault). Apple and
- email lead — both find-or-create, one button each, no sign-in vs
- sign-up split — and the passkey path is a quiet link beneath them.
- Create-a-passkey appears only after a failed ceremony (#newhere). -->
+ <!-- The signed-out doors; hidden once you're in (see showVault). home.js
+ composes them (#1112): ONE primary door — the last-used method when
+ this browser has one, else the platform default — with the email door
+ beneath it (unless it IS the primary) and the quiet "Other ways to
+ sign in" link to the all-ways step below. Demoted doors don't render
+ here. Apple and email are find-or-create, one button each, no sign-in
+ vs sign-up split. Create-a-passkey appears only after a failed
+ ceremony (#newhere). -->
<div id="signed-out">
- <div class="actions">
+ <div class="actions" id="main-doors">
<button id="apple-signin" class="apple-btn" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Continue with Apple</button>
<button id="email-signin" class="secondary" type="button">Continue with email</button>
<button id="signin" class="passkey-link" type="button">Sign in with a passkey</button>
+ <button id="other-ways" class="passkey-link" type="button">Other ways to sign in</button>
</div>
<!-- Revealed only after a failed or cancelled passkey ceremony — the one
@@ -741,6 +768,29 @@
</div>
</div>
+ <!-- The all-ways step (#1112): every door on one card, reached from the
+ quiet "Other ways to sign in" link. Same screen-swap pattern as the
+ email door (it hides #signed-out, so the greeter/cookie/reassure
+ visibility rules follow along), and each row invokes the SAME handler
+ as the main card's version of that door. -->
+ <div id="otherways" class="hidden screen">
+ <button id="otherways-back" class="icon-back" type="button" aria-label="Back to sign-in"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M10 3 5 8l5 5" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg></button>
+ <h2 class="create-title">Other ways to sign in</h2>
+ <div class="actions">
+ <button id="otherways-apple" class="apple-btn" type="button"><svg viewBox="0 0 814 1000" width="14" height="17" fill="currentColor" aria-hidden="true"><path d="M788.1 340.9c-5.8 4.5-108.2 62.2-108.2 190.5 0 148.4 130.3 200.9 134.2 202.2-.6 3.2-20.7 71.9-68.7 141.9-42.8 61.6-87.5 123.1-155.5 123.1s-85.5-39.5-164-39.5c-76.5 0-103.7 40.8-165.9 40.8s-105.6-57-155.5-127C46.7 790.7 0 663 0 541.8c0-194.4 126.4-297.5 250.8-297.5 66.1 0 121.2 43.4 162.7 43.4 39.5 0 101.1-46 176.3-46 28.5 0 130.9 2.6 198.3 99.2zm-234-181.5c31.1-36.9 53.1-88.1 53.1-139.3 0-7.1-.6-14.3-1.9-20.1-50.6 1.9-110.8 33.7-147.1 75.8-28.5 32.4-55.1 83.6-55.1 135.5 0 7.8 1.3 15.6 1.9 18.1 3.2.6 8.4 1.3 13.6 1.3 45.4 0 102.5-30.4 135.5-71.3z"/></svg>Continue with Apple</button>
+ <!-- Google's door joins here when the provider ships (#1112): flip
+ DOORS.google in home.js and add
+ <button id="otherways-google" class="secondary" type="button">Continue with Google</button> -->
+ <!-- Microsoft's door, same shape: DOORS.microsoft plus
+ <button id="otherways-microsoft" class="secondary" type="button">Continue with Microsoft</button> -->
+ <!-- GitHub's door ("for the nerds"), all-ways only, never a platform
+ default: DOORS.github plus
+ <button id="otherways-github" class="secondary" type="button">Continue with GitHub</button> -->
+ <button id="otherways-email" class="secondary" type="button">Continue with email</button>
+ <button id="otherways-passkey" class="secondary" type="button">Sign in with a passkey</button>
+ </div>
+ </div>
+
<!-- Inline status only — never a floating toast (product-wide rule). -->
<p class="status" id="status"></p>
diff --git a/home/web/static/home.js b/home/web/static/home.js
index 277e459..103ffde 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -1,12 +1,16 @@
-// The home landing page: three doors into the same anonymous account — Apple
-// and email up front (both find-or-create: one button resolves your account or
-// mints it, no sign-in vs sign-up split), and the passkey path as a quiet link
-// beneath them. Create-a-passkey appears only after a failed ceremony (the
-// #newhere explainer — a cancelled prompt and a missing credential are
-// deliberately indistinguishable, NotAllowedError either way, so the failure
-// moment serves both readings). All crypto is client-side (crypto.js); the
-// WebAuthn ceremonies + blob I/O live in backup-core.js. Status renders
-// inline — never a toast.
+// The home landing page: every sign-in method is a door into the same
+// anonymous account, and the card composes itself per browser (#1112): ONE
+// primary door — the last-used method when this browser holds one, else the
+// platform default — with the email door always beneath it (unless it IS the
+// primary) and a quiet "Other ways to sign in" link to the all-ways step,
+// which lists every door. Apple and email are find-or-create (one button
+// resolves your account or mints it, no sign-in vs sign-up split).
+// Create-a-passkey appears only after a failed ceremony (the #newhere
+// explainer — a cancelled prompt and a missing credential are deliberately
+// indistinguishable, NotAllowedError either way, so the failure moment serves
+// both readings). All crypto is client-side (crypto.js); the WebAuthn
+// ceremonies + blob I/O live in backup-core.js. Status renders inline —
+// never a toast.
//
// The identity-vs-key split, made visible: an Apple or email sign-in proves
// who you are (siwa.go / email.go answer with a device link token, exactly
@@ -208,8 +212,9 @@ async function saveVault() {
// door mid-code-round-trip only confuses. The passkey controls additionally
// stay off in a browser with no WebAuthn (the Apple/email doors still work).
function setSignedOutBusy(busy) {
- for (const id of ["apple-signin", "email-signin", "email-send", "email-verify"]) $(id).disabled = busy;
- for (const id of ["signin", "create-passkey", "unlock-passkey"]) {
+ for (const id of ["apple-signin", "email-signin", "email-send", "email-verify",
+ "other-ways", "otherways-apple", "otherways-email"]) $(id).disabled = busy;
+ for (const id of ["signin", "create-passkey", "unlock-passkey", "otherways-passkey"]) {
$(id).disabled = busy || !window.PublicKeyCredential;
}
}
@@ -231,6 +236,72 @@ function everSignedIn() {
try { return !!localStorage.getItem("home_last_signin"); } catch { return false; }
}
+// ---- the card algorithm (#1112) ------------------------------------------------
+
+// Which doors are LIVE on this build. Google, Microsoft, and GitHub are
+// declared dark: the platform mapping below already points at the first two,
+// so each provider ships by flipping its flag here and adding its button to
+// the all-ways step (index.html's commented slots) — no restructuring.
+const DOORS = {
+ apple: true,
+ email: true,
+ passkey: true,
+ google: false, // Android/ChromeOS's platform door, once it ships
+ microsoft: false, // Windows's platform door, once it ships
+ github: false, // all-ways only ("for the nerds") — never a platform default
+};
+
+// platformDefault picks the door for a browser with no last-used marker: the
+// OS vendor's account, since that's the one a person most likely holds.
+// userAgentData.platform where it exists (Chromium); navigator.platform + UA
+// as the fallback (Safari/Firefox — iPadOS reports "MacIntel", which still
+// reads apple). A platform whose door is still dark falls through to
+// email-first until it ships.
+function platformDefault() {
+ const hints = ((navigator.userAgentData && navigator.userAgentData.platform) ||
+ navigator.platform || "") + " " + navigator.userAgent;
+ const door =
+ /Mac|iPhone|iPad|iPod|iOS/i.test(hints) ? "apple" :
+ /Android|CrOS|Chrome OS/i.test(hints) ? "google" :
+ /Windows|Win32|Win64/i.test(hints) ? "microsoft" : // never bare /Win/ — "Darwin" contains it
+ "email";
+ return DOORS[door] ? door : "email";
+}
+
+// primaryDoor: last-used wins, else the platform default. The marker only
+// counts when it names a LIVE door — a value from a newer build (say
+// "google" before this build lights it) must not compose a door that isn't
+// here.
+function primaryDoor() {
+ let last = "";
+ try { last = localStorage.getItem("home_last_signin") || ""; } catch { /* no storage — platform default */ }
+ return DOORS[last] ? last : platformDefault();
+}
+
+// composeCard arranges the main card: ONE primary door, the email door
+// always beneath it (unless email IS the primary — the email alternative is
+// always available), then the quiet "Other ways to sign in" link. Demoted
+// doors don't render here at all — they live in the all-ways step. Runs at
+// boot and again on sign-out (the marker survives it and may have moved).
+function composeCard() {
+ const primary = primaryDoor();
+ const apple = $("apple-signin"), email = $("email-signin"), passkey = $("signin");
+ apple.classList.toggle("hidden", primary !== "apple");
+ // Promotions restyle in place: email sheds .secondary when it leads, and
+ // the passkey door trades its grey link for a proper primary button.
+ // Wholesale className is safe — these two never carry other classes.
+ email.className = primary === "email" ? "" : "secondary";
+ passkey.className = primary === "passkey" ? "" : "passkey-link hidden";
+ // DOM order is focus order: the primary leads, then the email alternative,
+ // then the link. append MOVES the existing (already-wired) nodes; hidden
+ // doors stay parked ahead of them, unfocusable.
+ $("main-doors").append(
+ { apple, email, passkey }[primary],
+ ...(primary === "email" ? [] : [email]),
+ $("other-ways"),
+ );
+}
+
async function signIn() {
setSignedOutBusy(true);
setStatus("Confirming it’s you…", "busy");
@@ -347,24 +418,30 @@ function releaseSiwaCover() {
// cancelled sheet never navigates, so any later input — or a bfcache
// return — releases the whole arrangement, cover included.
let applePendingTimer = 0;
+let applePendingBtn = null; // whichever Apple door was pressed (main card or all-ways)
function releaseApplePending() {
clearTimeout(applePendingTimer);
releaseSiwaCover();
document.querySelector("main.card").classList.remove("apple-pending");
- const b = $("apple-signin");
- b.disabled = false;
- b.classList.remove("loading");
+ if (applePendingBtn) {
+ applePendingBtn.disabled = false;
+ applePendingBtn.classList.remove("loading");
+ applePendingBtn = null;
+ }
window.removeEventListener("pointerdown", releaseApplePending, true);
window.removeEventListener("keydown", releaseApplePending, true);
}
-function startAppleSignIn() {
+function startAppleSignIn(e) {
// Tap: the button spins and the REST of the form fades away — the wait
// for Apple's sheet is just the button at work. By 1.6s the sheet is up,
// so the smoke cover takes over then; post-fingerprint there is nothing
// left to flash. pagehide backstops fast confirms; pointer/key input or a
- // bfcache return releases a cancelled arrangement.
- const appleBtn = $("apple-signin");
+ // bfcache return releases a cancelled arrangement. The main card's door
+ // and the all-ways step's are the same handler — the stage-clear CSS keys
+ // off .loading, not an id, so it clears whichever surface is up.
+ const appleBtn = e.currentTarget;
+ applePendingBtn = appleBtn;
appleBtn.disabled = true;
appleBtn.classList.add("loading");
document.querySelector("main.card").classList.add("apple-pending");
@@ -425,9 +502,14 @@ async function finishSiwa(idToken) {
// The email door (email.go): type your address, get a 6-digit code, type it —
// find-or-create like Apple. The address goes only into the two request
// bodies; nothing here stores it. It's a card step of its own — the doors
-// swap out for it, same screen-swap pattern as the signed-in screens.
+// swap out for it, same screen-swap pattern as the signed-in screens. It can
+// be entered from the main card or the all-ways step; Back returns to
+// whichever surface opened it.
+let emailDoorFrom = "signed-out";
function openEmailDoor() {
+ emailDoorFrom = $("otherways").classList.contains("hidden") ? "signed-out" : "otherways";
$("signed-out").classList.add("hidden");
+ $("otherways").classList.add("hidden");
$("reassure").classList.add("hidden");
$("email-door").classList.remove("hidden");
setStatus("");
@@ -448,8 +530,55 @@ function resetEmailDoor() {
function closeEmailDoor() {
resetEmailDoor();
setStatus("");
+ if (emailDoorFrom === "otherways") {
+ // Back to the all-ways step; #signed-out stays hidden, so the reassure/
+ // cookie/greeter visibility rules stay as the step left them.
+ $("otherways").classList.remove("hidden");
+ return;
+ }
+ $("signed-out").classList.remove("hidden");
+ $("reassure").classList.remove("hidden");
+}
+
+// ---- the all-ways step (#1112) --------------------------------------------------
+
+// openOtherWays swaps the card to the every-door step — the same screen-swap
+// pattern as the email door (hiding #signed-out is what keeps the greeter/
+// cookie/reassure rules honest). Focus lands on the first door; Back hands
+// it back to the link.
+function openOtherWays() {
+ $("signed-out").classList.add("hidden");
+ $("reassure").classList.add("hidden");
+ // The #newhere explainer follows the visible sign-in surface (revealNewhere).
+ if (!$("newhere").classList.contains("hidden")) $("otherways").append($("newhere"));
+ $("otherways").classList.remove("hidden");
+ setStatus("");
+ $("otherways-apple").focus();
+}
+
+function closeOtherWays() {
+ resetOtherWays();
+ setStatus("");
$("signed-out").classList.remove("hidden");
$("reassure").classList.remove("hidden");
+ $("other-ways").focus();
+}
+
+// resetOtherWays parks the all-ways step and re-homes the #newhere explainer
+// to its markup home — shared with the arrival paths (showVault and friends),
+// so signing in from the step never strands either piece.
+function resetOtherWays() {
+ $("otherways").classList.add("hidden");
+ $("signed-out").append($("newhere"));
+}
+
+// revealNewhere shows the after-failure explainer on whichever sign-in
+// surface is up — the passkey door sits on the main card only when promoted,
+// so most ceremonies (and their failures) happen inside the all-ways step.
+function revealNewhere() {
+ ($("otherways").classList.contains("hidden") ? $("signed-out") : $("otherways"))
+ .append($("newhere"));
+ $("newhere").classList.remove("hidden");
}
async function sendEmailCode(e) {
@@ -547,6 +676,7 @@ function showSealedVault() {
$("signed-out").classList.add("hidden");
$("reassure").classList.add("hidden");
resetEmailDoor(); // arriving from the email door hides it
+ resetOtherWays(); // …and from the all-ways step likewise
$("vault").classList.remove("hidden");
$("open-create").classList.add("hidden");
$("space-search").classList.add("hidden");
@@ -582,6 +712,8 @@ function signOut() {
$("signed-out").classList.remove("hidden");
$("newhere").classList.add("hidden"); // fresh signed-out state, no stale explainer
resetEmailDoor(); // back to the doors, address step ready for next time
+ resetOtherWays();
+ composeCard(); // the last-used marker survives sign-out and may have moved the primary
$("reassure").classList.remove("hidden");
setSignedOutBusy(false);
setStatus("Signed out.", "");
@@ -659,6 +791,7 @@ function showVault() {
$("signed-out").classList.add("hidden");
$("reassure").classList.add("hidden");
resetEmailDoor(); // arriving from the email door hides it
+ resetOtherWays(); // …and from the all-ways step likewise
$("create").classList.add("hidden");
$("archive").classList.add("hidden");
$("vault-sealed").classList.add("hidden");
@@ -1895,9 +2028,10 @@ const onSignInError = (err) => {
// ANY failure or cancel reveals #newhere, the one place the create path
// lives now: a cancelled prompt and a missing credential are deliberately
// indistinguishable (NotAllowedError either way), so the explainer serves
- // both readings and only then offers "Create your passkey".
+ // both readings and only then offers "Create your passkey". It shows on
+ // whichever surface ran the ceremony (main card or all-ways).
setStatus(formatCeremonyError(err, "signin"), "error");
- $("newhere").classList.remove("hidden");
+ revealNewhere();
prefetchRegister(); // ready the create ceremony in case they're new
setSignedOutBusy(false);
prefetchLogin(); // options were spent — refresh for a retry
@@ -1923,6 +2057,7 @@ if (window.PublicKeyCredential) {
prefetchLogin();
prefetchRegister();
}
+composeCard(); // the #1112 card: one primary door, email beneath, the other-ways link
setSignedOutBusy(false); // paints the no-WebAuthn disable onto the passkey controls
$("signin").addEventListener("click", () => signIn().catch(onSignInError));
$("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError));
@@ -1945,6 +2080,13 @@ $("email-signin").addEventListener("click", openEmailDoor);
$("email-back").addEventListener("click", closeEmailDoor);
$("email-form").addEventListener("submit", sendEmailCode);
$("email-code-form").addEventListener("submit", verifyEmailCode);
+// The all-ways step: each door invokes the SAME handler as the main card's
+// version — the step is another surface, never another code path.
+$("other-ways").addEventListener("click", openOtherWays);
+$("otherways-back").addEventListener("click", closeOtherWays);
+$("otherways-apple").addEventListener("click", startAppleSignIn);
+$("otherways-email").addEventListener("click", openEmailDoor);
+$("otherways-passkey").addEventListener("click", () => signIn().catch(onSignInError));
// The SiwA web round trip lands back here as #siwa=<id_token>&state=web.<r>
// (siwa.go's trampoline — the fragment never reaches the server). The state
@@ -2016,6 +2158,14 @@ apiJSON("/api/features")
// of the page, for everyone — started once, left running.
startHomeLoader();
+// The composing veil (raised pre-paint by the inline gate) lifts only now —
+// composeCard() has run, so the card's first visible frame is its final
+// composition. compose-reveal keeps the lift near-instant.
+document.documentElement.classList.add("compose-reveal");
+document.documentElement.classList.remove("composing");
+requestAnimationFrame(() => requestAnimationFrame(() =>
+ document.documentElement.classList.remove("compose-reveal")));
+
// Rehydrate a same-tab session (e.g. a reload): the DEK and token are still in
// sessionStorage, so the vault can reopen without a ceremony. The loading chrome
// (the Eleven lockup + tagline, already up pre-paint via html.booting) hides the