From 47ef8db3edad32a9bee79a53361bfe4a66c8c2e6 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 02:53:38 +0200 Subject: [PATCH 01/12] feat(splash): add WebGPU-unsupported static page renderer Co-Authored-By: Claude Opus 4.7 --- src/unsupportedPage.ts | 74 +++++++++++++++++++++++++++++++++++ tests/unsupportedPage.test.ts | 27 +++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/unsupportedPage.ts create mode 100644 tests/unsupportedPage.test.ts 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/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'); + }); +}); From def12f7fea4d3d6a1c24df1c7e3b2bd217dc597a Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 02:57:50 +0200 Subject: [PATCH 02/12] feat(splash): synchronous WebGPU support gate in main.tsx Skips React mount on browsers without navigator.gpu and renders a static "use Chrome or Edge" page instead. Co-Authored-By: Claude Opus 4.7 --- src/main.tsx | 82 ++++++++++++++++++++++++++++------------------------ 1 file changed, 45 insertions(+), 37 deletions(-) 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(); +} From 1d6d38550d174ee15cc122213bbeec06a518b8b9 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:02:34 +0200 Subject: [PATCH 03/12] feat(useFamousMeta): expose a `ready` flag for splash gating Flips true on both success and swallowed-error paths so a missing famous_meta.json doesn't deadlock downstream gates. Co-Authored-By: Claude Opus 4.7 --- src/@types/engine/UseFamousMetaReturn.d.ts | 8 ++++ src/hooks/useFamousMeta.ts | 49 +++++++++++----------- tests/hooks/useFamousMeta.test.ts | 36 ++++++++++++++++ 3 files changed, 69 insertions(+), 24 deletions(-) create mode 100644 tests/hooks/useFamousMeta.test.ts 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/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/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([]); + }); +}); From c482f88574194b98ff5a2792836cf66cf03d3a00 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:07:45 +0200 Subject: [PATCH 04/12] feat(splash): add SplashError + UseSplash types + hasDeepLink helper Co-Authored-By: Claude Opus 4.7 --- src/@types/splash/SplashError.d.ts | 22 ++++++++++ src/@types/splash/UseSplashInput.d.ts | 17 ++++++++ src/@types/splash/UseSplashReturn.d.ts | 25 +++++++++++ src/utils/url/hasDeepLink.ts | 59 ++++++++++++++++++++++++++ tests/utils/url/hasDeepLink.test.ts | 34 +++++++++++++++ 5 files changed, 157 insertions(+) create mode 100644 src/@types/splash/SplashError.d.ts create mode 100644 src/@types/splash/UseSplashInput.d.ts create mode 100644 src/@types/splash/UseSplashReturn.d.ts create mode 100644 src/utils/url/hasDeepLink.ts create mode 100644 tests/utils/url/hasDeepLink.test.ts 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..6606053c --- /dev/null +++ b/src/@types/splash/UseSplashInput.d.ts @@ -0,0 +1,17 @@ +import type { EngineStatus } from '../engine/EngineStatus'; +import type { LoadProgressState } from '../loading/LoadProgressState'; + +/** + * UseSplashInput — the signals the splash hook needs from upstream + * hooks (useEngine, useFamousMeta). Keeping these as a struct rather + * than positional args means App.tsx can wire them in any order without + * silently mis-binding two booleans. + */ +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; +}; 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/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/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); + }); +}); From 729ee2dfa111c93c80502ec9ede67d691adb6c6b Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:14:55 +0200 Subject: [PATCH 05/12] feat(splash): add useSplash hook (happy-path state machine) Owns visibility, readiness gate, dismiss/reopen, localStorage versioning, deep-link bypass, and the 8 s Continue-anyway timer. Co-Authored-By: Claude Opus 4.7 --- src/hooks/useSplash.ts | 199 ++++++++++++++++++++++++++++++++++ tests/hooks/useSplash.test.ts | 124 +++++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 src/hooks/useSplash.ts create mode 100644 tests/hooks/useSplash.test.ts diff --git a/src/hooks/useSplash.ts b/src/hooks/useSplash.ts new file mode 100644 index 00000000..7df3e96b --- /dev/null +++ b/src/hooks/useSplash.ts @@ -0,0 +1,199 @@ +/** + * 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'; + +/** 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 } = 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); + }, []); + + return { + splashVisible, + blocked, + canContinueAnyway, + error: null, // populated in Task 6 + dismissExplore, + dismissTour, + reopen, + }; +} diff --git a/tests/hooks/useSplash.test.ts b/tests/hooks/useSplash.test.ts new file mode 100644 index 00000000..fb71dec3 --- /dev/null +++ b/tests/hooks/useSplash.test.ts @@ -0,0 +1,124 @@ +// @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); + }); +}); From ecf40075850990f131fe8a01a9a90bacec64af8f Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:18:17 +0200 Subject: [PATCH 06/12] feat(splash): map engine + famous-meta errors to SplashError in useSplash Co-Authored-By: Claude Opus 4.7 --- src/@types/splash/UseSplashInput.d.ts | 14 ++--- src/hooks/useSplash.ts | 30 ++++++++++- tests/hooks/useSplash.test.ts | 74 +++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 8 deletions(-) diff --git a/src/@types/splash/UseSplashInput.d.ts b/src/@types/splash/UseSplashInput.d.ts index 6606053c..ae6b48ee 100644 --- a/src/@types/splash/UseSplashInput.d.ts +++ b/src/@types/splash/UseSplashInput.d.ts @@ -1,12 +1,6 @@ import type { EngineStatus } from '../engine/EngineStatus'; import type { LoadProgressState } from '../loading/LoadProgressState'; -/** - * UseSplashInput — the signals the splash hook needs from upstream - * hooks (useEngine, useFamousMeta). Keeping these as a struct rather - * than positional args means App.tsx can wire them in any order without - * silently mis-binding two booleans. - */ export type UseSplashInput = { /** Engine status from `useEngine`. */ status: EngineStatus; @@ -14,4 +8,12 @@ export type UseSplashInput = { 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/hooks/useSplash.ts b/src/hooks/useSplash.ts index 7df3e96b..02b56404 100644 --- a/src/hooks/useSplash.ts +++ b/src/hooks/useSplash.ts @@ -63,6 +63,7 @@ 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'; @@ -114,7 +115,7 @@ function readUrlAtMount(): { hash: string; search: string } { } export function useSplash(input: UseSplashInput): UseSplashReturn { - const { status, loadProgress, famousMetaReady } = input; + const { status, loadProgress, famousMetaReady, famousMetaFailed = false } = input; // ── Initial visibility (snapshot at mount) ─────────────────────────────── // @@ -187,11 +188,36 @@ export function useSplash(input: UseSplashInput): UseSplashReturn { 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: null, // populated in Task 6 + error, dismissExplore, dismissTour, reopen, diff --git a/tests/hooks/useSplash.test.ts b/tests/hooks/useSplash.test.ts index fb71dec3..209ed772 100644 --- a/tests/hooks/useSplash.test.ts +++ b/tests/hooks/useSplash.test.ts @@ -122,3 +122,77 @@ describe('useSplash', () => { 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(); + }); +}); From 5a2410d3fe63051c96e659a8b0f760d0e6d1a30e Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:22:41 +0200 Subject: [PATCH 07/12] feat(splash): add AboutPill top-bar reopener component Co-Authored-By: Claude Opus 4.7 --- src/components/Splash/AboutPill.module.css | 61 +++++++++++++++++ src/components/Splash/AboutPill.tsx | 78 ++++++++++++++++++++++ tests/components/Splash/AboutPill.test.ts | 42 ++++++++++++ 3 files changed, 181 insertions(+) create mode 100644 src/components/Splash/AboutPill.module.css create mode 100644 src/components/Splash/AboutPill.tsx create mode 100644 tests/components/Splash/AboutPill.test.ts diff --git a/src/components/Splash/AboutPill.module.css b/src/components/Splash/AboutPill.module.css new file mode 100644 index 00000000..45757a61 --- /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: var(--radius-pill); + backdrop-filter: var(--blur-card); + -webkit-backdrop-filter: 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/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'); + }); +}); From f1e3a217a5f7daf5b93a805f99079429a903b102 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:28:04 +0200 Subject: [PATCH 08/12] feat(splash): add Splash dialog component (markup, copy, error states) Co-Authored-By: Claude Opus 4.7 --- src/components/Splash/Splash.module.css | 221 ++++++++++++++++++++++++ src/components/Splash/Splash.tsx | 192 ++++++++++++++++++++ tests/components/Splash/Splash.test.ts | 113 ++++++++++++ 3 files changed, 526 insertions(+) create mode 100644 src/components/Splash/Splash.module.css create mode 100644 src/components/Splash/Splash.tsx create mode 100644 tests/components/Splash/Splash.test.ts 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..ffd277a7 --- /dev/null +++ b/src/components/Splash/Splash.tsx @@ -0,0 +1,192 @@ +// 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 { 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; + + return ( +
+
+

+ Explore millions of galaxies in 3D +

+

+ A real-time 3D map of the universe, rendered in your browser. 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/tests/components/Splash/Splash.test.ts b/tests/components/Splash/Splash.test.ts new file mode 100644 index 00000000..1bf12ff4 --- /dev/null +++ b/tests/components/Splash/Splash.test.ts @@ -0,0 +1,113 @@ +// @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/i)).toBeInTheDocument(); + }); +}); From 1e18ebea303b2eec17ea3f9163076414589f482b Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:30:01 +0200 Subject: [PATCH 09/12] fix(splash): revert body copy to grill-approved text + narrow WebGPU test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous Splash component edit changed the body to avoid a getByText ambiguity between the body and the error message. Revert the copy to the spec's mandated text ("Drawn in your browser with WebGPU. Built from real cosmic data…") and narrow the WebGPU-init test to match "/webgpu failed/i" so the assertion fires only on the error box, not the body mention. Co-Authored-By: Claude Opus 4.7 --- src/components/Splash/Splash.tsx | 2 +- tests/components/Splash/Splash.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Splash/Splash.tsx b/src/components/Splash/Splash.tsx index ffd277a7..10b40ffa 100644 --- a/src/components/Splash/Splash.tsx +++ b/src/components/Splash/Splash.tsx @@ -111,7 +111,7 @@ export function Splash(props: SplashProps): ReactNode { Explore millions of galaxies in 3D

- A real-time 3D map of the universe, rendered in your browser. Built from real cosmic data — the{' '} + Drawn in your browser with WebGPU. Built from real cosmic data — the{' '} SDSS diff --git a/tests/components/Splash/Splash.test.ts b/tests/components/Splash/Splash.test.ts index 1bf12ff4..5651440e 100644 --- a/tests/components/Splash/Splash.test.ts +++ b/tests/components/Splash/Splash.test.ts @@ -108,6 +108,6 @@ describe('Splash', () => { 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/i)).toBeInTheDocument(); + expect(screen.getByText(/webgpu failed/i)).toBeInTheDocument(); }); }); From 04c04b4b8628106a9643f7cca0024544baf56001 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:33:58 +0200 Subject: [PATCH 10/12] feat(splash): focus trap + initial focus + Esc dismiss in Splash dialog Co-Authored-By: Claude Opus 4.7 --- src/components/Splash/Splash.tsx | 63 +++++++++++++++++++++++++- tests/components/Splash/Splash.test.ts | 30 ++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/src/components/Splash/Splash.tsx b/src/components/Splash/Splash.tsx index 10b40ffa..d7305e66 100644 --- a/src/components/Splash/Splash.tsx +++ b/src/components/Splash/Splash.tsx @@ -34,7 +34,7 @@ * React mounts; the splash never sees that case. */ -import { type ReactNode } from 'react'; +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'; @@ -98,8 +98,68 @@ export function Splash(props: SplashProps): ReactNode { ? '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 diff --git a/tests/components/Splash/Splash.test.ts b/tests/components/Splash/Splash.test.ts index 5651440e..1e041eee 100644 --- a/tests/components/Splash/Splash.test.ts +++ b/tests/components/Splash/Splash.test.ts @@ -111,3 +111,33 @@ describe('Splash', () => { 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); + }); +}); From 1ac0bc0607fd1213bfcf49aab4d28fe7e220b708 Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:39:58 +0200 Subject: [PATCH 11/12] feat(splash): wire Splash + AboutPill + useSplash into App.tsx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splash dialog covers the first-paint window with branded content and two CTAs; AboutPill joins the top-bar pill row as the reopener. Tour currently dismisses like Explore — wiring of the stub tour lands in the companion plan. Co-Authored-By: Claude Opus 4.7 --- src/components/App/App.tsx | 49 ++++++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) 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()} + /> + )} ); } From 9e3434dd1759d98ad5d284bf335edac586a4075a Mon Sep 17 00:00:00 2001 From: Alexander Rulkens Date: Wed, 20 May 2026 03:50:41 +0200 Subject: [PATCH 12/12] fix(splash): correct AboutPill backdrop-filter syntax + pill border-radius MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `backdrop-filter: var(--blur-card)` is invalid — the token holds a bare length (12px), not a filter function. Wrap with `blur(...)` so the frosted-glass effect actually renders. Sibling pills (SearchTrigger, AutoRotateToggle) use the correct `blur(var(--blur-card))` pattern; AboutPill was an outlier. `--radius-pill` was never defined in global.css; the project defines --radius-xs through --radius-2xl, but no pill variant. Sibling pill components use the literal `999px` for the full-pill effect. Match that here so the AboutPill renders as a circle, not a square with 0px radius. Co-Authored-By: Claude Opus 4.7 --- src/components/Splash/AboutPill.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Splash/AboutPill.module.css b/src/components/Splash/AboutPill.module.css index 45757a61..b58ee8e5 100644 --- a/src/components/Splash/AboutPill.module.css +++ b/src/components/Splash/AboutPill.module.css @@ -12,9 +12,9 @@ .pill { background: var(--surface-card-soft); border: 1px solid var(--border-card); - border-radius: var(--radius-pill); - backdrop-filter: var(--blur-card); - -webkit-backdrop-filter: var(--blur-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;