diff --git a/README.md b/README.md
index d9eca4a7..94a848e6 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,7 @@ The example opens in an architecture view with work nested inside its performers
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility.
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
-3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Select the port and choose **Connect boundary exchange**; the picker identifies compatible ports by owner, name and alias. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions. Use **Create relationship** for allocations, functional exchanges, capability involvement, mission links, state transitions and interface relationships; **Data & interfaces** holds reusable payloads and contracts.
+3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. State machines contain regions and states/modes with guarded transitions, availability and state-action links. Conditions are descriptive, not executable simulation code. Creating records in Behavior or Data preserves your architecture layouts; **Show in this diagram** adds an explicit placement when desired.
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
@@ -55,13 +55,15 @@ The example opens in an architecture view with work nested inside its performers
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
-Drag cards to move on a 10-unit grid, drag empty space to pan, and scroll to zoom. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
+Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
-Connections start with orthogonal routes that avoid cards and container headers. Select a connection and drag a line segment to add a bend, or choose **Add bend**. Drag its circles to adjust the route; **Remove bend** removes the selected point, and **Reset routing** restores automatic routing. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Typed ports remain attached to their owning elements. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment—automatic routing is bounded and does not guarantee an overlap-free result.
+In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
+
+Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
## Edit the same model as text
-**Textual model** has per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Projects can use relative includes to organize source into files. For example:
+**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
```text
arcadia 3;
diff --git a/crates/oa-core/src/routing.rs b/crates/oa-core/src/routing.rs
index 92fe08b0..5765e4c8 100644
--- a/crates/oa-core/src/routing.rs
+++ b/crates/oa-core/src/routing.rs
@@ -315,9 +315,10 @@ pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView)
}
/// Transient route/label editing with unchanged projected nodes and ports.
-/// Retains other edges from a settled baseline and scores the selected edge
-/// against preceding stable IDs, exactly as a complete solve does. Recompute
-/// the complete view after commit, or whenever node/port geometry changes.
+/// Retains other edges from a settled baseline and scores the selected path
+/// against preceding stable IDs. Labels avoid all currently displayed paths.
+/// Recompute the complete view after commit: later paths and automatic labels
+/// may settle again. Also recompute whenever node/port geometry changes.
pub fn route_edge_preview(
project: &Project,
diagram: &Diagram,
@@ -341,7 +342,6 @@ fn route_diagram_impl(
ordered.sort_by_key(|e| e.id);
let mut edges = BTreeMap::new();
let mut previous = Vec::new();
- let mut previous_labels = Vec::new();
let mut budget = VIEW_SEARCH_BUDGET;
let mut bounds = BoundsBuilder::default();
for node in &view.nodes {
@@ -356,7 +356,7 @@ fn route_diagram_impl(
},
);
}
- for edge in ordered {
+ for edge in &ordered {
if let Some(cached) = preview.and_then(|(baseline, changed)| {
(edge.id != changed)
.then(|| baseline.edges.get(&edge.id))
@@ -365,10 +365,6 @@ fn route_diagram_impl(
for &point in &cached.points {
bounds.point(point);
}
- if !cached.label.text.is_empty() {
- bounds.rect(cached.label.bounds.position, cached.label.bounds.size);
- previous_labels.push(cached.label.bounds);
- }
record_segments(&mut previous, &cached.points);
edges.insert(edge.id, cached.clone());
continue;
@@ -383,19 +379,7 @@ fn route_diagram_impl(
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
- let mut traversable = BTreeSet::new();
- ancestors(edge.source, &nodes, &mut traversable);
- ancestors(edge.target, &nodes, &mut traversable);
- let obstacles: Vec<_> = rectangles
- .iter()
- .map(|r| {
- let mut r = *r;
- if traversable.contains(&r.id) {
- r.bottom = r.bottom.min(r.top + HEADER);
- }
- r.inflate(CLEARANCE)
- })
- .collect();
+ let obstacles = edge_obstacles(edge, &nodes, &rectangles);
let cached = preview.and_then(|(baseline, changed)| {
(edge.id == changed)
.then(|| baseline.edges.get(&edge.id))
@@ -406,24 +390,11 @@ fn route_diagram_impl(
if cached.pins == pins && cached.custom_suspended == custom_suspended {
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
- let mut updated = cached.clone();
- updated.label = make_label(
- project,
- edge.id,
- &updated.points,
- route.and_then(|r| r.label.as_ref()),
- &obstacles,
- &previous_labels,
- );
- for &point in &updated.points {
+ for &point in &cached.points {
bounds.point(point);
}
- if !updated.label.text.is_empty() {
- bounds.rect(updated.label.bounds.position, updated.label.bounds.size);
- previous_labels.push(updated.label.bounds);
- }
- record_segments(&mut previous, &updated.points);
- edges.insert(edge.id, updated);
+ record_segments(&mut previous, &cached.points);
+ edges.insert(edge.id, cached.clone());
continue;
}
}
@@ -458,28 +429,16 @@ fn route_diagram_impl(
.into_iter()
.map(|v| if total > 0.0 { v / total } else { 0.0 })
.collect();
- let label = make_label(
- project,
- edge.id,
- &points,
- route.and_then(|r| r.label.as_ref()),
- &obstacles,
- &previous_labels,
- );
for &point in &points {
bounds.point(point);
}
- if !label.text.is_empty() {
- bounds.rect(label.bounds.position, label.bounds.size);
- previous_labels.push(label.bounds);
- }
record_segments(&mut previous, &points);
edges.insert(
edge.id,
RoutedEdge {
id: edge.id,
points,
- label,
+ label: empty_label(),
pins,
pin_fractions,
warnings,
@@ -488,12 +447,67 @@ fn route_diagram_impl(
},
);
}
+ // Settle all paths before placing text: a later connection can cross an
+ // earlier label even when the labels themselves do not overlap. This pass
+ // never changes paths or authored metadata. Previews retain other cached
+ // labels and avoid the paths actually shown during the gesture.
+ let mut previous_labels = Vec::new();
+ for edge in ordered {
+ let Some(routed) = edges.get_mut(&edge.id) else {
+ continue;
+ };
+ if preview.is_none_or(|(baseline, changed)| {
+ edge.id == changed || !baseline.edges.contains_key(&edge.id)
+ }) {
+ let obstacles = edge_obstacles(edge, &nodes, &rectangles);
+ let authored_label = (!routed.custom_suspended)
+ .then(|| {
+ authored
+ .get(&edge.id)
+ .and_then(|route| route.label.as_ref())
+ })
+ .flatten();
+ routed.label = make_label(
+ project,
+ edge.id,
+ &routed.points,
+ authored_label,
+ &obstacles,
+ &previous_labels,
+ &previous,
+ );
+ }
+ if !routed.label.text.is_empty() {
+ bounds.rect(routed.label.bounds.position, routed.label.bounds.size);
+ previous_labels.push(routed.label.bounds);
+ }
+ }
RoutedView {
edges,
bounds: bounds.finish(),
}
}
+fn edge_obstacles(
+ edge: &ProjectedEdge,
+ nodes: &BTreeMap,
+ rectangles: &[Rect],
+) -> Vec {
+ let mut traversable = BTreeSet::new();
+ ancestors(edge.source, nodes, &mut traversable);
+ ancestors(edge.target, nodes, &mut traversable);
+ rectangles
+ .iter()
+ .map(|r| {
+ let mut r = *r;
+ if traversable.contains(&r.id) {
+ r.bottom = r.bottom.min(r.top + HEADER);
+ }
+ r.inflate(CLEARANCE)
+ })
+ .collect()
+}
+
#[derive(Clone, Copy)]
struct Segment {
a: Position,
@@ -1017,6 +1031,22 @@ fn label_text(project: &Project, edge: Id) -> String {
text.to_owned()
}
}
+fn empty_label() -> EdgeLabelGeometry {
+ EdgeLabelGeometry {
+ text: String::new(),
+ fraction: 0.5,
+ anchor: pos(0.0, 0.0),
+ center: pos(0.0, 0.0),
+ bounds: RoutingBounds {
+ position: pos(0.0, 0.0),
+ size: Size {
+ width: 0.0,
+ height: 0.0,
+ },
+ },
+ }
+}
+
fn make_label(
project: &Project,
edge: Id,
@@ -1024,6 +1054,7 @@ fn make_label(
authored: Option<&crate::EdgeLabel>,
obstacles: &[Rect],
previous_labels: &[RoutingBounds],
+ strokes: &[Segment],
) -> EdgeLabelGeometry {
let text = label_text(project, edge);
let size = Size {
@@ -1048,8 +1079,8 @@ fn make_label(
)
};
let mut fraction = authored.map_or(0.5, |l| l.fraction);
- // Keep authored placement exact. Automatic labels share the stable-ID
- // prefix used for route scoring, so previews and settled solves agree.
+ // Keep authored placement exact. Automatic labels avoid all visible
+ // strokes, with earlier stable-ID labels taking precedence.
if authored.is_none() && !text.is_empty() {
let total = total_length(points);
let mut traversed = 0.0;
@@ -1093,6 +1124,7 @@ fn make_label(
candidate,
obstacles,
previous_labels,
+ strokes,
best.as_ref().map(|(score, _, _)| *score),
&mut comparisons,
) else {
@@ -1101,7 +1133,7 @@ fn make_label(
if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
best = Some((score, candidate_fraction, candidate_offset));
}
- if score == (0, 0) {
+ if score == (0, 0, 0) {
break 'candidate;
}
}
@@ -1125,9 +1157,10 @@ fn label_collisions(
bounds: RoutingBounds,
obstacles: &[Rect],
labels: &[RoutingBounds],
- best: Option<(usize, usize)>,
+ strokes: &[Segment],
+ best: Option<(usize, usize, usize)>,
comparisons: &mut usize,
-) -> Option<(usize, usize)> {
+) -> Option<(usize, usize, usize)> {
let mut nodes = 0;
for obstacle in obstacles {
if *comparisons == 0 {
@@ -1138,7 +1171,7 @@ fn label_collisions(
nodes += 1;
}
if best.is_some_and(|score| nodes > score.0) {
- return Some((nodes, usize::MAX));
+ return Some((nodes, usize::MAX, usize::MAX));
}
}
// Once a candidate is worse than the current best, additional collisions
@@ -1160,11 +1193,35 @@ fn label_collisions(
{
collisions += 1;
if collisions >= label_limit {
+ return Some((nodes, collisions, usize::MAX));
+ }
+ }
+ }
+ let text = Rect {
+ id: Id::nil(),
+ left: bounds.position.x,
+ top: bounds.position.y,
+ right: bounds.position.x + bounds.size.width,
+ bottom: bounds.position.y + bounds.size.height,
+ }
+ .inflate(3.0);
+ let stroke_limit = best
+ .filter(|score| (nodes, collisions) == (score.0, score.1))
+ .map_or(usize::MAX, |score| score.2.saturating_add(1));
+ let mut crossings = 0;
+ for stroke in strokes {
+ if *comparisons == 0 {
+ return None;
+ }
+ *comparisons -= 1;
+ if text.crosses(stroke.a, stroke.b) {
+ crossings += 1;
+ if crossings >= stroke_limit {
break;
}
}
}
- Some((nodes, collisions))
+ Some((nodes, collisions, crossings))
}
#[derive(Default)]
diff --git a/crates/oa-core/tests/routing_labels.rs b/crates/oa-core/tests/routing_labels.rs
new file mode 100644
index 00000000..a6299a5e
--- /dev/null
+++ b/crates/oa-core/tests/routing_labels.rs
@@ -0,0 +1,152 @@
+use oa_core::{projection::project_diagram, routing::*, *};
+
+fn crosses(bounds: RoutingBounds, a: Position, b: Position) -> bool {
+ let left = bounds.position.x;
+ let top = bounds.position.y;
+ let right = left + bounds.size.width;
+ let bottom = top + bounds.size.height;
+ if a.x == b.x {
+ a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top
+ } else {
+ a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left
+ }
+}
+
+fn fixture(functional_first: bool) -> (Project, Id, Id, Id) {
+ let mut project = Project::demo();
+ let diagram = project
+ .diagrams
+ .values()
+ .find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
+ .unwrap()
+ .id;
+ // Freeze the observed default arrangement independently of random demo
+ // identities. The two parallel border connections are only 20 units apart.
+ for node in project
+ .diagrams
+ .get_mut(&diagram)
+ .unwrap()
+ .nodes
+ .values_mut()
+ {
+ let (x, y, width, height) = match project.elements[&node.element].alias.as_str() {
+ "vessel_communication_service" => (60., 60., 272., 220.),
+ "rescue_coordination_system" => (572., 60., 272., 220.),
+ "transmit_distress_report" => (84., 124., 224., 132.),
+ "classify_incident_and_alert_operator" => (596., 124., 224., 132.),
+ alias => panic!("Unexpected demo placement {alias}"),
+ };
+ node.position = Position { x, y };
+ node.size = Size { width, height };
+ }
+ let old_fe = project
+ .relations
+ .values()
+ .find(|r| r.alias == "validated_distress_report")
+ .unwrap()
+ .id;
+ let old_boundary = project
+ .relations
+ .values()
+ .find(|r| r.alias == "distress_reporting_connection")
+ .unwrap()
+ .id;
+ let fe = Id::from_u128(if functional_first { 1 } else { 2 });
+ let boundary = Id::from_u128(if functional_first { 2 } else { 1 });
+ let project = execute_batch(
+ &project,
+ &[
+ Command::Reidentify(old_fe, fe),
+ Command::Reidentify(old_boundary, boundary),
+ ],
+ )
+ .unwrap();
+ (project, diagram, fe, boundary)
+}
+
+fn stroke_collisions(project: &Project, routed: &RoutedView) -> Vec {
+ let mut collisions = Vec::new();
+ for edge in routed.edges.values().filter(|e| !e.label.text.is_empty()) {
+ for other in routed.edges.values().filter(|e| e.id != edge.id) {
+ for segment in other.points.windows(2) {
+ if crosses(edge.label.bounds, segment[0], segment[1]) {
+ collisions.push(format!(
+ "{} ({}) label {:?} crosses {} ({}) segment {:?}",
+ edge.label.text,
+ edge.id,
+ edge.label.bounds,
+ project.named(other.id),
+ other.id,
+ segment
+ ));
+ }
+ }
+ }
+ }
+ collisions
+}
+
+#[test]
+fn demo_architecture_labels_do_not_cover_earlier_or_later_connection_strokes() {
+ for functional_first in [true, false] {
+ let (project, diagram, _, _) = fixture(functional_first);
+ let before = project.clone();
+ let d = &project.diagrams[&diagram];
+ let view = project_diagram(&project, d);
+ let routed = route_diagram(&project, d, &view);
+ let collisions = stroke_collisions(&project, &routed);
+ assert!(collisions.is_empty(), "{collisions:#?}");
+ assert_eq!(project, before);
+ assert_eq!(routed, route_diagram(&project, d, &view));
+ }
+}
+
+#[test]
+fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
+ let (mut project, diagram, fe, _) = fixture(true);
+ let view = project_diagram(&project, &project.diagrams[&diagram]);
+ let automatic = route_diagram(&project, &project.diagrams[&diagram], &view);
+ let authored = EdgeLabel {
+ fraction: 0.5,
+ offset: Position { x: 0., y: -18. },
+ };
+ project
+ .diagrams
+ .get_mut(&diagram)
+ .unwrap()
+ .routes
+ .push(EdgeRoute {
+ edge: fe,
+ waypoints: vec![],
+ label: Some(authored.clone()),
+ });
+ let authored_snapshot = project.clone();
+ let explicit = route_diagram(&project, &project.diagrams[&diagram], &view);
+ let label = &explicit.edges[&fe].label;
+ assert_eq!(label.fraction, authored.fraction);
+ assert_eq!(
+ label.center,
+ Position {
+ x: label.anchor.x + authored.offset.x,
+ y: label.anchor.y + authored.offset.y
+ }
+ );
+ assert!(
+ !stroke_collisions(&project, &explicit).is_empty(),
+ "The authored label deliberately retains the native overlap"
+ );
+ assert_eq!(project, authored_snapshot);
+ for (&id, edge) in &automatic.edges {
+ assert_eq!(edge.points, explicit.edges[&id].points);
+ assert_eq!(edge.pins, explicit.edges[&id].pins);
+ assert_eq!(edge.warnings, explicit.edges[&id].warnings);
+ }
+ project.diagrams.get_mut(&diagram).unwrap().routes[0].label = None;
+ let reset = route_edge_preview(&project, &project.diagrams[&diagram], &view, &explicit, fe);
+ assert_eq!(reset.edges[&fe], automatic.edges[&fe]);
+ for (&id, edge) in &explicit.edges {
+ if id != fe {
+ assert_eq!(reset.edges[&id], *edge);
+ }
+ }
+}
diff --git a/crates/oa-desktop/src/app.rs b/crates/oa-desktop/src/app.rs
index 36491d56..cc86954d 100644
--- a/crates/oa-desktop/src/app.rs
+++ b/crates/oa-desktop/src/app.rs
@@ -82,6 +82,13 @@ pub struct FilePrompt {
#[derive(Debug, Clone)]
pub enum Message {
+ PortAnchor(crate::port_edit::Event),
+ Connection(crate::connection_edit::Event),
+ Menu(crate::model_menu::Event),
+ CreateContextElement(Kind, bool),
+ SourceTree(crate::source_tree::Event),
+ SourceTreeCollapse,
+ SourceTreeLocate,
ExactStrings(bool),
Page(Page),
Guide(crate::guide::Event),
@@ -176,6 +183,10 @@ pub enum Message {
}
pub struct App {
+ pub connection: Option,
+ pub model_menu: Option,
+ pub source_tree: crate::source_tree::State,
+ menu_sequence: u64,
pub canvas_document: Id,
pub canvas_epoch: u64,
pub selected_bend: Option<(Id, usize)>,
@@ -260,6 +271,10 @@ impl App {
let explorer_index = crate::explorer::Index::new(&project, layer);
let explorer_collapsed = project.elements.values().filter_map(|e| e.parent).collect();
Self {
+ connection: None,
+ model_menu: None,
+ source_tree: Default::default(),
+ menu_sequence: 0,
canvas_document: Id::new_v4(),
canvas_epoch: 0,
selected_bend: None,
@@ -346,6 +361,7 @@ impl App {
self.lint_requested = None;
}
fn show_source_file(&mut self, restore_cursor: bool) {
+ self.source_tree.reveal(&self.source_workspace.active);
self.source_select_line = false;
let source = self.source_workspace.text().to_owned();
self.source_navigation.refresh(&source);
@@ -465,7 +481,10 @@ impl App {
.and_then(|d| Ok((d.command()?, d.resulting_id()?)));
match result.and_then(|(command, id)| {
self.document
- .execute(command)
+ .execute(crate::port_edit::repair_owner_change(
+ self.document.project(),
+ command,
+ ))
.map(|_| id)
.map_err(|e| e.to_string())
}) {
@@ -505,6 +524,7 @@ impl App {
}
}
fn execute(&mut self, command: Command) -> bool {
+ let command = crate::port_edit::repair_owner_change(self.project(), command);
match self.document.execute(command) {
Ok(selected) => {
if let Some(id) = selected {
@@ -577,13 +597,14 @@ impl App {
}
}
pub fn view(&self) -> Element<'_, Message> {
- crate::views::view(self)
+ crate::context_menu::Popup::new(crate::views::view(self), crate::model_menu::popup(self))
+ .into()
}
pub fn subscription(&self) -> Subscription {
Subscription::batch([
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
- iced::event::listen_with(|event, _, _| {
+ iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers: mods,
@@ -593,6 +614,9 @@ impl App {
return None;
};
if key == keyboard::Key::Named(keyboard::key::Named::Escape) {
+ if status == iced::event::Status::Captured {
+ return None;
+ }
return Some(Message::Escape);
}
if key == keyboard::Key::Named(keyboard::key::Named::F1) {
@@ -624,6 +648,32 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task {
+ // Global subscriptions also receive keys consumed by an overlay.
+ if self.model_menu.is_some() && matches!(message, Message::Escape) {
+ self.model_menu = None;
+ self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
+ return Task::none();
+ }
+ if self.model_menu.is_some()
+ && !matches!(
+ message,
+ Message::Menu(_)
+ | Message::WindowResized(_)
+ | Message::CanvasResized(_)
+ | Message::Lint(_)
+ | Message::Linted(_, _)
+ | Message::SourceViewport
+ | Message::Request(Pending::Close(_))
+ )
+ {
+ return Task::none();
+ }
+ if matches!(
+ message,
+ Message::WindowResized(_) | Message::Request(Pending::Close(_))
+ ) {
+ self.model_menu = None;
+ }
if self.source_dirty()
&& matches!(
message,
@@ -723,6 +773,14 @@ impl App {
let entering_catalog =
self.page != Page::Catalog && matches!(message, Message::Page(Page::Catalog));
let mut task = self.handle(message);
+ if self.connection.as_ref().is_some_and(|session| {
+ Some(session.context) != self.route_context()
+ || self.page != Page::Diagram
+ || self.draft_dirty()
+ || self.source_dirty()
+ }) {
+ self.connection = None;
+ }
if previous_layer != self.layer {
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
@@ -758,7 +816,135 @@ impl App {
}
fn handle(&mut self, message: Message) -> Task {
match message {
+ Message::PortAnchor(event) => {
+ use crate::port_edit::Event;
+ let (captured, node, port, command) = match event {
+ Event::Commit(c, node, anchor) => (
+ c,
+ node,
+ anchor.port,
+ Command::SetPortAnchor {
+ diagram: c.diagram,
+ node,
+ anchor,
+ },
+ ),
+ Event::Reset(c, node, port) => (
+ c,
+ node,
+ port,
+ Command::RemovePortAnchor {
+ diagram: c.diagram,
+ node,
+ port,
+ },
+ ),
+ };
+ if self.route_context() != Some(captured)
+ || self.page != Page::Diagram
+ || self.draft_dirty()
+ || self.source_dirty()
+ || self.connection.is_some()
+ {
+ return Task::none();
+ }
+ let view = project_view(self.project(), captured.diagram);
+ if !view
+ .ports
+ .iter()
+ .any(|p| p.element == port && p.owner == node)
+ {
+ return Task::none();
+ }
+ if self.execute(command) {
+ self.selected_node = None;
+ self.status = "Port position updated in this diagram · Undo restores its previous position".into();
+ }
+ }
+ Message::Connection(event) => self.edit_connection(event),
+ Message::SourceTree(event) => match event {
+ crate::source_tree::Event::ToggleFolder(path) => self.source_tree.toggle(&path),
+ crate::source_tree::Event::OpenFile(path) => {
+ return self.update(Message::SourceFile(path));
+ }
+ crate::source_tree::Event::FileMenu(path, anchor) => {
+ let mut request = crate::model_menu::Request::object(self.project().id, anchor);
+ request.source_file = Some(path);
+ return self.update(Message::Menu(crate::model_menu::Event::Open(request)));
+ }
+ },
+ Message::SourceTreeCollapse => self.source_tree.collapse_all(),
+ Message::SourceTreeLocate => {
+ self.source_tree.reveal(&self.source_workspace.active);
+ return iced::widget::operation::scroll_to(
+ "source-files",
+ crate::source_tree::active_offset(
+ &self.source_workspace,
+ &self.source_diagnostics,
+ &self.source_tree,
+ ),
+ );
+ }
+ Message::Menu(crate::model_menu::Event::Open(request)) => {
+ // Validate the originating canvas before advancing its input
+ // epoch. Actions are then generated with the refreshed context,
+ // while pointer history hidden behind the menu becomes stale.
+ let Some(opened) = crate::model_menu::Opened::new(
+ self,
+ request,
+ self.menu_sequence.wrapping_add(1),
+ ) else {
+ return Task::none();
+ };
+ self.connection = None;
+ self.menu_sequence = opened.token;
+ self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
+ let mut request = opened.request;
+ if request.canvas.is_some() {
+ request.canvas = self.route_context();
+ }
+ self.model_menu = crate::model_menu::Opened::new(self, request, self.menu_sequence);
+ }
+ Message::Menu(crate::model_menu::Event::Close) => {
+ if self.model_menu.take().is_some() {
+ self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
+ }
+ }
+ Message::Menu(crate::model_menu::Event::Invoke(token, action)) => {
+ if self
+ .model_menu
+ .as_ref()
+ .is_none_or(|menu| menu.token != token)
+ {
+ return Task::none();
+ }
+ let Some(menu) = self.model_menu.take() else {
+ return Task::none();
+ };
+ if menu.token != token || !menu.valid(self) {
+ return Task::none();
+ }
+ if menu.request.source_file.is_some() {
+ return self.update(*action);
+ }
+ if self.draft_dirty() || self.source_dirty() {
+ self.status =
+ "Apply or revert pending edits before using modeling actions.".into();
+ return Task::none();
+ }
+ self.select(menu.request.object);
+ self.selected_node = menu.request.placement;
+ self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
+ return self.update(*action);
+ }
+ Message::CreateContextElement(kind, child) => {
+ self.create_kind = kind;
+ return self.update(Message::Add(child));
+ }
Message::Escape => {
+ if self.connection.take().is_some() {
+ self.status = "Drawing cancelled · Select / move is active".into();
+ }
self.source_navigation.open = false;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
@@ -1315,6 +1501,7 @@ impl App {
if self.apply()
&& let Some(diagram) = self.active_diagram
{
+ let selected = self.selected;
if let Some(node) = self
.project()
.diagrams
@@ -1324,7 +1511,7 @@ impl App {
{
self.selected_node = Some(node);
self.status = "Element is already shown in this diagram".into();
- return Task::none();
+ return self.handle(Message::RevealInDiagram(diagram, selected));
}
let mut p = self.project().clone();
let node = p.place(diagram, self.selected, Position { x: 60., y: 60. });
@@ -1333,9 +1520,24 @@ impl App {
.get(&diagram)
.and_then(|d| d.nodes.get(&node))
.cloned()
- && self.execute(Command::CreateNode { diagram, node: n })
{
- self.selected_node = Some(node);
+ let mut commands = vec![Command::CreateNode { diagram, node: n }];
+ for owner in p.diagrams[&diagram].nodes.values().filter(|n| {
+ n.collapsed
+ && projection::descendant_nodes(&p, diagram, n.id).contains(&node)
+ }) {
+ let mut expanded = owner.clone();
+ expanded.collapsed = false;
+ commands.push(Command::EditNode {
+ diagram,
+ original: owner.id,
+ node: expanded,
+ });
+ }
+ if self.execute(Command::Batch(commands)) {
+ self.selected_node = Some(node);
+ return self.handle(Message::RevealInDiagram(diagram, selected));
+ }
}
}
}
@@ -2356,8 +2558,111 @@ impl App {
epoch: self.canvas_epoch,
})
}
+ fn edit_connection(&mut self, event: crate::connection_edit::Event) {
+ use crate::connection_edit::{Event, Session};
+ let Some(context) = self.route_context() else {
+ return;
+ };
+ if matches!(event, Event::Choose(None)) {
+ self.connection = None;
+ self.status =
+ "Select · drag elements, ports and connection routes to arrange the diagram".into();
+ return;
+ }
+ if self.page != Page::Diagram || self.source_dirty() || self.draft_dirty() {
+ self.status = "Apply or revert pending edits before drawing a connection.".into();
+ return;
+ }
+ match event {
+ Event::Begin(tool, source) => {
+ self.edit_connection(Event::Choose(Some(tool)));
+ if let Some(context) = self.route_context() {
+ self.edit_connection(Event::Endpoint(context, source));
+ }
+ }
+ Event::Choose(Some(tool)) => {
+ if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
+ return;
+ }
+ self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
+ let context = self.route_context().unwrap();
+ self.connection = Some(Session::new(
+ self.project(),
+ &project_view(self.project(), context.diagram),
+ context,
+ tool,
+ ));
+ self.selected_bend = None;
+ self.status = tool.hint().into();
+ }
+ Event::Endpoint(captured, id) => {
+ if captured != context {
+ return;
+ }
+ let Some(mut session) = self.connection.take() else {
+ return;
+ };
+ if session.context != context {
+ return;
+ }
+ if !session.candidates.contains(&id) {
+ self.status =
+ "Choose a highlighted compatible endpoint · Esc cancels drawing".into();
+ self.connection = Some(session);
+ return;
+ }
+ if let Some(source) = session.source {
+ match session.tool.command(self.project(), source, id) {
+ Ok(command) => {
+ if self.execute(command) {
+ self.selected_node = None;
+ self.inspector_tab = Default::default();
+ self.status = format!(
+ "{} created · edit its name and carried items in Properties · Undo restores the complete operation",
+ session.tool.label()
+ );
+ } else {
+ self.connection = Some(session);
+ }
+ }
+ Err(error) => {
+ self.status = error.to_string();
+ self.connection = Some(session);
+ }
+ }
+ } else {
+ session.start(
+ self.project(),
+ &project_view(self.project(), context.diagram),
+ id,
+ );
+ self.select(id);
+ self.status = if session.candidates.is_empty() {
+ "No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
+ } else {
+ format!(
+ "From {} · choose a highlighted target · Esc cancels",
+ self.project().named(id)
+ )
+ };
+ self.connection = Some(session);
+ }
+ }
+ Event::Restart(captured) => {
+ if captured == context
+ && let Some(session) = &self.connection
+ {
+ self.edit_connection(Event::Choose(Some(session.tool)));
+ }
+ }
+ Event::Choose(None) => {}
+ }
+ }
fn edit_route(&mut self, event: crate::route_edit::Event) {
use crate::route_edit::{Event, insert_pin, stored_route};
+ if self.connection.is_some() {
+ return;
+ }
let Some(context) = self.route_context() else {
return;
};
@@ -2402,7 +2707,7 @@ impl App {
route,
}
}
- Event::AddBend => {
+ Event::AddBend | Event::AddBendAt(_) => {
if route.waypoints.len() >= 128 {
return;
}
@@ -2421,7 +2726,12 @@ impl App {
|s: &[Position]| (s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs();
len(a).total_cmp(&len(b))
});
- let point = if let Some(segment) = segment {
+ let point = if let Event::AddBendAt(point) = event {
+ if !point.x.is_finite() || !point.y.is_finite() {
+ return;
+ }
+ point
+ } else if let Some(segment) = segment {
Position {
x: (segment[0].x + segment[1].x) / 2.,
y: (segment[0].y + segment[1].y) / 2.,
diff --git a/crates/oa-desktop/src/connection_edit.rs b/crates/oa-desktop/src/connection_edit.rs
new file mode 100644
index 00000000..bd627fae
--- /dev/null
+++ b/crates/oa-desktop/src/connection_edit.rs
@@ -0,0 +1,142 @@
+//! A palette operation is transient; only a completed connection enters history.
+use crate::{connection_tool::Tool, route_edit::Context};
+use oa_core::{Id, Project, projection::ProjectedView};
+use std::collections::BTreeSet;
+
+#[derive(Debug, Clone)]
+pub enum Event {
+ Choose(Option),
+ Begin(Tool, Id),
+ Endpoint(Context, Id),
+ Restart(Context),
+}
+
+#[derive(Debug, Clone)]
+pub struct Session {
+ pub context: Context,
+ pub tool: Tool,
+ pub source: Option,
+ pub candidates: BTreeSet,
+}
+
+impl Session {
+ pub fn new(project: &Project, view: &ProjectedView, context: Context, tool: Tool) -> Self {
+ let candidates = visible(view)
+ .filter(|id| tool.can_start(project, *id))
+ .collect();
+ Self {
+ context,
+ tool,
+ source: None,
+ candidates,
+ }
+ }
+
+ pub fn start(&mut self, project: &Project, view: &ProjectedView, source: Id) {
+ self.source = Some(source);
+ let compatible = self.tool.targets(project, source);
+ self.candidates = visible(view).filter(|id| compatible.contains(id)).collect();
+ }
+}
+
+pub fn visible(view: &ProjectedView) -> impl Iterator + '_ {
+ view.nodes
+ .iter()
+ .map(|n| n.element)
+ .chain(view.ports.iter().map(|p| p.element))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::app::{App, Message, Page};
+ use oa_core::*;
+
+ fn fixture() -> (App, Id, Id, Id) {
+ let mut p = Project::empty();
+ let a = p.add(Kind::Function, "Send command", 0., 0.);
+ let b = p.add(Kind::Function, "Receive command", 400., 0.);
+ let hidden = p
+ .add_element(Kind::Function, Layer::Sa, "Hidden work", None)
+ .unwrap();
+ (App::with_project(p), a, b, hidden)
+ }
+ fn send(app: &mut App, event: Event) {
+ let _ = app.update(Message::Connection(event));
+ }
+
+ #[test]
+ fn visible_endpoint_drawing_commits_ports_and_exchange_once_with_exact_history() {
+ let (mut app, a, b, hidden) = fixture();
+ let before = app.document.bundle().clone();
+ let revision = app.document.revision();
+ send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
+ assert!(
+ !app.connection
+ .as_ref()
+ .unwrap()
+ .candidates
+ .contains(&hidden)
+ );
+ let context = app.route_context().unwrap();
+ send(&mut app, Event::Endpoint(context, a));
+ send(&mut app, Event::Endpoint(context, hidden));
+ assert_eq!(app.document.revision(), revision);
+ assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
+ send(&mut app, Event::Endpoint(context, b));
+ assert!(app.connection.is_none());
+ assert_eq!(app.document.revision(), revision + 1);
+ let relation = &app.project().relations[&app.selected];
+ assert_eq!(relation.kind(), RelationKind::FunctionalExchange);
+ assert_eq!((relation.source(), relation.target()), (a, b));
+ assert_eq!(
+ app.project()
+ .elements
+ .values()
+ .filter(|e| e.kind() == Kind::Port)
+ .count(),
+ 2
+ );
+ let after = app.document.bundle().clone();
+ let _ = app.update(Message::Undo);
+ assert_eq!(*app.document.bundle(), before);
+ let _ = app.update(Message::Redo);
+ assert_eq!(*app.document.bundle(), after);
+ }
+
+ #[test]
+ fn cancel_navigation_revision_and_dirty_drafts_cannot_commit_a_stale_connection() {
+ let (mut app, a, b, _) = fixture();
+ send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
+ let context = app.route_context().unwrap();
+ send(&mut app, Event::Endpoint(context, a));
+ let _ = app.update(Message::Escape);
+ send(&mut app, Event::Endpoint(context, b));
+ assert!(app.project().relations.is_empty());
+ send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
+ let current = app.route_context().unwrap();
+ send(&mut app, Event::Endpoint(current, a));
+ send(&mut app, Event::Endpoint(context, b));
+ assert_eq!(
+ app.connection.as_ref().unwrap().source,
+ Some(a),
+ "A stale click must leave the newer drawing session intact"
+ );
+ let revision = app.document.revision();
+ let _ = app.update(Message::Route(crate::route_edit::Event::AddBend));
+ assert_eq!(app.document.revision(), revision);
+ assert!(app.connection.is_some());
+ let _ = app.update(Message::Page(Page::Catalog));
+ assert!(app.connection.is_none());
+ let _ = app.update(Message::Page(Page::Diagram));
+ send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
+ send(&mut app, Event::Endpoint(context, b));
+ assert!(app.project().relations.is_empty());
+ let _ = app.update(Message::Select(a));
+ let _ = app.update(Message::Field("/name".into(), "Pending rename".into()));
+ assert!(app.draft_dirty());
+ send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
+ assert!(app.connection.is_none());
+ assert_eq!(app.project().named(a), "Send command");
+ }
+}
diff --git a/crates/oa-desktop/src/connection_tool.rs b/crates/oa-desktop/src/connection_tool.rs
new file mode 100644
index 00000000..7493df8a
--- /dev/null
+++ b/crates/oa-desktop/src/connection_tool.rs
@@ -0,0 +1,609 @@
+//! Semantic endpoints for direct canvas connections. Gesture state and visible
+//! hit targets belong to the canvas; ordinary core commands own model changes.
+use oa_core::*;
+use std::collections::BTreeSet;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Tool {
+ Interaction,
+ FunctionalExchange,
+ BoundaryExchange,
+}
+
+impl Tool {
+ pub fn for_layer(layer: Layer) -> &'static [Self] {
+ match layer {
+ Layer::Oa => &[Self::Interaction],
+ Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
+ }
+ }
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::Interaction => "Interaction",
+ Self::FunctionalExchange => "Functional exchange",
+ Self::BoundaryExchange => "Boundary exchange",
+ }
+ }
+
+ pub fn hint(self) -> &'static str {
+ match self {
+ Self::Interaction => "Click a source activity, then a target activity.",
+ Self::FunctionalExchange => {
+ "Click an output port or source function, then an input port or target function. Function cards use automatic ports."
+ }
+ Self::BoundaryExchange => {
+ "Click a source component port, then a port on another participant. Flow goes from Out to In; Standard is unoriented."
+ }
+ }
+ }
+
+ pub fn can_start(self, project: &Project, source: Id) -> bool {
+ match self {
+ Self::Interaction => project
+ .elements
+ .get(&source)
+ .is_some_and(|e| e.layer() == Layer::Oa && e.kind() == Kind::Activity),
+ Self::FunctionalExchange => function_endpoint(project, source, true).is_some(),
+ Self::BoundaryExchange => component_endpoint(project, source)
+ .is_some_and(|contract| component_allows(contract, true)),
+ }
+ }
+
+ /// Cheap feasibility for highlighting candidates. The caller intersects this
+ /// with the actual projected nodes and ports; hidden objects are not hits.
+ pub fn can_connect(self, project: &Project, source: Id, target: Id) -> bool {
+ if !self.can_start(project, source) {
+ return false;
+ }
+ match self {
+ Self::Interaction => {
+ validation::can_connect(project, RelationKind::Interaction, source, target)
+ }
+ Self::FunctionalExchange => {
+ let (Some((source, _)), Some((target, _))) = (
+ function_endpoint(project, source, true),
+ function_endpoint(project, target, false),
+ ) else {
+ return false;
+ };
+ validation::can_connect(project, RelationKind::FunctionalExchange, source, target)
+ }
+ Self::BoundaryExchange => {
+ component_endpoint(project, target).is_some()
+ && validation::can_connect_boundary(
+ project,
+ source,
+ target,
+ boundary_oriented(project, source),
+ )
+ }
+ }
+ }
+
+ pub fn targets(self, project: &Project, source: Id) -> BTreeSet {
+ if !self.can_start(project, source) {
+ return BTreeSet::new();
+ }
+ project
+ .elements
+ .keys()
+ .copied()
+ .filter(|target| self.can_connect(project, source, *target))
+ .collect()
+ }
+
+ /// Return one validated history operation without modifying the input.
+ /// FE endpoint generation uses the same atomic planner as the form workflow.
+ /// No carried items are inferred from a port's allowed item declarations.
+ pub fn command(self, project: &Project, source: Id, target: Id) -> Result {
+ if !self.can_connect(project, source, target) {
+ return Err(ModelError(format!(
+ "Choose compatible endpoints for {}. {}",
+ self.label(),
+ self.hint()
+ )));
+ }
+ let id = Id::new_v4();
+ if self == Self::FunctionalExchange {
+ let (source, source_port) = function_endpoint(project, source, true)
+ .expect("Functional source was checked above");
+ let (target, target_port) = function_endpoint(project, target, false)
+ .expect("Functional target was checked above");
+ let name = engineering_name(project, self, source, target);
+ return plan_functional_exchange(
+ project,
+ &FunctionalExchangeRequest {
+ id,
+ alias: project.fresh_alias(&name, "exchange", id),
+ name,
+ description: String::new(),
+ source,
+ target,
+ source_port,
+ target_port,
+ items: Vec::new(),
+ },
+ )
+ .map(Command::Batch);
+ }
+ let name = engineering_name(project, self, source, target);
+ let body = match self {
+ Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
+ Self::BoundaryExchange => RelationBody::BoundaryExchange {
+ source,
+ target,
+ oriented: boundary_oriented(project, source),
+ items: Vec::new(),
+ },
+ Self::FunctionalExchange => unreachable!("Functional planner returned above"),
+ };
+ let command = Command::CreateRelation(Relation {
+ id,
+ alias: project.fresh_alias(&name, "relation", id),
+ name,
+ description: String::new(),
+ body,
+ });
+ prepare_command(project, &command)?;
+ Ok(command)
+ }
+}
+
+/// A clicked port determines its own function owner. It cannot be paired with
+/// a different remembered owner or silently substituted by automatic selection.
+fn function_endpoint(project: &Project, id: Id, outgoing: bool) -> Option<(Id, PortChoice)> {
+ let element = project.elements.get(&id)?;
+ if element.layer() != Layer::Sa {
+ return None;
+ }
+ if element.kind() == Kind::Function {
+ return Some((id, PortChoice::Auto));
+ }
+ match (port_contract(element)?, outgoing) {
+ (PortContract::FunctionOutput { .. }, true)
+ | (PortContract::FunctionInput { .. }, false) => {}
+ _ => return None,
+ }
+ let owner = project.elements.get(&element.parent?)?;
+ (owner.layer() == Layer::Sa && owner.kind() == Kind::Function)
+ .then_some((owner.id, PortChoice::Reuse(id)))
+}
+
+fn component_endpoint(project: &Project, id: Id) -> Option<&PortContract> {
+ let element = project.elements.get(&id)?;
+ let owner = project.elements.get(&element.parent?)?;
+ if element.layer() != Layer::Sa || owner.layer() != Layer::Sa || !owner.kind().is_performer() {
+ return None;
+ }
+ let contract = port_contract(element)?;
+ match contract {
+ PortContract::Component {
+ kind: ComponentPortKind::Flow,
+ ..
+ }
+ | PortContract::Component {
+ kind: ComponentPortKind::Standard,
+ orientation: ComponentPortOrientation::Unset,
+ } => Some(contract),
+ _ => None,
+ }
+}
+
+fn boundary_oriented(project: &Project, source: Id) -> bool {
+ matches!(
+ component_endpoint(project, source),
+ Some(PortContract::Component {
+ kind: ComponentPortKind::Flow,
+ ..
+ })
+ )
+}
+
+fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> String {
+ let endpoint = |id| {
+ if tool == Tool::BoundaryExchange
+ && let Some(owner) = project.elements.get(&id).and_then(|e| e.parent)
+ {
+ format!("{} / {}", project.named(owner), project.named(id))
+ } else {
+ project.named(id).to_owned()
+ }
+ };
+ let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
+ "—"
+ } else {
+ "→"
+ };
+ format!(
+ "{}: {} {arrow} {}",
+ tool.label(),
+ endpoint(source),
+ endpoint(target)
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::document::Document;
+ use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
+
+ fn element(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
+ p.add_element(kind, layer, name, None).unwrap()
+ }
+
+ fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
+ let id = Id::new_v4();
+ let port = make_port(p, id, owner, name, contract).unwrap();
+ p.elements.insert(id, port);
+ id
+ }
+
+ fn flow(orientation: ComponentPortOrientation) -> PortContract {
+ PortContract::Component {
+ kind: ComponentPortKind::Flow,
+ orientation,
+ }
+ }
+
+ fn input() -> PortContract {
+ PortContract::FunctionInput {
+ incoming_items: Vec::new(),
+ }
+ }
+
+ fn output() -> PortContract {
+ PortContract::FunctionOutput {
+ outgoing_items: Vec::new(),
+ }
+ }
+
+ fn created(project: &Project, command: &Command) -> (Project, Relation) {
+ let prepared = prepare_command(project, command).unwrap();
+ let relation = prepared.project.relations[&prepared.selected.unwrap()].clone();
+ (prepared.project, relation)
+ }
+
+ #[test]
+ fn operational_interaction_targets_only_distinct_oa_activities() {
+ let mut p = Project::empty();
+ let source = element(&mut p, Kind::Activity, Layer::Oa, "Send report");
+ let target = element(&mut p, Kind::Activity, Layer::Oa, "Assess report");
+ let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
+ let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
+ let before = p.clone();
+ assert_eq!(Tool::for_layer(Layer::Oa), &[Tool::Interaction]);
+ assert_eq!(
+ Tool::Interaction.targets(&p, source),
+ BTreeSet::from([target])
+ );
+ for invalid in [source, actor, function, Id::new_v4()] {
+ assert!(Tool::Interaction.command(&p, source, invalid).is_err());
+ }
+ let command = Tool::Interaction.command(&p, source, target).unwrap();
+ assert!(matches!(command, Command::CreateRelation(_)));
+ let (_, relation) = created(&p, &command);
+ assert_eq!(relation.body.endpoints(), (source, target));
+ assert_eq!(relation.name, "Interaction: Send report → Assess report");
+ assert_eq!(p, before);
+ }
+
+ #[test]
+ fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
+ let mut p = Project::empty();
+ let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
+ let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
+ let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
+ let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
+ let source = port(
+ &mut p,
+ a,
+ "Reports out",
+ PortContract::FunctionOutput {
+ outgoing_items: vec![report],
+ },
+ );
+ let target = port(
+ &mut p,
+ b,
+ "Results in",
+ PortContract::FunctionInput {
+ incoming_items: vec![result],
+ },
+ );
+ let other = port(&mut p, a, "Other output", output());
+ let before = p.clone();
+ let command = Tool::FunctionalExchange
+ .command(&p, source, target)
+ .unwrap();
+ assert!(matches!(&command, Command::Batch(commands) if commands.len() == 1));
+ let (accepted, relation) = created(&p, &command);
+ assert!(matches!(relation.body, RelationBody::FunctionalExchange {
+ source: s, target: t, source_port: sp, target_port: tp, ref items,
+ } if (s,t,sp,tp) == (a,b,source,target) && items.is_empty()));
+ assert_eq!(accepted.elements, p.elements);
+ assert_eq!(relation.name, "Functional exchange: Detect → Assess");
+ for (s, t) in [
+ (target, source),
+ (source, other),
+ (source, a),
+ (source, source),
+ ] {
+ assert!(!Tool::FunctionalExchange.can_connect(&p, s, t));
+ assert!(Tool::FunctionalExchange.command(&p, s, t).is_err());
+ }
+ assert_eq!(p, before);
+ }
+
+ #[test]
+ fn function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous() {
+ let mut p = Project::empty();
+ let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
+ let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
+ let out = port(&mut p, a, "Report out", output());
+ let input = port(&mut p, b, "Report in", input());
+ let (_, reused) = created(&p, &Tool::FunctionalExchange.command(&p, a, b).unwrap());
+ assert!(
+ matches!(reused.body, RelationBody::FunctionalExchange { source_port, target_port, .. }
+ if (source_port,target_port) == (out,input))
+ );
+ let second = port(&mut p, a, "Other report out", output());
+ let command = Tool::FunctionalExchange.command(&p, a, input).unwrap();
+ assert!(matches!(&command, Command::Batch(commands) if commands.len() == 2));
+ let (accepted, generated) = created(&p, &command);
+ let RelationBody::FunctionalExchange {
+ source_port,
+ target_port,
+ ..
+ } = generated.body
+ else {
+ panic!("Expected functional exchange")
+ };
+ assert_eq!(target_port, input);
+ assert!(![out, second].contains(&source_port));
+ assert_eq!(accepted.elements[&source_port].parent, Some(a));
+ assert_eq!(accepted.elements.len(), p.elements.len() + 1);
+ }
+
+ #[test]
+ fn boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind() {
+ let mut p = Project::empty();
+ let a = element(&mut p, Kind::System, Layer::Sa, "Response system");
+ let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Dispatcher");
+ let mut endpoints = Vec::new();
+ for owner in [a, b] {
+ for orientation in [
+ ComponentPortOrientation::Out,
+ ComponentPortOrientation::In,
+ ComponentPortOrientation::InOut,
+ ComponentPortOrientation::Unset,
+ ] {
+ endpoints.push(port(
+ &mut p,
+ owner,
+ &format!("Flow {orientation:?}"),
+ flow(orientation),
+ ));
+ }
+ endpoints.push(port(
+ &mut p,
+ owner,
+ "Control",
+ PortContract::Component {
+ kind: ComponentPortKind::Standard,
+ orientation: ComponentPortOrientation::Unset,
+ },
+ ));
+ }
+ for source in &endpoints {
+ let oriented = boundary_oriented(&p, *source);
+ let targets = Tool::BoundaryExchange.targets(&p, *source);
+ for target in &endpoints {
+ let expected = validation::can_connect_boundary(&p, *source, *target, oriented);
+ assert_eq!(targets.contains(target), expected, "{source} → {target}");
+ let command = Tool::BoundaryExchange.command(&p, *source, *target);
+ assert_eq!(command.is_ok(), expected);
+ if let Ok(command) = command {
+ let (_, relation) = created(&p, &command);
+ assert!(
+ matches!(relation.body, RelationBody::BoundaryExchange { oriented: value, .. } if value == oriented)
+ );
+ assert!(relation.name.contains(" / "));
+ assert_eq!(relation.name.contains('→'), oriented);
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks() {
+ let mut p = Project::empty();
+ let a = element(&mut p, Kind::System, Layer::Sa, "System");
+ let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
+ let standard = PortContract::Component {
+ kind: ComponentPortKind::Standard,
+ orientation: ComponentPortOrientation::Unset,
+ };
+ let source = port(&mut p, a, "Service", standard.clone());
+ let target = port(&mut p, b, "Required service", standard);
+ let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
+ let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
+ let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
+ let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
+ for (interface, item) in [(provided, report), (required, result)] {
+ *p.elements
+ .get_mut(&interface)
+ .unwrap()
+ .shared_mut()
+ .unwrap() = SharedBody::Interface { items: vec![item] };
+ }
+ p.relate(
+ RelationKind::InterfaceProvision,
+ "Provides reporting",
+ source,
+ provided,
+ );
+ let requirement = p.relate(
+ RelationKind::InterfaceRequirement,
+ "Requires results",
+ target,
+ required,
+ );
+ validate_candidate(&p).unwrap();
+ let before = p.clone();
+ assert!(Tool::BoundaryExchange.targets(&p, source).contains(&target));
+ assert!(validation::can_connect_boundary(&p, source, target, false));
+ let (accepted, relation) = created(
+ &p,
+ &Tool::BoundaryExchange.command(&p, source, target).unwrap(),
+ );
+ let warnings: Vec<_> = validation::validate(&accepted)
+ .into_iter()
+ .filter(|issue| issue.code == "unsatisfied_interface_requirement")
+ .collect();
+ assert_eq!(warnings.len(), 1);
+ assert_eq!(warnings[0].severity, validation::Severity::Warning);
+ assert_eq!(warnings[0].element, Some(requirement));
+ assert_eq!(warnings[0].field.as_deref(), Some("target"));
+
+ let mut carrying = relation.clone();
+ let RelationBody::BoundaryExchange { items, .. } = &mut carrying.body else {
+ unreachable!()
+ };
+ items.push(result);
+ assert!(prepare_command(&p, &Command::CreateRelation(carrying.clone())).is_err());
+ assert_eq!(p, before, "Rejected carried items never mutate the input");
+
+ p.relate(
+ RelationKind::InterfaceProvision,
+ "Provides results",
+ source,
+ required,
+ );
+ let prepared = prepare_command(&p, &Command::CreateRelation(carrying)).unwrap();
+ assert!(
+ validation::validate(&prepared.project)
+ .iter()
+ .all(|issue| issue.code != "unsatisfied_interface_requirement")
+ );
+ }
+
+ #[test]
+ fn mismatched_port_owners_layers_and_contract_kinds_are_not_candidates() {
+ let mut p = Project::empty();
+ let function = element(&mut p, Kind::Function, Layer::Sa, "Function");
+ let system = element(&mut p, Kind::System, Layer::Sa, "System");
+ let activity = element(&mut p, Kind::Activity, Layer::Oa, "Activity");
+ let function_on_system = port(&mut p, system, "Wrong function owner", output());
+ let component_on_function = port(
+ &mut p,
+ function,
+ "Wrong component owner",
+ flow(ComponentPortOrientation::Out),
+ );
+ let oa_function_port = port(&mut p, activity, "Wrong layer", output());
+ let malformed_standard = port(
+ &mut p,
+ system,
+ "Wrong Standard orientation",
+ PortContract::Component {
+ kind: ComponentPortKind::Standard,
+ orientation: ComponentPortOrientation::Out,
+ },
+ );
+ for source in [
+ function_on_system,
+ component_on_function,
+ oa_function_port,
+ malformed_standard,
+ ] {
+ for tool in Tool::for_layer(Layer::Sa) {
+ assert!(!tool.can_start(&p, source));
+ assert!(tool.targets(&p, source).is_empty());
+ assert!(tool.command(&p, source, function).is_err());
+ }
+ }
+ }
+
+ #[test]
+ fn direct_connections_share_exact_source_history_and_model_parity() {
+ let mut p = Project::empty();
+ let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident");
+ let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident");
+ let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report");
+ let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
+ let system = element(&mut p, Kind::System, Layer::Sa, "System");
+ let actor = element(&mut p, Kind::SystemActor, Layer::Sa, "Actor");
+ let boundary_a = port(&mut p, system, "Send", flow(ComponentPortOrientation::Out));
+ let boundary_b = port(&mut p, actor, "Receive", flow(ComponentPortOrientation::In));
+ let mut bundle = format_bundle(&p);
+ bundle
+ .files
+ .get_mut(&bundle.entry.clone())
+ .unwrap()
+ .insert_str(0, "// Preserve engineering rationale exactly.\n");
+ let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
+ for (tool, source, target) in [
+ (Tool::Interaction, oa_a, oa_b),
+ (Tool::FunctionalExchange, sa_a, sa_b),
+ (Tool::BoundaryExchange, boundary_a, boundary_b),
+ ] {
+ let before = document.bundle().clone();
+ let original = document.project().clone();
+ let revision = document.revision();
+ let command = tool.command(document.project(), source, target).unwrap();
+ let expected = prepare_command(document.project(), &command).unwrap();
+ let selected = document.execute(command).unwrap();
+ assert_eq!(selected, expected.selected);
+ assert_eq!(document.project(), &expected.project);
+ assert_eq!(document.revision(), revision + 1);
+ assert_eq!(
+ compile_bundle(document.bundle(), ProjectLimits::default())
+ .unwrap()
+ .decoded
+ .project,
+ expected.project
+ );
+ let changed = document.bundle().clone();
+ assert!(document.undo());
+ assert_eq!(document.bundle(), &before);
+ assert_eq!(document.project(), &original);
+ assert!(document.redo());
+ assert_eq!(document.bundle(), &changed);
+ }
+ }
+
+ #[test]
+ fn boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map() {
+ let mut p = Project::empty();
+ let a = element(&mut p, Kind::System, Layer::Sa, "System");
+ let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
+ let source = port(&mut p, a, "Send", flow(ComponentPortOrientation::Out));
+ let target = port(&mut p, b, "Receive", flow(ComponentPortOrientation::In));
+ assert!(Tool::BoundaryExchange.can_connect(&p, source, target));
+ // An indexed key alone is not proof of a live model identity. Preserve
+ // the reference check even when inspecting an inconsistent input model.
+ p.elements.get_mut(&target).unwrap().id = Id::new_v4();
+ let before = p.clone();
+ assert!(!validation::can_connect_boundary(&p, source, target, true));
+ assert!(!Tool::BoundaryExchange.targets(&p, source).contains(&target));
+ assert!(Tool::BoundaryExchange.command(&p, source, target).is_err());
+ assert_eq!(p, before);
+ }
+
+ #[test]
+ fn final_command_validation_rejects_invalid_models_without_mutation() {
+ let mut p = Project::empty();
+ let a = element(&mut p, Kind::Activity, Layer::Oa, "Send");
+ let b = element(&mut p, Kind::Activity, Layer::Oa, "Receive");
+ p.elements.get_mut(&a).unwrap().alias = p.elements[&b].alias.clone();
+ let before = p.clone();
+ assert!(
+ Tool::Interaction.can_connect(&p, a, b),
+ "Candidate highlighting checks the proposed link; commit checks the whole model"
+ );
+ assert!(Tool::Interaction.command(&p, a, b).is_err());
+ assert_eq!(p, before);
+ }
+}
diff --git a/crates/oa-desktop/src/context_menu.rs b/crates/oa-desktop/src/context_menu.rs
new file mode 100644
index 00000000..34a3ef6f
--- /dev/null
+++ b/crates/oa-desktop/src/context_menu.rs
@@ -0,0 +1,1570 @@
+//! Controlled contextual menus. Keep `Popup` around the root even when closed
+//! so a dismissal click's release cannot reach the widget underneath it.
+//! Each invocation gets a fresh token; action messages should carry that token
+//! for application-level context/revision validation. The application closes
+//! the menu when handling an action; `on_close` handles dismissal only.
+use crate::style;
+use iced::advanced::Renderer as _;
+use iced::{
+ Border, Color, Element, Event, Length, Point, Rectangle, Renderer, Shadow, Size, Theme, Vector,
+ advanced::{
+ Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
+ text::{self, Paragraph as _, Renderer as _},
+ widget::{Operation, Tree, tree},
+ },
+ alignment, keyboard, mouse, touch, window,
+};
+
+const PAD: f32 = 6.;
+const GAP: f32 = 8.;
+const ROW: f32 = 34.;
+
+#[derive(Debug, Clone)]
+pub enum Entry {
+ Title(String),
+ Separator,
+ Action {
+ label: String,
+ shortcut: Option,
+ message: Option,
+ },
+}
+impl Entry {
+ pub fn title(label: impl Into) -> Self {
+ Self::Title(label.into())
+ }
+ pub fn separator() -> Self {
+ Self::Separator
+ }
+ pub fn action(label: impl Into, message: Message) -> Self {
+ Self::Action {
+ label: label.into(),
+ shortcut: None,
+ message: Some(message),
+ }
+ }
+ pub fn disabled(label: impl Into) -> Self {
+ Self::Action {
+ label: label.into(),
+ shortcut: None,
+ message: None,
+ }
+ }
+ pub fn shortcut(mut self, shortcut: impl Into) -> Self {
+ if let Self::Action {
+ shortcut: label, ..
+ } = &mut self
+ {
+ *label = Some(shortcut.into());
+ }
+ self
+ }
+ fn height(&self) -> f32 {
+ match self {
+ Self::Title(_) => 28.,
+ Self::Separator => 9.,
+ Self::Action { .. } => ROW,
+ }
+ }
+ fn enabled(&self) -> bool {
+ matches!(
+ self,
+ Self::Action {
+ message: Some(_),
+ ..
+ }
+ )
+ }
+}
+
+#[derive(Debug, Clone)]
+pub struct Menu {
+ pub token: u64,
+ /// Absolute window coordinates, as returned by `Area` or the diagram.
+ pub anchor: Point,
+ pub entries: Vec>,
+ pub on_close: Message,
+}
+
+pub struct Popup<'a, Message> {
+ content: Element<'a, Message>,
+ menu: Option
Model admission permits at most 5,000 elements plus relations, 20,000 total
-persistent records (including the project, views and placements), and 50,000
-ordered step references. Source declaration counts and decoded JSON record
+persistent records (including the project, views, placements, routes and manual
+port anchors), and 50,000 ordered step references. Source declaration counts and decoded JSON record
counts are checked before structural validation. Legacy import counts the
occurrences and placements migration will generate before validating or
migrating that model. Exceeding these budgets returns 422 model_limit;
@@ -910,6 +910,30 @@ nor reserves IDs. A committed response omits the proposed model and source
project. Commit must check the revision again. A no-op is successful with
changed: false and the original ETag.
+
+
Diagram port positions
+
NodePlacement.port_anchors is an optional list of
+{"port":"<portUUID>","side":"Top","offset":0.25} records. Side is
+Left, Right, Top, or Bottom; offset is a finite fraction from 0 through 1,
+measured from top on vertical edges and from left on horizontal edges.
+The port must be a direct child of the placement's semantic element and occur
+at most once in its anchor list. Placement is presentation state; it changes
+neither semantic ownership nor port direction. Other diagrams are independent.
SetPortAnchor upserts by port UUID. RemovePortAnchor takes
+{diagram,node,port} and restores automatic positioning. Repeating a reset
+is a no-op only while the referenced port still belongs to that placement's
+element; missing or stale owners are rejected. Remove old anchors before
+reparenting in the same atomic batch. Both commands support ordinary dry run,
+ETag checks and source-project writeback. Rejected batches preserve the model,
+authored files and revision. JSON/source imports may omit port_anchors;
+it defaults to [] and empty lists are omitted on export. The normative
+diagram semantics and source spelling are in language.rst section 5.
+
Contextual process and chain topology
Operational processes and system functional chains can combine work
diff --git a/docs/api.rst b/docs/api.rst
index 7b475dcd..61c0fbb5 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -136,8 +136,8 @@ Compressed or encoded request bodies and browser Origin requests are
not supported.
Model admission permits at most 5,000 elements plus relations, 20,000 total
-persistent records (including the project, views and placements), and 50,000
-ordered step references. Source declaration counts and decoded JSON record
+persistent records (including the project, views, placements, routes and manual
+port anchors), and 50,000 ordered step references. Source declaration counts and decoded JSON record
counts are checked before structural validation. Legacy import counts the
occurrences and placements migration will generate before validating or
migrating that model. Exceeding these budgets returns ``422 model_limit``;
@@ -291,6 +291,34 @@ nor reserves IDs. A committed response omits the proposed model and source
project. Commit must check the revision again. A no-op is successful with
``changed: false`` and the original ETag.
+Diagram port positions
+----------------------
+
+``NodePlacement.port_anchors`` is an optional list of
+``{"port":"","side":"Top","offset":0.25}`` records. Side is
+Left, Right, Top, or Bottom; offset is a finite fraction from 0 through 1,
+measured from top on vertical edges and from left on horizontal edges.
+The port must be a direct child of the placement's semantic element and occur
+at most once in its anchor list. Placement is presentation state; it changes
+neither semantic ownership nor port direction. Other diagrams are independent.
+
+The existing transaction endpoint accepts::
+
+ {"commands":[{"SetPortAnchor":{
+ "diagram":"","node":"",
+ "anchor":{"port":"","side":"Top","offset":0.25}
+ }}]}
+
+``SetPortAnchor`` upserts by port UUID. ``RemovePortAnchor`` takes
+``{diagram,node,port}`` and restores automatic positioning. Repeating a reset
+is a no-op only while the referenced port still belongs to that placement's
+element; missing or stale owners are rejected. Remove old anchors before
+reparenting in the same atomic batch. Both commands support ordinary dry run,
+ETag checks and source-project writeback. Rejected batches preserve the model,
+authored files and revision. JSON/source imports may omit ``port_anchors``;
+it defaults to ``[]`` and empty lists are omitted on export. The normative
+diagram semantics and source spelling are in ``language.rst`` section 5.
+
Contextual process and chain topology
-------------------------------------
diff --git a/docs/language.html b/docs/language.html
index 79fe7b75..5dfa1ed3 100644
--- a/docs/language.html
+++ b/docs/language.html
@@ -1296,7 +1296,7 @@ capabilities with their involvement, exploitation, include, extend and
generalization links. Decomposition and Overview retain general containment;
Overview also exposes all relationships. Changing the projection hides
inapplicable placements without deleting them or changing semantic records.
-
Ports are calculated boundary anchors of visible owners, with their existing
+
Ports are boundary anchors of visible owners, with their existing
semantic identity. An explicitly placed port is not rendered as a separate
large node. Functional exchanges use their declared source/target ports;
boundary exchanges attach to their participant ports even if those ports have
@@ -1305,13 +1305,45 @@ may terminate at a visible ancestor without creating a new port or exchange.
node has an alias and identity but no display-name string. Required fields
are diagram and element references. Optional fields are position
(record with x/y, default 0/0), size (width/height, default 224/94), and
-collapsed (false). Node identity is independent of its semantic element.
+collapsed (false), and port_anchors (empty list). Node identity is
+independent of its semantic element.
Coordinates are absolute diagram coordinates. Placement does not alter parent,
allocation, or realization. Hiding an element from a diagram must remain
distinguishable from deleting it from the model.
Changing a node's diagram preserves the placement identity, alias, coordinates,
-size, collapse state, and attached artifacts. The destination must accept the
-element's layer and must not already contain another placement of that element.
+size, collapse state, manual port anchors, and attached artifacts. The destination
+must accept the element's layer and must not already contain another placement
+of that element.
+
Each port_anchors entry is a PortAnchor record with required port
+reference, side (Left, Right, Top, or Bottom), and offset (finite
+binary32 from 0 through 1). It has no separate identity or alias. The port MUST
+be an existing Port directly owned by the placement's element. A port MAY
+have one manual anchor per placement; different diagrams MAY position it
+independently. List order is preserved but has no positional meaning.
+
offset measures a fraction of the complete projected owner edge, from top
+on Left/Right or from left on Top/Bottom. Zero and one denote its corners.
+The side is presentation data and MUST NOT change the port's contract,
+orientation, ownership, allocations, or connected exchanges. Absent anchors use
+automatic positioning. Empty lists are omitted in canonical JSON/source;
+earlier schema-3 input without this field hydrates as [].
+
node actor_view {
+ diagram @architecture; element @vessel;
+ port_anchors [{ port @communication; side Top; offset 0.25; }];
+}
+
SetPortAnchor { diagram, node, anchor } upserts by port identity and retains
+unrelated entries. RemovePortAnchor { diagram, node, port } restores
+automatic positioning; it is a no-op only for an existing direct child port
+of that placement. Unknown fields, duplicate port references, non-port or
+foreign-owner references, and invalid offsets are structural errors. Each
+anchor counts as one record against ParseLimits.max_records.
+
These commands use the same atomic history, conditional REST transactions, and
+source writeback as other presentation edits. Source references resolve across
+files, and anchor comments follow port identity. Reidentifying a port updates
+anchor references; deleting it removes its anchors. Reparenting MUST repair
+old-owner anchors in the same transaction, for example by removing them before
+the Reparent command. Moving, resizing, arranging, or folding an owner retains
+its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
+port_anchors; their explicit migration supplies an empty list.
Each routes entry is an EdgeRoute record with a required edge reference
to an existing Relation or Trace, waypoints (default []), and label
(default none). Route records have no separate UUID or alias. An edge occurs
@@ -1615,7 +1647,8 @@ preserve authored comments and whitespace on Apply while inserting missing
identities. Explicit canonical export remains a separate operation. Project
update/export distinctions are described below.
Camera position, selection, source cursor, and undo history are not stored
-model fields. Layout and collapse state inside NodePlacement are model data.
+model fields. Layout, collapse state, and manual port anchors inside
+NodePlacement are model data.
The pure format_bundle exporter uses the same typed declaration writer as
format. It creates a small entry file, layer/category modules, separate
activity/function roots and behavior contexts, and separate diagrams with their
diff --git a/docs/language.rst b/docs/language.rst
index b9267221..73f31a5a 100644
--- a/docs/language.rst
+++ b/docs/language.rst
@@ -638,7 +638,7 @@ generalization links. Decomposition and Overview retain general containment;
Overview also exposes all relationships. Changing the projection hides
inapplicable placements without deleting them or changing semantic records.
-Ports are calculated boundary anchors of visible owners, with their existing
+Ports are boundary anchors of visible owners, with their existing
semantic identity. An explicitly placed port is not rendered as a separate
large node. Functional exchanges use their declared source/target ports;
boundary exchanges attach to their participant ports even if those ports have
@@ -648,13 +648,52 @@ may terminate at a visible ancestor without creating a new port or exchange.
``node`` has an alias and identity but no display-name string. Required fields
are ``diagram`` and ``element`` references. Optional fields are ``position``
(record with x/y, default 0/0), ``size`` (width/height, default 224/94), and
-``collapsed`` (false). Node identity is independent of its semantic element.
+``collapsed`` (false), and ``port_anchors`` (empty list). Node identity is
+independent of its semantic element.
Coordinates are absolute diagram coordinates. Placement does not alter parent,
allocation, or realization. Hiding an element from a diagram must remain
distinguishable from deleting it from the model.
Changing a node's diagram preserves the placement identity, alias, coordinates,
-size, collapse state, and attached artifacts. The destination must accept the
-element's layer and must not already contain another placement of that element.
+size, collapse state, manual port anchors, and attached artifacts. The destination
+must accept the element's layer and must not already contain another placement
+of that element.
+
+Each ``port_anchors`` entry is a PortAnchor record with required ``port``
+reference, ``side`` (Left, Right, Top, or Bottom), and ``offset`` (finite
+binary32 from 0 through 1). It has no separate identity or alias. The port MUST
+be an existing Port directly owned by the placement's ``element``. A port MAY
+have one manual anchor per placement; different diagrams MAY position it
+independently. List order is preserved but has no positional meaning.
+
+``offset`` measures a fraction of the complete projected owner edge, from top
+on Left/Right or from left on Top/Bottom. Zero and one denote its corners.
+The side is presentation data and MUST NOT change the port's contract,
+orientation, ownership, allocations, or connected exchanges. Absent anchors use
+automatic positioning. Empty lists are omitted in canonical JSON/source;
+earlier schema-3 input without this field hydrates as ``[]``.
+
+.. code-block:: text
+
+ node actor_view {
+ diagram @architecture; element @vessel;
+ port_anchors [{ port @communication; side Top; offset 0.25; }];
+ }
+
+``SetPortAnchor { diagram, node, anchor }`` upserts by port identity and retains
+unrelated entries. ``RemovePortAnchor { diagram, node, port }`` restores
+automatic positioning; it is a no-op only for an existing direct child port
+of that placement. Unknown fields, duplicate port references, non-port or
+foreign-owner references, and invalid offsets are structural errors. Each
+anchor counts as one record against ``ParseLimits.max_records``.
+
+These commands use the same atomic history, conditional REST transactions, and
+source writeback as other presentation edits. Source references resolve across
+files, and anchor comments follow port identity. Reidentifying a port updates
+anchor references; deleting it removes its anchors. Reparenting MUST repair
+old-owner anchors in the same transaction, for example by removing them before
+the Reparent command. Moving, resizing, arranging, or folding an owner retains
+its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
+``port_anchors``; their explicit migration supplies an empty list.
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
@@ -986,7 +1025,8 @@ preserve authored comments and whitespace on Apply while inserting missing
identities. Explicit canonical export remains a separate operation. Project
update/export distinctions are described below.
Camera position, selection, source cursor, and undo history are not stored
-model fields. Layout and collapse state inside NodePlacement are model data.
+model fields. Layout, collapse state, and manual port anchors inside
+NodePlacement are model data.
The pure ``format_bundle`` exporter uses the same typed declaration writer as
``format``. It creates a small entry file, layer/category modules, separate
diff --git a/docs/openapi.json b/docs/openapi.json
index 5536a8d2..08d4fabf 100644
--- a/docs/openapi.json
+++ b/docs/openapi.json
@@ -3615,6 +3615,32 @@
],
"additionalProperties": false
},
+ "PortAnchorSide": {
+ "type": "string",
+ "enum": ["Left", "Right", "Top", "Bottom"],
+ "description": "Presentation side of the owner placement; independent of semantic port direction."
+ },
+ "PortAnchor": {
+ "type": "object",
+ "description": "Manual position of a direct child Port on one owner placement. Each port occurs at most once per placement; this record has no separate identity.",
+ "properties": {
+ "port": {
+ "type": "string",
+ "format": "uuid"
+ },
+ "side": {
+ "$ref": "#/components/schemas/PortAnchorSide"
+ },
+ "offset": {
+ "type": "number",
+ "minimum": 0,
+ "maximum": 1,
+ "description": "Finite binary32 fraction of the complete owner edge, measured from top on Left/Right and from left on Top/Bottom."
+ }
+ },
+ "required": ["port", "side", "offset"],
+ "additionalProperties": false
+ },
"NodePlacement": {
"type": "object",
"properties": {
@@ -3639,6 +3665,14 @@
},
"collapsed": {
"type": "boolean"
+ },
+ "port_anchors": {
+ "type": "array",
+ "items": {
+ "$ref": "#/components/schemas/PortAnchor"
+ },
+ "default": [],
+ "description": "Per-placement manual port positions. Omitted and empty both select automatic positioning; empty lists are omitted on serialization. Duplicate port references are invalid."
}
},
"required": [
@@ -4734,6 +4768,42 @@
],
"additionalProperties": false
},
+ {
+ "type": "object",
+ "properties": {
+ "SetPortAnchor": {
+ "type": "object",
+ "description": "Upsert manual port position by port identity on an existing owner placement, preserving unrelated entries. Port must be a direct child of the placement's semantic element.",
+ "properties": {
+ "diagram": { "type": "string", "format": "uuid" },
+ "node": { "type": "string", "format": "uuid" },
+ "anchor": { "$ref": "#/components/schemas/PortAnchor" }
+ },
+ "required": ["diagram", "node", "anchor"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["SetPortAnchor"],
+ "additionalProperties": false
+ },
+ {
+ "type": "object",
+ "properties": {
+ "RemovePortAnchor": {
+ "type": "object",
+ "description": "Restore automatic positioning for an existing direct child Port. No-op when that valid owner placement has no manual anchor for the port; wrong or stale ownership is rejected.",
+ "properties": {
+ "diagram": { "type": "string", "format": "uuid" },
+ "node": { "type": "string", "format": "uuid" },
+ "port": { "type": "string", "format": "uuid" }
+ },
+ "required": ["diagram", "node", "port"],
+ "additionalProperties": false
+ }
+ },
+ "required": ["RemovePortAnchor"],
+ "additionalProperties": false
+ },
{
"type": "object",
"properties": {
diff --git a/docs/scope.md b/docs/scope.md
index b8ea1681..06803192 100644
--- a/docs/scope.md
+++ b/docs/scope.md
@@ -1,20 +1,20 @@
-# V2 scope and tradeoffs
+# Current scope and tradeoffs
-V2 implements a general systems-engineering workbench spanning Operational Analysis and System Analysis. The normative [language and editing contract](language.rst) defines the supported Studio profile. It does not claim full Arcadia, Eclipse Capella or SysML conformance. [Acceptance](acceptance.md), [validation](validation.md) and [current state](../harness/STATE.md) record what has actually been exercised; implementation and verification are distinct.
+Arcadia Studio implements a systems-engineering workbench spanning Operational Analysis and System Analysis. The normative [language and editing contract](language.rst) defines the supported Studio profile. It does not claim full Arcadia, Eclipse Capella or SysML conformance. The [first-release plan](../harness/PLAN.md) remains incomplete; [current state](../harness/STATE.md) records current implementation and verification. The [V2 acceptance](acceptance.md) and [validation](validation.md) records describe the earlier baseline, not acceptance of every subsequent change.
## Supported modeling profile
| Area | Implemented semantics and authoring |
|---|---|
-| Operational Analysis | Entities, actors, roles, activities, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement |
+| Operational Analysis | Entities, actors, roles, activities, missions, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement |
| System Analysis | One System of Interest, external Actors, functions, missions, capabilities and functional chains; functional decomposition independent of the system boundary; allocation, functional/boundary exchanges and mission-capability links |
-| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, ports, interface contents, provided/required contracts and exchange allocations |
-| Behavior | Stable ordered exchange occurrences, repeated semantic exchanges, scenario lifelines and exchange/action/state occurrences, explicit ALT/PAR/LOOP/OPTIONAL fragments and branches, pre/postconditions |
-| States and conditions | Participant-owned state machines, regions, state/mode/initial/final nodes, trigger/guard/effect transitions, descriptive and linked entry/do/exit work, availability and constraints |
+| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
+| Behavior | Process/chain work and exchange contributions, control topology and sequence reuse; scenario lifelines, correlated calls/replies, timers, action/state occurrences, ALT/PAR/LOOP/OPTIONAL fragments, scenario reuse and pre/postconditions |
+| States and conditions | Participant-owned state machines, nested regions, states/modes and typed pseudostates, signal/time/change events, trigger/guard/effect transitions, linked entry/do/exit work, availability and constraints |
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
-| Views and editing | Multiple independent diagrams, decomposition/allocation nesting, collapse, dragging, pan/zoom/Fit/Arrange, SVG export, model explorer, typed property controls, exact strings, UUID/alias editing, history and source navigation |
-| Text and storage | Canonical `arcadia 2` language, persistent aliases/UUIDs, typed constructors, highlighting/live lint, recoverable invalid drafts, text/JSON equality, deterministic V1 migration and atomic saves |
-| Automation | Rust REST service for project/source/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
+| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
+| Text and storage | `arcadia 3` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
+| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
@@ -24,9 +24,11 @@ Graphical and textual parity applies to the persistent fields of this profile. D
- Work decomposition and performer allocation are independent. Only leaves have direct allocations. A parent drawn within one performer can be a computed leaf summary, not an additional allocation. Role sharing does not create several direct performers for one function.
- Nesting expresses semantic containment or the selected allocation projection. Placement movement does not change ownership or allocation. Hide and delete have different effects.
- Counterpart creation copies mapped typed content and required references; existing-target updates compare only names/descriptions against a reviewed baseline. They do not synchronize arbitrary structure, reverse-engineer operational needs or automatically overwrite the other perspective. Scope follows ownership/reference dependencies, not every incident relationship.
-- Processes/chains use ordered exchange occurrences. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile.
-- OA boundary exchanges are a simplified operational-communication representation. Capability grouping is containment, not include/extend/generalization. Data/item references remain within their layer and can have explicit transitioned counterparts.
-- Stable aliases are retained; comments and formatting normalize on Apply. Artifact references retain author-provided URI/revision/fragment values without fetching or executing resources.
+- Processes/chains use contributions and explicit sequence links; imported ordered exchange paths retain their implied order until topology conversion. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile.
+- Preserved OA boundary exchanges use Legacy ports. Capability grouping is containment; include, extend and generalization use separate typed relationships in OA and SA. Data/item references remain within their layer and can have explicit transitioned counterparts.
+- Applying a source project preserves authored comments and formatting while assigning missing identities. Graphical updates preserve unaffected source bytes; canonical export is a separate operation. JSON does not preserve comments or source-file organization. See the [source contract](language.rst) for comment ownership and update rules.
+- Port position and connection routing belong to each diagram and do not change semantic ownership or endpoints. Folding may temporarily promote an edge to a visible container; expansion restores its authored route. [Routing verification](../harness/release-routing-verification.md) records accepted behavior and measured limits.
+- Artifact references retain author-provided URI/revision/fragment values without fetching or executing resources.
## Capella-inspired improvement targets
@@ -34,20 +36,24 @@ These are concrete design responses, not claims that every Capella user experien
| Concern | Studio response | Remaining tradeoff |
|---|---|---|
-| Dense workbench and difficult onboarding | Task workspaces, OA/SA context, focused properties, guided example and explicit relation/transition builders | Broader usability studies, assistive technology and localization remain |
-| Noisy architecture pictures | Nested ownership/allocation views, independent placements and collapse | Manual edge routing and very large diagram optimization remain |
-| Hard-to-review model files | Readable typed source, stable aliases/UUIDs, canonical ordering and one specification | Comment preservation, semantic merges and an LSP are absent |
+| Dense workbench and difficult onboarding | Task workspaces, OA/SA context, contextual menus, direct architecture connections, searchable guidance and relation/transition builders | Direct behavior gestures and Home/recent-project workflows remain release work |
+| Noisy architecture pictures | Nested ownership/allocation views, independent placements, collapse, automatic routing, editable bends/labels and port positions | Dense diagrams may need manual adjustment; routing has a bounded solve budget |
+| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent |
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
+## Required first-release work
+
+Direct state/scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
+
## Remaining boundaries
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
-- Advanced capability include/extend/generalization and complete Capella communication/scenario variants beyond the specified Studio profile.
-- Autosave/crash recovery, recent-project management, semantic diff/merge, comment-preserving source edits, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
-- Direct desktop connection to hosted models, collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits.
-- Editable edge routing, production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
+- Complete Capella communication/scenario variants beyond the specified Studio profile.
+- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
+- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented.
+- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
Windows and Linux builds are supported. Documented Docker/systemd deployment recipes are available for the service, but a recipe is not evidence of a running remote deployment. Platform, package and GUI verification must be reported from the acceptance record rather than inferred from source or CI configuration.
diff --git a/harness/DECISIONS.md b/harness/DECISIONS.md
index a89838ed..8c9725cf 100644
--- a/harness/DECISIONS.md
+++ b/harness/DECISIONS.md
@@ -35,3 +35,6 @@
- Recovery state is independent of source diagnostics. Interrupted Save All journals remain discoverable after lint, typing, missing entry files or first-save failure. Recovery validates current bytes against known transaction states; it does not promise atomic multi-file visibility or cross-process filesystem locking. Source/property draft guards prevent one representation from silently replacing pending edits in the other.
- Production source3 nesting expresses semantic element ownership only. Extend the existing parser and source index with real lexical owners/body spans, rather than adopting the experiment's parallel parser. Flat canonical interchange remains valid. Structural writeback composes declaration pieces after ordinary token edits and uses original occurrence provenance through temporary UUID collisions. This preserves independent parent/child moves, authored files, comments and exact history while allowing new children to be emitted inside their owners. Allocation and ordered occurrence membership remain explicit.
- A hosted revision owns the accepted model and stabilized source bundle together. Store one atomic envelope, use the same core writer for commands and previews, and advance the ETag for comment-only edits. Old model-only envelopes acquire deterministic source in memory without a read-time write or revision change. A detached source sidecar would create inconsistent revisions and unsafe restart behavior. Accepted model equality alone is insufficient for a no-op when command provenance deletes/recreates a declaration; this was reproduced and corrected in desktop and REST adapters.
+- Contextual actions use a controlled Iced overlay and captured semantic target plus document/revision/view context. Menus reuse existing handlers, and opening a menu does not select/apply another draft. Canvas focus scopes routing keys; Actions is an accessible alternative to right-click. Source files use a folder tree with exact paths and aggregate draft/diagnostic markers.
+- Port anchors are per-placement presentation records with four sides and normalized offsets. Automatic sides use port direction and visible peer geometry; explicit anchors do not change owner minimum dimensions. Canvas, routing, Fit and SVG share projected anchors and side normals. Graphical reparent batches old-owner anchor cleanup with semantic reparent; raw source and API remain strict and require explicit repair.
+- Direct connections use explicit Select/move and exchange palette tools. Compatible visible endpoints are highlighted; click-click or drag creates one ordinary validated command. Function-card gestures use the existing automatic port planner. Tool invocation, blank-canvas restart, Escape and model/view changes invalidate old gesture contexts. No project clone or whole-model compile occurs per highlighted candidate; final commit still validates the complete model/source change.
diff --git a/harness/PLAN.md b/harness/PLAN.md
index 15f39071..c6ce27e1 100644
--- a/harness/PLAN.md
+++ b/harness/PLAN.md
@@ -14,7 +14,9 @@ The current forms are a closed typed-record adapter, not a second metamodel. Exp
## Milestones and measurable acceptance
-Nested source ownership and persistent REST bundles are implemented and under final native acceptance. Per-file repair/recovery passed at 1100×700; production parsing, exact structural writeback and coupled service revisions passed independent review and Windows/Linux suites. Root's Windows nested reparent/source/Undo/Redo/Save/reopen passed; minimum-size source-to-graphics review and rebuilt Linux trial remain. Preserve the existing grammar/updater and separate allocation/order semantics. Next, use the updated direct-state core/desktop maps to freeze one small persisted-canvas contract before implementation.
+Nested source ownership and persistent REST bundles are implemented and verified: Windows minimum-size source-to-graphics, nested reparent/source/Undo/Redo/Save/reopen, recovery, rebuilt Linux source/history/Save/reopen, independent review and both platform suites passed. Preserve the existing grammar/updater and separate allocation/order semantics.
+
+Current user-directed sequence: finish native contextual-menu/source-tree acceptance; then ship draggable per-placement port sides and intelligent automatic placement; then direct diagram connection tools for OA interactions, functional exchanges and boundary exchanges. Port placement is presentation state, and drawing an exchange uses the existing typed semantic command boundary. Select/move and Connect are explicit modes; connection targets highlight only compatible visible endpoints. A completed drag commits once, retains text/REST parity, and supports Undo/Redo/Save/reopen. Verify all four port sides, nested owners, routing/pins, compatible and incompatible endpoints, zoom, Escape, stale gestures, and minimum Windows/Linux layouts. The independently tested state-core slice is preserved in its worktree until these latest usability priorities are integrated; all broader release milestones remain required.
The user-directed actor/system port and model-navigation milestone is accepted: visible Add port → Connect boundary exchange, grouped roots, focused containment, breadcrumbs, contextual search and paging, clear full-depth outline, and separate allocations. Native Windows authoring/save/reopen, 251-root paging, 12-level navigation, 1440×900/1100×700 inspection, rebuilt Linux save/reopen and single-line font correction passed. Independent requirements, correctness, native UI and simplicity findings are addressed; release-explorer-usability.md records exact evidence and remaining performance boundaries. Resume source recovery acceptance and the release milestones below; the full scope remains intact.
diff --git a/harness/STATE.md b/harness/STATE.md
index 56a1f356..eaf80944 100644
--- a/harness/STATE.md
+++ b/harness/STATE.md
@@ -8,18 +8,18 @@ The editable connection-routing milestone is accepted at `23535b4` with viewport
## Completed foundations
-Nested source/REST integration checkpoint: production parser and iterative structural writer are implemented; REST persists the accepted bundle and model in one ETag envelope. Final workspace runs passed 422 Windows and 425 Linux tests; strict workspace Clippy, formatting and Windows/Linux builds passed. Actual server import, comment-only revision, cross-file reparent, stale ETag and exact restart/conditional GET passed. Owner-opening-line comment reassignment and equal-model delete/recreate provenance defects were reproduced, fixed and reverified. Independent parser/writer/model-source boundary reviews found no remaining material findings; fresh spawn was unavailable because of the thread limit, so workers reviewed one another's artifacts. Strict language/API reST rendering passed after correcting an API heading underline. Root's Windows nested reparent/arrange/source/Undo/Redo/Save/reopen passed with 22 objects,13 links,0 checks. Independent minimum-size/source-created port QA is active; actual rebuilt Linux nesting trial remains. Evidence: release-source-integration.md.
+Nested source/REST integration checkpoint: production parser and iterative structural writer are implemented; REST persists the accepted bundle and model in one ETag envelope. Final workspace runs passed 422 Windows and 425 Linux tests; strict workspace Clippy, formatting and Windows/Linux builds passed. Actual server import, comment-only revision, cross-file reparent, stale ETag and exact restart/conditional GET passed. Owner-opening-line comment reassignment and equal-model delete/recreate provenance defects were reproduced, fixed and reverified. Independent parser/writer/model-source boundary reviews found no remaining material findings; fresh spawn was unavailable because of the thread limit, so workers reviewed one another's artifacts. Strict language/API reST rendering passed after correcting an API heading underline. Root's Windows nested reparent/arrange/source/Undo/Redo/Save/reopen passed with 22 objects,13 links,0 checks. Independent minimum-size/source-created port QA passed. Root also verified Linux nested source edit/Undo/Redo/Save/reopen; the Linux instance closed normally with an empty log. Evidence: release-source-integration.md.
-Current continuation: previous goal turn made concrete progress at `d364d3c` (native-verified explorer/port usability). Root reread the complete authoritative objective. Native source recovery acceptance passed: comment-preserving reopen, graphical rename/writeback/Undo, inactive-file syntax repair, mixed interrupted-save rollback, first-save recovery with retained draft/retry, and included-file repair/Save/reopen at 1100×700. The prominent recovery banner closed normally with an empty runtime log. All implementation ownership is released to root. `explorer_usability_review` alone owns native input for its bounded QA; `source_document_review` and `source_integration_map` are mapping direct-state core/desktop contracts read-only. No blocker; release scope is unchanged.
+Historical start of this continuation (superseded by the active checkpoint below): nested source/REST is committed at `4e8575b`. Independent Windows source-created Standard actor port, boundary-exchange entry, Undo/Redo and Save/reopen passed at 1100×700; the isolated QA window closed normally. Linux native nesting acceptance passed (source comment/history/save/reopen and rendered hierarchy; cross-file graphical reparent was exercised on Windows). Original Windows window 6228978 contains unsaved user work: do not save, reset or close it. Root again owns native input. The state-core worker owns only oa-core in isolated worktree `D:/CodexBuilds/Capella2-state-diagram-core`, branch `codex/state-diagram-core`; root integrates after review. The latest user request prioritizes contextual right-click menus and removes permanent bend buttons. Root owns desktop integration; explorer_usability_review owns only the new reusable context_menu.rs; source_integration_map maps existing actions read-only. Native source recovery acceptance already passed; no blocker and broader release scope is unchanged.
- The user-directed explorer/port usability milestone is accepted. Add port is visible on participants, and selected component ports expose Connect boundary exchange. The browser uses grouped roots, focused direct children, breadcrumbs, paged contextual search and Locate; the outline keeps real depth, guides and single-line labels with full-name tooltips. Root created two Standard ports and a Boundary Exchange, exercised Undo/Redo, Save/reopen, duplicate-name search, 251-root paging and 12-level navigation. Independent native review and root's final Windows build passed at 1100×700; actual rebuilt Linux save/reopen and font-fix inspection passed. Full Windows workspace tests passed 382, followed by all 138 desktop tests; Linux full workspace passed 386. Strict checks and final Windows/Linux builds passed. Slow picker, stale reparent focus, deep-row cost, collapsed header sizing, scroll reset and font wrapping findings are fixed. Exact scope, hashes and evidence: release-explorer-usability.md.
-- Source integration checkpoint `3c87576` passed full366 Windows tests; its draft-history cap follow-up passed122 desktop tests and strict workspace Clippy. Root ran native build SHA256 `FCA6140E835B17AE4FAB003B0DAC38D7D820DD36C7559F536BA3CC607E30FE51`: opened two included buffers, typed separate rationale comments, switched with drafts retained, Apply all, model Undo/Redo, then Save All to `ui-source-project/native-source-trial.arcadia`. The entry is1496 bytes with20 other included files; both authored comments are present on disk and the UI reports all changes saved,41objects/25links/0checks. Runtime log is empty; the window closed normally (exec session99563 exited0). Native source reopen/failure/minimum-size acceptance is still required.
-- Fresh source integration review at 3c87576 reproduced property-draft loss and diagnostic-only recovery state. Both are fixed in the current app: guards retain both drafts, and durable pending_recovery survives typing/lint, missing entries and initial Save failure. Four real-journal recovery tests and source/property collision tests pass in the full workspace run. The bounded reviews are complete; all agents released file/native ownership to root. Frozen source probe evidence: D:/CodexBuilds/Capella2-source-app-independent/REVIEW.md. The explorer Linux trial additionally verified failed Save to a missing directory retained the model and successful retry saved all files; interrupted-journal UI recovery still needs native acceptance.
+- Source integration checkpoint `3c87576` passed full366 Windows tests; its draft-history cap follow-up passed122 desktop tests and strict workspace Clippy. Root ran native build SHA256 `FCA6140E835B17AE4FAB003B0DAC38D7D820DD36C7559F536BA3CC607E30FE51`: opened two included buffers, typed separate rationale comments, switched with drafts retained, Apply all, model Undo/Redo, then Save All to `ui-source-project/native-source-trial.arcadia`. The entry is1496 bytes with20 other included files; both authored comments are present on disk and the UI reports all changes saved,41objects/25links/0checks. Runtime log is empty; the window closed normally (exec session99563 exited0). Those initially pending native source reopen/failure/minimum-size checks were subsequently completed at the nested-source checkpoint above.
+- Fresh source integration review at 3c87576 reproduced property-draft loss and diagnostic-only recovery state. Both are fixed in the current app: guards retain both drafts, and durable pending_recovery survives typing/lint, missing entries and initial Save failure. Four real-journal recovery tests and source/property collision tests pass in the full workspace run. The bounded reviews are complete; all agents released file/native ownership to root. Frozen source probe evidence: D:/CodexBuilds/Capella2-source-app-independent/REVIEW.md. The explorer Linux trial additionally verified failed Save to a missing directory retained the model and successful retry saved all files; interrupted-journal UI recovery was subsequently verified in the source-recovery milestone above.
- Branch `codex/release1-engineering`; baseline `93ba83d`, hierarchy `c7101b9`, drag selection `2ef16dc`, typed core/schema 3 merge `757ab88`, alias and batch lifecycle corrections `e41b8de`.
- Typed desktop integration `fa096ce` provides contextual function/component ports, FE endpoint and exchange-item choices, generation, resolution, split and synchronization with semantic previews. Legacy migration, Standard/Flow distinctions and independent orientation are retained. Independent findings and native-discovered no-op synchronization and SA-only initial-perspective defects were fixed.
- Native typed-port trials created real function endpoints and exchanges, supplementary output/items, and a Standard component implementation with two port allocations and an exchange allocation. Undo/redo and save passed with zero model checks. Reviewed reopen entered SA correctly; unchanged synchronization disabled Apply. The long-reference review modal was inspected at 1440×900 and 1100×700. Evidence and fixtures are under `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-typed-ports` and `ui-typed-ports-reviewed`.
-- Source bundle foundation `06b3384` and token-preserving updater `dbc8971` passed 160 core tests, including 30 source-project tests, and strict core Clippy at that checkpoint. This is a pure core foundation; production editor, filesystem Save All/conflict handling and REST bundles remain to be integrated.
+- Source bundle foundation `06b3384` and token-preserving updater `dbc8971` passed 160 core tests, including 30 source-project tests, and strict core Clippy at that checkpoint. This was the initial core-only checkpoint; production editor, filesystem Save All/conflict handling and REST bundles were subsequently integrated at 4e8575b.
- Isolated source authoring comparison `c5788fc` and nested writeback experiment `2dd17aa` passed 23 experiment tests and strict Clippy. An independent executable probe at `D:/CodexBuilds/Capella2-source-writeback-independent/probe.log` reproduced positional list-comment misattachment after item reorder, rejection of a valid description edit on port shorthand, and unsupported occurrence deletion. The same comment-reorder probe retained attachment in the production updater. Complete model equality alone does not protect comment meaning. Extend the production parser/span index and shared updater for selected nesting/shorthand constructs; do not copy the experiment into a parallel production parser/writer. Freeze comment ownership, exact source Undo and full edit/delete support before adoption. `experiments/source-authoring/WRITEBACK.md` retains the bounded evidence; production syntax is not yet adopted.
- R1 process topology, R2 mission/capability, R4 scenarios and R5 events/states are implemented at the semantic/form level. Full native authoring acceptance and direct behavior canvas gestures remain required; release-state-gesture-design.md records the proposed first vertical slice.
@@ -52,14 +52,23 @@ Original Capella was operated in a disposable workspace. Native creation demonst
Earlier Studio windows disappeared during concurrent work. Fresh inventory confirmed absence and no matching application-crash event was observed; the earlier unsaved routing trial was not recovered. It is superseded by the explicitly saved reviewed fixture, not retroactively claimed as recovered.
+## Active drawing integration checkpoint
+
+Meaningful progress continues: direct connection tools, movable per-diagram port anchors, contextual menus and source folders are implemented. Core commits93fd32c (feasibility scaling) and35133de (port anchors) are integrated. Full Windows workspace passed485 tests (264core,185desktop,36server); full Linux passed488 (267core,185desktop,36server). Final ordered-cursor follow-up passed189 desktop tests on both platforms, strict workspace Clippy and rebuilt desktop binaries. Earlier full runs precede those four additional lifecycle regressions. API/port anchor HTTP tests and strict reST generation passed. Detailed scopes and saved fixtures are in release-port-drawing.md and release-context-menus.md.
+
+Fresh independent correctness review reproduced post-release mouse motion in one native batch creating a port move or exchange after a stationary click. Ordered CursorMoved coordinates are now authoritative for press and release; tests cover both failures, same-batch drags, stationary-source continuity, menu epochs, middle/arrow pan and fresh-context Reset/Draw. Nearest-glyph hit testing fixes incorrect port identity at overview zoom. Port hover shows name, owner and alias in a separate screen-space frame; no label-text overprint. Fresh requirements/architecture review found no further material issue after these fixes and scope-document correction.
+
+Root operated final Windows binary `ui-port-drawing-final/arcadia-studio.exe`, SHA2569A7FF074296D49A714AD664848170D717832A28964719A903B45ABE100BBAA1F, window328292, exec68098. Fast FE port-to-port drag, first Bottom port drag, repeated Left drag, arrow panning with source retained offscreen, target completion and single Undo passed with0checks. It currently contains the isolated unsaved41object/26link model with Left port anchor and first FE. Native input is EXCLUSIVELY delegated to drawing_requirements for Save/reopen, Standard boundary drawing, Function-card auto endpoints, menu lifecycle and minimum-size acceptance. Root must not issue native input until handoff. Current Linux binary SHA25623CEAE18F53736B9B38A94E4185C45D6639825A3775DEB3FB814938B9CD00387 is built/tested; native follow-up pending.
+
+Previous46EE7BD9 trial closed normally (exec90275 exit0) after saving its Bottom anchor/FE fixture, reopening the DE6BD100 Left-anchor/FE/Flow-boundary fixture, SVG export and1100x700 inspection. Saved fixtures and precise results are in release-port-drawing.md. Protected user window7210312 was absent after the interrupted turn; root did not close it. If it returns, do not save/reset/close it. Earlier6228978 likewise absent; no recovery claim.
+
+Parallel release work: drawing_correctness reviews preserved state-core commit a63654f against current anchors/routing. drawing_linux owns behavior draft lifecycle implementation only in a new isolated worktree, excluding app.rs. All broader first-release requirements remain active and incomplete.
## Exact next actions
-1. Explorer/port acceptance is complete; preserve release-explorer-usability.md and the saved native/large-model fixtures. Finish native source comment-preserving reopen/error/recovery acceptance under release-source-integration.md.
- Source integration is committed at 3c87576; recovery/property guard follow-ups are implemented and tested with the explorer milestone. Identity provenance and conflict-aware Save All foundations are committed at 86bf36b/271f062. Root verified multi-file editing, Apply, Undo/Redo and Save natively; comment-preserving reopen and interrupted recovery still require runtime evidence. Preserve these tests/evidence rather than restarting integration. Then extend production nested syntax and REST bundles; do not promote the experiment unchanged.
-2. Linux routing acceptance is complete. Independent verification passed all 324 workspace tests, strict Clippy, formatting and optimized desktop/server builds at `23535b4`. Root's actual Linux/WSLg bend, label, Undo/Redo, Save and reopen passed. That run exposed a dialog-induced viewport recenter, fixed in `366ce44`: all 105 Windows desktop tests and strict desktop Clippy passed, Windows/Linux rebuilds passed, and root's rebuilt Linux native trial preserved the viewport throughout Save. `release-routing-linux.md` records exact scopes/hashes; final saved fixture is `ui-routing-viewport/linux-routing.arcadia` (33,546 bytes). The Linux test window was closed normally after saving. No full 325-test Linux invocation is claimed.
-3. Implement direct state/scenario/process gestures, R3 semantic drift and reviewed reconciliation, R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag workflows, navigable offline guidance and normative reST/API completion. These are required release work, not non-blocking limitations of a finished release.
-4. Run fresh requirements, correctness, native UI/UX and architecture reviews for the complete release; address material findings. Verify current Windows/Linux/MSRV builds, runtime workflows, documentation, packages and checksums before completion.
-
+1. Finish independent native acceptance of the frozen9A7FF074 Windows build, then root verifies the frozen23CEAE18 Linux build. Cursor, pan, nearest-hit and tooltip fixes have passed automated verification and root's current native drawing/port/pan workflow. Preserve exact binary scopes while broader implementation continues.
+2. Finish direct-drawing acceptance: default/minimum-size Windows and Linux, existing-port FE and Function-card auto endpoints, Flow/Standard boundary links, invalid/cancelled/folded targets, Save/reopen and SVG. Record exact scopes in release-port-drawing.md, update menu/source-tree acceptance and commit the coherent UX milestone.
+3. Integrate preserved state-core commit `a63654f` from isolated worktree only after this user-directed usability milestone. Implement direct state/scenario/process gestures, R3 semantic drift and reviewed reconciliation, R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag workflows, offline guidance and normative contracts. All remain required release work.
+4. Complete independent requirements, correctness, native UI/UX and architecture reviews for the entire release; address material findings. Verify current Windows/Linux/MSRV builds, runtime workflows, documentation, packages and checksums before completing the broad goal.
## Environment and reproducibility
Windows Rust 1.98.1; `CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e`. Release logs and native fixtures are under `D:/CodexBuilds/Capella2-release1-01a06e2e`. Use `cargo test --locked --workspace`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, and `cargo build --locked -p oa-desktop -p oa-server`. Generate the routing example with `cargo run --locked -p oa-core --example routing_workshop`; historical migration goldens stay frozen.
diff --git a/harness/TASK_LEDGER.md b/harness/TASK_LEDGER.md
index a0515a03..4e1dc5bf 100644
--- a/harness/TASK_LEDGER.md
+++ b/harness/TASK_LEDGER.md
@@ -27,7 +27,7 @@ System engineers can develop a coherent OA→SA system model, including iterativ
- Independent core review reproduced alias reservation and same-transaction allocation replacement defects; fixes have focused regression evidence. Typed desktop integration `fa096ce` passed full checks and native creation/generation, undo/redo, save/reopen and no-op synchronization trials. Long-reference previews were inspected at 1440×900 and 1100×700; detailed evidence is in STATE.md.
- Per-diagram pins and labels, source/API parity and shared orthogonal routing are implemented in `4e5a88d`, `a7c6f96`, `68b1eff` and `30c7db1`. `65fa9ab` resolves all six reproduced default SA label-overlap pairs. Native OA/SA editing, movement/undo, fold restoration, save/reopen, SVG inspection and short gestures at 1100×700 passed. Routing code/native acceptance and independent native follow-up are complete at `23535b4`; exact scope is recorded in release-routing-verification.md.
- The source experiment passed 23 tests, but an independent probe reproduced positional item-comment misattachment, shorthand description rejection and unsupported deletion. Whole-model equality does not establish source-comment fidelity. The production updater retained comment attachment on the same reorder probe; extend its parser/updater instead of copying the experimental parallel implementation.
-- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The source editor retains modular accepted source; nested element syntax is implemented and under native acceptance. Port/exchange/steps shorthand remains an independent future implementation slice required by the current authoring strategy.
+- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The source editor retains modular accepted source; nested element syntax and cross-file graphical updates passed Windows/Linux native acceptance at4e8575b. Port/exchange/steps shorthand remains an independent future implementation slice required by the current authoring strategy.
- Independent source review exposed declaration/comment reassignment during UUID changes and swaps, Linux FIFO hangs during disk admission, file-mode changes on save, and overlong detached-file receipt lifetimes. Command provenance, pre-open regular-file admission, permission retention and saved-detach receipt retirement resolve the reproduced cases. Exact probes and test scopes are in STATE.md.
## Assumptions to test
@@ -54,3 +54,5 @@ System engineers can develop a coherent OA→SA system model, including iterativ
Accept native per-file editing, safe project saves and exact source Undo, then extend production source parsing/updating using the independent probe findings as regressions and integrate REST bundles. Linux routing is accepted with actual WSLg Save/reopen; all11 current Linux disk integration tests also pass. Full release Linux acceptance remains open. Prototype persisted direct behavior gestures next. Continue R3 reconciliation, R6 data contracts, lifecycle/recovery, hosted editing, offline guidance and fresh Windows/Linux release acceptance; no subsystem checkpoint constitutes release completion.
Meaningful progress now includes verified drag and typed-port authoring, accepted editable routing with native save/reopen, group movement and minimum-size evidence, and tested source-bundle/nested-writeback foundations with independent limitations exposed. Production source and direct behavior usability remain open engineering tasks; feature counts alone do not establish completion.
+
+Current user-directed drawing evidence: palette creation and draggable per-placement port anchors passed scoped native Windows trials with Save/reopen; full485 Windows and488 Linux workspace suites passed before final pointer-release and hover refinements. Fresh independent review exposed post-release motion turning clicks into edits, nearest-port mispicking at overview zoom, and missing target identity feedback. The first two have production regressions and fixes; named owner/alias hover details and source-preserving pan are implemented, with final native acceptance pending. See release-port-drawing.md. The preceding goal turn made concrete progress through implementation, saved source/SVG artifacts, actual runtime verification and reproduced findings; it was not a wait or no-progress turn. Direct behavior and the broader release requirements remain active.
\ No newline at end of file
diff --git a/harness/release-context-menus.md b/harness/release-context-menus.md
new file mode 100644
index 00000000..5b7b0e3f
--- /dev/null
+++ b/harness/release-context-menus.md
@@ -0,0 +1,23 @@
+# Contextual menus and source tree
+
+## Outcome and implementation
+
+The latest user priorities are discoverable, contextual actions without a crowded Properties column, no permanent bend-button row, and a source-file folder tree. Preserve the accepted port creation workflow: participants retain a primary Add port button, and component ports retain Connect boundary exchange. Actions and right-click menus expose secondary operations. Existing forms and core commands remain the semantic boundary; menus do not introduce a command registry or change stored models.
+
+`context_menu.rs` supplies a controlled Iced overlay and right/left press anchors. It clamps to the viewport, scrolls long menus, skips disabled rows during keyboard navigation, and captures outside-dismissal press/release without click-through. `model_menu.rs` captures target identity plus document/revision/view context. Opening a menu does not select another object or apply a pending draft. Invocation validates the captured context before using existing guarded handlers. Canvas hit testing shares the rendered port/route geometry; Add bend here uses the clicked world point. Insert/Delete are scoped to canvas focus. Shift+F10/Menu uses selected identity with geometry only for the popup anchor.
+
+`source_tree.rs` groups exact logical file paths, retains duplicate basenames and aggregate draft/diagnostic indicators on collapsed folders, and uses the explorer's 32-pixel rows and 16-pixel guides. Collapse/Locate/file navigation are presentation state only. File closing remains restricted to clean detached buffers and never deletes disk files.
+
+## Current verification and remaining acceptance
+
+Independent reusable popup tests and strict Clippy passed (8 tests); source tree tests passed (6 tests). Root is running integrated desktop regressions. Independent review identified stale menu tokens dismissing a newer menu and keyboard menus losing selected port/bend identity; both fixes are implemented, with focused regressions and revalidation active. Initial compile errors were corrected; an obsolete modeling-picker type was removed after strict warning review.
+
+Native acceptance still required on the new binary: unselected actor Add port, selected port connection, edge/bend insertion/removal/reset, Undo/Redo, menu dismissal and keyboard focus, minimum-size clamping/scrolling, folder collapse/reveal/diagnostics, and Save/reopen. Preserve original Windows window 6228978, which contains user edits. Root alone owns native input. The isolated state-core commit `a63654f` is not merged until this user-directed desktop milestone is reviewed and accepted.
+
+The first native menu trial on binary `6A28BCB0…` reproduced a blank 280×12 strip on actor right-click. Iced reconstructs overlay instances while retaining layout; geometry held only in the transient layout instance was lost before draw/update. The reusable widget owner is fixing this root cause and adding a reconstruction regression; no UI acceptance is claimed for that build. The same native trial verified source-folder expansion/file selection and identified unnecessary horizontal scrolling at depth1; root adjusted shallow row widths to match the explorer's approach while preserving true depth for larger trees.
+
+Follow-up binary `1B430A5822563B76699F421EA2C50EC23D0A5A47582BABC49598751683978F99` fixes overlay reconstruction by deriving geometry from retained layout children; 10 reusable widget tests pass including reconstruction regressions. Integrated162 desktop tests, strict Clippy and build pass. Root native default-size actor/explorer Add port and typed Boundary exchange creation passed,43objects/26links/0checks. Add bend here, Insert, Delete, Shift+F10/keyboard Remove, Reset routing and Undo passed; outside dismissal did not select the underlying object. One first drag was unchanged; a repeat succeeded, and three independent production-widget first-drag/coalesced-release probes passed without reproducing a code defect. Save All wrote the isolated authored/project.arcadia with both Standard ports, Coordination interface and the restored FE detour. Independent reopen/minimum-size/source-tree QA is active. Native minimum/menu acceptance is not yet complete.
+
+Independent native review on1B430 passed reopen,1100x700 Actions clamping and keyboard use, source/property draft gates, outside dismissal, input Delete isolation, duplicate basenames and seven-deep source navigation. It reproduced Actions -> Escape swallowing the next Revert click. Root fixed the Area opening-release lifecycle and added a regression; rebuilt177-test drawing binary contains the fix, native revalidation still pending. Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e/context-menu-independent-native-review.md.
+
+Root reverified the Actions -> Escape -> first Revert fix natively on DE6BD100: edited the selected FE name, observed all modeling actions disabled, pressed Escape, clicked Revert once, and captured the restored name and Property draft reverted status. This closes the independent first-click finding. The final pointer-batch follow-up still requires its own runtime acceptance.
diff --git a/harness/release-port-drawing.md b/harness/release-port-drawing.md
new file mode 100644
index 00000000..6972f003
--- /dev/null
+++ b/harness/release-port-drawing.md
@@ -0,0 +1,83 @@
+# Direct connections and port placement
+
+## User workflow and decisions
+
+Architecture has explicit Select/move and perspective-specific drawing tools:
+OA Interaction, SA Functional exchange and SA Boundary exchange. Select a source
+and a highlighted target, or drag between them. Function cards generate/reuse
+typed ports through the existing atomic planner; explicit port hits retain the
+chosen identity. Boundary links use actual compatible component ports. Middle
+drag and canvas-focused arrows pan while retaining the chosen source. Escape
+cancels; empty space restarts. Hover details identify ports, owners and aliases.
+
+Port sides and normalized positions belong to each diagram placement, preserving
+semantic ownership. Dragging snaps to the owner's border; automatic placement
+uses direction, connected peers and available border space. Right-click Reset
+automatic position restores the derived anchor. Core commands, source bundles,
+JSON/REST and SVG use the same model and projected geometry. Each completed
+gesture makes one source-aware history operation. Menus and source-file folders
+are covered separately in release-context-menus.md.
+
+The bounded automatic router still permits dense-model crossings. Authored bends
+remain available; no perfect-layout guarantee is made. Input direction and carried
+items are not guessed from geometry. Drawing creates an empty carried-item list;
+Properties supplies the name and contract editing workflow.
+
+## Findings resolved during integration
+
+- Shared feasibility queries previously scanned unrelated interface roles for
+ every candidate. Commit93fd32c reduced the measured4000-port/4000-role picker
+ from804ms to3.169ms optimized without relaxing final validation.
+- Per-placement port anchors are integrated in35133de. Owner changes repair
+ graphical anchors atomically; raw source/API reject invalid owner references.
+- Automatic labels now avoid other edge strokes as well as nodes/labels, with
+ bounded deterministic placement. Both source-ID orders have regression tests.
+- Iced supplies a batch-final window cursor for every event. Ordered motion is
+ now authoritative for both press and release, preserving stationary-source
+ drags and preventing later motion from converting a click into a model edit.
+ Menu transitions invalidate cached pointer state through captured epochs.
+- Enlarged port hit areas overlap at overview zoom. All three picking paths now
+ choose the nearest glyph, with stable tie-breaking for coincident ports.
+- Port labels use a separate screen-space frame, avoiding text overprint and
+ keeping target identification legible while zoomed out.
+
+## Observed verification and remaining acceptance
+
+Full Windows workspace passed485 tests (264 core,185 desktop,36 server); full
+Linux passed488 (267 core,185 desktop,36 server), with strict Linux workspace
+Clippy and optimized desktop/server builds. These full runs precede the last
+ordered-release/hover changes. Four subsequent production lifecycle tests and
+nine external lifecycle probes passed. Final189-test desktop runs, strict
+workspace Clippy and rebuilds passed on both Windows and Linux. Native acceptance
+is still in progress; do not infer it from automated suites.
+
+Root operated DE6BD100 and46EE7BD9 Windows builds through Computer Use. OA palette
+click-click creation/history, SA direct FE/Flow boundary links, fast port-to-port
+drag, four port sides across trials, automatic reset/Undo, draft/menu guards and
+save/reopen passed. The46EE build was inspected at1100x700, rejected an input port
+as an outgoing source, and cancelled without dirtying the saved model. Its window
+closed normally, exit0. The final ordered-pointer/hover/pan build still needs
+native acceptance on both platforms; middle-button dragging is unavailable in
+the native tool, so use the actual keyboard-pan path plus production mouse probes.
+
+Saved evidence under `D:/CodexBuilds/Capella2-release1-01a06e2e/`:
+
+- `ui-port-drawing/authored/project.arcadia`:41objects,27links,0checks; Left anchor,
+ FE and Flow boundary link. Reopened on46EE and exported `project.svg` (5050bytes).
+ Root inspected its raster rendering for port direction, nesting and routes;
+ the renderer's fallback font differs from the application.
+- `ui-port-drawing-reviewed/authored/project.arcadia`:41objects,26links,0checks;
+ Bottom anchor and fast-drawn FE, source bytes inspected after native Save.
+- `port-drawing-final-tests.log`, `port-drawing-final-linux-*.log` and independent
+ review/probe folders retain exact command scopes.
+
+Root's final Windows build9A7FF074 passed fast FE drag, first Bottom port drag,
+repeated Left drag, readable hover name/owner/alias, arrow pan with source retained
+offscreen, target completion and one Undo. Current native window328292 has the
+isolated41object/26link model; independent native reviewer owns it exclusively
+for the remaining Windows tasks. Linux final build23CEAE18 passed189 desktop
+tests and strict workspace Clippy but still needs native verification.
+
+Pending: final menu lifecycle, Function-card auto endpoint creation, Standard
+boundary drawing, minimum layout, save/reopen and Linux native workflow.
+The broader first-release objective remains incomplete.
diff --git a/harness/release-source-integration.md b/harness/release-source-integration.md
index e13b6cfb..47646662 100644
--- a/harness/release-source-integration.md
+++ b/harness/release-source-integration.md
@@ -209,3 +209,6 @@ saved project with 22 objects, 13 links, zero checks and all changes saved; the
SA allocation view retained the same participant/function ports and exchanges.
Minimum-size follow-up, independent native review and actual rebuilt Linux
nesting trial are still pending.
+# Linux nested source acceptance — 2026-09-06
+
+Root operated the optimized Linux desktop SHA256 `C794426BD91980B2384B92884176542B2B081FA7B4ADE8ADA5D7EECEB72D6351` through Computer Use. The isolated six-file fixture was on the native Linux filesystem at `/home/sorokinoleg/.cache/arcadia/nested-source-native-20260906/project.arcadia`. Native Open loaded 22 objects, 13 links and zero checks. OA allocated hierarchy and SA system/actor nesting, real function-port triangles and component-port squares were visually inspected. Textual model opened the nested `sa/reporting.arcadia` declarations with highlighting, stable UUIDs and comments. A comment edit was applied, model Undo restored the original accepted file and clean state, Redo restored the edit, Save all completed, and native Open reopened the entry. The comment and nested source were personally verified after reopening, with all changes saved and zero checks. The Linux validation window closed normally; the original Windows window containing user edits was untouched. This is source/comment and rendered hierarchy evidence; the cross-file graphical reparent workflow was exercised on Windows, not repeated on Linux. Save latency was not benchmarked.
diff --git a/harness/release-state-gesture-design.md b/harness/release-state-gesture-design.md
index fcf50d0e..13f6416a 100644
--- a/harness/release-state-gesture-design.md
+++ b/harness/release-state-gesture-design.md
@@ -8,7 +8,8 @@ Connection routing became the user's next priority after this design handoff.
Reuse Diagram/NodePlacement and existing atomic commands. Prefer a typed
`ProjectionKind::StateMachine { machine: Id }` over desktop-only coordinates or
a second persisted behavior-layout store. Existing projection strings remain
-valid. Proposed source: `projection StateMachine { machine @operating_modes; };`.
+valid. Use the existing constructor syntax:
+`projection { StateMachine { machine @operating_modes; }; };`.
The context references a same-layer machine and scopes placements to its machine,
regions and vertices. Explicit creation initializes existing contents in one
transaction; rendering must not dirty a model. Missing placements remain legal
@@ -85,3 +86,39 @@ two regions, a nested composite and guarded transitions. Try invalid edges into
Initial, invalid history reconnection, Escape mid-drag, a folded composite,
root-Event selection and save/reopen. Record task completion, discoverability,
ownership clarity and recoverability; no such native result is claimed here.
+
+## Current-interface review (2026-09-06)
+
+Independent core and desktop maps confirm the payload projection remains the
+smallest persisted boundary. Preserve unit-variant JSON strings; the new value
+is `{"StateMachine":{"machine":"UUID"}}`. A machine context is required for
+empty and partial views; inferring it from placements cannot represent an empty
+diagram, while an optional unrelated Diagram context adds invalid combinations.
+Use one narrow state-view membership predicate and preserve existing projections'
+legal hidden placements. Dedicated views disappear with their machine; moving
+elements across machines must repair incompatible views/routes in the same Batch
+or reject atomically.
+
+The shared router now owns route pins, label placement and grouped translation.
+An executed probe found that merely retaining an External self-transition yields
+an 18 px out-and-back stub. Add genuine self-loop and state-symbol endpoint
+geometry in the shared routing/projection path; Entry/Exit snapping must precede
+draw/hit testing. Internal transitions remain rows, and folded descendants must
+not create false visible self-transitions. Probe:
+`D:/CodexBuilds/Capella2-source-document-contract/examples/state_projection_boundary_probe.rs`.
+
+Before adding gestures, close existing behavior draft gaps: Start/Edit must not
+overwrite an unresolved form, Submit must retain it until Document source/model
+commit succeeds, and Save/Close/navigation must account for behavior drafts.
+Canvas events carry the existing document/diagram/revision/epoch context and
+recheck it before acceptance. Generic MoveNode message delivery selects the
+current active diagram and is insufficient for delayed behavior gestures.
+
+Keep Select, State/symbol, Region, Transition and Fit in a compact toolbar.
+Create inside an explicitly highlighted eligible owner, place missing ancestry
+in the same command batch, and expose unplaced semantic elements. Retain the
+behavior view independently of selection, including root Events. Reconnect
+clones the relation and changes one endpoint; preserve all other data and reject
+invalid cardinalities. Keep form editing in a bounded panel so the canvas remains
+usable at 1100×700. These are decision-ready interfaces; no implementation or
+native gesture acceptance is claimed.