feat(url): deep-link galaxy focus via #focus= hash#14
Merged
Conversation
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Prevents App's drain effect from re-firing on every parent render while a focus target is pending — would otherwise re-run the resolver's pos@ nearest-neighbour scan across all loaded clouds. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A reload-with-#focus=m31 was racing the famousMeta sidecar load against the first non-Famous cloud landing. GLADE typically lands first; the drain effect would then resolve a famous target against an empty famousMeta, get unknown, clearPending(), and lose the user's deep link moments before the sidecar finished loading. Each FocusTarget kind now gates resolution on the data its resolver branch actually reads, and pgc-without-alias-map waits for the palette to warm rather than collapsing to unknown. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…tion Two coupled fixes for the deep-link drain pipeline: 1) The drain effect treated a transient `unknown` from the resolver as a definitive give-up, calling clearPending() and losing the target the moment the smallest cloud (typically Famous, ~150 points) arrived — well before the actual survey clouds the user's pos@/pgc/sdss target lived in. Resolution during loading is monotonic: more clouds + sidecars can only ever promote `unknown → tier` or `unknown → resolved`, never the other way. So drain now never clears on `unknown`; pending is collapsed by a small "selection supersedes pending" effect that fires once any selection lands (deep-link OR a manual user click). 2) `engine.selectByAlias` was building a correct `PointInfo` from the data-side cloud store, but then dispatching it through `setSelected(globalIdx)` which re-derives the info via `pointInfoFromGlobal`. That re-derivation reads from the renderer-side `loadedSources()`, which lags `state.sources.clouds` by an upload-chain tick. In the deep-link race the renderer doesn't have the source yet, the offset is 0, and the round-trip resolves to the wrong source — `info` comes back null and the React-side selection never updates. `setSelected` now accepts an optional prebuilt `PointInfo` so callers that already have it can bypass the round-trip. Net effect: reloading with `#focus=...` reliably navigates the camera AND restores the hash to the URL bar via the hook's selection-effect once `selected` updates. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two coupled fixes for the famous-id branch of the deep-link drain:
1) The engine and App both fetch the famous-galaxy sidecars
independently (deliberately, per App.tsx's loader comment) and
the two copies fall out of sync during cold-load — App's lands
first because it has no other startup work to do, while the
engine's is fired only after `loadAllClouds` resolves. When
App's drain raced ahead and called `selectByAlias({Famous, ...})`,
the engine's `buildPointInfo` read its still-empty internal
famousMeta, `info.famous` came back undefined, and
`selectionToFocusId` fell through to the placeholder-PGC branch —
writing `#focus=pgc-<idx>` instead of `#focus=<famous-id>` to the
URL. selectByAlias now accepts an optional famousMeta + xrefs
override so App can pass its already-populated copy and dodge
the race. Other call sites (click, hover, palette alias-search)
leave it undefined and continue to read the engine's copy.
2) The drain effect ran the moment any cloud reported ready, but
`state.cam` is constructed slightly later — inside the
`loadAllClouds` resolution, after the bbox of the first arrival
is known. selectByAlias's tween dispatch silently bails on a
null camera, so the URL would update but the camera never moved.
Drain now gates on `status.kind === 'ready'`, the engine event
that fires when the render loop starts (post-camera-init).
Also: relax `buildPointInfo`'s `famousMeta` parameter to
`readonly FamousMetaEntry[]` so callers holding a readonly view
(EngineHandle's optional override) can pass it through unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pull the drain + supersede `useEffect`s out of App.tsx and into
useFocusUrlSync, so every effect that touches the `#focus=…`
lifecycle — mount-time hash capture, selection-driven URL writes,
drain-on-engine-ready, and supersede-on-selection — lives in one
place with a single shared rationale comment.
App.tsx loses ~120 lines + the resolveFocusTarget / ALL_SOURCES /
PointCloud imports it no longer needs. The hook's public surface
becomes useFocusUrlSync({ selected, status, sourceCounts, famousMeta,
famousXrefs, aliasMap, engineHandleRef }) → { pendingTarget }, with
pendingTarget exposed for a future tier-mismatch banner.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
skymap | fe7a5c9 | Commit Preview URL Branch Preview URL |
May 06 2026, 10:12 AM |
Bare canvas clicks no longer pollute browser history. Selection (the pin state shown by the InfoCard) and focus (the camera-tween target that drives the deep-link URL) become separate concepts: - A canvas click pins the galaxy and shows the InfoCard, but does nothing to the URL. - The Focus button on the InfoCard, the `f` shortcut, double-clicks, palette picks, and deep-link arrivals all trigger a camera-focus commitment, which updates the URL with a `pushState` history entry. - Back/Forward steps walk through pinned galaxies as the user expects. Changes: - `EngineCallbacks` adds `onFocusChange?: (info | null) => void`. - Engine fires it from `selectByAlias`, `selectFamous`, `focusOn`, `focusOnHome` (with null), and `clearSelection` (with null) — every call site that starts a camera commitment. - `EngineHandle.focusOn` now takes `(info: PointInfo)` instead of `(xyz, diameterKpc)`. The engine extracts xyz/diameter internally and fires the callback in one place — App.tsx no longer has to setFocused manually at every callsite. - App.tsx adds a `focused` state alongside `selected`; useFocusUrlSync subscribes to `focused` and supersedes pending deep links on focus changes (not on bare-click selections, so a deep-link still resolves even if the user clicks something else mid-load). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
#focus=<id>URLs that reproduce a galaxy selection on reload — paste a link in chat, the recipient lands on the same galaxy.m31) > PGC (pgc-2789) > SDSS objID (sdss-…) > 4-decimal RA/Dec fallback (pos@…,…). Synthetic rows aren't link-encodable.useFocusUrlSync).Architecture
src/services/url/focusUrl.ts— pure codec, no DOMsrc/services/engine/resolveFocusTarget.ts— pure resolver:FocusTarget+ loaded clouds + sidecars →{resolved} | {tier} | {unknown}src/hooks/useFocusUrlSync.ts— single React owner of the URL ↔ selection lifecycle (mount-time hash capture, selection-driven URL writes, drain-against-resolver once engine is ready, supersede-on-focus, popstate for SPA back/forward)EngineHandle.getCloud(source)— new optional method exposing full PointClouds (resolver needs positions for the pos@ fallback)EngineHandle.selectByAlias— gains optionalfamousMeta/famousXrefsoverrides so App can dodge the engine's internal sidecar-load racetierresults leavependingTargetset; banner UI is a follow-up (Task 5 of the plan).Selection vs focus split
Two state concepts with distinct triggers and side-effects:
selectedfocusedfshortcut, double-click, palette pick, deep-link resolveA casual click pins the galaxy without polluting browser history; only deliberate focus actions add a
#focus=…history entry. Engine fires a newonFocusChangecallback from every camera-commitment site, soEngineHandle.focusOn(info)is now a single-arg call — App callsites don't have to updatefocusedstate manually.History behavior
pushState, so each pin is a back-button stop.popstatelistener catches Back/Forward: if the new hash parses, setpendingTargetand let the drain re-resolve; if empty, callengine.clearSelection().matchesshort-circuit prevents history churn.status.kind === 'ready', which guaranteesstate.camis constructed before any tween dispatch.Plan
docs/superpowers/plans/2026-05-06-deep-link-galaxy-focus.md— Tasks 1-4 implemented; Task 5 (banner) deferred; Task 6 = this PR. The history/back-button + focused-split scope was not in the original plan; added during review.Test plan
#focus=m31(new history entry); camera tweensfover a pinned galaxy → same as above#focus=m31(famous) → camera lands on M31; URL restored#focus=pgc-…(non-famous GLADE row with PGC) → resolves#focus=pos@…,…(2MRS row without PGC) → resolves via nearest-neighbour#focus=sdss-…→ resolves#focus=garbage→ no crash; pending stays set silently (banner UI in follow-up)🤖 Generated with Claude Code