Skip to content

plan: harness — Busy ■ Stop stays on-canvas at ~390 px (full button-footprint reserve + narrow layout locks) #782

Description

@btipling

Plan header

Field Value
Status HANDOFF-READY
Date 2026-08-22
Type single
Parent N/A
Source issue #779 — harness: Busy ■ Stop is pushed off the right edge of the canvas
Branch plan/stop-on-canvas (or main)
Layers harness
Reusability impact none (in-canvas chrome; no infra/config seam)
Production mutate? no
Cloud ops path N/A — no Production mutate
Living docs docs/harness-limits.md (Layout / Touch·mobile / Composer chrome rows + the iconCellW value)

Human decision (2026-08-22) — cap approval

The operator approved the cap raise for plan #782 (one-word "approved", the confirmation form
established by #781). Sign-off: the trailing-icon reserve raise TOUCH_H×n + 8 (88 busy / 48 idle)
iconCellW×n + 8 (128 busy / 68 idle), +20 px/icon, implicitly accepting the all-width field
narrowing (idle 552→532, busy 512→472 @ 600 px) that is the tradeoff keeping ■ on-canvas at ~390 px.
This clears the plan-review BLOCK on cap governance and moves #782 BLOCKED → HANDOFF-READY. The
60 value is non-discretionary (a TOUCH_H-only reserve would re-truncate the real 60-px button);
TOUCH_H/SUBMIT_CAP/MAX_ITEMS themselves are NOT raised.

Review notes (2026-08-22)

Verdict: HANDOFF-READY. The single human-gated item (cap-governance) is RESOLVED — human
approved (2026-08-22)
. Everything else was verified-sound at plan-review and carries over as-is.

Baseline — all verified live (against main @ 393e5b3, the queue-pause merge):

Claim Check
Stop = Busy trailing ■; SEND id 0x70_0700, STOP id 0x70_0750, gravity_y = 1.0, min_size_content TOUCH_H×TOUCH_H composer_chrome.zig 189–217 (busy) / 217–234 (idle)
Reserve currently iconReserveW = TOUCH_H×n + 8 (88 busy / 48 idle); buttons set no .padding/.margin, so they inherit dvui defaults iconPackW/iconReserveW/fieldW 85–98; button opts have no pad/margin override
dvui ButtonWidget defaults = padding 6 / margin 4 (both sides) → real footprint TOUCH_H + 2·10 = 60 zig-pkg/.../ButtonWidget.zig:16–21 (defaults: .margin = Rect.all(4), .padding = Rect.all(6))
@ ~390 px the two 60-px buttons overrun → compactor crushes ■ flush to the right edge ✓ mechanism matches plan; reserve under-counts 20 px/icon
Composer laid out at true viewport width (avail_w = avail.w); queue band gets max_float_safe width → cannot widen canvas ui.zig 714–716 (chrome .avail_w = avail.w); queue_band.zig:102 (max_size_content.w = dvui.max_float_safe)
Tests: composer_layout.test.zig has WIN_LW = 600 only — no ~390 px row const WIN_LW: f32 = 600 (line 31); no 390 anywhere
Docs: harness-limits.md "Icon button" row documents the reserve as TOUCH_H×n + 8 px (40/square) ✓ line 92; no dedicated Caps-table section (caps are inline per-row)
No bridge export change → no public/harness/harness.wasm copy needed ✓ in-scope files are wasm-paint only

Findings

Sev Axis Finding Fix applied to issue body
BLOCK RESOLVED — human approved (2026-08-22) Cap governance iconReserveW is an existing effective cap/budget (documented TOUCH_H×n + 8 = 88/48) and the fix raises it to iconCellW×n + 8 = 128/68 (+20 px/icon), narrowing the field at every width (idle 552→532, busy 512→472 @ 600 px) — not only fixing ~390 px. Operator approved the cap raise (2026-08-22); recorded in the Human decision section. Caps table verdict → ✅ APPROVED by human
Minor Cap governance framing Plan asserted "not a raise or lower" — that is plan-self-justifying. iconCellW is documented as a NEW constant, but its net effect is a raise of the existing reserve budget token; the human decision must cover both the number AND the all-width field narrowing. Caps table note reworded to state the change is a reserve raise (not merely a NEW-constant entry), with the +20 px/icon delta and all-width effect made explicit — now covered by the human approval
Minor Docs Plan/Living-docs/DoD reference adding a "Caps table" to harness-limits.md, but that file documents caps inline (no separate Caps section exists). Living-docs row + Implementation step 3 + DoD reworded: place iconCellW (= 60) inline in the Composer chrome / Icon-button rows (file style) or add a small ### Caps values subsection — never claim a pre-existing Caps table
Minor Testing Existing WIN_LW = 600 assertions (fieldW(WIN_LW, …), send.x per row) will have changed expected values after the reserve raise (send.x shifts left by the delta). The "existing #737 idle lock preserved" wording must not read as "unchanged numbers." Added an explicit Implementation-order + Testing note that the 600 px row assertions are updated to the new reserve (field 552→532 idle, 512→472 busy), not merely retained

Scores: Correctness 5 · Performance 5 (pure layout arith, no I/O/loops) · Architecture 5 · Testing 4 · Cloud ops N/A (no mutate, correctly asserted) · Living docs 4 · Cap governance RESOLVED (human approved 2026-08-22) · Layer/UI 5 (in-canvas chrome only; no dual-chat; palette unused) · Reusability N/A (no seam) · Parent N/A (single issue).

Blocked item → RESOLVED: the single human-gated caps-table decision was approved by the
operator (2026-08-22). Plan is HANDOFF-READY for implementation (restore stashed WIP → gates →
PR).

Summary

While a turn is Busy, the composer's trailing icon pack is ▶ enqueue + ■ Stop. On a
~390 px phone-width canvas the ■ was crushed flush into the right edge of the viewport
(its tag rect slid past win.w) — un-hittable, so the operator cannot tap-cancel the
in-flight turn from the width they are actually using. Root cause (layout-rect proven, not
assumed): the trailing-icon reserve counted only TOUCH_H (40) per button, but each
ButtonWidget's real tag-rect footprint includes dvui's default padding (6) + margin (4)
per side
, i.e. TOUCH_H + 2·10 = 60. The reserve under-counted by 20 px/button, so the
two Busy buttons overran the row and dvui's horizontal compactor crushed the rightmost ■
off-canvas. This plan ships the full-footprint reserve (fieldW = avail_w − (n·iconCellW + 8)
with iconCellW = TOUCH_H + 2·(pad 6 + margin 4)), pins each button's padding/margin to the
same constants the arithmetic uses so the two can never drift, and locks the geometry with
new ~390 px on-canvas layout regressions.

Goals

# Goal Success signal
1 Busy ■ Stop is fully inside the visible canvas stop.x ≥ 0 and stop.x + stop.w ≤ viewport.w at ~390 px and at 600+ px (left rail open or closed)
2 Busy ▶ enqueue stays on-canvas too Same inequality for the send tag; ▶ left of ■, no overlap with the field
3 Idle ▶ Send stays on-canvas No regression of the #737 idle lock
4 Queue non-empty (Pause + Clear visible on the queue-band header) does not push Stop off-screen Busy on-canvas with n>0, paused and unpaused, including · full
5 Stop still cancels only the in-flight turn Hit target works; #757 / #760 queue semantics unchanged

Non-goals / out of scope

Architectural decisions

N/A — no new decision. This is a pure in-canvas chrome width-reservation fix with no
ownership, protocol, persistence, deploy, or reuse change. The layer is unchanged (harness).

Layer placement

Concern Layer Path(s) Rationale
Composer chrome trailing ▶/■ reserve + layout harness native/harness/src/ui/composer_chrome.zig In-canvas chrome; host shell owns nothing here
On-canvas geometry regression locks harness native/harness/src/composer_layout.test.zig dvui testing-backend rect asserts
No-h-scroll / touch limits doc harness docs/harness-limits.md Living limits table

Current baseline (live code)

Claim Path / symbol Notes
Stop is the Busy trailing ■ native/harness/src/ui/composer_chrome.zig busy branch (SEND id 0x70_0700, STOP id 0x70_0750) Idle = one ▶; Busy = ▶ then ■. gravity_y = 1.0 bottom-pinned, min_size_content TOUCH_H×TOUCH_H (40). Buttons set no padding/margin → inherit dvui defaults (6/4)
Pre-#779 reserve under-counted the footprint iconPackW/iconReserveW/fieldW Reserve was TOUCH_H×n (48/88 + 8 margin), but real ButtonWidget tag-rect width is TOUCH_H + 2·(default padding 6 + margin 4) = 60 → overrun → compactor crushes ■ off-canvas at ~390 px
dvui default button chrome vendored ButtonWidget.zig defaults .margin = Rect.all(4), .padding = Rect.all(6) — confirmed in the shipped 0.5.0-de sequence
Icons must stay on-canvas (prior lock, still closed) plan #737 (closed) / source #734 Field on a reserved sub-rect so a long unbreakable line cannot steal the icon columns
Pause/Clear queue-band header (above composer) PR #778 (merged, plan #777) Plan must include it in the ~390 px worst case so the header does not widen the canvas (queue_band width = avail.w with max_size_content.w = max_float_safe)
Layout tests native/harness/src/composer_layout.test.zig WIN_LW = 600 only before this plan — no ~390 px row, hence no regression caught. The 600 px rows assert fieldW(WIN_LW, …) and send.x — their expected values update under the new reserve
Docs docs/harness-limits.md Touch / mobile + Composer chrome “No horizontal overflow expected” — under-specified for Busy ~390 px; the Icon-button row documents the reserve as TOUCH_H×n + 8 (40/square), which this plan updates

Design

  • Reserve the FULL button footprint, not TOUCH_H. Define
    ICON_PAD = 6, ICON_MARGIN = 4, ICON_EDGE_W = ICON_PAD + ICON_MARGIN (=10).
    iconCellW() = TOUCH_H + 2·ICON_EDGE_W (=60). iconPackW(busy) = (2|1)·iconCellW().
    iconReserveW(busy) = iconPackW(busy) + TE_MARGIN_RIGHT(8). fieldW(avail_w, busy) = @max(0, avail_w − iconReserveW(busy)).
  • Pin each trailing button's options to those constants. On all three button sites
    (Busy ▶ enqueue, Busy ■ Stop, Idle ▶ Send) set .padding = {x,y,w,h}=ICON_PAD and
    .margin = {x,y,w,h}=ICON_MARGIN so the widget footprint is EXACTLY what the reserve
    arithmetic counts — the two can never drift apart.
  • Field wrapper already reports min=max=fieldW. paintComposerChrome keeps the
    trailing-reserved sub-rect (composer-field-wrap, FIELD_WRAP_ID) as a packed child
    (gravity.x = 0), so the box's leftover math gives the field at most
    avail_w − iconReserveW and the icons always land post-reserve at their full cell.
    The textEntry width max is fieldW (min 120 stays a floor that yields to the cap on
    narrow widths); scroll_horizontal = false lets break_lines fire at the field width.
  • Queue-band header isolation: the queue band (Pause ≥64 / Clear ≥56 + {n} queued +
    optional · full/· paused) paints in its own band (queue_band.paint(…, avail.w) in
    ui.zig) above the composer; max_size_content.w = max_float_safe means it cannot
    widen the canvas. Composer is laid out at the true viewport width. The regression test
    covers the worst-case header present so a future min-width growth is caught.
  • Cap note: TOUCH_H stays 40; SUBMIT_CAP / MAX_ITEMS unchanged. The reserve
    budget does raise
    (TOUCH_H×n + 8iconCellW×n + 8, +20 px/icon), narrowing the
    field at all widths. This raise was one-word-approved by the operator (2026-08-22)
    see Human decision + Caps table.

Cloud ops path

N/A — no Production mutate. Harness-only layout chrome; no data/secret/deploy/env
cutover. Zig gates run in the agent workspace / CI as usual (no new GHA work).

Living docs plan

Surface Change Notes
docs/harness-limits.md Layout row Restate: trailing ▶ / ■ stay fully on-canvas at ~390 px and wider (left rail open/closed), including with the queue-band Pause/Clear header visible (plan #779) timeless
docs/harness-limits.md Composer wrap/grow row #734 bound updated to full footprint: field_w = avail_w − (n·iconCellW + 8), iconCellW = TOUCH_H + 2·(pad 6 + margin 4); note why a TOUCH_H-only reserve crushed ■ at ~390 px timeless
docs/harness-limits.md Icon button row Reserve value → n × (TOUCH_H + 2·(pad 6 + margin 4)) + 8; never crushed flush to the edge even on ~390 px Busy timeless; caps here are inline (file style)
docs/harness-limits.md iconCellW value Add iconCellW = TOUCH_H + 2·ICON_EDGE_W = 60 inline in the Composer chrome / Icon-button rows (this file carries caps inline, no separate Caps table) — or add a small ### Caps values subsection if preferred rationale + code location; not a pre-existing Caps table
AGENTS.md N/A — no infra rule / ownership table change; ownership unchanged (harness)
README.md N/A — visitor-facing behavior unchanged
SECURITY.md / .env.example N/A — no secrets or new env

Caps table

Cap / ceiling Value Rationale Code location
iconCellW (full trailing-button footprint) TOUCH_H + 2·(pad 6 + margin 4) = 60 px Reserve-vs-real-footprint reconciliation for plan #779; the reserve raises TOUCH_H×n + 8iconCellW×n + 8, +20 px/icon, narrowing the field at all widths (idle 552→532, busy 512→472 @ 600 px). TOUCH_H itself is NOT raised; this is a width-reservation budget raise, not a product touch-target/capacity change. Carrier is canvas-local pixels (no wire). 2 busy cells + 8 margin = 128 ≤ the ~390 px floor, so no new narrow ceiling — no over-ceiling in play native/harness/src/ui/composer_chrome.zig ICON_PAD/ICON_MARGIN/ICON_EDGE_W/iconCellW

✅ APPROVED by human (2026-08-22). The operator approved the reserve raise TOUCH_H×n + 8
iconCellW×n + 8 (+20 px/icon, implicit all-width field narrowing). No further decision
required; this plan is HANDOFF-READY for implementation. Outside width budget (a plan that
reserved only TOUCH_H for a real 60-px button would re-truncate ■) is the inviolable floor that
forces this particular raise; the 60 value is not discretionary.

Implementation order

  1. composer_chrome.zig: add ICON_PAD/ICON_MARGIN/ICON_EDGE_W, redefine
    iconPackW/iconReserveW/fieldW on iconCellW(), pin button padding/margin on all
    three sites; update the module doc-comment.
  2. composer_layout.test.zig: parameterize the layout helper to accept an arbitrary content
    width (a global width slot) and add ~390 px locks: Busy empty-queue, Busy non-empty
    queue (Pause + Clear header), Busy paused/· full, idle narrow + 600. Update the
    existing 600 px row assertions to the new reserve
    (field 552→532 idle, 512→472 busy;
    send.x shifts left by the delta) — do not leave stale numbers.
  3. docs/harness-limits.md: update Layout / wrap-grow / Icon-button rows + add the
    iconCellW value (inline or a ### Caps values subsection) in the same PR as the
    code — never deferred to a later docs-only pass.
  4. Gates: zig fmt --check, zig build test-rich, zig build test-rich-invariants,
    zig build harness -Doptimize=Debug.

Testing

# Case Layer Type Command / method
1 Busy, canvas ~390 px, empty queue harness layout unit zig build test-rich — assert stop.x ≥ 0 and stop.x + stop.w ≤ win.w; assert send.x + send.w ≤ stop.x
2 Busy, ~390 px, queue non-empty (Pause + Clear header) harness layout unit worst-case header; ■/▶ still on-canvas; header does not widen window
3 Busy, ~390 px, paused + · full harness layout unit still on-canvas
4 Idle ▶ at ~390 and 600 (left rail 40 / 220) harness layout unit existing #737 tests repointed to the new reserve + a narrow row; ▶ on-canvas
5 Operator: Busy turn on a phone-width /harness browser operator ■ tappable and cancels the turn; Esc still works

Gates (min locked rows): zig fmt --check, zig build test-rich (composer_layout),
zig build test-rich-invariants, zig build harness -Doptimize=Debug. Since bridge
exports do not change, no public/harness/harness.wasm copy / wasm-int rerun is
strictly required unless the Debug build is used for local wasm-int fuzz.

Definition of done

  • Human decision: cap raise approved (2026-08-22) — BLOCK cleared
  • Goals 1–5 locked by the ~390 px + 600 px layout tests; full test-rich green
  • 600 px existing row assertions updated to the new reserve (not stale) — test-rich green on those too
  • test-rich-invariants green
  • zig fmt --check green on touched Zig
  • zig build harness -Doptimize=Debug compiles (full Wasm gate)
  • No new h-scroll / gutter; no TOUCH_H-based cap raised (the reserve raise is the only width budget change)
  • Cloud ops: N/A — no Production mutate
  • Living docs: docs/harness-limits.md Layout / Composer rows + iconCellW value updated in the same PR
  • AGENTS.md / README / SECURITY / .env.example: N/A justified
  • No dual-UI regression; Stop cancels only the in-flight turn

Risks & mitigations

Risk Mitigation
Reserve math and button options drift apart again Constrain the arithmetic AND the widget options to the same ICON_PAD/ICON_MARGIN/ICON_EDGE_W constants + comment the invariant
Queue-band header min-width grows and re-widens the canvas Locked at ~390 px in test 2/3 so any future growth is caught; header stays max_size_content.w = max_float_safe (cannot widen)
Field min 120 floor leaves too little room below ~200 px canvas Out of scope (sub-phone widths); fieldW clamps at 0 so reserve still wins
Laptop-only cutover / missing GHA N/A — no Production mutate (explicit)

Open questions

  • None — the caps-table reserve raise (BLOCK) was approved by the operator (2026-08-22) and is
    resolved. Plan is HANDOFF-READY.

References


Approved by operator 2026-08-22 (cap raise). Status BLOCKED → HANDOFF-READY. Deliverable was the reviewed plan-issue body update only; implementation is a separate /implement_plan invocation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    harnessHarness / agent UIui

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions