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