From 0ca9aa6bca3bd991b1536135450be038ba41f576 Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 2 Aug 2026 21:21:13 +0800 Subject: [PATCH] fix(agent): centre a deck board's content instead of stacking it at the top MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slide root is a fixed 1080 tall while its sections hug their own height, so content piled up from the top edge and left the lower half of every board blank — visible on every deck generated so far. `is_deck` now travels from `NormInfo` through `CleanupPolicy`, so the judgement is the design type the planner already decided, not a guess from the board's dimensions: a 1920x1080 design that is not a deck is a perfectly ordinary thing to make. Only `justifyContent` is written. The alternative — stretching sections to `fill_container` — would distort whatever composition the model produced; centring moves where the block sits without changing what it is. A board that states its own distribution (`space_between` and friends) is a composition rather than the default top-stack, and is left untouched. Both cases are covered by tests that build a board and run real cleanup, including one asserting the pinned 1080 height survives the centring — the two repairs touch the same node and must not undo each other. Verified end to end: six boards, all 1920x1080, all centred. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x --- crates/op-orchestrator/src/cleanup.rs | 56 +++++++++ .../src/cleanup_root_height_tests.rs | 116 ++++++++++++++++++ crates/op-orchestrator/src/plan_normalize.rs | 4 + .../op-orchestrator/src/run_orchestrator.rs | 1 + 4 files changed, 177 insertions(+) diff --git a/crates/op-orchestrator/src/cleanup.rs b/crates/op-orchestrator/src/cleanup.rs index 2a46d3335..9ac1d7add 100644 --- a/crates/op-orchestrator/src/cleanup.rs +++ b/crates/op-orchestrator/src/cleanup.rs @@ -257,6 +257,10 @@ fn find_root<'a>(state: &'a EditorState, root_id: &str) -> Option<&'a PenNode> { #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub(crate) struct CleanupPolicy { pub(crate) preserve_requested_root_height: bool, + /// Deck boards are fixed 16:9 surfaces. Their content is centred rather + /// than left to stack from the top edge, which on a 1080-tall board reads + /// as a half-empty slide. + pub(crate) is_deck: bool, } /// 阶段 4 清理 pass —— 在全部 subtask 插入完成后运行。 @@ -369,6 +373,43 @@ pub fn run_cleanup_passes_with_summary( ); } +/// Centre a deck board's content on its fixed 16:9 surface. +/// +/// A slide root is 1080 tall no matter how much content it holds, and its +/// sections hug their own height, so without this they stack from the top and +/// leave the lower half blank — measured on every generated deck. +/// +/// Only `justifyContent` is written. Stretching the sections to fill instead +/// would distort whatever the model composed; centring changes where the block +/// sits, not what it is. +fn centre_deck_board_content(sink: &mut dyn DocSink, root_id: &str) { + let Some(root) = sink + .state() + .active_children() + .iter() + .find(|node| node.id_str() == root_id) + else { + return; + }; + let value = serde_json::to_value(root).unwrap_or(serde_json::Value::Null); + // Respect an explicit distribution: a board that deliberately pushes + // content apart (space_between) or pins it low is a composition, not the + // default top-stack this repairs. + if value + .get("justifyContent") + .and_then(serde_json::Value::as_str) + .is_some_and(|mode| !mode.is_empty()) + { + return; + } + let node_id = NodeId::new(root.id_str()); + sink.apply(EditorCommand::PatchNodeData { + node_id, + patch_json: r#"{"justifyContent":"center"}"#.to_string(), + page_id: None, + }); +} + /// Write the repaired root gap as a property patch. /// /// Deliberately NOT an `apply_root_transform`: that rebuilds the subtree and @@ -402,6 +443,18 @@ fn patch_root_section_gap(sink: &mut dyn DocSink, root_id: &str) { }); } +/// Test-only alias so policy-dependent passes can be driven directly. +#[cfg(test)] +pub(crate) fn run_cleanup_passes_with_summary_and_policy_for_tests( + sink: &mut dyn DocSink, + plan: &OrchestratorPlan, + root_ids: &[&str], + summary: &mut RepairSummary, + policy: CleanupPolicy, +) { + run_cleanup_passes_with_summary_and_policy(sink, plan, root_ids, summary, policy); +} + fn run_cleanup_passes_with_summary_and_policy( sink: &mut dyn DocSink, plan: &OrchestratorPlan, @@ -507,6 +560,9 @@ fn run_cleanup_passes_with_summary_and_policy( // reach it in the same state. patch_root_section_gap(sink, &rid); debug_probe_child_height(sink, &rid, "root_gap"); + if policy.is_deck { + centre_deck_board_content(sink, &rid); + } // Text that resolves to ~1:1 against its own background is not // styled, it is missing. The lint crate has detected this since // 2026-05, but the generation path called exactly one of its diff --git a/crates/op-orchestrator/src/cleanup_root_height_tests.rs b/crates/op-orchestrator/src/cleanup_root_height_tests.rs index 2f4c5eb4b..9b28d0d50 100644 --- a/crates/op-orchestrator/src/cleanup_root_height_tests.rs +++ b/crates/op-orchestrator/src/cleanup_root_height_tests.rs @@ -492,6 +492,7 @@ fn cleanup_policy_preserves_only_requested_fixed_root_height() { &[&preserved_root_id], &mut summary, CleanupPolicy { + is_deck: false, preserve_requested_root_height: true, }, ); @@ -560,3 +561,118 @@ fn cleanup_recolors_safe_dark_bottom_nav_on_light_mobile_root() { "cleanup should replace safe-dark bottom nav fill with the light mobile root surface" ); } + +/// A deck board centres its content instead of stacking it from the top. +/// +/// The board is a fixed 1080 tall while its sections hug their own height, so +/// the default leaves the lower half of every slide blank. +#[test] +fn a_deck_board_centres_its_content() { + use op_editor_core::{EditorCommand, NodeId}; + + let mut sink = VecDocSink::new(); + let tree: jian_ops_schema::node::PenNode = serde_json::from_value(serde_json::json!({ + "type": "frame", + "id": "board", + "name": "Cover", + "width": 1920, + "height": 1080, + "layout": "vertical", + "children": [ + {"type": "frame", "id": "s1", "name": "Title", "width": "fill_container", "height": "fit_content"}, + {"type": "frame", "id": "s2", "name": "Body", "width": "fill_container", "height": "fit_content"}, + {"type": "frame", "id": "s3", "name": "Meta", "width": "fill_container", "height": "fit_content"} + ] + })) + .expect("board"); + sink.state.apply(EditorCommand::InsertSubtree { + nodes: vec![tree], + parent_id: NodeId::NONE, + page_id: None, + }); + let root_id = sink.state.active_children()[0].id_str().to_string(); + + let mut summary = crate::repair_summary::RepairSummary::default(); + crate::cleanup::run_cleanup_passes_with_summary_and_policy_for_tests( + &mut sink, + &deck_plan(&root_id), + &[&root_id], + &mut summary, + CleanupPolicy { + is_deck: true, + preserve_requested_root_height: true, + }, + ); + + let root = serde_json::to_value(&sink.state.active_children()[0]).expect("serialize"); + assert_eq!( + root["justifyContent"].as_str(), + Some("center"), + "a deck board must centre its content" + ); + assert_eq!( + root["height"].as_f64(), + Some(1080.0), + "centring must not disturb the pinned board height" + ); +} + +/// An authored distribution is a composition, not the default top-stack. +#[test] +fn a_deck_board_with_an_explicit_distribution_is_left_alone() { + use op_editor_core::{EditorCommand, NodeId}; + + let mut sink = VecDocSink::new(); + let tree: jian_ops_schema::node::PenNode = serde_json::from_value(serde_json::json!({ + "type": "frame", + "id": "board", + "name": "Cover", + "width": 1920, + "height": 1080, + "layout": "vertical", + "justifyContent": "space_between", + "children": [ + {"type": "frame", "id": "s1", "name": "Title", "width": "fill_container", "height": "fit_content"}, + {"type": "frame", "id": "s2", "name": "Meta", "width": "fill_container", "height": "fit_content"} + ] + })) + .expect("board"); + sink.state.apply(EditorCommand::InsertSubtree { + nodes: vec![tree], + parent_id: NodeId::NONE, + page_id: None, + }); + let root_id = sink.state.active_children()[0].id_str().to_string(); + + let mut summary = crate::repair_summary::RepairSummary::default(); + crate::cleanup::run_cleanup_passes_with_summary_and_policy_for_tests( + &mut sink, + &deck_plan(&root_id), + &[&root_id], + &mut summary, + CleanupPolicy { + is_deck: true, + preserve_requested_root_height: true, + }, + ); + + let root = serde_json::to_value(&sink.state.active_children()[0]).expect("serialize"); + assert_eq!(root["justifyContent"].as_str(), Some("space_between")); +} + +fn deck_plan(root_id: &str) -> crate::plan::OrchestratorPlan { + crate::plan::OrchestratorPlan { + root_frame: crate::plan::RootFrameSpec { + id: root_id.to_string(), + name: "Deck".into(), + width: 1920.0, + height: 1080.0, + layout: Some("vertical".into()), + gap: None, + padding: None, + fill: None, + }, + subtasks: Vec::new(), + style_guide_name: None, + } +} diff --git a/crates/op-orchestrator/src/plan_normalize.rs b/crates/op-orchestrator/src/plan_normalize.rs index 1543939db..c5bb94f72 100644 --- a/crates/op-orchestrator/src/plan_normalize.rs +++ b/crates/op-orchestrator/src/plan_normalize.rs @@ -39,6 +39,9 @@ pub struct NormInfo { /// The request explicitly fixed both root dimensions, so fresh-root /// cleanup must preserve the requested height instead of growing it. pub preserve_requested_root_height: bool, + /// A presentation deck. Its boards are fixed 16:9 surfaces, so cleanup + /// centres their content instead of letting it pile up at the top edge. + pub is_deck: bool, } /// 移动端宽度上限(含)—— ≤ 此值视为移动端单屏。 @@ -263,6 +266,7 @@ pub fn normalize(plan: &mut OrchestratorPlan, req: &DesignRequest) -> NormInfo { NormInfo { is_mobile, preserve_requested_root_height, + is_deck, } } diff --git a/crates/op-orchestrator/src/run_orchestrator.rs b/crates/op-orchestrator/src/run_orchestrator.rs index d38621ddd..6960fd60c 100644 --- a/crates/op-orchestrator/src/run_orchestrator.rs +++ b/crates/op-orchestrator/src/run_orchestrator.rs @@ -629,6 +629,7 @@ impl Orchestrator { &mut quality, CleanupPolicy { preserve_requested_root_height: norm.preserve_requested_root_height, + is_deck: norm.is_deck, }, ); }