Instruction file imported from gramaziokohler/compas_soundscape (
.cursor/rules/frontend-ux.mdc). Copyright stays with the author.
Frontend UX Rules
Simple vs Expert Mode
uiStore.uiMode ('simple' | 'expert', persisted, default simple). Switch only through
sceneWorkflowStore.switchUIMode(mode, focusUsageIndex?) — it points the expert sidebar at the
running / selected scene before Sidebar mounts. Toggle: Settings › Display › Interface
(AdvancedSettingsSection).
| Area | Expert | Simple |
|---|---|---|
| Left | Sidebar (wizard) |
components/simple/SimpleSoundscapes — one bubble per scene (usage card) |
| Right | RightSidebar chrome |
same RightSidebar, AcousticsSection/ListenersSection with presentation="bubbles" |
Rules:
- Never remount
AcousticsSectionon a mode switch — it owns simulation poll intervals and the IR →audioRenderingModeeffect.RightSidebarkeeps an identical element tree in both modes; only conditional slots ({!bubbles && …}) and styles change. Keep it that way. - In Simple mode the left
Sidebaris unmounted, soSimpleSoundscapesownsactiveSoundParentIndex/isInSoundsStep(repairs a stale selection). "No scene powered" =activeSoundParentIndex: nullandisInSoundsStep: false(shows no sounds) — a null parent inside the Sounds step would show every scene's sounds instead. - The scene pipeline lives in
sceneWorkflowStoreand only sequences existing store actions (handleAnalyze,handleScenarioAnalyze, page'shandleSendAnalysisToGeneration,handleGenerateFiltered). Add pipeline behaviour to those actions, not to the workflow store. Steps are idempotent (skip when the card already has the result) — that is what makes Resume work. - Scenes run concurrently and independently — no frontend queue, no per-scene cap (the backend
LLM pool / worker queues throttle). Stop and remove act on one scene only; never gate them on
"another scene is running". Per-scene UI reads per-card state (
useSceneProgress,useCardAnalysisStatus), never the globalanalysisStatus/soundGenProgressmirrors. OnlyselectFocusRunmay drive the expert sidebar, the powered scene and the 3D highlights. - Expert Sidebar follows a running pipeline via
uiStore.sidebarNavCommand(seq-based, not persisted). - Scene panel body = the real
SoundGenerationSection; build its props withbuildSoundGenerationSectionProps(components/layout/sidebar/soundSectionProps.ts), shared withSidebar. - Circle clicks only expand / reduce the circle's card — never a side effect. Unfinished
scenes open their workflow view. Powering is a separate control: ready circles pass
Bubble.onTogglePower, which turns the hover label into a flyout with a power button (joined to the circle by a CSS::beforehover bridge — no timers). The same power button sits in the expanded card header (Card.onTogglePower/BubblePanel.onTogglePower); "on" =.power-btn--on(solid card color, inverted icon; on-blue disc on generated cards). Scenes: one powered scene at most, none allowed. Simulations: auralization on / off. Listeners: single = FPS view, grid = show its points; one powered listener at a time. Floating cards (Simple mode,Card.onReduceset) keep−always visible and move remove to a bottom-right trash (DeleteConfirmButton, confirmation stacked above it);BubblePaneldoes the same. A powered single listener with its card closed shows a redBubbleExitButton("Leave FPS view"), and the user's first FPS entry ever showsFpsHelpPopup(once per user viauseOneTimeHint). - Circle states (
components/ui/Bubble.tsx, all circles): pending = frosted + muted icon;ready= primary border + icon;ready+selected= solid primary, inverted icon. Listener circles usetone="listener"(the listener card's warning color, headphones icon). - Headings ("Soundscapes", "Acoustics", "Listeners") use a difference
blend. They must be standalone fixed elements (
BubbleHeading) — never inside a stacking context (a fixed/z-indexed parent blends them against nothing). That is whyRightSidebar's<aside>isdisplay: contentsin bubble mode. - Panels reduce on a plain click on the 3D scene (
useDismissOnSceneClick) — not on drags, UI clicks, active viewer modes, or clicks that selected something. - Columns never overflow — every bubble column goes through
BubbleScrollColumnwith a budget fromuseBubbleColumnSpace(lifts with the docked DAW; Acoustics + Listeners share the right edge). Overflow renders a window of bubbles between "N more" arrow pills (wheel / drag / click); the selected / active / open bubble is thefocusIndexand stays in view. It slices instead of clipping (overflow/clip-pathwould cut hover labels and the bubbles' backdrop blur). - Mode switch → Expert expands the left sidebar on the selected scene's Sounds step; → Simple
reopens the panel of the scene the expert sidebar showed (
pendingSimpleFocus).
Scene Bottom Bar
components/scene/SceneBottomBar.tsx — full-width frosted strip (--scene-bottom-bar-height,
SCENE_BOTTOM_BAR) holding every scene-level control: left = "Sound is blue" home (always shown),
a "Simple" button (Expert mode only), undo/redo, save; center = playback (the only transport — the DAW's top status strip has none; the
DAW panel opens above the bar); right = volume, reset
view, refresh, Object Explorer / load model, then shortcuts, notifications, settings. Sidebars stop at
its top edge. Add new scene-level controls here as BarButtons — do not reintroduce floating corner
buttons.
Home Stage (no model)
Every fresh load of / starts empty (no seeded cards or sounds) on a small fixed grid
(HOME_STAGE: 6 × 6 m, 0.5 m cells, nothing drawn on it). HomeUploadPrompt ("Upload a 3D model
or start below" — "Upload" opens the load-model dialog via uiStore.showLoadModelPanel) sits above
the centred new-scene panel that SimpleSoundscapes opens once per page load; in Expert mode the
prompt shows alone above the bottom bar. Opening / uploading a model while Home holds work shows
ImportSandboxModal: import into the model, save as a Homepage project then open, discard, or cancel.
Sidebar 3-Step Wizard
Flow: Context (step 0) → Usage (step 1) → Sounds (step 2)
Implemented in frontend/src/components/layout/Sidebar.tsx.
Step Definitions
| Step | Section component | Valid card types | Prerequisite |
|---|---|---|---|
| 0 — Context | ContextSection |
model-analysis, audio, freeform |
— |
| 1 — Usage | UsageSection |
scenario, text, freeform |
parentContextOriginalIndex set |
| 2 — Sounds | SoundGenerationSection |
sound configs | parentUsageOriginalIndex set |
Key State (Sidebar.tsx)
| Variable | Type | Purpose |
|---|---|---|
currentStep |
0 | 1 | 2 |
Active wizard step |
activeContextOriginalIndex |
number | null |
Which context card is active / expanded |
activeUsageOriginalIndex |
number | null |
Which usage card is active |
bypassedUsage |
boolean |
User skipped the usage step entirely |
UIStore Fields
| Field | Purpose |
|---|---|
activeSoundParentIndex |
null = skip flow, show all sounds regardless of step |
isInSoundsStep |
Gates unifiedSoundscapeData — sounds must not render before this is true |
Card Linkage System
Cards use original index fields that never change even when cards are reordered:
parentContextOriginalIndex— links a usage card to its parent context cardparentUsageOriginalIndex— links a sound to its parent usage card
Usage cards are filtered in step 1 by activeContextOriginalIndex.
Sound cards are filtered in step 2 by activeUsageOriginalIndex.
Negative Index Namespace
Audio-type context cards (type audio) use:
parentUsageOriginalIndex = -(N + 1)
where N is the context card's original index. This distinguishes audio context linkage from
normal usage linkage (which uses non-negative integers).
Freeform (Bypass) Cards
| Trigger | Card type | Effect |
|---|---|---|
| "Skip context" | freeform context |
No parentContextOriginalIndex; auto-advances to step 1 on creation |
| "Skip usage" | freeform usage |
Has parentContextOriginalIndex; auto-advances to step 2 on creation |
Default names: "Untitled context" / "Untitled usage" — editable inline via display_name.
Navigation Guard Pattern
prevContextLength and prevUsageLength are useRef values initialised to the current card
count at component mount:
const prevContextLength = useRef(contextCards.length) // init to current count
This prevents re-triggering auto-advance when a section remounts during back-navigation.
Do not convert these refs to state. They must be non-reactive to work correctly — reactive state would re-run the effect and falsely trigger navigation.
FAB Navigation Rules
- Context FAB → advances to Usage (or directly to Sounds for
audio/freeformcontext cards) - Usage FAB → advances to Sounds
- FABs are hidden when the target step has no valid destination card
Breadcrumb Tooltips
| Breadcrumb | Shows |
|---|---|
| Context | All context card display_name + (pending) if not yet analysed |
| Usage | All usage card display_name + (pending) if no result/foley |
| Sounds | Grouped by usage parent; each sound name + (pending) if not in generatedSounds |
Card Expand / Reduce
Expanded card view shows linked child cards and sounds as a placeholder list. Expand state is local component state — do not persist it in any Zustand store.
Before / After Generation States
Components should render differently based on whether a sound has been generated:
| State | Condition | UI |
|---|---|---|
| Before generation | Sound ID not in generatedSounds (soundscapeStore) |
Generation button + pending badge |
| After generation | Sound ID in generatedSounds |
WaveSurfer waveform player + re-generate option |
Generated Cards have no header Reset button. Their collapsible "Change … settings" section
(SettingsSummary) shows the pending-state editors via settingsContent, in a bordered box styled
like a pending card. Editing one adds Regenerate at the bottom of that box (scrolled into view;
clicking it folds the section), a warning-coloured reload icon right of the title that reverts
(settingsDirty / onRevertSettings / onRegenerate), and a warning-coloured "*" on the edited
labels — new editors should pass modified={useIsFieldModified()(key)} to RangeSlider /
ToggleField or render <ModifiedMark /> (keys from getModifiedGenerationFields).
Types where regenerating makes no sense (upload, sample-audio, import-irs, audio / model analysis)
keep the read-only getSettingsRows recap. See ARCHITECTURE.md § Generated-Card Settings & Regenerate.
isInSoundsStep (uiStore) must be true before rendering any generated sound content.
page.tsx — Intentionally Large
frontend/src/app/page.tsx imports 17+ stores and wires all global effects and callbacks.
This is the orchestration layer by design — it owns the single-mount lifecycle contract for
the entire app.
Do not decompose store subscriptions into child components to "clean it up". Distributing them would scatter ownership, break mount ordering, and make global side effects unpredictable.
