diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 0722c6a3..7890ea6f 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -16,6 +16,7 @@ use oa_core::*; use std::collections::BTreeMap; #[cfg(test)] mod contextual_source_tests; +pub(crate) mod canvas_workspace; pub(crate) mod creation; pub(crate) mod scenario; // Projection acceptance precedes the coordinated scenario renderer integration. @@ -1233,6 +1234,7 @@ struct Scene { selected: Id, scale: f32, scroll_offset: iced::Vector, + camera_pan: iced::Vector, interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>, pending_placement: Option<(scenario::Slot, Option)>, lanes: BTreeMap, @@ -1287,6 +1289,7 @@ impl Scene { let mut scene = Self { scale: 1., scroll_offset: iced::Vector::ZERO, + camera_pan: iced::Vector::ZERO, boxes: vec![], lines: vec![], size: Size::new(700., 200.), @@ -1951,7 +1954,7 @@ impl canvas::Program for Scene { }; let world = cursor .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + .map(|p| Point::new((p.x - self.camera_pan.x) / self.scale, (p.y - self.camera_pan.y) / self.scale)); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { if !visible { @@ -1988,10 +1991,11 @@ impl canvas::Program for Scene { }); return Some(Action::request_redraw().and_capture()); } - if self.reorder_handle().is_some_and(|r| r.contains(point)) { + if let Some(occurrence) = self.hit(point).filter(|id| self.rows.contains_key(id)) + .or_else(|| self.reorder_handle().filter(|r| r.contains(point)).map(|_| self.selected)) { state.press = Some(SequencePress::Reorder { - occurrence: self.selected, - parent: self.rows[&self.selected].0, + occurrence, + parent: self.rows[&occurrence].0, start: cursor.position()? - self.scroll_offset, moved: false, }); @@ -2066,6 +2070,9 @@ impl canvas::Program for Scene { ); } } + SequencePress::Reorder { occurrence, moved: false, .. } => { + return Some(Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(context, occurrence))).and_capture()); + } SequencePress::Reorder { occurrence, parent, @@ -2103,6 +2110,7 @@ impl canvas::Program for Scene { ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); f.scale(self.scale); for b in &self.boxes { let outline = matches!( @@ -2216,7 +2224,7 @@ impl canvas::Program for Scene { let active = editable && state.context == Some(context) && state.tool == tool; let point = cursor .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + .map(|p| Point::new((p.x - self.camera_pan.x) / self.scale, (p.y - self.camera_pan.y) / self.scale)); let source = active.then_some(state.source).flatten(); let reorder = active.then_some(state.press).flatten().and_then(|press| { if let SequencePress::Reorder { parent, .. } = press { diff --git a/crates/oa-desktop/src/behavior/canvas_workspace.rs b/crates/oa-desktop/src/behavior/canvas_workspace.rs new file mode 100644 index 00000000..024c9ebe --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace.rs @@ -0,0 +1,90 @@ +//! Shared presentation and input contract; contains no model commands. +use iced::{Element, Length::Fill, Point, Theme, Vector}; +use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip}; +use oa_core::Id; +mod viewport; +pub use viewport::Viewport; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Scope { + pub document: Id, + pub owner: Id, + pub revision: u64, +} +impl Scope { + pub fn widget_id(self) -> iced::advanced::widget::Id { + iced::advanced::widget::Id::new(format!("behavior-canvas:{}:{}", self.document, self.owner)) + } +} +#[derive(Debug, Clone, Copy)] +pub enum Event { + Pan(Vector), + Zoom { factor: f32, at: Point, base: f32 }, + Focus, + Traverse(bool), +} +/// Fold relative input in event order. The base scale is used only on leaving Fit. +pub fn change(pan: &mut Vector, zoom: &mut Option, fit: &mut bool, event: Event) -> bool { + match event { + Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => { + let next = *pan + delta; + if next.x.is_finite() && next.y.is_finite() && next != *pan { + *pan = next; + true + } else { false } + } + Event::Zoom { factor, at, base } + if factor.is_finite() && factor > 0. && base.is_finite() && base > 0. + && at.x.is_finite() && at.y.is_finite() => + { + let old = if *fit { base } else { zoom.unwrap_or(base) }; + let next = (old * factor).clamp(0.05, 3.); + if old == next { return false; } + let anchor = Vector::new(at.x, at.y); + *pan = anchor - (anchor - *pan) * (next / old); + *zoom = Some(next); + *fit = false; + true + } + _ => false, + } +} + +/// Constant-width labelled palette. Arming a tool never reallocates the canvas. +pub fn palette<'a, Message: Clone + 'a>( + entries: impl IntoIterator)>, +) -> Element<'a, Message> { + let mut tools = column![].spacing(3); + for (glyph, label, selected, message) in entries { + tools = tools.push(tooltip( + button(text(glyph).size(12)).width(34).height(30).padding(3) + .style(if selected { button::primary } else { crate::style::nav }) + .on_press_maybe(message), + container(text(label).size(12)).padding(7).style(crate::style::card), + tooltip::Position::Right, + )); + } + container(scrollable(tools)).width(40).height(Fill).padding(3) + .style(crate::style::panel).into() +} + +/// Header and palette are permanent. Details/editors overlay the same viewport. +pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>( + header: Element<'a, Message, Theme, R>, + palette: Element<'a, Message, Theme, R>, + canvas: Element<'a, Message, Theme, R>, + editor: Option>, +) -> Element<'a, Message, Theme, R> { + let base = row![canvas, palette].width(Fill).height(Fill); + let mut body = stack![base]; + if let Some(editor) = editor { + body = body.push( + container(opaque(container(editor).width(320).height(Fill).style(crate::style::card))) + .align_right(Fill).height(Fill).padding(8), + ); + } + column![header, body].spacing(6).padding(8).width(Fill).height(Fill).into() +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs new file mode 100644 index 00000000..4277cd72 --- /dev/null +++ b/crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs @@ -0,0 +1,149 @@ +use super::{Event as CameraEvent, Scope}; +use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector}; +use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + widget::{Operation, Tree, operation, tree}}; +use iced::{keyboard, mouse}; + +#[derive(Default)] +struct Input { + scope: Option, + focused: bool, + pointer: Option, + pan: Option<(mouse::Button, Point)>, + space: bool, +} +impl operation::Focusable for Input { + fn is_focused(&self) -> bool { self.focused } + fn focus(&mut self) { self.focused = true; } + fn unfocus(&mut self) { self.focused = false; self.pan = None; self.space = false; } +} +pub struct Viewport<'a, Message, R = iced::Renderer> +where R: renderer::Renderer { + content: Element<'a, Message, Theme, R>, + scope: Scope, + scale: f32, + keyboard: bool, + publish: Box Message + 'a>, +} +impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> { + pub fn new(content: impl Into>, scope: Scope, + scale: f32, keyboard: bool, publish: impl Fn(CameraEvent) -> Message + 'a) -> Self { + Self { content: content.into(), scope, scale, keyboard, publish: Box::new(publish) } + } +} +impl Widget for Viewport<'_, Message, R> { + fn tag(&self) -> tree::Tag { tree::Tag::of::() } + fn state(&self) -> tree::State { tree::State::new(Input { scope: Some(self.scope), ..Default::default() }) } + fn children(&self) -> Vec { vec![Tree::new(self.content.as_widget())] } + fn diff(&self, tree: &mut Tree) { + let state = tree.state.downcast_mut::(); + if state.scope != Some(self.scope) { *state = Input { scope: Some(self.scope), ..Default::default() }; } + tree.diff_children(std::slice::from_ref(&self.content)); + } + fn size(&self) -> Size { Size::new(Length::Fill, Length::Fill) } + fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node { + self.content.as_widget_mut().layout(&mut tree.children[0], renderer, limits) + } + fn operate(&mut self, tree: &mut Tree, layout: Layout<'_>, renderer: &R, operation: &mut dyn Operation) { + operation.focusable(Some(&self.scope.widget_id()), layout.bounds(), tree.state.downcast_mut::()); + self.content.as_widget_mut().operate(&mut tree.children[0], layout, renderer, operation); + } + fn update(&mut self, tree: &mut Tree, event: &Event, layout: Layout<'_>, cursor: mouse::Cursor, + renderer: &R, clipboard: &mut dyn Clipboard, shell: &mut Shell<'_, Message>, viewport: &Rectangle) { + let state = tree.state.downcast_mut::(); + if let Event::Mouse(mouse::Event::CursorMoved { position }) = event { state.pointer = Some(*position); } + let pointer = match event { + Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_)) => + state.pointer.or_else(|| cursor.position()), + _ => cursor.position().or(state.pointer), + }; + let visible = layout.bounds().intersection(viewport); + let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p))) && cursor.position().is_some(); + let mut camera = None; + let mut capture = false; + match event { + Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => { + state.pan = None; state.space = false; state.focused = false; + } + Event::Mouse(mouse::Event::CursorLeft) => { state.pan = None; state.pointer = None; } + Event::Mouse(mouse::Event::ButtonPressed(button)) => { + state.focused = inside; + if inside && (*button == mouse::Button::Middle || (*button == mouse::Button::Left && state.space)) { + state.pan = pointer.map(|p| (*button, p)); + capture = true; + } + } + Event::Mouse(mouse::Event::CursorMoved { position }) => { + if let Some((button, previous)) = state.pan { + state.pan = Some((button, *position)); + camera = Some(CameraEvent::Pan(*position - previous)); + capture = true; + } + } + Event::Mouse(mouse::Event::ButtonReleased(button)) => { + if let Some((held, previous)) = state.pan && held == *button { + state.pan = None; + camera = pointer.filter(|_| inside).map(|p| CameraEvent::Pan(p - previous)); + capture = true; + } + } + Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside => { + state.pan = None; + let y = match delta { mouse::ScrollDelta::Lines { y, .. } => *y, + mouse::ScrollDelta::Pixels { y, .. } => *y / 40. }; + if y != 0. { + let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y); + camera = Some(CameraEvent::Zoom { factor: 1.1_f32.powf(y.clamp(-8., 8.)), at, base: self.scale }); + capture = true; + } + } + Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. }) if state.focused && self.keyboard => { + use keyboard::{Key, key::Named}; + match key.as_ref() { + Key::Named(Named::Space) => { state.space = true; capture = true; } + Key::Named(Named::Tab) => { camera = Some(CameraEvent::Traverse(modifiers.shift())); capture = true; } + Key::Named(Named::Escape) => { state.pan = None; state.space = false; } + Key::Named(key) if modifiers.is_empty() => { + let delta = match key { + Named::ArrowLeft => Some(Vector::new(60., 0.)), + Named::ArrowRight => Some(Vector::new(-60., 0.)), + Named::ArrowUp => Some(Vector::new(0., 60.)), + Named::ArrowDown => Some(Vector::new(0., -60.)), + _ => None, + }; + camera = delta.map(CameraEvent::Pan); capture = camera.is_some(); + } + _ => {} + } + } + Event::Keyboard(keyboard::Event::KeyReleased { key, .. }) + if *key == keyboard::Key::Named(keyboard::key::Named::Space) => { state.space = false; } + _ => {} + } + if let Some(event) = camera { shell.publish((self.publish)(event)); } + if capture { shell.capture_event(); shell.request_redraw(); return; } + self.content.as_widget_mut().update(&mut tree.children[0], event, layout, cursor, renderer, clipboard, shell, viewport); + } + fn draw(&self, tree: &Tree, renderer: &mut R, theme: &Theme, style: &renderer::Style, + layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle) { + renderer.with_layer(layout.bounds(), |renderer| { + self.content.as_widget().draw(&tree.children[0], renderer, theme, style, layout, cursor, viewport); + }); + if tree.state.downcast_ref::().focused && self.keyboard { + renderer.fill_quad(renderer::Quad { bounds: layout.bounds(), + border: Border { color: crate::style::TEAL, width: 1., ..Default::default() }, + ..Default::default() }, iced::Color::TRANSPARENT); + } + } + fn mouse_interaction(&self, tree: &Tree, layout: Layout<'_>, cursor: mouse::Cursor, viewport: &Rectangle, renderer: &R) -> mouse::Interaction { + if tree.state.downcast_ref::().pan.is_some() { mouse::Interaction::Grabbing } + else { self.content.as_widget().mouse_interaction(&tree.children[0], layout, cursor, viewport, renderer) } + } + fn overlay<'a>(&'a mut self, tree: &'a mut Tree, layout: Layout<'a>, renderer: &R, viewport: &Rectangle, translation: Vector) + -> Option> { + self.content.as_widget_mut().overlay(&mut tree.children[0], layout, renderer, viewport, translation) + } +} +impl<'a, Message: 'a, R: renderer::Renderer + 'a> From> for Element<'a, Message, Theme, R> { + fn from(viewport: Viewport<'a, Message, R>) -> Self { Element::new(viewport) } +} diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 430ecfd3..218ac309 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -101,6 +101,7 @@ pub enum CanvasEvent { Viewport(Context, Size), FormShown(Context, Id), Zoom(Context, Option), + Camera(Context, super::canvas_workspace::Event), Focus(Context), Shelf(Context, Shelf), OpenUse(Context, Id), @@ -141,6 +142,7 @@ impl CanvasEvent { | Self::Viewport(context, _) | Self::FormShown(context, _) | Self::Zoom(context, _) + | Self::Camera(context, _) | Self::Focus(context) | Self::Shelf(context, _) | Self::OpenUse(context, _) @@ -330,6 +332,10 @@ impl State { pub(crate) fn viewport(&mut self, size: Size) -> bool { self.presentation.viewport(size) } + pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool { + let p = &mut self.presentation; + super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event) + } pub(crate) fn zoom(&mut self, scale: Option) -> bool { self.presentation.zoom(scale) } diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 123cf842..52316fb0 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -17,6 +17,7 @@ pub(super) struct Presentation { pub(super) viewport: Option, pub(super) focused: bool, pub(super) shelf: Option, + pub(super) pan: iced::Vector, pub(super) zoom: Option, pub(super) fit: bool, } diff --git a/crates/oa-desktop/src/behavior/topology.rs b/crates/oa-desktop/src/behavior/topology.rs index d23c40f2..b52e3404 100644 --- a/crates/oa-desktop/src/behavior/topology.rs +++ b/crates/oa-desktop/src/behavior/topology.rs @@ -84,6 +84,7 @@ pub struct State { return_to: Vec<(Id, Id, Option)>, draft: Option, viewport: Option, + pan: iced::Vector, zoom: Option, fit: bool, pub(crate) focused: bool, @@ -970,6 +971,7 @@ struct Graph<'a> { project: &'a Project, interaction: Option<(Context, CanvasTool, bool)>, scroll_offset: iced::Vector, + camera_pan: iced::Vector, nodes: Vec, edges: Vec, ordered_paths: Vec, @@ -1302,6 +1304,7 @@ impl<'a> Graph<'a> { project: p, interaction: None, scroll_offset: iced::Vector::ZERO, + camera_pan: iced::Vector::ZERO, nodes, edges, ordered_paths, @@ -1401,6 +1404,7 @@ impl canvas::Program for Graph<'_> { ) -> Vec { let mut f = Frame::new(renderer, bounds.size()); f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE); + f.translate(self.camera_pan); f.scale(self.scale); for pass in PAINT_PASSES { match pass { @@ -1616,7 +1620,7 @@ impl canvas::Program for Graph<'_> { cursor: mouse::Cursor, ) -> mouse::Interaction { if cursor.position_in(bounds).is_some_and(|p| { - let p = Point::new(p.x / self.scale, p.y / self.scale); + let p = Point::new((p.x - self.camera_pan.x) / self.scale, (p.y - self.camera_pan.y) / self.scale); self.nodes.iter().any(|n| n.rect.contains(p)) || self.ordered_paths.iter().any(|path| { path.points.windows(2).any(|pair| { diff --git a/crates/oa-desktop/src/behavior/topology/direct.rs b/crates/oa-desktop/src/behavior/topology/direct.rs index 88c6e3f7..cca69956 100644 --- a/crates/oa-desktop/src/behavior/topology/direct.rs +++ b/crates/oa-desktop/src/behavior/topology/direct.rs @@ -60,6 +60,7 @@ pub enum CanvasEvent { Open(Context, Id), Back(Context), Zoom(Context, Option), + Camera(Context, super::super::canvas_workspace::Event), Focus(Context), Viewport(Context, Size), } @@ -77,6 +78,7 @@ impl CanvasEvent { | Self::Back(c) | Self::AppendExchange(c) | Self::Zoom(c, ..) + | Self::Camera(c, ..) | Self::Focus(c) | Self::Viewport(c, ..) => *c, Self::Begin { context, .. } @@ -138,6 +140,7 @@ impl State { if self.identity != Some((document, owner)) { self.identity = Some((document, owner)); self.tool = CanvasTool::Select; + self.pan = iced::Vector::ZERO; self.zoom = Some(1.); self.fit = false; self.viewport = None; @@ -162,6 +165,9 @@ impl State { // Choosing how to edit must retain the user's current view. self.tool = tool; } + pub(crate) fn camera(&mut self, event: super::super::canvas_workspace::Event) -> bool { + super::super::canvas_workspace::change(&mut self.pan, &mut self.zoom, &mut self.fit, event) + } pub(crate) fn zoom(&mut self, value: Option) { self.fit = value.is_none(); self.zoom = value.map(|v| v.clamp(0.5, 2.)); @@ -577,7 +583,7 @@ impl Graph<'_> { }; let world = cursor .position_in(bounds) - .map(|p| Point::new(p.x / self.scale, p.y / self.scale)); + .map(|p| Point::new((p.x - self.camera_pan.x) / self.scale, (p.y - self.camera_pan.y) / self.scale)); let publish = |event| Some(Action::publish(Message::TopologyCanvas(event)).and_capture()); match event { canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if visible => { @@ -738,7 +744,7 @@ impl Graph<'_> { .map(|p| p + self.scroll_offset) .or_else(|| cursor.position()) .filter(|p| bounds.contains(*p)) - .map(|p| Point::new((p.x - bounds.x) / self.scale, (p.y - bounds.y) / self.scale)); + .map(|p| Point::new((p.x - bounds.x - self.camera_pan.x) / self.scale, (p.y - bounds.y - self.camera_pan.y) / self.scale)); let live = state.context == Some(context) && state.tool == tool && state.bounds == Some(bounds); if live && let Some(source) = state.source { diff --git a/harness/release-behavior-canvas-reboot-handoff-20260911.md b/harness/release-behavior-canvas-reboot-handoff-20260911.md new file mode 100644 index 00000000..d9ee2ab6 --- /dev/null +++ b/harness/release-behavior-canvas-reboot-handoff-20260911.md @@ -0,0 +1,58 @@ +# Canvas phase 1 — interrupted WIP handoff + +Codex-956atf /root/behavior_audit. Implementation stopped immediately on root's user-requested reboot checkpoint, 2026-09-11. **Frozen; do not resume until root's post-reboot follow-up.** + +Workspace: C:/Users/User/Documents/Git/ARCADIA-Studio/.local/behavior-canvas-worktree-20260911 +Branch assigned by root: codex/behavior-canvas-20260911 +Verified starting commit: c0e66f94e3412b5c3495e96f7934ae94116505c9 + +This is intentionally unfinished and **not compile-coherent**. No Cargo/rustc/rustfmt/native/Git mutation was run by this worker. Original checkout was not edited. Root owns WIP commit/push and reboot-safe confirmation. Coordination: issue #2 comment 261; ratified design is release-unified-behavior-canvas-design-20260911.md. User clarification #2/250 explicitly permits semantic sequence constraints. + +## Exact Rust paths changed + +New: + +- crates/oa-desktop/src/behavior/canvas_workspace.rs +- crates/oa-desktop/src/behavior/canvas_workspace/viewport.rs + +Modified: + +- crates/oa-desktop/src/behavior.rs +- crates/oa-desktop/src/behavior/topology.rs +- crates/oa-desktop/src/behavior/topology/direct.rs +- crates/oa-desktop/src/behavior/scenario.rs +- crates/oa-desktop/src/behavior/scenario/workspace.rs + +This new handoff is the only report written in the implementation worktree. No App, core, source grammar, REST, schema, architecture/state implementation, existing tests, ledgers or prerequisites files were edited. The earlier design report was written in the original checkout under its prior report-only lease; do not confuse that completed handoff with this interrupted implementation. + +## Saved implementation so far + +- New shared module defines Scope (document/owner/revision), relative Pan and cursor-anchored Zoom events, Camera change helper, a constant-width tooltip palette, and a stable header/canvas/palette host with an overlaid editor. It contains no model commands. +- New generic Iced Viewport wrapper implements focusable state, ordered press-position retention, middle/Space-drag, focused arrow camera keys, wheel zoom, focus border, input capture, child rendering clipped to viewport, and delegated widget operations/overlays. No tests or compiler validated its API usage. +- Behavior declares canvas_workspace. Scene and Graph have a default-zero camera_pan, frame translation and inverse-hit coordinate changes. Existing scroll_offset remains for transitional compatibility; current production views still set/use old scroll offsets. +- Scenario Presentation and process State have a new transient pan. Both expose camera() using the shared relative-input helper. Process enter resets pan with owner/document. +- Both CanvasEvent enums gained Camera(Context, Event), and context() covers it. +- Scenario whole-glyph reorder starts a captured Reorder for a hit occurrence with a row, preserving current-parent slot validation. The current interrupted code selects an unmoved occurrence on release; this was being reviewed against existing press-selection expectations. No process free-placement behavior was added; process semantic SetSteps remains explicitly constrained. + +## Known incomplete work / blockers + +1. canvas_workspace.rs declares mod tests, but **canvas_workspace/tests.rs does not exist**. Create meaningful mounted tests, not an empty placeholder. No existing test assertions were weakened or changed. +2. New Camera variants have **no App handlers**; exhaustive matches may fail. Add minimal guarded adapters in App ScenarioCanvas and app/process_canvas.rs, plus Focus/Traverse Tasks. Relative camera events need document/owner/revision validation and correct same-batch ordering; root modal/pending guards must remain intact. +3. Production process/scenario view functions have **not** been migrated to Fill viewport, the new palette or the stable overlay host. They still use model-sized canvases/Scrollables and split forms. Thus the current source does not deliver phase 1. +4. Pan fields are not yet passed from presentation state into Scene/Graph, and old Fit/manual zoom paths are not reconciled with camera pan. Need explicit Fit reset/recenter and no automatic refit after editor/selection changes. +5. The new Viewport's CursorMoved branch stores the event position, but its subsequent local pointer choice can still prefer Iced's final batch cursor. Correct this before testing; actual event positions must win for ordered movement. +6. Starting camera input should invalidate an in-progress semantic gesture even with zero pan distance. A planned CancelGesture camera intent / epoch bump is not written. Camera input across batches must not lose an active pan solely because its own epoch changed; semantic releases must reject stale contexts. +7. Scope widget focus identity currently omits revision. Review delayed Focus Tasks against replaced modal/document/revision state. Pending structured editors must own keyboard input; a retained background canvas focus must not leak arrows/Space into camera actions. +8. Scenario caller frames already clone Presentation, so they can retain the new pan. Current ScrollRestore receipt machinery still targets the old Canvas Scrollable; migrate actual canvas restoration coherently while preserving Page/outline restoration, exact caller identity, one-shot behavior and raw guards. Process caller restoration/reveal currently targets Scrollable and also needs camera adaptation. +9. Existing scenario FormShown/name focus, one-time marker reveal and staged child-source editor must survive the new stable editor presentation. Keep the same Draft and source_draft state; do not create a second draft, implicit Apply or substitute definitions. +10. Existing workspace/scroll tests target old hosts. Replace obsolete presentation expectations with production-mounted canvas assertions while retaining all semantic/source/history/identity expectations. Do not leave legacy helper-only tests represented as new production acceptance. + +## Next bounded work after authorization + +Finish minimal BOTH-scene Fill/palette/editor adapters, guarded camera App events, stable caller return and whole-glyph scenario reorder. Exercise real Iced root layout and ordered pointer/key batches at nonzero pan and several zooms, frozen canvas bounds across edit/cancel, stale scope/release rejection, modal keyboard ownership, and exact App source/history/raw preservation. Send a coherent source freeze to root for compile and native gates; this worker remains prohibited from running Cargo/native/Git. + +No persistent layout map, core/view schema extension, range wrapping, reconnection expansion, new allocation semantics or B4 changes belong in this slice. Root is compiling the original combined c0e66f9 source independently; do not modify that checkout or equate its results with this WIP. The incoming source_draft essentials-first fix is owned by Codex-9e8r6t; B4 renderer by Codex-ytr83r. Phase 1 is not issue #2 completion. + +## Tool outcome + +The first mechanical edit command attempted plain python and failed because python was not on PATH; it changed no files. The same intended edits were then applied successfully with the bundled Python at C:/Users/User/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe. Shared new files were written through apply_patch. No compile, formatter, unit test or native acceptance has run on these changes.