WIP checkpoint shared behavior canvas before user reboot

This commit is contained in:
Codex
2026-09-11 06:44:13 -07:00
parent c0e66f94e3
commit e511c4153f
8 changed files with 330 additions and 8 deletions
+13 -5
View File
@@ -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<Id>)>,
lanes: BTreeMap<Id, f32>,
@@ -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<Message> 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<Message> 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<Message> 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<Message> for Scene {
) -> Vec<canvas::Geometry> {
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<Message> 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 {
@@ -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<f32>, 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<Item = (&'static str, &'static str, bool, Option<Message>)>,
) -> 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>>,
) -> 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;
@@ -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<Scope>,
focused: bool,
pointer: Option<Point>,
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<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
pub fn new(content: impl Into<Element<'a, Message, Theme, R>>, 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<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
fn tag(&self) -> tree::Tag { tree::Tag::of::<Input>() }
fn state(&self) -> tree::State { tree::State::new(Input { scope: Some(self.scope), ..Default::default() }) }
fn children(&self) -> Vec<Tree> { vec![Tree::new(self.content.as_widget())] }
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
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<Length> { 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::<Input>());
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::<Input>();
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::<Input>().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::<Input>().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<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(&mut tree.children[0], layout, renderer, viewport, translation)
}
}
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>> for Element<'a, Message, Theme, R> {
fn from(viewport: Viewport<'a, Message, R>) -> Self { Element::new(viewport) }
}
@@ -101,6 +101,7 @@ pub enum CanvasEvent {
Viewport(Context, Size),
FormShown(Context, Id),
Zoom(Context, Option<f32>),
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<f32>) -> bool {
self.presentation.zoom(scale)
}
@@ -17,6 +17,7 @@ pub(super) struct Presentation {
pub(super) viewport: Option<Size>,
pub(super) focused: bool,
pub(super) shelf: Option<Shelf>,
pub(super) pan: iced::Vector,
pub(super) zoom: Option<f32>,
pub(super) fit: bool,
}
+5 -1
View File
@@ -84,6 +84,7 @@ pub struct State {
return_to: Vec<(Id, Id, Option<oa_core::topology_context::ContextAddress>)>,
draft: Option<Draft>,
viewport: Option<Size>,
pan: iced::Vector,
zoom: Option<f32>,
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<GraphNode>,
edges: Vec<GraphEdge>,
ordered_paths: Vec<OrderedPath>,
@@ -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<Message> for Graph<'_> {
) -> Vec<canvas::Geometry> {
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<Message> 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| {
@@ -60,6 +60,7 @@ pub enum CanvasEvent {
Open(Context, Id),
Back(Context),
Zoom(Context, Option<f32>),
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<f32>) {
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 {
@@ -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.