PURPOSE

Shared types, styles, and reusable components for the Ship Playground tab system. Provides the common UI vocabulary (panels, sliders, push buttons, save-status pipeline trace) that every playground tab (ShipsTab, ModsTab, WeaponsTab, EnemiesTab, UpgradesTab, ArtifactsTab, LevelTab, VfxTab, PhysicsTab, BossTab, BossGauntletTab, PlanetsTab, FxTab, TestRunnerTab) imports from. Centralizes the “edit a value → push to source files → see git/deploy result” pipeline.

OWNS

  • PlaygroundTab union — canonical list of tab identifiers.
  • PanelSide'instance' | 'base', for split-panel tabs that show a live entity on one side and the base definition on the other.
  • TabProps interface — the standard prop bundle each tab receives (mission ref, ship id, restart hook, optional build/VFX/level state lifted to parent).
  • VfxState interface + DEFAULT_VFX_STATE — full VFX dashboard state shape (darkness, lights, shadows, bloom, contrast, fog above/below, dust, bokeh) so it persists across tab switches.
  • Style constants — STROKE_SHADOW, PANEL_BG, PANEL_BORDER, PANEL_RADIUS, HEADER_STYLE, LABEL_STYLE, VALUE_STYLE, BTN_STYLE, SECTION_HEADER_STYLE, PUSH_BTN_STYLE, RESET_BTN_STYLE.
  • Layout components — Section (collapsible inner section with / toggle) and Panel (collapsible outer panel with header + optional extra-slot).
  • Slider components — StatRow (plain power-curve slider + number input + optional reset) and StatRowWithSaved (adds saved-value indicator: green when equal to saved, yellow when dirty, green triangle marker on the track at the saved position).
  • ResetDropdown — two-option menu: “Reset to Saved” vs “Reset to Session Start”.
  • PushButton — async button with idle | saving | saved | failed toast states; auto-clears after 2 s.
  • Save-status pipeline — SAVE_STATUS_STORAGE_KEY (ss_playground_save_status), SaveStatusEntry interface, recordSaveStatus() writer, internal loadSaveStatus() reader, fmtTime() helper, internal StatusRow component, and the public SaveStatusPanel component that renders the four-row trace (File write / Git commit / Push to main / Vercel) with ✓ / ✗ / — / … glyphs and SHA or error detail.
  • Editable primitives — EditableTextRow and EditableNumberRow for heterogeneous key/value editors (base-stats panels in ArtifactsTab / UpgradesTab / ModsTab).
  • useDraft<T> hook — keeps a structuredCloned working copy of an object, exposes draft, setDraft, reset, and a JSON.stringify-based isDirty flag.
  • Internal slider math — SLIDER_POWER (2.5), SLIDER_STEPS (1000), valueToSlider, sliderToValue (power-curve mapping so small values get more resolution).

READS FROM

  • reactuseState, useCallback, useMemo, useEffect, CSSProperties.
  • ../../engine/bridgeMissionHandle type (imported for TabProps.missionRef).
  • ../../data/level-configLevelConfig type (imported for TabProps.levelConfig).
  • ../../services/playgroundPushPushResult type, used as the result field of SaveStatusEntry and decoded by SaveStatusPanel to render the per-step trace.
  • Browser globals — localStorage (for save-status persistence), window (for ss-playground-save-status and storage event listeners), CustomEvent, Date, JSON, structuredClone.

PUSHES TO

  • localStorage — under key ss_playground_save_status, writes the most recent SaveStatusEntry so the trace survives HMR reloads and tab switches.
  • window event bus — dispatches CustomEvent('ss-playground-save-status') from recordSaveStatus() so any mounted SaveStatusPanel in the same window refreshes immediately; also listens to storage events for cross-tab updates.
  • DOM via React — renders panel chrome, sliders, buttons, and the status trace into whatever parent mounts them; no direct DOM mutation.
  • No calls to playgroundPush, fetch, or git — this module only displays the PushResult produced elsewhere. The actual file-write / commit / push pipeline lives in services/playgroundPush and the dev-server /__dev/push-stats endpoint.

DOES NOT

  • Does not perform the push itself — PushButton is a generic wrapper around a caller-supplied async onClick; the actual write/commit/push round-trip is the caller’s responsibility.
  • Does not own per-tab state — VFX state, level config, build-mode flags are lifted to the parent screen via optional TabProps fields; this module just defines the shapes.
  • Does not validate values — StatRow/StatRowWithSaved clamp numeric input to [min, max] but do not enforce step alignment beyond rounding, do not warn on out-of-range, and do not block invalid input on number inputs (NaN is silently ignored).
  • Does not handle keyboard shortcuts globally — only stops propagation on keydown inside its inputs so gameplay hotkeys do not fire while the user types.
  • Does not animate — transitions are limited to background 0.12s on buttons and all 0.2s on the push-button state change.
  • Does not throw — recordSaveStatus and loadSaveStatus swallow all localStorage and JSON errors silently (best-effort persistence).

Signals

  • window CustomEvent('ss-playground-save-status') — fired by recordSaveStatus() after every localStorage write; consumed by SaveStatusPanel’s useEffect to re-read and re-render.
  • window storage event — consumed by SaveStatusPanel so two playground tabs in two windows stay in sync.
  • PushButton internal state machine — idle → saving → saved | failed → idle (auto-resets after 2 s via setTimeout); the resolved value of the caller’s promise is treated as false === failed, anything else === saved, and any thrown exception === failed.
  • PushResult.reason === 'endpoint unavailable (deploy to dev)' — special-cased by SaveStatusPanel to display the red “OFFLINE BUILD” banner explaining that SAVE is a no-op on production Vercel builds.
  • PushResult.gitAttempted / commitOk / pushOk — branched on in SaveStatusPanel to choose ok | fail | skip | pending glyphs and detail strings for each row.
  • Slider accentColor#22c55e (green) when |value − savedValue| < step * 0.5, else #fbbf24 (yellow); the same threshold drives the label color and reset-button color in StatRowWithSaved.

Entry points

  • Imported by every file under src/starship-survivors/screens/playground/*.tsx for the shared types, styles, and components.
  • recordSaveStatus() is called by tab components (and indirectly by helpers in services/playgroundPush) after each SAVE round-trip to record the pipeline trace.
  • SaveStatusPanel is mounted at the bottom of the playground UI (in the playground screen or a per-tab footer) and auto-refreshes — no props.
  • useDraft(original) is used by tab editors that need a dirty-tracking working copy of a record (e.g. a single ship row, artifact tier, mod stat block).
  • PushButton is the standard “PUSH” / “SAVE” affordance — pass an async onClick that returns Promise<boolean> (or void); a false resolution renders the FAILED state.
  • Section / Panel are the structural primitives for every collapsible group in the playground; toggle state is owned by the parent.

Pattern notes

  • Lifted state, not contexts. Cross-tab persistence (VFX state, level config, build mode) flows as optional props in TabProps rather than via React Context, so tabs that do not need it pay no subscription cost and child re-renders stay local.
  • Power-curve sliders. SLIDER_POWER = 2.5 maps the slider’s internal 0-1000 linear range through pow(t, power) so small real-world values (typical ship stats sit around the 30th percentile of [min, max]) get about 30% of the slider travel. valueToSlider uses the inverse exponent 1 / power. Step snapping happens in real units after the curve is applied.
  • Saved-value indicators are the source of truth for “is this a change?” The yellow/green color split is the only signal — there is no separate “dirty” flag. StatRowWithSaved derives it inline as Math.abs(value - savedValue) < step * 0.5. EditableNumberRow uses 1e-9 as the float-equality epsilon. EditableTextRow uses strict !==.
  • Save-status persistence survives HMR. Vite’s dev plugin can reload the playground module mid-edit; without localStorage, the user would lose the most-recent SAVE trace. SaveStatusPanel is intentionally fed from localStorage rather than React state, so a fresh mount immediately shows the last known trace.
  • recordSaveStatus never throws. Storage may be disabled (private browsing, embedded WebView); the writer wraps everything in try / catch and silently degrades to “no SAVE this session yet” if localStorage is unavailable.
  • The four-row trace is rendered from a single PushResult. SaveStatusPanel does not poll Vercel; the “Vercel” row is pending if push succeeded (auto-deploy queued ~30-60 s) and skip otherwise. Real deploy verification happens out-of-band via the vercel-deploy-monitor cron, not in the playground UI.
  • useDraft uses JSON.stringify for the dirty check. Fast enough for the small records the playground edits (single ship row, mod stat block). Not safe for objects with non-JSON values (functions, Date, Map, undefined) — the playground never edits those.
  • Style constants are exported, not themed. The playground uses literal rgba(...) values rather than CSS variables because the playground UI is a fixed-style designer tool, not part of the game’s themable surface.
  • Number inputs stop key propagation. onKeyDown={(e) => { e.stopPropagation(); e.nativeEvent.stopImmediatePropagation(); }} is on every numeric input so typing into a field does not trigger gameplay hotkeys (e.g. WASD move, Esc pause) bound at the window level.