Let setup progress build slowly and launch beyond the bar on readiness
Carry animation time through the personal-account handoff, reserve more space for infinity, and only trigger the completion burst after setup succeeds.
Co-Authored-By: Codex <codex@openai.com>
5 files changed,
+42
−7
docs/infinity/status.md+4 −0internal/infinity/static/app.js+7 −2internal/infinity/static/onboarding.js+2 −1internal/infinity/static/setup-progress.js+26 −4internal/infinity/static/style.css+3 −0
Intent
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| index 5c84251..ad1b0b5 100644 |
| --- a/docs/infinity/status.md |
| +++ b/docs/infinity/status.md |
| @@ -43,3 +43,7 @@ Source work is on infinity-v1 in the isolated .worktrees/infinity checkout. The |
| 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. |
| The setup interlude is live in both apps at 2017ba3d. Go tests/vet, Chromium/WebKit flows and nine crypto/protocol checks passed. Desktop, mobile, dark-mode and reduced-motion previews were inspected. A fresh deployed WebKit signup verified the dedicated screen, indeterminate meter, available live announcements and successful personal-account handoff. |
| + |
| +## Progress animation refinement — 2026-09-06 (Codex) |
| + |
| +At Paul’s request, infinity sits farther beyond the bar and the fill advances slowly during setup instead of looping. This is an estimated visual cue, with no numerical percentage presented; it remains below the end until readiness is confirmed. The start time travels with the existing setup fragment to keep motion continuous across the account handoff. Successful personal setup triggers an accelerating 800 ms burst beyond the bar to infinity before opening chat. Reduced motion skips that animation. Chromium/WebKit checks verified increasing fill, the pending cap, extra spacing, completion overshoot and no mobile overflow. |
diff --git a/internal/infinity/static/app.js b/internal/infinity/static/app.js| index b08ff23..c2188fb 100644 |
| --- a/internal/infinity/static/app.js |
| +++ b/internal/infinity/static/app.js |
| @@ -19,8 +19,13 @@ 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); |
| + let progress; |
| + if (params.setup) { |
| + const transfer = JSON.parse(new TextDecoder().decode(unb64(params.setup))); |
| + progress = setupProgress(root, { startedAt: transfer.progressStarted }); progress.update('opening'); |
| + } |
| + const setup = await finishSetup(params); |
| + if (setup) { await progress.complete(); return ready(setup); } |
| const identity = readIdentity(); |
| if (identity && identity.origin === location.origin) return ready(new Client(identity)); |
| // Invitations open on the inviter's origin. Move to the public front door or an existing account. |
diff --git a/internal/infinity/static/onboarding.js b/internal/infinity/static/onboarding.js| index 8064cd4..288f36d 100644 |
| --- a/internal/infinity/static/onboarding.js |
| +++ b/internal/infinity/static/onboarding.js |
| @@ -39,6 +39,7 @@ export function welcome(root, params, ready) { |
| 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); |
| + await progress.complete(); |
| history.replaceState(null, '', '/' + (params.invite ? '#invite=' + encodeURIComponent(params.invite) : '')); ready(c); return; |
| } |
| const nonce = sessionStorage.getItem('infinity.signup') || random(); sessionStorage.setItem('infinity.signup', nonce); |
| @@ -56,7 +57,7 @@ export function welcome(root, params, ready) { |
| 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 }))); |
| + const setup = b64(new TextEncoder().encode(JSON.stringify({ identity: id, claim: result.claim, invite: params.invite, progressStarted: progress.startedAt }))); |
| location.assign(origin + '/#setup=' + setup); |
| } catch (error) { |
| root.replaceChildren(welcomeScreen); |
diff --git a/internal/infinity/static/setup-progress.js b/internal/infinity/static/setup-progress.js| index 12dbd4b..e8d6420 100644 |
| --- a/internal/infinity/static/setup-progress.js |
| +++ b/internal/infinity/static/setup-progress.js |
| @@ -1,4 +1,4 @@ |
| -// 🤖 An indeterminate infinity meter; milestones follow real setup work, not a timer. |
| +// 🤖 Estimated setup motion stays below the end; only confirmed readiness launches it beyond. |
| import { el, mark } from './dom.js'; |
| const phases = { |
| checking: ['Counting past eight…', 'Checking this device.'], |
| @@ -6,7 +6,10 @@ const phases = { |
| 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) { |
| +export function setupProgress(root, { startedAt = Date.now() } = {}) { |
| + const now = Date.now(); |
| + if (!Number.isFinite(startedAt) || startedAt > now || startedAt < now - 600000) startedAt = now; |
| + const reduced = matchMedia('(prefers-reduced-motion: reduce)'); |
| 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' }); |
| @@ -20,12 +23,31 @@ export function setupProgress(root) { |
| 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 fill = content.querySelector('.infinity-trail'); |
| + const growth = reduced.matches ? null : fill.animate([ |
| + { width: '2%', offset: 0 }, { width: '30%', offset: .05 }, |
| + { width: '55%', offset: .2 }, { width: '76%', offset: .5 }, { width: '88%', offset: 1 }, |
| + ], { duration: 120000, fill: 'forwards', easing: 'linear' }); |
| + if (growth) growth.currentTime = Math.min(now - startedAt, 120000); |
| + let current, completion; |
| 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 }; |
| + const complete = () => completion ||= (async () => { |
| + const width = getComputedStyle(fill).width; |
| + growth?.cancel(); content.dataset.phase = 'complete'; |
| + heading.textContent = 'To infinity!'; detail.textContent = 'Your account is ready.'; |
| + meter.setAttribute('aria-label', 'Your private account is ready'); |
| + meter.setAttribute('aria-valuenow', '100'); |
| + if (reduced.matches) return; |
| + const launch = fill.animate([ |
| + { width, offset: 0 }, { width: '100%', offset: .4 }, |
| + { width: 'calc(100% + var(--infinity-runway) - 22px)', offset: 1 }, |
| + ], { duration: 800, easing: 'cubic-bezier(.6,0,1,.5)', fill: 'forwards' }); |
| + await launch.finished.catch(() => {}); |
| + })(); |
| + return { update, complete, startedAt }; |
| } |
diff --git a/internal/infinity/static/style.css b/internal/infinity/static/style.css| index 5f783c8..ba989c8 100644 |
| --- a/internal/infinity/static/style.css |
| +++ b/internal/infinity/static/style.css |
| @@ -5,3 +5,6 @@ |
| @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}} |
| + |
| +/* 🤖 The runway gives infinity its own space beyond the conventional end. */ |
| +.infinity-meter{--infinity-runway:120px;padding-right:var(--infinity-runway)}.infinity-track{overflow:visible}.infinity-trail{width:2%;animation:none;background:linear-gradient(90deg,#a997f0,var(--accent));box-shadow:0 0 14px #7965de20}.infinity-beyond{animation:none;opacity:.75}.infinity-meter::after{right:49px;width:calc(var(--infinity-runway) - 49px)}[data-phase=complete] .infinity-beyond{opacity:1;text-shadow:0 0 22px #7965de66}[data-phase=complete] .infinity-trail{width:calc(100% + var(--infinity-runway) - 22px)}@media(max-width:420px){.infinity-meter{--infinity-runway:96px}}@media(prefers-reduced-motion:reduce){.infinity-trail{left:0;width:45%;opacity:1}} |