diff --git a/backlog/backlog.md b/backlog/backlog.md
index 6505388..7c74b45 100644
--- a/backlog/backlog.md
+++ b/backlog/backlog.md
@@ -8,7 +8,7 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
| 2 | Template Designer GUI Foundation | `epics/02_template_designer_gui_foundation.md` | Done (7 of 7 stories — Sprint 2, 4, 6) |
| 3 | CSV Integration and Field Mapping | `epics/03_csv_integration_and_field_mapping.md` | Done (4 of 4 stories — Sprint 4) |
| 4 | CLI Rendering Engine and Debenu Integration | `epics/05_cli_rendering_engine_and_debenu_integration.md` | Done (6 of 6 stories — Sprint 1-5) |
-| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Support rotated wrapped text..." (8 pts, flagged for re-sizing — largely already delivered as a byproduct) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) |
+| 5 | Live Preview and Record Navigation | `epics/04_live_preview_and_record_navigation.md` | In Progress (2 of original 3 stories Done — Sprint 7; the new wrap/clip core story also Done — Sprint 9; "Add a live wrap/clip indicator for text elements" (re-scoped, 3 pts) and "...Address Control lines" (8 pts) remain Ready; "Warn on text overflow before render" provisionally sized at 5 pts, still Not Ready until both land) |
| 6 | Composite Address Controls and Mixed-Content Text | `epics/08_composite_address_controls.md` | Done (4 of 4 stories — Sprint 5, 6, 8; whole-control rotation delivered Sprint 8, see Sprint 8 Review outcome below) |
| 7 | Layout Efficiency and Operator Tooling | `epics/06_layout_efficiency_and_operator_tooling.md` | In Progress (1 of 4 stories Done — Sprint 7; "Select multiple elements at once on the canvas" and "Align and distribute multiple elements" Ready as a dependent pair, "Undo and redo layout changes" Ready, feasibility-checked 2026-10-19, still highest-uncertainty) |
| 8 | Dynamic and Network Image Handling | `epics/07_dynamic_and_network_image_handling.md` | Not Started (still blocked behind 2 open impediments; confirmed still non-blocking — both Composite Address Controls and Layout Efficiency still sit ahead of it in this table's order — see Sprint 8 refinement outcome below) |
@@ -200,6 +200,89 @@ Index of all epics, ordered by priority (top = highest priority). Each epic is i
geometry concept, with a possible further split recommended at sizing time if it doesn't fit one
sprint even alone.
+### Sprint 10 planning outcome (2026-10-27)
+
+`scrum-master` facilitated with `product-owner`/`dev-team` input, immediately following Sprint 10
+backlog refinement above.
+
+- Capacity signal now has nine data points (Sprints 1-9: 20, 19, 18, 18, 15, 18, 18, 13, 13). The
+ 18-20 range remains this team's proven, repeatable velocity; 15 and both 13s are documented,
+ deliberate under-commits (Sprint 5's large/risky story, Sprint 8's fresh-priority story, Sprint
+ 9's fresh-priority story), not capacity misses.
+- **Committed 13 points**: "Add a live wrap/clip indicator for text elements" (3 pts, epic 4) paired
+ with epic 6's multi-select ("Select multiple elements at once on the canvas," 5 pts) +
+ align/distribute ("Align and distribute multiple elements," 5 pts) dependent pair, pulled whole
+ per this team's established practice. Full plan: `backlog/sprints/sprint-10.md`.
+- **Reasoning for finally pulling epic 6's pair now, explicit rather than default:** it has been
+ Ready and waiting since Sprint 7 — deferred at Sprint 8 planning (Address Control rotation was a
+ fresher, more urgent human request) and again at Sprint 9 planning (the wrap/clip core story was
+ both fresher and ahead in table order). Neither reason applies with the same force this sprint:
+ the wrap/clip chain's own next step is now only 3 points, leaving real spare capacity, and no
+ new, fresher request has displaced epic 6 a third time. Continuing to defer a Ready, sized,
+ 3-sprint-old pair without a stated reason would itself become the anti-pattern this team's own
+ practice exists to avoid.
+- **Why pairing (not a concern the "two large/novel stories" rule blocks):** the established
+ caution is about stacking *two* large, first-of-its-kind, architecturally risky stories in one
+ sprint (e.g., Sprint 8's rejected rotation-drag-handle + multi-select combination). Here, only
+ one side of the pairing — multi-select's new selection model — carries that character; the
+ indicator story is small, additive, and touches no shared foundational state (it only reads
+ existing box/rotation data to decide what to draw). This is a small-plus-one-large combination,
+ not two-large, so the rule's concern does not apply.
+- **Checked for anything else to round out capacity — nothing else fits.** "...Address Control
+ lines" (8 pts) depends on the indicator landing first and reusing its design, so pulling both in
+ the same sprint would mean building the indicator and then immediately extending a still-warm
+ design under time pressure rather than letting it settle — deferred to Sprint 11. "Undo and redo
+ layout changes" (13 pts) alone would total 16 with the indicator, a reasonable combination in
+ principle, but was not chosen over the multi-select/align pair given the pair's own 3-sprint-old
+ deferral making it the more overdue commitment. "Complete the first text-only operator workflow"
+ (5 pts) remains explicitly excluded pending its own stale-label re-verification.
+- No changes to the two open impediments (template asset path strategy; UNC timeout/retry
+ behavior, both in `logs/impediment_log.md`) — still non-blocking for this sprint's committed
+ items.
+
+### Sprint 10 backlog refinement outcome (2026-10-27)
+
+`product-owner` ran Sprint 10 backlog refinement after the human product owner confirmed the
+post-Sprint-9-retrospective pause point with "continue," folding in the Sprint 9 retrospective's
+own flagged action item directly rather than deferring it.
+
+- **Re-confirmed/re-sized "Support rotated wrapped text and a live wrap/clip indicator for text
+ elements" as the retrospective asked**, via real code inspection and a real, licensed end-to-end
+ test rather than assumption. Confirmed `DebenuPdfRenderer.AddPage`'s box branch already calls
+ `DrawRotatedTextBox` for any rotated boxed draw, and both `TemplateCanvasControl.DrawElement`'s
+ and `TemplatePreviewControl.DrawItem`'s box-mode draw already run inside the same rotation
+ transform every other element uses — rotation composition was a byproduct of Sprint 9's own
+ implementation shape, not deferred work as the original estimate assumed. Proved this with a
+ real template (20°-rotated, 150x80pt boxed static element) rendered through the actual built
+ `EnvelopeRenderer.Cli.exe`, visually confirming correct wrapped-and-rotated output, plus a new
+ permanent regression test (`DebenuPdfRendererWrapTests.AddPage_WithLicenseKey_RotatedBox_...`).
+- **Renamed and re-sized the story**: "Support rotated wrapped text and a live wrap/clip indicator
+ for text elements" (8 pts) becomes "Add a live wrap/clip indicator for text elements" (**3
+ pts**) — its former rotation-composition ACs are already satisfied, so all that remains is the
+ passive visual indicator itself. Updated its dependency references in the Address Control
+ wrap/clip story and "Warn on text overflow before render." Full detail:
+ `epics/04_live_preview_and_record_navigation.md`.
+- **Real finding worth naming, not just the estimate change:** verifying exact wrap/clip behavior
+ under rotation via automated pixel-row counting turned out to be unreliable (a heavily-clipped
+ rotated block can occupy *more* rasterized pixel rows than a taller, unclipped one, since
+ rotation angle affects vertical footprint as much as line count does) — discovered while writing
+ the new regression test, and documented directly in that test's own remarks so a future session
+ doesn't rediscover the same dead end. The test was simplified to assert real content renders
+ correctly rather than a specific row-count relationship; the stronger correctness evidence
+ remains the direct screenshot inspection already performed.
+- **No other Ready items needed re-verification.** Epic 6's multi-select/align pair (10 pts) and
+ undo/redo (13 pts) were already re-verified at Sprint 9's own refinement with no material change
+ found; nothing has changed in that area since.
+- **Recommended Sprint 10 candidate:** "Add a live wrap/clip indicator for text elements" (3 pts)
+ as a small, low-risk, self-contained pull — it has no dependency beyond Sprint 9's already-Done
+ story. Given its small size, `scrum-master`/`dev-team` should weigh at planning whether to pair
+ it with another Ready item to round out capacity, per this team's established practice of not
+ leaving obviously-available capacity idle without a stated reason.
+- No changes to the two open impediments (template asset path strategy; UNC timeout/retry
+ behavior, both in `logs/impediment_log.md`) — still non-blocking. "Complete the first text-only
+ operator workflow" (epic 1, 5 pts) remains flagged as needing re-verification before ever being
+ pulled — not resolved in this refinement, carried forward as-is.
+
### Sprint 9 Review outcome (2026-10-27)
- The committed story (13/13 points) is Done; verified against all 6 acceptance criteria with
diff --git a/backlog/epics/02_template_designer_gui_foundation.md b/backlog/epics/02_template_designer_gui_foundation.md
index c4d5416..f3e9904 100644
--- a/backlog/epics/02_template_designer_gui_foundation.md
+++ b/backlog/epics/02_template_designer_gui_foundation.md
@@ -45,6 +45,14 @@ As a **print operator**, I want to place static and dynamic text placeholders on
**Post-Sprint-8 user-reported fix (2026-10-26, handled outside formal ceremony):** User reported that dragging or rotating a dynamic/mixed-content element on the canvas felt visibly choppy, not smooth like a static element. Root cause confirmed by code inspection before writing anything down: `TemplateCanvasControl.OnMouseMove` fires `ElementsChanged` on every single mouse-move tick of a drag or rotate gesture, and `TemplateDesignerForm`'s handler responded by running the full `RefreshPropertiesPanel()` — which, for an element bound to a single CSV column (`HasSingleColumnRun`), also calls `RefreshRebindColumnComboBox`, clearing and fully repopulating the rebind combo box from every loaded CSV header (plus a `.ToList().FindIndex(...)` re-scan) on every tick. A purely static element skips that path entirely (`isSingleColumn` is false), which is exactly why the choppiness was specific to dynamic/mixed elements and easy to miss during Sprint 2's original static-vs-dynamic drag verification. Fixed by adding `TemplateCanvasControl.IsInteracting` (true while any move/resize/rotate gesture is active) and a new cheap `TemplateDesignerForm.RefreshInteractivePositionFields()` that syncs only the X/Y/width/angle fields a gesture can actually change; the full panel refresh now runs once, on `OnMouseUp`, instead of on every tick. Live-verified with a reflection-driven harness (same technique as prior sprints) against the real built assembly, using the real 22-header sample CSV and a dynamic placeholder bound to "Full Name": before the fix, 60 synthetic drag ticks took 88.56 ms total (1.48 ms/tick) and enumerated the header list 120 times *during* the drag; after the fix, the same 60 ticks took 1.32 ms total (0.022 ms/tick, a ~67x improvement) with 0 header-list enumerations during the drag and exactly one full refresh firing after mouse-up — confirmed identically for the rotate gesture. Position and angle were confirmed to still update correctly on every tick (the element visibly moved/rotated throughout), so the fix removes only the redundant expensive work, not the live feedback itself. Full test suite re-run: 433/433 passing, no regression. Not logged as technical debt (resolved same-day, not left open); documented here per this team's established pattern for same-day post-review/ad-hoc user feedback (see `state.md`'s history for the 2026-10-19 precedents).
+**Post-Sprint-9 user-requested correction and gap fix (2026-10-27, handled outside formal ceremony, mid-Sprint-10):** User reported two issues while reviewing the live build: (1) no way to delete a placed element or Address Control at all; (2) the canvas resize handle should control box width only, with font size set exclusively elsewhere — reversing the Sprint 8 "handle scales font size when there's no box" behavior above, not just refining it. Clarified the exact intended behavior with the user via one round of questions before changing anything.
+
+*Delete:* added `CanvasElementEditor.RemoveSelected()` (Desktop.Core) and `TemplateCanvasControl.RemoveSelectedElement()` (handles both a standalone element and a whole Address Control — never a drilled-into address line, which already has its own `RemoveSelectedAddressLine`). Wired to both the Delete/Backspace key on the canvas (`OnKeyDown`, guarded against firing mid-gesture) and a new "Delete Selected" toolbar button, enabled only while something is selected. `TemplateCanvasControl` needed `ControlStyles.Selectable`/`TabStop`/an explicit `Focus()` call in `OnMouseDown` to receive key events at all — a plain `Control` (unlike `UserControl`) isn't focusable by default, which is the root cause this gap existed in the first place.
+
+*Resize handle:* removed the Sprint 8 uniform-font-scale behavior entirely rather than keeping it conditional. `CanvasElementEditor.ResizeDragTo` now only ever sets `TextElementLayout.Width` from the pointer's horizontal distance to the fixed (X, Y) anchor — never `Height`, never `FontSize`. The handle itself moved from the box's top-right corner to the right-edge midpoint, mirroring the Address Control's own width-only handle position, so its placement communicates "width only" rather than implying a corner-drag scale. `Width`'s own gating logic changed as a direct consequence (see the dependent "Add an adjustable width and height..." story's own post-Sprint-9 note in epic 4): `TextElementLayout.HasBox` now means "Width is set" alone, with `Height` independent and optional (a `HasHeightClip` flag), rather than requiring both together as Sprint 9 originally shipped.
+
+Tests: removed 8 obsolete font-scale-specific tests, added 15 new ones (3 `RemoveSelected*` tests; the rest updated/added resize-handle and `HasBox`/`HasHeightClip` semantic tests across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, and the CLI's `TemplateXmlParserTests`/`DebenuPdfRendererWrapTests`). Full suite 476/476 -> 488/488. Live-verified via a reflection-driven built-form harness: a real mouse-driven drag on the real resize handle grew `Width` to 578.9pt while `Height` stayed null and `FontSize` stayed exactly 12; the real Delete key removed the selected element and cleared selection; a fresh narrow-boxed (120pt) element's canvas screenshot shows genuine multi-line wrap plus the dashed-orange wrap indicator (see the epic 4 indicator story) correctly appearing. One disclosed evidence-depth caveat: the toolbar "Delete Selected" button's own `PerformClick()` did not reliably fire in this synthetic reflection harness (a known WinForms quirk for a button never shown in a real top-level window with a running message loop) — the underlying `RemoveSelectedElement()` method was proven correct via a direct call returning `true` and removing the control, and the button's `Click` wiring is structurally identical to every other already-shipped, real-world-proven toolbar button in this form, but the button's own click-to-delete path specifically was not re-observed through a real mouse click.
+
**Estimate:** 5 points
**Dependencies:** None
diff --git a/backlog/epics/04_live_preview_and_record_navigation.md b/backlog/epics/04_live_preview_and_record_navigation.md
index 089463b..ff152f7 100644
--- a/backlog/epics/04_live_preview_and_record_navigation.md
+++ b/backlog/epics/04_live_preview_and_record_navigation.md
@@ -162,6 +162,23 @@ avoidable, not a genuine environment constraint — flagged for the retrospectiv
retroactively (those sprints' shipped behavior was still verified correct by other means at the
time).
+**Post-Sprint-9 semantics correction (2026-10-27, user-requested, handled outside formal ceremony,
+mid-Sprint-10):** the human product owner reported the canvas resize handle should control only
+box width, with font size set exclusively via the properties panel. This required decoupling
+`Width` and `Height`'s gating logic from how this story originally shipped them: `HasBox` now
+means "Width is set" alone (not "both Width and Height"), with `Height` independent and optional
+(a new `HasHeightClip` flag) — present alone with `Width`, it's a clip ceiling exactly as this
+story originally specified; present without `Width`, it has no effect; absent entirely, wrapped
+content grows to whatever height it needs (no clip). `DebenuPdfRenderer.AddPage` gained a new
+"Width alone" branch using Debenu's `DrawWrappedText` (unrotated) or a `GetWrappedText`-then-
+`DrawRotatedMultiLineText` composition (rotated, no native "rotated wrap, no height" primitive
+exists) — both confirmed via a real, licensed end-to-end CLI render showing correct multi-line
+wrap with no clipping, unrotated and rotated. This did not change this story's own original ACs
+(width+height together still wrap-then-clip exactly as shipped) — it only changes what happens
+when just one dimension is set, which this story's original acceptance criteria didn't fully
+specify. Full detail, including the resize-handle side of this change: `epics/02_template_
+designer_gui_foundation.md`'s matching post-Sprint-9 note.
+
**Card**
As a **print operator**, I want to give a static or dynamic text element a real width and height
so that long or variable-length content wraps within a defined box instead of running off
@@ -268,67 +285,64 @@ real PDF) without requiring the polish layer first.
are all Done). Should land before the Address Control extension story, the rotated-wrap/indicator
story immediately below, and before "Warn on text overflow before render."
-### Support rotated wrapped text and a live wrap/clip indicator for text elements - Status: Ready
+### Add a live wrap/clip indicator for text elements - Status: Ready (re-scoped and re-sized, 2026-10-27 — see note)
**Card**
-As a **print operator**, I want a rotated text element's wrapped/clipped content to behave exactly
-like an unrotated one, and to see at a glance when a box is currently wrapping or clipping content,
-so that I don't have to guess or rotate back to check.
-
-**Conversation notes**
-- Split out from the story above during Sprint 9 refinement (`dev-team` sizing, 2026-10-26): the
- combined scope (persisted geometry + native wrap/clip render + rotation composition + a new
- visual indicator) was too large to responsibly size as one story and land in a single sprint.
- This story is the "polish" layer on top of the previous story's core capability, the same
- fallback-split shape "Mix static text and CSV fields" itself once offered (core vertical slice
- now, polish layer next) rather than a new pattern invented here.
-- Rotation and wrapping have never coexisted in this product before. Confirmed by code inspection:
- Debenu exposes `DPLDrawRotatedMultiLineText` alongside the unrotated wrap primitives found in the
- previous story's sizing note — the empirical question for `dev-team` to resolve here is whether
- that entry point (or `DPLGetWrappedText` to pre-compute line breaks, then feeding each line
- through the existing per-line rotated-anchor `TextDraw` path Sprint 6 already established) is the
- right fit, mirroring how the original rotation story empirically tested `DrawRotatedText` before
- committing to a design.
+As a **print operator**, I want to see at a glance when a box is currently wrapping or clipping
+content, so that I don't have to guess or eyeball the preview to notice.
+
+**Re-scoping note (Sprint 10 refinement, 2026-10-27, `product-owner`/`dev-team`):** this story was
+originally "Support rotated wrapped text and a live wrap/clip indicator," carrying an 8-point
+estimate that assumed rotated wrap composition was unconfirmed, undelivered work. Confirmed by
+direct inspection and a real, licensed end-to-end test during Sprint 9 execution that this is not
+the case: `DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox` whenever
+`draw.Angle != 0`, and `TemplateCanvasControl.DrawElement`/`TemplatePreviewControl.DrawItem` both
+run their box-mode draw *inside* the same rotation transform every other element already uses — so
+rotation composition was a byproduct of Sprint 9's own implementation shape, not deferred work.
+Proven, not assumed: a hand-authored template with a 20°-rotated, 150x80pt boxed static element,
+rendered through the real `EnvelopeRenderer.Cli.exe` against the real sample CSV and visually
+inspected, shows the text correctly wrapped onto 4 lines rotating together as one rigid unit (see
+the previous story's development-verification note); a new regression test,
+`DebenuPdfRendererWrapTests.AddPage_WithLicenseKey_RotatedBox_WrapsOntoMultipleLinesAsOneRigidRotatedUnit`,
+locks this in. **This story's former AC1 (rotated wrap consistency across all three surfaces) and
+AC3/AC4 (rigid-unit rotation, no regression) are therefore already satisfied — confirmed, not
+re-verified from scratch here.** The only genuinely remaining scope is the live visual indicator
+(the former AC2), which does not exist in any form yet. Renamed accordingly so the story's title
+matches its actual remaining scope.
- The box, its wrapped lines, and the wrap/clip indicator itself must all rotate together as one
- rigid unit — the same rigid-group approach Address Control rotation (Sprint 8) already
- established, reused here rather than invented fresh.
+ rigid unit — already true for the box/lines (confirmed above); the new indicator must be drawn
+ inside the same existing rotation transform so it inherits this for free, not reinvent it.
- A visible on-canvas/preview indicator (e.g., a colored border or badge, in the same visual
language as the existing dashed-orange unmapped-column warning) shows when the *current* sample
record's content is actually wrapping and/or being clipped for the selected element — the "warn
- this is happening" half of the original request, distinct from and in addition to the full-CSV
- pre-render validation pass in "Warn on text overflow before render" below.
+ this is happening" half of the original "make the boxes longer" request, distinct from and in
+ addition to the full-CSV pre-render validation pass in "Warn on text overflow before render"
+ below.
+- Detecting "is this wrapping/clipping right now" for the indicator is a measurement question, not
+ a drawing one: on the canvas/preview (GDI+), compare the resolved text's natural (unwrapped)
+ measured size against the box; whether GDI+ exposes a direct "would this clip" query or this
+ needs a manual line-count comparison (mirroring how the CLI could ask Debenu's
+ `GetWrappedTextLineCount`, though the CLI has no live indicator to build here — this story is
+ canvas/preview only) is a Development Team implementation detail to confirm empirically, not
+ decided here.
**Confirmation (Acceptance Criteria)**
-- [ ] A rotated element with a set width/height wraps and rotates consistently across all three
- surfaces (CLI render, canvas, preview panel) — the box and its wrapped lines rotate together
- as one rigid unit, never independently.
- [ ] The canvas and preview panel visibly indicate, for the currently previewed sample record,
when the element's content is wrapping and/or being clipped, in both rotated and unrotated
states.
- [ ] The wrap/clip indicator itself rotates together with the box rather than staying axis-aligned
while the box it describes is rotated.
-- [ ] No regression to the previous story's unrotated wrap/clip behavior or to any other rotation
- acceptance criteria already shipped (Sprint 4, Sprint 6, Sprint 8).
-
-**Estimate:** 8 points (`dev-team`, 2026-10-26) — smaller than the previous story since the
-persisted model, the native-call investigation pattern, and the shared text-resolution routine are
-already established; the genuinely new work here is narrower: one more empirical vendor-API
-question (rotated wrap composition) plus a new, but visually-precedented (dashed-orange-style),
-indicator. Comparable to "Rotate elements by dragging a handle on the canvas" (5 points) in shape
-but larger, since rotation there had a direct existing pattern to copy for its bidirectional-sync
-plumbing, whereas rotated wrap composition here has no existing precedent in this codebase at all.
-
-**Re-sizing flag (Sprint 9, 2026-10-27, `dev-team`):** while implementing the previous story, the
-"one more empirical vendor-API question" this estimate was built around got answered as a
-byproduct, not deferred to this story as planned — `DebenuPdfRenderer` already calls Debenu's
-native `DrawRotatedTextBox` for a rotated boxed draw (confirmed via a real, licensed probe:
-rotation and wrapping compose correctly in one native call, no manual pre-wrap-then-rotate-per-line
-synthesis needed). AC1 (rotated wrap consistency across CLI/canvas/preview) is very likely already
-satisfied by the previous story's shipped code, not new work. What's left for this story is
-narrower than sized: primarily the live wrap/clip visual indicator (AC2/AC3) and confirming/testing
-the rotated case explicitly rather than building it. Not re-sized here — flagged for
-`product-owner`/`dev-team` to re-confirm at the next backlog refinement before this is committed to
-a sprint, rather than silently shipping at a stale, likely-inflated estimate.
+- [ ] No regression to the previous story's rotated or unrotated wrap/clip behavior, or to any
+ other rotation acceptance criteria already shipped (Sprint 4, Sprint 6, Sprint 8, Sprint 9).
+
+**Estimate:** 3 points (`dev-team`, 2026-10-27, re-sized down from 8) — the rotation-composition
+work the original 8-point estimate was sized around turned out to already exist as a byproduct of
+the previous story's implementation shape (confirmed above, not assumed). What's left is narrow:
+a measurement comparison (resolved text size vs. box) plus a small, visually-precedented
+(dashed-orange-style) drawing addition on the canvas and preview panel, both of which already share
+one drawing/rotation code path per element. Comparable to "Snap elements to grid and guides" (5
+points) in shape but smaller, since that story added new interactive drag behavior across two
+gesture types, while this one only adds a passive visual check plus drawing.
**Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and
dynamic text elements" (above, same epic — Done, Sprint 9).
@@ -389,9 +403,13 @@ Sized the same as "Group lines into a single, movable Address Control" reused Sp
but still carried genuine new composite-element scope — the same shape here: real reuse, real
residual scope, not a trivial pass-through.
**Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and
-dynamic text elements" and "Support rotated wrapped text and a live wrap/clip indicator for text
-elements" (both above, same epic) — sequenced after both so Address Control's rotation-composed
-wrap/clip and indicator can directly reuse a proven pattern rather than prove it here first.
+dynamic text elements" and "Add a live wrap/clip indicator for text elements" (both above, same
+epic) — sequenced after both so Address Control's rotation-composed wrap/clip and indicator can
+directly reuse a proven pattern rather than prove it here first. Note (2026-10-27): rotation
+composition itself is already proven for standalone elements (see the indicator story's own
+re-scoping note) — this dependency is really about reusing the indicator's design once it exists,
+not about waiting on rotated-wrap correctness, which Address Control's own Sprint 8 rigid-group
+rotation should compose with just as cleanly.
### Render an accurate, record-specific preview of the current template - Status: Done
@@ -549,6 +567,5 @@ scoped — re-confirm once their actual data model and wrap/clip-detection API s
same discipline "Investigate and address high-volume render throughput degradation" used for its
own assumption-qualified estimate.
**Dependencies:** Depends on "Add an adjustable width and height with text wrapping to static and
-dynamic text elements," "Support rotated wrapped text and a live wrap/clip indicator for text
-elements," and "Add an adjustable width and height with text wrapping to Address Control lines"
-(all three above, same epic).
+dynamic text elements," "Add a live wrap/clip indicator for text elements," and "Add an adjustable
+width and height with text wrapping to Address Control lines" (all three above, same epic).
diff --git a/backlog/sprints/sprint-10.md b/backlog/sprints/sprint-10.md
new file mode 100644
index 0000000..9cd1220
--- /dev/null
+++ b/backlog/sprints/sprint-10.md
@@ -0,0 +1,36 @@
+# Sprint Backlog
+
+**Sprint:** 10 **Dates:** 2026-10-28 - 2026-11-01
+**Sprint Goal:** Let an operator select multiple canvas elements at once (rubber-band or modifier-click, mixing standalone elements and Address Controls) and align/distribute them together, while also finishing the epic-4 wrap/clip chain's live visual indicator — the first new selection model this designer has ever had, plus a small, low-risk finish to already-shipped work.
+
+## Committed Items
+
+| Story | Size | Status | Tasks |
+|---|---|---|---|
+| Add a live wrap/clip indicator for text elements | 3 points | Done | - [x] Determine, per selected/previewed element with a box, whether the resolved text's natural (unwrapped) GDI+-measured size exceeds the box width (wrapping) and/or the wrapped height exceeds the box height (clipping)
- [x] Draw a small visual indicator (dashed-orange-style, consistent with the existing unmapped-column warning) on `TemplateCanvasControl` and `TemplatePreviewControl` when either condition is true for the current sample record, drawn inside the existing per-element rotation transform so it rotates for free
- [x] Unit tests for the wrap/clip detection logic (Desktop.Core, framework-free)
- [x] Live built-form smoke: a narrow (120pt) boxed element's real canvas screenshot shows both genuine multi-line wrap and the dashed-orange indicator appearing correctly |
+| Select multiple elements at once on the canvas | 5 points | Not Started | - [ ] Add a multi-select data structure alongside the existing single-selection state (`CanvasElementEditor` for standalone elements, `TemplateCanvasControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected
- [ ] Rubber-band selection: a click-drag starting on empty canvas space (not on any element/handle) draws a selection rectangle and selects every element/control whose bounding box intersects it on release
- [ ] Modifier-click (Ctrl/Shift): adds or removes a single element/control from the current multi-selection without disturbing the rest
- [ ] Visible selection indication for every selected item, not just the primary one
- [ ] Dragging any selected item moves the entire multi-selection together, preserving each item's relative offset (delta-based movement, not absolute repositioning)
- [ ] Properties panel shows a clear "N items selected" state and disables per-item property editing while a multi-selection is active (Development Team's chosen simplest option from the story's own notes)
- [ ] Unit tests: rubber-band hit-testing, modifier-click toggle, multi-drag delta math (Desktop.Core)
- [ ] Live built-form smoke (touches the properties panel's multi-item behavior) |
+| Align and distribute multiple elements | 5 points | Not Started | - [ ] Alignment operations (left/right/top/bottom edges, horizontal/vertical centers) over the current multi-selection, using each item's existing bounding box (Address Control's `Width`/computed height, standalone elements' measured or box width/height)
- [ ] Distribution operations (equal horizontal/vertical spacing) over the current multi-selection
- [ ] A toolbar affordance to trigger each operation, enabled only when 2+ items are selected, clearly disabled (not silently a no-op) otherwise
- [ ] Unit tests for the alignment/distribution math (Desktop.Core, framework-free)
- [ ] Live full built-form smoke with a **fresh** screenshot (this story adds a new toolbar control to `TemplateDesignerForm`, per the Sprint 7 retrospective's carry-in) |
+
+## Notes
+- **Capacity signal:** completed totals across Sprints 1-9 are **20, 19, 18, 18, 15, 18, 18, 13, 13** — nine data points. The 18-20 range remains this team's proven, repeatable velocity; 15 and both 13s are documented, deliberate under-commits (a large/risky story, then two fresh-priority stories), not capacity misses.
+- **Why epic 6's pair is finally committed this sprint, explicit reasoning:** Ready and waiting since Sprint 7, deferred twice for fresher, more urgent human requests (Sprint 8's Address Control rotation reversal; Sprint 9's wrap/clip request). Neither reason applies a third time — continuing to defer a Ready, sized, 3-sprint-old pair without a stated reason would itself become the anti-pattern this team's practice exists to avoid.
+- **Why pairing it with the small indicator story doesn't trip the "two large/novel stories" rule:** that caution is about stacking *two* large, first-of-its-kind, architecturally risky stories in one sprint (e.g., Sprint 8's rejected rotation-drag-handle + multi-select combination). Only multi-select carries that character here; the indicator story is small, additive, and touches no shared foundational state.
+- Impediments: template asset path strategy (absolute vs. relative) and UNC timeout/retry behavior remain open in `logs/impediment_log.md`. Neither blocks this sprint's committed items.
+- Carried over from previous sprint: none (Sprint 9 completed its one committed story, 13/13 points).
+- Not committed: "Add an adjustable width and height with text wrapping to Address Control lines" (8 pts, epic 4 — depends on this sprint's indicator story landing and its design settling first, deferred to Sprint 11 rather than built under time pressure in the same sprint); "Undo and redo layout changes" (13 pts, epic 6 — a reasonable alternative pairing with the indicator, not chosen given the multi-select/align pair's own 3-sprint-old deferral making it the more overdue commitment); "Complete the first text-only operator workflow" (5 pts, epic 1 — stale label, needs re-verification before ever being pulled).
+
+## Execution Order
+
+Sequenced by dependency and risk: the small, independent indicator story first (quick, de-risks nothing else but clears easily), then the hard-dependent multi-select/align pair in order.
+
+| Batch | Story | Why it's gated here |
+|---|---|---|
+| 1 | Add a live wrap/clip indicator for text elements | No dependency on anything else this sprint; small and independent. |
+| 2 | Select multiple elements at once on the canvas | No dependency on Batch 1, but sequenced next as the foundational half of the epic-6 pair. |
+| 3 | Align and distribute multiple elements | Strictly depends on Batch 2's multi-selection existing — there is nothing to align/distribute without it. |
+
+## Daily Scrum Log
+
+| Day | Date | Completed | Planned | Blocked/At risk |
+|---|---|---|---|---|
+| 1 | 2026-10-27 | **Batch 1** ("Add a live wrap/clip indicator for text elements", 3 points) done, all ACs met. New framework-free `WrapClipDetector` (Desktop.Core) compares an element's natural (unwrapped) GDI+-measured width against its box width, and its GDI+-wrapped height against an explicit clip ceiling when one is set (never flags clipping otherwise, since there is no ceiling to exceed). Wired into `TemplateCanvasControl.DrawElement` and `TemplatePreviewControl.DrawItem`'s existing box-mode branches, drawing a dashed-orange outline (the unmapped-column warning's visual language) inside the same rotation transform every other per-element visual already uses, so it orbits for free when rotated. **Mid-batch, the human product owner raised two live-build issues, addressed together before continuing to Batch 2** (full detail in `epics/02_template_designer_gui_foundation.md`'s and `epics/04_live_preview_and_record_navigation.md`'s post-Sprint-9 correction notes): (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to the Delete/Backspace key and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely, `ResizeDragTo` now only ever sets `Width`, and `TextElementLayout.HasBox`/`TemplateElement.HasBox` were decoupled from `Height` (a new independent `HasHeightClip` flag) as a direct consequence, with `DebenuPdfRenderer.AddPage` gaining a "Width alone" native-wrap-no-clip render path (`DrawWrappedText` unrotated, `GetWrappedText`+`DrawRotatedMultiLineText` rotated). Tests: net +12 (8 obsolete font-scale tests removed, 20 new/updated across `CanvasElementEditorTests`, `TextElementPropertiesEditorTests`, `TemplateLayoutXmlSerializerTests`, `TemplateXmlParserTests`, `DebenuPdfRendererWrapTests`, plus the new `WrapClipDetectorTests`). Full suite 476/476 -> 488/488. Live-verified: a real, licensed end-to-end CLI render of a width-only (no height) template showed correct unrotated and rotated auto-height wrap with no clipping; a reflection-driven built-form harness confirmed a real mouse-driven resize drag sets only `Width` (Height/FontSize unchanged), the real Delete key removes the selected element, and a real canvas screenshot shows both genuine wrap and the new indicator on a narrow boxed element. One disclosed evidence-depth caveat: the toolbar Delete button's own `PerformClick()` didn't reliably fire in the synthetic harness (a known WinForms quirk absent a real shown window) — the underlying method was proven correct via a direct call, and the button's wiring matches every other proven-working toolbar button. | Batch 2, "Select multiple elements at once on the canvas" (5 points, epic 6). | None. |
diff --git a/code/TEMPLATE_FORMAT.md b/code/TEMPLATE_FORMAT.md
index 8db3e56..77b75a5 100644
--- a/code/TEMPLATE_FORMAT.md
+++ b/code/TEMPLATE_FORMAT.md
@@ -132,23 +132,32 @@ editor, per this story's sizing note) rather than a silently-swallowed edge case
and unaffected by this internal normalization. An `angle` of `0` (including when the attribute
is absent) takes the exact same `DrawText` code path this renderer used before this story —
byte-for-byte unchanged output for every pre-existing template.
-- `width`, `height` — optional, positive, PDF points, default absent (no box). Sprint 9, "Add an
- adjustable width and height with text wrapping...": when **both** are present, the element's
- content wraps within `width` and any wrapped content exceeding `height` is clipped (cut off),
- rather than growing the box or shrinking the text. A one-sided value (only `width` or only
- `height`) is accepted but has no effect — wrap-without-a-clip-boundary and clip-without-a-wrap-
- width both have no well-defined meaning, so it's treated the same as neither being present.
- Internally this calls Debenu's native `DrawTextBox(x, top, width, height, text, options)` (or
+- `width` — optional, positive, PDF points, default absent (no box). Sprint 9, "Add an adjustable
+ width and height with text wrapping...", refined post-Sprint-9 (2026-10-27) per direct user
+ feedback: `width` **alone** is enough to wrap — the desktop canvas's resize handle only ever
+ sets `width` (never `height`, never font size, which stays a separate, panel-only control).
+ Wrapped content grows to whatever height it needs, with no clip, unless `height` is *also*
+ present (see below). An element with no `width` (every template written before Sprint 9) takes
+ the exact same plain `DrawText`/`DrawRotatedText` code path this renderer always used —
+ byte-for-byte unchanged output.
+- `height` — optional, positive, PDF points, default absent, **independent** of `width` and only
+ ever set via the properties panel (never the canvas resize handle). Present together with
+ `width`, it becomes an explicit clip ceiling: wrapped content exceeding `height` is clipped (cut
+ off) rather than growing the box or shrinking the text. Present *without* `width`, it has no
+ effect (there is no wrap boundary for it to clip against). Internally: `width` + `height` both
+ present calls Debenu's native `DrawTextBox(x, top, width, height, text, options)` (or
`DrawRotatedTextBox` when `angle` is also non-zero — confirmed empirically to handle rotation
- and wrapping together in one native call, not something this renderer has to compose from
- separate steps) rather than a hand-rolled line-breaking algorithm; `top` is `y + height`, since
- `(x, y)` is the box's bottom-left corner for a boxed element (unlike the plain baseline-`y`
- meaning `y` has for an unboxed element). An element with no `width`/`height` (every template
- written before this story) takes the exact same plain `DrawText`/`DrawRotatedText` code path
- this renderer always used — byte-for-byte unchanged output. The desktop designer's canvas and
+ and wrapping together in one native call); `width` alone (no `height`) calls `DrawWrappedText`
+ (unrotated) or pre-wraps via `GetWrappedText` and draws via `DrawRotatedMultiLineText` (rotated —
+ no native "rotated wrapped text, no height" primitive exists), with the auto-height Debenu's own
+ `GetWrappedTextHeight` reports for that width/text/font. `(x, y)` is the box's bottom-left corner
+ for any boxed element (`top` is always `y` + the effective height, explicit or auto) — unlike
+ the plain baseline-`y` meaning `y` has for an unboxed element. The desktop designer's canvas and
preview panel approximate the same wrap/clip visually using GDI+'s own native rectangle-bounded
- text drawing — a documented design-time approximation, not a pixel-for-point guarantee, since
- GDI+ and Debenu are two independent text-layout engines with no shared line-breaking code path.
+ text drawing (using the same auto-height logic via `MeasureString` with a width constraint when
+ no explicit `height` is set) — a documented design-time approximation, not a pixel-for-point
+ guarantee, since GDI+ and Debenu are two independent text-layout engines with no shared
+ line-breaking code path.
## ``
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs
index d6cedc1..0fa42e8 100644
--- a/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs
+++ b/code/src/EnvelopeRenderer.Cli.Tests/DebenuPdfRendererWrapTests.cs
@@ -57,6 +57,37 @@ public class DebenuPdfRendererWrapTests
}
}
+ [Fact]
+ public void AddPage_WidthOnly_NoHeight_Unrotated_Succeeds()
+ {
+ // Post-Sprint-9 user-requested change (2026-10-27): Width alone (no Height) means "wrap,
+ // auto-height, no clip" - uses DrawWrappedText, not DrawTextBox.
+ var created = DebenuPdfRenderer.TryCreate(DllPath(), licenseKey: null, out var renderer, out var createError);
+ Assert.True(created, createError);
+
+ using (renderer)
+ {
+ var draw = new TextDraw(20, 300, "Arial", 12, LongText, Width: 120);
+ Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError);
+ }
+ }
+
+ [Fact]
+ public void AddPage_WidthOnly_NoHeight_Rotated_Succeeds()
+ {
+ // Post-Sprint-9: no native "rotated wrapped text, no height" primitive exists, so this
+ // path pre-wraps via GetWrappedText and draws via DrawRotatedMultiLineText - confirms that
+ // composition succeeds end to end.
+ var created = DebenuPdfRenderer.TryCreate(DllPath(), licenseKey: null, out var renderer, out var createError);
+ Assert.True(created, createError);
+
+ using (renderer)
+ {
+ var draw = new TextDraw(150, 150, "Arial", 12, LongText, Angle: 30, Width: 120);
+ Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError);
+ }
+ }
+
[Fact]
public void AddPage_BoxTooShortToFitAllWrappedContent_StillSucceeds_ClippingRatherThanFailing()
{
@@ -153,7 +184,7 @@ public class DebenuPdfRendererWrapTests
/// Renders one box-wrapped draw to a rasterized image and counts how many distinct
/// horizontal pixel rows contain any "ink" (dark pixel) — a cheap, real proxy for "how many
/// text lines are visibly present" without needing to parse compressed PDF content streams.
- private static int InkRowCount(string dllPath, string licenseKey, string workDir, string label, double height)
+ private static int InkRowCount(string dllPath, string licenseKey, string workDir, string label, double? height, double angle = 0)
{
var created = DebenuPdfRenderer.TryCreate(dllPath, licenseKey, out var renderer, out var createError);
Assert.True(created, createError);
@@ -161,7 +192,7 @@ public class DebenuPdfRendererWrapTests
var pdfPath = Path.Combine(workDir, $"{label}.pdf");
using (renderer)
{
- var draw = new TextDraw(20, 300, "Arial", 12, LongText, Width: 120, Height: height);
+ var draw = new TextDraw(20, 300, "Arial", 12, LongText, Angle: angle, Width: 120, Height: height);
Assert.True(renderer!.AddPage(300, 400, new[] { draw }, out var pageError), pageError);
Assert.True(renderer.Save(pdfPath, out var saveError), saveError);
}
@@ -195,4 +226,85 @@ public class DebenuPdfRendererWrapTests
return inkRows;
}
+
+ [Fact]
+ public void AddPage_WithLicenseKey_WidthOnlyAutoHeight_DrawsAllLinesLikeATallExplicitBox_NotAShortOne()
+ {
+ // Post-Sprint-9 user-requested change: Width alone (no Height) must behave like a tall
+ // box (grows to fit every wrapped line, no clip) - not like a short one.
+ var licenseKey = DebenuLicenseKey.Resolve();
+ if (licenseKey is null)
+ {
+ Console.WriteLine("SKIPPED: no Debenu license key available locally.");
+ return;
+ }
+
+ var dllPath = DllPath();
+ var workDir = Path.Combine(Path.GetTempPath(), $"wrap-autoheight-license-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(workDir);
+ try
+ {
+ var autoHeightInkRows = InkRowCount(dllPath, licenseKey, workDir, "auto", height: null);
+ var shortInkRows = InkRowCount(dllPath, licenseKey, workDir, "short", height: 20);
+
+ Assert.True(
+ autoHeightInkRows > shortInkRows * 2,
+ $"Expected the auto-height (Width-only) box ({autoHeightInkRows} ink rows) to draw visibly more wrapped lines than a short explicit-height box ({shortInkRows} ink rows) - i.e. to NOT clip.");
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(workDir, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup only, matching this project's established pattern.
+ }
+ }
+ }
+
+ [Fact]
+ public void AddPage_WithLicenseKey_RotatedBox_WrapsOntoMultipleLinesAsOneRigidRotatedUnit()
+ {
+ // Confirms, via the real render pipeline and real rasterized pixels (not just a return
+ // code), that a rotated box genuinely draws wrapped content rather than collapsing to
+ // nothing or a single sliver — Debenu's DrawRotatedTextBox composes rotation and wrapping
+ // natively in one call, so this is a regression guard for that finding, not new production
+ // code. Note: pixel-row counting is not a reliable proxy for "how many lines clipped" once
+ // rotation is involved (a rotated block's vertical pixel footprint depends on angle as
+ // much as line count, confirmed by direct measurement while writing this test — a short,
+ // heavily-clipped rotated box can occupy *more* rasterized rows than a taller one, not
+ // fewer), so this test only asserts real content was drawn, not a specific row count
+ // relationship; the actual wrap+rotate+clip visual correctness for a realistic case was
+ // separately confirmed by direct screenshot inspection during this story's development
+ // (see the epic 4 development-verification note).
+ var licenseKey = DebenuLicenseKey.Resolve();
+ if (licenseKey is null)
+ {
+ Console.WriteLine("SKIPPED: no Debenu license key available locally.");
+ return;
+ }
+
+ var dllPath = DllPath();
+ var workDir = Path.Combine(Path.GetTempPath(), $"wrap-rotate-license-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(workDir);
+ try
+ {
+ var rotatedInkRows = InkRowCount(dllPath, licenseKey, workDir, "tall-rotated", height: 200, angle: 30);
+
+ Assert.True(rotatedInkRows > 20, $"Expected the rotated box to draw multiple wrapped lines' worth of ink; got {rotatedInkRows} ink rows.");
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(workDir, recursive: true);
+ }
+ catch (IOException)
+ {
+ // Best-effort cleanup only, matching this project's established pattern.
+ }
+ }
+ }
}
diff --git a/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs b/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs
index 1e508fa..da407c7 100644
--- a/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs
+++ b/code/src/EnvelopeRenderer.Cli.Tests/TemplateXmlParserTests.cs
@@ -252,8 +252,10 @@ public class TemplateXmlParserTests
}
[Fact]
- public void Parse_OnlyWidthAttribute_HasBoxIsFalse()
+ public void Parse_OnlyWidthAttribute_HasBoxIsTrue_HasHeightClipIsFalse()
{
+ // Post-Sprint-9 user-requested change: Width alone is enough to have a box (wrap, no
+ // clip ceiling) — see TemplateElement.HasBox's remarks.
var path = WriteTemplate("""
Hi
@@ -266,6 +268,25 @@ public class TemplateXmlParserTests
var element = result.Document!.Elements[0];
Assert.Equal(100, element.Width);
Assert.Null(element.Height);
+ Assert.True(element.HasBox);
+ Assert.False(element.HasHeightClip);
+ }
+
+ [Fact]
+ public void Parse_OnlyHeightAttribute_HasBoxIsFalse()
+ {
+ var path = WriteTemplate("""
+
+ Hi
+
+ """);
+
+ var result = TemplateXmlParser.Parse(path);
+
+ Assert.True(result.Succeeded);
+ var element = result.Document!.Elements[0];
+ Assert.Null(element.Width);
+ Assert.Equal(50, element.Height);
Assert.False(element.HasBox);
}
diff --git a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
index a4dbf36..b3a65d4 100644
--- a/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
+++ b/code/src/EnvelopeRenderer.Cli/Render/DebenuPdfRenderer.cs
@@ -175,27 +175,61 @@ public sealed class DebenuPdfRenderer : IPdfRenderer
return false;
}
- if (draw.Width is > 0 && draw.Height is > 0)
+ if (draw.Width is > 0)
{
- // Sprint 9, "Add an adjustable width and height with text wrapping...": Debenu's
- // own native DrawTextBox/DrawRotatedTextBox already wrap to Width and clip to
- // Height in one call — confirmed empirically against the real DLL (a box too
- // short to fit every wrapped line returns fewer lines than
- // GetWrappedTextLineCount reports for the same width, rather than growing the box
- // or throwing), so no hand-rolled line-breaking is needed here. (X, Y) is this
- // draw's box bottom-left corner (matching every other box-shaped element in this
- // codebase, e.g. Address Control), so Debenu's own `Top` parameter is Y + Height.
- var top = draw.Y + draw.Height.Value;
- var boxResult = draw.Angle == 0
- ? _pdf.DrawTextBox(draw.X, top, draw.Width.Value, draw.Height.Value, draw.Text, Options: 0)
- : _pdf.DrawRotatedTextBox(
- draw.X, top, draw.Width.Value, draw.Height.Value,
- ((draw.Angle % 360) + 360) % 360, draw.Text, Options: 0);
+ // Sprint 9, "Add an adjustable width and height with text wrapping...", extended
+ // post-Sprint-9 (2026-10-27) so Width alone (no Height) means "wrap, auto-height,
+ // no clip" — the canvas resize handle only ever sets Width; Height remains an
+ // independent, optional explicit clip ceiling set separately via the properties
+ // panel. (X, Y) is this draw's box bottom-left corner (matching every other
+ // box-shaped element in this codebase, e.g. Address Control); Debenu's own `Top`
+ // parameter is always Y + , whether that height is the explicit
+ // Height or (when absent) the wrapped height Debenu itself reports for this exact
+ // width/text/font via GetWrappedTextHeight — so the anchor convention never
+ // depends on which case applies.
+ var wrapWidth = draw.Width.Value;
+ var normalizedAngle = ((draw.Angle % 360) + 360) % 360;
+ int boxResult;
+
+ if (draw.Height is > 0)
+ {
+ // Explicit clip ceiling: Debenu's own native DrawTextBox/DrawRotatedTextBox
+ // already wrap to Width and clip to Height in one call — confirmed empirically
+ // against the real DLL (a box too short to fit every wrapped line returns
+ // fewer lines than GetWrappedTextLineCount reports for the same width, rather
+ // than growing the box or throwing), so no hand-rolled line-breaking is needed.
+ var top = draw.Y + draw.Height.Value;
+ boxResult = draw.Angle == 0
+ ? _pdf.DrawTextBox(draw.X, top, wrapWidth, draw.Height.Value, draw.Text, Options: 0)
+ : _pdf.DrawRotatedTextBox(draw.X, top, wrapWidth, draw.Height.Value, normalizedAngle, draw.Text, Options: 0);
+ }
+ else if (draw.Angle == 0)
+ {
+ // No clip ceiling, unrotated: DrawWrappedText wraps to Width and grows as tall
+ // as needed, with no separate measure-then-draw step.
+ var autoHeight = _pdf.GetWrappedTextHeight(wrapWidth, draw.Text);
+ boxResult = _pdf.DrawWrappedText(draw.X, draw.Y + autoHeight, wrapWidth, draw.Text);
+ }
+ else
+ {
+ // No clip ceiling, rotated: there is no native "rotated wrapped text, no
+ // height" primitive, so pre-wrap via GetWrappedText (Debenu's own wrap
+ // algorithm — the same one DrawWrappedText/DrawTextBox use internally, so this
+ // stays consistent with the unrotated case above) and draw the result via
+ // DrawRotatedMultiLineText, confirmed empirically to rotate the whole wrapped
+ // block as one rigid unit. A private-use-area delimiter (never realistic
+ // address-text content) avoids any chance of colliding with a literal
+ // character in the actual text.
+ const string delimiter = "\uE000"; // literal escape - Unicode Private Use Area, never realistic in real address text
+ var wrappedText = _pdf.GetWrappedText(wrapWidth, delimiter, draw.Text);
+ var autoHeight = _pdf.GetWrappedTextHeight(wrapWidth, draw.Text);
+ boxResult = _pdf.DrawRotatedMultiLineText(draw.X, draw.Y + autoHeight, normalizedAngle, delimiter, wrappedText);
+ }
if (boxResult == 0)
{
- error = $"Failed to draw wrapped text box with font '{draw.FontName}' at " +
- $"({draw.X}, {draw.Y}), size {draw.Width}x{draw.Height} " +
+ error = $"Failed to draw wrapped text with font '{draw.FontName}' at " +
+ $"({draw.X}, {draw.Y}), width {draw.Width} " +
$"(error code {_pdf.LastErrorCode()}).";
return false;
}
diff --git a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
index 2464038..5b49238 100644
--- a/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
+++ b/code/src/EnvelopeRenderer.Cli/Render/TemplateElement.cs
@@ -40,11 +40,15 @@ public sealed record TemplateElement(
double? Width = null,
double? Height = null)
{
- /// True only when both and are set — a
- /// one-sided value (e.g. a width with no height) is treated as "no box" rather than guessing
- /// a missing dimension, since wrap-without-a-clip-boundary or clip-without-a-wrap-width both
- /// have no well-defined meaning for this story.
- public bool HasBox => Width is > 0 && Height is > 0;
+ /// Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has
+ /// been set — is independent and optional, no longer required for
+ /// wrapping to apply (the desktop canvas resize handle only ever sets Width; Height, when
+ /// wanted, is an explicit clip ceiling set separately via the properties panel).
+ public bool HasBox => Width is > 0;
+
+ /// True only when an explicit clip ceiling is also set, on top of the wrap
+ /// already establishes.
+ public bool HasHeightClip => Height is > 0;
/// Back-compat convenience factory matching the pre-Sprint-5 "pure static text"
/// shape — builds the equivalent one-literal-run list. (A second
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
index cf660ef..da0bea1 100644
--- a/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/CanvasElementEditor.cs
@@ -31,27 +31,11 @@ public sealed class CanvasElementEditor
/// space points) when snapping is enabled but no explicit size has been set.
public const double DefaultGridSizePoints = 10.0;
- /// Post-Sprint-8 user-requested feature (resize a standalone static/dynamic text
- /// element by dragging a canvas handle): a static/dynamic element has no independent
- /// Width/Height of its own — its box is purely derived from measuring
- /// at its current
- /// (see TemplateCanvasControl.MeasureElement) — so "resize" here means uniformly
- /// scaling , the only property that actually changes
- /// what renders. These bounds mirror the properties panel's existing "Font size (pt)"
- /// NumericUpDown (Minimum 1, Maximum 1000) so a drag can never produce a value the
- /// panel would then silently re-clamp on its next refresh.
- public const double MinFontSize = 1.0;
-
- public const double MaxFontSize = 1000.0;
-
private readonly TemplateLayoutDocument _document;
private readonly Func _measureText;
private (double Dx, double Dy)? _dragOffset;
private bool _isRotating;
- private bool _isResizingFontSize;
- private bool _resizeIsBoxMode;
- private double _resizeOriginalFontSize;
- private double _resizeOriginalDiagonal;
+ private bool _isResizingWidth;
public CanvasElementEditor(
TemplateLayoutDocument document, Func measureText)
@@ -164,6 +148,24 @@ public sealed class CanvasElementEditor
public void Select(TextElementLayout? element) => Selected = element;
+ /// Post-Sprint-9 user-reported gap: there was no way to delete a placed standalone
+ /// element at all. Removes the currently selected element from the document and clears the
+ /// selection; a no-op if nothing is selected.
+ public bool RemoveSelected()
+ {
+ if (Selected is null)
+ {
+ return false;
+ }
+
+ var removed = _document.Elements.Remove(Selected);
+ Selected = null;
+ _dragOffset = null;
+ _isRotating = false;
+ _isResizingWidth = false;
+ return removed;
+ }
+
/// Starts a drag of the currently selected element, recording the offset between
/// the grab point and the element's origin so moves it smoothly rather
/// than snapping its origin to the mouse position.
@@ -208,20 +210,21 @@ public sealed class CanvasElementEditor
{
_dragOffset = null;
_isRotating = false;
- _isResizingFontSize = false;
+ _isResizingWidth = false;
}
public bool IsDragging => _dragOffset is not null;
- public bool IsResizingFontSize => _isResizingFontSize;
+ public bool IsResizingWidth => _isResizingWidth;
- /// Post-Sprint-8 user-requested feature: the current world-space (canvas-point)
- /// position of the selected element's font-size resize handle, or null if nothing is
- /// selected. Sits at the box's own top-right corner ( +
- /// width, + height) — the corner diagonally opposite the
- /// element's fixed (X, Y) anchor, which never moves as font size changes — rotated around the
- /// same pivot uses so it orbits correctly for a rotated
- /// element.
+ /// Post-Sprint-9 user-requested change (2026-10-27): the current world-space
+ /// (canvas-point) position of the selected element's width resize handle, or null if
+ /// nothing is selected. Sits at the right-edge midpoint ( +
+ /// width, vertically centered), mirroring the Address Control's own width-only handle
+ /// position for visual consistency — the handle's placement itself communicates "this only
+ /// changes width," unlike the pre-Sprint-9-fix corner position that also implied a height/font
+ /// change — rotated around the same pivot uses so it orbits
+ /// correctly for a rotated element.
public (double X, double Y)? ResizeHandlePosition()
{
if (Selected is null)
@@ -238,13 +241,13 @@ public sealed class CanvasElementEditor
var pivot = RotationPivot(element, width, height);
return RotatePointAroundPivot(
element.X + width,
- element.Y + height,
+ element.Y + (height / 2.0),
pivot,
element.RotationAngle);
}
/// Whether the given canvas-space point is within grab range of the currently
- /// selected element's font-size resize handle. Always false when nothing is
+ /// selected element's width resize handle. Always false when nothing is
/// selected.
public bool HitTestResizeHandle(double xPoints, double yPoints)
{
@@ -260,19 +263,11 @@ public sealed class CanvasElementEditor
}
/// Starts a resize-drag of the currently selected element via its corner handle.
- /// Sprint 9: the handle is context-sensitive — captured once at drag-start via
- /// so a mid-drag box-clearing edit elsewhere can't
- /// change the interpretation partway through a single gesture. When the element already has
- /// a box (), the drag directly resizes
- /// / — the box is
- /// now the element's real geometry, so its own corner should size to the pointer, not scale
- /// the font uniformly. Otherwise (the pre-Sprint-9 case, and still the default for any
- /// element with no box) the drag scales uniformly,
- /// capturing the current font size and measured-box diagonal so can
- /// compute an absolute scale factor from the pointer's distance to the element's fixed
- /// (X, Y) anchor — the same "recompute from scratch every tick" style
- /// already uses, rather than accumulating incremental deltas, which
- /// would drift. No-op if nothing is selected.
+ /// Post-Sprint-9 user-requested change (2026-10-27): the handle controls only
+ /// — font size is set exclusively via the properties
+ /// panel's own Font size field, never by dragging, and the earlier Sprint 8 uniform-font-scale
+ /// behavior this method used to have for an unboxed element has been removed entirely, not
+ /// just made conditional. No-op if nothing is selected.
public void BeginResizeDrag()
{
if (Selected is null)
@@ -280,74 +275,35 @@ public sealed class CanvasElementEditor
return;
}
- _resizeIsBoxMode = Selected.HasBox;
- if (!_resizeIsBoxMode)
- {
- var (width, height) = _measureText(Selected);
- _resizeOriginalDiagonal = Math.Sqrt((width * width) + (height * height));
- _resizeOriginalFontSize = Selected.FontSize;
- }
-
- _isResizingFontSize = true;
+ _isResizingWidth = true;
_dragOffset = null;
}
- /// Updates the selected element from the pointer's current position — either its
- /// / (box mode) or
- /// its (uniform scale mode), per the mode captured at
- /// . In box mode, the pointer's local-space offset from the
- /// element's fixed (X, Y) anchor directly becomes the new width/height (only applied when
- /// positive, so a drag that overshoots back past the anchor simply stops shrinking rather than
- /// producing a negative or zero size). In font-scale mode, dragging farther from the anchor
- /// grows the text, dragging closer shrinks it, uniformly, relative to the diagonal captured at
- /// drag-start. No-op if nothing is selected, a resize-drag hasn't begun, or (font-scale mode
- /// only) the captured original diagonal was degenerate.
+ /// Updates the selected element's from the
+ /// pointer's current local-space horizontal offset from the element's fixed (X, Y) anchor —
+ /// only applied when positive, so a drag that overshoots back past the anchor simply stops
+ /// shrinking rather than producing a negative or zero width. Never touches
+ /// or — see
+ /// 's remarks. No-op if nothing is selected or a resize-drag
+ /// hasn't begun.
public void ResizeDragTo(double xPoints, double yPoints)
{
- if (Selected is null || !_isResizingFontSize)
+ if (Selected is null || !_isResizingWidth)
{
return;
}
var (width, height) = _measureText(Selected);
var pivot = RotationPivot(Selected, width, height);
- var (localX, localY) = Selected.RotationAngle == 0
+ var (localX, _) = Selected.RotationAngle == 0
? (xPoints, yPoints)
: RotatePointAroundPivot(xPoints, yPoints, pivot, -Selected.RotationAngle);
- if (_resizeIsBoxMode)
- {
- var newWidth = localX - Selected.X;
- var newHeight = localY - Selected.Y;
- if (newWidth > 0)
- {
- Selected.Width = newWidth;
- }
-
- if (newHeight > 0)
- {
- Selected.Height = newHeight;
- }
-
- return;
- }
-
- if (_resizeOriginalDiagonal <= 0)
+ var newWidth = localX - Selected.X;
+ if (newWidth > 0)
{
- return;
+ Selected.Width = newWidth;
}
-
- var dx = localX - Selected.X;
- var dy = localY - Selected.Y;
- var distance = Math.Sqrt((dx * dx) + (dy * dy));
- if (distance <= 0)
- {
- return;
- }
-
- var scale = distance / _resizeOriginalDiagonal;
- var newFontSize = Math.Clamp(_resizeOriginalFontSize * scale, MinFontSize, MaxFontSize);
- Selected.FontSize = newFontSize;
}
/// Sprint 4, "Rotate elements by dragging a handle on the canvas": the current
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs
index 1449a67..4142088 100644
--- a/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/TextElementLayout.cs
@@ -67,10 +67,15 @@ public sealed class TextElementLayout
/// is also set.
public double? Height { get; set; }
- /// True only when both and are set — a
- /// one-sided value doesn't count as having a box (wrap-without-a-clip-boundary and
- /// clip-without-a-wrap-width both have no well-defined meaning for this story).
- public bool HasBox => Width is > 0 && Height is > 0;
+ /// Post-Sprint-9 user-requested change (2026-10-27): true whenever a wrap width has
+ /// been set — is independent and optional, no longer required for
+ /// wrapping to apply (the canvas resize handle only ever sets Width; Height, when wanted, is
+ /// an explicit clip ceiling set separately via the properties panel).
+ public bool HasBox => Width is > 0;
+
+ /// True only when an explicit clip ceiling is also set, on top of the wrap
+ /// already establishes.
+ public bool HasHeightClip => Height is > 0;
/// Sprint 5: the element's content as an ordered run sequence. Always has at least
/// one entry (an empty single literal run is this model's equivalent of "no content yet") —
diff --git a/code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs b/code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs
new file mode 100644
index 0000000..d29ecb9
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Core/Design/WrapClipDetector.cs
@@ -0,0 +1,24 @@
+namespace EnvelopeRenderer.Desktop.Core.Design;
+
+///
+/// Sprint 10, "Add a live wrap/clip indicator for text elements": pure, framework-free comparison
+/// behind the canvas/preview panel's live wrap/clip warning — deciding *whether* a boxed
+/// element's current content is wrapping and/or being clipped is just arithmetic once the caller
+/// has already measured the text (GDI+, a WinForms-only concern, stays out of this class so the
+/// decision itself is unit-testable without a WinForms host, mirroring how
+/// takes an injected measurement delegate for the same reason).
+///
+public static class WrapClipDetector
+{
+ /// The text's own unconstrained (single-line) measured width.
+ /// The text's measured height once wrapped to .
+ /// The element's authored box width.
+ /// The element's authored box height.
+ public static (bool IsWrapping, bool IsClipping) Detect(
+ double naturalWidth, double wrappedHeight, double boxWidth, double boxHeight)
+ {
+ var isWrapping = naturalWidth > boxWidth;
+ var isClipping = wrappedHeight > boxHeight;
+ return (isWrapping, isClipping);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
index 19faaab..a9506aa 100644
--- a/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/CanvasElementEditorTests.cs
@@ -62,6 +62,48 @@ public class CanvasElementEditorTests
Assert.Equal($"{{{columnName}}}", element.DisplayText);
}
+ // Post-Sprint-9 user-reported gap: there was no way to delete a placed standalone element.
+
+ [Fact]
+ public void RemoveSelected_RemovesElementFromDocumentAndClearsSelection()
+ {
+ var editor = CreateEditor(out var document);
+ var element = editor.AddStaticText(10, 20, "Hello");
+
+ var removed = editor.RemoveSelected();
+
+ Assert.True(removed);
+ Assert.DoesNotContain(element, document.Elements);
+ Assert.Null(editor.Selected);
+ }
+
+ [Fact]
+ public void RemoveSelected_NoSelection_ReturnsFalse_DocumentUnchanged()
+ {
+ var editor = CreateEditor(out var document);
+ editor.AddStaticText(10, 20, "Hello");
+ editor.Select(null);
+
+ var removed = editor.RemoveSelected();
+
+ Assert.False(removed);
+ Assert.Single(document.Elements);
+ }
+
+ [Fact]
+ public void RemoveSelected_DoesNotAffectOtherElements()
+ {
+ var editor = CreateEditor(out var document);
+ var keep = editor.AddStaticText(10, 20, "Keep");
+ var removeMe = editor.AddStaticText(30, 40, "Remove");
+ editor.Select(removeMe);
+
+ editor.RemoveSelected();
+
+ Assert.Contains(keep, document.Elements);
+ Assert.DoesNotContain(removeMe, document.Elements);
+ }
+
[Fact]
public void AddMultipleElements_AssignsIncreasingZOrder()
{
@@ -424,14 +466,13 @@ public class CanvasElementEditorTests
Assert.Equal(100, element.Y, precision: 6);
}
- // Post-Sprint-8 user-requested feature: resize a standalone static/dynamic element by
- // dragging a canvas handle. A standalone element has no independent Width/Height of its own
- // — its box is purely derived from measuring its text at the current FontSize — so "resize"
- // here means uniformly scaling FontSize, the only property that actually changes what
- // renders.
+ // Post-Sprint-9 user-requested change (2026-10-27): the canvas resize handle controls only
+ // Width — font size is set exclusively via the properties panel's Font size field, never by
+ // dragging (the earlier Sprint 8 uniform-font-scale handle behavior has been removed
+ // entirely, not just made conditional).
[Fact]
- public void ResizeHandlePosition_UnrotatedElement_IsAtTopRightCorner()
+ public void ResizeHandlePosition_UnrotatedElement_IsAtRightEdgeMidpoint()
{
var editor = CreateEditor(out _);
editor.AddStaticText(100, 100, "Hello"); // box [100,120]x[100,110]
@@ -439,8 +480,8 @@ public class CanvasElementEditorTests
var handle = editor.ResizeHandlePosition();
Assert.NotNull(handle);
- Assert.Equal(120, handle!.Value.X, precision: 6);
- Assert.Equal(110, handle.Value.Y, precision: 6);
+ Assert.Equal(120, handle!.Value.X, precision: 6); // right edge
+ Assert.Equal(105, handle.Value.Y, precision: 6); // vertically centered
}
[Fact]
@@ -492,20 +533,20 @@ public class CanvasElementEditorTests
{
var editor = CreateEditor(out _);
- Assert.False(editor.HitTestResizeHandle(120, 110));
+ Assert.False(editor.HitTestResizeHandle(120, 105));
}
[Fact]
- public void BeginResizeDrag_SetsIsResizingFontSize_AndEndDragClearsIt()
+ public void BeginResizeDrag_SetsIsResizingWidth_AndEndDragClearsIt()
{
var editor = CreateEditor(out _);
editor.AddStaticText(100, 100, "Hello");
editor.BeginResizeDrag();
- Assert.True(editor.IsResizingFontSize);
+ Assert.True(editor.IsResizingWidth);
editor.EndDrag();
- Assert.False(editor.IsResizingFontSize);
+ Assert.False(editor.IsResizingWidth);
}
[Fact]
@@ -522,53 +563,44 @@ public class CanvasElementEditorTests
}
[Fact]
- public void ResizeDragTo_DraggingTwiceTheOriginalDiagonalFromAnchor_DoublesFontSize()
- {
- var editor = CreateEditor(out _);
- var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); // anchor (100,100), diagonal sqrt(500)
- editor.BeginResizeDrag();
-
- var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0));
- editor.ResizeDragTo(100 + (2 * originalDiagonal), 100); // straight right, 2x the original distance
-
- Assert.Equal(24, element.FontSize, precision: 3);
- }
-
- [Fact]
- public void ResizeDragTo_DraggingHalfTheOriginalDiagonalFromAnchor_HalvesFontSize()
+ public void ResizeDragTo_SetsWidthFromHorizontalDistanceToAnchor()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
editor.BeginResizeDrag();
- var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0));
- editor.ResizeDragTo(100 + (0.5 * originalDiagonal), 100);
+ editor.ResizeDragTo(180, 999); // 80 right of the (100,100) anchor; Y is irrelevant
- Assert.Equal(6, element.FontSize, precision: 3);
+ Assert.Equal(80, element.Width!.Value, precision: 3);
}
[Fact]
- public void ResizeDragTo_DraggingVeryClose_ClampsToMinFontSize()
+ public void ResizeDragTo_NeverTouchesHeightOrFontSize()
{
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
+ element.Height = 55; // pre-existing explicit clip ceiling
editor.BeginResizeDrag();
- editor.ResizeDragTo(100.0001, 100); // tiny but nonzero distance from the anchor
+ editor.ResizeDragTo(180, 999);
- Assert.Equal(CanvasElementEditor.MinFontSize, element.FontSize, precision: 3);
+ Assert.Equal(55, element.Height!.Value, precision: 3);
+ Assert.Equal(12, element.FontSize, precision: 3);
}
[Fact]
- public void ResizeDragTo_DraggingVeryFar_ClampsToMaxFontSize()
+ public void ResizeDragTo_DraggingPastTheAnchor_LeavesWidthUnchanged()
{
+ // Only a positive resulting width is applied — dragging back past the anchor simply stops
+ // shrinking rather than going negative or to zero.
var editor = CreateEditor(out _);
var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
+ element.Width = 20;
editor.BeginResizeDrag();
- editor.ResizeDragTo(1_000_000, 100);
+ editor.ResizeDragTo(80, 999); // X (80) is left of the anchor (100)
- Assert.Equal(CanvasElementEditor.MaxFontSize, element.FontSize, precision: 3);
+ Assert.Equal(20, element.Width!.Value, precision: 3);
}
[Fact]
@@ -579,75 +611,37 @@ public class CanvasElementEditorTests
editor.ResizeDragTo(1000, 1000);
- Assert.Equal(12, element.FontSize, precision: 6);
+ Assert.Null(element.Width);
}
[Fact]
- public void ResizeDragTo_DynamicElement_ScalesFontSize_SameAsStatic()
+ public void ResizeDragTo_DynamicElement_SetsWidth_SameAsStatic()
{
var editor = CreateEditor(out _);
var element = editor.AddDynamicPlaceholder(100, 100, "Full Name", fontSize: 12);
editor.BeginResizeDrag();
- var originalDiagonal = Math.Sqrt((20.0 * 20.0) + (10.0 * 10.0));
- editor.ResizeDragTo(100 + (2 * originalDiagonal), 100);
+ editor.ResizeDragTo(180, 999);
- Assert.Equal(24, element.FontSize, precision: 3);
+ Assert.Equal(80, element.Width!.Value, precision: 3);
}
[Fact]
- public void ResizeDragTo_RotatedElement_DraggingToCurrentHandlePosition_KeepsFontSizeUnchanged()
+ public void ResizeDragTo_RotatedElement_DraggingToCurrentHandlePosition_KeepsWidthUnchanged()
{
// Proves the rotate-point-backward-into-local-space math round-trips correctly: dragging
// to exactly where the handle currently sits (already accounting for rotation) must be a
- // no-op scale change, not an accidental jump caused by comparing a rotated world point
+ // no-op width change, not an accidental jump caused by comparing a rotated world point
// against unrotated anchor coordinates.
var editor = CreateEditor(out _);
- var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
+ var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12); // FixedSize: 20x10
element.RotationAngle = 90;
var handle = editor.ResizeHandlePosition()!.Value;
editor.BeginResizeDrag();
editor.ResizeDragTo(handle.X, handle.Y);
- Assert.Equal(12, element.FontSize, precision: 3);
- }
-
- // Sprint 9, "Add an adjustable width and height with text wrapping...": once an element
- // already has a box, the same corner handle resizes Width/Height directly instead of
- // scaling FontSize.
-
- [Fact]
- public void ResizeDragTo_ElementHasBox_UpdatesWidthAndHeightDirectly_NotFontSize()
- {
- var editor = CreateEditor(out _);
- var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
- element.Width = 20;
- element.Height = 10;
- editor.BeginResizeDrag();
-
- editor.ResizeDragTo(180, 160); // 80 right, 60 up from the (100,100) anchor
-
- Assert.Equal(80, element.Width!.Value, precision: 3);
- Assert.Equal(60, element.Height!.Value, precision: 3);
- Assert.Equal(12, element.FontSize, precision: 3); // unaffected in box mode
- }
-
- [Fact]
- public void ResizeDragTo_ElementHasBox_DraggingPastTheAnchor_LeavesDimensionUnchanged()
- {
- // Only a positive resulting width/height is applied — dragging back past the anchor on
- // one axis simply stops shrinking that axis rather than going negative or to zero.
- var editor = CreateEditor(out _);
- var element = editor.AddStaticText(100, 100, "Hello", fontSize: 12);
- element.Width = 20;
- element.Height = 10;
- editor.BeginResizeDrag();
-
- editor.ResizeDragTo(80, 160); // X (80) is now left of the anchor (100)
-
- Assert.Equal(20, element.Width!.Value, precision: 3); // unchanged - would have gone negative
- Assert.Equal(60, element.Height!.Value, precision: 3); // Y direction still applied normally
+ Assert.Equal(20, element.Width!.Value, precision: 3);
}
[Fact]
@@ -655,9 +649,9 @@ public class CanvasElementEditorTests
{
// CanvasElementEditor itself has no opinion on measurement - it just uses whatever
// _measureText(element) returns. Production wiring (TemplateCanvasControl.MeasureElement)
- // is box-aware (returns Width/Height directly instead of measuring text) once this story
- // lands; this test proves the handle position correctly follows a box-aware measurer,
- // using a purpose-built fake rather than duplicating that production logic here.
+ // is box-aware once this story lands; this test proves the handle position correctly
+ // follows a box-aware measurer, using a purpose-built fake rather than duplicating that
+ // production logic here.
var document = TemplateLayoutDocument.CreateDefault();
var editor = new CanvasElementEditor(document, BoxAwareSize);
var element = editor.AddStaticText(100, 100, "Hi");
@@ -667,11 +661,11 @@ public class CanvasElementEditorTests
var handle = editor.ResizeHandlePosition();
Assert.NotNull(handle);
- Assert.Equal(300, handle!.Value.X, precision: 3); // 100 + 200
- Assert.Equal(250, handle.Value.Y, precision: 3); // 100 + 150
+ Assert.Equal(300, handle!.Value.X, precision: 3); // 100 + 200 (right edge)
+ Assert.Equal(175, handle.Value.Y, precision: 3); // 100 + 150/2 (vertical midpoint)
static (double Width, double Height) BoxAwareSize(TextElementLayout e) =>
- e.HasBox ? (e.Width!.Value, e.Height!.Value) : (20.0, 10.0);
+ e.HasBox ? (e.Width!.Value, e.Height ?? 0) : (20.0, 10.0);
}
// Sprint 7, "Snap elements to grid and guides".
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs
index cba058f..30c5b12 100644
--- a/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/TemplateLayoutXmlSerializerTests.cs
@@ -286,10 +286,10 @@ public class TemplateLayoutXmlSerializerTests : IDisposable
}
[Fact]
- public void Load_OnlyOneOfWidthOrHeightSet_HasBoxIsFalse()
+ public void Load_OnlyWidthSet_HasBoxIsTrue_HasHeightClipIsFalse()
{
- // A one-sided value doesn't count as having a box (see TextElementLayout.HasBox's
- // remarks) — it's accepted (not a parse error), just inert.
+ // Post-Sprint-9 user-requested change: Width alone is enough to have a box (wrap, no
+ // clip ceiling) — see TextElementLayout.HasBox's remarks.
File.WriteAllText(
_tempPath,
"" +
@@ -301,6 +301,25 @@ public class TemplateLayoutXmlSerializerTests : IDisposable
Assert.True(result, string.Join("; ", errors));
Assert.Equal(100, document!.Elements[0].Width);
Assert.Null(document.Elements[0].Height);
+ Assert.True(document.Elements[0].HasBox);
+ Assert.False(document.Elements[0].HasHeightClip);
+ }
+
+ [Fact]
+ public void Load_OnlyHeightSet_HasBoxIsFalse()
+ {
+ // A height with no width is inert - there is no wrap boundary for it to clip against.
+ File.WriteAllText(
+ _tempPath,
+ "" +
+ "Hi" +
+ "");
+
+ var result = TemplateLayoutXmlSerializer.TryLoad(_tempPath, out var document, out var errors);
+
+ Assert.True(result, string.Join("; ", errors));
+ Assert.Null(document!.Elements[0].Width);
+ Assert.Equal(50, document.Elements[0].Height);
Assert.False(document.Elements[0].HasBox);
}
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs
index c635f0b..c99c511 100644
--- a/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/TextElementPropertiesEditorTests.cs
@@ -277,8 +277,11 @@ public class TextElementPropertiesEditorTests
[Theory]
[InlineData(0)]
[InlineData(-5)]
- public void SetHeight_NonPositiveValue_ClearsTheBox(double nonPositive)
+ public void SetHeight_NonPositiveValue_ClearsTheHeightClipOnly_NotTheBox(double nonPositive)
{
+ // Post-Sprint-9 user-requested change: Height is independent of Width - clearing it drops
+ // the explicit clip ceiling (HasHeightClip) but the box itself (HasBox, driven by Width
+ // alone) stays intact.
var editor = new TextElementPropertiesEditor();
var element = TextElementLayout.CreateStatic(0, 0, "a");
element.Width = 100;
@@ -288,7 +291,8 @@ public class TextElementPropertiesEditorTests
editor.SetHeight(nonPositive);
Assert.Null(element.Height);
- Assert.False(element.HasBox);
+ Assert.False(element.HasHeightClip);
+ Assert.True(element.HasBox);
}
[Fact]
diff --git a/code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs b/code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs
new file mode 100644
index 0000000..8e4f8ab
--- /dev/null
+++ b/code/src/EnvelopeRenderer.Desktop.Tests/WrapClipDetectorTests.cs
@@ -0,0 +1,61 @@
+using EnvelopeRenderer.Desktop.Core.Design;
+
+namespace EnvelopeRenderer.Desktop.Tests;
+
+public class WrapClipDetectorTests
+{
+ [Fact]
+ public void Detect_NaturalWidthWithinBox_WrappedHeightWithinBox_NeitherFlagIsSet()
+ {
+ var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 50, wrappedHeight: 15, boxWidth: 100, boxHeight: 50);
+
+ Assert.False(isWrapping);
+ Assert.False(isClipping);
+ }
+
+ [Fact]
+ public void Detect_NaturalWidthExceedsBoxWidth_IsWrappingIsTrue()
+ {
+ var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 150, wrappedHeight: 15, boxWidth: 100, boxHeight: 50);
+
+ Assert.True(isWrapping);
+ Assert.False(isClipping);
+ }
+
+ [Fact]
+ public void Detect_WrappedHeightExceedsBoxHeight_IsClippingIsTrue()
+ {
+ var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 150, wrappedHeight: 80, boxWidth: 100, boxHeight: 50);
+
+ Assert.True(isWrapping);
+ Assert.True(isClipping);
+ }
+
+ [Fact]
+ public void Detect_NaturalWidthExactlyEqualsBoxWidth_IsNotWrapping()
+ {
+ // Exactly fitting is not "overflow" - only strictly exceeding counts.
+ var (isWrapping, _) = WrapClipDetector.Detect(naturalWidth: 100, wrappedHeight: 15, boxWidth: 100, boxHeight: 50);
+
+ Assert.False(isWrapping);
+ }
+
+ [Fact]
+ public void Detect_WrappedHeightExactlyEqualsBoxHeight_IsNotClipping()
+ {
+ var (_, isClipping) = WrapClipDetector.Detect(naturalWidth: 50, wrappedHeight: 50, boxWidth: 100, boxHeight: 50);
+
+ Assert.False(isClipping);
+ }
+
+ [Fact]
+ public void Detect_ClippingWithoutWrapping_IsPossible()
+ {
+ // A single line taller than the box (very large font, short box) clips without ever
+ // needing to wrap onto a second line.
+ var (isWrapping, isClipping) = WrapClipDetector.Detect(naturalWidth: 40, wrappedHeight: 60, boxWidth: 100, boxHeight: 50);
+
+ Assert.False(isWrapping);
+ Assert.True(isClipping);
+ }
+}
diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
index a0b00e5..5b01f16 100644
--- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
+++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateCanvasControl.cs
@@ -47,6 +47,12 @@ public sealed class TemplateCanvasControl : Control
DoubleBuffered = true;
BackColor = SystemColors.ControlDark;
SetStyle(ControlStyles.ResizeRedraw, true);
+ // Post-Sprint-9: a plain Control (unlike UserControl) is not selectable/focusable by
+ // default, so it never received key events at all — needed for the Delete key to remove
+ // the selected element (see OnKeyDown below and RemoveSelectedElement's own remarks on
+ // the reported gap this fixes).
+ SetStyle(ControlStyles.Selectable, true);
+ TabStop = true;
}
/// Sprint 7, "Snap elements to grid and guides": mirrors
@@ -88,7 +94,7 @@ public sealed class TemplateCanvasControl : Control
/// Mirrors the exact condition already used inline before this
/// property existed.
public bool IsInteracting =>
- _editor.IsDragging || _editor.IsRotating || _editor.IsResizingFontSize || _addressControlDragOffset is not null
+ _editor.IsDragging || _editor.IsRotating || _editor.IsResizingWidth || _addressControlDragOffset is not null
|| _isResizingAddressControl || _isRotatingAddressControl;
public TextElementLayout? SelectedElement => _editor.Selected;
@@ -231,6 +237,41 @@ public sealed class TemplateCanvasControl : Control
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
+ /// Post-Sprint-9 user-reported gap: there was no way to delete a placed element or
+ /// Address Control at all once added. Removes whichever is currently selected (a standalone
+ /// element or a whole Address Control — never just a drilled-into address line, which has its
+ /// own dedicated ) and clears the selection. Returns
+ /// false (a no-op) if nothing is selected.
+ public bool RemoveSelectedElement()
+ {
+ if (_selectedAddressControl is not null)
+ {
+ var removed = _document.AddressControls.Remove(_selectedAddressControl);
+ ClearSelection();
+ if (removed)
+ {
+ ElementsChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ return removed;
+ }
+
+ if (_editor.Selected is not null)
+ {
+ var removed = _editor.RemoveSelected();
+ Invalidate();
+ SelectionChanged?.Invoke(this, EventArgs.Empty);
+ if (removed)
+ {
+ ElementsChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ return removed;
+ }
+
+ return false;
+ }
+
private (double X, double Y) DefaultNewElementPosition()
{
// Cascade slightly so repeatedly clicking "Add" doesn't stack every new element exactly
@@ -401,6 +442,20 @@ public sealed class TemplateCanvasControl : Control
// tier scope for a later story, not required here.
var boxRect = new RectangleF((float)drawX, (float)drawY, (float)(width * transform.Scale), (float)boxHeight);
g.DrawString(element.DisplayText, font, brush, boxRect);
+
+ // Sprint 10, "Add a live wrap/clip indicator for text elements": a dashed-orange
+ // outline, the same visual language as the unmapped-column warning below, drawn
+ // whenever the element's *current* content is actually wrapping and/or being
+ // clipped — reusing the same box-mode text this method just drew, not a second
+ // divergent measurement.
+ var (isWrapping, isClipping) = MeasureWrapClip(element, font);
+ if (isWrapping || isClipping)
+ {
+ using var wrapClipPen = new Pen(System.Drawing.Color.OrangeRed, 1.5f) { DashStyle = DashStyle.Dot };
+ g.DrawRectangle(
+ wrapClipPen, (float)drawX - 1, (float)drawY - 1,
+ (float)(width * transform.Scale) + 2, (float)boxHeight + 2);
+ }
}
else
{
@@ -710,17 +765,23 @@ public sealed class TemplateCanvasControl : Control
/// measuring context so the result is directly comparable to
/// the point-based coordinates stores — this is a design-time
/// visual approximation of the real Debenu-rendered size, not a guarantee of pixel-for-point
- /// parity with the final PDF. Sprint 9, "Add an adjustable width and height with text
- /// wrapping...": once an element has a box (), its size
- /// *is* the box — authored geometry, not something to (re-)measure from text — so every
- /// downstream consumer of this method (hit-testing, the rotate handle, the resize handle, and
- /// itself) automatically treats the box as the element's real
- /// bounding box with no changes needed at those call sites.
+ /// parity with the final PDF. Sprint 9/10, "Add an adjustable width and height with text
+ /// wrapping...": once an element has a box ( — Width
+ /// alone, post-Sprint-9), its width is the authored box width, not something to (re-)measure
+ /// from text; its height is the explicit clip ceiling when
+ /// is set, or (when not) the natural wrapped height GDI+ reports for this exact width/text/font
+ /// — never a fixed clip in that case. Every downstream consumer of this method (hit-testing,
+ /// the rotate handle, the resize handle, and itself) automatically
+ /// treats this as the element's real bounding box with no changes needed at those call sites.
private static (double Width, double Height) MeasureElement(TextElementLayout element)
{
if (element.HasBox)
{
- return (element.Width!.Value, element.Height!.Value);
+ using var boxFont = ResolveFont(element.FontFamily, (float)element.FontSize);
+ var effectiveHeight = element.HasHeightClip
+ ? element.Height!.Value
+ : MeasureWrappedHeight(element.DisplayText, boxFont, element.Width!.Value);
+ return (element.Width!.Value, effectiveHeight);
}
using var bitmap = new Bitmap(1, 1);
@@ -732,6 +793,40 @@ public sealed class TemplateCanvasControl : Control
return (size.Width, size.Height);
}
+ /// Sprint 9/10: the height GDI+'s own rectangle-bounded layout needs to wrap
+ /// to points — the auto-height a boxed
+ /// element with no explicit clip ceiling uses, mirroring
+ /// how the CLI asks Debenu's own GetWrappedTextHeight for the equivalent value.
+ private static double MeasureWrappedHeight(string text, Font font, double width)
+ {
+ using var bitmap = new Bitmap(1, 1);
+ using var g = Graphics.FromImage(bitmap);
+ g.PageUnit = GraphicsUnit.Point;
+ var displayText = string.IsNullOrEmpty(text) ? " " : text;
+ var size = g.MeasureString(displayText, font, Math.Max(1, (int)Math.Round(width)));
+ return size.Height;
+ }
+
+ /// Sprint 10, "Add a live wrap/clip indicator for text elements": compares the
+ /// element's natural (unconstrained) width and GDI+-wrapped height against its box to decide
+ /// whether the *current* content is actually wrapping and/or being clipped right now — pure
+ /// arithmetic lives in ; this just does the GDI+ measurement
+ /// that decision needs.
+ private static (bool IsWrapping, bool IsClipping) MeasureWrapClip(TextElementLayout element, Font font)
+ {
+ using var bitmap = new Bitmap(1, 1);
+ using var g = Graphics.FromImage(bitmap);
+ g.PageUnit = GraphicsUnit.Point;
+ var text = string.IsNullOrEmpty(element.DisplayText) ? " " : element.DisplayText;
+ var naturalWidth = g.MeasureString(text, font).Width;
+ var wrappedHeight = MeasureWrappedHeight(text, font, element.Width!.Value);
+
+ // No explicit clip ceiling means "no clip" by definition (see HasHeightClip's remarks) —
+ // never flag clipping in that case, regardless of how tall the wrapped content grows.
+ var boxHeight = element.HasHeightClip ? element.Height!.Value : double.PositiveInfinity;
+ return WrapClipDetector.Detect(naturalWidth, wrappedHeight, element.Width!.Value, boxHeight);
+ }
+
private static (double Width, double Height) MeasureText(string text, Font font)
{
using var bitmap = new Bitmap(1, 1);
@@ -786,6 +881,9 @@ public sealed class TemplateCanvasControl : Control
protected override void OnMouseDown(MouseEventArgs e)
{
base.OnMouseDown(e);
+ // Post-Sprint-9: needed so this control actually receives OnKeyDown (Delete key) at all —
+ // see the constructor's ControlStyles.Selectable remarks.
+ Focus();
if (e.Button != MouseButtons.Left)
{
return;
@@ -973,7 +1071,7 @@ public sealed class TemplateCanvasControl : Control
return;
}
- if (_editor.IsResizingFontSize)
+ if (_editor.IsResizingWidth)
{
_editor.ResizeDragTo(x, y);
}
@@ -1010,4 +1108,21 @@ public sealed class TemplateCanvasControl : Control
ElementsChanged?.Invoke(this, EventArgs.Empty);
}
}
+
+ /// Post-Sprint-9 user-reported gap: Delete/Backspace removes whichever element or
+ /// Address Control is currently selected — there was no way to delete a placed item at all
+ /// before this. Only fires while not mid-gesture, so an accidental key press during a drag
+ /// can't delete out from under an in-progress move/resize/rotate.
+ protected override void OnKeyDown(KeyEventArgs e)
+ {
+ base.OnKeyDown(e);
+ if ((e.KeyCode is Keys.Delete or Keys.Back) && !IsInteracting)
+ {
+ RemoveSelectedElement();
+ e.Handled = true;
+ }
+ }
+
+ protected override bool IsInputKey(Keys keyData) =>
+ keyData is Keys.Delete or Keys.Back || base.IsInputKey(keyData);
}
diff --git a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
index a7e0230..03d5bb6 100644
--- a/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
+++ b/code/src/EnvelopeRenderer.Desktop/Views/TemplateDesignerForm.cs
@@ -65,6 +65,9 @@ public sealed class TemplateDesignerForm : Form
private readonly Button _addStaticTextButton = new() { Text = "Add Stati&c Text", AutoSize = true };
private readonly Button _addDynamicPlaceholderButton = new() { Text = "Add &Dynamic Placeholder", AutoSize = true, Enabled = false };
private readonly Button _addAddressControlButton = new() { Text = "Add &Address Control", AutoSize = true };
+ // Post-Sprint-9 user-reported gap: there was previously no way to delete a placed element or
+ // Address Control at all. Enabled only while something is selected (see RefreshSelectionLabel).
+ private readonly Button _deleteSelectedButton = new() { Text = "&Delete Selected", AutoSize = true, Enabled = false };
private readonly Label _selectionLabel = new() { AutoSize = true, Anchor = AnchorStyles.Left, Text = "No element selected." };
// CSV field mapping (Sprint 3, Batches 2-4).
@@ -1190,6 +1193,15 @@ public sealed class TemplateDesignerForm : Form
_dynamicFieldColumnComboBox.Margin = new Padding(6, 3, 3, 3);
panel.Controls.Add(_dynamicFieldColumnComboBox);
panel.Controls.Add(_addAddressControlButton);
+ _deleteSelectedButton.Click += (_, _) =>
+ {
+ if (_canvas.RemoveSelectedElement())
+ {
+ RefreshPropertiesPanel();
+ RefreshSelectionLabel();
+ }
+ };
+ panel.Controls.Add(_deleteSelectedButton);
_selectionLabel.Margin = new Padding(18, 6, 3, 3);
panel.Controls.Add(_selectionLabel);
@@ -1208,6 +1220,7 @@ public sealed class TemplateDesignerForm : Form
if (selected is not null)
{
_selectionLabel.Text = $"Selected: {selected.DisplayText} at ({selected.X:0.#}, {selected.Y:0.#}) pt.";
+ _deleteSelectedButton.Enabled = true;
return;
}
@@ -1215,6 +1228,7 @@ public sealed class TemplateDesignerForm : Form
_selectionLabel.Text = selectedAddress is null
? "No element selected."
: $"Selected address control at ({selectedAddress.X:0.#}, {selectedAddress.Y:0.#}) pt.";
+ _deleteSelectedButton.Enabled = selectedAddress is not null;
}
private Control BuildCanvasSettingsPanel()
diff --git a/state.md b/state.md
index 77c7f63..043b390 100644
--- a/state.md
+++ b/state.md
@@ -2,23 +2,23 @@
> The single live "where are we right now" file. Read this FIRST at the start of any session that touches this repo - don't infer phase or sprint from conversation history. Updated LAST by whichever agent completes the current step, as documented in `AGENTS.md` under "Automated State-Driven Handoff."
-**Phase:** 1 - Backlog refinement (next sprint cycle)
-**Leading agent:** `product-owner`
-**Process file:** `process/01_backlog_refinement.md`
+**Phase:** 3 - Sprint execution
+**Leading agent:** `dev-team`
+**Process file:** `process/03_sprint_execution.md`
-**Sprint:** 9 executed, reviewed, and retrospected (2026-10-27). 13/13 points Done, sprint goal met in full.
-**Sprint goal:** Give static and dynamic text elements a real, independently adjustable width and height, so that content too long to fit wraps onto additional lines and is clipped (not silently overrun) once it exceeds the box — using Debenu's native wrap/box-drawing primitives for the real PDF and GDI+'s native rectangle-wrap for a close design-time approximation on the canvas and preview panel, directly answering the human product owner's "make the boxes longer" request. **Met in full**, per Sprint Review.
-**Current sprint backlog:** `backlog/sprints/sprint-9.md`
+**Sprint:** 10 planned (2026-10-27), not yet executed.
+**Sprint goal:** Let an operator select multiple canvas elements at once (rubber-band or modifier-click, mixing standalone elements and Address Controls) and align/distribute them together, while also finishing the epic-4 wrap/clip chain's live visual indicator.
+**Current sprint backlog:** `backlog/sprints/sprint-10.md`
-**Sprint 9 execution, review, and retrospective summary:** The committed story ("Add an adjustable width and height with text wrapping to static and dynamic text elements," 13 pts) is Done. Real vendor-DLL investigation (licensed probe against the actual Debenu library, done *before* writing implementation code) found native `DrawTextBox`/`DrawRotatedTextBox`/`GetWrappedTextHeight`/`GetWrappedTextLineCount` entry points, confirmed via real rendered screenshots to wrap, clip (a too-short box returns fewer lines than exist, not an error), and compose rotation with wrapping natively in one call. `DebenuPdfRenderer.AddPage` now branches to these calls when a draw carries a box; canvas (`TemplateCanvasControl`) and preview (`TemplatePreviewBuilder`/`TemplatePreviewControl`) approximate the same wrap/clip via GDI+'s native rectangle-bounded drawing (a documented design-time approximation, since the desktop process shares no code path with the CLI's Debenu engine). The resize-handle coexistence question was resolved more simply than planned: the existing font-size handle became context-sensitive (box mode vs. font-scale mode, chosen once at drag-start via `HasBox`) rather than needing a second handle. Properties panel gained "Box width"/"Box height" fields. Tests: 448/448 -> 476/476 (23 new). Live-verified at three layers: a standalone licensed vendor-DLL probe with screenshots; a true end-to-end run (hand-authored template through the real built `EnvelopeRenderer.Cli.exe` against the real 392-record sample CSV, output PDF rasterized and visually inspected showing genuine 4-line wrap and genuine clip to "WILLIAM EDWARD"); and a reflection-driven built-form smoke against the real `TemplateDesignerForm` (real content TextBox, real Box width/height panel fields, real mouse-driven resize-handle drag, real canvas screenshot). Full detail: `backlog/sprints/sprint-9.md`, `backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/backlog.md`'s Sprint 9 refinement/planning/Review outcome notes, `backlog/sprints/sprint-9-retrospective.md`.
+**Recent history (Sprint 9, closed 2026-10-27):** Static/dynamic text elements shipped real adjustable width/height with native Debenu wrap/clip (13/13 points, sprint goal met in full); test suite 448/448 -> 476/476. Full detail: `backlog/sprints/sprint-9.md`, `backlog/epics/04_live_preview_and_record_navigation.md`, `backlog/sprints/sprint-9-retrospective.md`. A **Proposed** (not yet applied) process-improvement log entry awaits human review: the project's own Debenu license key (`key.txt`, repo root) was overlooked by at least two prior sprints' searches — logged in `logs/process_improvement_log.md` (2026-10-27).
-**Real de-risking finding with a downstream effect:** Debenu composes rotation and wrapping natively in one `DrawRotatedTextBox` call, meaning "Support rotated wrapped text and a live wrap/clip indicator for text elements" (epic 4, previously estimated 8 pts) likely already has its rotation half satisfied by this sprint's shipped code. Not re-sized unilaterally — flagged for `product-owner`/`dev-team` to re-confirm at the next backlog refinement before it is ever committed to a sprint.
+**Sprint 10 backlog refinement (2026-10-27):** Per the Sprint 9 retrospective's own action item, re-confirmed "Support rotated wrapped text and a live wrap/clip indicator for text elements" (previously 8 pts) via real code inspection and a new licensed end-to-end test — found its rotation-composition scope was already delivered as a byproduct of Sprint 9's implementation (confirmed: `DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox`, and both canvas/preview box-mode draws already run inside the existing rotation transform). Renamed to "Add a live wrap/clip indicator for text elements" and re-sized down to **3 points** — only the passive visual indicator itself remains. Full detail: `backlog/backlog.md`'s Sprint 10 backlog refinement outcome, `backlog/epics/04_live_preview_and_record_navigation.md`.
-**Process-improvement finding, Proposed (not yet applied — awaiting human product owner review):** the project's own Debenu license key (`key.txt` at the repo root, one level above `code/`) was found this sprint after at least two prior points (this project's own `DebenuPdfRendererIntegrationTests`/`DebenuPdfRendererRotationTests` design, and the Sprint 7 Sprint Review) concluded or implied no key was available, having only searched under `code/` rather than the actual repo root the resolver's own 10-level upward walk reaches. Logged in `logs/process_improvement_log.md` (2026-10-27) as a documentation-nudge proposal (no code change needed) for `CLI_CONTRACT.md`'s "Debenu license key" section or `AGENTS.md` — status **Proposed**, needs a human decision on exact wording/placement before being applied.
+**Sprint 10 planning (2026-10-27):** Committed 13 points: the now-3-point indicator story plus epic 6's multi-select ("Select multiple elements at once on the canvas," 5 pts) + align/distribute ("Align and distribute multiple elements," 5 pts) dependent pair — finally pulled after being Ready and deferred twice (Sprint 8, Sprint 9) for fresher, more urgent requests; neither reason applies a third time. Pairing the small indicator story with multi-select's genuinely novel selection-model work does not trip the established "two large/novel stories" caution, since only one side of the pairing carries that risk profile. Full reasoning: `backlog/backlog.md`'s Sprint 10 planning outcome note.
-**Retrospective outcome (`scrum-master`, `backlog/sprints/sprint-9-retrospective.md`):** Ninth sprint, clean delivery, no drops (one item — built-form-smoke discipline — showed genuine follow-through in substance despite its literal multi-batch trigger not arising this single-story sprint; two items correctly carry forward unchanged since untriggered for a third sprint running: epic 6's built-form-smoke rule, and the Sprint 6 Address Control interaction-code extraction evaluation). One near-miss named honestly: the live built-form GUI smoke was initially dropped from the story's own task list mid-execution (only the CLI-side check was recorded), caught and completed within the same session before Sprint Review — flagged as a new action item so "the CLI check feels sufficient" doesn't recur as reasoning for skipping a GUI-touching story's own DoD-required smoke.
+**Sprint 10 Batch 1 complete (2026-10-27), plus two ad-hoc corrections mid-batch:** "Add a live wrap/clip indicator for text elements" (3/13 points) shipped — a new framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel, drawn inside the existing per-element rotation transform. Mid-batch, the human product owner raised two live-build issues, addressed together before continuing: (1) **no way to delete a placed element or Address Control** — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) **the resize handle should control only box width, font size set elsewhere** — removed the Sprint 8 uniform-font-scale handle behavior entirely; `TextElementLayout.HasBox`/`TemplateElement.HasBox` now mean "Width is set" alone, with `Height` independent and optional (new `HasHeightClip` flag), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path (`DrawWrappedText`/`GetWrappedText`+`DrawRotatedMultiLineText`). Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) showed correct auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator correctly appearing. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness (a WinForms quirk absent a real shown window) — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`.
-**Next action:** Natural post-retrospective pause per `AGENTS.md`. When the user says to continue, start Sprint 10 backlog refinement as `product-owner`, folding in the Sprint 9 retrospective's action items above (especially: re-confirm/re-size "Support rotated wrapped text..." before ever committing it). Ready-and-waiting work for Sprint 10: "Support rotated wrapped text and a live wrap/clip indicator for text elements" (epic 4, ~8 pts pending re-sizing) and "...Address Control lines" (epic 4, 8 pts, depends on the former); epic 6's multi-select/align pair (10 pts) and "Undo and redo layout changes" (13 pts) remain Ready and unchanged. Still open, non-blocking: "Warn on text overflow before render" (epic 4, provisional 5 pts, blocked on both wrap/clip stories above landing first); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as a stale onboarding-era placeholder needing re-verification before ever being pulled; the Proposed process-improvement log entry above awaiting a human decision.
+**Next action:** Continue Sprint 10 as `dev-team`: Batch 2 "Select multiple elements at once on the canvas" (5 pts, epic 6, no dependency but sequenced next as the epic-6 pair's foundation), then Batch 3 "Align and distribute multiple elements" (5 pts, depends on Batch 2), per `backlog/sprints/sprint-10.md`. Key design note for Batch 2, not to be rediscovered mid-sprint: add multi-select as an *additive* layer alongside the existing single-selection state (`CanvasElementEditor.Selected` for standalone elements, `TemplateCanvasControl._selectedAddressControl` for Address Controls) rather than replacing it, so every existing single-select code path (properties panel, rotate/resize handles, line drill-in) keeps working unchanged when only one item is selected. This is a natural mid-sprint checkpoint (a large, first-of-its-kind feature remains, plus the human product owner just gave live-build feedback twice this session) — worth confirming with the user before continuing into the multi-select implementation. Still open, non-blocking: "Add an adjustable width and height with text wrapping to Address Control lines" (epic 4, 8 pts, deferred to Sprint 11); "Undo and redo layout changes" (epic 6, 13 pts); "Warn on text overflow before render" (epic 4, provisional 5 pts, still blocked); both epic 7 impediments (asset path strategy, UNC timeout/retry, open since 2026-09-04); "Complete the first text-only operator workflow" (epic 1, 5 pts) remains flagged as stale; the Proposed process-improvement log entry above awaiting a human decision.
## Phase reference
@@ -89,3 +89,6 @@ After phase 5, loop back to phase 1 for the next sprint.
| 2026-10-27 | 3 - Sprint execution | `dev-team` completed the sprint's single committed story, "Add an adjustable width and height with text wrapping to static and dynamic text elements" (13/13 points). A licensed real-DLL probe against the actual Debenu library, run before any implementation code, found and confirmed (via real rendered screenshots) native `DrawTextBox`/`DrawRotatedTextBox`/`GetWrappedTextHeight`/`GetWrappedTextLineCount` entry points that wrap, clip, and compose rotation with wrapping natively. `TextElementLayout`/`TemplateElement` gained nullable `Width`/`Height` (optional `width`/`height` XML attributes, both sides independently implemented); `DebenuPdfRenderer.AddPage` branches to the native calls when a draw has a box; canvas and preview panel approximate the same wrap/clip via GDI+'s native rectangle-bounded drawing (a documented design-time approximation). The resize-handle coexistence question was resolved by making the existing font-size handle context-sensitive (box mode vs. font-scale mode, chosen once at drag-start) rather than adding a second handle. Properties panel gained Box width/height fields. Tests: 448/448 -> 476/476 (23 new, including a licensed pixel-based clipping test). Live-verified at three layers: the vendor-DLL probe itself; a true end-to-end run (real template through the real built `EnvelopeRenderer.Cli.exe` against the real 392-record sample CSV, output rasterized and visually confirmed to wrap/clip correctly); and a reflection-driven built-form smoke against the real `TemplateDesignerForm` (real panel fields, real mouse-driven resize drag, real canvas screenshot) — the last of these was initially skipped mid-execution and caught/completed before Sprint Review. Found, not assumed: the project's own Debenu license key (`key.txt`, repo root) had been overlooked by at least two prior sprints' searches (which only checked under `code/`). Full detail: `backlog/sprints/sprint-9.md`. |
| 2026-10-27 | 4 - Sprint review | `product-owner` verified the Sprint 9 story against acceptance criteria (verdict: sprint goal met in full). Independently re-ran the full test suite (476/476 confirmed directly via `dotnet test`) and read the actual `DebenuPdfRenderer.AddPage` box branch, `TemplateLayoutXmlSerializer`/`TemplateXmlParser` persistence, and new test files directly, confirming the claims rather than trusting dev-team's report alone. Agreed the dependent "Support rotated wrapped text..." story (previously 8 pts) needs re-confirmation, not silent reuse, given this sprint's own de-risking finding. Updated `backlog/backlog.md`'s epic table and added a "Sprint 9 Review outcome" section; added a Development verification note to the story in `backlog/epics/04_live_preview_and_record_navigation.md`. |
| 2026-10-27 | 5 - Sprint retrospective | `scrum-master` ran the retrospective (`backlog/sprints/sprint-9-retrospective.md`). Ninth sprint, clean delivery, no drops (one item showed genuine follow-through in substance despite its literal trigger not arising; two correctly carry forward unchanged, one now a third sprint running). Named one honest near-miss: the built-form GUI smoke was initially dropped from the story's own task list mid-execution, caught and completed before review — logged as a new action item. Logged a **Proposed** (not yet applied) process-improvement entry in `logs/process_improvement_log.md`: the project's own Debenu license key was overlooked by at least two prior sprints' searches, a recurring (2+ occurrence) verification-depth gap meeting `AGENTS.md`'s kit-level bar — recommended fix is a documentation nudge in `CLI_CONTRACT.md`/`AGENTS.md`, awaiting a human product owner decision on wording/placement. All anti-patterns checked; none found. |
+| 2026-10-27 | 1 - Backlog refinement | `product-owner` ran Sprint 10 backlog refinement after the user confirmed the post-Sprint-9-retrospective pause point with "continue." Per the Sprint 9 retrospective's own action item, re-confirmed "Support rotated wrapped text and a live wrap/clip indicator for text elements" (previously 8 pts) via real code inspection and a new licensed end-to-end test — found rotation composition was already delivered as a byproduct of Sprint 9's implementation (`DebenuPdfRenderer.AddPage`'s box branch already calls `DrawRotatedTextBox`; canvas/preview box-mode draws already run inside the existing rotation transform), proven with a real rendered screenshot of a 20°-rotated, wrapped, boxed element. Renamed to "Add a live wrap/clip indicator for text elements," re-sized down to 3 points. Also found and documented that automated pixel-row-count clip verification is unreliable under rotation, simplifying a new regression test accordingly. Full detail: `backlog/backlog.md`'s Sprint 10 backlog refinement outcome, `backlog/epics/04_live_preview_and_record_navigation.md`. |
+| 2026-10-27 | 2 - Sprint planning | `scrum-master` facilitated with `product-owner`/`dev-team` input. Capacity signal now has nine data points (20, 19, 18, 18, 15, 18, 18, 13, 13). Committed 13 points: the 3-point indicator story plus epic 6's multi-select/align pair (10 pts), finally pulled after being deferred twice (Sprint 8, Sprint 9) for fresher requests that no longer apply. Reasoned explicitly that pairing a small, additive story with one large/novel story (multi-select's new selection model) does not trip the established "two large/novel stories" caution, since only one side carries that risk profile. Recorded in `backlog/sprints/sprint-10.md`; full reasoning in `backlog/backlog.md`'s Sprint 10 planning outcome note. |
+| 2026-10-27 | 3 - Sprint execution | `dev-team` completed Sprint 10 Batch 1, "Add a live wrap/clip indicator for text elements" (3/13 points). New framework-free `WrapClipDetector` (Desktop.Core) drives a dashed-orange indicator on the canvas and preview panel. Mid-batch, the human product owner raised two live-build issues, addressed together: (1) no way to delete a placed element or Address Control — added `CanvasElementEditor.RemoveSelected()`/`TemplateCanvasControl.RemoveSelectedElement()`, wired to Delete/Backspace and a new toolbar button; (2) the resize handle should control only box width, font size set elsewhere — removed the Sprint 8 uniform-font-scale handle behavior entirely; `HasBox` now means "Width is set" alone, `Height` independent and optional (new `HasHeightClip`), and `DebenuPdfRenderer.AddPage` gained a "Width alone, auto-height, no clip" render path. Full suite 476/476 -> 488/488. Live-verified: a real licensed end-to-end CLI render (width-only, unrotated and rotated) confirmed auto-height wrap with no clipping; a reflection-driven built-form harness confirmed the real resize handle sets only Width, the real Delete key removes the selection, and a real canvas screenshot shows the wrap indicator. One disclosed caveat: the toolbar Delete button's `PerformClick()` didn't reliably fire in the synthetic harness — proven correct via a direct method call instead. Full detail: `backlog/sprints/sprint-10.md`, `backlog/epics/02_template_designer_gui_foundation.md`, `backlog/epics/04_live_preview_and_record_navigation.md`. |