| 1 | # π€ Signup-first join page β design |
| 2 | |
| 3 | **Date:** 2026-07-31 Β· **Status:** approved by Paul Β· **Scope:** web join page |
| 4 | (native follows as a stated follow-up) |
| 5 | |
| 6 | ## What changes |
| 7 | |
| 8 | Today a joiner opening `/join/<name>#<secret>` sees the app/web choice first, |
| 9 | claims an invite with no account, and is asked to sign in only afterwards β |
| 10 | by Eleven's welcome conversation, then the fragility banner. This flips the |
| 11 | order: the **first screen is signup/sign-in**, and by the time the joiner |
| 12 | reaches the continue-on-web / continue-in-the-app choice they already have an |
| 13 | Eleven account and passkey. This is the golden path |
| 14 | `docs/onboarding-and-home-server.md` Β§1 designed but never built. |
| 15 | |
| 16 | The account gate is **offered first, skippable** β a quiet "continue without |
| 17 | an account" escape preserves the zero-friction join; skippers fall through to |
| 18 | today's exact flow, including Eleven's ask and the fragility banner. |
| 19 | |
| 20 | ## 1. The lead card |
| 21 | |
| 22 | New first screen for fresh joiners β all fresh-person shapes: generic |
| 23 | invites, DM/user invites, and setup (new-space) invites β rendered by |
| 24 | `join.js` from a new template in `join.html`: |
| 25 | |
| 26 | - Branded like today's welcome card (space avatar + name via the existing |
| 27 | sealed-branding machinery; plaintext branding fallback). |
| 28 | - Copy: "You've been invited to *(space)*" plus one explainer line β your |
| 29 | sign-in makes your access portable to any device. |
| 30 | - Primary: **Continue with your Eleven sign-in** β same-tab round-trip to the |
| 31 | home server (Β§3). |
| 32 | - Quiet link: **Continue without an account** β reveals today's welcome card |
| 33 | unchanged (Open in the app / Get the app / Continue in the browser, with |
| 34 | the browser-only caveat). |
| 35 | |
| 36 | "All the sign-in options" are satisfied by landing on home's existing doors |
| 37 | surface (smart single door β last-used else platform default β plus "Other |
| 38 | ways to sign in": passkey, Apple, Google, Microsoft, GitHub, email code). The |
| 39 | lead card links to that surface; it does not rebuild it. |
| 40 | |
| 41 | ## 2. Who never sees the lead card |
| 42 | |
| 43 | These paths fall through to today's behaviour untouched: |
| 44 | |
| 45 | - Add-device links (`payload.priv`) β the person already has an identity. |
| 46 | - Already-a-member adopt paths (all shapes). |
| 47 | - The native embed form path (`window.__lchatEmbed`) and the Mac webview. |
| 48 | - Dead/consumed invites β the existing error card (with its sign-in door). |
| 49 | - Instances with no home server configured (`__HOME_ORIGIN__` empty β |
| 50 | self-hosters). |
| 51 | - π€ Setup links opened from home's create-a-space flow (`?src=home`): that |
| 52 | person signed in at home one tap ago, and home has already saved the new |
| 53 | space to their account. The sign-in they still owe β adding this space's |
| 54 | KEY to that account β is the setup conversation's `keys-home` step, at the |
| 55 | end. |
| 56 | |
| 57 | ## 3. The home-side pre-join intent |
| 58 | |
| 59 | Home's sign-in page (`home/`) grows a lightweight pre-join mode: |
| 60 | |
| 61 | - Query params: `?return=<url>` plus the instance host for display (host and |
| 62 | group name are plaintext branding by design β CLAUDE.md Β§ branding). |
| 63 | - Any door works. After the account exists **and a passkey is saved**, home |
| 64 | redirects to the return URL. An account that already exists in this browser |
| 65 | session completes immediately (the shipped session-reuse behaviour, |
| 66 | `d2c4092`). |
| 67 | - **Return-URL guard:** must be `https` and path `/join/β¦`. Instance hosts |
| 68 | are arbitrary custom domains, so no host allowlist is possible; home |
| 69 | displays the destination host so the person sees where they're being sent. |
| 70 | This is post-auth navigation, not a credential redirect, so the residual |
| 71 | open-redirect risk is display-mitigated, not allowlist-mitigated. |
| 72 | |
| 73 | ## 4. The invite secret never travels |
| 74 | |
| 75 | - Before navigating to home, `join.js` stashes the full original `#fragment` |
| 76 | in **instance-origin localStorage**, keyed by invite id, with an expiry, |
| 77 | cleared on restore. localStorage (not sessionStorage) so a mobile OAuth |
| 78 | app-switch that returns in a fresh tab can't orphan the secret. Lingering |
| 79 | risk is bounded: entries expire, and the invite is one-time anyway. |
| 80 | - The `return` URL carries **no fragment**. On return, `join.js` restores the |
| 81 | fragment from the stash and cleans the URL (`history.replaceState`). |
| 82 | - The home origin never sees the invite secret, preserving the invariant |
| 83 | that fragments never reach any server and the secret stays on the instance |
| 84 | origin. |
| 85 | |
| 86 | ## 5. Return: the caveat-free choice card |
| 87 | |
| 88 | Back on the join page with a signed-in marker (query flag from home, cleaned |
| 89 | after read): |
| 90 | |
| 91 | - The joiner sees the **same web/app choice**, but the "this browser only" |
| 92 | caveat is replaced with a positive line: "Your access is saved to your |
| 93 | Eleven account β you can sign in from any device." |
| 94 | - **Continue in the browser** claims exactly as today β keypair, deferred |
| 95 | claim, bind-MAC (the claim-on-button-press invariant is untouched) β then, |
| 96 | before `location.href = "/"`, `join.js` auto-runs the existing `/enroll` |
| 97 | round-trip (`launchEnrol` machinery). Against the live home session this |
| 98 | completes instantly with no second ceremony, so the space is genuinely |
| 99 | saved to the account before the joiner lands in the chat. |
| 100 | - **Open in the app** keeps today's `elevenmessenger://add` scheme handoff. |
| 101 | The vault-first handoff ("the space is already in your account β just sign |
| 102 | in in the app") is stated **phase 2**, because it changes invite-consumption |
| 103 | timing (claim-at-signup) and touches native sign-in. |
| 104 | |
| 105 | ## 6. Eleven stands down |
| 106 | |
| 107 | - The welcome flow's `access` step (`web/static/welcome.flow.js`) gains an |
| 108 | "already saved" skip condition alongside its existing `native` / |
| 109 | `canEnrol` skips; likewise the `finish` step's "you haven't signed in yet" |
| 110 | warning and chip. |
| 111 | - The fragility banner never arms for a saved session (existing behaviour β |
| 112 | verify, don't rebuild). |
| 113 | |
| 114 | ## 7. Testing |
| 115 | |
| 116 | - Pull the which-card-for-which-state decision (link shape Γ home-origin |
| 117 | presence Γ signed-in marker Γ embed) out of DOM code into a pure helper |
| 118 | (`ui.js` or a new module) with Node tests, per the testable-module |
| 119 | convention. |
| 120 | - Go test on home for the `?return` guard (https-only, `/join/` path only). |
| 121 | - `node --check` on touched JS; `npm test` + `go test ./...` green. |
| 122 | - Manual verification per the developer's recorded preference |
| 123 | (`CLAUDE.local.md`; ask if unrecorded). |
| 124 | - Changelog entry (user-visible web change). |
| 125 | |
| 126 | ## Out of scope (stated) |
| 127 | |
| 128 | - Native (iOS/Mac) direct-join restructure β follow-up, building on |
| 129 | `SignInWithEleven`. |
| 130 | - Claim-at-signup + vault-first app handoff β phase 2. |
| 131 | - Any change to invite consumption timing or the deferred-claim invariant. |
| 132 | - The dead `homescreen-tpl` markup in `join.html` may be deleted in passing |
| 133 | if touched, but its removal is not a goal of this change. |
| 134 | |