feat(agent): report deck row overflow instead of clipping it

The horizontal-overflow floor sets `clipContent` on a row whose children
do not fit. On a scrolling screen that is recoverable — the content can
still be scrolled back into view. On a projector board it is content the
audience never learns exists, and the honest fixes (shorten it, re-type
it, split the page) are all decisions this pass cannot make.

So a board now reports the overflow and leaves the row visibly too wide.
The report travels as an echo rather than a repair record, because
nothing was repaired and a record would claim credit for a fix that
never happened: the finalize path notes it onto the user-visible
summary, and a sub-task, which has no summary to write to, logs it.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-08-09 19:56:54 +08:00
parent f10b660099
commit 4f124b0b1d
11 changed files with 497 additions and 21 deletions

View file

@ -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<String>,
/// Target row's name, when it has one.
node_name: Option<String>,
/// 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"));
}
}

View file

@ -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,
};

View file

@ -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<String>, 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<f64> {
@ -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<crate::deck_echo::DeckEcho>;
// 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;

View file

@ -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<serde_json::Value>) -> Vec<PenNode> {
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}"
);
}

View file

@ -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());
}

View file

@ -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<DeckEcho>,
) {
// 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);
}
}
}
}

View file

@ -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));
}

View file

@ -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<Value>,
canvas_width: f64,
run_intent: bool,
form: DesignForm,
echoes: &mut Vec<DeckEcho>,
) {
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<DeckEcho> {
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::<PenNode>(v) {
*node = new_node;
}
}
echoes
}
/// Surface-color discipline over the whole forest. MUST run AFTER

View file

@ -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<Value>, 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 ───────────────────────────────────────────

View file

@ -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"],

View file

@ -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