diff --git a/crates/op-orchestrator/src/deck_echo.rs b/crates/op-orchestrator/src/deck_echo.rs new file mode 100644 index 000000000..4f69df0b6 --- /dev/null +++ b/crates/op-orchestrator/src/deck_echo.rs @@ -0,0 +1,105 @@ +//! Geometry violations a deck board forbids repairing in place. +//! +//! Every other surface we generate has a defensible last resort for content +//! that will not fit: a web page can clip a scroll row at the viewport edge +//! and the reader scrolls it back into view. A projector board has no such +//! move — clipped content is simply gone, and nobody in the room knows it was +//! ever there. The deck contract (deck-system spec §3.1) orders the real +//! fixes as shorten the copy → change the page type → split the page, and +//! none of the three is a call a geometry pass can make on its own. +//! +//! So the pass reports instead of repairing. These echoes carry the measured +//! numbers that proved the violation, and the caller renders them into +//! whatever channel it has — `RepairSummary::note` on the finalize path +//! (deliberately NOT a `RepairRecord`, which means "one edit was applied"), +//! a log line on the per-subtask path. + +/// One deck-board violation that was detected and deliberately not repaired. +#[derive(Debug, Clone, PartialEq)] +pub enum DeckEcho { + /// A horizontal row whose children are wider than the board. On any other + /// surface `fix_horizontal_overflow` spans the viewport and sets + /// `clipContent`; on a board that would delete the tail of the row from + /// the projection, so the row is left visibly too wide instead. + HorizontalOverflow { + /// Target row, when it carries an id. + node_id: Option, + /// Target row's name, when it has one. + node_name: Option, + /// Summed child widths plus gaps, in board pixels. + content_width: f64, + /// The row's inner width (its own width minus horizontal padding). + available_width: f64, + }, +} + +impl DeckEcho { + /// One-line rendering for a note or a log line. + pub fn line(&self) -> String { + match self { + DeckEcho::HorizontalOverflow { + content_width, + available_width, + .. + } => format!( + "deck · {} · row content {}px exceeds the board's {}px — split the slide \ + or shorten the row; not clipped (clipping hides it on the projector)", + self.node_label(), + round(*content_width), + round(*available_width), + ), + } + } + + /// `Name [id]`, `[id]`, `Name`, or `an unnamed row` — whichever the node + /// actually has. A row a weak model emitted without an id is the common + /// case here, and "unnamed" is more honest than an empty slot. + fn node_label(&self) -> String { + let (node_id, node_name) = match self { + DeckEcho::HorizontalOverflow { + node_id, node_name, .. + } => (node_id.as_deref(), node_name.as_deref()), + }; + let name = node_name.map(str::trim).filter(|name| !name.is_empty()); + match (name, node_id) { + (Some(name), Some(id)) => format!("{name} [{id}]"), + (Some(name), None) => name.to_string(), + (None, Some(id)) => format!("[{id}]"), + (None, None) => "an unnamed row".to_string(), + } + } +} + +fn round(value: f64) -> i64 { + value.round() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_line_names_the_row_and_both_measurements() { + let echo = DeckEcho::HorizontalOverflow { + node_id: Some("n12".into()), + node_name: Some("KPI Row".into()), + content_width: 2140.0, + available_width: 1776.0, + }; + let line = echo.line(); + assert!(line.contains("KPI Row [n12]"), "{line}"); + assert!(line.contains("2140px"), "{line}"); + assert!(line.contains("1776px"), "{line}"); + } + + #[test] + fn an_anonymous_row_still_renders() { + let echo = DeckEcho::HorizontalOverflow { + node_id: None, + node_name: None, + content_width: 100.0, + available_width: 50.0, + }; + assert!(echo.line().contains("an unnamed row")); + } +} diff --git a/crates/op-orchestrator/src/lib.rs b/crates/op-orchestrator/src/lib.rs index ee1f63b76..a99c61325 100644 --- a/crates/op-orchestrator/src/lib.rs +++ b/crates/op-orchestrator/src/lib.rs @@ -13,6 +13,7 @@ pub mod agent_identity; pub mod compact_prompt; pub mod compact_skills; pub mod dashboard_columns; +pub mod deck_echo; pub mod design_md_policy; // (run_dashboard / scaffold_dashboard removed: dashboards flow through the // generic sequential path; dashboard_columns keeps only normalizer predicates.) @@ -124,6 +125,7 @@ mod sidebar_archetype_tests; mod test_support; pub use compact_prompt::{build_compact_planning_prompt, CompactPlanningPrompt}; +pub use deck_echo::DeckEcho; pub use design_md_policy::{ build_design_md_style_policy, guess_neutral_background_from_theme, infer_design_md_background, }; diff --git a/crates/op-orchestrator/src/loop_finalize.rs b/crates/op-orchestrator/src/loop_finalize.rs index 09cf084de..1008fe345 100644 --- a/crates/op-orchestrator/src/loop_finalize.rs +++ b/crates/op-orchestrator/src/loop_finalize.rs @@ -52,6 +52,7 @@ //! anything, and there is no per-subtask retry to drive at loop end, so it is //! not part of the finalize. +use crate::design_type::DesignForm; use crate::repair_summary::{CheckCategory, RepairCounter, RepairSummary}; use crate::role_defaults::{detect_theme_from_fill, Theme}; use jian_ops_schema::node::PenNode; @@ -134,6 +135,35 @@ fn locate_section_context(forest: &[PenNode]) -> (bool, Option, f64, The } } +/// The form of the surface the section forest sits on. +/// +/// Two shapes carry an artboard. The page-root wrapper is one, and it +/// classifies directly. The other is a multi-slide deck, which has no wrapper +/// at all — every board sits at the top level, so the "forest" walked below is +/// the boards themselves. That case is admitted only when EVERY top-level node +/// is a board: a deck is all boards or it is not a deck, and requiring +/// unanimity is what stops one section that happens to be 16:9 from putting a +/// whole page under the board contracts. +/// +/// Anything else is a flat list of sections with no surface around them — +/// `Unknown`, never a guess taken from the first section's width (which is +/// what `locate_section_context` uses for `canvas_width`, and is a different +/// question). +fn locate_root_form(forest: &[PenNode]) -> DesignForm { + if forest.len() == 1 && is_page_root_wrapper(&forest[0]) { + return crate::design_type::classify_root_form_node(&forest[0]); + } + let all_boards = !forest.is_empty() + && forest + .iter() + .all(|node| crate::design_type::classify_root_form_node(node).is_deck_board()); + if all_boards { + DesignForm::Deck + } else { + DesignForm::Unknown + } +} + /// Perceived luminance (0.299/0.587/0.114) of an `#RRGGBB` hex. `None` on a /// non-hex string. Local reimplementation to avoid a cross-module `pub`. fn hex_luminance(hex: &str) -> Option { @@ -513,6 +543,11 @@ pub fn apply_loop_finalize_counted(state: &mut EditorState) -> RepairSummary { let (has_wrapper, page_bg, canvas_width, theme) = locate_section_context(state.active_children()); let light_theme = theme == Theme::Light; + let root_form = locate_root_form(state.active_children()); + // Violations the section walk below detected and deliberately did not + // repair (see `crate::deck_echo`). Filled inside the borrow, noted onto + // the summary once the forest borrow ends. + let deck_echoes: Vec; // Resolved BEFORE the forest is mutably borrowed below. The loop path has // no plan to carry provenance on, which is exactly why the policy is read @@ -539,7 +574,12 @@ pub fn apply_loop_finalize_counted(state: &mut EditorState) -> RepairSummary { let prior_accent = crate::tree_heuristics::dominant_design_accent(forest); crate::role_infer::resolve_forest_roles(forest, canvas_width, theme); - crate::role_post_pass::post_pass_forest_with_tier(forest, canvas_width, &tier); + deck_echoes = crate::role_post_pass::post_pass_forest_with_tier( + forest, + canvas_width, + &tier, + root_form, + ); jian_ops_schema::promote::promote_forest(forest); // The remaining three are intent-tier in full (`crate::repair_tier`): // fill/decoration heuristics, hex→token rebinding, and surface-colour @@ -558,6 +598,11 @@ pub fn apply_loop_finalize_counted(state: &mut EditorState) -> RepairSummary { } crate::role_post_pass::enforce_surface_color_discipline_with_tier(forest, &tier); } + // A note, not a `RepairRecord`: nothing was applied, and a record would + // make the credential claim a repair that never happened. + for echo in &deck_echoes { + summary.note(echo.line()); + } // Hoist node-level `state` into the document root, mirroring the // orchestrator's per-subtask `hoist_app_state` — without this the @@ -650,6 +695,10 @@ fn synthesize_plan(forest: &[PenNode], canvas_width: f64) -> crate::plan::Orches #[path = "loop_finalize_tests.rs"] mod tests; +#[cfg(test)] +#[path = "loop_finalize_deck_form_tests.rs"] +mod deck_form_tests; + #[cfg(test)] #[path = "loop_finalize_polarity_tests.rs"] mod polarity_tests; diff --git a/crates/op-orchestrator/src/loop_finalize_deck_form_tests.rs b/crates/op-orchestrator/src/loop_finalize_deck_form_tests.rs new file mode 100644 index 000000000..66698454b --- /dev/null +++ b/crates/op-orchestrator/src/loop_finalize_deck_form_tests.rs @@ -0,0 +1,101 @@ +//! Which surface the loop-finalize path believes it is repairing, and what it +//! does with a deck violation it refuses to repair (deck-system spec §4.5/§4.6). + +use super::*; +use serde_json::json; + +fn nodes(values: Vec) -> Vec { + values + .into_iter() + .map(|value| serde_json::from_value(value).expect("fixture must deserialize as PenNode")) + .collect() +} + +fn board(id: &str, children: serde_json::Value) -> serde_json::Value { + json!({ + "type":"frame","id":id,"name":id,"width":1920,"height":1080, + "layout":"vertical","children":children + }) +} + +#[test] +fn a_page_root_wrapper_around_a_board_is_a_board() { + let forest = nodes(vec![board( + "slide", + json!([{"type":"text","id":"t","content":"Q3"}]), + )]); + assert_eq!(locate_root_form(&forest), DesignForm::Deck); +} + +#[test] +fn a_multi_slide_deck_has_no_wrapper_and_is_still_a_deck() { + // The shape a real deck actually has: every board at the top level. Without + // the all-boards branch this classified as Unknown and the deck contracts + // never reached the one document type they exist for. + let forest = nodes(vec![ + board( + "slide-1", + json!([{"type":"text","id":"t1","content":"Cover"}]), + ), + board( + "slide-2", + json!([{"type":"text","id":"t2","content":"Agenda"}]), + ), + board( + "slide-3", + json!([{"type":"text","id":"t3","content":"Close"}]), + ), + ]); + assert_eq!(locate_root_form(&forest), DesignForm::Deck); +} + +#[test] +fn one_board_shaped_section_does_not_make_a_page_a_deck() { + let forest = nodes(vec![ + board("hero", json!([{"type":"text","id":"t1","content":"Hero"}])), + json!({ + "type":"frame","id":"features","name":"Features","width":1920,"height":640, + "children":[{"type":"text","id":"t2","content":"Features"}] + }), + ]); + assert_eq!(locate_root_form(&forest), DesignForm::Unknown); +} + +#[test] +fn an_empty_document_has_no_form() { + assert_eq!(locate_root_form(&[]), DesignForm::Unknown); +} + +#[test] +fn a_boards_overflowing_row_is_reported_in_the_summary_not_clipped() { + // End to end over the loop path: the board's row cannot fit at any width, + // so the clip floor would normally take it. On a board the pass reports + // instead — as a NOTE, because nothing was applied. + let mut state = EditorState::default(); + let forest = nodes(vec![board( + "slide-1", + json!([{ + "type":"frame","id":"row","name":"KPI Row","layout":"horizontal","width":1776,"gap":48, + "children":[ + {"type":"frame","id":"k1","name":"k1","width":700,"height":240,"children":[]}, + {"type":"frame","id":"k2","name":"k2","width":700,"height":240,"children":[]}, + {"type":"frame","id":"k3","name":"k3","width":700,"height":240,"children":[]} + ] + }]), + )]); + *state.active_children_mut() = forest; + + let summary = apply_loop_finalize_counted(&mut state); + + let notes = summary.notes().join("\n"); + assert!( + notes.contains("KPI Row") && notes.contains("split the slide"), + "the board overflow must reach the user-visible summary: {notes:?}" + ); + let row = serde_json::to_value(state.active_children()).unwrap(); + assert_eq!( + row[0]["children"][0].get("clipContent"), + None, + "and the row must not have been clipped: {row}" + ); +} diff --git a/crates/op-orchestrator/src/role_layout_deck_overflow_tests.rs b/crates/op-orchestrator/src/role_layout_deck_overflow_tests.rs new file mode 100644 index 000000000..c5e8804f3 --- /dev/null +++ b/crates/op-orchestrator/src/role_layout_deck_overflow_tests.rs @@ -0,0 +1,136 @@ +//! The deck gate on `fix_horizontal_overflow`'s clip floor +//! (deck-system spec §4.6). +//! +//! Clipping an over-wide row is the right last resort on a screen and the +//! wrong one on a projector board: the audience cannot scroll the tail back +//! into view, so the clipped content is simply gone and nobody knows it was +//! there. On a board the pass reports and leaves the row alone. + +use serde_json::json; + +use super::fix_horizontal_overflow; +use crate::deck_echo::DeckEcho; +use crate::design_type::DesignForm; + +/// A 1920-wide board row whose children cannot fit it at any width — the same +/// shape as the phone chip row the clip floor was written for, scaled up. +fn overfull_board_row() -> serde_json::Value { + json!({ + "type":"frame","id":"kpi","name":"KPI Row","layout":"horizontal", + "width":1776,"gap":48, + "children":[ + {"type":"frame","width":460,"height":240}, + {"type":"frame","width":460,"height":240}, + {"type":"frame","width":460,"height":240}, + {"type":"frame","width":460,"height":240} + ] + }) +} + +#[test] +fn a_deck_board_reports_the_overflow_instead_of_clipping_it() { + let mut row = overfull_board_row(); + let before = row.clone(); + let mut echoes = Vec::new(); + fix_horizontal_overflow(&mut row, 1920.0, DesignForm::Deck, &mut echoes); + + assert_eq!( + row.get("clipContent"), + None, + "a board row must never take the clip floor — clipped slide content is gone: {row}" + ); + assert_eq!( + row["width"], before["width"], + "the row is left visibly too wide rather than silently spanned + clipped: {row}" + ); + assert_eq!( + echoes.len(), + 1, + "the violation must still be reported: {echoes:?}" + ); + let DeckEcho::HorizontalOverflow { + node_id, + node_name, + content_width, + available_width, + } = &echoes[0]; + assert_eq!(node_id.as_deref(), Some("kpi")); + assert_eq!(node_name.as_deref(), Some("KPI Row")); + assert!( + content_width > available_width, + "the echo carries the measurements that proved the overflow: {echoes:?}" + ); +} + +#[test] +fn every_other_form_still_takes_the_clip_floor() { + // The gate is deck-only: Page / MobileScreen / Unknown must behave exactly + // as they did before it existed. + for form in [ + DesignForm::Page, + DesignForm::MobileScreen, + DesignForm::Unknown, + ] { + let mut row = overfull_board_row(); + let mut echoes = Vec::new(); + fix_horizontal_overflow(&mut row, 1920.0, form, &mut echoes); + assert_eq!( + row["width"], + json!("fill_container"), + "{form:?} still spans the viewport: {row}" + ); + assert_eq!( + row["clipContent"], + json!(true), + "{form:?} still clips at the edge: {row}" + ); + assert!( + echoes.is_empty(), + "only a board echoes — every other form repaired it: {echoes:?}" + ); + } +} + +#[test] +fn a_board_row_that_only_needs_a_tighter_gap_is_still_repaired() { + // The gate covers ONLY the clip branch. A row that fits once its gap + // shrinks is a repair with a single correct answer, and a board takes it + // like anything else — nothing is hidden and no page needs splitting. + let mut row = json!({ + "type":"frame","id":"row","layout":"horizontal","width":1776,"gap":48, + "children":[ + {"type":"frame","width":880,"height":240}, + {"type":"frame","width":880,"height":240} + ] + }); + let mut echoes = Vec::new(); + fix_horizontal_overflow(&mut row, 1920.0, DesignForm::Deck, &mut echoes); + + assert_eq!( + row["gap"], + json!(8.0), + "the gap still tightens on a board: {row}" + ); + assert_eq!( + row["width"], + json!(1776), + "and the row keeps its width: {row}" + ); + assert!(echoes.is_empty(), "nothing was left unrepaired: {echoes:?}"); +} + +#[test] +fn a_board_row_that_fits_is_untouched() { + let mut row = json!({ + "type":"frame","id":"row","layout":"horizontal","width":1776,"gap":48, + "children":[ + {"type":"frame","width":400,"height":240}, + {"type":"frame","width":400,"height":240} + ] + }); + let before = row.clone(); + let mut echoes = Vec::new(); + fix_horizontal_overflow(&mut row, 1920.0, DesignForm::Deck, &mut echoes); + assert_eq!(row, before); + assert!(echoes.is_empty()); +} diff --git a/crates/op-orchestrator/src/role_layout_post_pass.rs b/crates/op-orchestrator/src/role_layout_post_pass.rs index ec4ca69ec..513b9ad4c 100644 --- a/crates/op-orchestrator/src/role_layout_post_pass.rs +++ b/crates/op-orchestrator/src/role_layout_post_pass.rs @@ -1,6 +1,9 @@ use jian_ops_schema::node::PenNode; use serde_json::{json, Value}; +use crate::deck_echo::DeckEcho; +use crate::design_type::DesignForm; + /// Read a width/height as a pixel number (port of TS `toSizeNumber`). pub(crate) fn size_number(node: &Value, key: &str) -> f64 { match node.get(key) { @@ -14,6 +17,10 @@ pub(crate) fn size_number(node: &Value, key: &str) -> f64 { #[path = "role_layout_radial_tests.rs"] mod radial_tests; +#[cfg(test)] +#[path = "role_layout_deck_overflow_tests.rs"] +mod deck_overflow_tests; + fn gap_number(node: &Value) -> f64 { match node.get("gap") { Some(Value::Number(n)) => n.as_f64().unwrap_or(0.0), @@ -44,7 +51,18 @@ fn padding_lr(node: &Value) -> (f64, f64) { } } -pub(crate) fn fix_horizontal_overflow(node: &mut Value, canvas_width: f64) { +/// Repair a horizontal row whose children sum wider than it is. +/// +/// `form` decides what happens when the row cannot be widened enough to fit: +/// every surface except a deck board takes the `clipContent` floor, while a +/// board reports the overflow through `echoes` and is left alone (see +/// [`crate::deck_echo`] and the clip branch below). +pub(crate) fn fix_horizontal_overflow( + node: &mut Value, + canvas_width: f64, + form: DesignForm, + echoes: &mut Vec, +) { // Summing child widths as a ROW is only valid for a row layout. A // `vertical` column stacks its children (widths don't sum — the max // applies) and a `none` container positions them absolutely; running the @@ -121,8 +139,25 @@ pub(crate) fn fix_horizontal_overflow(node: &mut Value, canvas_width: f64) { // models (e.g. glm-5.2) routinely emit a bare horizontal frame without // it, so this is the deterministic floor that keeps off-screen children // from rendering outside the device frame. - node["width"] = json!("fill_container"); - node["clipContent"] = json!(true); + // + // …except on a projector board, where the floor is a semantic + // error (deck-system spec §4.6): a clipped scroll row on a screen + // can be scrolled back into view, a clipped row on a slide is + // content the audience never learns existed. The correct deck fix + // is to shorten, re-type, or split the page — none of which this + // pass can decide — so report the overflow and leave the row + // visibly too wide. + if form.is_deck_board() { + echoes.push(DeckEcho::HorizontalOverflow { + node_id: node.get("id").and_then(Value::as_str).map(str::to_string), + node_name: node.get("name").and_then(Value::as_str).map(str::to_string), + content_width: total_w, + available_width: avail_w, + }); + } else { + node["width"] = json!("fill_container"); + node["clipContent"] = json!(true); + } } } } diff --git a/crates/op-orchestrator/src/role_layout_radial_tests.rs b/crates/op-orchestrator/src/role_layout_radial_tests.rs index 6b1bd5427..9207ff65a 100644 --- a/crates/op-orchestrator/src/role_layout_radial_tests.rs +++ b/crates/op-orchestrator/src/role_layout_radial_tests.rs @@ -16,7 +16,7 @@ fn horizontal_overflow_preserves_direct_arc_stack_wrapper_width() { ] }); - fix_horizontal_overflow(&mut ring, 375.0); + fix_horizontal_overflow(&mut ring, 375.0, DesignForm::Unknown, &mut Vec::new()); assert_eq!(ring["width"], json!(120)); assert!(ring.get("clipContent").is_none()); @@ -34,7 +34,7 @@ fn horizontal_overflow_still_expands_row_of_plain_ellipses() { ] }); - fix_horizontal_overflow(&mut row, 375.0); + fix_horizontal_overflow(&mut row, 375.0, DesignForm::Unknown, &mut Vec::new()); assert_eq!(row["width"], json!(240.0)); } @@ -50,7 +50,7 @@ fn horizontal_overflow_still_expands_wide_row_of_independent_arcs() { ] }); - fix_horizontal_overflow(&mut row, 375.0); + fix_horizontal_overflow(&mut row, 375.0, DesignForm::Unknown, &mut Vec::new()); assert_eq!(row["width"], json!(136.0)); } diff --git a/crates/op-orchestrator/src/role_post_pass.rs b/crates/op-orchestrator/src/role_post_pass.rs index b95711d60..23b891093 100644 --- a/crates/op-orchestrator/src/role_post_pass.rs +++ b/crates/op-orchestrator/src/role_post_pass.rs @@ -29,6 +29,8 @@ use jian_ops_schema::node::PenNode; use serde_json::{json, Value}; +use crate::deck_echo::DeckEcho; +use crate::design_type::DesignForm; use crate::role_layout_post_pass::{fix_horizontal_overflow, fix_text_heights, size_number}; // Sibling modules: this file keeps the public surface (`post_pass_forest` / @@ -62,6 +64,8 @@ fn post_pass_value( parent_fill: Option, canvas_width: f64, run_intent: bool, + form: DesignForm, + echoes: &mut Vec, ) { if node.get("type").and_then(Value::as_str) != Some("frame") { return; @@ -70,7 +74,7 @@ fn post_pass_value( // Still deferred: frame-height expansion (needs intrinsic measurement) and // placeholder icon repair (needs the icon catalog). equalize_card_row(node); - fix_horizontal_overflow(node, canvas_width); + fix_horizontal_overflow(node, canvas_width, form, echoes); normalize_form_input_widths(node); normalize_input_trailing_icon_alignment(node); normalize_nested_search_shell(node); @@ -116,7 +120,14 @@ fn post_pass_value( let this_fill = Some(node.get("fill").cloned().unwrap_or(Value::Null)); if let Some(children) = node.get_mut("children").and_then(Value::as_array_mut) { for child in children.iter_mut() { - post_pass_value(child, this_fill.clone(), canvas_width, run_intent); + post_pass_value( + child, + this_fill.clone(), + canvas_width, + run_intent, + form, + echoes, + ); } } } @@ -126,32 +137,43 @@ pub fn post_pass_forest(nodes: &mut [PenNode], canvas_width: f64) { nodes, canvas_width, &crate::repair_tier::RepairTierPolicy::all(), + DesignForm::Unknown, ); } -/// [`post_pass_forest`] under a repair-tier policy. +/// [`post_pass_forest`] under a repair-tier policy, for a known surface. /// /// The walk is overwhelmingly contract-tier (overflow, text heights, contrast), /// so it is not gated as a whole — only the one intent-tier fix inside it /// (`fix_structural_wrapper_transparency`) answers to `policy`. Production /// callers use this form; `post_pass_forest` is the full-tier shorthand for /// tests and any caller with no document in hand. +/// +/// `form` is the ROOT's form, not each section's: the forest handed here is a +/// list of sections that sit on a board / page / screen, and only the surface +/// they sit on decides whether clipping an overflowing row is acceptable. The +/// returned echoes are violations the walk deliberately did not repair (see +/// [`crate::deck_echo`]); a caller with nowhere to put them may drop them, +/// which is why they are returned rather than written anywhere here. pub fn post_pass_forest_with_tier( nodes: &mut [PenNode], canvas_width: f64, policy: &crate::repair_tier::RepairTierPolicy, -) { + form: DesignForm, +) -> Vec { let run_intent = policy.runs_pass(crate::repair_tier::TieredPass::StructuralWrapperTransparency); + let mut echoes = Vec::new(); for node in nodes.iter_mut() { let Ok(mut v) = serde_json::to_value(&*node) else { continue; }; - post_pass_value(&mut v, None, canvas_width, run_intent); + post_pass_value(&mut v, None, canvas_width, run_intent, form, &mut echoes); if let Ok(new_node) = serde_json::from_value::(v) { *node = new_node; } } + echoes } /// Surface-color discipline over the whole forest. MUST run AFTER diff --git a/crates/op-orchestrator/src/role_post_pass_mobile_tests.rs b/crates/op-orchestrator/src/role_post_pass_mobile_tests.rs index 7c0ff126f..0a59a2e45 100644 --- a/crates/op-orchestrator/src/role_post_pass_mobile_tests.rs +++ b/crates/op-orchestrator/src/role_post_pass_mobile_tests.rs @@ -5,12 +5,20 @@ use super::*; use serde_json::json; -/// The walk with every tier enabled. These tests exercise the normalizers -/// themselves, not the repair-tier gate in front of one of them (see -/// `crate::repair_tier`), so they call the shape the pass had before the gate -/// existed and stay readable as normalizer tests. +/// The walk with every tier enabled, on an unclassified surface. These tests +/// exercise the normalizers themselves — not the repair-tier gate in front of +/// one of them (see `crate::repair_tier`), and not the deck gate on the +/// overflow clip floor (see `crate::deck_echo`) — so they call the shape the +/// pass had before either gate existed and stay readable as normalizer tests. fn post_pass_value(node: &mut Value, parent_fill: Option, canvas_width: f64) { - super::post_pass_value(node, parent_fill, canvas_width, true); + super::post_pass_value( + node, + parent_fill, + canvas_width, + true, + DesignForm::Unknown, + &mut Vec::new(), + ); } // ── fixInputSiblingConsistency ─────────────────────────────────────────── diff --git a/crates/op-orchestrator/src/role_post_pass_tests.rs b/crates/op-orchestrator/src/role_post_pass_tests.rs index ca54681a7..f51e9810e 100644 --- a/crates/op-orchestrator/src/role_post_pass_tests.rs +++ b/crates/op-orchestrator/src/role_post_pass_tests.rs @@ -87,7 +87,7 @@ fn horizontal_overflow_reduces_gap_before_expanding_parent() { {"type":"frame","width":130,"height":44} ] }); - fix_horizontal_overflow(&mut row, 375.0); + fix_horizontal_overflow(&mut row, 375.0, DesignForm::Unknown, &mut Vec::new()); assert_eq!(row["gap"], json!(8.0)); assert_eq!(row["width"], json!(300)); } @@ -101,7 +101,7 @@ fn horizontal_overflow_uses_fill_when_needed_width_nears_canvas() { {"type":"frame","width":180,"height":44} ] }); - fix_horizontal_overflow(&mut row, 375.0); + fix_horizontal_overflow(&mut row, 375.0, DesignForm::Unknown, &mut Vec::new()); assert_eq!(row["width"], json!("fill_container")); } @@ -121,7 +121,7 @@ fn horizontal_overflow_beyond_viewport_clips_instead_of_spilling() { {"type":"frame","width":96,"height":34} ] }); - fix_horizontal_overflow(&mut row, 375.0); + fix_horizontal_overflow(&mut row, 375.0, DesignForm::Unknown, &mut Vec::new()); assert_eq!(row["width"], json!("fill_container")); assert_eq!( row["clipContent"], diff --git a/crates/op-orchestrator/src/subagent.rs b/crates/op-orchestrator/src/subagent.rs index 748065f05..bf8fc635f 100644 --- a/crates/op-orchestrator/src/subagent.rs +++ b/crates/op-orchestrator/src/subagent.rs @@ -221,7 +221,25 @@ pub(crate) async fn run_subtask_with_reveal_at( // intent-tier passes below defer to authored template input. Read off the // sink's state so this path and the agentic loop reach the same answer. let tier = crate::repair_tier::RepairTierPolicy::for_document(sink.state()); - crate::role_post_pass::post_pass_forest_with_tier(&mut nodes, canvas_width, &tier); + // The board/page/screen these sections will sit on — taken from the PLAN's + // root frame, which is the artboard; the forest here is its content. + let root_form = crate::design_type::classify_root_form( + Some(plan.root_frame.width), + Some(plan.root_frame.height), + ); + let deck_echoes = crate::role_post_pass::post_pass_forest_with_tier( + &mut nodes, + canvas_width, + &tier, + root_form, + ); + // A subtask has no `RepairSummary` to note against (it reports through + // `SubtaskOutcome`, which counts nodes, not repairs), so the log is the + // channel here. The whole-document finalize path echoes the same + // violations into the user-visible summary. + for echo in &deck_echoes { + tracing::warn!(subtask = %subtask.id, echo = %echo.line(), "deck geometry left unrepaired"); + } // Promote explicitly-marked role frames to first-class widget nodes. // Must run AFTER post_pass_forest (which keys on `role` to set defaults) // and BEFORE variable binding (which resolves hex refs — widgets produced