You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#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
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)
B — STEPS = 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)
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:
Iterate UTF-8 scalars in .text — count them (N)
Compute float head position: head = (phase % (N * STEPS)) / STEPS where STEPS = 3
For each scalar at index i:
Compute cyclic distance: dist = min(|i - head|, N - |i - head|) (cyclic, so wave wraps)
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
Create native/harness/src/text_wave.zig — pure paint() function, no dvui deps for the LUT logic; the paint function uses dvui textLayout
Create native/harness/src/text_wave.test.zig — unit tests for the wave position LUT and ramp mapping (host-testable via test-rich)
Register text_wave in native/harness/build.zigtest-rich
Replace tl.addText("Waiting for model…", .{}) in busy_row.zig with text_wave.paint(...)
Update docs/harness-limits.md Busy spinner row
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
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
Source issue:#650 — harness: reusable monotone text-wave on Waiting for model…
native/harness/src/rect_spinner.zig — reusable paint pattern to follow
native/harness/src/busy_spinner.zig — pure LUT pattern to follow
Plan header
docs/harness-limits.md— update Busy spinner row with text-wave detailSummary
Extract a reusable
text_wave.zigmodule (same shape asrect_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 inbusy_row.zigwith this wave. The· mm:ssclock stays solid. Reduced motion: wave off, solidwarm_accent(same as today).Goals
text_wave.zigmodule — takes text, phase, two palette stops, paints a left-to-right wavenative/harness/src/text_wave.zig, no dvui frame deps, test-hostable undertest-richbusy_row.zigwaiting copy waves left-to-right while Busy"Waiting for model…"glyphs, WARM-only, driven by the existing 10 Hz busy tick· mm:ssdigits never change color during the waveprefers-reduced-motion: reduceis true (host skipssetBusyTickpushes), all chars stay solidwarm_accentinv_set_busy_tickunchangedNon-goals / out of scope
Architectural decisions
addTextvs single runaddTextwith computed color (not possible — dvuitextLayout.addTexttakes one.color_textper call). B) Split into individual characters, each with its own.color_textaddTextboundaries is acceptable for the ~20-char waiting labelphase % text_len(jumps per char). B) Float head =(phase % (text_len * STEPS)) / STEPS(sub-char smoothing)STEPS = 3ColorRamp)rect_spinner.zig[head, trail1, trail2, rest]→warm_accent→warm_muted→warm_border→warm_surfacebusy_row.zig. B) Standalonetext_wave.zig(mirrorsrect_spinner.zig)test-rich. Same shape asrect_spinner.zig/busy_spinner.zigaddTextper codepoint)addTextcall. For "Waiting for model…" this is ~20 scalars — trivial at 10 HzbusyTick()from the existing 10 Hz host feedbusyTick()inv_set_busy_tickat 10 Hz;text_wave.paint()receives the same phase as the spinnerLayer placement
native/harness/src/text_wave.zignative/harness/src/text_wave.zigbusy_spinner.zig)native/harness/src/busy_row.zigaddText("Waiting for model…")withtext_wave.paint(...)native/harness/src/busy_row.zigtl.addText(" · ", .{}); tl.addText(clock, .{});at solidwarm_accentapp/harness/HarnessHost.tsx→lib/harnessBridge.ts→native/harness/src/bridge.ziginv_set_busy_tickat 10 HzCurrent baseline (live code)
addTextat solidwarm_accentnative/harness/src/busy_row.zig:105tl.addText("Waiting for model…", .{});— no color override, inheritscolor_text = palette.warm_accentfrom textLayout optionsaddTextcallsnative/harness/src/busy_row.zig:111-114tl.addText(" · ", .{}); tl.addText(clock, .{});— same textLayout, solidwarm_accentbusyTick()provides u8 phase (wrapping at 256)native/harness/src/bridge.zig:111busy_tickstored as u8; reset to 0 on idle/stop/errorapp/harness/HarnessHost.tsx:749-770if (!reduceMotion) setBusyTick(tick)— phase stays 0 when reduced motionrect_spinner.zigis the existing reusable LUT-paint patternnative/harness/src/rect_spinner.zigColorRamp = [4]Color,Options { phase, ramp, tag_prefix, id_extra, margin_right },paint(src, opts)busy_spinner.zighas the pure LUT (busySpinnerCells)native/harness/src/busy_spinner.zigtest-richhost-testablebusy_row.zigalready callsrect_spinner.paintbefore the textnative/harness/src/busy_row.zig:96-100WARM_RAMP) — text wave uses same phasebuild-harnessworkflow.github/workflows/build-harness.ymlDesign
Module:
text_wave.zigWave algorithm:
.text— count them (N)head = (phase % (N * STEPS)) / STEPSwhereSTEPS = 3i:dist = min(|i - head|, N - |i - head|)(cyclic, so wave wraps)step = min(floor(dist * 4 / (N/2)), 3)→ ramp[step]tl.addText(scalar_as_text, .{ .color_text = ramp[step] })Edge cases:
warm_accentConsumer:
busy_row.zigReplace lines ~104-107:
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 againstteal_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: reduceis true,setBusyTick(tick)is skipped, sobusyTick()stays 0. Phase 0 → head at 0.0 → all chars at distance 0 → allwarm_accent→ solid, unchanged from today.text_waveneeds no awareness of reduced motion.Performance
addTextcalls per frame while busyaddTextcalls/second — trivial for dvui's text shaperaddTextcopies into dvui's arenaCloud ops path
N/A — no Production mutate. Pure Wasm paint change.
Living docs plan
docs/harness-limits.mdAGENTS.mdREADME.mdSECURITY.md.env.exampleImplementation order
native/harness/src/text_wave.zig— purepaint()function, no dvui deps for the LUT logic; the paint function uses dvui textLayoutnative/harness/src/text_wave.test.zig— unit tests for the wave position LUT and ramp mapping (host-testable viatest-rich)text_waveinnative/harness/build.zigtest-richtl.addText("Waiting for model…", .{})inbusy_row.zigwithtext_wave.paint(...)docs/harness-limits.mdBusy spinner rowbuild-harnesson the self-hosted runner to compile the Wasm artifactTesting
text_wave— head at position 0 maps all chars to distance 0 → step 0 (accent)zig build test-richtext_wave— head at position 5 on 10-char text → distances [5,4,3,2,1,0,1,2,3,4] (cyclic), correct step mappingzig build test-richtext_wave— phase 0 → head = 0.0; phase 7 → head = 2.33 (with STEPS=3, 10 chars)zig build test-richtext_wave— empty text → no addText calls, no crashzig build test-richtext_wave— single-char text → solid head color at all phaseszig build test-richtext_wave— all 4 stops in the ramp are exercised across a full phase cyclezig build test-richbusy_row_layout— the waiting text row geometry still locks (same LEAD/TRAIL, same vertical alignment) with text_wave instead of flat addTextzig build test-rich(busy_row_layout test)warm_accent(covered by test 1: phase 0 = all distance 0)zig build test-rich(already covered)addTextcalls at solidwarm_accent, unchangednpm run typecheck/npm test— no TS/DOM regressionsbuild-harnesscompiles without errorsDefinition of done
text_wave.zigmodule exists — pure, reusable, same shape asrect_spinner.zigtext_wave.test.zigwith ≥6 cases (head position, distance calc, ramp mapping, empty, single-char, full-cycle)busy_row.zigwaiting copy usestext_wave.paint()instead of solidaddTextaddTextcalls)docs/harness-limits.mdupdated with text-wave detailzig build test-richgreen (all existing + new tests pass)npm run typecheck+npm testgreen (no TS/DOM regressions)build-harnessgreen on the self-hosted runnerdocs/harness-limits.mdupdated (timeless)Caps table
N/A — no new caps or limits. The text_wave operates on caller-provided strings;
busy_row.zigpasses a compile-time constant ("Waiting for model…"). No ring, no bridge, no session changes.Risks & mitigations
addTextcalls makes the waiting text look jaggedaddTextcalls with dvui's standard text shaper still produce reasonable character placement. If kerning loss is visible, group into 2-3 char runs peraddTextcall (still distinct colors per run, just slightly lower resolution wave). The module'spaint()signature stays the same — this is an internal tuningtextLayoutblock (differenttl) with no wave module involved — structurally impossible to leak the wave into the clockbuild-harnessCI failure from a type mismatch not caught locally (no Zig toolchain in this workspace)build-harnesscatches it. The module follows the exact same signature asrect_spinner.zigwhich compiles cleanlyOpen questions
None — all in-scope engineering decisions locked above.
References
native/harness/src/rect_spinner.zig— reusable paint pattern to follownative/harness/src/busy_spinner.zig— pure LUT pattern to follownative/harness/src/busy_row.zig— first consumer