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
ShapePickerexported function component and its rendered grid. - Local hover state (
hoveredId) and derivedactiveId(hoveredId ?? currentTemplateId). - Internal helper components
LiveCardPreviewandStaticCardPreview. - The single
PreviewSurfaceinstance per live card, created and destroyed insideLiveCardPreview’suseEffect. - 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) andSLOW_SPEED(0.2 cycles per second).
READS FROM
TEMPLATESandTemplateIdtype from@engine/vfx-workbench/shader-templates/registry(drives the list of cards and the per-carduniformSchemadefaults).PreviewSurfaceclass from@engine/vfx-workbench/preview-surface(instantiated for the active card).DEFAULT_PALETTEandflattenPalettefrom@engine/vfx-workbench/palette(seeded into the live card’su_paletteuniform).- Props:
currentTemplateId(selected template) andonPick(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
divby clearinginnerHTMLand appending thePreviewSurfacecanvas. - Calls
surface.useTemplate,surface.setUniforms,surface.render, andsurface.destroyon the ownedPreviewSurface.
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
PreviewSurfaceat 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/onMouseLeaveon each card — set/clearhoveredId, which promotes that card to the live preview slot.useEffectcleanup inLiveCardPreview— cancels therafIdand callssurface.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 anddata-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 ?? currentTemplateIdrule 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
useEffectkeyed ontemplateId. The effect builds thePreviewSurface, seeds uniforms fromtemplate.uniformSchemadefaults (number-typed only) plusu_palettefromflattenPalette(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 mockcanvasis 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.
StaticCardPreviewrenders a centered, monospaced four-character label (templateId.slice(0, 4)) on a near-black tile — cheap to render for every non-active card.