diff --git a/docs/plans/splash_mascot.md b/docs/plans/splash_mascot.md
new file mode 100644
index 00000000..13c5c609
--- /dev/null
+++ b/docs/plans/splash_mascot.md
@@ -0,0 +1,130 @@
+# Splash & mascot — open notes
+
+State of play after the connection-indicator (`SyncIndicator`) work landed.
+Anything in this file is **deferred / future work**, not active. Treat it as
+context for the next person to pick up the splash topic.
+
+## Current visible chain (cold load, web/desktop)
+
+1. **Initial blank** — HTML loaded, React mounts. ~50ms. `body` background
+ is `#0d0e11` (the `--vojo-safe-area-bg` var), so it's a dark frame, not
+ white.
+2. **`ConfigConfigLoading` mascot splash** —
+ [`pages/ConfigConfig.tsx`](../../src/app/pages/ConfigConfig.tsx) renders
+ `` (mascot + footer) while `/config.json` is fetched.
+ ~50–300ms on Caddy-served static.
+3. **`SpecVersions` mascot splash** —
+ [`pages/client/SpecVersions.tsx`](../../src/app/pages/client/SpecVersions.tsx)
+ renders `` while `/_matrix/client/versions` is fetched
+ from the homeserver. **0.5–2s, network-bound, the longest of the chain.**
+4. **`ClientRoot` mascot splash** —
+ [`pages/client/ClientRoot.tsx`](../../src/app/pages/client/ClientRoot.tsx)
+ `loading || !mx ? : ...`. Shown while `initClient`
+ runs (IndexedDB open + crypto WASM init, ~300–700ms) AND while waiting
+ for `SyncState.Prepared` (the `useSyncState` gate at line ~200). The
+ Prepared wait can be 1–30s depending on cache warmth and account size.
+5. App tree mounts. `SyncIndicator` takes over for the residual sync
+ activity (green slide while sync is still settling, hidden once Syncing).
+
+## Current visible chain (Android native)
+
+1. **OS system splash** — Android 12+ enforces a system splash with the
+ launcher icon centered over `windowBackground`. Configured via
+ [`android/app/src/main/res/values/styles.xml`](../../android/app/src/main/res/values/styles.xml)
+ `AppTheme.NoActionBarLaunch` → `windowBackground=@android:color/black`.
+ Cannot be fully eliminated; ~300–500ms while Android starts the process
+ and Capacitor loads the WebView.
+2. Then the web chain (steps 1–5 above).
+
+So an Android cold-launch sees: black-icon (OS) → black blank (HTML) →
+mascot (config + spec-versions + initClient + Prepared) → app.
+
+## What we tried and reverted (during the SyncIndicator work)
+
+In the 0.3.0 attempt that got reverted, we made several aggressive changes
+to shorten the chain. They worked, but introduced subtle bugs that the user
+chose to roll back to keep the diff clean. Specifically:
+
+### A. SpecVersions made non-blocking
+
+[Commit reverted in `ff01e6c`.] The change made `SpecVersions` render
+children immediately with `versions: []` and hydrate the real value in the
+background (with retry on `online` event). This eliminated the 0.5–2s
+mascot in step 3.
+
+**Why reverted**: it surfaced a latent bug in
+[`components/message/content/ImageContent.tsx`](../../src/app/components/message/content/ImageContent.tsx)
+and `VideoContent.tsx`. While `versions: []`, `useMediaAuthentication()`
+returns false, autoPlay images load with unauth URLs, the server rejects,
+the local `error=true` flag latches and is never cleared on the post-
+hydration retry. The reviewer flagged this as a blocker.
+
+**Fix attempted**: reset `error` and `load` state in the `loadSrc`-deps
+useEffect AND in `handleLoad` — both `ImageContent.tsx` and
+`VideoContent.tsx`. The fix was correct but the user judged it as out-of-
+scope scope-creep for the connection-indicator feature and rolled the
+whole change back.
+
+**To redo cleanly**: ship SpecVersions-non-blocking and the
+ImageContent/VideoContent fix together, in a separate dedicated commit
+(scope = "make boot non-blocking", not bundled with the indicator).
+
+### B. ClientRoot loading paths replaced with `null`
+
+[`pages/ConfigConfig.tsx::ConfigConfigLoading`](../../src/app/pages/ConfigConfig.tsx)
+returned `null` instead of ``. Same for the `!mx` branch
+in `ClientRoot`. Eliminated the mascot during steps 2 and 4-init-only.
+Reverted as part of the same revert.
+
+**To redo**: low risk on its own. Change those two callsites to render
+`null` (the dark body background shows through). Skip the Prepared gate
+removal (that's the deeper change).
+
+### C. ClientRoot Prepared gate removed
+
+The ambitious move: render `MatrixClientProvider` as soon as `mx` is created
+(after `initClient`), not after `Prepared`. Eliminates the 1–30s wait for
+first sync — app shell renders against the IndexedDB cache while sync
+populates new events in background.
+
+**Why reverted**: needed an extra fix in
+[`pages/client/direct/RoomProvider.tsx`](../../src/app/pages/client/direct/RoomProvider.tsx)
+to add `useAtomValue(allRoomsAtom)` so cold-start deep-links (push tap)
+re-render when the room arrives. Plus arguably exposes a brief empty-UI
+flash on first-time logins.
+
+**To redo**: do it after a careful audit of every consumer of
+`mx.getRoom()` / `mx.getRooms()` to make sure they all tolerate empty
+stores. The deep-link re-render fix in `direct/RoomProvider.tsx` is the
+mechanical trigger; there may be others.
+
+## What's open
+
+In rough priority order:
+
+1. **SpecVersions non-blocking + media-auth race fix** (B above).
+ Biggest win for cold-start time-to-interactive (~1.5s saved). Bundle
+ with the ImageContent/VideoContent error reset in one commit.
+2. **`null` instead of mascot during ConfigConfig + `!mx` loading**
+ (B above, simpler half). ~400ms shaved.
+3. **Drop the Prepared gate** (C above). Most invasive; needs full audit.
+ Could wait until `dm_1x1_redesign.md` or another major-mode change is
+ ready to absorb the risk.
+4. **OS system splash on Android** — currently iconic-on-black via
+ `AppTheme.NoActionBarLaunch`. Can't eliminate on Android 12+ but can
+ be tuned (different background color, different icon) via
+ `android/app/src/main/res/values/styles.xml`. Not user-reported as a
+ problem, just noting the lever exists.
+
+## Don't break
+
+- **`body { background: var(--vojo-safe-area-bg, #0d0e11) }`** in
+ `src/index.css` — the dark frame is what makes a `null` loading path
+ look intentional. Without it the user sees a white flash.
+- **Mascot on auth pages** (`/login`, `/register`) is INTENDED — those
+ pages design the mascot in deliberately. Don't gate-remove it there.
+ Only the loading-state mascot is the candidate for removal.
+- **The error-retry mascot in `ClientRoot`** (the
+ `{retry-dialog}` branch in
+ `ClientRoot.tsx`) is the rare case where the mascot is fine to keep —
+ it's a hard-error context that needs anchoring for the dialog.
diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx
index 9973d56d..62b9c13c 100644
--- a/src/app/pages/client/ClientRoot.tsx
+++ b/src/app/pages/client/ClientRoot.tsx
@@ -12,7 +12,7 @@ import {
RectCords,
Text,
} from 'folds';
-import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
+import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient, SyncState } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
import {
@@ -30,7 +30,7 @@ import { SpecVersions } from './SpecVersions';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
-import { SyncStatus } from './SyncStatus';
+import { SyncIndicator } from './SyncIndicator';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
@@ -179,6 +179,24 @@ export function ClientRoot({ children }: ClientRootProps) {
writeSessionBridge(mx);
}, [mx]);
+ // When the OS reports the network is back, prod the sync loop instead of
+ // waiting for matrix-js-sdk's internal keep-alive jitter (5–10s backoff
+ // per `sync.js`). Only acts when the SDK is genuinely paused on a failed
+ // connection — Error or Reconnecting — so a healthy Syncing client just
+ // ignores the event. `retryImmediately` is itself idempotent, so spurious
+ // duplicate `online` events from flaky NICs are harmless.
+ useEffect(() => {
+ if (!mx) return undefined;
+ const onOnline = () => {
+ const state = mx.getSyncState();
+ if (state === SyncState.Error || state === SyncState.Reconnecting) {
+ mx.retryImmediately();
+ }
+ };
+ window.addEventListener('online', onOnline);
+ return () => window.removeEventListener('online', onOnline);
+ }, [mx]);
+
useSyncState(
mx,
useCallback((state) => {
@@ -191,7 +209,7 @@ export function ClientRoot({ children }: ClientRootProps) {
return (
- {mx && }
+ {mx && }
{loading && }
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
diff --git a/src/app/pages/client/SyncIndicator.css.ts b/src/app/pages/client/SyncIndicator.css.ts
new file mode 100644
index 00000000..70b81fca
--- /dev/null
+++ b/src/app/pages/client/SyncIndicator.css.ts
@@ -0,0 +1,70 @@
+import { keyframes, style } from '@vanilla-extract/css';
+import { config } from 'folds';
+
+// One sweep, left → right. The bar is full-viewport-wide; the radial gradient
+// fades to transparent at both edges, so the keyframe loop from
+// `translateX(100%)` back to `translateX(-100%)` happens entirely off-screen
+// and the snap is invisible.
+const slide = keyframes({
+ '0%': { transform: 'translateX(-100%)' },
+ '100%': { transform: 'translateX(100%)' },
+});
+
+// Container is taller than the visible bar so the drop-shadow halo isn't
+// clipped by `overflow: hidden`. The bar itself sits flush to the bottom
+// edge of the safe-area inset (the 26px above is a transparent
+// pointer-events: none zone where only the glow renders).
+export const root = style({
+ position: 'fixed',
+ left: 'env(safe-area-inset-left, 0px)',
+ right: 'env(safe-area-inset-right, 0px)',
+ bottom: 'env(safe-area-inset-bottom, 0px)',
+ height: '28px',
+ pointerEvents: 'none',
+ // Z400 sits above app chrome and below folds modals/popouts (which use
+ // Max=9999). Connection state should be visible on the page, not over
+ // dialogs.
+ zIndex: config.zIndex.Z400,
+ overflow: 'hidden',
+});
+
+const barBase = style({
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ width: '100%',
+ height: '2px',
+ // 250ms opacity transition carries progress↔hidden and progress↔error
+ // crossfades smoothly. Both layers are always mounted; the React side
+ // toggles opacity.
+ transition: 'opacity 250ms ease',
+});
+
+export const barProgress = style([
+ barBase,
+ {
+ background:
+ 'radial-gradient(ellipse 50% 100% at center, #5BE3C5 0%, rgba(91, 227, 197, 0.45) 35%, rgba(91, 227, 197, 0) 70%)',
+ filter: 'drop-shadow(0 0 6px rgba(91, 227, 197, 0.8))',
+ animation: `${slide} 1.8s linear infinite`,
+ willChange: 'transform',
+ '@media': {
+ '(prefers-reduced-motion: reduce)': {
+ animation: 'none',
+ },
+ },
+ },
+]);
+
+export const barError = style([
+ barBase,
+ {
+ // Slightly brighter, denser-fade red — the gradient extends closer to
+ // the viewport edges than the green sweep so the static error visual
+ // reads as a stronger statement than the moving progress one. Same
+ // 2px height as the progress bar.
+ background:
+ 'radial-gradient(ellipse 50% 100% at center, #FF3030 0%, rgba(255, 48, 48, 0.7) 50%, rgba(255, 48, 48, 0) 90%)',
+ filter: 'drop-shadow(0 0 12px rgba(255, 48, 48, 0.95))',
+ },
+]);
diff --git a/src/app/pages/client/SyncIndicator.tsx b/src/app/pages/client/SyncIndicator.tsx
new file mode 100644
index 00000000..2570b81c
--- /dev/null
+++ b/src/app/pages/client/SyncIndicator.tsx
@@ -0,0 +1,175 @@
+import React, { useEffect, useState } from 'react';
+import { App } from '@capacitor/app';
+import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
+import { isAndroidPlatform } from '../../utils/capacitor';
+import * as css from './SyncIndicator.css';
+
+// Suppress 0-150ms green flashes during fast transient state cycles
+// (e.g. one /sync request blips Reconnecting then immediately recovers to
+// Syncing — without the delay the bar would briefly appear at whatever
+// position the slide animation happened to be at).
+const PROGRESS_FADE_IN_DELAY_MS = 150;
+
+// On detected resume, hold the visual at "progress" (green) for this long if
+// the SDK is in `Error`. Worst-case keep-alive backoff in matrix-js-sdk is
+// ~5–10s (jittered), so 10s is wide enough that we don't lose the race and
+// flash red on a stale Error that the SDK is about to clear on its own.
+const RESUME_GRACE_MS = 10_000;
+
+// Below this threshold, ignore the inactive period — it's a notification
+// pulldown, brief alt-tab, or system permission prompt, not a real lock /
+// background. 3s is conservative: anything shorter than the SDK's first
+// retry attempt is uninteresting.
+const RESUME_HIDDEN_THRESHOLD_MS = 3_000;
+
+type SyncIndicatorProps = {
+ mx: MatrixClient;
+};
+
+type Visual = 'hidden' | 'progress' | 'error';
+
+// Steady-state mapping. The real SDK state machine has more values; this
+// collapses the cases that matter for the indicator:
+// - Syncing / Stopped → connection is healthy or terminating → hidden
+// - Error → SDK gave up after 3 retries → red
+// - everything else → SDK is in transit (Reconnecting / Catchup / the
+// unrealistic post-mount null/Prepared) → green
+const stateToVisual = (state: SyncState | null): Visual => {
+ if (state === SyncState.Error) return 'error';
+ if (state === SyncState.Syncing || state === SyncState.Stopped) return 'hidden';
+ return 'progress';
+};
+
+export function SyncIndicator({ mx }: SyncIndicatorProps) {
+ const [syncState, setSyncState] = useState(() => mx.getSyncState());
+ // Timestamp until which a latched `Error` should be demoted to `progress`
+ // (green). Set on a real resume from background; cleared either by the
+ // expiry timer below or by the first `Syncing` event after resume.
+ const [resumeGraceUntil, setResumeGraceUntil] = useState(0);
+
+ useEffect(() => {
+ // Re-read after the listener is wired to close the gap between the
+ // useState initializer (synchronous render) and the effect mount
+ // (post-paint). A Sync event in that ~16ms window would otherwise be
+ // dropped.
+ setSyncState(mx.getSyncState());
+ const handler = (state: SyncState) => {
+ setSyncState(state);
+ // First Syncing after resume — recovery confirmed, drop the grace
+ // immediately rather than waiting out the timer. Avoids leaving the
+ // bar green for the residual seconds when the SDK already recovered.
+ if (state === SyncState.Syncing) {
+ setResumeGraceUntil(0);
+ }
+ };
+ mx.on(ClientEvent.Sync, handler);
+ return () => {
+ mx.removeListener(ClientEvent.Sync, handler);
+ };
+ }, [mx]);
+
+ // Lifecycle: on resume from a long-hidden state (phone unlock, app
+ // foregrounded), kick the SDK out of any latched Error with
+ // retryImmediately() and set a grace window during which the indicator
+ // shows green ("reconnecting") instead of red. Without this, an Android
+ // screen-off period kills the in-flight long-poll, the SDK retries 3×
+ // during the throttled JS window, lands on Error, and the user sees a
+ // ~5s red flash on unlock before the SDK's keep-alive jitter recovers.
+ //
+ // Listener split mirrors IncomingCallStripRenderer.tsx: Android pause/
+ // resume fire on Activity onPause/onResume (ms latency), while
+ // appStateChange ties to onStop (10–1000ms after onPause) which is too
+ // late. iOS/web have no `pause` event — `appStateChange` is the canonical
+ // signal there.
+ useEffect(() => {
+ let cancelled = false;
+ const handles: Array<{ remove: () => void }> = [];
+ const track = (h: { remove: () => void }) => {
+ if (cancelled) h.remove();
+ else handles.push(h);
+ };
+
+ let hiddenAt: number | null = null;
+
+ const onActiveChange = (isActive: boolean) => {
+ if (!isActive) {
+ hiddenAt = performance.now();
+ return;
+ }
+ if (hiddenAt === null) return;
+ const gap = performance.now() - hiddenAt;
+ hiddenAt = null;
+ if (gap < RESUME_HIDDEN_THRESHOLD_MS) return;
+ setResumeGraceUntil(performance.now() + RESUME_GRACE_MS);
+ // retryImmediately is idempotent: a no-op when the SDK isn't waiting
+ // on a scheduled retry (i.e. healthy Syncing). Cheap to call.
+ if (mx.getSyncState() !== SyncState.Syncing) {
+ mx.retryImmediately();
+ }
+ };
+
+ if (isAndroidPlatform()) {
+ App.addListener('pause', () => onActiveChange(false)).then(track);
+ App.addListener('resume', () => onActiveChange(true)).then(track);
+ } else {
+ App.addListener('appStateChange', ({ isActive }) =>
+ onActiveChange(isActive)
+ ).then(track);
+ }
+
+ return () => {
+ cancelled = true;
+ handles.forEach((h) => h.remove());
+ };
+ }, [mx]);
+
+ // Auto-clear the grace window when its timer expires. Skipped if the
+ // first-Syncing handler above already cleared it.
+ useEffect(() => {
+ if (resumeGraceUntil === 0) return undefined;
+ const remaining = resumeGraceUntil - performance.now();
+ if (remaining <= 0) {
+ setResumeGraceUntil(0);
+ return undefined;
+ }
+ const timer = setTimeout(() => setResumeGraceUntil(0), remaining);
+ return () => clearTimeout(timer);
+ }, [resumeGraceUntil]);
+
+ const baseVisual = stateToVisual(syncState);
+ // Grace window only overrides Error → progress. Other transitions
+ // (Syncing→hidden, etc) fire normally — the user's recovery is real.
+ const visual: Visual =
+ resumeGraceUntil > 0 && baseVisual === 'error' ? 'progress' : baseVisual;
+
+ // Anti-flicker debounce: the green layer only becomes visible if the
+ // progress visual is sustained for at least 150ms.
+ const [showProgress, setShowProgress] = useState(false);
+ useEffect(() => {
+ if (visual !== 'progress') {
+ setShowProgress(false);
+ return undefined;
+ }
+ const timer = setTimeout(() => setShowProgress(true), PROGRESS_FADE_IN_DELAY_MS);
+ return () => clearTimeout(timer);
+ }, [visual]);
+
+ const showError = visual === 'error';
+
+ // Both layers are always mounted; opacity controls visibility with a
+ // 250ms transition (set on barBase). `animationPlayState: 'paused'` on
+ // the green layer when invisible avoids continuous compositor work —
+ // `opacity: 0` does NOT pause CSS animations in any major browser.
+ return (
+
+ );
+}
diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx
deleted file mode 100644
index 9cd4b0b2..00000000
--- a/src/app/pages/client/SyncStatus.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-import { MatrixClient, SyncState } from 'matrix-js-sdk';
-import React, { useCallback, useState } from 'react';
-import { Box, config, Line, Text } from 'folds';
-import { useSyncState } from '../../hooks/useSyncState';
-import { ContainerColor } from '../../styles/ContainerColor.css';
-
-type StateData = {
- current: SyncState | null;
- previous: SyncState | null | undefined;
-};
-
-type SyncStatusProps = {
- mx: MatrixClient;
-};
-export function SyncStatus({ mx }: SyncStatusProps) {
- const [stateData, setStateData] = useState({
- current: null,
- previous: undefined,
- });
-
- useSyncState(
- mx,
- useCallback((current, previous) => {
- setStateData((s) => {
- if (s.current === current && s.previous === previous) {
- return s;
- }
- return { current, previous };
- });
- }, [])
- );
-
- if (
- (stateData.current === SyncState.Prepared ||
- stateData.current === SyncState.Syncing ||
- stateData.current === SyncState.Catchup) &&
- stateData.previous !== SyncState.Syncing
- ) {
- return (
-
-
- Connecting...
-
-
-
- );
- }
-
- if (stateData.current === SyncState.Reconnecting) {
- return (
-
-
- Connection Lost! Reconnecting...
-
-
-
- );
- }
-
- if (stateData.current === SyncState.Error) {
- return (
-
-
- Connection Lost!
-
-
-
- );
- }
-
- return null;
-}
diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx
index 0bad07af..f08f9f2c 100644
--- a/src/app/pages/client/direct/Direct.tsx
+++ b/src/app/pages/client/direct/Direct.tsx
@@ -1,10 +1,12 @@
-import React, { useEffect, useMemo, useRef, useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai';
import { Box, Button, Icon, Icons, Text, color, config, toRem } from 'folds';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useNavigate } from 'react-router-dom';
+import { SyncState } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { useSyncState } from '../../../hooks/useSyncState';
import { factoryRoomIdByActivity } from '../../../utils/sort';
import { NavCategory, NavEmptyCenter, NavEmptyLayout } from '../../../components/nav';
import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils';
@@ -65,6 +67,34 @@ function DirectEmpty() {
function DirectFooterStatus() {
const { t } = useTranslation();
+ const mx = useMatrixClient();
+ const [syncState, setSyncState] = useState(() => mx.getSyncState());
+ useSyncState(
+ mx,
+ useCallback((state: SyncState) => {
+ setSyncState(state);
+ }, [])
+ );
+ // Re-read post-mount to close the same render-to-effect gap as in
+ // SyncIndicator — useState initializer runs before useSyncState attaches
+ // its listener.
+ useEffect(() => {
+ setSyncState(mx.getSyncState());
+ }, [mx]);
+
+ // Steady-state mapping (same source-of-truth philosophy as SyncIndicator):
+ // - SDK Syncing → green dot, all is well
+ // - SDK Error → red dot, give-up state
+ // - everything else (transitional Reconnecting / Catchup / etc) → muted
+ // `currentColor` so the dot reads as a transitional gray under the
+ // parent's 0.45 opacity.
+ const dotBackground =
+ syncState === SyncState.Error
+ ? color.Critical.Main
+ : syncState === SyncState.Syncing
+ ? color.Success.Main
+ : 'currentColor';
+
return (