UI Feature Development (React Studios)
SkillDev toolsGuides adding or modifying UI features (React "studios") in HOT-Step CPP — studio-folder convention, store patterns, API wiring, routing registration, Tailwind/i18n rules, and the Vite HMR dev loop. Use when creating a new studio/panel/view, adding UI controls or sliders, wiring UI to a server endpoint, or changing anything under ui/src/. NOT for solver/scheduler/guidance knobs — those are declared by Lua plugins in engine/plugins/ (see the lua-plugin-authoring skill), with zero ui/src/ changes.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the UI Feature Development (React Studios) skill
What this skill tells your AI
The instructions your AI receives, as published by scragnog/hot-step-cpp in .claude/skills/ui-feature-dev/SKILL.md and read by ahel’s review.
The UI tier of HOT-Step CPP is a single-page React 19 app in ui/ (Vite 8, Tailwind 3.4, zustand 5, react-i18next, lucide-react icons). Every user-facing feature is a "studio" — a folder of components under ui/src/components/ mounted as a top-level view. There is no react-router: routing is hand-rolled history.pushState in App.tsx. All server calls are plain fetch() against relative /api/... URLs (no axios, no react-query).
Terms used below:
- DiT — the diffusion transformer, the C++ engine's core music-generation model. The UI only ever references it as a model name string.
- Studio — one feature area (Lyric Studio, Stem Studio, Cover Studio...), i.e. one folder under
ui/src/components/plus a branch inApp.tsx. - hs-* — the localStorage key namespace used for persisted UI state.
When to use this skill
- Adding a new studio/view/panel to the UI.
- Adding a control (slider, toggle, dropdown) to an existing studio or the global parameter bar.
- Exception: a knob for a solver/scheduler/guidance mode is NOT a UI task — Lua plugins declare their own UI params (
docs/PLUGINS.md, lua-plugin-authoring skill). Wiring one intogetGlobalParams()is the wrong implementation entirely.
- Exception: a knob for a solver/scheduler/guidance mode is NOT a UI task — Lua plugins declare their own UI params (
- Wiring the UI to a new or existing server API endpoint.
- Debugging UI state, routing, persistence, or "my new param does nothing" issues.
Golden rules (hard constraints)
- NEVER use a browser agent for visual verification — ask the human user for screenshots/feedback. The browser agent is too slow/unreliable in this environment. It is acceptable ONLY for non-visual checks (hitting API endpoints). Workflow: make the change → confirm type-check is clean → ask the user "please check X on the /stem-studio page".
- Don't
npm run buildduring dev. Vite HMR means UI edits need no build and no restart. Type-check withnpx tsc -bfromui\(all tsconfigs havenoEmit: true— nothing is emitted). Only build right before user prod testing. WHY: builds are slow and pointless mid-dev; the dev server already serves source. - Node 18–22 only.
ui/package.jsonenforces"node": ">=18.0.0 <24.0.0". Node 24+ breaks dependencies. - Registering a new studio requires FIVE edits (four in
App.tsx: the import +viewFromUrl+urlForView+ therenderContentbranch, plus oneNavIteminSidebar.tsx— see procedure below). SkippingviewFromUrl()/urlForView()gives a studio that renders but breaks on refresh/back-button. - Any new generation parameter MUST be added to
getGlobalParams()inui/src/stores/globalParamsStore.ts(starts line 324) — that function is the SINGLE assembly point for the request body. A slider not wired there silently does nothing. - Every string through
t()(react-i18next) with a key inui/src/i18n/locales/en.json; other locales fall back to English. Note: en.json nests everything under a top-level"translation"key. - Git discipline (repo-wide): all work on
master, nevergit add -A, nevergit add -fon gitignored paths — stage explicit paths only. Push only with explicit user approval, commit locally often. Never push av*tag casually — it triggers a full multi-platform CI release build. - Cross-tier: if your UI feature needs engine (C++) changes, rebuild via
dev-rebuild.batat repo root, NEVERengine/build.cmddirectly, under any circumstances (Node auto-respawns ace-server; only dev-rebuild.bat performs the clean shutdown). Nevercmake --clean-first(20+ min CUDA recompile). - Sideband rule for new generation params: server-side params outside the C++
AceRequeststruct never survive the/lmround trip. Synth requests are rebuilt by spreading the originalaceReqand taking ONLY LM-generated fields from the echo (server/src/routes/generate.ts:312-328). Add new sideband params toaceReqbefore the LM phase; NEVER extend the LM-field pick-list into a copy-across whitelist (bug class fixed in 8ea519b/168dcb5). - When verifying generation params, never delete generated test audio — the user verifies results by ear. Hand over song IDs/URLs and ask the user to listen, just as visual changes get a screenshot request.
Dev loop (PowerShell, from repo root)
.\dev.bat
This starts BOTH tiers (verified dev.bat): the Node server via server\restart-loop.cmd on :3001 (tsx watch, auto-restart) and npx vite --port 3000 --host in ui\. Develop against http://localhost:3000 — Vite proxies /api, /audio, and /references to :3001 (ui/vite.config.ts:10-24). LAUNCH.bat = prod mode, serving prebuilt ui/dist/ from :3001 (server/src/index.ts:126).
Checks while developing:
cd D:\Ace-Step-Latest\hot-step-cpp\ui
npx tsc -b # type-check UI (project references — catches what a plain tsc --noEmit on one file misses)
npx eslint . # lint
If you touched server/src/: cd D:\Ace-Step-Latest\hot-step-cpp\server; npx tsc --noEmit.
Only before handing to the user for prod testing: npm run build in ui\ (runs tsc -b && vite build).
Procedure: adding a new studio
Say the studio is "My Studio", slug my-studio.
- Create the folder
ui/src/components/my-studio/with an orchestratorMyStudio.tsx(PascalCase files, kebab-case folder). Export both named and default (export const MyStudio: React.FC = ...+export default MyStudio— seeStemStudio.tsx:40,503). Layout convention (documented in-source atStemStudio.tsx:9-10): flex columns with border dividers, resizable right sidebar. - Register the route in
ui/src/App.tsx— add theimport { MyStudio } ...at the top (cf. App.tsx:38), then three places:viewFromUrl()(App.tsx:84-95):if (path.startsWith('/my-studio')) return 'my-studio';urlForView()(App.tsx:98-116):if (view === 'my-studio') return '/my-studio';renderContent()(App.tsx:652 onward): add a branch, wrapped inDiscoPulseWrapper(a beat-reactive visual wrapper) with a hue from theDISCOconstants (App.tsx:279-289, e.g.DISCO.assistant= 175 cyan):if (activeView === 'my-studio') { return ( <DiscoPulseWrapper hue={DISCO.assistant} className="flex-1 overflow-hidden"> <MyStudio /> </DiscoPulseWrapper> ); }
navigateTo()(App.tsx:391-401) →setActiveView+history.pushState; back/forward is apopstatelistener (App.tsx:404-408). Only Lyric Studio persists a deep URL (hs-lastLyricStudioUrl) — copy that only if your studio has sub-routes. - Add a nav item in
ui/src/components/sidebar/Sidebar.tsx— copy an existingNavItemblock (e.g. Stem Studio at Sidebar.tsx:111-117):icon(lucide-react),label={t('sidebar.myStudio')},active={activeView === 'my-studio'},onClick={() => onViewChange('my-studio')},isExpanded={isOpen}. - If it needs server endpoints: create
server/src/routes/myStudio.ts, mount it inserver/src/index.ts(app.use('/api/my-studio', ...)— see the mount block at index.ts:72-95), and create a per-studio clientui/src/services/myStudioApi.ts(see API layer below). Keep all endpoints under/api/...or the Vite proxy won't forward them in dev. - Add i18n keys to
ui/src/i18n/locales/en.json(under the"translation"root) for every user-visible string. - Type-check, then ask the user to visually verify — never a browser agent.
State management — three coexisting patterns
Top-level docs say "Zustand" but only 2 of 8 files in ui/src/stores/ use zustand's create() (globalParamsStore.ts:9, vstChainStore.ts:6 — verified by grep). The majority pattern is a hand-rolled useSyncExternalStore module singleton. Match the pattern of the nearest existing store; don't introduce a fourth.
Pattern A — hand-rolled external store (majority; canonical example ui/src/stores/abCompareStore.ts, 101 lines). Module-level let _state, a Set of listeners, setState() that spreads + notifies, exported plain action functions (importable from non-React code), plus a useXxx() hook (useSyncExternalStore(subscribe, getSnapshot)) and a ref-cached useXxxSelector(selector) hook. Selector hooks are the preferred subscription style: useAudioGenQueueSelector(s => s.items.filter(...).length) (StemStudio.tsx:77-79). Used by: playbackStore, audioGenQueueStore, discoStore, streamingStore, abCompareStore, components/lyric-studio/playlistStore (studio-local stores may live inside the studio folder).
Pattern B — zustand + per-field localStorage (ui/src/stores/globalParamsStore.ts, 460 lines). create<any>()((set, get) => ({...})) — the any is deliberate (line 39-40). Every field initialises via readKey('hs-<name>', default) and every setter does set({...}); writeKey('hs-<name>', v) — per-key hs-* persistence, NOT zustand/persist middleware. getGlobalParams() (line 324-459, end of file) assembles ~100 knobs into a Partial<GenerationParams> request body with conditional gating — new generation params go here or nowhere. ui/src/context/GlobalParamsContext.tsx is a legacy compat shim; its own header (lines 1-8) says new code must import the store directly. Don't extend the context.
Pattern C — plain useState + localStorage inside the studio. StemStudio persists studio-local state via manual localStorage with hs-stem-* keys (StemStudio.tsx:43-46,97-104) or via usePersistedState(key, default) (ui/src/hooks/usePersistedState.ts:9). Cross-component/same-tab sync requires writePersistedState() (usePersistedState.ts:43-49), which manually dispatches a StorageEvent — native StorageEvents only fire cross-tab.
The generation queue store audioGenQueueStore.ts (992 lines) persists to IndexedDB (lireek-queue-store DB) with debounced 2 s writes because large queues blew localStorage's 5 MB cap (lines 65-73,126-133); on reload it resets in-flight items to pending + "Reconnecting…" (_sanitizeItems, lines 142-156).
API layer — how the UI reaches the server
Two tiers, both in ui/src/services/:
- Central client
api.ts(406 lines): privateget/post/patch/del<T>fetch helpers,BASE = '/api', optionalBearertoken, uniform errors (non-OK →throw new Error(err.error || 'API error: <status>'), api.ts:10-64). Exports per-domain objects (songApi,generateApi,modelApi,adapterApi,settingsApi, ...). - Per-studio client files (model:
stemStudioApi.ts, 220 lines): standalone exported async functions, ownAPI_BASE = '/api/stem-studio', own types/constants, and a submit-then-poll helperwaitForExtraction(jobId, onProgress, pollMs=1000)(stemStudioApi.ts:190-211). New studios with their own routes follow this shape.
Auth: single-user local app. AuthContext auto-logs-in on mount via authApi.autoLogin() (GET /api/auth/auto); components do const { token } = useAuth() and pass token to api methods. Newer endpoints (stem-studio, vst, settings) skip the token entirely — both styles coexist.
snake_case gotcha: the server returns SQLite snake_case rows. normalizeSong() (api.ts:76-114) maps to camelCase while keeping BOTH spellings (audioUrl and audio_url) and parses generation_params if it arrives as a JSON string. Any new endpoint returning songs must route through it or the player/library break.
Generation job protocol (verified api.ts:148-152, types.ts:266-281, polling loop audioGenQueueStore.ts:534-600):
POST /api/generate → { jobId, status }
GET /api/generate/status/:id → GenerationJob { status: 'pending|lm_running|synth_running|saving|succeeded|failed|cancelled',
progress?, stage?, error?,
result?: { audioUrls[], songIds[], masteredAudioUrl?, duration?, ... } }
Poll every 1.5 s. Progress may arrive as 0–1 OR 0–100 — normalise both, as audioGenQueueStore.ts:548-550 does. No websockets. Job status is polled (audioGenQueueStore); SSE exists in three places: logs (hooks/useEventSource.ts → TerminalPanel), model-download progress (components/model-manager/useDownloadStream.ts, its own EventSource), and streaming audio previews during generation (hooks/useStreamGeneration.ts → GET /api/generate/stream/:jobId).
Styling and i18n conventions
- Tailwind v3 (not v4).
ui/tailwind.config.js:darkMode: 'class', customsunodark palette (#09090bbg /#121214panel /#18181bcard /#27272ahover-border) andbrandpink#ec4899; font Inter.ui/src/index.cssadds CSS-variable design tokens on top of the@tailwinddirectives. - Dual-theme classes are mandatory — always write light+dark pairs:
border-zinc-200 dark:border-white/5,text-zinc-700 dark:text-zinc-300(see StemStudio.tsx:271,289). - Accents: purple (
focus:border-purple-500/40,#a78bfalabels) inside studios; brand pink for global CTAs. - Mixing Tailwind with a bottom-of-file
const styles: Record<string, React.CSSProperties>object for one-off widgets is accepted convention (StemStudio.tsx:419-501) — not tech debt to "fix". - Resizable sidebars: hand-rolled mousemove handler, width via
usePersistedState('hs-activitySidebarWidth', 320)— this key is shared across studios so sidebar width is consistent app-wide (StemStudio.tsx:76,244-262). - Icons: lucide-react everywhere.
- i18n:
const { t } = useTranslation(), namespaced keys per studio (t('stem.recentExtractions')), keys inlocales/en.jsonunder the"translation"root;ja/ko/ru/zhfall back to en. Helper scriptsui/add_strings.mjsandui/generate_translations.mjsexist (purpose inferred from names — unverified, read before running). Some existing placeholders are hard-coded English (StemStudio.tsx:288,295); uset()anyway for new strings.
Key files
| Path | Role |
|---|---|
ui/src/App.tsx (1437 ln) | Root: hand-rolled routing (viewFromUrl/urlForView/navigateTo), renderContent(), DISCO hues, player wiring |
ui/src/main.tsx | Entry: StrictMode > AuthProvider > Suspense > App |
ui/src/components/<studio>/ | One folder per studio; orchestrator component named after the studio |
ui/src/components/shared/ | Section (in ActivitySidebar.tsx — props title, icon, count?, countColor?, defaultOpen?), Slider, EditableSlider, Toast, ConfirmDialog, StemMixer, DiscoPulseWrapper, ... |
ui/src/components/sidebar/Sidebar.tsx | Left nav — one NavItem per studio |
ui/src/stores/globalParamsStore.ts | zustand; all generation knobs; getGlobalParams() = sole request assembler (line 324) |
ui/src/stores/abCompareStore.ts | Smallest canonical hand-rolled store — copy this as a template |
ui/src/stores/audioGenQueueStore.ts | Generation queue; IndexedDB persistence; submit/poll loop (line 534) |
ui/src/services/api.ts | Central fetch client + normalizeSong() (line 76) |
ui/src/services/stemStudioApi.ts | Model per-studio API client (submit-then-poll) |
ui/src/hooks/usePersistedState.ts | Persisted useState + writePersistedState() same-tab sync |
ui/src/context/AuthContext.tsx | Auto-login, useAuth() → { user, token } |
ui/src/context/GlobalParamsContext.tsx | LEGACY compat shim — do not extend |
ui/src/i18n/locales/en.json | Source-of-truth strings (root key "translation") |
ui/vite.config.ts | Dev proxy: /api, /audio, /references → :3001 |
ui/tailwind.config.js | suno palette, brand pink, darkMode: 'class' |
server/src/index.ts | Route mounts (lines 72-95), serves ui/dist in prod |
server/src/routes/stemStudio.ts | Model server route paired with stemStudioApi.ts |
Failure signatures
| Symptom | Cause → fix |
|---|---|
npm install/dev fails with dep/engine errors | Node 24+. Use Node 18–22 (ui/package.json engines field). |
New studio renders but URL resets to / on refresh, or back-button breaks | Added renderContent() branch but missed viewFromUrl() and/or urlForView() — all three + Sidebar are required. |
| API 404 in dev but works in prod (or vice versa) | Route prefix not proxied — only /api, /audio, /references are in vite.config.ts. Keep endpoints under /api/.... |
| Songs missing audio/cover in one studio only | Endpoint bypassed normalizeSong() — raw snake_case leaked (api.ts:76-114). |
| Effects/API calls fire twice in dev | React StrictMode double-invoke (main.tsx:9). Dev-only; make mount effects idempotent, don't "fix" with flags. |
| New slider has no effect on generations | Value not wired into getGlobalParams() (globalParamsStore.ts:324) — the only place the request body is assembled. If it reaches getGlobalParams() but never reaches /synth: the server chain is routes/generate.ts → translateParams() (server/src/services/generation/translateParams.ts:11) → aceReq; sideband params must join aceReq BEFORE the LM phase (Golden rule 9 — never whitelist across the LM echo). |
| Setting change in one studio doesn't reflect in another | Wrote localStorage directly instead of writePersistedState() — same-tab listeners need the manually-dispatched StorageEvent. |
| Progress bar jumps 0→100 or shows 5000% | Server progress is 0–1 on some paths, 0–100 on others; normalise like audioGenQueueStore.ts:548-550. |
| Queue items stuck "generating" after reload | Expected behaviour: _sanitizeItems resets to pending+"Reconnecting…" and resumeQueue() re-submits (audioGenQueueStore.ts:142-156,610-621). |
UI shows a raw key like stem.queue | Key missing from i18n/locales/en.json. |
npx tsc --noEmit on a file passes but npm run build fails | build runs tsc -b across ALL project references — reproduce with npx tsc -b from ui\. |
| Generation fails and the UI just shows "failed" | Debug server-side: newest logs/YYYY-MM-DD_HH-MM-SS/ folder → generations/gen_*.log, then ace_engine.log / node_console.log. |
Institutional knowledge
- VALIDATED (lead engineer, verbatim rule): Never use a browser agent for visual verification — ask the human user for screenshots/feedback. Browser agent is acceptable only for non-visual API checks.
- VALIDATED:
getGlobalParams()is the sole assembly point for generation request bodies; params bypass it → silently dropped. Confirmed by code and by commit 8ea519b ("adapter param plumbing bugs"). - VALIDATED: the "Zustand" label in top-level docs is aspirational — 5 of the 8 files in
ui/src/stores/are hand-rolleduseSyncExternalStorestores (a 6th,playlistStore, lives incomponents/lyric-studio/); 2 use zustand;playbackConverters.tsis pure helpers, not a store. Match your neighbours. - VALIDATED: queue persistence moved localStorage → IndexedDB because 600+-item queues exceeded the 5 MB localStorage cap (comment at audioGenQueueStore.ts:66-68).
- VALIDATED:
GlobalParamsContext.tsxis a migration shim by its own header — extend the store, not the context. - UNVERIFIED: exact behaviour of
ui/add_strings.mjs/ui/generate_translations.mjs(files exist; purpose inferred from names — read before running). - NOTE:
ui/README.mdis the untouched Vite template — ignore it as documentation.
Deeper reading
- reference.md (same folder) — full Stem Studio end-to-end dissection, store code templates, DISCO hue table, localStorage key namespaces.
CLAUDE.md(repo root) — cross-tier build/git rules.FEATURES.md— feature catalogue.docs/PLUGINS.md— if your UI feature exposes a new solver/scheduler/guidance knob, that's a Lua plugin, not C++ (plugins declare their own UI params).engine/docs/ARCHITECTURE.md— engine request JSON, generation modes.docs/plans/— internal design docs. Gitignored, local-only — may be absent on a fresh clone.
Signals
- GitHub stars
- 137
- Forks
- 20
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
ui-feature-dev- Source
- github.com/scragnog/hot-step-cpp