From e5e84ea9323b8ee9fd683b9eaab9605ac43394bd Mon Sep 17 00:00:00 2001 From: Fini Date: Sun, 2 Aug 2026 23:31:36 +0800 Subject: [PATCH] fix(agent): give wrapped canvas rows headroom for frame labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The canvas paints each top-level frame's name ABOVE the frame at a fixed screen-space offset that does not scale with zoom (canvas_frame_labels.rs puts the baseline at sy-18 and the label box top at sy-32). A row gap equal to the column gap therefore cannot work: at the zoom where a wrapped canvas is actually viewed, the gap is narrower than the label itself, so every second-row label sat pressed against the bottom edge of the row above. For the 3x2 deck the numbers are exact: 6000 doc px of content framed by zoom_to_fit into a 944-1424 px canvas region lands at zoom 0.14-0.22, where a 120 doc px row gap is only 16-26 screen px against a label that always occupies 32. It overlapped at every realistic window size. Add 240 doc px of headroom to the VERTICAL step only — 33-52 screen px at that zoom, clearing the label with room to spare. Columns are untouched: labels are left-aligned to their own frame and never reach sideways. The slide-deck template takes the same allowance on top of its own gap, so both the generated canvas and the shipped template space rows the same way. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x --- .../assets/scene_templates/slide-deck.op | 6 ++--- crates/op-orchestrator/src/scaffold.rs | 24 ++++++++++++++++-- crates/op-orchestrator/src/scaffold_tests.rs | 9 ++++--- templates/step0/_generators/tpl_slides.py | 25 ++++++++++++++----- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/crates/op-editor-core/assets/scene_templates/slide-deck.op b/crates/op-editor-core/assets/scene_templates/slide-deck.op index 6836828a7..ff43cfd7b 100644 --- a/crates/op-editor-core/assets/scene_templates/slide-deck.op +++ b/crates/op-editor-core/assets/scene_templates/slide-deck.op @@ -1232,7 +1232,7 @@ } ], "x": 0, - "y": 1200 + "y": 1440 }, { "type": "frame", @@ -1575,7 +1575,7 @@ } ], "x": 2040, - "y": 1200 + "y": 1440 }, { "type": "frame", @@ -1664,7 +1664,7 @@ } ], "x": 4080, - "y": 1200 + "y": 1440 } ] } diff --git a/crates/op-orchestrator/src/scaffold.rs b/crates/op-orchestrator/src/scaffold.rs index 46ed5d9a0..c4f5a60ec 100644 --- a/crates/op-orchestrator/src/scaffold.rs +++ b/crates/op-orchestrator/src/scaffold.rs @@ -462,6 +462,24 @@ pub(crate) const SCREEN_GROUP_GAP: f64 = 80.0; /// - 390 mobile → 17 per row, i.e. unchanged for any realistic fan-out const MAX_ROW_WIDTH: f64 = 8200.0; +/// Extra vertical clearance between rows, on top of `SCREEN_GROUP_GAP`. +/// +/// The canvas paints each top-level frame's name ABOVE the frame at a FIXED +/// screen-space offset that does not scale with zoom +/// (`canvas_frame_labels.rs`: baseline at `sy - 18`, label box top at +/// `sy - 32`). So a row gap equal to the column gap makes every second-row +/// label collide with the bottom edge of the row above — 80 doc px is only +/// ~11-17 screen px at the zoom where a wrapped canvas is actually viewed, +/// well inside the label's fixed 32 px footprint (user report 2026-08-02). +/// +/// 240 doc px is the allowance: fitting a wrapped multi-row canvas to the +/// screen lands around zoom 0.14-0.22 (a 3-wide 1920 deck is 6000 doc px +/// across, framed by `zoom_to_fit` into a 944-1424 px canvas region), where +/// 240 doc px reads as 33-52 screen px — the label's 32 px plus breathing +/// room. Horizontal spacing is untouched: labels are left-aligned to their +/// own frame and never reach sideways. +const ROW_LABEL_HEADROOM: f64 = 240.0; + /// How many screen roots fit in one row at `board_width`. /// /// Always ≥ 1: a board wider than the whole row budget still has to go @@ -527,14 +545,16 @@ pub(crate) fn build_screen_group_scaffold( // Roots wrap into rows instead of one endless strip (see `MAX_ROW_WIDTH`). // `y` is the top of the FIRST row; each later row steps down by a board - // height plus the same gutter the columns use. The row step reads the + // height, the same gutter the columns use, and the label headroom the + // columns do NOT need (see `ROW_LABEL_HEADROOM`). The row step reads the // height the scaffold actually builds with — a plan's `height: 0` means // "size me from content", and `build_root_frame_node` presets that to the // device-class artboard, so both must ask `resolved_root_height` or the // rows would overlap by exactly the amount that preset adds. let per_row = boards_per_row(rf.width); let column_step = rf.width + SCREEN_GROUP_GAP; - let row_step = resolved_root_height(rf.height, rf.width) + SCREEN_GROUP_GAP; + let row_step = + resolved_root_height(rf.height, rf.width) + SCREEN_GROUP_GAP + ROW_LABEL_HEADROOM; for (index, group) in groups.iter().enumerate() { // Placeholder id: `{root_frame.id}-{screen}` — mirrors the deleted diff --git a/crates/op-orchestrator/src/scaffold_tests.rs b/crates/op-orchestrator/src/scaffold_tests.rs index 4b6afec3c..5c09b508f 100644 --- a/crates/op-orchestrator/src/scaffold_tests.rs +++ b/crates/op-orchestrator/src/scaffold_tests.rs @@ -520,10 +520,13 @@ fn wide_screen_group_boards_wrap_onto_a_second_row() { ); // The fifth board is the wrap: back to the left edge, one board height - // plus a gutter further down. + // plus a gutter plus the frame-name label's headroom further down. assert_eq!( positions[4], - (start_x, start_y + 1080.0 + SCREEN_GROUP_GAP), + ( + start_x, + start_y + 1080.0 + SCREEN_GROUP_GAP + ROW_LABEL_HEADROOM + ), "board 5 starts a new row: {positions:?}" ); assert_eq!( @@ -583,7 +586,7 @@ fn content_sized_boards_still_step_down_by_the_preset_height() { let positions = root_positions(nodes); assert_eq!( positions[4], - (0.0, 900.0 + SCREEN_GROUP_GAP), + (0.0, 900.0 + SCREEN_GROUP_GAP + ROW_LABEL_HEADROOM), "the desktop preset height drives the row step: {positions:?}" ); } diff --git a/templates/step0/_generators/tpl_slides.py b/templates/step0/_generators/tpl_slides.py index 038400b90..ad6d66b58 100644 --- a/templates/step0/_generators/tpl_slides.py +++ b/templates/step0/_generators/tpl_slides.py @@ -231,10 +231,23 @@ def closing(): return s -# Gap between boards on the canvas, used on both axes. Without explicit -# positions every frame defaults to the origin and the deck stacks into one -# visible slide — the other five are underneath it (reported 2026-08-02). -BOARD_GAP = 120 +# Horizontal gap between boards. Without explicit positions every frame +# defaults to the origin and the deck stacks into one visible slide — the +# other five are underneath it (reported 2026-08-02). +BOARD_GAP_X = 120 + +# Vertical gap. Larger than the horizontal one because the canvas paints each +# frame's name ABOVE the frame at a fixed screen-space offset that does not +# scale with zoom (canvas_frame_labels.rs: baseline at sy-18, label box top at +# sy-32). At the zoom where a 3x2 deck is framed to the screen (~0.14-0.22 — +# the deck is 6000 doc px across, fitted into a 944-1424 px canvas region), a +# 120 doc px row gap is only ~16-26 screen px, i.e. NARROWER than the label's +# fixed 32 px footprint, so every second-row label sat on the bottom edge of +# the row above (reported 2026-08-02). The extra 240 doc px reads as 33-52 +# screen px there, clearing the label with room to spare. Mirrors +# `scaffold.rs::ROW_LABEL_HEADROOM`, which fixes the generated multi-screen +# canvas the same way. +BOARD_GAP_Y = BOARD_GAP_X + 240 # Boards per row. Six 1920px slides in one strip are ~12,000px across, which # can only be read by panning; wrapping them into a 3x2 grid matches the @@ -247,8 +260,8 @@ BOARDS_PER_ROW = 3 def build(): boards = [cover(), agenda(), points(), metrics(), chart(), closing()] for index, board in enumerate(boards): - board["x"] = (index % BOARDS_PER_ROW) * (W + BOARD_GAP) - board["y"] = (index // BOARDS_PER_ROW) * (H + BOARD_GAP) + board["x"] = (index % BOARDS_PER_ROW) * (W + BOARD_GAP_X) + board["y"] = (index // BOARDS_PER_ROW) * (H + BOARD_GAP_Y) return boards