diff --git a/docs/grill-sessions/2026-05-20-splash-screen.md b/docs/grill-sessions/2026-05-20-splash-screen.md new file mode 100644 index 00000000..47147681 --- /dev/null +++ b/docs/grill-sessions/2026-05-20-splash-screen.md @@ -0,0 +1,395 @@ +# Grill Session: Splash Screen — 2026-05-20 + +Source: ad-hoc conversation. User asked to build a splash screen for skymap; this session resolved the design tree before writing the implementation plan. + +We're adding a first-paint overlay that covers the awkward "blank canvas → catalog pop-in" moment with branded content + two CTAs (Explore / Tour). The splash doubles as an entry surface that funnels users into either free-roam exploration or a scripted camera tour through the dataset. Both as a UX improvement (the loading curtain) and as a marketing surface (intro blurb tells r/Astronomy / r/WebGPU visitors what they're looking at). + +The session also produced one meta-decision: the existing 2026-05-07 tour design brainstorm is being **superseded** by a future "real tour" plan that will be written as a follow-up to this splash plan. The splash ships with a *stub tour* (chained `cameraTween` calls — rough but functional); the real tour engine replaces the stub later behind the same Tour button. + +--- + +## Q1: What is the splash screen primarily *for*? + +**The question:** "Splash screen" can mean very different things. The design tree branches entirely differently for a loading curtain vs. a persistent onboarding card vs. a cinematic title reveal vs. a defensive WebGPU-unsupported gate. We need the primary purpose before we can decide anything else. + +**Considerations:** + +- **Option A (loading curtain):** Full-screen branded overlay that covers the canvas during WebGPU init + first catalog fetch, fades away once the first real frame is ready. Solves the "blank black canvas → pop-in galaxies" moment. ~1–4 s typical lifetime. Most concrete UX problem skymap currently has. +- **Option B (onboarding intro):** Persistent "What is this?" card shown once per visitor (localStorage-gated), with explicit "Begin" button, project pitch, controls hint, credits. User dismisses explicitly. Higher friction but more informative. +- **Option C (cinematic title reveal):** Always-plays brand moment — animated title, maybe a teaser camera move — purely aesthetic, gated by click-to-enter. Highest polish, highest friction. +- **Option D (WebGPU gate):** Defensive screen that detects unsupported environments and either explains or offers a fallback. "Splash" is the explainer for unsupported clients; happy path goes straight to canvas. Separate concern that can ship alongside but doesn't need to be *the* splash. + +**Decision:** Hybrid of A + B + C, with A as the primary purpose. The splash is a loading curtain that includes a small intro blurb to read during the load, plus two CTAs: **Explore** (dismiss → free-roam) and **Tour** (start scripted guided sequence through the dataset). User explicitly mentioned having existing research plans on the tour content (local group → voids → cosmic web → fully zoomed-out wide view). + +This frames the splash as time-the-user-already-has-to-wait converted into onboarding value, rather than friction added on top of a working app. + +--- + +## Q2: Dismiss model — gated entry or auto-dismiss overlay? + +**The question:** Does the user have to click a button to dismiss the splash, or does it auto-dismiss when loading finishes? This choice cascades into deep-link behavior, returning-visitor experience, and whether the splash needs a "Skip" affordance. + +**Considerations:** + +- **Option A (gated entry):** Splash is the single front door. The canvas is paused/hidden behind it until the user clicks Explore or Tour. Loading happens during read time; CTAs are disabled until ready, then activate. User always makes an explicit choice. Predictable but adds friction on every visit unless we layer localStorage on top. +- **Option B (auto-dismiss overlay):** Splash appears, loading happens behind it, and when the catalog is ready the splash fades on its own. CTAs are shortcuts — Explore just dismisses early ("I'm done reading"), Tour starts the scripted sequence. If user does nothing, they end up in free-roam mode automatically. Zero-friction; CTAs add value without being gates. +- **Hybrid (middle ground):** Gated on first visit, auto-dismiss (or skip entirely) on subsequent visits and/or deep-link arrivals. + +Sub-effects that distinguish the options: + +- **Deep-link behavior:** A forces every `https://skymap.rulkens.com/#focus=NGC224` arrival to click through the splash before reaching their target. B can auto-dismiss instantly for deep-link arrivals. +- **Returning visitors:** A needs localStorage opt-out logic to avoid annoying regulars. B handles this implicitly via auto-dismiss. +- **Skip affordance:** A needs "Skip intro" or Esc-to-dismiss; B doesn't. + +**Decision:** Hybrid — **gated on first visit, auto-skip entirely on deep-link arrival, content remains discoverable via a small "About" affordance in the chrome.** + +A deep link is any URL that expresses specific user intent: `#focus=…`, `#poi=…`, `?tour=…`. Power-user URL gates (`?debug`, `?gpuTimings`) don't count because they don't change what the user is looking *at*. A first-time visitor who lands on a deep link from social media is still given the option to discover the intro content later via the About pill — zero-friction for the deep-link user, still discoverable. + +This rejects pure A (too much friction on every visit + every deep link) and pure B (collapses the marketing surface — most users would see the splash for <1 s before auto-dismiss and miss the blurb entirely). + +--- + +## Q3: Relationship between this splash and the (currently unbuilt) tour engine? + +**The question:** The Tour button is one of two equal CTAs, but the tour engine is a paused brainstorm in `docs/superpowers/specs/2026-05-07-tour-animation-design.md` with ~1 day of work and several pending design decisions (rotation slerp, MSDF labels, easing curves, API shape, UI-hide coupling). The splash project either blocks on the tour, scopes it in, or ships with the Tour button doing something rougher. + +**Considerations:** + +- **Option A (splash blocks on tour):** Don't start the splash until the full tour engine ships. Means picking up the 2026-05-07 brainstorm, finishing open questions, building it, then building the splash on top. ~3–4 days of work. Loading-curtain win waits on tour polish. +- **Option B (splash ships first, Tour button disabled):** Both buttons render; Tour is grayed out with "Coming soon" until the tour engine lands. Half-shipped UX. A disabled button on a marketing surface looks like a bug. +- **Option C (splash ships with a stub tour):** Tour button does a minimal canned thing — a 4–6 step queue of existing `cameraTween` calls chained in sequence. No rotation slerp, no narration, no labels, no easing polish. "Good enough" first cut that ships with the splash. Real tour engine swaps in later behind the same button. ~1 hour of `cameraTween` chaining vs. the full tour's ~1 day. +- **Option D (splash + tour as one unified deliverable):** Treat as one feature; finish brainstorm, build tour engine, build splash, ship together. Same total work as A, just framed as one project. + +**Decision:** **Option C (splash ships with a stub tour).** + +Trade-off acknowledged: the first version of "Tour" will not feel cinematic — `cameraTween` produces snap-rotate-then-dolly transitions (decision 1 of the original tour brainstorm). Acceptable for v1 because: + +1. The splash's primary purpose (loading curtain) doesn't depend on tour polish. +2. Shipping the rough cut validates both splash UX and basic tour mechanics, informing the real tour design rather than guessing at it. +3. The stub forces a stable trigger surface (button click → tour starts) that the real tour can adopt without API churn. +4. The real tour engine is genuinely a separate project with its own open questions; bundling it into the splash inflates scope without architectural payoff. + +Follow-up: the user decided mid-session that the existing 2026-05-07 brainstorm should be **superseded** by a new dedicated "real tour" plan, written as a follow-up to the splash plan. The old spec gets retired (or rewritten as the new plan's spec doc) when that new plan is written — not as part of the splash plan. + +--- + +## Q4: Readiness gating + escape valve for slow loads + +**The question:** What must be true before Explore / Tour activate? And what happens if loading takes a long time on a slow connection? + +**Considerations — gating level:** + +- **Option A (aggressive, gate on WebGPU init only):** CTAs enable in <500 ms. User clicks Explore → splash dismisses → catalog pops in over the next 1–5 s, visible behind the disappearing splash. Fast time-to-interactive but the moment-of-reveal is a wave of incoming dots, not a populated cosmos. Defeats the loading-curtain purpose. +- **Option B (medium, gate on WebGPU + first catalog batch):** CTAs enable when there's something to look at. Maybe 2MRS done (2.4 MB) plus first galaxy source. User clicks → splash dismisses → cosmos is already there. Higher-quality reveal at the cost of 1–3 s additional gated time. +- **Option C (conservative, gate on full catalog + famous meta + everything):** Best reveal, worst patience cost. Most users reach for a button in 3–4 s of reading and forcing them to wait for filaments + volumes + tier-3 GLADE feels broken. + +**Considerations — escape valve:** + +- **Slow but not failed:** Show inline progress (we already have `LoadProgressState` aggregating this). After threshold (8 s) surface a "Continue anyway" link. Or trust the load and never offer escape. +- **WebGPU unsupported:** Swap splash content for browser-compatibility message. No CTAs. +- **Fetch fails:** Retry with backoff; surface error state with reload button. + +**Considerations — should Tour have a different readiness signal than Explore?** The stub tour needs anchor lookups in `famous_meta.json` (Milky Way, M31, etc.). Could gate Tour stricter than Explore, or use a single shared signal. + +**Decision:** **Medium gating (option B) + famous-meta also required + "Continue anyway" escape after 8 s.** + +User explicitly asked for famous-meta to be loaded before CTAs activate — "famous meta should definitely have loaded." This means both CTAs share a single readiness signal: **WebGPU init done + first catalog batch loaded + `famous_meta.json` loaded.** No differentiation between Explore and Tour readiness — simplifies state, prevents "Tour disabled, Explore enabled" intermediate UI. + +Aggressive (A) rejected because it defeats the splash's core purpose. Conservative (C) rejected because waiting for filaments / volumes / tier-3 GLADE creates an unfairly long gate on slow connections. + +The 8 s "Continue anyway" escape is a safety valve for slow-network users who otherwise feel trapped. It's a small link that fades in only after the threshold elapses, doesn't clutter the happy-path UI for normal-speed users. + +WebGPU-unsupported and fetch-failure states are deferred to Q7. + +--- + +## Q5: Visual relationship to the canvas behind + +**The question:** How does the splash surface relate to the WebGPU canvas it's covering? This decision drives polish budget, when the engine starts rendering, mobile considerations, and how the tour transitions feel. + +**Considerations:** + +- **Option A (opaque curtain):** Full-viewport solid surface (dark cosmic-styled background, maybe static starfield SVG). Canvas behind may or may not render. When dismissed, fade-out reveals populated cosmos in one beat. Simple, robust, no engine coordination needed. Boring-correct. +- **Option B (translucent / blurred over a live canvas):** Engine starts rendering as soon as it can; splash sits on top as semi-transparent + backdrop-blurred panel. User sees galaxies materializing softly behind during load. Costs: galaxies pop in *behind* splash visibly, careful contrast handling against moving background, `backdrop-filter: blur` has real perf cost (especially on mobile Safari). +- **Option C (splash-as-cinematic):** Canvas plays a deliberate intro camera move during the splash — camera starts very far out, slowly dollies in. Splash card sits over this directed shot. Clicking Tour seamlessly continues the motion. Most cinematic, most coupled to engine + tour code. + +These compose with the tour-stub choice (Q3): C makes the splash's ambient camera move essentially leg-zero of the tour, but adds days of coordination work (what if loading finishes mid-shot? how does handoff to tour stub feel?). + +Mobile / low-end-GPU: B's `backdrop-filter: blur` is the most fragile (some browsers drop it); A is essentially free; C runs renderer harder during startup, competing with catalog uploads. + +**Decision:** **Option B (translucent + blurred over live canvas), with auto-rotate explicitly OFF.** + +User overrode my recommendation of A here. Going with B because the tease/atmosphere payoff is worth the perf and contrast complexity. The "UI fully hidden" rider (panels, InfoCard, search trigger, ScaleBar, even the existing LoadingBar) reuses the existing `uiHidden` mechanism in App.tsx (`appStyles.uiStackHidden`), so hiding all chrome during splash costs essentially nothing to implement. + +Auto-rotate stays in its default off state during splash (user's explicit pushback: "lets not make it hard"). The user still sees catalog dots materialize through the blur as load progresses — just without motion. Removes coordination complexity (no need to start/stop auto-rotate on mount/dismiss, no need to remember and restore the user's previous autoRotate preference). + +Mobile fallback documented for later: if `backdrop-filter: blur` perf hurts on iOS Safari, fall back to higher-opacity solid dark backdrop via `@media (max-width: 768px)` CSS override. Cheap insurance, one-line change if needed. + +C rejected: cinematic ambient shot is a project of its own that doesn't pay back proportionally for a 2–4 s screen. Splash should be replaceable later without painting into a corner. + +--- + +## Q6: First-visit detection mechanism + +**The question:** The gated-on-first-visit decision (Q2) only works if we have a reliable signal for "is this the user's first visit?" Plus a sub-question: should the splash *reappear* when content changes meaningfully? + +**Considerations — detection:** + +- **Option A (`localStorage["skymap.splash.seen"]`):** Set when user dismisses. Survives across sessions and tabs. Easy to opt-out test (clear site data → splash returns). Fails silently in privacy modes that block localStorage — user gets splash every time, which is a worse-but-acceptable degradation. +- **Option B (`sessionStorage["skymap.splash.seen"]`):** Survives reload within one tab but not new tabs / next-day visits. Every new tab triggers the splash. Too noisy for regulars. +- **Option C (cookie):** Same as A but works under stricter privacy regimes. Skymap doesn't use cookies anywhere else; adding one is a (small) policy/banner question in EU jurisdictions. +- **Option D (no persistence — splash always shows):** Honest, no storage to manage. Annoying for regulars. + +**Considerations — version-busting:** + +- **Sub-option i (never re-show once seen):** Set-and-forget. +- **Sub-option ii (re-show on version bump):** Bake a version constant; store `seen-version`; re-show when current version > stored version. Cheap to implement; gives a deliberate lever to re-engage returning visitors when there's genuinely new content. +- **Sub-option iii (re-show after N days of inactivity):** "Welcome back, here's what's new." Cuter, more complex, requires timestamp storage. + +**Decision:** **A + ii — `localStorage["skymap.splash.seenVersion"] = ` compared against a `CURRENT_SPLASH_VERSION` constant in code.** Splash shows when stored value is missing or lower than current. Bumping the version is a single-line change made alongside meaningful content edits. + +Caveat acknowledged: the first deploy of the splash shows it to *every* existing returning user once (no stored version exists yet). That's the desired behavior — existing visitors are the audience most likely to benefit from learning the new affordance. + +B / C / D rejected per their cons above; iii rejected as added complexity without proportional benefit (the cuteness of an inactivity-based reappearance doesn't pay back the storage management). + +--- + +## Q7: Failure-state handling + +**The question:** The splash is now the entire visual surface during startup, making it the natural place to surface what goes wrong. Four failure modes are possible: +1. WebGPU unsupported (browser lacks `navigator.gpu`). +2. WebGPU init fails (`requestAdapter()` returns null on supported browser). +3. Catalog fetch fails (R2 down, network error, CORS regression). +4. Famous-meta fetch fails (small JSON; Tour needs it, Explore doesn't strictly). + +**Considerations:** + +- **Option A (one unified "something went wrong" state):** Splash content swaps to an error message with Reload button. Same surface for all four; message text differentiates. Simple, but loses information — "WebGPU unsupported" needs fundamentally different recovery (switch browsers) vs. "fetch failed" (retry). +- **Option B (fully differentiated states):** WebGPU-unsupported gets dedicated screen with browser compat list and fallback link. Fetch failures get "Reconnecting…" with backoff retry. Famous-meta failure degrades gracefully (Explore live, Tour disabled with tooltip). More polish, more code paths. +- **Option B-lite (mostly B, with WebGPU-unsupported handled OUTSIDE the splash entirely):** WebGPU-unsupported is detectable synchronously before React mounts (`typeof navigator.gpu === 'undefined'`). Render a static HTML page from `index.html`-style markup at that point; never mount React or splash machinery. Splash handles the remaining three runtime failures distinctly. +- **Option C (splash punts; defer to existing surfaces):** Splash assumes happy path. WebGPU-unsupported handled separately; catalog failures fall through to whatever engine does today (probably nothing graceful). + +**Decision:** **Option B-lite.** + +Rationale: + +- WebGPU-unsupported is silly to handle inside the React tree — it's a synchronous boolean check that should fail fast and render a static page. Avoids instantiating React, useEngine, useFamousMeta just to show "your browser can't do this." +- Splash handles the three runtime failures as differentiated in-splash states because they need different recovery affordances. Bundling them into one error state loses meaningful information. +- Famous-meta failure is the one graceful-degradation case worth treating specially: Explore stays live, Tour disables with explanation tooltip. Because the famous-meta gate is required for tour anchor lookups (Q4), this is also the only failure mode where the splash can still complete its primary purpose. + +A rejected (loses recovery information). Full B rejected (over-engineered WebGPU-unsupported path adds React weight for a fast-fail case). C rejected (if we're building the splash, building its failure path is part of the job, not a separate concern). + +Open sub-decision deferred: the exact browser-compat copy on the WebGPU-unsupported page (whether to say "Use Chrome or Edge" or list the real WebGPU support matrix). That's a copy iteration, not architectural. + +--- + +## Q8: Tour stub itinerary — what are the actual stops? + +**The question:** What is the scripted sequence the stub tour plays when the user clicks Tour? Knowing the implementation is chained `cameraTween` calls with snap-rotate-then-dolly transitions (no smooth orientation slerp until the real tour engine ships). + +**Considerations:** + +- **Option A (faithful to original spec, 3 stops, ~25 s):** Milky Way impostor → M31 → wide SDSS wedge view. Hits the "familiar → bigger neighbour → cosmic structure" beat. Tightest scope; stub is genuinely a rough cut of the planned tour. +- **Option B (expanded "powers of ten" arc, 5–6 stops, ~40–60 s):** Milky Way → Local Group (zoom out) → Virgo Cluster → Coma / Great Wall (filaments visible) → Boötes Void or similar → fully zoomed out wide view. More content, more "story" — but more snap-rotate-stub transitions to hit bugs at, bigger gap between stub feel and final tour feel. +- **Option C (minimal teaser, 2 stops, ~15 s):** MW → wide view. Shortest possible stub. Lets you ship faster; intentionally undersells so the real tour is a clear upgrade. + +Sub-flag: the **void beat** in option B is risky in a snap-cut stub without narration text — "camera arrives at a location with no galaxies" reads ambiguously, could be intentional or could read as "the app broke." + +**Decision:** **Option B (expanded "powers of ten" arc).** + +Risk mitigation for the void beat carried forward into spec/plan stage: either (a) put the void beat earlier in the sequence so the climax is the cosmic-web wide-view payoff (where it's visual not conceptual), or (b) add a temporary on-screen caption per beat using the existing MSDF labels system ("Boötes Void — 330M ly across, ~60 known galaxies"). The MSDF system is already shipped, so per-beat captions are cheap even in the stub. The exact arc, per-leg durations, and caption-vs-no-caption decision happen during the splash plan's task breakdown. + +A rejected as too narrow given the user's stated intent (they explicitly mentioned local group, voids, cosmic web, zooming out). C rejected as too short — at ~15 s the user has barely registered "oh, this is a tour" before it's over. + +--- + +## Q9: Tour mode UX (interaction model + end state) + +**The question:** Once the Tour starts, how does it behave? Specifically: (1) UI visibility during tour, (2) what happens when user interacts mid-tour, (3) what end state does the tour leave the user in? + +These were decision 6 of the original 2026-05-07 brainstorm. For the stub, we lock conservative defaults; the future real-tour plan will revisit all three. + +**Considerations — UI visibility:** + +- **1A (auto-hide chrome on Tour start, restore on Tour end):** Matches splash's UI-hidden choice. User can Tab to reveal manually. Cohesive cinematic feel. +- **1B (leave chrome visible):** Less cinematic but lets user bail via UI clicks. + +**Considerations — interaction during tour:** + +- **2A (any input cancels tour and returns control):** Standard cinematic-tour pattern (Google Earth, planetariums). Predictable, never feels trapped. +- **2B (uninterruptible until end or explicit Esc):** Pure "sit back and watch." Controlled but frustrating for inspect-mid-flight intent. +- **2C (drag pauses, Esc cancels):** Most sophisticated, most code. Better than 2A but punts to the real tour plan. + +**Considerations — end state:** + +- **3A (stop at final position, restore UI, user explores from there):** End-state = looking at the whole dataset from outside. Natural starting point for free exploration. +- **3B (loop back to start):** Right for installations, weird for normal visitors. +- **3C (return camera to home):** Symmetric with Explore but wastes the climax — undoing the journey. + +**Decision:** **1A + 2A + 3A — auto-hide UI on Tour start, any input cancels and returns control, tour ends at final position with UI restored.** Conservative/simple choices; the real tour plan revisits all three. + +Implementation note: 2A requires the tour engine to poll a "cancel requested" flag every frame and bail cleanly between legs. The existing `cameraTween` is already cancelable (starting a new tween snapshots current state), so the stub just needs to (a) detect any input and (b) stop scheduling the next leg. + +Side effect: clicking the About pill mid-tour reopens the splash AND cancels the tour (treated as input). Confirmed and intentional. + +--- + +## Q10: About reopener placement + +**The question:** Where in the existing chrome does the "About" affordance live? This is the post-dismissal reopener for deep-link arrivals who want to see the intro content, and for returning users who want to re-read. + +**Considerations:** + +- **Option A (top-right corner pill):** Small `?` or "About" button matching SearchTrigger pill styling, in empty top-right space. Canonical "help in top-right" pattern. Unambiguously meta; doesn't crowd existing controls. +- **Option B (fold into top-center cluster):** Add About pill next to SearchTrigger + AutoRotateToggle. Keeps chrome lean (one cluster, not two) but top-center gets busy. +- **Option C (settings panel section):** "Show intro" link inside SettingsPanel. Lowest discoverability — casual users never find it. +- **Option D (combine with future "replay tour" affordance):** Defer About to a tour-replay button that doesn't exist yet. Cuter, more deferred. + +**Decision:** **Option B — pill in the top-center cluster, next to the play (AutoRotateToggle) button.** Top-bar flex row becomes `[SearchTrigger | AutoRotate | About]`. All three share `appStyles.topBar` and the palette-open fade-out coordination is already wired. + +User explicitly chose this layout over my recommendation of A (top-right). Top-center clustering keeps related controls in one visual group; the user is comfortable with the slight density increase. + +The About pill participates in the Tab-hide behavior and the splash's own UI-hidden state. Clicking About mid-tour reopens splash AND cancels the tour (per Q9 sub-decision). + +C rejected (worst discoverability — deep-link arrivals won't think to open Settings). D rejected as coupling About's design to a tour-replay feature that doesn't exist yet; better to ship About now and decide later whether to merge with a tour-replay affordance. + +--- + +## Q11: Blurb content + +**The question:** What does the splash actually say? Has to land for multiple audiences (astronomy nerds, WebGPU devs, casual social-share visitors, returning users on version bumps). + +**Considerations — structure:** + +- **Option A (one-line tagline + buttons):** Maximum speed. "Three million galaxies. In your browser." Pithy, mysterious; relies on visual context. +- **Option B (tagline + 1–2 sentence supporting blurb + buttons):** Standard hero pattern. Names the thing and the differentiator (real data, not procedural art). +- **Option C (tagline + blurb + controls hint + buttons):** Adds "Drag • Scroll • Cmd+K". Most informative but crowds focal point and competes with CTAs for attention. + +**Considerations — sub-decisions:** + +- **Attribution:** Inside blurb (signed), tertiary footer, separate About panel, or omitted. +- **Version / build info:** Helps bug reports but adds noise. +- **"What's new" callout on version bump:** Requires changelog-line-in-code per bump. + +**Decision:** **Option B, with these specifics:** + +- **Title:** "Explore millions of galaxies in 3D" (user's wording; action-first, matches the existing meta description style "Fly through millions of galaxies..."). +- **Body:** Mentions real data + the three survey names (SDSS, GLADE, 2MRS). Survey names linked inline (`target="_blank" rel="noopener"`) to their project homepages — gives astronomy-curious visitors a credibility anchor. Links don't dismiss splash. +- **Footer:** Tertiary muted text with author name + attribution / GitHub link. +- **No controls hint inside the splash** — relies on user's natural drag-to-orbit instinct. Compensating tooltip after dismissal is a separate future polish question. +- **No version / build info on splash.** +- **No "what's new" line on version bump** — bumps are for content that the main blurb itself announces. + +Working stub copy (the user explicitly said "we can iterate on the copy later"): + +``` +Explore millions of galaxies in 3D + +Drawn in your browser with WebGPU. Built from real cosmic data — +the SDSS, GLADE, and 2MRS galaxy surveys. + +[Explore] [Tour] + +by Alexander Rulkens · github.com/rulkens/skymap +``` + +A rejected (too cool-kid mysterious — visitor from a Bluesky link doesn't know SDSS or 2MRS but cares that "this is real data"). C rejected (controls hint adds visual weight to "this might be complicated" exactly when you want it to feel inviting). + +--- + +## Q12: Mobile / small-viewport behavior + +**The question:** Skymap has a 768 px breakpoint via `initialMobile`. The desktop splash layout (centered card, ~480 px wide, side-by-side CTAs, footer attribution) won't fit comfortably below ~400 px viewport width. How do we adapt? + +**Considerations:** + +- **Option A (single responsive layout):** One splash component with CSS that gracefully reflows below 768 px — smaller type, stacked-vertical CTAs, tighter padding, footer wraps. Standard responsive web pattern. +- **Option B (two distinct layouts via `initialMobile`):** Mobile gets fuller-bleed card, drops attribution to tap-to-reveal, prioritizes CTAs above fold. More polish potential, more divergence to maintain. +- **Option C (auto-skip splash entirely on small viewports):** Mobile visitor is more likely a quick-tap-from-social arrival who closes the tab on friction. Render canvas directly; surface splash content only via About pill. + +**Decision:** **Option A — single responsive layout.** + +Specific mobile adaptations to bake into CSS (no JS branch): +- Stack CTAs vertically (full-width, tappable) when viewport < ~480 px. +- Reduce title ~32 → ~24 px, body ~16 → ~14 px. +- Footer wraps to two lines. +- About pill in chrome stays in top-center cluster, shrinks like its siblings. +- Touch targets ≥44×44 px for About pill and CTAs. + +B rejected: divergent code paths buy slight polish at the cost of every copy iteration being two edits. C rejected: the loading-curtain purpose is *most* valuable on mobile networks (slowest connections), not least; throwing away the read-during-load moment on the audience that needs it most is backwards. + +Backdrop-blur perf fallback flagged for spec/plan stage: if iOS Safari has issues, `@media (max-width: 768px)` swaps the blur for higher-opacity solid dark backdrop. Cheap insurance, one-line CSS change if needed. + +--- + +## Q13: Remaining details (Explore behavior, accessibility, visual styling, architectural placement) + +**The question:** Four small/mechanical decisions presented as a single cluster to respect the user's preference for not over-segmenting design presentation. Defaults proposed for each; user accepted all four with "ok". + +### (a) Explore button behavior + +**Decision:** **Just dismiss.** Splash fades out (200 ms), UI chrome fades in via existing `uiHidden`-style transition, camera stays at engine's initial home position. No auto-focus, no auto-rotate trigger, no welcome tooltip. Simple, predictable, fewest interactions to test. + +Alternatives considered but punted to follow-up UX polish: +- Auto-rotate on after Explore (gives the user something visually alive immediately). +- One-time "Drag • Scroll • Cmd+K" tooltip near canvas to compensate for hidden chrome on the way in. + +Neither rejected; just not Splash v1. + +### (b) Accessibility + +**Decisions:** +- Esc dismisses splash (treated identically to Explore — sets `seenVersion`). +- Focus trapped inside splash while visible. Tab cycles: external survey links → Explore → Tour → loop. Initial focus on Explore. +- Splash is an accessible dialog: `role="dialog"`, `aria-modal="true"`, `aria-labelledby` → title, `aria-describedby` → body. +- Background canvas `aria-hidden="true"`, not focusable while splash up. +- About pill: `aria-label="About skymap"`, reopens splash with focus on close affordance or first button (TBD during implementation). +- Continue-anyway escape is keyboard-reachable; appearance announced via `aria-live="polite"`. + +Standard a11y-dialog pattern. Nothing surprising. + +### (c) Visual styling + +**Decisions (defaults — open to taste calls during implementation):** +- Card: rounded-rect (matches existing panels), semi-transparent dark backdrop with `backdrop-filter: blur(20px)`, max-width ~520 px desktop / 90vw mobile, vertically centered. +- Backdrop overlay outside card: full-viewport dim layer (~60% black) without blur — cosmos motion still legible behind. +- Typography: reuse existing font stack from `index.html` / `App.module.css`. Title matches InfoCard headings; body matches StatusBar / InfoCard body. No new font. +- CTAs: **Explore is primary (filled accent), Tour is secondary (outlined / ghost).** Equal-weight buttons risk decision paralysis; Explore is the most-frequent intent. +- Survey links inline: underlined-on-hover, accent color, opens in new tab. +- Footer: small muted text, ~12 px. + +User flagged this is the one opinion most likely to be revisited — if Tour should feel co-equal with Explore (treating it as a peer experience rather than a "scenic alternative"), the button weights flip to equal during implementation. + +### (d) Architectural placement + +**File layout:** + +``` +src/components/Splash/ + Splash.tsx Dialog component (pure presentational) + Splash.module.css + AboutPill.tsx Top-bar reopener, sits in topBar row + splashStubTour.ts Chained-tween itinerary + runner (pure function, no React) + +src/hooks/ + useSplash.ts Orchestration: seenVersion, deep-link detection, + readiness signal, dismiss state, Continue-Anyway timer +``` + +`useSplash` reads `useEngine`'s existing `loadProgress` + `status` plus a famous-meta-loaded signal (probably pulled up from `useFamousMeta`). Returns `{ splashVisible, blocked, canDismiss, dismissExplore, dismissTour, reopen }`. App.tsx uses it alongside other hooks — small wiring footprint. + +`splashStubTour.ts` is called by `dismissTour`; chains `handleRef.current?.camera.focusOn(...)` calls with await-able tween completion. + +WebGPU-unsupported handling lives **outside** all of this — synchronous check at top of `main.tsx`, before React mounts; renders static HTML page if `typeof navigator.gpu === 'undefined'`. Keeps the React tree out of the failure case entirely (per Q7 decision). + +--- + +## Out-of-scope items flagged for future plans + +These came up during the grill but explicitly belong to follow-up work, not the splash plan: + +1. **Real tour engine.** New plan to be written as a follow-up to the splash plan. Picks up the 2026-05-07 brainstorm's open questions (rotation slerp, easing, narration, MSDF labels integration, API shape, pause-on-drag, UI-hide coupling). Replaces the splash plan's stub tour behind the same Tour button. The old `2026-05-07-tour-animation-design.md` spec gets retired (or rewritten as the new plan's spec doc) during that work. +2. **Per-beat tour captions / narration text.** Either implemented in the splash stub via MSDF labels (mitigation for the void-beat ambiguity from Q8) or punted entirely to the real tour plan. Decision happens during splash plan's task breakdown. +3. **Post-dismiss controls tooltip.** A one-time "Drag • Scroll • Cmd+K" hint near the canvas after Explore is clicked, to compensate for hidden chrome on the way in. Solves a UX gap but isn't part of Splash v1. +4. **Browser-compat copy on WebGPU-unsupported page.** Whether to say "Use Chrome or Edge" (true today, ages conservatively) or list the actual WebGPU support matrix (more accurate, ages worse). Copy iteration, not architectural. +5. **Tour-replay UI affordance.** Once the real tour ships, the chrome may want a way to re-trigger the tour from outside the splash. Decision then whether to merge with About pill or keep separate. diff --git a/docs/superpowers/plans/2026-05-20-splash-screen-01-core.md b/docs/superpowers/plans/2026-05-20-splash-screen-01-core.md new file mode 100644 index 00000000..960d29bf --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-splash-screen-01-core.md @@ -0,0 +1,2358 @@ +# Splash Screen — Core Implementation Plan (Part 1 of 2) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Companion plan:** `2026-05-20-splash-screen-02-stub-tour.md` — implements the camera tour that the Tour button triggers. Plan 2 depends on this plan landing first. While this plan is in flight, Tour renders a no-op stub so the loading curtain ships standalone. + +**Goal:** Add a first-paint splash that covers the WebGPU init + first-catalog-batch window with branded content, two CTAs (Explore + Tour), a reopener pill in the top-bar, and graceful failure handling. Pre-React WebGPU-unsupported gate ships alongside. + +**Architecture:** A new `Splash` component is rendered conditionally above the existing `uiStack`, gated by a new `useSplash` hook that owns localStorage versioning, deep-link detection, readiness signaling (engine status + load progress + famous-meta), and the 8 s "Continue anyway" timer. An `AboutPill` component joins the existing `topBar` flex row. A synchronous WebGPU support check in `main.tsx` swaps the React root for a static page when `navigator.gpu` is missing — React never mounts on unsupported browsers. + +**Tech Stack:** React 19, TypeScript, CSS modules, Vitest + @testing-library/react + jsdom. Reuses existing engine handle (`useEngine`'s `status`/`loadProgress`/`handleRef`) and `useFamousMeta` (with a new `ready` field added). + +--- + +## Skymap conventions reminder (applies to every task below) + +- Always `export type X = { ... }`. Never `interface`. (Project convention.) +- Every module gets a multi-paragraph didactic header comment explaining *why* it exists and what alternatives were considered — match the style of existing files like `LoadingBar.tsx`, `SearchTrigger.tsx`. (Project convention.) +- No barrel exports for components. Import directly from the `.tsx` file. (Project convention.) +- Tests live under `tests/` mirroring `src/`. Component tests use jsdom (`// @vitest-environment jsdom`) + `@testing-library/react`. +- The dev server is left running. Do not kill it; do not run `npm run dev`. +- Run tests with `npm test` (single pass, vitest run) or a focused `npx vitest run `. + +--- + +## Task 1: WebGPU-unsupported static page module + +**Files:** +- Create: `src/unsupportedPage.ts` +- Test: `tests/unsupportedPage.test.ts` + +**Why this is a task on its own:** the splash work assumes React mounts; on browsers without `navigator.gpu` we never want to mount React at all (instantiating useEngine / useFamousMeta on an unsupported browser would just produce errors). A tiny pure module that returns the static HTML string is cleanest — main.tsx (Task 2) just calls it. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/unsupportedPage.test.ts +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(); + // Spot-check the structural markers; we don't lock the exact CSS. + expect(html).toContain('100vh'); + expect(html.toLowerCase()).toContain('skymap'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/unsupportedPage.test.ts` +Expected: FAIL with "Cannot find module '../src/unsupportedPage'". + +- [ ] **Step 3: Write the module** + +```ts +// src/unsupportedPage.ts +/** + * 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(); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/unsupportedPage.test.ts` +Expected: PASS — all four assertions green. + +- [ ] **Step 5: Commit** + +```bash +git add src/unsupportedPage.ts tests/unsupportedPage.test.ts +git commit -m "$(cat <<'EOF' +feat(splash): add WebGPU-unsupported static page renderer + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 2: Wire the pre-React WebGPU gate into main.tsx + +**Files:** +- Modify: `src/main.tsx` (replace the `createRoot` call) +- Test: covered by manual smoke (Vitest's node env can't simulate a real `navigator.gpu` missing condition cleanly; we keep the renderer pure-tested in Task 1). + +- [ ] **Step 1: Replace `src/main.tsx`** + +```tsx +// src/main.tsx +/** + * 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 + * + * 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'; +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'; + +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(); +} +``` + +- [ ] **Step 2: Run typecheck to confirm no regressions** + +Run: `npm run typecheck` +Expected: PASS — no new errors. + +- [ ] **Step 3: Run the full test suite** + +Run: `npm test` +Expected: PASS — all existing tests still green (no test depends on the old main.tsx body). + +- [ ] **Step 4: Commit** + +```bash +git add src/main.tsx +git commit -m "$(cat <<'EOF' +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 +EOF +)" +``` + +--- + +## Task 3: Expose a `ready` signal from `useFamousMeta` + +**Files:** +- Modify: `src/@types/engine/UseFamousMetaReturn.d.ts` +- Modify: `src/hooks/useFamousMeta.ts` +- Test: `tests/hooks/useFamousMeta.test.ts` (new) + +**Why:** `useSplash` (Task 5) needs to know when the famous-meta JSON has loaded so the CTAs can activate. Today `useFamousMeta` returns `{ famousMeta, famousXrefs }` but provides no way to distinguish "still loading" from "loaded successfully" (both states have `famousMeta.length === 0` until the fetch resolves). We add a `ready: boolean` flag that flips true on success OR on the swallowed-error path (per the existing "absent file = feature off" UX, a 404 is still "we tried, the splash should activate"). + +- [ ] **Step 1: Update the type** + +```ts +// src/@types/engine/UseFamousMetaReturn.d.ts +import type { FamousMetaEntry } from '../loading/FamousMetaEntry'; +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; +}; +``` + +- [ ] **Step 2: Write the failing test** + +```ts +// tests/hooks/useFamousMeta.test.ts +// @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([]); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run tests/hooks/useFamousMeta.test.ts` +Expected: FAIL — `result.current.ready` is undefined. + +- [ ] **Step 4: Update `useFamousMeta`** + +Replace the existing body with: + +```ts +// src/hooks/useFamousMeta.ts +/** + * `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, + * 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 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. 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 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'; +import { famousMetaFetcher } from '../services/loading/fetchers/famousMetaFetcher'; +import type { FamousMetaEntry } from '../@types/loading/FamousMetaEntry'; +import type { FamousXrefMap } from '../@types/loading/FamousXrefMap'; +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(); + famousMetaFetcher(undefined as void, ac.signal, () => {}) + .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 AND still flips + // `ready` to true so the splash gate doesn't deadlock. + setReady(true); + }); + return () => ac.abort(); + }, []); + + return { famousMeta, famousXrefs, ready }; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/hooks/useFamousMeta.test.ts` +Expected: PASS — all three assertions green. + +- [ ] **Step 6: Verify no callers break** + +Run: `npm run typecheck` +Expected: PASS — `App.tsx` destructures `{ famousMeta, famousXrefs }` which is still valid; the new `ready` field is additive. + +- [ ] **Step 7: Commit** + +```bash +git add src/@types/engine/UseFamousMetaReturn.d.ts src/hooks/useFamousMeta.ts tests/hooks/useFamousMeta.test.ts +git commit -m "$(cat <<'EOF' +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 +EOF +)" +``` + +--- + +## Task 4: SplashError type + deep-link detection helper + +**Files:** +- Create: `src/@types/splash/SplashError.d.ts` +- Create: `src/@types/splash/UseSplashReturn.d.ts` +- Create: `src/@types/splash/UseSplashInput.d.ts` +- Create: `src/utils/url/hasDeepLink.ts` +- Test: `tests/utils/url/hasDeepLink.test.ts` + +**Why split into types + a tiny pure helper:** the `useSplash` hook (Task 5) will be the largest piece in the plan; pulling out the pure deep-link predicate keeps the hook short, testable, and SSR-safe (the predicate guards `typeof window`). Putting types in `@types/splash/` follows the project's one-file-per-type convention. + +- [ ] **Step 1: Write the type files** + +```ts +// src/@types/splash/SplashError.d.ts +/** + * 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' }; +``` + +```ts +// src/@types/splash/UseSplashInput.d.ts +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; +}; +``` + +```ts +// src/@types/splash/UseSplashReturn.d.ts +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; +}; +``` + +- [ ] **Step 2: Write the failing test for the deep-link helper** + +```ts +// tests/utils/url/hasDeepLink.test.ts +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); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run tests/utils/url/hasDeepLink.test.ts` +Expected: FAIL with "Cannot find module". + +- [ ] **Step 4: Write the helper** + +```ts +// src/utils/url/hasDeepLink.ts +/** + * 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; +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/utils/url/hasDeepLink.test.ts` +Expected: PASS — all seven assertions green. + +- [ ] **Step 6: Commit** + +```bash +git add src/@types/splash src/utils/url/hasDeepLink.ts tests/utils/url/hasDeepLink.test.ts +git commit -m "$(cat <<'EOF' +feat(splash): add SplashError + UseSplash types + hasDeepLink helper + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 5: `useSplash` hook — happy-path state machine + +**Files:** +- Create: `src/hooks/useSplash.ts` +- Test: `tests/hooks/useSplash.test.ts` + +**Scope:** ready-signal derivation, localStorage seenVersion, deep-link gating, dismiss/reopen, the 8 s `canContinueAnyway` timer. The error mapping (engine status → SplashError) is added in Task 6 so this task stays bite-sized. + +- [ ] **Step 1: Write the failing tests** + +```ts +// tests/hooks/useSplash.test.ts +// @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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/hooks/useSplash.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the hook (happy-path only; error mapping in Task 6)** + +```ts +// src/hooks/useSplash.ts +/** + * 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, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/hooks/useSplash.test.ts` +Expected: PASS — all eleven assertions green. + +- [ ] **Step 5: Commit** + +```bash +git add src/hooks/useSplash.ts tests/hooks/useSplash.test.ts +git commit -m "$(cat <<'EOF' +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 +EOF +)" +``` + +--- + +## Task 6: `useSplash` — error mapping + +**Files:** +- Modify: `src/hooks/useSplash.ts` +- Modify: `tests/hooks/useSplash.test.ts` + +**Why a separate task:** keeps Task 5's diff small. The error mapping derives a `SplashError | null` from `status.kind === 'error'` plus a new `famousMetaFailed` input flag (added in this task because Task 3 collapsed success+failure into `ready=true`). + +- [ ] **Step 1: Extend UseSplashInput with the failure signal** + +```ts +// src/@types/splash/UseSplashInput.d.ts +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; +}; +``` + +- [ ] **Step 2: Add failing tests to the existing splash test file** + +Append to `tests/hooks/useSplash.test.ts`: + +```ts +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(); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run tests/hooks/useSplash.test.ts` +Expected: FAIL — the new tests expect non-null error payloads; the hook still returns `null`. + +- [ ] **Step 4: Implement the mapping in `useSplash`** + +Find the line `const { status, loadProgress, famousMetaReady } = input;` and replace with: + +```ts + const { status, loadProgress, famousMetaReady, famousMetaFailed = false } = input; +``` + +Then replace `error: null, // populated in Task 6` with a derived value computed before the return: + +```ts + // ── 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]); +``` + +Add the import at the top of the file: + +```ts +import type { SplashError } from '../@types/splash/SplashError'; +``` + +And update the return value: + +```ts + return { + splashVisible, + blocked, + canContinueAnyway, + error, + dismissExplore, + dismissTour, + reopen, + }; +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/hooks/useSplash.test.ts` +Expected: PASS — all original + new tests green. + +- [ ] **Step 6: Commit** + +```bash +git add src/hooks/useSplash.ts src/@types/splash/UseSplashInput.d.ts tests/hooks/useSplash.test.ts +git commit -m "$(cat <<'EOF' +feat(splash): map engine + famous-meta errors to SplashError in useSplash + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 7: `AboutPill` component + +**Files:** +- Create: `src/components/Splash/AboutPill.tsx` +- Create: `src/components/Splash/AboutPill.module.css` +- Test: `tests/components/Splash/AboutPill.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/components/Splash/AboutPill.test.ts +// @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'); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/components/Splash/AboutPill.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the CSS module** + +```css +/* src/components/Splash/AboutPill.module.css */ +/* + * 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; + } +} +``` + +- [ ] **Step 4: Write the component** + +```tsx +// src/components/Splash/AboutPill.tsx +/** + * 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); +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/components/Splash/AboutPill.test.ts` +Expected: PASS — all five assertions green. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/Splash/AboutPill.tsx src/components/Splash/AboutPill.module.css tests/components/Splash/AboutPill.test.ts +git commit -m "$(cat <<'EOF' +feat(splash): add AboutPill top-bar reopener component + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 8: `Splash` component — markup, copy, and a11y + +**Files:** +- Create: `src/components/Splash/Splash.tsx` +- Create: `src/components/Splash/Splash.module.css` +- Test: `tests/components/Splash/Splash.test.ts` + +**Scope of this task:** the dialog markup, the CSS, the copy, the role/aria attributes, click handlers wired through props, the disabled-CTA states from `blocked`, the "Continue anyway" link, and the per-error rendering. Focus trap + initial focus + Esc handling come in Task 9 to keep this task focused on markup/CSS. + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/components/Splash/Splash.test.ts +// @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(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/components/Splash/Splash.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the CSS module** + +```css +/* 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; } +} +``` + +- [ ] **Step 4: Write the component** + +```tsx +// 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 +

+

+ 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 + +

+
+
+ ); +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/components/Splash/Splash.test.ts` +Expected: PASS — all twelve assertions green. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/Splash/Splash.tsx src/components/Splash/Splash.module.css tests/components/Splash/Splash.test.ts +git commit -m "$(cat <<'EOF' +feat(splash): add Splash dialog component (markup, copy, error states) + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 9: Splash focus trap + Esc-dismiss + +**Files:** +- Modify: `src/components/Splash/Splash.tsx` +- Modify: `tests/components/Splash/Splash.test.ts` + +- [ ] **Step 1: Add failing tests** + +Append to `tests/components/Splash/Splash.test.ts`: + +```ts +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); + }); +}); +``` + +- [ ] **Step 2: Run test to verify the new ones fail** + +Run: `npx vitest run tests/components/Splash/Splash.test.ts` +Expected: the three new tests FAIL — Esc doesn't fire onExplore; autoFocus alone may not consistently focus Explore in jsdom; Tab cycles past the dialog. + +- [ ] **Step 3: Add the focus trap + Esc handler** + +Replace the body of `Splash` (the section returned from the component) by wrapping it with focus-management logic. Update the imports and add a ref + useEffect: + +```tsx +// Top of file — add to existing imports +import { useEffect, useRef, type ReactNode } from 'react'; +``` + +Inside the component body, before the `return`: + +```tsx + // ── 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]); +``` + +And attach the ref to the backdrop: + +```tsx +
+EOF +)" +``` + +--- + +## Task 10: Integrate Splash + AboutPill into App.tsx + +**Files:** +- Modify: `src/components/App/App.tsx` + +**Scope:** wire `useSplash`, render `` conditionally, render `` in `.topBar`, force `uiHidden` while splash is visible, pass deep-link signal in. + +- [ ] **Step 1: Read and re-confirm the App.tsx imports + topBar block before editing** + +Run: `grep -n "topBar\|uiStack\|useSplash\|AboutPill\|useFamousMeta" /Users/rulkens/Development/js/skymap/src/components/App/App.tsx` +Expected output to confirm: existing topBar div, useFamousMeta import, uiStack className usage. + +- [ ] **Step 2: Add the new imports** + +Insert near the existing hook imports: + +```tsx +import { useSplash } from '../../hooks/useSplash'; +import { Splash } from '../Splash/Splash'; +import AboutPill from '../Splash/AboutPill'; +``` + +- [ ] **Step 3: Destructure `ready` from useFamousMeta** + +Locate `const { famousMeta, famousXrefs } = useFamousMeta();` and replace with: + +```tsx +const { famousMeta, famousXrefs, ready: famousMetaReady } = useFamousMeta(); +``` + +- [ ] **Step 4: Wire `useSplash` after the engine hook block** + +Add after the `useEngine({ ... })` call (and after the `_onVolumeFieldsChangedTarget.current = ...` assignment): + +```tsx + // ── 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. + 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`. + }); +``` + +- [ ] **Step 5: Force `uiHidden` while splash is visible** + +Find the existing `
` line and change the condition: + +```tsx +
+``` + +(This keeps the user's manual Tab toggle working as before AND adds splash-visible as a forced-hide trigger. When the splash dismisses, `splash.splashVisible` goes false and the existing fade-in transition plays.) + +- [ ] **Step 6: Render AboutPill inside the topBar div** + +Locate the `
` block. Replace it with: + +```tsx +
+
+``` + +(All three pills participate in the splash UI-hidden fade — they're inside `.uiStack` so the wrapper class already fades them, but explicitly setting `hidden` on each keeps the per-pill scale/opacity transition consistent and means a future split of the wrapper doesn't silently regress.) + +- [ ] **Step 7: Render the Splash dialog** + +Add immediately after the closing `
` of the `uiStack` wrapper, but before the closing `` of the fragment: + +```tsx + {splash.splashVisible && ( + window.location.reload()} + /> + )} +``` + +- [ ] **Step 8: Mark the canvas aria-hidden while splash is up** + +Locate `` and replace with: + +```tsx + +``` + +- [ ] **Step 9: Typecheck + run full test suite** + +Run: `npm run typecheck && npm test` +Expected: PASS — no type errors, no regressions in any existing test. + +- [ ] **Step 10: Commit** + +```bash +git add src/components/App/App.tsx +git commit -m "$(cat <<'EOF' +feat(splash): wire Splash + AboutPill + useSplash into App.tsx + +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 +EOF +)" +``` + +--- + +## Task 11: Final smoke + plan handoff + +**Files:** none modified; this is a verification + handoff task. + +- [ ] **Step 1: Run the full check suite** + +Run: `npm run typecheck && npm test && npm run build` +Expected: all green. Build produces a `dist/` without warnings about the new modules. + +- [ ] **Step 2: Manual smoke checklist (ask the user to verify in the dev server)** + +Confirm with the user that: +1. First visit (localStorage cleared): splash appears, Explore + Tour disabled initially, enable when load completes. +2. Clicking Explore dismisses; refreshing the tab does NOT show splash again. +3. Visiting `/#focus=ngc224` directly does NOT show splash. +4. The About pill (now in the top-bar row) reopens splash; clicking Explore again dismisses. +5. The canvas behind is visible through the blur on desktop. +6. Mobile viewport (< 480 px) stacks CTAs vertically. +7. Pressing Esc dismisses the splash. +8. Tab cycles through links → Explore → Tour → loops back. + +- [ ] **Step 3: Note the deferred items** + +Per the 2026-05-20 grill's out-of-scope section, do NOT implement here: +- Real tour engine (plan 2 handles the stub). +- Post-dismiss controls tooltip. +- Tour-replay UI affordance. + +- [ ] **Step 4: Commit any final tweaks discovered during smoke** + +```bash +# If smoke testing surfaces a small CSS or copy nit, commit it as: +git add +git commit -m "$(cat <<'EOF' +fix(splash): + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Self-review notes + +- **Spec coverage check:** Q1 (loading curtain + intro hybrid) → Splash component renders both. Q2 (gated first-visit + auto-skip on deep-link + About reopener) → useSplash. Q3 (Tour as stub) → covered in plan 2; this plan ships Tour wired to dismiss only. Q4 (medium gating + 8 s escape) → useSplash readiness signal + CONTINUE_ANYWAY_DELAY_MS. Q5 (translucent over live canvas + mobile fallback) → Splash.module.css. Q6 (localStorage seenVersion + version-bust) → useSplash. Q7 (B-lite failure split: WebGPU pre-React, others in splash) → main.tsx + SplashError. Q8 (tour itinerary) → plan 2. Q9 (UI hide + cancel-on-input + end state) → plan 2. Q10 (About pill in top-bar cluster) → AboutPill in Task 7 + 10. Q11 (copy) → Splash component body. Q12 (single responsive layout + mobile blur fallback) → CSS. Q13a (Explore = just dismiss) → useSplash.dismissExplore. Q13b (a11y) → Tasks 8 + 9. Q13c (visual styling defaults) → CSS. Q13d (architectural placement) → file layout matches the grill spec. +- **Type consistency:** `UseSplashInput.famousMetaFailed` is optional with a default of false in the hook (Task 6). `SplashError` discriminated union used identically in hook (Task 6) and Splash component (Task 8). `LoadProgressState` is imported from the existing types — no duplication. +- **Placeholder scan:** No "TBD" / "implement appropriately" / "fill in details" steps. All code blocks are concrete. diff --git a/docs/superpowers/plans/2026-05-20-splash-screen-02-stub-tour.md b/docs/superpowers/plans/2026-05-20-splash-screen-02-stub-tour.md new file mode 100644 index 00000000..4057158b --- /dev/null +++ b/docs/superpowers/plans/2026-05-20-splash-screen-02-stub-tour.md @@ -0,0 +1,758 @@ +# Splash Screen — Stub Tour Implementation Plan (Part 2 of 2) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +> **Companion plan:** `2026-05-20-splash-screen-01-core.md` — the splash dialog + AboutPill + useSplash hook + WebGPU gate. **This plan depends on Plan 1 landing first.** While Plan 1 is live the Tour button just dismisses; this plan replaces that no-op with a real stub camera tour. + +**Goal:** Wire a short scripted camera tour (Powers-of-Ten-style: Milky Way → Local Group → Virgo Cluster → Boötes Void → Coma Cluster → wide view) to the Tour button. The tour is a chained sequence of `camera.focusOn(...)` calls with cancel-on-input, UI-hidden coordination, and end-state restoration. + +**Architecture:** A pure async function `runSplashStubTour(deps)` takes the engine handle and a cancellation token, awaits a fixed dwell after each `focusOn` (matching `FOCUS_TWEEN_MS` plus a beat), and bails between beats if cancellation is requested. App.tsx owns a `tourActive` state that (1) replaces the previous no-op `onTour` wiring with a real invocation, (2) forces `uiHidden` while the tour runs, and (3) cancels on any pointer / key event captured at the window level. + +**Tech Stack:** Existing engine handle (`camera.focusOn`, `filaments.setEnabled`), `services/camera/cameraTween.ts`'s `FOCUS_TWEEN_MS` constant, `buildStaticAnchorPois` for POI lookups, React useEffect for the input-cancel listener. + +--- + +## Skymap conventions reminder (applies to every task below) + +- `type` aliases only, never `interface`. +- Multi-paragraph didactic comments at module headers. +- No barrel exports; deep imports. +- Tests under `tests/` mirroring `src/`. +- Dev server is left running. + +## Mitigation decision (Q8 follow-up) + +The grill flagged the **Boötes Void** beat as risky in a snap-cut stub: a camera arriving at a location with no galaxies could read as "the app broke". Two mitigations were considered: + +- **(a) reorder** — make wide view the climax; put the void mid-sequence. +- **(b) MSDF per-beat captions** — requires building new MSDF caption infra in the renderer (the existing labels system is per-galaxy / per-POI, not per-tour-beat). + +**This plan picks (a).** Rationale: the existing renderer has no "ephemeral tour caption" API, and building one is a meaningful sub-project (atlas slot allocation, lifetime management, fade-in/fade-out coupled to tween completion). Caption infra is a natural piece of the real-tour plan; bundling it here would inflate scope past the "stub" framing. Reordering is free and addresses the ambiguity through positioning alone: the void is one beat in a longer sequence with a strong climax, so the user reads it as part of a tour through different scales, not as a broken final state. + +The chosen itinerary (six beats, ~50 s wall time): + +1. **Milky Way** (home view, pulled out slightly) — anchor; "you are here". +2. **Local Group** (M31 / Andromeda area via the famous-meta atlas) — "our neighbourhood". +3. **Virgo Cluster** (POI: `cluster-virgo-m87`) — "the nearest big cluster". +4. **Boötes Void** (POI: `void-bootes-void`) — "the strangest absence we know about". Mid-sequence; the next beat re-populates the frame. +5. **Coma Supercluster** with **filaments on** (POI: `supercluster-coma-sc`) — "the cosmic web in action". +6. **Wide view** (camera home) — climax; "this is the whole map". + +Filaments are toggled on for beat 5 and restored to the user's pre-tour setting at end-of-tour. + +--- + +## Task 1: `TourCancelToken` type + dependency types + +**Files:** +- Create: `src/@types/splash/TourCancelToken.d.ts` +- Create: `src/@types/splash/SplashStubTourDeps.d.ts` + +- [ ] **Step 1: Write the type files** + +```ts +// src/@types/splash/TourCancelToken.d.ts +/** + * TourCancelToken — a thin cooperative cancellation primitive used by the + * splash stub tour. We don't use AbortController because the tour does + * not perform any fetch — the only thing to cancel is "skip the rest of + * the beats and bail cleanly". An AbortController would work but pulls + * in DOM-typed surface and a heavier API than we need. + * + * The token is a plain mutable object with a single boolean. The tour + * runner reads `cancelled` between beats and short-circuits the await + * chain if set. Callers flip it by calling `cancel()`. + * + * Idempotent: calling `cancel()` multiple times is fine; the token is + * single-shot from the runner's perspective. + */ +export type TourCancelToken = { + readonly cancelled: boolean; + cancel(): void; +}; +``` + +```ts +// src/@types/splash/SplashStubTourDeps.d.ts +import type { EngineHandle } from '../engine/EngineHandle'; +import type { TourCancelToken } from './TourCancelToken'; + +/** + * SplashStubTourDeps — everything the tour runner needs from the outside. + * + * `handle` is the engine handle (we use `camera.focusOn`, `camera.focusOnHome`, + * and `filaments.setEnabled`). `token` is the cancellation token; the runner + * checks it between beats. `filamentsEnabledBefore` carries the user's + * pre-tour filaments setting so the runner can restore it on the way out. + * + * `sleep` is injected to make the runner deterministically testable — tests + * pass a synchronous fake that records the requested delays; production + * passes a real `(ms) => new Promise(resolve => setTimeout(resolve, ms))`. + */ +export type SplashStubTourDeps = { + handle: EngineHandle; + token: TourCancelToken; + filamentsEnabledBefore: boolean; + sleep: (ms: number) => Promise; +}; +``` + +- [ ] **Step 2: Typecheck** + +Run: `npm run typecheck` +Expected: PASS — types compile. + +- [ ] **Step 3: Commit** + +```bash +git add src/@types/splash/TourCancelToken.d.ts src/@types/splash/SplashStubTourDeps.d.ts +git commit -m "$(cat <<'EOF' +feat(splash-tour): add TourCancelToken + SplashStubTourDeps types + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 2: `createTourCancelToken` factory + +**Files:** +- Create: `src/components/Splash/createTourCancelToken.ts` +- Test: `tests/components/Splash/createTourCancelToken.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// tests/components/Splash/createTourCancelToken.test.ts +import { describe, it, expect } from 'vitest'; +import { createTourCancelToken } from '../../../src/components/Splash/createTourCancelToken'; + +describe('createTourCancelToken', () => { + it('starts uncancelled', () => { + const t = createTourCancelToken(); + expect(t.cancelled).toBe(false); + }); + + it('flips to cancelled after cancel()', () => { + const t = createTourCancelToken(); + t.cancel(); + expect(t.cancelled).toBe(true); + }); + + it('is idempotent — calling cancel() twice is safe', () => { + const t = createTourCancelToken(); + t.cancel(); + t.cancel(); + expect(t.cancelled).toBe(true); + }); + + it('returns a fresh token per call (independent state)', () => { + const a = createTourCancelToken(); + const b = createTourCancelToken(); + a.cancel(); + expect(b.cancelled).toBe(false); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run tests/components/Splash/createTourCancelToken.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write the factory** + +```ts +// src/components/Splash/createTourCancelToken.ts +/** + * createTourCancelToken — produce a fresh cooperative-cancellation token + * for one tour run. + * + * ### Why a function rather than a class + * + * A class would work, but the token has only two fields (a flag + a + * mutator) and no inheritance or polymorphism. A factory returning an + * object literal reads more honestly: the consumer reads `t.cancelled` + * and calls `t.cancel()` — no `new`, no `this` capture pitfalls. + * + * The `cancelled` field is declared as a getter so external readers + * can't mutate it directly (only `cancel()` can). Keeps the contract + * one-directional. + */ + +import type { TourCancelToken } from '../../@types/splash/TourCancelToken'; + +export function createTourCancelToken(): TourCancelToken { + let flag = false; + return { + get cancelled(): boolean { + return flag; + }, + cancel(): void { + flag = true; + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `npx vitest run tests/components/Splash/createTourCancelToken.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/components/Splash/createTourCancelToken.ts tests/components/Splash/createTourCancelToken.test.ts +git commit -m "$(cat <<'EOF' +feat(splash-tour): add createTourCancelToken factory + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 3: `runSplashStubTour` — the runner + +**Files:** +- Create: `src/components/Splash/splashStubTour.ts` +- Test: `tests/components/Splash/splashStubTour.test.ts` + +**Why one big task:** the runner is ~80 lines, all of which is tested in one spec file. Splitting beats into separate tasks would create six near-identical copy-paste tasks with no payoff. + +- [ ] **Step 1: Confirm the FOCUS_TWEEN_MS constant exists for reference** + +Run: `grep -n "export const FOCUS_TWEEN_MS" /Users/rulkens/Development/js/skymap/src/services/engine/camera/focusTween.ts` +Expected output: `24:export const FOCUS_TWEEN_MS = 600;` + +This is the duration of a single `focusOn` tween — the runner dwells at each beat for `FOCUS_TWEEN_MS + STUB_TOUR_DWELL_MS`. + +- [ ] **Step 2: Write the failing test** + +```ts +// tests/components/Splash/splashStubTour.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { + runSplashStubTour, + STUB_TOUR_BEATS, + STUB_TOUR_DWELL_MS, +} from '../../../src/components/Splash/splashStubTour'; +import { createTourCancelToken } from '../../../src/components/Splash/createTourCancelToken'; +import type { EngineHandle } from '../../../src/@types/engine/EngineHandle'; + +function makeFakeHandle() { + const calls: { method: string; arg?: unknown }[] = []; + const handle = { + camera: { + focusOn: (target: unknown) => calls.push({ method: 'focusOn', arg: target }), + focusOnHome: () => calls.push({ method: 'focusOnHome' }), + focusOnMilkyWay: () => calls.push({ method: 'focusOnMilkyWay' }), + }, + filaments: { + setEnabled: (enabled: boolean) => calls.push({ method: 'setFilamentsEnabled', arg: enabled }), + }, + selection: { + selectFamous: (id: string) => calls.push({ method: 'selectFamous', arg: id }), + }, + } as unknown as EngineHandle; + return { handle, calls }; +} + +describe('runSplashStubTour', () => { + it('plays all STUB_TOUR_BEATS in order when not cancelled', async () => { + const { handle, calls } = makeFakeHandle(); + const sleep = vi.fn().mockResolvedValue(undefined); + await runSplashStubTour({ + handle, + token: createTourCancelToken(), + filamentsEnabledBefore: false, + sleep, + }); + // One camera-effect call per beat (focusOnMilkyWay, selectFamous, focusOn x N, focusOnHome). + const beatActions = calls.filter((c) => + ['focusOnMilkyWay', 'focusOn', 'selectFamous', 'focusOnHome'].includes(c.method), + ); + expect(beatActions.length).toBe(STUB_TOUR_BEATS.length); + }); + + it('sleeps FOCUS_TWEEN_MS + STUB_TOUR_DWELL_MS between beats', async () => { + const { handle } = makeFakeHandle(); + const sleep = vi.fn().mockResolvedValue(undefined); + await runSplashStubTour({ + handle, + token: createTourCancelToken(), + filamentsEnabledBefore: false, + sleep, + }); + // FOCUS_TWEEN_MS = 600 (verified separately). + const expected = 600 + STUB_TOUR_DWELL_MS; + expect(sleep).toHaveBeenCalledWith(expected); + }); + + it('cancels cleanly between beats when token.cancel() is called', async () => { + const { handle, calls } = makeFakeHandle(); + const token = createTourCancelToken(); + // Sleep cancels after the second beat. + let beatIndex = 0; + const sleep = vi.fn().mockImplementation(async () => { + beatIndex += 1; + if (beatIndex === 2) token.cancel(); + }); + await runSplashStubTour({ + handle, + token, + filamentsEnabledBefore: false, + sleep, + }); + const beatActions = calls.filter((c) => + ['focusOnMilkyWay', 'focusOn', 'selectFamous', 'focusOnHome'].includes(c.method), + ); + // We ran two full beats, then cancelled before the third — so we + // expect ≤ 2 beat actions to have fired. (Restoration calls below + // don't count as beat actions.) + expect(beatActions.length).toBeLessThanOrEqual(2); + }); + + it('restores filaments to filamentsEnabledBefore=false on completion', async () => { + const { handle, calls } = makeFakeHandle(); + await runSplashStubTour({ + handle, + token: createTourCancelToken(), + filamentsEnabledBefore: false, + sleep: vi.fn().mockResolvedValue(undefined), + }); + // The final filaments call must restore to false. + const filamentCalls = calls.filter((c) => c.method === 'setFilamentsEnabled'); + expect(filamentCalls[filamentCalls.length - 1]?.arg).toBe(false); + }); + + it('leaves filaments enabled on completion when filamentsEnabledBefore=true', async () => { + const { handle, calls } = makeFakeHandle(); + await runSplashStubTour({ + handle, + token: createTourCancelToken(), + filamentsEnabledBefore: true, + sleep: vi.fn().mockResolvedValue(undefined), + }); + const filamentCalls = calls.filter((c) => c.method === 'setFilamentsEnabled'); + expect(filamentCalls[filamentCalls.length - 1]?.arg).toBe(true); + }); + + it('restores filaments even when cancelled mid-tour', async () => { + const { handle, calls } = makeFakeHandle(); + const token = createTourCancelToken(); + let beatIndex = 0; + const sleep = vi.fn().mockImplementation(async () => { + beatIndex += 1; + if (beatIndex === 1) token.cancel(); + }); + await runSplashStubTour({ + handle, + token, + filamentsEnabledBefore: false, + sleep, + }); + const filamentCalls = calls.filter((c) => c.method === 'setFilamentsEnabled'); + // At minimum, the restore call to false must be present at the end. + expect(filamentCalls[filamentCalls.length - 1]?.arg).toBe(false); + }); +}); +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `npx vitest run tests/components/Splash/splashStubTour.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 4: Write the runner** + +```ts +// src/components/Splash/splashStubTour.ts +/** + * splashStubTour — the rough-cut camera tour the splash launches from + * its Tour CTA. + * + * ### What this is (and isn't) + * + * This is a STUB. It's a chained sequence of `camera.focusOn(...)` calls + * separated by fixed dwells — no rotation slerp, no narration text, no + * easing polish. The polished cinematic tour is a separate future plan + * (see `docs/superpowers/specs/2026-05-07-tour-animation-design.md`, which + * will be retired or rewritten as that plan's spec doc). This file ships + * now so the Tour button on the splash isn't a dead-end during the gap + * between splash GA and the real tour. + * + * ### Why chained `focusOn` + `sleep` (and not a real tour state machine) + * + * The existing `cameraTween` machinery already does the heavy lifting: + * each `focusOn` call snapshots the current camera and tweens to the new + * target over `FOCUS_TWEEN_MS = 600` ms with an ease-out cubic. Starting + * a new tween while the previous one is in flight cancels the old one and + * snapshots fresh, so a sleep-based driver is enough: wait + * FOCUS_TWEEN_MS + dwell, fire the next tween, repeat. This deliberately + * accepts the "snap-rotate-then-dolly" artifact (decision 1 of the + * original tour brainstorm) because the polished tour is the right place + * to fix that, not the stub. + * + * ### Cancellation contract + * + * The runner checks `token.cancelled` between beats and short-circuits + * the await chain if set. Cancellation does NOT interrupt an in-flight + * tween — that's intentional, because cancelling mid-tween would leave + * the camera at a half-baked intermediate state. Cancelling at a beat + * boundary lets the camera settle at the last completed target, which + * is a much cleaner end state for "any input cancels". The try/finally + * ensures filaments are restored to their pre-tour setting regardless + * of whether the runner completes normally or bails on cancellation. + * + * ### Why `sleep` is injected + * + * Tests can pass a synchronous fake (`vi.fn().mockResolvedValue(...)`) + * to avoid burning real wall-clock time and to control the order of + * cancellation events relative to beat boundaries. Production passes + * a real `setTimeout`-based sleep from App.tsx. + * + * ### Why we reorder Boötes Void mid-sequence + * + * Per the 2026-05-20 grill (Q8 risk-mitigation pick (a)): the void as + * the final beat reads ambiguously ("camera arrived somewhere with no + * galaxies — is the app broken?"). Moving it mid-sequence and putting + * the cosmic-web + wide-view climax after re-populates the frame + * naturally, removing the ambiguity without needing per-beat caption + * infrastructure (which is real-tour-plan territory). + * + * ### Why beats are an exported constant + * + * The beat list is the load-bearing piece of the tour's narrative. + * Exposing it as `STUB_TOUR_BEATS` lets tests assert on the sequence + * shape without re-stating it, and lets a future caller (e.g. a debug + * panel that wants to jump to beat N for tuning) reuse the table. + */ + +import type { EngineHandle } from '../../@types/engine/EngineHandle'; +import type { SplashStubTourDeps } from '../../@types/splash/SplashStubTourDeps'; +import { FOCUS_TWEEN_MS } from '../../services/engine/camera/focusTween'; +import { buildStaticAnchorPois } from '../../data/buildStaticAnchorPois'; + +/** + * Dwell after each tween completes. Long enough to register the beat as + * a deliberate pause; short enough that the six-beat tour finishes in + * ~50 s wall time (~8 s per beat = 600 ms tween + 7400 ms dwell). + */ +export const STUB_TOUR_DWELL_MS = 2_500; + +/** + * Discriminated union of the action a beat performs. Three shapes: + * - 'milky-way' → call camera.focusOnMilkyWay() + * - 'famous' → call selection.selectFamous(id) (pins + focuses) + * - 'poi' → look up the static POI by id, call camera.focusOn(poi) + * - 'home' → call camera.focusOnHome() + * + * 'famous' uses selectFamous (which auto-focuses) because famous galaxies + * have no static-POI counterpart — selectFamous is the canonical way to + * land a focus on a famous-atlas galaxy from outside. + */ +export type StubTourBeat = + | { kind: 'milky-way'; name: string } + | { kind: 'famous'; name: string; id: string } + | { kind: 'poi'; name: string; poiId: string; filamentsOn?: boolean } + | { kind: 'home'; name: string }; + +/** + * The exported beat sequence. See module header for the "powers of ten" + * reordering rationale (void mid-sequence, wide view as climax). + */ +export const STUB_TOUR_BEATS: readonly StubTourBeat[] = [ + { kind: 'milky-way', name: 'Milky Way' }, + // The Local Group is anchored on the M31 (Andromeda) famous-atlas + // entry. selectFamous handles the focus-tween + pin in one call. + { kind: 'famous', name: 'Local Group (M31)', id: 'm31' }, + { kind: 'poi', name: 'Virgo Cluster', poiId: 'cluster-virgo-m87' }, + { kind: 'poi', name: 'Boötes Void', poiId: 'void-bootes-void' }, + { + kind: 'poi', + name: 'Coma Supercluster', + poiId: 'supercluster-coma-sc', + filamentsOn: true, + }, + { kind: 'home', name: 'Wide View' }, +]; + +/** + * Run the stub tour to completion (or cancellation). Returns a Promise + * that resolves when the tour is done — either ran all beats, or bailed + * on cancellation. The try/finally restores filaments unconditionally. + */ +export async function runSplashStubTour(deps: SplashStubTourDeps): Promise { + const { handle, token, filamentsEnabledBefore, sleep } = deps; + + // Pre-build the POI lookup map once. buildStaticAnchorPois returns a + // fresh array per call, so we memoize at the runner's entry rather than + // inside the per-beat dispatch (small win, but cleaner). + const pois = buildStaticAnchorPois(); + const poiById = new Map(pois.map((p) => [p.id, p])); + + try { + for (const beat of STUB_TOUR_BEATS) { + if (token.cancelled) return; + + // Filaments toggle — only specific beats want it on; others leave + // the current setting alone. The end-of-tour restore (in the + // finally block) returns to the user's pre-tour preference. + if (beat.kind === 'poi' && beat.filamentsOn) { + handle.filaments.setEnabled(true); + } + + // Dispatch the beat's camera action. Each call kicks off a + // FOCUS_TWEEN_MS tween that the engine drives per-frame; we just + // sleep through it before starting the next beat. + switch (beat.kind) { + case 'milky-way': + handle.camera.focusOnMilkyWay(); + break; + case 'famous': + handle.selection.selectFamous(beat.id); + break; + case 'poi': { + const poi = poiById.get(beat.poiId); + if (poi) { + handle.camera.focusOn(poi); + } + // If the POI is missing (renamed slug?), skip silently — the + // tour proceeds to the next beat. A misordered slug is a + // catalog bug, not a tour bug; we don't want one beat to + // crash the rest of the sequence. + break; + } + case 'home': + handle.camera.focusOnHome(); + break; + } + + // Dwell for the tween + the pause. Cancellation is re-checked + // at the top of the next iteration; we don't poll inside the + // sleep because the sleep is a single Promise and splitting it + // would add complexity for negligible UX improvement. + await sleep(FOCUS_TWEEN_MS + STUB_TOUR_DWELL_MS); + } + } finally { + // Always restore filaments to the user's pre-tour setting. Runs on + // happy-path completion, cancellation, AND on any thrown error from + // the engine handle. Safe even if filaments were never toggled on + // (setEnabled(false) when already false is a no-op). + handle.filaments.setEnabled(filamentsEnabledBefore); + } +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `npx vitest run tests/components/Splash/splashStubTour.test.ts` +Expected: PASS — all six assertions green. + +- [ ] **Step 6: Commit** + +```bash +git add src/components/Splash/splashStubTour.ts tests/components/Splash/splashStubTour.test.ts +git commit -m "$(cat <<'EOF' +feat(splash-tour): add runSplashStubTour runner with cancel + filament restore + +Six-beat Powers-of-Ten-style camera tour: Milky Way → Local Group → Virgo +Cluster → Boötes Void → Coma Supercluster (with filaments) → wide view. +Cancellation is cooperative at beat boundaries; filaments are restored +via try/finally regardless of completion path. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 4: Wire the tour into App.tsx (start / cancel / UI hide) + +**Files:** +- Modify: `src/components/App/App.tsx` + +- [ ] **Step 1: Read the current Splash wiring** + +Run: `grep -n "splash\|onTour\|Splash" /Users/rulkens/Development/js/skymap/src/components/App/App.tsx` +Expected to see: `useSplash` hook call, the `` JSX block from Plan 1's Task 10, currently with `onTour={splash.dismissTour}`. + +- [ ] **Step 2: Add the new imports near the top of App.tsx** + +```tsx +import { runSplashStubTour } from '../Splash/splashStubTour'; +import { createTourCancelToken } from '../Splash/createTourCancelToken'; +import type { TourCancelToken } from '../../@types/splash/TourCancelToken'; +``` + +- [ ] **Step 3: Add tour-active state + the start handler** + +Add immediately after the `const splash = useSplash({ ... });` block: + +```tsx + // ── Tour state ───────────────────────────────────────────────────────── + // + // `tourActive` drives two effects: (1) it forces `uiHidden` while the + // tour runs, and (2) it arms the window-level input listener that + // cancels the tour on any pointer / key event. `tourTokenRef` holds + // the active cancellation token so the input listener (which is + // mounted via a useEffect that can't directly close over `tourActive`'s + // setter chain cleanly) can flip the token without state coupling. + const [tourActive, setTourActive] = useState(false); + const tourTokenRef = useRef(null); + + const startStubTour = useCallback(() => { + // Idempotent: if a tour is already running, do nothing (the splash + // shouldn't be able to re-trigger but defending the entry point is + // cheap). + if (tourActive) return; + const handle = handleRef.current; + if (!handle) return; + + // Dismiss the splash first. dismissTour bumps localStorage's + // seenVersion — same effect as if the user had clicked Explore. + splash.dismissTour(); + + const token = createTourCancelToken(); + tourTokenRef.current = token; + setTourActive(true); + + void runSplashStubTour({ + handle, + token, + filamentsEnabledBefore: filamentsEnabled, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + }).finally(() => { + // Whether the tour completed normally or was cancelled, clear the + // active flag and the token so the UI restores and a future tour + // run starts from a clean slate. + setTourActive(false); + tourTokenRef.current = null; + }); + }, [tourActive, splash, filamentsEnabled]); +``` + +- [ ] **Step 4: Replace the existing `onTour` prop on `` to call `startStubTour`** + +Locate the `` block. Change `onTour={splash.dismissTour}` to: + +```tsx + onTour={startStubTour} +``` + +- [ ] **Step 5: Add the cancel-on-input listener** + +Add a useEffect block alongside the existing input-handling hooks: + +```tsx + // ── Tour cancel-on-input ──────────────────────────────────────────────── + // + // Any user input cancels the tour. Per the 2026-05-20 grill (Q9 — 2A), + // the stub uses the simplest cancel pattern: pointerdown / keydown / + // wheel / touchstart all flip the cancellation token. The tour + // runner sees the flip at the next beat boundary and bails. + // + // The listener is armed only while `tourActive` is true so a normal + // session doesn't pay for the listeners. Capture-phase listeners on + // window ensure we see the events before the canvas / palette absorb + // them; passive: true on touch / wheel keeps default behaviours + // (page scroll, etc.) intact. + useEffect(() => { + if (!tourActive) return; + const cancel = () => tourTokenRef.current?.cancel(); + window.addEventListener('pointerdown', cancel, { capture: true }); + window.addEventListener('keydown', cancel, { capture: true }); + window.addEventListener('wheel', cancel, { capture: true, passive: true }); + window.addEventListener('touchstart', cancel, { capture: true, passive: true }); + return () => { + window.removeEventListener('pointerdown', cancel, { capture: true } as EventListenerOptions); + window.removeEventListener('keydown', cancel, { capture: true } as EventListenerOptions); + window.removeEventListener('wheel', cancel, { capture: true } as EventListenerOptions); + window.removeEventListener('touchstart', cancel, { capture: true } as EventListenerOptions); + }; + }, [tourActive]); +``` + +- [ ] **Step 6: Force `uiHidden` while tour is active** + +Locate the current `uiStack` wrapper line (which Plan 1's Task 10 left as `(uiHidden || splash.splashVisible)`): + +```tsx +
+``` + +Update the condition to include `tourActive`: + +```tsx +
+``` + +- [ ] **Step 7: Typecheck + run full test suite** + +Run: `npm run typecheck && npm test` +Expected: PASS — no type errors; all existing tests still green. + +- [ ] **Step 8: Commit** + +```bash +git add src/components/App/App.tsx +git commit -m "$(cat <<'EOF' +feat(splash-tour): wire stub tour to Splash Tour button + cancel-on-input + +Clicking Tour dismisses splash, starts a six-beat camera tour, and arms +window-level pointer/key listeners that cancel the tour cooperatively +at the next beat boundary. UI chrome is auto-hidden while the tour +runs; the existing Tab toggle still works as an override. + +Co-Authored-By: Claude Opus 4.7 +EOF +)" +``` + +--- + +## Task 5: Final smoke + integration check + +**Files:** none modified; verification task. + +- [ ] **Step 1: Run the full check suite** + +Run: `npm run typecheck && npm test && npm run build` +Expected: all green. + +- [ ] **Step 2: Manual smoke (ask the user)** + +Confirm in the live dev server: +1. Tour button on the splash now triggers a six-beat camera tour, not just a dismiss. +2. UI chrome (left stack, top bar, status bar) is hidden while the tour plays. +3. Clicking, scrolling, or pressing any key during the tour stops the tour cleanly at the current beat. +4. Filaments are on for the Coma beat and turn off again at end-of-tour (assuming they were off before). +5. The void beat is mid-sequence; the Coma + wide-view beats follow as climax. +6. After the tour ends, normal UI returns and the user has free orbit control. +7. Clicking About on the top bar mid-tour: the splash reopens AND the tour cancels (the splash reopen click event triggers the cancel listener). + +- [ ] **Step 3: Verify the deprecated tour spec is still in place** + +The old `docs/superpowers/specs/2026-05-07-tour-animation-design.md` should still exist untouched. The grill explicitly deferred its retirement/rewrite to the real-tour plan — not this plan, not Plan 1. + +Run: `ls /Users/rulkens/Development/js/skymap/docs/superpowers/specs/ | grep tour` +Expected output: `2026-05-07-tour-animation-design.md` present. + +- [ ] **Step 4: Update plan cross-references if anything drifted** + +If task numbering, file names, or behaviour drifted from Plan 1's assumptions during execution, edit Plan 1's Task 11 "deferred items" note to reflect reality before considering this plan complete. + +--- + +## Self-review notes + +- **Spec coverage check:** Q3 (stub tour shipped with splash) → Tasks 1-4. Q8 (six-beat powers-of-ten arc with void mitigation) → STUB_TOUR_BEATS in Task 3. Q9-1A (auto-hide UI on tour start, restore on end) → Task 4 step 6. Q9-2A (any input cancels) → Task 4 step 5. Q9-3A (stop at final position, restore UI) → STUB_TOUR_BEATS final beat is `home`; tourActive cleared in the .finally(). Side-effect (About pill mid-tour reopens splash + cancels tour) → the input listener fires on the About pill click, satisfying both conditions. +- **Type consistency:** `TourCancelToken` defined in Task 1, used identically in Task 2 (factory return type), Task 3 (runner input), Task 4 (App.tsx state). `SplashStubTourDeps` defined once in Task 1 and consumed only in Task 3 — no drift. +- **Placeholder scan:** No "TBD" / "implement appropriately" / "fill in details" entries. The `sleep` injection is a real pattern, not a placeholder. +- **Dependency on Plan 1:** the Splash component, the `useSplash` hook, the `dismissTour` setter, and the `` prop all come from Plan 1's Task 10. This plan assumes that integration is in place; if Plan 1 didn't land, Task 4 step 4 will fail to find `` and the integration step won't apply cleanly.