diff --git a/docs/superpowers/plans/2026-07-17-ui-panel-redesign.md b/docs/superpowers/plans/2026-07-17-ui-panel-redesign.md new file mode 100644 index 0000000..969e4e8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-ui-panel-redesign.md @@ -0,0 +1,1379 @@ +# Control Panel Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the single floating `egui::Window` of 11 `CollapsingHeader` sections with a docked right `SidePanel`, cohesive deep-space cyan/orange theme, layered grouping (4 always-open cards + 6 collapsing sections), a Cinematic/Performance/Web/Custom preset bar, and aligned two-column `Grid` slider rows — without touching physics, shaders, params struct, or camera control. + +**Architecture:** Split the current 186-line `src/ui.rs` into a `src/ui/` module directory (`mod.rs` orchestrator + `style.rs` theming + `preset.rs` preset logic + `sections.rs` the 10 per-group render functions). The egui `Context` is themed once at startup via a `Local`-guarded `setup_egui_style` system in `EguiPrimaryContextPass`. Preset state (`current_preset`, `just_applied_preset`) is `Local` UI-layer state, not added to `BlackHoleParams`. + +**Tech Stack:** Bevy 0.19, bevy_egui 0.41 (egui 0.34), Rust edition 2024. + +**Spec:** `docs/superpowers/specs/2026-07-17-ui-panel-redesign-design.md` + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/ui.rs` → `src/ui/mod.rs` | Module root. `ui_system` orchestrator (chassis: SidePanel + TopBottomPanel + ScrollArea), `group()` / `collapsing()` helpers, `preset_bar()` | +| `src/ui/style.rs` (new) | All egui styling: `setup()`, palette consts, `sci_fi_visuals()`, `text_styles()` | +| `src/ui/preset.rs` (new) | `Preset` enum, `apply()`, `canonical_hash()`, `params_hash()` | +| `src/ui/sections.rs` (new) | 10 `section_*` render functions, one per group | +| `src/main.rs` | `mod ui;` unchanged (module dir works with same decl) | +| `src/render/plugin.rs` | Register `setup_egui_style` in `EguiPrimaryContextPass` | + +**Testable surface.** This project's only test harness is `src/physics.rs` + `tests/physics_test.rs` (the CPU↔shader mirror). The UI layer has no existing test infrastructure, and egui widget rendering is not unit-testable in isolation (it needs a live `egui::Context`). The two pure-logic functions that *are* testable — `preset::canonical_hash` / `params_hash` (§3 of the spec, "Custom detection") — get unit tests via a new `tests/preset_test.rs` through the `singularity_rs::ui::preset` lib export. Everything else is verified by the visual/compile acceptance checklist in Task 12. This matches the codebase convention: "The GPU shader is not unit-tested. The whole point of `physics.rs` is to be a CPU mirror that _is_ testable." — the analog here is that `preset.rs`'s hash logic is the testable mirror of the preset-bar UI behavior. + +**Lib export requirement.** `src/lib.rs` currently exports only `pub mod physics;`. To make `preset` testable we add `pub mod ui;` and `pub mod params;` to `lib.rs`. (`params.rs` must be exported because `preset::apply` / `params_hash` take `&mut BlackHoleParams` / `&BlackHoleParams`; the test constructs one.) + +--- + +### Task 1: Convert `src/ui.rs` to `src/ui/mod.rs` module directory + +This task only moves the file into a directory so subsequent tasks can add sibling modules. No behavior change. The build must still pass at the end. + +**Files:** +- Move: `src/ui.rs` → `src/ui/mod.rs` +- No content change yet + +- [ ] **Step 1: Move the file into a directory** + +```bash +mkdir -p src/ui +git mv src/ui.rs src/ui/mod.rs +``` + +- [ ] **Step 2: Verify the module still resolves** + +`src/main.rs:9` is `mod ui;` — this resolves both `src/ui.rs` and `src/ui/mod.rs`, so no edit to `main.rs` is needed. + +Run: `cargo build` +Expected: compiles with no errors (no behavior change). + +- [ ] **Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(ui): move ui.rs into ui/ module directory + +No content change; sets up for sibling style/preset/sections modules." +``` + +--- + +### Task 2: Add `style.rs` — palette constants and `sci_fi_visuals()` + +The palette and `Visuals` builder are pure data; landing them first lets later tasks reference the consts by name. + +**Files:** +- Create: `src/ui/style.rs` +- Modify: `src/ui/mod.rs` (add `mod style;`) + +- [ ] **Step 1: Create `src/ui/style.rs` with palette constants and `sci_fi_visuals()`** + +```rust +//! egui styling for the control panel. Deep-space cyan/orange theme, +//! hand-applied over `Visuals::dark()`. See design spec §1. + +use egui::{Color32, Stroke, TextStyle, Visuals}; + +// --- Palette (single source of truth; referenced by every section helper) --- +pub const ACCENT_CYAN: Color32 = Color32::from_rgb(90, 200, 255); // #5AC8FF +pub const ACCENT_ORANGE: Color32 = Color32::from_rgb(255, 140, 66); // #FF8C42 +pub const PANEL_FILL: Color32 = Color32::from_rgb(14, 16, 20); // #0E1014 +pub const EXTREME_BG: Color32 = Color32::from_rgb(8, 9, 12); // #08090C +pub const MUTED_TEXT: Color32 = Color32::from_rgb(140, 140, 140); // read-only / disabled labels +pub const DIM_TEXT: Color32 = Color32::from_rgb(110, 110, 110); // section-disabled headers + +/// Text sizes applied over egui's default `Proportional` family. No custom +/// font binary is embedded (spec non-goal). +pub fn text_styles() -> Vec<(TextStyle, f32)> { + vec![ + (TextStyle::Body, 14.0), + (TextStyle::Monospace, 12.0), + (TextStyle::Heading, 16.0), + (TextStyle::Small, 11.0), + ] +} + +/// The themed `Visuals`. Built fresh from `dark()` each call so it is +/// independent of whatever egui's defaults evolve into. +pub fn sci_fi_visuals() -> Visuals { + let mut v = Visuals::dark(); + v.panel_fill = PANEL_FILL; + v.extreme_bg_color = EXTREME_BG; + v.hyperlink_color = ACCENT_CYAN; // also used as the section-heading color by convention + v.selection.bg_fill = ACCENT_CYAN; + v.selection.stroke = Stroke::new(1.0, ACCENT_ORANGE); + + v.widgets.inactive.weak_bg_fill = Color32::from_rgb(30, 36, 48); + v.widgets.inactive.bg_stroke = Stroke::new(0.5, Color32::from_rgb(60, 70, 90)); + v.widgets.hovered.weak_bg_fill = Color32::from_rgb(40, 50, 70); + v.widgets.hovered.fg_stroke = Stroke::new(1.0, Color32::from_rgb(200, 220, 255)); + v.widgets.active.fg_stroke = Stroke::new(1.0, ACCENT_CYAN); + v.widgets.active.weak_bg_fill = Color32::from_rgb(50, 70, 100); + v.widgets.noninteractive.bg_stroke = Stroke::new(0.5, Color32::from_rgb(40, 46, 60)); + + v.window_shadow = epaint::Shadow { + offset: [0, 4], + blur: 16.0, + spread: 0.0, + color: Color32::from_black_alpha(120), + }; + v.animation_time = 0.12; + v +} + +/// Apply fonts + spacing + visuals to a context. Called exactly once from +/// `setup_egui_style` (plugin.rs). +pub fn setup(ctx: &egui::Context) { + let mut style = (*ctx.style()).clone(); + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + style.spacing.slider_width = 150.0; + style.spacing.indent = 18.0; + style.spacing.button_padding = egui::vec2(10.0, 4.0); + style.spacing.indent_ends_with_horizontal_line = false; + for (ts, size) in text_styles() { + style.text_styles_mut().insert(ts, egui::FontId::proportional(size)); + } + ctx.set_style(style); + ctx.set_visuals(sci_fi_visuals()); +} +``` + +- [ ] **Step 2: Register the module in `src/ui/mod.rs`** + +Add at the very top of `src/ui/mod.rs` (above any existing `use`): + +```rust +mod style; +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build` +Expected: compiles. `style` module is declared but not yet called from `ui_system`, so it may emit `dead_code` warnings for unused items — that is expected and acceptable for now (Task 4 wires them up). If the build hard-errors on `epaint::Shadow` field names, check `cargo doc --document-private-items -p epaint` for the current field set; egui 0.34's `Shadow` has `offset: [i32;2]`, `blur`, `spread`, `color`. + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/style.rs src/ui/mod.rs +git commit -m "feat(ui): add style module — deep-space cyan/orange theme + +Palette constants + sci_fi_visuals() + setup(). Hand-applied over +Visuals::dark(); no custom font, no new deps. Not yet wired into +ui_system (Task 4 does that via setup_egui_style)." +``` + +--- + +### Task 3: Add `preset.rs` with hash-based Custom detection + its test + +The preset logic is the one piece of this UI layer that IS unit-testable (pure functions over `BlackHoleParams`). We build it TDD-style so the hash field set is pinned by a test, not guessed. + +**Files:** +- Create: `src/ui/preset.rs` +- Create: `tests/preset_test.rs` +- Modify: `src/lib.rs` (export `ui` + `params` modules for the test) +- Modify: `src/ui/mod.rs` (add `mod preset;`) + +- [ ] **Step 1: Export `ui` and `params` from `src/lib.rs`** + +Current `src/lib.rs` is a single line `pub mod physics;`. Replace its contents with: + +```rust +pub mod params; +pub mod physics; +pub mod ui; +``` + +- [ ] **Step 2: Write the failing test first** + +Create `tests/preset_test.rs`: + +```rust +use singularity_rs::params::BlackHoleParams; +use singularity_rs::ui::preset::{Preset, apply, canonical_hash, params_hash}; + +#[test] +fn canonical_hash_matches_just_applied_params() { + // After applying a preset, params_hash of the result must equal that + // preset's canonical_hash — otherwise the Custom-detection logic would + // immediately flip a freshly-applied preset back to Custom. + let mut p = BlackHoleParams::default(); + for preset in [Preset::Cinematic, Preset::Performance, Preset::Web] { + apply(preset, &mut p); + assert_eq!( + canonical_hash(preset), + params_hash(&p), + "preset {:?}: apply() did not reproduce canonical_hash", + preset + ); + } +} + +#[test] +fn non_preset_field_change_flips_to_custom() { + // Editing a field that NO preset touches (camera distance lives on + // OrbitCamera, not BlackHoleParams — use disk_tilt, also not preset-touched) + // must NOT change params_hash. This guards the "hash only preset fields" + // invariant: non-preset edits must not spuriously flip to Custom. + let mut p = BlackHoleParams::default(); + let h0 = params_hash(&p); + p.disk_tilt = 1.0; // not in any preset bundle + assert_eq!(h0, params_hash(&p), "non-preset field leaked into hash"); +} + +#[test] +fn preset_field_change_differs_from_canonical() { + // Editing a preset-touched field after applying must change the hash + // away from the preset's canonical_hash (i.e. flip to Custom). + let mut p = BlackHoleParams::default(); + apply(Preset::Cinematic, &mut p); + let h_canonical = canonical_hash(Preset::Cinematic); + p.steps = 299; // off by one from the Cinematic bundle + assert_ne!(h_canonical, params_hash(&p)); +} +``` + +- [ ] **Step 3: Run the test to verify it fails (module not found)** + +Run: `cargo test --test preset_test` +Expected: FAIL — `unresolved module` / `cannot find` errors for `singularity_rs::ui::preset`. + +- [ ] **Step 4: Create `src/ui/preset.rs`** + +```rust +//! Preset bundles + Custom-edit detection (design spec §3). +//! +//! `params_hash` hashes ONLY the fields that presets touch — otherwise a +//! non-preset edit (e.g. disk_tilt) would spuriously flip the bar to Custom. +//! The field set below is the single source of truth; if you add a field to +//! a preset bundle, add it to `hashed_fields` too or the test +//! `non_preset_field_change_flips_to_custom` will not catch the leak. + +use crate::params::{AaQuality, BlackHoleParams, BloomQuality, DiskQuality}; + +use std::hash::{Hash, Hasher}; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Preset { + Cinematic, + Performance, + Web, + /// Read-only marker set when any preset-touched field is hand-edited + /// away from a preset bundle. `apply(Custom, _)` is a no-op. + Custom, +} + +/// The exact params each preset writes. `Custom` writes nothing. +fn bundle(p: Preset) -> Option { + // Defaults chosen to mirror cfg!(wasm32) dual-defaults already in the codebase: + // Cinematic = desktop default, Web = wasm default, Performance = a low tier. + Some(match p { + Preset::Cinematic => HashedParams { + steps: 300, render_scale: 0.75, + bloom_quality: BloomQuality::High, disk_quality: DiskQuality::High, aa_quality: AaQuality::High, + }, + Preset::Performance => HashedParams { + steps: 150, render_scale: 0.5, + bloom_quality: BloomQuality::Low, disk_quality: DiskQuality::Low, aa_quality: AaQuality::Off, + }, + Preset::Web => HashedParams { + steps: 200, render_scale: 0.5, + bloom_quality: BloomQuality::Low, disk_quality: DiskQuality::Low, aa_quality: AaQuality::Off, + }, + Preset::Custom => return None, + }) +} + +/// Apply a preset's bundle to params. `Custom` is a no-op. +pub fn apply(p: Preset, params: &mut BlackHoleParams) { + if let Some(b) = bundle(p) { + params.steps = b.steps; + params.render_scale = b.render_scale; + params.bloom_quality = b.bloom_quality; + params.disk_quality = b.disk_quality; + params.aa_quality = b.aa_quality; + } +} + +/// Stable hash of a preset's canonical bundle. `Custom` returns 0 (it never +/// matches any real params state, by construction — see `hashed_fields`). +pub fn canonical_hash(p: Preset) -> u64 { + match bundle(p) { + Some(b) => b.hash(), + None => 0, + } +} + +/// Hash of the preset-touched fields of a live params. Used by `ui_system` +/// to detect hand-edits and flip the bar to Custom. +pub fn params_hash(params: &BlackHoleParams) -> u64 { + let h = HashedParams { + steps: params.steps, + render_scale: params.render_scale, + bloom_quality: params.bloom_quality, + disk_quality: params.disk_quality, + aa_quality: params.aa_quality, + }; + h.hash() +} + +// --- internals --- + +#[derive(Clone, Copy)] +struct HashedParams { + steps: u32, + render_scale: f32, + bloom_quality: BloomQuality, + disk_quality: DiskQuality, + aa_quality: AaQuality, +} + +impl HashedParams { + fn hash(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.steps.hash(&mut hasher); + self.render_scale.to_bits().hash(&mut hasher); // f32: hash bit pattern, not value + self.bloom_quality.as_u32_().hash(&mut hasher); + self.disk_quality.as_u32().hash(&mut hasher); + self.aa_quality.samples().hash(&mut hasher); + hasher.finish() + } +} + +// Local trait adapters: BloomQuality has levels(), DiskQuality has as_u32(), +// AaQuality has samples(). Unify under one name for hashing. +trait AsU32ForHash { fn as_u32_(self) -> u32; } +impl AsU32ForHash for BloomQuality { fn as_u32_(self) -> u32 { self.levels() } } +``` + +Note on the `AsU32ForHash` trait: it exists only because `BloomQuality` exposes `levels()` while `DiskQuality` exposes `as_u32()`. Rather than touch `params.rs` (a non-goal), we adapt locally. `AaQuality` uses its existing `samples()` directly. + +- [ ] **Step 5: Register the module in `src/ui/mod.rs`** + +Add below `mod style;`: + +```rust +mod preset; +``` + +- [ ] **Step 6: Run the test to verify it passes** + +Run: `cargo test --test preset_test` +Expected: PASS — all three tests green. + +If it fails on `canonical_hash_matches_just_applied_params`, the `apply()` field assignments don't line up with `HashedParams` — re-check that both reference the same five fields. + +- [ ] **Step 7: Run the full test suite to confirm no regressions** + +Run: `cargo test` +Expected: all tests pass (physics mirror + new preset tests). + +- [ ] **Step 8: Commit** + +```bash +git add src/ui/preset.rs src/ui/mod.rs src/lib.rs tests/preset_test.rs +git commit -m "feat(ui): add preset module with hash-based Custom detection + +Preset::Cinematic/Performance/Web write 5 params (steps, render_scale, +bloom/disk/aa quality); Custom is a no-op marker. params_hash hashes +ONLY preset-touched fields so non-preset edits (disk_tilt, etc.) don't +spuriously flip the bar to Custom. Test-pinned via tests/preset_test.rs. + +Exports ui + params from lib.rs so the test can construct params." +``` + +--- + +### Task 4: Wire `setup_egui_style` into the plugin + +Register the one-shot styling system in `EguiPrimaryContextPass`. `Local` guard makes it retry until the egui context exists, then run once. + +**Files:** +- Modify: `src/render/plugin.rs` (add system + registration) +- Modify: `src/ui/mod.rs` (add `pub fn setup_egui_style`) + +- [ ] **Step 1: Add `setup_egui_style` to `src/ui/mod.rs`** + +Add this function to `src/ui/mod.rs` (anywhere at module scope, after the `mod` declarations): + +```rust +/// One-shot egui styling. Registered in `EguiPrimaryContextPass` with a +/// `Local` guard so it retries until `ctx_mut()` first succeeds, then +/// runs exactly once. Per-frame set_style/set_visuals would dirty layout +/// caches every frame; this avoids that. +pub fn setup_egui_style( + mut contexts: bevy_egui::EguiContexts, + mut done: Local, +) { + if *done { + return; + } + if let Ok(ctx) = contexts.ctx_mut() { + crate::ui::style::setup(&ctx); + *done = true; + } +} +``` + +- [ ] **Step 2: Register it in `src/render/plugin.rs`** + +Find line 134 (current): +```rust + .add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system); +``` + +Replace with: +```rust + .add_systems( + bevy_egui::EguiPrimaryContextPass, + (crate::ui::setup_egui_style, crate::ui::ui_system).chain(), + ); +``` + +`.chain()` ensures the style is applied before the UI renders on the very first successful frame. The comment on lines 132-133 ("bevy_egui 0.41 requires UI systems to run inside the egui context pass") already documents why these live here — add a sibling note for the new system. + +Update the comment block above to read: +```rust + // bevy_egui 0.41 requires UI systems to run inside the egui context + // pass (fonts/ctx are initialized there); placing them in Update panics. + // setup_egui_style is a one-shot (Local guard): retries until the + // context exists, applies the theme once, never runs again. +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build` +Expected: compiles. + +- [ ] **Step 4: Verify visually that styling now applies** + +Run: `cargo run --release` +Expected: the Controls window is now dark blue-black with cyan accents (panel bg `#0E1014`, sliders cyan-on-hover). The layout is still the old 11-CollapsingHeader structure — that's fine; this task only verifies the theme is wired. Quit after confirming the colors changed. + +If the window is still default-styled, `setup_egui_style` isn't running — check that `*done` starts false and `ctx_mut()` returns `Ok`. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/mod.rs src/render/plugin.rs +git commit -m "feat(ui): wire setup_egui_style one-shot into EguiPrimaryContextPass + +Local guard retries until ctx_mut() first succeeds, applies the +sci-fi theme once, then never runs again. .chain()'d before ui_system so +the very first successful frame is already themed. Per-frame set_style +would dirty layout caches; this avoids that." +``` + +--- + +### Task 5: Add `group()` and `collapsing()` section helpers + +The two chassis helpers that eliminate the 11× `CollapsingHeader` boilerplate. Once these exist, Task 8's section rewrite uses them. + +**Files:** +- Modify: `src/ui/mod.rs` (add the two helpers) + +- [ ] **Step 1: Add the helpers to `src/ui/mod.rs`** + +Add at module scope (after `setup_egui_style`): + +```rust +use crate::ui::style::ACCENT_CYAN; + +/// An always-open framed card. Title in cyan RichText::strong().small(). +/// Used for the 4 most-tuned groups (Camera / Black Hole / Disk / Quality). +fn group(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui)) { + egui::Frame::group(ui.style()) + .inner_margin(8.0) + .corner_radius(6.0) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().window_stroke.color, + )) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.label( + egui::RichText::new(title) + .strong() + .small() + .color(ACCENT_CYAN), + ); + ui.add_space(2.0); + body(ui); + }); +} + +/// A collapsible section (default_open controls initial state). +/// Used for the 6 secondary groups. The body closure is only invoked when open. +fn collapsing( + ui: &mut egui::Ui, + id: &str, + title: &str, + default_open: bool, + body: impl FnOnce(&mut egui::Ui), +) { + egui::CollapsingHeader::new(title) + .default_open(default_open) + .id_salt(id) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + body(ui); + }); +} +``` + +Notes: +- `.corner_radius(6.0)` — egui 0.34 renamed `Rounding` to `CornerRadius`; `Frame::corner_radius(f32)` is the current builder method. +- `.id_salt(id)` makes the open/closed state stable across frames independent of header text. +- `collapsing` here is the simple form (no header toggle). Task 9 adds a second variant `collapsing_with_toggle` for the four groups that have an enable checkbox in the header. + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build` +Expected: compiles. Helpers are unused so far → `dead_code` warnings are fine (cleared in Task 8). + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/mod.rs +git commit -m "feat(ui): add group()/collapsing() section helpers + +group() = always-open framed card with cyan heading. +collapsing() = CollapsingHeader wrapper with stable id_salt. +These eliminate the 11x boilerplate in the upcoming section rewrite." +``` + +--- + +### Task 6: Add `collapsing_with_toggle()` for header-row enable checkboxes + +Doppler / Jets / Grid / Planets put their enable checkbox in the collapsing header, not as a body row. This needs `CollapsingState::show_header`, which the plain `collapsing()` from Task 5 doesn't expose. + +**Files:** +- Modify: `src/ui/mod.rs` + +- [ ] **Step 1: Add the variant helper** + +Add below `collapsing()`: + +```rust +/// Collapsible section whose header hosts an enable toggle on the right. +/// Used by Doppler / Jets / Grid / Planets (design §4.1). The body closure +/// receives the current `enabled` state so it can `add_enabled` its rows. +fn collapsing_with_toggle( + ui: &mut egui::Ui, + id: &str, + title: &str, + default_open: bool, + enabled: &mut bool, + body: impl FnOnce(&mut egui::Ui, bool), +) { + let id = ui.make_persistent_id(id); + let mut state = + egui::CollapsingState::load_with_default_open(ui.ctx(), id, default_open); + state.show_header(ui, |ui| { + ui.checkbox(enabled, title); + }) + .body(|ui| { + ui.set_width(ui.available_width()); + body(ui, *enabled); + }); + state.store(ui.ctx()); +} +``` + +Design note: the checkbox *is* the header label (no separate title text + checkbox). `ui.checkbox(&mut enabled, title)` reads as "Doppler ☑" in one row — cleaner than title+separate-checkbox. `toggle_value` would render as a button; `checkbox` renders as the conventional box-with-check, which the spec §4.4 chose. + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build` +Expected: compiles. + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/mod.rs +git commit -m "feat(ui): add collapsing_with_toggle() for header-row enables + +Doppler/Jets/Grid/Planets get their enable checkbox in the collapsing +header (not a body row). Uses CollapsingState::show_header. Body closure +receives the enabled flag so it can add_enabled() its rows." +``` + +--- + +### Task 7: Create `sections.rs` skeleton with the 4 always-open card functions + +Start the section module with the always-open cards (Camera / Black Hole / Disk / Quality). Each is a `pub fn section_*` that takes `&mut egui::Ui` and the params/camera refs it needs. Land Camera + Black Hole + Quality here; Disk follows in Task 8 because it has the color-mode/temp interaction. + +**Files:** +- Create: `src/ui/sections.rs` +- Modify: `src/ui/mod.rs` (add `mod sections;`) + +- [ ] **Step 1: Create `src/ui/sections.rs` with Camera, Black Hole, Quality** + +```rust +//! Per-group section render functions. Each takes the `&mut egui::Ui` it +//! draws into plus the params/camera refs it needs. The `ui_system` +//! orchestrator (mod.rs) calls them inside `group()` / `collapsing()`. + +use crate::camera::OrbitCamera; +use crate::params::{ + AaQuality, BlackHoleParams, BloomQuality, DiskColorMode, +}; +use crate::ui::style::{ACCENT_ORANGE, MUTED_TEXT}; + +use std::f32::consts::PI; + +/// Shared two-column row helper: label + sized slider. +fn row( + ui: &mut egui::Ui, + label: &str, + add_widget: impl FnOnce(&mut egui::Ui) -> egui::Response, +) { + ui.label(label); + add_widget(ui); + ui.end_row(); +} + +// ============================ Always-open cards ============================ + +pub fn section_camera(ui: &mut egui::Ui, cam: &mut OrbitCamera) { + egui::Grid::new("camera_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + row(ui, "Distance", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut cam.distance, 3.0..=200.0) + .suffix(" r_g").fixed_decimals(1))); + row(ui, "Yaw", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut cam.yaw, -PI..=PI) + .suffix(" rad").fixed_decimals(2))); + row(ui, "Pitch", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut cam.pitch, (-PI + 0.05)..=(PI - 0.05)) + .suffix(" rad").fixed_decimals(2))); + row(ui, "FOV", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut cam.fov, 0.3..=2.0).fixed_decimals(2))); + }); +} + +pub fn section_black_hole(ui: &mut egui::Ui, params: &mut BlackHoleParams) { + egui::Grid::new("bh_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + row(ui, "Spin (χ)", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.spin, 0.0..=1.0).fixed_decimals(2))); + // Read-only derived values: right column, muted color. + ui.label(egui::RichText::new("ISCO (disk inner)").color(MUTED_TEXT)); + ui.label(egui::RichText::new(format!("{:.3}", crate::physics::kerr_isco(params.spin))) + .color(MUTED_TEXT)); + ui.end_row(); + ui.label(egui::RichText::new("Horizon r+").color(MUTED_TEXT)); + ui.label(egui::RichText::new(format!("{:.3}", crate::physics::kerr_horizon(params.spin))) + .color(MUTED_TEXT)); + ui.end_row(); + }); +} + +pub fn section_quality(ui: &mut egui::Ui, params: &mut BlackHoleParams) { + // Bloom quality combobox + threshold/strength (hidden when Off — §4.3). + { + let mut q = params.bloom_quality; + egui::Grid::new("bloom_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + ui.label("Bloom"); + egui::ComboBox::from_id_salt("bloom_combo") + .selected_text(format!("{:?}", q)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut q, BloomQuality::Off, "Off"); + ui.selectable_value(&mut q, BloomQuality::Low, "Low"); + ui.selectable_value(&mut q, BloomQuality::Medium, "Medium"); + ui.selectable_value(&mut q, BloomQuality::High, "High"); + }); + ui.end_row(); + }); + params.bloom_quality = q; + } + if params.bloom_quality != BloomQuality::Off { + egui::Grid::new("bloom_detail_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + row(ui, "Threshold", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.bloom_threshold, 0.0..=3.0).fixed_decimals(2))); + row(ui, "Strength", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.bloom_strength, 0.0..=2.0).fixed_decimals(2))); + }); + } + egui::Grid::new("renderer_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + // steps + render_scale migrated here from the deleted Renderer section. + row(ui, "Steps", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.steps, 50..=600) + .logarithmic(true).fixed_decimals(0))); + row(ui, "Resolution", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.render_scale, 0.25..=1.0) + .logarithmic(true).fixed_decimals(2))); + row(ui, "Exposure", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.exposure, 0.5..=3.0).fixed_decimals(2))); + row(ui, "Star AA", |ui| ui.checkbox(&mut params.star_aa, "")); + // Ring anti-alias (supersampling for lensed-image rings). + let mut a = params.aa_quality; + ui.label("Ring AA"); + egui::ComboBox::from_id_salt("aa_combo") + .selected_text(format!("{:?} ({}×)", a, a.samples())) + .show_ui(ui, |ui| { + ui.selectable_value(&mut a, AaQuality::Off, "Off (1×)"); + ui.selectable_value(&mut a, AaQuality::Low, "Low (2×)"); + ui.selectable_value(&mut a, AaQuality::High, "High (4×)"); + }); + ui.end_row(); + params.aa_quality = a; + }); + ui.label( + egui::RichText::new("MSAA is decorative on a fullscreen shader (no geometry edges).") + .small().color(MUTED_TEXT), + ); +} +``` + +Note: `_disk_color_mode` import will be used in Task 8; mark `#[allow(unused_imports)]` on the `use crate::params::{...}` line if the compiler warns in this task (it won't — `DiskColorMode` is only imported in Task 8's file edit). To keep the import list stable across tasks, this task imports only what it uses; Task 8 adds `DiskColorMode` to the same `use` line. + +- [ ] **Step 2: Register the module in `src/ui/mod.rs`** + +Add below `mod preset;`: + +```rust +mod sections; +``` + +- [ ] **Step 3: Trim the unused import in `sections.rs` for this task** + +Since Task 7 doesn't yet reference `DiskColorMode` or `ACCENT_ORANGE`, change the import line in `sections.rs` to only what this task uses, to avoid warnings: + +```rust +use crate::params::{ + AaQuality, BlackHoleParams, BloomQuality, +}; +use crate::ui::style::MUTED_TEXT; +``` + +(Task 8 will re-add `DiskColorMode` and `ACCENT_ORANGE` to these lines.) + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo build` +Expected: compiles. `dead_code` warnings expected (sections not called yet). + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/sections.rs src/ui/mod.rs +git commit -m "feat(ui): add sections.rs — Camera/BlackHole/Quality cards + +Two-column Grid rows with unit suffixes + log scales on wide ranges. +Bloom threshold/strength hidden when Bloom=Off (spec §4.3). steps and +render_scale migrated here from the deleted Renderer section, fixing +the duplicate-render_scale bug." +``` + +--- + +### Task 8: Add the Accretion Disk card (color mode + temp interaction) + +The Disk section has the `DiskColorMode` ↔ `disk_temp` enable interaction; landing it separately from Task 7 keeps the diffs reviewable. + +**Files:** +- Modify: `src/ui/sections.rs` + +- [ ] **Step 1: Add `DiskColorMode` + `ACCENT_ORANGE` to the section imports** + +Change the import lines at the top of `src/ui/sections.rs` from: + +```rust +use crate::params::{ + AaQuality, BlackHoleParams, BloomQuality, +}; +use crate::ui::style::MUTED_TEXT; +``` + +to: + +```rust +use crate::params::{ + AaQuality, BlackHoleParams, BloomQuality, DiskColorMode, +}; +use crate::ui::style::{ACCENT_ORANGE, MUTED_TEXT}; +``` + +- [ ] **Step 2: Add `section_disk` at the end of `src/ui/sections.rs`** + +```rust +pub fn section_disk(ui: &mut egui::Ui, params: &mut BlackHoleParams) { + egui::Grid::new("disk_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + row(ui, "Outer radius", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.disk_outer, 6.0..=50.0) + .suffix(" r_g").fixed_decimals(1))); + row(ui, "Tilt", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.disk_tilt, 0.0..=PI) + .suffix(" rad").fixed_decimals(2))); + row(ui, "Brightness", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.disk_brightness, 0.0..=3.0).fixed_decimals(2))); + row(ui, "Rotation", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.disk_rotation_speed, 0.0..=3.0).fixed_decimals(2))); + // Color model combobox. + let mut cm = params.disk_color_mode; + ui.label("Color model"); + egui::ComboBox::from_id_salt("disk_color_combo") + .selected_text(format!("{:?}", cm)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut cm, DiskColorMode::Gradient, "Gradient"); + ui.selectable_value(&mut cm, DiskColorMode::Blackbody, "Blackbody"); + }); + ui.end_row(); + params.disk_color_mode = cm; + }); + // Temperature only meaningful in Blackbody mode — disable (not hide) so + // the user sees their value while briefly in Gradient. + ui.add_enabled( + cm_blackbody(params), + egui::Slider::new(&mut params.disk_temp, 1000.0..=50000.0) + .suffix(" K") + .logarithmic(true) + .fixed_decimals(0) + .text("Temperature"), + ); +} + +fn cm_blackbody(params: &BlackHoleParams) -> bool { + params.disk_color_mode == DiskColorMode::Blackbody +} +``` + +Note: `disk_temp` slider uses `.text("Temperature")` (label inside the slider widget) here rather than a Grid row, because the log-scale slider reads better with its label inline. This is an intentional local deviation from the pure two-column pattern; the visual still aligns because the slider width is the same 150px. + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build` +Expected: compiles. (`ACCENT_ORANGE` is now imported but not yet used until Task 9 adds warning text — `#[allow(unused_imports)]` is NOT added; Task 9 clears it within the same module. If the warning bothers you, you may land Task 9's first warning-text usage first. Prefer: leave the warning for one task.) + +- [ ] **Step 4: Commit** + +```bash +git add src/ui/sections.rs +git commit -m "feat(ui): add Accretion Disk card with color-mode/temp interaction + +Temperature disabled (not hidden) when color mode = Gradient, so the +user sees their value. log-scale slider for the 1000-50000K range. +" +``` + +--- + +### Task 9: Add the 6 collapsing sections (Turbulence, Doppler, Jets, Planets, Background, Grid) + +All six secondary sections. The four with header toggles use `collapsing_with_toggle`; Turbulence and Background use plain `collapsing`. Warning text uses `ACCENT_ORANGE` (clears the unused-import from Task 8). + +**Files:** +- Modify: `src/ui/sections.rs` + +- [ ] **Step 1: Add the six sections at the end of `src/ui/sections.rs`** + +```rust +// ============================ Collapsing sections ========================== + +pub fn section_turbulence(ui: &mut egui::Ui, params: &mut BlackHoleParams) { + use crate::params::DiskQuality; + let mut q = params.disk_quality; + egui::Grid::new("diskq_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + ui.label("Disk quality"); + egui::ComboBox::from_id_salt("diskq_combo") + .selected_text(format!("{:?}", q)) + .show_ui(ui, |ui| { + ui.selectable_value(&mut q, DiskQuality::Off, "Off"); + ui.selectable_value(&mut q, DiskQuality::Low, "Low"); + ui.selectable_value(&mut q, DiskQuality::Medium, "Medium"); + ui.selectable_value(&mut q, DiskQuality::High, "High"); + }); + ui.end_row(); + }); + params.disk_quality = q; + + let on = q != DiskQuality::Off; + if !on { + ui.label(egui::RichText::new( + "Disk quality Off → flat zero-thickness disk rendered.", + ).small().color(ACCENT_ORANGE)); + } + egui::Grid::new("turb_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + ui.add_enabled(on, egui::Slider::new(&mut params.disk_half_thickness, 0.02..=0.3).text("Thickness (H/R)")); + ui.end_row(); + ui.add_enabled(on, egui::Slider::new(&mut params.filament_freq, 0.2..=4.0).text("Filament frequency")); + ui.end_row(); + ui.add_enabled(on, egui::Slider::new(&mut params.filament_sharpness, 1.0..=6.0).text("Filament sharpness")); + ui.end_row(); + ui.add_enabled(on, egui::Slider::new(&mut params.density_freq, 0.2..=3.0).text("Density frequency")); + ui.end_row(); + ui.add_enabled(on, egui::Slider::new(&mut params.density_strength, 0.0..=2.0).text("Density strength")); + ui.end_row(); + ui.add_enabled(on, egui::Slider::new(&mut params.arm_count, 0.0..=6.0).text("Arm count")); + ui.end_row(); + ui.add_enabled(on, egui::Slider::new(&mut params.arm_tightness, 0.0..=6.0).text("Arm tightness")); + ui.end_row(); + ui.add_enabled(on, egui::Slider::new(&mut params.arm_strength, 0.0..=1.0).text("Arm strength")); + ui.end_row(); + }); +} + +pub fn section_doppler(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) { + ui.add_enabled(enabled, egui::Slider::new(&mut params.doppler_strength, 0.0..=3.0).text("Strength")); +} + +pub fn section_jets(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) { + // Mirror the shader's spin gate: jets render only for χ ≥ 0.05. + let jets_renderable = params.spin >= 0.05; + if enabled && !jets_renderable { + ui.label(egui::RichText::new( + "Jets need χ ≥ 0.05 (Blandford-Znajek is spin-powered).", + ).small().color(ACCENT_ORANGE)); + } + ui.add_enabled( + enabled && jets_renderable, + egui::Slider::new(&mut params.jets_strength, 0.0..=3.0).text("Strength"), + ); +} + +pub fn section_planets( + ui: &mut egui::Ui, + params: &mut BlackHoleParams, + enabled: bool, + planet_dirty: &mut crate::scene::planets::PlanetSystemDirty, +) { + ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_count_target, 0..=8).text("Count")); + ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_radius_factor, 1.1..=2.0).prefix("× ").text("Radius (× disk outer)")); + ui.add_enabled( + enabled, + egui::Label::new( + egui::RichText::new(format!( + "Orbit r = {:.2} (disk outer: {:.1})", + params.planet_radius_factor * params.disk_outer, + params.disk_outer + )).color(MUTED_TEXT), + ), + ); + ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_seed, 0..=1000).text("Seed")); + ui.add_enabled(enabled, egui::Slider::new(&mut params.planet_time_scale, 1.0..=200.0).text("Time scale")); +} + +pub fn section_background(ui: &mut egui::Ui, params: &mut BlackHoleParams) { + egui::Grid::new("bg_grid").num_columns(2).spacing([8.0, 4.0]) + .show(ui, |ui| { + row(ui, "Star intensity", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.star_intensity, 0.0..=3.0).fixed_decimals(2))); + row(ui, "Skybox", |ui| ui.add_sized([140.0, 16.0], + egui::Slider::new(&mut params.skybox_intensity, 0.0..=3.0).fixed_decimals(2))); + }); +} + +pub fn section_grid(ui: &mut egui::Ui, params: &mut BlackHoleParams, enabled: bool) { + ui.add_enabled(enabled, egui::Slider::new(&mut params.grid_density, 0.1..=4.0).text("Density")); +} +``` + +Design note on `section_planets`: the dirty-detection (compare prev vs curr of `planets_enabled/count/radius/seed`) moves OUT of the section function into `ui_system` (Task 11), because the comparison needs the values *before* the section draws them. The section function only renders; the orchestrator decides dirty. `planet_time_scale` is excluded from dirty (per the existing comment at `ui.rs:62-63`). + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build` +Expected: compiles. `ACCENT_ORANGE` now used → no warning. + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/sections.rs +git commit -m "feat(ui): add 6 collapsing sections + +Turbulence (default closed, longest; orange warning when disk quality Off), +Doppler/Jets/Grid/Planets (header enable toggle passed in as 'enabled'), +Background. Planets dirty-detection moves to ui_system (Task 11) since it +needs pre-render values. Jets retains its spin<0.05 warning." +``` + +--- + +### Task 10: Add `preset_bar()` to `mod.rs` + +The top bar: a preset combobox + a global reset. Reads/writes `Local` + `Local` state that `ui_system` owns. + +**Files:** +- Modify: `src/ui/mod.rs` + +- [ ] **Step 1: Add `preset_bar` to `src/ui/mod.rs`** + +Add below the helpers: + +```rust +use crate::ui::preset::{Preset, apply, canonical_hash, params_hash}; + +/// The top preset bar. `current` is the UI-layer state (which preset is +/// shown as selected); `just_applied` is set for one frame after a preset +/// is chosen, to skip the Custom-detection hash compare on that frame +/// (applying a preset changes params; that change must not flip to Custom). +fn preset_bar( + ui: &mut egui::Ui, + params: &mut crate::params::BlackHoleParams, + current: &mut Preset, + just_applied: &mut bool, +) { + ui.horizontal(|ui| { + ui.label("Preset:"); + let prev = *current; + egui::ComboBox::from_id_salt("preset_combo") + .selected_text(format!("{:?}", prev)) + .show_ui(ui, |ui| { + ui.selectable_value(current, Preset::Cinematic, "Cinematic"); + ui.selectable_value(current, Preset::Performance, "Performance"); + ui.selectable_value(current, Preset::Web, "Web"); + ui.selectable_value(current, Preset::Custom, "Custom"); + }); + if *current != prev && *current != Preset::Custom { + // User picked a concrete preset → apply its bundle. + apply(*current, params); + *just_applied = true; + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Global reset-to-default for all params. Destructive, so a + // confirmation button is not added — the panel is exploratory + // tooling and re-tuning is cheap. + if ui.button("↺ all").clicked() { + *params = crate::params::BlackHoleParams::default(); + *current = Preset::Custom; + *just_applied = true; + } + }); + }); + ui.separator(); +} +``` + +**Spec deviation noted.** The spec §3 says [↺] is a per-section reset. On review during plan-writing, per-section reset requires either (a) threading a reset closure into every section function signature (10 signatures change), or (b) storing per-section default snapshots (extra state). Both are heavy for a feature the spec itself flagged as "the safer granularity." For a single-panel tool where re-tuning is cheap, a single global "↺ all" reset is simpler and the destructive cost is low. This deviation is flagged here for the human; if they want per-section reset instead, swap this function's body for a closure-based approach. (Acceptance checklist Task 12 step 3 asks the human to confirm this choice.) + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo build` +Expected: compiles. `preset_bar` is unused until Task 11. + +- [ ] **Step 3: Commit** + +```bash +git add src/ui/mod.rs +git commit -m "feat(ui): add preset_bar() — combobox + global reset + +Selecting a concrete preset applies its bundle and sets just_applied +for one frame to suppress Custom-detection on that frame. Global '↺ all' +reset (deviation from spec's per-section reset, flagged in plan for review)." +``` + +--- + +### Task 11: Rewrite `ui_system` as the chassis orchestrator + +Replace the 186-line body of `ui_system` with: SidePanel → preset_bar → ScrollArea → section calls. Adds the `Local` / `Local` state and the planets dirty-detection (relocated from the old Planets section). + +**Files:** +- Modify: `src/ui/mod.rs` (replace `ui_system` body) + +- [ ] **Step 1: Replace `ui_system` in `src/ui/mod.rs`** + +Replace the entire current `pub fn ui_system(...)` function with: + +```rust +pub fn ui_system( + mut contexts: bevy_egui::EguiContexts, + mut params: ResMut, + mut camera: ResMut, + mut wants: ResMut, + mut planet_dirty: ResMut, + mut current_preset: Local, + mut just_applied: Local, + mut last_hash: Local, +) { + // Default the preset state on the first frame. Local would + // require deriving Default for Preset; we instead initialize manually + // via a sentinel hash of u64::MAX. + if *last_hash == u64::MAX { + *current_preset = Preset::Custom; + } + + let Ok(ctx) = contexts.ctx_mut() else { + wants.0 = false; + return; + }; + + // --- Custom-detection (skip on the frame a preset was just applied) --- + let now_hash = params_hash(¶ms); + if !*just_applied && *last_hash != u64::MAX && now_hash != *last_hash { + // Some preset-touched field changed by hand. If it no longer matches + // any concrete preset's canonical bundle, flip to Custom. + let matches_any = matches!( + *current_preset, + Preset::Cinematic | Preset::Performance | Preset::Web + ) && canonical_hash(*current_preset) == now_hash; + if !matches_any && *current_preset != Preset::Custom { + *current_preset = Preset::Custom; + } + } + *just_applied = false; + *last_hash = now_hash; + + // --- Chassis --- + egui::SidePanel::right("controls") + .default_width(300.0) + .width_range(260.0..=400.0) + .resizable(true) + .show(ctx.clone(), |ui| { + // Top bar (fixed). + preset_bar(ui, &mut params, &mut current_preset, &mut just_applied); + + // Section stack (scrolls). + egui::ScrollArea::vertical().show(ui, |ui| { + use crate::ui::sections::*; + + // --- Always-open cards --- + group(ui, "Camera", |ui| section_camera(ui, &mut camera)); + group(ui, "Black Hole", |ui| section_black_hole(ui, &mut params)); + group(ui, "Accretion Disk", |ui| section_disk(ui, &mut params)); + group(ui, "Quality", |ui| section_quality(ui, &mut params)); + + // --- Collapsing sections --- + collapsing(ui, "turbulence", "Disk Turbulence", false, + |ui| section_turbulence(ui, &mut params)); + + collapsing_with_toggle(ui, "doppler", "Doppler", false, + &mut params.doppler_enabled, + |ui, en| section_doppler(ui, &mut params, en)); + + collapsing_with_toggle(ui, "jets", "Jets", false, + &mut params.jets_enabled, + |ui, en| section_jets(ui, &mut params, en)); + + // Planets: snapshot dirty-relevant fields before rendering so + // we can detect changes (relocated from the old Planets block). + let prev_planet = ( + params.planets_enabled, + params.planet_count_target, + params.planet_radius_factor, + params.planet_seed, + ); + collapsing_with_toggle(ui, "planets", "Planets", false, + &mut params.planets_enabled, + |ui, en| section_planets(ui, &mut params, en, &mut planet_dirty)); + let curr_planet = ( + params.planets_enabled, + params.planet_count_target, + params.planet_radius_factor, + params.planet_seed, + ); + if curr_planet != prev_planet { + planet_dirty.0 = true; + } + + collapsing(ui, "background", "Background", false, + |ui| section_background(ui, &mut params)); + + collapsing_with_toggle(ui, "grid", "Grid", false, + &mut params.grid_enabled, + |ui, en| section_grid(ui, &mut params, en)); + }); + }); + + // egui captures pointer when the cursor is over a window or being + // interacted with. MUST stay last — load-bearing for orbit camera. + wants.0 = ctx.egui_wants_pointer_input(); +} +``` + +Key points: +- `SidePanel::show` takes `ctx.clone()` — `SidePanel::show` requires owned `Context`, but `ui_system` still needs `ctx` for the final `egui_wants_pointer_input()` call. Cloning the `Context` is cheap (it's an `Arc` internally). +- The old `if let Ok(ctx) = ... { ... } else { wants.0 = false }` is preserved as an early-return guard. +- `Local` / `Local` / `Local` initialize to `Preset::default()` / `false` / `0` — but `Preset` has no `Default`, and `0` is a valid hash. Use `*last_hash == u64::MAX` as the "uninitialized" sentinel: initialize `last_hash` to `MAX` on first frame... **but `Local` defaults to `0`, not `MAX`.** Fix: derive `Default` for `Preset` returning `Custom`, and use `Option` for `last_hash`. Apply that fix now before this task's commit: + +- [ ] **Step 2: Fix the Local initialization properly** + +Step 1's `u64::MAX` sentinel is fragile. Replace with `Option`: + +(a) At the top of `src/ui/preset.rs`, add `Default` for `Preset`: + +Change the `Preset` enum declaration to: + +```rust +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum Preset { + #[default] + Custom, + Cinematic, + Performance, + Web, +} +``` + +(Moving `Custom` to first position with `#[default]` so `Local` initializes to `Custom`.) + +(b) In `ui_system`'s signature, change `mut last_hash: Local` to `mut last_hash: Local>`, and replace the two blocks: + +Replace: +```rust + // Default the preset state on the first frame. Local would + // require deriving Default for Preset; we instead initialize manually + // via a sentinel hash of u64::MAX. + if *last_hash == u64::MAX { + *current_preset = Preset::Custom; + } +``` +with: +```rust + // Local defaults to Custom (Preset::default()). + // Local> defaults to None — first-frame sentinel. +``` + +Replace: +```rust + if !*just_applied && *last_hash != u64::MAX && now_hash != *last_hash { +``` +with: +```rust + if !*just_applied && last_hash.is_some() && now_hash != last_hash.unwrap() { +``` + +Replace: +```rust + *last_hash = now_hash; +``` +with: +```rust + *last_hash = Some(now_hash); +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo build` +Expected: compiles cleanly. + +- [ ] **Step 4: Run tests (preset test must still pass after the Default change)** + +Run: `cargo test` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui/mod.rs src/ui/preset.rs +git commit -m "feat(ui): rewrite ui_system as SidePanel chassis orchestrator + +SidePanel::right(300, resizable 260-400) → preset_bar (top, fixed) → +ScrollArea (section stack). 4 always-open group() cards + 6 collapsing +sections (4 with header toggles). Planets dirty-detection relocated here +(needs pre-render field snapshot). Preset Custom-detection runs each +frame unless just_applied. WantsPointer assignment preserved verbatim. + +Preset now derives Default=Custom; last_hash is Option (None=first +frame) to avoid the u64::MAX sentinel fragility." +``` + +--- + +### Task 12: Acceptance — build, test, visual, web + +No new code. This task runs the spec's acceptance checklist (§5) and confirms every item. + +**Files:** none + +- [ ] **Step 1: Clean release build** + +Run: `cargo build --release` +Expected: compiles with no errors. + +- [ ] **Step 2: Full test suite** + +Run: `cargo test` +Expected: all tests pass — the existing `physics` mirror tests + the three new `preset_test` tests. + +- [ ] **Step 3: Desktop visual check** + +Run: `cargo run --release` + +Confirm each of these (tick as you see it): +- [ ] SidePanel docked on the right edge, draggable width in 260–400px range +- [ ] 4 always-open cards visible: Camera, Black Hole, Accretion Disk, Quality +- [ ] 6 collapsing headers visible: Disk Turbulence, Doppler, Jets, Planets, Background, Grid +- [ ] No section named "Renderer" (it was merged into Quality) +- [ ] Only ONE "Resolution"/`render_scale` slider exists (in Quality) — the duplicate-slider bug is gone +- [ ] Preset bar at top: dropdown shows "Custom" initially +- [ ] Select "Cinematic" → sliders jump (steps=300, render_scale=0.75, bloom=High, disk=High, aa=High), bar shows "Cinematic" +- [ ] Drag any preset-touched slider (e.g. steps) → bar auto-switches to "Custom" +- [ ] Drag a non-preset slider (e.g. camera Distance, or disk Tilt) → bar STAYS on current preset (does NOT flip to Custom) +- [ ] Click "↺ all" → all params reset to default, bar shows "Custom" +- [ ] Open Jets, set spin to 0 → header checkbox still checkable but the warning "Jets need χ ≥ 0.05" appears in orange, strength slider disabled +- [ ] In Quality, set Bloom to "Off" → Threshold and Strength rows disappear entirely +- [ ] Open Disk Turbulence, set Disk quality to "Off" → orange warning "flat zero-thickness disk rendered", 7 turbulence sliders grey out but stay visible +- [ ] **Regression-critical**: drag a slider in the panel → the orbit camera does NOT rotate +- [ ] **Regression-critical**: no grey screen / frozen view — `nudge_camera` still working (the view updates as you change params) +- [ ] Panel background is the dark blue-black `#0E1014`, accents are cyan, warnings orange +- [ ] Confirm with the human: global "↺ all" reset is acceptable vs. the spec's per-section reset (see Task 10 deviation note). If they want per-section, file as a follow-up. + +- [ ] **Step 4: Web build boots without panic** + +Run: `trunk serve` → open http://127.0.0.1:8080 +Expected: +- [ ] Page loads, no console panic +- [ ] SidePanel renders on the right +- [ ] No egui 0.34 `Rounding`/`CornerRadius` or `Frame` stroke-in-padding errors in the console +- [ ] Preset bar works, sections expand/collapse + +If the build fails to compile for wasm, the most likely cause is an egui 0.34 API surface difference — check the error against the research findings (egui 0.34 renamed `Rounding`→`CornerRadius`, `Frame` counts stroke width in padding). + +- [ ] **Step 5: Final commit (only if any fixups were needed during acceptance)** + +If Steps 1–4 needed fixes, commit them. Otherwise no commit — the implementation is complete as of Task 11. + +```bash +git add -A +git commit -m "fix(ui): acceptance pass fixups + +(If empty, skip this commit.)" +``` + +--- + +## Self-Review (run before handing off) + +**1. Spec coverage** — every spec section maps to a task: +- §1 Chassis + startup styling → Tasks 2 (style.rs) + 4 (wire) + 11 (SidePanel) +- §2 Section skeleton + `section()` → Tasks 5 (group/collapsing) + 6 (collapsing_with_toggle) + 7-9 (10 sections) +- §3 Preset bar + Grid rows → Tasks 3 (preset.rs + test) + 10 (preset_bar) + 11 (orchestrator wiring) + Grid rows throughout 7-9 +- §4 Toggles / disabled / warnings → Tasks 6 (header toggle) + 9 (orange warnings, hide vs disable) +- §5 File structure + acceptance → Task 1 (module dir) + 12 (acceptance) + +**2. Placeholder scan** — no TBDs/TODOs. Each step has complete code or an exact command. The one "deviation from spec" (Task 10 global reset) is flagged, not hidden. + +**3. Type consistency** — checked signatures across tasks: +- `group(ui, title, body)` used in Task 11 matches Task 5's definition +- `collapsing(ui, id, title, default_open, body)` matches +- `collapsing_with_toggle(ui, id, title, default_open, enabled, body)` — Task 6 defines `body: FnOnce(&mut Ui, bool)`, Task 11 calls it with `|ui, en| section_*(ui, &mut params, en)` ✓ +- `section_planets(ui, params, enabled, planet_dirty)` — Task 9 signature matches Task 11 call ✓ +- `Preset::default() == Custom` (Task 11.2) is consistent with `Local` initialization +- `params_hash` / `canonical_hash` / `apply` (Task 3) match preset.rs and the Task 11 detection logic + +**4. Bite-size** — each task is one cohesive change, each step is one action, commits are granular and explain what+why. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-07-17-ui-panel-redesign.md`. Two execution options: + +**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration + +**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints + +Which approach?