ProPresenter timer tiles looked jumpy — the digit often flicked back to the previous second. Root cause, confirmed by tracing the poll/render pipeline: ProPresenter reports running timers as truncated whole seconds ("12:34" means anywhere in [754 s, 755 s)), and the poller re-anchored the tiles' local count at the bottom of that second on every 500 ms poll. The tile's local count therefore ran up to a second ahead, and whenever a fresh poll sampled the next flip later than the previous one did (the poll clock and ProPresenter's second boundaries drift through each other), the new anchor yanked the digit back a step — …12:33 → 12:34 → 12:33… about once a second.
The fix
phase.js — a per-timer phase lock on the server:
The only real information in a poll is a flip — the report stepping one second in the direction of travel. At the flip instant the true value equals the interval boundary just crossed, and the two polls that straddle it date it to within half a poll.
While reports stay consistent with the anchor (±450 ms — above the flip-sampling error bound, safely below a real one-second seek), the anchor holds, so tiles count along one steady clock instead of a freshly re-phased one per sample.
A report the anchor can't explain (operator reset/seek, resume, accumulated clock drift) re-anchors — at the flip boundary when a clean flip was observed, else mid-interval, and a provisional mid-interval anchor always yields to the first observed flip (strictly better evidence).
Truncation semantics cover overrun: a countdown at −12.4 s reports "-12", so negative intervals and the ~2 s "0:00" dwell around zero are handled exactly the way ProPresenter itself displays them.
Timers now carry the anchor as a per-timer asOf the client extrapolates from — added to the provider contract as an optional field (documented in MODULE-GUIDE; absent = today's behavior, so pco-plan and other providers are untouched). The provider snapshot (GET /timers) re-anchors to its own instant as before, so consumers see the plain contract. As a belt-and-braces, the client also refuses to tick a running card backwards over sub-1.5 s jitter (clock-offset wobble); a bigger step is a real reset and snaps.
A side benefit: steady-state SSE frames for the ProPresenter source drop to nearly zero (values only change on re-anchor; the tiles were already extrapolating locally).
Verification
phase.selftest.js (66 checks): a simulated ProPresenter — truncated reports, jittered polls — driven through the tracker and the client's exact render math. Digits never step backwards across 12 sub-second phases × seeds; dwells hold 1000 ms (one ~2 s zero-crossing dwell allowed — trunc's honest behavior); anchors stay within tolerance of the true continuous value; elapsed timers symmetric; resets snap within a poll or two; pause → resume re-locks; 0.2 % ProPresenter-vs-server clock drift is absorbed by occasional ≤450 ms boundary re-placements, never a backwards step.
Live, against tools/pp-timers-mock.js: 78 s of 100 ms DOM sampling across four running cards (countdown, overrunning countdown, elapsed, clock) — every observed step was ±1 in the card's own direction (mixedDirection: false on all), dwells 998–1002 ms, zero regressions. Killed and restarted the mock mid-run: red status, clean recovery, phase re-locks.
ltc.selftest.js still passes; module bumped to 1.4.2 (no shell changes).
Not deployed to AB/NA (Sunday) — booths still run the flickering build until the next module update.
## The bug
ProPresenter timer tiles looked jumpy — the digit often flicked back to the previous second. Root cause, confirmed by tracing the poll/render pipeline: ProPresenter reports running timers as **truncated whole seconds** ("12:34" means anywhere in [754 s, 755 s)), and the poller re-anchored the tiles' local count at the **bottom** of that second on **every 500 ms poll**. The tile's local count therefore ran up to a second ahead, and whenever a fresh poll sampled the next flip later than the previous one did (the poll clock and ProPresenter's second boundaries drift through each other), the new anchor yanked the digit back a step — `…12:33 → 12:34 → 12:33…` about once a second.
## The fix
**`phase.js`** — a per-timer phase lock on the server:
- The only real information in a poll is a **flip** — the report stepping one second in the direction of travel. At the flip instant the true value equals the interval boundary just crossed, and the two polls that straddle it date it to within half a poll.
- While reports stay consistent with the anchor (±450 ms — above the flip-sampling error bound, safely below a real one-second seek), the anchor **holds**, so tiles count along one steady clock instead of a freshly re-phased one per sample.
- A report the anchor can't explain (operator reset/seek, resume, accumulated clock drift) re-anchors — at the flip boundary when a clean flip was observed, else mid-interval, and a provisional mid-interval anchor always yields to the first observed flip (strictly better evidence).
- Truncation semantics cover overrun: a countdown at −12.4 s reports "-12", so negative intervals and the ~2 s "0:00" dwell around zero are handled exactly the way ProPresenter itself displays them.
Timers now carry the anchor as a per-timer **`asOf`** the client extrapolates from — added to the provider contract as an optional field (documented in MODULE-GUIDE; absent = today's behavior, so pco-plan and other providers are untouched). The provider snapshot (`GET /timers`) re-anchors to its own instant as before, so consumers see the plain contract. As a belt-and-braces, the client also refuses to tick a running card backwards over sub-1.5 s jitter (clock-offset wobble); a bigger step is a real reset and snaps.
A side benefit: steady-state SSE frames for the ProPresenter source drop to nearly zero (values only change on re-anchor; the tiles were already extrapolating locally).
## Verification
- **`phase.selftest.js` (66 checks)**: a simulated ProPresenter — truncated reports, jittered polls — driven through the tracker and the client's exact render math. Digits never step backwards across 12 sub-second phases × seeds; dwells hold 1000 ms (one ~2 s zero-crossing dwell allowed — trunc's honest behavior); anchors stay within tolerance of the true continuous value; elapsed timers symmetric; resets snap within a poll or two; pause → resume re-locks; 0.2 % ProPresenter-vs-server clock drift is absorbed by occasional ≤450 ms boundary re-placements, never a backwards step.
- **Live, against `tools/pp-timers-mock.js`**: 78 s of 100 ms DOM sampling across four running cards (countdown, overrunning countdown, elapsed, clock) — every observed step was ±1 in the card's own direction (`mixedDirection: false` on all), dwells 998–1002 ms, **zero regressions**. Killed and restarted the mock mid-run: red status, clean recovery, phase re-locks.
- `ltc.selftest.js` still passes; module bumped to 1.4.2 (no shell changes).
Not deployed to AB/NA (Sunday) — booths still run the flickering build until the next module update.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
ProPresenter reports running timers as truncated whole seconds, and the
poller re-anchored the tiles' local count at the bottom of that second on
every 500 ms poll — so the displayed digit ran up to a second ahead and
got yanked back a step whenever a fresh poll sampled the next flip later
than the last one did. Tiles flickered ...12:33 → 12:34 → 12:33... about
once a second.
phase.js turns the quantized reports into one stable anchor per timer:
the only information in a poll is a FLIP (the report stepping one second
in the direction of travel), which dates the true boundary to the
midpoint of the two polls that straddle it. While reports stay consistent
with the anchor (±450 ms) the anchor holds, so tiles count along one
steady clock; a report the anchor can't explain (reset, seek, resume,
accumulated drift) re-anchors. Timers carry the anchor as a per-timer
asOf the client extrapolates from (optional in the provider contract,
documented in the guide); the client additionally refuses to tick a
running card backwards over sub-1.5 s jitter.
phase.selftest.js (66 checks) drives a simulated ProPresenter — truncated
reports, jittered polls — through the tracker and the client's render
math: digits never step backwards, dwells hold 1 s (the ~2 s zero-crossing
dwell is trunc's honest behavior), anchors stay within tolerance of the
true value, resets snap, pause/resume re-locks, 0.2% clock drift is
absorbed. Verified live against tools/pp-timers-mock.js: 78 s of 100 ms
DOM sampling across four running cards — every step ±1 in the card's own
direction, dwells 998–1002 ms, zero regressions; upstream kill/restart
still recovers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
michael
merged commit 5f60cd563c into main2026-09-27 09:05:51 -04:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
The bug
ProPresenter timer tiles looked jumpy — the digit often flicked back to the previous second. Root cause, confirmed by tracing the poll/render pipeline: ProPresenter reports running timers as truncated whole seconds ("12:34" means anywhere in [754 s, 755 s)), and the poller re-anchored the tiles' local count at the bottom of that second on every 500 ms poll. The tile's local count therefore ran up to a second ahead, and whenever a fresh poll sampled the next flip later than the previous one did (the poll clock and ProPresenter's second boundaries drift through each other), the new anchor yanked the digit back a step —
…12:33 → 12:34 → 12:33…about once a second.The fix
phase.js— a per-timer phase lock on the server:Timers now carry the anchor as a per-timer
asOfthe client extrapolates from — added to the provider contract as an optional field (documented in MODULE-GUIDE; absent = today's behavior, so pco-plan and other providers are untouched). The provider snapshot (GET /timers) re-anchors to its own instant as before, so consumers see the plain contract. As a belt-and-braces, the client also refuses to tick a running card backwards over sub-1.5 s jitter (clock-offset wobble); a bigger step is a real reset and snaps.A side benefit: steady-state SSE frames for the ProPresenter source drop to nearly zero (values only change on re-anchor; the tiles were already extrapolating locally).
Verification
phase.selftest.js(66 checks): a simulated ProPresenter — truncated reports, jittered polls — driven through the tracker and the client's exact render math. Digits never step backwards across 12 sub-second phases × seeds; dwells hold 1000 ms (one ~2 s zero-crossing dwell allowed — trunc's honest behavior); anchors stay within tolerance of the true continuous value; elapsed timers symmetric; resets snap within a poll or two; pause → resume re-locks; 0.2 % ProPresenter-vs-server clock drift is absorbed by occasional ≤450 ms boundary re-placements, never a backwards step.tools/pp-timers-mock.js: 78 s of 100 ms DOM sampling across four running cards (countdown, overrunning countdown, elapsed, clock) — every observed step was ±1 in the card's own direction (mixedDirection: falseon all), dwells 998–1002 ms, zero regressions. Killed and restarted the mock mid-run: red status, clean recovery, phase re-locks.ltc.selftest.jsstill passes; module bumped to 1.4.2 (no shell changes).Not deployed to AB/NA (Sunday) — booths still run the flickering build until the next module update.
🤖 Generated with Claude Code