PURPOSE

Renders the Upgrades tab inside the Ships screen: a 4x4 mod grid on top, a static “drag to grid / tap to rotate” header with sort pills, and a scrolling inventory of owned mod stacks below. Lets the player drag a mod from a stack onto the grid to equip it, drag an equipped mod off to return it to inventory, merge three same-rarity copies into the next rarity, rotate pieces in place or pre-drag, and purchase locked grid cells with credits. Surfaces an EQUIPPED summary of summed stats from currently equipped mods. Honors the loot-bag deep link via ?highlight=new, which forces the NEWEST sort and scroll-snaps to the first row containing an unseen mod. Built on the medical design system; rarity color is preserved as an identity exception per ADR 20260513-001.

OWNS

  • UpgradesTab — exported tab component; layout, drag dispatch, sort/highlight handling, stack rotation cache, shake flash.
  • DndGrid — internal 4x4 grid container; computes cell unlock states, renders DropCell per visible cell, renders EquippedBlock per equipped mod.
  • DropCell — single grid cell visual; three states (unlocked, purchasable, blocked); purchasable cells render an amber coin CTA with cellEntice keyframe and call purchaseCell on click.
  • EquippedBlock — draggable placed mod on the grid; tap-without-drag rotates in place via rotateEquipped, falling back to inspector when rotation declines.
  • ShapeBricks — shared brick renderer for placed mods and ghosts; computes exterior edge segments to draw one continuous silhouette stroke around the union of filled cells; variants placed, ghost-legal, ghost-illegal, ghost-trash.
  • StackInventoryRow — one row per (templateId, rarity) stack; left shape tile (drag source, tap-to-rotate, NEW ribbon, × N count pill, rotate badge); right name + rarity chip + scaled stats; optional MERGE button when count >= 3 and rarity !== 'legendary'.
  • SnapGhost — body-portaled drag preview locked at grid scale; resolves template/rarity/rotation from either inv or grid payload; switches between ghost-legal, ghost-illegal, and free-floating placed variants based on overGrid + canPlace.
  • RotateBadge — small rotate-glyph affordance shown in the corner of stack tiles.
  • Helpers: summedStats, formatTemplateStats, _flashShake, _hashToDelay.
  • Local constants: STAT_LABEL (visible-stat copy; intentionally omits thrust), GRID = 4, CELL = 62, GAP = 4, GRID_PAD = 8.
  • Local styles: headerStripStyle, emptyStyle, emptyMiniStyle.
  • Module-scope <style id="mod-tile-pulse-keyframes"> injection defining modTilePulse, modRotateBadgePulse, modRowShake, cellEntice keyframes.
  • Local types: DragPayload, CellState.

READS FROM

  • @starship-survivors/stores/modGridStore selectors: equipped, inventoryStacks, inventory, canPlace, isCellUnlocked, cellPurchaseCost, nextUnlockable, purchasedCells.
  • @starship-survivors/stores/walletStore selector: credits.
  • @starship-survivors/data/mods: MOD_TEMPLATES_BY_ID, RARITY_COLORS, RARITY_MULTIPLIER, types ModStatBlock, ModTemplate, ModRarity.
  • @starship-survivors/data/mods/_types: resolveShape (computes rotated { w, h, cells }).
  • @starship-survivors/data/unseen-mods: getUnseenModUids (snapshot at mount), clearAllUnseenMods (called once after snapshot).
  • ./useDragDrop: useDraggable, DropShield, useDragStore, computeGridSnap.
  • ./Inspector: Inspector component, InspectTarget type.
  • ./upgrades-sort: sortUpgradeStacks, SortMode, StackForSort.
  • react-router-dom: useSearchParams (reads highlight query param).
  • react-dom: createPortal (mounts SnapGhost on document.body).

PUSHES TO

  • useModGridStore actions: equipFromInventory, moveEquipped, unequip, merge (aliased mergeStack), rotateEquipped, purchaseCell, persist.
  • clearAllUnseenMods() — called once at mount inside the useState initializer.
  • setSearchParams — strips ?highlight=new once consumed.
  • Local React state: inspected, stackRotations, newUids (set once), shakeStackKey, sortMode, searchParams.
  • useDragStore (via useDraggable / useDragStore.getState()) — initiates drags with kind: 'mod', reads live pointer position for snap math.
  • DOM: appends a single <style id="mod-tile-pulse-keyframes"> to document.head on first module load; portals the snap ghost to document.body.

DOES NOT

  • Does not implement drag mechanics — useDraggable / DropShield / useDragStore from ./useDragDrop own pointer tracking, lifting, and drop dispatch.
  • Does not implement placement validation, merge resolution, cell unlock sequencing, or persistence — those live entirely in modGridStore.
  • Does not award or spawn mods; inventory is populated by mission rewards before the tab opens.
  • Does not delete or trash mods — there is no destroy path; off-grid drops always return to inventory and merge is the only consumer.
  • Does not deduct credits directly; purchaseCell in modGridStore is the source of truth for cell-cost spend.
  • Does not compute rotated shapes — defers to resolveShape.
  • Does not sort the inventory — defers to sortUpgradeStacks in ./upgrades-sort.
  • Does not render the per-mod inspector body — Inspector (from ./Inspector) renders when inspected is non-null.
  • Does not render a mod-inventory drop target reaction; the data-dnd-target="mod-inventory" attribute exists on the scroll container but grid-source drops outside mod-grid are handled by an explicit unequip path in handleDrop, not by a dedicated inventory drop target.
  • Does not show the thrust stat in any UI surface; it is intentionally fused into SPEED.
  • Does not expose a “delete” or “sell” affordance — copy reads “no delete / trash; mods are always useful as merge fuel.”
  • Does not render blocked cells; DndGrid filters them out before mapping. Only unlocked cells and the single next purchasable cell are drawn.
  • Does not allow merge at legendary rarity (canMerge = count >= 3 && rarity !== 'legendary').

Signals

  • URL query ?highlight=new — initializes sortMode to 'newest', attaches firstNewRowRef to the first stack with an unseen uid, scrolls it into view via useLayoutEffect, then strips the param so a refresh does not re-trigger.
  • useDragStore — drag activity (active, kind), live pointer coords (pointerX, pointerY), and current payload drive snap math, cell highlights, ghost rendering, and tap-vs-drag disambiguation in click handlers.
  • Pre-drag stackRotations[key]templateId::rarity keys cycle 0..3 via onRotate; persisted in component state only (not store).
  • shakeStackKey — set by _flashShake for 400ms after a rejected drop (e.g. canPlace false or equipFromInventory returned false); drives modRowShake keyframe on the corresponding row.
  • Unseen-mod snapshot — newUids captured once at mount; controls the NEW ribbon and the scroll-target ref for the session.
  • Drop target attributes — data-dnd-target="mod-grid" on the outer grid wrapper, data-dnd-target="mod-inventory" on the inventory scroll container.

Entry points

  • Imported by the Ships screen (sibling tabs in screens/ships/: SelectTab, ArtifactsTab, UpgradesTab). Mounted as one of the ship-screen tabs.
  • Deep-linked from the loot-bag “VIEW IN MENU” action via ?highlight=new.
  • Cross-tab inspector requests funnel through the local inspected state and the shared Inspector component; closing it nulls inspected.

Pattern notes

  • Medical design system tokens (--med-*) for surfaces, borders, status colors, radii, durations, easings; rarity colors retained on bricks, frames, chips, and count rings as an explicit identity exception (ADR 20260513-001).
  • “No credits in this view” — mod acquisition is mission drops + merge; cell purchases are the only credit sink in the tab.
  • useLayoutEffect chosen for the highlight scroll so layout is settled before scrollIntoView fires; no RAF or polling.
  • useMemo for stacks adapts raw inventoryStacks() output into StackForSort shape, derives hasUnseen against the mounted snapshot, then defers ordering to sortUpgradeStacks. The inventory dependency forces the memo to refresh when fresh mods arrive even though inventoryStacks is a stable selector; eslint-disable-line documents the intentional dep set.
  • Stack-row drag consumes uids[0] as headUid; order-independence holds because every uid in a stack shares (templateId, rarity).
  • EquippedBlock distinguishes tap-rotate from drag-end-without-move by checking useDragStore.getState().active inside the click handler.
  • ShapeBricks builds the outer silhouette by walking each filled cell, pushing only edges whose neighbor is empty or out-of-bounds, and overlaying them as raw SVG <line> segments above the bricks — avoids any per-cell border seams inside the shape.
  • SnapGhost lives at the tab level (rendered once) and is portaled to document.body so it escapes overflow clipping; it reads drag state directly rather than receiving props per drag.
  • DropCell purchasable cells use amber (--med-amber) intentionally to align with the gold credit-coin identity elsewhere in the UI; non-affordable cells render the same shape but desaturated and de-animated.
  • Stack-row tile pulse delays are deterministic per (templateId, rarity) via _hashToDelay so the visual remains stable across renders without RNG.
  • Cell unlock is sequence-driven: nextUnlockable() returns at most one [col, row], and only that cell renders the purchasable state; every other locked cell is filtered out. Buying advances the sequence in modGridStore.
  • Persistence: every successful state mutation in the tab (equipFromInventory, moveEquipped, unequip, merge, rotateEquipped, purchaseCell) is followed by an explicit persist() call; the component never assumes auto-persist.
  • Keyframes are injected once via a module-scope guard checking for #mod-tile-pulse-keyframes; safe across hot reloads.