You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.zigpaintComposerChrome(...) 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
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.zigtest-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
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 unboundednextVirtualSize.w = max(..., s.w)), so a space-less line keeps its natural width
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.
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
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).
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.
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).
docs/harness-limits.md — update the two rows (same PR as the code, per
living-docs rule).
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)
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.zigpaintComposerChrome(...); 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
Strategy A's explicit sub-rect is treated as a BoxWidget overlay (centered gravity.x ⇒ child_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 #584te.data().id-before-deinit discipline is preserved and read in frame()
native/harness/src/busy_row_layout.test.zig — dvui testing-backend layout-rect test pattern reused (drives a standalone paintBusyRow — the extraction that paintComposerChrome mirrors)
native/harness/build.zig queue_band_tests (L282–289) / composer_tests (L303–308) — the addTest + dvui/web-backend import shape the new composer_layout block must match
Plan header
textEntrygrows horizontally and squeezes ▶/■ off-canvas before wrappingfix/composer-textentry-squeezebuild-harnessGHA compiles/promotes the Wasm onmainwhennative/harness/**changes)docs/harness-limits.md(Wrap / grow + Icon button rows); optional one-clause note inAGENTS.mdfeature-divide "Composer + Send" rowReview 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+ deinitminSizeReportToParent,ScrollContainerWidget unbounded
nextVirtualSize.w) verified. Applied fixes:ui.zig frame()(L588–788), not a standalone paint fn likebusy_row.paintBusyRow— so the plannedbusy_row_layout-clone dvui frame test has no real seam to drive the actual surface and would test a shadow copyui/composer_chrome.zigpaintComposerChrome(...)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 bothdvui(dvui_testing) andweb-backend(test_web_backend_stub.zig) imports, mirroring queue_band_tests (L282–289)ui.zigL703.margin.w = 8) so the reserve exactly fitsfield + margin + TOUCH_H×nand the icons get their full squareicon_reserve_w = TOUCH_H×n + TE_MARGIN_RIGHT(8);field_w = avail.w − icon_reserve_w; Goal/edge-case math updated to the new constantchild_positioned→ overlay, which would overlap the icons instead of packing)~L595); real block is L588–788AGENTS.mdN/A is defensible (contract is canonical under harness-limits) but feature-divide "Composer + Send" row already describes this areaVerdict: 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_linescannot split it), the
textEntry's reported min width equals the line's naturalwidth, 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_Hduring every growth frame. No caps change, no revert of the #702 caret-pan wrap.
Goals
TOUCH_Hfor every growth frame; field never exceedsavail.w − icon_reserve_wCOMPOSER_MAX_CHROME_Hthen scrolls vertically inside; repo no-h-scroll holdscomposer_layout.test.zig(dvui testing backend, physical px viawindowRectPixels()) green inzig build test-rich, driving the real extracted composer chromeNon-goals / out of scope
scroll_horizontal = false(that was the harness: composer text field horizontally scrolls instead of wrapping and growing #702 caret-pan fix) —break_linesmust keep firing at the field width.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_PADall stay.multiline = false) — untouched.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 belocked 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.zigexposingpaintComposerChrome(...);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
native/harness/src/ui/composer_chrome.zig(called byui.zigframe() L588–788 composer block)native/harness/src/composer_layout.test.zig(+build.zigtest-rich, withdvui+web-backendstub imports)zig build test-richruns on the runnerdocs/harness-limits.mdCurrent baseline (live code)
native/harness/src/ui.zigL588–788 (dvui.box(... .dir = .horizontal ...)L595–603,textEntryL681–742, icondvui.buttonL751–787)frame(); extraction targetexpand = .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.zigL681–706TOUCH_H×TOUCH_Hgravity_y = 1.0with no reserved trailing slotnative/harness/src/ui.zigL751–787 (busy ▶+■; idle ▶)BoxWidgetallocates each child its min width first, then distributes only the remainder to expanded childrenwidgets/BoxWidget.zigminSizeForChildL243–262 (min_space_taken += s.w),rectForL184–207 (ms.w += pixels_per_w * weight),pixels_per_w = max(0, child_rect.w − dp.min_space_taken)/packed_weightL108TextEntryWidgetreports its min size to the parent at deinitwidgets/TextEntryWidget.zigdeinitL1174–1188 (minSizeReportToParent), init pads min/max L131–141scroll_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.minSizeForChildL346–349 accumulates an unboundednextVirtualSize.w = max(..., s.w)), so a space-less line keeps its natural widthnative/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=2te.data().idbeforete.deinit()native/harness/src/ui.zigL731–736native/harness/src/busy_row_layout.test.zigL1–91 (esp.windowRectPixels()+dvui.tagGet+ two-step frame)busy_row.paintBusyRow, which is why the chrome extraction (step 1) is requiredtest-richregistration is an explicit per-fileaddTestlist; layout tests needdvui(dvui_testing) andweb-backend(test_web_backend_stub.zig) importsnative/harness/build.zig(e.g. queue_band_tests L282–289, composer_tests L303–308)composer_layoutblock must add both imports (the chrome transitively pulls bridge.zig)zig build test-rich,test-rich-invariants,zig build harness -Doptimize=Debug,zig fmt --check, CIbuild-harness(self-hosted release wasm)gh issue list(search734,plan:composer)Design
Root mechanism (locked): the composer chrome is an absolute-rect horizontal
box of known width (
avail.wfrom the frame). The defect is that dvui's boxleftover 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:
textEntryin an explicit trailing-reserved sub-rect(
field_wwide), so its own reported min width (the unwrapped line) can neverpush 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 fitsfield + margin + TOUCH_H×nand the icons get their fullTOUCH_Hsquare.min_size_content TOUCH_H×TOUCH_H,gravity_y = 1.0(bottom-pinned on thefield baseline) — unchanged. Because the icon columns are reserved for them by
the field-wide sub-rect, they always get their full
TOUCH_Hsquare.in the box — a BoxWidget child with centered
gravity.xischild_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).
scroll_horizontal = false+break_lines = trueon the field: the innerTextLayoutwraps at the givenfield_w; a genuinely unbreakable line clipsinside the field (no internal h-pan, no gutter). The vertical growth path
(
composer_last_h,dvui.minSizeGet(te_id)beforete.deinit(), L731–736) isuntouched, so wrapping grows the chrome up to
COMPOSER_MAX_CHROME_H, thenscrolls vertically inside — as today.
min_size_content.w = 120stays a floor;max_size_content.w = max_float_safestays (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.zigexposingpaintComposerChrome(...);ui.zig frame()calls it in place of the inline L588–788 block. The dynamic-hug height logic (
composer_last_h), thetyped/eventhandlers around the field, and the submit dispatch stay in
frame(); only thewidth/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) sothe frame test can assert rendered rects (clones the
busy-row-*tag pattern).Use distinct
id_extraper button so Busy's ▶+■ do not alias. Assert iconx-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 = .horizontalcontainer with a fixedmin_size_content.w = 120floor; thetextEntryinside is forced to thewrapper'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
ScrollContainerWidgetsizing (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(minSizeForChildsums the rawmin width). Patching vendored dvui is out of scope.
Edge cases (all must hold)
field_w = avail.w − (TOUCH_H + 8); ▶ fully visible; field hugsone line + 8 px right margin.
field_w = avail.w − (2×TOUCH_H + 8); ▶ + ■ both fully visible; fieldwraps one narrower column (still ≥ its 120 floor when space allows).
line wraps (breakable) or clips inside the field (
scroll_horizontal=false);chrome grows up on following frames via
composer_last_h; icons staybottom-pinned on the field baseline at every height; nothing leaves the
viewport horizontally.
field_wshrinks toward the 120 floor; icon slot stillreserved — no hidden icon, no horizontal gutter.
test helper only; the product composer chrome width is absolute per frame.
Caps table
COMPOSER_MAX_CHROME_H/COMPOSER_INPUT_MAX_H/COMPOSER_IDLE_CHROME_H/TOUCH_Hare explicit non-goals and are untouched; the reserved icon-pack `TOUCH_H×(1+ 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-hostedbuild-harnessGHAcompiles the release Wasm on
pull_request → mainand onmain(artifact →Vercel via the existing deploy-hook seam). No new workflow, no secrets, no
data/secret migration.
Living docs plan
docs/harness-limits.mdavail.w − (TOUCH_H×n + 8)), and note the horizontal min-width no longer tracks the unwrapped lineAGENTS.mdREADME.mdSECURITY.md.env.exampleImplementation order
native/harness/src/ui/composer_chrome.zig(NEW) — extract the composerchrome (box + field + icons + reserve math) into
paintComposerChrome(...),called from
ui.zigframe() in place of the inline L588–788 block. Inside:compute
icon_reserve_w/field_w(incl. the 8 px field right margin), placethe textEntry on the explicit trailing-reserved sub-rect; add
composer-field/composer-send/composer-stoptags (+ distinctid_extrafor Busy's twobuttons). 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).native/harness/src/composer_layout.test.zig— dvui testing-backend frametest driving
paintComposerChromedirectly (real seam, not a shadow copy),cloning the
busy_row_layoutpattern (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.
native/harness/build.zig— test-rich is an explicit per-file addTestlist: add a new
composer_layoutaddTest block with bothdvui(dvui_testing) and
web-backend(test_web_backend_stub.zig) imports,mirroring queue_band_tests (L282–289).
docs/harness-limits.md— update the two rows (same PR as the code, perliving-docs rule).
build-harnessrun to green.Testing
zig build test-rich(composer_layout.test.zig, drives realpaintComposerChrome)zig build test-richzig build test-rich— assertsend.x + send.w ≤ winW,field.x + field.w + margin(8) ≤ send.x, and icon x-rects ≈avail.w − icon_reserve_w(seam proof)COMPOSER_MAX_CHROME_H; icons bottom-pinnedzig build test-rich(height + gravity-y assertions)zig build test-rich+zig build test-rich-invariantszig build harness -Doptimize=Debugzig fmt --check <touched zig>build-harnesson the PR branch to go greenMinimum locked rows for DoD: 1, 2, 3 (#734 regression lock + reserve-seam
proof), 5, 6, 7, 8.
Assertions use
windowRectPixels()(physical px) anddvui.tagGet(...)rectexactly as
busy_row_layout.test.zigdocuments — tag rects include margins andauto-sized boxes need two frame steps. The new addTest must import
dvui(dvui_testing) +
web-backend(test_web_backend_stub.zig) or the host-targetbuild fails to compile (bridge.zig is Wasm-only).
Definition of done
ui/composer_chrome.zigpaintComposerChrome(...);frame()calls it; the layout test drives the REAL surface (not a shadow copy)TOUCH_H + 8, Busy2×TOUCH_H + 8, incl. 8 px field right margin); ▶/■ never pushed off-canvascomposer-field/-send/-stoptags (+ distinctid_extrafor Busy's two buttons)scroll_horizontal=false); chrome grows up toCOMPOSER_MAX_CHROME_Hthen scrolls vertically inside; no horizontal gutter#734-lock test (case 3) incomposer_layout.test.ziggreen inzig build test-rich(withdvui+web-backendimports wired in build.zig)zig build test-rich+zig build test-rich-invariants+zig fmt --checkgreen on the branchzig build harness -Doptimize=Debugclean (full Wasm compile — never justast-check)build-harnessgreen on the PR branch (release-wasm gate) before the turn stopsdocs/harness-limits.mdWrap / grow + Icon button rows updated (timeless; no phase/issue artifacts)AGENTS.md/README.md/SECURITY.md/.env.exampleconsidered → N/A (justified; optional feature-divide note)build-harnesspromotes the Wasm)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, readte.data().idbeforete.deinit()) preserved through the extractionRisks & mitigations
gravity.x⇒child_positioned) and overlaps the icons instead of packingtextEntryinternalScrollContainerWidgetsizing, fall back to Strategy B (bounded wrapper reporting a fixed 120 floor); keepscroll_horizontal=falseso inner wrap stays finite. This is a merge gateframe()verbatim; the test's multi-line case (test 4) locks height + gravity-y, and the #584te.data().id-before-deinitdiscipline is preserved and read inframe()scroll_horizontal/break_lines; wrap still fires at the field width and is covered by test case 1/3busy_row_layout.test.zigdoes)DejaVuglyph lane / scene branch differences)build-harnessis the release gate — wait for green, do not claim "CI will catch it"TE_MARGIN_RIGHT; Goal 2 + edge cases + tests assert the margin-inclusive fitOpen questions
non-goals; nothing routes to a human gate.)
References
docs/harness-limits.md— Wrap / grow, Icon button, no-h-scroll policy (harness: multiline composer paste squishes Send/Stop/PONG row (unreadable) #344/plan: composer input+icon Send/Stop row (wrap/grow; drop hint/busy) — closes #344 + #347 #457/plan: composer hugs one line + grows up; status-bar flush text #579)native/harness/src/busy_row_layout.test.zig— dvui testing-backend layout-rect test pattern reused (drives a standalonepaintBusyRow— the extraction thatpaintComposerChromemirrors)native/harness/build.zigqueue_band_tests (L282–289) / composer_tests (L303–308) — theaddTest+dvui/web-backendimport shape the newcomposer_layoutblock must match