Give account setup an infinity-themed loading interlude
Replace the signup form during setup with an indeterminate 0–8–infinity meter and phase-aware messages. Restore the form on failure, preserve its input and invitation, and respect reduced motion.
Co-Authored-By: Codex <codex@openai.com>
6 files changed,
+57
−4
docs/infinity/status.md+4 −0internal/infinity/static/app.js+2 −0internal/infinity/static/onboarding.js+14 −4internal/infinity/static/setup-progress.js+31 −0internal/infinity/static/style.css+2 −0test/infinity/browser.mjs+4 −0
Intent
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 95a39a0..71283eb 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -37,3 +37,7 @@ This is an early text-chat implementation, not an independently audited security |
| Apple apps share the tested web UI; universal links, background push and App Store signing are not configured. Attachments, voice and video are not implemented. Amadan CI is not yet configured for the Ocho fork; local and hosted validation above ran explicitly. |
| Source work is on infinity-v1 in the isolated .worktrees/infinity checkout. The root checkout preserves the Eleven fork baseline. No Ocho implementation merge to main has been performed. |
| + |
| +## Setup interlude — 2026-09-06 (Codex) |
| + |
| +Account creation replaces the form with a dedicated “To infinity. And your people.” screen. An indeterminate light travels along a 0–8 meter toward ∞. Messages follow actual device checking, allocation, startup and opening; there are no invented percentages. Reduced-motion preferences stop the animation. Errors restore the existing form, name and invitation with the same registration nonce. |
diff --git a/internal/infinity/static/app.js b/internal/infinity/static/app.js| index a1660b0..b08ff23 100644 |
| --- a/internal/infinity/static/app.js |
| +++ b/internal/infinity/static/app.js |
| @@ -1,5 +1,6 @@ |
| // 🤖 Entry point: no content is painted until account state has been decrypted. |
| import { request, peerOrigin } from './api.js'; |
| +import { setupProgress } from './setup-progress.js'; |
| import { Client } from './client.js'; |
| import { Chat } from './chat.js'; |
| import { fragment, readIdentity, remember, finishSetup, welcome } from './onboarding.js'; |
| @@ -18,6 +19,7 @@ async function boot() { |
| if (identity.origin !== location.origin) throw new Error('This account belongs to a different address.'); |
| const client = new Client(identity); await client.refresh(); remember(identity); return ready(client); |
| } |
| + if (params.setup) setupProgress(root).update('opening'); |
| const setup = await finishSetup(params); if (setup) return ready(setup); |
| const identity = readIdentity(); |
| if (identity && identity.origin === location.origin) return ready(new Client(identity)); |
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js| index 6aea101..8064cd4 100644 |
| --- a/internal/infinity/static/onboarding.js |
| +++ b/internal/infinity/static/onboarding.js |
| @@ -4,6 +4,7 @@ import { createIdentity, importRecovery, b64, unb64, random } from './crypto.js' |
| import { request, peerOrigin } from './api.js'; |
| import { Client } from './client.js'; |
| import { registrationProof } from './registration.js'; |
| +import { setupProgress } from './setup-progress.js'; |
| export const storageKey = 'infinity.identity.v1'; |
| export function remember(id) { localStorage.setItem(storageKey, JSON.stringify(id)); } |
| export function readIdentity() { try { return JSON.parse(localStorage.getItem(storageKey)); } catch { return null; } } |
| @@ -22,6 +23,7 @@ export async function finishSetup(params) { |
| } |
| export function welcome(root, params, ready) { |
| const note = el('div', {}); |
| + let welcomeScreen; |
| const content = el('section', { class: 'welcome' }, mark(), el('h1', {}, 'Just you. And your people.'), |
| el('p', { class: 'lead' }, 'Private conversations, without a phone number.'), |
| el('p', { class: 'muted' }, params.invite ? 'Someone shared an invitation with you. Open your account to connect.' : 'Share a link. Start a conversation. Make yourself at home.'), note); |
| @@ -33,33 +35,41 @@ export function welcome(root, params, ready) { |
| await busy(submit, async () => { |
| try { |
| const displayName = name.input.value.trim(); if (!displayName) throw new Error('Enter your name.'); |
| + const progress = setupProgress(root); |
| if (params.claim) { |
| + progress.update('opening'); |
| const id = await createIdentity(location.origin, displayName); const c = new Client(id); await c.setup(params.claim); remember(id); |
| history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); return; |
| } |
| const nonce = sessionStorage.getItem('infinity.signup') || random(); sessionStorage.setItem('infinity.signup', nonce); |
| const deadline = Date.now() + 120000; |
| - note.replaceChildren(message('Checking this device…')); |
| + progress.update('checking'); |
| const proof = await registrationProof(nonce); |
| let result; |
| while (Date.now() < deadline) { |
| - note.replaceChildren(message(result?.preparing ? 'Your account is being started. This can take a minute…' : 'Creating your private account…')); |
| + progress.update(result?.preparing ? 'starting' : 'creating'); |
| result = await request(location.origin, '/api/accounts', { method: 'POST', body: { nonce, proof }, timeoutMs: Math.min(30000, deadline - Date.now()) }); |
| if (!result.preparing) break; |
| await new Promise(resolve => setTimeout(resolve, Math.min(1500, Math.max(0, deadline - Date.now())))); |
| } |
| if (!result?.origin) throw new Error('Your account is taking longer than expected to start. Try again to continue setting up the same account.'); |
| + progress.update('opening'); |
| const origin = peerOrigin(result.origin), id = await createIdentity(origin, displayName); |
| localStorage.setItem('infinity.home', origin); |
| const setup = b64(new TextEncoder().encode(JSON.stringify({ identity: id, claim: result.claim, invite: params.invite }))); |
| location.assign(origin + '/#setup=' + setup); |
| - } catch (error) { note.replaceChildren(message(error.message, true)); } |
| + } catch (error) { |
| + root.replaceChildren(welcomeScreen); |
| + note.replaceChildren(message(error.message, true)); |
| + name.input.focus({ preventScroll: true }); |
| + } |
| }); |
| }); |
| const saved = localStorage.getItem('infinity.home'); |
| if (saved) content.append(button('Open your account', () => location.assign(peerOrigin(saved) + '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')), { class: 'primary' })); |
| content.append(form, button('I have a recovery file', () => restore(root, params, ready), { class: 'quiet' }), el('p', { class: 'footnote' }, 'Your messages are encrypted on your device. Save a recovery file after creating your account.')); |
| - root.replaceChildren(el('div', { class: 'welcome-wrap' }, content)); |
| + welcomeScreen = el('div', { class: 'welcome-wrap' }, content); |
| + root.replaceChildren(welcomeScreen); |
| } |
| export function restore(root, params, ready) { |
| const file = field('Recovery file', { type: 'file', accept: '.json,application/json', required: true }); |
diff --git a/internal/infinity/static/setup-progress.js b/internal/infinity/static/setup-progress.js| new file mode 100644 |
| index 0000000..5dd80b1 |
| --- /dev/null |
| +++ b/internal/infinity/static/setup-progress.js |
| @@ -0,0 +1,31 @@ |
| +// 🤖 An indeterminate infinity meter; milestones follow real setup work, not a timer. |
| +import { el, mark } from './dom.js'; |
| +const phases = { |
| + checking: ['Counting past eight…', 'Checking this device.'], |
| + creating: ['Making room for you…', 'Creating your private account.'], |
| + starting: ['A little further into infinity…', 'Starting your account. This can take a minute.'], |
| + opening: ['Your people await.', 'Opening your private account.'], |
| +}; |
| +export function setupProgress(root) { |
| + const title = el('h1', { tabindex: '-1' }, 'To infinity.', el('br', {}), 'And your people.'); |
| + const heading = el('p', { class: 'setup-status-title' }); |
| + const detail = el('p', { class: 'setup-status-detail' }); |
| + const status = el('div', { id: 'setup-status', class: 'setup-status', role: 'status', 'aria-live': 'polite', 'aria-atomic': 'true' }, heading, detail); |
| + const meter = el('div', { class: 'infinity-meter', role: 'progressbar', 'aria-label': 'Setting up your private account', 'aria-describedby': 'setup-status' }, |
| + el('div', { class: 'infinity-track', 'aria-hidden': 'true' }, el('span', { class: 'infinity-trail' })), |
| + el('div', { class: 'infinity-ticks', 'aria-hidden': 'true' }, ...[0, 1, 2, 3, 4, 5, 6, 7, 8].map(n => el('span', {}, n))), |
| + el('span', { class: 'infinity-beyond', 'aria-hidden': 'true' }, '∞')); |
| + const content = el('section', { class: 'setup-progress', 'aria-busy': 'true' }, mark(), |
| + el('p', { class: 'setup-eyebrow' }, 'This one goes to ∞'), title, meter, status, |
| + el('p', { class: 'setup-footnote' }, 'A little space of your own. A lot of possibilities.')); |
| + root.replaceChildren(el('div', { class: 'welcome-wrap' }, content)); |
| + title.focus({ preventScroll: true }); |
| + let current; |
| + const update = phase => { |
| + if (phase === current) return; // Repeated readiness polls should not repeat announcements. |
| + current = phase; content.dataset.phase = phase; |
| + [heading.textContent, detail.textContent] = phases[phase]; |
| + }; |
| + update('checking'); |
| + return { update }; |
| +} |
diff --git a/internal/infinity/static/style.css b/internal/infinity/static/style.css| index f3fd709..5f783c8 100644 |
| --- a/internal/infinity/static/style.css |
| +++ b/internal/infinity/static/style.css |
| @@ -3,3 +3,5 @@ |
| *{box-sizing:border-box}body{margin:0}button,input,textarea{font:inherit}button,a,input,textarea,summary{-webkit-tap-highlight-color:transparent}button{cursor:pointer;border:0;border-radius:12px;padding:11px 17px;background:var(--soft);color:var(--ink);font-weight:600}button:hover{filter:brightness(.96)}button:disabled{cursor:default;opacity:.55}button:focus-visible,input:focus-visible,textarea:focus-visible,summary:focus-visible{outline:3px solid #a997f0;outline-offset:3px}.primary{background:var(--accent);color:#fff}.quiet{background:transparent;color:var(--accent)}.small{padding:8px 13px;font-size:13px}.danger{color:#a83248}p{line-height:1.6}h1,h2,h3,p{margin-top:0}.mark{display:block;flex:none}.muted,.footnote{color:var(--muted)}.footnote{font-size:13px}.loading{display:grid;place-items:center;min-height:100dvh;color:var(--muted)}.shell{height:100dvh;display:grid;grid-template-columns:340px 1fr;max-width:1600px;margin:auto;background:var(--surface)}.sidebar{min-width:0;border-right:1px solid var(--line);display:flex;flex-direction:column;background:var(--surface)}.sidebar-header{display:flex;align-items:center;justify-content:space-between;padding:28px 22px 18px;gap:12px}.brand{display:flex;gap:12px;align-items:center;font-size:20px;font-weight:700;letter-spacing:-.5px}.brand .mark{width:35px;height:35px}.search-wrap{padding:0 20px 20px}input[type=search]{width:100%;border:0;background:#f4f2f7;border-radius:12px;padding:11px 14px;font-size:14px;color:var(--ink)}.thread-list{flex:1;overflow:auto;padding:0 10px}.thread-row{display:flex;align-items:center;gap:12px;width:100%;text-align:left;background:transparent;padding:14px 12px;border-radius:13px;margin:3px 0;font-weight:400}.thread-row.active{background:#eee9fc}.avatar{width:44px;height:44px;border-radius:50%;display:inline-grid;place-items:center;flex:none;font-size:18px;font-weight:600;background:#e8e1fa;color:#6550a7}.thread-summary{min-width:0;flex:1}.thread-top{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px}.thread-top strong{font-size:15px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.thread-top time{font-size:11px;color:var(--muted);flex:none}.preview{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:13px;color:var(--muted)}.list-empty{padding:25px 10px;text-align:center;color:var(--muted);font-size:14px}.account-menu{border-top:1px solid var(--line);padding:15px 22px;position:relative}.account-menu summary{display:flex;gap:12px;align-items:center;cursor:pointer;font-size:14px}.account-menu summary .muted{margin-left:auto}.account-menu .avatar{width:32px;height:32px;font-size:14px}.menu-items{position:absolute;bottom:75px;left:16px;right:16px;background:var(--surface);border:1px solid var(--line);border-radius:15px;padding:8px;box-shadow:0 12px 36px #231d4315;z-index:2}.menu-items button{display:block;text-align:left;width:100%;background:transparent;font-size:14px}.connection{font-size:12px;color:var(--muted);margin:0;padding:0 15px}.connection:not(:empty){padding:10px 15px}.empty-pane{display:flex;align-items:center;justify-content:center;flex-direction:column;background:radial-gradient(ellipse at center,#f2eefc 0%,#faf9fd 70%);padding:30px;text-align:center}.empty-pane .mark{width:88px;height:88px;margin-bottom:30px}.empty-pane h1{font-size:34px;letter-spacing:-1.1px;margin-bottom:12px}.empty-pane p{color:var(--muted);margin-bottom:27px}.encryption-note{font-size:12px;color:var(--muted);margin-top:25px}.conversation{display:flex;flex-direction:column;min-width:0;min-height:0;background:#fbfafd}.chat-header{height:90px;display:flex;align-items:center;gap:14px;padding:18px 30px;background:var(--surface);border-bottom:1px solid var(--line)}.chat-title{flex:1;min-width:0}.chat-title h1{font-size:17px;margin:0 0 4px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.chat-title p{font-size:12px;color:var(--muted);margin:0}.back{display:none}.messages{flex:1;overflow:auto;padding:25px 40px;display:flex;flex-direction:column;gap:8px;overscroll-behavior:contain}.date-label{text-align:center;color:var(--muted);font-size:11px;margin:14px 0}.message{max-width:min(76%,560px);padding:11px 15px 8px;border-radius:19px;align-self:flex-start;background:var(--surface);box-shadow:0 1px 3px #21113b0b;min-width:74px;overflow-wrap:anywhere}.message.mine{align-self:flex-end;background:var(--accent);color:#fff;border-bottom-right-radius:5px}.message.theirs{border-bottom-left-radius:5px;border:1px solid #efebf4}.message p{margin:0;white-space:pre-wrap;font-size:15px;line-height:1.5}.message time{display:block;text-align:right;margin-top:4px;font-size:10px;opacity:.8}.sender-name{font-size:11px;display:block;margin-bottom:4px;color:var(--accent)}.composer{display:flex;align-items:flex-end;gap:12px;padding:16px 28px 24px;background:var(--surface);border-top:1px solid var(--line)}.composer textarea{resize:none;flex:1;border:1px solid var(--line);background:#faf9fc;border-radius:24px;padding:13px 19px;line-height:22px;min-height:48px;max-height:140px;color:var(--ink)}.send{border-radius:50%;height:46px;width:46px;padding:0;font-size:26px}.composer-note{background:var(--surface);padding:0 28px}.start-note{margin:auto;text-align:center;color:var(--muted);font-size:13px}.request{background:var(--soft);border-radius:14px;margin:8px 2px 14px;padding:15px}.request strong{font-size:14px}.request p{font-size:12px;margin:5px 0 10px}.actions{display:flex;gap:8px}.welcome-wrap{min-height:100dvh;display:grid;place-items:center;padding:32px 20px;background:radial-gradient(ellipse at 50% 25%,#eee8ff,#faf9fd 65%)}.welcome{width:min(100%,440px)}.welcome>.mark{width:72px;height:72px;margin-bottom:34px}.welcome h1{font-size:42px;line-height:1.12;letter-spacing:-1.7px;max-width:400px;margin-bottom:20px}.lead{font-size:20px;color:#58516f}.welcome form{margin:25px 0 16px}.welcome form>.primary{width:100%}.welcome>.quiet{padding-left:0}.field{display:flex;flex-direction:column;gap:9px;font-size:13px;font-weight:600;margin:18px 0}.field input{width:100%;padding:13px 14px;border:1px solid #d9d3e6;border-radius:11px;background:var(--surface);color:var(--ink);font-size:16px}.notice{font-size:13px;color:var(--muted);padding:9px 0;margin:0}.error{color:#a83248}.onboard-form .field{margin-top:0}dialog{width:min(92vw,480px);max-height:85dvh;overflow:auto;border:1px solid var(--line);border-radius:22px;padding:25px;color:var(--ink);background:var(--surface);box-shadow:0 25px 100px #20103d33}dialog::backdrop{background:#21182d55;backdrop-filter:blur(3px)}dialog header{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:20px}dialog h2{font-size:20px;margin:0}dialog header button{font-size:12px;padding:7px}dialog p{font-size:14px;color:var(--muted)}dialog textarea{width:100%;padding:12px;border:1px solid var(--line);border-radius:10px;margin:10px 0;resize:vertical;font-size:12px;color:var(--ink);background:var(--soft)}dialog form>.primary{width:100%;margin-top:12px}.choices label{display:flex;align-items:center;gap:12px;padding:12px 0}.choices input{width:18px;height:18px;accent-color:var(--accent)}.member{display:flex;align-items:center;justify-content:space-between;padding:10px 0}.safety{font-size:15px;line-height:1.8;white-space:pre-wrap;text-align:center;background:var(--soft);padding:18px;border-radius:14px} |
| @media(max-width:700px){.shell{display:block}.sidebar,.conversation{height:100dvh}.sidebar-header{padding-top:max(22px,env(safe-area-inset-top))}.empty-pane{display:none}.conversation-open .sidebar{display:none}.shell:not(.conversation-open) .conversation{display:none}.chat-header{height:auto;min-height:76px;padding:14px 16px;padding-top:max(14px,env(safe-area-inset-top));gap:10px}.back{display:block;font-size:32px;padding:0 5px}.chat-header .avatar{width:37px;height:37px;font-size:16px}.chat-title p{font-size:10px}.chat-header>.quiet:last-child{font-size:12px;padding:8px}.messages{padding:10px 15px}.message{max-width:85%}.composer{padding:12px 13px max(14px,env(safe-area-inset-bottom));gap:8px}.welcome h1{font-size:37px}.account-menu{padding-bottom:max(16px,env(safe-area-inset-bottom))}} |
| @media(prefers-color-scheme:dark){:root{--accent:#7965de;--ink:#f0edf8;--muted:#aaa3bb;--line:#302b3c;--surface:#211d2a;--soft:#30283f;color:var(--ink);background:#19161f;color-scheme:dark}.empty-pane,.welcome-wrap{background:radial-gradient(ellipse at center,#302440,#19161f 75%)}.conversation{background:#19161f}input[type=search],.composer textarea{background:#2a2433}.thread-row.active{background:#372c4d}.avatar{background:#423258;color:#d4c2ef}.message.theirs{border-color:#383044}.lead{color:#c2b6d3}.field input{border-color:#493d59}.error,.danger{color:#f7a5b2}} |
| +/* 🤖 Setup gets its own stage. The travelling light is indeterminate, not a fabricated percentage. */ |
| +.setup-progress{width:min(100%,520px);padding:24px 0}.setup-progress>.mark{width:54px;height:54px;margin-bottom:44px}.setup-eyebrow{text-transform:uppercase;letter-spacing:.16em;font-size:11px;font-weight:700;color:var(--accent);margin-bottom:18px}.setup-progress h1{font-size:clamp(38px,7vw,60px);line-height:1.06;letter-spacing:-2.4px;margin:0 0 48px;outline:none}.infinity-meter{position:relative;padding-right:60px;margin-bottom:44px}.infinity-track{position:relative;height:12px;border-radius:20px;background:var(--soft);box-shadow:inset 0 0 0 1px var(--line);overflow:hidden}.infinity-trail{position:absolute;inset:0 auto 0 0;width:44%;border-radius:inherit;background:linear-gradient(90deg,transparent,var(--accent));animation:infinity-travel 2.8s cubic-bezier(.4,0,.2,1) infinite}.infinity-ticks{display:flex;justify-content:space-between;padding:12px 3px 0;font-size:11px;font-variant-numeric:tabular-nums;color:var(--muted)}.infinity-ticks span:last-child{font-weight:700;color:var(--accent)}.infinity-beyond{position:absolute;right:0;top:-20px;color:var(--accent);font-size:54px;line-height:1;animation:infinity-glow 2.8s ease-in-out infinite}.infinity-meter::after{content:'';position:absolute;right:41px;top:5px;width:17px;border-top:2px dotted var(--accent);opacity:.35}.setup-status{min-height:86px}.setup-status-title{font-size:19px;font-weight:600;letter-spacing:-.35px;margin:0 0 6px}.setup-status-detail{font-size:14px;color:var(--muted);margin:0}.setup-footnote{margin:30px 0 0;font-size:12px;color:var(--muted)}[data-phase=opening] .infinity-trail{animation-duration:1.5s}@keyframes infinity-travel{0%{transform:translateX(-110%)}100%{transform:translateX(340%)}}@keyframes infinity-glow{0%,100%{opacity:.45}55%{opacity:1}}@media(prefers-reduced-motion:reduce){.infinity-trail{animation:none;left:28%;background:var(--accent);opacity:.65}.infinity-beyond{animation:none}}@media(max-width:420px){.setup-progress h1{letter-spacing:-1.6px}.setup-progress>.mark{margin-bottom:34px}.setup-footnote{max-width:240px}.setup-progress{padding:12px 0}} |
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs| index 94e867b..a68bddd 100644 |
| --- a/test/infinity/browser.mjs |
| +++ b/test/infinity/browser.mjs |
| @@ -19,10 +19,14 @@ try{ |
| await stalled.goto(origins[0]);await stalled.getByLabel('Your name',{exact:true}).fill('Timeout test'); |
| const pending=stalled.waitForRequest('**/api/registration-challenge'); |
| await stalled.getByRole('button',{name:'Create your account',exact:true}).click();await pending; |
| + await stalled.getByRole('heading',{name:/To infinity/}).waitFor(); |
| + assert.equal(await stalled.locator('.onboard-form').count(),0); |
| + assert.equal(await stalled.getByRole('progressbar').getAttribute('aria-valuenow'),null); |
| const nonce=await stalled.evaluate(()=>sessionStorage.getItem('infinity.signup')); |
| await stalled.clock.fastForward(31000); |
| await stalled.getByRole('alert').filter({hasText:'The connection took too long.'}).waitFor(); |
| assert.equal(await stalled.getByRole('button',{name:'Create your account',exact:true}).isEnabled(),true); |
| + assert.equal(await stalled.getByLabel('Your name',{exact:true}).inputValue(),'Timeout test'); |
| const retry=stalled.waitForRequest('**/api/registration-challenge'); |
| await stalled.getByRole('button',{name:'Create your account',exact:true}).click();await retry; |
| assert.equal(await stalled.evaluate(()=>sessionStorage.getItem('infinity.signup')),nonce); |