singularity-rs/docs/superpowers/specs/2026-07-17-ui-panel-redesign-design.md
xfy c260b6fbfb docs(spec): UI panel redesign — SidePanel + sci-fi theme + presets
Design spec for the egui control panel overhaul. Addresses the
user-reported 'settings all look bad' by fixing the structural root
causes (flat 11-CollapsingHeader scroll, no styling, ragged sliders,
no presets) rather than a cosmetic reskin.

Key decisions captured:
- Chassis: right SidePanel (replaces floating Window)
- Theme: deep-space cyan/orange, hand-applied over Visuals::dark()
- Structure: 4 always-open framed cards + 6 collapsing sections
  (11→10 groups; Renderer merges into Quality, fixing a latent
  duplicate render_scale slider bug)
- Preset bar: Cinematic/Performance/Web/Custom with per-frame hash
  detection of hand-edits
- Two-column Grid rows with unit suffixes + log scales on wide ranges
- Group-enable toggles in collapsing headers; standardized warning text

Non-goals explicit: no params/shader/physics changes, no custom font
binary, no painted toggle, no new deps.
2026-07-17 14:39:43 +08:00

17 KiB
Raw Blame History

Control Panel Redesign — Design Spec

Date: 2026-07-17 Phase: UI (presentation layer only; no physics/shader changes) Status: approved design, pending implementation plan

Goal

Redesign the egui control panel from a single floating Window with 11 CollapsingHeader sections of 40+ raw Slider::new(...).text(...) calls (no styling) into a docked right-side SidePanel with a cohesive dark sci-fi theme, layered grouping, presets, and aligned label/value rows.

This addresses the user-observed problem: "the settings all look bad." The root causes are structural, not cosmetic:

  1. No global styling — egui defaults render against a busy HDR black-hole render; no accent color, no spacing tuning, default font sizes.
  2. Flat structure — 11 CollapsingHeaders in a single scroll produces 11 collapse arrows and a long scroll, with no priority distinction between always-tuned (Camera, Quality) and rarely-tuned (Grid, Doppler) sections.
  3. Ragged widgetsSlider::new(x, r).text("label") produces misaligned labels and value readouts; no units on any readout.
  4. No presets — the project already ships two quality tiers (cfg!(wasm32) defaults) but exposes them only at startup; the user cannot switch between "cinematic" and "performance" intents without nudging 6 sliders.

Non-goals

  • No BlackHoleParams structural changes. No new fields. Preset is a UI-layer concept, not stored in the params resource.
  • No shader / physics-mirror changes. physics.rs, black_hole.wgsl, material.rs are untouched. The CPU↔shader mirror invariant is not at risk.
  • No camera-control logic changes. WantsPointer and the wants.0 = ctx.egui_wants_pointer_input() assignment are retained verbatim — this is load-bearing (lets the orbit camera ignore drags over the panel).
  • No custom font binary. Use egui's default Proportional family; only adjust text_styles sizes. Embedding Inter would add an asset and a Trunk.toml/include_bytes! step not justified by the visual gain.
  • No custom-painted toggle switch. egui has no native iOS-style toggle, but painting one (~30 lines of Painter) is maintenance cost the project doesn't need. Use ui::checkbox with accent coloring instead.
  • No new dependencies. egui_colors / egui_widget_ext / egui-thematic are not added. The accent palette is hand-applied via Visuals field overrides (§1).

Approach A — layered grouping (chosen)

Among three approaches considered:

  • A. Layered grouping (chosen): SidePanel + top preset bar + 4 always-open framed cards + 6 collapsing sections, Grid two-column rows, hand-tuned sci-fi theme.
  • B. Pure cosmetic refresh: keep the 11-CollapsingHeader structure, only restyle. Rejected — the structural problem (flat long scroll, no priority) survives.
  • C. Tabbed (TopBottomPanel of tabs): zero scroll but cross-tab tuning is hostile to explorable debugging.

A balances noise reduction against parameter access. Its preset entry (§3) directly maps onto the existing cfg!(wasm32) dual defaults — those two default bundles are essentially two presets already.

§1 — Chassis and startup styling

Chassis

egui::SidePanel::right("controls"), default_width(300.0), width_range(260.0..=400.0), resizable(true). Inside: a TopBottomPanel::top("preset_bar") (fixed) followed by a ScrollArea::vertical (the section stack).

One-shot styling

A new setup_egui_style system runs once (not per-frame — per-frame set_style/set_visuals dirties font/layout caches every frame). It is registered in EguiPrimaryContextPass (not Startup — see gotcha below), guarded by a Local<bool> flag so it runs exactly once on the first tick where ctx_mut() succeeds. It applies three things to the egui::Context:

Fonts — keep default Proportional family; override text_styles sizes:

  • TextStyle::Body → 14
  • TextStyle::Monospace → 12
  • TextStyle::Heading → 16
  • TextStyle::Small → 11

Spacing (Style::spacing):

  • item_spacing = (8.0, 6.0) — tighter gutters than default
  • slider_width = 150.0 — uniform rail length (the single biggest alignment fix)
  • indent = 18.0
  • button_padding = (10.0, 4.0)
  • indent_ends_with_horizontal_line = false — removes the collapsing-header divider noise

Visuals (deep-space cyan/orange, hand-applied over Visuals::dark()):

  • panel_fill = #0E1014 (blue-black, not pure black)
  • extreme_bg_color = #08090C
  • hyperlink_color = #5AC8FF (cyan; by convention also used for section headings)
  • selection.bg_fill = #5AC8FF
  • selection.stroke = Stroke::new(1.0, #FF8C42) (orange selection outline)
  • widgets.inactive.weak_bg_fill = #1E2430, bg_stroke = Stroke::new(0.5, #3C4646)
  • widgets.hovered.weak_bg_fill = #283246, fg_stroke = #C8DCFF
  • widgets.active.fg_stroke = #5AC8FF, weak_bg_fill = #324664
  • widgets.noninteractive.bg_stroke = Stroke::new(0.5, #282E3C)
  • animation_time = 0.12 (snappier than default 0.25)

Color constants are defined once in src/ui/style.rs (ACCENT_CYAN, ACCENT_ORANGE, PANEL_FILL, MUTED_TEXT, …) and reused by every section helper.

bevy_egui 0.41 startup gotcha

The egui context is exposed via EguiContexts, which is reliable in EguiPrimaryContextPass (where ui_system already runs) but not guaranteed available in Startup. So setup_egui_style is registered in EguiPrimaryContextPass, guarded by a Local<bool>: it retries each tick until ctx_mut() first succeeds, applies the style once, sets the flag, and never runs again.

§2 — Section skeleton and section() abstraction

Two helper functions in src/ui/mod.rs eliminate the current 11× CollapsingHeader::new(...).default_open(...).show(...) boilerplate:

/// Always-open framed card. Title in cyan RichText::strong().small().
fn group(ui: &mut egui::Ui, title: &str, body: impl FnOnce(&mut egui::Ui));

/// Collapsible section. Title row can host an enable toggle (§4).
fn collapsing(
    ui: &mut egui::Ui,
    id: &str,
    title: &str,
    default_open: bool,
    body: impl FnOnce(&mut egui::Ui),
);

Section roster (11 → 10 groups)

The current Renderer section is removed; its contents merge into Quality (see bug fix below).

Group Form Contents
Camera always-open card distance / yaw / pitch / fov
Black Hole always-open card spin slider + ISCO / horizon r+ read-only
Accretion Disk always-open card outer / tilt / brightness / rotation / color model / temp
Quality always-open card bloom + exposure + steps + render_scale + AA + star_aa
Disk Turbulence collapsing (closed) disk_quality + 7 turbulence params (longest section)
Doppler collapsing enabled + strength
Jets collapsing enabled + strength (spin<0.05 warning retained)
Planets collapsing 6 params (dirty-detection retained)
Background collapsing star_intensity / skybox_intensity
Grid collapsing enabled + density

Always-open: the 4 most-frequently-tuned. Collapsing: 6 secondary sections, most defaulting closed. This halves the visible scroll length and drops collapse-arrow count from 11 to 6.

Bug fix: duplicate render_scale slider

Current ui.rs binds params.render_scale twice — once in Renderer (ui.rs:131, "Render scale") and once in Quality (ui.rs:158, "Resolution scale"). Both write the same field; whichever runs second wins the frame, and the two labels disagree. The redesign consolidates this into a single Quality-row entry labeled "Resolution scale" with .logarithmic(true). This is a latent bug fix included opportunistically in the refactor (the AGENTS.md guidance to "improve code you're working in").

§3 — Preset bar and Grid two-column rows

Preset bar

TopBottomPanel::top("preset_bar") fixed above the ScrollArea:

┌─────────────────────────────┐
│ Preset: [Cinematic ▾]  [↺] │
├─────────────────────────────┤
│ (ScrollArea sections…)      │

Four presets, each writes a bundle of params:

Preset Intent Key overrides
Cinematic desktop showpiece steps=300, render_scale=0.75, bloom=High, disk=High, aa=High
Performance high fps steps=150, render_scale=0.5, bloom=Low, disk=Low, aa=Off
Web wasm default bundle the existing cfg!(wasm32) default set
Custom read-only marker written when any slider is hand-edited; writes nothing

Custom detection. egui's Slider has no on_change callback. The reliable cross-widget "user modified something" signal is a per-frame hash: at the top of ui_system, compute a hash of the params fields that presets touch; if it differs from last frame's hash and does not equal the current preset's canonical hash, set current_preset = Custom. Applying a preset sets a just_applied_preset flag for one frame to skip the hash comparison (the apply itself changes params; that change must not re-trigger Custom).

[↺] reset button resets only the currently displayed section to its Default::default() values, not all params. Resetting the whole panel is destructive (erases hand-tuned values across sections); per-section reset is the safer granularity and matches the section's visual scope.

Preset state location. current_preset: Local<Preset> and just_applied_preset: Local<bool> live as Local system params in ui_system. They are UI-layer state, not added to BlackHoleParams (non-goal).

Grid two-column rows

Every numeric row converts from:

ui.add(egui::Slider::new(&mut cam.distance, 3.0..=200.0).text("Distance"));

to a two-column Grid with label + sized slider:

egui::Grid::new("camera").num_columns(2).spacing([8.0, 4.0])
    .show(ui, |ui| {
        ui.label("Distance");
        ui.add_sized([140.0, 16.0],
            egui::Slider::new(&mut cam.distance, 3.0..=200.0)
                .suffix(" r_g").fixed_decimals(1));
        ui.end_row();
        // …
    });

This produces aligned label/value gutters and uniform slider rails — the biggest single visual upgrade.

Units and scales

Every slider gains a unit suffix to disambiguate readouts:

  • Angles (yaw / pitch / tilt) → .suffix(" rad") + .fixed_decimals(2)
  • disk_temp.suffix(" K") + .logarithmic(true) (range 100050000; linear wastes the low end)
  • steps (50600) and render_scale (0.251.0) → .logarithmic(true)
  • planet_radius_factor.prefix("× ")

Read-only derived values (ISCO / horizon r+ / planet orbit radius) stay as ui.label(format!(...)) but are placed in the Grid's right column for alignment, colored MUTED_TEXT (Color32::from_gray(140)) to signal "not editable."

§4 — Toggles, disabled state, and warning text

The current panel has three inconsistent enable/disable patterns:

  • ui.checkbox(&mut x, "Enable") on its own row (Doppler/Jets/Grid/Planets)
  • ui.add_enabled(cond, slider) scattered (turbulence / bloom / temp / jets strength)
  • Inline warning text (Jets only)

Unified into one rule set:

1. Group-enable toggle in the header row

Doppler / Jets / Grid / Planets — the four "whole section disableable" groups — get their enable checkbox moved into the collapsing header row via CollapsingState::show_header, not as a separate row inside the body:

▼ Doppler                          [✓]
   └ Strength
▼ Jets                             [✓]   (spin<0.05 → checkbox disabled, title dimmed)
   └ Strength

The body's widgets are wrapped in ui.add_enabled(group_enabled && extra_cond, ...).

2. Disabled-state visualization

Currently disabled rows are merely non-interactive — indistinguishable from enabled. Two changes:

  • Disabled widgets pick up widgets.noninteractive.weak_bg_fill (darker gray) automatically via egui's add_enabled.
  • Section title and row labels drop to Color32::from_gray(110) when their section is disabled.

3. Inline warning text, templated

The existing Jets warning ("Spin too low — jets need χ ≥ 0.05") is the template. Standardized to orange RichText::small() at the top of any section whose controls are gated:

  • Jets + spin<0.05 → "Jets need χ ≥ 0.05 (Blandford-Znajek is spin-powered)"
  • Disk quality=Off → "Disk quality Off → flat zero-thickness disk rendered" (turbulence sliders below stay visible-but-disabled)
  • Bloom=Off → threshold/strength rows are hidden (not disabled). Rationale: those two params are meaningless when bloom is off; hiding is cleaner than greying. This is an intentional asymmetry vs. the turbulence case (§4 note below).

Asymmetry rationale. Bloom's child params (threshold/strength) have no interpretation when bloom is off → hide. Turbulence's child params (the 7 noise sliders) retain their values as user-tuned state worth seeing while quality=Off briefly → disable-but-show. The distinction is "param meaningless" vs. "param currently not applied but still yours."

4. Toggle visual

No custom-painted iOS toggle (non-goal). ui.checkbox in the header row, with accent coloring: cyan ✓ when on, gray when off. This reads as a switch in context without the maintenance cost of a Painter widget.

§5 — File structure, migration, and acceptance

File changes

New src/ui/style.rs — all egui styling:

  • pub fn setup(ctx: &egui::Context) — fonts + spacing + visuals; called once from the startup system
  • pub const ACCENT_CYAN / ACCENT_ORANGE / PANEL_FILL / MUTED_TEXT / … — palette constants
  • pub fn sci_fi_visuals() -> egui::Visuals — returns the themed Visuals (unit-testable in isolation)
  • pub fn text_styles() -> Vec<(egui::TextStyle, f32)> — the size table

New src/ui/preset.rs — preset logic:

  • pub enum Preset { Cinematic, Performance, Web, Custom }
  • pub fn apply(p: Preset, params: &mut BlackHoleParams)
  • pub fn canonical_hash(p: Preset) -> u64 — the hash each preset's param bundle produces
  • pub fn params_hash(params: &BlackHoleParams) -> u64 — current-state hash for Custom detection

New src/ui/sections.rs — the 10 section_* render functions, one per group. Each takes (ui, params, …) and mutates params in place. Keeps mod.rs a thin orchestrator.

Refactor src/ui.rssrc/ui/mod.rs:

  • mod style; mod preset; mod sections;
  • pub fn ui_system(...) — thinned to the chassis: SidePanel::rightTopBottomPanel(preset bar) + ScrollArea(section calls)
  • fn group(...) / fn collapsing(...) helpers (§2)
  • fn preset_bar(...) — top bar UI
  • Local<Preset> / Local<bool> (current_preset / just_applied_preset) state

Edit src/render/plugin.rs:

  • Register setup_egui_style as a one-shot system in EguiPrimaryContextPass (with Local<bool> guard, §1). Must be EguiPrimaryContextPass, not Startup — the egui context is reliable there but not guaranteed in Startup.

Unchanged: params.rs, lib.rs, all shaders, physics.rs, camera.rs, material.rs, scene/planets.rs, web.rs, main.rs.

Out of scope (explicit)

  • No BlackHoleParams field changes or default value changes
  • No shader / physics mirror edits
  • No camera control / WantsPointer changes
  • No embedded font binary
  • No custom-painted toggle
  • No new crate dependencies

Acceptance / regression checklist

Run after implementation:

  1. cargo build --release compiles
  2. cargo test passes (physics.rs mirror unaffected; confirms lib.rs export intact)
  3. cargo run --release visual checks:
    • SidePanel docked right, draggable width 260400
    • 4 always-open cards + 6 collapsing headers visible
    • Preset bar at top: select Cinematic → sliders jump to its bundle, bar shows "Cinematic"
    • Drag any slider → bar auto-switches to "Custom"
    • [↺] in a section header → that section's params reset to default
    • Jets + spin=0 → header checkbox disabled + orange warning text
    • Bloom=Off → threshold/strength rows disappear
    • Disk quality=Off → turbulence sliders disabled-but-visible + warning
    • Regression-critical: dragging a slider does NOT rotate the orbit camera (WantsPointer still correct)
    • Regression-critical: no "grey screen" / frozen-frame — the nudge_camera workaround is untouched
  4. trunk serve web build boots without panic; SidePanel renders; egui 0.34 breaking changes (Rounding→CornerRadius, Frame stroke-in-padding) surface here if missed

Open questions for implementation plan

  • The hash function for preset detection (params_hash) needs to hash only the fields presets touch, not all of BlackHoleParams — otherwise non-preset edits (e.g. camera distance) would spuriously flip to Custom. The plan should enumerate the exact hashed field set.