fix(agent): backfill generated screen interactions

This commit is contained in:
Fini 2026-07-30 08:59:39 +08:00
parent ee44d9b2c0
commit e29ea4229c
21 changed files with 1855 additions and 167 deletions

View file

@ -31,12 +31,14 @@
//! accumulates screens incrementally and each turn should link the new
//! ones in. At PREVIEW-ENTRY time it is deliberately stricter: an author
//! who has started marking screens by hand is mid-way through an
//! intentional App Mode setup, and silently binding additional nav/back
//! taps behind their back on every preview open would be a confusing,
//! intentional App Mode setup, and silently binding additional nav taps
//! behind their back on every preview open would be a confusing,
//! preview-only side effect they never asked for and can't see in the
//! editor. So this module's gate is document-wide ("does ANY authored
//! marker exist"), not per-node — user/model intent wins outright rather
//! than being merged with.
//! Generation-only back/card completion is deliberately not part of this
//! cloned-state fallback; those interactions are persisted by cleanup.
//!
//! ## Why op-host-native can depend on op-orchestrator
//!

View file

@ -610,14 +610,18 @@ pub fn run_cleanup_passes_with_summary(
// point, so both the classic and loop-finalize paths pick it up.
crate::unify_shared_status_bar::unify_shared_status_bar(sink);
// Track A of the interactive-preview plan: mark screen-shaped top-level
// frames + wire their nav tabs / back buttons, so a multi-screen document
// enters App Mode preview with zero model cooperation. Runs LAST — after
// bottom-nav anchoring/dedup/distribution above have settled the final
// nav shape, so tab-item discovery sees the real tree, not an
// in-progress one. Whole-doc (scans `sink.state()`, not `root_ids`) so
// it also links PRE-EXISTING screens from earlier turns, matching
// `avatar_repair` above.
// Establish final screen routes first. The cleanup-only semantic pass can
// then persist only fact-proven back/card interactions against those real
// routes, before the label-matching nav fallback. Keeping the semantic pass
// outside public `wire_screen_navigation` prevents Cmd+P's cloned-state
// fallback from creating preview-only interactions that never reach the
// saved document.
crate::wire_screen_navigation::ensure_screen_routes(sink);
crate::geometry_validation::wire_interaction_backfill(sink);
// Track A fallback: wire bottom-nav/sidebar tabs after final chrome shape
// and semantic interactions are settled. Whole-doc (scans `sink.state()`,
// not `root_ids`) so it also links pre-existing screens from earlier turns.
crate::wire_screen_navigation::wire_screen_navigation(sink);
counter.checkpoint(summary, CheckCategory::Structure);
}

View file

@ -142,6 +142,92 @@ fn an_over_bold_screen_counts_its_repairs_under_hierarchy() {
);
}
#[test]
fn interaction_backfill_edits_are_counted_as_structure_repairs() {
let nodes: Vec<PenNode> = serde_json::from_value(json!([
{
"type": "frame", "id": "entry", "name": "Discover", "screen": "/",
"x": 0, "y": 0, "width": 390, "height": 844, "layout": "none",
"children": [{
"type": "frame", "id": "row", "x": 20, "y": 180,
"width": 350, "height": 170, "layout": "horizontal",
"children": [
{
"type": "frame", "id": "card-a", "width": 100, "height": 150,
"layout": "vertical", "children": [
{"type":"image","id":"image-a","width":100,"height":90,
"src":"https://example.invalid/a.png"},
{"type":"text","id":"title-a","content":"A","width":100,"height":20}
]
},
{
"type": "frame", "id": "card-b", "width": 100, "height": 150,
"layout": "vertical", "children": [
{"type":"image","id":"image-b","width":100,"height":90,
"src":"https://example.invalid/b.png"},
{"type":"text","id":"title-b","content":"B","width":100,"height":20}
]
}
]
}]
},
{
"type": "frame", "id": "detail", "name": "Movie Detail",
"screen": "/detail", "x": 450, "y": 0, "width": 390, "height": 844,
"layout": "none", "children": [
{
"type": "frame", "id": "back", "x": 24, "y": 80,
"width": 44, "height": 44, "layout": "none", "children": [{
"type":"icon_font","id":"back-icon","x":12,"y":12,
"width":20,"height":20,"iconFontName":"arrow-left"
}]
},
{"type":"frame","id":"detail-content","x":0,"y":160,
"width":390,"height":600,"children":[]}
]
}
]))
.expect("fixture nodes");
let mut sink = VecDocSink::new();
sink.state.apply(EditorCommand::InsertSubtree {
nodes,
parent_id: NodeId::NONE,
page_id: None,
});
let root_ids = sink
.state
.active_children()
.iter()
.map(|node| node.id_str().to_string())
.collect::<Vec<_>>();
let root_refs = root_ids.iter().map(String::as_str).collect::<Vec<_>>();
sink.applied.clear();
let mut summary = RepairSummary::default();
run_cleanup_passes_with_summary(&mut sink, &plan(), &root_refs, &mut summary);
let interaction_patches = sink
.applied
.iter()
.filter(|command| {
matches!(
command,
EditorCommand::PatchNodeData { patch_json, .. }
if patch_json.contains(r#""pop":null"#)
|| patch_json.contains(r#""push":"\"/detail\"""#)
)
})
.count();
assert_eq!(
interaction_patches, 3,
"one back frame and two cards must be persisted"
);
assert!(
summary.repairs_for(CheckCategory::Structure) >= interaction_patches,
"the quality credential must count every interaction patch: {summary:?}"
);
}
#[test]
fn the_tally_never_exceeds_the_edits_the_sink_actually_took() {
// The credential's number must be defensible against the document: it

View file

@ -101,7 +101,11 @@ pub(super) fn push_mobile_bottom_gap_diagnostic(
/// the authored `role` semantic the corpus mandates, or the resolved shape a
/// tab bar always has — a full-width band in the nav height range laying out
/// at least three evenly-sized tap targets in a row.
fn is_bottom_nav_shape(child: &Value, root_rect: &Rect, rects: &HashMap<String, Rect>) -> bool {
pub(super) fn is_bottom_nav_shape(
child: &Value,
root_rect: &Rect,
rects: &HashMap<String, Rect>,
) -> bool {
if child.get("role").and_then(Value::as_str) == Some("bottom-tab-bar") {
return true;
}

View file

@ -14,7 +14,9 @@
use op_editor_core::{EditorState, NodeId};
use super::text_collision::push_text_collision_diagnostics;
use super::{collect_diagnostics, resolved_rects, MAX_DIAGNOSTICS};
use super::{
collect_diagnostics, push_interaction_backfill_diagnostics, resolved_rects, MAX_DIAGNOSTICS,
};
/// Geometry diagnostics for exactly the subtrees rooted at `root_ids` —
/// the `inserted_root_ids` one subtask's `InsertSubtree` produced. Runs the
@ -30,6 +32,7 @@ pub(crate) fn geometry_diagnostics_for_roots(
) -> Vec<String> {
let rects = resolved_rects(state);
let mut out = Vec::new();
push_interaction_backfill_diagnostics(state, Some(root_ids), &mut out);
for root_id in root_ids {
if out.len() >= MAX_DIAGNOSTICS {
break;

View file

@ -0,0 +1,480 @@
//! Shared facts for deterministic interaction backfill and its geometry echo.
//!
//! Back controls are recognized only from resolved geometry and dedicated icon
//! data (`iconFontName` / `iconId`). Card routing requires one unambiguous
//! detail-shaped screen and repeated image+text subtree shapes. Node names,
//! ids, and inferred intent never participate.
use std::collections::{BTreeMap, BTreeSet, HashSet};
use jian_ops_schema::node::PenNode;
use super::geometry_bottom_gap::is_bottom_nav_shape;
use super::*;
const HEADER_STRIP_MAX_Y: f64 = 120.0;
const LEFT_STRIP_MAX_X: f64 = 120.0;
const MAX_BACK_CONTROL_SIZE: f64 = 56.0;
const SQUARE_EPS: f64 = 1.0;
const BOUNDS_EPS: f64 = 1.0;
#[derive(Clone, Debug, PartialEq, Eq)]
struct InteractionTarget {
node_id: String,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
struct InteractionBackfillFacts {
back_targets: Vec<InteractionTarget>,
card_targets: Vec<InteractionTarget>,
detail_route: Option<String>,
}
struct ScreenFact {
id: String,
route: String,
value: Value,
rect: Rect,
}
#[derive(Clone, Debug)]
struct BackShapeFact {
node_id: String,
can_wire: bool,
has_pop: bool,
}
/// Persist every interaction that the shared fact scan proves unambiguous.
/// This is cleanup-only; preview's cloned-state fallback never calls it.
pub(crate) fn wire_interaction_backfill(sink: &mut dyn DocSink) {
let facts = interaction_backfill_facts(sink.state());
for target in facts.back_targets {
sink.apply(EditorCommand::PatchNodeData {
node_id: NodeId::new(target.node_id),
patch_json: r#"{"events":{"onTap":[{"pop":null}]}}"#.to_string(),
page_id: None,
});
}
if let Some(route) = facts.detail_route {
for target in facts.card_targets {
sink.apply(EditorCommand::PatchNodeData {
node_id: NodeId::new(target.node_id),
patch_json: crate::wire_screen_navigation::navigate_patch("push", &route),
page_id: None,
});
}
}
}
/// Shape-only strict back-control fact for compatibility consumers that must
/// recognize an already-wired detail screen without repeating M1's detector.
pub(crate) fn screen_has_back_control_shape(state: &EditorState, screen_id: &str) -> bool {
let rects = resolved_rects(state);
let Some(root) = op_editor_core::walkers::find_node(
state.active_children(),
&NodeId::new(screen_id.to_string()),
) else {
return false;
};
let Some(root_rect) = rects.get(screen_id).copied() else {
return false;
};
let Ok(value) = serde_json::to_value(root) else {
return false;
};
let mut shapes = Vec::new();
collect_back_shapes(&value, &root_rect, &rects, false, &mut shapes);
!shapes.is_empty()
}
/// Whole-document interaction-gap echo. The repair and echo consume the exact
/// same [`interaction_backfill_facts`] result, so their eligibility cannot drift.
pub(super) fn push_interaction_backfill_diagnostics(
state: &EditorState,
root_ids: Option<&[String]>,
out: &mut Vec<String>,
) {
if out.len() >= MAX_DIAGNOSTICS {
return;
}
let facts = interaction_backfill_facts(state);
let allowed = root_ids.map(|ids| descendant_ids_for_roots(state, ids));
let included = |target: &InteractionTarget| {
allowed
.as_ref()
.is_none_or(|ids| ids.contains(&target.node_id))
};
let back_count = facts
.back_targets
.iter()
.filter(|target| included(target))
.count();
if back_count > 0 {
out.push(format!(
"interaction-unwired-back: {back_count} non-entry top-left back control(s) have no \
onTap; bind events.onTap to pop."
));
}
if out.len() >= MAX_DIAGNOSTICS {
return;
}
let card_count = facts
.card_targets
.iter()
.filter(|target| included(target))
.count();
if card_count > 0 {
if let Some(route) = facts.detail_route {
out.push(format!(
"interaction-unwired-cards: {card_count} repeated image+text card(s) have no \
onTap, and the document has one detail route {route:?}; bind their onTap to \
push that exact route."
));
}
}
}
fn interaction_backfill_facts(state: &EditorState) -> InteractionBackfillFacts {
let rects = resolved_rects(state);
let screens = collect_screen_facts(state, &rects);
if screens.is_empty() {
return InteractionBackfillFacts::default();
}
let mut existing_push_routes = BTreeSet::new();
for root in state.active_children() {
if let Ok(value) = serde_json::to_value(root) {
collect_push_routes(&value, &mut existing_push_routes);
}
}
let mut back_targets = Vec::new();
let mut detail_indices = Vec::new();
for (index, screen) in screens.iter().enumerate() {
let mut shapes = Vec::new();
collect_back_shapes(&screen.value, &screen.rect, &rects, false, &mut shapes);
if screen.route != "/" {
let unreachable_single =
screens.len() == 1 && !existing_push_routes.contains(screen.route.as_str());
if !unreachable_single {
back_targets.extend(shapes.iter().filter(|fact| fact.can_wire).map(|fact| {
InteractionTarget {
node_id: fact.node_id.clone(),
}
}));
}
let has_trailing_bottom_nav = node_children(&screen.value)
.last()
.is_some_and(|last| is_bottom_nav_shape(last, &screen.rect, &rects));
let has_return_control = shapes.iter().any(|shape| shape.can_wire || shape.has_pop);
if !has_trailing_bottom_nav && has_return_control {
detail_indices.push(index);
}
}
}
if detail_indices.len() != 1 {
return InteractionBackfillFacts {
back_targets,
card_targets: Vec::new(),
detail_route: None,
};
}
let detail = &screens[detail_indices[0]];
let mut card_targets = Vec::new();
for screen in &screens {
if screen.id == detail.id {
continue;
}
collect_card_targets(&screen.value, false, &mut card_targets);
}
InteractionBackfillFacts {
back_targets,
card_targets,
detail_route: Some(detail.route.clone()),
}
}
fn collect_screen_facts(state: &EditorState, rects: &HashMap<String, Rect>) -> Vec<ScreenFact> {
state
.active_children()
.iter()
.filter_map(|node| {
let PenNode::Frame(frame) = node else {
return None;
};
let route = frame
.screen
.clone()
.filter(|route| route.starts_with('/'))?;
let rect = rects.get(&frame.base.id).copied()?;
let value = serde_json::to_value(node).ok()?;
Some(ScreenFact {
id: frame.base.id.clone(),
route,
value,
rect,
})
})
.collect()
}
fn collect_back_shapes(
node: &Value,
root_rect: &Rect,
rects: &HashMap<String, Rect>,
ancestor_has_on_tap: bool,
out: &mut Vec<BackShapeFact>,
) {
let node_has_on_tap = has_on_tap(node);
if is_back_control_shape(node, root_rect, rects) {
if let Some(node_id) = node.get("id").and_then(Value::as_str) {
out.push(BackShapeFact {
node_id: node_id.to_string(),
can_wire: !has_events(node) && !ancestor_has_on_tap && !subtree_has_on_tap(node),
has_pop: !ancestor_has_on_tap
&& !descendants_have_on_tap(node)
&& has_exact_pop_action(node),
});
}
return;
}
let descendant_ancestor_has_on_tap = ancestor_has_on_tap || node_has_on_tap;
for child in node_children(node) {
collect_back_shapes(child, root_rect, rects, descendant_ancestor_has_on_tap, out);
}
}
fn is_back_control_shape(node: &Value, root_rect: &Rect, rects: &HashMap<String, Rect>) -> bool {
if node.get("type").and_then(Value::as_str) != Some("frame") {
return false;
}
let children = node_children(node);
let [icon] = children.as_slice() else {
return false;
};
if !is_back_icon_data(icon) {
return false;
}
let Some(rect) = node
.get("id")
.and_then(Value::as_str)
.and_then(|id| rects.get(id))
.copied()
else {
return false;
};
if rect.w <= 0.0
|| rect.h <= 0.0
|| rect.w > MAX_BACK_CONTROL_SIZE
|| rect.h > MAX_BACK_CONTROL_SIZE
|| (rect.w - rect.h).abs() > SQUARE_EPS
{
return false;
}
let local_x = rect.x - root_rect.x;
let local_y = rect.y - root_rect.y;
local_x >= -BOUNDS_EPS
&& local_x + rect.w <= LEFT_STRIP_MAX_X + BOUNDS_EPS
&& (-BOUNDS_EPS..HEADER_STRIP_MAX_Y).contains(&local_y)
}
fn is_back_icon_data(icon: &Value) -> bool {
let raw = match icon.get("type").and_then(Value::as_str) {
Some("icon_font") => icon.get("iconFontName").and_then(Value::as_str),
Some("path") => icon.get("iconId").and_then(Value::as_str),
_ => None,
};
raw.and_then(|name| name.trim().rsplit(':').next())
.map(str::to_ascii_lowercase)
.is_some_and(|name| matches!(name.as_str(), "chevron-left" | "arrow-left"))
}
fn collect_card_targets(
parent: &Value,
ancestor_has_on_tap: bool,
out: &mut Vec<InteractionTarget>,
) {
let parent_chain_has_on_tap = ancestor_has_on_tap || has_on_tap(parent);
let mut groups: BTreeMap<String, Vec<&Value>> = BTreeMap::new();
if !parent_chain_has_on_tap {
for child in node_children(parent) {
if is_card_container(child)
&& !has_events(child)
&& !subtree_has_on_tap(child)
&& subtree_has_text(child)
&& subtree_has_image(child)
{
groups.entry(subtree_shape(child)).or_default().push(child);
}
}
}
let mut selected = HashSet::new();
for group in groups.values().filter(|group| group.len() >= 2) {
for card in group {
if let Some(id) = card.get("id").and_then(Value::as_str) {
selected.insert(id.to_string());
out.push(InteractionTarget {
node_id: id.to_string(),
});
}
}
}
for child in node_children(parent) {
let selected_here = child
.get("id")
.and_then(Value::as_str)
.is_some_and(|id| selected.contains(id));
if !selected_here {
collect_card_targets(child, parent_chain_has_on_tap, out);
}
}
}
fn is_card_container(value: &Value) -> bool {
matches!(
value.get("type").and_then(Value::as_str),
Some("frame" | "group" | "rectangle")
) && !node_children(value).is_empty()
}
fn subtree_shape(value: &Value) -> String {
let kind = value
.get("type")
.and_then(Value::as_str)
.unwrap_or("unknown");
let child_shapes = node_children(value)
.iter()
.map(|child| subtree_shape(child))
.collect::<Vec<_>>()
.join(",");
format!("{kind}[{child_shapes}]")
}
fn subtree_has_text(value: &Value) -> bool {
value.get("type").and_then(Value::as_str) == Some("text")
|| node_children(value)
.iter()
.any(|child| subtree_has_text(child))
}
fn subtree_has_image(value: &Value) -> bool {
let has_image_fill = value
.get("fill")
.and_then(Value::as_array)
.is_some_and(|fills| {
fills
.iter()
.any(|fill| fill.get("type").and_then(Value::as_str) == Some("image"))
});
value.get("type").and_then(Value::as_str) == Some("image")
|| value
.get("imagePrompt")
.and_then(Value::as_str)
.is_some_and(|prompt| !prompt.trim().is_empty())
|| has_image_fill
|| node_children(value)
.iter()
.any(|child| subtree_has_image(child))
}
fn has_events(value: &Value) -> bool {
value.get("events").is_some()
}
fn has_on_tap(value: &Value) -> bool {
value
.get("events")
.and_then(|events| events.get("onTap"))
.and_then(Value::as_array)
.is_some_and(|actions| !actions.is_empty())
}
fn has_exact_pop_action(value: &Value) -> bool {
let Some(actions) = value
.get("events")
.and_then(|events| events.get("onTap"))
.and_then(Value::as_array)
else {
return false;
};
let [action] = actions.as_slice() else {
return false;
};
action
.as_object()
.is_some_and(|action| action.len() == 1 && action.get("pop").is_some_and(Value::is_null))
}
fn descendants_have_on_tap(value: &Value) -> bool {
node_children(value)
.iter()
.any(|child| subtree_has_on_tap(child))
}
fn subtree_has_on_tap(value: &Value) -> bool {
has_on_tap(value)
|| node_children(value)
.iter()
.any(|child| subtree_has_on_tap(child))
}
fn collect_push_routes(value: &Value, out: &mut BTreeSet<String>) {
if let Some(actions) = value
.get("events")
.and_then(|events| events.get("onTap"))
.and_then(Value::as_array)
{
for raw in actions
.iter()
.filter_map(|action| action.get("push"))
.filter_map(Value::as_str)
{
let decoded = serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_string());
out.insert(decoded);
}
}
for child in node_children(value) {
collect_push_routes(child, out);
}
}
fn descendant_ids_for_roots(state: &EditorState, root_ids: &[String]) -> HashSet<String> {
let mut ids = HashSet::new();
for root_id in root_ids {
let Some(root) = op_editor_core::walkers::find_node(
state.active_children(),
&NodeId::new(root_id.clone()),
) else {
continue;
};
if let Ok(value) = serde_json::to_value(root) {
collect_ids(&value, &mut ids);
}
}
ids
}
fn collect_ids(value: &Value, out: &mut HashSet<String>) {
if let Some(id) = value.get("id").and_then(Value::as_str) {
out.insert(id.to_string());
}
for child in node_children(value) {
collect_ids(child, out);
}
}
fn node_children(value: &Value) -> Vec<&Value> {
value
.get("children")
.and_then(Value::as_array)
.map(|children| children.iter().collect())
.unwrap_or_default()
}
#[cfg(test)]
#[path = "geometry_interaction_backfill_tests.rs"]
mod tests;

View file

@ -0,0 +1,567 @@
use super::*;
use crate::test_support::VecDocSink;
use jian_ops_schema::PenDocument;
use op_editor_core::EditorState;
use serde_json::{json, Value};
fn sink_from_value(value: Value) -> VecDocSink {
let document: PenDocument = serde_json::from_value(value).expect("valid document");
VecDocSink {
state: EditorState::from_document(document),
applied: Vec::new(),
batch_depth: 0,
}
}
fn icon_back(id: &str, x: f64, y: f64) -> Value {
json!({
"type": "frame",
"id": id,
"x": x,
"y": y,
"width": 44,
"height": 44,
"layout": "none",
"children": [{
"type": "icon_font",
"id": format!("{id}-icon"),
"x": 12,
"y": 12,
"width": 20,
"height": 20,
"iconFontName": "chevron-left"
}]
})
}
fn path_back(id: &str, icon_id: Option<&str>, name: Option<&str>) -> Value {
let mut path = json!({
"type": "path",
"id": format!("{id}-path"),
"x": 12,
"y": 12,
"width": 20,
"height": 20,
"d": "M15 18l-6-6 6-6"
});
if let Some(icon_id) = icon_id {
path["iconId"] = json!(icon_id);
}
if let Some(name) = name {
path["name"] = json!(name);
}
json!({
"type": "frame",
"id": id,
"x": 24,
"y": 80,
"width": 44,
"height": 44,
"layout": "none",
"children": [path]
})
}
fn card(id: &str) -> Value {
json!({
"type": "frame",
"id": id,
"width": 100,
"height": 150,
"layout": "vertical",
"children": [
{
"type": "image",
"id": format!("{id}-image"),
"width": 100,
"height": 90,
"src": "https://example.invalid/poster.png"
},
{
"type": "text",
"id": format!("{id}-title"),
"width": 100,
"height": 20,
"content": "Movie"
}
]
})
}
fn entry_screen(cards: Vec<Value>) -> Value {
json!({
"type": "frame",
"id": "entry",
"name": "Discover",
"screen": "/",
"x": 0,
"y": 0,
"width": 390,
"height": 844,
"layout": "none",
"children": [{
"type": "frame",
"id": "card-row",
"x": 20,
"y": 180,
"width": 350,
"height": 170,
"layout": "horizontal",
"gap": 12,
"children": cards
}]
})
}
fn detail_screen(id: &str, route: &str, back: Value) -> Value {
json!({
"type": "frame",
"id": id,
"name": "Movie Detail",
"screen": route,
"x": 450,
"y": 0,
"width": 390,
"height": 844,
"layout": "none",
"children": [
back,
{
"type": "frame",
"id": format!("{id}-content"),
"x": 0,
"y": 160,
"width": 390,
"height": 600,
"children": []
}
]
})
}
fn document(children: Vec<Value>) -> Value {
json!({ "version": "1.0", "children": children })
}
fn node_value(state: &EditorState, id: &str) -> Value {
let node =
op_editor_core::walkers::find_node(state.active_children(), &NodeId::new(id.to_string()))
.unwrap_or_else(|| panic!("missing node {id}"));
serde_json::to_value(node).expect("serialize node")
}
fn on_tap(state: &EditorState, id: &str) -> Option<Value> {
node_value(state, id)
.get("events")
.and_then(|events| events.get("onTap"))
.cloned()
}
#[test]
fn strict_back_and_isomorphic_cards_are_wired_and_echo_clears() {
let mut sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail_screen("detail", "/detail", icon_back("back", 24.0, 80.0)),
]));
let before = crate::geometry_validation::geometry_diagnostics(&sink.state);
assert!(before
.iter()
.any(|line| line.starts_with("interaction-unwired-back: 1")));
assert!(before
.iter()
.any(|line| line.starts_with("interaction-unwired-cards: 2")));
wire_interaction_backfill(&mut sink);
assert_eq!(on_tap(&sink.state, "back"), Some(json!([{"pop": null}])));
assert!(
on_tap(&sink.state, "back-icon").is_none(),
"the square frame, not its icon child, owns the tap target"
);
for card_id in ["movie-a", "movie-b"] {
assert_eq!(
on_tap(&sink.state, card_id),
Some(json!([{"push": "\"/detail\""}])),
"push must carry a Tier-1 quoted string literal"
);
}
let after = crate::geometry_validation::geometry_diagnostics(&sink.state);
assert!(
after
.iter()
.all(|line| !line.starts_with("interaction-unwired-")),
"{after:?}"
);
}
#[test]
fn root_scoped_echo_reports_only_targets_inside_the_inserted_subtree() {
let sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail_screen("detail", "/detail", icon_back("back", 24.0, 80.0)),
]));
let mut cards_only = Vec::new();
push_interaction_backfill_diagnostics(
&sink.state,
Some(&["card-row".to_string()]),
&mut cards_only,
);
assert_eq!(cards_only.len(), 1);
assert!(cards_only[0].starts_with("interaction-unwired-cards: 2"));
let mut detail_only = Vec::new();
push_interaction_backfill_diagnostics(
&sink.state,
Some(&["detail".to_string()]),
&mut detail_only,
);
assert_eq!(detail_only.len(), 1);
assert!(detail_only[0].starts_with("interaction-unwired-back: 1"));
}
#[test]
fn entry_screen_back_shape_is_never_wired() {
let mut entry = entry_screen(vec![card("movie-a"), card("movie-b")]);
entry["children"]
.as_array_mut()
.unwrap()
.insert(0, icon_back("entry-menu", 24.0, 80.0));
let mut sink = sink_from_value(document(vec![
entry,
detail_screen("detail", "/detail", icon_back("detail-back", 24.0, 80.0)),
]));
wire_interaction_backfill(&mut sink);
assert!(on_tap(&sink.state, "entry-menu").is_none());
assert_eq!(
on_tap(&sink.state, "detail-back"),
Some(json!([{"pop": null}]))
);
}
#[test]
fn two_detail_candidates_abandon_all_card_pushes_without_guessing() {
let mut second = detail_screen(
"detail-b",
"/detail-b",
icon_back("detail-b-back", 24.0, 80.0),
);
second["x"] = json!(900);
let mut sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail_screen(
"detail-a",
"/detail-a",
icon_back("detail-a-back", 24.0, 80.0),
),
second,
]));
wire_interaction_backfill(&mut sink);
assert!(on_tap(&sink.state, "movie-a").is_none());
assert!(on_tap(&sink.state, "movie-b").is_none());
assert_eq!(
on_tap(&sink.state, "detail-a-back"),
Some(json!([{"pop": null}]))
);
assert_eq!(
on_tap(&sink.state, "detail-b-back"),
Some(json!([{"pop": null}]))
);
}
#[test]
fn existing_events_are_byte_stable_and_clean_siblings_still_qualify() {
let mut authored_card = card("movie-authored");
authored_card["events"] = json!({
"onTap": [{"custom_action": null}],
"onHover": [{"set": {"selected": true}}]
});
let mut authored_back = icon_back("back", 24.0, 80.0);
authored_back["events"] = json!({"onTap": [{"pop": null}]});
let mut sink = sink_from_value(document(vec![
entry_screen(vec![authored_card, card("movie-a"), card("movie-b")]),
detail_screen("detail", "/detail", authored_back),
]));
let card_events_before = node_value(&sink.state, "movie-authored")["events"].clone();
let back_events_before = node_value(&sink.state, "back")["events"].clone();
wire_interaction_backfill(&mut sink);
assert_eq!(
node_value(&sink.state, "movie-authored")["events"],
card_events_before
);
assert_eq!(
node_value(&sink.state, "back")["events"],
back_events_before
);
assert_eq!(
on_tap(&sink.state, "movie-a"),
Some(json!([{"push": "\"/detail\""}]))
);
assert_eq!(
on_tap(&sink.state, "movie-b"),
Some(json!([{"push": "\"/detail\""}]))
);
}
#[test]
fn conflicting_back_action_is_not_a_detail_fact_and_never_routes_cards() {
let mut conflicting_back = icon_back("back", 24.0, 80.0);
conflicting_back["events"] = json!({
"onTap": [{"pop": null}, {"custom_action": null}]
});
let mut sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail_screen("detail", "/detail", conflicting_back),
]));
let before = node_value(&sink.state, "back")["events"].clone();
wire_interaction_backfill(&mut sink);
assert_eq!(node_value(&sink.state, "back")["events"], before);
assert!(on_tap(&sink.state, "movie-a").is_none());
assert!(on_tap(&sink.state, "movie-b").is_none());
}
#[test]
fn exact_pop_detail_stays_navless_before_backfill_and_routes_cards() {
let mut entry = entry_screen(vec![card("movie-a"), card("movie-b")]);
let entry_tabs = ["Home", "Search", "Library"]
.into_iter()
.enumerate()
.map(|(index, label)| {
json!({
"type": "frame",
"id": format!("tab-{index}"),
"width": 130,
"height": 64,
"children": [{
"type": "text",
"id": format!("tab-{index}-label"),
"content": label,
"fontSize": 12
}]
})
})
.collect::<Vec<_>>();
entry["children"].as_array_mut().unwrap().push(json!({
"type": "frame",
"id": "entry-nav",
"role": "bottom-tab-bar",
"x": 0,
"y": 780,
"width": 390,
"height": 64,
"layout": "horizontal",
"children": entry_tabs
}));
let mut back = icon_back("back", 24.0, 80.0);
back["events"] = json!({"onTap": [{"pop": null}]});
let mut detail = detail_screen("detail", "/detail", back);
detail["name"] = json!("Library");
let mut sink = sink_from_value(document(vec![entry, detail]));
crate::unify_shared_nav::unify_shared_nav(&mut sink);
wire_interaction_backfill(&mut sink);
assert_eq!(
node_value(&sink.state, "detail")["children"]
.as_array()
.unwrap()
.len(),
2
);
assert_eq!(
on_tap(&sink.state, "movie-a"),
Some(json!([{"push": "\"/detail\""}]))
);
assert_eq!(
on_tap(&sink.state, "movie-b"),
Some(json!([{"push": "\"/detail\""}]))
);
}
#[test]
fn invalid_detail_route_is_not_a_screen_fact() {
let mut detail = detail_screen("detail", "movie-detail", icon_back("back", 24.0, 80.0));
detail["x"] = json!(450);
let mut sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail,
]));
wire_interaction_backfill(&mut sink);
assert!(on_tap(&sink.state, "back").is_none());
assert!(on_tap(&sink.state, "movie-a").is_none());
assert!(on_tap(&sink.state, "movie-b").is_none());
}
#[test]
fn ancestor_on_tap_blocks_card_backfill_for_the_whole_chain() {
let mut entry = entry_screen(vec![card("movie-a"), card("movie-b")]);
entry["children"][0]["events"] = json!({"onTap": [{"custom_action": null}]});
let mut sink = sink_from_value(document(vec![
entry,
detail_screen("detail", "/detail", icon_back("back", 24.0, 80.0)),
]));
wire_interaction_backfill(&mut sink);
assert!(on_tap(&sink.state, "movie-a").is_none());
assert!(on_tap(&sink.state, "movie-b").is_none());
}
#[test]
fn path_uses_icon_id_only_and_never_falls_back_to_node_name() {
let mut detail = detail_screen(
"detail",
"/detail",
path_back("real-back", Some("lucide:arrow-left"), Some("Anything")),
);
detail["children"]
.as_array_mut()
.unwrap()
.insert(1, path_back("name-only", None, Some("arrow-left")));
let mut sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail,
]));
wire_interaction_backfill(&mut sink);
assert_eq!(
on_tap(&sink.state, "real-back"),
Some(json!([{"pop": null}]))
);
assert!(on_tap(&sink.state, "name-only").is_none());
}
#[test]
fn geometry_rejects_late_large_non_square_and_multi_child_controls() {
let mut detail = detail_screen("detail", "/detail", icon_back("valid-back", 24.0, 80.0));
let late = icon_back("late", 24.0, 140.0);
let mut large = icon_back("large", 24.0, 80.0);
large["width"] = json!(64);
large["height"] = json!(64);
let mut non_square = icon_back("non-square", 24.0, 80.0);
non_square["width"] = json!(48);
non_square["height"] = json!(36);
let mut multi = icon_back("multi", 24.0, 80.0);
multi["children"]
.as_array_mut()
.unwrap()
.push(json!({"type":"text","id":"extra","content":"x","width":10,"height":10}));
detail["children"]
.as_array_mut()
.unwrap()
.splice(1..1, [late, large, non_square, multi]);
let mut sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail,
]));
wire_interaction_backfill(&mut sink);
assert_eq!(
on_tap(&sink.state, "valid-back"),
Some(json!([{"pop": null}]))
);
for id in ["late", "large", "non-square", "multi"] {
assert!(
on_tap(&sink.state, id).is_none(),
"{id} must stay untouched"
);
}
}
#[test]
fn trailing_bottom_nav_uses_geometry_bottom_gap_predicate_for_detail_gate() {
let nav_tabs = [0, 1, 2]
.into_iter()
.map(|index| {
json!({
"type": "frame",
"id": format!("unnamed-tab-{index}"),
"width": 130,
"height": 64,
"children": []
})
})
.collect::<Vec<_>>();
let mut tab_screen = detail_screen(
"tab-screen",
"/tab-screen",
icon_back("tab-back", 24.0, 80.0),
);
tab_screen["children"].as_array_mut().unwrap().push(json!({
"type": "frame",
"id": "unnamed-nav",
"x": 0,
"y": 780,
"width": 390,
"height": 64,
"layout": "horizontal",
"children": nav_tabs
}));
let mut actual_detail = detail_screen(
"actual-detail",
"/actual-detail",
icon_back("actual-back", 24.0, 80.0),
);
actual_detail["x"] = json!(900);
let mut sink = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
tab_screen,
actual_detail,
]));
wire_interaction_backfill(&mut sink);
assert_eq!(
on_tap(&sink.state, "movie-a"),
Some(json!([{"push": "\"/actual-detail\""}]))
);
assert_eq!(
on_tap(&sink.state, "movie-b"),
Some(json!([{"push": "\"/actual-detail\""}]))
);
}
#[test]
fn single_page_without_screen_is_a_byte_exact_noop_and_pass_is_idempotent() {
let mut single = sink_from_value(document(vec![json!({
"type": "frame",
"id": "poster",
"name": "Single-page poster",
"width": 390,
"height": 844,
"children": [icon_back("decorative-arrow", 24.0, 80.0)]
})]));
let before = serde_json::to_value(single.state.active_children()).unwrap();
wire_interaction_backfill(&mut single);
assert_eq!(
serde_json::to_value(single.state.active_children()).unwrap(),
before
);
assert!(single.applied.is_empty());
let mut multi = sink_from_value(document(vec![
entry_screen(vec![card("movie-a"), card("movie-b")]),
detail_screen("detail", "/detail", icon_back("back", 24.0, 80.0)),
]));
wire_interaction_backfill(&mut multi);
let once = serde_json::to_value(multi.state.active_children()).unwrap();
wire_interaction_backfill(&mut multi);
assert_eq!(
serde_json::to_value(multi.state.active_children()).unwrap(),
once
);
}

View file

@ -46,6 +46,12 @@ use text_collision::push_text_collision_diagnostics;
#[path = "geometry_bottom_gap.rs"]
mod geometry_bottom_gap;
use geometry_bottom_gap::push_mobile_bottom_gap_diagnostic;
#[path = "geometry_interaction_backfill.rs"]
mod geometry_interaction_backfill;
use geometry_interaction_backfill::push_interaction_backfill_diagnostics;
pub(crate) use geometry_interaction_backfill::{
screen_has_back_control_shape, wire_interaction_backfill,
};
#[path = "geometry_card_rail_fixes.rs"]
mod geometry_card_rail_fixes;
#[path = "geometry_diagnostics_collect.rs"]
@ -222,6 +228,7 @@ const MAX_DIAGNOSTICS: usize = 8;
pub fn geometry_diagnostics(state: &EditorState) -> Vec<String> {
let rects = resolved_rects(state);
let mut out = Vec::new();
push_interaction_backfill_diagnostics(state, None, &mut out);
for root in state.active_children() {
if out.len() >= MAX_DIAGNOSTICS {
break;

View file

@ -399,6 +399,7 @@ fn tight_budget_dashboard_keeps_component_composition() {
false,
false,
&lib,
&[],
);
let sys = &cr.system_prompt;

View file

@ -316,6 +316,7 @@ fn subagent_prompt_reduced_complexity_full_tier_skill_filtering_is_noop() {
false,
true,
&ComponentLibrary::default(),
&[],
);
let (reduced_cr, _) = build_subagent_prompt_core(
&st,
@ -326,6 +327,7 @@ fn subagent_prompt_reduced_complexity_full_tier_skill_filtering_is_noop() {
false,
true,
&ComponentLibrary::default(),
&[],
);
assert_eq!(
full_cr.system_prompt, reduced_cr.system_prompt,

View file

@ -24,6 +24,36 @@ pub fn build_subagent_prompt(
reduced_complexity: bool,
minimal_skills: bool,
components: &ComponentLibrary,
) -> (CallRequest, SkillLoadReport) {
build_subagent_prompt_with_screen_routes(
subtask,
plan,
req,
abort,
reduced_complexity,
minimal_skills,
components,
&[],
)
}
/// Production prompt builder with the document-wide screen route inventory.
///
/// The public compatibility wrapper above passes an empty inventory so direct
/// callers that do not own a document snapshot keep byte-identical prompts.
/// Generation paths call this variant after resolving normalized plan groups
/// (or loop continuation's live screens) through navigation's shared route
/// allocator.
#[allow(clippy::too_many_arguments)]
pub(crate) fn build_subagent_prompt_with_screen_routes(
subtask: &Subtask,
plan: &OrchestratorPlan,
req: &DesignRequest,
abort: AbortFlag,
reduced_complexity: bool,
minimal_skills: bool,
components: &ComponentLibrary,
screen_routes: &[(String, String)],
) -> (CallRequest, SkillLoadReport) {
// Script-gen is THE subagent protocol on every rung. Retry flags narrow
// the skill set only; they never switch the output protocol.
@ -37,6 +67,7 @@ pub fn build_subagent_prompt(
minimal_skills,
script_on,
components,
screen_routes,
)
}
@ -53,6 +84,7 @@ pub(super) fn build_subagent_prompt_core(
minimal_skills: bool,
script_on: bool,
components: &ComponentLibrary,
screen_routes: &[(String, String)],
) -> (CallRequest, SkillLoadReport) {
// Resolve the full generation skill set, then apply tier-gated filtering.
let model_id = req.model.as_deref().unwrap_or("");
@ -293,6 +325,7 @@ pub(super) fn build_subagent_prompt_core(
.as_ref()
.map(|instruction| format!("{instruction}\n\n"))
.unwrap_or_default();
let screen_route_block = screen_route_prompt_block(screen_routes);
let spacing_rule = if is_mobile_layout {
"SPACING CONSISTENCY — MOBILE CONTENT RAIL: The root page may keep 0 horizontal padding for full-width status/navigation/full-bleed media. This ordinary transparent root-direct section owns padding:[0,24] exactly once; do not duplicate it on an inner wrapper. If this section is a clipped horizontal scroller, keep its section full width, inset its header 24px on both sides, and give the clipped viewport a 24px leading inset with a flush 0px trailing edge."
} else {
@ -320,6 +353,7 @@ pub(super) fn build_subagent_prompt_core(
Generate ONLY \"{}\" (~{:.0}px of content).{}\n\
Overall design: {}\n\n\
{}\
{}\
CRITICAL LAYOUT CONSTRAINTS:\n\
- {}\n\
- Target content amount: ~{:.0}px tall. Generate enough elements to fill this area.\n\
@ -347,6 +381,7 @@ CRITICAL LAYOUT CONSTRAINTS:\n\
subtask.region.height,
my_elements,
req.prompt,
screen_route_block,
explicit_user_token_block,
root_rule,
subtask.region.height,
@ -469,3 +504,24 @@ CRITICAL LAYOUT CONSTRAINTS:\n\
report,
)
}
fn screen_route_prompt_block(screen_routes: &[(String, String)]) -> String {
if screen_routes.is_empty() {
return String::new();
}
let rows = screen_routes
.iter()
.map(|(name, route)| {
let name = serde_json::to_string(name).expect("serializing a string cannot fail");
let route = serde_json::to_string(route).expect("serializing a string cannot fail");
format!("- {name} -> {route}")
})
.collect::<Vec<_>>()
.join("\n");
format!(
"DOCUMENT SCREEN ROUTES (use these exact route values in schema-encoded navigation \
actions; never invent another route):\n{rows}\n\n"
)
}

View file

@ -3,6 +3,98 @@
use super::*;
#[test]
fn subagent_prompt_injects_exact_json_quoted_screen_route_inventory() {
let subtask = subtask();
let plan = plan();
let req = req();
let routes = vec![
("Home".to_string(), "/".to_string()),
(
"Movie \"Night\"\nDetail".to_string(),
"/movie-detail".to_string(),
),
];
let (call, with_routes_report) = build_subagent_prompt_with_screen_routes(
&subtask,
&plan,
&req,
AbortFlag::new(),
false,
false,
&ComponentLibrary::default(),
&routes,
);
let expected = r#"DOCUMENT SCREEN ROUTES (use these exact route values in schema-encoded navigation actions; never invent another route):
- "Home" -> "/"
- "Movie \"Night\"\nDetail" -> "/movie-detail"
CRITICAL LAYOUT CONSTRAINTS:"#;
assert!(
call.user_prompt.contains(expected),
"route inventory must be exact and JSON-quoted:\n{}",
call.user_prompt
);
let (_, empty_report) = build_subagent_prompt_with_screen_routes(
&subtask,
&plan,
&req,
AbortFlag::new(),
false,
false,
&ComponentLibrary::default(),
&[],
);
assert_eq!(
with_routes_report, empty_report,
"user-prompt route context must not perturb the skill budget"
);
}
#[test]
fn empty_screen_route_inventory_matches_public_builder_byte_for_byte() {
let subtask = subtask();
let plan = plan();
let req = req();
let components = ComponentLibrary::default();
let (compat_call, compat_report) = build_subagent_prompt(
&subtask,
&plan,
&req,
AbortFlag::new(),
false,
false,
&components,
);
let (empty_call, empty_report) = build_subagent_prompt_with_screen_routes(
&subtask,
&plan,
&req,
AbortFlag::new(),
false,
false,
&components,
&[],
);
assert_eq!(compat_call.system_prompt, empty_call.system_prompt);
assert_eq!(compat_call.user_prompt, empty_call.user_prompt);
assert_eq!(compat_call.timeout, empty_call.timeout);
assert_eq!(compat_call.no_text_timeout, empty_call.no_text_timeout);
assert_eq!(
compat_call.first_text_timeout,
empty_call.first_text_timeout
);
assert_eq!(compat_report, empty_report);
assert!(
!empty_call.user_prompt.contains("DOCUMENT SCREEN ROUTES"),
"empty inventory must not grow a route header"
);
}
#[test]
fn subagent_prompt_honors_explicit_radius_and_spacing_numbers() {
let mobile_req = DesignRequest {

View file

@ -121,6 +121,12 @@ pub(crate) fn insert_screen_group_roots(
}
}
// Freeze the normalized planning screen list into real route markers
// before any fan-out prompt is built. The prompt inventory reads the same
// merged candidates and allocator, so generated cross-screen actions can
// never reference a virtual route that cleanup later assigns differently.
crate::wire_screen_navigation::ensure_planned_screen_routes(sink, plan);
// Only the genuinely-concurrent shape (`identities.len() ==
// new_root_ids.len()`) tags anything — an empty slice (the sequential
// path) deliberately leaves every root untagged; see this function's doc.

View file

@ -159,6 +159,41 @@ fn multi_screen_plan_gets_one_root_per_screen_group() {
assert_eq!(summary.subtasks.len(), 2);
}
#[test]
fn every_fanout_subtask_prompt_receives_the_full_screen_route_inventory() {
let llm = ScriptedLlm::new(vec![
ScriptResponse::Text(MULTI_SCREEN_PLAN_JSON.into()),
ScriptResponse::Text(node_json("home")),
ScriptResponse::Text(node_json("profile")),
]);
let mut sink = VecDocSink::new();
futures::executor::block_on(Orchestrator::new().run(
req(),
&mut sink,
&llm,
&mut |_| {},
&AbortFlag::new(),
&stub_providers(),
))
.expect("multi-screen run ok");
let route_prompts = llm
.user_prompts()
.into_iter()
.filter(|prompt| prompt.contains("DOCUMENT SCREEN ROUTES"))
.collect::<Vec<_>>();
assert_eq!(
route_prompts.len(),
2,
"both Home and Profile workers need the same document-wide inventory"
);
for prompt in route_prompts {
assert!(prompt.contains(r#"- "Home" -> "/""#), "{prompt}");
assert!(prompt.contains(r#"- "Profile" -> "/profile""#), "{prompt}");
}
}
/// Co-op point with Track A (`wire_screen_navigation`, run as part of
/// `finalize_design`'s cleanup tail): once N screen-shaped roots exist,
/// EVERY one of them — not just the first — gets a `screen` route marker,

View file

@ -0,0 +1,182 @@
//! Planning/live screen-route context shared by fan-out prompts and wiring.
use super::*;
/// Name/route context for loop continuation prompts. Authored top-level
/// screens are runtime truth regardless of width; unmarked roots still use
/// navigation's conservative screen-shape bands.
pub(crate) fn screen_route_inventory(state: &EditorState) -> Vec<(String, String)> {
let screens = collect_prompt_live_candidates(state);
if screens.len() < 2 {
return Vec::new();
}
route_inventory_for_candidates(&screens)
}
/// Classic fan-out uses normalized planning groups, merged with existing
/// screens. Synthetic loop plans have no distinct screen roots and fall back
/// to the live inventory above.
pub(crate) fn prompt_screen_route_inventory(
plan: &crate::plan::OrchestratorPlan,
state: &EditorState,
) -> Vec<(String, String)> {
plan_route_candidates(plan, state)
.map(|candidates| route_inventory_for_candidates(&candidates))
.unwrap_or_else(|| screen_route_inventory(state))
}
/// Persist the same merged assignments that fan-out prompts see. Called once
/// after per-screen scaffold roots exist and before their workers run.
pub(crate) fn ensure_planned_screen_routes(
sink: &mut dyn DocSink,
plan: &crate::plan::OrchestratorPlan,
) {
let Some(candidates) = plan_route_candidates(plan, sink.state()) else {
return;
};
for (node_id, path) in assign_screen_paths(&candidates) {
sink.apply(EditorCommand::PatchNodeData {
node_id: NodeId::new(node_id),
patch_json: format!(r#"{{"screen":"{path}"}}"#),
page_id: None,
});
}
}
fn route_inventory_for_candidates(screens: &[ScreenCandidate]) -> Vec<(String, String)> {
let assignments = assign_screen_paths(screens);
let assigned: HashMap<&str, &str> = assignments
.iter()
.map(|(id, path)| (id.as_str(), path.as_str()))
.collect();
screens
.iter()
.filter_map(|screen| {
screen
.existing_path
.clone()
.or_else(|| {
assigned
.get(screen.id.as_str())
.map(|path| path.to_string())
})
.map(|path| (screen.name.clone(), path))
})
.collect()
}
fn plan_route_candidates(
plan: &crate::plan::OrchestratorPlan,
state: &EditorState,
) -> Option<Vec<ScreenCandidate>> {
let groups = crate::screen_groups::group_subtasks_by_screen(&plan.subtasks);
if groups.len() < 2 {
return None;
}
let planned = groups
.iter()
.enumerate()
.map(|(index, group)| {
let root_id = group
.indices
.iter()
.filter_map(|subtask_index| plan.subtasks.get(*subtask_index))
.find_map(|subtask| subtask.parent_frame_id.clone())
.unwrap_or_else(|| format!("planned-screen-{index}"));
(root_id, group.screen.clone())
})
.collect::<Vec<_>>();
let distinct_ids = planned
.iter()
.map(|(id, _)| id.as_str())
.collect::<BTreeSet<_>>();
if distinct_ids.len() < 2 || distinct_ids.len() != planned.len() {
return None;
}
let planned_names = planned.iter().cloned().collect::<HashMap<String, String>>();
let shaped = collect_screen_candidates(state)
.into_iter()
.map(|candidate| (candidate.id.clone(), candidate))
.collect::<HashMap<_, _>>();
let mut seen = BTreeSet::new();
let mut candidates = Vec::new();
for node in state.active_children() {
let PenNode::Frame(frame) = node else {
continue;
};
let id = frame.base.id.clone();
if let Some(name) = planned_names.get(&id) {
seen.insert(id.clone());
candidates.push(ScreenCandidate {
id,
name: name.clone(),
existing_path: frame.screen.clone(),
});
} else if let Some(candidate) = shaped.get(&id) {
seen.insert(id);
candidates.push(ScreenCandidate {
id: candidate.id.clone(),
name: candidate.name.clone(),
existing_path: candidate.existing_path.clone(),
});
} else if frame.screen.is_some() {
seen.insert(id.clone());
candidates.push(ScreenCandidate {
id,
name: frame
.base
.name
.clone()
.unwrap_or_else(|| frame.base.id.clone()),
existing_path: frame.screen.clone(),
});
}
}
for (id, name) in planned {
if seen.insert(id.clone()) {
candidates.push(ScreenCandidate {
id,
name,
existing_path: None,
});
}
}
Some(candidates)
}
pub(super) fn collect_prompt_live_candidates(state: &EditorState) -> Vec<ScreenCandidate> {
let shaped = collect_screen_candidates(state)
.into_iter()
.map(|candidate| (candidate.id.clone(), candidate))
.collect::<HashMap<_, _>>();
state
.active_children()
.iter()
.filter_map(|node| {
let PenNode::Frame(frame) = node else {
return None;
};
shaped.get(&frame.base.id).map_or_else(
|| {
frame.screen.as_ref().map(|path| ScreenCandidate {
id: frame.base.id.clone(),
name: frame
.base
.name
.clone()
.unwrap_or_else(|| frame.base.id.clone()),
existing_path: Some(path.clone()),
})
},
|candidate| {
Some(ScreenCandidate {
id: candidate.id.clone(),
name: candidate.name.clone(),
existing_path: candidate.existing_path.clone(),
})
},
)
})
.collect()
}

View file

@ -96,6 +96,52 @@ fn run_spawned_agents_invokes_real_runner_and_inserts_n_subtrees() {
);
}
#[test]
fn loop_spawned_agent_prompts_receive_live_document_screen_routes() {
let mut sink = VecDocSink::new();
let screens: Vec<jian_ops_schema::node::PenNode> = serde_json::from_str(
r#"[
{"type":"frame","id":"home","name":"Home","screen":"/",
"x":0,"y":0,"width":390,"height":844,"children":[]},
{"type":"frame","id":"detail","name":"Movie Detail","screen":"/movie-detail",
"x":470,"y":0,"width":390,"height":844,"children":[]}
]"#,
)
.unwrap();
sink.apply(EditorCommand::InsertSubtree {
nodes: screens,
parent_id: op_editor_core::NodeId::NONE,
page_id: None,
});
sink.applied.clear();
let llm = ScriptedLlm::new(vec![
ScriptResponse::Text(node_json("a")),
ScriptResponse::Text(node_json("b")),
]);
let specs = vec![spec("a"), spec("b")];
let results = block_on(run_spawned_agents_concurrent(
&specs,
&make_req(),
&llm,
&mut sink,
&AbortFlag::new(),
2,
None,
));
assert!(results.iter().all(|result| result.error.is_none()));
let prompts = llm.user_prompts();
assert_eq!(prompts.len(), 2);
for prompt in prompts {
assert!(prompt.contains(r#"- "Home" -> "/""#), "{prompt}");
assert!(
prompt.contains(r#"- "Movie Detail" -> "/movie-detail""#),
"{prompt}"
);
}
}
/// Concurrency is GENUINE: with a cap ≥ N, `CountingLlm` observes more than
/// one in-flight call — the workers' LLM calls overlap (vs. strictly
/// sequential, which would cap at 1).

View file

@ -8,7 +8,7 @@
//! - `node_count > 0`(`error` 可带软错误)—— 部分产出,继续后续。
use crate::plan::{OrchestratorPlan, Subtask};
use crate::prompt::build_subagent_prompt;
use crate::prompt::build_subagent_prompt_with_screen_routes;
use crate::types::{AbortFlag, DesignRequest, DocSink, LlmChunk, LlmClient, SubtaskOutcome};
use futures::StreamExt;
use jian_ops_schema::node::PenNode;
@ -108,15 +108,16 @@ pub(crate) async fn run_subtask_with_reveal_at(
subtask: Some(subtask.clone()),
};
// Snapshot the document's reusable-component registry before the prompt
// build so the AVAILABLE COMPONENTS manifest reflects whatever masters were
// merged into the doc (e.g. a loaded `.lib.op`). Cloned to release the
// shared `sink` borrow before the later mutable inserts. Empty registry ⇒
// `build_subagent_prompt` leaves the prompt unchanged.
// Snapshot document-wide prompt context before building the prompt.
// Classic fan-out derives routes from normalized planning groups; loop
// continuation (whose synthetic plan has no screen labels) falls back to
// live screen markers. Both paths share navigation's route allocator.
let screen_routes =
crate::wire_screen_navigation::prompt_screen_route_inventory(plan, sink.state());
let components = sink.state().components.clone();
// 收集 LLM 文本输出。
let (call_req, skill_report) = build_subagent_prompt(
let (call_req, skill_report) = build_subagent_prompt_with_screen_routes(
subtask,
plan,
req,
@ -124,6 +125,7 @@ pub(crate) async fn run_subtask_with_reveal_at(
reduced_complexity,
minimal_skills,
&components,
&screen_routes,
);
// Surface the per-subtask skill-load report to the chat UI immediately
// after the prompt is built (spec Component 4).

View file

@ -554,7 +554,7 @@ fn active_retarget_preserves_descendant_routes_by_tab_position() {
/// A nav-less screen with a back-shaped header control (chevron-left /
/// arrow-left icon as the first child — resolves within the header region
/// exactly like `wire_screen_navigation_tests.rs::header_back_icon_gets_pop`).
/// exactly like the legacy shared-nav detail exemption).
fn screen_json_no_nav_with_back_header(id: &str, name: &str) -> serde_json::Value {
serde_json::json!({
"type": "frame", "id": id, "name": name, "width": 390, "height": 844,

View file

@ -3,10 +3,10 @@
//! The preview engine (PreviewSession + jian-core `ScreenRouter` App Mode)
//! already understands multi-screen documents: it looks for top-level
//! `FrameNode.screen` markers and `events.onTap` navigation actions. What is
//! missing is generation-side wiring — AI-produced documents never carry
//! these fields, so `project_screens` finds nothing and preview degrades to
//! a single scrolling page. This pass fills that gap deterministically, with
//! zero model cooperation required (see
//! missing is generation-side wiring — AI-produced documents may omit these
//! fields, so `project_screens` finds nothing and preview degrades to a single
//! scrolling page. This module fills the screen/nav gap deterministically,
//! while cleanup-only interaction backfill owns strict back/card actions (see
//! `openpencil-docs/openpencil/generation/preview-interactive-app-mode-0712.md`,
//! "Track A contract v2").
//!
@ -24,11 +24,9 @@
//! 3. `screen` only ever marks a top-level (page-root-level) frame — the
//! projection pass (`jian_ops_schema::screen_projection`) only scans
//! top-level children per page (or per-document when pageless).
//! 4. Idempotent + additive-only: a node that already carries `screen` or
//! `events` is never touched (an authored marker may be a future
//! breakpoint-variant of the same path — see the jian
//! `feat/responsive-m1a` compatibility notes in the plan doc). Running
//! the pass twice must be a no-op the second time.
//! 4. Idempotent + additive-only: an authored `screen` marker or `events`
//! collection is never overwritten. Running the pass twice must be a no-op
//! the second time.
//! 5. Zero new schema fields — only the existing `screen` / `events` fields
//! are ever written.
//!
@ -36,8 +34,7 @@
//!
//! 1. `crate::cleanup::run_cleanup_passes` — the in-crate generation-pipeline
//! caller (orchestrator per-subtask cleanup + the agentic loop's whole-doc
//! finalize), which is why the pass writes through the crate's own
//! `DocSink` rather than a concrete document type.
//! finalize), which invokes this after cleanup-only interaction backfill.
//! 2. `op_host_native::preview::auto_wire` (Track C-1) — enter-preview
//! auto-wiring. When a document carries no authored `screen` marker at
//! all, the preview host runs this SAME pass over a JSON-cloned
@ -60,11 +57,9 @@ use crate::types::DocSink;
const MOBILE_SCREEN_WIDTH: std::ops::RangeInclusive<f64> = 320.0..=480.0;
const DESKTOP_SCREEN_MIN_WIDTH: f64 = 1024.0;
/// A back control must resolve within this many px of its screen's top edge
/// to count as "header region" — generous enough to cover a padded header
/// band (56-96px measured) plus a nested icon's own inset, tight enough that
/// a mid-page control is never mistaken for a nav-bar back arrow.
const HEADER_REGION_MAX_Y: f64 = 140.0;
/// Compatibility band for shared-nav's shipped detail-page exemption.
/// Interaction backfill uses its own stricter geometry fact.
const LEGACY_HEADER_REGION_MAX_Y: f64 = 140.0;
const ENTRY_NAME_HINTS: [&str; 4] = ["home", "main", "dashboard", "index"];
@ -98,47 +93,53 @@ pub(crate) struct NavParts<'a> {
}
/// Entry point: mark screen-shaped top-level frames with a `screen` route
/// path and wire each screen's bottom-nav / sidebar-nav tabs + header back
/// buttons to `events.onTap` navigation actions. No-ops when the document
/// path and wire each screen's bottom-nav / sidebar-nav tabs to `events.onTap`
/// navigation actions. No-ops when the document
/// has fewer than two screen-shaped top-level frames (single-screen docs
/// keep today's scrolling-page preview — zero regression surface).
pub fn wire_screen_navigation(sink: &mut dyn DocSink) {
let screens = collect_screen_candidates(sink.state());
let screens = ensure_screen_routes(sink);
if screens.len() < 2 {
return;
}
let assignments = assign_screen_paths(&screens);
for (node_id, path) in &assignments {
sink.apply(EditorCommand::PatchNodeData {
node_id: NodeId::new(node_id.clone()),
patch_json: format!(r#"{{"screen":"{path}"}}"#),
page_id: None,
});
}
// Full id -> (path, name) table: authored markers keep their path, fresh
// assignments add theirs. Every candidate has exactly one entry.
let assigned: HashMap<&str, &str> = assignments
.iter()
.map(|(id, path)| (id.as_str(), path.as_str()))
.collect();
let screen_paths: Vec<(String, String)> = screens
.iter()
.map(|s| {
let path = s
.filter_map(|screen| {
screen
.existing_path
.clone()
.or_else(|| assigned.get(s.id.as_str()).map(|p| p.to_string()))
.unwrap_or_default();
(s.name.clone(), path)
.as_ref()
.map(|path| (screen.name.clone(), path.clone()))
})
.collect();
wire_nav_tabs(sink, &screens, &screen_paths);
wire_back_buttons(sink, &screens);
}
/// Persist routes without wiring interactions. Generation cleanup uses this
/// before its document-writing interaction backfill; preview fallback keeps
/// calling [`wire_screen_navigation`] on a clone and only gets route/nav wiring.
pub(crate) fn ensure_screen_routes(sink: &mut dyn DocSink) -> Vec<ScreenCandidate> {
let screens = screen_route_inventory::collect_prompt_live_candidates(sink.state());
if screens.len() < 2 {
return screens;
}
for (node_id, path) in assign_screen_paths(&screens) {
sink.apply(EditorCommand::PatchNodeData {
node_id: NodeId::new(node_id),
patch_json: format!(r#"{{"screen":"{path}"}}"#),
page_id: None,
});
}
screen_route_inventory::collect_prompt_live_candidates(sink.state())
}
#[path = "screen_route_inventory.rs"]
mod screen_route_inventory;
pub(crate) use screen_route_inventory::{
ensure_planned_screen_routes, prompt_screen_route_inventory,
};
/// Scan the active page's top-level `Frame` children for screen-shaped
/// candidates (numeric width AND height, width in a mobile or desktop band).
/// `pub(crate)` for `unify_shared_nav`'s reuse — see [`ScreenCandidate`].
@ -562,44 +563,20 @@ pub(crate) fn labels_match(a: &str, b: &str) -> bool {
ta.iter().any(|t| tb.contains(t))
}
// ── Back-button wiring ──────────────────────────────────────────────────
// ── Shared-nav legacy detail signal ──────────────────────────────────────
/// Bind a header-region back control (name/icon containing back /
/// arrow-left / chevron-left) to `pop`. There is no system-level back
/// affordance in v1 (design §7) — only an authored UI node can carry it.
fn wire_back_buttons(sink: &mut dyn DocSink, screens: &[ScreenCandidate]) {
let y_offsets = resolved_y_offsets(sink.state());
let mut patches: Vec<String> = Vec::new();
for screen in screens {
let Some(root) = op_editor_core::walkers::find_node(
sink.state().active_children(),
&NodeId::new(screen.id.clone()),
) else {
continue;
};
let screen_top = y_offsets.get(&screen.id).copied().unwrap_or(0.0);
collect_back_controls(root, screen_top, &y_offsets, &mut patches);
}
for node_id in patches {
sink.apply(EditorCommand::PatchNodeData {
node_id: NodeId::new(node_id),
patch_json: r#"{"events":{"onTap":[{"pop":null}]}}"#.to_string(),
page_id: None,
});
}
}
/// Whether `screen` has a back-shaped control (role `back`/`back-button`/
/// `nav-back`, or a name/icon containing "back" / "arrow-left" /
/// "chevron-left") within its header region — reuses
/// [`collect_back_controls`]'s exact detection (same `HEADER_REGION_MAX_Y`
/// band `wire_back_buttons` binds `pop` against). Shared detail-page signal
/// for `unify_shared_nav`'s Inject-exemption gate: a screen with a header
/// back control reads as a push-in detail screen, not a tab destination.
/// Preserve shared-nav's shipped detail-page exemption. This intentionally
/// remains broader than M1 interaction backfill: it may recognize legacy
/// direct icons, roles, and authored names, but it never writes an event.
/// The strict cleanup repair and diagnostic predicate lives in
/// `geometry_interaction_backfill`.
pub(crate) fn screen_has_back_control_in_header(
sink: &dyn DocSink,
screen: &ScreenCandidate,
) -> bool {
if crate::geometry_validation::screen_has_back_control_shape(sink.state(), &screen.id) {
return true;
}
let Some(root) = op_editor_core::walkers::find_node(
sink.state().active_children(),
&NodeId::new(screen.id.clone()),
@ -609,37 +586,34 @@ pub(crate) fn screen_has_back_control_in_header(
let y_offsets = resolved_y_offsets(sink.state());
let screen_top = y_offsets.get(&screen.id).copied().unwrap_or(0.0);
let mut hits = Vec::new();
collect_back_controls(root, screen_top, &y_offsets, &mut hits);
collect_legacy_back_controls(root, screen_top, &y_offsets, &mut hits);
!hits.is_empty()
}
fn collect_back_controls(
fn collect_legacy_back_controls(
node: &PenNode,
screen_top: f64,
y_offsets: &HashMap<String, f64>,
out: &mut Vec<String>,
) {
if node_has_events(node) {
// An authored interactive control owns its whole subtree — wiring a
// descendant underneath (e.g. the arrow icon inside an already-bound
// back button) would double-dispatch the same tap.
return;
}
if is_back_control(node) {
if is_legacy_back_control(node) {
let within_header = y_offsets
.get(node.id_str())
.is_some_and(|y| (y - screen_top) <= HEADER_REGION_MAX_Y);
.is_some_and(|y| (y - screen_top) <= LEGACY_HEADER_REGION_MAX_Y);
if within_header {
out.push(node.id_str().to_string());
return; // don't also bind a nested icon inside the matched control
return;
}
}
for child in node.children().into_iter().flatten() {
collect_back_controls(child, screen_top, y_offsets, out);
collect_legacy_back_controls(child, screen_top, y_offsets, out);
}
}
fn is_back_control(node: &PenNode) -> bool {
fn is_legacy_back_control(node: &PenNode) -> bool {
if matches!(
node.base().role.as_deref(),
Some("back" | "back-button" | "nav-back")
@ -656,10 +630,11 @@ fn is_back_control(node: &PenNode) -> bool {
hay.contains("back") || hay.contains("arrowleft") || hay.contains("chevronleft")
}
fn compact_lower(s: &str) -> String {
s.chars()
.filter(|c| c.is_ascii_alphanumeric())
.flat_map(|c| c.to_lowercase())
fn compact_lower(value: &str) -> String {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(|character| character.to_lowercase())
.collect()
}
@ -694,17 +669,12 @@ pub fn subtree_has_events(node: &PenNode) -> bool {
/// `/`-rooted route path; the JSON string VALUE is the literal
/// `"<path>"` (quotes included) so it compiles as a Tier-1 string-literal
/// expression — see the module doc, contract point 1.
fn navigate_patch(verb: &str, path: &str) -> String {
pub(crate) fn navigate_patch(verb: &str, path: &str) -> String {
let body = serde_json::to_string(path).unwrap_or_default(); // -> "\"/path\""
let escaped_body = serde_json::to_string(&body).unwrap_or_default(); // -> "\"\\\"/path\\\"\""
format!(r#"{{"events":{{"onTap":[{{"{verb}":{escaped_body}}}]}}}}"#)
}
/// Node id -> resolved absolute-doc-space Y offset, via the same jian layout
/// pass `snapshot_layout` / `geometry_validation` use. Only Y is needed here
/// (header-region gating); a fuller Rect lives in `geometry_validation`
/// scoped to its own module, mirroring the existing per-module
/// `resolved_widths` precedent in `sidebar_archetype`.
fn resolved_y_offsets(state: &EditorState) -> HashMap<String, f64> {
let scene = op_pen_loader::editor_state_to_active_page_layout_scene(state);
let mut out = HashMap::new();

View file

@ -194,6 +194,172 @@ fn duplicate_slugs_get_numeric_suffix() {
assert_eq!(frame_screen(b), Some("/settings-2"));
}
#[test]
fn prompt_inventory_matches_the_routes_the_pass_persists() {
let json = r#"{"version":"1.0","children":[
{"type":"frame","id":"authored","name":"Checkout","width":390,"height":844,
"layout":"vertical","children":[],"screen":"/buy-now"},
{"type":"frame","id":"home","name":"Home","width":390,"height":844,
"layout":"vertical","children":[]},
{"type":"frame","id":"settings-a","name":"Settings","width":390,"height":844,
"layout":"vertical","children":[]},
{"type":"frame","id":"settings-b","name":"Settings","width":390,"height":844,
"layout":"vertical","children":[]}
]}"#;
let mut state = state_from_json(json);
let inventory = screen_route_inventory::screen_route_inventory(&state);
run_pass(&mut state);
let persisted = state
.active_children()
.iter()
.filter_map(|node| {
let PenNode::Frame(frame) = node else {
return None;
};
Some((
frame.base.name.clone().unwrap_or_default(),
frame.screen.clone()?,
))
})
.collect::<Vec<_>>();
assert_eq!(inventory, persisted);
assert_eq!(
inventory,
vec![
("Checkout".into(), "/buy-now".into()),
("Home".into(), "/".into()),
("Settings".into(), "/settings".into()),
("Settings".into(), "/settings-2".into()),
]
);
}
#[test]
fn planned_inventory_merges_existing_entry_and_persists_midwidth_routes() {
let json = r#"{"version":"1.0","children":[
{"type":"frame","id":"home","name":"Home","screen":"/",
"width":390,"height":844,"layout":"vertical","children":[]},
{"type":"frame","id":"search","name":"Search",
"width":768,"height":1024,"layout":"vertical","children":[]},
{"type":"frame","id":"profile","name":"Profile",
"width":768,"height":1024,"layout":"vertical","children":[]}
]}"#;
let mut state = state_from_json(json);
let plan: crate::plan::OrchestratorPlan = serde_json::from_value(serde_json::json!({
"rootFrame": {
"id": "root",
"name": "App",
"width": 768,
"height": 1024
},
"subtasks": [
{
"id": "search-task",
"label": "Search",
"screen": "Search",
"parentFrameId": "search",
"region": {"width": 768, "height": 400}
},
{
"id": "profile-task",
"label": "Profile",
"screen": "Profile",
"parentFrameId": "profile",
"region": {"width": 768, "height": 400}
}
]
}))
.unwrap();
let planned = prompt_screen_route_inventory(&plan, &state);
assert_eq!(
planned,
vec![
("Home".into(), "/".into()),
("Search".into(), "/search".into()),
("Profile".into(), "/profile".into()),
]
);
{
let mut sink = crate::loop_finalize::StateDocSink { state: &mut state };
ensure_planned_screen_routes(&mut sink, &plan);
}
assert_eq!(prompt_screen_route_inventory(&plan, &state), planned);
assert_eq!(
["home", "search", "profile"]
.into_iter()
.map(|id| frame_screen(find_by_id(state.active_children(), id).unwrap()).unwrap())
.collect::<Vec<_>>(),
vec!["/", "/search", "/profile"]
);
}
#[test]
fn authored_midwidth_entry_reserves_root_for_new_shaped_screen() {
let json = r#"{"version":"1.0","children":[
{"type":"frame","id":"tablet-home","name":"Home","screen":"/",
"width":768,"height":1024,"layout":"vertical","children":[]},
{"type":"frame","id":"detail","name":"Detail",
"width":390,"height":844,"layout":"vertical","children":[]}
]}"#;
let mut state = state_from_json(json);
assert_eq!(
screen_route_inventory::screen_route_inventory(&state),
vec![
("Home".into(), "/".into()),
("Detail".into(), "/detail".into())
]
);
run_pass(&mut state);
assert_eq!(
frame_screen(find_by_id(state.active_children(), "detail").unwrap()),
Some("/detail")
);
}
#[test]
fn collapsed_plan_groups_fall_back_to_the_live_route_inventory() {
let state = state_from_json(
r#"{"version":"1.0","children":[
{"type":"frame","id":"home","name":"Home","screen":"/",
"width":390,"height":844,"children":[]},
{"type":"frame","id":"detail","name":"Detail","screen":"/detail",
"width":390,"height":844,"children":[]}
]}"#,
);
let plan: crate::plan::OrchestratorPlan = serde_json::from_value(serde_json::json!({
"rootFrame": {"id": "root", "name": "App", "width": 390, "height": 844},
"subtasks": [
{
"id": "search-task",
"label": "Search",
"screen": "Search",
"parentFrameId": "home",
"region": {"width": 390, "height": 300}
},
{
"id": "profile-task",
"label": "Profile",
"screen": "Profile",
"parentFrameId": "home",
"region": {"width": 390, "height": 300}
}
]
}))
.unwrap();
assert_eq!(
prompt_screen_route_inventory(&plan, &state),
vec![
("Home".into(), "/".into()),
("Detail".into(), "/detail".into())
]
);
}
#[test]
fn authored_screen_marker_is_never_overwritten_and_not_reused_as_entry() {
// "Checkout" already carries an authored (non-"/") marker. Since the
@ -557,36 +723,36 @@ fn existing_tab_events_are_left_alone() {
);
}
// ── Back-button wiring ──────────────────────────────────────────────────
// ── Cleanup-only interaction boundary ──────────────────────────────────
#[test]
fn header_back_icon_gets_pop() {
fn preview_fallback_does_not_create_a_temporary_back_binding() {
let json = r#"{"version":"1.0","children":[
{"type":"frame","id":"profile","name":"Profile","width":390,"height":844,
"layout":"vertical","children":[
{"type":"icon_font","id":"back-icon","name":"Back","iconFontName":"arrow-left",
"width":24,"height":24}
"layout":"none","children":[
{"type":"frame","id":"back","x":24,"y":80,"width":44,"height":44,
"layout":"none","children":[
{"type":"icon_font","id":"back-icon","x":12,"y":12,
"iconFontName":"arrow-left","width":20,"height":20}
]}
]},
{"type":"frame","id":"home","name":"Home","width":390,"height":844,
"layout":"vertical","children":[]}
]}"#;
let mut state = state_from_json(json);
run_pass(&mut state);
let back = find_by_id(state.active_children(), "back-icon").unwrap();
assert_eq!(
node_events_json(back).unwrap(),
serde_json::json!({"onTap": [{"pop": null}]})
assert!(
node_events_json(find_by_id(state.active_children(), "back").unwrap()).is_none(),
"public preview fallback may assign routes/nav, but cleanup-only backfill must write \
interactions to the real document"
);
}
/// An authored interactive control owns its whole subtree: the arrow icon
/// INSIDE an already-bound back button must not receive a second `pop`
/// (both handlers firing on one tap would double-pop the route stack).
#[test]
fn icon_inside_authored_back_button_is_not_double_bound() {
fn authored_back_binding_is_preserved_by_preview_fallback() {
let json = r#"{"version":"1.0","children":[
{"type":"frame","id":"profile","name":"Profile","width":390,"height":844,
"layout":"vertical","children":[
"layout":"none","children":[
{"type":"frame","id":"back-btn","name":"Back Button","width":44,"height":44,
"events":{"onTap":[{"pop":null}]},
"children":[
@ -598,32 +764,9 @@ fn icon_inside_authored_back_button_is_not_double_bound() {
"layout":"vertical","children":[]}
]}"#;
let mut state = state_from_json(json);
let before = node_events_json(find_by_id(state.active_children(), "back-btn").unwrap());
run_pass(&mut state);
let icon = find_by_id(state.active_children(), "inner-icon").unwrap();
assert!(
node_events_json(icon).is_none(),
"a descendant of an authored interactive control must never be wired"
);
}
#[test]
fn back_icon_outside_header_region_is_not_bound() {
let json = r#"{"version":"1.0","children":[
{"type":"frame","id":"profile","name":"Profile","width":390,"height":844,
"layout":"vertical","children":[
{"type":"frame","id":"filler","name":"Filler","width":"fill_container","height":700,
"children":[]},
{"type":"icon_font","id":"chevron-icon","name":"chevron-left","iconFontName":"chevron-left",
"width":24,"height":24}
]},
{"type":"frame","id":"home","name":"Home","width":390,"height":844,
"layout":"vertical","children":[]}
]}"#;
let mut state = state_from_json(json);
run_pass(&mut state);
let chevron = find_by_id(state.active_children(), "chevron-icon").unwrap();
assert!(
node_events_json(chevron).is_none(),
"a back-shaped icon well below the header band must not be bound"
);
let after = node_events_json(find_by_id(state.active_children(), "back-btn").unwrap());
assert_eq!(after, before);
assert!(node_events_json(find_by_id(state.active_children(), "inner-icon").unwrap()).is_none());
}

View file

@ -9,13 +9,14 @@
//! `hasEntryScreen` / `navBoundTabs` / `navTotalTabs` / `popBound` /
//! `appModeReady` quantify whether a generated document actually enters
//! PreviewSession's routed multi-screen App Mode — `wire_screen_navigation`
//! (Track A) now auto-wires `screen` markers + nav-tab / back-button
//! `events.onTap` bindings at the end of every generation turn, so this is
//! the deterministic check that it actually landed, not just that the pass
//! exists. The nav-container / has-events predicates are reused directly
//! from `op_orchestrator::wire_screen_navigation` (bumped `pub` for this;
//! (Track A) now auto-wires `screen` markers + nav-tab bindings, while the
//! cleanup-only interaction backfill persists fact-proven back/card actions
//! at the end of every generation turn. This is the deterministic check that
//! the bindings actually landed, not just that the passes exist. The
//! nav-container / has-events predicates are reused directly from
//! `op_orchestrator::wire_screen_navigation` (bumped `pub` for this;
//! op-smoke already depends on `op-orchestrator`) rather than reimplemented,
//! so the rubric can never silently disagree with what the pass wired.
//! so the rubric can never silently disagree with what the nav pass wired.
//!
//! M3 addition (content-completeness follow-up): `completeness` closes a
//! blind spot the geometry + chrome + interactivity metrics above all share
@ -208,12 +209,11 @@ fn count_pop_bound_value(value: &Value) -> usize {
}
/// Whether a node's (already-serialized) `events.onTap` includes a bound
/// `pop` navigation action. Track A's own back-button wiring only ever
/// writes `{"pop": null}` (`wire_screen_navigation::wire_back_buttons`);
/// checked generically (rather than reusing a Track A "is this shaped like a
/// back button" name/icon heuristic) so a hand-authored pop binding on any
/// node counts too — this metric is "did a pop binding land", not "did the
/// back-button heuristic fire".
/// `pop` navigation action. The cleanup interaction backfill only ever writes
/// `{"pop": null}`; checked generically (rather than repeating its strict
/// geometry/icon-data predicate) so a hand-authored pop binding on any node
/// counts too — this metric is "did a pop binding land", not "did the strict
/// back-control fact match".
fn has_pop_action(value: &Value) -> bool {
value
.get("events")