PURPOSE

Grid picker UI for selecting a shader TemplateId in the weapon workbench. Renders one card per registered template with a live animated preview on hover/selection and a static fallback for idle cards.

OWNS

  • The ShapePicker exported function component and its rendered grid.
  • Local hover state (hoveredId) and derived activeId (hoveredId ?? currentTemplateId).
  • Internal helper components LiveCardPreview and StaticCardPreview.
  • The single PreviewSurface instance per live card, created and destroyed inside LiveCardPreview’s useEffect.
  • The requestAnimationFrame loop driving the live card’s normalized time progress.
  • Card layout styling (grid columns, gap, border highlight for the selected template).
  • Module-level constants CARD_SIZE (64) and SLOW_SPEED (0.2 cycles per second).

READS FROM

  • TEMPLATES and TemplateId type from @engine/vfx-workbench/shader-templates/registry (drives the list of cards and the per-card uniformSchema defaults).
  • PreviewSurface class from @engine/vfx-workbench/preview-surface (instantiated for the active card).
  • DEFAULT_PALETTE and flattenPalette from @engine/vfx-workbench/palette (seeded into the live card’s u_palette uniform).
  • Props: currentTemplateId (selected template) and onPick (callback fired on card click).
  • performance.now() for the animation timebase.

PUSHES TO

  • Calls onPick(id) when a card button is clicked; selection state lives in the parent.
  • Mutates the live card’s mount div by clearing innerHTML and appending the PreviewSurface canvas.
  • Calls surface.useTemplate, surface.setUniforms, surface.render, and surface.destroy on the owned PreviewSurface.

DOES NOT

  • Does not persist the selected template; the parent screen holds selection state.
  • Does not mutate the shader template registry, palette module, or any uniforms outside the live card’s own surface.
  • Does not render more than one live PreviewSurface at a time (intentional — browsers cap concurrent WebGL2 contexts around 16, so only the hovered or selected card spins up a surface).
  • Does not render a thumbnail image in idle cards; the static fallback shows the first four characters of the template id.
  • Does not handle keyboard navigation or touch-specific gestures beyond default button behavior.
  • Does not animate the static cards.

Signals

  • onPick(id: TemplateId) — invoked on card click with the picked template id.
  • onMouseEnter / onMouseLeave on each card — set/clear hoveredId, which promotes that card to the live preview slot.
  • useEffect cleanup in LiveCardPreview — cancels the rafId and calls surface.destroy() when the template changes or the card unmounts.

Entry points

  • ShapePicker({ currentTemplateId, onPick }) — the only exported symbol; mounted by the weapon-workbench screen wherever shape selection is offered.
  • data-testid="shape-picker" on the grid container and data-testid="shape-card-${id}" on each card button — used by tests to locate the picker and individual cards.

Pattern notes

  • One shared live preview slot. The activeId = hoveredId ?? currentTemplateId rule means exactly one card is ever live; hover takes precedence over selection. This is the WebGL2 context budget guard called out in the file’s own header comment.
  • Live-card setup runs in a single useEffect keyed on templateId. The effect builds the PreviewSurface, seeds uniforms from template.uniformSchema defaults (number-typed only) plus u_palette from flattenPalette(DEFAULT_PALETTE), attaches the canvas, and starts the RAF loop. Cleanup cancels the frame and destroys the surface, so swapping the hovered card releases the prior GL context before allocating the next.
  • jsdom safety. The effect appends the canvas only when c instanceof Node, because the test-env mock canvas is a plain object. In real browsers this check is always true.
  • Time domain. The shader’s normalized progress is (elapsed * SLOW_SPEED) % 1, giving a 5-second loop (SLOW_SPEED = 0.2).
  • Visual selection cue. The selected card uses a teal border (#44ffcc) and dark-teal background; non-selected cards use a flat dark background and a muted border.
  • Static fallback. StaticCardPreview renders a centered, monospaced four-character label (templateId.slice(0, 4)) on a near-black tile — cheap to render for every non-active card.