Skip to content

plan: composer textEntry squeezes ▶/■ off-canvas — reserve trailing icon pack, bound field to leftover (source #734) #737

Description

@btipling

Plan header

Field Value
Status IMPLEMENTED
Date 2026-08-21
Type single
Parent N/A
Source issue #734 — composer textEntry grows horizontally and squeezes ▶/■ off-canvas before wrapping
Branch fix/composer-textentry-squeeze
Layers harness (Wasm / dvui Zig)
Reusability impact none — harness layout only; no config seam, no per-owner hardcoding
Production mutate? no
Cloud ops path N/A — no Production mutate (existing build-harness GHA compiles/promotes the Wasm on main when native/harness/** changes)
Living docs docs/harness-limits.md (Wrap / grow + Icon button rows); optional one-clause note in AGENTS.md feature-divide "Composer + Send" row

Review notes (2026-08-21)

Reviewed per plan-review (mode=fix). Every baseline anchor re-grounded against live
source; dvui mechanism claims (BoxWidget leftover math, TextEntryWidget
scroll_horizontal=false.none + deinit minSizeReportToParent,
ScrollContainerWidget unbounded nextVirtualSize.w) verified. Applied fixes:

# Sev Axis Issue Fix landed
1 Major Testing / arch Composer chrome is inline in ui.zig frame() (L588–788), not a standalone paint fn like busy_row.paintBusyRow — so the planned busy_row_layout-clone dvui frame test has no real seam to drive the actual surface and would test a shadow copy Locked the extraction refactor as Implementation step 1: a standalone ui/composer_chrome.zig paintComposerChrome(...) holding the chrome box + field + reserved icon pack; frame() calls it; the new test targets that fn. Step 3: build.zig test-rich is an explicit per-file addTest list — add a new block with both dvui (dvui_testing) and web-backend (test_web_backend_stub.zig) imports, mirroring queue_band_tests (L282–289)
2 Minor Correctness / design Width budget should fold in the field's 8 px right margin (ui.zig L703 .margin.w = 8) so the reserve exactly fits field + margin + TOUCH_H×n and the icons get their full square icon_reserve_w = TOUCH_H×n + TE_MARGIN_RIGHT(8); field_w = avail.w − icon_reserve_w; Goal/edge-case math updated to the new constant
3 Minor Correctness / design Strategy A's explicit sub-rect must be proven to compact in BoxWidget (a child whose gravity.x is centered is child_positioned → overlay, which would overlap the icons instead of packing) Locked test assertions (icon x-rects after reserve, no overlay) + DoD seam-prove gate + strengthened Risk row
4 Nit Baseline Composer chrome anchor was stale (~L595); real block is L588–788 Corrected baseline + Design
5 Nit Living docs AGENTS.md N/A is defensible (contract is canonical under harness-limits) but feature-divide "Composer + Send" row already describes this area Added optional one-clause note in the docs plan; default stays N/A

Verdict: HANDOFF-READY — no Blocker; the Major is resolved by locking the
extraction seam; scores Correctness 4, Architecture 4, Testing 4 (after fix),
Cloud ops N/A (no Prod mutate), Living docs 4, Caps N/A (no cap added/changed),
Parent N/A, Palette N/A.

Summary

The composer field width is currently driven by dvui's horizontal-box min-space
leftover math, which honors the field's reported min width first
. When the user
types or pastes a long single unbreakable line (no spaces → break_lines
cannot split it), the textEntry's reported min width equals the line's natural
width, so the row allocates that to the field first and the trailing ▶/■ icon
packet gets leftover ≈ 0 — the icons slide/crush off-canvas before the chrome
grows up. This plan fixes the composer chrome so the field is explicitly
width-bounded to the leftover after a reserved trailing icon pack
, so long lines
wrap at the field width, the chrome grows up to COMPOSER_MAX_CHROME_H
(124 px), then scrolls inside — and ▶/■ stay fully on-canvas at TOUCH_H
during every growth frame. No caps change, no revert of the #702 caret-pan wrap.

Goals

# Goal Success signal
1 Field width = leftover of the chrome row after the reserved trailing icon pack Idle: field right edge (incl. its 8 px right margin) ≤ ▶ left edge; Busy: field wraps one icon narrower (2-slot reservation)
2 Long single-line input never squeezes ▶/■ ▶ (and Busy ▶+■) stay fully on-canvas at TOUCH_H for every growth frame; field never exceeds avail.w − icon_reserve_w
3 Wrapping → vertical growth → internal scroll unchanged and no horizontal gutter Long lines wrap at field width; chrome hugs upward to COMPOSER_MAX_CHROME_H then scrolls vertically inside; repo no-h-scroll holds
4 Regression locked by a dvui frame layout test New composer_layout.test.zig (dvui testing backend, physical px via windowRectPixels()) green in zig build test-rich, driving the real extracted composer chrome

Non-goals / out of scope

  • Reverting scroll_horizontal = false (that was the harness: composer text field horizontally scrolls instead of wrapping and growing #702 caret-pan fix) — break_lines must keep firing at the field width.
  • Dual DOM input (forbidden dual-UI pattern; the WASM composer is the product path).
  • Changing any cap: COMPOSER_MAX_CHROME_H (124 px), COMPOSER_INPUT_MAX_H (120 px), COMPOSER_IDLE_CHROME_H (44 px), TOUCH_H (40 px), COMPOSER_HUG_PAD, COMPOSER_TE_PAD all stay.
  • Queue-row editor (single-line, multiline = false) — untouched.
  • Patching vendored dvui to add a "clamp child min width" knob — out of scope.

Architectural decisions

One new structure, no ownership/bridge/ops change: because the composer
chrome is currently inline in ui.zig frame() and the #734 regression must be
locked by a dvui frame-layout test (which needs a standalone surface to drive),
we extract the chrome (box + field + reserved trailing icon pack) into a new
ui/composer_chrome.zig exposing paintComposerChrome(...); frame() calls it.
The composer stays Wasm-primary; host/feature-divide untouched. No bridge/protocol
change, no persistence, no multi-tenant/reuse seam, no Production ops path. The
mechanism choice is a design lock (see Design), not an architectural
decision beyond this extraction.

Layer placement

Concern Layer Path(s) Rationale
Composer field width + icon-on-canvas (extracted paint fn) harness (Wasm) NEW native/harness/src/ui/composer_chrome.zig (called by ui.zig frame() L588–788 composer block) Composer + Send/Stop are product-path Wasm (feature-divide); DOM is host shell only; extraction gives the frame test a real seam
Geometry test harness host-target native/harness/src/composer_layout.test.zig (+ build.zig test-rich, with dvui + web-backend stub imports) dvui testing backend computes layout rects; zig build test-rich runs on the runner
Wrap/grow contract docs docs docs/harness-limits.md Durable operator truth; timeless

Current baseline (live code)

Claim Path / symbol Notes
Composer chrome is an absolute-rect horizontal box holding field + icon buttons native/harness/src/ui.zig L588–788 (dvui.box(... .dir = .horizontal ...) L595–603, textEntry L681–742, icon dvui.button L751–787) verified — the block is inline in frame(); extraction target
Field sets expand = .horizontal, min_size_content.w = 120, max_size_content.w = dvui.max_float_safe, scroll_horizontal = false, break_lines = true, multiline = true, and .margin.w = 8 (right) native/harness/src/ui.zig L681–706 verified; width discipline currently delegated to the box layout; the 8 px field right margin must be folded into the reserve budget
Icons are TOUCH_H×TOUCH_H gravity_y = 1.0 with no reserved trailing slot native/harness/src/ui.zig L751–787 (busy ▶+■; idle ▶) the leftover-after-field allocation is the squeeze point
Horizontal BoxWidget allocates each child its min width first, then distributes only the remainder to expanded children dvui widgets/BoxWidget.zig minSizeForChild L243–262 (min_space_taken += s.w), rectFor L184–207 (ms.w += pixels_per_w * weight), pixels_per_w = max(0, child_rect.w − dp.min_space_taken)/packed_weight L108 verified — a field whose reported min width is the full unwrapped line leaves ≈0 for the icons
TextEntryWidget reports its min size to the parent at deinit dvui widgets/TextEntryWidget.zig deinit L1174–1188 (minSizeReportToParent), init pads min/max L131–141 verified — scroll_horizontal=false (init L114 → .none) makes the inner layout wrap at a finite viewport, but does not cap the widget's reported min width (ScrollContainerWidget.minSizeForChild L346–349 accumulates an unbounded nextVirtualSize.w = max(..., s.w)), so a space-less line keeps its natural width
Chrome cap / pad constants native/harness/src/ui/metrics.zig: TOUCH_H=40, COMPOSER_HUG_PAD=2, COMPOSER_TE_PAD=5, COMPOSER_INPUT_MAX_H=120, COMPOSER_MAX_CHROME_H=124, COMPOSER_IDLE_CHROME_H=44, TE_OVERHEAD=2 verified
Measured-height dynamic hug (idle 44 → cap 124 → internal scroll); reads te.data().id before te.deinit() native/harness/src/ui.zig L731–736 keep as-is (adversarial #584 pattern) and preserve through the extraction
dvui frame layout-test seam (physical px, 2-frame settle, tag rects include margin) native/harness/src/busy_row_layout.test.zig L1–91 (esp. windowRectPixels() + dvui.tagGet + two-step frame) verified pattern to clone for the new test — but it drives a standalone busy_row.paintBusyRow, which is why the chrome extraction (step 1) is required
test-rich registration is an explicit per-file addTest list; layout tests need dvui (dvui_testing) and web-backend (test_web_backend_stub.zig) imports native/harness/build.zig (e.g. queue_band_tests L282–289, composer_tests L303–308) verified — the new composer_layout block must add both imports (the chrome transitively pulls bridge.zig)
Zig gates (native/harness touched) zig build test-rich, test-rich-invariants, zig build harness -Doptimize=Debug, zig fmt --check, CI build-harness (self-hosted release wasm) per AGENTS.md — local Debug is host smoke; CI is the release gate
No existing plan / PR references #734 (search: none) gh issue list (search 734, plan: composer) verified — no duplicate plan to update

Design

Root mechanism (locked): the composer chrome is an absolute-rect horizontal
box of known width (avail.w from the frame). The defect is that dvui's box
leftover math — not us — decides the field width, and it gives the unwrapped-line
min width priority over the fixed icon columns. The fix is to take the width
decision back
: reserve a fixed trailing icon-pack slot and give the field an
explicit width equal to the leftover.

Strategy A (recommended) — trailing-reserved icon pack + explicit field width

Before painting the row:

icon_pack_w     = (busy ? 2 : 1) * TOUCH_H           // ▶ enqueue + ■ Stop when busy; ▶ alone idle
icon_reserve_w  = icon_pack_w + TE_MARGIN_RIGHT      // TE_MARGIN_RIGHT = 8 (field .margin.w)
field_w         = avail.w - icon_reserve_w           // leftover for the field (chrome padding x = 0)
  • Render the textEntry in an explicit trailing-reserved sub-rect
    (field_w wide), so its own reported min width (the unwrapped line) can never
    push past the reserved icon columns. Budget math includes the field's 8 px
    right margin
    (ui.zig L703 .margin.w = 8) so the reserve exactly fits
    field + margin + TOUCH_H×n and the icons get their full TOUCH_H square.
  • The icon buttons remain children of the same row after the field, keep
    min_size_content TOUCH_H×TOUCH_H, gravity_y = 1.0 (bottom-pinned on the
    field baseline) — unchanged. Because the icon columns are reserved for them by
    the field-wide sub-rect, they always get their full TOUCH_H square.
  • Seam proof (required, not assumed): verify the sub-rect actually compacts
    in the box — a BoxWidget child with centered gravity.x is child_positioned
    (overlay) and would overlap the icons instead of packing. The frame test asserts
    the icon x-rects land at avail.x + avail.w − icon_reserve_w …,
    sensibly spaced from the field's right edge — proving the reserve, not an
    overlay. This is a merge gate (DoD).
  • Keep scroll_horizontal = false + break_lines = true on the field: the inner
    TextLayout wraps at the given field_w; a genuinely unbreakable line clips
    inside the field (no internal h-pan, no gutter). The vertical growth path
    (composer_last_h, dvui.minSizeGet(te_id) before te.deinit(), L731–736) is
    untouched, so wrapping grows the chrome up to COMPOSER_MAX_CHROME_H, then
    scrolls vertically inside — as today.
  • min_size_content.w = 120 stays a floor; max_size_content.w = max_float_safe
    stays (both handled by the disable pad-bake; they are no longer the width
    authority).

Extraction (enables the real test): the whole chrome (box + field + icons +
reserve math) moves into a standalone ui/composer_chrome.zig exposing
paintComposerChrome(...); ui.zig frame() calls it in place of the inline L588–
788 block. The dynamic-hug height logic (composer_last_h), the typed/event
handlers around the field, and the submit dispatch stay in frame(); only the
width/layout surface moves and is parameterized (busy flag, avail.w, composer_y,
composer_h).

Add widget tags (e.g. composer-field, composer-send, composer-stop) so
the frame test can assert rendered rects (clones the busy-row-* tag pattern).
Use distinct id_extra per button so Busy's ▶+■ do not alias. Assert icon
x-placement (post-reserve) in addition to on-canvas so an overlay failure is
caught.

Strategy B (fallback) — wrap the field in a bounded horizontal container

Make the box's direct child a thin .expand = .horizontal container with a fixed
min_size_content.w = 120 floor; the textEntry inside is forced to the
wrapper's width, so the outer absolute-rect box sees only the 120 floor, never the
unwrapped-line width. Adds a container + must keep the inner field from visibly
overflowing the wrapper (clip) — more dvui surface, harder to reason about. Use
only if Strategy A's explicit sub-rect fights the textEntry's internal
ScrollContainerWidget sizing (proven by the seam test, not assumed).

Strategy C (rejected) — cap the field min width inside vendored dvui

No clean per-child clamp exists in BoxWidget (minSizeForChild sums the raw
min width). Patching vendored dvui is out of scope.

Edge cases (all must hold)

  • Idle short line: field_w = avail.w − (TOUCH_H + 8); ▶ fully visible; field hugs
    one line + 8 px right margin.
  • Busy: field_w = avail.w − (2×TOUCH_H + 8); ▶ + ■ both fully visible; field
    wraps one narrower column (still ≥ its 120 floor when space allows).
  • Long space-less single line: unwrapped min no longer steals the icon slot;
    line wraps (breakable) or clips inside the field (scroll_horizontal=false);
    chrome grows up on following frames via composer_last_h; icons stay
    bottom-pinned on the field baseline at every height; nothing leaves the
    viewport horizontally.
  • Multi-line paste: unchanged growth path (44 → 124 → internal vertical scroll).
  • Mobile ~390px: field_w shrinks toward the 120 floor; icon slot still
    reserved — no hidden icon, no horizontal gutter.
  • Refresh/settle: the dvui 2-frame settle for auto-sized boxes applies to the
    test helper only; the product composer chrome width is absolute per frame.
  • No interaction / reduced motion: width-only change; no event-path impact.

Caps table

Cap / ceiling Value Rationale Code location
N/A — no cap added or changed COMPOSER_MAX_CHROME_H/COMPOSER_INPUT_MAX_H/COMPOSER_IDLE_CHROME_H/TOUCH_H are explicit non-goals and are untouched; the reserved icon-pack `TOUCH_H×(1 2)+ 8 px field margin is a **layout** reservation, not a wire-size budget;TE_MARGIN_RIGHT = 8is read from the existing field.margin.w` (no new cap introduced)

Cloud ops path

N/A — no Production mutate. This is a Wasm harness layout fix. When
native/harness/** changes, the existing self-hosted build-harness GHA
compiles the release Wasm on pull_request → main and on main (artifact →
Vercel via the existing deploy-hook seam). No new workflow, no secrets, no
data/secret migration.

Living docs plan

Surface Change Notes
docs/harness-limits.md refresh Wrap / grow (L77) and Icon button (L78) rows to state the field width = leftover after a reserved trailing icon pack (explicit sub-rect avail.w − (TOUCH_H×n + 8)), and note the horizontal min-width no longer tracks the unwrapped line timeless; no phase/issue process artifacts
AGENTS.md N/A (default) — the composer-width contract already lives under feature-divide/harness-limits. Optional one-clause note in the feature-divide "Composer + Send" row: "field width = leftover after a reserved trailing icon pack" (agent truth-in-one-place). Add only if the reviewer/implementer prefers the duplication; otherwise N/A is defensible
README.md N/A — no visitor-facing change
SECURITY.md N/A — no security surface change
.env.example N/A — no env change

Implementation order

  1. native/harness/src/ui/composer_chrome.zig (NEW) — extract the composer
    chrome (box + field + icons + reserve math) into paintComposerChrome(...),
    called from ui.zig frame() in place of the inline L588–788 block. Inside:
    compute icon_reserve_w/field_w (incl. the 8 px field right margin), place
    the textEntry on the explicit trailing-reserved sub-rect; add composer-field /
    composer-send / composer-stop tags (+ distinct id_extra for Busy's two
    buttons). Preserve the dynamic-hug height path and the typed-capture /
    submit wiring in frame(); only layout width moves and is parameterized
    (busy, avail.w, composer_y, composer_h).
  2. native/harness/src/composer_layout.test.zig — dvui testing-backend frame
    test driving paintComposerChrome directly (real seam, not a shadow copy),
    cloning the busy_row_layout pattern (two-step settle, windowRectPixels(),
    physical px). Assert field right edge (incl. margin) ≤ ▶ left edge AND icon
    x-rects land post-reserve (seam/overlay proof) in addition to on-canvas.
  3. native/harness/build.zig — test-rich is an explicit per-file addTest
    list
    : add a new composer_layout addTest block with both dvui
    (dvui_testing) and web-backend (test_web_backend_stub.zig) imports
    ,
    mirroring queue_band_tests (L282–289).
  4. docs/harness-limits.md — update the two rows (same PR as the code, per
    living-docs rule).
  5. Run the Zig gates (§testing), then let CI build-harness run to green.

Testing

# Case Layer Type Command / method
1 Idle, short text — ▶ rect fully on-canvas; field right edge (incl. 8 px margin) ≤ ▶ left edge harness dvui frame layout zig build test-rich (composer_layout.test.zig, drives real paintComposerChrome)
2 Busy — ▶ and ■ both fully on-canvas; field wraps to reduced leftover harness dvui frame layout zig build test-rich
3 Long space-less single line in prompt buffer — icons stay on-canvas AND land post-reserve, no horizontal gutter, no overlay (#734 lock) harness dvui frame layout zig build test-rich — assert send.x + send.w ≤ winW, field.x + field.w + margin(8) ≤ send.x, and icon x-rects ≈ avail.w − icon_reserve_w (seam proof)
4 Multi-line prompt — chrome/hug grows toward COMPOSER_MAX_CHROME_H; icons bottom-pinned harness dvui frame layout zig build test-rich (height + gravity-y assertions)
5 Host unit suite + invariants harness unit zig build test-rich + zig build test-rich-invariants
6 Full Wasm compile (catches real compile errors) harness build zig build harness -Doptimize=Debug
7 Format harness lint zig fmt --check <touched zig>
8 Release-wasm gate (self-hosted) harness/CI CI wait for build-harness on the PR branch to go green

Minimum locked rows for DoD: 1, 2, 3 (#734 regression lock + reserve-seam
proof
), 5, 6, 7, 8.

Assertions use windowRectPixels() (physical px) and dvui.tagGet(...)rect
exactly as busy_row_layout.test.zig documents — tag rects include margins and
auto-sized boxes need two frame steps. The new addTest must import dvui
(dvui_testing) + web-backend (test_web_backend_stub.zig) or the host-target
build fails to compile (bridge.zig is Wasm-only).

Definition of done

  • Composer chrome extracted to a standalone ui/composer_chrome.zig paintComposerChrome(...); frame() calls it; the layout test drives the REAL surface (not a shadow copy)
  • Field width = leftover after a reserved trailing icon pack (idle TOUCH_H + 8, Busy 2×TOUCH_H + 8, incl. 8 px field right margin); ▶/■ never pushed off-canvas
  • Reserve-seam proven: icon x-rects land post-reserve (see test 3) — no BoxWidget overlay-crush; set composer-field/-send/-stop tags (+ distinct id_extra for Busy's two buttons)
  • Long space-less single line wraps at the field width (or clips inside via scroll_horizontal=false); chrome grows up to COMPOSER_MAX_CHROME_H then scrolls vertically inside; no horizontal gutter
  • #734-lock test (case 3) in composer_layout.test.zig green in zig build test-rich (with dvui + web-backend imports wired in build.zig)
  • zig build test-rich + zig build test-rich-invariants + zig fmt --check green on the branch
  • zig build harness -Doptimize=Debug clean (full Wasm compile — never just ast-check)
  • CI build-harness green on the PR branch (release-wasm gate) before the turn stops
  • docs/harness-limits.md Wrap / grow + Icon button rows updated (timeless; no phase/issue artifacts)
  • AGENTS.md / README.md / SECURITY.md / .env.example considered → N/A (justified; optional feature-divide note)
  • Cloud ops: N/A — no Production mutate (existing build-harness promotes the Wasm)
  • No dual-chat regression; composer stays Wasm; no cap changed (Caps table N/A)
  • No revert of scroll_horizontal = false (harness: composer text field horizontally scrolls instead of wrapping and growing #702 caret pan guarded); dynamic-hug height path (composer_last_h, read te.data().id before te.deinit()) preserved through the extraction

Risks & mitigations

Risk Mitigation
Strategy A's explicit sub-rect is treated as a BoxWidget overlay (centered gravity.xchild_positioned) and overlaps the icons instead of packing The seam is proven by the test (icon x-rects post-reserve, test 3) BEFORE merge; if it fights the textEntry internal ScrollContainerWidget sizing, fall back to Strategy B (bounded wrapper reporting a fixed 120 floor); keep scroll_horizontal=false so inner wrap stays finite. This is a merge gate
Extraction refactor drifts the dynamic-hug height logic The hug (L731–736) stays in frame() verbatim; the test's multi-line case (test 4) locks height + gravity-y, and the #584 te.data().id-before-deinit discipline is preserved and read in frame()
Regression of the #702 caret-pan wrap Non-goal guard: do not touch scroll_horizontal/break_lines; wrap still fires at the field width and is covered by test case 1/3
dvui auto-size 2-frame settle vs product absolute rect Test helper paints the same composition for two steps; doc the settle rationale (as busy_row_layout.test.zig does)
Local Debug build ≠ release-wasm (e.g. DejaVu glyph lane / scene branch differences) Local Debug + host tests catch logic; CI build-harness is the release gate — wait for green, do not claim "CI will catch it"
Hidden icon at narrow widths Reserved slot means the field yields, never the icon — asserted by test cases 1–3
8 px field right margin forgotten in the budget Reserve math folds TE_MARGIN_RIGHT; Goal 2 + edge cases + tests assert the margin-inclusive fit

Open questions

  • None — all engineering choices are locked above. (In-scope caps are explicit
    non-goals; nothing routes to a human gate.)

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions