diff --git a/.gitattributes b/.gitattributes index 639a64f6..aed9dbd8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -44,3 +44,4 @@ harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at- harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol +harness/experiments/analysis-numeric-gate-20260911/** -text diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs index e908fe88..4a8fb381 100644 --- a/crates/oa-desktop/src/app.rs +++ b/crates/oa-desktop/src/app.rs @@ -5,6 +5,7 @@ mod checkpoints; #[cfg(test)] mod diagram_selection_tests; mod hosted; +mod reference_authoring; #[cfg(test)] mod review_navigation_tests; #[cfg(test)] @@ -371,6 +372,8 @@ pub enum Message { FieldValuePath(String, crate::data_values::FieldPathKey), FieldValueBrowse(String, Option, usize), FieldReferenceBrowse(String, String, usize), + FieldReferenceOpen(crate::forms::reference_chooser::adapter::Open), + FieldReferenceEvent(crate::forms::reference_chooser::Event), FieldVariant(String, Value), FieldArray(String, Option, i32), Apply, @@ -445,6 +448,7 @@ pub struct App { pub hosted: crate::hosted::State, pub connection: Option, pub model_menu: Option, + reference_authoring: reference_authoring::State, pub source_tree: crate::source_tree::State, menu_sequence: u64, pub canvas_document: Id, @@ -547,6 +551,7 @@ impl App { checkpoint_close: None, connection: None, model_menu: None, + reference_authoring: Default::default(), source_tree: Default::default(), menu_sequence: 0, canvas_document: Id::new_v4(), @@ -1023,7 +1028,7 @@ impl App { crate::model_menu::popup(self), ) .into(); - crate::hosted::view::overlay(self, base) + crate::hosted::view::overlay(self, self.reference_overlay(base)) } pub fn subscription(&self) -> Subscription { Subscription::batch([ @@ -1077,6 +1082,9 @@ impl App { ]) } pub fn update(&mut self, message: Message) -> Task { + if let Some(task) = self.reference_message(&message) { + return task; + } if matches!(&message, Message::Connection(_)) && self .architecture @@ -1299,6 +1307,7 @@ impl App { self.last_workspace_page = self.page; } let mut task = self.handle(message); + self.reconcile_reference_chooser(); self.route_state_diagram_workspace(); if self.page != Page::Home { self.last_workspace_page = self.page; @@ -1383,6 +1392,17 @@ impl App { Message::ScenarioCanvas(event) => { use crate::behavior::scenario::{self, CanvasEvent}; let context = event.context(); + if let CanvasEvent::FormShown(_, session) = event { + if self.scenario_context().is_some_and(|current| { + current.document == context.document + && current.scenario == context.scenario + && current.revision == context.revision + }) && self.behavior.scenario.take_form_focus(session) + { + return iced::widget::operation::focus(scenario::NAME_INPUT); + } + return Task::none(); + } if let CanvasEvent::Viewport(_, size) = event { if self.scenario_context().is_some_and(|current| { current.document == context.document @@ -1817,6 +1837,9 @@ impl App { .insert(path, crate::forms::ValueBrowser { scope, page }); } } + Message::FieldReferenceOpen(_) | Message::FieldReferenceEvent(_) => { + return Task::none(); + } Message::FieldReferenceBrowse(path, query, page) => { if let Some(draft) = &mut self.draft { let page = if draft diff --git a/crates/oa-desktop/src/app/reference_authoring.rs b/crates/oa-desktop/src/app/reference_authoring.rs new file mode 100644 index 00000000..3ccd1f45 --- /dev/null +++ b/crates/oa-desktop/src/app/reference_authoring.rs @@ -0,0 +1,296 @@ +//! One transient chooser for the currently mounted Inspector or review field. +//! Browsing never calls a draft setter or semantic planner. +use super::{App, Message, Page, Pending}; +use crate::{ + forms::{ + Draft, + reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger}, + }, + navigation::InspectorTab, + semantic_review::{self, RowAddress, RowEvent}, + workflow, +}; +use iced::{Element, Rectangle, Task}; +use oa_core::{FacetSelector, Id, Layer}; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Host { + Inspector, + Review { + address: Option, + facet: FacetSelector, + }, +} +impl Host { + fn message(&self, event: chooser::Event) -> Message { + match self { + Self::Inspector => Message::FieldReferenceEvent(event), + Self::Review { address, facet } => { + Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row { + address: *address, + facet: Some(facet.clone()), + event: RowEvent::ReferenceEvent(event), + })) + } + } + } +} +struct Opened { + host: Host, + request: adapter::Open, + document_session: Id, + page: Page, + layer: Layer, + chooser: chooser::State, +} +#[derive(Default)] +pub(super) struct State { + opened: Option, +} + +impl App { + fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> { + match host { + Host::Inspector + if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties => + { + self.draft.as_ref() + } + Host::Review { address, facet } => match self.workflow.as_ref()? { + workflow::Workflow::Semantic(state) => { + state.reference_draft(self.project(), *address, facet, path) + } + _ => None, + }, + _ => None, + } + } + fn reference_available(&self) -> bool { + !self.busy + && !self.hosted.visible + && self.architecture.is_none() + && self.model_menu.is_none() + && self.file_prompt.is_none() + && !self.delete_pending + && self.pending.is_none() + && !self.source_dirty() + && !self.behavior.has_pending() + && !matches!(self.page, Page::Home | Page::Guide | Page::Text) + } + fn reference_valid(&self, opened: &Opened) -> bool { + self.reference_available() + && self.canvas_document == opened.document_session + && self.page == opened.page + && self.layer == opened.layer + && self.document.revision() == opened.chooser.stamp().context.revision + && self.project().id == opened.request.document + && self + .reference_draft(&opened.host, &opened.request.path) + .is_some_and(|draft| draft.reference_matches(&opened.request)) + } + pub(super) fn reconcile_reference_chooser(&mut self) { + if self + .reference_authoring + .opened + .as_ref() + .is_some_and(|opened| !self.reference_valid(opened)) + { + self.reference_authoring + .opened + .as_mut() + .unwrap() + .chooser + .invalidate(); + self.reference_authoring.opened = None; + } + } + fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task { + if !self.reference_available() { + return Task::none(); + } + let Some(draft) = self.reference_draft(&host, &request.path) else { + return Task::none(); + }; + let Ok(chooser) = + draft.reference_catalog(self.project(), &request, self.document.revision()) + else { + return Task::none(); + }; + self.reference_authoring.opened = Some(Opened { + host, + request, + document_session: self.canvas_document, + page: self.page, + layer: self.layer, + chooser, + }); + Task::none() + } + fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task { + let Some(mut opened) = self.reference_authoring.opened.take() else { + return Task::none(); + }; + if !self.reference_valid(&opened) { + return Task::none(); + } + if opened.host != host { + self.reference_authoring.opened = Some(opened); + return Task::none(); + } + match opened.chooser.update(event) { + Outcome::Ignored | Outcome::Browsed => { + self.reference_authoring.opened = Some(opened); + Task::none() + } + Outcome::Closed(reason) => self.reference_focus( + &opened, + match reason { + Close::Escape => Some(0), + Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }), + Close::Outside | Close::WindowChanged => None, + }, + ), + Outcome::Write { context, value } => { + let intent = adapter::Set { + context, + generation: opened.request.generation, + value, + }; + let result = match &opened.host { + Host::Inspector => self + .draft + .as_mut() + .ok_or_else(|| "The property editor closed.".to_owned()) + .and_then(|draft| draft.reference_set(self.document.project(), &intent)), + Host::Review { address, facet } => match self.workflow.as_mut() { + Some(workflow::Workflow::Semantic(state)) => { + state.set_reference(self.document.project(), *address, facet, intent) + } + _ => Err("The review editor closed.".into()), + }, + }; + if let Err(error) = result { + self.status = error; + } + // A scoped write rotates field generation. The task targets the + // newly rendered field, while a later structural edit makes it inert. + self.reference_focus(&opened, Some(0)) + } + } + } + fn reference_focus(&self, opened: &Opened, step: Option) -> Task { + let Some(step) = step else { + return Task::none(); + }; + let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else { + return Task::none(); + }; + if draft.reference_session() != opened.request.session { + return Task::none(); + } + trigger::focus( + draft.reference_session(), + draft.reference_generation(), + &opened.request.path, + step, + ) + } + /// Called before generic modal dispatch: the review owns a modal, and this + /// chooser owns only its active field. All browser events stop here. + pub(super) fn reference_message(&mut self, message: &Message) -> Option> { + self.reconcile_reference_chooser(); + match message { + Message::FieldReferenceOpen(request) => { + Some(self.open_reference(Host::Inspector, request.clone())) + } + Message::FieldReferenceEvent(event) => { + Some(self.reference_event(Host::Inspector, event.clone())) + } + Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row { + address, + facet: Some(facet), + event, + })) => { + let host = Host::Review { + address: *address, + facet: facet.clone(), + }; + match event { + RowEvent::ReferenceOpen(request) => { + Some(self.open_reference(host, request.clone())) + } + RowEvent::ReferenceEvent(event) => { + Some(self.reference_event(host, event.clone())) + } + _ => None, + } + } + Message::Escape | Message::Request(Pending::Close(_)) + if self.reference_authoring.opened.is_some() => + { + let opened = self.reference_authoring.opened.as_ref().unwrap(); + let host = opened.host.clone(); + let event = opened.chooser.event(Action::Close(Close::Escape)); + Some(self.reference_event(host, event)) + } + // Global shortcuts are also emitted for keys captured by the search + // input. They must not apply drafts, navigate, or undo the model. + Message::Save(_) + | Message::ShortcutUndo(_) + | Message::Request(Pending::Open | Pending::New) + | Message::SourceNavigation(_) + | Message::Page(Page::Guide) + if self.reference_authoring.opened.is_some() => + { + Some(Task::none()) + } + _ => None, + } + } + pub(super) fn reference_overlay< + 'a, + R: iced::advanced::text::Renderer + 'a, + >( + &'a self, + content: Element<'a, Message, iced::Theme, R>, + ) -> Element<'a, Message, iced::Theme, R> { + let opened = self + .reference_authoring + .opened + .as_ref() + .filter(|opened| self.reference_valid(opened)); + let host = opened + .map(|opened| opened.host.clone()) + .unwrap_or(Host::Inspector); + let focus_scope = if self.reference_available() { + match self.workflow.as_ref() { + None if self.inspector_tab == InspectorTab::Properties => { + self.draft.as_ref().map(|draft| trigger::FormScope { + session: draft.reference_session(), + modal: false, + }) + } + Some(workflow::Workflow::Semantic(state)) => state + .reference_form(self.project()) + .map(|draft| trigger::FormScope { + session: draft.reference_session(), + modal: true, + }), + _ => None, + } + } else { + None + }; + chooser::widget::Popup::new( + content, + opened.map(|opened| &opened.chooser), + opened.map_or(Rectangle::default(), |opened| opened.request.anchor), + move |event| host.message(event), + ) + .focus_scope(focus_scope) + .into() + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/oa-desktop/src/app/reference_authoring/tests.rs b/crates/oa-desktop/src/app/reference_authoring/tests.rs new file mode 100644 index 00000000..10d137a6 --- /dev/null +++ b/crates/oa-desktop/src/app/reference_authoring/tests.rs @@ -0,0 +1,550 @@ +use super::*; +use crate::forms::Draft; +use oa_core::*; +use serde_json::{Value, json}; + +const OWNER: Id = Id::from_u128(100); +const BASE: u128 = 10_000; +const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype"; + +fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option) { + let generated = p.add_element(kind, layer, name, parent).unwrap(); + let mut element = p.elements.remove(&generated).unwrap(); + element.id = id; + element.alias = format!("object_{}", id.as_u128()); + if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() { + *definition = DataTypeDefinition::Record { supertype: None }; + } + p.elements.insert(id, element); +} +fn catalog() -> Project { + let mut p = Project::empty(); + p.id = Id::from_u128(1); + p.name = "Reference catalog".into(); + element( + &mut p, + OWNER, + Kind::DataType, + Layer::Oa, + "Edited record", + None, + ); + for n in (0..1_000).rev() { + let id = Id::from_u128(BASE + n); + element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None); + p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}"); + } + element( + &mut p, + Id::from_u128(200), + Kind::DataType, + Layer::Sa, + "Controller", + None, + ); + element( + &mut p, + Id::from_u128(201), + Kind::Activity, + Layer::Oa, + "Controller", + None, + ); + p +} +fn inspector(p: Project, selected: Id) -> App { + let mut app = App::with_project(p); + let _ = app.update(Message::Select(selected)); + app +} +fn request(app: &App, host: &Host, path: &str) -> adapter::Open { + app.reference_draft(host, path).unwrap().reference_open( + app.project(), + path, + Rectangle { + x: 790., + y: 640., + width: 278., + height: 32., + }, + ) +} +fn open(app: &mut App, host: &Host, path: &str) { + let request = request(app, host, path); + let message = match host { + Host::Inspector => Message::FieldReferenceOpen(request), + Host::Review { address, facet } => { + Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row { + address: *address, + facet: Some(facet.clone()), + event: RowEvent::ReferenceOpen(request), + })) + } + }; + let _ = app.update(message); + assert!( + app.reference_authoring.opened.is_some(), + "field must open: {}", + app.status + ); +} +fn event(app: &App, action: Action) -> Message { + let opened = app.reference_authoring.opened.as_ref().unwrap(); + opened.host.message(opened.chooser.event(action)) +} +fn search(app: &mut App, query: &str) { + let serial = app + .reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .query_serial() + + 1; + let event = event( + app, + Action::Query { + text: query.into(), + serial, + }, + ); + let _ = app.update(event); +} +fn choose(app: &mut App, id: Id) { + let event = event(app, Action::Choose(id)); + let _ = app.update(event); +} +fn raw(app: &App) -> Value { + serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap() +} +fn review(app: &App) -> &semantic_review::State { + let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else { + panic!("Review is open") + }; + state +} +fn send_review(app: &mut App, event: RowEvent) { + let addressed = review(app).address_event(event); + let _ = app.update(Message::Workflow(workflow::Event::Semantic(addressed))); +} +fn review_host(app: &App) -> Host { + let semantic_review::Event::Row { + address, + facet: Some(facet), + .. + } = review(app).address_event(RowEvent::Preview) + else { + panic!("Active facet") + }; + Host::Review { address, facet } +} +fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) { + p.traces.insert( + id, + Trace { + id, + alias: format!("trace_{}", id.as_u128()), + name: "Rationale".into(), + description: String::new(), + oa, + sa, + kind: TraceKind::Manual, + baseline: None, + }, + ); +} + +#[test] +fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() { + let p = catalog(); + let mut app = inspector(p.clone(), OWNER); + let before = app.document.bundle().clone(); + let revision = app.document.revision(); + // Malformed escaped text is unrelated to the selected reference. Browsing + // and the scoped write must keep its exact spelling and inactive payload. + app.draft.as_mut().unwrap().set_exact(true).unwrap(); + let _ = app.update(Message::Field("/name".into(), "\"unfinished".into())); + let raw_before = raw(&app); + open(&mut app, &Host::Inspector, PATH); + assert_eq!( + app.reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .count(), + 1_000 + ); + search(&mut app, "controller @controller_0720 OA"); + let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser; + assert_eq!(picker.count(), 1); + assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720)); + assert_eq!(raw(&app), raw_before); + assert_eq!(app.document.bundle(), &before); + assert_eq!(app.document.revision(), revision); + assert!(!app.document.can_undo()); + // Captured search shortcuts cannot accidentally save/apply or undo a model. + let _ = app.update(Message::Save(false)); + let _ = app.update(Message::ShortcutUndo(false)); + assert_eq!(raw(&app), raw_before); + assert_eq!(app.document.bundle(), &before); + choose(&mut app, Id::from_u128(BASE + 720)); + let mut expected_raw = raw_before; + *expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720)); + expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string()); + assert_eq!(raw(&app), expected_raw); + assert_eq!(app.document.bundle(), &before); + assert!(!app.document.can_undo()); + let _ = app.update(Message::Apply); + assert_eq!( + app.document.bundle(), + &before, + "Unrelated invalid text remains an Apply error" + ); + let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into())); + let _ = app.update(Message::Apply); + let mut expected = p; + if let Some(SharedBody::DataType { definition, .. }) = + expected.elements.get_mut(&OWNER).unwrap().shared_mut() + { + *definition = DataTypeDefinition::Record { + supertype: Some(Id::from_u128(BASE + 720)), + }; + } + assert_eq!(app.project(), &expected); + let accepted = app.document.bundle().clone(); + assert_ne!(accepted, before); + let _ = app.update(Message::Undo); + assert_eq!(app.document.bundle(), &before); + let _ = app.update(Message::Redo); + assert_eq!(app.document.bundle(), &accepted); + assert_eq!(app.project(), &expected); +} + +#[test] +fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() { + for (raw_id, expected) in [ + ("-".into(), "invalid"), + (Id::from_u128(999).to_string(), "missing"), + (Id::from_u128(200).to_string(), "incompatible"), + ] { + let mut app = inspector(catalog(), OWNER); + app.draft + .as_mut() + .unwrap() + .shape_scoped(PATH, json!(raw_id)) + .unwrap(); + let original = raw(&app); + let accepted = app.document.bundle().clone(); + open(&mut app, &Host::Inspector, PATH); + let current = &app + .reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .current; + assert!(matches!( + (expected, current), + ("invalid", chooser::Current::InvalidRaw(_)) + | ("missing", chooser::Current::Missing(_)) + | ("incompatible", chooser::Current::Incompatible(_)) + )); + let _ = app.update(Message::Escape); + assert_eq!(raw(&app), original); + open(&mut app, &Host::Inspector, PATH); + let clear = event(&app, Action::Clear); + let _ = app.update(clear); + assert!( + app.draft + .as_ref() + .unwrap() + .value + .pointer(PATH) + .unwrap() + .is_null() + ); + assert_eq!(app.document.bundle(), &accepted); + assert!(!app.document.can_undo()); + } +} + +#[test] +fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() { + for change in 0..7 { + let mut app = inspector(catalog(), OWNER); + open(&mut app, &Host::Inspector, PATH); + let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1))); + match change { + 0 => { + app.draft + .as_mut() + .unwrap() + .shape_scoped(PATH, json!(Id::from_u128(BASE + 2))) + .unwrap(); + } + 1 => { + let saved = app.draft.as_ref().unwrap().recovery_snapshot(); + app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap()); + } + 2 => { + app.canvas_document = Id::new_v4(); + } + 3 => { + app.hosted.visible = true; + } + 4 => { + app.draft + .as_mut() + .unwrap() + .set_context(Some(json!({"different":"scope"}))); + } + 5 => { + let _ = app.update(Message::Command(Command::Rename( + Id::from_u128(BASE + 3), + "Changed model".into(), + ))); + } + _ => { + app.source_workspace + .edit("unfinished source".into(), Default::default()); + } + } + let expected = raw(&app); + let source = app.document.bundle().clone(); + let _ = app.update(delayed); + assert!(app.reference_authoring.opened.is_none()); + assert_eq!(raw(&app), expected); + assert_eq!(app.document.bundle(), &source); + } + let mut p = catalog(); + trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200)); + let app = inspector(p, Id::from_u128(300)); + let draft = app.draft.as_ref().unwrap(); + let request = draft.reference_open( + app.project(), + "/baseline/semantic/facets/0/references/0/target", + Rectangle::default(), + ); + assert!(draft.reference_catalog(app.project(), &request, 0).is_err()); +} + +#[test] +fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() { + let mut p = Project::empty(); + for n in 0..3 { + element( + &mut p, + Id::from_u128(BASE + n), + Kind::ExchangeItem, + Layer::Oa, + "Signal item", + None, + ); + } + element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None); + *p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event { + body: EventBody::Signal { + items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)], + }, + }; + let mut app = inspector(p, OWNER); + let list = "/body/Oa/Shared/Event/body/Signal/items"; + let path = format!("{list}/0"); + open(&mut app, &Host::Inspector, &path); + let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2))); + let _ = app.update(Message::FieldArray(list.into(), Some(0), 1)); + let expected = raw(&app); + assert_eq!( + app.draft.as_ref().unwrap().value.pointer(list), + Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)])) + ); + let _ = app.update(delayed); + assert_eq!(raw(&app), expected); + open(&mut app, &Host::Inspector, &path); + let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2))); + let _ = app.update(Message::FieldVariant( + "/body/Oa/Shared/Event/body".into(), + json!({"Change":{"expression":"unfinished"}}), + )); + let expected = raw(&app); + let _ = app.update(delayed); + assert_eq!(raw(&app), expected); + assert!(!app.document.can_undo()); +} + +#[test] +fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet() + { + for mode in 0..3 { + let mut p = Project::empty(); + p.id = Id::from_u128(1); + for (id, layer, parent) in [ + (100, Layer::Oa, None), + (101, Layer::Sa, None), + (102, Layer::Sa, None), + (103, Layer::Oa, Some(Id::from_u128(100))), + (104, Layer::Sa, Some(Id::from_u128(101))), + ] { + element( + &mut p, + Id::from_u128(id), + if parent.is_some() { + Kind::DataField + } else { + Kind::DataType + }, + layer, + "Record", + parent, + ); + } + trace( + &mut p, + Id::from_u128(300), + Id::from_u128(100), + Id::from_u128(101), + ); + let facet = match mode { + 0 => FacetSelector::Data(DataFacet::RecordSupertype), + 1 => FacetSelector::Data(DataFacet::Constructor), + _ => FacetSelector::Child(KeyPair { + oa: Some(Id::from_u128(103)), + sa: Some(Id::from_u128(104)), + }), + }; + let path = if mode == 2 { + "/parent" + } else { + "/body/Sa/Shared/DataType/definition/Record/supertype" + }; + let mut app = App::with_project(p.clone()); + let _ = app.update(Message::StartSemanticReview(Id::from_u128(100))); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)), + ); + let host = review_host(&app); + let record = if mode == 2 { + Id::from_u128(104) + } else { + Id::from_u128(101) + }; + assert_eq!(app.reference_draft(&host, path).unwrap().original, record); + let before = review(&app).recovery_snapshot().unwrap(); + let plan = serde_json::to_value(&review(&app).plan).unwrap(); + let proposed = review(&app).proposed.clone(); + let source = app.document.bundle().clone(); + open(&mut app, &host, path); + search(&mut app, "@object_102 SA"); + assert_eq!( + app.reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .count(), + 1 + ); + assert_eq!(review(&app).recovery_snapshot().unwrap(), before); + assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan); + assert_eq!(review(&app).proposed, proposed); + assert_eq!(app.document.bundle(), &source); + let old = event(&app, Action::Choose(Id::from_u128(102))); + send_review( + &mut app, + RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)), + ); + let switched = review(&app).recovery_snapshot().unwrap(); + let _ = app.update(old); + assert_eq!(review(&app).recovery_snapshot().unwrap(), switched); + send_review(&mut app, RowEvent::Select(facet)); + open(&mut app, &host, path); + choose(&mut app, Id::from_u128(102)); + let draft = app.reference_draft(&host, path).unwrap(); + let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap(); + *expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102)); + assert_eq!(draft.materialize().unwrap(), expected_record); + assert_eq!(app.document.bundle(), &source); + assert!( + !app.document.can_undo(), + "A choice only stages the active review candidate" + ); + } +} + +mod mounted; + +#[test] +fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() { + let mut p = Project::empty(); + for (id, layer) in [ + (100, Layer::Oa), + (101, Layer::Sa), + (102, Layer::Sa), + (110, Layer::Oa), + (111, Layer::Sa), + ] { + element( + &mut p, + Id::from_u128(id), + Kind::DataType, + layer, + "Record", + None, + ); + } + trace( + &mut p, + Id::from_u128(300), + Id::from_u128(100), + Id::from_u128(101), + ); + trace( + &mut p, + Id::from_u128(310), + Id::from_u128(110), + Id::from_u128(111), + ); + let mut app = App::with_project(p); + let _ = app.update(Message::StartSemanticReview(Id::from_u128(300))); + let facet = FacetSelector::Data(DataFacet::RecordSupertype); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + let host = review_host(&app); + let path = "/body/Sa/Shared/DataType/definition/Record/supertype"; + let old_open = request(&app, &host, path); + open(&mut app, &host, path); + let old_choice = event(&app, Action::Choose(Id::from_u128(102))); + for event in [ + semantic_review::Event::ChooseTrace(crate::app::Choice { + id: Id::from_u128(310), + label: "Second exact trace".into(), + }), + semantic_review::Event::OpenTrace, + ] { + let _ = app.update(Message::Workflow(workflow::Event::Semantic(event))); + } + assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111))); + let expected = review(&app).recovery_snapshot().unwrap(); + let source = app.document.bundle().clone(); + let _ = app.update(old_choice); + let Host::Review { address, facet } = host else { + unreachable!() + }; + let _ = app.update(Message::Workflow(workflow::Event::Semantic( + semantic_review::Event::Row { + address, + facet: Some(facet), + event: RowEvent::ReferenceOpen(old_open), + }, + ))); + assert!(app.reference_authoring.opened.is_none()); + assert_eq!(review(&app).recovery_snapshot().unwrap(), expected); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/app/reference_authoring/tests/mounted.rs b/crates/oa-desktop/src/app/reference_authoring/tests/mounted.rs new file mode 100644 index 00000000..76388029 --- /dev/null +++ b/crates/oa-desktop/src/app/reference_authoring/tests/mounted.rs @@ -0,0 +1,890 @@ +//! Real field + app-root overlay interaction. The null text renderer lets the +//! same retained widget tree exercise focus and geometry without a GPU/window. +use super::*; +use iced::futures::StreamExt; +use iced::{ + Event as Input, Point, Size, Theme, Vector, + advanced::{ + Layout, Shell, layout, + widget::{Id as WidgetId, Operation, Tree, operation}, + }, + keyboard, mouse, + widget::{Space, column, container, opaque, scrollable, stack, text_input}, +}; + +#[derive(Default)] +struct Clipboard; +impl iced::advanced::Clipboard for Clipboard { + fn read(&self, _: iced::advanced::clipboard::Kind) -> Option { + None + } + fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {} +} +#[derive(Default)] +struct Fields { + values: Vec<(WidgetId, Rectangle, bool)>, + scrolls: Vec<(Rectangle, Vector)>, +} +impl Operation for Fields { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + visit(self); + } + fn focusable( + &mut self, + id: Option<&WidgetId>, + bounds: Rectangle, + state: &mut dyn operation::Focusable, + ) { + if let Some(id) = id { + self.values.push((id.clone(), bounds, state.is_focused())); + } + } + fn scrollable( + &mut self, + _: Option<&WidgetId>, + bounds: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn operation::Scrollable, + ) { + self.scrolls.push((bounds, translation)); + } +} +impl Fields { + fn field(&self, id: &WidgetId) -> (Rectangle, bool) { + self.values + .iter() + .find(|(actual, _, _)| actual == id) + .map(|(_, bounds, focused)| (*bounds, *focused)) + .expect("Mounted field identity") + } +} +fn view( + app: &App, + scrolled: bool, + nested: bool, + show_review: bool, +) -> Element<'_, Message, Theme, ()> { + let draft = app.draft.as_ref().unwrap(); + let form = column![ + text_input("Name", &draft.inputs["/name"].raw) + .id("before-reference") + .on_input(|raw| Message::Field("/name".into(), raw)), + adapter::field(draft, app.project(), PATH), + text_input("Description", &draft.inputs["/description"].raw) + .id("after-reference") + .on_input(|raw| Message::Field("/description".into(), raw)), + ] + .spacing(9); + let form = trigger::scope(form, draft.reference_session()); + let base: Element<'_, Message, Theme, ()> = if scrolled { + container( + scrollable(column![Space::new().height(500), form]) + .id("reference-scroll") + .height(180), + ) + .width(300) + .into() + } else { + container(form).width(300).height(iced::Fill).into() + }; + let base: Element<'_, Message, Theme, ()> = if nested { + scrollable(column![Space::new().height(400), base]) + .id("outer-reference-scroll") + .height(180) + .width(300) + .into() + } else { + base + }; + let base = if show_review { + let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() { + Some(workflow::Workflow::Semantic(state)) => { + match state.reference_form(app.project()) { + Some(draft) => { + let mut content = column![]; + if let Some(raw) = &state.operation_omission { + content = content.push( + text_input("Required engineering rationale", raw) + .id("review-rationale") + .on_input(|raw| { + Message::Workflow(workflow::Event::Semantic( + state.address_event(RowEvent::OperationRationale(raw)), + )) + }), + ); + } + content = content.push(trigger::scope( + adapter::field(draft, app.project(), "/parent").map(|message| { + match message { + Message::FieldReferenceOpen(request) => { + Message::Workflow(workflow::Event::Semantic( + state.address_event(RowEvent::ReferenceOpen(request)), + )) + } + other => other, + } + }), + draft.reference_session(), + )); + opaque(container(trigger::modal(content)).width(300).height(180)).into() + } + None => Space::new().into(), + } + } + _ => Space::new().into(), + }; + stack![base, review].into() + } else { + base + }; + app.reference_overlay(base) +} +struct Mount { + tree: Option, + size: Size, + scrolled: bool, + nested: bool, + show_review: bool, +} +impl Default for Mount { + fn default() -> Self { + Self { + tree: None, + size: Size::new(360., 320.), + scrolled: false, + nested: false, + show_review: false, + } + } +} +impl Mount { + fn frame( + &mut self, + app: &App, + events: &[(Input, Option)], + ) -> (Vec, Fields, Option) { + let mut view = view(app, self.scrolled, self.nested, self.show_review); + let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget())); + view.as_widget().diff(tree); + let node = + view.as_widget_mut() + .layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size)); + let viewport = Rectangle::with_size(self.size); + let overlay_node = view + .as_widget_mut() + .overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO) + .map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size)); + let mut messages = Vec::new(); + for (input, point) in events { + let mut shell = Shell::new(&mut messages); + let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available); + if let Some(overlay_node) = &overlay_node { + let mut overlay = view + .as_widget_mut() + .overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO) + .unwrap(); + overlay.as_overlay_mut().update( + input, + Layout::new(overlay_node), + cursor, + &(), + &mut Clipboard, + &mut shell, + ); + assert!(shell.is_event_captured()); + } else { + view.as_widget_mut().update( + tree, + input, + Layout::new(&node), + cursor, + &(), + &mut Clipboard, + &mut shell, + &viewport, + ); + } + } + let mut fields = Fields::default(); + view.as_widget_mut() + .operate(tree, Layout::new(&node), &(), &mut fields); + ( + messages, + fields, + overlay_node.map(|node| node.children()[0].bounds()), + ) + } + fn click(&mut self, app: &App, point: Point) -> Vec { + self.frame( + app, + &[ + ( + Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)), + Some(point), + ), + ( + Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)), + Some(point), + ), + ], + ) + .0 + } + async fn perform(&mut self, app: &App, task: Task) { + let Some(mut stream) = iced_runtime::task::into_stream(task) else { + return; + }; + while let Some(action) = stream.next().await { + let iced_runtime::Action::Widget(mut operation) = action else { + panic!("Chooser only requests scoped widget focus") + }; + let mut view = view(app, self.scrolled, self.nested, self.show_review); + let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget())); + view.as_widget().diff(tree); + let node = + view.as_widget_mut() + .layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size)); + loop { + view.as_widget_mut() + .operate(tree, Layout::new(&node), &(), operation.as_mut()); + match operation.finish() { + operation::Outcome::Chain(next) => operation = next, + _ => break, + } + } + } + } + async fn dispatch(&mut self, app: &mut App, messages: Vec) { + for message in messages { + let task = app.update(message); + self.perform(app, task).await; + } + } +} +fn key(key: keyboard::key::Named, shift: bool) -> Input { + Input::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(key), + modified_key: keyboard::Key::Named(key), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space), + location: keyboard::Location::Standard, + modifiers: if shift { + keyboard::Modifiers::SHIFT + } else { + keyboard::Modifiers::empty() + }, + text: None, + repeat: false, + }) +} +fn typing(raw: &str) -> Vec<(Input, Option)> { + raw.chars() + .map(|character| { + let raw = character.to_string(); + ( + Input::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Character(raw.clone().into()), + modified_key: keyboard::Key::Character(raw.clone().into()), + physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA), + location: keyboard::Location::Standard, + modifiers: keyboard::Modifiers::empty(), + text: Some(raw.into()), + repeat: false, + }), + None, + ) + }) + .collect() +} + +#[tokio::test] +async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() { + let mut app = inspector(catalog(), OWNER); + let accepted = app.document.bundle().clone(); + let original = raw(&app); + let mut mount = Mount::default(); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("before-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]); + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + assert!( + fields.field(&id).1, + "Tab reaches the real reference trigger" + ); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + assert_eq!(messages.len(), 1, "One keyboard activation opens one field"); + mount.dispatch(&mut app, messages).await; + let popup = mount + .frame(&app, &[]) + .2 + .expect("App root overlay is mounted"); + assert!( + popup.x >= 0. + && popup.y >= 0. + && popup.x + popup.width <= 360. + && popup.y + popup.height <= 320. + ); + let mut inputs = typing("@controller_0720"); + inputs.push((key(keyboard::key::Named::Escape, false), None)); + let messages = mount.frame(&app, &inputs).0; + mount.dispatch(&mut app, messages).await; + let (_, fields, popup) = mount.frame(&app, &[]); + assert!(popup.is_none()); + assert!( + fields.field(&id).1, + "Escape returns to its exact reference field" + ); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &accepted); + for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] { + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]) + .0; + mount.dispatch(&mut app, messages).await; + let (_, fields, popup) = mount.frame(&app, &[]); + assert!(popup.is_none()); + assert!( + fields.field(&WidgetId::new(expected)).1, + "Chooser Tab stays within the originating form" + ); + } + assert_eq!(app.document.bundle(), &accepted); +} + +#[tokio::test] +async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field() + { + let mut app = inspector(catalog(), OWNER); + let accepted = app.document.bundle().clone(); + let mut mount = Mount::default(); + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + let messages = mount.frame(&app, &typing("@controller_0720")).0; + mount.dispatch(&mut app, messages).await; + assert_eq!( + app.reference_authoring + .opened + .as_ref() + .unwrap() + .chooser + .count(), + 1 + ); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + assert_eq!(messages.len(), 1); + let delayed_focus = app.update(messages.into_iter().next().unwrap()); + assert_eq!( + app.draft.as_ref().unwrap().value.pointer(PATH), + Some(&json!(Id::from_u128(BASE + 720))) + ); + assert_eq!(app.document.bundle(), &accepted); + // A structural edit occurs after Choose but before its asynchronous focus. + app.draft + .as_mut() + .unwrap() + .shape_scoped(PATH, Value::Null) + .unwrap(); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("after-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + mount.perform(&app, delayed_focus).await; + let fields = mount.frame(&app, &[]).1; + assert!( + fields.field(&WidgetId::new("after-reference")).1, + "A missing old field target makes no focus changes" + ); + assert!( + !fields + .field(&trigger::field_id( + app.draft.as_ref().unwrap().reference_generation(), + PATH + )) + .1 + ); + assert_eq!(app.document.bundle(), &accepted); +} + +#[tokio::test] +async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() { + let mut app = inspector(catalog(), OWNER); + let mut mount = Mount { + scrolled: true, + ..Default::default() + }; + mount.frame(&app, &[]); + mount + .perform( + &app, + iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END), + ) + .await; + let fields = mount.frame(&app, &[]).1; + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let content = fields.field(&id).0; + let (viewport, offset) = fields.scrolls[0]; + assert!(offset.y > 400., "Fixture is genuinely scrolled"); + let expected = Rectangle { + x: content.x - offset.x, + y: content.y - offset.y, + ..content + } + .intersection(&viewport) + .unwrap(); + let messages = mount.click(&app, expected.center()); + let [Message::FieldReferenceOpen(request)] = messages.as_slice() else { + panic!("Scrolled field should emit one actual Open") + }; + assert_eq!(request.anchor, expected); + mount.dispatch(&mut app, messages).await; + let close = app.update(Message::Escape); + mount.perform(&app, close).await; + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + let [Message::FieldReferenceOpen(request)] = messages.as_slice() else { + panic!("Focused scrolled field opens without a pointer") + }; + assert_eq!(request.anchor, expected); +} + +#[tokio::test] +async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() { + for nested in [false, true] { + let mut app = inspector(catalog(), OWNER); + let original = raw(&app); + let source = app.document.bundle().clone(); + let mut mount = Mount { + scrolled: true, + nested, + ..Default::default() + }; + let fields = mount.frame(&app, &[]).1; + assert!( + fields + .scrolls + .iter() + .all(|(_, offset)| *offset == Vector::ZERO) + ); + assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]) + .0; + assert!( + messages.is_empty(), + "Keyboard reveal must not author any input" + ); + let fields = mount.frame(&app, &[]).1; + assert!(fields.field(&WidgetId::new("before-reference")).1); + assert!( + fields.scrolls.iter().all(|(_, offset)| offset.y > 0.), + "Every enclosing viewport reveals fresh focus" + ); + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum(); + assert!( + fields.field(&id).0.y - offset >= 180., + "The next reference starts below the fold" + ); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]) + .0; + assert!(messages.is_empty()); + let fields = mount.frame(&app, &[]).1; + let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum(); + let (bounds, focused) = fields.field(&id); + assert!(focused); + assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01); + let messages = mount + .frame(&app, &[(key(keyboard::key::Named::Enter, false), None)]) + .0; + assert_eq!( + messages.len(), + 1, + "A newly revealed field opens through Enter" + ); + mount.dispatch(&mut app, messages).await; + let return_focus = app.update(Message::Escape); + mount + .perform( + &app, + iced::widget::operation::snap_to( + "reference-scroll", + scrollable::RelativeOffset::START, + ), + ) + .await; + if nested { + mount + .perform( + &app, + iced::widget::operation::snap_to( + "outer-reference-scroll", + scrollable::RelativeOffset::START, + ), + ) + .await; + } + assert!( + mount + .frame(&app, &[]) + .1 + .scrolls + .iter() + .all(|(_, offset)| *offset == Vector::ZERO) + ); + mount.perform(&app, return_focus).await; + let fields = mount.frame(&app, &[]).1; + let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum(); + let (bounds, focused) = fields.field(&id); + assert!( + focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01 + ); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &source); + } +} + +#[tokio::test] +async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() { + for backwards in [false, true] { + let app = inspector(catalog(), OWNER); + let original = raw(&app); + let mut mount = Mount::default(); + let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let forward = [ + WidgetId::new("before-reference"), + reference.clone(), + WidgetId::new("after-reference"), + ]; + let reverse = [ + WidgetId::new("after-reference"), + reference, + WidgetId::new("before-reference"), + ]; + let expected = if backwards { &reverse } else { &forward }; + for n in 0..7 { + let (messages, fields, popup) = + mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]); + assert!(messages.is_empty() && popup.is_none()); + assert!(fields.field(&expected[n % 3]).1); + assert_eq!( + fields + .values + .iter() + .filter(|(_, _, focused)| *focused) + .count(), + 1 + ); + } + assert_eq!(raw(&app), original); + assert!(!app.document.can_undo()); + } +} + +#[tokio::test] +async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() { + let mut p = catalog(); + trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200)); + let mut app = inspector(p, OWNER); + let source = app.document.bundle().clone(); + let original = raw(&app); + let mut mount = Mount { + show_review: true, + ..Default::default() + }; + let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH); + let session = app.draft.as_ref().unwrap().reference_session(); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + let return_focus = app.update(Message::Escape); + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("after-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let _ = app.update(Message::StartSemanticReview(Id::from_u128(300))); + let facet = FacetSelector::Header(HeaderFacet::Parent); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + assert_eq!( + app.draft.as_ref().unwrap().reference_session(), + session, + "Old Inspector really remains mounted" + ); + assert_eq!( + trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH), + id + ); + let review_before = review(&app).recovery_snapshot().unwrap(); + let review_field = trigger::field_id( + review(&app) + .reference_form(app.project()) + .unwrap() + .reference_generation(), + "/parent", + ); + mount.perform(&app, return_focus).await; + let fields = mount.frame(&app, &[]).1; + assert!( + !fields.field(&id).1, + "A live old field underneath a modal cannot receive the stale focus task" + ); + assert!(fields.field(&WidgetId::new("after-reference")).1); + assert!(!fields.field(&review_field).1); + let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]); + assert!(messages.is_empty()); + assert!( + fields.field(&review_field).1, + "Review entry ignores retained focus under its modal" + ); + assert!( + !fields.field(&WidgetId::new("after-reference")).1, + "Modal focus acquisition deactivates background inputs" + ); + let messages = mount.frame(&app, &typing("x")).0; + assert!( + messages.is_empty(), + "A character at the focused Review trigger cannot type into the Inspector below it" + ); + let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]); + assert!(messages.is_empty()); + assert!(fields.field(&review_field).1); + assert!(!fields.field(&id).1); + assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &source); +} + +#[tokio::test] +async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() { + let mut p = catalog(); + trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200)); + let mut app = inspector(p, OWNER); + let original = raw(&app); + let source = app.document.bundle().clone(); + let mut mount = Mount { + show_review: true, + ..Default::default() + }; + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("after-reference")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let _ = app.update(Message::StartSemanticReview(Id::from_u128(300))); + let facet = FacetSelector::Header(HeaderFacet::Parent); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + let saved = review(&app).recovery_snapshot().unwrap(); + let id = trigger::field_id( + review(&app) + .reference_form(app.project()) + .unwrap() + .reference_generation(), + "/parent", + ); + let fields = mount.frame(&app, &[]).1; + assert!( + fields.field(&WidgetId::new("after-reference")).1, + "Fixture retains Inspector focus before the modal receives input" + ); + let messages = mount.click(&app, fields.field(&id).0.center()); + mount.dispatch(&mut app, messages).await; + assert!(matches!( + &app.reference_authoring.opened.as_ref().unwrap().host, + Host::Review { .. } + )); + let fields = mount.frame(&app, &[]).1; + assert!(!fields.field(&WidgetId::new("after-reference")).1); + let messages = mount.click(&app, Point::new(359., 319.)); + assert!(matches!( + messages.as_slice(), + [Message::Workflow(workflow::Event::Semantic( + semantic_review::Event::Row { + event: RowEvent::ReferenceEvent(chooser::Event { + action: Action::Close(Close::Outside), + .. + }), + .. + } + ))] + )); + for message in messages { + let task = app.update(message); + assert!( + iced_runtime::task::into_stream(task).is_none(), + "Outside dismissal must not request focus or apply" + ); + } + let (messages, fields, popup) = mount.frame(&app, &typing("x")); + assert!(messages.is_empty() && popup.is_none()); + assert!(!fields.field(&WidgetId::new("after-reference")).1); + assert_eq!(review(&app).recovery_snapshot().unwrap(), saved); + assert_eq!(raw(&app), original); + assert_eq!(app.document.bundle(), &source); +} + +#[tokio::test] +async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form() + { + let mut p = persistence::decode(include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../harness/fixtures/interface-operation-probe/output/boundary-only.json" + ))) + .unwrap(); + let sa = p + .elements + .values() + .find(|element| { + element.layer() == Layer::Sa + && matches!( + element.shared(), + Some(SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + operation: Some(_), + message_kind: MessageKind::Call | MessageKind::Asynchronous, + .. + }, + .. + }) + ) + }) + .unwrap() + .id; + let inspector_id = Id::new_v4(); + element( + &mut p, + inspector_id, + Kind::DataType, + Layer::Oa, + "Inspector record", + None, + ); + let scenario = p + .add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None) + .unwrap(); + let oa = p + .add_element( + Kind::Occurrence, + Layer::Oa, + "Operational message", + Some(scenario), + ) + .unwrap(); + *p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence { + body: OccurrenceBody::Exchange { + exchange: None, + sender: None, + receiver: None, + message_kind: MessageKind::Asynchronous, + reply_to: None, + values: vec![], + operation: None, + }, + precondition: String::new(), + postcondition: String::new(), + }; + p.elements + .get_mut(&scenario) + .unwrap() + .steps_mut() + .unwrap() + .push(oa); + let trace_id = Id::new_v4(); + trace(&mut p, trace_id, oa, sa); + let mut app = inspector(p, inspector_id); + let source = app.document.bundle().clone(); + let inspector_raw = raw(&app); + let _ = app.update(Message::StartSemanticReview(trace_id)); + let _ = app.update(Message::Workflow(workflow::Event::Semantic( + semantic_review::Event::Direction(TransitionDirection::SaToOa), + ))); + let facet = FacetSelector::Header(HeaderFacet::Parent); + send_review(&mut app, RowEvent::Select(facet.clone())); + send_review( + &mut app, + RowEvent::Decide(facet, Some(SemanticDecision::Candidate)), + ); + send_review(&mut app, RowEvent::OperationOmission(true)); + assert_eq!(review(&app).direction, TransitionDirection::SaToOa); + assert_eq!(review(&app).operation_omission.as_deref(), Some("")); + let candidate = serde_json::to_value( + review(&app) + .reference_form(app.project()) + .unwrap() + .recovery_snapshot(), + ) + .unwrap(); + let mut mount = Mount { + show_review: true, + ..Default::default() + }; + let fields = mount.frame(&app, &[]).1; + let messages = mount.click( + &app, + fields.field(&WidgetId::new("review-rationale")).0.center(), + ); + mount.dispatch(&mut app, messages).await; + let messages = mount.frame(&app, &typing("Engineering reason")).0; + assert_eq!( + messages.len(), + "Engineering reason".chars().count(), + "Every key reaches the legitimate sibling inside Review" + ); + assert!(messages.iter().all(|message| matches!( + message, + Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row { + event: RowEvent::OperationRationale(_), + .. + })) + ))); + mount.dispatch(&mut app, messages).await; + assert_eq!( + review(&app).operation_omission.as_deref(), + Some("Engineering reason") + ); + assert!( + mount + .frame(&app, &[]) + .1 + .field(&WidgetId::new("review-rationale")) + .1 + ); + assert_eq!( + serde_json::to_value( + review(&app) + .reference_form(app.project()) + .unwrap() + .recovery_snapshot() + ) + .unwrap(), + candidate + ); + assert_eq!(raw(&app), inspector_raw); + assert_eq!(app.document.bundle(), &source); + assert!(!app.document.can_undo()); +} diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs index 21e1e749..307081e3 100644 --- a/crates/oa-desktop/src/behavior.rs +++ b/crates/oa-desktop/src/behavior.rs @@ -45,6 +45,22 @@ mod scenario_scroll { area, process: false, restore, + reveal: None, + }) + } + pub(super) fn observe_canvas<'a, R: renderer::Renderer + 'a>( + context: scenario::Context, + restore: Option, + reveal: Option, + content: impl Into>, + ) -> Element<'a, Message, Theme, R> { + Element::new(Observer { + content: content.into(), + context, + area: scenario::ScrollArea::Canvas, + process: false, + restore, + reveal, }) } pub(super) fn observe_topology<'a>( @@ -63,6 +79,7 @@ mod scenario_scroll { area, process: true, restore: None, + reveal: None, }) } struct Observer<'a, R = Renderer> { @@ -71,12 +88,14 @@ mod scenario_scroll { area: scenario::ScrollArea, process: bool, restore: Option, + reveal: Option, } type Report = Option<(bool, Id, Id, Vector)>; struct ObservedScroll { identity: (Id, Id), report: Report, restored: Option, + revealed: Option<(Id, scenario::Slot)>, } #[derive(Default)] struct Translation(Option); @@ -115,6 +134,41 @@ mod scenario_scroll { self.applied = true; } } + struct RevealTranslation { + request: scenario::ScrollReveal, + applied: bool, + } + impl widget::Operation for RevealTranslation { + fn traverse(&mut self, _: &mut dyn FnMut(&mut dyn widget::Operation)) {} + fn scrollable( + &mut self, + _: Option<&widget::Id>, + bounds: Rectangle, + _: Rectangle, + translation: Vector, + state: &mut dyn widget::operation::Scrollable, + ) { + let reveal = self.request; + if !reveal.y.is_finite() || !reveal.padding.is_finite() || bounds.height <= 0. { + return; + } + let padding = reveal.padding.max(0.).min(bounds.height / 2.); + let y = if reveal.y - padding < translation.y { + (reveal.y - padding).max(0.) + } else if reveal.y + padding > translation.y + bounds.height { + (reveal.y + padding - bounds.height).max(0.) + } else { + translation.y + }; + // Preserve horizontal position and reveal only once per invocation + // and explicit destination. Later ordinary scrolling stays ordinary. + state.scroll_to(widget::operation::scrollable::AbsoluteOffset { + x: None, + y: Some(y), + }); + self.applied = true; + } + } impl Observer<'_, R> { fn report( &mut self, @@ -142,6 +196,27 @@ mod scenario_scroll { ); operation.applied.then_some(request.token) }); + if let Some(reveal) = self.reveal.filter(|request| { + !self.process + && receipt.is_none() + && tree.state.downcast_ref::().revealed + != Some((request.token, request.slot)) + }) { + let mut operation = RevealTranslation { + request: reveal, + applied: false, + }; + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + &mut operation, + ); + if operation.applied { + tree.state.downcast_mut::().revealed = + Some((reveal.token, reveal.slot)); + } + } let mut offset = Translation::default(); self.content.as_widget_mut().operate( &mut tree.children[0], @@ -202,6 +277,7 @@ mod scenario_scroll { identity: (self.context.document, self.context.scenario), report: None, restored: None, + revealed: None, }) } fn children(&self) -> Vec { diff --git a/crates/oa-desktop/src/behavior/scenario.rs b/crates/oa-desktop/src/behavior/scenario.rs index 82f37e95..83c3e654 100644 --- a/crates/oa-desktop/src/behavior/scenario.rs +++ b/crates/oa-desktop/src/behavior/scenario.rs @@ -5,6 +5,7 @@ use super::*; use iced::widget::{Column, column, text_input}; use std::{cell::RefCell, collections::BTreeSet}; mod workspace; +pub(crate) use workspace::NAME_INPUT; pub use workspace::{Shelf, view}; #[cfg(test)] @@ -97,6 +98,7 @@ pub enum CanvasEvent { Scrolled(Context, ScrollArea, iced::Vector), Restored(Context, Id, ScrollArea, iced::Vector), Viewport(Context, Size), + FormShown(Context, Id), Zoom(Context, Option), Focus(Context), Shelf(Context, Shelf), @@ -135,6 +137,7 @@ impl CanvasEvent { Self::Scrolled(context, _, _) | Self::Restored(context, ..) | Self::Viewport(context, _) + | Self::FormShown(context, _) | Self::Zoom(context, _) | Self::Focus(context) | Self::Shelf(context, _) @@ -197,6 +200,8 @@ const VALUE_PAGE_SIZE: usize = 24; #[derive(Default)] pub struct State { draft: Option, + form_session: Id, + form_focus_pending: bool, canvas_tool: Option<(Id, CanvasTool)>, call_choices: RefCell)>>, scroll_offsets: [iced::Vector; 2], @@ -228,6 +233,13 @@ pub(super) struct ScrollRestore { pub token: Id, pub offset: iced::Vector, } +#[derive(Clone, Copy)] +pub(super) struct ScrollReveal { + pub token: Id, + pub slot: Slot, + pub y: f32, + pub padding: f32, +} #[derive(PartialEq, Eq)] struct CallChoiceContext { document: Id, @@ -415,10 +427,25 @@ impl State { let draft = serde_json::from_value(value).map_err(|e| e.to_string())?; Ok(Self { draft: Some(draft), + form_session: Id::new_v4(), + form_focus_pending: true, ..Default::default() }) } + pub(crate) fn form_session(&self) -> Option { + self.draft.as_ref().map(|_| self.form_session) + } + + pub(crate) fn take_form_focus(&mut self, session: Id) -> bool { + if self.form_session() == Some(session) && self.form_focus_pending { + self.form_focus_pending = false; + true + } else { + false + } + } + pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool { if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. { return false; @@ -576,6 +603,8 @@ impl State { if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) { return Err("Choose a scenario.".into()); } + self.form_session = Id::new_v4(); + self.form_focus_pending = true; self.draft = Some(Draft { owner, parent: owner, diff --git a/crates/oa-desktop/src/behavior/scenario/workspace.rs b/crates/oa-desktop/src/behavior/scenario/workspace.rs index 121136ce..9ed1d152 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace.rs @@ -1,7 +1,9 @@ //! Scenario presentation. Layout/camera controls never construct model commands. use super::super::scenario_scroll; use super::*; -use iced::widget::{Space, column, sensor}; +use iced::widget::{Space, column, keyed_column, sensor}; + +pub(crate) const NAME_INPUT: &str = "scenario-form-name"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Shelf { @@ -82,10 +84,21 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a // The outline is a sibling shelf, not an ancestor of this canvas. scene.scroll_offset = state.scroll_offset(); scene.pending_placement = state.pending_placement(owner); + let reveal = state.form_session().and_then(|token| { + let (slot, _) = scene.pending_placement?; + let marker = scene.slots.iter().find(|marker| marker.slot == slot)?; + Some(ScrollReveal { + token, + slot, + y: marker.y * scale, + padding: (24. * scale).max(12.), + }) + }); let size = Size::new(scene.size.width * scale, scene.size.height * scale); let canvas = canvas_pane( context, state.scroll_restore(context, ScrollArea::Canvas), + reveal, canvas::Canvas::new(scene) .width(size.width) .height(size.height) @@ -287,7 +300,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a presentation.shelf.is_some(), pending, ); - let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into()); + let mut form_panel = editor_panel( + Id::nil(), + Space::new().height(0).into(), + Space::new().height(0).into(), + ); if let Some(d) = app .behavior .scenario @@ -298,6 +315,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a let mut form = column![ text(d.tool.label()).size(16), text_input("Name (defaults to selected source)", &d.name) + .id(NAME_INPUT) .on_input(|s| event(Event::Name(s))) .padding(9) .size(12) @@ -560,7 +578,11 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a action("Cancel", event(Event::Cancel)) ] .spacing(7); - form_panel = editor_panel(footer.into(), form.into()); + let session = state.form_session().expect("visible scenario draft"); + form_panel = sensor(editor_panel(session, footer.into(), form.into())) + .key(session) + .on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, session))) + .into(); } workspace_layout(header.into(), details, canvas, form_panel, pending) } @@ -633,13 +655,14 @@ fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>( fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>( context: Context, restore: Option, + reveal: Option, content: iced::Element<'a, Message, Theme, R>, ) -> iced::Element<'a, Message, Theme, R> { container( - sensor(scenario_scroll::observe( + sensor(scenario_scroll::observe_canvas( context, - ScrollArea::Canvas, restore, + reveal, scrollable(content) .id("scenario-canvas") .direction(scrollable::Direction::Both { @@ -661,16 +684,23 @@ fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>( } fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>( + session: Id, actions: iced::Element<'a, Message, Theme, R>, fields: iced::Element<'a, Message, Theme, R>, ) -> iced::Element<'a, Message, Theme, R> { container( column![ container(actions).id("scenario-actions").width(Fill), - scrollable(fields) - .id("scenario-form") - .width(Fill) - .height(Fill), + keyed_column([( + session, + scrollable(fields) + .id("scenario-form") + .width(Fill) + .height(Fill) + .into(), + )]) + .width(Fill) + .height(Fill), ] .spacing(6), ) diff --git a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs index 27b2441e..5321851b 100644 --- a/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs +++ b/crates/oa-desktop/src/behavior/scenario/workspace/tests.rs @@ -49,15 +49,278 @@ fn fixture_view(state: &State, context: Context, pending: bool, header: f32) -> canvas_pane( context, state.scroll_restore(context, ScrollArea::Canvas), + None, Space::new().width(1800.).height(2400.).into(), ), editor_panel( + state.form_session().unwrap_or_default(), Space::new().height(32.).into(), Space::new().height(2000.).into(), ), pending, ) } + +#[test] +fn new_scenario_form_resets_only_its_scroll_and_recovery_gets_a_fresh_session() { + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let _ = app.update(event(Event::Start(f.owner, Tool::Action))); + let first = app.behavior.scenario.form_session().unwrap(); + let size = Size::new(500., 570.); + let mut view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + true, + 132., + ); + let mut tree = widget::Tree::new(view.as_widget()); + let (node, _) = geometry(&mut view, &mut tree, size); + scroll( + &mut view, + &mut tree, + &node, + "scenario-form", + Vector::new(0., 700.), + ); + scroll( + &mut view, + &mut tree, + &node, + "scenario-canvas", + Vector::new(137., 850.), + ); + redraw(&mut view, &mut tree, size, &mut app); + let accepted = app.document.snapshot(); + let _ = app.update(event(Event::Name(" retained raw 雨 ".into()))); + let raw = app.behavior.scenario.recovery_snapshot().unwrap(); + view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + true, + 132., + ); + tree.diff(view.as_widget()); + let (_, same_form) = geometry(&mut view, &mut tree, size); + assert_eq!(same_form.scrolls["scenario-form"].1.y, 700.); + assert_eq!(app.behavior.scenario.form_session(), Some(first)); + + // No intervening closed render: replacing the editing session itself must + // invalidate the old scroll, while preserving the canvas's actual offset. + let _ = app.update(event(Event::Cancel)); + let _ = app.update(event(Event::Start(f.owner, Tool::Observation))); + assert_ne!(app.behavior.scenario.form_session(), Some(first)); + view = fixture_view( + &app.behavior.scenario, + app.scenario_context().unwrap(), + true, + 132., + ); + tree.diff(view.as_widget()); + let (_, next_form) = geometry(&mut view, &mut tree, size); + assert_eq!(next_form.scrolls["scenario-form"].1, Vector::ZERO); + assert_eq!( + next_form.scrolls["scenario-canvas"].1, + Vector::new(137., 850.) + ); + let recovered = State::from_recovery(raw.unwrap()).unwrap(); + assert_ne!(recovered.form_session(), Some(first)); + assert_eq!( + recovered.draft.as_ref().unwrap().name, + " retained raw 雨 " + ); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo()); +} + +#[test] +fn pending_marker_reveals_in_the_actual_smaller_canvas_once_without_horizontal_or_history_drift() { + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let context = app.scenario_context().unwrap(); + let make = |reveal| -> TestView { + canvas_pane( + context, + None, + reveal, + Space::new().width(1800.).height(2400.).into(), + ) + }; + let mut view = make(None); + let mut tree = widget::Tree::new(view.as_widget()); + let wide = Size::new(500., 500.); + let (node, _) = geometry(&mut view, &mut tree, wide); + scroll( + &mut view, + &mut tree, + &node, + "scenario-canvas", + Vector::new(137., 800.), + ); + redraw(&mut view, &mut tree, wide, &mut app); + let accepted = app.document.snapshot(); + let request = ScrollReveal { + token: Id::new_v4(), + slot: Slot { + parent: f.owner, + before: Some(f.reply), + }, + y: 1100., + padding: 24., + }; + view = make(Some(request)); + tree.diff(view.as_widget()); + let small = Size::new(500., 200.); + redraw(&mut view, &mut tree, small, &mut app); + let (node, shown) = geometry(&mut view, &mut tree, small); + let (bounds, offset) = shown.scrolls["scenario-canvas"]; + assert_eq!(offset.x, 137.); + assert_eq!(offset.y, 1100. + 24. - bounds.height); + assert!(1100. - 24. >= offset.y); + assert!(1100. + 24. <= offset.y + bounds.height); + scroll( + &mut view, + &mut tree, + &node, + "scenario-canvas", + Vector::new(137., 960.), + ); + redraw(&mut view, &mut tree, small, &mut app); + view = make(Some(request)); + tree.diff(view.as_widget()); + redraw(&mut view, &mut tree, small, &mut app); + assert_eq!( + geometry(&mut view, &mut tree, small).1.scrolls["scenario-canvas"].1, + Vector::new(137., 960.) + ); + + view = make(Some(ScrollReveal { + token: Id::new_v4(), + y: 780., + ..request + })); + tree.diff(view.as_widget()); + redraw(&mut view, &mut tree, small, &mut app); + assert_eq!( + geometry(&mut view, &mut tree, small).1.scrolls["scenario-canvas"].1, + Vector::new(137., 756.) + ); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo()); +} + +#[tokio::test] +async fn scenario_form_focus_targets_the_name_and_rejects_stale_sessions_or_modals() { + use iced::futures::StreamExt; + let f = super::super::direct_tests::fixture(); + let mut app = App::with_project(f.project); + let _ = app.update(Message::Select(f.owner)); + let _ = app.update(Message::Page(crate::app::Page::Behavior)); + let _ = app.update(event(Event::Start(f.owner, Tool::Fragment))); + let _ = app.update(event(Event::Name(" retained 雨 ".into()))); + let context = app.scenario_context().unwrap(); + let session = app.behavior.scenario.form_session().unwrap(); + let raw = app.behavior.recovery_snapshot().unwrap(); + let accepted = app.document.snapshot(); + for mismatch in 0..4 { + let mut stale = context; + let mut token = session; + match mismatch { + 0 => stale.document = Id::new_v4(), + 1 => stale.scenario = Id::new_v4(), + 2 => stale.revision += 1, + _ => token = Id::new_v4(), + } + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + stale, token, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); + } + app.hosted.visible = true; + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, session, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); + app.hosted.visible = false; + app.busy = true; + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, session, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); + app.busy = false; + + // The viewport report from the same mount may advance its geometry epoch. + // That does not change the identity of the field awaiting its first focus. + let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport( + context, + Size::new(480., 180.), + ))); + assert_ne!(app.scenario_context(), Some(context)); + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, session, + ))); + let mut fields: iced::Element<'_, (), Theme, ()> = column![ + text_input("Scenario name", " retained 雨 ") + .id(NAME_INPUT) + .on_input(|_| ()), + text_input("Other field", "other") + .id("another-focus-target") + .on_input(|_| ()), + ] + .into(); + let mut tree = widget::Tree::new(fields.as_widget()); + let node = fields.as_widget_mut().layout( + &mut tree, + &(), + &layout::Limits::new(Size::ZERO, Size::new(320., 100.)), + ); + tree.children[1] + .state + .downcast_mut::>() + .focus(); + let mut stream = iced_runtime::task::into_stream(task).expect("valid form receives focus"); + let mut count = 0; + while let Some(action) = stream.next().await { + let iced_runtime::Action::Widget(mut operation) = action else { + panic!("Form focus must not apply, lint or load a model"); + }; + fields + .as_widget_mut() + .operate(&mut tree, Layout::new(&node), &(), operation.as_mut()); + count += 1; + } + assert_eq!(count, 1); + let repeated = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + context, session, + ))); + assert!(iced_runtime::task::into_stream(repeated).is_none()); + assert!( + tree.children[0] + .state + .downcast_ref::>() + .is_focused() + ); + assert!( + !tree.children[1] + .state + .downcast_ref::>() + .is_focused() + ); + assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw); + assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot())); + assert!(!app.document.can_undo()); + let _ = app.update(event(Event::Cancel)); + let _ = app.update(event(Event::Start(f.owner, Tool::Fragment))); + let task = app.update(Message::ScenarioCanvas(CanvasEvent::FormShown( + app.scenario_context().unwrap(), + session, + ))); + assert!(iced_runtime::task::into_stream(task).is_none()); +} fn geometry(view: &mut TestView, tree: &mut widget::Tree, size: Size) -> (layout::Node, Geometry) { let node = view .as_widget_mut() diff --git a/crates/oa-desktop/src/forms.rs b/crates/oa-desktop/src/forms.rs index fe826c81..4363d9b0 100644 --- a/crates/oa-desktop/src/forms.rs +++ b/crates/oa-desktop/src/forms.rs @@ -18,8 +18,7 @@ mod context_fields; mod data_fields; mod history_fields; mod port_fields; -#[cfg(test)] -mod reference_chooser; +pub(crate) mod reference_chooser; mod view_fields; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -87,6 +86,8 @@ pub struct Draft { pub value_browsers: BTreeMap, pub reference_browsers: BTreeMap, history: history_fields::State, + reference_session: Id, + reference_generation: Id, } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] @@ -211,11 +212,14 @@ impl Draft { value_browsers: BTreeMap::new(), reference_browsers: BTreeMap::new(), history: Default::default(), + reference_session: Id::new_v4(), + reference_generation: Id::new_v4(), }; draft.rebuild(); Some(draft) } fn rebuild(&mut self) { + self.reference_generation = Id::new_v4(); // Array/variant edits can change the meaning of a row path. self.value_browsers.clear(); self.inputs.clear(); @@ -276,6 +280,9 @@ impl Draft { .unwrap_or_else(|| self.materialize().unwrap_or_else(|_| self.value.clone())) } pub fn set_context(&mut self, context: Option) { + if self.context != context { + self.reference_generation = Id::new_v4(); + } self.context = context; } /// Materialize selected scalar subtrees without parsing retained, unselected drafts. @@ -361,6 +368,9 @@ impl Draft { } pub fn input(&mut self, path: &str, value: String) { if let Some(input) = self.inputs.get_mut(path) { + if input.raw != value { + self.reference_generation = Id::new_v4(); + } input.editor = text_editor::Content::with_text(&value); input.raw = value; } @@ -368,7 +378,11 @@ impl Draft { pub fn edit_text(&mut self, path: &str, action: text_editor::Action) { if let Some(input) = self.inputs.get_mut(path) { input.editor.perform(action); - input.raw = input.editor.text(); + let raw = input.editor.text(); + if input.raw != raw { + self.reference_generation = Id::new_v4(); + } + input.raw = raw; } } pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> { @@ -1797,7 +1811,7 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> { )); } } - content.into() + reference_chooser::trigger::scope(content, draft.reference_session) } /// Render one closed property subtree for a workflow-owned typed draft. @@ -1939,41 +1953,12 @@ fn property<'a>( return out.into(); } if reference(path) && (value.is_string() || value.is_null()) { - let raw = draft - .inputs - .get(path) - .map(|v| v.raw.as_str()) - .or_else(|| value.as_str()); - let current = raw.and_then(|s| s.parse::().ok()).unwrap_or(Id::nil()); - let choices = reference_choices(draft, project, path, current); - let selected = choices.iter().find(|c| c.id == current).cloned(); - let owned_path = path.to_string(); - let optional = optional_reference(path); - let mut content = column![ + return column![ label_view(), - pick_list(choices, selected, move |choice| Message::FieldShape( - owned_path.clone(), - if choice.id.is_nil() && optional { - Value::Null - } else { - json!(choice.id) - } - )) - .text_size(12) - .padding(8) - .width(Fill) + reference_chooser::adapter::field(draft, project, path) ] - .spacing(5); - if let Some(raw) = raw - && raw.parse::().is_err() - { - content = content.push( - text(format!("Invalid reference retained in the draft: {raw}")) - .size(11) - .color(style::MUTED), - ); - } - return content.into(); + .spacing(5) + .into(); } match value { Value::Object(map) => { diff --git a/crates/oa-desktop/src/forms/reference_chooser.rs b/crates/oa-desktop/src/forms/reference_chooser.rs index a19eced8..211d2242 100644 --- a/crates/oa-desktop/src/forms/reference_chooser.rs +++ b/crates/oa-desktop/src/forms/reference_chooser.rs @@ -5,8 +5,10 @@ use oa_core::Id; use std::collections::BTreeSet; +pub mod adapter; #[cfg(test)] mod tests; +pub mod trigger; pub mod widget; pub const MAX_ROWS: usize = 6; diff --git a/crates/oa-desktop/src/forms/reference_chooser/adapter.rs b/crates/oa-desktop/src/forms/reference_chooser/adapter.rs new file mode 100644 index 00000000..f62e5ead --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/adapter.rs @@ -0,0 +1,307 @@ +//! Live form adapter. Compatibility stays in the existing parent adapter; +//! browser metadata and scoped writes never reinterpret historical UUID text. +use super::super::{Draft, friendly, history_fields, optional_reference, reference, reference_ids}; +use super::{Candidate, Context, Current, State, trigger}; +use crate::{app::Message, style}; +use iced::{ + Element, Fill, Rectangle, + widget::{column, container, row, text, tooltip}, +}; +use oa_core::{Id, Project}; +use serde_json::{Value, json}; +use std::collections::BTreeSet; + +#[derive(Debug, Clone)] +pub struct Open { + pub document: Id, + pub session: Id, + pub generation: Id, + pub record: Id, + pub path: String, + pub anchor: Rectangle, +} +#[derive(Debug, Clone)] +pub struct Set { + pub context: Context, + pub generation: Id, + pub value: Option, +} +impl Draft { + pub fn reference_session(&self) -> Id { + self.reference_session + } + pub fn reference_generation(&self) -> Id { + self.reference_generation + } + pub fn reference_open(&self, project: &Project, path: &str, anchor: Rectangle) -> Open { + Open { + document: project.id, + session: self.reference_session, + generation: self.reference_generation, + record: self.original, + path: path.into(), + anchor, + } + } + pub fn reference_matches(&self, request: &Open) -> bool { + self.reference_session == request.session + && self.reference_generation == request.generation + && self.original == request.record + && self.live_reference(&request.path) + } + fn live_reference(&self, path: &str) -> bool { + !history_fields::is_path(path) + && reference(path) + && self + .value + .pointer(path) + .is_some_and(|v| v.is_string() || v.is_null()) + } + pub fn reference_catalog( + &self, + project: &Project, + request: &Open, + revision: u64, + ) -> Result { + if request.document != project.id || !self.reference_matches(request) { + return Err("The reference field changed. Open its chooser again.".into()); + } + let allowed: BTreeSet<_> = reference_ids(self, project, &request.path) + .into_iter() + .filter(|id| !id.is_nil()) + .collect(); + let candidates = allowed + .iter() + .filter_map(|id| candidate(project, *id)) + .collect(); + let raw = self + .inputs + .get(&request.path) + .map(|v| v.raw.as_str()) + .or_else(|| self.value.pointer(&request.path).and_then(Value::as_str)); + let current = match raw { + None => Current::Unassigned, + Some(raw) => match raw.parse::() { + Ok(id) if id.is_nil() => Current::Unassigned, + Ok(id) => match candidate(project, id) { + Some(c) if allowed.contains(&id) => Current::Valid(c), + Some(c) => Current::Incompatible(c), + None => Current::Missing(id), + }, + Err(_) => Current::InvalidRaw(raw.into()), + }, + }; + let context = Context::live( + project.id, + revision, + self.reference_session, + self.original, + &request.path, + optional_reference(&request.path), + ) + .map_err(str::to_owned)?; + State::open( + context, + candidates, + current, + format!( + "{} · compatible references", + friendly(request.path.rsplit('/').next().unwrap_or("Reference")) + ), + ) + .map_err(str::to_owned) + } + pub fn reference_set(&mut self, project: &Project, intent: &Set) -> Result<(), String> { + let value = self.reference_value(project, intent)?; + self.shape_scoped(&intent.context.path, value) + } + pub fn reference_value(&self, project: &Project, intent: &Set) -> Result { + let context = &intent.context; + if context.document != project.id + || context.draft != self.reference_session + || context.record != self.original + || intent.generation != self.reference_generation + || !self.live_reference(&context.path) + || context.optional != optional_reference(&context.path) + { + return Err("The originating reference field is no longer current.".into()); + } + let value = match intent.value { + Some(id) + if !id.is_nil() && reference_ids(self, project, &context.path).contains(&id) => + { + json!(id) + } + None if context.optional => Value::Null, + _ => return Err("Choose a currently compatible reference.".into()), + }; + Ok(value) + } +} + +fn named(project: &Project, id: Id) -> String { + if let Some(e) = project.elements.get(&id) { + format!("{} · @{}", e.name, e.alias) + } else { + project.named(id).into() + } +} +fn ancestry(project: &Project, id: Id) -> String { + let mut parts = vec![]; + let mut seen = BTreeSet::new(); + let mut current = project.elements.get(&id).and_then(|e| e.parent); + while let Some(id) = current { + if !seen.insert(id) { + parts.push("[containment cycle]".into()); + break; + } + parts.push(named(project, id)); + current = project.elements.get(&id).and_then(|e| e.parent); + } + parts.reverse(); + if parts.is_empty() { + project.name.clone() + } else { + parts.join(" / ") + } +} +pub(crate) fn candidate(project: &Project, id: Id) -> Option { + let layer = |layer| format!("{layer:?}").to_uppercase(); + if id == project.id { + return Some(Candidate { + id, + name: project.name.clone(), + alias: project.alias.clone(), + kind: "Project".into(), + scope: "Model".into(), + containment: "Model root".into(), + }); + } + if let Some(e) = project.elements.get(&id) { + return Some(Candidate { + id, + name: e.name.clone(), + alias: e.alias.clone(), + kind: e.kind().to_string(), + scope: layer(e.layer()), + containment: ancestry(project, id), + }); + } + if let Some(r) = project.relations.get(&id) { + return Some(Candidate { + id, + name: r.name.clone(), + alias: r.alias.clone(), + kind: r.kind().to_string(), + scope: project.layer_of(id).map_or_else(|| "Model".into(), layer), + containment: format!( + "{} → {}", + named(project, r.source()), + named(project, r.target()) + ), + }); + } + for diagram in project.diagrams.values() { + if id == diagram.id { + return Some(Candidate { + id, + name: diagram.name.clone(), + alias: diagram.alias.clone(), + kind: format!("{:?} diagram", diagram.projection), + scope: layer(diagram.layer), + containment: project.name.clone(), + }); + } + if let Some(node) = diagram.nodes.get(&id) { + return Some(Candidate { + id, + name: project.named(node.element).into(), + alias: node.alias.clone(), + kind: "Placement".into(), + scope: layer(diagram.layer), + containment: format!( + "{} · @{} / {}", + diagram.name, + diagram.alias, + named(project, node.element) + ), + }); + } + } + if let Some(link) = project.artifact_links.get(&id) { + return Some(Candidate { + id, + name: link.name.clone(), + alias: link.alias.clone(), + kind: "Artifact link".into(), + scope: project + .layer_of(link.owner) + .map_or_else(|| "Model".into(), layer), + containment: named(project, link.owner), + }); + } + project.traces.get(&id).map(|trace| Candidate { + id, + name: trace.name.clone(), + alias: trace.alias.clone(), + kind: "Trace".into(), + scope: "OA ↔ SA".into(), + containment: format!( + "{} ↔ {}", + named(project, trace.oa), + named(project, trace.sa) + ), + }) +} + +pub fn field<'a, R: iced::advanced::text::Renderer + 'a>( + draft: &'a Draft, + project: &'a Project, + path: &str, +) -> Element<'a, Message, iced::Theme, R> { + let raw = draft + .inputs + .get(path) + .map(|v| v.raw.as_str()) + .or_else(|| draft.value.pointer(path).and_then(Value::as_str)); + let (label, detail) = match raw { + None => ( + if optional_reference(path) { + "Not assigned" + } else { + "Choose a reference…" + } + .into(), + String::new(), + ), + Some(raw) => match raw.parse::() { + Ok(id) if id.is_nil() => ("Choose a reference…".into(), String::new()), + Ok(id) => match candidate(project, id) { + Some(c) => (format!("{} · @{}", c.name, c.alias), c.detail()), + None => ("Missing reference".into(), raw.into()), + }, + Err(_) => ("Invalid reference retained".into(), raw.into()), + }, + }; + let label = crate::views::short(&label, 35); + let body = container(row![text(label).size(12).width(Fill), text("⌄").size(12)].spacing(6)) + .padding(8) + .width(Fill); + let path = path.to_owned(); + let id = trigger::field_id(draft.reference_generation, &path); + let field = trigger::Field::new(body, id, move |anchor| { + Message::FieldReferenceOpen(draft.reference_open(project, &path, anchor)) + }); + if detail.is_empty() { + field.into() + } else { + tooltip( + field, + container(column![text(detail).size(11)]) + .padding(8) + .style(style::card), + tooltip::Position::Left, + ) + .into() + } +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/trigger.rs b/crates/oa-desktop/src/forms/reference_chooser/trigger.rs new file mode 100644 index 00000000..ba7123dc --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/trigger.rs @@ -0,0 +1,447 @@ +//! Keyboard entry and focus operations scoped to one property form. Focus +//! targets include the draft generation so delayed operations cannot focus a +//! replacement array row or a recovered document with the same semantic IDs. +use crate::style; +use iced::{ + Border, Color, Element, Event, Length, Rectangle, Size, Task, Theme, Vector, + advanced::{ + Clipboard, Layout, Shell, Widget, layout, overlay, renderer, + widget::{Id, Operation, Tree, operation, tree}, + }, + keyboard, mouse, + widget::container, +}; +use oa_core::Id as ModelId; +mod focus; + +#[derive(Clone, Copy)] +pub struct FormScope { + pub session: ModelId, + pub modal: bool, +} +pub(super) struct FormOwner(pub Option); +const REVIEW_MODAL: &str = "reference-owning-review-modal"; + +/// Marks the entire Review dialog without changing its layout or event flow. +pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>( + content: impl Into>, +) -> Element<'a, Message, Theme, R> { + Field { + content: content.into(), + id: Some(Id::new(REVIEW_MODAL)), + on_open: None, + } + .into() +} + +pub fn field_id(generation: ModelId, path: &str) -> Id { + Id::new(format!("reference:{generation}:{path}")) +} +pub fn scope_id(session: ModelId) -> Id { + Id::new(format!("reference-form:{session}")) +} + +#[derive(Default)] +struct Interaction { + focused: bool, + pressed: bool, + identity: Option, + anchor: Option, +} +impl operation::Focusable for Interaction { + fn is_focused(&self) -> bool { + self.focused + } + fn focus(&mut self) { + self.focused = true; + } + fn unfocus(&mut self) { + self.focused = false; + self.pressed = false; + } +} +pub struct Field<'a, Message, R = iced::Renderer> +where + R: renderer::Renderer, +{ + content: Element<'a, Message, Theme, R>, + id: Option, + on_open: Option Message + 'a>>, +} +impl<'a, Message, R: renderer::Renderer> Field<'a, Message, R> { + pub fn new( + content: impl Into>, + id: Id, + on_open: impl Fn(Rectangle) -> Message + 'a, + ) -> Self { + Self { + content: content.into(), + id: Some(id), + on_open: Some(Box::new(on_open)), + } + } +} +pub fn scope<'a, Message: 'a, R: renderer::Renderer + 'a>( + content: impl Into>, + session: ModelId, +) -> Element<'a, Message, Theme, R> { + container(Field { + content: content.into(), + id: None, + on_open: None, + }) + .id(scope_id(session)) + .width(Length::Fill) + .into() +} + +fn run( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + mut operation: Box, +) -> bool { + let mut changed = false; + loop { + content + .as_widget_mut() + .operate(tree, layout, renderer, &mut *operation); + match operation.finish() { + operation::Outcome::Chain(next) => { + operation = next; + changed = true; + } + _ => break, + } + } + changed +} + +pub(super) fn tab( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + scope: FormScope, + backwards: bool, +) -> bool { + run( + content, + tree, + layout, + renderer, + Box::new(focus::relative(scope.session, backwards, scope.modal)), + ) +} +pub(super) fn own_input( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, +) { + content + .as_widget_mut() + .operate(tree, layout, renderer, &mut focus::exclusive()); +} +impl Widget for Field<'_, Message, R> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + fn state(&self) -> tree::State { + tree::State::new(Interaction { + identity: self.id.clone(), + ..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.identity != self.id { + *state = Interaction { + identity: self.id.clone(), + ..Default::default() + }; + } + tree.diff_children(std::slice::from_ref(&self.content)); + } + fn size(&self) -> Size { + self.content.as_widget().size() + } + 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, + ) { + if let Some(id) = &self.id + && self.on_open.is_some() + { + operation.focusable( + Some(id), + layout.bounds(), + tree.state.downcast_mut::(), + ); + operation.custom( + Some(id), + layout.bounds(), + tree.state.downcast_mut::(), + ); + } + if self.on_open.is_none() { + operation.container(self.id.as_ref(), layout.bounds()); + operation.traverse(&mut |operation| { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout, + renderer, + operation, + ) + }); + } else { + 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, + ) { + if self.on_open.is_some() { + let state = tree.state.downcast_mut::(); + let visible = layout.bounds().intersection(viewport); + let hovered = visible.is_some_and(|bounds| cursor.is_over(bounds)); + let activate = match event { + Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if hovered => { + state.focused = true; + state.pressed = true; + shell.capture_event(); + shell.request_redraw(); + false + } + Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { + let activate = state.pressed && hovered; + if state.pressed { + shell.capture_event(); + } + state.pressed = false; + activate + } + Event::Keyboard(keyboard::Event::KeyPressed { + key: + keyboard::Key::Named(keyboard::key::Named::Enter | keyboard::key::Named::Space), + repeat: false, + .. + }) if state.focused => true, + Event::Mouse(mouse::Event::ButtonPressed(_)) => { + state.focused = false; + state.pressed = false; + false + } + _ => false, + }; + if activate && let Some(bounds) = state.anchor { + shell.publish(self.on_open.as_ref().unwrap()(bounds)); + shell.capture_event(); + 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, + renderer_style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + if self.on_open.is_some() { + let focus = tree.state.downcast_ref::().focused; + renderer.fill_quad( + renderer::Quad { + bounds: layout.bounds(), + border: Border { + color: if focus || cursor.is_over(layout.bounds()) { + style::TEAL + } else { + style::LINE + }, + width: if focus { 2. } else { 1. }, + radius: 5.into(), + }, + ..Default::default() + }, + Color::WHITE, + ); + } + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + renderer_style, + layout, + cursor, + viewport, + ); + } + fn mouse_interaction( + &self, + tree: &Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &R, + ) -> mouse::Interaction { + if self.on_open.is_some() && cursor.is_over(layout.bounds()) { + mouse::Interaction::Pointer + } else { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout, + cursor, + viewport, + renderer, + ) + } + } + fn overlay<'b>( + &'b mut self, + tree: &'b mut Tree, + layout: Layout<'b>, + renderer: &R, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + self.content.as_widget_mut().overlay( + &mut tree.children[0], + layout, + renderer, + viewport, + translation, + ) + } +} + +/// Scrollable child updates use content coordinates. The root visits the +/// retained tree before each input, so field anchors use current window +/// coordinates even when a wheel event and activation share one runtime batch. +pub(super) fn measure( + content: &mut Element<'_, Message, Theme, R>, + tree: &mut Tree, + layout: Layout<'_>, + renderer: &R, + viewport: Rectangle, +) { + content.as_widget_mut().operate( + tree, + layout, + renderer, + &mut Measure { + translation: Vector::ZERO, + viewport: Some(viewport), + pending: None, + }, + ); +} +struct Measure { + translation: Vector, + viewport: Option, + pending: Option<(Vector, Option)>, +} +impl Operation for Measure { + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + let previous = (self.translation, self.viewport); + if let Some((translation, viewport)) = self.pending.take() { + self.translation = translation; + self.viewport = viewport; + } + visit(self); + (self.translation, self.viewport) = previous; + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + _: Rectangle, + translation: Vector, + _: &mut dyn operation::Scrollable, + ) { + let bounds = Rectangle { + x: bounds.x - self.translation.x, + y: bounds.y - self.translation.y, + ..bounds + }; + self.pending = Some(( + self.translation + translation, + self.viewport + .and_then(|viewport| viewport.intersection(&bounds)), + )); + } + fn custom(&mut self, _: Option<&Id>, bounds: Rectangle, state: &mut dyn std::any::Any) { + if let Some(state) = state.downcast_mut::() { + let bounds = Rectangle { + x: bounds.x - self.translation.x, + y: bounds.y - self.translation.y, + ..bounds + }; + state.anchor = self + .viewport + .and_then(|viewport| viewport.intersection(&bounds)); + } + } +} +impl<'a, Message: 'a, R: renderer::Renderer + 'a> From> + for Element<'a, Message, Theme, R> +{ + fn from(field: Field<'a, Message, R>) -> Self { + Element::new(field) + } +} + +pub fn focus_operation( + session: ModelId, + generation: ModelId, + path: &str, + step: i32, +) -> impl Operation + 'static { + focus::exact(session, generation, path, step) +} +pub fn focus( + session: ModelId, + generation: ModelId, + path: &str, + step: i32, +) -> Task { + iced::advanced::widget::operate(focus_operation(session, generation, path, step)).discard() +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/trigger/focus.rs b/crates/oa-desktop/src/forms/reference_chooser/trigger/focus.rs new file mode 100644 index 00000000..5705ca55 --- /dev/null +++ b/crates/oa-desktop/src/forms/reference_chooser/trigger/focus.rs @@ -0,0 +1,303 @@ +//! Focus movement and reveal share one root traversal. A form may be inside a +//! scrollable; restricting traversal to its container would hide that ancestor. +use super::{FormOwner, Id, ModelId, Operation, Rectangle, Vector, field_id, operation, scope_id}; +use std::collections::BTreeMap; + +enum Origin { + Current, + Exact(Id), +} +#[derive(Clone)] +struct Scroll { + index: usize, + bounds: Rectangle, + capacity: Vector, + offset: Vector, +} +struct Field { + id: Option, + bounds: Rectangle, + ancestors: Vec, +} +pub(super) struct Collect { + scope: Id, + origin: Origin, + step: i32, + inside: bool, + entering: Option, + translation: Vector, + scrolls: Vec, + pending_scroll: Option, + scroll_index: usize, + fields: Vec, + focused: Option, + outside_focused: bool, + modal: bool, + owner_valid: bool, +} +impl Collect { + fn new(scope: Id, origin: Origin, step: i32) -> Self { + let owner_valid = matches!(origin, Origin::Current); + Self { + scope, + origin, + step, + inside: false, + entering: None, + translation: Vector::ZERO, + scrolls: vec![], + pending_scroll: None, + scroll_index: 0, + fields: vec![], + focused: None, + outside_focused: false, + modal: false, + owner_valid, + } + } +} +pub(super) fn relative(session: ModelId, backwards: bool, modal: bool) -> Collect { + Collect { + modal, + ..Collect::new( + scope_id(session), + Origin::Current, + if backwards { -1 } else { 1 }, + ) + } +} +pub(super) fn exact(session: ModelId, generation: ModelId, path: &str, step: i32) -> Collect { + Collect::new( + scope_id(session), + Origin::Exact(field_id(generation, path)), + step, + ) +} +impl Operation for Collect { + fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) { + if let Some(owner) = state.downcast_ref::() { + self.owner_valid = owner + .0 + .is_some_and(|owner| scope_id(owner.session) == self.scope); + self.modal = owner.0.is_some_and(|owner| owner.modal); + } + } + fn container(&mut self, id: Option<&Id>, _: Rectangle) { + self.entering = Some(self.inside || id == Some(&self.scope)); + } + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + let previous = (self.inside, self.translation, self.scrolls.len()); + if let Some(inside) = self.entering.take() { + self.inside = inside; + } + if let Some(scroll) = self.pending_scroll.take() { + self.translation += scroll.offset; + self.scrolls.push(scroll); + } + visit(self); + self.inside = previous.0; + self.translation = previous.1; + self.scrolls.truncate(previous.2); + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + content: Rectangle, + offset: Vector, + _: &mut dyn operation::Scrollable, + ) { + self.pending_scroll = Some(Scroll { + index: self.scroll_index, + bounds: translated(bounds, self.translation), + capacity: Vector::new( + (content.width - bounds.width).max(0.), + (content.height - bounds.height).max(0.), + ), + offset, + }); + self.scroll_index += 1; + } + fn focusable( + &mut self, + id: Option<&Id>, + bounds: Rectangle, + state: &mut dyn operation::Focusable, + ) { + if !self.inside { + self.outside_focused |= state.is_focused(); + return; + } + if state.is_focused() { + self.focused = Some(self.fields.len()); + } + self.fields.push(Field { + id: id.cloned(), + bounds: translated(bounds, self.translation), + ancestors: self.scrolls.clone(), + }); + } + fn finish(&self) -> operation::Outcome<()> { + if !self.owner_valid || self.fields.is_empty() { + return operation::Outcome::None; + } + let origin = match &self.origin { + Origin::Current if self.focused.is_none() && self.outside_focused && !self.modal => { + return operation::Outcome::None; + } + Origin::Current => self + .focused + .map(|index| index as i64) + .unwrap_or(if self.step < 0 { + self.fields.len() as i64 + } else { + -1 + }), + Origin::Exact(id) => { + let Some(index) = self + .fields + .iter() + .position(|field| field.id.as_ref() == Some(id)) + else { + return operation::Outcome::None; + }; + index as i64 + } + }; + // A form keeps a reachable focus at both boundaries. Initial Tab enters + // the first field; initial Shift+Tab enters the last field. + let target = (origin + i64::from(self.step)).rem_euclid(self.fields.len() as i64) as usize; + let field = &self.fields[target]; + let mut bounds = field.bounds; + let mut shifts = BTreeMap::new(); + for ancestor in field.ancestors.iter().rev() { + let dx = reveal_axis( + bounds.x, + bounds.width, + ancestor.bounds.x, + ancestor.bounds.width, + ); + let dy = reveal_axis( + bounds.y, + bounds.height, + ancestor.bounds.y, + ancestor.bounds.height, + ); + let delta = Vector::new( + (ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x, + (ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y, + ); + if delta != Vector::ZERO { + shifts.insert(ancestor.index, delta); + } + bounds = translated(bounds, delta); + } + operation::Outcome::Chain(Box::new(Apply { + scope: self.scope.clone(), + inside: false, + entering: None, + target, + field_index: 0, + scroll_index: 0, + shifts, + owner_valid: matches!(self.origin, Origin::Current), + clear_outside: self.modal, + preserve_inside: false, + })) + } +} +fn translated(bounds: Rectangle, delta: Vector) -> Rectangle { + Rectangle { + x: bounds.x - delta.x, + y: bounds.y - delta.y, + ..bounds + } +} +fn reveal_axis(start: f32, length: f32, viewport_start: f32, viewport_length: f32) -> f32 { + if start < viewport_start || length > viewport_length { + start - viewport_start + } else { + (start + length - viewport_start - viewport_length).max(0.) + } +} +pub(super) struct Apply { + scope: Id, + inside: bool, + entering: Option, + target: usize, + field_index: usize, + scroll_index: usize, + shifts: BTreeMap, + owner_valid: bool, + clear_outside: bool, + preserve_inside: bool, +} +pub(super) fn exclusive() -> Apply { + Apply { + scope: Id::new(super::REVIEW_MODAL), + inside: false, + entering: None, + target: 0, + field_index: 0, + scroll_index: 0, + shifts: BTreeMap::new(), + owner_valid: true, + clear_outside: true, + preserve_inside: true, + } +} +impl Operation for Apply { + fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) { + if let Some(owner) = state.downcast_ref::() { + self.owner_valid = owner + .0 + .is_some_and(|owner| scope_id(owner.session) == self.scope); + self.clear_outside = owner.0.is_some_and(|owner| owner.modal); + } + } + fn container(&mut self, id: Option<&Id>, _: Rectangle) { + self.entering = Some(self.inside || id == Some(&self.scope)); + } + fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) { + let previous = self.inside; + if let Some(inside) = self.entering.take() { + self.inside = inside; + } + visit(self); + self.inside = previous; + } + fn scrollable( + &mut self, + _: Option<&Id>, + bounds: Rectangle, + content: Rectangle, + _: Vector, + state: &mut dyn operation::Scrollable, + ) { + if self.owner_valid + && let Some(delta) = self.shifts.get(&self.scroll_index) + { + state.scroll_by( + operation::scrollable::AbsoluteOffset { + x: delta.x, + y: delta.y, + }, + bounds, + content, + ); + } + self.scroll_index += 1; + } + fn focusable(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) { + if self.owner_valid && self.inside && !self.preserve_inside { + if self.field_index == self.target { + state.focus(); + } else { + state.unfocus(); + } + self.field_index += 1; + } else if self.owner_valid && !self.inside && self.clear_outside { + state.unfocus(); + } + } +} diff --git a/crates/oa-desktop/src/forms/reference_chooser/widget.rs b/crates/oa-desktop/src/forms/reference_chooser/widget.rs index 854406c0..112935dc 100644 --- a/crates/oa-desktop/src/forms/reference_chooser/widget.rs +++ b/crates/oa-desktop/src/forms/reference_chooser/widget.rs @@ -163,6 +163,7 @@ where chooser: Option<&'a Chooser>, anchor: Rectangle, on_event: Rc Message + 'a>, + focus_scope: Option, } impl<'a, Message: Clone + 'a, R: text::Renderer + 'a> Popup<'a, Message, R> { pub fn new( @@ -187,8 +188,13 @@ impl<'a, Message: Clone + 'a, R: text::Renderer + 'a> Popup<'a, Mes chooser, anchor, on_event: Rc::new(on_event), + focus_scope: None, } } + pub fn focus_scope(mut self, scope: Option) -> Self { + self.focus_scope = scope; + self + } fn reconcile(&self, tree: &mut Tree) { let state = tree.state.downcast_mut::(); let stamp = self.chooser.map(Chooser::stamp); @@ -250,6 +256,9 @@ impl> Widget renderer: &R, operation: &mut dyn Operation, ) { + let mut owner = + super::trigger::FormOwner(self.focus_scope.filter(|_| self.chooser.is_none())); + operation.custom(None, layout.bounds(), &mut owner); self.content .as_widget_mut() .operate(&mut tree.children[0], layout, renderer, operation); @@ -270,6 +279,35 @@ impl> Widget shell.capture_event(); return; } + if is_input(event) && self.focus_scope.is_some_and(|scope| scope.modal) { + super::trigger::own_input(&mut self.content, &mut tree.children[0], layout, renderer); + } + if let Event::Keyboard(keyboard::Event::KeyPressed { + key: keyboard::Key::Named(keyboard::key::Named::Tab), + modifiers, + .. + }) = event + && let Some(scope) = self.focus_scope + && super::trigger::tab( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + scope, + modifiers.shift(), + ) + { + shell.capture_event(); + shell.request_redraw(); + return; + } + super::trigger::measure( + &mut self.content, + &mut tree.children[0], + layout, + renderer, + *viewport, + ); self.content.as_widget_mut().update( &mut tree.children[0], event, diff --git a/crates/oa-desktop/src/semantic_review.rs b/crates/oa-desktop/src/semantic_review.rs index 414f3ccd..d7bba064 100644 --- a/crates/oa-desktop/src/semantic_review.rs +++ b/crates/oa-desktop/src/semantic_review.rs @@ -87,6 +87,9 @@ pub enum RowEvent { ValuePath(String, crate::data_values::FieldPathKey), ValueBrowse(String, Option, usize), ReferenceBrowse(String, String, usize), + ReferenceOpen(crate::forms::reference_chooser::adapter::Open), + ReferenceEvent(crate::forms::reference_chooser::Event), + ReferenceSet(crate::forms::reference_chooser::adapter::Set), OperationOmission(bool), OperationRationale(String), Tab(Tab), diff --git a/crates/oa-desktop/src/semantic_review/batch.rs b/crates/oa-desktop/src/semantic_review/batch.rs index cf30f56e..cd22b0b8 100644 --- a/crates/oa-desktop/src/semantic_review/batch.rs +++ b/crates/oa-desktop/src/semantic_review/batch.rs @@ -296,6 +296,51 @@ impl State { } Ok(()) } + pub(crate) fn reference_form(&self, project: &Project) -> Option<&Draft> { + let facet = self.active.selected.as_ref()?; + if self.frozen(project).is_err() + || self.active.tab != Tab::Facts + || self.active.decisions.get(facet) != Some(&SemanticDecision::Candidate) + { + return None; + } + self.active.candidate_draft(facet) + } + pub(crate) fn reference_draft( + &self, + project: &Project, + address: Option, + facet: &FacetSelector, + path: &str, + ) -> Option<&Draft> { + if self.frozen(project).is_err() + || self.active_address != address + || self.active.tab != Tab::Facts + { + return None; + } + self.active.reference_draft(project, facet, path) + } + pub(crate) fn set_reference( + &mut self, + project: &Project, + address: Option, + facet: &FacetSelector, + intent: crate::forms::reference_chooser::adapter::Set, + ) -> Result<(), String> { + self.reference_draft(project, address, facet, &intent.context.path) + .ok_or("The originating review row or property is no longer active.")? + .reference_value(project, &intent)?; + self.update( + project, + Event::Row { + address, + facet: Some(facet.clone()), + event: RowEvent::ReferenceSet(intent), + }, + ); + Ok(()) + } pub fn update(&mut self, project: &Project, event: impl Into) { let event = event.into(); #[cfg(test)] @@ -303,6 +348,30 @@ impl State { Event::Editor(event) => self.address_event(event), other => other, }; + match &event { + Event::Row { + event: RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_), + .. + } + | Event::Editor(RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_)) => return, + Event::Row { + address, + facet, + event: RowEvent::ReferenceSet(intent), + } => { + let valid = facet + .as_ref() + .and_then(|facet| { + self.reference_draft(project, *address, facet, &intent.context.path) + }) + .is_some_and(|draft| draft.reference_value(project, intent).is_ok()); + if !valid { + return; + } + } + Event::Editor(RowEvent::ReferenceSet(_)) => return, + _ => {} + } if let Err(error) = self.frozen(project) { // Withdrawing a pending reset is authored intent only. Retain it while // keeping the original accepted base and disabling every stale plan. @@ -464,7 +533,8 @@ impl State { ); let scoped_edit = matches!( event, - RowEvent::Input(_, _) + RowEvent::ReferenceSet(_) + | RowEvent::Input(_, _) | RowEvent::Text(_, _) | RowEvent::Shape(_, _) | RowEvent::Variant(_, _) @@ -747,13 +817,15 @@ impl State { if let Some(notice) = &self.notice { header = header.push(text(notice).size(11)); } - self.active + let view = self + .active .view(&self.base, header.into(), self.can_apply()) .map(move |message| match message { Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => { Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event))) } other => other, - }) + }); + crate::forms::reference_chooser::trigger::modal(view) } } diff --git a/crates/oa-desktop/src/semantic_review/row.rs b/crates/oa-desktop/src/semantic_review/row.rs index 092922cf..e05f9bc1 100644 --- a/crates/oa-desktop/src/semantic_review/row.rs +++ b/crates/oa-desktop/src/semantic_review/row.rs @@ -166,6 +166,14 @@ impl ReviewRow { pub fn update(&mut self, project: &Project, event: RowEvent) { let mut replan = true; let result = match event { + // Browser messages belong to the app overlay and must never replan. + RowEvent::ReferenceOpen(_) | RowEvent::ReferenceEvent(_) => return, + RowEvent::ReferenceSet(intent) => { + let path = intent.context.path.clone(); + self.edit(project, &path, |draft| { + draft.reference_set(project, &intent) + }) + } RowEvent::OperationOmission(enabled) => { self.operation_omission = enabled.then(|| self.operation_omission.clone().unwrap_or_default()); @@ -518,6 +526,25 @@ impl ReviewRow { } } + pub(super) fn reference_draft( + &self, + project: &Project, + facet: &FacetSelector, + path: &str, + ) -> Option<&Draft> { + if self.selected.as_ref() != Some(facet) + || self.decisions.get(facet) != Some(&SemanticDecision::Candidate) + || matches!(path, "/id" | "/alias" | "/body" | "/layer") + || !self + .candidate_roots(project, facet)? + .iter() + .any(|root| path == root || path.starts_with(&format!("{root}/"))) + { + return None; + } + self.candidate_draft(facet) + } + fn edit( &mut self, project: &Project, @@ -895,6 +922,8 @@ pub fn form_event(message: Message) -> Message { Message::FieldReferenceBrowse(path, query, page) => { RowEvent::ReferenceBrowse(path, query, page) } + Message::FieldReferenceOpen(request) => RowEvent::ReferenceOpen(request), + Message::FieldReferenceEvent(event) => RowEvent::ReferenceEvent(event), _ => RowEvent::Preview, }; Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) diff --git a/crates/oa-desktop/src/semantic_review/view.rs b/crates/oa-desktop/src/semantic_review/view.rs index 19962619..d5bb9b2d 100644 --- a/crates/oa-desktop/src/semantic_review/view.rs +++ b/crates/oa-desktop/src/semantic_review/view.rs @@ -867,7 +867,13 @@ impl ReviewRow { ) { detail = detail.push(text("Attached facts are separate from referenced definitions. Removing membership never implies deleting a semantic object.").size(11).color(style::MUTED)); } - detail.into() + if self.decisions.get(&fact.selector) == Some(&SemanticDecision::Candidate) + && let Some(draft) = self.candidate_draft(&fact.selector) + { + crate::forms::reference_chooser::trigger::scope(detail, draft.reference_session()) + } else { + detail.into() + } } fn members_view<'a>(&'a self, project: &'a Project) -> Element<'a, Message> { diff --git a/harness/PLAN.md b/harness/PLAN.md index 7dfd57f1..43aaca73 100644 --- a/harness/PLAN.md +++ b/harness/PLAN.md @@ -45,16 +45,21 @@ cross-representation evaluators guide implementation boundaries. 1. Complete the coherent workspace/production chooser build and focused gates, then stage a new GUI for real engineering tasks. Full676/format/Clippy passed for2cc4d11; working changes require their own verification. -2. Exchange exact verified commits with the separate host, preserving its workspace - ownership. Do not merge the bundle-transfer branch or assume remote publication. -3. Use the self-checked [scenario oracle](scenario-authoring-contract.rs), follow +2. Implement the user-required common canvas interactions for Process and Scenario + in issue2 after the bounded shared-viewport/domain-constraint design decision. + Treat this as direct-manipulation acceptance, not a tool-shelf restyle. +3. Exchange exact verified commits with the separate host, preserving its workspace + ownership. Direct authenticated publication works. CLI01b and exact evidence2ec + are imported; next contextual-source and B4 handoffs retain their owners. +4. Use the self-checked [scenario oracle](scenario-authoring-contract.rs), follow its [native procedure](fixtures/scenario-authoring/README.md), and compare exact whole snapshots for Undo/Redo/reopen at minimum/wide sizes. -4. Integrate and measure the exact numeric/unit/work-meter foundation, resolve - independent findings, then extend actual model/source/API/GUI under the ratified +5. Exact numeric/unit/work-meter foundation26/26 unit tests pass at7d14bd0; complete + its strict gate while actual schema8 core work proceeds in an isolated worktree. + Extend actual model/source/API/GUI under the ratified B/R6/R7/full-capture and exact-pin/typed-compatibility contracts. Preserve explicit approximate numeric, complete consumer/archive and two-project release gates. -5. Update current ledgers and durable decisions when evidence changes the plan. +6. Update current ledgers and durable decisions when evidence changes the plan. Reassess unresolved assumptions before expanding downstream implementation. ## Coordination and evidence rules diff --git a/harness/STATE.md b/harness/STATE.md index df5ef756..f8e6c650 100644 --- a/harness/STATE.md +++ b/harness/STATE.md @@ -1,6 +1,6 @@ # Progress Ledger — first release -Updated 2026-09-11, coordination through Gitea #190. This ledger describes the +Updated 2026-09-11, coordination through Gitea #245 and issue2. This ledger describes the Codex-956atf checkout; external-host evidence is labeled separately. The entire previous ledger is preserved [byte-for-byte](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md). Historical dates, binaries, process IDs and stopped-session instructions in that @@ -20,8 +20,10 @@ is `2cc4d115d0ff28423c7ed87fd5f4065efb011f2d`, including external named creation `4bb4aaf` as `1516949`, the isolated chooser and final baseline/library contracts. Full desktop676/676, formatting and strict Clippy pass at that source. The active native GUI remains immutable80cf197. New workspace and production chooser changes -are outside those artifacts and await a coherent build. Numeric foundation files -are undeclared and under independent review. +are outside those artifacts and await a coherent build. Current committed source +adds exact numeric foundation7d14bd0, imported CLIbc311bc and evidence/native +dc0f2e1. Numeric26/26 unit tests and strict core all-target Clippy pass. Shared +instruction ancestrycab01c3 is merged asc7ed894, retaining the exact uploaded goal. The authoritative goal was copied byte-identically from attachment `a49d1780-25d5-4778-ad68-6140c60e4207/goal-objective.md`; SHA256 `DD58F75C93D55FE16ED141A23D834898FB6EBC625AE7E7AE379DA552A8312435`. @@ -74,14 +76,26 @@ Actual native Studio remains `.local/native-qa/studio-80cf197/`, GUI SHA256 `A5ED6DF334B1BFB620425AFDF3E863F0B522990E6FB47C6C401A71CF4443954A`. That GUI build passed3m28s. At1100x700, all four OA creation checkpoints and final reopen now pass complete Project/two-source prediction and exact UUID restoration. -Preserved snapshots end in `oa-reopened/`; no SA or new-fix acceptance is inferred. +Preserved OA snapshots end in `oa-reopened/`; separate SA Action Save/Undo/Redo +also passes complete oracle/history comparisons, ending in `sa-action-redo/`, +63objects. The GUI is saved at SA A caller, no active form. SA State/Reuse/Fragment +and final reopen remain. No new-fix acceptance is inferred. [Native report](release-scenario-native-20260911.md) records details and two real workspace findings: new forms retain downward scroll, and opening a form can hide its clicked marker below the reduced canvas. Root has written a fresh form-session key, guarded Name focus, one-time marker reveal and three meaningful mounted/App regressions. They are not yet compiled. The isolated chooser's corrected realistic typing/paste/IME gate passes13/13; production inspector/review integration is now -owned by local behavior_audit, with exact row/facet/group/attachment guards. +source is now frozen, with exact row/facet/group/attachment guards. Independent +review exposed and closed three focus defects at source level, including the +legitimate Review rationale input. Fourteen new App/mounted tests await execution; +the earlier isolated13 is separately scoped. [Review](release-reference-chooser-production-review-20260911.md). + +User [issue2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2) +requires Process and Scenario to behave as actual canvases like architecture/state +diagrams. Current scrollable diagrams/tool shelves do not close that requirement. +behavior_audit owns a bounded shared-canvas design; root re-observed the saved +native scenario and its restricted viewport, without new model input. The [baseline contract](release-analysis-baseline-contract-20260911.md) supplies a complete17-UUID proposed model,11 exact fixtures, explicit ModelBaseline/Case, @@ -98,27 +112,37 @@ The [independent refined review](release-refined-contracts-review-20260911.md) passes292 fixture/identity checks but finds C1:333 stored AST nodes can expand to over2billion calls. Request-local dynamic/numeric work accounting is mandatory; static depth/node limits alone fail. C2 is a finite-prototype threshold-unit gap, -not a passing production type-checker. Local requirements_delta owns undeclared -`analysis_numeric` foundation (exact arithmetic, dimension/unit operations and -terminal work meter), with semantics_critic independently reviewing correctness. -Root owns dependency/module admission and all compilation. Numeric ceiling values -remain provisional pending measured gates. Approximate engineering calculations, +not a passing production type-checker. Exact arithmetic, unit/dimension operations +and terminal work meter are declared/committed at7d14bd0; their26/26 unit gate has +[exact source/artifact evidence](release-analysis-numeric-gate-20260911.md). +requirements_delta now owns actual schema8 definitions, expressions, commands, +references, migration and static admission in the isolated +`.local/analysis-core-worktree-20260911/`, based at7d14bd0. It remains uncompiled. +semantics_critic reviews the bounded decoder strategy separately; existing default +recursion rejection stays until a full allocation/lifecycle gate is justified. +Root owns compilation. Numeric/runtime and static-admission ceilings remain +explicitly provisional pending measured gates. Approximate engineering calculations, full evaluator/model/source/REST/GUI parity and actual library maintenance are open. Codex-9e8r6t and Codex-ytr83r are separate hosts/teams. The former owns contextual -prerequisite creation, source CLI and next Windows named-authoring prerelease. -Its direct branch `codex/release1-9e8r6t` has coherent CLI checkpoint01b76a1; corrected -evidence2ec72d4 and CLI are not yet imported here. External675 staged-source and -CLI12/183-assertion results remain externally attributed. Codex-ytr83r owns B4 +prerequisite creation and source CLI. CLI01b76a1 is imported asbc311bc; exact +corrected evidence2ec72d4 is imported atdc0f2e1, preserving root's separate native +report. External675 staged-source and CLI12/183-assertion results remain externally +attributed. Its runnable Studio/server preview release1 and standalone CLI preview +release2 are published; see their release notes for exact source/runtime limits. +Strict language/API HTML regeneration and the72-fixture source7 schema gate pass +locally after that evidence import. Codex-ytr83r owns B4 production Scene wiring, independent native oracle and local Linux route. Its rendering source has independent criticism but no passing build/native gate yet. Do not duplicate these changes. Preserve root's distinct scenario-scroll/focus hook when integrating other hosts' small App/behavior bridges. -Updated AGENTS d93cddac adds coordinated runnable milestone prereleases. Gitea +Updated AGENTS4b8465c8 adds concise shared maintenance to milestone releases. Gitea user#178 explicitly authorizes programmatic reuse of existing MCP credentials for Git/REST and relevant commits/tags/prereleases, without printing or committing them. -Direct push setup is in progress on this host;9e8r6t has independently succeeded. +Direct authenticated push/readback passed on this host, most recently publishing +dc0f2e1. The subsequent owner's instruction commitcab01c3 has been fetched and +merged locally; no force push or additional credentials are required. Historical verified bundle exchange remains reproducible: c6d296a bundle on `codex/behavior-transfer-20260910`,334830 decoded bytes, SHA256 `526A80E2D32ED0E57A7B2372B5F02D4652E8751DFEBA81953C4F045225D6AACB`, @@ -130,8 +154,8 @@ Older bundles/logs retain historical scope in Git and the archived ledger. 1. Freeze and compile the new workspace/chooser integration, resolve focused failures, then build an immutable GUI and verify both native workspace fixes. The676-test gate is complete for2cc4d11; new changes require their own evidence. -2. Publish coherent checkpoints directly; integrate9e8r6t CLI/corrected evidence - and later staged-source work, then ytr83r B4 only after its coherent handoff. +2. Publish coherent checkpoints directly; integrate9e8r6t's next staged-source + work, then ytr83r B4 only after its coherent handoff. CLI/evidence import is done. Preserve ownership and distinguish every external versus local gate. 3. Use the self-checked independent oracle to exercise current Studio at minimum/wide sizes: named creation, direct process/scenario edits, nested reuse, diff --git a/harness/TASK_LEDGER.md b/harness/TASK_LEDGER.md index 2e67f1e9..a76465d9 100644 --- a/harness/TASK_LEDGER.md +++ b/harness/TASK_LEDGER.md @@ -10,8 +10,12 @@ prototype results retain their separate scope. Current compiled checkpoint2cc4d11 includes external creation4bb4aaf as1516949, isolated chooser13, and final baseline/library contracts. Local full676/format/ strict Clippy pass; native80cf197 now passes four OA creations, exact history and -reopen. Working workspace/production chooser changes and undeclared numeric files -have no passing gate yet. External results retain their host/source attribution. +reopen. Separate native SA Action/history also passes. Exact numeric foundation +is declared at7d14bd0 and passes26/26 unit tests plus strict core all-target Clippy. +Working workspace/production chooser changes remain outside compiled artifacts; +independent chooser source review closed three focus findings,14 new host tests +are prepared. Schema8 core implementation is isolated and uncompiled. External +results retain their host/source attribution. ## Goal and success criteria @@ -49,10 +53,14 @@ cannot establish completion. at effort appropriate to the question. Root owns synthesis/integration. - Project instructions require Gitea coordination, ownership preservation and durable handoffs. Current root reserves one Cargo/native operator per host. - Updated AGENTS d93cddac requires coordinated runnable milestone prereleases; + Updated AGENTS4b8465c8 requires coordinated runnable milestone prereleases and + concise current shared maintenance with unique historical evidence preserved; user Gitea#178 authorizes programmatic existing-MCP-credential reuse for relevant Git/REST publication without printing/committing secrets. Restart and usage reset remain unauthorized. +- Gitea issue2 explicitly requires actual Process/Scenario canvases with the same + interaction approach as architecture/state diagrams. Existing pointer edits + inside scrollable projections do not by themselves satisfy that requirement. ## Verified facts diff --git a/harness/experiments/analysis-numeric-gate-20260911/manifest.json b/harness/experiments/analysis-numeric-gate-20260911/manifest.json new file mode 100644 index 00000000..7befacaa --- /dev/null +++ b/harness/experiments/analysis-numeric-gate-20260911/manifest.json @@ -0,0 +1,840 @@ +{ + "source_commit": "7d14bd0dd1cf4a2293c00c185573fd4a7fc364ae", + "command": "cargo test --locked -p oa-core --lib analysis_numeric::tests -- --test-threads=1", + "exit_code": 0, + "passed": 26, + "failed": 0, + "ignored": 0, + "filtered_out": 42, + "test_seconds": 0.29, + "build_seconds": 385, + "scope": "Exact numeric foundation unit tests only; no schema8 model/evaluator or desktop acceptance.", + "toolchain": "Rust/Cargo1.98.1 x86_64-pc-windows-gnullvm; LLVM-MinGW20260908; jobs1", + "prior_attempt": "The broader command without --lib was intentionally stopped before tests while compiling unrelated integration executables. It supplies no pass result.", + "artifacts": { + "oa-core-tests.exe": { + "bytes": 24529920, + "sha256": "F92A4C4CF01DB0AD4810527F0D8AC2212196AEB3A4907E8EEA12FD5EDEADDB0D" + }, + "libunwind.dll": { + "bytes": 190464, + "sha256": "AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A" + }, + "numeric-unit.log": { + "bytes": 2754, + "sha256": "DF7F71D037CF2B3D38234C834F9F399F3BB30CC4757F453D72833DE9A9C3C063" + } + }, + "input_files": [ + { + "path": "Cargo.lock", + "bytes": 119973, + "sha256": "065C23C94908FE499D32ED91D55A70946ED5B08ECFDA3B483EC1E635572257BF" + }, + { + "path": "Cargo.toml", + "bytes": 400, + "sha256": "369F5B4B2B60705A94623C8C0B68F8E0B048F263280CE07AE0629AD353797D62" + }, + { + "path": "crates/oa-core/Cargo.toml", + "bytes": 408, + "sha256": "6B5C062474EBB62D5082E07DABED99EE85E4A833EDF26690FB4F887028D3416F" + }, + { + "path": "crates/oa-core/build.rs", + "bytes": 45, + "sha256": "AB1BE6AE1FD8BF3EBAEEF6348347A9D5E666BDE693126F199E041659E0B094A9" + }, + { + "path": "crates/oa-core/examples/generate_demo.rs", + "bytes": 795, + "sha256": "E26F432C95331AECA50985DBB3AD2C558AFFD43023B585811F0F7BFAA59F6661" + }, + { + "path": "crates/oa-core/examples/generate_release1.rs", + "bytes": 1798, + "sha256": "1D5F11165D74893E4BC18DBF3A2F2111F4026BD89FD06EE7F0BAE69E45C4D4B5" + }, + { + "path": "crates/oa-core/examples/release_dense_views.rs", + "bytes": 14664, + "sha256": "A83158488EF9B7394F136C52B1496719BE935C4E5BDFE457CAB5C9894F5F9412" + }, + { + "path": "crates/oa-core/examples/routing_workshop.rs", + "bytes": 17248, + "sha256": "571307B18B6D4FA04700C5A4D4B0C2154C419E93A21292B2028E7B6F46A2FAA7" + }, + { + "path": "crates/oa-core/src/analysis_numeric.rs", + "bytes": 3735, + "sha256": "6C88EEF3119162AEB6C07B9E75812C3AF9FBFE75554B0503AD00F103CB478408" + }, + { + "path": "crates/oa-core/src/analysis_numeric/dimension.rs", + "bytes": 3045, + "sha256": "D4AD8E5307A1620E31791D724A0E31FDBA860D00DC83E3E1F2209D9C5E22A340" + }, + { + "path": "crates/oa-core/src/analysis_numeric/exact.rs", + "bytes": 17338, + "sha256": "4EB8CB0F66B10E5DA204899AF82EE26ADFEC74BAB47B1A6C9842B5488EE04C4D" + }, + { + "path": "crates/oa-core/src/analysis_numeric/fixtures/r7-literal-boundaries.json", + "bytes": 6085, + "sha256": "3B959DEC5E100A6F03817F5314BEF497347BA579EBD235476516D4BD83399181" + }, + { + "path": "crates/oa-core/src/analysis_numeric/meter.rs", + "bytes": 7129, + "sha256": "1F7174BEB992935B4C399ACDEF91114AF73FAF041070FEAB968455AC827E8EE4" + }, + { + "path": "crates/oa-core/src/analysis_numeric/quantity.rs", + "bytes": 12257, + "sha256": "EAD5142D6EEEA8F99399D57E02D8F7D819980923DE76387CD454694D6A747AEE" + }, + { + "path": "crates/oa-core/src/analysis_numeric/tests.rs", + "bytes": 27486, + "sha256": "323CE82FC057D36F44463569FD8EA324C7D700FDDFBF32BCE15E04BD2A66970B" + }, + { + "path": "crates/oa-core/src/data.rs", + "bytes": 42696, + "sha256": "7F9C7C7DD8353CB5A38597B92CDE6D3E890203D392473187EB387D41FF37414A" + }, + { + "path": "crates/oa-core/src/demo.rs", + "bytes": 20657, + "sha256": "AD62123F96C20FF023CAFC1A9849B0FA06BC62EDD1FB9701799E4599B458600C" + }, + { + "path": "crates/oa-core/src/language.rs", + "bytes": 99938, + "sha256": "638F08A42E9CF4996B5D07F69B4D6F517A518369F3604D25BE1006E40E080765" + }, + { + "path": "crates/oa-core/src/language/semantic_source.rs", + "bytes": 7326, + "sha256": "584B5BB59D87A7B8D44D5DD2D124C3A1323B21492B138A17BC9526C0087C9E29" + }, + { + "path": "crates/oa-core/src/language_nesting_tests.rs", + "bytes": 20639, + "sha256": "BD061611E8CB6F28AB203C73E544AC98BF275EB464CD314AD5FBD25F773DF33B" + }, + { + "path": "crates/oa-core/src/lib.rs", + "bytes": 549, + "sha256": "EA1D9BF5ACBA8381BA204DB12FF5B5FE1F320417D17AF517E46B90FD6BCB241C" + }, + { + "path": "crates/oa-core/src/migration_v2/README.md", + "bytes": 1515, + "sha256": "FB61D3FAA33D7BDFBA73415415A0882C0FBD3E0B45DF42F972A19AEC6DA927B9" + }, + { + "path": "crates/oa-core/src/migration_v2/adapter.rs", + "bytes": 9490, + "sha256": "66741B4C8354A23B8A932ECC5BF1BC3BB6E74279558757B8C35BC2157A7E51B5" + }, + { + "path": "crates/oa-core/src/migration_v2/mod.rs", + "bytes": 1158, + "sha256": "857D8BB13B6814DABA6780CA770C16352943C01CB130245D055F11B3B957B3E9" + }, + { + "path": "crates/oa-core/src/migration_v2/model.rs", + "bytes": 52059, + "sha256": "8AC63EB59B2E57BDECFF2477EE0634096090924D3DD1851E1C454880A889DACB" + }, + { + "path": "crates/oa-core/src/migration_v2/references.rs", + "bytes": 13369, + "sha256": "33A6819ABC83139FDAEBEEBD1B553238A328637106CCE7DD5A535F1791D43E20" + }, + { + "path": "crates/oa-core/src/migration_v2/source.rs", + "bytes": 21946, + "sha256": "74FEE9B24E7C912182C998AB9D9AEEC125E2009C3214959229E844C67AFE2BF6" + }, + { + "path": "crates/oa-core/src/migration_v2/state_validation.rs", + "bytes": 15769, + "sha256": "E29EF071BAB0A68460DDF55C46131C6F66C022D52C3C602C37C013C3649DA1B0" + }, + { + "path": "crates/oa-core/src/migration_v2/transitions.rs", + "bytes": 704, + "sha256": "F74174C532BA56B57C80B719A11FB4ADEA0A78CCC2828FFF72FE2B9EDA5A10B1" + }, + { + "path": "crates/oa-core/src/migration_v2/validation.rs", + "bytes": 98736, + "sha256": "6D5E4A4E2805AE5045BF789A0485E14A61C63346587BACE2B4A91173A688F780" + }, + { + "path": "crates/oa-core/src/migration_v3/README.md", + "bytes": 2128, + "sha256": "3755A63F840D7F0EADF68AFA7A9D587877A9468FF982D3610539E8A0BB067A82" + }, + { + "path": "crates/oa-core/src/migration_v3/mod.rs", + "bytes": 23023, + "sha256": "F4EDB6A0B50ECFE8670973776D703047B064420CB1F05913129C00FC656B3722" + }, + { + "path": "crates/oa-core/src/migration_v4.rs", + "bytes": 4020, + "sha256": "50C652964F0F309E7F3F54B6EF12C40D42FCECC0490F005995C713DF1179AF70" + }, + { + "path": "crates/oa-core/src/model.rs", + "bytes": 66352, + "sha256": "4585C895C64C4A183941C92CF047A9E52FF0CBE41971E16E78D229EA9DC101FB" + }, + { + "path": "crates/oa-core/src/operations.rs", + "bytes": 11405, + "sha256": "A5FFBBF54CD440316A106D1188BFB699B57DAE59F466EDCB33618C333CD19F41" + }, + { + "path": "crates/oa-core/src/persistence.rs", + "bytes": 31774, + "sha256": "1C01474F906A63E552C0D590B272C95821C00A100B3E79E58E67DBBEAC341A14" + }, + { + "path": "crates/oa-core/src/ports.rs", + "bytes": 45413, + "sha256": "5CAC12EEA70A54EBEBBD6BE77B63CE92E3195325C4BC7D27705C518686CBAC80" + }, + { + "path": "crates/oa-core/src/projection.rs", + "bytes": 21374, + "sha256": "69ED12BACE2CC5024E151752A174AB14034AC2D05EB741FCF13E9A27521C059B" + }, + { + "path": "crates/oa-core/src/projection/port_layout.rs", + "bytes": 13474, + "sha256": "A08D17D10A58BB8B206DD001B19B91D5AA3A9F743B298A4DA57D503223C20079" + }, + { + "path": "crates/oa-core/src/projection/state.rs", + "bytes": 5233, + "sha256": "73A607BAC713EC7FF49A509FEFB1A53C7686DCA93EE241FFC835564D743AFB28" + }, + { + "path": "crates/oa-core/src/references.rs", + "bytes": 21957, + "sha256": "93553C234F32DA35F1F41A7B83AD81C471C9803EFA78CA222CF7DE3C2F86C0BB" + }, + { + "path": "crates/oa-core/src/routing.rs", + "bytes": 50309, + "sha256": "C5EBB350BA76F731F5C48D968BFBBA4B6EF68EC046EB5893D116C4637EF15080" + }, + { + "path": "crates/oa-core/src/semantic.rs", + "bytes": 540, + "sha256": "F9895DD5B8FB27D10D4CFBB41C3542EF2018365A589B8D5778E5674AE5A1F239" + }, + { + "path": "crates/oa-core/src/semantic/catalog.rs", + "bytes": 49407, + "sha256": "1FF74FCCA18084D178681D7FF38EE1CCAC632C27C4DE0595BA1CEAB8067AB3A4" + }, + { + "path": "crates/oa-core/src/semantic/historical.rs", + "bytes": 15271, + "sha256": "EAF3284BF234312698514CDB636BC8348E1B5D746CB16B18DF777E77D312ABFB" + }, + { + "path": "crates/oa-core/src/semantic/linked.rs", + "bytes": 6035, + "sha256": "6F335B14E809AECFC35DEFCAF77CB821B080D1506DBC46297912B25AF98C07A2" + }, + { + "path": "crates/oa-core/src/semantic/mutate.rs", + "bytes": 22683, + "sha256": "DC2B4C4A6EB156FE468EAA8C85FD37A505C0CD8EA1F226CB69CD01689AFD3D9D" + }, + { + "path": "crates/oa-core/src/semantic/review.rs", + "bytes": 51116, + "sha256": "0C157D2C327E72E89CCA3B423E71EE73798105C5BB7271439C1A101642C91E3E" + }, + { + "path": "crates/oa-core/src/semantic/types.rs", + "bytes": 10405, + "sha256": "6922FE35089F12FAD43AB0CE1C6886D01A3855F90B8E2543E99802AC5E6F1BBB" + }, + { + "path": "crates/oa-core/src/session.rs", + "bytes": 35237, + "sha256": "1EB77B329B2986FC6F5DC0171E77FD860375CDFD8C769A1367B445D6C49EBE00" + }, + { + "path": "crates/oa-core/src/source_files.rs", + "bytes": 74803, + "sha256": "669491DBDDB8E8E6556161426DFFA1A49AC1C826D31020BFF0956D81A4D84683" + }, + { + "path": "crates/oa-core/src/source_project.rs", + "bytes": 61843, + "sha256": "1394370BCC5908873B830C2C11312E6E1611AF41F46C4F43CA0A6BCB713BB466" + }, + { + "path": "crates/oa-core/src/source_project/source_migration.rs", + "bytes": 4700, + "sha256": "ADD463AE85C420F853D324B00A221F0F874164CC3F3ED6BBB07557A9ED7A8C95" + }, + { + "path": "crates/oa-core/src/source_project/source_structure.rs", + "bytes": 20225, + "sha256": "9F1F83315856A7025FE662C0FBB13452F31613224A03663150D6225D1DDC162F" + }, + { + "path": "crates/oa-core/src/state_validation.rs", + "bytes": 15665, + "sha256": "E28D7F74385E2A78763716B34491BD73A00BFECCC085970CEB130627E3FAEE18" + }, + { + "path": "crates/oa-core/src/topology_context.rs", + "bytes": 13734, + "sha256": "16DE69B297DE95D93C73808621607833B713AF811DD082F0169186AE1E5C3E2D" + }, + { + "path": "crates/oa-core/src/topology_context/analysis.rs", + "bytes": 20036, + "sha256": "DEAD6194F0AA60CC46045C0E5EE10DBDBB689158C38634DFBBBC45D2BA88AC65" + }, + { + "path": "crates/oa-core/src/topology_context/analysis_tests.rs", + "bytes": 5258, + "sha256": "17839263410815E96FE027D4EBA3AAE0A9AB3B82B16947E2B29EBE0D09782B56" + }, + { + "path": "crates/oa-core/src/transitions.rs", + "bytes": 51998, + "sha256": "FADD1B07B89CAE4117B91ABB4F1AC9EA81FF818EF20F2175E76EE8CF21FB708D" + }, + { + "path": "crates/oa-core/src/transitions/communication.rs", + "bytes": 8634, + "sha256": "2B476630A88EDD9021B9156CA12A5402E01604095F0D4002AFB452671903A028" + }, + { + "path": "crates/oa-core/src/validation.rs", + "bytes": 112792, + "sha256": "7C1B4AE5E4EBBA13E99AF9158CCDE138DFC5E9A7AFB580E76FBA72D0B64BA74E" + }, + { + "path": "crates/oa-core/src/validation/coverage.rs", + "bytes": 6478, + "sha256": "B08E4B344ECAD055714168C83BEFF7FE29BA4EDC53EDBFFA2EAE04E5580A4D5F" + }, + { + "path": "crates/oa-core/src/view_options.rs", + "bytes": 24481, + "sha256": "421EDE0A04C593CA1DF0E6E0D06FABCFCC8113CDBCFB0ADC7F5C8EF4D1724208" + }, + { + "path": "crates/oa-core/src/view_options_tests.rs", + "bytes": 21433, + "sha256": "64AAC44DE2DF42A1012239A3DD75C473DAF0C8990B3170B7BF7E78A556080208" + }, + { + "path": "crates/oa-core/tests/communication_boundary_scenarios.rs", + "bytes": 8816, + "sha256": "E1100B8F363910A487D90C73ACC7DF543DC6D532158C3C63AD11CD3D40D8E12B" + }, + { + "path": "crates/oa-core/tests/communication_means.rs", + "bytes": 18102, + "sha256": "A25F2749180BC6F4BEB1445BFAD3351EEDF0669131A7439B633AB804439E95A4" + }, + { + "path": "crates/oa-core/tests/coverage_diagnostics.rs", + "bytes": 20527, + "sha256": "E2E9C3996DAAB47F2BD23F5D8C48B4E372AF33331152F5FC85B76F06D04FCDF4" + }, + { + "path": "crates/oa-core/tests/fixtures/v2-coastal-rescue.arcadia", + "bytes": 29220, + "sha256": "F0DF129C1914BFC01D0B047B0CD4B520D8CF0C249EA9CED085DE400C0BE87FF3" + }, + { + "path": "crates/oa-core/tests/fixtures/v2-coastal-rescue.json", + "bytes": 43990, + "sha256": "3ED53508EB544BBD788832F7BEBEA2EC31B3D66191F24F45098CC9750EC4C005" + }, + { + "path": "crates/oa-core/tests/fixtures/v3-data-contract.arcadia", + "bytes": 2450, + "sha256": "305F19D1B79F99F8D4CF627BA2E9CA6836BB7959CC0070227815937A699EAFEA" + }, + { + "path": "crates/oa-core/tests/fixtures/v3-data-contract.json", + "bytes": 8240, + "sha256": "1B41299C53204E6232397D25A58D5BCCDBBC16E770C75E8EDB55CE36CCFE58A2" + }, + { + "path": "crates/oa-core/tests/fixtures/v4-reviewed-prose.arcadia", + "bytes": 1222, + "sha256": "5FA53A26A5AD010C8E59EB56D9B2B52003569E66C0377FFB58840590168AD0C1" + }, + { + "path": "crates/oa-core/tests/fixtures/v4-reviewed-prose.json", + "bytes": 1734, + "sha256": "2808088AFCC165F4BD6DA5129151143411C997041C3DE61D600AB76E05D24A47" + }, + { + "path": "crates/oa-core/tests/language.rs", + "bytes": 56482, + "sha256": "77F6CB2A69976BFB5F60B243B71C07FB0EDC7505002C1E9E73FCD6DDF6D9496E" + }, + { + "path": "crates/oa-core/tests/port_anchors.rs", + "bytes": 26893, + "sha256": "EFBC0FA18733B1AAAC501F9785A4C392FC49C05C45A5778D26E9B5DD951CA6FA" + }, + { + "path": "crates/oa-core/tests/port_migration.rs", + "bytes": 15620, + "sha256": "1801C376A5435AC7EFB2A8342348B2C7FFEF930BBA65DECA4ACB66B06E0483D7" + }, + { + "path": "crates/oa-core/tests/port_source.rs", + "bytes": 4858, + "sha256": "8D2DDACA4045D330F6EDA2591561497B858AAD53769A823249D0BEA4F5916E12" + }, + { + "path": "crates/oa-core/tests/prepared_commands.rs", + "bytes": 9555, + "sha256": "52CAB1C668064777412DF0A6A3C20D74D579B33794B4B00525B9F151C06EAC9B" + }, + { + "path": "crates/oa-core/tests/process_diagnostics.rs", + "bytes": 1104, + "sha256": "1DBBC590239EF7542BE4A2BDC9543AAA64D108417B03DD312A0894E364745906" + }, + { + "path": "crates/oa-core/tests/r3_constructor_ack.rs", + "bytes": 24163, + "sha256": "E5C44676EA0AAB97F2E091DDAC2F4BC4A4A90F5877F7BBF08B61229600944DD0" + }, + { + "path": "crates/oa-core/tests/r3_migration.rs", + "bytes": 6590, + "sha256": "EBD04120F5215B3956FDC79EE78F6E38734C7D13392F1FC28AABA957912265BD" + }, + { + "path": "crates/oa-core/tests/r3_semantic.rs", + "bytes": 77473, + "sha256": "49C3763EAF2131070A7C46CC23A84F917D292D41610B6404510E5607F8890C32" + }, + { + "path": "crates/oa-core/tests/r3_source.rs", + "bytes": 15311, + "sha256": "F31F60659DF3C7EF601650D1CFF140589D8729F01A3F65A4A6A6C2272F15C408" + }, + { + "path": "crates/oa-core/tests/r6_data.rs", + "bytes": 41437, + "sha256": "5F7FFB3BC73C3C85423623FE56F8680646DC97040327BDBEAD9D47A37E80F155" + }, + { + "path": "crates/oa-core/tests/r6_migration.rs", + "bytes": 9174, + "sha256": "81519BC1512F5E069432E9A1CDAD04C10216D492DF01B4B75B02102480EBD6A1" + }, + { + "path": "crates/oa-core/tests/r6_source.rs", + "bytes": 9519, + "sha256": "88A0AA087A7678E2898FF6057538072CA92072190712F05FFF08372CC56EBED6" + }, + { + "path": "crates/oa-core/tests/release1_teaching.rs", + "bytes": 10010, + "sha256": "BE179290D871125DC97CFD2A3067C7AC89C8A538FDB0AFAFC5B825BCDFB2BEF2" + }, + { + "path": "crates/oa-core/tests/release_capabilities.rs", + "bytes": 19422, + "sha256": "B92CBA2A562E78287DDD7DC3E94557848D17FAA0C8A102601B50286B9AF48A30" + }, + { + "path": "crates/oa-core/tests/release_diagrams.rs", + "bytes": 7689, + "sha256": "CEFCC0582D91266D5DE6EE5DAEFCFE41B606AB971AB235B59034A8E1DBC24B7D" + }, + { + "path": "crates/oa-core/tests/release_language_limits.rs", + "bytes": 5204, + "sha256": "8578DA4F76BBD2D0E1BFE295AB1259E2162A3FE3D0D2BA8901915D7A40942806" + }, + { + "path": "crates/oa-core/tests/release_ports.rs", + "bytes": 32740, + "sha256": "506E62BEAB0C7EC0CECE83E9E0C1A810FA1F99285C9E1CAB0C77F06DE55A40E1" + }, + { + "path": "crates/oa-core/tests/release_routing.rs", + "bytes": 23165, + "sha256": "AE168FAABA23769B7CA1FD65F217B9DB671726E67276EB5A04E5E72B1447A462" + }, + { + "path": "crates/oa-core/tests/release_scenarios.rs", + "bytes": 41067, + "sha256": "0F5D876BADEEC653FC7B12610A77D867E9A53582E605253DEFF8D379DA21572F" + }, + { + "path": "crates/oa-core/tests/release_states.rs", + "bytes": 26873, + "sha256": "B1A26ACEE95EABA7F9920F9DA738A1E33606552EB078E6D298895DAFF7E3D811" + }, + { + "path": "crates/oa-core/tests/release_topology.rs", + "bytes": 17643, + "sha256": "C51DBE5AA5A038F9ED830DAA02AA67714472179EB7FD6DA5CE36DC6931C8B3A9" + }, + { + "path": "crates/oa-core/tests/review_regressions.rs", + "bytes": 21823, + "sha256": "F154E5C88D35E31512E2B07733A1327EA3E2FCFD1EF8EB23066A4E9573F387C1" + }, + { + "path": "crates/oa-core/tests/route_source.rs", + "bytes": 7270, + "sha256": "1A73FDBAC0D0AE0E84D8D67B6AD398E11223958F5DCA007F73757C91827DECFB" + }, + { + "path": "crates/oa-core/tests/routing_geometry.rs", + "bytes": 31890, + "sha256": "2D8EEA4A2BBA535ED0EA6DAABCCDAF87B375828C404BAA10518C7BD7FD6253D3" + }, + { + "path": "crates/oa-core/tests/routing_labels.rs", + "bytes": 5530, + "sha256": "DFF884D799C3C5C9257210248C55767E294E3CB317C605CBD0DDC9600C216EB6" + }, + { + "path": "crates/oa-core/tests/semantic_artifact_absence_oracle.rs", + "bytes": 4640, + "sha256": "D93EAF9667F4221232168BEF0FE9F069E5BE1B1F23410BDA5DD67010B96E5C8A" + }, + { + "path": "crates/oa-core/tests/semantic_attachments_gui_oracle.rs", + "bytes": 14957, + "sha256": "7F526D363BA05E7AC89FAF0FEAD0796E419C34EE204EDF44A1B46C78308B0970" + }, + { + "path": "crates/oa-core/tests/source6_view_contract.rs", + "bytes": 14307, + "sha256": "FA566DE53946D40BBBD9033EDFEED177180C4DC44AFA2F2F4667FFDC622EB3BB" + }, + { + "path": "crates/oa-core/tests/source7_contract.rs", + "bytes": 43262, + "sha256": "CAD4F1A135DDBD947BDF3C71D1B44F1E438725095EA8C4D893464BC721C2363F" + }, + { + "path": "crates/oa-core/tests/source_files.rs", + "bytes": 13168, + "sha256": "F0429A0B89A443458EA9AF7083C1E4E7411417A7E6B47E618BFBB9266DC164F9" + }, + { + "path": "crates/oa-core/tests/source_identity_updates.rs", + "bytes": 5139, + "sha256": "F52AA38B4D317E532C641AD7463472E4B40E802E60052CF0017556FFC63DA06C" + }, + { + "path": "crates/oa-core/tests/source_nested_updates.rs", + "bytes": 16892, + "sha256": "9AC3390936C0D250651EA94A1B8FAF0ADFB680F061ECECC56F2A52909B5BA855" + }, + { + "path": "crates/oa-core/tests/source_project.rs", + "bytes": 45910, + "sha256": "CEF58ADB16AA4D2527DDC0628AEC3E26F0B42B4BE70014A7E360FAFDAC13A942" + }, + { + "path": "crates/oa-core/tests/state_diagrams.rs", + "bytes": 23973, + "sha256": "6B02B387A3AE559CB3AD72DE24292A5ABCB76AAAC0ADBAB5D2AA5211B22CD2E5" + }, + { + "path": "crates/oa-core/tests/workflows.rs", + "bytes": 33426, + "sha256": "BD843DFDDBD5724EC08F657571F887496203056FE2FA5864EF50A168A613B89B" + }, + { + "path": "docs/language.rst", + "bytes": 130753, + "sha256": "AC6B1D982C55F246DE2F3C844B24B874599A3C20CFBD7ED22EFE4561AC622B83" + }, + { + "path": "examples/coastal-rescue.arcadia", + "bytes": 33107, + "sha256": "7E183EF9FD8154D3074303F1956CD0D9871C19E3FFC630D333B0BFFBD6E30E17" + }, + { + "path": "examples/coastal-rescue.json", + "bytes": 49715, + "sha256": "242F0B031229A87AF5C4EBE5D651DEFE59C191A6B7A69DC0A77F7E203411F195" + }, + { + "path": "examples/legacy-v1-coastal-rescue.arcadia", + "bytes": 3449, + "sha256": "4DBBBDF8BC7E6CB8E0A81DD678069DAE585A9E5187C59BC3665E1FDAB3F1A86A" + }, + { + "path": "examples/legacy-v1-coastal-rescue.oa.json", + "bytes": 5976, + "sha256": "FD0E9DEDFB7ADB922DC0575CC2B820D40344D639F1D8052BD70363AA59DE9C24" + }, + { + "path": "examples/release1/README.md", + "bytes": 11137, + "sha256": "113CFB1CF38A98B51BFF60442D86A35D636A3D80EF206BECE7D53D0AAE67F2D5" + }, + { + "path": "examples/release1/evidence/repair-check.md", + "bytes": 1419, + "sha256": "F4DB17BDCA4ECBB1BF0CAEF01B2DA5F1F7F798AB3152B648CE6DD8696A6A68E5" + }, + { + "path": "examples/release1/expected-diagnostics.json", + "bytes": 288, + "sha256": "3700393D76B940CFC076BF52861BD7B1F43370F8EABCE3594531BD0BF1FC851E" + }, + { + "path": "examples/release1/model.json", + "bytes": 134520, + "sha256": "EF0EF4006C3E09D5DB6E1B621256B3F7D6642F3A7B09CC34607CFBCCA830C727" + }, + { + "path": "examples/release1/model.rs", + "bytes": 28762, + "sha256": "15F30F6BDE655808FE754665C26D560E36F2A9BAD340F346B09137D0D05045F5" + }, + { + "path": "examples/release1/oa/activities/activity_oa_restore_work.arcadia", + "bytes": 1307, + "sha256": "83E73D0A013D5C3E1A4AF0D9139AB0CFA362D25A846671FC2DAC59181061A9D4" + }, + { + "path": "examples/release1/oa/behavior/process_oa_assessment_procedure.arcadia", + "bytes": 697, + "sha256": "172C32C044EF4B5BC58A1F4635CC81F48DABB4014479B47DCE7B25FEDF2DBD0D" + }, + { + "path": "examples/release1/oa/behavior/process_oa_field_procedure.arcadia", + "bytes": 1781, + "sha256": "D2552DC5383E2E91CB302A790364E197F663C2EDC013A2AB1978789825C9C324" + }, + { + "path": "examples/release1/oa/behavior/process_oa_response.arcadia", + "bytes": 3225, + "sha256": "CF86F46FA5C5D70592EF43F1BC1CB4D968EFD8ADD384BA88036BEF6B88AA4ABC" + }, + { + "path": "examples/release1/oa/behavior/scenario_oa_conversation.arcadia", + "bytes": 1795, + "sha256": "B25BE524E98A35EBAC5E4481B7609C027B32BAF9605E8097F807560F94A77CA6" + }, + { + "path": "examples/release1/oa/behavior/scenario_oa_full_scenario.arcadia", + "bytes": 1261, + "sha256": "00A1EDF2C9707A0EB62AC070C2D02467874F2C9C1F273CC6DDB53D6D65999A1C" + }, + { + "path": "examples/release1/oa/behavior/state_machine_oa_monitor.arcadia", + "bytes": 2022, + "sha256": "447C9AD1359F4A31ECF975AD07C0B08D8E7E03191FB0858E2FC56F377B223CD5" + }, + { + "path": "examples/release1/oa/capabilities.arcadia", + "bytes": 781, + "sha256": "FC8012E5554350B12E1CB80080F8FEAB80D933C9B33C0F2A7BB500CB7D609936" + }, + { + "path": "examples/release1/oa/model.arcadia", + "bytes": 661, + "sha256": "F7CF914766EA73EC53B85E32D117EA9A9DBA52D3E038246863541AE7069B1A15" + }, + { + "path": "examples/release1/oa/participants.arcadia", + "bytes": 1834, + "sha256": "951764C14179F27C10E315ACF2EA37D1FFF54C77B8441E23ADDD239E8C715CE3" + }, + { + "path": "examples/release1/oa/types.arcadia", + "bytes": 788, + "sha256": "9D0396FB82DD5733EDD8D1CF52F968CA84906927C704F9CF0944A7FAE215CF49" + }, + { + "path": "examples/release1/project.arcadia", + "bytes": 1958, + "sha256": "B2674E6BF77CFE0F6F0BA79C81D2E96D92F39C02CA2C6266D80E91723BFB9FDA" + }, + { + "path": "examples/release1/sa/behavior/process_sa_assessment_procedure.arcadia", + "bytes": 706, + "sha256": "73928CED61C06CBC08728D7956676A50FD4C05AA5B7F1FF4F29CE27A0B0F2088" + }, + { + "path": "examples/release1/sa/behavior/process_sa_field_procedure.arcadia", + "bytes": 1790, + "sha256": "886D462AF83110CBFA55DD232A667EA1446761234471357CBB878794E98E8768" + }, + { + "path": "examples/release1/sa/behavior/process_sa_response.arcadia", + "bytes": 3234, + "sha256": "041785FC22842423822431BD5E6E8D16094EBD0716965E7C765FB8C1D7549E8A" + }, + { + "path": "examples/release1/sa/behavior/scenario_sa_conversation.arcadia", + "bytes": 2127, + "sha256": "97276F5757EE9AA12A0DBD9D3CCD58174B3091162BB8BCCE357CEC20A45F5D85" + }, + { + "path": "examples/release1/sa/behavior/scenario_sa_full_scenario.arcadia", + "bytes": 1256, + "sha256": "E12FC406B99D60A2F7768AAA0B25BA560E8605F0B8E37F3A7CDD3B8443562B74" + }, + { + "path": "examples/release1/sa/behavior/state_machine_sa_monitor.arcadia", + "bytes": 2022, + "sha256": "05CE29AF99424C491C47FE2450DC6F5F8CE995511C01EC7E17BB5855BE3C9736" + }, + { + "path": "examples/release1/sa/capabilities.arcadia", + "bytes": 781, + "sha256": "B6912FB3ECE439912345FA71724ACD462E716A1E5A88203A1058A5C3B2C20A4F" + }, + { + "path": "examples/release1/sa/functions/function_sa_audit_thresholds.arcadia", + "bytes": 297, + "sha256": "E5A04325D52183956DCA814DD90F4E56C96C6B7DB0F4F202F203ECB49C3E0F4F" + }, + { + "path": "examples/release1/sa/functions/function_sa_restore_work.arcadia", + "bytes": 2576, + "sha256": "FDDEB67308051325041FEE641FE758DDF43345B7C47B66D433616AD07FDCCDD6" + }, + { + "path": "examples/release1/sa/model.arcadia", + "bytes": 661, + "sha256": "D431509251ACB859FBB98733B6D18ADD952495E10A78703FBE8F31E40BC083C4" + }, + { + "path": "examples/release1/sa/participants.arcadia", + "bytes": 4012, + "sha256": "780D2EB08B217B67B55FEBB3D34846F727B8EE9018DF6C5741748647E36B2FF8" + }, + { + "path": "examples/release1/sa/types.arcadia", + "bytes": 1227, + "sha256": "101FC0FBD99F1101514AA7B6DA4F8E535AAE0BB94C9E2B9E7E6C151135B28660" + }, + { + "path": "examples/release1/traceability.arcadia", + "bytes": 29506, + "sha256": "48A96F543456C9B5A5798FC795594E4119147FE45F11A6F109F141B7F514560F" + }, + { + "path": "examples/release1/transition-before.json", + "bytes": 35801, + "sha256": "2253E6434F741687E22327C37D520ED15C043BC36025498650BC2366BB6A874D" + }, + { + "path": "examples/release1/transition-request.json", + "bytes": 4276, + "sha256": "43B9A22E5F95AD3BC0EBD2478EAD0E14406854ABC989AE94DED9367B013D812A" + }, + { + "path": "examples/release1/views/diagram_oa_allocation_view.arcadia", + "bytes": 2176, + "sha256": "DFC0EB61E0E2AA5BDA45524571FBFA3494F44CE19696F3E15E07438074A07BC7" + }, + { + "path": "examples/release1/views/diagram_sa_allocation_view.arcadia", + "bytes": 2173, + "sha256": "B78B43A26A82EFB8FBD5374B09C329FA341F57B29F635D397B9EF8AEDE71D389" + }, + { + "path": "examples/release1/views/diagram_sa_capability_view.arcadia", + "bytes": 1620, + "sha256": "75E8DC9FB30F25D6F8FD3C6B595593AB4F98958A25798F961E336E0FAC557649" + }, + { + "path": "examples/release1/views/diagram_sa_service_view.arcadia", + "bytes": 2209, + "sha256": "B08C84F3EA633E7C78DFDAD3272F75415F6256CF3614B6D99BFD4F0269076C9B" + }, + { + "path": "examples/release1/views/diagram_sa_state_view.arcadia", + "bytes": 1931, + "sha256": "5F42F4C605E57B817D8400ED21F2B14BFC4E1592AEBE941FF113767504C2B869" + }, + { + "path": "examples/routing-workshop.arcadia", + "bytes": 66431, + "sha256": "DC6B75DA72884D399132ED9E300FA5A26B73D802BB526C9AF986AE9EEB0A646A" + }, + { + "path": "examples/routing-workshop.json", + "bytes": 100281, + "sha256": "8592714F75B271133F21CE017D87896B6CF91EA0E4837F1C2ED453E21BE26B14" + }, + { + "path": "examples/source-project/oa.arcadia", + "bytes": 2142, + "sha256": "5F60642F211B2D352A951092FAEEB7503D4FB7F6F7FCA99E810347231101D31C" + }, + { + "path": "examples/source-project/project.arcadia", + "bytes": 606, + "sha256": "787F6B67B179FFD221F7E5FAF1A4EDD74CFD116AA1E3B5E9ED9F22FBFF612D8D" + }, + { + "path": "examples/source-project/sa/assurance.arcadia", + "bytes": 682, + "sha256": "AB76044836E4DCD42A82D316846EC87DA2108939AA2037640C5BCA1D2ECDEE0F" + }, + { + "path": "examples/source-project/sa/participants.arcadia", + "bytes": 1231, + "sha256": "1ABAD579812E77877B45A3D8E3C3E9DA734BE2D04A0254CC730A5257EBD2DC88" + }, + { + "path": "examples/source-project/sa/reporting.arcadia", + "bytes": 2839, + "sha256": "F0B7443F728912CA163DE39B6055876B533A77FEAA3219B2D40A2DD65712E058" + }, + { + "path": "examples/source-project/views.arcadia", + "bytes": 3508, + "sha256": "E29AD3F406A6A50D2F0AE2CA2A53EE3B0C18222CD4098704A46D4F48B64EA17F" + }, + { + "path": "examples/text-first/README.md", + "bytes": 1367, + "sha256": "4EC8250D5ED556F1D496E8C85137E973E802ECB11DF1A36488411E8CDC5A9F9E" + }, + { + "path": "examples/text-first/project.arcadia", + "bytes": 480, + "sha256": "F441535C623E0CF20E1875EB2CD33B587ED00E078C37AF39CB36D89BB5EDC19A" + }, + { + "path": "rust-toolchain.toml", + "bytes": 66, + "sha256": "A6A0BBD29FFAA8182DC22D1D9149709F1091E47DF40ED96EB8A78A711C66A4CE" + } + ] +} diff --git a/harness/release-analysis-numeric-gate-20260911.md b/harness/release-analysis-numeric-gate-20260911.md new file mode 100644 index 00000000..cae3fa15 --- /dev/null +++ b/harness/release-analysis-numeric-gate-20260911.md @@ -0,0 +1,45 @@ +# Exact numeric foundation: local execution gate + +Codex-956atf `/root`, Windows, 2026-09-11. Source +`7d14bd0dd1cf4a2293c00c185573fd4a7fc364ae` passes **26/26 numeric unit tests**, +zero failed or ignored. The test binary also contains42 unrelated unit tests, +which were filtered out. This is the numeric foundation gate, not schema8 model, +calculation evaluation, source/REST/GUI parity, or release acceptance. + +Command: `cargo test --locked -p oa-core --lib analysis_numeric::tests -- --test-threads=1`. +Exit0; warning-free compilation6m25s; test execution0.29s. The task-local toolchain +selects Rust/Cargo1.98.1, Windows GNU/LLVM, LLVM-MinGW20260908 and one Cargo job. +The earlier command without `--lib` was intentionally stopped before running tests +because it compiled unrelated integration executables; that attempt supplies no +numeric test result. Its log remains under `.local/logs/analysis-numeric-first-20260911.log`. + +The gate executes independent small-integer arithmetic expectations, complete R7 +literal/wire packets, reduced component and wider intermediate boundaries, nominal +quantity and affine-point operations, dimension/power failures, aggregate target +accounting and terminal dynamic/numeric work limits. Source-level review is in +[the independent review](release-analysis-numeric-review-20260911.md); the exact +implementation contract and its provisional limits remain in +[the implementation handoff](release-analysis-numeric-implementation-20260911.md). + +Preserved [execution log](experiments/analysis-numeric-gate-20260911/numeric-unit.log) +and [manifest](experiments/analysis-numeric-gate-20260911/manifest.json) record162 +source/build/fixture inputs. Every input was compared byte-for-byte with its Git +blob at7d14bd0. The immutable local executable and runtime are retained in +`.local/analysis-numeric-gate-20260911/`; executables are not committed. + +| Artifact | SHA256 | +| --- | --- | +| 24,529,920-byte test executable | `F92A4C4CF01DB0AD4810527F0D8AC2212196AEB3A4907E8EEA12FD5EDEADDB0D` | +| 190,464-byte libunwind.dll | `AA282FA47BB2C4E210CEA5E470CDBDCA58568F0BE0BC73E404D86AB9CD95EF6A` | +| Exact test log | `DF7F71D037CF2B3D38234C834F9F399F3BB30CC4757F453D72833DE9A9C3C063` | +| Manifest | `CBAF29385DE79A821EF3A76C8B64C802983B50584AA504BED373A5D9A04066A1` | + +Strict core all-target Clippy also passes with exit0: `cargo clippy --locked -p +oa-core --all-targets -- -D warnings`,11m49s, unchanged core/build inputs. Its +[complete log](experiments/analysis-numeric-gate-20260911/core-clippy.log) is preserved +separately from the unit-test manifest. The isolated schema8 worktree starts +from7d14bd0 and must preserve or +rerun this gate when extracting shared pure type operations. Decoder allocation, +bounded resolved identities, actual AST traversal and full-context captures remain +production integration obligations. No approximate engineering profile or model +evaluator is supplied by these numeric unit tests. diff --git a/harness/release-reference-chooser-design-20260910.md b/harness/release-reference-chooser-design-20260910.md index ee0a4873..c0f9b47d 100644 --- a/harness/release-reference-chooser-design-20260910.md +++ b/harness/release-reference-chooser-design-20260910.md @@ -117,4 +117,33 @@ The revised candidate has **13 tests prepared** at the worker handoff. No produc ### Second executed gate — root integration checkpoint -Root ran the corrected candidate through `cargo test --locked -p oa-desktop forms::reference_chooser::tests::`. The log `.local/logs/reference-chooser-second-20260911.log` records a warning-free compile in 1m47s and **13/13 passing**, zero failures/ignored, 642 filtered out, in 0.41s. This includes actual mounted clipboard paste and Unicode IME commit alongside the corrected physical-key fixtures. The module is declared under `#[cfg(test)]` only. This result establishes the isolated widget/state tests; the complete inspector/review adapters, native font/focus/geometry, responsiveness and model/source/history acceptance remain open. +Root ran the corrected candidate through `cargo test --locked -p oa-desktop forms::reference_chooser::tests::`. The log `.local/logs/reference-chooser-second-20260911.log` records a warning-free compile in 1m47s and **13/13 passing**, zero failures/ignored, 642 filtered out, in 0.41s. This includes actual mounted clipboard paste and Unicode IME commit alongside the corrected physical-key fixtures. At that checkpoint the module was declared under `#[cfg(test)]` only. This result establishes the isolated widget/state tests; the complete inspector/review adapters, native font/focus/geometry, responsiveness and model/source/history acceptance remain open. + +### Production adapter candidate — source handoff, 2026-09-11 + +Codex-956atf /root/behavior_audit implemented the coordinated Inspector and active Review integration after the source freeze was released. This new candidate has **not yet been compiled or executed**. The prior 13/13 result above does not validate these changes. + +- `forms.rs` now declares the chooser in production, renders live scalar reference fields through `reference_chooser/adapter.rs`, and maintains transient draft/session generations. Existing `reference_ids` remains the eligibility authority. A Choose/Clear rechecks compatibility and replaces only its authorized subtree with `shape_scoped`; invalid sibling text and inactive constructor caches remain staged. Historical baseline paths retain the historical editor and never enter the catalog. Recovery encoding is unchanged. +- New `app/reference_authoring.rs` owns one transient popup and validates document instance/revision, page/layer, exact draft generation, and current Inspector or Review host. `App::view` keeps the wrapper mounted at the root. Small Message/update hooks route browser events before generic modal handling and invalidate obsolete hosts after ordinary changes. Captured search shortcuts cannot save, apply, navigate, or undo a model. Escape closes this popup before closing its owning review. +- `semantic_review.rs`, `row.rs`, and `batch.rs` carry exact addressed Open/Event/Set messages. Browse/search/page/cancel stop at the app controller, without invoking Review update/replanning. Writes require the currently active row, selected Candidate facet, authorized root, and the exact scalar/group/attachment draft. A delayed banked-row message is inert. `semantic_review/view.rs` adds one focus scope around the active candidate detail. +- `reference_chooser/trigger.rs` implements the compact focusable trigger, Enter/Space opening, form-local Tab traversal, and generation-scoped Escape/Tab return. An asynchronous focus operation first finds its exact field; a removed/replaced field causes no focus changes. Source inspection caught an anchoring defect before compilation: Iced child updates use scroll-content coordinates. The root popup now measures field bounds through an operation that accumulates nested scroll translations and clips to visible scroll viewports before each input. This also handles scroll then activation without relying on a paint to update coordinates. + +Nine new tests are prepared under `app/reference_authoring/tests.rs` and `tests/mounted.rs`: literal 1,000-entry eligibility/search and scoped writes with invalid raw text; exact accepted Project/source and Undo/Redo after explicit Apply; invalid/missing/incompatible current retention and Clear; recovery/document/revision/source/modal/context invalidation; array reorder/constructor replacement; scalar/group/attachment Review routing and no replan during browsing; stale active-facet and banked-row rejection; real mounted trigger→App→root-overlay keyboard flow, same-batch query/Escape, adjacent-field Tab return, stale focus, and scrolled pointer/keyboard anchors. Existing 13 isolated expectations were not weakened. These are prepared assertions, not passing results. + +Root gate: format and compile current source, then run `cargo test --locked -p oa-desktop app::reference_authoring::tests::` and `cargo test --locked -p oa-desktop forms::reference_chooser::tests::`; include existing forms/history and semantic-review regression suites, strict Clippy/all-targets, and the combined desktop suite before native staging. The worker ran no Cargo/native commands or commits. `git diff --check` was clean at handoff; root's separate ScenarioCanvas FormShown hook is preserved. + +Open native gate: the actual Inspector and Review popup at 1100×700, scrolled forms, repeated names and long containment/UUID detail, keyboard focus/Tab, pointer cancellation and overlay priority, real text/font clipping, and measured 1,000-entry query/eligibility latency. The new host tests use actual retained widgets with the null text renderer; they do not establish native typography, accessibility, touch usability, or release acceptance. Other specialized multi-member/reference-mapping controls remain their existing bounded workflows; this slice replaces scalar live-reference property choosers in both hosts. + +### Independent focus counterexamples and correction — source only + +The independent production reviewer found concrete P2 keyboard failures in the initial host candidate: local Tab enumerated offscreen fields without revealing them; a fully clipped reference then had no anchor and Enter silently failed. Fresh forms could not acquire focus through Tab, and traversal past the final field left no focus from which to continue. The review also identified stale return tasks that could locate an unchanged Inspector field underneath a newly opened Review modal, and background focus that could receive ordinary characters after modal reference-trigger acquisition or outside dismissal. These findings came from installed Iced and application source inspection, not native execution. + +Under a renewed desktop-only lease, focus traversal moved to the stable root Popup. App supplies the exact active form session and whether it owns the Review modal. New `reference_chooser/trigger/focus.rs` collects only that form's focusable controls, tracks enclosing scrollable geometry, and reveals the selected destination from the innermost viewport outward. Fresh Tab/Shift+Tab enter at the first/last field; boundaries cycle within the form. Ordinary Inspector entry respects an already focused control outside the form. Review entry clears background focus, and the modal root enforces that ownership before ordinary input as well, including pointer opening and outside dismissal. + +Asynchronous Escape/Tab/Choose return still requires the exact generation-scoped originating field, and now also reads the root's current form owner before applying focus or scroll. A different modal, a new chooser invocation, or a retained old Inspector underneath Review makes the old operation inert. The active Review form is exposed by one small `batch.rs::reference_form` accessor; no scenario/workspace hook changed. + +Four additional mounted tests are prepared, bringing the new App/host suite to **13 tests**: initial offscreen fields with one and two scroll containers, Tab-to-reference/Enter and return reveal; fresh entry and repeated boundaries in both directions; delayed Inspector return while its unchanged field remains under a Review Stack/Opaque; and actual Review pointer opening, Outside dismissal, then a character key with zero leaked messages or raw/model/source changes. Existing literal model, source, raw, history and isolated-widget expectations remain. No compile/native result is claimed for this corrected candidate. Root retains formatting, first combined compile, focused suites and native gates. + +A subsequent independent read-back caught an overbroad ordinary-input rule: clearing focus outside the Candidate form would also deactivate Review's legitimate engineering-rationale input, which is a sibling above that form. The correction now uses two distinct widget boundaries. `trigger::modal`, applied to the complete `batch::State::view`, marks the whole owning Review dialog without changing its layout. Ordinary-input cleanup preserves all controls inside that modal. The narrower draft/session boundary continues to define explicit Tab and return-to-field traversal. Explicit focus transfer can deactivate previous controls as usual; mere typing does not clear a sibling inside Review. + +The new regression starts from the existing source7 interface-operation fixture, adds an OA occurrence counterpart, enters actual SA-to-OA Review with an operation-omission rationale and an active Candidate reference facet, then types the complete reason through the mounted sibling input outside the Candidate scope. It requires one correctly addressed rationale event per physical key, retained focus, unchanged candidate/Inspector raw buffers, unchanged accepted source and no history entry. The modal fixture used by the prior dismissal tests now includes this complete modal boundary. The App/host candidate suite now has **14 prepared tests**. The independent finding is source-corrected; execution and native acceptance remain pending. diff --git a/harness/release-reference-chooser-production-review-20260911.md b/harness/release-reference-chooser-production-review-20260911.md new file mode 100644 index 00000000..776ddffb --- /dev/null +++ b/harness/release-reference-chooser-production-review-20260911.md @@ -0,0 +1,79 @@ +# Independent production reference chooser review — 2026-09-11 + +Reviewer: Codex-956atf `/root/semantics_critic`. Scope: Inspector and active semantic Review scalar live-reference chooser; read-only production inspection. The reviewer did not run Cargo, the desktop application, or native input, and did not edit production, prior reports, or Git state. Root owns integration and execution; `behavior_audit` owns corrections. Scenario FormShown and canvas scrolling are separate work. + +Status: the three concrete P2 findings below are corrected at source-review level after independent read-back. No remaining source blocker was found in the assigned chooser slice. The corrected host suite is still uncompiled/unexecuted at this review; this is not production or native acceptance. Gitea issue 1 comments 209 and 219 record ownership and the initial findings. + +## Findings in the first production candidate + +These locations refer to the formatted candidate inspected before the focus correction: `forms/reference_chooser/trigger.rs` SHA256 `529C36791272346C166064C02BB659FD0BE6C2A76376CC6C5D440B6BA22495F7`; `app/reference_authoring.rs` SHA256 `A8FEA3D252C01C849CE5B0ADE4FBBB9D3CF3A34E58C062FBEBF152710377F8E8`. Paths below are relative to `crates/oa-desktop/src` unless stated otherwise. They are source-derived falsifiers, not executed failure logs. + +### K1 — P2: keyboard focus can enter a clipped field that cannot open + +`forms/reference_chooser/trigger.rs:185` runs ordinary Iced focus traversal across all focusable descendants. `FromField`/`SetFocus` around lines 408–449 also select by traversal index alone. Neither reveals the selected field. The root `Measure` correctly clips reference anchors to nested viewport intersections at lines 357–391, and activation at line 236 publishes an Open only when such an anchor exists. + +Minimal falsifier: put the reference trigger immediately below a 180-pixel form viewport; focus the last visible text input, press Tab, then Enter. The reference becomes focused below the viewport, but its anchor is `None`, so Enter publishes no chooser. Text inputs below the fold can likewise receive unseen typing. Escape/Tab return can select an offscreen neighbor. The original mounted scroll test snaps the reference into view before interaction, so it does not test this sequence. + +The installed Iced 0.14 `widget/operation/focusable.rs` implements focus selection and traversal without using scroll geometry; there is no implicit reveal to complete this workflow. The author confirmed the defect. Resolution: select within the active form, minimally reveal the destination through its enclosing scrollables, and remeasure before activation; add a mounted test without a preparatory scroll. + +### K2 — P2: fresh/boundary Tab loses form entry and can reach an inactive host + +The same scope branch handles Tab only if `Count.focused` is true (`trigger.rs:185`). Iced `focus_next` unfocuses the last item and `focus_previous` unfocuses the first. Subsequent Tab sees no focused child and does nothing; a newly mounted unfocused form cannot enter its first field through this handler. Custom chooser return at `trigger.rs:430` also selects `None` beyond either boundary and clears all focus. + +Minimal falsifiers: mount an unfocused form and press Tab; or focus its last field and press Tab twice; or close a chooser on its final field with Tab. No active field is recovered. Additionally, opening Review while Inspector retains focus leaves two mounted scopes: Iced `opaque` captures only mouse presses (`iced_widget-0.14.2/src/helpers.rs:660–682`), and `Stack` stops descending only after event capture (`stack.rs:231–263`). An unfocused Review can therefore let Tab reach the underlying Inspector. This is a real host-routing consequence of the same missing active entry policy, not a claim that an obsolete chooser passes the App's write guard. + +The author confirmed the defect. Resolution: put keyboard routing at the stable root with the exact active form session, explicit first/last entry and boundary policy, and a distinction between a modal-owned Review form and an ordinary Inspector alongside other editable controls. Regression coverage must include an inactive mounted scope. + +The correction must also prevent simultaneous inactive text focus: if modal entry focuses a Review reference trigger while an underlying Inspector text input stays focused, a character key unhandled by that trigger can pass through `Stack`/`opaque` into Inspector. Check retained Inspector raw text after modal entry, reference focus and character input, including chooser dismissal. Correct Tab routing alone does not prove general keyboard isolation. + +### Additional ordering case required by the correction + +An asynchronous Escape/Choose focus Task can be created while Inspector is active, then run after Review or another modal opens. The same Inspector field and generation can remain mounted underneath that modal. A traversal that only proves target existence can still focus the inactive host. Require current root ownership as well as exact field/session/generation before applying return focus. The original stale-focus test replaces the generation, so its target disappears and does not establish this stronger condition. The author has received this concrete test case. + +### K3 — P2: the first focus correction disables a legitimate sibling Review input + +The first correction's `Popup::update` calls `own_input` before every input while Review owns a Candidate form. Its `focus::exclusive` clears every focusable outside that form. This protects Inspector but also clears controls inside the same Review modal. A concrete existing control is `semantic_review/view.rs:333`, the required engineering rationale for explicit SA→OA technical-operation omission. It is mounted above `facts_view`, outside the Candidate detail scope around line 873. + +Minimal falsifier: review such an Exchange occurrence in SA→OA direction, enable operation omission, select a Candidate facet, click its required rationale, then type a character. `own_input` unfocuses the rationale before dispatching the character, so its required text cannot be entered. The first correction's modal fixture contains only the reference trigger, so it cannot detect this regression. Resolution: distinguish the entire owning modal from the narrower field/form scope, clear only background focus, and add a mounted sibling-input regression. This finding was sent to the author and root at correction read-back before acceptance. + +## Source checks with no counterexample found + +- `app/reference_authoring.rs:52–89` checks Inspector versus exact active Review ownership and binds opening to document instance, project identity, document revision, page/layer, draft session, generation and live path. Unrelated modal, hosted, source-dirty, pending and busy states invalidate the chooser. This prevents the keyboard defects above from becoming authority to write an obsolete draft. +- `semantic_review/batch.rs:299–365` requires the current active row and Facts tab, checks the frozen project, rejects unaddressed writes and stops Open/Event messages before normal update/planning. `semantic_review/row.rs:529–545` requires the selected Candidate facet and authorized path roots, then resolves the correct scalar/group/attachment draft. The App validates again before mutation. +- `forms/reference_chooser/adapter.rs` derives candidates from the existing typed `reference_ids`, excludes nil and historical paths, retains invalid/missing/incompatible current text, and rechecks actual eligibility and optionality immediately before a scoped setter. Candidate metadata covers the actual `Project::identities` variants; the chooser does not introduce a second eligibility authority. +- The App's `Outcome::Browsed` branch at `reference_authoring.rs:140–144` restores only transient chooser state; it does not call the Review planner or the draft setter. Only explicit Choose/Clear uses `shape_scoped`, preserving unrelated raw inputs and constructor caches. Apply, accepted Project/source and history remain their existing separate boundary. +- The popup is persistently mounted above the ordinary view/context-menu wrapper and below hosted UI. Browser input is captured; the controller blocks global save/undo/open/new/source-navigation/guide shortcuts while open, and Escape closes the picker before its owning Review. Absolute row actions are invocation/result stamped; relative navigation/query ordering is intentional. Press and release must match the same pointer/action/stamp before a row write. +- The original root measurement traversal matches installed Iced's `Scrollable::operate` call order: each scrollable reports its offset immediately before child traversal. It accumulates nested offsets and viewport clipping with stack restoration. No coordinate contradiction was found by source inspection. The prepared mounted test establishes only a single already-scrolled case when executed; nested/clipped/full-app behavior still needs evidence. + +## Evidence boundaries + +The earlier 13/13 isolated chooser tests passed under root's test-only declaration and do not establish the new host adapter. The first production handoff adds six App tests and three retained-widget tests, initially unexecuted. They cover 1,000-entry eligibility, literal invalid sibling preservation, source/Project/Undo/Redo after Apply, invalid current/Clear, stale contexts, constructor/array changes, Review scalar/group/attachment behavior, banked rows and mounted opening/return/scrolling. The mounted renderer is a null text renderer with a purpose-built form, not the actual complete Inspector/Review view. + +The correction must be compiled and its new focus/reveal tests executed by root, followed by the relevant existing forms/history/Review gates. Native acceptance remains necessary for actual font clipping, long details, 1100×700 layout, nested scrollers, modal priority, keyboard focus, gesture cancellation and measured 1,000-entry query/eligibility latency. This report does not turn authored tests, source reasoning, or the earlier isolated pass into production/native evidence. + +## Corrected-source read-back + +| Finding | Resolution independently inspected | Evidence still required | +| --- | --- | --- | +| K1: clipped focus | `trigger/focus.rs::Collect` retains enclosing scroll geometry, chooses the exact form destination, computes bounded reveal from inner viewport to outer viewport, then applies scrolling and focus. Root measurement runs again before Enter activation. | Execute the prepared fresh offscreen, one/two nested scroll, Enter, and return-after-scrolling-away test. The test starts at zero scroll and checks the reference really begins below the fold. | +| K2: entry, boundaries and ownership | Root Popup receives the current App form session. Fresh Tab/Shift+Tab enter first/last; boundaries cycle. Ordinary Inspector traversal respects another focused control. Exact asynchronous return requires both current root ownership and the original generation-scoped field. Review deactivates background input before ordinary input, including pointer opening and Outside dismissal. | Execute the bidirectional cycles, still-mounted old Inspector under actual Review ownership, character non-leak and Outside/no-focus-task tests. | +| K3: sibling rationale | A separate layout-neutral `trigger::modal` boundary wraps the entire `batch::State::view`, including header, rationale, facts and footer. Ordinary-input cleanup preserves everything inside that modal; Candidate form identity remains the narrower explicit Tab/return scope. | Execute the added SA→OA operation-omission rationale test and confirm the actual full view in native use. | + +The final mounted rationale fixture loads a real operation-bearing SA occurrence, creates an OA counterpart, opens reverse Review, enables omission, and activates a Candidate parent facet. Its rationale is inside the modal but outside the Candidate form. It asserts every character emits the correct addressed rationale event, the final literal is `Engineering reason`, the rationale retains focus, and Candidate recovery, Inspector raw input, SourceBundle and Undo remain unchanged. The Inspector-under-Review and Outside fixtures likewise retain the actual old input and use real Stack/Opaque plus the reference adapter; they do not substitute a no-background modal. + +The corrected host suite contains **14 prepared tests**: six App tests and eight mounted tests. None was run by this reviewer or the author at handoff. The separate earlier isolated **13/13** remains exactly that earlier executed checkpoint. Installed Iced Winit source also confirms that synchronous App updates rebuild user interfaces before returned widget operations (`iced_winit-0.14.0/src/lib.rs` around 1214–1251), supporting the new-generation/root-owner return design; it does not replace runtime acceptance. + +Root can proceed with the compiled integration gates against this corrected candidate. A compile/test failure or native focus/geometry failure must be resolved before treating these findings as execution-closed. No redesign of typed eligibility, raw staging, semantic planning, or source/history contracts is indicated by this review. + +Final correction hashes were independently read from disk and match the author's final freeze. Paths remain relative to `crates/oa-desktop/src`: + +| File | SHA256 | +| --- | --- | +| `forms/reference_chooser/trigger.rs` | `4932CCE196DB20B7EC444C42AB01B3217D9B9AA0B9203D0E7466759B04722552` | +| `forms/reference_chooser/trigger/focus.rs` | `C59FC25096829B86632BE78979F54178D28653FB1E7447E5122FC5C1243456AE` | +| `forms/reference_chooser/widget.rs` | `D380A574F381C0AA6A5CEABC57F5879487623C816C120F2C3ADFE4C7737F03DC` | +| `app/reference_authoring.rs` | `81CFC1057082451C70E12DB3F5DCDE9FEC820F2B101592894B6CBAF132A5013C` | +| `app/reference_authoring/tests/mounted.rs` | `D50F89A1998DD6B37C841D63B33C1F72CD844FEB3CEA2F75C9294D3935416F9E` | +| `semantic_review/batch.rs` | `29F498265FC12C0B33B8186911ECC4D7C7DC806CC36BCBA51862436EAB1E12A1` | + +These identify the inspected source before root formatting/build. They do not identify an executable. The author reports a clean `git diff --check`; the reviewer checked only the new review artifact for whitespace and preserved all frozen reports and production files. diff --git a/harness/release-unified-behavior-canvas-design-20260911.md b/harness/release-unified-behavior-canvas-design-20260911.md new file mode 100644 index 00000000..8ca5d4fd --- /dev/null +++ b/harness/release-unified-behavior-canvas-design-20260911.md @@ -0,0 +1,85 @@ +# Process and scenario canvas interaction decision + +Codex-956atf `/root/behavior_audit`, 2026-09-11. Design-only response to [user issue #2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2), part of #1. Root assigned this report; no production files, Cargo, native session or Git publication are owned here. The current chooser source is frozen. Codex-9e8r6t owns contextual prerequisites (`df6f595` published in issue #1 comment 242); Codex-ytr83r owns the separately coordinated B4 renderer. Core schema work has another active owner. This proposal does not silently acquire those files or infer another host's current execution state. + +## Recommendation and decision boundary + +Use one viewport, camera, selection/focus contract and compact contextual palette for architecture, state, process and scenario canvases. Keep a domain-specific scene and semantic gesture adapter behind that shared shell. A process contribution can be positioned freely without changing precedence; a scenario occurrence moves between discrete ordered slots, with its current operand made visible. Both are direct manipulation. Neither requires navigating a Tools shelf, selecting a tiny margin reorder handle, or using a separate list as the primary editor. + +The smallest coherent first implementation is a **shared viewport/input shell used by both process and scenario**, with stable canvas bounds, pan/zoom, full-object selection and drag previews, a side palette and point-anchored editing. Reuse the existing guarded command proposals. This is an independently useful slice, but it does not complete #2 until authored process placement and lifeline arrangement survive Save/reopen and the engineering workflows below pass. Replacing button rows alone would leave the main interaction problem intact. + +The persistent view contract needs an explicit core-owner decision. Current `ProjectionKind` has no process/scenario owner projection; `Diagram` does already own `NodePlacement` and routes (`oa-core/src/model.rs:1431,1485,1549`). Do not hide new layout in descriptions, abuse unrelated architecture views, or leave accepted visual moves in a desktop-only UUID map. Do not independently select a schema version while schema 8 work is in flight. + +## What the code and recorded native work establish + +| Surface | Actual implementation | Consequence for this decision | +| --- | --- | --- | +| Architecture | `diagram.rs:39,319,523,966`: Fill viewport, inverse world coordinates, ordered pointer events, whole-node movement, connection/reconnection gestures, pan and wheel zoom. `app/architecture_authoring/palette.rs:167` provides a 36/156 px side palette. | Reuse these interaction patterns. Do not copy all App-specific messages or duplicate the architecture renderer. Current wheel zoom is not evidence of cursor-anchored zoom; make that a stated target. | +| State | `behavior/state_view.rs:47,169` mounts a Fill canvas backed by a real diagram; `state_canvas.rs:729` has focus, world coordinates, full-vertex drag, blank/middle pan, transition endpoints and keyboard camera movement. `state_edit.rs:456` proposes `MoveNode`. | Graphical movement and semantic transition change are separate operations already demonstrated in this codebase. State still has some horizontal controls and a modal details form; those are not requirements to perpetuate. | +| Process/chain | `behavior/topology/view.rs:62,347,618` mounts a model-sized Canvas inside a two-axis Scrollable, Tools/Selection shelves and a lower half-height form. `topology/direct.rs:588,600,615` already supports clicked insertion, connect and reconnect. | It is inaccurate to call the current process image entirely static. The user-facing mismatch is the document/shelf workflow and absent authored placement. | +| Process movement | `topology.rs:892–949` derives 420 px rank columns from links/legacy order. `topology/direct.rs:362–381` refuses movement across those columns and issues `SetSteps`; dragging starts only on the selected handle at `:623`. | Rank is an automatic layout choice, not a semantic prohibition on visual placement. Free visual movement must not be implemented as reordered semantic steps. | +| Scenario | `behavior.rs:1214,1717,1798` projects rows, lanes, slots, fragments and real hit targets. `scenario/workspace.rs:68,206` puts that model-sized scene in a Scrollable, with tool selection in a shelf and Lifeline starting from a separate button. | Preserve real scene semantics and exact clicked-slot capture while changing the host/input contract. Lifeline point placement is a remaining direct-authoring gap. | +| Scenario order/layout | `scenario.rs:935` validates and executes `SetSteps` within the existing parent. `behavior.rs:1230` collects lifelines from children; `model.rs:624–634` stores scenario steps and a represented identity but no lane layout. `model.rs:582` stores fragment operator/branches, not covered lifelines or arbitrary time coordinates. | Vertical order and operand membership are semantic. Horizontal lane position and row spacing are presentation. Drawing a wider fragment must not invent coverage, lifetime or execution semantics. | + +These are source observations, not a fresh native acceptance run. The recorded [80cf197 native run](release-scenario-native-20260911.md) passed the four OA clicked-slot creation/history checkpoints, OA reopen, exact caller occurrence/camera return, and SA root Action. It also observed new forms inheriting a prior downward scroll and a captured marker falling below the canvas when the bottom form opened. Root has a separate correction in flight; this design must preserve it until its host is replaced and independently verified. Fit at 21–28% on the minimum window established navigation, not readable editing. The older [process teaching observation](release-teaching-native-20260906.md) found the overview unreadable and less than one column visible at 100%; do not mislabel that older binary as a regression in today's source. + +During this design, root freshly re-observed the immutable 80cf197 SA caller at native 1100 px width: 63 objects/11 checks, saved and no draft. Root reports a center canvas of about 502×340 px, its own horizontal/vertical scrollbars, and Tools/zoom/current-tool/Lifeline/property controls taking space before the lane headers; Inspector showed general Project fields. This is root-attributed native confirmation of the interaction framing, not this worker's native run or acceptance of changed source. It supports replacing ordinary layout chrome immediately without tying passive camera changes to a schema migration. + +The actual [Capella 7.1 architecture study](release-capella-basic-editing-20260907.md) observed tool → point creation, F2 naming and distinct represented work/allocation. The [scenario study](release-capella-scenario-benchmark-20260910.md) observed Activity point placement with a separate InstanceRole and a dragged LOOP with an operand/start/end records. It did **not** exercise wrapping existing messages, cross-operand moves, reuse or lifecycle drawing. Adopt its direct gestures and identity distinctions; its selection-induced canvas jump is a recorded problem to avoid. No new Capella use or web-derived semantic assumption is claimed here. + +## Alternatives considered + +| Approach | Concrete operation | Assessment | +| --- | --- | --- | +| Keep the derived document, decorate it as a canvas | Palette replaces Tools; a node drag still changes `steps` only within a computed rank; opening details still splits the document height. | Small change, but the clicked position is not an authored visual position and process movement still depends on arbitrary columns. Useful interim repair only; insufficient response to #2. | +| Universal freeform diagram editor | Every process/scenario box, arrow endpoint and fragment rectangle has unrestricted x/y; order is inferred later or left independent. | Attractive reuse of architecture editing, but creates contradictory scenario order, accidental operand/lifeline reassignment and misleading fragment coverage. Reconciling two competing sources of order would be costly and unsafe. Reject. | +| Shared canvas shell with domain constraints | Same palette, camera, focus, point creation, full-object selection and drag preview; process movement edits view placement, scenario movement snaps to named semantic slots and lane/header layout. | Recommended. Shared input and rendering infrastructure stays small; each domain retains its existing validation and atomic transaction boundary. | + +## Interaction contract + +**Stable workspace.** Keep the same Fill canvas mounted while arming a tool, selecting an object, typing, showing validation or cancelling. A compact owner/view breadcrumb and camera controls occupy a fixed strip. The side palette is always discoverable, collapsed by default with labels/tooltips and explicitly expandable. Expansion may resize the viewport; other ordinary authoring phases do not. Selection details replace irrelevant general Project fields in the existing Inspector; a small name/reference editor can be anchored at the object. Long structured editing reuses that same Inspector allocation, or an overlaid side drawer if the Inspector is hidden, with bounded scrolling and fixed Apply/Cancel. It must leave a useful part of the canvas visible and must not resize it. There is one draft and one Apply boundary, not a second independent popup copy. Camera input is allowed only outside the editor's bounds; moving the camera updates its anchor or keeps a visible tether, and never changes the captured semantic destination. + +**Point creation.** A selected tool previews exactly what a click will mean. In a process, empty space means an unconnected contribution at that world point; the midpoint of a highlighted path means insertion on that exact path. Node hover must not ambiguously mean either “before” or “inside.” In a scenario, a lifeline tool previews a header at the proposed horizontal position. Action, State, Reuse and Fragment preview a named root/operand slot; Action/State on a lane preselect that lane. Message preview shows source, destination and slot. Use the current typed chooser and prerequisite flow in the anchored editor; no placeholder object is accepted before Enter/Apply. Blank-name defaults may be displayed as suggestions without committing them. + +**Selection and movement.** Clicking a glyph, its label or a connector selects its precise occurrence/placement. Full node bodies initiate movement after a screen-space threshold; small handles remain for explicit connector endpoints and fragment boundaries, not as the only way to reorder. Process movement changes placement only, leaving all links, guards and semantic `steps` byte-for-byte equivalent. Scenario message/action/state/reuse bodies drag vertically to an explicit insertion line. A fragment header drags the entire fragment subtree as one occurrence. Ordinary drag stays in the current operand; crossing its boundary shows an invalid destination and does not silently reparent. Offer an explicit “Move to operand” operation with an exact destination preview for deliberate cross-operand changes. Lane headers drag horizontally as visual arrangement only; no represented identities, bindings or occurrences change. + +**Connections and nesting.** The same click-click and press-drag-release connection grammar applies. Process sequence edges retain their relation ID and guard on reconnection; assembly incidence stays distinct from precedence. A scenario message is an occurrence with message kind/correlation/value/operation fields, not a new sequence link. Reconnecting its endpoint must validate the whole proposed occurrence (and correlated reply constraints) while preserving all unrelated fields; do not silently retarget a call's reply. Fragment tool drag selects a contiguous range of whole siblings within one sequence; wrapping moves those identities into a new operand and replaces their original interval with one fragment in a single transaction. Zero-range click creates the existing empty typed fragment. ALT/PAR provide explicit operand creation/guards at the fragment, and each empty operand has a visible insertion target. A range that slices a child fragment or crosses parents is rejected with visible boundaries. Horizontal drag extent does not assert stored semantic coverage; the frame encloses the required lanes using derived geometry. Lifecycle/activation rendering consumes the separately owned B4 projection unchanged. + +**Camera and keyboard.** Use the same transform for draw, hit, overlays and handles: `screen = viewport_origin + pan + zoom * world`. Middle-drag or Space-drag pans; blank left-drag follows the architecture/state default when no creation/range tool owns it. Wheel zoom and explicit +/−/100%/Fit work only over the focused canvas; pointer zoom keeps the world point under the cursor stationary. Fit is an explicit navigation action and does not re-run merely because selection/editor contents change. Keyboard focus is visible; palette and canvas can be reached from the application without pointer input. F2 renames the selected occurrence; Enter edits it; an explicit Open definition command plus Back restores the exact caller use and camera. Arrow keys follow the current state-canvas camera convention; an explicit modified Move command provides nudge/reorder without confusing navigation and model writes. Delete targets the local occurrence/placement, with the existing impact UI for semantic deletion; deleting a process/scenario use never substitutes deletion of its reusable definition. Remove from view is separately labelled where supported. Text fields own their own editing keys. + +**Cancel, history and recovery.** Escape cancels the active gesture/editor before changing the selected tool. Press snapshots contain document instance, semantic owner, view, revision, tool/gesture epoch and exact occurrence/path/slot; release validates them again. Mode/owner/revision changes, hidden release or window deactivation cannot commit a stale gesture. A successful semantic or authored-layout drag yields one command/history entry on release; pointer movement and camera movement yield none. Preserve accepted Project/source, raw invalid inputs and captured context across recovery; validate restored destination identities before submission. Camera/selection may be restored as transient presentation, but recovery must retain the application's existing explicit limitation that Undo history is not resumed from a recovery snapshot. + +## Persistent presentation and semantic boundaries + +Propose owner-bound process and scenario diagram projections through the existing `Diagram`/placement/source machinery, subject to core-owner review. For an imported model with no view, show a derived canvas immediately; materialize the first authored view together with its first layout edit. Merely opening, panning, fitting or inspecting must not dirty source. Use deterministic automatic placement for items lacking authored positions. Explicit Auto arrange is one undoable layout operation; adding a link does not unexpectedly overwrite hand placement. + +- Process local contributions use placements of their occurrence IDs. Position/size/routes are graphical; topology links determine engineering precedence. Legacy ordered paths retain their admitted meaning; displaying or moving them must not trigger conversion. The existing `preserved_order` conversion remains part of an explicit semantic editing transaction where required, with the original ordered source restored by Undo. +- Scenario lifeline headers use placements of lifeline IDs, with x/width determining visual arrangement. Do not reorder `Scenario.steps` to reorder lanes. Time rows continue to derive from steps and operand nesting. If manual vertical spacing is later needed, define a view-only minimum-gap contract; arbitrary y is not required for the first complete workflow. +- Reused process previews retain the qualified `OccurrenceEndpoint.references + occurrence` identity. Local use-block layout may be edited; nested definition previews are derived/read-only until opened. Do not key two different uses' nested selections or placements by a bare definition occurrence UUID. +- A new view contract must include shared core validation, identity/reference/deletion handling, source grammar and exact round-trip tests, REST command/schema exposure and historical compatibility. Reuse `NodePlacement` only where its fields have a defined meaning; do not invent a partial desktop-only alternative. Source authors must be able to create the same views/layout without the GUI. + +## Minimum complete workflows and falsifiers + +Run the same tasks in OA and SA at **1100×700 client** and a wider window, recording actual viewport/input bounds and preserved before/after snapshots. Existing independent semantic oracles remain authoritative; new visual-layout expectations must be fixed before native execution. + +| Workflow | Required result and falsifier | +| --- | --- | +| Empty process/chain | Create/open an empty owner, arm Work, click a visible point, choose existing work or invoke its owned “create prerequisite” flow, name, Apply. One local contribution appears at that point; no unrelated allocation or definition replacement. Cancel leaves every accepted source byte unchanged. A forced trip through a Tools shelf or an offscreen form fails the workflow. | +| Process structure | Place a second work, control and exchange/reuse, connect two paths, name a guard, reconnect a chosen endpoint and insert on one of two separately guarded links. Verify exact relation IDs, endpoint/guard retention and no accidental link picked by proximity alone. Move a node across former rank columns; only the view placement changes. Repeat on legacy ordered input, preserving original source on Undo. | +| Empty scenario | Create each supported viewpoint, place two lifelines by canvas points, select represented objects with context, reorder headers and Save/reopen. Both IDs and visual order persist; scenario steps and representations do not change merely from lane drag. New source creation uses the incoming contextual prerequisite adapter, not a second implementation. | +| Message and action | Draw source-to-target by both gesture styles; create Action/State at a clicked root and nested-operand slot; edit values/operation and a call/reply where admitted. Compare full body fields and exact before-anchor, parent, sender/receiver, correlation and values. Invalid endpoint edits retain the original occurrence and the editable raw attempt. | +| Nested control | Create empty ALT/PAR/LOOP/OPT, add an operand at its boundary, insert into an empty nested operand, wrap a contiguous existing range, move a whole fragment and explicitly transfer an occurrence to a chosen operand. Verify parent lists, branches, moved IDs and all untouched siblings. Cross-parent drag and partial-subtree wrapping must visibly refuse before any command. Width alone must not change modeled lane participation. | +| Reuse navigation | A→B→C and Back twice, then open the second distinct A→B use. Return selects the exact initiating occurrence, restores the owner/view and camera once, and allows subsequent camera input without snapping back. Renamed uses remain valid; deleted/retargeted uses and document reload invalidate old returns safely. | +| Camera and access | At 50/100/200% and nonzero pan, glyph/label/endpoint hits, click-create, drag and popup anchors agree. Include negative world coordinates, edges, resize and coalesced pointer events. At minimum size, palette, name/first field, required values and Apply/Cancel are reachable with visible focus; Tab reveals offscreen fields. Fit is not used as a substitute for legible 100% editing. | +| Cancel/history/recovery | Escape at every phase; release after pan/tool/owner/revision change; valid and invalid source/property/behavior drafts; delete/Undo/Redo; pending recovery. Accepted model plus complete SourceBundle must match saved pre/Undo/Redo snapshots exactly, including identities and comments. Layout edits have their own exact one-step expectation; camera/selection/search have none. Restored raw invalid values must remain raw and unapplied. | +| Prerequisites and long lists | With no compatible definition, invoke the already-owned staged prerequisite flow in captured canvas context. Create work/exchange/participant only after an explicit choice; no automatic allocation or reassignment. For 1000 repeated names, query and selection show kind/layer/containment/alias and bounded results, preserve the clicked destination, and do not apply while searching. Historical UUID text stays outside live-reference choosing. | + +Mounted tests must feed actual ordered key/pointer batches through the root Iced view, not directly set an internal drag state. Independent App tests assert complete commands, source and raw drafts. Native Windows and supported Linux work remain separate gates, including real-font wrapping, keyboard ownership, touchpad input, popup geometry and usable latency. Screen-reader semantics of custom Canvas glyphs are not established by pointer/keyboard tests: retain a synchronized, keyboard-accessible outline as an alternate representation and measure platform accessibility before claiming it. The outline is optional access, not a required step for ordinary canvas editing. + +## Proposed implementation partitions + +1. **Shared shell experiment for both behavior scenes (desktop lease).** New small `canvas_workspace` camera/focus/gesture-context primitives and palette host; adapt process `topology/view.rs,direct.rs` and scenario `workspace.rs,behavior.rs` to Fill viewport/world coordinates. Use existing Draft and Command adapters. Capture absolute point/slot/path in typed events, present their one existing editor as an anchored surface, retain App pending guards and exact caller-return context. Replace lower split-form and Tools-shelf dependence only after both scenes pass mounted origin/geometry/stale-event and minimum native workflows. Do not require an immediate rewrite of architecture/state or build a generic graph semantic framework. +2. **Authored view/placement contract (core + source + REST owner lease).** Ratify owner-bound projections and placement rules above with schema owner, migrate/import absent views, validate/delete/reference them and add independent exact source fixtures. Then process creation/movement and scenario lane placement use those shared commands. This is necessary before claiming persistent direct canvas editing complete; no fake desktop persistence in partition 1. +3. **Domain manipulation completion (separate bounded desktop/core command lease).** Full-object process drag with view placement; scenario slot drag, message endpoint edit, range-wrap and explicit operand transfer. `oa-core/src/session.rs:67,81,82` already exposes `Reparent`, `SetSteps` and `MoveNode`; reuse those commands where their final batch validates, adding a core helper only to centralize genuine shared semantics. Preserve explicit before/after lists and original IDs in wrapping/transfer tests; do not infer their correctness from the final drawing. Keep B4 rendering and incoming prerequisite drafts intact. Acceptance is the workflow matrix, not the number of tools moved into a palette. + +Root should ratify the interaction and persistent-view boundary with the independent critic before assigning these production leases. This report is ready for that decision; it records no implementation or new execution pass.