PURPOSE

Mobile-first drag-and-drop primitive for the Ships sub-tabs. Provides a unified pointer-event-based DnD pipeline that works identically on iOS Safari, Android Chrome, and desktop mouse. Built on Zustand plus document.elementsFromPoint for hit testing; no third-party DnD library. Powers card dragging, slot assignment, and grid-snap previews used by UpgradesTab and other Ships sub-screens.

OWNS

  • The global useDragStore Zustand store holding the single active drag (active, hoverTarget, kind, pointerX, pointerY, payload).
  • useDraggable<T> hook that attaches a draggable source element, returning { ref, onPointerDown, dragging, ghostX, ghostY, hoverTarget, sourceW, sourceH, payload }.
  • DragGhost component — a body-portaled, pointer-events-disabled visual that follows the pointer with a slight scale/rotate/drop-shadow.
  • DropShield component — wraps the inside of a draggable so child elements (images, buttons) can’t intercept pointer events; also disables text selection and the iOS long-press callout.
  • computeGridSnap pure helper — translates pointer coords into a clamped { col, row, overGrid } for grid-based drop previews.
  • Click suppression on the source element after a drag ends, preventing the synthetic post-drag click from re-opening inspectors/popovers.

READS FROM

  • Browser pointer events: pointermove, pointerup, pointercancel on window.
  • document.elementsFromPoint(clientX, clientY) to discover drop targets by walking elements with the data-dnd-target attribute.
  • The source element’s getBoundingClientRect() for ghost sizing on drag start.
  • Internal refs holding the latest payload, onDrop, and kind so closures stay current without re-binding listeners.

PUSHES TO

  • useDragStoreset({ active, hoverTarget, kind, pointerX, pointerY, payload }) on every move; cleared on up/cancel/unmount.
  • The consumer’s onDrop(targetId, payload) callback on pointerup once the movement threshold has been crossed; targetId is the data-dnd-target value under the pointer, or null if released over empty space.
  • document.body via React createPortal — the DragGhost renders here so it escapes any clipping/stacking contexts.
  • A one-shot capture-phase click listener attached to the source element for ~400 ms after drag-end to swallow the synthetic click.

DOES NOT

  • Does not consume the React DnD ecosystem or HTML5 native drag events; pointer events only.
  • Does not handle multi-touch or pinch — single primary pointer (button === 0 or unset) only.
  • Does not animate ghost release back to source on a missed drop; on null target it just calls onDrop(null, payload).
  • Does not own drop-target rendering. Drop zones declare themselves by setting data-dnd-target="<id>" and reading useDragStore for hover state.
  • Does not persist drag state across reloads or read from game stores; it is purely a UI primitive.
  • Does not enforce kind compatibility — drop zones must filter on kind themselves before reacting.
  • Does not throttle/raf pointer updates; relies on React batching and the simplicity of the store writes.

Signals

  • useDragStore is the public broadcast channel. Any drop zone or HUD element can subscribe:
    • active: boolean — a drag is in progress.
    • hoverTarget: string | null — id of the current hover drop zone, or null.
    • kind: string | null — discriminator the source declared (e.g. 'mod', 'ship'); drop zones use this to ignore foreign drags.
    • pointerX, pointerY — viewport coords of the current pointer.
    • payload: unknown — the opaque source payload, cast by consumers based on kind.
  • The useDraggable return also exposes dragging, ghostX/Y, sourceW/H, and hoverTarget for the source-side render of the ghost and any inline visual feedback.

Entry points

  • useDraggable<T>({ payload, onDrop, kind?, threshold? }) — attached to the source element via ref and onPointerDown. threshold defaults to 6 px before drag engages.
  • <DropShield> — wrap inside any draggable. Sets the inner subtree to pointer-events: none and disables selection/callout so the outer wrapper is the sole pointer surface.
  • <DragGhost x y w h> — render while drag.dragging is true; portals to document.body and follows the pointer.
  • computeGridSnap(pointerX, pointerY, gridLeft, gridTop, cell, gap, gridSize, pieceW, pieceH, slop?) — returns the auto-clamped { col, row, overGrid } snap position for a piece of footprint pieceW × pieceH. Slop defaults to 24 px.
  • useDragStore — direct subscription for drop-zone hover/preview rendering.

Pattern notes

  • Standard usage: const drag = useDraggable({ payload, onDrop, kind }), attach drag.ref plus onPointerDown to a wrapper with touchAction: 'none', wrap inner visuals in <DropShield>, and conditionally render <DragGhost> when drag.dragging.
  • Drop targets are declared declaratively via data-dnd-target="<id>" on any DOM node — no registration step. Hit testing walks the stack at the pointer and picks the first matching element.
  • Drag engages only after movement crosses threshold (default 6 px); short taps pass through to normal click handlers.
  • Pointer listeners are attached to window so the drag survives the pointer leaving the source element; they are removed on up, cancel, and component unmount.
  • Refs (payloadRef, onDropRef, kindRef) mirror props each render so the long-lived pointermove closure always sees current values without churn-and-rebind cycles.
  • After a successful pointerup, a capture-phase click listener is attached to the source for one click or 400 ms — whichever fires first — to suppress the synthetic click that would otherwise reopen inspectors when dropping a card on itself.
  • Only e.button === 0 (primary) or unset triggers a drag; secondary buttons are ignored.
  • DragGhost uses transform: scale(1.08) rotate(-2deg) plus a drop-shadow filter and zIndex: 100000 so it floats above all screen chrome.
  • DropShield is a plain width: 100%; height: 100% block wrapper with pointer-events: none; sized children that use height: 100% still resolve because the parent’s height propagates.
  • computeGridSnap clamps the pointer to the grid’s pixel rect, divides by cell + gap stride for the raw cell, then clamps again by gridSize - pieceW/pieceH so the piece’s full footprint always lands inside the grid; dragging a 1×4 to the bottom-right corner therefore shifts it up so the bottom row aligns with the last row.
  • The store is intentionally shared/singleton: only one drag exists at a time on a mobile screen, so a single slot is enough and the price of prop-drilling is avoided.