From 513ea50946efa0e74b066097416eff296398d1e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Wed, 7 Oct 2026 06:39:05 -0400 Subject: [PATCH] =?UTF-8?q?Panics=20=C2=B7=20P38=20Interaction:=20handlers?= =?UTF-8?q?=20in=20new;=20resizes=20fit=20the=20limits=20now,=20stale=20dr?= =?UTF-8?q?ags=20and=20leaders=20do=20nothing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/gallery/pages/interaction/keys.rs | 20 +-- examples/gallery/pages/interaction/pointer.rs | 32 ++-- src/interaction/resizable.rs | 64 ++++---- src/interaction/rotatable.rs | 15 +- src/interaction/roving.rs | 23 +-- src/interaction/selection.rs | 27 ++-- src/interaction/sync.rs | 5 +- src/interaction/tests/limits.rs | 142 ++++++++++++++++++ src/interaction/{tests.rs => tests/mod.rs} | 82 ++++++---- tasks/panics.md | 3 +- 10 files changed, 278 insertions(+), 135 deletions(-) create mode 100644 src/interaction/tests/limits.rs rename src/interaction/{tests.rs => tests/mod.rs} (89%) diff --git a/examples/gallery/pages/interaction/keys.rs b/examples/gallery/pages/interaction/keys.rs index 93fa729..ce44a5a 100644 --- a/examples/gallery/pages/interaction/keys.rs +++ b/examples/gallery/pages/interaction/keys.rs @@ -246,12 +246,15 @@ pub fn focus(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { ("strike", IconName::Strikethrough), ]; let toolbar = tools.into_iter().fold( - RovingFocus::new("interaction-tools", Axis::Horizontal) - .gap_0p5() - .p_0p5() - .rounded_lg() - .border_1() - .border_color(theme.colors.border), + RovingFocus::new("interaction-tools", Axis::Horizontal, move |key, _, cx| { + let key = key.clone(); + change(&press, cx, |focusing| focusing.tool = key) + }) + .gap_0p5() + .p_0p5() + .rounded_lg() + .border_1() + .border_color(theme.colors.border), |bar, (key, icon)| { let on = tool.as_ref() == key; bar.item( @@ -308,10 +311,7 @@ pub fn focus(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { "RovingFocus", probe( "interaction-tools", - toolbar.on_press(move |key, _, cx| { - let key = key.clone(); - change(&press, cx, |focusing| focusing.tool = key) - }), + toolbar, ), cx, )) diff --git a/examples/gallery/pages/interaction/pointer.rs b/examples/gallery/pages/interaction/pointer.rs index dbf1aaa..aa3f756 100644 --- a/examples/gallery/pages/interaction/pointer.rs +++ b/examples/gallery/pages/interaction/pointer.rs @@ -162,15 +162,18 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { let theme = cx.theme(); let [resize, turn, pick, sort, double, press, long] = [(); 7].map(|_| state.clone()); let tiles = (0..12).fold( - SelectionArea::new("interaction-tiles") - .w(px(300.)) - .flex() - .flex_wrap() - .gap(px(8.)) - .p_3() - .rounded_lg() - .border_1() - .border_color(theme.colors.border), + SelectionArea::new("interaction-tiles", move |keys, _, cx| { + let keys = keys.to_vec(); + change(&pick, cx, |shapes| shapes.picked = keys) + }) + .w(px(300.)) + .flex() + .flex_wrap() + .gap(px(8.)) + .p_3() + .rounded_lg() + .border_1() + .border_color(theme.colors.border), |area, ix| { area.item( SharedString::from(format!("tile-{ix}")), @@ -204,8 +207,7 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { "Resizable", probe( "interaction-resizable", - Resizable::new("interaction-resizable", size_now, size(px(160.), px(96.)), size(px(360.), px(220.))) - .on_resize(move |next, _, cx| change(&resize, cx, |shapes| shapes.size = next)) + Resizable::new("interaction-resizable", size_now, size(px(160.), px(96.)), size(px(360.), px(220.)), move |next, _, cx| change(&resize, cx, |shapes| shapes.size = next)) .child( div() .size_full() @@ -225,8 +227,7 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { "Rotatable", probe( "interaction-rotatable", - Rotatable::new("interaction-rotatable", "icons/arrow-up.svg", px(120.), angle) - .on_turn(move |next, _, cx| change(&turn, cx, |shapes| shapes.angle = next)), + Rotatable::new("interaction-rotatable", "icons/arrow-up.svg", px(120.), angle, move |next, _, cx| change(&turn, cx, |shapes| shapes.angle = next)), ), cx, )), @@ -237,10 +238,7 @@ pub fn shapes(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { "SelectionArea", probe( "interaction-tiles", - tiles.selected(picked).on_change(move |keys, _, cx| { - let keys = keys.to_vec(); - change(&pick, cx, |shapes| shapes.picked = keys) - }), + tiles.selected(picked), ), cx, )) diff --git a/src/interaction/resizable.rs b/src/interaction/resizable.rs index e6f94c3..bf2445d 100644 --- a/src/interaction/resizable.rs +++ b/src/interaction/resizable.rs @@ -27,22 +27,30 @@ struct ResizeDrag { start: Size, } -/// The size after moving `side` by `delta` from `start`, kept within `min` and `max`. +/// `size` within `min` and `max`. +fn fit(size: Size, (min, max): (Size, Size)) -> Size { + gpui::size( + size.width.clamp(min.width, max.width), + size.height.clamp(min.height, max.height), + ) +} + +/// The size after moving `side` by `delta` from `start`, both measures kept within the limits. fn resized( start: Size, side: Side, delta: Point, - (min, max): (Size, Size), + limits: (Size, Size), ) -> Size { let width = match side { Side::Bottom => start.width, - Side::Right | Side::Corner => (start.width + delta.x).clamp(min.width, max.width), + Side::Right | Side::Corner => start.width + delta.x, }; let height = match side { Side::Right => start.height, - Side::Bottom | Side::Corner => (start.height + delta.y).clamp(min.height, max.height), + Side::Bottom | Side::Corner => start.height + delta.y, }; - size(width, height) + fit(size(width, height), limits) } type OnResize = Rc, &mut Window, &mut App)>; @@ -54,7 +62,7 @@ pub struct Resizable { size: Size, limits: (Size, Size), children: SmallVec<[AnyElement; 1]>, - on_resize: Option, + on_resize: OnResize, } impl Resizable { @@ -64,38 +72,24 @@ impl Resizable { size: Size, min: Size, max: Size, + on_resize: impl Fn(Size, &mut Window, &mut App) + 'static, ) -> Self { let id = id.into(); assert!( - min.width <= size.width && size.width <= max.width, - "resizable {id:?}: width {:?} is not within {:?} and {:?}", - size.width, - min.width, - max.width - ); - assert!( - min.height <= size.height && size.height <= max.height, - "resizable {id:?}: height {:?} is not within {:?} and {:?}", - size.height, - min.height, - max.height + min.width <= max.width && min.height <= max.height, + "resizable {id:?}: limits {min:?} to {max:?} run backwards" ); + if fit(size, (min, max)) != size { + log::error!("resizable {id:?}: size {size:?} outside {min:?} to {max:?}; pegged"); + } Self { id, size, limits: (min, max), children: SmallVec::new(), - on_resize: None, + on_resize: Rc::new(on_resize), } } - - pub fn on_resize( - mut self, - handler: impl Fn(Size, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_resize = Some(Rc::new(handler)); - self - } } impl ParentElement for Resizable { @@ -107,14 +101,17 @@ impl ParentElement for Resizable { impl RenderOnce for Resizable { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_resize = self - .on_resize - .unwrap_or_else(|| panic!("resizable {id:?} has no on_resize")); + let on_resize = self.on_resize; let limits = self.limits; let held = use_seeded((id.clone(), "size"), self.size, window, cx); let pressed = window.use_keyed_state((id.clone(), "press"), cx, |_, _| None::>); - let (owner, now) = (held.entity_id(), held.read(cx).value); + let (owner, kept) = (held.entity_id(), held.read(cx).value); + let now = fit(kept, limits); + if now != kept { + log::info!("resizable: {kept:?} fits the limits at {now:?}"); + held.update(cx, |held, _| held.value = now); + } let report: OnResize = Rc::new(move |next, window, cx| { log::info!("resizable: {:?} by {:?}", next.width, next.height); held.update(cx, |held, cx| { @@ -153,7 +150,10 @@ impl RenderOnce for Resizable { return; } let at = event.event.position; - let anchor = pressed.read(cx).expect("a resize starts from a press"); + let Some(anchor) = *pressed.read(cx) else { + log::error!("resizable: a drag with no press behind it"); + return; + }; let next = resized(drag.start, drag.side, at - anchor, limits); if next != now { dragged(next, window, cx); diff --git a/src/interaction/rotatable.rs b/src/interaction/rotatable.rs index ba2d712..ca58670 100644 --- a/src/interaction/rotatable.rs +++ b/src/interaction/rotatable.rs @@ -35,7 +35,7 @@ pub struct Rotatable { path: SharedString, side: Pixels, angle: f32, - on_turn: Option, + on_turn: OnTurn, } impl Rotatable { @@ -45,6 +45,7 @@ impl Rotatable { path: impl Into, side: Pixels, angle: f32, + on_turn: impl Fn(f32, &mut Window, &mut App) + 'static, ) -> Self { assert!( angle.is_finite(), @@ -55,23 +56,15 @@ impl Rotatable { path: path.into(), side, angle: angle.rem_euclid(360.0), - on_turn: None, + on_turn: Rc::new(on_turn), } } - - /// Runs with each new angle. - pub fn on_turn(mut self, handler: impl Fn(f32, &mut Window, &mut App) + 'static) -> Self { - self.on_turn = Some(Rc::new(handler)); - self - } } impl RenderOnce for Rotatable { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_turn = self - .on_turn - .unwrap_or_else(|| panic!("rotatable {id:?} has no on_turn")); + let on_turn = self.on_turn; let held = use_seeded((id.clone(), "angle"), self.angle, window, cx); let (owner, angle) = (held.entity_id(), held.read(cx).value); let turn: OnTurn = Rc::new(move |next, window, cx| { diff --git a/src/interaction/roving.rs b/src/interaction/roving.rs index dd610db..d74bdb1 100644 --- a/src/interaction/roving.rs +++ b/src/interaction/roving.rs @@ -35,17 +35,21 @@ pub struct RovingFocus { axis: Axis, base: Div, items: Vec<(SharedString, AnyElement)>, - on_press: Option, + on_press: OnKey, } impl RovingFocus { - pub fn new(id: impl Into, axis: Axis) -> Self { + pub fn new( + id: impl Into, + axis: Axis, + on_press: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), axis, base: div(), items: Vec::new(), - on_press: None, + on_press: Rc::new(on_press), } } @@ -53,15 +57,6 @@ impl RovingFocus { self.items.push((key.into(), content.into_any_element())); self } - - /// Runs with the key of the item pressed. - pub fn on_press( - mut self, - handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, - ) -> Self { - self.on_press = Some(Rc::new(handler)); - self - } } impl Styled for RovingFocus { @@ -73,9 +68,7 @@ impl Styled for RovingFocus { impl RenderOnce for RovingFocus { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_press = self - .on_press - .unwrap_or_else(|| panic!("roving focus {id:?} has no on_press")); + let on_press = self.on_press; assert!(!self.items.is_empty(), "roving focus {id:?} has no item"); let stop = window.use_keyed_state((id.clone(), "stop"), cx, |_, _| 0usize); let count = self.items.len(); diff --git a/src/interaction/selection.rs b/src/interaction/selection.rs index 59d5f8e..c2e2bf6 100644 --- a/src/interaction/selection.rs +++ b/src/interaction/selection.rs @@ -82,17 +82,20 @@ pub struct SelectionArea { base: Div, items: Vec<(SharedString, AnyElement)>, selected: Vec, - on_change: Option, + on_change: OnSelect, } impl SelectionArea { - pub fn new(id: impl Into) -> Self { + pub fn new( + id: impl Into, + on_change: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, + ) -> Self { Self { id: id.into(), base: div(), items: Vec::new(), selected: Vec::new(), - on_change: None, + on_change: Rc::new(on_change), } } @@ -105,15 +108,6 @@ impl SelectionArea { self.selected = keys.into_iter().map(Into::into).collect(); self } - - /// Gets the keys now selected, in the items' order. - pub fn on_change( - mut self, - handler: impl Fn(&[SharedString], &mut Window, &mut App) + 'static, - ) -> Self { - self.on_change = Some(Rc::new(handler)); - self - } } impl Styled for SelectionArea { @@ -125,9 +119,7 @@ impl Styled for SelectionArea { impl RenderOnce for SelectionArea { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - let on_change = self - .on_change - .unwrap_or_else(|| panic!("selection area {id:?} has no on_change")); + let on_change = self.on_change; let area = window.use_keyed_state((id.clone(), "area"), cx, |_, _| Area::default()); let owner = area.entity_id(); let focus = tab_stop((id.clone(), "focus").into(), true, window, cx); @@ -233,7 +225,10 @@ impl RenderOnce for SelectionArea { } let origin = event.bounds.origin; let at = event.event.position - origin; - let press = moved.read(cx).press.expect("a band starts from a press"); + let Some(press) = moved.read(cx).press else { + log::error!("selection area: a band with no press behind it"); + return; + }; let start = press - origin; let adding = match event.event.modifiers.platform || event.event.modifiers.shift { true => drag.adding.clone(), diff --git a/src/interaction/sync.rs b/src/interaction/sync.rs index bf53908..dba5a79 100644 --- a/src/interaction/sync.rs +++ b/src/interaction/sync.rs @@ -64,7 +64,10 @@ impl RenderOnce for ScrollSync { let sync = canvas( move |_, _, _| { let Some(lead) = lead else { return }; - let place = synced[lead].offset(); + let Some(place) = synced.get(lead).map(|handle| handle.offset()) else { + log::error!("scroll sync: leader pane {lead} of {} left", synced.len()); + return; + }; for (ix, handle) in synced.iter().enumerate() { let now = handle.offset(); let next = along(now, place, axis); diff --git a/src/interaction/tests/limits.rs b/src/interaction/tests/limits.rs new file mode 100644 index 0000000..9480d97 --- /dev/null +++ b/src/interaction/tests/limits.rs @@ -0,0 +1,142 @@ +use gpui::{ + AnyElement, Axis, Entity, IntoElement, Modifiers, MouseButton, ParentElement, Styled, + TestAppContext, div, point, px, size, +}; + +use super::{Bench, at, bench, picked, settle, tab, tap}; +use crate::interaction::{Resizable, ScrollSync}; + +/// A box whose greatest width drops to 205 once the angle turns. +fn narrowing(bench: &Bench, _: Entity) -> AnyElement { + let max_width = if bench.angle == 0.0 { 300.0 } else { 205.0 }; + Resizable::new( + "box", + bench.size, + size(px(100.0), px(80.0)), + size(px(max_width), px(200.0)), + |_, _, _| {}, + ) + .child(div().size_full()) + .into_any_element() +} + +fn width(cx: &mut gpui::VisualTestContext) -> gpui::Pixels { + cx.debug_bounds("resizable-box") + .expect("the box") + .size + .width +} + +#[gpui::test] +fn a_local_size_fits_limits_that_shrank(cx: &mut TestAppContext) { + let (host, cx) = bench(narrowing, cx); + tab(1, cx); + tap("right", cx); + assert_eq!(width(cx), px(208.0)); + host.update(cx, |bench, cx| { + bench.angle = 1.0; + cx.notify(); + }); + settle(cx); + assert_eq!(width(cx), px(205.0)); +} + +#[gpui::test] +fn a_new_owner_size_past_the_limits_replaces_a_local_one(cx: &mut TestAppContext) { + let (host, cx) = bench(narrowing, cx); + host.update(cx, |bench, cx| { + bench.size.width = px(400.0); + cx.notify(); + }); + settle(cx); + assert_eq!(width(cx), px(300.0)); + tab(1, cx); + tap("left", cx); + assert_eq!(width(cx), px(292.0)); + host.update(cx, |bench, cx| { + bench.size.width = px(500.0); + cx.notify(); + }); + settle(cx); + assert_eq!(width(cx), px(300.0)); +} + +/// A box whose greatest height drops to 100 once the angle turns, and the heights it reports. +fn lowering(bench: &Bench, owner: Entity) -> AnyElement { + let max_height = if bench.angle == 0.0 { 200.0 } else { 100.0 }; + Resizable::new( + "box", + bench.size, + size(px(100.0), px(80.0)), + size(px(300.0), px(max_height)), + move |next, _, cx| { + owner.update(cx, |bench, cx| { + bench.size = next; + bench + .selected + .push(format!("{}", f32::from(next.height)).into()); + cx.notify(); + }) + }, + ) + .child(div().size_full()) + .into_any_element() +} + +#[gpui::test] +fn an_edge_drag_reports_both_measures_within_the_limits_now(cx: &mut TestAppContext) { + let (host, cx) = bench(lowering, cx); + let none = Modifiers::none(); + cx.simulate_mouse_down(at(217.0, 60.0), MouseButton::Left, none); + for x in [230.0, 240.0] { + cx.simulate_mouse_move(at(x, 60.0), Some(MouseButton::Left), none); + settle(cx); + } + assert!(!picked(&host, cx).is_empty(), "the drag began"); + host.update(cx, |bench, cx| { + bench.angle = 1.0; + bench.selected.clear(); + cx.notify(); + }); + settle(cx); + cx.simulate_mouse_move(at(250.0, 60.0), Some(MouseButton::Left), none); + settle(cx); + assert_eq!(picked(&host, cx).last().map(String::as_str), Some("100")); +} + +/// Panes that drop to two once the angle turns. +fn synced(bench: &Bench, _: Entity) -> AnyElement { + let count = if bench.angle == 0.0 { 3 } else { 2 }; + (0..count) + .fold( + ScrollSync::new("sync", Axis::Vertical) + .w(px(300.0)) + .h(px(200.0)), + |sync, _| { + sync.pane( + div() + .flex() + .flex_col() + .children((0..40).map(|_| div().h(px(30.0)))), + ) + }, + ) + .into_any_element() +} + +#[gpui::test] +fn a_leader_pane_that_left_syncs_nothing(cx: &mut TestAppContext) { + let (host, cx) = bench(synced, cx); + cx.simulate_event(gpui::ScrollWheelEvent { + position: at(270.0, 100.0), + delta: gpui::ScrollDelta::Pixels(point(px(0.0), px(-300.0))), + modifiers: Modifiers::none(), + touch_phase: gpui::TouchPhase::Moved, + }); + settle(cx); + host.update(cx, |bench, cx| { + bench.angle = 1.0; + cx.notify(); + }); + settle(cx); +} diff --git a/src/interaction/tests.rs b/src/interaction/tests/mod.rs similarity index 89% rename from src/interaction/tests.rs rename to src/interaction/tests/mod.rs index 06cc50e..b946425 100644 --- a/src/interaction/tests.rs +++ b/src/interaction/tests/mod.rs @@ -7,6 +7,8 @@ use gpui::{ use super::{Resizable, Rotatable, RovingFocus, ScrollSync, SelectionArea}; use crate::{forms, primitives::FocusNext, theme::Theme}; +mod limits; + type Part = fn(&Bench, Entity) -> AnyElement; /// A view that shows one interaction part and holds what the owner decides: a size, an angle and a selection. @@ -88,13 +90,13 @@ fn resizable(bench: &Bench, owner: Entity) -> AnyElement { bench.size, size(px(100.0), px(80.0)), size(px(300.0), px(200.0)), + move |next, _, cx| { + owner.update(cx, |bench, cx| { + bench.size = next; + cx.notify(); + }) + }, ) - .on_resize(move |next, _, cx| { - owner.update(cx, |bench, cx| { - bench.size = next; - cx.notify(); - }) - }) .child(div().size_full()) .into_any_element() } @@ -135,6 +137,18 @@ fn a_box_resizes_from_its_grip_and_its_edge_within_its_limits(cx: &mut TestAppCo } /// The grip and the edges' handles lie inside the box, the grip in its lower right corner. +#[gpui::test] +fn a_size_past_its_limits_pegs_and_draws(cx: &mut TestAppContext) { + let (host, cx) = bench(resizable, cx); + host.update(cx, |bench, cx| { + bench.size = size(px(900.0), px(10.0)); + cx.notify(); + }); + settle(cx); + let shown = cx.debug_bounds("resizable-box").expect("the box"); + assert_eq!(shown.size, size(px(300.0), px(80.0))); +} + #[gpui::test] fn the_grip_lies_inside_the_box(cx: &mut TestAppContext) { let (_, cx) = bench(resizable, cx); @@ -150,14 +164,19 @@ fn the_grip_lies_inside_the_box(cx: &mut TestAppContext) { } fn rotatable(bench: &Bench, owner: Entity) -> AnyElement { - Rotatable::new("dial", "icons/arrow-up.svg", px(100.0), bench.angle) - .on_turn(move |next, _, cx| { + Rotatable::new( + "dial", + "icons/arrow-up.svg", + px(100.0), + bench.angle, + move |next, _, cx| { owner.update(cx, |bench, cx| { bench.angle = next; cx.notify(); }) - }) - .into_any_element() + }, + ) + .into_any_element() } /// The ring is the one stop; Left and Right turn it a notch, round the circle. @@ -206,21 +225,20 @@ fn area(bench: &Bench, owner: Entity) -> AnyElement { ["a", "b", "c"] .into_iter() .fold( - SelectionArea::new("tiles") - .flex() - .gap(px(10.0)) - .p(px(20.0)) - .w(px(300.0)), + SelectionArea::new("tiles", move |keys, _, cx| { + let keys = keys.to_vec(); + owner.update(cx, |bench, cx| { + bench.selected = keys; + cx.notify(); + }) + }) + .flex() + .gap(px(10.0)) + .p(px(20.0)) + .w(px(300.0)), |area, key| area.item(key, div().size(px(40.0))), ) .selected(bench.selected.clone()) - .on_change(move |keys, _, cx| { - let keys = keys.to_vec(); - owner.update(cx, |bench, cx| { - bench.selected = keys; - cx.notify(); - }) - }) .into_any_element() } @@ -315,16 +333,16 @@ fn roving(bench: &Bench, owner: Entity) -> AnyElement { let _ = bench; ["a", "b", "c"] .into_iter() - .fold(RovingFocus::new("tools", Axis::Horizontal), |group, key| { - group.item(key, div().size(px(24.0))) - }) - .on_press(move |key, _, cx| { - let key = key.clone(); - owner.update(cx, |bench, cx| { - bench.selected.push(key); - cx.notify(); - }) - }) + .fold( + RovingFocus::new("tools", Axis::Horizontal, move |key, _, cx| { + let key = key.clone(); + owner.update(cx, |bench, cx| { + bench.selected.push(key); + cx.notify(); + }) + }), + |group, key| group.item(key, div().size(px(24.0))), + ) .into_any_element() } diff --git a/tasks/panics.md b/tasks/panics.md index 5e99021..0d2e04b 100644 --- a/tasks/panics.md +++ b/tasks/panics.md @@ -52,7 +52,7 @@ Each item reads every site in its folders, fixes what the rules name, updates ca - [x] P35 Settings — `src/settings` - [x] P36 Account — `src/account` - [x] P37 Onboarding — `src/onboarding` -- [ ] P38 Interaction — `src/interaction` +- [x] P38 Interaction — `src/interaction` - [ ] P39 Theme — `src/theme` - [ ] P40 i18n & a11y — `src/i18n` - [ ] P41 Maps — `src/maps` @@ -103,3 +103,4 @@ One line per item: what changed, what stayed and why, review rounds. - P35: eighteen settings components take their handlers in `new`; a section or syntax theme not offered marks none; a font size or default outside its range pegs. Kept: options offered by construction, the proxy's Apply behind its read, drops that bring a file. Review: 1 round. - P36: seventeen account components take their handlers in `new`, the email step's `on_send` with them; a busy provider, a current account or workspace, and a role no longer listed mark none and log; an invitation waits for its role to be offered; an expiry year that overflows reads as unread. Kept: quotas, providers, one current session, roles to invite as, the card behind Save. Review: 2 rounds. - P37: seven onboarding components take their handlers in `new`, OnboardingWizard's `on_step` and `on_finish` among them; a help article no longer listed opens none; a wizard step past its last says so; a support topic no longer offered counts as none and holds Send. Kept: tasks, steps, releases and topics present, sentiments by construction, Send behind a pick. Review: 2 rounds. +- P38: Resizable, Rotatable, RovingFocus and SelectionArea take their handlers in `new`; a resize or band whose press state is gone moves nothing; Resizable keeps the owner's raw size as its seed and fits what it draws and reports to this frame's limits; ScrollSync's leader that left syncs nothing. Kept: finite angles, items and panes present. Review: 2 rounds.