Skip to content

plan: reusable WARM text-wave on Waiting for model… (source #650) #655

Description

@btipling

Plan header

Field Value
Status DRAFT
Date 2026-08-19
Type single
Parent N/A
Source issue #650 — harness: reusable monotone text-wave on Waiting for model…
Branch plan/text-wave-waiting-copy
Layers harness
Reusability impact none — pure Wasm paint module, no config seams
Production mutate? no
Cloud ops path N/A — no Production mutate
Living docs docs/harness-limits.md — update Busy spinner row with text-wave detail

Summary

Extract a reusable text_wave.zig module (same shape as rect_spinner.zig) that paints a text string with a left-to-right monotone WARM color wave — brightness traveling through the glyphs driven by the existing 10 Hz busy tick. Replace the solid-warm_accent "Waiting for model…" text in busy_row.zig with this wave. The · mm:ss clock stays solid. Reduced motion: wave off, solid warm_accent (same as today).

Goals

# Goal Success signal
1 Reusable text_wave.zig module — takes text, phase, two palette stops, paints a left-to-right wave Exists at native/harness/src/text_wave.zig, no dvui frame deps, test-hostable under test-rich
2 busy_row.zig waiting copy waves left-to-right while Busy Visible shimmer across "Waiting for model…" glyphs, WARM-only, driven by the existing 10 Hz busy tick
3 Clock stays solid · mm:ss digits never change color during the wave
4 Reduced motion disables wave When prefers-reduced-motion: reduce is true (host skips setBusyTick pushes), all chars stay solid warm_accent
5 No protocol bump, no DOM/backend changes Pure Wasm paint; inv_set_busy_tick unchanged

Non-goals / out of scope

  • Waving the clock digits (must stay readable)
  • Waving the status-bar spinner cells (harness: status-bar lifecycle word → idle/busy spinner (static teal_muted when ready) #649 is a different paint)
  • Applying the helper anywhere else in this issue — only the waiting-row copy; other call sites are why the helper is extracted, not scope to ship here
  • CSS/DOM shimmer (this is Wasm canvas)
  • Protocol bump
  • EMBER or TEAL wave — WARM only for v1
  • Forbidden wiring: dual DOM chat · secrets in Wasm · laptop-only Production ops

Architectural decisions

Decision Options considered Choice Why
Wave paint: glyph-by-glyph addText vs single run A) Single addText with computed color (not possible — dvui textLayout.addText takes one .color_text per call). B) Split into individual characters, each with its own .color_text B — individual chars Only way to get per-glyph color variation in dvui. Kerning loss across addText boundaries is acceptable for the ~20-char waiting label
Wave head position A) phase % text_len (jumps per char). B) Float head = (phase % (text_len * STEPS)) / STEPS (sub-char smoothing) BSTEPS = 3 Smoother travel: with 3 steps per char and ~20 chars, the wave cycles through in 60 phases = 6 seconds at 10 Hz — visually fluid without visible jumps
Color ramp A) 2-stop linear interpolation. B) 4-stop LUT (reuse ColorRamp) B — 4-stop LUT matching rect_spinner.zig Same proven pattern. [head, trail1, trail2, rest]warm_accentwarm_mutedwarm_borderwarm_surface
Wave wrap A) Linear (wave exits right). B) Cyclic (wraps around) B — cyclic A left-to-right wave that wraps creates continuous motion without a dead tail period. The head re-enters from the left as it exits right
Module shape A) Inline in busy_row.zig. B) Standalone text_wave.zig (mirrors rect_spinner.zig) B — standalone Reusable contract: pure, no dvui frame deps, test-hostable under test-rich. Same shape as rect_spinner.zig / busy_spinner.zig
Text splitting granularity A) Per-byte (breaks UTF-8). B) Per-scalar (one addText per codepoint) B — per-scalar UTF-8 safe. Each Unicode scalar value gets its own addText call. For "Waiting for model…" this is ~20 scalars — trivial at 10 Hz
Phase source A) New protocol export. B) Reuse busyTick() from the existing 10 Hz host feed B — reuse busyTick() No protocol bump. The host already feeds inv_set_busy_tick at 10 Hz; text_wave.paint() receives the same phase as the spinner

Layer placement

Concern Layer Path(s) Rationale
Text wave paint harness (Wasm) native/harness/src/text_wave.zig Canvas paint — no DOM/backend involvement
Wave LUT / position logic harness (Wasm) native/harness/src/text_wave.zig Pure logic, no dvui deps — test-hostable (same as busy_spinner.zig)
First consumer harness (Wasm) native/harness/src/busy_row.zig Replace single addText("Waiting for model…") with text_wave.paint(...)
Clock (solid) harness (Wasm) native/harness/src/busy_row.zig Unchanged — tl.addText(" · ", .{}); tl.addText(clock, .{}); at solid warm_accent
Phase feed DOM host → Wasm app/harness/HarnessHost.tsxlib/harnessBridge.tsnative/harness/src/bridge.zig Unchanged — existing inv_set_busy_tick at 10 Hz

Current baseline (live code)

Claim Path / symbol Notes
Busy row text is single addText at solid warm_accent native/harness/src/busy_row.zig:105 tl.addText("Waiting for model…", .{}); — no color override, inherits color_text = palette.warm_accent from textLayout options
Clock is appended as separate addText calls native/harness/src/busy_row.zig:111-114 tl.addText(" · ", .{}); tl.addText(clock, .{}); — same textLayout, solid warm_accent
busyTick() provides u8 phase (wrapping at 256) native/harness/src/bridge.zig:111 busy_tick stored as u8; reset to 0 on idle/stop/error
Host feeds 10 Hz tick, skips on reduced motion app/harness/HarnessHost.tsx:749-770 if (!reduceMotion) setBusyTick(tick) — phase stays 0 when reduced motion
rect_spinner.zig is the existing reusable LUT-paint pattern native/harness/src/rect_spinner.zig ColorRamp = [4]Color, Options { phase, ramp, tag_prefix, id_extra, margin_right }, paint(src, opts)
busy_spinner.zig has the pure LUT (busySpinnerCells) native/harness/src/busy_spinner.zig Pure logic, no dvui deps — test-rich host-testable
busy_row.zig already calls rect_spinner.paint before the text native/harness/src/busy_row.zig:96-100 Same phase, different ramp (WARM_RAMP) — text wave uses same phase
Existing build-harness workflow .github/workflows/build-harness.yml Compiles Wasm on self-hosted runner

Design

Module: text_wave.zig

text_wave.paint(src, opts) where opts:
  .text: []const u8          — the string to wave (e.g. "Waiting for model…")
  .phase: u8                 — current tick phase from busyTick()
  .ramp: ColorRamp           — 4-stop palette ramp (reuses rect_spinner.ColorRamp)
  .id_extra: usize           — base for inner box ids (spaced ≥ ID_SPAN from other widgets)
  .tag_prefix: []const u8    — tag namespace (e.g. "busy-wave")

Wave algorithm:

  1. Iterate UTF-8 scalars in .text — count them (N)
  2. Compute float head position: head = (phase % (N * STEPS)) / STEPS where STEPS = 3
  3. For each scalar at index i:
    • Compute cyclic distance: dist = min(|i - head|, N - |i - head|) (cyclic, so wave wraps)
    • Map distance to ramp index: step = min(floor(dist * 4 / (N/2)), 3) → ramp[step]
    • Call tl.addText(scalar_as_text, .{ .color_text = ramp[step] })

Edge cases:

  • Empty text → no-op (no addText calls, still deinit the textLayout)
  • Single character → solid head color (distance always 0)
  • Very short text (2-3 chars) → wave still works, just less granular
  • Phase overflow: u8 naturally wraps at 256 — the modulo in head calculation handles any value
  • Reduced motion: phase stays 0 → head at position 0 → all chars at distance 0 → solid warm_accent

Consumer: busy_row.zig

Replace lines ~104-107:

// Before:
tl.addText("Waiting for model…", .{});

// After:
text_wave.paint(src, .{
    .text = "Waiting for model…",
    .phase = phase,
    .ramp = rect_spinner.WARM_RAMP,
    .tag_prefix = "busy-wave",
    .id_extra = 0x60_0100,
});

The clock lines (111-114) stay unchanged — solid warm_accent, no wave.

Color ramp

Reuse rect_spinner.WARM_RAMP: [warm_accent, warm_muted, warm_border, warm_surface].

At distance 0 (head): warm_accent (#d47c2c) — brightest.
At distance N/2 (opposite end of cycle): warm_surface (#1a120c) — darkest, nearly invisible against teal_bg.

Since the wave is cyclic, the head wraps around — there's always a bright spot traveling through the text.

Reduced motion

Already handled by the host: when prefers-reduced-motion: reduce is true, setBusyTick(tick) is skipped, so busyTick() stays 0. Phase 0 → head at 0.0 → all chars at distance 0 → all warm_accent → solid, unchanged from today. text_wave needs no awareness of reduced motion.

Performance

  • ~20 scalar values for "Waiting for model…" → ~20 addText calls per frame while busy
  • At 10 Hz this is ~200 addText calls/second — trivial for dvui's text shaper
  • No alloc in the frame path: the scalar iteration is stack-only, addText copies into dvui's arena
  • Empty/non-busy path: module not called at all

Cloud ops path

N/A — no Production mutate. Pure Wasm paint change.

Living docs plan

Surface Change Notes
docs/harness-limits.md Update Busy spinner row: add text-wave detail — "While Busy the waiting copy runs a left-to-right WARM color wave (same 10 Hz phase as the spinner); reduced motion keeps it solid." Timeless; no phase/issue process artifacts
AGENTS.md N/A — no agent rules / infra changes
README.md N/A — visitor-facing entry unchanged
SECURITY.md N/A — no secrets / trust boundary changes
.env.example N/A — no new env

Implementation order

  1. Create native/harness/src/text_wave.zig — pure paint() function, no dvui deps for the LUT logic; the paint function uses dvui textLayout
  2. Create native/harness/src/text_wave.test.zig — unit tests for the wave position LUT and ramp mapping (host-testable via test-rich)
  3. Register text_wave in native/harness/build.zig test-rich
  4. Replace tl.addText("Waiting for model…", .{}) in busy_row.zig with text_wave.paint(...)
  5. Update docs/harness-limits.md Busy spinner row
  6. Run build-harness on the self-hosted runner to compile the Wasm artifact

Testing

# Case Layer Type Command / method
1 text_wave — head at position 0 maps all chars to distance 0 → step 0 (accent) harness unit zig build test-rich
2 text_wave — head at position 5 on 10-char text → distances [5,4,3,2,1,0,1,2,3,4] (cyclic), correct step mapping harness unit zig build test-rich
3 text_wave — phase 0 → head = 0.0; phase 7 → head = 2.33 (with STEPS=3, 10 chars) harness unit zig build test-rich
4 text_wave — empty text → no addText calls, no crash harness unit zig build test-rich
5 text_wave — single-char text → solid head color at all phases harness unit zig build test-rich
6 text_wave — all 4 stops in the ramp are exercised across a full phase cycle harness unit zig build test-rich
7 busy_row_layout — the waiting text row geometry still locks (same LEAD/TRAIL, same vertical alignment) with text_wave instead of flat addText harness integration zig build test-rich (busy_row_layout test)
8 Reduced motion: phase stays 0 → solid warm_accent (covered by test 1: phase 0 = all distance 0) harness unit zig build test-rich (already covered)
9 Clock digits never wave — they're separate addText calls at solid warm_accent, unchanged harness code review Visual: clock is added after text_wave.deinit() in a different textLayout
10 npm run typecheck / npm test — no TS/DOM regressions DOM unit Agent workspace
11 build-harness compiles without errors harness build Self-hosted runner CI

Definition of done

  • text_wave.zig module exists — pure, reusable, same shape as rect_spinner.zig
  • text_wave.test.zig with ≥6 cases (head position, distance calc, ramp mapping, empty, single-char, full-cycle)
  • busy_row.zig waiting copy uses text_wave.paint() instead of solid addText
  • Clock stays solid (unchanged addText calls)
  • docs/harness-limits.md updated with text-wave detail
  • zig build test-rich green (all existing + new tests pass)
  • npm run typecheck + npm test green (no TS/DOM regressions)
  • build-harness green on the self-hosted runner
  • Cloud ops: N/A — no Production mutate
  • Living docs: docs/harness-limits.md updated (timeless)

Caps table

N/A — no new caps or limits. The text_wave operates on caller-provided strings; busy_row.zig passes a compile-time constant ("Waiting for model…"). No ring, no bridge, no session changes.

Risks & mitigations

Risk Mitigation
Kerning loss across per-scalar addText calls makes the waiting text look jagged The text is ~20 chars of a sans-serif face at body size — individual addText calls with dvui's standard text shaper still produce reasonable character placement. If kerning loss is visible, group into 2-3 char runs per addText call (still distinct colors per run, just slightly lower resolution wave). The module's paint() signature stays the same — this is an internal tuning
Clock digits accidentally wave Clock stays in a separate textLayout block (different tl) with no wave module involved — structurally impossible to leak the wave into the clock
build-harness CI failure from a type mismatch not caught locally (no Zig toolchain in this workspace) Same pattern as every harness PR — build-harness catches it. The module follows the exact same signature as rect_spinner.zig which compiles cleanly
Phase overflow at 256 creates a visible jump The cyclic wrap (back to 0) is at 256 phases ≈ 25.6 seconds. With the wave cycling through ~20 chars every 6 seconds (60 phases), the phase wraps ~4 wave cycles per phase overflow — the jump at 255→0 is no more visible than any head-wrap through the cyclic text

Open questions

None — all in-scope engineering decisions locked above.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions