CollectibleCard

PURPOSE

Shared v4 ship card component used by the Ships screen and ShipSelectOverlay. Renders a rarity-frame card containing the real ship sprite, hull name, star row, XP bar, and a locked overlay. Mobile-first sizing — readable at 100px wide. Supports a compact variant for the bottom-sheet picker.

OWNS

  • CollectibleCard React functional component (default per-file export, named).
  • Module-scope side effect that injects a <style> tag with id collectible-selected-pulse-keyframes into document.head, defining the collectibleSelectedPulse CSS keyframes (white-border + outer-glow pulse for the selected state).
  • Inline-style derivation of: stacked card shadow with optional rarity glow, dual rarity gradient vs locked dark gradient background, locked-vs-selected-vs-default border treatments, hull-name 8-direction black stroke + drop shadow, sprite-area drop shadow + colored frame glow filter, sprite grayscale on lock, the “CURRENTLY SELECTED” blackout overlay.
  • Width-of-current-tier XP percentage calculation via xp / (xp + xpToNextStar(xp)), clamped to 100 when star >= 5.

READS FROM

  • useInventoryStore (@starship-survivors/stores/inventoryStore) — currentXp(hull) and currentStar(hull) selectors.
  • getShipDef, RARITY_COLORS, displayHullName from @starship-survivors/data/ships.
  • xpToNextStar from @starship-survivors/data/ship-progression.
  • getShipV4SpritePath from @starship-survivors/engine/rendering/ships-v4-loader.
  • rarityGradientCss, RARITY_BADGE_LETTER, RARITY_BADGE_FILL, RARITY_ACCENT, RARITY_EFFECT_TIER, CARD_SHADOW_CSS, CARD_BADGE, and the Rarity type from @starship-survivors/engine/rendering/card-theme.
  • Sibling components RarityBadge and TrimmedShipImage.
  • HullStarBar from @starship-survivors/components/HullStarBar.
  • Props: hull (string), locked (boolean), selected?, onClick?, compact?.

PUSHES TO

  • DOM: appends a single <style> element to document.head on first module load (guarded by id lookup to prevent duplicates).
  • Caller via onClick callback, suppressed when locked is true.
  • Render tree: RarityBadge, TrimmedShipImage, HullStarBar.

DOES NOT

  • Does not mutate inventory, XP, or star state — read-only against useInventoryStore.
  • Does not fetch sprites or load images directly; delegates to TrimmedShipImage with a resolved path from getShipV4SpritePath.
  • Does not render the rarity badge or “CURRENTLY SELECTED” overlay when locked is true.
  • Does not render the star row / XP bar when locked is true.
  • Does not own layout for collections of cards (grid placement, ordering, filtering live in the parent screen / overlay).
  • Does not handle keyboard navigation beyond the native disabled <button> semantics.
  • Does not animate on hover or press — only the selected-state pulse keyframe runs.

Signals

  • locked prop disables the button, swaps background to a dark gradient (#2a2a30#14141a), suppresses the rarity badge and star-bar banner, applies grayscale(1) brightness(0.35) to the sprite, overlays a lock glyph, and sets cursor not-allowed.
  • selected prop (when not locked) swaps the border to a 5px white outline, applies the collectibleSelectedPulse 1.4s infinite ease-in-out animation, and renders a full-card 85%-opacity black overlay with a “CURRENTLY SELECTED” label.
  • Rarity drives: outer border color (RARITY_ACCENT), gradient background (rarityGradientCss), optional outer glow whose radius and alpha scale with RARITY_EFFECT_TIER (tier ≥ 1 enables glow; tier ≥ 4 bumps alpha from 88 to cc), badge letter and fill, and sprite drop-shadow tint (frame.color55).
  • compact prop reduces padding (8 → 6), name font size (15 → 13), gap (5 → 3), star size (30 → 24), bar height (11 → 8), dangle offset (6 → 4), selected-label font size (17 → 14), and suppresses no other rendering. Naming aside, the XP bar is still rendered in compact mode — the file header’s “no XP bar” comment is descriptive of the compact variant’s smaller bar treatment via HullStarBar, not removal.
  • isMax (star >= 5) pins xpBarPct to 100.

Entry points

  • Default usage: <CollectibleCard hull={hull} locked={locked} selected={selected} onClick={onClick} />.
  • Compact usage: pass compact for bottom-sheet picker contexts (e.g. ShipSelectOverlay).
  • Module import side effect: importing the module once registers the collectibleSelectedPulse keyframes globally. Safe under SSR via the typeof document !== 'undefined' guard.

Pattern notes

  • All styling is inline; no CSS modules or styled-components. The one exception is the keyframes block, which must live in a stylesheet to drive a CSS animation.
  • The keyframes injection is idempotent: it checks document.getElementById('collectible-selected-pulse-keyframes') before creating the element, so reloading the module (HMR) does not duplicate the style tag.
  • The card uses all: 'unset' on the <button> to drop user-agent styling, then rebuilds layout from scratch as a flex column. boxSizing: 'border-box' is set explicitly because all: 'unset' can reset it.
  • overflow: 'visible' on the root is required so the rarity badge can dangle outside the upper-left corner via negative top / left (CARD_BADGE.dangleY / dangleX).
  • marginBottom: 12 reserves space below the card so the 10px hard drop shadow does not collide with adjacent cards in a grid.
  • The star-bar wrapper uses negative marginTop (-10 compact, -14 default) to pull the star glyph up so it overlaps the bottom of the sprite art — a deliberate visual anchor mirroring the hero card on the Ships screen.
  • The hull-name textShadow is an 8-direction 1.5px black stroke plus a vertical drop shadow, kept inline rather than abstracted because it’s a one-off effect tuned to this card’s gradient.
  • Rarity-specific visuals are looked up through card-theme constants (RARITY_ACCENT, RARITY_EFFECT_TIER, CARD_BADGE, CARD_SHADOW_CSS, rarityGradientCss) rather than hand-coded per rarity — the file pulls every rarity-driven number from that theme module.
  • TrimmedShipImage is invoked with zoom={1.35} to deliberately crop the widest hulls so every ship reads large and dominant within the card.
  • The locked lock glyph is rendered with a literal emoji character inline; the rest of the file is emoji-free.
  • The selected && !locked overlay sits at position: absolute; inset: 0 and uses pointerEvents: 'none' so clicks still hit the underlying button.