From 656a8919135d1ab4e76d7ca7e29a87bbd027bca5 Mon Sep 17 00:00:00 2001 From: thewrz Date: Mon, 29 Jun 2026 21:06:20 -0700 Subject: [PATCH 1/4] refactor(ui): address panel-feather flourish review follow-ups Review follow-ups for the merged #182/#184 feather work: - Remove the dead close-burst path. Feathers emit on panel *open* only; the Close transition just clears them. The `transition` parameter threaded through emit -> seed_particles -> seed_particle and the Close `rotation_bias` branch were unreachable from the app (only a test still seeded with Close). Drop them. - panel_burst_emitter now falls back to Center on a zero-length edge (a panel collapsed to zero height/width) instead of seeding a degenerate edge line that stacks every particle on one point. Pinned with a test. - De-brittle the motion tests: assert the dust-vs-feather ordering invariants instead of absolute pixel margins coupled to current tuning, and assert lateral spread across the feather set rather than hunting for two particles with byte-identical seeds (which panicked on any seeding refactor). - Split the now over-budget side_panel_flourish.rs (>400) into emitter-geometry and motion suites sharing a flourish_support module. - Small cleanup: compute wobble phase advance once per tick; reconcile the design spec's Non-Goals with the shipped open-only behavior. Co-Authored-By: Claude Opus 4.8 --- ...06-26-panel-feather-edge-emitter-design.md | 6 +- src/app/panels.rs | 3 +- src/ui/side_panel/flourish.rs | 44 ++- tests/flourish_support/mod.rs | 105 ++++++++ tests/side_panel_flourish.rs | 250 +++--------------- tests/side_panel_flourish_emitter.rs | 108 ++++++++ 6 files changed, 275 insertions(+), 241 deletions(-) create mode 100644 tests/flourish_support/mod.rs create mode 100644 tests/side_panel_flourish_emitter.rs diff --git a/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md b/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md index 33b3d6b..94b6dd1 100644 --- a/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md +++ b/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md @@ -24,8 +24,10 @@ deterministic for tests. - Do not introduce small/medium/large feather variants. - Do not add pixel-art feather sprites. -- Do not change the settings toggle, duration, fade, gravity, cursor bump, or - panel open/close lifecycle. +- Do not change the settings toggle, duration, fade, gravity, or cursor bump. + > Update (as shipped): the close burst was dropped — feathers now emit on + > panel *open* only, and closing simply clears them. The open/close *lifecycle* + > is otherwise unchanged. - Do not add unrelated side-panel abstractions beyond what the emitter model needs. diff --git a/src/app/panels.rs b/src/app/panels.rs index e6acf5e..a0962d5 100644 --- a/src/app/panels.rs +++ b/src/app/panels.rs @@ -87,8 +87,7 @@ impl HonkHonk { return; } let panel = panel_geometry(self.window_size, effects_panel_view::EFFECTS_PANEL_W); - self.panel_flourish - .emit(panel, self.window_size, transition, now); + self.panel_flourish.emit(panel, self.window_size, now); } } diff --git a/src/ui/side_panel/flourish.rs b/src/ui/side_panel/flourish.rs index 63ccc3b..64a8071 100644 --- a/src/ui/side_panel/flourish.rs +++ b/src/ui/side_panel/flourish.rs @@ -74,7 +74,13 @@ pub fn panel_burst_emitter(panel: PanelRect, window: (f32, f32)) -> BurstEmitter let touches_right = win_w > 0.0 && panel.x + panel.w >= win_w - EDGE_EPS; let touches_bottom = win_h > 0.0 && panel.y + panel.h >= win_h - EDGE_EPS; - if touches_right && panel.x > EDGE_EPS { + // Vertical edges need a non-degenerate height (and horizontal a width) to + // spread feathers along; a collapsed panel falls through to the center + // point rather than seeding a zero-length edge that stacks every particle. + let has_vertical_span = panel.h > EDGE_EPS; + let has_horizontal_span = panel.w > EDGE_EPS; + + if touches_right && panel.x > EDGE_EPS && has_vertical_span { return edge_line( Point::new(panel.x, panel.y), Point::new(panel.x, panel.y + panel.h), @@ -82,7 +88,7 @@ pub fn panel_burst_emitter(panel: PanelRect, window: (f32, f32)) -> BurstEmitter 0.0, ); } - if touches_left && panel.x + panel.w < win_w - EDGE_EPS { + if touches_left && panel.x + panel.w < win_w - EDGE_EPS && has_vertical_span { return edge_line( Point::new(panel.x + panel.w, panel.y), Point::new(panel.x + panel.w, panel.y + panel.h), @@ -90,7 +96,7 @@ pub fn panel_burst_emitter(panel: PanelRect, window: (f32, f32)) -> BurstEmitter 0.0, ); } - if touches_bottom && panel.y > EDGE_EPS { + if touches_bottom && panel.y > EDGE_EPS && has_horizontal_span { return edge_line( Point::new(panel.x, panel.y), Point::new(panel.x + panel.w, panel.y), @@ -98,7 +104,7 @@ pub fn panel_burst_emitter(panel: PanelRect, window: (f32, f32)) -> BurstEmitter -1.0, ); } - if touches_top && panel.y + panel.h < win_h - EDGE_EPS { + if touches_top && panel.y + panel.h < win_h - EDGE_EPS && has_horizontal_span { return edge_line( Point::new(panel.x, panel.y + panel.h), Point::new(panel.x + panel.w, panel.y + panel.h), @@ -111,15 +117,9 @@ pub fn panel_burst_emitter(panel: PanelRect, window: (f32, f32)) -> BurstEmitter } impl PanelFlourish { - pub fn emit( - &mut self, - panel: PanelRect, - window: (f32, f32), - transition: PanelTransition, - now: Instant, - ) { + pub fn emit(&mut self, panel: PanelRect, window: (f32, f32), now: Instant) { let emitter = panel_burst_emitter(panel, window); - self.particles = seed_particles(emitter, transition); + self.particles = seed_particles(emitter); self.started = Some(now); self.last_tick = Some(now); } @@ -167,13 +167,11 @@ fn edge_line(start: Point, end: Point, x: f32, y: f32) -> BurstEmitter { }) } -fn seed_particles(emitter: BurstEmitter, transition: PanelTransition) -> Vec { - (0..PARTICLES) - .map(|i| seed_particle(emitter, transition, i)) - .collect() +fn seed_particles(emitter: BurstEmitter) -> Vec { + (0..PARTICLES).map(|i| seed_particle(emitter, i)).collect() } -fn seed_particle(emitter: BurstEmitter, transition: PanelTransition, i: usize) -> FeatherParticle { +fn seed_particle(emitter: BurstEmitter, i: usize) -> FeatherParticle { let class = feather_class(i); let params = class_params(class, i); let dir = particle_direction(emitter, i); @@ -188,18 +186,13 @@ fn seed_particle(emitter: BurstEmitter, transition: PanelTransition, i: usize) - scale(dir, 5.0 + (i % 3) as f32 * 2.0), scale(perp, scatter * 6.0), ); - let rotation_bias = match transition { - PanelTransition::Open => 0.0, - PanelTransition::Close => 0.35, - }; - FeatherParticle { class, position: translate(emitter_point(emitter, i), offset), velocity, alpha: 1.0, size: params.size, - rotation: rotation_bias + scatter * 0.45, + rotation: scatter * 0.45, wobble_phase: i as f32 * 1.618_034, wobble_frequency: params.wobble_frequency, wobble_strength: params.wobble_strength, @@ -306,8 +299,9 @@ fn tick_particle(particle: &mut FeatherParticle, dt: f32, cursor: Option) particle.velocity = add(particle.velocity, cursor_bump(*particle, cursor, dt)); } - let wobble = (particle.wobble_phase + particle.wobble_frequency * dt).sin(); - particle.wobble_phase += particle.wobble_frequency * dt; + let advance = particle.wobble_frequency * dt; + let wobble = (particle.wobble_phase + advance).sin(); + particle.wobble_phase += advance; particle.velocity.x += wobble * particle.wobble_strength * dt; particle.velocity.y += GRAVITY * dt; diff --git a/tests/flourish_support/mod.rs b/tests/flourish_support/mod.rs new file mode 100644 index 0000000..08bc487 --- /dev/null +++ b/tests/flourish_support/mod.rs @@ -0,0 +1,105 @@ +//! Shared fixtures and helpers for the panel-feather flourish integration +//! tests, used by `side_panel_flourish.rs` (motion) and +//! `side_panel_flourish_emitter.rs` (emitter geometry). Each test binary +//! compiles this module independently, so not every helper is used in both; +//! `allow(dead_code, unused_imports)` keeps the unused-in-one-binary warnings +//! quiet (each binary uses a different subset of the helpers and re-exports). +#![allow(dead_code, unused_imports)] + +use std::time::{Duration, Instant}; + +pub use honkhonk::ui::side_panel::{ + BurstEmitter, BurstLine, FeatherClass, FeatherParticle, PanelFlourish, PanelRect, + panel_burst_emitter, +}; +pub use iced::{Point, Vector}; + +pub fn right_panel() -> PanelRect { + PanelRect { + x: 880.0, + y: 0.0, + w: 400.0, + h: 800.0, + center: Point::new(1080.0, 400.0), + } +} + +pub fn left_panel() -> PanelRect { + PanelRect { + x: 0.0, + y: 0.0, + w: 320.0, + h: 700.0, + center: Point::new(160.0, 350.0), + } +} + +pub fn top_panel() -> PanelRect { + PanelRect { + x: 120.0, + y: 0.0, + w: 720.0, + h: 180.0, + center: Point::new(480.0, 90.0), + } +} + +pub fn bottom_panel() -> PanelRect { + PanelRect { + x: 120.0, + y: 620.0, + w: 720.0, + h: 180.0, + center: Point::new(480.0, 710.0), + } +} + +pub fn floating_panel() -> PanelRect { + PanelRect { + x: 300.0, + y: 180.0, + w: 420.0, + h: 320.0, + center: Point::new(510.0, 340.0), + } +} + +pub fn assert_line(actual: BurstLine, start: Point, end: Point, direction: Vector) { + assert_eq!(actual.start, start); + assert_eq!(actual.end, end); + assert_eq!(actual.direction, direction); +} + +pub fn first_particle_of(flourish: &PanelFlourish, class: FeatherClass) -> FeatherParticle { + *flourish + .particles() + .iter() + .find(|p| p.class == class) + .expect("burst should include requested feather class") +} + +pub fn size_range_for(flourish: &PanelFlourish, class: FeatherClass) -> f32 { + let sizes = flourish + .particles() + .iter() + .filter(|p| p.class == class) + .map(|p| p.size) + .collect::>(); + assert!( + sizes.len() > 1, + "burst should include multiple particles for {class:?}" + ); + let min = sizes.iter().copied().fold(f32::INFINITY, f32::min); + let max = sizes.iter().copied().fold(f32::NEG_INFINITY, f32::max); + max - min +} + +pub fn tick_for(flourish: &mut PanelFlourish, start: Instant, total: Duration) { + let total_ms = total.as_millis() as u64; + let mut elapsed_ms = 16; + while elapsed_ms < total_ms { + assert!(flourish.tick(start + Duration::from_millis(elapsed_ms), None)); + elapsed_ms += 16; + } + assert!(flourish.tick(start + total, None)); +} diff --git a/tests/side_panel_flourish.rs b/tests/side_panel_flourish.rs index 22fa1ad..4af586d 100644 --- a/tests/side_panel_flourish.rs +++ b/tests/side_panel_flourish.rs @@ -1,194 +1,18 @@ +//! Motion and animation tests for the panel-feather flourish: drag, wobble, +//! gravity, fade, and cursor interaction. Emitter geometry lives in +//! `side_panel_flourish_emitter.rs`; shared fixtures in `flourish_support`. +mod flourish_support; + use std::collections::BTreeSet; use std::time::{Duration, Instant}; -use honkhonk::ui::side_panel::{ - BurstEmitter, BurstLine, FeatherClass, PanelFlourish, PanelRect, PanelTransition, - panel_burst_emitter, -}; -use iced::{Point, Vector}; - -fn right_panel() -> PanelRect { - PanelRect { - x: 880.0, - y: 0.0, - w: 400.0, - h: 800.0, - center: Point::new(1080.0, 400.0), - } -} - -fn left_panel() -> PanelRect { - PanelRect { - x: 0.0, - y: 0.0, - w: 320.0, - h: 700.0, - center: Point::new(160.0, 350.0), - } -} - -fn top_panel() -> PanelRect { - PanelRect { - x: 120.0, - y: 0.0, - w: 720.0, - h: 180.0, - center: Point::new(480.0, 90.0), - } -} - -fn bottom_panel() -> PanelRect { - PanelRect { - x: 120.0, - y: 620.0, - w: 720.0, - h: 180.0, - center: Point::new(480.0, 710.0), - } -} - -fn floating_panel() -> PanelRect { - PanelRect { - x: 300.0, - y: 180.0, - w: 420.0, - h: 320.0, - center: Point::new(510.0, 340.0), - } -} - -fn assert_line(actual: BurstLine, start: Point, end: Point, direction: Vector) { - assert_eq!(actual.start, start); - assert_eq!(actual.end, end); - assert_eq!(actual.direction, direction); -} - -fn first_particle_of( - flourish: &PanelFlourish, - class: FeatherClass, -) -> honkhonk::ui::side_panel::FeatherParticle { - *flourish - .particles() - .iter() - .find(|p| p.class == class) - .expect("burst should include requested feather class") -} - -fn size_range_for(flourish: &PanelFlourish, class: FeatherClass) -> f32 { - let sizes = flourish - .particles() - .iter() - .filter(|p| p.class == class) - .map(|p| p.size) - .collect::>(); - assert!( - sizes.len() > 1, - "burst should include multiple particles for {class:?}" - ); - let min = sizes.iter().copied().fold(f32::INFINITY, f32::min); - let max = sizes.iter().copied().fold(f32::NEG_INFINITY, f32::max); - max - min -} - -fn tick_for(flourish: &mut PanelFlourish, start: Instant, total: Duration) { - let total_ms = total.as_millis() as u64; - let mut elapsed_ms = 16; - while elapsed_ms < total_ms { - assert!(flourish.tick(start + Duration::from_millis(elapsed_ms), None)); - elapsed_ms += 16; - } - assert!(flourish.tick(start + total, None)); -} - -#[test] -fn right_docked_panel_emits_from_full_inner_edge_away_from_panel() { - let emitter = panel_burst_emitter(right_panel(), (1280.0, 800.0)); - let BurstEmitter::Edge(line) = emitter else { - panic!("right docked panel should emit from an edge line"); - }; - assert_line( - line, - Point::new(880.0, 0.0), - Point::new(880.0, 800.0), - Vector::new(-1.0, 0.0), - ); -} - -#[test] -fn all_docked_panel_edges_emit_from_full_inner_edge() { - let BurstEmitter::Edge(left) = panel_burst_emitter(left_panel(), (1280.0, 700.0)) else { - panic!("left docked panel should emit from an edge line"); - }; - let BurstEmitter::Edge(top) = panel_burst_emitter(top_panel(), (960.0, 800.0)) else { - panic!("top docked panel should emit from an edge line"); - }; - let BurstEmitter::Edge(bottom) = panel_burst_emitter(bottom_panel(), (960.0, 800.0)) else { - panic!("bottom docked panel should emit from an edge line"); - }; - assert_line( - left, - Point::new(320.0, 0.0), - Point::new(320.0, 700.0), - Vector::new(1.0, 0.0), - ); - assert_line( - top, - Point::new(120.0, 180.0), - Point::new(840.0, 180.0), - Vector::new(0.0, 1.0), - ); - assert_line( - bottom, - Point::new(120.0, 620.0), - Point::new(840.0, 620.0), - Vector::new(0.0, -1.0), - ); -} - -#[test] -fn floating_panel_emits_from_center() { - let emitter = panel_burst_emitter(floating_panel(), (1280.0, 800.0)); - assert_eq!(emitter, BurstEmitter::Center(Point::new(510.0, 340.0))); -} - -#[test] -fn emitted_edge_particles_start_away_from_panel() { - let now = Instant::now(); - let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); - let particles = flourish.particles(); - assert!(!particles.is_empty()); - assert!(particles.iter().all(|p| p.velocity.x < 0.0)); -} - -#[test] -fn edge_particles_span_most_of_the_panel_edge() { - let now = Instant::now(); - let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); - let particles = flourish.particles(); - assert!(!particles.is_empty()); - let min_y = particles - .iter() - .map(|p| p.position.y) - .fold(f32::INFINITY, f32::min); - let max_y = particles - .iter() - .map(|p| p.position.y) - .fold(f32::NEG_INFINITY, f32::max); - assert!(min_y < 80.0, "feathers should start near the top edge"); - assert!(max_y > 720.0, "feathers should start near the bottom edge"); - assert!( - max_y - min_y > 700.0, - "feathers should span most of the edge" - ); -} +use flourish_support::*; #[test] fn burst_seeds_all_feather_classes() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); let classes = flourish .particles() @@ -205,7 +29,7 @@ fn burst_seeds_all_feather_classes() { fn feather_classes_have_distinct_visual_sizes() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); let dust_size = first_particle_of(&flourish, FeatherClass::Dust).size; let chunk_size = first_particle_of(&flourish, FeatherClass::Chunk).size; @@ -219,7 +43,7 @@ fn feather_classes_have_distinct_visual_sizes() { fn same_class_particles_have_seed_variation() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); assert!(size_range_for(&flourish, FeatherClass::Dust) > 0.5); assert!(size_range_for(&flourish, FeatherClass::Chunk) > 0.5); @@ -230,7 +54,7 @@ fn same_class_particles_have_seed_variation() { fn dust_descends_farther_than_full_feather() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); let dust_start = first_particle_of(&flourish, FeatherClass::Dust).position.y; let feather_start = first_particle_of(&flourish, FeatherClass::Feather) @@ -245,8 +69,10 @@ fn dust_descends_farther_than_full_feather() { .y - feather_start; + // Assert the ordering invariant, not a tuning-specific pixel gap: an + // aesthetic retune that keeps "dust descends farther" must stay green. assert!( - dust_drop > feather_drop + 18.0, + dust_drop > feather_drop, "dust should fall faster than a full feather: dust={dust_drop}, feather={feather_drop}" ); } @@ -255,7 +81,7 @@ fn dust_descends_farther_than_full_feather() { fn full_feathers_swoop_more_than_dust() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); let dust_start = first_particle_of(&flourish, FeatherClass::Dust).position.x; let feather_start = first_particle_of(&flourish, FeatherClass::Feather) @@ -271,8 +97,9 @@ fn full_feathers_swoop_more_than_dust() { - feather_start) .abs(); + // Ordering invariant only — no absolute-pixel coupling to current tuning. assert!( - feather_dx > dust_dx + 8.0, + feather_dx > dust_dx, "full feathers should have more lateral swoop: dust={dust_dx}, feather={feather_dx}" ); } @@ -281,7 +108,7 @@ fn full_feathers_swoop_more_than_dust() { fn long_frame_hitch_does_not_snap_feather_drag_to_zero() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); let feather_start = first_particle_of(&flourish, FeatherClass::Feather) .position @@ -301,37 +128,36 @@ fn long_frame_hitch_does_not_snap_feather_drag_to_zero() { } #[test] -fn same_class_feathers_diverge_from_seeded_wobble_phase() { +fn same_class_feathers_diverge_laterally() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); - let starts = flourish + // Record each full feather's seed lateral position, then tick and measure + // how far each drifted sideways. Distinct seeded wobble phases must make the + // class spread, not move in lockstep. Asserting spread across the whole set + // avoids depending on two particles happening to share identical seeds. + let starts: Vec<(usize, f32)> = flourish .particles() .iter() .enumerate() .filter(|(_, p)| p.class == FeatherClass::Feather) - .map(|(i, p)| (i, p.position.x, p.velocity.x)) - .collect::>(); - let Some((first, second)) = starts.iter().enumerate().find_map(|(i, first)| { - starts.iter().skip(i + 1).find_map(|second| { - let same_x = (first.1 - second.1).abs() <= f32::EPSILON; - let same_vx = (first.2 - second.2).abs() <= f32::EPSILON; - (same_x && same_vx).then_some((*first, *second)) - }) - }) else { - panic!("burst should include same-class feathers with matching x seeds"); - }; + .map(|(i, p)| (i, p.position.x)) + .collect(); + assert!(starts.len() >= 2, "burst should seed multiple full feathers"); tick_for(&mut flourish, now, Duration::from_millis(1200)); - let first_dx = flourish.particles()[first.0].position.x - first.1; - let second_dx = flourish.particles()[second.0].position.x - second.1; - let divergence = (first_dx - second_dx).abs(); + let drifts: Vec = starts + .iter() + .map(|(i, start_x)| flourish.particles()[*i].position.x - start_x) + .collect(); + let spread = drifts.iter().copied().fold(f32::MIN, f32::max) + - drifts.iter().copied().fold(f32::MAX, f32::min); assert!( - divergence > 4.0, - "same-class feathers should diverge from seeded wobble phase: first={first_dx}, second={second_dx}, divergence={divergence}" + spread > 4.0, + "same-class feathers should diverge laterally, not move in lockstep: drifts={drifts:?}" ); } @@ -339,7 +165,7 @@ fn same_class_feathers_diverge_from_seeded_wobble_phase() { fn feathers_float_down_and_fade_out() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Close, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); let start_y = flourish.particles()[0].position.y; assert!(flourish.tick(now + Duration::from_millis(1500), None)); let mid = flourish.particles()[0]; @@ -353,7 +179,7 @@ fn feathers_float_down_and_fade_out() { fn all_classes_clear_at_shared_burst_duration() { let now = Instant::now(); let mut flourish = PanelFlourish::default(); - flourish.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + flourish.emit(right_panel(), (1280.0, 800.0), now); assert!( flourish @@ -383,7 +209,7 @@ fn all_classes_clear_at_shared_burst_duration() { fn cursor_gently_bumps_nearby_feathers() { let now = Instant::now(); let mut plain = PanelFlourish::default(); - plain.emit(right_panel(), (1280.0, 800.0), PanelTransition::Open, now); + plain.emit(right_panel(), (1280.0, 800.0), now); let mut bumped = plain.clone(); let first = plain.particles()[0]; let cursor = Point::new(first.position.x + 8.0, first.position.y); diff --git a/tests/side_panel_flourish_emitter.rs b/tests/side_panel_flourish_emitter.rs new file mode 100644 index 0000000..b0457ec --- /dev/null +++ b/tests/side_panel_flourish_emitter.rs @@ -0,0 +1,108 @@ +//! Emitter-geometry tests for the panel-feather flourish: where the burst seeds +//! from given a panel's docked edge (or lack of one). Motion/animation tests +//! live in `side_panel_flourish.rs`; shared fixtures in `flourish_support`. +mod flourish_support; + +use std::time::Instant; + +use flourish_support::*; + +#[test] +fn right_docked_panel_emits_from_full_inner_edge_away_from_panel() { + let emitter = panel_burst_emitter(right_panel(), (1280.0, 800.0)); + let BurstEmitter::Edge(line) = emitter else { + panic!("right docked panel should emit from an edge line"); + }; + assert_line( + line, + Point::new(880.0, 0.0), + Point::new(880.0, 800.0), + Vector::new(-1.0, 0.0), + ); +} + +#[test] +fn all_docked_panel_edges_emit_from_full_inner_edge() { + let BurstEmitter::Edge(left) = panel_burst_emitter(left_panel(), (1280.0, 700.0)) else { + panic!("left docked panel should emit from an edge line"); + }; + let BurstEmitter::Edge(top) = panel_burst_emitter(top_panel(), (960.0, 800.0)) else { + panic!("top docked panel should emit from an edge line"); + }; + let BurstEmitter::Edge(bottom) = panel_burst_emitter(bottom_panel(), (960.0, 800.0)) else { + panic!("bottom docked panel should emit from an edge line"); + }; + assert_line( + left, + Point::new(320.0, 0.0), + Point::new(320.0, 700.0), + Vector::new(1.0, 0.0), + ); + assert_line( + top, + Point::new(120.0, 180.0), + Point::new(840.0, 180.0), + Vector::new(0.0, 1.0), + ); + assert_line( + bottom, + Point::new(120.0, 620.0), + Point::new(840.0, 620.0), + Vector::new(0.0, -1.0), + ); +} + +#[test] +fn panels_without_a_spreadable_edge_emit_from_center() { + // A free-floating panel has no docked edge to spread along. + assert_eq!( + panel_burst_emitter(floating_panel(), (1280.0, 800.0)), + BurstEmitter::Center(Point::new(510.0, 340.0)), + ); + // A docked panel collapsed to zero height touches an edge but has no span, + // so it must also fall back to the center rather than a zero-length edge. + let collapsed = PanelRect { + x: 880.0, + y: 400.0, + w: 400.0, + h: 0.0, + center: Point::new(1080.0, 400.0), + }; + assert_eq!( + panel_burst_emitter(collapsed, (1280.0, 800.0)), + BurstEmitter::Center(Point::new(1080.0, 400.0)), + ); +} + +#[test] +fn emitted_edge_particles_start_away_from_panel() { + let now = Instant::now(); + let mut flourish = PanelFlourish::default(); + flourish.emit(right_panel(), (1280.0, 800.0), now); + let particles = flourish.particles(); + assert!(!particles.is_empty()); + assert!(particles.iter().all(|p| p.velocity.x < 0.0)); +} + +#[test] +fn edge_particles_span_most_of_the_panel_edge() { + let now = Instant::now(); + let mut flourish = PanelFlourish::default(); + flourish.emit(right_panel(), (1280.0, 800.0), now); + let particles = flourish.particles(); + assert!(!particles.is_empty()); + let min_y = particles + .iter() + .map(|p| p.position.y) + .fold(f32::INFINITY, f32::min); + let max_y = particles + .iter() + .map(|p| p.position.y) + .fold(f32::NEG_INFINITY, f32::max); + assert!(min_y < 80.0, "feathers should start near the top edge"); + assert!(max_y > 720.0, "feathers should start near the bottom edge"); + assert!( + max_y - min_y > 700.0, + "feathers should span most of the edge" + ); +} From c8932fb092d476262496fa1fdff54f6608a65c14 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 5 Jul 2026 23:16:55 -0700 Subject: [PATCH 2/4] style: apply cargo fmt to flourish tests Fixes the CI lint (cargo fmt --check) failure on this branch. Co-Authored-By: Claude Fable 5 --- tests/side_panel_flourish.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/side_panel_flourish.rs b/tests/side_panel_flourish.rs index 4af586d..bbd0c76 100644 --- a/tests/side_panel_flourish.rs +++ b/tests/side_panel_flourish.rs @@ -144,7 +144,10 @@ fn same_class_feathers_diverge_laterally() { .filter(|(_, p)| p.class == FeatherClass::Feather) .map(|(i, p)| (i, p.position.x)) .collect(); - assert!(starts.len() >= 2, "burst should seed multiple full feathers"); + assert!( + starts.len() >= 2, + "burst should seed multiple full feathers" + ); tick_for(&mut flourish, now, Duration::from_millis(1200)); From c6ea92df2c748beaa8d99d5b663dfdfc498bfe17 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 5 Jul 2026 23:16:55 -0700 Subject: [PATCH 3/4] docs: sync feather emitter spec with shipped emit API The spec still showed the removed transition parameter on PanelFlourish::emit, mentioned the dropped close-transition rotation bias, and omitted the side_panel_flourish_emitter test binary. Addresses CodeRabbit nitpick on PR #188. Co-Authored-By: Claude Fable 5 --- .../specs/2026-06-26-panel-feather-edge-emitter-design.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md b/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md index 94b6dd1..fc0be30 100644 --- a/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md +++ b/docs/superpowers/specs/2026-06-26-panel-feather-edge-emitter-design.md @@ -91,8 +91,8 @@ position = lerp(line.start, line.end, t) + outward_offset + side_jitter The stratification gives even coverage across the whole edge. The deterministic jitter prevents the particles from looking mechanically spaced. Velocity keeps -the current outward direction, scatter, speed variation, close-transition -rotation bias, gravity, and cursor interaction. +the current outward direction, scatter, speed variation, gravity, and cursor +interaction. ## Data Flow @@ -101,7 +101,7 @@ The existing app flow remains unchanged: ```text ToggleEffectsPanel / CloseEffectsPanel -> app/panels.rs computes panel_geometry(...) - -> PanelFlourish::emit(panel, window, transition, now) + -> PanelFlourish::emit(panel, window, now) -> side_panel/flourish.rs computes BurstEmitter -> side_panel/flourish_view.rs renders current particles ``` @@ -132,6 +132,7 @@ Run at minimum: ```bash cargo test --test side_panel_flourish +cargo test --test side_panel_flourish_emitter cargo test app::panels::tests ``` From bb5c5bf2ec40786ed01fbe8696ae3c5898d44f00 Mon Sep 17 00:00:00 2001 From: thewrz Date: Sun, 5 Jul 2026 23:35:24 -0700 Subject: [PATCH 4/4] ci(deny): ignore quick-xml RUSTSEC-2026-0194/0195 pending upstream bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both advisories are DoS-via-untrusted-XML in quick-xml < 0.41. The sole consumer is wayland-scanner, a build-time proc-macro parsing vendored Wayland protocol XML; no untrusted XML is parsed at runtime, so neither issue is reachable in the shipped binary. wayland-scanner 0.31.10 (latest) still pins quick-xml ^0.39 — drop these ignores when it moves to 0.41. Closes #192. Co-Authored-By: Claude Fable 5 --- deny.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deny.toml b/deny.toml index badad7e..bb6cf34 100644 --- a/deny.toml +++ b/deny.toml @@ -17,6 +17,13 @@ ignore = [ # ttf-parser is unmaintained; transitive via iced's font stack. No safe # upgrade is available until iced/cosmic-text/winit move off it upstream. { id = "RUSTSEC-2026-0192", reason = "transitive via iced font dependencies, no patched version available upstream" }, + # quick-xml < 0.41 DoS via untrusted XML (quadratic attribute check / + # unbounded namespace allocations). Sole consumer here is wayland-scanner, + # a build-time proc-macro parsing the vendored Wayland protocol XML — no + # untrusted XML is ever parsed at runtime. wayland-scanner 0.31.10 (latest) + # still requires quick-xml ^0.39; drop both ignores when it moves to 0.41. + { id = "RUSTSEC-2026-0194", reason = "build-time only: wayland-scanner proc-macro parses vendored protocol XML, never untrusted input; no upstream release requires the patched quick-xml 0.41 yet" }, + { id = "RUSTSEC-2026-0195", reason = "build-time only: wayland-scanner proc-macro parses vendored protocol XML, never untrusted input; no upstream release requires the patched quick-xml 0.41 yet" }, ] # ── Licenses ──────────────────────────────────────────────────────────────────