diff --git a/src/@types/engine/UseFamousMetaReturn.d.ts b/src/@types/engine/UseFamousMetaReturn.d.ts
index 1dea6036..26a88b13 100644
--- a/src/@types/engine/UseFamousMetaReturn.d.ts
+++ b/src/@types/engine/UseFamousMetaReturn.d.ts
@@ -4,4 +4,12 @@ import type { FamousXrefMap } from '../loading/FamousXrefMap';
export type UseFamousMetaReturn = {
famousMeta: readonly FamousMetaEntry[];
famousXrefs: FamousXrefMap;
+ /**
+ * True once the famous-meta fetch has settled (success OR swallowed
+ * error). Splash gating reads this to know when the Tour CTA can
+ * activate. Mirrors the fail-soft UX: a missing famous_meta.json
+ * still flips `ready` to true (with empty meta arrays) so the splash
+ * doesn't deadlock on a deployment that hasn't shipped the sidecar.
+ */
+ ready: boolean;
};
diff --git a/src/@types/splash/SplashError.d.ts b/src/@types/splash/SplashError.d.ts
new file mode 100644
index 00000000..f0f8b60d
--- /dev/null
+++ b/src/@types/splash/SplashError.d.ts
@@ -0,0 +1,22 @@
+/**
+ * SplashError — discriminated union of the three runtime failure modes
+ * the splash can surface. Each kind carries the minimum information
+ * needed to render a specific recovery affordance.
+ *
+ * - `webgpu-init-failed` → requestAdapter() returned null on a browser
+ * that has `navigator.gpu`. Show error +
+ * reload button. The synchronous "no
+ * navigator.gpu at all" case is handled in
+ * main.tsx before React mounts; it never
+ * reaches the splash.
+ * - `catalog-fetch-failed` → an essential galaxy catalog fetch failed.
+ * Show error + reload button.
+ * - `famous-meta-failed` → the famous-meta sidecar failed. Splash
+ * stays usable: Explore live, Tour disabled
+ * with a tooltip. This kind is informational,
+ * not blocking.
+ */
+export type SplashError =
+ | { kind: 'webgpu-init-failed'; message: string }
+ | { kind: 'catalog-fetch-failed'; message: string }
+ | { kind: 'famous-meta-failed' };
diff --git a/src/@types/splash/UseSplashInput.d.ts b/src/@types/splash/UseSplashInput.d.ts
new file mode 100644
index 00000000..ae6b48ee
--- /dev/null
+++ b/src/@types/splash/UseSplashInput.d.ts
@@ -0,0 +1,19 @@
+import type { EngineStatus } from '../engine/EngineStatus';
+import type { LoadProgressState } from '../loading/LoadProgressState';
+
+export type UseSplashInput = {
+ /** Engine status from `useEngine`. */
+ status: EngineStatus;
+ /** Aggregated load progress from `useEngine`. `null` when no fetches in flight. */
+ loadProgress: LoadProgressState | null;
+ /** Famous-meta `ready` flag from `useFamousMeta`. */
+ famousMetaReady: boolean;
+ /**
+ * Optional flag set by App.tsx when famous-meta is known to have failed
+ * (not just absent). Drives the splash's `famous-meta-failed` informational
+ * error — Explore stays live, Tour is disabled with a tooltip. Defaults
+ * to false; the famousMetaFetcher currently swallows errors silently, so
+ * App can hook a tighter signal in later without breaking this hook.
+ */
+ famousMetaFailed?: boolean;
+};
diff --git a/src/@types/splash/UseSplashReturn.d.ts b/src/@types/splash/UseSplashReturn.d.ts
new file mode 100644
index 00000000..e8485485
--- /dev/null
+++ b/src/@types/splash/UseSplashReturn.d.ts
@@ -0,0 +1,25 @@
+import type { SplashError } from './SplashError';
+
+/**
+ * UseSplashReturn — the splash hook's public surface.
+ *
+ * `splashVisible` is the render gate App reads. `blocked` reports whether
+ * CTAs should be disabled (loading not yet ready). `canContinueAnyway`
+ * exposes the 8 s timer's expiration so the splash can show the escape
+ * link. `error` is null on the happy path; `famous-meta-failed` leaves
+ * the splash usable, the other kinds force the error layout.
+ *
+ * `dismissExplore` / `dismissTour` bump localStorage's `seenVersion` and
+ * close the splash. `reopen` (called by the AboutPill) shows the splash
+ * again but does NOT touch localStorage — reopening is informational, not
+ * a "first-time" event.
+ */
+export type UseSplashReturn = {
+ splashVisible: boolean;
+ blocked: boolean;
+ canContinueAnyway: boolean;
+ error: SplashError | null;
+ dismissExplore: () => void;
+ dismissTour: () => void;
+ reopen: () => void;
+};
diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx
index f7494547..81717696 100644
--- a/src/components/App/App.tsx
+++ b/src/components/App/App.tsx
@@ -56,6 +56,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import { useEngine } from '../../hooks/useEngine';
+import { useSplash } from '../../hooks/useSplash';
import { StatusBar } from '../StatusBar/StatusBar';
import { LoadingBar } from '../LoadingBar/LoadingBar';
import { InfoCard } from '../InfoCard/InfoCard';
@@ -66,6 +67,8 @@ import StatsPanel from '../StatsPanel/StatsPanel';
import { CommandPalette } from '../CommandPalette/CommandPalette';
import SearchTrigger from '../SearchTrigger/SearchTrigger';
import AutoRotateToggle from '../AutoRotateToggle/AutoRotateToggle';
+import { Splash } from '../Splash/Splash';
+import AboutPill from '../Splash/AboutPill';
import { MILKY_WAY_ENTRY, MILKY_WAY_ID } from '../../data/milkyWayEntry';
import appStyles from './App.module.css';
import { useUrlSync } from '../../hooks/useUrlSync';
@@ -366,7 +369,27 @@ export function App(): React.ReactElement {
const [loadingDevPanelOpen, setLoadingDevPanelOpen] = useState(false);
// ── Famous-galaxy sidecars (CommandPalette + deep-link drain) ────────────
- const { famousMeta, famousXrefs } = useFamousMeta();
+ const { famousMeta, famousXrefs, ready: famousMetaReady } = useFamousMeta();
+
+ // ── Splash dialog state ─────────────────────────────────────────────────
+ //
+ // The splash hook gates on engine readiness (status=ready + no fetches
+ // in flight) + famous-meta loaded. It owns localStorage versioning,
+ // deep-link bypass, the 8 s Continue-anyway timer, and dismiss/reopen.
+ // See `useSplash.ts` for the full design rationale.
+ //
+ // Placed after `useFamousMeta` so `famousMetaReady` is in scope. React
+ // hook ordering rules require hooks to run unconditionally — we satisfy
+ // that; the dependency on `famousMetaReady` is purely a JS scoping
+ // concern, not a conditional hook.
+ const splash = useSplash({
+ status,
+ loadProgress,
+ famousMetaReady,
+ // `famousMetaFailed` is not currently wired — useFamousMeta swallows
+ // errors silently per the fail-soft contract. A future iteration
+ // could promote the catch-branch into a flag exposed alongside `ready`.
+ });
// ── Palette entries — famous catalog + Milky Way pseudo-entry ────────────
//
@@ -493,7 +516,7 @@ export function App(): React.ReactElement {
`id="c"` matches the CSS rule in index.html: `#c { display: block; ... }`.
*/}
-
+
{/*
UI overlay wrapper. All HUD chrome (loading bar, status,
@@ -508,7 +531,7 @@ export function App(): React.ReactElement {
class so the fade animates in BOTH directions (opacity 1 → 0
on hide, 0 → 1 on show).
*/}
-
+
{/*
Loading bar — pinned to top of viewport above every other overlay.
Fades itself out when `loadProgress` becomes null (no fetches in
@@ -822,12 +845,13 @@ export function App(): React.ReactElement {
source of truth for placement. See `.topBar` in App.module.css.
*/}
+ );
+}
+
+export function Splash(props: SplashProps): ReactNode {
+ const { blocked, canContinueAnyway, loadProgress, error, onExplore, onTour, onContinueAnyway, onReload } = props;
+
+ const hardError = error?.kind === 'webgpu-init-failed' || error?.kind === 'catalog-fetch-failed';
+ const tourDisabled = blocked || error?.kind === 'famous-meta-failed';
+ const tourTooltip =
+ error?.kind === 'famous-meta-failed'
+ ? 'Tour is unavailable — failed to load the famous-galaxy index.'
+ : undefined;
+
+ // ── Focus trap + initial focus + Esc dismiss ─────────────────────────────
+ //
+ // Rationale: standard a11y-dialog pattern. Modal dialogs must:
+ // 1. Move focus into themselves on mount.
+ // 2. Trap focus inside while open (Tab from last → first, Shift+Tab from
+ // first → last).
+ // 3. Dismiss on Esc, restoring focus on the way out (handled implicitly
+ // because the splash unmounts; the next interactive element receives
+ // focus naturally).
+ //
+ // We implement focus trap with a Tab keydown listener that queries the
+ // dialog's focusable descendants and bounces the focused element back when
+ // it would otherwise escape. Smaller than pulling in `focus-trap-react`
+ // and the splash has a tiny number of focusables (≤5).
+ const dialogRef = useRef(null);
+
+ useEffect(() => {
+ const root = dialogRef.current;
+ if (!root) return;
+
+ // Initial focus — find the autofocused Explore button (or the first
+ // focusable if Explore is disabled / replaced by Reload).
+ const FOCUSABLE_SELECTOR =
+ 'button:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
+ const focusables = () =>
+ Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR));
+
+ const initial =
+ root.querySelector(`.${styles.ctaPrimary}:not([disabled])`) ??
+ focusables()[0] ??
+ null;
+ initial?.focus();
+
+ const onKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ // Esc is treated identically to Explore (per the 2026-05-20 grill, Q13b).
+ onExplore();
+ return;
+ }
+ if (e.key !== 'Tab') return;
+ const items = focusables();
+ if (items.length === 0) return;
+ const first = items[0]!;
+ const last = items[items.length - 1]!;
+ const active = document.activeElement as HTMLElement | null;
+ if (e.shiftKey && (active === first || !root.contains(active))) {
+ e.preventDefault();
+ last.focus();
+ } else if (!e.shiftKey && (active === last || !root.contains(active))) {
+ e.preventDefault();
+ first.focus();
+ }
+ };
+
+ document.addEventListener('keydown', onKeyDown);
+ return () => document.removeEventListener('keydown', onKeyDown);
+ }, [onExplore]);
+
+ return (
+
+
+
+ Explore millions of galaxies in 3D
+
+
+ Drawn in your browser with WebGPU. Built from real cosmic data — the{' '}
+
+ SDSS
+
+ ,{' '}
+
+ GLADE
+
+ , and{' '}
+
+ 2MRS
+ {' '}
+ galaxy surveys.
+
+
+ {hardError ? (
+
+ {error?.kind === 'webgpu-init-failed'
+ ? 'WebGPU failed to initialize on this device. Try reloading, or use a recent version of Chrome or Edge.'
+ : 'Failed to load the galaxy data. Check your connection and try reloading.'}
+
+ );
+}
diff --git a/src/hooks/useFamousMeta.ts b/src/hooks/useFamousMeta.ts
index 88cbc3ab..e1a88f25 100644
--- a/src/hooks/useFamousMeta.ts
+++ b/src/hooks/useFamousMeta.ts
@@ -2,33 +2,33 @@
* `useFamousMeta` — load the famous-galaxy sidecars (`famous_meta.json`
* and `famous_xrefs.json`) once at mount. The engine *also* loads them
* internally (via its `famousMeta` AssetSlot), but exposing a parallel
- * copy here lets the React layer (CommandPalette, deep-link drain) read
- * them without reaching into engine private state. Double-loading is
- * cheap because the browser caches the JSON fetch — both readers hit
- * the same response.
+ * copy here lets the React layer (CommandPalette, deep-link drain,
+ * splash gating) read them without reaching into engine private state.
+ * Double-loading is cheap because the browser caches the JSON fetch —
+ * both readers hit the same response.
*
- * Why a hook rather than a top-level await or a context provider?
- * `famousMetaFetcher` is async; we need the React render cycle to pick
- * up the result, which means state. And every call site is a single
- * React tree, so a hook is lighter than a Context.
+ * ### Why we expose a `ready` flag
+ *
+ * The splash gating (`useSplash`) needs to know when the famous-meta
+ * fetch has settled so it can activate the Tour CTA (which depends on
+ * famous-meta lookups to anchor the tour beats). `ready` flips true
+ * on both success AND swallowed-error paths so a deployment without a
+ * famous_meta.json doesn't deadlock the splash — same fail-soft
+ * contract as the empty-state defaults below.
*
* ### Why call the fetcher directly (rather than the engine handle)?
*
* The engine's slot loads at boot, but its result lives inside engine
- * state. Exposing it through the handle would either require an
- * imperative getter (App polls), a callback prop (App reconstructs the
- * Engine spec), or a React Context wrapping the engine. Calling the
- * pure fetcher here keeps the App's mental model simple: the engine
- * owns its copy for InfoCard text, App owns its copy for palette /
- * deep-link work. HTTP cache makes the duplication free at the wire.
+ * state. Calling the pure fetcher here keeps the App's mental model
+ * simple: the engine owns its copy for InfoCard text, App owns its copy
+ * for palette / deep-link / splash work. HTTP cache makes the
+ * duplication free at the wire.
*
* ### Why catch on error rather than throw?
*
- * The old `loadFamousSidecars` swallowed 404s into empty values; the new
- * fetcher throws so retry policy can branch on status. We replicate the
- * pre-rework "absent file = feature off" UX here at the React seam by
- * catching and falling through to empty state, matching the engine's
- * own subscriber-side error handler in `engine.ts`.
+ * The fetcher throws on network/HTTP errors so retry policy can branch
+ * on status. We catch here and fall through to empty state + `ready=true`,
+ * matching the engine's own subscriber-side error handler in `engine.ts`.
*/
import { useEffect, useState } from 'react';
@@ -40,6 +40,7 @@ import type { UseFamousMetaReturn } from '../@types/engine/UseFamousMetaReturn';
export function useFamousMeta(): UseFamousMetaReturn {
const [famousMeta, setFamousMeta] = useState([]);
const [famousXrefs, setFamousXrefs] = useState({});
+ const [ready, setReady] = useState(false);
useEffect(() => {
const ac = new AbortController();
@@ -47,16 +48,16 @@ export function useFamousMeta(): UseFamousMetaReturn {
.then((sc) => {
setFamousMeta(sc.meta);
setFamousXrefs(sc.xrefs);
+ setReady(true);
})
.catch(() => {
// Match the pre-rework "absent file = feature off" UX: a 404 or
- // network error leaves the empty defaults in place so the
- // CommandPalette and deep-link drain operate without enriched
- // text rather than crashing. Same fail-soft contract the
- // engine's own slot subscriber implements (see engine.ts).
+ // network error leaves the empty defaults in place AND still flips
+ // `ready` to true so the splash gate doesn't deadlock.
+ setReady(true);
});
return () => ac.abort();
}, []);
- return { famousMeta, famousXrefs };
+ return { famousMeta, famousXrefs, ready };
}
diff --git a/src/hooks/useSplash.ts b/src/hooks/useSplash.ts
new file mode 100644
index 00000000..02b56404
--- /dev/null
+++ b/src/hooks/useSplash.ts
@@ -0,0 +1,225 @@
+/**
+ * useSplash — orchestrates the splash visibility, the readiness gate, the
+ * "Continue anyway" escape, dismiss + reopen, and version-busted re-show.
+ *
+ * ### Why a separate hook
+ *
+ * App.tsx already wires six hooks. The splash has its own state shape
+ * (visibility, blocked, error, canContinueAnyway), its own derived
+ * predicates (deep-link detection, readiness signal), and its own side
+ * effects (localStorage persistence, 8 s timer). Bolting all of that
+ * onto App.tsx would push the file past its already-substantial size
+ * and would scatter "splash logic" across the file. A dedicated hook
+ * gives the splash a single home with a clean public contract.
+ *
+ * ### Readiness signal
+ *
+ * The grill (Q4) resolved to "medium gating": the CTAs activate when
+ * 1. the engine is in `ready` state (WebGPU init done + first frame),
+ * 2. no catalog fetch is currently in flight (`loadProgress === null`),
+ * 3. famous-meta has settled (`famousMetaReady`).
+ * The hook does NOT differentiate between Explore and Tour readiness —
+ * both buttons activate together so the user never sees "Tour disabled,
+ * Explore enabled" intermediate UI. Famous-meta failure is treated as
+ * "ready" downstream (the hook's input plumbing receives `ready=true`
+ * from useFamousMeta in both success and error cases), but the splash
+ * does render a disabled Tour tooltip — that's wired in Task 6's error
+ * mapping plus the Splash component's disabled-state CSS.
+ *
+ * ### localStorage versioning
+ *
+ * Key: `skymap.splash.seenVersion` — an integer. Hook reads on first
+ * mount; if missing or lower than `CURRENT_SPLASH_VERSION`, splash is
+ * shown. Dismiss (either CTA) writes the current version; bumping
+ * `CURRENT_SPLASH_VERSION` re-shows the splash to all returning users.
+ * `reopen()` (called by the AboutPill) shows the splash WITHOUT touching
+ * storage — informational reopens shouldn't reset the version stamp.
+ *
+ * ### Deep-link bypass
+ *
+ * A URL with `#focus=`, `#poi=`, or `?tour=` (see `hasDeepLink`) skips
+ * the splash on first arrival and never auto-shows it. About pill
+ * `reopen()` still works. Power-user gates (`?debug`, etc.) do NOT
+ * count as deep links.
+ *
+ * ### 8 s "Continue anyway" timer
+ *
+ * Starts when the splash becomes visible AND blocked. Fires once,
+ * flipping `canContinueAnyway` to true so the splash can show the
+ * escape link. Cleared on unmount and re-armed if the splash is
+ * reopened. Does NOT fire when the splash isn't visible (deep-link
+ * path) — the timer is a UX affordance for slow loads, not a global
+ * timeout.
+ *
+ * ### SSR-safety
+ *
+ * `typeof window` guards wrap localStorage and location reads so unit
+ * tests that render `useSplash` without a jsdom env don't blow up.
+ * The deep-link helper itself takes plain strings — no `window`
+ * dependency.
+ */
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { hasDeepLink } from '../utils/url/hasDeepLink';
+import type { UseSplashInput } from '../@types/splash/UseSplashInput';
+import type { UseSplashReturn } from '../@types/splash/UseSplashReturn';
+import type { SplashError } from '../@types/splash/SplashError';
+
+/** Persisted storage key — never rename without a migration. */
+export const SPLASH_STORAGE_KEY = 'skymap.splash.seenVersion';
+
+/**
+ * Version stamp written to localStorage on dismiss. Bump when meaningful
+ * splash content changes — increments re-show the splash to returning
+ * users on their next visit.
+ */
+export const CURRENT_SPLASH_VERSION = 1;
+
+/** Milliseconds before the "Continue anyway" escape appears. */
+export const CONTINUE_ANYWAY_DELAY_MS = 8_000;
+
+/**
+ * Read seenVersion from localStorage. SSR-safe and try/catch-guarded
+ * against private-browsing modes that throw on storage access.
+ */
+function readSeenVersion(): number {
+ if (typeof window === 'undefined') return 0;
+ try {
+ const raw = window.localStorage.getItem(SPLASH_STORAGE_KEY);
+ if (raw === null) return 0;
+ const parsed = Number.parseInt(raw, 10);
+ return Number.isFinite(parsed) ? parsed : 0;
+ } catch {
+ return 0;
+ }
+}
+
+/** Write seenVersion to localStorage. Swallows storage errors silently. */
+function writeSeenVersion(version: number): void {
+ if (typeof window === 'undefined') return;
+ try {
+ window.localStorage.setItem(SPLASH_STORAGE_KEY, String(version));
+ } catch {
+ // Private browsing or storage quota — best-effort; the splash will
+ // re-show next time, which is acceptable degraded behaviour.
+ }
+}
+
+/**
+ * Read the current URL hash + search, returning empty strings under SSR.
+ * Captured lazily inside the hook's initializer so it runs once at mount.
+ */
+function readUrlAtMount(): { hash: string; search: string } {
+ if (typeof window === 'undefined') return { hash: '', search: '' };
+ return { hash: window.location.hash, search: window.location.search };
+}
+
+export function useSplash(input: UseSplashInput): UseSplashReturn {
+ const { status, loadProgress, famousMetaReady, famousMetaFailed = false } = input;
+
+ // ── Initial visibility (snapshot at mount) ───────────────────────────────
+ //
+ // Three gates compose:
+ // 1. Deep link present → never auto-show.
+ // 2. Stored seenVersion >= CURRENT_SPLASH_VERSION → don't re-show.
+ // 3. Otherwise → show.
+ //
+ // We capture once via a lazy initializer so the splash decision doesn't
+ // flip mid-session if the user manually edits the URL. `reopen()`
+ // overrides this snapshot via the `userOpened` slot below.
+ const [splashVisible, setSplashVisible] = useState(() => {
+ const { hash, search } = readUrlAtMount();
+ if (hasDeepLink({ hash, search })) return false;
+ if (readSeenVersion() >= CURRENT_SPLASH_VERSION) return false;
+ return true;
+ });
+
+ // ── Readiness signal ─────────────────────────────────────────────────────
+ //
+ // The CTAs activate when the engine reports `ready`, no catalog fetches
+ // are in flight, and famous-meta has settled. `blocked` is the
+ // negation — true while we're still waiting.
+ const ready = useMemo(
+ () => status.kind === 'ready' && loadProgress === null && famousMetaReady,
+ [status, loadProgress, famousMetaReady],
+ );
+ const blocked = !ready;
+
+ // ── 8 s "Continue anyway" timer ──────────────────────────────────────────
+ //
+ // Starts when the splash is visible AND blocked. Cleared on unmount and
+ // re-armed if the splash is reopened. Does not fire if the splash is
+ // not visible (deep-link path).
+ const [canContinueAnyway, setCanContinueAnyway] = useState(false);
+ useEffect(() => {
+ if (!splashVisible || !blocked) {
+ // Re-arm when the splash becomes visible again (reopen flow).
+ // We don't reset `canContinueAnyway` on the unblocked path because
+ // the splash hides itself on dismiss anyway; whether the link was
+ // ever visible doesn't matter after that.
+ return;
+ }
+ const t = setTimeout(() => setCanContinueAnyway(true), CONTINUE_ANYWAY_DELAY_MS);
+ return () => clearTimeout(t);
+ }, [splashVisible, blocked]);
+
+ // Reset canContinueAnyway when the splash is reopened so the link
+ // appears again only after another 8 s if loading is somehow slow
+ // again (rare — content is cached — but cheap to handle).
+ useEffect(() => {
+ if (!splashVisible) setCanContinueAnyway(false);
+ }, [splashVisible]);
+
+ // ── Dismiss + reopen ─────────────────────────────────────────────────────
+
+ const dismissExplore = useCallback(() => {
+ writeSeenVersion(CURRENT_SPLASH_VERSION);
+ setSplashVisible(false);
+ }, []);
+
+ const dismissTour = useCallback(() => {
+ writeSeenVersion(CURRENT_SPLASH_VERSION);
+ setSplashVisible(false);
+ }, []);
+
+ const reopen = useCallback(() => {
+ // Intentionally does NOT write seenVersion — reopening from the About
+ // pill is informational, not a "first-time dismissal" event.
+ setSplashVisible(true);
+ }, []);
+
+ // ── Error mapping ────────────────────────────────────────────────────────
+ //
+ // Engine errors (status.kind === 'error') take precedence over famous-meta
+ // failures because an engine error blocks the whole app — the famous-meta
+ // tooltip would be misleading next to a "catalog failed to load" headline.
+ // We discriminate engine errors by inspecting the message: anything
+ // mentioning "WebGPU" is reported as a webgpu-init failure (since the
+ // synchronous "no navigator.gpu at all" case is handled in main.tsx, the
+ // only thing left to surface here is the requestAdapter-returned-null
+ // path). Everything else is bucketed as a catalog fetch failure, which
+ // is the dominant non-WebGPU error mode (a network blip on sdss.bin /
+ // glade.bin / 2mrs.bin).
+ const error = useMemo(() => {
+ if (status.kind === 'error') {
+ if (/webgpu/i.test(status.message)) {
+ return { kind: 'webgpu-init-failed', message: status.message };
+ }
+ return { kind: 'catalog-fetch-failed', message: status.message };
+ }
+ if (famousMetaFailed) {
+ return { kind: 'famous-meta-failed' };
+ }
+ return null;
+ }, [status, famousMetaFailed]);
+
+ return {
+ splashVisible,
+ blocked,
+ canContinueAnyway,
+ error,
+ dismissExplore,
+ dismissTour,
+ reopen,
+ };
+}
diff --git a/src/main.tsx b/src/main.tsx
index 9780b826..b74c41a3 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,47 +1,55 @@
/**
- * Application entry point — mounts the React tree.
- *
- * This file is intentionally minimal: it finds the root DOM node and hands
- * control to ``. All the interesting work happens in `App.tsx`
- * (engine lifecycle, state management) and the component tree beneath it.
+ * Application entry point — synchronous WebGPU support gate, then mount React.
+ *
+ * ### Why the synchronous gate runs before createRoot
+ *
+ * On a browser without `navigator.gpu` (older Safari, Firefox stable, most
+ * mobile browsers as of 2026), every downstream module in our React tree
+ * either fails immediately (createEngine throws) or runs a no-op render
+ * loop and shows the user a black canvas with no explanation. We want a
+ * deliberate "your browser can't do this, here's why" surface, and we want
+ * it WITHOUT the cost of instantiating React + useEngine + useFamousMeta
+ * just to render one error. A synchronous `typeof navigator.gpu` check at
+ * the top of main.tsx accomplishes that: on unsupported browsers we swap
+ * the body's innerHTML to the static page (`renderUnsupportedPageHtml()`)
+ * and bail before `createRoot` is ever called.
+ *
+ * The check is intentionally permissive — it fires only on "definitely no
+ * WebGPU" (the property is `undefined`). If `navigator.gpu` exists but
+ * `requestAdapter()` returns `null` (the GPU is present but the driver
+ * refuses), that's a runtime failure surfaced via the splash's error state
+ * (handled inside `useSplash`). Two different failure modes, two different
+ * surfaces — the gate here covers only the synchronously-detectable one.
*
* ### React 19 createRoot
*
- * `createRoot` is the React 18+ API for rendering. It enables concurrent
- * features (automatic batching, transitions, Suspense for data fetching) and
- * replaces the legacy `ReactDOM.render`. The call:
- *
- * createRoot(container).render()
- *
- * mounts the React tree into `container` and starts the first render. After
- * this point, React owns the DOM subtree inside `container` — do not modify
- * it imperatively.
- *
- * ### No React.StrictMode
- *
- * We deliberately do NOT wrap `` in ``. StrictMode
- * double-mounts components in development to surface cleanup bugs, but our
- * WebGPU engine is not designed for double-mounting (it creates GPU resources
- * and starts a render loop on mount). See the note in `App.tsx` for the full
- * reasoning. The cleanup in `App.tsx`'s `useEffect` is still correct — it just
- * runs on hot-reload, not on every development mount.
- *
- * ### The `!` non-null assertion
- *
- * `document.getElementById('root')` returns `HTMLElement | null`. We use `!`
- * to assert it is non-null. This is safe because `index.html` always contains
- * `` — if that element is missing, the app is broken by
- * a build/deployment error, not a runtime condition we can gracefully handle.
- * In that scenario, throwing immediately (rather than silently rendering into
- * `null`) is the correct behaviour.
+ * Standard React 18+ entry pattern. Concurrent features, automatic batching,
+ * Suspense — see the legacy header comment for the full rationale. We do
+ * NOT wrap `` in `` because StrictMode double-mounts
+ * components and our WebGPU engine is not designed for that pattern (it
+ * creates GPU resources and starts a render loop on mount).
*/
import { createRoot } from 'react-dom/client';
import { App } from './components/App/App';
-// Side-effect import — defines the design-token custom properties on
-// `:root` and the page-level reset. Loaded once at app boot so every
-// CSS module can reference the variables via `var(--token-name)`.
+import { renderUnsupportedPageHtml } from './unsupportedPage';
+// Side-effect import — defines design-token custom properties on `:root`
+// and the page-level reset. Loaded once at app boot so every CSS module
+// can reference `var(--token-name)`.
import './styles/global.css';
-// Mount the React app into the `#root` div declared in index.html.
-createRoot(document.getElementById('root')!).render();
+const root = document.getElementById('root');
+if (!root) {
+ // index.html always contains ``. If it's missing
+ // we're catastrophically broken — throw rather than silently render
+ // into nothing.
+ throw new Error('main.tsx: #root element not found in index.html');
+}
+
+if (typeof navigator === 'undefined' || typeof navigator.gpu === 'undefined') {
+ // No WebGPU — swap the entire document body for the static unsupported
+ // page and bail. React never mounts; no engine objects are constructed.
+ document.body.innerHTML = renderUnsupportedPageHtml();
+} else {
+ createRoot(root).render();
+}
diff --git a/src/unsupportedPage.ts b/src/unsupportedPage.ts
new file mode 100644
index 00000000..614a2ee2
--- /dev/null
+++ b/src/unsupportedPage.ts
@@ -0,0 +1,74 @@
+/**
+ * renderUnsupportedPageHtml — produce the static HTML body shown to
+ * visitors whose browser lacks `navigator.gpu`.
+ *
+ * ### Why a string-returning function rather than a JSX component
+ *
+ * On unsupported browsers we never want to mount React. Doing so would
+ * instantiate `useEngine` / `useFamousMeta` / the entire splash machinery
+ * for a session that can't render a single frame — wasted code, wasted
+ * error surfaces, and one more place where "did we forget to early-return?"
+ * could bite us. Instead, `main.tsx` checks `typeof navigator.gpu === 'undefined'`
+ * synchronously *before* `createRoot`, swaps the body's innerHTML to the
+ * string returned here, and bails. React never enters the picture.
+ *
+ * ### Why static HTML and inline styles
+ *
+ * The only CSS the unsupported page needs is dark-on-light contrast and a
+ * centered card. Pulling in the design-token stylesheet would require
+ * either an import-and-bundle (defeats the "React never mounts" point) or
+ * a side-effect import in main.tsx that runs even on the happy path.
+ * Inline styles keep the unsupported page self-contained: one function,
+ * one return value, no external dependencies.
+ *
+ * ### Why we link to caniuse rather than enumerating support
+ *
+ * The WebGPU support matrix changes month to month — Safari Technology
+ * Preview, Firefox Nightly, mobile Chrome rollout, etc. Anything we
+ * hard-code here ages worse than caniuse does. The text says "use a
+ * recent version of Chrome or Edge" (the safe always-true recommendation
+ * today) and the link delegates the live matrix to the canonical source.
+ */
+export function renderUnsupportedPageHtml(): string {
+ return `
+
+
+
+ Skymap needs WebGPU
+
+
+ Your browser doesn't support WebGPU yet. Skymap renders millions of
+ galaxies in 3D and needs the modern GPU API to do that smoothly.
+
+
+ Try a recent version of Chrome or Edge on
+ desktop, or check the live support matrix:
+