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:
parent
f10b660099
commit
4f124b0b1d
105
crates/op-orchestrator/src/deck_echo.rs
Normal file
105
crates/op-orchestrator/src/deck_echo.rs
Normal 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
101
crates/op-orchestrator/src/loop_finalize_deck_form_tests.rs
Normal file
101
crates/op-orchestrator/src/loop_finalize_deck_form_tests.rs
Normal 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}"
|
||||
);
|
||||
}
|
||||
136
crates/op-orchestrator/src/role_layout_deck_overflow_tests.rs
Normal file
136
crates/op-orchestrator/src/role_layout_deck_overflow_tests.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue