Status: IMPLEMENTED
Source issue: #702
Layers: Harness Wasm only (no Vercel backend, no DOM, no protocol bump)
Summary
The composer textEntry uses max_size_content.w = dvui.max_float_safe (2,000,000 px — effectively unbounded) so a 0 width + 2×COMPOSER_TE_PAD bake would not cap the internal layout at 10 px. But max_float_safe lets the internal TextLayout believe it has infinite width, so break_lines = true never fires — the widget h-scrolls instead of wrapping.
The fix: replace max_float_safe with a finite content width computed from the available viewport width (avail.w).
Baseline (live main at plan-review)
| Symbol |
File:line |
Value |
Composer textEntry max_size_content.w |
ui.zig:696 |
dvui.max_float_safe = 2,000,000 |
break_lines |
ui.zig:686 |
true |
multiline |
ui.zig:685 |
true |
| Composer chrome box |
ui.zig:595-602 |
.expand = .horizontal, dir horizontal |
| Icon button width |
ui.zig:752 |
TOUCH_H = 40 px |
| textEntry margin.w |
ui.zig:700 |
8 px |
avail viewport size |
ui.zig:156 |
root.data().contentRect().justSize() |
COMPOSER_TE_PAD |
metrics.zig:22 |
5 px |
Queue row multiline |
queue_band.zig:165 |
false (out of scope) |
COMPOSER_INPUT_MAX_H |
metrics.zig:28 |
120 px |
Design
Root cause
max_size_content.w = max_float_safe makes the TextEntryWidget negotiate an effectively unbounded max width. Inside, the ScrollAreaWidget → TextLayoutWidget chain receives a rect wide enough that break_lines never triggers word-wrap — the text stays on one line and the internal horizontal scroll activates.
Strategy A (chosen): replace max_float_safe with avail.w
// Before:
.max_size_content = .{ .w = dvui.max_float_safe, .h = metrics.COMPOSER_INPUT_MAX_H - 2 * metrics.COMPOSER_TE_PAD },
// After:
.max_size_content = .{ .w = @max(120, avail.w), .h = metrics.COMPOSER_INPUT_MAX_H - 2 * metrics.COMPOSER_TE_PAD },
avail.w is the viewport width — always available by this point in frame() (line 156).
- The textEntry receives
avail.w − TOUCH_H − 8 px from the flex layout (icon + margin).
- With
max_size_content.w = avail.w, the TextLayout's self.data().rect.w is bounded, and break_lines wraps at the actual allocated width.
- Floor of 120 matches
min_size_content.w — prevents pathological zero-width wraplosion.
Why not subtract the icon column
max_size_content is a negotiation cap, not the actual allocation. The flex layout already subtracts the icon column. Setting the cap to avail.w is generous — the actual allocated width will be less, and break_lines wraps at that actual width. Subtracting the icon column would double-count.
No other changes
COMPOSER_TE_PAD bake still works: dvui adds padding.x + padding.w into max_size_content, but avail.w is large enough that avail.w + 10 is still finite and wrapping triggers.
- The dynamic hug height sampling (
composer_last_h) automatically picks up the wrapped height — no code changes needed there.
- Idle single-line (Enter only) still hugs ~44 px; multi-line paste grows the chrome up to 124 px cap.
Layers
| Layer |
Change |
| Harness (Wasm) |
ui.zig — one line: dvui.max_float_safe → @max(120, avail.w) |
| Vercel backend |
No change |
| DOM |
No change |
No protocol bump. No bridge changes. No new exports.
Testing
Unit tests
No host-testable module exists for frame() layout — the composer_text module tests text normalization only. Manual smoke plan:
- Long URL: Type/paste a 200+ char URL with no newline → line wraps at field width, chrome grows, no h-scroll
- Multi-line paste: Paste 10 lines → chrome grows up to 124 px, then vertical internal scroll
- Idle single line: Short message → ~44 px chrome, no growth
- Narrow viewport: Resize canvas to ~400 px → wrap point narrows, chrome grows taller (fewer chars per line), no h-scroll
- Send unchanged: Ctrl+Enter sends; Enter inserts newline — no regression
- Queue row: Single-line queue editor unchanged (
multiline = false)
Zig gates
Pure Zig change (ui.zig only) — standard harness gates:
zig fmt --check native/harness/src/ui.zig
zig build harness -Doptimize=Debug
zig build test-rich-invariants
# + build-harness CI (wasm32-freestanding release)
No TS changes → no vitest / typecheck / di-gate / npm run build.
Ops & docs
- Cloud ops: N/A — no Production mutate
- Docs:
docs/harness-limits.md — Wrap / grow row: update to note max_size_content.w is finite (viewport-width). No new caps. Update the code comment that currently defends max_float_safe.
- Caps: No new caps, no existing cap changed.
avail.w is a live viewport measurement, not a static constant.
- Protocol: No bump — Wasm-only, no bridge export change.
Risk assessment
Low. Single-line change, no new imports, no alloc, no bridge. The @max(120, avail.w) floor preserves the existing min-width behavior on pathological zero-width canvases. If avail.w is too narrow (e.g. a 200 px wide window), wrapping happens at ~128 px after icon + margin — still legible.
Edge cases
| Case |
Behavior |
| Fullscreen 1920 px |
textEntry wraps at ~1848 px — effectively no wrap, same as now |
| 800 px viewport |
textEntry wraps at ~728 px — readable width |
| 400 px viewport |
textEntry wraps at ~328 px — narrow but functional |
| 200 px viewport (pathological) |
Floor 120 px + icon 40 px + margin 8 px = 168 px > 200 — tight but break_lines fires at the allocated width |
Review (plan-review)
Scores
| Axis |
Score |
Notes |
| Correctness |
4 |
Algorithm correct; line numbers drifted (fixed in baseline table) |
| Performance |
5 |
One @max call in existing paint path; no alloc/I/O/bridge |
| Architectural soundness |
5 |
Wasm-only; reuses avail.w already in scope; no protocol bump; no bridge changes |
| Testing |
3 |
No host-testable unit test (same as all ui.frame() changes) — manual smoke 6 scenarios is proportional |
| Cloud ops |
N/A |
No Production mutate |
| Living docs |
4 |
harness-limits.md wrap row + code comment both identified |
| Cap governance |
5 |
No new caps; avail.w is a live viewport measurement, not a static constant |
Findings
| # |
Sev |
Issue |
Resolution |
| 1 |
Minor (L8) |
Line numbers drifted: multiline at 685 not 670, break_lines at 686 not 671, margin at 700 not 685, max_size_content at 696 not 681 |
Fixed in baseline table |
| 2 |
Minor (L8) |
Doc comment at lines 690–694 ("Width max is max_float_safe…") defends the current max_float_safe value — must be updated when the value changes |
Implementer: update comment to explain @max(120, avail.w) — "finite viewport-width cap so break_lines fires; floor 120 guards zero-width canvases" |
Ready for /implement-plan #706 (done).
Implementation
- PR: #709
- Commit:
39073b7
- Branch:
plan/composer-wrap
Status:
IMPLEMENTEDSource issue: #702
Layers: Harness Wasm only (no Vercel backend, no DOM, no protocol bump)
Summary
The composer textEntry uses
max_size_content.w = dvui.max_float_safe(2,000,000 px — effectively unbounded) so a0width + 2×COMPOSER_TE_PADbake would not cap the internal layout at 10 px. Butmax_float_safelets the internal TextLayout believe it has infinite width, sobreak_lines = truenever fires — the widget h-scrolls instead of wrapping.The fix: replace
max_float_safewith a finite content width computed from the available viewport width (avail.w).Baseline (live
mainat plan-review)max_size_content.wui.zig:696dvui.max_float_safe= 2,000,000break_linesui.zig:686truemultilineui.zig:685trueui.zig:595-602.expand = .horizontal, dir horizontalui.zig:752TOUCH_H= 40 pxui.zig:700availviewport sizeui.zig:156root.data().contentRect().justSize()COMPOSER_TE_PADmetrics.zig:22multilinequeue_band.zig:165false(out of scope)COMPOSER_INPUT_MAX_Hmetrics.zig:28Design
Root cause
max_size_content.w = max_float_safemakes the TextEntryWidget negotiate an effectively unbounded max width. Inside, the ScrollAreaWidget → TextLayoutWidget chain receives a rect wide enough thatbreak_linesnever triggers word-wrap — the text stays on one line and the internal horizontal scroll activates.Strategy A (chosen): replace
max_float_safewithavail.wavail.wis the viewport width — always available by this point inframe()(line 156).avail.w − TOUCH_H − 8px from the flex layout (icon + margin).max_size_content.w = avail.w, the TextLayout'sself.data().rect.wis bounded, andbreak_lineswraps at the actual allocated width.min_size_content.w— prevents pathological zero-width wraplosion.Why not subtract the icon column
max_size_contentis a negotiation cap, not the actual allocation. The flex layout already subtracts the icon column. Setting the cap toavail.wis generous — the actual allocated width will be less, andbreak_lineswraps at that actual width. Subtracting the icon column would double-count.No other changes
COMPOSER_TE_PADbake still works: dvui addspadding.x + padding.wintomax_size_content, butavail.wis large enough thatavail.w + 10is still finite and wrapping triggers.composer_last_h) automatically picks up the wrapped height — no code changes needed there.Layers
ui.zig— one line:dvui.max_float_safe→@max(120, avail.w)No protocol bump. No bridge changes. No new exports.
Testing
Unit tests
No host-testable module exists for
frame()layout — thecomposer_textmodule tests text normalization only. Manual smoke plan:multiline = false)Zig gates
Pure Zig change (
ui.zigonly) — standard harness gates:zig fmt --check native/harness/src/ui.zig zig build harness -Doptimize=Debug zig build test-rich-invariants # + build-harness CI (wasm32-freestanding release)No TS changes → no vitest / typecheck / di-gate / npm run build.
Ops & docs
docs/harness-limits.md— Wrap / grow row: update to notemax_size_content.wis finite (viewport-width). No new caps. Update the code comment that currently defendsmax_float_safe.avail.wis a live viewport measurement, not a static constant.Risk assessment
Low. Single-line change, no new imports, no alloc, no bridge. The
@max(120, avail.w)floor preserves the existing min-width behavior on pathological zero-width canvases. Ifavail.wis too narrow (e.g. a 200 px wide window), wrapping happens at ~128 px after icon + margin — still legible.Edge cases
break_linesfires at the allocated widthReview (plan-review)
Scores
@maxcall in existing paint path; no alloc/I/O/bridgeavail.walready in scope; no protocol bump; no bridge changesui.frame()changes) — manual smoke 6 scenarios is proportionalharness-limits.mdwrap row + code comment both identifiedavail.wis a live viewport measurement, not a static constantFindings
multilineat 685 not 670,break_linesat 686 not 671, margin at 700 not 685,max_size_contentat 696 not 681max_float_safevalue — must be updated when the value changes@max(120, avail.w)— "finite viewport-width cap so break_lines fires; floor 120 guards zero-width canvases"Ready for
/implement-plan #706(done).Implementation
39073b7plan/composer-wrap