eleven / messenger Public

home: the three-door sign-in — the card, the journey, and staying signed in (#1122)

* home: Sign in with Apple — the identity-anchor front door (#1089)

The one verifier for every platform: POST /api/siwa/verify checks
Apple's identity token (hand-rolled RS256 + JWKS cache, the apns.go
style — no new deps), keys accounts on HMAC(pepper, sub), and answers
exactly like finishCeremony (link token + ceremony proof). link:true +
X-Ceremony-Proof anchors an Apple ID to an existing account (same
freshness bar as adding a passkey; one account per Apple ID, 409 on
conflict). POST /siwa/return is the web flow's form_post trampoline —
bounces the token to the app over the custom scheme, verifying nothing,
so there is exactly one verifier. siwa/<subHash> anchors land in both
stores (SQLite table + S3 pointer/doc pair). Feature is off unless BOTH
LCHAT_HOME_SIWA_CLIENT_IDS and LCHAT_HOME_SIWA_PEPPER are set — the
pepper names accounts, so no ephemeral fallback exists by design.

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

* home: the email anchor — sign in with a typed code, sent via SES (#1091)

Email joins SiwA as an identity anchor, mirroring it exactly: verify
answers like finishCeremony (link token + proof), link:true attaches to
an existing account behind the same ceremony-proof bar (proof gate runs
BEFORE the redeem so a missing proof never burns a code), one address
per account. The store only ever holds the blind index —
HMAC(pepper, lowercase(address)) — never an address; mail goes to what
the person typed. Codes: in-memory, 15-min, single-use, attempt-capped,
silent 30s resend gap; the request endpoint answers identically for
known and unknown addresses (no enumeration — unknown ones create the
account at verify, the same seamless front door). SES v2 via the
existing SigV4 signer — no SDK, no new deps. Off unless
LCHAT_HOME_EMAIL_FROM + LCHAT_HOME_SES_REGION + LCHAT_HOME_EMAIL_PEPPER
are all set.

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

* home: three-door sign-in card — Apple + email lead, passkey demotes to a link

Restructures the home landing around the identity anchors: "Continue with
Apple" (full-page authorize navigate, web.-state bounced back to the page by
the /siwa/return trampoline) and "Continue with email" (inline address → code
→ session) are the top two doors, both find-or-create with no sign-in vs
sign-up split; the passkey path becomes a quiet grey link, and create-a-passkey
appears only after a failed ceremony. Signed-in-with-zero-spaces is now a
success state (empty list + the create affordance), and an anchor sign-in over
an existing sealed vault lands on a quiet "Unlock your spaces with your
passkey" prompt — the identity-vs-key split made visible.

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

* home: the email door becomes its own card step

Paul's live-test feedback: "Continue with email" revealing a field under
itself read redundant. Tapping it now swaps the doors out for a dedicated
email step — "Sign in with email" heading, the address → code forms and the
no-spam note, with the standard back caret returning to the doors — the same
screen-swap pattern the signed-in screens use.

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

* home: the demo offer moves out of the sign-in card into its own

Paul's feedback: "Just looking? / Try a demo space" looked like part of
sign-in and isn't. It now lives in its own quieter card — same card
vocabulary, ids and copy unchanged: a full-width card below the sign-in card
on narrow screens, and from 960px an offset flap tucked beside it (absolute
against the body, its inner 16px sliding under the main card's edge). A
body:has rule keeps it following the doors — any state that hides them
(signed in, the email step) hides the demo card too, so the JS toggle stays
the feature flag's alone.

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

* home: the demo card leans into the sales-assistant read

Paul's feedback on the companion card ("like a little sales assistant
peeking out a shop door"): the desktop flap now tilts (-3deg, pivoting at
the edge tucked under the main card; static, so no reduced-motion concern —
the stacked narrow card stays straight), a 👋 joins the heading, and the
card gets its own tint — an 8% brand-purple dilution of the panel via
color-mix (light #f1f3fe / dark #252937; muted text ≥5.6:1 in both themes).
The card is also newcomers-only now: any successful sign-in (passkey
finish, register finish, email verify, SiwA verify) records the #1112
last-used marker (localStorage home_last_signin = "passkey" | "email" |
"apple"; sign-out deliberately leaves it), and the demo card only renders
while that marker is absent.

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

* home: the greeter leans the other way

* home: the greeter's button earns its outline on the tinted card

* home: the shopkeeper pops their head out — delayed peek animation

Desktop: the flap slides out from under the main card 1.4s after
reveal with a springy overshoot; narrow: a gentle fade-rise on the same
beat. Reduced motion: no pop, the card is simply present.

* home: the shopkeeper starts fully behind the door

* home: the shopkeeper pivots from their feet, below the fold

* home: code step in place, Apple button loader, the anti-cookie-banner

The email door's code step replaces the address step instead of stacking
under it; Continue with Apple shows a loading ring the moment the page
hands over (bfcache-safe — Back un-sticks it); and a quiet 'Where's the
cookie banner?' link answers with the truth in a dialog: there are no
cookies, no trackers, nothing wanted.

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

* home: Send code earns its second — shared button loader with a spin floor

* home: the cookie question belongs to the sign-in screen only

* home: the reassure line covers the email door — we don't store your email

* home: the card holds its breath through the Apple round trip

Tap → the whole card enters a held loading state (Safari keeps the page
visible under Apple's overlay), the return reload re-holds it before
first paint, and it releases only when the outcome — spaces, sealed
vault, or the error — has painted. No clean-card flash mid-sign-in.

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

* home: button-only on tap; the card hold starts when the flow proceeds

* home: the hold outlives the landing fade — spinner to finished spaces, one frame

* home: the Apple tap clears the stage — fade the form, then the full hold

Tap: only the spinning button remains (the rest fades); 1.6s later the
full card hold takes over, so the fingerprint moment has nothing left to
flash. Cancelled sheets release via any input or bfcache return.

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

* home: the Apple return engages the pre-paint cover — the first frame is the loader

The #siwa= landing now trips the same inline pre-paint gate as a
rehydrating session, so the raw sign-in card never gets its one
pre-module frame (the last flash). The siwa path lifts the cover once
the spaces have painted beneath it.

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

* home: the pre-paint gate finally RUNS — CSP hash-allows inline scripts

The landing CSP's hardcoded script-src 'self' silently discarded the
inline boot gate (added after the 'no inline scripts' comment was true),
so the cover never engaged pre-paint — the unfixable-looking sign-in
flash. The allowance is now computed from the served bytes at boot
('sha256-…' per inline block), so an edited gate can never drift from
its hash. Pinned by TestLandingCSPAllowsInlineGate.

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

* home: the Apple journey gets a waiting face — smoke + ring, brand on arrival

Paul's choreography for the Apple round trip, replacing the card-hold
spinner on this path. Tap side keeps the stage-clear + spinning button; at
the 1.6s timer (and on pagehide) the SMOKE cover engages instead — the
ambient boot canvas comes to full, the card fades beneath it, and a ring
holds the lockup's spot (html.siwa-wait; a pure opacity crossfade, nothing
heavy starts at tap time). A cancelled sheet releases it all as before.
The return leg's pre-paint gate engages the same face from frame zero.
Verify success earns the arrival: the ring gives way to the Eleven lockup
(~400ms fade, .brand-in), a ~450ms dwell, then the cover lifts while the
spaces screen slides up into place (#vault.arrive, 350ms ease-out). Failure
or a state mismatch lifts straight to the signed-out card + the error line —
no brand moment for a sign-in that didn't happen. Reduced motion: no dwell,
no slide, static ring. finishHomeLoader gains { immediate } for callers
that ran their own choreography, and now parks the cover chrome instead of
removing it (the tap side re-engages it later); the cardHold/holding
machinery is gone — the cover is the one system.

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

* home: stay signed in (Option A) — identity persists, keys never do

Paul's decision: the link token now survives the tab. Every successful
sign-in (passkey login AND register, email verify, SiwA verify) persists
home_session (JSON {token, method}) + home_session_expires (plain epoch-ms,
a separate key so the inline pre-paint gate can check it without JSON) with
a 30-day ROLLING expiry — a boot that accepts the session re-persists it.
The DEK is never persisted: a cold open adopts the token and lands exactly
like an anchor sign-in via enterAnchorSession — sealed prompt when a blob
exists, empty list when not — so the passkey ceremony stays the only way to
plaintext. A 401 (revoked token) clears the persisted session and falls to
the doors; a flaky network doesn't. The gate raises the regular booting
cover (not siwa-wait) for a live persisted session, and every path lifts it.
Sign-out clears both keys; the #1112 marker deliberately survives.

Also folds in Paul's after-flash fix: removing #vault.arrive on a timer
while the vault was visible swapped the computed animation-name back to
.screen's and RESTARTED the entry fade. The class now comes off only in the
new hideVault() (every screen-swap/hide path), under display:none where
it's free; no other path strips animation classes from a visible element
(brand-in/cover-out come off in the same recalc that parks the chrome).

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

* home: session refreshes skip the pageantry — cover chrome earns its 500ms

Paul: the loading screen is right for the sign-in journey, too long for a
refresh. On the non-siwa booting paths the cover still BLOCKS from frame
zero (the doors-flash protection is untouched), but its visible chrome —
the lockup + tagline and the smoke's full ramp — now holds for 500ms
before appearing, pure CSS (cover-wait animation with both fill; a
transition-delay on the canvas). finishHomeLoader learns the fast path: a
lift while the page is younger than the chrome delay means nothing
pageant-like was ever seen, so it skips the minimum dwell and the exit
fade — html.boot-fast trims the content fade to an imperceptible 80ms and
comes off right after, so the stage-clear fades keep their timing. A load
that genuinely exceeds 500ms keeps today's graceful minimum + lift. The
siwa journey face (html.siwa-wait) keeps its frame-zero chrome and its
choreography, and the delay is opacity-only so reduced motion is unchanged.

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

* home: the account kebab (#1098) — Sign out moves into the vault header menu

The signed-in view gains issue #1098's single control: a quiet kebab beside
the + in the vault header (30px round target, labelled "Account menu",
aria-haspopup/expanded) opening a card-vocabulary popup menu — right-aligned
under it so it never clips the card edge. Sign out is its first and only
item (keeping id="signout", so the wiring holds); "How you sign in" and
account-merge join it later. Focus lands on the first item on open and
returns to the kebab on Escape or the toggle; click-outside closes without
yanking focus; hideVault closes it on any screen swap. The big bottom
Sign-out pill is gone, and the menu serves the sealed state too.

Also in this batch, two of Paul's calls: the + hides unless the decrypted
vault actually holds a space (an empty account's + only invited the
server's 403 dead-end — sealed and empty states both hide it; the kebab
stays), and the email code step's Sign in button gets the Send-code
treatment with a longer floor — a 1200ms spin the verify races, so the
signed-in landing never swaps in before the moment registers.

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

* home: the kebab takes the card's corner; .hidden actually hides the +

The + showed on empty lists because the inline sheet's .icon-add display
beat home.css's .hidden at equal specificity — restated like
.menu-pop.hidden. The kebab moves out of the header row to the card's
top-right (account scope); the + keeps the row's end slot.

* home: the menu's focus ring is a keyboard courtesy — pointer opens get the wash

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 6f58816ae5ceea38168f232e7ba1e9b0637f9a9c parent 3fa60fc
7 files changed, +956 −112
  • home/home_test.go +17 −0
  • home/main.go +40 −8
  • home/siwa.go +9 −0
  • home/siwa_test.go +30 −0
  • home/web/pages/index.html +335 −41
  • home/web/static/bootscene.js +28 −7
  • home/web/static/home.js +497 −56
diff --git a/home/home_test.go b/home/home_test.go
index f79b0cc..77396de 100644
--- a/home/home_test.go
+++ b/home/home_test.go
@@ -534,3 +534,20 @@ func TestInfrastructureRejectsPathTraversal(t *testing.T) {
t.Fatalf("GET /infrastructure/../../../../etc/passwd: want non-200, got 200")
}
}
+
+// The landing page's inline pre-paint gate must be allowed by the CSP — a
+// hardcoded script-src silently discarded it for months (the sign-in flash).
+func TestLandingCSPAllowsInlineGate(t *testing.T) {
+ ts, s := newTestServer(t)
+ if !strings.Contains(string(s.page), "<script>") {
+ t.Skip("no inline scripts on the page")
+ }
+ resp := do(t, "GET", ts.URL+"/", "", nil, nil)
+ csp := resp.Header.Get("Content-Security-Policy")
+ if !strings.Contains(csp, "'sha256-") {
+ t.Fatalf("CSP has no inline-script hash: %q", csp)
+ }
+ if s.pageHashes == "" {
+ t.Fatalf("pageHashes empty despite inline script")
+ }
+}
diff --git a/home/main.go b/home/main.go
index 48115e6..6963cc0 100644
--- a/home/main.go
+++ b/home/main.go
@@ -7,7 +7,10 @@
package main
import (
+ "bytes"
+ "crypto/sha256"
"embed"
+ "encoding/base64"
"encoding/json"
"errors"
"flag"
@@ -87,6 +90,7 @@ type Server struct {
// used to build the checkout return URLs.
origin string
page []byte
+ pageHashes string // 'sha256-…' CSP sources for the page's inline scripts
enrollPage []byte
restorePage []byte
// enrol remembers recently-completed enrolments by nonce (enrolstatus.go)
@@ -301,11 +305,38 @@ func newServer(store vaultStore, origin string, stateSecret []byte) (*Server, er
keys: keys,
limiter: newRateLimiter(10, 10.0/60), // burst of 10, refills 10/minute
page: page,
+ pageHashes: inlineScriptHashes(page),
origin: strings.TrimRight(origin, "/"),
enrol: newEnrolMarks(),
}, nil
}
+// inlineScriptHashes renders 'sha256-…' CSP sources for every inline
+// <script> on a page. The landing page's pre-paint boot gate is inline BY
+// NECESSITY (it must run before first paint), and a hash allowance computed
+// from the served bytes can never drift from an edited script — the previous
+// hardcoded "no inline scripts" CSP silently discarded the gate for months,
+// which surfaced as an unfixable-looking sign-in flash (2026-07-18).
+func inlineScriptHashes(page []byte) string {
+ var out strings.Builder
+ rest := page
+ for {
+ i := bytes.Index(rest, []byte("<script>"))
+ if i < 0 {
+ break
+ }
+ rest = rest[i+len("<script>"):]
+ j := bytes.Index(rest, []byte("</script>"))
+ if j < 0 {
+ break
+ }
+ sum := sha256.Sum256(rest[:j])
+ out.WriteString(" 'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'")
+ rest = rest[j:]
+ }
+ return out.String()
+}
+
// handler wires up all routes. It's a method so tests can exercise the full
// router via httptest without starting a real listener.
func (s *Server) handler() http.Handler {
@@ -444,15 +475,16 @@ func (s *Server) servePage(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- // No inline scripts on this page, so no nonce needed — everything the CSP
- // allows is our own embedded modules and styles. connect-src admits https:
- // beyond 'self' for exactly one client-side move: the last-admin check asks a
- // saved space's own /api/me before allowing "Delete your access" (home.js
- // checkSoleAdmin). Space hosts are dynamic, so they can't be allowlisted by
- // name — and the fetch happening in the browser is the point: this server
- // stays blind to which spaces a user holds.
+ // The page's one inline script — the pre-paint boot gate — is allowed by
+ // its content hash (pageHashes, computed from the served bytes at boot).
+ // connect-src admits https: beyond 'self' for exactly one client-side
+ // move: the last-admin check asks a saved space's own /api/me before
+ // allowing "Delete your access" (home.js checkSoleAdmin). Space hosts are
+ // dynamic, so they can't be allowlisted by name — and the fetch happening
+ // in the browser is the point: this server stays blind to which spaces a
+ // user holds.
w.Header().Set("Content-Security-Policy",
- "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "+
+ "default-src 'self'; script-src 'self'"+s.pageHashes+"; style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:; connect-src 'self' https:; "+
"object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
w.Header().Set("Cache-Control", "no-cache") // pages must never outlive a deploy
diff --git a/home/siwa.go b/home/siwa.go
index 3c166fa..4a62ea7 100644
--- a/home/siwa.go
+++ b/home/siwa.go
@@ -305,6 +305,15 @@ func (s *Server) siwaReturn(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
+ // A "web."-prefixed state was minted by the home page itself (home.js):
+ // bounce back to the page — a relative redirect, same origin — and it
+ // picks the fragment up on load. Every other state is a native app's
+ // ASWebAuthenticationSession, which intercepts the custom scheme.
+ if strings.HasPrefix(state, "web.") {
+ http.Redirect(w, r, "/#siwa="+url.QueryEscape(idToken)+
+ "&state="+url.QueryEscape(state), http.StatusSeeOther)
+ return
+ }
http.Redirect(w, r, siwaReturnScheme+"#state="+url.QueryEscape(state)+
"&id_token="+url.QueryEscape(idToken), http.StatusSeeOther)
}
diff --git a/home/siwa_test.go b/home/siwa_test.go
index 04fc8a5..f5a263e 100644
--- a/home/siwa_test.go
+++ b/home/siwa_test.go
@@ -246,3 +246,33 @@ func TestSiwaReturnTrampoline(t *testing.T) {
t.Fatalf("trampoline location: got %q, want %q", loc, want)
}
}
+
+func TestSiwaReturnWebState(t *testing.T) {
+ ts, _, _ := siwaServer(t)
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
+ return http.ErrUseLastResponse
+ }}
+ // A "web."-prefixed state (minted by the home page) bounces back to the
+ // page itself — a relative, same-origin redirect with the token in the
+ // fragment.
+ resp, err := client.PostForm(ts.URL+"/siwa/return", map[string][]string{
+ "id_token": {"tok.en.value"}, "state": {"web.abc123"}})
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ if resp.StatusCode != http.StatusSeeOther {
+ t.Fatalf("web trampoline: got %d, want 303", resp.StatusCode)
+ }
+ if loc, want := resp.Header.Get("Location"), "/#siwa=tok.en.value&state=web.abc123"; loc != want {
+ t.Fatalf("web trampoline location: got %q, want %q", loc, want)
+ }
+ // Any other state keeps the native apps' custom-scheme bounce.
+ resp, err = client.PostForm(ts.URL+"/siwa/return", map[string][]string{
+ "id_token": {"tok.en.value"}, "state": {"native-state"}})
+ if err != nil {
+ t.Fatalf("post: %v", err)
+ }
+ if loc, want := resp.Header.Get("Location"), siwaReturnScheme+"#state=native-state&id_token=tok.en.value"; loc != want {
+ t.Fatalf("native trampoline location: got %q, want %q", loc, want)
+ }
+}
diff --git a/home/web/pages/index.html b/home/web/pages/index.html
index 846e714..598e065 100644
--- a/home/web/pages/index.html
+++ b/home/web/pages/index.html
@@ -10,8 +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 two sign-in
- paths, and a reassuring line. Your spaces appear only once you've signed in. */
+ /* 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. */
/* 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. */
@@ -26,20 +27,166 @@
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
font-weight: 500; font-size: 26px; letter-spacing: -0.02em;
}
- #signin { width: 100%; }
+ /* 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
+ dissolving into a dark panel. */
+ .apple-btn {
+ display: flex; align-items: center; justify-content: center; gap: 8px;
+ background: #000; color: #fff;
+ border: 1px solid light-dark(#000, rgba(255,255,255,.28));
+ position: relative; /* anchors the loading ring */
+ }
+ /* 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
+ static ring (progress must still be visible). */
+ button.loading { color: transparent; position: relative; }
+ button.loading svg { opacity: 0; }
+ button.loading::after {
+ content: ""; position: absolute; inset: 0; margin: auto;
+ width: 18px; height: 18px; border-radius: 50%;
+ border: 2px solid rgba(255,255,255,.35); border-top-color: #fff;
+ animation: btn-spin .8s linear infinite;
+ }
+ @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),
+ main.card.apple-pending #newhere {
+ opacity: 0; transform: translateY(4px); pointer-events: none;
+ }
+ body:has(main.card.apple-pending) .reassure,
+ 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; }
+ }
+ .apple-btn svg { display: block; flex: none; }
+ .passkey-link {
+ background: none; border: none; padding: 6px 0; font: inherit;
+ font-size: 14px; font-weight: 600; color: var(--muted);
+ cursor: pointer; text-decoration: underline;
+ }
+ .passkey-link:disabled { opacity: .5; cursor: default; }
+ .passkey-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
+ /* 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-flow { display: flex; flex-direction: column; gap: 8px; }
+ #email-flow form { display: flex; gap: 8px; }
+ #email-flow form.hidden { display: none; }
+ #email-flow input { flex: 1; min-width: 0; }
+ #email-flow button { flex: none; }
+ .anchor-note { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.4; }
/* The after-failure explainer: a quiet inset, left-aligned so the longer copy
- reads as a message rather than more hero text. Reinforcement only — both
- action paths already sit above it, up front. */
+ reads as a message rather than more hero text. It also hosts the ONLY
+ create path — full-width like the doors above it. */
#newhere {
margin: 14px 0 0; padding: 14px; border-radius: 14px;
background: var(--quiet); text-align: left; font-size: 14px; line-height: 1.45;
}
#newhere p { margin: 0; }
+ #newhere .path-divider { margin: 12px 0 10px; }
+ #newhere button { width: 100%; }
+ /* The sealed-list prompt (an email/Apple sign-in that can't decrypt yet). */
+ #vault-sealed { margin-top: 6px; }
+ #vault-sealed button { width: 100%; }
/* The reassurance sits OUTSIDE the card, under it. */
+ .cookie-ask { text-align: center; margin: 8px 0 0; }
+ .cookie-ask button {
+ background: none; border: none; padding: 4px 8px; cursor: pointer;
+ color: var(--muted); font-size: 13px; text-decoration: underline;
+ }
+ #cookie-dialog {
+ border: none; border-radius: 22px; padding: 26px 28px; max-width: 380px;
+ background: var(--panel); color: inherit; text-align: center;
+ box-shadow: 0 18px 50px var(--shadow);
+ }
+ #cookie-dialog::backdrop { background: rgba(0, 0, 0, .35); }
+ #cookie-dialog h2 { margin: 0 0 10px; font-size: 20px; }
+ #cookie-dialog p { margin: 0 0 10px; line-height: 1.45; }
+ #cookie-dialog .cookie-small { color: var(--muted); font-size: 13px; }
+ #cookie-close { margin-top: 6px; }
.reassure {
max-width: 360px; text-align: center; margin: 16px auto 0;
color: var(--muted); font-size: 13px; line-height: 1.45;
}
+ /* The demo offer's own card — it isn't a sign-in, so it leaves the sign-in
+ card entirely: a little sales assistant peeking out a shop door. Same
+ card vocabulary but tinted toward the brand purple (an 8% dilution —
+ muted text holds ≥5.6:1 on it in both themes, comfortably AA). Narrow
+ screens stack it below as a full-width card, kept straight; wide ones
+ tuck it beside the main card as a tilted flap (absolute against the
+ body, its inner edge sliding under the card — main.card's z-index keeps
+ the card on top; the tilt is static, so no reduced-motion concern). It
+ follows the doors: any state that hides them (signed in, the email
+ step) hides it too — CSS-only, so the JS toggle stays the feature
+ flag's + the newcomer gate's alone. */
+ body.landing { position: relative; }
+ .demo-card {
+ background: color-mix(in srgb, var(--accent) 8%, var(--panel));
+ border: 1px solid color-mix(in srgb, var(--accent) 22%, var(--border));
+ border-radius: 18px;
+ padding: 18px;
+ width: 100%;
+ max-width: 420px;
+ box-shadow: 0 8px 24px var(--shadow);
+ margin: 14px auto 0;
+ text-align: center;
+ }
+ /* The shopkeeper pops their head out a beat after the door opens: the
+ card animates from tucked-away to its resting peek, delayed so the
+ main card registers first. Keyframes animate the FULL transform (the
+ resting pose differs per layout). Reduced motion: no pop, the card is
+ simply there. */
+ .demo-card:not(.hidden) {
+ animation: demo-rise 480ms cubic-bezier(.34,1.56,.64,1) 1400ms both;
+ }
+ @keyframes demo-rise {
+ from { opacity: 0; transform: translateY(10px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @media (prefers-reduced-motion: reduce) {
+ .demo-card:not(.hidden) { animation: none; }
+ }
+ /* The button must read as a BUTTON on the tinted card — plain --quiet
+ nearly matches the lavender and the pill vanished (Paul's catch). */
+ .demo-card #try-demo {
+ background: var(--panel);
+ border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--border));
+ box-shadow: 0 1px 4px var(--shadow);
+ }
+ body:has(#signed-out.hidden) .demo-card { display: none; }
+ /* The cookie question belongs to the sign-in screen only — it follows the
+ doors, exactly like the reassure line beside it. */
+ body:has(#signed-out.hidden) .cookie-ask { display: none; }
+ @media (min-width: 960px) {
+ .demo-card {
+ position: absolute; left: calc(50% + 170px); top: 50%;
+ width: 252px; margin: 0;
+ transform: translateY(-50%) rotate(3deg);
+ /* The pivot is the shopkeeper's FEET, well below the fold — the lean
+ arcs from a body planted off-screen, not a hinge at the card edge.
+ The far origin makes rotate(3deg) throw the card ~25px rightward,
+ so `left` is trimmed to land the same resting tuck as before. */
+ transform-origin: left calc(100% + 50vh);
+ z-index: 0; /* the 16px overlap tucks behind the main card's edge */
+ }
+ .demo-card:not(.hidden) {
+ animation: demo-peek 560ms cubic-bezier(.34,1.56,.64,1) 1400ms both;
+ }
+ @keyframes demo-peek {
+ from { opacity: 1; transform: translateY(-50%) translateX(-240px) rotate(0deg); }
+ to { opacity: 1; transform: translateY(-50%) translateX(0) rotate(3deg); }
+ }
+ }
.linkish {
background: none; border: none; padding: 0; font: inherit; font-weight: 600;
color: var(--accent); cursor: pointer; text-decoration: underline;
@@ -51,6 +198,9 @@
.screen { animation: screen-in .18s ease; }
@media (prefers-reduced-motion: reduce) { .screen { animation: none; } }
.vault-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; margin-bottom: 6px; }
+ /* Three children now (heading, +, kebab): all the free space goes to the
+ heading's auto margin, so the two controls sit together on the right. */
+ .vault-head h2 { margin-right: auto; }
#vault h2 { font-size: 19px; font-weight: 400; margin: 0; font-family: var(--font-display); letter-spacing: -0.01em; }
/* The + that starts a new space, inline with the heading. */
.icon-add {
@@ -59,9 +209,52 @@
background: var(--accent); color: var(--accent-ink);
}
.icon-add svg { display: block; }
+ /* This inline sheet loads after home.css, so its .icon-add display beats
+ the global .hidden at equal specificity — restate the hide here (the
+ same trap .menu-pop.hidden answers below). The + showed on empty lists
+ for exactly this reason (Paul's catch). */
+ .icon-add.hidden { display: none; }
.icon-add:hover { filter: brightness(1.06); }
.icon-add:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
- /* Low-emphasis, full-width: Sign out (list) and Done (confirmation). */
+ /* The account kebab (issue #1098's single control) + its popup menu. The
+ kebab is the +'s quiet sibling — same 30px round target, no fill until
+ hover/open. The menu hangs under it, right-aligned so it opens inward
+ (never clipping the card edge), in the card vocabulary. */
+ /* The kebab lives at the CARD's top-right corner — account-scope, above
+ the list's own controls (the + keeps the header row's end slot). Inside
+ #vault so it exists only for signed-in views; anchored to main.card's
+ position. */
+ .kebab-wrap { position: absolute; top: 16px; right: 16px; }
+ .icon-kebab {
+ flex: none; width: 30px; height: 30px; border-radius: 50%;
+ display: grid; place-items: center; padding: 0; border: none; cursor: pointer;
+ background: none; color: var(--muted);
+ }
+ .icon-kebab svg { display: block; }
+ .icon-kebab:hover, .icon-kebab[aria-expanded="true"] { background: var(--quiet); color: var(--ink); }
+ .icon-kebab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+ .menu-pop {
+ position: absolute; top: calc(100% + 6px); right: 0; z-index: 5;
+ min-width: 160px; padding: 6px;
+ display: flex; flex-direction: column;
+ background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
+ box-shadow: 0 12px 32px var(--shadow);
+ animation: menu-in .12s ease;
+ }
+ .menu-pop.hidden { display: none; }
+ @keyframes menu-in { from { opacity: 0; } }
+ @media (prefers-reduced-motion: reduce) { .menu-pop { animation: none; } }
+ .menu-pop [role="menuitem"] {
+ font: inherit; font-weight: 600; text-align: left; cursor: pointer;
+ background: none; border: none; border-radius: 9px; padding: 9px 12px;
+ color: var(--ink);
+ }
+ .menu-pop [role="menuitem"]:hover { background: var(--quiet); }
+ .menu-pop [role="menuitem"]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
+ /* Pointer-born opens: focus still lands in the menu, but the ring is a
+ keyboard courtesy — the focused item shows the hover wash instead. */
+ .menu-pop.pointer-open [role="menuitem"]:focus-visible { outline: none; background: var(--quiet); }
+ /* Low-emphasis, full-width: Done (confirmation) and Cancel (archive). */
.wide-quiet {
width: 100%; margin-top: 18px; font: inherit; font-weight: 600; cursor: pointer;
background: var(--quiet); color: var(--ink); border: none; border-radius: 999px; padding: 11px 18px;
@@ -401,23 +594,70 @@
transition: opacity .5s ease;
}
html.booting .app-loading { display: grid; }
- /* Hide the content (card + its outside-the-card reassurance) beneath the loader
- so nothing flashes; both fade back in when booting drops. */
- html.booting main.card, html.booting .reassure { opacity: 0; }
- main.card, .reassure { transition: opacity .5s ease; }
- .app-loading.cover-out { opacity: 0; pointer-events: none; }
+ /* A session refresh shouldn't feel like a journey: on the non-siwa booting
+ paths the pageantry — the lockup chrome and the smoke's full ramp —
+ holds for 500ms before appearing at all. The cover still BLOCKS from
+ frame zero (the content-hiding rules below are undelayed); only its
+ visibility waits. A load that lifts inside the window never shows
+ chrome: bootscene's fast path reveals the content near-instantly
+ (html.boot-fast trims the content fade to an imperceptible beat, and
+ comes off right after so the stage-clear fades keep their timing). The
+ siwa journey face (html.siwa-wait) deliberately keeps its frame-zero
+ chrome. Opacity-only, so it stays under reduced motion like the fades. */
+ html.booting:not(.siwa-wait) .app-loading { animation: cover-wait .45s ease .5s both; }
+ @keyframes cover-wait { from { opacity: 0; } to { opacity: 1; } }
+ html.booting:not(.siwa-wait) .boot-canvas { transition-delay: .5s; }
+ html.boot-fast main.card, html.boot-fast .reassure,
+ html.boot-fast .demo-card, html.boot-fast .cookie-ask { transition-duration: .08s; }
+ /* 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; }
+ 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. */
+ .app-loading.cover-out { display: grid; opacity: 0; pointer-events: none; }
.boot-brand { display: flex; flex-direction: column; align-items: center; gap: 10px; }
.boot-goesto {
position: absolute; left: 0; right: 0; bottom: 18px; margin: 0;
text-align: center; color: var(--muted); font-size: 13px; letter-spacing: .02em;
}
+ /* The Apple round trip's waiting face (html.siwa-wait, both legs of the
+ trip): the same cover, but a ring where the lockup sits — no Eleven
+ brand until the sign-in has actually confirmed. Verify success drops
+ siwa-wait and stamps .brand-in: the lockup fades in (~400ms), dwells a
+ beat, then the cover lifts while the spaces screen slides up beneath it
+ (#vault.arrive). Reduced motion: instant swaps, no slide — the ring's
+ own static-under-reduce rule is .build-spin's. */
+ .boot-ring { display: none; }
+ html.siwa-wait .app-loading .boot-ring { display: block; }
+ html.siwa-wait .app-loading .boot-brand,
+ html.siwa-wait .app-loading .boot-goesto { display: none; }
+ .app-loading.brand-in .boot-brand,
+ .app-loading.brand-in .boot-goesto { animation: brand-in .4s ease both; }
+ @keyframes brand-in { from { opacity: 0; } }
+ #vault.arrive { animation: vault-arrive .35s ease-out; }
+ @keyframes vault-arrive { from { opacity: 0; transform: translateY(14px); } }
+ @media (prefers-reduced-motion: reduce) {
+ .app-loading.brand-in .boot-brand,
+ .app-loading.brand-in .boot-goesto { animation: none; }
+ #vault.arrive { animation: none; }
+ }
</style>
<script>
- // Pre-paint gate: if this browser holds a live session, show the loading
- // cover from the very first frame so a refresh doesn't flash the Sign-in
- // screen before the spaces list decrypts. Signed-out visitors skip it.
+ // Pre-paint gate: if this browser holds a live session — the tab's own, or
+ // a stay-signed-in one in localStorage (home_session_expires is a plain
+ // epoch-ms key exactly so this check needs no JSON) — or is landing back
+ // from Apple's sign-in (#siwa= fragment), show the loading cover from the
+ // very first frame, so no path ever flashes the Sign-in screen. The Apple
+ // return gets the WAITING face (smoke + ring, no lockup — siwa-wait): the
+ // 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 {
- if (sessionStorage.getItem("home_token") && sessionStorage.getItem("home_dek"))
+ if (location.hash.indexOf("#siwa=") === 0)
+ document.documentElement.classList.add("booting", "siwa-wait");
+ else if ((sessionStorage.getItem("home_token") && sessionStorage.getItem("home_dek")) ||
+ +localStorage.getItem("home_session_expires") > Date.now())
document.documentElement.classList.add("booting");
} catch (e) {}
</script>
@@ -441,6 +681,9 @@
<img class="brand-mark" src="/static/icon-512.png" width="64" height="64" alt="Eleven" />
<div class="brand-word">Eleven</div>
</div>
+ <!-- The Apple wait face (html.siwa-wait): a ring where the lockup sits —
+ the brand only lands once the sign-in has confirmed. -->
+ <div class="build-spin boot-ring" aria-hidden="true"></div>
<p class="boot-goesto">The group chat of dreams</p>
</div>
<main class="card">
@@ -449,34 +692,52 @@
<div class="brand-word">Eleven</div>
</div>
- <!-- The signed-out affordances; hidden once you're in (see showVault). Both
- intents are visible up front — Sign in for returning members, a quieter
- create for someone genuinely new — so nobody has to fail a ceremony to
- find their path. -->
+ <!-- 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). -->
<div id="signed-out">
<div class="actions">
- <button id="signin">Sign in</button>
- <div class="path-divider">New here?</div>
- <button id="create-passkey" class="secondary" type="button">Create your sign-in</button>
- <!-- The no-account path (demo.go) — hidden until /api/features says this
- home server offers demos. -->
- <div id="demo-offer" class="hidden">
- <div class="path-divider">Just looking?</div>
- <button id="try-demo" class="secondary" type="button">Try a demo space</button>
- <p class="demo-hint">No sign-in needed. Demo spaces disappear after 24 hours — claiming one keeps it.</p>
- </div>
+ <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>
</div>
- <!-- Revealed after a failed sign-in, as reinforcement. Cancelled and
- no-sign-in are deliberately indistinguishable (NotAllowedError either
- way), so this explainer serves both readings: steer a returning user
- back to Sign in — one sign-in opens every space, a duplicate would
- shadow it — and point someone genuinely new at Create. -->
+ <!-- Revealed only after a failed or cancelled passkey ceremony — the one
+ place the create path lives now. Cancelled and no-passkey are
+ deliberately indistinguishable (NotAllowedError either way), so the
+ explainer serves both readings: a returning member likely picked the
+ wrong passkey — try again — and someone genuinely new creates theirs
+ here. -->
<div id="newhere" class="hidden screen">
- <p><strong>Which one is yours?</strong> Your Eleven sign-in is one key that
- opens every space you belong to — you only ever need one. If you've
- signed in before, tap Sign in and choose it from the list. Create a new
- sign-in only if you've never made one.</p>
+ <p><strong>That didn't work?</strong> It might just be the wrong passkey —
+ your Eleven sign-in is one key that opens every space you belong to, so
+ if you've signed in before, try again and choose it from the list.</p>
+ <div class="path-divider">New here?</div>
+ <button id="create-passkey" class="secondary" type="button">Create your passkey</button>
+ </div>
+ </div>
+
+ <!-- The email door (email.go), a card step of its own — the doors swap out
+ for it (same screen-swap pattern as the signed-in screens): address →
+ code → session. Forms so Enter submits and native validation gates;
+ home.js drives the steps. -->
+ <div id="email-door" class="hidden screen">
+ <button id="email-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">Sign in with email</h2>
+ <div id="email-flow">
+ <form id="email-form">
+ <input id="email-addr" type="email" required autocomplete="email"
+ placeholder="you@example.com" aria-label="Your email address">
+ <button id="email-send" type="submit">Send code</button>
+ </form>
+ <p class="anchor-note" id="email-note">We never keep your email address. No spam, ever.</p>
+ <form id="email-code-form" class="hidden">
+ <input id="email-code" type="text" required inputmode="numeric" pattern="[0-9]{6}"
+ maxlength="6" autocomplete="one-time-code" placeholder="6-digit code"
+ aria-label="The 6-digit code from your email">
+ <button id="email-verify" type="submit">Sign in</button>
+ </form>
</div>
</div>
@@ -489,12 +750,27 @@
<div class="vault-head">
<h2>Your Eleven spaces</h2>
<button id="open-create" class="icon-add hidden" type="button" aria-label="Create a new space"><svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M8 2v12M2 8h12" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"/></svg></button>
+ <!-- The account menu (issue #1098's single control): everything about
+ the account itself hangs off this one kebab — Sign out today;
+ "How you sign in" and account-merge join it later. -->
+ <div class="kebab-wrap">
+ <button id="account-menu-btn" class="icon-kebab" type="button" aria-label="Account menu"
+ aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="currentColor"><circle cx="8" cy="3" r="1.5"/><circle cx="8" cy="8" r="1.5"/><circle cx="8" cy="13" r="1.5"/></svg></button>
+ <div id="account-menu" class="menu-pop hidden" role="menu" aria-label="Account menu">
+ <button id="signout" type="button" role="menuitem">Sign out</button>
+ </div>
+ </div>
</div>
<input id="space-search" class="hidden" type="search" inputmode="search"
autocomplete="off" spellcheck="false" placeholder="Search spaces" aria-label="Search your spaces">
+ <!-- The sealed-list state (an email/Apple sign-in): the account is real and
+ the token works, but the list only opens under a passkey's PRF — the
+ identity-vs-key split, made visible. The prompt IS the trigger. -->
+ <div id="vault-sealed" class="hidden">
+ <button id="unlock-passkey" type="button" class="secondary">Unlock your spaces with your passkey</button>
+ </div>
<div id="spaces"></div>
<p class="status" id="vault-status"></p>
- <button id="signout" type="button" class="wide-quiet">Sign out</button>
</div>
<!-- Create a space: a focused screen — one line, one button. On success the
@@ -610,8 +886,26 @@
</main>
<!-- Reassurance lives outside the card, under it; hidden once signed in. -->
-<p class="reassure" id="reassure">No signup required, we don't store any personal
- details. We can't spam what we don't have.</p>
+<p class="reassure" id="reassure">No signup required, we don't store your email
+ or any personal details. We can't spam what we don't have.</p>
+<p class="cookie-ask"><button id="cookie-ask" type="button">Where's the cookie banner?</button></p>
+<dialog id="cookie-dialog" aria-labelledby="cookie-title">
+ <h2 id="cookie-title">There isn't one.</h2>
+ <p>There are no cookies. We're not tracking you. We don't want your data.</p>
+ <p class="cookie-small">This page keeps your sign-in on your own device — and nothing about you anywhere else.</p>
+ <button id="cookie-close" type="button" class="secondary">Nice</button>
+</dialog>
+<!-- The demo offer (demo.go) lives OUTSIDE the sign-in card — it isn't a
+ sign-in. Its own tinted card: below everything on narrow screens, a
+ tilted flap peeking out beside the main card on wide ones (see
+ .demo-card). Newcomers only — shown when /api/features offers demos AND
+ this browser has never signed in (the #1112 last-used marker, home.js),
+ and only while the sign-in doors are up (the body:has rule). -->
+<aside class="demo-card hidden" id="demo-offer" aria-label="Try a demo space">
+ <div class="path-divider">👋 Just looking?</div>
+ <button id="try-demo" class="secondary" type="button">Try a demo space</button>
+ <p class="demo-hint">No sign-in needed. Demo spaces disappear after 24 hours — claiming one keeps it.</p>
+</aside>
<script type="module" src="/static/home.js"></script>
</body>
</html>
diff --git a/home/web/static/bootscene.js b/home/web/static/bootscene.js
index 5efbe5c..cc511b5 100644
--- a/home/web/static/bootscene.js
+++ b/home/web/static/bootscene.js
@@ -16,6 +16,11 @@
// Hold the loader at least this long before it fades — a deliberate beat on the
// scene, so even an instant load gets ~2s of smoke rather than a strobe.
const MIN_SHOW = 2000;
+// On the session paths the cover's pageantry holds this long before appearing
+// at all (the cover-wait CSS in index.html — the cover still blocks from frame
+// zero, only its visibility waits). A load that lifts inside the window never
+// showed chrome, so it skips the dwell and the exit fade too.
+const CHROME_DELAY = 500;
let scene = null; // the live scene, if startHomeLoader ran
@@ -96,21 +101,37 @@ export function startHomeLoader() {
S.raf = requestAnimationFrame(tick);
}
-// finishHomeLoader lifts the loading chrome (honouring a minimum on-screen time):
-// it drops html.booting so the content fades in, and fades out the Eleven lockup
-// + tagline — leaving the smoke canvas running as the page's ambient background.
-export function finishHomeLoader() {
+// finishHomeLoader lifts the loading chrome: it drops html.booting so the
+// content fades in, and fades out the cover's chrome — leaving the smoke canvas
+// running as the page's ambient background. A minimum on-screen time keeps an
+// instant load from strobing; { immediate: true } skips the floor for callers
+// that ran their own choreography (the Apple return's staged exit in home.js).
+// The chrome is parked, not removed — the Apple tap re-engages it later (the
+// html.siwa-wait waiting face), so it must survive its first fade-out.
+export function finishHomeLoader({ immediate = false } = {}) {
const chrome = document.getElementById("app-loading");
const elapsed = scene ? performance.now() - scene.t0 : MIN_SHOW;
+ // The fast path: a session boot lifting while the page is younger than the
+ // chrome delay. The pageantry never appeared (its CSS clock starts at first
+ // paint — later than performance.now()'s navigation-start epoch, so this
+ // check errs on the safe side of "never seen"), so nothing needs a graceful
+ // exit: reveal the content at once, html.boot-fast trimming its fade to an
+ // imperceptible beat. The siwa journey face keeps its own choreography.
+ const fast = !immediate && performance.now() < CHROME_DELAY &&
+ !document.documentElement.classList.contains("siwa-wait");
setTimeout(() => {
+ if (fast) document.documentElement.classList.add("boot-fast");
document.documentElement.classList.remove("booting"); // reveal + fade the content in
if (chrome) {
- chrome.classList.add("cover-out"); // fade the lockup + tagline out
- setTimeout(() => chrome.remove(), 600);
+ chrome.classList.add("cover-out"); // fade the chrome out (it keeps display for the ride)
+ setTimeout(() => {
+ chrome.classList.remove("cover-out", "brand-in"); // parked (display:none), ready to re-engage
+ document.documentElement.classList.remove("siwa-wait", "boot-fast"); // the wait face dies with its chrome
+ }, fast ? 80 : 600);
}
// NB: the smoke canvas + its rAF are intentionally left running — the scene
// is the page's living backdrop now, not a cover to tear down.
- }, Math.max(0, MIN_SHOW - elapsed));
+ }, immediate || fast ? 0 : Math.max(0, MIN_SHOW - elapsed));
}
function sizeScene(S) {
diff --git a/home/web/static/home.js b/home/web/static/home.js
index f4db267..277e459 100644
--- a/home/web/static/home.js
+++ b/home/web/static/home.js
@@ -1,18 +1,21 @@
-// The home landing page: "Sign in" unlocks your list of spaces, plus a Sign
-// out that forgets this browser's session. 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: 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.
//
-// Both intents are visible up front: Sign in (primary) and, quieter beneath a
-// divider, Create your sign-in for someone genuinely new — nobody has to fail
-// a ceremony to find their path. Sign-in still leads: a cancelled prompt and a
-// missing credential are deliberately indistinguishable (NotAllowedError
-// either way), so a failed sign-in reveals the #newhere explainer steering a
-// returning user back to Sign in — one sign-in opens every space, and a
-// duplicate empty account would shadow the real one — while pointing someone
-// genuinely new at Create. "Sign in" is the friendly name for unlocking the
-// encrypted key vault; the server only ever holds opaque ciphertext, never a
-// usable key.
+// 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
+// like a passkey ceremony) but can never decrypt — the vault blob only opens
+// under a passkey's PRF-derived KEK. So an anchor sign-in lands signed in
+// either empty (fresh account, no blob yet) or sealed, with a quiet
+// unlock-with-your-passkey prompt. "Sign in" is the friendly name for
+// unlocking the encrypted key vault; the server only ever holds opaque
+// ciphertext, never a usable key.
import {
kekFromPRF, generateDEK, exportDEK, importDEK, wrapDEK, unwrapDEK,
@@ -32,9 +35,12 @@ import { startHomeLoader, finishHomeLoader } from "/static/bootscene.js";
import { BANDS, bandName, bandsOffered, tierForBand, regionCurrency, approxLocal, capNote } from "/static/fx.js";
// ---- session state ----------------------------------------------------------
-// Session-only on purpose (the design doc's web default): closing the tab
-// forgets the DEK and link token, so signing in on a shared computer doesn't
-// durably enrol it. A "trust this browser" persistence opt-in comes later.
+// The identity-vs-key split, applied to persistence (stay signed in, Option
+// A): the link TOKEN outlives the tab — it also lives in localStorage with a
+// 30-day rolling expiry, so a cold open lands signed in — but the DEK never
+// does: it stays sessionStorage-only, so closing the tab always re-seals the
+// list and a stolen disk yields membership, never plaintext. Cold opens land
+// on the sealed-vault prompt; the passkey ceremony stays the only way in.
const state = {
get token() { return sessionStorage.getItem("home_token") || ""; },
set token(v) { sessionStorage.setItem("home_token", v); },
@@ -46,6 +52,37 @@ const state = {
let vault = null; // { obj, keys, version } once unlocked
let dekKey = null; // the imported CryptoKey
+// The persisted session, two localStorage keys: home_session (JSON {token,
+// method}) and home_session_expires (plain epoch-ms, a separate key so the
+// inline pre-paint gate can check it without JSON parsing). The expiry ROLLS:
+// any boot that accepts the session re-persists, buying another 30 days.
+const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+
+function persistSession(token, method) {
+ try {
+ localStorage.setItem("home_session", JSON.stringify({ token, method }));
+ localStorage.setItem("home_session_expires", String(Date.now() + SESSION_TTL_MS));
+ } catch { /* private mode — the sign-in stays tab-only, as before */ }
+}
+
+function clearPersistedSession() {
+ try {
+ localStorage.removeItem("home_session");
+ localStorage.removeItem("home_session_expires");
+ } catch { /* nothing to clear */ }
+}
+
+// persistedSession reads the stored {token, method} while it's still live —
+// null when absent, expired, or unreadable. Pure read; the boot path bumps
+// the expiry only once the token has actually been accepted.
+function persistedSession() {
+ try {
+ const exp = Number(localStorage.getItem("home_session_expires") || "");
+ const s = JSON.parse(localStorage.getItem("home_session") || "null");
+ return s && s.token && exp > Date.now() ? s : null;
+ } catch { return null; }
+}
+
// ---- tiny DOM helpers ---------------------------------------------------------
const $ = (id) => document.getElementById(id);
@@ -166,11 +203,32 @@ async function saveVault() {
// ---- sign in ------------------------------------------------------------------
-// Both signed-out buttons disable together while either ceremony runs — a
-// second WebAuthn request would only cancel the sheet that's already up.
+// All the doors disable together while any of them is mid-flight — a second
+// WebAuthn request would only cancel the sheet that's already up, and a second
+// 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) {
- $("signin").disabled = busy;
- $("create-passkey").disabled = 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"]) {
+ $(id).disabled = busy || !window.PublicKeyCredential;
+ }
+}
+
+// markSignedIn records which method last signed in on this browser — this is
+// deliberately the #1112 last-used marker ("passkey" | "email" | "apple").
+// localStorage on purpose: it outlives the session-only tab state, and
+// sign-out leaves it — a returning person is still returning. Its absence is
+// also what lets the newcomers-only demo card show, so marking retires the
+// card on the spot.
+function markSignedIn(method) {
+ try { localStorage.setItem("home_last_signin", method); } catch { /* no storage — the card just stays */ }
+ $("demo-offer").classList.add("hidden");
+}
+
+// everSignedIn gates the demo card: an absent marker (or no storage at all,
+// where nothing could ever have been recorded) reads as a newcomer.
+function everSignedIn() {
+ try { return !!localStorage.getItem("home_last_signin"); } catch { return false; }
}
async function signIn() {
@@ -179,10 +237,25 @@ async function signIn() {
const { token, credId, prf } = await runLogin(loginBegin);
state.token = token;
state.credId = credId;
+ markSignedIn("passkey");
+ persistSession(token, "passkey");
const got = await fetchBlob(token);
if (!got) {
- setStatus("Signed in, but no spaces are saved to this sign-in yet.", "error");
- setSignedOutBusy(false);
+ // Zero spaces is a SUCCESS state, not a dead end: land in the normal
+ // signed-in view, empty, with the create affordance. A PRF result lets
+ // this passkey seal future saves (same shape as createPasskey — and like
+ // there, nothing is written until the first space enrols).
+ if (prf) {
+ const kek = await kekFromPRF(prf);
+ dekKey = await generateDEK();
+ state.dek = await exportDEK(dekKey);
+ vault = { obj: { v: 1, servers: [] }, keys: { [credId]: await wrapDEK(dekKey, kek) }, version: "0" };
+ } else {
+ dekKey = null;
+ vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" };
+ }
+ setStatus("");
+ showVault();
return;
}
if (!prf) {
@@ -230,6 +303,8 @@ async function createPasskey() {
}
state.token = reg.token;
state.credId = reg.credId;
+ markSignedIn("passkey"); // register/finish is a sign-in too (#1112)
+ persistSession(reg.token, "passkey");
const kek = await kekFromPRF(reg.prf);
dekKey = await generateDEK();
@@ -249,20 +324,253 @@ async function createPasskey() {
showVault();
}
+// ---- the identity anchors: Apple + email ---------------------------------------
+
+// startAppleSignIn hands the whole page to Apple's authorize endpoint — a
+// full-page navigate, no Apple JS SDK (the CSP pins scripts to self). The
+// state is minted here, "web."-prefixed so the /siwa/return trampoline
+// bounces back to this page instead of the native apps' custom scheme, and
+// parked in sessionStorage for the return check.
+// The Apple round trip's waiting face: the ambient smoke (already running
+// behind the page) comes to full and a ring holds the lockup's spot — no
+// brand yet; this is the wait, not the arrival. A pure opacity crossfade
+// over the boot cover's classes: nothing heavy starts at tap time. The same
+// face goes up pre-paint on the return leg (the #siwa= gate in index.html).
+function engageSiwaCover() {
+ document.documentElement.classList.add("booting", "siwa-wait");
+}
+function releaseSiwaCover() {
+ document.documentElement.classList.remove("booting", "siwa-wait");
+}
+
+// The Apple tap's staged wait: stage-clear → (timer) the waiting face. A
+// cancelled sheet never navigates, so any later input — or a bfcache
+// return — releases the whole arrangement, cover included.
+let applePendingTimer = 0;
+function releaseApplePending() {
+ clearTimeout(applePendingTimer);
+ releaseSiwaCover();
+ document.querySelector("main.card").classList.remove("apple-pending");
+ const b = $("apple-signin");
+ b.disabled = false;
+ b.classList.remove("loading");
+ window.removeEventListener("pointerdown", releaseApplePending, true);
+ window.removeEventListener("keydown", releaseApplePending, true);
+}
+
+function startAppleSignIn() {
+ // 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");
+ appleBtn.disabled = true;
+ appleBtn.classList.add("loading");
+ document.querySelector("main.card").classList.add("apple-pending");
+ applePendingTimer = setTimeout(engageSiwaCover, 1600);
+ window.addEventListener("pointerdown", releaseApplePending, true);
+ window.addEventListener("keydown", releaseApplePending, true);
+ const rand = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
+ const st = "web." + rand;
+ sessionStorage.setItem("home_siwa_state", st);
+ location.href = "https://appleid.apple.com/auth/authorize" +
+ "?client_id=com.elevenmessenger.signin" +
+ "&redirect_uri=" + encodeURIComponent(location.origin + "/siwa/return") +
+ "&response_type=code%20id_token" +
+ "&response_mode=form_post" +
+ "&state=" + encodeURIComponent(st);
+}
+
+// finishSiwa completes the round trip: the id_token from the return fragment
+// goes to the one verifier (/api/siwa/verify), which answers exactly like a
+// passkey ceremony — then the anchor-session landing sorts the outcomes. The
+// pre-paint waiting face (smoke + ring, html.siwa-wait) is up the whole time;
+// verify success earns the arrival: ring gives way to the Eleven lockup,
+// a beat to land, then the cover lifts as the spaces screen slides up.
+async function finishSiwa(idToken) {
+ setStatus("Confirming it’s you…", "busy");
+ try {
+ const res = await apiJSON("/api/siwa/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ identity_token: idToken }),
+ });
+ markSignedIn("apple");
+ persistSession(res.token, "apple");
+ await enterAnchorSession(res); // renders the landing beneath the cover
+ } catch {
+ // No brand moment for a sign-in that didn't happen: lift straight to
+ // the signed-out card and say what went wrong.
+ setStatus("That Apple sign-in couldn’t be confirmed — try again.", "error");
+ finishHomeLoader({ immediate: true });
+ return;
+ }
+ // Phase two, arrival: the lockup fades in over the ring's spot (~400ms),
+ // dwells (~450ms), then the cover lifts while the spaces screen slides up
+ // into place beneath it. Reduced motion: no dwell, no slide — plain fades
+ // (brand-in and arrive are inert under the reduce query).
+ const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
+ document.documentElement.classList.remove("siwa-wait"); // ring → lockup
+ $("app-loading").classList.add("brand-in");
+ setTimeout(() => {
+ // .arrive stays on while the vault is visible — swapping the computed
+ // animation-name back to .screen's live would RESTART the entry fade
+ // (the after-flash); it comes off in hideVault, under display:none.
+ $("vault").classList.add("arrive");
+ finishHomeLoader({ immediate: true });
+ }, reduced ? 0 : 850); // 400ms brand fade + 450ms dwell
+}
+
+// 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.
+function openEmailDoor() {
+ $("signed-out").classList.add("hidden");
+ $("reassure").classList.add("hidden");
+ $("email-door").classList.remove("hidden");
+ setStatus("");
+ $("email-addr").focus();
+}
+
+// resetEmailDoor folds the door back to its address step (shared with
+// signOut, so the next visitor never sees a half-finished code round trip).
+function resetEmailDoor() {
+ $("email-door").classList.add("hidden");
+ $("email-code-form").classList.add("hidden");
+ $("email-form").classList.remove("hidden");
+ $("email-note").classList.remove("hidden");
+ $("email-addr").value = "";
+ $("email-code").value = "";
+}
+
+function closeEmailDoor() {
+ resetEmailDoor();
+ setStatus("");
+ $("signed-out").classList.remove("hidden");
+ $("reassure").classList.remove("hidden");
+}
+
+async function sendEmailCode(e) {
+ e.preventDefault();
+ const email = $("email-addr").value.trim();
+ setSignedOutBusy(true);
+ setStatus("Sending your code…", "busy");
+ // The button spins for at least a beat even when the server is instant —
+ // a mail send that appears to take zero time reads as a glitch, and the
+ // sudden step-swap was jarring (Paul). The floor, not a fake: the request
+ // runs concurrently.
+ const sendBtn = $("email-send");
+ sendBtn.classList.add("loading");
+ const spinFloor = new Promise((r) => setTimeout(r, 900));
+ try {
+ await Promise.all([apiJSON("/api/email/request", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email }),
+ }), spinFloor]);
+ // The server answers the same 200 known-or-not (no enumeration), so this
+ // line is honest for everyone.
+ // The code step takes the address step's PLACE — not a row below it.
+ setStatus("Check your email for a 6-digit code.", "");
+ $("email-form").classList.add("hidden");
+ $("email-note").classList.add("hidden");
+ $("email-code-form").classList.remove("hidden");
+ setSignedOutBusy(false);
+ sendBtn.classList.remove("loading");
+ $("email-code").focus();
+ } catch (err) {
+ setStatus(err.message || "Couldn’t send the code — try again.", "error");
+ setSignedOutBusy(false);
+ sendBtn.classList.remove("loading");
+ }
+}
+
+async function verifyEmailCode(e) {
+ e.preventDefault();
+ const email = $("email-addr").value.trim();
+ const code = $("email-code").value.trim();
+ setSignedOutBusy(true);
+ setStatus("Checking your code…", "busy");
+ // Same trade as Send code, a touch longer (Paul: "it's too fast!"): the
+ // button spins for a floor the verify races, so the signed-in landing
+ // never swaps in before the moment has registered.
+ const verifyBtn = $("email-verify");
+ verifyBtn.classList.add("loading");
+ const spinFloor = new Promise((r) => setTimeout(r, 1200));
+ try {
+ const [res] = await Promise.all([apiJSON("/api/email/verify", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, code }),
+ }), spinFloor]);
+ markSignedIn("email");
+ persistSession(res.token, "email");
+ await enterAnchorSession(res);
+ setSignedOutBusy(false);
+ verifyBtn.classList.remove("loading");
+ } catch (err) {
+ // The server's refusals are member-facing ("that code didn't work").
+ setStatus(err.message || "That code didn’t work — try again.", "error");
+ setSignedOutBusy(false);
+ verifyBtn.classList.remove("loading");
+ }
+}
+
+// enterAnchorSession lands an email/Apple sign-in. The token is held exactly
+// like the passkey path holds its own (sessionStorage, this tab only); the
+// ceremony proof is not kept, matching the passkey path, which only ever uses
+// one transiently. An anchor authenticates but can never decrypt (no PRF), so:
+// fresh account or no blob yet → the normal signed-in view, empty; an
+// existing sealed blob → the quiet unlock-with-your-passkey prompt.
+async function enterAnchorSession(res) {
+ state.token = res.token;
+ dekKey = null;
+ const got = res.created ? null : await fetchBlob(res.token);
+ if (got) {
+ vault = null;
+ setStatus("");
+ showSealedVault();
+ return;
+ }
+ vault = { obj: { v: 1, servers: [] }, keys: {}, version: "0" };
+ setStatus("");
+ showVault();
+}
+
+// showSealedVault is the signed-in view for an account whose list exists but
+// can't open here: the token works, the blob is real, and the DEK that opens
+// it lives behind a passkey's PRF. The prompt runs the ordinary sign-in
+// ceremony, which decrypts and takes over the view.
+function showSealedVault() {
+ $("signed-out").classList.add("hidden");
+ $("reassure").classList.add("hidden");
+ resetEmailDoor(); // arriving from the email door hides it
+ $("vault").classList.remove("hidden");
+ $("open-create").classList.add("hidden");
+ $("space-search").classList.add("hidden");
+ $("spaces").textContent = "";
+ $("vault-sealed").classList.remove("hidden");
+}
+
// ---- sign out -----------------------------------------------------------------
-// signOut forgets this browser's session — the link token and DEK held only in
-// sessionStorage — and returns to the signed-out landing. It doesn't revoke the
-// passkey or touch the backup: signing back in is one tap. (Revoking a device
-// is a separate, ceremony-gated action.)
+// signOut forgets this browser's session — the tab's token and DEK, and the
+// persisted stay-signed-in token — and returns to the signed-out landing. It
+// doesn't revoke the passkey or touch the backup: signing back in is one tap.
+// (Revoking a device is a separate, ceremony-gated action.) The #1112
+// last-used marker deliberately survives: a returning person is still
+// returning.
function signOut() {
sessionStorage.removeItem("home_token");
sessionStorage.removeItem("home_dek");
sessionStorage.removeItem("home_cred");
+ clearPersistedSession();
vault = null;
dekKey = null;
provisioned = null; // the ledger read is per-sign-in
- $("vault").classList.add("hidden");
+ hideVault();
$("create").classList.add("hidden");
$("upgrade").classList.add("hidden");
$("claim").classList.add("hidden");
@@ -270,20 +578,79 @@ function signOut() {
archiving = null;
clearUndo();
$("spaces").textContent = "";
+ $("vault-sealed").classList.add("hidden");
$("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
$("reassure").classList.remove("hidden");
setSignedOutBusy(false);
setStatus("Signed out.", "");
prefetchLogin(); // ready a fresh ceremony for the next sign-in
}
+// ---- the account menu (issue #1098's single control) ---------------------------
+
+// One kebab in the vault header collects everything about the account itself:
+// Sign out today; "How you sign in" and account-merge join it later (#1098).
+// Menu semantics by hand: focus lands on the first item on open and returns
+// to the kebab on close; Escape and click-outside both close.
+function accountMenuOpen() {
+ return !$("account-menu").classList.contains("hidden");
+}
+
+// A pointerdown on the kebab marks the NEXT open as pointer-born: focus
+// still moves into the menu (menu semantics), but the ring stays keyboard-
+// only — Chrome's focus-visible heuristic treats script focus as keyboard
+// and over-eagerly ringed mouse opens (Paul's catch).
+let kebabPointerOpen = false;
+function openAccountMenu() {
+ $("account-menu").classList.toggle("pointer-open", kebabPointerOpen);
+ kebabPointerOpen = false;
+ $("account-menu").classList.remove("hidden");
+ $("account-menu-btn").setAttribute("aria-expanded", "true");
+ $("account-menu").querySelector('[role="menuitem"]').focus();
+ addEventListener("pointerdown", accountMenuOutside, true);
+ addEventListener("keydown", accountMenuKey, true);
+}
+
+// close(refocus): refocus only for keyboard paths (Escape, the kebab toggle) —
+// a click elsewhere shouldn't yank focus back to the header.
+function closeAccountMenu(refocus) {
+ if (!accountMenuOpen()) return;
+ $("account-menu").classList.add("hidden");
+ $("account-menu-btn").setAttribute("aria-expanded", "false");
+ removeEventListener("pointerdown", accountMenuOutside, true);
+ removeEventListener("keydown", accountMenuKey, true);
+ if (refocus) $("account-menu-btn").focus();
+}
+
+function accountMenuOutside(e) {
+ if (!e.target.closest || !e.target.closest(".kebab-wrap")) closeAccountMenu(false);
+}
+
+function accountMenuKey(e) {
+ if (e.key === "Escape") { e.preventDefault(); closeAccountMenu(true); }
+}
+
// ---- spaces list --------------------------------------------------------------
-// Creating a space is gated to existing members: only offered once you already
-// have at least one saved space. Mirrors (and stays ahead of) the home server's
-// blob-existence gate on POST /api/spaces, so creation stays invite-gated — not
-// open to the world — rather than surfacing a button that 403s.
+// hideVault parks the spaces screen for a sibling screen (or sign-out). The
+// siwa-arrival class comes off HERE, under display:none where it's free —
+// removing it while the vault is visible changes the computed animation-name
+// back to .screen's, and a changed name RESTARTS the entry animation on an
+// already-visible card (Paul's after-flash). An open account menu doesn't
+// survive the screen either.
+function hideVault() {
+ closeAccountMenu(false);
+ $("vault").classList.add("hidden");
+ $("vault").classList.remove("arrive");
+}
+
+// Creating a space is for people already IN one: the + hides until the
+// decrypted vault actually holds a space entry (an empty account's + only
+// invited the server's 403 dead-end — creation is gated to members, and the
+// sealed and empty states both hide it). The server enforces the same
+// boundary on POST /api/spaces.
function canCreateSpace() {
return spacesEnabled && !!(vault && (vault.obj.servers || []).length);
}
@@ -291,8 +658,10 @@ function canCreateSpace() {
function showVault() {
$("signed-out").classList.add("hidden");
$("reassure").classList.add("hidden");
+ resetEmailDoor(); // arriving from the email door hides it
$("create").classList.add("hidden");
$("archive").classList.add("hidden");
+ $("vault-sealed").classList.add("hidden");
$("vault").classList.remove("hidden");
$("open-create").classList.toggle("hidden", !canCreateSpace()); // the + to create
@@ -747,7 +1116,7 @@ let fxSnapshot = null; // {date, rates} once /api/fx answers; null before
// openUpgrade shows the plan picker for one space. The payment itself is
// Stripe's hosted page; this screen only chooses the plan and hands off.
function openUpgrade(s) {
- $("vault").classList.add("hidden");
+ hideVault();
$("upgrade").classList.remove("hidden");
$("upgrade-host").textContent = hostOf(s.url);
upgradeStatus("");
@@ -929,7 +1298,7 @@ function consumeClaimLink() {
// provisioner's answers (quota, not-a-demo, expired) are member-facing and
// shown verbatim, so asking twice would only add a failure mode.
function openClaim(host) {
- $("vault").classList.add("hidden");
+ hideVault();
$("claim").classList.remove("hidden");
$("claim-host").textContent = host;
claimStatus("");
@@ -1193,7 +1562,7 @@ function openCreate() {
$("new-space-result").classList.add("hidden");
$("create-back").classList.remove("hidden");
spaceStatus("");
- $("vault").classList.add("hidden");
+ hideVault();
$("create").classList.remove("hidden");
const f = ensureFader();
f.reset();
@@ -1227,7 +1596,7 @@ let sternShell = false;
function openArchive() {
clearUndo();
archiving = null;
- $("vault").classList.add("hidden");
+ hideVault();
$("archive-stern").classList.add("hidden");
$("archive-checking").classList.add("hidden");
$("archive-main").classList.remove("hidden");
@@ -1522,16 +1891,14 @@ async function destroyForEveryone() {
const onSignInError = (err) => {
// A friendly, specific line for what went wrong (ceremony-errors.js), with
- // the technical crumb parenthesised. A cancelled prompt and a missing
- // credential both surface as NotAllowedError, deliberately — so that case
- // also reveals the #newhere explainer, which serves both readings: it steers
- // returning users back to Sign in and the genuinely new at Create (already
- // visible above it).
+ // the technical crumb parenthesised — it might just be the wrong passkey.
+ // 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".
setStatus(formatCeremonyError(err, "signin"), "error");
- if (err && err.name === "NotAllowedError") {
- $("newhere").classList.remove("hidden");
- prefetchRegister(); // ready the create ceremony in case they're new
- }
+ $("newhere").classList.remove("hidden");
+ prefetchRegister(); // ready the create ceremony in case they're new
setSignedOutBusy(false);
prefetchLogin(); // options were spent — refresh for a retry
};
@@ -1545,20 +1912,68 @@ const onCreatePasskeyError = (err) => {
// you're looking at a sign-in page. The fragment waits; the claim screen
// opens on the first vault render after sign-in (or after creating one).
if (claimLinkHost && !(state.token && state.dek)) {
- setStatus("Sign in — or create your sign-in — to keep your demo space.", "");
+ setStatus("Sign in to keep your demo space.", "");
}
-if (!window.PublicKeyCredential) {
- setStatus("This browser can't sign you in — try another browser.", "error");
- setSignedOutBusy(true);
-} else {
- // Both up-front buttons get pre-fetched ceremony options, so either click
- // reaches navigator.credentials.* inside the gesture (Safari activation).
+if (window.PublicKeyCredential) {
+ // The passkey controls get pre-fetched ceremony options, so a click reaches
+ // navigator.credentials.* inside the gesture (Safari activation). A browser
+ // without WebAuthn keeps those controls off (setSignedOutBusy) — the Apple
+ // and email doors still work.
prefetchLogin();
prefetchRegister();
- $("signin").addEventListener("click", () => signIn().catch(onSignInError));
- $("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError));
}
+setSignedOutBusy(false); // paints the no-WebAuthn disable onto the passkey controls
+$("signin").addEventListener("click", () => signIn().catch(onSignInError));
+$("create-passkey").addEventListener("click", () => createPasskey().catch(onCreatePasskeyError));
+$("unlock-passkey").addEventListener("click", () => signIn().catch(onSignInError));
+$("apple-signin").addEventListener("click", startAppleSignIn);
+// The instant the Apple confirmation proceeds, this page navigates away —
+// the waiting face covers whatever frames remain (bfcache return releases it).
+window.addEventListener("pagehide", engageSiwaCover);
+// Coming BACK from Apple (bfcache restore) must not strand a spinning button.
+window.addEventListener("pageshow", (e) => {
+ if (!e.persisted) return;
+ releaseApplePending();
+});
+$("cookie-ask").addEventListener("click", () => $("cookie-dialog").showModal());
+$("cookie-close").addEventListener("click", () => $("cookie-dialog").close());
+$("cookie-dialog").addEventListener("click", (e) => {
+ if (e.target === $("cookie-dialog")) $("cookie-dialog").close(); // backdrop tap
+});
+$("email-signin").addEventListener("click", openEmailDoor);
+$("email-back").addEventListener("click", closeEmailDoor);
+$("email-form").addEventListener("submit", sendEmailCode);
+$("email-code-form").addEventListener("submit", verifyEmailCode);
+
+// 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
+// must match the one this tab minted before leaving for Apple; the hash is
+// cleared either way, so a reload can't replay the token.
+let siwaBooting = false; // claims the boot — the cold-open path below stands down
+{
+ const m = location.hash.match(/^#siwa=([^&]*)&state=([^&]*)$/);
+ if (m) {
+ siwaBooting = true;
+ // The pre-paint gate already put the waiting face up (html.siwa-wait) —
+ // the clean card never shows.
+ history.replaceState(null, "", location.pathname + location.search);
+ const minted = sessionStorage.getItem("home_siwa_state") || "";
+ sessionStorage.removeItem("home_siwa_state");
+ if (minted && decodeURIComponent(m[2]) === minted) {
+ finishSiwa(decodeURIComponent(m[1]));
+ } else {
+ // A state that didn't start here: straight to the signed-out card.
+ finishHomeLoader({ immediate: true });
+ setStatus("That Apple sign-in didn’t start here — try again.", "error");
+ }
+ }
+}
+
+$("account-menu-btn").addEventListener("pointerdown", () => { kebabPointerOpen = true; });
+$("account-menu-btn").addEventListener("click", () => {
+ if (accountMenuOpen()) closeAccountMenu(true); else openAccountMenu();
+});
$("signout").addEventListener("click", signOut);
$("open-create").addEventListener("click", openCreate);
$("create-back").addEventListener("click", closeCreate);
@@ -1587,8 +2002,9 @@ $("space-search").addEventListener("input", () => { if (vault) showVault(); });
apiJSON("/api/features")
.then((f) => {
spacesEnabled = !!f.spaces;
- // The no-account demo path (shown signed-out, where it matters).
- $("demo-offer").classList.toggle("hidden", !f.demo);
+ // The no-account demo path — newcomers only: a browser that has ever
+ // signed in (the #1112 marker) has outgrown the demo card.
+ $("demo-offer").classList.toggle("hidden", !f.demo || everSignedIn());
if (vault) $("open-create").classList.toggle("hidden", !canCreateSpace());
// If the list rendered before this answer, re-render: the lazy ledger read
// (Upgrade affordance) only triggers once spacesEnabled is known.
@@ -1620,4 +2036,29 @@ if (state.token && state.dek) {
} catch { /* token or DEK went stale; the button still works */ }
finally { finishHomeLoader(); }
})();
+} else if (!siwaBooting) {
+ // The cold open (stay signed in, Option A): no tab session, but a live
+ // persisted token in localStorage. Adopt it and land exactly like an anchor
+ // sign-in — the sealed prompt when a blob exists, the empty list when not;
+ // the DEK was never persisted, so the passkey ceremony stays the only way
+ // to plaintext. Acceptance rolls the 30-day expiry; a REJECTED token
+ // (revoked server-side) clears the persisted session and falls to the
+ // doors — a flaky network doesn't, it just gets this boot no further.
+ // Every path lifts the pre-paint cover.
+ const saved = persistedSession();
+ if (saved) {
+ (async () => {
+ try {
+ await enterAnchorSession({ token: saved.token, created: false });
+ persistSession(saved.token, saved.method); // accepted — another 30 days
+ } catch (e) {
+ if (/\b401\b/.test((e && e.message) || "")) clearPersistedSession();
+ } finally { finishHomeLoader(); }
+ })();
+ } else if (document.documentElement.classList.contains("booting")) {
+ // The gate raised the cover for a session that didn't survive the read
+ // (expired between frames, or unparseable) — tidy up and lift to the doors.
+ clearPersistedSession();
+ finishHomeLoader();
+ }
}