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>
7 files changed,
+956
−112
home/home_test.go+17 −0home/main.go+40 −8home/siwa.go+9 −0home/siwa_test.go+30 −0home/web/pages/index.html+335 −41home/web/static/bootscene.js+28 −7home/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(); |
| + } |
| } |