Skip to content
Merged
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
9 changes: 8 additions & 1 deletion docs/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,14 @@ From [ADR 0001 §"explicitly not deciding"](adrs/0001-fade-ownership.md):

Diagnosed but unplanned. Captured here so they don't get lost; promote to a spec or plan when prioritised. Most have richer notes in agent memory (`~/.claude/projects/-Users-rulkens-Development-js-skymap/memory/`).

- **Mobile layout reflow** — hover-on-touch is handled (`disable hover on touch input`, #226: hover-only affordances now route through tap). What remains is the general responsive layout pass: reflow the InfoCard / SettingsPanel / StatusBar for narrow viewports so the UI is usable on a phone, not just non-broken.
- **Make the codebase `poi`-free (`poi*` → `structure*`)** — **Priority: high.** The data layer is already `StructureRecord` and the InfoCard family was renamed to `StructureDetailCard` / `CompactStructureCard` (`structure` props, `Structure` eyebrow). The legacy `poi` vocabulary survives in the engine/identity/URL layers and reads as a second name for the same concept — the exact "two names, one thing" wart the simplicity convention flags. Holdouts found 2026-06-07:
- **`isPoi` predicate** (`services/engine/isPoi.ts`) — imported by ~8 non-InfoCard sites (`FocusableTarget.d.ts`, `EngineCallbacks.d.ts`, `EngineCameraHandle.d.ts`, `useUrlSync.ts`, `useStructureMemberCount.ts`, `engine.ts`, `commitFocus.ts` + tests). Rename to `isStructure`.
- **`POI_CATEGORY_INFO` / `data/poiCategoryInfo.ts` / `PoiCategory` type** (`@types/engine/data/PoiCategory.d.ts`) — the category-info table + its type alias; co-design with the `STRUCTURE_CATEGORY_META` consolidation in the DRY item below (don't merge the genuinely-different category *lists*).
- **`poiUrl` / `resolvePoiFromPick`** (`services/url/poiUrl.ts`, `services/engine/helpers/resolvePoiFromPick.ts`) and the engine `selectedPoi` params in `selectionRingPass.ts` / `EngineSubsystemHandles.d.ts` / `engine.ts`.
- **⚠️ The `#poi=` deep-link URL param** (`services/url/poiUrl.ts`, `utils/url/hasDeepLink.ts`, `App.tsx`) is a **persisted format** — existing shared links use `#poi=cluster-…`. Renaming the param string would break them, so this needs a back-compat read (accept both `#poi=` and a new `#structure=`) or must be deliberately left as-is. Decide explicitly; do **not** blind-rename.

**Approach:** run `entanglement-radar` first to map the knots and the `#poi=` back-compat boundary, then apply via `/simplify` (delegate the edits per `feedback_simplify_edits_in_subagent`). Pairs with the `cluster* → structure*` naming migration and the structure-category-identity DRY item below — all three are the same "converge on `Structure*` vocabulary" cleanup. Mechanical apart from the URL-param decision; typecheck + tests are the safety net.
- **Mobile layout reflow** — hover-on-touch is handled (`disable hover on touch input`, #226: hover-only affordances now route through tap). What remains is the general responsive layout pass: reflow the InfoCard / SettingsPanel / StatusBar for narrow viewports so the UI is usable on a phone, not just non-broken. **Update 2026-06-07:** the InfoCard half is specced — [2026-06-07 mobile bottom-sheet design](superpowers/specs/2026-06-07-mobile-info-card-bottom-sheet-design.md) (awaiting plan); the SettingsPanel launcher is a gated fast-follow pending an `entanglement-radar` pass over `SettingsPanel`.
- **Lower-tier "close to home" weighting** — retune the small/medium tier subsampling so more galaxies survive near the camera's home position for maximum visual density on first load, while keeping the on-screen count fast. Distinct from the deliberate SDSS far-shell sample (memory `project_sdss_medium_intentionally_far`).
- **Densely seed the Local Volume across all tiers (group explorability)** — surfaced 2026-06-04 with the `group` category. The 16 Local Volume groups are only interesting to fly into if their *member* galaxies are present, but `subsampleByAbsMag` (`tools/catalog/`) thins the nearby volume by absolute-magnitude cut, so faint dwarfs in the Local Group / M81 / Cen A / Sculptor etc. get culled — a group ring you focus into can be nearly empty at small/medium tier. Bias the subsampling to **keep galaxies inside (or near) the featured group spheres** regardless of `M_abs`, across small + medium and ideally large tiers, so each group has as many members as possible. Related to but distinct from the "close to home" weighting above: that's camera-home density; this is per-group membership density keyed off the structure seed. Implementation hooks: the group seed positions/radii (`data/cluster_anchors.seed.json`) are available to the build, so the subsampler can spare points within `apparentRadiusMpc` of each group centre. Keep an eye on the on-screen count budget. Pairs with the cluster-focus member count (`PoiDetailCard` "Galaxies" row) — denser seeding makes that number meaningful at lower tiers.
- **Milliquas colour check** — Milliquas points currently all render blue; verify the colour-index / colour mapping for the quasar source isn't collapsing to a single hue.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
# Mobile info-card bottom sheet — design

**Date:** 2026-06-07
**Status:** Awaiting review
**Scope:** The selected-target info card on mobile (< 768 px). Desktop is untouched.

## Problem

On a phone, selecting a galaxy or structure makes the info card cover most of
the screen — including the galaxy/structure it describes, which renders in the
centre. The card is `position: fixed` top-right with `max-width: 320px` and **no
mobile media query** (`DetailCard.module.css:22`, `InfoCard.module.css:17`), so
at a 360–390 px viewport it spans ~85 % of the width and runs the full height of
the content. The user reads the data but loses sight of the thing.

A second, related complaint — the three bottom-left panels (Settings / Stats /
Navigation) overrunning the bottom-right scale bar — is **out of scope here** and
captured as a gated fast-follow (see the last section).

## Goals

- On mobile, a selected galaxy/structure stays **visible** — the card no longer
covers the centre.
- The full detail set is still **one gesture away**.
- The scale bar no longer sits under the card.
- **Desktop (≥ 768 px) is byte-for-byte unchanged** — same top-right card, same
panels, same behaviour.
- Reuse the existing `GalaxyDetailCard` / `StructureDetailCard` bodies; don't fork a
parallel mobile card with its own drifting content.

## Non-goals

- The Settings/Stats/Navigation panel launcher (fast-follow, gated on an
entanglement-radar pass over `SettingsPanel`).
- Any change to what data the card shows, or to `galaxyInfoBuilder`.
- Live re-layout on orientation change beyond what CSS media queries give for
free (the existing one-shot `initialMobile` sample stays as-is).

## Chosen direction — bottom sheet (Option A)

Selected via the visual-companion brainstorm. On mobile the card becomes a
**bottom sheet** with two states:

| State | What shows | When |
| --- | --- | --- |
| **Collapsed (peek)** | Grab handle, name + survey badge, one line `<distance> · <lookback> ago` (galaxy) / `<category> · <distance>` (structure). ~2 lines tall. | Default the moment something is selected. The object stays in view above. |
| **Expanded** | The full existing detail-card body, scrolling internally, capped so the sky still peeks at the top (~75 vh). | After the user drags/taps the sheet up. |

### Peek content (decided: minimal)

- **Galaxy:** `displayName` + `sourceLabel` badge, then `formatDistance(distanceMpc) · lookbackGyr Gyr ago`.
- **Structure:** `name` + category-label badge, then `category · formatDistance`.

Everything else (thumbnail, cosmology lines, catalogues, redshift, diameter, the
existing "More details" fold) lives in the expanded state. The peek is the
**top slice of the same card** revealed by the sheet's collapsed snap — not a
separate component — so there is no second copy of the headline to keep in sync.

### Gesture (decided: drag, via CSS scroll-snap)

The drag is a **scroll-snap bottom sheet** — pure CSS for the gesture:

- The sheet lives in a `position: fixed; inset: 0` vertical scroll container with
`scroll-snap-type: y mandatory` and two snap children: **peek** and
**expanded**.
- Dragging the sheet is native touch scrolling; the browser owns the momentum
and the snap. No JS for the gesture.
- A transparent spacer fills the area above the sheet with `pointer-events:
none`, so taps/drags on the sky still reach the canvas; only the sheet itself
has `pointer-events: auto`.

**The one piece that is not pure CSS:** when the user selects a *different*
object (and on the ✕ close), the container must scroll back to the peek snap.
That is a ~3-line `useEffect` keyed on the selected target's identity calling
`ref.scrollTo({ top: peekOffset })`. Selection is already React state, so this
adds no new state — only a reset. (If we ever want *zero* JS, the fallback is a
`<details>` tap-toggle with no drag; we are not taking that.)

### Hover on mobile

Touch has no hover, so on mobile the sheet renders **only `selected`**. The
compact hover preview (`CompactCard` / `CompactStructureCard`) and the stacked-pair
layout are desktop-only and are suppressed below the breakpoint. `InfoCard`
already returns null when nothing is selected; the mobile branch simply ignores
`hovered`.

### Scale bar

When a sheet is present on mobile, the scale bar lifts to sit **above** the
collapsed peek (it is irrelevant while reading details and is allowed to be
covered when the sheet is expanded). Implementation: the UI-stack root already
knows selection state in React; it carries a class (e.g. `hasSelection`) that a
mobile-only media rule reads to raise the scale bar's `bottom` offset by the peek
height. No new state, no JS measurement.

## Component / contract changes

- **`InfoCard.tsx`** — add a mobile branch. Above the breakpoint: today's
stack, unchanged. Below it: a `MobileSheet` wrapper around the single
`GalaxyDetailCard` / `StructureDetailCard` for `selected`, with `hovered` ignored.
Layout switches via CSS media queries wherever possible; JS reads the
breakpoint (via `matchMedia`) only for what CSS can't do — choosing the
mobile render branch and the scroll-reset. The plan decides whether that read
is a new `useIsMobile()` hook or a reuse of the existing `initialMobile`
sample.
- **New `MobileSheet` component + CSS module** — the scroll-snap container, the
spacer, the grab handle, the peek/expanded snap children, and the
scroll-reset effect. Contains a detail card as its child; knows nothing about
*which* card.
- **`DetailCard.module.css`** — a mobile media block that, inside the sheet,
reorders/restyles so the card's top is exactly the peek (headline + a single
compact distance line) and the remainder flows below the expanded snap line.
No desktop rules change.
- **`App.module.css` + the UI-stack root** — the `hasSelection` class hook and
the mobile scale-bar offset rule.

No changes to `GalaxyInfo`, `galaxyInfoBuilder`, the engine, or any data path.

## Testing strategy

- **Unit (jsdom + Testing Library):** `InfoCard` renders only the selected
sheet on mobile and ignores `hovered`; renders today's stack on desktop.
Drive the breakpoint by mocking `matchMedia`. Assert peek content (name,
badge, the single distance line) is present and that "More details" reference
rows are in the DOM (the scroll-snap reveal is CSS — we assert content
presence, not pixel position, matching the existing
`InfoCard.structureHover.test.ts` philosophy of testing rendered text, not class
fragments).
- **Reset effect:** changing the `selected` target calls `scrollTo` on the
container ref (spy).
- **Desktop parity:** existing `InfoCard` tests stay green unchanged — the
desktop branch must be untouched.
- **Visual:** manual check on the dev server at a phone width (the gesture and
snap are browser-native and not unit-testable).

## Risks / open questions

- **`pointer-events` passthrough vs. the canvas' `touch-action: none`.** Need to
confirm on a real device that dragging the sheet scrolls it while dragging the
sky still orbits the camera. Mitigation: the spacer is `pointer-events: none`;
only the sheet captures touch.
- **Peek height as a CSS constant.** The scale-bar lift and the peek snap both
depend on the peek height; it should be one token, not two literals, to avoid
drift.
- **`useIsMobile` vs. pure CSS.** Prefer CSS media queries for layout; use JS
only for the scroll-reset and the `hasSelection` hook. The plan should resist
introducing a JS `isMobile` branch where a media query suffices.

## Fast-follow (gated, not part of this spec)

Collapse Settings / Stats / Navigation behind a single `⚙` launcher on mobile,
sharing a "utility row" with the scale bar so they stop overrunning it. **This is
gated on an entanglement-radar review of `SettingsPanel`** — it is likely tangled
(value × place mirror state, a god-panel of independent toggles), and we want to
un-braid it before bolting on a mobile presentation. Separate spec + plan after
that review.
42 changes: 28 additions & 14 deletions src/@types/engine/GalaxyInfo.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ export type GalaxyInfo = {
* string shown in the info card (e.g. "Red, quiescent galaxy").
*/
galaxyType: GalaxyTypeInfo;

/**
* Curated morphological (Hubble) type, pre-formatted for display
* (e.g. "Barred spiral (SBb)"), or `undefined` when none is known.
*
* Only curated famous galaxies carry a morphology; it's an independent fact
* from the colour-derived `galaxyType` (morphology says nothing about
* red-sequence vs blue-cloud membership), so it lives in its own field rather
* than overwriting `galaxyType.description`. The info card prefers it over
* the colour description when present: `morphology ?? galaxyType.description`.
*/
morphology?: string;
/**
* Survey-aware IAU designation, e.g. "SDSS J123456.75+012345.5",
* "GLADE J234500.00-104500.5", "2MASX J...". Built from RA/Dec via
Expand Down Expand Up @@ -199,27 +211,29 @@ export type GalaxyInfo = {
/** @group External URLs */

/**
* URL of an external catalogue page for this object (opens in a new tab).
* External-catalogue links for this object, pre-labelled and ready to render
* as the InfoCard's "Catalogues" row (each opens in a new tab). Empty when
* the row has no resolvable catalogue page (e.g. Synthetic).
*
* Picked per-source so every real galaxy gets a useful link:
* The builder picks both the URL and its label per-source so the label can't
* drift from the page it points at:
*
* - SDSS rows with a valid objID → SDSS DR18 Quick Look (skyserver)
* - 2MRS rows → NED near-position search at the row's RA/Dec. We
* - SDSS rows with a valid objID → "SDSS Explorer" → SDSS DR18 Quick Look
* - 2MRS rows → "NED" → near-position search at the row's RA/Dec. We
* deliberately don't use 2MASX byname here: NED's name index has
* coverage gaps for the 2MASX prefix (verified empirically), so a
* position search lands more reliably even when one extra click
* is required to drill into the object page.
* - GLADE rows with a real PGC → NED byname `PGC <n>` (the PGC is
* position search lands more reliably even when one extra click is
* required to drill into the object page.
* - GLADE rows with a real PGC → "NED" → byname `PGC <n>` (the PGC is
* persisted in `objID` — see `tools/parsers/glade.ts`)
* - GLADE rows with no PGC → NED near-position search at the row's RA/Dec
* - Famous rows → NED byname using the primary curated name
* (M31, NGC 224, …) from the famous catalog sidecar
* - Synthetic rows → `null` (no real coords to look up)
* - GLADE rows with no PGC → "NED" → near-position search at the row's RA/Dec
* - Famous rows → "NED" (byname on the primary curated name) plus a
* "Wikipedia" link resolved from the curated names.
*
* The InfoCard component picks an appropriate link label off `source`
* (e.g. "View in SDSS Explorer" for SDSS, "View on NED" otherwise).
* Pre-resolving here keeps the card presentational — it just maps over the
* array — and is the single place that knows which page each label points at.
*/
catalogUrl: string | null;
catalogues: Array<{ label: string; href: string }>;

/** @group Physical size */

Expand Down
19 changes: 13 additions & 6 deletions src/components/InfoCard/CompactCard.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -67,23 +67,30 @@
text-transform: uppercase;
}

/* Name on the left, source badge top-right — mirrors the FullCard header. */
.headlineRow {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-3);
}

/* SDSS name headline */
.cardHeadline {
flex: 1;
min-width: 0;
font-size: var(--font-size-xl);
color: var(--color-fg);
margin-bottom: var(--space-2);
letter-spacing: var(--letter-spacing-tight);
word-break: break-all;
}

/*
* Compact variant of the FullCard source badge. Slightly tighter padding
* and a smaller bottom margin to fit the slim preview format.
*/
/* Compact variant of the FullCard source badge — slightly tighter padding. */
.sourceBadge {
display: inline-block;
flex-shrink: 0;
padding: 0 5px;
margin: 0 0 var(--space-3) 0;
border-radius: var(--radius-sm);
background: var(--surface-badge);
border: 1px solid var(--border-control);
Expand Down
8 changes: 5 additions & 3 deletions src/components/InfoCard/CompactCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ export function CompactCard({ info }: CompactCardProps): ReactNode {
<div className={styles.cardTitle}>
<span>Hover</span>
</div>
<div className={styles.cardHeadline}>{info.displayName}</div>
<div className={styles.sourceBadge}>{info.sourceLabel}</div>
<div className={styles.headlineRow}>
<div className={styles.cardHeadline}>{info.displayName}</div>
<span className={styles.sourceBadge}>{info.sourceLabel}</span>
</div>
<div className={styles.cardLookbackLine}>
Light left {info.lookbackGyr.toFixed(1)} Gyr ago
</div>
<div className={styles.cardLookbackEra}>— {info.earthEra}</div>
<div className={styles.cardDistLine}>
{formatDistance(info.distanceMpc)} &middot; {info.galaxyType.description}
{formatDistance(info.distanceMpc)} &middot; {info.morphology ?? info.galaxyType.description}
</div>
</div>
);
Expand Down
33 changes: 0 additions & 33 deletions src/components/InfoCard/CompactPoiCard.tsx

This file was deleted.

Loading
Loading