From c10c6f78c6c93ad1bfcb182f43e8e86d8ed914a7 Mon Sep 17 00:00:00 2001 From: Tiago Tavares Date: Mon, 17 Aug 2026 11:51:25 +0100 Subject: [PATCH 1/2] feat(ui): let the loading screen speak in the visitor's words and match the desktop logo size --- apps/host/index.html | 2 +- apps/host/src/main.ts | 48 ++++++++++++++++++++++++++++--------------- packages/ui/src/ui.ts | 18 ++++++++-------- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/apps/host/index.html b/apps/host/index.html index 7d834c9d..2f3687c4 100644 --- a/apps/host/index.html +++ b/apps/host/index.html @@ -260,7 +260,7 @@

Login with Polkadot Mobile

-

Reaching out

+ + +

diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts index dbff4263..f25e667e 100644 --- a/apps/host/src/main.ts +++ b/apps/host/src/main.ts @@ -26,6 +26,7 @@ import { } from "@dotli/metrics/sentry"; import { trackStatus, + setLoadingDomain, showError, showNoContentError, showLanding, @@ -1160,38 +1161,74 @@ async function main(): Promise { // peers) cap at the band top and let the sheen carry motion rather than // inflating the pace. Both smoldot backends share one model. See // `advancePhase` mapping below. - // Labels are what the visitor reads while they wait, so they name the step - // in their terms rather than ours. Which of the two smoldot backends is - // running is not something a visitor can act on, so both open the same way. - const smoldotPhases = (): LoadingPhase[] => [ - { label: "Reaching out", base: 2, target: 6, expectedMs: 650 }, - { label: "Connecting to Polkadot", base: 6, target: 10, expectedMs: 120 }, + // The label names the step for us. What the visitor reads is the stage's + // copy, which says the same thing in words they can act on. + const smoldotPhases = (startLabel: string): LoadingPhase[] => [ { - label: `Looking up ${withActiveTld(label)}`, + label: startLabel, + base: 2, + target: 6, + expectedMs: 650, + stage: "starting", + }, + { + label: "Adding relay chain", + base: 6, + target: 10, + expectedMs: 120, + stage: "relay", + }, + { + label: "Syncing Asset Hub", base: 10, target: 55, expectedMs: 6500, + stage: "assetHub", + }, + { + label: "Resolving", + base: 55, + target: 62, + expectedMs: 1200, + stage: "resolving", + }, + { + label: "Fetching content", + base: 62, + target: 95, + expectedMs: 10000, + stage: "content", }, - { label: "Found it", base: 55, target: 62, expectedMs: 1200 }, - { label: "Downloading the app", base: 62, target: 95, expectedMs: 10000 }, ]; - if ( - chainBackend === "smoldot-shared-worker" || - chainBackend === "smoldot-direct" - ) { - initPhases(smoldotPhases()); + if (chainBackend === "smoldot-shared-worker") { + initPhases(smoldotPhases("Starting Worker")); + } else if (chainBackend === "smoldot-direct") { + initPhases(smoldotPhases("Starting")); } else { // Gateway path resolves over RPC with no smoldot sync, then fetches // content the same way every backend does. initPhases([ { - label: "Connecting to Polkadot", + label: "Connecting", base: 5, target: 50, expectedMs: 1200, + stage: "relay", + }, + { + label: "Resolving", + base: 50, + target: 62, + expectedMs: 1200, + stage: "resolving", + }, + { + label: "Fetching content", + base: 62, + target: 95, + expectedMs: 10000, + stage: "content", }, - { label: "Found it", base: 50, target: 62, expectedMs: 1200 }, - { label: "Downloading the app", base: 62, target: 95, expectedMs: 10000 }, ]); } // Content fetch (bitswap/IPFS) runs in the sandbox after the CID resolves and @@ -1199,6 +1236,7 @@ async function main(): Promise { // It is always the last phase; advance to it just before handing off to the // sandbox render. const contentFetchPhase = chainBackend === "rpc-gateway" ? 2 : 4; + setLoadingDomain(label); advancePhase(0); trackStatus(`Resolving ${withActiveTld(label)}`); diff --git a/packages/ui/src/styles/base.css b/packages/ui/src/styles/base.css index e42d19fd..a08b11e3 100644 --- a/packages/ui/src/styles/base.css +++ b/packages/ui/src/styles/base.css @@ -148,9 +148,24 @@ body { line-height: 1.3; text-align: center; width: 100%; - white-space: nowrap; + /* The explanatory lines run past one line at this width, and clipping them + to an ellipsis would defeat the point of showing them. Two lines are + reserved so the bar above does not shift as a sentence wraps. */ + text-wrap: balance; + min-height: 2.6em; +} +/* Off-screen but still announced. `display: none` and `visibility: hidden` + would take it out of the accessibility tree too. */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; overflow: hidden; - text-overflow: ellipsis; + clip-path: inset(50%); + white-space: nowrap; + border: 0; } /* Slow-step hint (shown below status when a step exceeds its threshold) */ .loading-hint { diff --git a/packages/ui/src/ui.ts b/packages/ui/src/ui.ts index b8e06cbe..f007d8d7 100644 --- a/packages/ui/src/ui.ts +++ b/packages/ui/src/ui.ts @@ -41,6 +41,8 @@ export interface LoadingPhase { base: number; target: number; expectedMs: number; + /** Which set of messages narrates this phase. */ + stage: LoadingStage; } let phases: LoadingPhase[] = []; let currentPhase = -1; @@ -97,11 +99,18 @@ export function completeProgress(): void { export function initPhases(phaseList: LoadingPhase[]): void { phases = phaseList; currentPhase = -1; + currentStageIndex = -1; + openingLine = true; currentProgress = 0; targetProgress = 0; progressFillEl = document.getElementById("loading-progress-fill"); progressPctEl = document.getElementById("loading-progress-pct"); + + // Not on the first `advancePhase`, which lands seconds later once the + // protocol frame is up. The markup already shows this stage's opening line, + // so the rotation clock has to start from when that line became visible. + setLoadingStage("starting"); } /** @@ -117,7 +126,7 @@ export function advancePhase(index: number): void { currentPhase = index; // Update progress bar - const { base, target, label, expectedMs } = phases[index]; + const { base, target, expectedMs } = phases[index]; if (base > currentProgress) { setProgress(base); } @@ -130,11 +139,220 @@ export function advancePhase(index: number): void { ((target - base) * CRAWL_TICK_MS) / Math.max(expectedMs, CRAWL_TICK_MS); startProgressCrawl(); - // Update headline + // The headline is the stage's, not the phase label's: the label names the + // step for us, the stage says it in words the visitor can act on. Adjacent + // phases can share one stage, and re-entering a running stage is a no-op. + setLoadingStage(phases[index].stage); +} + +// How often the line turns over. Most of this window is the turnover +// animation, so the finished sentence itself is only still for the last ~2.7s. +const MESSAGE_ROTATE_MS = 6_500; + +/** Placeholder swapped for the domain being loaded when a message is shown. */ +const DOMAIN_TOKEN = "{domain}"; + +function prefersReducedMotion(): boolean { + return window.matchMedia("(prefers-reduced-motion: reduce)").matches; +} + +/** The steps a load moves through, in the order they happen. */ +export const LOADING_STAGES = [ + "starting", + "relay", + "assetHub", + "resolving", + "content", + "preparing", +] as const; +export type LoadingStage = (typeof LOADING_STAGES)[number]; + +/** + * What the shell is doing, in the user's terms. + * + * The first line of each stage names the step. The rest explain what a light + * client is doing, and only a slow load reaches them. List lengths follow how + * long each step runs, so the long ones do not repeat. + */ +const STAGE_MESSAGES: Record = { + starting: [ + "Reaching out", + "This page comes from a network, with no one in between", + "That takes a few seconds the first time", + ], + relay: [ + "Connecting to Polkadot", + "Looking for other computers to talk to", + "Your browser does the checking itself, not a server", + ], + assetHub: [ + `Looking up ${DOMAIN_TOKEN}`, + "Catching up on the newest blocks", + "The network itself decides where this name points", + "This is the slow part, and it is faster next time", + ], + resolving: [ + "Found it", + "Reading where the name points", + "The network proved this answer, so it cannot be faked", + ], + content: [ + "Downloading the app", + "The files come from many computers at once", + "The more of them are nearby, the faster this goes", + "Every piece is checked against its fingerprint as it lands", + "No single computer holds the app, so no one can take it down", + "Bigger apps take longer the first time", + "Your browser keeps a copy, so the next visit is quick", + ], + preparing: [ + "Got everything", + "Unpacking the files", + "Handing over to the app", + "Almost there", + ], +}; + +let stageTimer: ReturnType | null = null; +let currentStageIndex = -1; +/** True until the first stage turn, which the markup already painted. */ +let openingLine = true; +let loadingDomain = ""; + +/** Name the domain being loaded, for the messages that mention it. */ +export function setLoadingDomain(domain: string): void { + loadingDomain = domain; +} + +// A fixed budget rather than a per-character delay, so a long sentence +// animates at the same pace as a short one and always lands inside the +// rotation interval. +const ERASE_MS = 1_000; +const TYPE_MS = 2_800; +let typingFrame: number | null = null; +let pendingMessage: string | null = null; + +/** Slow at both ends, quickest in the middle. */ +function easeInOut(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; +} + +function cancelTyping(): void { + pendingMessage = null; + if (typingFrame !== null) { + cancelAnimationFrame(typingFrame); + typingFrame = null; + } +} + +function writeStatus(message: string): void { const status = document.getElementById("status"); - if (status !== null) { - status.textContent = label; + if (status === null) { + return; + } + // Falls back to "the name" when no domain has been set, which is the + // preview and local-target paths where there is no `.dot` to name. + const next = message.replace( + DOMAIN_TOKEN, + loadingDomain === "" ? "the name" : `${loadingDomain}.dot`, + ); + // Screen readers get the whole sentence once, from an element the typing + // never touches. + const announce = document.getElementById("status-sr"); + if (announce !== null) { + announce.textContent = next; } + // A sentence already being typed is left to finish. Stages turn over faster + // than a line takes to render on a quick load, and cutting one off mid-word + // meant a step's opening line was never actually read: the screen went + // straight from "Reaching out" to the download copy. Only the newest + // waiting sentence is kept, so the queue can never fall behind by more + // than one. + if (typingFrame !== null) { + pendingMessage = next; + return; + } + const previous = status.textContent; + if (next === previous || prefersReducedMotion()) { + status.textContent = next; + return; + } + const start = performance.now(); + const step = (now: number): void => { + const elapsedMs = now - start; + if (elapsedMs < ERASE_MS) { + const gone = easeInOut(elapsedMs / ERASE_MS); + status.textContent = previous.slice( + 0, + Math.ceil(previous.length * (1 - gone)), + ); + } else if (elapsedMs < ERASE_MS + TYPE_MS) { + const shown = easeInOut((elapsedMs - ERASE_MS) / TYPE_MS); + status.textContent = next.slice(0, Math.ceil(next.length * shown)); + } else { + status.textContent = next; + typingFrame = null; + if (pendingMessage !== null) { + const queued = pendingMessage; + pendingMessage = null; + writeStatus(queued); + } + return; + } + typingFrame = requestAnimationFrame(step); + }; + typingFrame = requestAnimationFrame(step); +} + +/** + * Move to a stage and start cycling its messages. + * + * Only ever moves forward. Re-entering the running stage is ignored so the + * copy does not restart on every signal for a step already underway, and an + * earlier stage is refused so a late event cannot walk the story backwards. + */ +export function setLoadingStage(stage: LoadingStage): void { + const stageIndex = LOADING_STAGES.indexOf(stage); + if (stageIndex <= currentStageIndex) { + return; + } + currentStageIndex = stageIndex; + const messages = STAGE_MESSAGES[stage]; + let line = 0; + // Only the rotation clock is stopped here. Cancelling the typing as well + // would kill the animation this very line was just queued behind and drop + // the queue with it, stranding the headline on a half-typed word. + stopStageTimer(); + writeStatus(messages[0]); + // Cycle back to the second line rather than the first: the opener names + // the step, and showing it again would read as the load starting over. + const loopFrom = messages.length > 2 ? 1 : 0; + // The opening line has been on screen since the page painted, so its turn + // is due relative to that, not to whenever this ran. Later turns get the + // full interval. + const firstDelay = openingLine + ? Math.max(500, MESSAGE_ROTATE_MS - performance.now()) + : MESSAGE_ROTATE_MS; + openingLine = false; + const turn = (): void => { + line = line + 1 >= messages.length ? loopFrom : line + 1; + writeStatus(messages[line]); + stageTimer = setTimeout(turn, MESSAGE_ROTATE_MS); + }; + stageTimer = setTimeout(turn, firstDelay); +} + +function stopStageTimer(): void { + if (stageTimer !== null) { + clearTimeout(stageTimer); + stageTimer = null; + } +} + +/** Stop narrating entirely: no more turns, and no line half-written. */ +function stopStageMessages(): void { + stopStageTimer(); + cancelTyping(); } export const GATEWAY_ESCAPE_DELAY_MS = 10_000; @@ -311,6 +529,7 @@ export function trackStatus(message: string): void { */ export function stopStatusTick(): void { stopProgressCrawl(); + stopStageMessages(); clearSlowWarning(); } @@ -320,6 +539,7 @@ export function stopStatusTick(): void { */ export function dismissLoading(): void { completeProgress(); + stopStageMessages(); clearSlowWarning(); const loading = document.querySelector("#app > .loading"); if (loading !== null) { @@ -360,6 +580,14 @@ export function listenForSandboxStatus(): void { } if (typeof data.message === "string") { trackStatus(data.message); + // The tail of the load is the sandbox unpacking the archive and + // painting, which otherwise hides behind download copy while the bar + // creeps. Only the gateway path announces it. The bitswap path has no + // unpack signal here yet, so it stays on the download copy until the + // sandbox reports done. + if (data.message.startsWith("Parsing content")) { + setLoadingStage("preparing"); + } } if (data.done === true) { dismissLoading();