Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/@types/engine/UseFamousMetaReturn.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,12 @@ import type { FamousXrefMap } from '../loading/FamousXrefMap';
export type UseFamousMetaReturn = {
famousMeta: readonly FamousMetaEntry[];
famousXrefs: FamousXrefMap;
/**
* True once the famous-meta fetch has settled (success OR swallowed
* error). Splash gating reads this to know when the Tour CTA can
* activate. Mirrors the fail-soft UX: a missing famous_meta.json
* still flips `ready` to true (with empty meta arrays) so the splash
* doesn't deadlock on a deployment that hasn't shipped the sidecar.
*/
ready: boolean;
};
22 changes: 22 additions & 0 deletions src/@types/splash/SplashError.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* SplashError — discriminated union of the three runtime failure modes
* the splash can surface. Each kind carries the minimum information
* needed to render a specific recovery affordance.
*
* - `webgpu-init-failed` → requestAdapter() returned null on a browser
* that has `navigator.gpu`. Show error +
* reload button. The synchronous "no
* navigator.gpu at all" case is handled in
* main.tsx before React mounts; it never
* reaches the splash.
* - `catalog-fetch-failed` → an essential galaxy catalog fetch failed.
* Show error + reload button.
* - `famous-meta-failed` → the famous-meta sidecar failed. Splash
* stays usable: Explore live, Tour disabled
* with a tooltip. This kind is informational,
* not blocking.
*/
export type SplashError =
| { kind: 'webgpu-init-failed'; message: string }
| { kind: 'catalog-fetch-failed'; message: string }
| { kind: 'famous-meta-failed' };
19 changes: 19 additions & 0 deletions src/@types/splash/UseSplashInput.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { EngineStatus } from '../engine/EngineStatus';
import type { LoadProgressState } from '../loading/LoadProgressState';

export type UseSplashInput = {
/** Engine status from `useEngine`. */
status: EngineStatus;
/** Aggregated load progress from `useEngine`. `null` when no fetches in flight. */
loadProgress: LoadProgressState | null;
/** Famous-meta `ready` flag from `useFamousMeta`. */
famousMetaReady: boolean;
/**
* Optional flag set by App.tsx when famous-meta is known to have failed
* (not just absent). Drives the splash's `famous-meta-failed` informational
* error — Explore stays live, Tour is disabled with a tooltip. Defaults
* to false; the famousMetaFetcher currently swallows errors silently, so
* App can hook a tighter signal in later without breaking this hook.
*/
famousMetaFailed?: boolean;
};
25 changes: 25 additions & 0 deletions src/@types/splash/UseSplashReturn.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { SplashError } from './SplashError';

/**
* UseSplashReturn — the splash hook's public surface.
*
* `splashVisible` is the render gate App reads. `blocked` reports whether
* CTAs should be disabled (loading not yet ready). `canContinueAnyway`
* exposes the 8 s timer's expiration so the splash can show the escape
* link. `error` is null on the happy path; `famous-meta-failed` leaves
* the splash usable, the other kinds force the error layout.
*
* `dismissExplore` / `dismissTour` bump localStorage's `seenVersion` and
* close the splash. `reopen` (called by the AboutPill) shows the splash
* again but does NOT touch localStorage — reopening is informational, not
* a "first-time" event.
*/
export type UseSplashReturn = {
splashVisible: boolean;
blocked: boolean;
canContinueAnyway: boolean;
error: SplashError | null;
dismissExplore: () => void;
dismissTour: () => void;
reopen: () => void;
};
49 changes: 44 additions & 5 deletions src/components/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import cx from 'classnames';
import { useEngine } from '../../hooks/useEngine';
import { useSplash } from '../../hooks/useSplash';
import { StatusBar } from '../StatusBar/StatusBar';
import { LoadingBar } from '../LoadingBar/LoadingBar';
import { InfoCard } from '../InfoCard/InfoCard';
Expand All @@ -66,6 +67,8 @@ import StatsPanel from '../StatsPanel/StatsPanel';
import { CommandPalette } from '../CommandPalette/CommandPalette';
import SearchTrigger from '../SearchTrigger/SearchTrigger';
import AutoRotateToggle from '../AutoRotateToggle/AutoRotateToggle';
import { Splash } from '../Splash/Splash';
import AboutPill from '../Splash/AboutPill';
import { MILKY_WAY_ENTRY, MILKY_WAY_ID } from '../../data/milkyWayEntry';
import appStyles from './App.module.css';
import { useUrlSync } from '../../hooks/useUrlSync';
Expand Down Expand Up @@ -366,7 +369,27 @@ export function App(): React.ReactElement {
const [loadingDevPanelOpen, setLoadingDevPanelOpen] = useState(false);

// ── Famous-galaxy sidecars (CommandPalette + deep-link drain) ────────────
const { famousMeta, famousXrefs } = useFamousMeta();
const { famousMeta, famousXrefs, ready: famousMetaReady } = useFamousMeta();

// ── Splash dialog state ─────────────────────────────────────────────────
//
// The splash hook gates on engine readiness (status=ready + no fetches
// in flight) + famous-meta loaded. It owns localStorage versioning,
// deep-link bypass, the 8 s Continue-anyway timer, and dismiss/reopen.
// See `useSplash.ts` for the full design rationale.
//
// Placed after `useFamousMeta` so `famousMetaReady` is in scope. React
// hook ordering rules require hooks to run unconditionally — we satisfy
// that; the dependency on `famousMetaReady` is purely a JS scoping
// concern, not a conditional hook.
const splash = useSplash({
status,
loadProgress,
famousMetaReady,
// `famousMetaFailed` is not currently wired — useFamousMeta swallows
// errors silently per the fail-soft contract. A future iteration
// could promote the catch-branch into a flag exposed alongside `ready`.
});

// ── Palette entries — famous catalog + Milky Way pseudo-entry ────────────
//
Expand Down Expand Up @@ -493,7 +516,7 @@ export function App(): React.ReactElement {

`id="c"` matches the CSS rule in index.html: `#c { display: block; ... }`.
*/}
<canvas ref={canvasRef} id="c" />
<canvas ref={canvasRef} id="c" aria-hidden={splash.splashVisible || undefined} />

{/*
UI overlay wrapper. All HUD chrome (loading bar, status,
Expand All @@ -508,7 +531,7 @@ export function App(): React.ReactElement {
class so the fade animates in BOTH directions (opacity 1 → 0
on hide, 0 → 1 on show).
*/}
<div className={cx(appStyles.uiStack, uiHidden && appStyles.uiStackHidden)}>
<div className={cx(appStyles.uiStack, (uiHidden || splash.splashVisible) && appStyles.uiStackHidden)}>
{/*
Loading bar — pinned to top of viewport above every other overlay.
Fades itself out when `loadProgress` becomes null (no fetches in
Expand Down Expand Up @@ -822,12 +845,13 @@ export function App(): React.ReactElement {
source of truth for placement. See `.topBar` in App.module.css.
*/}
<div className={appStyles.topBar}>
<SearchTrigger onClick={openPalette} hidden={paletteOpen} />
<SearchTrigger onClick={openPalette} hidden={paletteOpen || splash.splashVisible} />
<AutoRotateToggle
playing={autoRotate}
onToggle={() => handleRef.current?.camera.setAutoRotate(!autoRotate)}
hidden={paletteOpen}
hidden={paletteOpen || splash.splashVisible}
/>
<AboutPill onClick={splash.reopen} hidden={paletteOpen || splash.splashVisible} />
</div>
<CommandPalette
entries={paletteEntries}
Expand Down Expand Up @@ -890,6 +914,21 @@ export function App(): React.ReactElement {
/>
)}
</div>
{splash.splashVisible && (
<Splash
blocked={splash.blocked}
canContinueAnyway={splash.canContinueAnyway}
loadProgress={loadProgress}
error={splash.error}
onExplore={splash.dismissExplore}
// Plan 2 (stub tour) replaces this with the real tour wiring.
// For now Tour just dismisses like Explore — the splash work
// ships independently of the tour itinerary.
onTour={splash.dismissTour}
onContinueAnyway={splash.dismissExplore}
onReload={() => window.location.reload()}
/>
)}
</>
);
}
61 changes: 61 additions & 0 deletions src/components/Splash/AboutPill.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* AboutPill — 40 × 40 px frosted-glass button matching SearchTrigger
* and AutoRotateToggle so the three pills feel like one cohesive
* top-bar cluster (`.topBar` in App.module.css).
*
* Same surface vocabulary as the siblings: `--surface-card-soft`,
* `--border-card`, `--blur-card`, `--shadow-card`. Hover/focus shift
* to `--surface-card-strong` + `--border-hover`; the icon tints to
* `--color-accent`.
*/

.pill {
background: var(--surface-card-soft);
border: 1px solid var(--border-card);
border-radius: 999px;
backdrop-filter: blur(var(--blur-card));
-webkit-backdrop-filter: blur(var(--blur-card));
box-shadow: var(--shadow-card);
width: 40px;
height: 40px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--color-fg);
transition: background 0.15s ease-out, border-color 0.15s ease-out,
opacity 0.2s ease-out, transform 0.2s ease-out;
}

.pill:hover,
.pill:focus-visible {
background: var(--surface-card-strong);
border-color: var(--border-hover);
color: var(--color-accent);
}

.pill:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}

.icon {
display: block;
}

/* Hidden state — matches the SearchTrigger / AutoRotateToggle pattern.
* Faded + slightly scaled + non-interactive so the splash modal can
* sit cleanly on top during reopens. */
.hidden {
opacity: 0;
transform: scale(0.9);
pointer-events: none;
}

/* Mobile — still 44 × 44 minimum touch target (WCAG 2.5.5). */
@media (max-width: 480px) {
.pill {
width: 44px;
height: 44px;
}
}
78 changes: 78 additions & 0 deletions src/components/Splash/AboutPill.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* AboutPill — 40 × 40 frosted-glass pill that reopens the splash
* dialog. Sits in the top-bar flex row (`.topBar` in App.module.css)
* alongside SearchTrigger and AutoRotateToggle.
*
* ### Why a dedicated pill rather than a SettingsPanel link
*
* Per the 2026-05-20 grill (Q10), the About affordance needs to be
* discoverable to deep-link arrivals who skipped the splash and to
* returning visitors who want to re-read the intro. Burying it in
* the Settings panel (the most-frequently-collapsed surface on
* mobile) defeats both audiences. A top-bar pill is canonical
* "help / about" placement and matches the user's chosen layout
* (Search · AutoRotate · About).
*
* ### Why React.memo
*
* Reads only `onClick`, `hidden` — neither changes per frame. Without
* memo, App's animation re-renders would re-render the inline SVG
* every frame. Same rationale as SearchTrigger / AutoRotateToggle.
*/

import { memo, type ReactNode } from 'react';
import cx from 'classnames';
import styles from './AboutPill.module.css';

export type AboutPillProps = {
/** Called when the user clicks/activates the pill — reopens splash. */
onClick: () => void;
/**
* When true, the pill fades out and stops accepting clicks — matches
* SearchTrigger and AutoRotateToggle's `hidden` semantics so the
* three pills coordinate during palette-open and splash-visible
* transitions.
*/
hidden?: boolean;
};

/** Inline circled-? glyph — nine lines of SVG we own end-to-end. */
function InfoIcon(): ReactNode {
return (
<svg
className={styles.icon}
viewBox="0 0 16 16"
width="14"
height="14"
aria-hidden="true"
focusable="false"
>
<circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path
d="M6.5 6 Q6.5 4.5 8 4.5 Q9.5 4.5 9.5 6 Q9.5 7 8 7.5 L8 9"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="8" cy="11.25" r="0.85" fill="currentColor" />
</svg>
);
}

function AboutPill({ onClick, hidden = false }: AboutPillProps): ReactNode {
return (
<button
type="button"
className={cx(styles.pill, hidden && styles.hidden)}
onClick={onClick}
aria-label="About skymap"
aria-hidden={hidden || undefined}
>
<InfoIcon />
</button>
);
}

export default memo(AboutPill);
Loading
Loading