PURPOSE

Renders a minimal layer-stack list for the VFX weapon-workbench editor. Each row represents one Layer in the component schema and exposes the controls needed to rearrange and toggle layers without leaving the list: enabled checkbox, kind chip, name input, solo toggle, blend-mode dropdown, move-up / move-down buttons, and delete button. Parameter editing for the selected layer is delegated to a downstream Inspector — this component is intentionally restricted to stack-level concerns.

OWNS

  • Local soloId state (useState<string | null>) — which layer, if any, is currently soloed.
  • The add, remove, move, and update helpers that derive a new Layer[] from the current layers prop and hand it to onChange.
  • ID-allocation logic for new layers — strips the leading l from existing ids, parses the integer, takes the max, and adds one to produce the next l<n> id.
  • The default shape of a newly added layer: kind: 'fill', shader: 'disc', params: {}, blend: 'additive', opacity: 1, enabled: true, name: 'new layer'.
  • Visual styling — ROW_STYLE, KIND_COLOR per LayerKind, and the btnMini style shared by reorder and delete buttons.
  • Re-exports of the in-file constants LAYER_KINDS and BLEND_MODES.

READS FROM

  • @engine/vfx-workbench/component-schema — type imports Layer, LayerBlendMode, LayerKind.
  • Props from the parent: layers: Layer[], selectedLayerId: string | null, onSelect, onChange.

PUSHES TO

  • onChange(layers: Layer[]) — invoked for every structural or per-field edit: reorder, enable toggle, name change, blend-mode change, add, delete.
  • onSelect(layerId: string) — invoked when a row is clicked (excluding clicks on interactive children, which call stopPropagation).

DOES NOT

  • Does not edit layer parameters (params), shaders, opacity, or any field beyond name, enabled, and blend. Those are the Inspector’s job.
  • Does not persist anything itself — all state lives in the parent via onChange.
  • Does not implement true drag-and-drop reorder; reorder is via up / down buttons only.
  • Does not allow deletion of the last remaining layer — remove is a no-op when layers.length <= 1, and the delete button is also disabled in that case.
  • Does not mutate the input layers array; every helper slices a copy.
  • Does not own the authoritative LayerKind / LayerBlendMode enums — the comment at the bottom of the file defers to @engine/vfx-workbench/component-schema.

Signals

  • Selected row is outlined with borderColor: '#44ffcc' instead of #222.
  • Disabled or non-soloed-while-solo-active rows render at opacity: 0.35 via the effectiveEnabled predicate.
  • The solo button turns cyan (#44ffcc background, #001 text) when that layer is the active solo target.
  • Each LayerKind has a distinct kind-chip color: shape #ffaa44, fill #44ffcc, mask #cc88ff, stroke #ff6688, glow #ffee44, post #4488ff.
  • Empty state — when layers.length === 0, the component renders an italic placeholder No layers. Click "+ layer" to add one.
  • Up button is disabled at the top of the stack (idx === layers.length - 1); down button is disabled at the bottom (idx === 0); delete button is disabled when only one layer remains.
  • Header reads LAYERS (<count>) with an + layer add button on the right.

Entry points

  • Exported component: LayerStackEditor({ layers, selectedLayerId, onSelect, onChange }).
  • Exported props interface: LayerStackEditorProps.
  • Re-exported constants: LAYER_KINDS, BLEND_MODES.
  • data-testid hooks used by tests: layer-stack-editor, add-layer-button, and per-layer layer-row-<id>, layer-enable-<id>, layer-name-<id>, layer-solo-<id>, layer-blend-<id>, layer-up-<id>, layer-down-<id>, layer-delete-<id>.

Pattern notes

  • Render order is reversed from array order: layers.slice().reverse().map(...) — index 0 is the bottom of the visual stack, so it appears at the bottom of the list. The original idx (in the unreversed layers array) is recovered via layers.indexOf(layer) and is what the helpers use.
  • Because of the reversal, the move-up button () calls move(idx, +1) and the move-down button () calls move(idx, -1) — the visual direction is the opposite of the array-index direction.
  • All interactive children inside a row call e.stopPropagation() on click so they do not also trigger the row-level onSelect from the parent <div>.
  • Solo is local UI state, not part of the persisted Layer model — leaving and re-entering the editor resets it. effectiveEnabled combines layer.enabled with the local solo gate purely for the visual dim treatment; it does not mutate the layer.
  • update, remove, and move always produce a fresh array via slice / filter and never mutate layers in place — safe to pass directly to a Zustand or other immutable store.
  • Inline styles are used throughout; there is no CSS module or class hookup. Font stack is Space Grotesk, sans-serif for text and monospace for kind chips, mini-buttons, and the blend dropdown.
  • The component is presentational with one piece of local state — it can be unit-tested by mounting it with a controlled layers prop and asserting on onChange / onSelect call shapes.