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
PlaygroundTabunion — 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.TabPropsinterface — the standard prop bundle each tab receives (mission ref, ship id, restart hook, optional build/VFX/level state lifted to parent).VfxStateinterface +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) andPanel(collapsible outer panel with header + optional extra-slot). - Slider components —
StatRow(plain power-curve slider + number input + optional reset) andStatRowWithSaved(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 withidle | saving | saved | failedtoast states; auto-clears after 2 s.- Save-status pipeline —
SAVE_STATUS_STORAGE_KEY(ss_playground_save_status),SaveStatusEntryinterface,recordSaveStatus()writer, internalloadSaveStatus()reader,fmtTime()helper, internalStatusRowcomponent, and the publicSaveStatusPanelcomponent that renders the four-row trace (File write / Git commit / Push to main / Vercel) with✓ / ✗ / — / …glyphs and SHA or error detail. - Editable primitives —
EditableTextRowandEditableNumberRowfor heterogeneous key/value editors (base-stats panels inArtifactsTab/UpgradesTab/ModsTab). useDraft<T>hook — keeps astructuredCloned working copy of an object, exposesdraft,setDraft,reset, and aJSON.stringify-basedisDirtyflag.- Internal slider math —
SLIDER_POWER(2.5),SLIDER_STEPS(1000),valueToSlider,sliderToValue(power-curve mapping so small values get more resolution).
READS FROM
react—useState,useCallback,useMemo,useEffect,CSSProperties.../../engine/bridge—MissionHandletype (imported forTabProps.missionRef).../../data/level-config—LevelConfigtype (imported forTabProps.levelConfig).../../services/playgroundPush—PushResulttype, used as theresultfield ofSaveStatusEntryand decoded bySaveStatusPanelto render the per-step trace.- Browser globals —
localStorage(for save-status persistence),window(forss-playground-save-statusandstorageevent listeners),CustomEvent,Date,JSON,structuredClone.
PUSHES TO
localStorage— under keyss_playground_save_status, writes the most recentSaveStatusEntryso the trace survives HMR reloads and tab switches.windowevent bus — dispatchesCustomEvent('ss-playground-save-status')fromrecordSaveStatus()so any mountedSaveStatusPanelin the same window refreshes immediately; also listens tostorageevents 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 thePushResultproduced elsewhere. The actual file-write / commit / push pipeline lives inservices/playgroundPushand the dev-server/__dev/push-statsendpoint.
DOES NOT
- Does not perform the push itself —
PushButtonis a generic wrapper around a caller-supplied asynconClick; 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
TabPropsfields; this module just defines the shapes. - Does not validate values —
StatRow/StatRowWithSavedclamp 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
keydowninside its inputs so gameplay hotkeys do not fire while the user types. - Does not animate — transitions are limited to
background 0.12son buttons andall 0.2son the push-button state change. - Does not throw —
recordSaveStatusandloadSaveStatusswallow alllocalStorageandJSONerrors silently (best-effort persistence).
Signals
windowCustomEvent('ss-playground-save-status')— fired byrecordSaveStatus()after every localStorage write; consumed bySaveStatusPanel’suseEffectto re-read and re-render.windowstorageevent — consumed bySaveStatusPanelso two playground tabs in two windows stay in sync.PushButtoninternal state machine —idle → saving → saved | failed → idle(auto-resets after 2 s viasetTimeout); the resolved value of the caller’s promise is treated asfalse === failed, anything else=== saved, and any thrown exception=== failed.PushResult.reason === 'endpoint unavailable (deploy to dev)'— special-cased bySaveStatusPanelto display the red “OFFLINE BUILD” banner explaining that SAVE is a no-op on production Vercel builds.PushResult.gitAttempted/commitOk/pushOk— branched on inSaveStatusPanelto chooseok | fail | skip | pendingglyphs 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 inStatRowWithSaved.
Entry points
- Imported by every file under
src/starship-survivors/screens/playground/*.tsxfor the shared types, styles, and components. recordSaveStatus()is called by tab components (and indirectly by helpers inservices/playgroundPush) after each SAVE round-trip to record the pipeline trace.SaveStatusPanelis 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).PushButtonis the standard “PUSH” / “SAVE” affordance — pass an asynconClickthat returnsPromise<boolean>(orvoid); afalseresolution renders the FAILED state.Section/Panelare 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
TabPropsrather 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.5maps the slider’s internal 0-1000 linear range throughpow(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.valueToSlideruses the inverse exponent1 / 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.
StatRowWithSavedderives it inline asMath.abs(value - savedValue) < step * 0.5.EditableNumberRowuses1e-9as the float-equality epsilon.EditableTextRowuses 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.
SaveStatusPanelis intentionally fed from localStorage rather than React state, so a fresh mount immediately shows the last known trace. recordSaveStatusnever throws. Storage may be disabled (private browsing, embedded WebView); the writer wraps everything intry / catchand silently degrades to “no SAVE this session yet” if localStorage is unavailable.- The four-row trace is rendered from a single
PushResult.SaveStatusPaneldoes not poll Vercel; the “Vercel” row ispendingif push succeeded (auto-deploy queued ~30-60 s) andskipotherwise. Real deploy verification happens out-of-band via thevercel-deploy-monitorcron, not in the playground UI. useDraftusesJSON.stringifyfor 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.WASDmove,Escpause) bound at the window level.