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. */}
-
)}
+ {splash.splashVisible && ( + window.location.reload()} + /> + )} ); } diff --git a/src/components/Splash/AboutPill.module.css b/src/components/Splash/AboutPill.module.css new file mode 100644 index 00000000..b58ee8e5 --- /dev/null +++ b/src/components/Splash/AboutPill.module.css @@ -0,0 +1,61 @@ +/* + * AboutPill — 40 × 40 px frosted-glass button matching SearchTrigger + * and AutoRotateToggle so the three pills feel like one cohesive + * top-bar cluster (`.topBar` in App.module.css). + * + * Same surface vocabulary as the siblings: `--surface-card-soft`, + * `--border-card`, `--blur-card`, `--shadow-card`. Hover/focus shift + * to `--surface-card-strong` + `--border-hover`; the icon tints to + * `--color-accent`. + */ + +.pill { + background: var(--surface-card-soft); + border: 1px solid var(--border-card); + border-radius: 999px; + backdrop-filter: blur(var(--blur-card)); + -webkit-backdrop-filter: blur(var(--blur-card)); + box-shadow: var(--shadow-card); + width: 40px; + height: 40px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--color-fg); + transition: background 0.15s ease-out, border-color 0.15s ease-out, + opacity 0.2s ease-out, transform 0.2s ease-out; +} + +.pill:hover, +.pill:focus-visible { + background: var(--surface-card-strong); + border-color: var(--border-hover); + color: var(--color-accent); +} + +.pill:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.icon { + display: block; +} + +/* Hidden state — matches the SearchTrigger / AutoRotateToggle pattern. + * Faded + slightly scaled + non-interactive so the splash modal can + * sit cleanly on top during reopens. */ +.hidden { + opacity: 0; + transform: scale(0.9); + pointer-events: none; +} + +/* Mobile — still 44 × 44 minimum touch target (WCAG 2.5.5). */ +@media (max-width: 480px) { + .pill { + width: 44px; + height: 44px; + } +} diff --git a/src/components/Splash/AboutPill.tsx b/src/components/Splash/AboutPill.tsx new file mode 100644 index 00000000..f32c4895 --- /dev/null +++ b/src/components/Splash/AboutPill.tsx @@ -0,0 +1,78 @@ +/** + * AboutPill — 40 × 40 frosted-glass pill that reopens the splash + * dialog. Sits in the top-bar flex row (`.topBar` in App.module.css) + * alongside SearchTrigger and AutoRotateToggle. + * + * ### Why a dedicated pill rather than a SettingsPanel link + * + * Per the 2026-05-20 grill (Q10), the About affordance needs to be + * discoverable to deep-link arrivals who skipped the splash and to + * returning visitors who want to re-read the intro. Burying it in + * the Settings panel (the most-frequently-collapsed surface on + * mobile) defeats both audiences. A top-bar pill is canonical + * "help / about" placement and matches the user's chosen layout + * (Search · AutoRotate · About). + * + * ### Why React.memo + * + * Reads only `onClick`, `hidden` — neither changes per frame. Without + * memo, App's animation re-renders would re-render the inline SVG + * every frame. Same rationale as SearchTrigger / AutoRotateToggle. + */ + +import { memo, type ReactNode } from 'react'; +import cx from 'classnames'; +import styles from './AboutPill.module.css'; + +export type AboutPillProps = { + /** Called when the user clicks/activates the pill — reopens splash. */ + onClick: () => void; + /** + * When true, the pill fades out and stops accepting clicks — matches + * SearchTrigger and AutoRotateToggle's `hidden` semantics so the + * three pills coordinate during palette-open and splash-visible + * transitions. + */ + hidden?: boolean; +}; + +/** Inline circled-? glyph — nine lines of SVG we own end-to-end. */ +function InfoIcon(): ReactNode { + return ( + + ); +} + +function AboutPill({ onClick, hidden = false }: AboutPillProps): ReactNode { + return ( + + ); +} + +export default memo(AboutPill); diff --git a/src/components/Splash/Splash.module.css b/src/components/Splash/Splash.module.css new file mode 100644 index 00000000..9451e7d7 --- /dev/null +++ b/src/components/Splash/Splash.module.css @@ -0,0 +1,221 @@ +/* src/components/Splash/Splash.module.css */ +/* + * Splash — first-paint loading curtain + onboarding card. + * + * Translucent rounded-rect card centered over a full-viewport dim + * overlay. Live canvas underneath stays visible through the card's + * backdrop-filter blur so galaxies materialize softly behind the + * splash as load progresses (per the 2026-05-20 grill, Q5). Mobile + * (≤768 px) drops the blur for a higher-opacity solid backdrop + * because backdrop-filter is fragile on iOS Safari. + */ + +.backdrop { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + /* Fade-in on mount. Skipped under prefers-reduced-motion (rule below). */ + animation: splashFadeIn 0.25s ease-out; +} + +@keyframes splashFadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.card { + background: rgba(8, 12, 28, 0.65); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--border-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + color: var(--color-fg); + max-width: 520px; + width: 100%; + padding: 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.title { + margin: 0; + font-size: 28px; + font-weight: 600; + color: #ffffff; +} + +.body { + margin: 0; + font-size: 16px; + line-height: 1.5; + color: var(--color-fg); +} + +.body a { + color: var(--color-accent); + text-decoration: none; + border-bottom: 1px dotted var(--color-accent); +} + +.body a:hover, +.body a:focus-visible { + text-decoration: underline; +} + +.progressRow { + display: flex; + align-items: center; + gap: 12px; + font-size: 13px; + color: var(--color-fg-muted, #9aa3b3); +} + +.progressTrack { + flex: 1; + height: 4px; + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + overflow: hidden; +} + +.progressFill { + height: 100%; + background: var(--color-accent); + transition: width 0.2s ease-out; +} + +.progressIndeterminate { + height: 100%; + width: 30%; + background: linear-gradient( + 90deg, + transparent, + var(--color-accent), + transparent + ); + animation: splashIndeterminate 1.4s ease-in-out infinite; +} + +@keyframes splashIndeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } +} + +.ctas { + display: flex; + gap: 12px; + margin-top: 8px; +} + +.cta { + flex: 1; + min-height: 44px; + padding: 12px 20px; + border-radius: var(--radius-md); + font-size: 15px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s ease-out, border-color 0.15s ease-out, opacity 0.15s; +} + +.cta:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.ctaPrimary { + background: var(--color-accent); + border: 1px solid var(--color-accent); + color: #05070d; +} +.ctaPrimary:hover:not(:disabled), +.ctaPrimary:focus-visible:not(:disabled) { + background: #a3c9ff; + border-color: #a3c9ff; +} + +.ctaSecondary { + background: transparent; + border: 1px solid var(--border-card); + color: var(--color-fg); +} +.ctaSecondary:hover:not(:disabled), +.ctaSecondary:focus-visible:not(:disabled) { + background: var(--surface-card-soft); + border-color: var(--border-hover); +} + +.cta:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.continueAnyway { + margin-top: 4px; + background: none; + border: none; + color: var(--color-fg-muted, #9aa3b3); + font-size: 12px; + text-decoration: underline; + cursor: pointer; + padding: 8px; + align-self: center; +} +.continueAnyway:hover, +.continueAnyway:focus-visible { + color: var(--color-fg); +} + +.footer { + margin: 8px 0 0; + font-size: 12px; + color: var(--color-fg-muted, #9aa3b3); + text-align: center; +} + +.footer a { + color: inherit; + text-decoration: underline; +} + +.errorBox { + background: rgba(255, 80, 80, 0.08); + border: 1px solid rgba(255, 120, 120, 0.4); + border-radius: var(--radius-md); + padding: 12px 16px; + color: #ffb3b3; + font-size: 14px; +} + +/* ── Mobile — stacked CTAs, smaller type, solid backdrop ────────────── */ +@media (max-width: 480px) { + .title { font-size: 22px; } + .body { font-size: 14px; } + .ctas { flex-direction: column; } + .cta { min-height: 48px; } +} + +@media (max-width: 768px) { + .backdrop { + /* Skip the heavy blur on mobile — backdrop-filter is fragile on + * iOS Safari and most phones have a darker viewing context anyway. */ + background: rgba(0, 0, 0, 0.82); + } + .card { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(8, 12, 28, 0.92); + } +} + +@media (prefers-reduced-motion: reduce) { + .backdrop { animation: none; } + .progressIndeterminate { animation: none; } +} diff --git a/src/components/Splash/Splash.tsx b/src/components/Splash/Splash.tsx new file mode 100644 index 00000000..d7305e66 --- /dev/null +++ b/src/components/Splash/Splash.tsx @@ -0,0 +1,251 @@ +// src/components/Splash/Splash.tsx +/** + * Splash — first-paint loading curtain + onboarding dialog. + * + * Renders a translucent card centered over a full-viewport dim overlay. + * Two CTAs (Explore primary, Tour secondary), a progress indicator while + * loading, a "Continue anyway" escape after 8 s of waiting, and per-error + * rendering for the three runtime failure modes. + * + * ### Why presentational + * + * All state lives in `useSplash` (the hook). This component takes only + * the rendered state + handlers as props. That split keeps the dialog + * trivially testable (just feed it prop combinations) and lets the + * hook be tested independently with renderHook. + * + * ### Accessibility + * + * `role="dialog"`, `aria-modal="true"`, `aria-labelledby` to the title, + * `aria-describedby` to the body. The background canvas is marked + * `aria-hidden="true"` from App.tsx while the splash is up. Focus trap + * and Esc handling are added in Task 9 (this file keeps the markup + + * presentation contract separate from the trap logic). + * + * ### Failure rendering + * + * - `webgpu-init-failed` → swap CTAs for an error box explaining the + * requestAdapter failure. Reload button only. + * - `catalog-fetch-failed` → CTAs hidden; error box + Reload. + * - `famous-meta-failed` → CTAs stay; Tour is disabled with a `title` + * tooltip; Explore is unaffected. + * + * The synchronous "no navigator.gpu" path is handled in main.tsx before + * React mounts; the splash never sees that case. + */ + +import { useEffect, useRef, type ReactNode } from 'react'; +import cx from 'classnames'; +import type { SplashError } from '../../@types/splash/SplashError'; +import type { LoadProgressState } from '../../@types/loading/LoadProgressState'; +import styles from './Splash.module.css'; + +export type SplashProps = { + /** True while loading is incomplete; disables CTAs. */ + blocked: boolean; + /** True after the 8 s "Continue anyway" timer has fired. */ + canContinueAnyway: boolean; + /** Optional load progress to render below the body (null hides the row). */ + loadProgress?: LoadProgressState | null; + /** Current error state; null on the happy path. */ + error: SplashError | null; + /** Called when the user clicks Explore (or Esc — wired in Task 9). */ + onExplore: () => void; + /** Called when the user clicks Tour. */ + onTour: () => void; + /** Called when the user clicks the Continue anyway escape link. */ + onContinueAnyway: () => void; + /** Called when the user clicks Reload (catalog-fetch-failed / webgpu-init-failed). */ + onReload: () => void; +}; + +const TITLE_ID = 'splash-title'; +const BODY_ID = 'splash-body'; + +function ProgressRow({ progress }: { progress: LoadProgressState | null | undefined }): ReactNode { + if (!progress) return null; + const indeterminate = progress.totalBytes === 0; + const fraction = + progress.totalBytes > 0 ? Math.min(1, progress.loadedBytes / progress.totalBytes) : 0; + return ( +
+
+ {indeterminate ? ( +
+ ) : ( +
+ )} +
+ {indeterminate ? 'Loading…' : `${Math.round(fraction * 100)}%`} +
+ ); +} + +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.'} +
+ ) : ( + + )} + + {hardError ? ( +
+ +
+ ) : ( +
+ + +
+ )} + + {blocked && canContinueAnyway && !hardError ? ( + + ) : null} + +

+ by Alexander Rulkens ·{' '} + + github.com/rulkens/skymap + +

+
+
+ ); +} 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: +

+

+ caniuse.com/webgpu +

+
+
+ `.trim(); +} diff --git a/src/utils/url/hasDeepLink.ts b/src/utils/url/hasDeepLink.ts new file mode 100644 index 00000000..5da51c84 --- /dev/null +++ b/src/utils/url/hasDeepLink.ts @@ -0,0 +1,59 @@ +/** + * hasDeepLink — does the URL express specific user intent that should + * suppress the splash on first arrival? + * + * ### Rationale + * + * The splash UX (per the 2026-05-20 grill) treats deep-link arrivals as + * "this user already knows what they want — get out of their way". Three + * URL shapes qualify: + * + * - `#focus=` — pin a specific galaxy (set by the InfoCard + * deep-link drain in useUrlSync). + * - `#poi=` — focus a specific cluster / supercluster / void. + * - `?tour=` — request the tour at a specific anchor. + * + * Power-user gates (`?debug`, `?volumes`, `?anchors`, `?gpuTimings`) + * don't qualify — they change developer surfaces, not what the visitor + * is looking at. Bundling them into the deep-link predicate would + * suppress the splash for every contributor running with `?debug` on, + * which is the opposite of useful. + * + * ### Pure + * + * Takes hash + search as plain strings; the caller decides where to read + * them from (typically `window.location.hash` / `window.location.search`, + * but the splash hook also feeds in fixtures in tests). No `typeof + * window` guard needed here — that's the caller's job. + * + * ### Search-string normalisation + * + * `window.location.search` includes the leading `?`; query strings passed + * by tests sometimes don't. We normalise by stripping a leading `?` and + * then parsing with `URLSearchParams` so callers can be sloppy about the + * leading character. + */ + +export type DeepLinkInput = { + hash: string; + search: string; +}; + +const DEEP_LINK_QUERY_KEYS = new Set(['tour']); + +export function hasDeepLink({ hash, search }: DeepLinkInput): boolean { + // Hash: look for the two deep-link prefixes anywhere in the body. + // (The hash always starts with `#` if present, so a prefix check is safe.) + if (hash.includes('#focus=') || hash.startsWith('#focus=')) return true; + if (hash.includes('#poi=') || hash.startsWith('#poi=')) return true; + + // Search: parse and look for known deep-link keys. We strip a leading + // `?` so callers can pass either `?tour=foo` or `tour=foo`. + const normalized = search.startsWith('?') ? search.slice(1) : search; + if (normalized.length === 0) return false; + const params = new URLSearchParams(normalized); + for (const key of params.keys()) { + if (DEEP_LINK_QUERY_KEYS.has(key)) return true; + } + return false; +} diff --git a/tests/components/Splash/AboutPill.test.ts b/tests/components/Splash/AboutPill.test.ts new file mode 100644 index 00000000..970c95ee --- /dev/null +++ b/tests/components/Splash/AboutPill.test.ts @@ -0,0 +1,42 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from 'react'; +import AboutPill from '../../../src/components/Splash/AboutPill'; + +describe('AboutPill', () => { + it('renders a button with the aria-label "About skymap"', () => { + render(createElement(AboutPill, { onClick: () => {} })); + expect(screen.getByRole('button', { name: /about skymap/i })).toBeInTheDocument(); + }); + + it('fires onClick when clicked', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render(createElement(AboutPill, { onClick })); + await user.click(screen.getByRole('button', { name: /about skymap/i })); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('fires onClick on Enter (keyboard accessibility)', async () => { + const onClick = vi.fn(); + const user = userEvent.setup(); + render(createElement(AboutPill, { onClick })); + screen.getByRole('button', { name: /about skymap/i }).focus(); + await user.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledOnce(); + }); + + it('reflects hidden=true via aria-hidden (matches SearchTrigger / AutoRotateToggle)', () => { + render(createElement(AboutPill, { onClick: () => {}, hidden: true })); + const btn = screen.getByRole('button', { hidden: true }); + expect(btn).toHaveAttribute('aria-hidden', 'true'); + }); + + it('omits aria-hidden when hidden=false (default)', () => { + render(createElement(AboutPill, { onClick: () => {} })); + const btn = screen.getByRole('button', { name: /about skymap/i }); + expect(btn).not.toHaveAttribute('aria-hidden'); + }); +}); diff --git a/tests/components/Splash/Splash.test.ts b/tests/components/Splash/Splash.test.ts new file mode 100644 index 00000000..1e041eee --- /dev/null +++ b/tests/components/Splash/Splash.test.ts @@ -0,0 +1,143 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from 'react'; +import { Splash } from '../../../src/components/Splash/Splash'; + +function makeProps(overrides: Partial> = {}) { + return { + blocked: false, + canContinueAnyway: false, + error: null, + onExplore: vi.fn(), + onTour: vi.fn(), + onContinueAnyway: vi.fn(), + onReload: vi.fn(), + ...overrides, + } as React.ComponentProps; +} + +describe('Splash', () => { + it('renders a dialog with the title "Explore millions of galaxies in 3D"', () => { + render(createElement(Splash, makeProps())); + const dialog = screen.getByRole('dialog'); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(screen.getByText('Explore millions of galaxies in 3D')).toBeInTheDocument(); + }); + + it('mentions SDSS, GLADE, and 2MRS with new-tab links', () => { + render(createElement(Splash, makeProps())); + const sdss = screen.getByRole('link', { name: /sdss/i }); + const glade = screen.getByRole('link', { name: /glade/i }); + const mrs2 = screen.getByRole('link', { name: /2mrs/i }); + for (const link of [sdss, glade, mrs2]) { + expect(link).toHaveAttribute('target', '_blank'); + expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')); + } + }); + + it('renders the author + github attribution in the footer', () => { + render(createElement(Splash, makeProps())); + expect(screen.getByText(/alexander rulkens/i)).toBeInTheDocument(); + const ghLink = screen.getByRole('link', { name: /github\.com\/rulkens\/skymap/i }); + expect(ghLink).toHaveAttribute('href', 'https://github.com/rulkens/skymap'); + }); + + it('renders Explore (primary) and Tour (secondary) CTAs', () => { + render(createElement(Splash, makeProps())); + expect(screen.getByRole('button', { name: /^explore$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^tour$/i })).toBeInTheDocument(); + }); + + it('disables CTAs when blocked=true', () => { + render(createElement(Splash, makeProps({ blocked: true }))); + expect(screen.getByRole('button', { name: /^explore$/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /^tour$/i })).toBeDisabled(); + }); + + it('fires onExplore when Explore is clicked', async () => { + const onExplore = vi.fn(); + const user = userEvent.setup(); + render(createElement(Splash, makeProps({ onExplore }))); + await user.click(screen.getByRole('button', { name: /^explore$/i })); + expect(onExplore).toHaveBeenCalledOnce(); + }); + + it('fires onTour when Tour is clicked', async () => { + const onTour = vi.fn(); + const user = userEvent.setup(); + render(createElement(Splash, makeProps({ onTour }))); + await user.click(screen.getByRole('button', { name: /^tour$/i })); + expect(onTour).toHaveBeenCalledOnce(); + }); + + it('shows the Continue anyway link only when canContinueAnyway=true and blocked=true', () => { + const { rerender } = render(createElement(Splash, makeProps({ blocked: true, canContinueAnyway: false }))); + expect(screen.queryByRole('button', { name: /continue anyway/i })).not.toBeInTheDocument(); + rerender(createElement(Splash, makeProps({ blocked: true, canContinueAnyway: true }))); + expect(screen.getByRole('button', { name: /continue anyway/i })).toBeInTheDocument(); + }); + + it('fires onContinueAnyway when the link is clicked', async () => { + const onContinueAnyway = vi.fn(); + const user = userEvent.setup(); + render(createElement(Splash, makeProps({ blocked: true, canContinueAnyway: true, onContinueAnyway }))); + await user.click(screen.getByRole('button', { name: /continue anyway/i })); + expect(onContinueAnyway).toHaveBeenCalledOnce(); + }); + + it('disables Tour with a tooltip when error.kind=famous-meta-failed', () => { + render(createElement(Splash, makeProps({ error: { kind: 'famous-meta-failed' } }))); + const tour = screen.getByRole('button', { name: /^tour$/i }); + expect(tour).toBeDisabled(); + expect(tour).toHaveAttribute('title', expect.stringMatching(/tour|unavailable/i)); + // Explore stays interactive in this case. + expect(screen.getByRole('button', { name: /^explore$/i })).not.toBeDisabled(); + }); + + it('shows a reload button when error.kind=catalog-fetch-failed', async () => { + const onReload = vi.fn(); + const user = userEvent.setup(); + render(createElement(Splash, makeProps({ error: { kind: 'catalog-fetch-failed', message: 'fail' }, onReload }))); + const reload = screen.getByRole('button', { name: /reload/i }); + await user.click(reload); + expect(onReload).toHaveBeenCalledOnce(); + }); + + it('shows the WebGPU-init error message when error.kind=webgpu-init-failed', () => { + render(createElement(Splash, makeProps({ error: { kind: 'webgpu-init-failed', message: 'adapter null' } }))); + expect(screen.getByText(/webgpu failed/i)).toBeInTheDocument(); + }); +}); + +describe('Splash focus trap + Esc', () => { + it('fires onExplore when Esc is pressed', async () => { + const onExplore = vi.fn(); + const user = userEvent.setup(); + render(createElement(Splash, makeProps({ onExplore }))); + await user.keyboard('{Escape}'); + expect(onExplore).toHaveBeenCalledOnce(); + }); + + it('focuses Explore on mount (initial focus)', () => { + render(createElement(Splash, makeProps())); + const explore = screen.getByRole('button', { name: /^explore$/i }); + expect(document.activeElement).toBe(explore); + }); + + it('traps Tab: pressing Tab from Tour cycles back to the first focusable element', async () => { + const user = userEvent.setup(); + render(createElement(Splash, makeProps())); + const tour = screen.getByRole('button', { name: /^tour$/i }); + tour.focus(); + await user.tab(); + // The focused element after wrap should be inside the dialog — at minimum, + // it should NOT be `document.body`. + expect(document.activeElement).not.toBe(document.body); + // And it should be one of the dialog's focusable items. + const dialog = screen.getByRole('dialog'); + expect(dialog.contains(document.activeElement)).toBe(true); + }); +}); diff --git a/tests/hooks/useFamousMeta.test.ts b/tests/hooks/useFamousMeta.test.ts new file mode 100644 index 00000000..b35012c4 --- /dev/null +++ b/tests/hooks/useFamousMeta.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; + +// Mock the fetcher before importing the hook so the import binds to the mock. +vi.mock('../../src/services/loading/fetchers/famousMetaFetcher', () => ({ + famousMetaFetcher: vi.fn(), +})); + +import { famousMetaFetcher } from '../../src/services/loading/fetchers/famousMetaFetcher'; +import { useFamousMeta } from '../../src/hooks/useFamousMeta'; + +describe('useFamousMeta `ready` flag', () => { + beforeEach(() => { + vi.mocked(famousMetaFetcher).mockReset(); + }); + + it('starts with ready=false', () => { + vi.mocked(famousMetaFetcher).mockReturnValue(new Promise(() => {})); + const { result } = renderHook(() => useFamousMeta()); + expect(result.current.ready).toBe(false); + }); + + it('flips ready=true once the fetch resolves', async () => { + vi.mocked(famousMetaFetcher).mockResolvedValue({ meta: [], xrefs: {} }); + const { result } = renderHook(() => useFamousMeta()); + await waitFor(() => expect(result.current.ready).toBe(true)); + }); + + it('flips ready=true even when the fetch rejects (fail-soft)', async () => { + vi.mocked(famousMetaFetcher).mockRejectedValue(new Error('404')); + const { result } = renderHook(() => useFamousMeta()); + await waitFor(() => expect(result.current.ready).toBe(true)); + expect(result.current.famousMeta).toEqual([]); + }); +}); diff --git a/tests/hooks/useSplash.test.ts b/tests/hooks/useSplash.test.ts new file mode 100644 index 00000000..209ed772 --- /dev/null +++ b/tests/hooks/useSplash.test.ts @@ -0,0 +1,198 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSplash, CURRENT_SPLASH_VERSION, SPLASH_STORAGE_KEY } from '../../src/hooks/useSplash'; +import type { UseSplashInput } from '../../src/@types/splash/UseSplashInput'; + +function makeInput(overrides: Partial = {}): UseSplashInput { + return { + status: { kind: 'initializing' }, + loadProgress: null, + famousMetaReady: false, + ...overrides, + }; +} + +describe('useSplash', () => { + beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState(null, '', '/'); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts visible on a first-time visit with no deep link', () => { + const { result } = renderHook(() => useSplash(makeInput())); + expect(result.current.splashVisible).toBe(true); + expect(result.current.blocked).toBe(true); + }); + + it('starts hidden on a deep-link arrival (#focus=)', () => { + window.history.replaceState(null, '', '/#focus=ngc224'); + const { result } = renderHook(() => useSplash(makeInput())); + expect(result.current.splashVisible).toBe(false); + }); + + it('starts hidden on a deep-link arrival (?tour=)', () => { + window.history.replaceState(null, '', '/?tour=intro'); + const { result } = renderHook(() => useSplash(makeInput())); + expect(result.current.splashVisible).toBe(false); + }); + + it('starts hidden when localStorage seenVersion >= current', () => { + window.localStorage.setItem(SPLASH_STORAGE_KEY, String(CURRENT_SPLASH_VERSION)); + const { result } = renderHook(() => useSplash(makeInput())); + expect(result.current.splashVisible).toBe(false); + }); + + it('shows splash when seenVersion is lower than current', () => { + window.localStorage.setItem(SPLASH_STORAGE_KEY, String(CURRENT_SPLASH_VERSION - 1)); + const { result } = renderHook(() => useSplash(makeInput())); + expect(result.current.splashVisible).toBe(true); + }); + + it('flips blocked=false when status=ready AND famousMetaReady AND loadProgress=null', () => { + const { result, rerender } = renderHook(({ input }) => useSplash(input), { + initialProps: { input: makeInput() }, + }); + expect(result.current.blocked).toBe(true); + rerender({ + input: makeInput({ + status: { kind: 'ready', count: 100, source: 'sdss.bin' }, + loadProgress: null, + famousMetaReady: true, + }), + }); + expect(result.current.blocked).toBe(false); + }); + + it('stays blocked while loadProgress is non-null even after status=ready', () => { + const { result } = renderHook(() => + useSplash( + makeInput({ + status: { kind: 'ready', count: 100, source: 'sdss.bin' }, + loadProgress: { loadedBytes: 1, totalBytes: 2, inFlightCount: 1 }, + famousMetaReady: true, + }), + ), + ); + expect(result.current.blocked).toBe(true); + }); + + it('dismissExplore writes CURRENT_SPLASH_VERSION to localStorage and hides splash', () => { + const { result } = renderHook(() => useSplash(makeInput())); + act(() => result.current.dismissExplore()); + expect(result.current.splashVisible).toBe(false); + expect(window.localStorage.getItem(SPLASH_STORAGE_KEY)).toBe(String(CURRENT_SPLASH_VERSION)); + }); + + it('dismissTour writes seenVersion and hides splash', () => { + const { result } = renderHook(() => useSplash(makeInput())); + act(() => result.current.dismissTour()); + expect(result.current.splashVisible).toBe(false); + expect(window.localStorage.getItem(SPLASH_STORAGE_KEY)).toBe(String(CURRENT_SPLASH_VERSION)); + }); + + it('reopen shows splash again WITHOUT touching localStorage', () => { + window.localStorage.setItem(SPLASH_STORAGE_KEY, String(CURRENT_SPLASH_VERSION)); + const { result } = renderHook(() => useSplash(makeInput())); + expect(result.current.splashVisible).toBe(false); + act(() => result.current.reopen()); + expect(result.current.splashVisible).toBe(true); + expect(window.localStorage.getItem(SPLASH_STORAGE_KEY)).toBe(String(CURRENT_SPLASH_VERSION)); + }); + + it('canContinueAnyway flips true after 8 s of being blocked', () => { + const { result } = renderHook(() => useSplash(makeInput())); + expect(result.current.canContinueAnyway).toBe(false); + act(() => { + vi.advanceTimersByTime(8001); + }); + expect(result.current.canContinueAnyway).toBe(true); + }); + + it('does not start the 8 s timer when splash is not visible (deep-link path)', () => { + window.history.replaceState(null, '', '/#focus=ngc224'); + const { result } = renderHook(() => useSplash(makeInput())); + act(() => { + vi.advanceTimersByTime(10_000); + }); + expect(result.current.canContinueAnyway).toBe(false); + }); +}); + +describe('useSplash error mapping', () => { + beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState(null, '', '/'); + }); + + it('returns error.kind=webgpu-init-failed when status.kind=error with a webgpu message', () => { + const { result } = renderHook(() => + useSplash( + makeInput({ + status: { kind: 'error', message: 'WebGPU: requestAdapter returned null' }, + }), + ), + ); + expect(result.current.error).toEqual({ + kind: 'webgpu-init-failed', + message: 'WebGPU: requestAdapter returned null', + }); + }); + + it('returns error.kind=catalog-fetch-failed for non-webgpu engine errors', () => { + const { result } = renderHook(() => + useSplash( + makeInput({ + status: { kind: 'error', message: 'Failed to fetch sdss.bin' }, + }), + ), + ); + expect(result.current.error).toEqual({ + kind: 'catalog-fetch-failed', + message: 'Failed to fetch sdss.bin', + }); + }); + + it('returns error.kind=famous-meta-failed when famousMetaFailed=true and no engine error', () => { + const { result } = renderHook(() => + useSplash( + makeInput({ + status: { kind: 'ready', count: 100, source: 'sdss.bin' }, + loadProgress: null, + famousMetaReady: true, + famousMetaFailed: true, + }), + ), + ); + expect(result.current.error).toEqual({ kind: 'famous-meta-failed' }); + }); + + it('prefers engine error over famous-meta-failed (engine error blocks the whole app)', () => { + const { result } = renderHook(() => + useSplash( + makeInput({ + status: { kind: 'error', message: 'Failed to fetch sdss.bin' }, + famousMetaFailed: true, + }), + ), + ); + expect(result.current.error?.kind).toBe('catalog-fetch-failed'); + }); + + it('returns null on the happy path', () => { + const { result } = renderHook(() => + useSplash( + makeInput({ + status: { kind: 'ready', count: 100, source: 'sdss.bin' }, + loadProgress: null, + famousMetaReady: true, + }), + ), + ); + expect(result.current.error).toBeNull(); + }); +}); diff --git a/tests/unsupportedPage.test.ts b/tests/unsupportedPage.test.ts new file mode 100644 index 00000000..602d96da --- /dev/null +++ b/tests/unsupportedPage.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { renderUnsupportedPageHtml } from '../src/unsupportedPage'; + +describe('renderUnsupportedPageHtml', () => { + it('returns a non-empty HTML string', () => { + const html = renderUnsupportedPageHtml(); + expect(typeof html).toBe('string'); + expect(html.length).toBeGreaterThan(50); + }); + + it('mentions WebGPU and a supported-browser recommendation', () => { + const html = renderUnsupportedPageHtml(); + expect(html).toMatch(/WebGPU/i); + expect(html.toLowerCase()).toMatch(/chrome|edge/); + }); + + it('links to the caniuse WebGPU page so users can self-diagnose', () => { + const html = renderUnsupportedPageHtml(); + expect(html).toContain('https://caniuse.com/webgpu'); + }); + + it('uses the skymap brand colors and is full-viewport', () => { + const html = renderUnsupportedPageHtml(); + expect(html).toContain('100vh'); + expect(html.toLowerCase()).toContain('skymap'); + }); +}); diff --git a/tests/utils/url/hasDeepLink.test.ts b/tests/utils/url/hasDeepLink.test.ts new file mode 100644 index 00000000..56ff2e53 --- /dev/null +++ b/tests/utils/url/hasDeepLink.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { hasDeepLink } from '../../../src/utils/url/hasDeepLink'; + +describe('hasDeepLink', () => { + it('returns false for empty hash and empty search', () => { + expect(hasDeepLink({ hash: '', search: '' })).toBe(false); + }); + + it('detects #focus= in the hash', () => { + expect(hasDeepLink({ hash: '#focus=ngc224', search: '' })).toBe(true); + }); + + it('detects #poi= in the hash', () => { + expect(hasDeepLink({ hash: '#poi=virgo-cluster', search: '' })).toBe(true); + }); + + it('detects ?tour= in the search', () => { + expect(hasDeepLink({ hash: '', search: '?tour=intro' })).toBe(true); + }); + + it('ignores power-user gates like ?debug, ?volumes, ?anchors', () => { + expect(hasDeepLink({ hash: '', search: '?debug' })).toBe(false); + expect(hasDeepLink({ hash: '', search: '?volumes' })).toBe(false); + expect(hasDeepLink({ hash: '', search: '?anchors&gpuTimings' })).toBe(false); + }); + + it('returns true when both hash and search carry deep-link content', () => { + expect(hasDeepLink({ hash: '#focus=ngc224', search: '?tour=intro' })).toBe(true); + }); + + it('handles leading-? and missing-? variants in the search string', () => { + expect(hasDeepLink({ hash: '', search: 'tour=intro' })).toBe(true); + }); +});