eleven / ocho Public

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>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev 1b91749f913dee9a1505143f06ad9cbec02a3786 parent 29edccd
6 files changed, +57 −4
  • docs/infinity/status.md +4 −0
  • internal/infinity/static/app.js +2 −0
  • internal/infinity/static/onboarding.js +14 −4
  • internal/infinity/static/setup-progress.js +31 −0
  • internal/infinity/static/style.css +2 −0
  • test/infinity/browser.mjs +4 −0
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);