fix(agent): centre a deck board's content instead of stacking it at the top

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
This commit is contained in:
Fini 2026-08-02 21:21:13 +08:00
parent 7807c06aec
commit 0ca9aa6bca
4 changed files with 177 additions and 0 deletions

View file

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

View file

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

View file

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

View file

@ -629,6 +629,7 @@ impl Orchestrator {
&mut quality,
CleanupPolicy {
preserve_requested_root_height: norm.preserve_requested_root_height,
is_deck: norm.is_deck,
},
);
}