Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions docs/harness-limits.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,15 +67,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 padding into min/max — sizes are passed minus 2×pad so the 44/124 chrome caps hold). 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. 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 — `min_size_content` / `max_size_content` are passed minus 2×pad because `TextEntryWidget.init` bakes padding in. 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. The outer `max_size_content.w` is `max_float_safe` (stripped by `TextEntryWidget.init` before reaching the inner layout — not the wrap mechanism). The Send/Stop icon at `gravity_y = 1.0` stays 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. Idle = one ▶ Send (submit when non-empty). 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 − (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). 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 |
Expand Down
26 changes: 26 additions & 0 deletions native/harness/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,32 @@ pub fn build(b: *std.Build) void {
test_rich.dependOn(&b.addRunArtifact(busy_row_layout_tests).step);
}

// Host dvui testing-backend layout-rect tests for composer_chrome.zig
// (plan #737, source #734). Drives the REAL extracted `paintComposerChrome`
// and locks the trailing-reserved icon-pack geometry: the field is
// width-bounded to `avail_w − (TOUCH_H×n + 8)` so a long unbreakable line
// can never squeeze the ▶/■ icons off-canvas. composer_chrome imports
// chrome → rich/toolrun → bridge.zig, so the test needs BOTH dvui
// (dvui_testing) and the web-backend stub (mirrors queue_band_tests /
// composer_tests wiring).
{
const composer_layout_tests = b.addTest(.{
.name = "composer_layout",
.root_module = b.createModule(.{
.root_source_file = b.path("src/composer_layout.test.zig"),
.target = host_target,
.optimize = optimize,
}),
});
composer_layout_tests.root_module.addImport("dvui", dvui_testing_dep.module("dvui_testing"));
composer_layout_tests.root_module.addImport("web-backend", b.createModule(.{
.root_source_file = b.path("src/test_web_backend_stub.zig"),
.target = host_target,
.optimize = optimize,
}));
test_rich.dependOn(&b.addRunArtifact(composer_layout_tests).step);
}

// Host dvui testing-backend layout-rect tests for transcript_split.zig
// (empty collapsible left rail). Closed 40 / open 220 widths + toggle tag.
{
Expand Down
Loading
Loading