PURPOSE

Modal stat-sheet popover that displays the main combat, drive, recovery, and meta stats for a single ship hull at a given star level. Styled like a racing-game stat card. Deliberately omits feel-knobs and physics-tuning fields (sprite color, ramSpeedBleed, terrainFriction, etc.) — those belong in the playground, not the player-facing collection screen.

OWNS

  • Local useEffect that wires an Escape-key listener and tears it down on unmount.
  • A STAT_GROUPS table (module-level constant) that drives all rendered rows. Four groups: COMBAT, DRIVE, RECOVERY, META.
  • Per-row formatter functions: fmt0 (rounded integer), fmt2 (2 decimals), fmtPct (rounded percent), fmtSec (1-decimal seconds).
  • Two internal types: StatRow and StatGroup.
  • A portaled overlay rendered to document.body so it escapes the hub’s portrait clipping.
  • Inline styling for the backdrop, card frame, close button, header, group headings, and stat rows. Frame color is driven by ship rarity.

READS FROM

  • @starship-survivors/data/shipsgetShipDef(hull, star), displayHullName(hull), RARITY_COLORS.
  • Resolved ShipDef from getShipDef for the stat fields listed in STAT_GROUPS: hp, shield, armor, weaponDamagePct, fireRatePct, weaponSlots, upgradeSlots, speed, acceleration, turnRate, drag, heatBuildup, heatCooldown, shieldRegenRate, shieldRegenDelay, hpRegen, luck, magnetRange, currencyBonus, plus rarity (used to pick the frame color).
  • Props: hull: string, star: number, onClose: () => void.
  • document.body (portal target).
  • window (Escape-key listener).

PUSHES TO

  • Invokes the onClose prop on backdrop click, X-button click, and Escape key.
  • Renders a React portal subtree into document.body. No store writes, no network, no telemetry.

DOES NOT

  • Mutate the ship def or any store.
  • Persist any state — purely presentational and stateless beyond the keydown effect.
  • Render feel-knobs, tuning fields, sprite color, ram bleed, or terrain friction.
  • Display rows for stats not declared in STAT_GROUPS.
  • Animate. Open/close is instant; no transitions.
  • Trap focus or manage aria-modal semantics beyond the close button’s aria-label.
  • Lock body scroll when open.
  • Guard against getShipDef throwing; assumes hull is valid.

Signals

  • Backdrop click (onClick={onClose} on the outer fixed-position div) closes the modal.
  • Card click is stopped via e.stopPropagation() so it does not bubble to the backdrop.
  • Close button (<button aria-label="Close">) calls onClose.
  • Escape key, captured globally on window, calls onClose.

Entry points

  • Default export: none. Named export ShipInfoPopover.
  • Props contract: { hull: string; star: number; onClose: () => void }. The popover normalizes star to Math.max(1, star || 1) before resolving stats and rendering the star indicator.

Pattern notes

  • Driven by a data-table-then-loop pattern. Adding a stat row is a one-line edit to STAT_GROUPS; no JSX changes required as long as a suitable formatter exists.
  • Type-erases the def lookup with (def as unknown as Record<string, number>)[row.key as string] to keep StatRow.key strongly typed against ReturnType<typeof getShipDef> while letting the renderer treat values uniformly. Non-number values fall through to 0.
  • The default formatter when row.fmt is omitted is fmt0.
  • Rarity-driven theming: border, glow, and title color all key off RARITY_COLORS[def.rarity].color.
  • Uses createPortal to escape ancestor clipping/stacking. Fixed at zIndex: 1000.
  • Backdrop is rgba(0,0,0,0.82) with padding: 20 so the card never butts against the viewport edge on small phones.
  • Card is capped at maxWidth: 420 and maxHeight: 90vh with overflowY: 'auto' to stay scrollable on short screens.
  • Typography uses 'Cal Sans' for labels/eyebrows and 'Space Grotesk' for headings and stat values.
  • Effect cleanup correctly removes the keydown listener on unmount and on onClose identity change.