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, rendersDropCellper visible cell, rendersEquippedBlockper equipped mod.DropCell— single grid cell visual; three states (unlocked,purchasable,blocked); purchasable cells render an amber coin CTA withcellEnticekeyframe and callpurchaseCellon click.EquippedBlock— draggable placed mod on the grid; tap-without-drag rotates in place viarotateEquipped, 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; variantsplaced,ghost-legal,ghost-illegal,ghost-trash.StackInventoryRow— one row per(templateId, rarity)stack; left shape tile (drag source, tap-to-rotate, NEW ribbon,× Ncount pill, rotate badge); right name + rarity chip + scaled stats; optional MERGE button whencount >= 3andrarity !== 'legendary'.SnapGhost— body-portaled drag preview locked at grid scale; resolves template/rarity/rotation from either inv or grid payload; switches betweenghost-legal,ghost-illegal, and free-floatingplacedvariants 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 omitsthrust),GRID = 4,CELL = 62,GAP = 4,GRID_PAD = 8. - Local styles:
headerStripStyle,emptyStyle,emptyMiniStyle. - Module-scope
<style id="mod-tile-pulse-keyframes">injection definingmodTilePulse,modRotateBadgePulse,modRowShake,cellEnticekeyframes. - Local types:
DragPayload,CellState.
READS FROM
@starship-survivors/stores/modGridStoreselectors:equipped,inventoryStacks,inventory,canPlace,isCellUnlocked,cellPurchaseCost,nextUnlockable,purchasedCells.@starship-survivors/stores/walletStoreselector:credits.@starship-survivors/data/mods:MOD_TEMPLATES_BY_ID,RARITY_COLORS,RARITY_MULTIPLIER, typesModStatBlock,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:Inspectorcomponent,InspectTargettype../upgrades-sort:sortUpgradeStacks,SortMode,StackForSort.react-router-dom:useSearchParams(readshighlightquery param).react-dom:createPortal(mountsSnapGhostondocument.body).
PUSHES TO
useModGridStoreactions:equipFromInventory,moveEquipped,unequip,merge(aliasedmergeStack),rotateEquipped,purchaseCell,persist.clearAllUnseenMods()— called once at mount inside theuseStateinitializer.setSearchParams— strips?highlight=newonce consumed.- Local React state:
inspected,stackRotations,newUids(set once),shakeStackKey,sortMode,searchParams. useDragStore(viauseDraggable/useDragStore.getState()) — initiates drags withkind: 'mod', reads live pointer position for snap math.- DOM: appends a single
<style id="mod-tile-pulse-keyframes">todocument.headon first module load; portals the snap ghost todocument.body.
DOES NOT
- Does not implement drag mechanics —
useDraggable/DropShield/useDragStorefrom./useDragDropown 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;
purchaseCellinmodGridStoreis the source of truth for cell-cost spend. - Does not compute rotated shapes — defers to
resolveShape. - Does not sort the inventory — defers to
sortUpgradeStacksin./upgrades-sort. - Does not render the per-mod inspector body —
Inspector(from./Inspector) renders wheninspectedis non-null. - Does not render a
mod-inventorydrop target reaction; thedata-dnd-target="mod-inventory"attribute exists on the scroll container but grid-source drops outsidemod-gridare handled by an explicit unequip path inhandleDrop, not by a dedicated inventory drop target. - Does not show the
thruststat 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
blockedcells;DndGridfilters them out before mapping. Only unlocked cells and the single next purchasable cell are drawn. - Does not allow merge at
legendaryrarity (canMerge = count >= 3 && rarity !== 'legendary').
Signals
- URL query
?highlight=new— initializessortModeto'newest', attachesfirstNewRowRefto the first stack with an unseen uid, scrolls it into view viauseLayoutEffect, then strips the param so a refresh does not re-trigger. useDragStore— drag activity (active,kind), live pointer coords (pointerX,pointerY), and currentpayloaddrive snap math, cell highlights, ghost rendering, and tap-vs-drag disambiguation in click handlers.- Pre-drag
stackRotations[key]—templateId::raritykeys cycle 0..3 viaonRotate; persisted in component state only (not store). shakeStackKey— set by_flashShakefor 400ms after a rejected drop (e.g.canPlacefalse orequipFromInventoryreturned false); drivesmodRowShakekeyframe on the corresponding row.- Unseen-mod snapshot —
newUidscaptured 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
inspectedstate and the sharedInspectorcomponent; closing it nullsinspected.
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.
useLayoutEffectchosen for the highlight scroll so layout is settled beforescrollIntoViewfires; no RAF or polling.useMemoforstacksadapts rawinventoryStacks()output intoStackForSortshape, deriveshasUnseenagainst the mounted snapshot, then defers ordering tosortUpgradeStacks. Theinventorydependency forces the memo to refresh when fresh mods arrive even thoughinventoryStacksis a stable selector; eslint-disable-line documents the intentional dep set.- Stack-row drag consumes
uids[0]asheadUid; order-independence holds because every uid in a stack shares(templateId, rarity). EquippedBlockdistinguishes tap-rotate from drag-end-without-move by checkinguseDragStore.getState().activeinside the click handler.ShapeBricksbuilds 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.SnapGhostlives at the tab level (rendered once) and is portaled todocument.bodyso it escapes overflow clipping; it reads drag state directly rather than receiving props per drag.DropCellpurchasable 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_hashToDelayso 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 inmodGridStore. - Persistence: every successful state mutation in the tab (
equipFromInventory,moveEquipped,unequip,merge,rotateEquipped,purchaseCell) is followed by an explicitpersist()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.