From 1e122ef61f2e5a4f2d426640b65268b0fa9b7d97 Mon Sep 17 00:00:00 2001 From: btipling Date: Sat, 22 Aug 2026 19:04:25 +0000 Subject: [PATCH 1/3] =?UTF-8?q?harness:=20Busy=20=E2=96=A0=20Stop=20stays?= =?UTF-8?q?=20on-canvas=20at=20~390px=20via=20full=20button-footprint=20re?= =?UTF-8?q?serve=20(plan=20#782,=20issue=20#779)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause (rect-proven): the trailing-icon reserve counted only TOUCH_H (40) per button, but each ButtonWidget's real tag-rect footprint includes dvui default padding (6) + margin (4) per side = TOUCH_H + 2*10 = 60. The reserve under-counted by 20px/button so the two Busy buttons overran the row and dvui's compactor crushed the trailing ■ flush into the right edge at ~390px (Stop un-hittable). Fix: define ICON_PAD(6)/ICON_MARGIN(4)/ICON_EDGE_W(10); iconCellW() = TOUCH_H + 2*ICON_EDGE_W = 60; iconPackW/iconReserveW/fieldW rebuilt on iconCellW(); pin every trailing ▶/■ button's padding/margin to those same constants so the arithmetic and the widget options can never drift. Tests: parameterize the layout helper to arbitrary width and add ~390px locks — busy empty-queue, idle narrow, busy unbreakable-line — asserting ■/▶ full tag rects stay on-canvas with real slack (never flush) and the field stays left of the icons. 600px rows: composer_layout now 11 pass. Docs: harness-limits.md Layout/Wrap-grow/Icon-button rows + iconCellW inlined (cap raise approved by operator 2026-08-22). Cloud ops: N/A (no Production mutate). --- docs/harness-limits.md | 8 +- native/harness/src/composer_layout.test.zig | 82 ++++++++++++++++++++- native/harness/src/ui/composer_chrome.zig | 60 +++++++++++++-- 3 files changed, 138 insertions(+), 12 deletions(-) diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 8536092..bcbb254 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -70,7 +70,7 @@ closed). | Topic | Behavior | |-------|----------| -| Layout | Full-bleed canvas under host nav; no horizontal overflow expected | +| Layout | Full-bleed canvas under host nav; no horizontal overflow expected. Trailing ▶ / ■ stay **fully on-canvas at ~390 px** and wider (left rail open or closed), including with the queue-band Pause/Clear header visible (plan #779) | | Hit targets | Send / message **📋** (clipboard / copy) ≥ ~40px tall | | Fallback | No “use the DOM chat instead” product path | @@ -81,15 +81,15 @@ Vertical bands inside the Wasm root (not a DOM panel): | Band | Behavior | |------|----------| | **Transcript** | Horizontal pair inside the leftover band from the canvas top to the composer: a collapsible **left rail** (closed = 40 px icon strip; open = 220 px TEAL column with the session list, scroll inside the rail) plus the `scrollArea`. Row labels are pixel-ellipsized to the 220 px column (UTF-8, trailing `…`). The auto vertical bar sits on the **canvas right edge**. No pane pad, no card fill/border on the scroller. Rail height always equals the transcript band (`scroll_h`) — it shrinks when the composer grows up. | -| **Composer chrome** | Single-row text field + trailing **icon-only** button(s): idle = one ▶ Send; Busy = ▶ enqueue + ■ Stop. Dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes the **height** padding into min/max — **height** sizes are passed minus 2×pad so the 44/124 chrome caps hold; the width max is raw `field_w`, never pad-shortened). Icons are bottom-pinned (`gravity_y = 1.0`) so they stay on the field baseline at all heights. A **submit-queue band** (header + up to 3 rows) sits **above** the composer when the queue is non-empty; the composer stays glued to the status bar | +| **Composer chrome** | Single-row text field + trailing **icon-only** button(s): idle = one ▶ Send; Busy = ▶ enqueue + ■ Stop. Dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes the **height** padding into min/max — **height** sizes are passed minus 2×pad so the 44/124 chrome caps hold; the width max is raw `field_w`, never pad-shortened). Icons are bottom-pinned (`gravity_y = 1.0`) so they stay on the field baseline at all heights. The trailing ▶/■ strip reserves each button's **full footprint** (`TOUCH_H + 2·(pad+margin)`, plan #779) so the icons never crush off-canvas at phone widths. A **submit-queue band** (header + up to 3 rows) sits **above** the composer when the queue is non-empty; the composer stays glued to the status bar | | **Status bar** | **Two-line** always-mounted full-width strip **below the composer**: line 1 = identity (spinner (always kept) · `h:{build-id}` (drops first on narrow viewport) · model menu (pixel-ellipsized after build-id drop); plan #695), line 2 = status-slot pack (sandbox · cwd · git · context); fixed `STATUS_BAR_H` = 64 px, never collapses (plan #570, merged from header per plan #555 → #554) | | Rule | Behavior | |------|----------| | Composer visibility | Fully on-canvas while the harness is ready; not optional | | Height budget | Every frame: absolute-rect bands — the transcript band is **`[left rail \| scrollArea]`** from the canvas top to the queue band (or the composer when the queue is empty). Height = viewport − dynamic composer `composer_h` − optional queue band − status bar `STATUS_BAR_H`; no inter-band gap. The rail is a sibling `Options.rect` of the scroller (closed 40 px, open 220 px TEAL session-list column); the scroller’s `Options.rect.x` is the rail width so both share `scroll_h`. Composer sits above the status bar with dynamic height from previous-frame measurement (idle ~44 px, max 124 px), full canvas width. The queue band sits **above** the composer and does **not** move it. Status bar absolute-rect flush to the canvas bottom, full width. Transcript scroller **and** composer use `Options.rect` so neither participates in root flex; the scrollArea's `.auto` bar cannot publish virtual content height as min-size (dvui `ScrollContainerWidget.deinit` overwrites `min_size.h` with full content). Tall content cannot push chrome off-canvas | -| Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — the field's `min_size_content` / `max_size_content` are passed minus 2×pad on the **height axis only** (`TOUCH_H − 2×pad`, `COMPOSER_INPUT_MAX_H − 2×pad`) because `TextEntryWidget.init` bakes the padding in; the **width** is passed raw (`min 120`, `max field_w`) — it is already the leftover after the reserved strip, so it must **not** be pad-shortened or the wrap would shrink 10 px inside the reserve. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737):** the field's `max_size_content.w` is `field_w = avail_w − (TOUCH_H×n + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin) — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | -| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `TOUCH_H×n + 8` px (plan #737/#734): the field is width-bounded to the leftover after that reserve, so the icons always keep their full `TOUCH_H` square and are never squeezed off-canvas by a long unbreakable line — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty; **an empty composer + non-empty queue promotes the head** — explicit Play). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | +| Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — the field's `min_size_content` / `max_size_content` are passed minus 2×pad on the **height axis only** (`TOUCH_H − 2×pad`, `COMPOSER_INPUT_MAX_H − 2×pad`) because `TextEntryWidget.init` bakes the padding in; the **width** is passed raw (`min 120`, `max field_w`) — it is already the leftover after the reserved strip, so it must **not** be pad-shortened or the wrap would shrink 10 px inside the reserve. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737/#779):** the field's `max_size_content.w` is `field_w = avail_w − (n×iconCellW + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin), where `iconCellW = TOUCH_H + 2·(pad 6 + margin 4)` is each button's **full tag-rect footprint** — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. Plan #779 root-caused why a `TOUCH_H`-only reserve still crushed the trailing ■ flush to the viewport edge at ~390 px: the button's real footprint includes dvui's default padding (6) + margin (4) per side, so the reserve now counts the FULL footprint and the button options pin `padding`/`margin` to the same `ICON_PAD`/`ICON_MARGIN` constants the arithmetic uses. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | +| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `n × (TOUCH_H + 2·(pad 6 + margin 4)) + 8` px (plan #737/#734/#779): the reserve counts each button's **full tag-rect footprint** (content `TOUCH_H` + default padding + margin per side), and the field is width-bounded to the leftover after that reserve, so the icons always keep their full square and are never squeezed — or crushed flush to the edge — off-canvas by a long unbreakable line, even on a **~390 px phone-width canvas while Busy (▶ enqueue + ■ Stop)** with the queue Pause/Clear header visible — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty; **an empty composer + non-empty queue promotes the head** — explicit Play). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | | Turn clock | Whole-turn **`mm:ss`** is painted **in-canvas** by the Wasm busy row (`Waiting for model… · 0:42`), protocol **v14** (`inv_set_turn_elapsed`). The **DOM host** owns the only reliable wall-clock (no WASI clock in Wasm): its ~1 Hz Busy effect pushes the elapsed seconds to the bridge (`HarnessBridge.setTurnElapsed` → `inv_set_turn_elapsed`), reset to 0 on Ready/Stop/error so no bare `0:00` lingers. The clock is client wall-time from turn start — **not** provider `usage` duration. See [feature-divide.md](feature-divide.md) | | Busy spinner | Two instances of a **2×4 rectangle grid**: (a) **transcript busy row** — a **WARM rectangle grid** paints **left of** `Waiting for model…` on a **full-width `teal_bg` bar**; while Busy the waiting copy runs a **left-to-right cyclic WARM color wave** (same 10 Hz phase as the spinner) via `text_wave.zig` — a **localized directed comet** behind the traveling head: the head scalar is `warm_accent`, the next 2 scalars behind it are `warm_muted`, the next 1 is `warm_border`, and the **rest of the line stays `warm_accent`** (readable; `warm_surface` #1a120c is ~1:1 on `teal_bg` and reserved for rect_spinner off-cells). The wave doubles its speed via `SPEED=2` — **~2.7 s full cycle** at 10 Hz (`N*STEPS/SPEED` ticks). The wave is painted inside the same textLayout as the ` · mm:ss` clock (no separate clock textLayout). (b) **status-bar line 1** — during **Busy** the same WARM pulse runs; during **idle** (Ready / boot / error) all 8 cells paint `teal_muted` as a static, subtle grid. The grid is **4×4 px cells** with **2 px sibling gaps** (inner 10×22), centered by equal pad (**1.5 / 3.5**) inside a reserved **13×29 slot**; the slot and the **10 px `TRAIL`** before the waiting copy do not move. The pulse is a **clockwise loop**: left column **bottom→top**, right column **top→bottom**. The **DOM host** feeds the pulse phase at **`HARNESS_BUSY_TICK_HZ` = 10 Hz while Busy** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`). **NEW cap:** 10 Hz while Busy, **0 otherwise** — well below the dvui 60 fps ceiling, host-local `setInterval` (no transport), turns transient. Pulse is pure Wasm LUT paint (`native/harness/src/busy_spinner.zig` / `rect_spinner.zig` / `text_wave.zig`), **no I/O / alloc in the frame path**. Each `setBusyTick` triggers a full dvui `refresh()` (re-layout + repaint) at up to 10 Hz while Busy vs 1 Hz today — the 10 Hz bound is the lock, not a cached-redraw claim. **Reduced motion:** host skips tick pushes → `busy_tick` stays 0 (reserved sentinel). Both grids static (spinner head **bottom-left**; wave all `warm_accent` — the phase-0 fast-path in `text_wave.zig` paints the whole string at `ramp[0]`). The bridge stores the host's monotonic `u32` tick as-is; `0` is reserved for idle / reduced motion / busy-start. The comet wraps only on `N*STEPS` (visible loop), not on a u8 fold. The live `mm:ss` **clock feed keeps running** (solid `warm_accent`, never waves). Old host + new Wasm: `busy_tick` stays 0 → static grids + static wave (graceful). New host + old Wasm: `inv_set_busy_tick` is in `REQUIRED_FNS`, so a stale build fails closed at load | | Short canvas | Transcript shrinks / scrolls first — chrome keeps touch-sized targets (~40px). `SCROLL_FLOOR_H` (32 px) prevents the transcript from collapsing to zero on absurdly short canvases | diff --git a/native/harness/src/composer_layout.test.zig b/native/harness/src/composer_layout.test.zig index 37a7167..37ca5e8 100644 --- a/native/harness/src/composer_layout.test.zig +++ b/native/harness/src/composer_layout.test.zig @@ -33,6 +33,7 @@ const WIN_LW: f32 = 600; /// Fields shared between the frame closure and each test (Zig has no closures, /// so the test body stages these before stepping the frame). var T_buf: [512]u8 = [_]u8{0} ** 512; +var T_avail_w: f32 = WIN_LW; var T_busy: bool = false; var T_want_focus: bool = true; var T_last_res: composer_chrome.Result = .{}; @@ -55,11 +56,19 @@ const FieldRects = struct { /// Paint TWO frames of the real `paintComposerChrome` and return the tag rects /// from the second frame. `T_last_res` holds the second frame's Result. fn paintAndGetRects() FieldRects { + return paintAndGetRectsW(WIN_LW); +} + +/// Width-parameterized variant (plan #779 — the #734/#737 lock was only ever +/// proven at WIN_LW=600; a ~390 px canvas row would silently slip ■ past the +/// viewport). `avail_w` is the logical composer row width handed to the paint. +fn paintAndGetRectsW(avail_w: f32) FieldRects { + T_avail_w = avail_w; const frame = struct { fn paint() !dvui.App.Result { T_last_res = composer_chrome.paintComposerChrome(.{ .busy = T_busy, - .avail_w = WIN_LW, + .avail_w = T_avail_w, .y = 0, .h = metrics.COMPOSER_IDLE_CHROME_H, .prompt_buf = &T_buf, @@ -226,6 +235,77 @@ test "busy + #734 lock: a long unbreakable line during Busy keeps ▶ + ■ on-c try t.expect(stop.x + stop.w <= win.w + EPS); } +// ── Plan #779 — ~390 px canvas row (operator report: Busy ■ Stop crushed / +// ── off the right edge). The #734/#737 lock was only ever proven at +// WIN_LW=600; on a phone width the FULL button footprint (TOUCH_H + 2·(pad + +// margin)) overran a TOUCH_H-only reserve and dvui squeezed the trailing ■ to +// zero slack at the viewport edge. These tests run the REAL paint at ~390 px +// and assert the full tag rects (margin-included, physical px) stay on-canvas +// with real slack — the exact rows goals 1–4 name. +const WIN_NARROW: f32 = 390; + +/// Paint busy chrome at `avail_w` and assert every success-predicate from the +/// plan #779 goals: ■ and ▶ fully on-canvas, ▶ left of ■, and the field kept +/// left of the icons. Returns whether ■ had a comfortable right gutter. +fn assertBusyOnCanvasW(avail_w: f32) !void { + const r = paintAndGetRectsW(avail_w); + const win = dvui.windowRectPixels(); + const stop = r.stop orelse @panic("busy must render ■ stop"); + // Goal 1 — ■ fully inside the visible canvas (physical px, margin-incl). + try t.expect(stop.x >= 0); + try t.expect(stop.x + stop.w <= win.w); + // Goal 2 — ▶ on-canvas too, and ▶ left of ■ (no overlap, distinct cells). + try t.expect(r.send.x >= 0); + try t.expect(r.send.x + r.send.w <= win.w + EPS); + try t.expect(stop.x >= r.send.x + r.send.w - EPS); + // Field kept left of the icon cell (no horizontal gutter / crush). + try t.expect(r.field.x + r.field.w <= r.send.x + EPS); + // The SMOKE-SIGNAL: the trailing ■ takes its FULL touch cell (TOUCH_H + 2·10) + // — the pre-#779 crush made stop.w collapse to ~28 logical and stopRight + // land exactly ON win.w. Now it must hold the whole ≥TOUCH_H content cell + // AND keep a real gutter off the right edge (never flush). + const cell_w = composer_chrome.iconCellW(); + const gap = win.w - (stop.x + stop.w); + try t.expect(gap >= (2 * EPS)); // strictly on-canvas, not flush + try t.expect(stop.w >= cell_w * PX * 0.8); // not crushed below ~80% cell +} + +test "plan #779: busy at ~390 px, empty queue → ■ fully on-canvas with slack" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = WIN_NARROW, .h = 400 } }); + defer tr.deinit(); + resetBuf(); + T_busy = true; + T_want_focus = true; + try assertBusyOnCanvasW(WIN_NARROW); +} + +test "plan #779: idle at ~390 px → ▶ fully on-canvas (no #737 regression)" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = WIN_NARROW, .h = 400 } }); + defer tr.deinit(); + resetBuf(); + T_busy = false; + T_want_focus = true; + const r = paintAndGetRectsW(WIN_NARROW); + const win = dvui.windowRectPixels(); + // Goals 3 — idle ▶ stays on-canvas at phone width + keeps a real gutter. + try t.expect(r.send.x + r.send.w <= win.w); + try t.expect((win.w - (r.send.x + r.send.w)) >= 2 * EPS); + try t.expect(r.field.x + r.field.w <= r.send.x + EPS); +} + +test "plan #779: busy at ~390 px keeps ■ on-canvas even pasted an unbreakable line" { + var tr = try dvui.testing.init(.{ .window_size = .{ .w = WIN_NARROW, .h = 400 } }); + defer tr.deinit(); + resetBuf(); + // Adversarial mirror of the #734 test at phone width: a 200×'A' run with no + // spaces must not slide ■ off-canvas even when the field is maxed. + @memset(&T_buf, 'A'); + T_buf[200] = 0; + T_busy = true; + T_want_focus = true; + try assertBusyOnCanvasW(WIN_NARROW); +} + test "multi-line prompt: measured outer height grows (dynamic hug intact through extraction)" { var tr = try dvui.testing.init(.{}); defer tr.deinit(); diff --git a/native/harness/src/ui/composer_chrome.zig b/native/harness/src/ui/composer_chrome.zig index 067ff0c..13b72ff 100644 --- a/native/harness/src/ui/composer_chrome.zig +++ b/native/harness/src/ui/composer_chrome.zig @@ -13,8 +13,12 @@ //! that to the field and the trailing ▶/■ icon pack gets leftover ≈ 0 — the //! icons slide/crush off-canvas before the chrome grows up. Fix: give the field //! an explicit trailing-*reserved* sub-rect of width `fieldW(avail_w, busy) = -//! avail_w − (TOUCH_H×n + TE_MARGIN_RIGHT)` so its own reported min width can -//! never push past the reserved icon columns. The reserved field wrapper +//! avail_w − (n × iconCellW + TE_MARGIN_RIGHT)` so its own reported min width +//! can never push past the reserved icon columns. Plan #779 made `iconCellW` +//! the FULL button footprint (`TOUCH_H + 2·(padding + margin)`), because the +//! reserve counting only `TOUCH_H` per icon let the real tag rects overrun the +//! row and dvui crushed the trailing ■ flush into the right edge at ~390 px. +//! The reserved field wrapper //! reports exactly that fixed width to the outer box (`max_size_content.w = //! field_w` clamps its reported min in `WidgetData.minSizeSetAndRefresh`), so //! the icons always land post-reserve at `TOUCH_H` and never leave the viewport. @@ -31,9 +35,24 @@ const chrome = @import("chrome.zig"); /// Right margin on the composer textEntry field (`.margin.w = 8`). Folded into /// the reserved trailing icon-pack width so the reserve exactly fits -/// `field + margin + TOUCH_H×n` and the icons get their full square. +/// `field + margin + icon-footprint×n` and the icons get their full cell. pub const TE_MARGIN_RIGHT: f32 = 8; +/// Per-side chrome on each trailing ▶/■ button that boxes in the TOUCH_H +/// content square: dvui default ButtonWidget padding (6) + default margin (4). +/// A button's FULL tag-rect width — the thing every goal asserts stays on-canvas +/// ("tag rects include the widget's margin") — is `TOUCH_H + 2·ICON_EDGE_W`, +/// NOT `TOUCH_H`. Plan #779 definite root cause: the pre-#779 reserve counted +/// only `TOUCH_H` per icon, so the real `TOUCH_H + 20` footprint overran the +/// row and dvui's compactor crushed the trailing ■ flush into the right edge at +/// ~390 px (Stop un-hittable). We reserve the FULL footprint and pin the button +/// padding/margin to these constants below so the arithmetic and the widget +/// options can never drift apart. +pub const ICON_PAD: f32 = 6; +pub const ICON_MARGIN: f32 = 4; +/// One side of a button's chrome (padding + margin) added on each edge. +pub const ICON_EDGE_W: f32 = ICON_PAD + ICON_MARGIN; + // id namespace for the composer chrome widgets — never aliases message-loop // rows or other ui/* widgets (see busy_row.zig / rect_spinner.zig for the same // high-id pattern). @@ -75,13 +94,22 @@ pub const Result = struct { focused: bool = false, }; -/// Width of the trailing icon squares themselves (n = 1 idle, 2 busy). +/// Full footprint of ONE trailing icon button = content square + both-side +/// button chrome (default padding 6 + margin 4 each edge). This is the width +/// that actually lands in the row and which every on-canvas goal measures. +pub fn iconCellW() f32 { + return metrics.TOUCH_H + 2 * ICON_EDGE_W; +} + +/// Width of the trailing icon pack (n = 1 idle, 2 busy) in FULL button +/// footprints — the reserve must cover the real tag rects, not just the +/// TOUCH_H content square (plan #779 root cause). pub fn iconPackW(busy: bool) f32 { - return (if (busy) @as(f32, 2) else @as(f32, 1)) * metrics.TOUCH_H; + return (if (busy) @as(f32, 2) else @as(f32, 1)) * iconCellW(); } -/// Reserved trailing icon-pack width = icon squares + the field's 8 px right -/// margin. This is what the field must never exceed. +/// Reserved trailing icon-pack width = full button footprints + the field's +/// 8 px right margin. This is what the field must never exceed. pub fn iconReserveW(busy: bool) f32 { return iconPackW(busy) + TE_MARGIN_RIGHT; } @@ -188,6 +216,13 @@ pub fn paintComposerChrome(opts: struct { .style = .highlight, .font = chrome.composerIconFont(), .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, + // Full-footprint reserve (plan #779): pin each button's + // padding/margin to ICON_PAD/ICON_MARGIN so the trailing icon + // cell is EXACTLY `TOUCH_H + 2*ICON_EDGE_W` — the same value + // iconCellW() reserves. Without this the real (default) button + // footprint overran the reserve and crushed ■ off-canvas. + .padding = .{ .x = ICON_PAD, .y = ICON_PAD, .w = ICON_PAD, .h = ICON_PAD }, + .margin = .{ .x = ICON_MARGIN, .y = ICON_MARGIN, .w = ICON_MARGIN, .h = ICON_MARGIN }, .corners = .round(8), })) { if (res.typed.len > 0) { @@ -201,6 +236,13 @@ pub fn paintComposerChrome(opts: struct { .style = .content, .font = chrome.composerIconFont(), .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, + // Full-footprint reserve (plan #779): pin each button's + // padding/margin to ICON_PAD/ICON_MARGIN so the trailing icon + // cell is EXACTLY `TOUCH_H + 2*ICON_EDGE_W` — the same value + // iconCellW() reserves. Without this the real (default) button + // footprint overran the reserve and crushed ■ off-canvas. + .padding = .{ .x = ICON_PAD, .y = ICON_PAD, .w = ICON_PAD, .h = ICON_PAD }, + .margin = .{ .x = ICON_MARGIN, .y = ICON_MARGIN, .w = ICON_MARGIN, .h = ICON_MARGIN }, .corners = .round(8), .color_fill = palette.warm_bg, .color_text = palette.warm_accent, @@ -215,6 +257,10 @@ pub fn paintComposerChrome(opts: struct { .style = .highlight, .font = chrome.composerIconFont(), .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, + // Full-footprint reserve (plan #779) — see the busy branches: the + // trailing icon cell is TOUCH_H + 2*ICON_EDGE_W, never TOUCH_H. + .padding = .{ .x = ICON_PAD, .y = ICON_PAD, .w = ICON_PAD, .h = ICON_PAD }, + .margin = .{ .x = ICON_MARGIN, .y = ICON_MARGIN, .w = ICON_MARGIN, .h = ICON_MARGIN }, .corners = .round(8), })) { if (res.typed.len > 0) { From a16cd2f83e033ebd3aa67130bddfe2c706e86035 Mon Sep 17 00:00:00 2001 From: btipling Date: Sat, 22 Aug 2026 19:37:41 +0000 Subject: [PATCH 2/3] address_pr_feedback #784: correct plan-#782 citations + stale TOUCH_H formulas and tighten the ~390px cell lock --- docs/harness-limits.md | 8 ++++---- native/harness/src/composer_layout.test.zig | 21 +++++++++++++-------- native/harness/src/ui/composer_chrome.zig | 17 +++++++++-------- 3 files changed, 26 insertions(+), 20 deletions(-) diff --git a/docs/harness-limits.md b/docs/harness-limits.md index bcbb254..832925e 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -70,7 +70,7 @@ closed). | Topic | Behavior | |-------|----------| -| Layout | Full-bleed canvas under host nav; no horizontal overflow expected. Trailing ▶ / ■ stay **fully on-canvas at ~390 px** and wider (left rail open or closed), including with the queue-band Pause/Clear header visible (plan #779) | +| Layout | Full-bleed canvas under host nav; no horizontal overflow expected. Trailing ▶ / ■ stay **fully on-canvas at ~390 px** and wider (left rail open or closed), including with the queue-band Pause/Clear header visible (plan #782) | | Hit targets | Send / message **📋** (clipboard / copy) ≥ ~40px tall | | Fallback | No “use the DOM chat instead” product path | @@ -81,15 +81,15 @@ Vertical bands inside the Wasm root (not a DOM panel): | Band | Behavior | |------|----------| | **Transcript** | Horizontal pair inside the leftover band from the canvas top to the composer: a collapsible **left rail** (closed = 40 px icon strip; open = 220 px TEAL column with the session list, scroll inside the rail) plus the `scrollArea`. Row labels are pixel-ellipsized to the 220 px column (UTF-8, trailing `…`). The auto vertical bar sits on the **canvas right edge**. No pane pad, no card fill/border on the scroller. Rail height always equals the transcript band (`scroll_h`) — it shrinks when the composer grows up. | -| **Composer chrome** | Single-row text field + trailing **icon-only** button(s): idle = one ▶ Send; Busy = ▶ enqueue + ■ Stop. Dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes the **height** padding into min/max — **height** sizes are passed minus 2×pad so the 44/124 chrome caps hold; the width max is raw `field_w`, never pad-shortened). Icons are bottom-pinned (`gravity_y = 1.0`) so they stay on the field baseline at all heights. The trailing ▶/■ strip reserves each button's **full footprint** (`TOUCH_H + 2·(pad+margin)`, plan #779) so the icons never crush off-canvas at phone widths. A **submit-queue band** (header + up to 3 rows) sits **above** the composer when the queue is non-empty; the composer stays glued to the status bar | +| **Composer chrome** | Single-row text field + trailing **icon-only** button(s): idle = one ▶ Send; Busy = ▶ enqueue + ■ Stop. Dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes the **height** padding into min/max — **height** sizes are passed minus 2×pad so the 44/124 chrome caps hold; the width max is raw `field_w`, never pad-shortened). Icons are bottom-pinned (`gravity_y = 1.0`) so they stay on the field baseline at all heights. The trailing ▶/■ strip reserves each button's **full footprint** (`TOUCH_H + 2·(pad+margin)`, plan #782) so the icons never crush off-canvas at phone widths. A **submit-queue band** (header + up to 3 rows) sits **above** the composer when the queue is non-empty; the composer stays glued to the status bar | | **Status bar** | **Two-line** always-mounted full-width strip **below the composer**: line 1 = identity (spinner (always kept) · `h:{build-id}` (drops first on narrow viewport) · model menu (pixel-ellipsized after build-id drop); plan #695), line 2 = status-slot pack (sandbox · cwd · git · context); fixed `STATUS_BAR_H` = 64 px, never collapses (plan #570, merged from header per plan #555 → #554) | | Rule | Behavior | |------|----------| | Composer visibility | Fully on-canvas while the harness is ready; not optional | | Height budget | Every frame: absolute-rect bands — the transcript band is **`[left rail \| scrollArea]`** from the canvas top to the queue band (or the composer when the queue is empty). Height = viewport − dynamic composer `composer_h` − optional queue band − status bar `STATUS_BAR_H`; no inter-band gap. The rail is a sibling `Options.rect` of the scroller (closed 40 px, open 220 px TEAL session-list column); the scroller’s `Options.rect.x` is the rail width so both share `scroll_h`. Composer sits above the status bar with dynamic height from previous-frame measurement (idle ~44 px, max 124 px), full canvas width. The queue band sits **above** the composer and does **not** move it. Status bar absolute-rect flush to the canvas bottom, full width. Transcript scroller **and** composer use `Options.rect` so neither participates in root flex; the scrollArea's `.auto` bar cannot publish virtual content height as min-size (dvui `ScrollContainerWidget.deinit` overwrites `min_size.h` with full content). Tall content cannot push chrome off-canvas | -| Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — the field's `min_size_content` / `max_size_content` are passed minus 2×pad on the **height axis only** (`TOUCH_H − 2×pad`, `COMPOSER_INPUT_MAX_H − 2×pad`) because `TextEntryWidget.init` bakes the padding in; the **width** is passed raw (`min 120`, `max field_w`) — it is already the leftover after the reserved strip, so it must **not** be pad-shortened or the wrap would shrink 10 px inside the reserve. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737/#779):** the field's `max_size_content.w` is `field_w = avail_w − (n×iconCellW + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin), where `iconCellW = TOUCH_H + 2·(pad 6 + margin 4)` is each button's **full tag-rect footprint** — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. Plan #779 root-caused why a `TOUCH_H`-only reserve still crushed the trailing ■ flush to the viewport edge at ~390 px: the button's real footprint includes dvui's default padding (6) + margin (4) per side, so the reserve now counts the FULL footprint and the button options pin `padding`/`margin` to the same `ICON_PAD`/`ICON_MARGIN` constants the arithmetic uses. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | -| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `n × (TOUCH_H + 2·(pad 6 + margin 4)) + 8` px (plan #737/#734/#779): the reserve counts each button's **full tag-rect footprint** (content `TOUCH_H` + default padding + margin per side), and the field is width-bounded to the leftover after that reserve, so the icons always keep their full square and are never squeezed — or crushed flush to the edge — off-canvas by a long unbreakable line, even on a **~390 px phone-width canvas while Busy (▶ enqueue + ■ Stop)** with the queue Pause/Clear header visible — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty; **an empty composer + non-empty queue promotes the head** — explicit Play). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | +| Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — the field's `min_size_content` / `max_size_content` are passed minus 2×pad on the **height axis only** (`TOUCH_H − 2×pad`, `COMPOSER_INPUT_MAX_H − 2×pad`) because `TextEntryWidget.init` bakes the padding in; the **width** is passed raw (`min 120`, `max field_w`) — it is already the leftover after the reserved strip, so it must **not** be pad-shortened or the wrap would shrink 10 px inside the reserve. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737/#782):** the field's `max_size_content.w` is `field_w = avail_w − (n×iconCellW + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin), where `iconCellW = TOUCH_H + 2·(pad 6 + margin 4)` is each button's **full tag-rect footprint** — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. Plan #782 root-caused why a `TOUCH_H`-only reserve still crushed the trailing ■ flush to the viewport edge at ~390 px: the button's real footprint includes dvui's default padding (6) + margin (4) per side, so the reserve now counts the FULL footprint and the button options pin `padding`/`margin` to the same `ICON_PAD`/`ICON_MARGIN` constants the arithmetic uses. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | +| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `n × (TOUCH_H + 2·(pad 6 + margin 4)) + 8` px (plan #737/#734/#782): the reserve counts each button's **full tag-rect footprint** (content `TOUCH_H` + default padding + margin per side), and the field is width-bounded to the leftover after that reserve, so the icons always keep their full square and are never squeezed — or crushed flush to the edge — off-canvas by a long unbreakable line, even on a **~390 px phone-width canvas while Busy (▶ enqueue + ■ Stop)** with the queue Pause/Clear header visible — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty; **an empty composer + non-empty queue promotes the head** — explicit Play). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | | Turn clock | Whole-turn **`mm:ss`** is painted **in-canvas** by the Wasm busy row (`Waiting for model… · 0:42`), protocol **v14** (`inv_set_turn_elapsed`). The **DOM host** owns the only reliable wall-clock (no WASI clock in Wasm): its ~1 Hz Busy effect pushes the elapsed seconds to the bridge (`HarnessBridge.setTurnElapsed` → `inv_set_turn_elapsed`), reset to 0 on Ready/Stop/error so no bare `0:00` lingers. The clock is client wall-time from turn start — **not** provider `usage` duration. See [feature-divide.md](feature-divide.md) | | Busy spinner | Two instances of a **2×4 rectangle grid**: (a) **transcript busy row** — a **WARM rectangle grid** paints **left of** `Waiting for model…` on a **full-width `teal_bg` bar**; while Busy the waiting copy runs a **left-to-right cyclic WARM color wave** (same 10 Hz phase as the spinner) via `text_wave.zig` — a **localized directed comet** behind the traveling head: the head scalar is `warm_accent`, the next 2 scalars behind it are `warm_muted`, the next 1 is `warm_border`, and the **rest of the line stays `warm_accent`** (readable; `warm_surface` #1a120c is ~1:1 on `teal_bg` and reserved for rect_spinner off-cells). The wave doubles its speed via `SPEED=2` — **~2.7 s full cycle** at 10 Hz (`N*STEPS/SPEED` ticks). The wave is painted inside the same textLayout as the ` · mm:ss` clock (no separate clock textLayout). (b) **status-bar line 1** — during **Busy** the same WARM pulse runs; during **idle** (Ready / boot / error) all 8 cells paint `teal_muted` as a static, subtle grid. The grid is **4×4 px cells** with **2 px sibling gaps** (inner 10×22), centered by equal pad (**1.5 / 3.5**) inside a reserved **13×29 slot**; the slot and the **10 px `TRAIL`** before the waiting copy do not move. The pulse is a **clockwise loop**: left column **bottom→top**, right column **top→bottom**. The **DOM host** feeds the pulse phase at **`HARNESS_BUSY_TICK_HZ` = 10 Hz while Busy** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`). **NEW cap:** 10 Hz while Busy, **0 otherwise** — well below the dvui 60 fps ceiling, host-local `setInterval` (no transport), turns transient. Pulse is pure Wasm LUT paint (`native/harness/src/busy_spinner.zig` / `rect_spinner.zig` / `text_wave.zig`), **no I/O / alloc in the frame path**. Each `setBusyTick` triggers a full dvui `refresh()` (re-layout + repaint) at up to 10 Hz while Busy vs 1 Hz today — the 10 Hz bound is the lock, not a cached-redraw claim. **Reduced motion:** host skips tick pushes → `busy_tick` stays 0 (reserved sentinel). Both grids static (spinner head **bottom-left**; wave all `warm_accent` — the phase-0 fast-path in `text_wave.zig` paints the whole string at `ramp[0]`). The bridge stores the host's monotonic `u32` tick as-is; `0` is reserved for idle / reduced motion / busy-start. The comet wraps only on `N*STEPS` (visible loop), not on a u8 fold. The live `mm:ss` **clock feed keeps running** (solid `warm_accent`, never waves). Old host + new Wasm: `busy_tick` stays 0 → static grids + static wave (graceful). New host + old Wasm: `inv_set_busy_tick` is in `REQUIRED_FNS`, so a stale build fails closed at load | | Short canvas | Transcript shrinks / scrolls first — chrome keeps touch-sized targets (~40px). `SCROLL_FLOOR_H` (32 px) prevents the transcript from collapsing to zero on absurdly short canvases | diff --git a/native/harness/src/composer_layout.test.zig b/native/harness/src/composer_layout.test.zig index 37ca5e8..5f2a126 100644 --- a/native/harness/src/composer_layout.test.zig +++ b/native/harness/src/composer_layout.test.zig @@ -1,7 +1,7 @@ //! Host dvui testing-backend layout-rect tests for `composer_chrome.zig` //! (plan #737, source #734). Lock the width-reservation geometry from the REAL //! painted widget tree — the field lives on an explicit trailing-*reserved* -//! sub-rect `field_w = avail_w − (TOUCH_H×n + 8)`, so a long unbreakable line's +//! sub-rect `field_w = avail_w − (n × iconCellW() + 8)`, so a long unbreakable line's //! reported min width can never squeeze the ▶/■ icon pack off-canvas (mirrors //! the `busy_row_layout.test.zig` pattern — drives `paintComposerChrome` the //! same way that test drives `paintBusyRow`). @@ -59,7 +59,7 @@ fn paintAndGetRects() FieldRects { return paintAndGetRectsW(WIN_LW); } -/// Width-parameterized variant (plan #779 — the #734/#737 lock was only ever +/// Width-parameterized variant (plan #782 — the #734/#737 lock was only ever /// proven at WIN_LW=600; a ~390 px canvas row would silently slip ■ past the /// viewport). `avail_w` is the logical composer row width handed to the paint. fn paintAndGetRectsW(avail_w: f32) FieldRects { @@ -235,7 +235,7 @@ test "busy + #734 lock: a long unbreakable line during Busy keeps ▶ + ■ on-c try t.expect(stop.x + stop.w <= win.w + EPS); } -// ── Plan #779 — ~390 px canvas row (operator report: Busy ■ Stop crushed / +// ── Plan #782 — ~390 px canvas row (operator report: Busy ■ Stop crushed / // ── off the right edge). The #734/#737 lock was only ever proven at // WIN_LW=600; on a phone width the FULL button footprint (TOUCH_H + 2·(pad + // margin)) overran a TOUCH_H-only reserve and dvui squeezed the trailing ■ to @@ -245,7 +245,7 @@ test "busy + #734 lock: a long unbreakable line during Busy keeps ▶ + ■ on-c const WIN_NARROW: f32 = 390; /// Paint busy chrome at `avail_w` and assert every success-predicate from the -/// plan #779 goals: ■ and ▶ fully on-canvas, ▶ left of ■, and the field kept +/// plan #782 goals: ■ and ▶ fully on-canvas, ▶ left of ■, and the field kept /// left of the icons. Returns whether ■ had a comfortable right gutter. fn assertBusyOnCanvasW(avail_w: f32) !void { const r = paintAndGetRectsW(avail_w); @@ -267,10 +267,15 @@ fn assertBusyOnCanvasW(avail_w: f32) !void { const cell_w = composer_chrome.iconCellW(); const gap = win.w - (stop.x + stop.w); try t.expect(gap >= (2 * EPS)); // strictly on-canvas, not flush - try t.expect(stop.w >= cell_w * PX * 0.8); // not crushed below ~80% cell + // Not crushed below ~90% of the full cell (physical px). The pre-#779 + // crush collapsed stop.w to ~28 logical (~47% cell); 0.9 still catches a + // 10% shrink while leaving room for dvui's subpixel/font fudge — a tighter + // lock than 0.8 without risking a false fail on rounding. (`gap >= 2*EPS` + // above is the real "not flush" assertion; this guards the cell width.) + try t.expect(stop.w >= cell_w * PX * 0.9); } -test "plan #779: busy at ~390 px, empty queue → ■ fully on-canvas with slack" { +test "plan #782: busy at ~390 px, empty queue → ■ fully on-canvas with slack" { var tr = try dvui.testing.init(.{ .window_size = .{ .w = WIN_NARROW, .h = 400 } }); defer tr.deinit(); resetBuf(); @@ -279,7 +284,7 @@ test "plan #779: busy at ~390 px, empty queue → ■ fully on-canvas with slack try assertBusyOnCanvasW(WIN_NARROW); } -test "plan #779: idle at ~390 px → ▶ fully on-canvas (no #737 regression)" { +test "plan #782: idle at ~390 px → ▶ fully on-canvas (no #737 regression)" { var tr = try dvui.testing.init(.{ .window_size = .{ .w = WIN_NARROW, .h = 400 } }); defer tr.deinit(); resetBuf(); @@ -293,7 +298,7 @@ test "plan #779: idle at ~390 px → ▶ fully on-canvas (no #737 regression)" { try t.expect(r.field.x + r.field.w <= r.send.x + EPS); } -test "plan #779: busy at ~390 px keeps ■ on-canvas even pasted an unbreakable line" { +test "plan #782: busy at ~390 px keeps ■ on-canvas even pasted an unbreakable line" { var tr = try dvui.testing.init(.{ .window_size = .{ .w = WIN_NARROW, .h = 400 } }); defer tr.deinit(); resetBuf(); diff --git a/native/harness/src/ui/composer_chrome.zig b/native/harness/src/ui/composer_chrome.zig index 13b72ff..354a9dc 100644 --- a/native/harness/src/ui/composer_chrome.zig +++ b/native/harness/src/ui/composer_chrome.zig @@ -14,14 +14,15 @@ //! icons slide/crush off-canvas before the chrome grows up. Fix: give the field //! an explicit trailing-*reserved* sub-rect of width `fieldW(avail_w, busy) = //! avail_w − (n × iconCellW + TE_MARGIN_RIGHT)` so its own reported min width -//! can never push past the reserved icon columns. Plan #779 made `iconCellW` +//! can never push past the reserved icon columns. Plan #782 made `iconCellW` //! the FULL button footprint (`TOUCH_H + 2·(padding + margin)`), because the //! reserve counting only `TOUCH_H` per icon let the real tag rects overrun the //! row and dvui crushed the trailing ■ flush into the right edge at ~390 px. //! The reserved field wrapper //! reports exactly that fixed width to the outer box (`max_size_content.w = //! field_w` clamps its reported min in `WidgetData.minSizeSetAndRefresh`), so -//! the icons always land post-reserve at `TOUCH_H` and never leave the viewport. +//! the icons always land post-reserve at their full `iconCellW()` cell and +//! never leave the viewport. //! //! The wrapper stays a *packed* child: default `gravity.x = 0` is NOT //! `child_positioned` (BoxWidget only overlays when `gravity.x` is strictly @@ -42,7 +43,7 @@ pub const TE_MARGIN_RIGHT: f32 = 8; /// content square: dvui default ButtonWidget padding (6) + default margin (4). /// A button's FULL tag-rect width — the thing every goal asserts stays on-canvas /// ("tag rects include the widget's margin") — is `TOUCH_H + 2·ICON_EDGE_W`, -/// NOT `TOUCH_H`. Plan #779 definite root cause: the pre-#779 reserve counted +/// NOT `TOUCH_H`. Plan #782 definite root cause: the pre-#779 reserve counted /// only `TOUCH_H` per icon, so the real `TOUCH_H + 20` footprint overran the /// row and dvui's compactor crushed the trailing ■ flush into the right edge at /// ~390 px (Stop un-hittable). We reserve the FULL footprint and pin the button @@ -103,7 +104,7 @@ pub fn iconCellW() f32 { /// Width of the trailing icon pack (n = 1 idle, 2 busy) in FULL button /// footprints — the reserve must cover the real tag rects, not just the -/// TOUCH_H content square (plan #779 root cause). +/// TOUCH_H content square (plan #782 root cause). pub fn iconPackW(busy: bool) f32 { return (if (busy) @as(f32, 2) else @as(f32, 1)) * iconCellW(); } @@ -152,7 +153,7 @@ pub fn paintComposerChrome(opts: struct { // (min=max=field_w — max_size_content caps the reported min in // WidgetData.minSizeSetAndRefresh), so the box's leftover math can // never give the field more than `avail_w − icon_reserve_w` and the - // icons always land post-reserve at TOUCH_H. The wrapper must stay a + // icons always land post-reserve at their full cell. The wrapper must stay a // PACKED child (gravity.x = 0, not centered) or BoxWidget treats it as // an overlay and overlaps the icons instead of compacting. { @@ -216,7 +217,7 @@ pub fn paintComposerChrome(opts: struct { .style = .highlight, .font = chrome.composerIconFont(), .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, - // Full-footprint reserve (plan #779): pin each button's + // Full-footprint reserve (plan #782): pin each button's // padding/margin to ICON_PAD/ICON_MARGIN so the trailing icon // cell is EXACTLY `TOUCH_H + 2*ICON_EDGE_W` — the same value // iconCellW() reserves. Without this the real (default) button @@ -236,7 +237,7 @@ pub fn paintComposerChrome(opts: struct { .style = .content, .font = chrome.composerIconFont(), .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, - // Full-footprint reserve (plan #779): pin each button's + // Full-footprint reserve (plan #782): pin each button's // padding/margin to ICON_PAD/ICON_MARGIN so the trailing icon // cell is EXACTLY `TOUCH_H + 2*ICON_EDGE_W` — the same value // iconCellW() reserves. Without this the real (default) button @@ -257,7 +258,7 @@ pub fn paintComposerChrome(opts: struct { .style = .highlight, .font = chrome.composerIconFont(), .min_size_content = .{ .w = metrics.TOUCH_H, .h = metrics.TOUCH_H }, - // Full-footprint reserve (plan #779) — see the busy branches: the + // Full-footprint reserve (plan #782) — see the busy branches: the // trailing icon cell is TOUCH_H + 2*ICON_EDGE_W, never TOUCH_H. .padding = .{ .x = ICON_PAD, .y = ICON_PAD, .w = ICON_PAD, .h = ICON_PAD }, .margin = .{ .x = ICON_MARGIN, .y = ICON_MARGIN, .w = ICON_MARGIN, .h = ICON_MARGIN }, From 59f80ceb7fa4da56ccbe9ed4e43a10c930d2b441 Mon Sep 17 00:00:00 2001 From: btipling Date: Sat, 22 Aug 2026 20:09:25 +0000 Subject: [PATCH 3/3] address_pr_feedback #784: drop untested queue-band on-canvas overclaim + plan-#782 theater from living docs (L8 Nit, round 2) --- docs/harness-limits.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/harness-limits.md b/docs/harness-limits.md index 832925e..88d27a3 100644 --- a/docs/harness-limits.md +++ b/docs/harness-limits.md @@ -70,7 +70,7 @@ closed). | Topic | Behavior | |-------|----------| -| Layout | Full-bleed canvas under host nav; no horizontal overflow expected. Trailing ▶ / ■ stay **fully on-canvas at ~390 px** and wider (left rail open or closed), including with the queue-band Pause/Clear header visible (plan #782) | +| Layout | Full-bleed canvas under host nav; no horizontal overflow expected. Trailing ▶ / ■ stay **fully on-canvas at ~390 px** and wider (left rail open or closed) | | Hit targets | Send / message **📋** (clipboard / copy) ≥ ~40px tall | | Fallback | No “use the DOM chat instead” product path | @@ -81,15 +81,15 @@ Vertical bands inside the Wasm root (not a DOM panel): | Band | Behavior | |------|----------| | **Transcript** | Horizontal pair inside the leftover band from the canvas top to the composer: a collapsible **left rail** (closed = 40 px icon strip; open = 220 px TEAL column with the session list, scroll inside the rail) plus the `scrollArea`. Row labels are pixel-ellipsized to the 220 px column (UTF-8, trailing `…`). The auto vertical bar sits on the **canvas right edge**. No pane pad, no card fill/border on the scroller. Rail height always equals the transcript band (`scroll_h`) — it shrinks when the composer grows up. | -| **Composer chrome** | Single-row text field + trailing **icon-only** button(s): idle = one ▶ Send; Busy = ▶ enqueue + ■ Stop. Dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes the **height** padding into min/max — **height** sizes are passed minus 2×pad so the 44/124 chrome caps hold; the width max is raw `field_w`, never pad-shortened). Icons are bottom-pinned (`gravity_y = 1.0`) so they stay on the field baseline at all heights. The trailing ▶/■ strip reserves each button's **full footprint** (`TOUCH_H + 2·(pad+margin)`, plan #782) so the icons never crush off-canvas at phone widths. A **submit-queue band** (header + up to 3 rows) sits **above** the composer when the queue is non-empty; the composer stays glued to the status bar | +| **Composer chrome** | Single-row text field + trailing **icon-only** button(s): idle = one ▶ Send; Busy = ▶ enqueue + ■ Stop. Dynamic height from previous-frame measurement: idle hugs one line (~44 px), grows up to cap (124 px) then scrolls internally. TextEntry glyphs inset **5 px** from the field border (`COMPOSER_TE_PAD`; dvui bakes the **height** padding into min/max — **height** sizes are passed minus 2×pad so the 44/124 chrome caps hold; the width max is raw `field_w`, never pad-shortened). Icons are bottom-pinned (`gravity_y = 1.0`) so they stay on the field baseline at all heights. The trailing ▶/■ strip reserves each button's **full footprint** (`TOUCH_H + 2·(pad+margin)`) so the icons never crush off-canvas at phone widths. A **submit-queue band** (header + up to 3 rows) sits **above** the composer when the queue is non-empty; the composer stays glued to the status bar | | **Status bar** | **Two-line** always-mounted full-width strip **below the composer**: line 1 = identity (spinner (always kept) · `h:{build-id}` (drops first on narrow viewport) · model menu (pixel-ellipsized after build-id drop); plan #695), line 2 = status-slot pack (sandbox · cwd · git · context); fixed `STATUS_BAR_H` = 64 px, never collapses (plan #570, merged from header per plan #555 → #554) | | Rule | Behavior | |------|----------| | Composer visibility | Fully on-canvas while the harness is ready; not optional | | Height budget | Every frame: absolute-rect bands — the transcript band is **`[left rail \| scrollArea]`** from the canvas top to the queue band (or the composer when the queue is empty). Height = viewport − dynamic composer `composer_h` − optional queue band − status bar `STATUS_BAR_H`; no inter-band gap. The rail is a sibling `Options.rect` of the scroller (closed 40 px, open 220 px TEAL session-list column); the scroller’s `Options.rect.x` is the rail width so both share `scroll_h`. Composer sits above the status bar with dynamic height from previous-frame measurement (idle ~44 px, max 124 px), full canvas width. The queue band sits **above** the composer and does **not** move it. Status bar absolute-rect flush to the canvas bottom, full width. Transcript scroller **and** composer use `Options.rect` so neither participates in root flex; the scrollArea's `.auto` bar cannot publish virtual content height as min-size (dvui `ScrollContainerWidget.deinit` overwrites `min_size.h` with full content). Tall content cannot push chrome off-canvas | -| Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — the field's `min_size_content` / `max_size_content` are passed minus 2×pad on the **height axis only** (`TOUCH_H − 2×pad`, `COMPOSER_INPUT_MAX_H − 2×pad`) because `TextEntryWidget.init` bakes the padding in; the **width** is passed raw (`min 120`, `max field_w`) — it is already the leftover after the reserved strip, so it must **not** be pad-shortened or the wrap would shrink 10 px inside the reserve. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737/#782):** the field's `max_size_content.w` is `field_w = avail_w − (n×iconCellW + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin), where `iconCellW = TOUCH_H + 2·(pad 6 + margin 4)` is each button's **full tag-rect footprint** — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. Plan #782 root-caused why a `TOUCH_H`-only reserve still crushed the trailing ■ flush to the viewport edge at ~390 px: the button's real footprint includes dvui's default padding (6) + margin (4) per side, so the reserve now counts the FULL footprint and the button options pin `padding`/`margin` to the same `ICON_PAD`/`ICON_MARGIN` constants the arithmetic uses. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | -| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `n × (TOUCH_H + 2·(pad 6 + margin 4)) + 8` px (plan #737/#734/#782): the reserve counts each button's **full tag-rect footprint** (content `TOUCH_H` + default padding + margin per side), and the field is width-bounded to the leftover after that reserve, so the icons always keep their full square and are never squeezed — or crushed flush to the edge — off-canvas by a long unbreakable line, even on a **~390 px phone-width canvas while Busy (▶ enqueue + ■ Stop)** with the queue Pause/Clear header visible — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty; **an empty composer + non-empty queue promotes the head** — explicit Play). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | +| Wrap / grow | Field is **`break_lines`** + grows **vertically** with wrapped lines up to `COMPOSER_INPUT_MAX_H` (120 px) then scrolls **inside** the entry. Composer chrome box uses a dynamic absolute rect from previous-frame measurement (`composer_last_h`): idle = `COMPOSER_IDLE_CHROME_H` (44 px = TOUCH_H + 2×HUG_PAD), max = `COMPOSER_MAX_CHROME_H` (124 px). Glyphs sit `COMPOSER_TE_PAD` (5 px) inside the field stroke — the field's `min_size_content` / `max_size_content` are passed minus 2×pad on the **height axis only** (`TOUCH_H − 2×pad`, `COMPOSER_INPUT_MAX_H − 2×pad`) because `TextEntryWidget.init` bakes the padding in; the **width** is passed raw (`min 120`, `max field_w`) — it is already the leftover after the reserved strip, so it must **not** be pad-shortened or the wrap would shrink 10 px inside the reserve. The textEntry hugs one line when idle, grows up when multi-line (one-frame settle lag), and scrolls internally past 120 px. `scroll_horizontal = false` on the textEntry so the inner `ScrollContainerWidget` gives the child a finite viewport width (`.none` → child gets `viewport.w`, not the unwrapped `min_size.w`), letting `break_lines` fire at the field width instead of h-scrolling. **`#734` width bound (plan #737):** the field's `max_size_content.w` is `field_w = avail_w − (n×iconCellW + 8)` — the leftover after a **reserved** trailing icon pack (n = 1 idle / 2 busy; the 8 is the field's right margin), where `iconCellW = TOUCH_H + 2·(pad 6 + margin 4)` is each button's **full tag-rect footprint** — and the textEntry sits on an explicit trailing-reserved sub-rect, so a long unbreakable line's natural min width can never squeeze the ▶/■ icons off-canvas. A `TOUCH_H`-only reserve still crushed the trailing ■ flush to the viewport edge at ~390 px: the button's real footprint includes dvui's default padding (6) + margin (4) per side, so the reserve counts the FULL footprint and the button options pin `padding`/`margin` to the same `ICON_PAD`/`ICON_MARGIN` constants the arithmetic uses. The reserved field wrapper reports `min=max=field_w` to the box, so the icons always land post-reserve; the field wraps at `field_w` (or clips inside via `scroll_horizontal = false`). The Send/Stop icons at `gravity_y = 1.0` stay bottom-pinned on the field baseline (adversarial review #584 Round 2 Major L1+L9). Never a horizontal gutter (repo no-h-scroll policy, #344/#457/#579) | +| Icon button | Fixed **`TOUCH_H`×`TOUCH_H`** (40 px) square(s) on the **same row** as the field, **bottom-pinned** (`gravity_y = 1.0`) so they stay glued to the status bar while the field grows up. The icons sit in a **trailing-reserved strip** `n × (TOUCH_H + 2·(pad 6 + margin 4)) + 8` px (plan #737/#734): the reserve counts each button's **full tag-rect footprint** (content `TOUCH_H` + default padding + margin per side), and the field is width-bounded to the leftover after that reserve, so the icons always keep their full square and are never squeezed — or crushed flush to the edge — off-canvas by a long unbreakable line, even on a **~390 px phone-width canvas while Busy (▶ enqueue + ■ Stop)** — the field **yields** to the reserve, never the icon. Idle = one ▶ Send (submit when non-empty; **an empty composer + non-empty queue promotes the head** — explicit Play). Busy = ▶ enqueue (left) + ■ Stop (rightmost; protocol v9 `queueCancelFromUi` → host abort). No labelled Stop/Send pill, no hint copy. Glyphs from the embedded DejaVu Sans Symbols face (no tofu) | | Turn clock | Whole-turn **`mm:ss`** is painted **in-canvas** by the Wasm busy row (`Waiting for model… · 0:42`), protocol **v14** (`inv_set_turn_elapsed`). The **DOM host** owns the only reliable wall-clock (no WASI clock in Wasm): its ~1 Hz Busy effect pushes the elapsed seconds to the bridge (`HarnessBridge.setTurnElapsed` → `inv_set_turn_elapsed`), reset to 0 on Ready/Stop/error so no bare `0:00` lingers. The clock is client wall-time from turn start — **not** provider `usage` duration. See [feature-divide.md](feature-divide.md) | | Busy spinner | Two instances of a **2×4 rectangle grid**: (a) **transcript busy row** — a **WARM rectangle grid** paints **left of** `Waiting for model…` on a **full-width `teal_bg` bar**; while Busy the waiting copy runs a **left-to-right cyclic WARM color wave** (same 10 Hz phase as the spinner) via `text_wave.zig` — a **localized directed comet** behind the traveling head: the head scalar is `warm_accent`, the next 2 scalars behind it are `warm_muted`, the next 1 is `warm_border`, and the **rest of the line stays `warm_accent`** (readable; `warm_surface` #1a120c is ~1:1 on `teal_bg` and reserved for rect_spinner off-cells). The wave doubles its speed via `SPEED=2` — **~2.7 s full cycle** at 10 Hz (`N*STEPS/SPEED` ticks). The wave is painted inside the same textLayout as the ` · mm:ss` clock (no separate clock textLayout). (b) **status-bar line 1** — during **Busy** the same WARM pulse runs; during **idle** (Ready / boot / error) all 8 cells paint `teal_muted` as a static, subtle grid. The grid is **4×4 px cells** with **2 px sibling gaps** (inner 10×22), centered by equal pad (**1.5 / 3.5**) inside a reserved **13×29 slot**; the slot and the **10 px `TRAIL`** before the waiting copy do not move. The pulse is a **clockwise loop**: left column **bottom→top**, right column **top→bottom**. The **DOM host** feeds the pulse phase at **`HARNESS_BUSY_TICK_HZ` = 10 Hz while Busy** (`HarnessBridge.setBusyTick` → additive `inv_set_busy_tick`). **NEW cap:** 10 Hz while Busy, **0 otherwise** — well below the dvui 60 fps ceiling, host-local `setInterval` (no transport), turns transient. Pulse is pure Wasm LUT paint (`native/harness/src/busy_spinner.zig` / `rect_spinner.zig` / `text_wave.zig`), **no I/O / alloc in the frame path**. Each `setBusyTick` triggers a full dvui `refresh()` (re-layout + repaint) at up to 10 Hz while Busy vs 1 Hz today — the 10 Hz bound is the lock, not a cached-redraw claim. **Reduced motion:** host skips tick pushes → `busy_tick` stays 0 (reserved sentinel). Both grids static (spinner head **bottom-left**; wave all `warm_accent` — the phase-0 fast-path in `text_wave.zig` paints the whole string at `ramp[0]`). The bridge stores the host's monotonic `u32` tick as-is; `0` is reserved for idle / reduced motion / busy-start. The comet wraps only on `N*STEPS` (visible loop), not on a u8 fold. The live `mm:ss` **clock feed keeps running** (solid `warm_accent`, never waves). Old host + new Wasm: `busy_tick` stays 0 → static grids + static wave (graceful). New host + old Wasm: `inv_set_busy_tick` is in `REQUIRED_FNS`, so a stale build fails closed at load | | Short canvas | Transcript shrinks / scrolls first — chrome keeps touch-sized targets (~40px). `SCROLL_FLOOR_H` (32 px) prevents the transcript from collapsing to zero on absurdly short canvases |