diff --git a/apps/host/src/main.ts b/apps/host/src/main.ts
index b7f3d67a..f25e667e 100644
--- a/apps/host/src/main.ts
+++ b/apps/host/src/main.ts
@@ -25,7 +25,8 @@ import {
captureException,
} from "@dotli/metrics/sentry";
import {
- showStatus,
+ trackStatus,
+ setLoadingDomain,
showError,
showNoContentError,
showLanding,
@@ -1160,12 +1161,44 @@ 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.
+ // 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: startLabel, base: 2, target: 6, expectedMs: 650 },
- { label: "Adding relay chain", base: 6, target: 10, expectedMs: 120 },
- { label: "Syncing Asset Hub", base: 10, target: 55, expectedMs: 6500 },
- { label: "Resolving", base: 55, target: 62, expectedMs: 1200 },
- { label: "Fetching content", base: 62, target: 95, expectedMs: 10000 },
+ {
+ 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",
+ },
];
if (chainBackend === "smoldot-shared-worker") {
initPhases(smoldotPhases("Starting Worker"));
@@ -1175,9 +1208,27 @@ async function main(): Promise {
// Gateway path resolves over RPC with no smoldot sync, then fetches
// content the same way every backend does.
initPhases([
- { label: "Connecting", base: 5, target: 50, expectedMs: 1200 },
- { label: "Resolving", base: 50, target: 62, expectedMs: 1200 },
- { label: "Fetching content", base: 62, target: 95, expectedMs: 10000 },
+ {
+ 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",
+ },
]);
}
// Content fetch (bitswap/IPFS) runs in the sandbox after the CID resolves and
@@ -1185,8 +1236,9 @@ 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);
- showStatus(`Resolving ${withActiveTld(label)}`);
+ trackStatus(`Resolving ${withActiveTld(label)}`);
try {
const cachedCid = cacheSettings.skipCidCache
@@ -1320,7 +1372,7 @@ async function main(): Promise {
advancePhase(3);
}
emitPhase(msg, phase ?? "progress");
- showStatus(msg);
+ trackStatus(msg);
};
cid = await resolveDotNameRemote(`app.${label}`, onResolveProgress);
if (cid === null) {
@@ -1340,7 +1392,7 @@ async function main(): Promise {
await import("@dotli/resolver/rpc-resolve");
const onResolveProgress = (msg: string): void => {
emitPhase(msg, "progress");
- showStatus(msg);
+ trackStatus(msg);
};
cid = await resolveDotNameViaRpc(`app.${label}`, onResolveProgress);
if (cid === null) {
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 269f8b28..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;
@@ -275,16 +493,14 @@ function clearSlowWarning(): void {
}
/**
- * Update the single status line below the progress bar.
- * Replaces the previous message in place. No new DOM elements are created.
- * Schedules a slow-step hint if the step exceeds its time threshold.
+ * Note the step a status message describes, without putting it on screen.
+ *
+ * The slow-step hints are keyed on the resolver's own wording, so they still
+ * need every message. The headline is the phase label's instead. Painting the
+ * raw prose here overwrote that label in the tick it was set, so the labels
+ * never reached the screen at all.
*/
-export function showStatus(message: string): void {
- const status = document.getElementById("status");
- if (status !== null) {
- status.textContent = message;
- }
-
+export function trackStatus(message: string): void {
clearSlowWarning();
const threshold = getSlowThreshold(message);
@@ -313,6 +529,7 @@ export function showStatus(message: string): void {
*/
export function stopStatusTick(): void {
stopProgressCrawl();
+ stopStageMessages();
clearSlowWarning();
}
@@ -322,6 +539,7 @@ export function stopStatusTick(): void {
*/
export function dismissLoading(): void {
completeProgress();
+ stopStageMessages();
clearSlowWarning();
const loading = document.querySelector("#app > .loading");
if (loading !== null) {
@@ -361,7 +579,15 @@ export function listenForSandboxStatus(): void {
return;
}
if (typeof data.message === "string") {
- showStatus(data.message);
+ 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();