Skip to content

Make the landing snow actually render on an idle screen - #428

Merged
TheGreatAxios merged 3 commits into
mainfrom
worktree-agent-a7c55c0948a40f073
Aug 9, 2026
Merged

Make the landing snow actually render on an idle screen#428
TheGreatAxios merged 3 commits into
mainfrom
worktree-agent-a7c55c0948a40f073

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 9, 2026

Copy link
Copy Markdown
Collaborator

CL-5737: Landing snow is never drawn

Fixes three independent gates that all had to be crossed before the snow decoration over the landing-screen mountain (src/tui-opentui/mark-anim.ts, rendered via src/tui-opentui/landing.ts) could ever draw:

  1. createLandingAbove mounts with still=true, and the old snowOn check (!input.still && alpha === 1) tied snow visibility directly to that flag — so the mount-time paint had no snow, by construction.
  2. paintLanding in shell.ts early-returned unless re-entered with animating: true, which never happens while idle.
  3. Its only caller was the turn monitor in runtime-bridge.ts, which deliberately stops ticking once idle (ANIMATION_TICK_MS vs DEFAULT_TICK_MS, monitor === undefined disables scheduling) — so even fixing (1) and (2) would leave nothing driving repaint while idle.

The design question: what drives frames for an idle surface

Two candidates were on the table for (3): a mount-scoped timer, or riding the renderer's own FRAME event. The first version of this PR chose FRAME, reasoning it avoided a second clock source. That turned out to be wrong, and was reverted — see below.

Why the FRAME approach failed

This renderer is purely on-demand: it never calls start/auto/requestLive, and only renders when something is dirtied. paintLandingMark assigns a freshly allocated StyledText to every row on every call, and the content setter does reference-identity comparison, so a fresh allocation always looks "changed" and unconditionally requests another render. Because FRAME fires from inside the render loop, that request reschedules the very next frame — each paint call is what causes the next one to be scheduled. Riding that event for the idle repaint means the paint that produces frame N is what schedules frame N+1.

The first version of this PR throttled that repaint to ~125ms to cut what looked like an accidental ~60fps loop down to ~8fps. That throttle broke the loop outright: since the throttle interval is necessarily longer than the frame interval, the frame immediately following a throttled paint is always a "skip" — no dirty, no reschedule — and the on-demand renderer parks for good. Measured: the pre-throttle build self-drove 166 frames in 3 seconds with nothing external calling render; the throttled build self-drove exactly 1 frame in 3 seconds, then froze forever. This is not a tuning problem — any throttle above zero frames kills the loop, because the throttle and the frame source were literally the same mechanism.

The fix: a mount-scoped timer

Reverted the throttle and the FRAME hookup entirely. The landing exists for the lifetime of the shell until the first transcript row tears it down (clearLandingMark), so a plain ~125ms setInterval, armed right after the shell's internal state is set up in createAppShell and cleared on whichever teardown happens first — the landing going away, or the whole shell disposing — is a clock source independent of the render loop's own scheduling. It repaints only while idle (landingAnimating false), so it never fights paintPhaseAt in runtime-bridge.ts, which still drives the mountain's own draw/fill/fade timeline off the turn monitor's clock while a turn is processing.

The timer also self-cancels once renderer.isDestroyed is true. Headless test harnesses commonly destroy the renderer directly (withTestRenderer's cleanup) without calling shell.dispose(); without this check the timer would keep firing 125ms ticks against renderables the harness already tore down, throwing across unrelated tests running in the same process.

What changed

  • mark-anim.ts: snowOn no longer depends on still — only on alpha === 1 && !reducedMotion. still still freezes the mountain's own draw/fill/fade timeline to its filled frame; a new, separate reducedMotion input on MarkInput is the actual motion-suppression hook for snow. Nothing wires a live reduced-motion setting in yet, but the parameter is threaded end-to-end (renderMarkmarkChunkspaintLandingMarkpaintLanding) and defaults to false everywhere, so behavior is unchanged until something sets it.
  • shell.ts: paintLanding no longer early-returns when idle — it always repaints while the landing is up, since idle is exactly when snow needs to keep moving. createAppShell arms a ~125ms setInterval at mount that repaints the landing while idle; it is cleared in clearLandingMark (landing torn down) and in shell.dispose (whole shell torn down), and self-cancels if the renderer is destroyed out from under it. The renderer's FRAME event is no longer involved in driving the landing at all.
  • landing.ts: docblocks that said still suppressed snow (stale after the snowOn change) now describe the actual split: still = idle-mount/frozen-timeline state for the mountain, reducedMotion = the motion-suppression hook for snow. Unchanged from the previous revision.

Test

src/tui-opentui/landing.test.ts has a test that mounts the shell via createAppShell (the real production entry point) and asserts the mark actually moves over real wall-clock time with no manual frame pumping at all — no renderOnce() loop, no hand-set clock passed to paintLanding. This is deliberate: every other test in this file (and the first version of this test) drove the mark by calling paintLanding directly, or by pumping renderOnce() in a loop to manufacture frames. Both of those stay green even when the production driver is completely dead, which is exactly how the throttled build shipped with frozen snow while its own tests passed. The new test only reads the final state via h.flush() (waits on the renderer's own scheduler settling, not a forced render), so the only thing that can move the snow between the two captures is the shell's own mount-scoped timer.

Verified by hand against both prior commits on this branch: the new assertion fails on the throttled build and passes on the pre-throttle build, confirming it actually distinguishes a self-driving loop from a dead one.

Two existing tests directly encoded the old "still implies no snow" and "idle re-entry produces the exact same frame" behavior; both were updated deliberately rather than deleted, since that behavior was intentionally changed here.

Also verified live: ran the built binary in a 120x40 tmux pane, let it sit idle on the landing screen, and diffed two tmux capture-pane snapshots taken several seconds apart. The · snow glyphs moved rows/columns between captures while every mountain block glyph (▁▂▃▄▅▆▇█) stayed in the same position.

Validation

  • bun run typecheck: pass
  • bun run build: pass
  • bun run test: 4226 pass, 0 fail (317 files)

This supersedes #380, which added the snow-drawing code but never made it reachable; that PR should stay open until this one is reviewed and can then be closed in favor of this one.

Three independent gates kept the falling-snow decoration over the landing
mountain from ever drawing: the mount-time paint was still=true, which the
old snowOn check tied directly to snow visibility; paintLanding early-returned
whenever it wasn't re-entered with animating=true, which never happens while
idle; and its only caller was the turn monitor, which deliberately stops
ticking once idle.

Fix: snow visibility no longer depends on `still` (that flag now only freezes
the mountain's own draw/fill/fade timeline, not the flakes over it), the
early-return is gone so idle repaints actually happen, and the shell's
createAppShell now repaints the landing off the renderer's own FRAME event
whenever the landing is up and not mid-turn-animation. That event is already
scoped to shell lifetime (wired at construction, unwired in dispose) and
paintLanding already no-ops once the landing tears down, so this needed no
new timer to arm or leak, and it doesn't touch the turn monitor's cadence at
all. The alternative — a separate timer armed from createLandingAbove and
stopped in the landing teardown — was rejected: it would duplicate the
monitor's own cadence-management responsibility for no real gain, since the
FRAME event already has the right lifetime.

Added a real-mount-path regression test in landing.test.ts that goes through
createAppShell and lets the renderer's FRAME event drive the repaint, unlike
every existing test in that file, which drives the mark by calling
paintLanding directly with a hand-picked clock. That gap is exactly why this
shipped broken and nobody caught it.

This supersedes PR #380, which added the snow-drawing code but never made it
reachable; that PR should stay open until this one is reviewed and can then
be closed in favor of this one.
FRAME fires from inside the on-demand render loop, and paintLanding
reassigns a fresh StyledText to every row every call regardless of
whether content changed, which unconditionally dirties renderables and
requests another render. Left unthrottled that turned the idle landing
into a perpetual ~60fps render loop instead of riding an existing one.
Guard the onFrame-driven repaint with a stored last-paint timestamp so
it only actually repaints once every ~125ms, and drop the comment that
falsely claimed only changed rows get touched.

Also thread an explicit reducedMotion input through the mark renderer
(renderMark/markChunks/paintLandingMark/paintLanding), separate from
still, so a future reduced-motion setting has a real plumbing path
instead of overloading still (which only freezes the mountain's own
draw/fill/fade timeline). Defaults to false everywhere; no behavior
change until something sets it. Updates the mark-anim.ts and
landing.ts docblocks that stated still suppressed snow, which PR #428
made no longer true.
…led FRAME hook

The FRAME-driven throttle added in the previous commit killed the landing's
self-driving loop entirely: the renderer only keeps rendering because each
paint dirties a row, which schedules the next FRAME; skipping a paint on a
throttled tick breaks that chain on the very next frame and the snow freezes
after the first paint. Any throttle above zero frames has the same effect,
since the throttle and the frame source were the same mechanism.

Replace it with a plain ~125ms interval armed when the shell mounts (the
landing exists for the lifetime of the shell until the first transcript row
tears it down) and cleared on whichever teardown happens first: the landing
going away, or the shell disposing. The timer self-cancels once the renderer
reports destroyed, so headless test harnesses that skip explicit
shell.dispose() don't leave it firing against torn-down renderables.

Also replace the test's manual renderOnce-loop clock with a real wall-clock
wait and no frame pumping at all, so the test can no longer stay green while
production's self-driving mechanism is dead — that blind spot is exactly how
the frozen throttle shipped in the first place.
@TheGreatAxios
TheGreatAxios merged commit 65eba66 into main Aug 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant