12 bite-sized tasks: module-dir split → style.rs → preset.rs (TDD) → wire setup_egui_style → group/collapsing helpers → collapsing_with_toggle → sections.rs (4 cards + 6 collapsing) → preset_bar → ui_system rewrite → acceptance (build/test/desktop/web). Testable surface is preset.rs's hash logic (tests/preset_test.rs via new lib.rs exports); everything else verified by the visual/web acceptance checklist, matching the codebase convention that rendering isn't unit-tested.
56 KiB
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<bool>-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
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
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(addmod style;) -
Step 1: Create
src/ui/style.rswith palette constants andsci_fi_visuals()
//! 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):
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
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(exportui+paramsmodules for the test) -
Modify:
src/ui/mod.rs(addmod preset;) -
Step 1: Export
uiandparamsfromsrc/lib.rs
Current src/lib.rs is a single line pub mod physics;. Replace its contents with:
pub mod params;
pub mod physics;
pub mod ui;
- Step 2: Write the failing test first
Create tests/preset_test.rs:
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
//! 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<HashedParams> {
// 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;:
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
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<bool> 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(addpub fn setup_egui_style) -
Step 1: Add
setup_egui_styletosrc/ui/mod.rs
Add this function to src/ui/mod.rs (anywhere at module scope, after the mod declarations):
/// One-shot egui styling. Registered in `EguiPrimaryContextPass` with a
/// `Local<bool>` 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<bool>,
) {
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):
.add_systems(bevy_egui::EguiPrimaryContextPass, crate::ui::ui_system);
Replace with:
.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:
// 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<bool> 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
git add src/ui/mod.rs src/render/plugin.rs
git commit -m "feat(ui): wire setup_egui_style one-shot into EguiPrimaryContextPass
Local<bool> 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):
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 renamedRoundingtoCornerRadius;Frame::corner_radius(f32)is the current builder method. -
.id_salt(id)makes the open/closed state stable across frames independent of header text. -
collapsinghere is the simple form (no header toggle). Task 9 adds a second variantcollapsing_with_togglefor 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
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():
/// 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
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(addmod sections;) -
Step 1: Create
src/ui/sections.rswith Camera, Black Hole, Quality
//! 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;:
mod sections;
- Step 3: Trim the unused import in
sections.rsfor 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:
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
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_ORANGEto the section imports
Change the import lines at the top of src/ui/sections.rs from:
use crate::params::{
AaQuality, BlackHoleParams, BloomQuality,
};
use crate::ui::style::MUTED_TEXT;
to:
use crate::params::{
AaQuality, BlackHoleParams, BloomQuality, DiskColorMode,
};
use crate::ui::style::{ACCENT_ORANGE, MUTED_TEXT};
- Step 2: Add
section_diskat the end ofsrc/ui/sections.rs
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
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
// ============================ 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
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<Preset> + Local<bool> state that ui_system owns.
Files:
-
Modify:
src/ui/mod.rs -
Step 1: Add
preset_bartosrc/ui/mod.rs
Add below the helpers:
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
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<Preset> / Local<bool> state and the planets dirty-detection (relocated from the old Planets section).
Files:
-
Modify:
src/ui/mod.rs(replaceui_systembody) -
Step 1: Replace
ui_systeminsrc/ui/mod.rs
Replace the entire current pub fn ui_system(...) function with:
pub fn ui_system(
mut contexts: bevy_egui::EguiContexts,
mut params: ResMut<crate::params::BlackHoleParams>,
mut camera: ResMut<crate::camera::OrbitCamera>,
mut wants: ResMut<crate::camera::WantsPointer>,
mut planet_dirty: ResMut<crate::scene::planets::PlanetSystemDirty>,
mut current_preset: Local<Preset>,
mut just_applied: Local<bool>,
mut last_hash: Local<u64>,
) {
// Default the preset state on the first frame. Local<T: Default> 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::showtakesctx.clone()—SidePanel::showrequires ownedContext, butui_systemstill needsctxfor the finalegui_wants_pointer_input()call. Cloning theContextis cheap (it's anArcinternally). -
The old
if let Ok(ctx) = ... { ... } else { wants.0 = false }is preserved as an early-return guard. -
Local<Preset>/Local<bool>/Local<u64>initialize toPreset::default()/false/0— butPresethas noDefault, and0is a valid hash. Use*last_hash == u64::MAXas the "uninitialized" sentinel: initializelast_hashtoMAXon first frame... butLocal<u64>defaults to0, notMAX. Fix: deriveDefaultforPresetreturningCustom, and useOption<u64>forlast_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<u64>:
(a) At the top of src/ui/preset.rs, add Default for Preset:
Change the Preset enum declaration to:
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Preset {
#[default]
Custom,
Cinematic,
Performance,
Web,
}
(Moving Custom to first position with #[default] so Local<Preset> initializes to Custom.)
(b) In ui_system's signature, change mut last_hash: Local<u64> to mut last_hash: Local<Option<u64>>, and replace the two blocks:
Replace:
// Default the preset state on the first frame. Local<T: Default> 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:
// Local<Preset> defaults to Custom (Preset::default()).
// Local<Option<u64>> defaults to None — first-frame sentinel.
Replace:
if !*just_applied && *last_hash != u64::MAX && now_hash != *last_hash {
with:
if !*just_applied && last_hash.is_some() && now_hash != last_hash.unwrap() {
Replace:
*last_hash = now_hash;
with:
*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
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<u64> (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_scaleslider 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_camerastill 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/CornerRadiusorFramestroke-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.
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 definitioncollapsing(ui, id, title, default_open, body)matchescollapsing_with_toggle(ui, id, title, default_open, enabled, body)— Task 6 definesbody: 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 withLocal<Preset>initializationparams_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?